@@ -170,12 +170,35 @@ pub struct RewriteParams {
170170 pub preserve_frontmatter : Option < bool > ,
171171}
172172
173+ /// Operation kind for frontmatter edits.
174+ #[ derive( Debug , Clone , Copy , Deserialize , JsonSchema ) ]
175+ #[ serde( rename_all = "snake_case" ) ]
176+ pub enum FrontmatterOpKind {
177+ Set ,
178+ Remove ,
179+ AddTag ,
180+ RemoveTag ,
181+ AddAlias ,
182+ RemoveAlias ,
183+ }
184+
185+ /// A single frontmatter operation.
186+ #[ derive( Debug , Clone , Deserialize , JsonSchema ) ]
187+ pub struct FrontmatterOpInput {
188+ /// Operation type.
189+ pub op : FrontmatterOpKind ,
190+ /// Property key (required for "set" and "remove").
191+ pub key : Option < String > ,
192+ /// Value (required for "set", "add_tag", "remove_tag", "add_alias", "remove_alias").
193+ pub value : Option < String > ,
194+ }
195+
173196#[ derive( Debug , Deserialize , JsonSchema ) ]
174197pub struct EditFrontmatterParams {
175198 /// Target note: file path, basename, or #docid.
176199 pub file : String ,
177200 /// Operations to apply. Array of objects like {"op": "add_tag", "value": "rust"} or {"op": "set", "key": "status", "value": "done"} or {"op": "remove", "key": "status"} or {"op": "remove_tag", "value": "old"}.
178- pub operations : Vec < serde_json :: Value > ,
201+ pub operations : Vec < FrontmatterOpInput > ,
179202}
180203
181204#[ derive( Debug , Deserialize , JsonSchema ) ]
@@ -266,108 +289,82 @@ async fn record_write(recent_writes: &RecentWrites, path: &Path) {
266289 }
267290}
268291
269- /// Parse a JSON operations array into `Vec<FrontmatterOp>`.
270- fn parse_frontmatter_ops ( operations : & [ serde_json:: Value ] ) -> Result < Vec < FrontmatterOp > , McpError > {
292+ /// Convert typed operation inputs into `Vec<FrontmatterOp>`.
293+ fn parse_frontmatter_ops (
294+ operations : & [ FrontmatterOpInput ] ,
295+ ) -> Result < Vec < FrontmatterOp > , McpError > {
271296 let mut ops = Vec :: with_capacity ( operations. len ( ) ) ;
272- for op_val in operations {
273- let op_str = op_val. get ( "op" ) . and_then ( |v| v. as_str ( ) ) . ok_or_else ( || {
274- McpError :: new (
275- rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
276- "each operation must have an \" op\" string field" ,
277- None :: < serde_json:: Value > ,
278- )
279- } ) ?;
280- match op_str {
281- "set" => {
282- let key = op_val. get ( "key" ) . and_then ( |v| v. as_str ( ) ) . ok_or_else ( || {
297+ for input in operations {
298+ let op = match input. op {
299+ FrontmatterOpKind :: Set => {
300+ let key = input. key . as_deref ( ) . ok_or_else ( || {
283301 McpError :: new (
284302 rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
285303 "\" set\" operation requires a \" key\" field" ,
286304 None :: < serde_json:: Value > ,
287305 )
288306 } ) ?;
289- let value = op_val
290- . get ( "value" )
291- . and_then ( |v| v. as_str ( ) )
292- . ok_or_else ( || {
293- McpError :: new (
294- rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
295- "\" set\" operation requires a \" value\" field" ,
296- None :: < serde_json:: Value > ,
297- )
298- } ) ?;
299- ops. push ( FrontmatterOp :: Set ( key. to_string ( ) , value. to_string ( ) ) ) ;
307+ let value = input. value . as_deref ( ) . ok_or_else ( || {
308+ McpError :: new (
309+ rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
310+ "\" set\" operation requires a \" value\" field" ,
311+ None :: < serde_json:: Value > ,
312+ )
313+ } ) ?;
314+ FrontmatterOp :: Set ( key. to_string ( ) , value. to_string ( ) )
300315 }
301- "remove" => {
302- let key = op_val . get ( " key" ) . and_then ( |v| v . as_str ( ) ) . ok_or_else ( || {
316+ FrontmatterOpKind :: Remove => {
317+ let key = input . key . as_deref ( ) . ok_or_else ( || {
303318 McpError :: new (
304319 rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
305320 "\" remove\" operation requires a \" key\" field" ,
306321 None :: < serde_json:: Value > ,
307322 )
308323 } ) ?;
309- ops. push ( FrontmatterOp :: Remove ( key. to_string ( ) ) ) ;
310- }
311- "add_tag" => {
312- let value = op_val
313- . get ( "value" )
314- . and_then ( |v| v. as_str ( ) )
315- . ok_or_else ( || {
316- McpError :: new (
317- rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
318- "\" add_tag\" operation requires a \" value\" field" ,
319- None :: < serde_json:: Value > ,
320- )
321- } ) ?;
322- ops. push ( FrontmatterOp :: AddTag ( value. to_string ( ) ) ) ;
324+ FrontmatterOp :: Remove ( key. to_string ( ) )
323325 }
324- "remove_tag" => {
325- let value = op_val
326- . get ( "value" )
327- . and_then ( |v| v. as_str ( ) )
328- . ok_or_else ( || {
329- McpError :: new (
330- rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
331- "\" remove_tag\" operation requires a \" value\" field" ,
332- None :: < serde_json:: Value > ,
333- )
334- } ) ?;
335- ops. push ( FrontmatterOp :: RemoveTag ( value. to_string ( ) ) ) ;
326+ FrontmatterOpKind :: AddTag => {
327+ let value = input. value . as_deref ( ) . ok_or_else ( || {
328+ McpError :: new (
329+ rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
330+ "\" add_tag\" operation requires a \" value\" field" ,
331+ None :: < serde_json:: Value > ,
332+ )
333+ } ) ?;
334+ FrontmatterOp :: AddTag ( value. to_string ( ) )
336335 }
337- "add_alias" => {
338- let value = op_val
339- . get ( "value" )
340- . and_then ( |v| v. as_str ( ) )
341- . ok_or_else ( || {
342- McpError :: new (
343- rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
344- "\" add_alias\" operation requires a \" value\" field" ,
345- None :: < serde_json:: Value > ,
346- )
347- } ) ?;
348- ops. push ( FrontmatterOp :: AddAlias ( value. to_string ( ) ) ) ;
336+ FrontmatterOpKind :: RemoveTag => {
337+ let value = input. value . as_deref ( ) . ok_or_else ( || {
338+ McpError :: new (
339+ rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
340+ "\" remove_tag\" operation requires a \" value\" field" ,
341+ None :: < serde_json:: Value > ,
342+ )
343+ } ) ?;
344+ FrontmatterOp :: RemoveTag ( value. to_string ( ) )
349345 }
350- "remove_alias" => {
351- let value = op_val
352- . get ( "value" )
353- . and_then ( |v| v. as_str ( ) )
354- . ok_or_else ( || {
355- McpError :: new (
356- rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
357- "\" remove_alias\" operation requires a \" value\" field" ,
358- None :: < serde_json:: Value > ,
359- )
360- } ) ?;
361- ops. push ( FrontmatterOp :: RemoveAlias ( value. to_string ( ) ) ) ;
346+ FrontmatterOpKind :: AddAlias => {
347+ let value = input. value . as_deref ( ) . ok_or_else ( || {
348+ McpError :: new (
349+ rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
350+ "\" add_alias\" operation requires a \" value\" field" ,
351+ None :: < serde_json:: Value > ,
352+ )
353+ } ) ?;
354+ FrontmatterOp :: AddAlias ( value. to_string ( ) )
362355 }
363- unknown => {
364- return Err ( McpError :: new (
365- rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
366- format ! ( "unknown frontmatter operation: \" {unknown}\" " ) ,
367- None :: < serde_json:: Value > ,
368- ) ) ;
356+ FrontmatterOpKind :: RemoveAlias => {
357+ let value = input. value . as_deref ( ) . ok_or_else ( || {
358+ McpError :: new (
359+ rmcp:: model:: ErrorCode :: INVALID_PARAMS ,
360+ "\" remove_alias\" operation requires a \" value\" field" ,
361+ None :: < serde_json:: Value > ,
362+ )
363+ } ) ?;
364+ FrontmatterOp :: RemoveAlias ( value. to_string ( ) )
369365 }
370- }
366+ } ;
367+ ops. push ( op) ;
371368 }
372369 Ok ( ops)
373370}
@@ -1165,3 +1162,59 @@ pub async fn run_serve(
11651162
11661163 Ok ( ( ) )
11671164}
1165+
1166+ #[ cfg( test) ]
1167+ mod tests {
1168+ use super :: * ;
1169+
1170+ /// Regression test for <https://github.com/devwhodevs/engraph/issues/32>.
1171+ #[ test]
1172+ fn edit_frontmatter_operations_schema_has_object_items ( ) {
1173+ let schema = schemars:: schema_for!( EditFrontmatterParams ) ;
1174+ let json = serde_json:: to_value ( & schema) . unwrap ( ) ;
1175+
1176+ let items = & json[ "properties" ] [ "operations" ] [ "items" ] ;
1177+ assert ! (
1178+ items. is_object( ) ,
1179+ "operations.items must be an object schema, got: {items}"
1180+ ) ;
1181+
1182+ // schemars may inline properties or use a $ref to $defs; both are
1183+ // valid object schemas that OpenAI accepts.
1184+ let has_properties = items. get ( "properties" ) . is_some ( ) ;
1185+ let has_ref = items. get ( "$ref" ) . is_some ( ) ;
1186+ assert ! (
1187+ has_properties || has_ref,
1188+ "operations.items must define properties or $ref, got: {items}"
1189+ ) ;
1190+ }
1191+
1192+ #[ test]
1193+ fn frontmatter_op_input_deserializes_all_variants ( ) {
1194+ let cases = [
1195+ ( r#"{"op":"set","key":"status","value":"done"}"# , "set" ) ,
1196+ ( r#"{"op":"remove","key":"status"}"# , "remove" ) ,
1197+ ( r#"{"op":"add_tag","value":"rust"}"# , "add_tag" ) ,
1198+ ( r#"{"op":"remove_tag","value":"old"}"# , "remove_tag" ) ,
1199+ ( r#"{"op":"add_alias","value":"eng"}"# , "add_alias" ) ,
1200+ ( r#"{"op":"remove_alias","value":"eng"}"# , "remove_alias" ) ,
1201+ ] ;
1202+ for ( json, label) in cases {
1203+ let input: FrontmatterOpInput = serde_json:: from_str ( json)
1204+ . unwrap_or_else ( |e| panic ! ( "failed to deserialize {label}: {e}" ) ) ;
1205+ // Verify the parsed input converts to a valid FrontmatterOp.
1206+ let ops = parse_frontmatter_ops ( & [ input] ) ;
1207+ assert ! ( ops. is_ok( ) , "{label} should produce a valid op: {:?}" , ops) ;
1208+ }
1209+ }
1210+
1211+ #[ test]
1212+ fn frontmatter_op_input_rejects_unknown_variant ( ) {
1213+ let json = r#"{"op":"unknown_op","value":"x"}"# ;
1214+ let result: Result < FrontmatterOpInput , _ > = serde_json:: from_str ( json) ;
1215+ assert ! (
1216+ result. is_err( ) ,
1217+ "unknown op variant should fail deserialization"
1218+ ) ;
1219+ }
1220+ }
0 commit comments