diff --git a/AGENTS.md b/AGENTS.md index 3ad186e..49ff7e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,7 +119,7 @@ oauth_accounts ( | 字段 | 含义 | 示例 | |------|------|------| | `folder` | 隔离空间(参与唯一键) | `refining` | -| `type` | 软分类(不参与唯一键) | `server`, `service`, `person`, `document` | +| `type` | 软分类(不参与唯一键,用户自定义) | `server`, `service`, `account`, `person`, `document` | | `name` | 标识名 | `gitea`, `aliyun` | | `notes` | 非敏感说明 | 自由文本 | | `tags` | 标签 | `["aliyun","prod"]` | diff --git a/Cargo.lock b/Cargo.lock index 9bc70d3..725461e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1969,7 +1969,7 @@ dependencies = [ [[package]] name = "secrets-mcp" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "askama", diff --git a/README.md b/README.md index 0d1dda5..e34dccf 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ flowchart LR | 位置 | 字段 | 说明 | |------|------|------| | entries | folder | 组织/隔离空间,如 `refining`、`ricnsmart`;参与唯一键 | -| entries | type | 软分类,如 `server`、`service`、`person`、`document`(可扩展,不参与唯一键) | +| entries | type | 软分类,用户自定义,如 `server`、`service`、`account`、`person`、`document`(不参与唯一键) | | entries | name | 人类可读标识;与 `folder` 一起在用户内唯一 | | entries | notes | 非敏感说明文本 | | entries | metadata | 明文 JSON(ip、url、subtype 等) | @@ -195,12 +195,9 @@ flowchart LR - 同一 secret 可被多个 entry 引用,删除某 entry 不会级联删除被共享的 secret - 当 secret 不再被任何 entry 引用时,自动清理(`NOT EXISTS` 子查询) -### 类型规范化(Taxonomy) +### 类型(Type) -`type` 字段用于软分类,系统会自动将历史遗留类型映射为标准化类型: -- `git-server`、`database`、`cache`、`queue`、`storage` 等 → `service`(原始值存入 `metadata.subtype`) -- 新增条目时建议使用标准类型:`server`、`service`、`person`、`document` -- 类型映射在 `crates/secrets-core/src/taxonomy.rs` 中定义 +`type` 字段用于软分类,由用户自由填写,不做任何自动转换或归一化。常见示例:`server`、`service`、`account`、`person`、`document`,但任何值均可接受。 ## 审计日志 @@ -220,7 +217,7 @@ LIMIT 20; Cargo.toml crates/secrets-core/ # db / crypto / models / audit / service src/ - taxonomy.rs # 类型规范化(legacy type → standard type + subtype) + taxonomy.rs # SECRET_TYPE_OPTIONS(secret 字段类型下拉选项) service/ # 业务逻辑(add, search, update, delete, export, env_map 等) crates/secrets-mcp/ # MCP HTTP、Web、OAuth、API Key scripts/ diff --git a/crates/secrets-core/src/models.rs b/crates/secrets-core/src/models.rs index 42c924d..16e0b3c 100644 --- a/crates/secrets-core/src/models.rs +++ b/crates/secrets-core/src/models.rs @@ -4,7 +4,7 @@ use serde_json::Value; use std::collections::BTreeMap; use uuid::Uuid; -/// A top-level entry (server, service, key, person, …). +/// A top-level entry (server, service, account, person, …). /// Sensitive fields are stored separately in `secrets`. #[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Entry { diff --git a/crates/secrets-core/src/service/add.rs b/crates/secrets-core/src/service/add.rs index 4e1a53f..3835ab3 100644 --- a/crates/secrets-core/src/service/add.rs +++ b/crates/secrets-core/src/service/add.rs @@ -9,7 +9,6 @@ use crate::crypto; use crate::db; use crate::error::{AppError, DbErrorContext}; use crate::models::EntryRow; -use crate::taxonomy; // ── Key/value parsing helpers ───────────────────────────────────────────────── @@ -186,11 +185,10 @@ pub struct AddParams<'a> { } pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> Result { - let Value::Object(mut metadata_map) = build_json(params.meta_entries)? else { + let Value::Object(metadata_map) = build_json(params.meta_entries)? else { unreachable!("build_json always returns a JSON object"); }; - let normalized_entry_type = - taxonomy::normalize_entry_type_and_metadata(params.entry_type, &mut metadata_map); + let entry_type = params.entry_type.trim(); let metadata = Value::Object(metadata_map); let secret_json = build_json(params.secret_entries)?; let meta_keys = collect_key_paths(params.meta_entries)?; @@ -232,7 +230,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> entry_id: ex.id, user_id: params.user_id, folder: params.folder, - entry_type: &normalized_entry_type, + entry_type, name: params.name, version: ex.version, action: "add", @@ -262,7 +260,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> ) .bind(uid) .bind(params.folder) - .bind(&normalized_entry_type) + .bind(entry_type) .bind(params.name) .bind(params.notes) .bind(params.tags) @@ -285,7 +283,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> RETURNING id"#, ) .bind(params.folder) - .bind(&normalized_entry_type) + .bind(entry_type) .bind(params.name) .bind(params.notes) .bind(params.tags) @@ -307,7 +305,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> entry_id, user_id: params.user_id, folder: params.folder, - entry_type: &normalized_entry_type, + entry_type, name: params.name, version: current_entry_version, action: "create", @@ -434,7 +432,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> params.user_id, "add", params.folder, - &normalized_entry_type, + entry_type, params.name, serde_json::json!({ "tags": params.tags, @@ -449,7 +447,7 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) -> Ok(AddResult { name: params.name.to_string(), folder: params.folder.to_string(), - entry_type: normalized_entry_type, + entry_type: entry_type.to_string(), tags: params.tags.to_vec(), meta_keys, secret_keys, diff --git a/crates/secrets-core/src/service/update.rs b/crates/secrets-core/src/service/update.rs index 1997a23..9c34a70 100644 --- a/crates/secrets-core/src/service/update.rs +++ b/crates/secrets-core/src/service/update.rs @@ -11,7 +11,6 @@ use crate::service::add::{ collect_field_paths, collect_key_paths, flatten_json_fields, insert_path, parse_key_path, parse_kv, remove_path, }; -use crate::taxonomy; #[derive(Debug, serde::Serialize)] pub struct UpdateResult { @@ -501,13 +500,7 @@ pub async fn update_fields_by_id( tracing::warn!(error = %e, "failed to snapshot entry history before web update"); } - let mut metadata_map = match params.metadata { - Value::Object(m) => m.clone(), - _ => Map::new(), - }; - let normalized_type = - taxonomy::normalize_entry_type_and_metadata(params.entry_type, &mut metadata_map); - let normalized_metadata = Value::Object(metadata_map); + let entry_type = params.entry_type.trim(); let res = sqlx::query( "UPDATE entries SET folder = $1, type = $2, name = $3, notes = $4, tags = $5, metadata = $6, \ @@ -515,11 +508,11 @@ pub async fn update_fields_by_id( WHERE id = $7 AND version = $8", ) .bind(params.folder) - .bind(&normalized_type) + .bind(entry_type) .bind(params.name) .bind(params.notes) .bind(params.tags) - .bind(&normalized_metadata) + .bind(params.metadata) .bind(row.id) .bind(row.version) .execute(&mut *tx) @@ -546,7 +539,7 @@ pub async fn update_fields_by_id( Some(user_id), "update", params.folder, - &normalized_type, + entry_type, params.name, serde_json::json!({ "source": "web", diff --git a/crates/secrets-core/src/taxonomy.rs b/crates/secrets-core/src/taxonomy.rs index dcd9b48..fbf48be 100644 --- a/crates/secrets-core/src/taxonomy.rs +++ b/crates/secrets-core/src/taxonomy.rs @@ -1,111 +1,4 @@ -use serde_json::{Map, Value}; - -fn normalize_token(input: &str) -> String { - input.trim().to_lowercase().replace('_', "-") -} - -fn normalize_subtype_token(input: &str) -> String { - normalize_token(input) -} - -fn map_legacy_entry_type(input: &str) -> Option<(&'static str, &'static str)> { - match input { - "log-ingestion-endpoint" => Some(("service", "log-ingestion")), - "cloud-api" => Some(("service", "cloud-api")), - "git-server" => Some(("service", "git")), - "mqtt-broker" => Some(("service", "mqtt-broker")), - "database" => Some(("service", "database")), - "monitoring-dashboard" => Some(("service", "monitoring")), - "dns-api" => Some(("service", "dns-api")), - "notification-webhook" => Some(("service", "webhook")), - "api-endpoint" => Some(("service", "api-endpoint")), - "credential" | "credential-key" => Some(("service", "credential")), - "key" => Some(("service", "credential")), - _ => None, - } -} - -/// Normalize entry `type` and optionally backfill `metadata.subtype` for legacy values. -/// -/// This keeps backward compatibility: -/// - stable primary types stay unchanged -/// - known legacy long-tail types are mapped to `service` + `metadata.subtype` -/// - unknown values are kept (normalized to kebab-case) instead of hard failing -pub fn normalize_entry_type_and_metadata( - entry_type: &str, - metadata: &mut Map, -) -> String { - let original_raw = entry_type.trim(); - let normalized = normalize_token(original_raw); - if normalized.is_empty() { - return String::new(); - } - - if let Some((mapped_type, mapped_subtype)) = map_legacy_entry_type(&normalized) { - if !metadata.contains_key("subtype") { - metadata.insert( - "subtype".to_string(), - Value::String(mapped_subtype.to_string()), - ); - } - if !metadata.contains_key("_original_type") && original_raw != mapped_type { - metadata.insert( - "_original_type".to_string(), - Value::String(original_raw.to_string()), - ); - } - return mapped_type.to_string(); - } - - if let Some(subtype) = metadata.get_mut("subtype") - && let Some(s) = subtype.as_str() - { - *subtype = Value::String(normalize_subtype_token(s)); - } - - normalized -} - /// Canonical secret type options for UI dropdowns. pub const SECRET_TYPE_OPTIONS: &[&str] = &[ "text", "password", "token", "api-key", "ssh-key", "url", "phone", "id-card", ]; - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::{Map, Value}; - - #[test] - fn normalize_entry_type_maps_legacy_type_and_backfills_metadata() { - let mut metadata = Map::new(); - let normalized = normalize_entry_type_and_metadata("git-server", &mut metadata); - - assert_eq!(normalized, "service"); - assert_eq!( - metadata.get("subtype"), - Some(&Value::String("git".to_string())) - ); - assert_eq!( - metadata.get("_original_type"), - Some(&Value::String("git-server".to_string())) - ); - } - - #[test] - fn normalize_entry_type_normalizes_existing_subtype() { - let mut metadata = Map::new(); - metadata.insert( - "subtype".to_string(), - Value::String("Cloud_API".to_string()), - ); - - let normalized = normalize_entry_type_and_metadata("service", &mut metadata); - - assert_eq!(normalized, "service"); - assert_eq!( - metadata.get("subtype"), - Some(&Value::String("cloud-api".to_string())) - ); - } -} diff --git a/crates/secrets-mcp/Cargo.toml b/crates/secrets-mcp/Cargo.toml index 4605af0..4e471ec 100644 --- a/crates/secrets-mcp/Cargo.toml +++ b/crates/secrets-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "secrets-mcp" -version = "0.5.0" +version = "0.5.1" edition.workspace = true [[bin]] diff --git a/crates/secrets-mcp/src/tools.rs b/crates/secrets-mcp/src/tools.rs index 36b8e11..a0482fb 100644 --- a/crates/secrets-mcp/src/tools.rs +++ b/crates/secrets-mcp/src/tools.rs @@ -70,6 +70,94 @@ mod deser { } } } + + /// Deserialize a bool that may come as a JSON bool or a JSON string ("true"/"false"). + pub fn option_bool_from_string<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum BoolOrStr { + Bool(bool), + Str(String), + } + + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(BoolOrStr::Bool(b)) => Ok(Some(b)), + Some(BoolOrStr::Str(s)) => { + if s.is_empty() { + return Ok(None); + } + s.parse::().map(Some).map_err(de::Error::custom) + } + } + } + + /// Deserialize a Vec that may come as a JSON array or a JSON string containing an array. + pub fn option_vec_string_from_string<'de, D>( + deserializer: D, + ) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum VecOrStr { + Vec(Vec), + Str(String), + } + + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(VecOrStr::Vec(v)) => Ok(Some(v)), + Some(VecOrStr::Str(s)) => { + if s.is_empty() { + return Ok(None); + } + serde_json::from_str(&s) + .map(Some) + .map_err(|e| { + de::Error::custom(format!( + "invalid string value for array field: expected a JSON array, e.g. '[\"a\",\"b\"]': {e}" + )) + }) + } + } + } + + /// Deserialize a Map that may come as a JSON object or a JSON string containing an object. + pub fn option_map_from_string<'de, D>( + deserializer: D, + ) -> Result>, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum MapOrStr { + Map(Map), + Str(String), + } + + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(MapOrStr::Map(m)) => Ok(Some(m)), + Some(MapOrStr::Str(s)) => { + if s.is_empty() { + return Ok(None); + } + serde_json::from_str(&s) + .map(Some) + .map_err(|e| { + de::Error::custom(format!( + "invalid string value for object field: expected a JSON object, e.g. '{{\"key\":\"value\"}}': {e}" + )) + }) + } + } + } } use secrets_core::models::ExportFormat; @@ -229,7 +317,7 @@ struct FindInput { #[schemars(description = "Exact folder filter (e.g. 'refining', 'ricnsmart')")] folder: Option, #[schemars( - description = "Exact type filter (recommended: 'server', 'service', 'person', 'document')" + description = "Exact type filter (e.g. 'server', 'service', 'account', 'person', 'document'). User-defined, any value accepted." )] #[serde(rename = "type")] entry_type: Option, @@ -240,6 +328,7 @@ struct FindInput { )] name_query: Option, #[schemars(description = "Tag filters (all must match)")] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] tags: Option>, #[schemars(description = "Max results (default 20)")] #[serde(default, deserialize_with = "deser::option_u32_from_string")] @@ -253,7 +342,7 @@ struct SearchInput { #[schemars(description = "Folder filter (e.g. 'refining', 'personal', 'family')")] folder: Option, #[schemars( - description = "Type filter (recommended: 'server', 'service', 'person', 'document')" + description = "Type filter (e.g. 'server', 'service', 'account', 'person', 'document'). User-defined, any value accepted." )] #[serde(rename = "type")] entry_type: Option, @@ -264,8 +353,10 @@ struct SearchInput { )] name_query: Option, #[schemars(description = "Tag filters (all must match)")] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] tags: Option>, #[schemars(description = "Return only summary fields (name/tags/notes/updated_at)")] + #[serde(default, deserialize_with = "deser::option_bool_from_string")] summary: Option, #[schemars(description = "Sort order: 'name' (default), 'updated', 'created'")] sort: Option, @@ -292,35 +383,42 @@ struct AddInput { #[schemars(description = "Folder for organization (optional, e.g. 'personal', 'refining')")] folder: Option, #[schemars( - description = "Type/category of this entry (optional, recommended: 'server', 'service', 'person', 'document')" + description = "Type/category of this entry (optional, e.g. 'server', 'service', 'account', 'person', 'document'). Free-form, choose what best describes the entry." )] #[serde(rename = "type")] entry_type: Option, #[schemars(description = "Free-text notes for this entry (optional)")] notes: Option, #[schemars(description = "Tags for this entry")] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] tags: Option>, #[schemars(description = "Metadata fields as 'key=value' or 'key:=json' strings")] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] meta: Option>, #[schemars( description = "Metadata fields as a JSON object {\"key\": value}. Merged with 'meta' if both provided." )] + #[serde(default, deserialize_with = "deser::option_map_from_string")] meta_obj: Option>, #[schemars( description = "Secret fields as 'key=value' strings. Reminder: non-sensitive endpoint/address fields should go to metadata.address instead of secrets." )] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] secrets: Option>, #[schemars( description = "Secret fields as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided. Reminder: non-sensitive endpoint/address fields should go to metadata.address." )] + #[serde(default, deserialize_with = "deser::option_map_from_string")] secrets_obj: Option>, #[schemars( description = "Secret types as {\"secret_name\": \"type\"}. Keys must match secret field names. Missing keys default to \"text\"." )] + #[serde(default, deserialize_with = "deser::option_map_from_string")] secret_types: Option>, #[schemars( description = "Link existing secrets by secret name. Names must resolve uniquely under current user." )] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] link_secret_names: Option>, } @@ -339,38 +437,49 @@ struct UpdateInput { #[schemars(description = "Update the notes field")] notes: Option, #[schemars(description = "Tags to add")] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] add_tags: Option>, #[schemars(description = "Tags to remove")] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] remove_tags: Option>, #[schemars(description = "Metadata fields to update/add as 'key=value' strings")] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] meta: Option>, #[schemars( description = "Metadata fields to update/add as a JSON object {\"key\": value}. Merged with 'meta' if both provided." )] + #[serde(default, deserialize_with = "deser::option_map_from_string")] meta_obj: Option>, #[schemars(description = "Metadata field keys to remove")] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] remove_meta: Option>, #[schemars( description = "Secret fields to update/add as 'key=value' strings. Reminder: non-sensitive endpoint/address fields should go to metadata.address instead of secrets." )] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] secrets: Option>, #[schemars( description = "Secret fields to update/add as a JSON object {\"key\": \"value\"}. Merged with 'secrets' if both provided. Reminder: non-sensitive endpoint/address fields should go to metadata.address." )] + #[serde(default, deserialize_with = "deser::option_map_from_string")] secrets_obj: Option>, #[schemars( description = "Secret types as {\"secret_name\": \"type\"}. Keys must match secret field names. Missing keys default to \"text\"." )] + #[serde(default, deserialize_with = "deser::option_map_from_string")] secret_types: Option>, #[schemars(description = "Secret field keys to remove")] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] remove_secrets: Option>, #[schemars( description = "Link existing secrets by name to this entry. Names must resolve uniquely under current user." )] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] link_secret_names: Option>, #[schemars( description = "Unlink secrets by name from this entry. Orphaned secrets are auto-deleted." )] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] unlink_secret_names: Option>, } @@ -390,6 +499,7 @@ struct DeleteInput { #[serde(rename = "type")] entry_type: Option, #[schemars(description = "Preview deletions without writing")] + #[serde(default, deserialize_with = "deser::option_bool_from_string")] dry_run: Option, } @@ -437,6 +547,7 @@ struct ExportInput { #[schemars(description = "Exact name filter")] name: Option, #[schemars(description = "Tag filters")] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] tags: Option>, #[schemars(description = "Fuzzy query")] query: Option, @@ -454,8 +565,10 @@ struct EnvMapInput { #[schemars(description = "Exact name filter")] name: Option, #[schemars(description = "Tag filters")] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] tags: Option>, #[schemars(description = "Only include these secret fields")] + #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] only_fields: Option>, #[schemars(description = "Environment variable name prefix. \ Variable names are built as UPPER(prefix)_UPPER(entry_name)_UPPER(field_name), \ @@ -1286,3 +1399,202 @@ impl ServerHandler for SecretsService { info } } + +#[cfg(test)] +mod deser_tests { + use super::deser; + use serde::Deserialize; + use serde_json::json; + + #[derive(Deserialize)] + struct TestU32 { + #[serde(deserialize_with = "deser::option_u32_from_string")] + val: Option, + } + + #[derive(Deserialize)] + struct TestI64 { + #[serde(deserialize_with = "deser::option_i64_from_string")] + val: Option, + } + + #[derive(Deserialize)] + struct TestBool { + #[serde(deserialize_with = "deser::option_bool_from_string")] + val: Option, + } + + #[derive(Debug, Deserialize)] + struct TestVec { + #[serde(deserialize_with = "deser::option_vec_string_from_string")] + val: Option>, + } + + #[derive(Debug, Deserialize)] + struct TestMap { + #[serde(deserialize_with = "deser::option_map_from_string")] + val: Option>, + } + + // option_u32_from_string + #[test] + fn u32_native_number() { + let v: TestU32 = serde_json::from_value(json!({"val": 42})).unwrap(); + assert_eq!(v.val, Some(42)); + } + + #[test] + fn u32_string_number() { + let v: TestU32 = serde_json::from_value(json!({"val": "42"})).unwrap(); + assert_eq!(v.val, Some(42)); + } + + #[test] + fn u32_empty_string() { + let v: TestU32 = serde_json::from_value(json!({"val": ""})).unwrap(); + assert_eq!(v.val, None); + } + + #[test] + fn u32_none() { + let v: TestU32 = serde_json::from_value(json!({"val": null})).unwrap(); + assert_eq!(v.val, None); + } + + // option_i64_from_string + #[test] + fn i64_native_number() { + let v: TestI64 = serde_json::from_value(json!({"val": -100})).unwrap(); + assert_eq!(v.val, Some(-100)); + } + + #[test] + fn i64_string_number() { + let v: TestI64 = serde_json::from_value(json!({"val": "999"})).unwrap(); + assert_eq!(v.val, Some(999)); + } + + #[test] + fn i64_empty_string() { + let v: TestI64 = serde_json::from_value(json!({"val": ""})).unwrap(); + assert_eq!(v.val, None); + } + + #[test] + fn i64_none() { + let v: TestI64 = serde_json::from_value(json!({"val": null})).unwrap(); + assert_eq!(v.val, None); + } + + // option_bool_from_string + #[test] + fn bool_native_true() { + let v: TestBool = serde_json::from_value(json!({"val": true})).unwrap(); + assert_eq!(v.val, Some(true)); + } + + #[test] + fn bool_native_false() { + let v: TestBool = serde_json::from_value(json!({"val": false})).unwrap(); + assert_eq!(v.val, Some(false)); + } + + #[test] + fn bool_string_true() { + let v: TestBool = serde_json::from_value(json!({"val": "true"})).unwrap(); + assert_eq!(v.val, Some(true)); + } + + #[test] + fn bool_string_false() { + let v: TestBool = serde_json::from_value(json!({"val": "false"})).unwrap(); + assert_eq!(v.val, Some(false)); + } + + #[test] + fn bool_empty_string() { + let v: TestBool = serde_json::from_value(json!({"val": ""})).unwrap(); + assert_eq!(v.val, None); + } + + #[test] + fn bool_none() { + let v: TestBool = serde_json::from_value(json!({"val": null})).unwrap(); + assert_eq!(v.val, None); + } + + // option_vec_string_from_string + #[test] + fn vec_native_array() { + let v: TestVec = serde_json::from_value(json!({"val": ["a", "b"]})).unwrap(); + assert_eq!(v.val, Some(vec!["a".to_string(), "b".to_string()])); + } + + #[test] + fn vec_json_string_array() { + let v: TestVec = serde_json::from_value(json!({"val": "[\"x\",\"y\"]"})).unwrap(); + assert_eq!(v.val, Some(vec!["x".to_string(), "y".to_string()])); + } + + #[test] + fn vec_empty_string() { + let v: TestVec = serde_json::from_value(json!({"val": ""})).unwrap(); + assert_eq!(v.val, None); + } + + #[test] + fn vec_none() { + let v: TestVec = serde_json::from_value(json!({"val": null})).unwrap(); + assert_eq!(v.val, None); + } + + #[test] + fn vec_invalid_string_errors() { + let err = serde_json::from_value::(json!({"val": "not-json"})) + .expect_err("should fail on invalid JSON"); + let msg = err.to_string(); + assert!(msg.contains("invalid string value for array field")); + assert!(msg.contains("expected a JSON array")); + } + + // option_map_from_string + #[test] + fn map_native_object() { + let v: TestMap = serde_json::from_value(json!({"val": {"key": "value"}})).unwrap(); + assert!(v.val.is_some()); + let m = v.val.unwrap(); + assert_eq!( + m.get("key"), + Some(&serde_json::Value::String("value".to_string())) + ); + } + + #[test] + fn map_json_string_object() { + let v: TestMap = serde_json::from_value(json!({"val": "{\"a\":1}"})).unwrap(); + assert!(v.val.is_some()); + let m = v.val.unwrap(); + assert_eq!(m.get("a"), Some(&serde_json::Value::Number(1.into()))); + } + + #[test] + fn map_empty_string() { + let v: TestMap = serde_json::from_value(json!({"val": ""})).unwrap(); + assert_eq!(v.val, None); + } + + #[test] + fn map_none() { + let v: TestMap = serde_json::from_value(json!({"val": null})).unwrap(); + assert_eq!(v.val, None); + } + + #[test] + fn map_invalid_string_errors() { + let err = serde_json::from_value::(json!({"val": "not-json"})) + .expect_err("should fail on invalid JSON"); + let msg = err.to_string(); + assert!(msg.contains("invalid string value for object field")); + assert!(msg.contains("expected a JSON object")); + } +}