chore(secrets-mcp): 0.5.1 — 移除 entry type 归一化,MCP 参数兼容字符串形式
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m22s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 6s

- 去掉 taxonomy 对 entry type 的自动映射与 metadata.subtype 回填;仅 trim 后入库
- MCP tools:Vec/Map/bool 等可选字段支持 JSON 内嵌字符串解析,并改进解析失败提示
- 新增 deser 单元测试;README/AGENTS 与 models 注释同步

Made-with: Cursor
This commit is contained in:
voson
2026-04-04 21:27:33 +08:00
parent 0ffb81e57f
commit aefad33870
9 changed files with 335 additions and 142 deletions

View File

@@ -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 {

View File

@@ -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<AddResult> {
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,

View File

@@ -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",

View File

@@ -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, Value>,
) -> 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()))
);
}
}

View File

@@ -1,6 +1,6 @@
[package]
name = "secrets-mcp"
version = "0.5.0"
version = "0.5.1"
edition.workspace = true
[[bin]]

View File

@@ -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<Option<bool>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum BoolOrStr {
Bool(bool),
Str(String),
}
match Option::<BoolOrStr>::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::<bool>().map(Some).map_err(de::Error::custom)
}
}
}
/// Deserialize a Vec<String> 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<Option<Vec<String>>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum VecOrStr {
Vec(Vec<String>),
Str(String),
}
match Option::<VecOrStr>::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<String, Value> 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<Option<Map<String, Value>>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum MapOrStr {
Map(Map<String, Value>),
Str(String),
}
match Option::<MapOrStr>::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<String>,
#[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<String>,
@@ -240,6 +328,7 @@ struct FindInput {
)]
name_query: Option<String>,
#[schemars(description = "Tag filters (all must match)")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
tags: Option<Vec<String>>,
#[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<String>,
#[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<String>,
@@ -264,8 +353,10 @@ struct SearchInput {
)]
name_query: Option<String>,
#[schemars(description = "Tag filters (all must match)")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
tags: Option<Vec<String>>,
#[schemars(description = "Return only summary fields (name/tags/notes/updated_at)")]
#[serde(default, deserialize_with = "deser::option_bool_from_string")]
summary: Option<bool>,
#[schemars(description = "Sort order: 'name' (default), 'updated', 'created'")]
sort: Option<String>,
@@ -292,35 +383,42 @@ struct AddInput {
#[schemars(description = "Folder for organization (optional, e.g. 'personal', 'refining')")]
folder: Option<String>,
#[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<String>,
#[schemars(description = "Free-text notes for this entry (optional)")]
notes: Option<String>,
#[schemars(description = "Tags for this entry")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
tags: Option<Vec<String>>,
#[schemars(description = "Metadata fields as 'key=value' or 'key:=json' strings")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
meta: Option<Vec<String>>,
#[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<Map<String, Value>>,
#[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<Vec<String>>,
#[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<Map<String, Value>>,
#[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<Map<String, Value>>,
#[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<Vec<String>>,
}
@@ -339,38 +437,49 @@ struct UpdateInput {
#[schemars(description = "Update the notes field")]
notes: Option<String>,
#[schemars(description = "Tags to add")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
add_tags: Option<Vec<String>>,
#[schemars(description = "Tags to remove")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
remove_tags: Option<Vec<String>>,
#[schemars(description = "Metadata fields to update/add as 'key=value' strings")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
meta: Option<Vec<String>>,
#[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<Map<String, Value>>,
#[schemars(description = "Metadata field keys to remove")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
remove_meta: Option<Vec<String>>,
#[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<Vec<String>>,
#[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<Map<String, Value>>,
#[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<Map<String, Value>>,
#[schemars(description = "Secret field keys to remove")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
remove_secrets: Option<Vec<String>>,
#[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<Vec<String>>,
#[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<Vec<String>>,
}
@@ -390,6 +499,7 @@ struct DeleteInput {
#[serde(rename = "type")]
entry_type: Option<String>,
#[schemars(description = "Preview deletions without writing")]
#[serde(default, deserialize_with = "deser::option_bool_from_string")]
dry_run: Option<bool>,
}
@@ -437,6 +547,7 @@ struct ExportInput {
#[schemars(description = "Exact name filter")]
name: Option<String>,
#[schemars(description = "Tag filters")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
tags: Option<Vec<String>>,
#[schemars(description = "Fuzzy query")]
query: Option<String>,
@@ -454,8 +565,10 @@ struct EnvMapInput {
#[schemars(description = "Exact name filter")]
name: Option<String>,
#[schemars(description = "Tag filters")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
tags: Option<Vec<String>>,
#[schemars(description = "Only include these secret fields")]
#[serde(default, deserialize_with = "deser::option_vec_string_from_string")]
only_fields: Option<Vec<String>>,
#[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<u32>,
}
#[derive(Deserialize)]
struct TestI64 {
#[serde(deserialize_with = "deser::option_i64_from_string")]
val: Option<i64>,
}
#[derive(Deserialize)]
struct TestBool {
#[serde(deserialize_with = "deser::option_bool_from_string")]
val: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct TestVec {
#[serde(deserialize_with = "deser::option_vec_string_from_string")]
val: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct TestMap {
#[serde(deserialize_with = "deser::option_map_from_string")]
val: Option<serde_json::Map<String, serde_json::Value>>,
}
// 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::<TestVec>(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::<TestMap>(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"));
}
}