From f9e11c3b62bf277c4d0fd2b7d61c4a5a3421089d Mon Sep 17 00:00:00 2001 From: Scot Campbell Date: Tue, 21 Apr 2026 12:09:27 -0400 Subject: [PATCH] fix: writers emit tags as JSON array frontmatter (skill/command/subagent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the round-trip bug #200 caught for rule_writer: the DB reader for skills, commands, and sub-agents deserialises the `tags` column via `serde_json::from_str` (see `db/schema.rs:1727`, `:1860`, `:2009`), but the three corresponding writers silently dropped the `tags` field from frontmatter entirely. No live bug today — no scanner re-ingests these primitives — but once scanners do (and given the asymmetry #200 closed, that direction is likely), disk round-trip would either lose tags or, if someone shipped a comma-joined stopgap, silently corrupt them. Fixes: - skill_writer::generate_skill_markdown emits `tags: ` when non-empty - command_writer::generate_command_markdown emits `tags: ` when non-empty - subagent_writer::generate_subagent_markdown emits `tags: ` when non-empty Each writer gains two tests: one pinning the JSON-array shape, one pinning the empty-vec skip path. Full `cargo test --lib` passes. Not touched: `paths`, `allowed_tools`, `tools`, `skills`, `disallowed_tools` in the same writers still use comma-joined form. They have the same latent shape but are out of scope for this PR (would expand the diff; can land separately once their scanners exist and decide on a canonical form). --- src-tauri/src/services/command_writer.rs | 31 +++++++++++++++++++++++ src-tauri/src/services/skill_writer.rs | 31 +++++++++++++++++++++++ src-tauri/src/services/subagent_writer.rs | 31 +++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/src-tauri/src/services/command_writer.rs b/src-tauri/src/services/command_writer.rs index d5cd00c..cecf757 100644 --- a/src-tauri/src/services/command_writer.rs +++ b/src-tauri/src/services/command_writer.rs @@ -41,6 +41,12 @@ pub(crate) fn generate_command_markdown(command: &Command) -> String { } } + if let Some(ref tags) = command.tags { + if !tags.is_empty() { + frontmatter.push_str(&format!("tags: {}\n", serde_json::to_string(tags).unwrap())); + } + } + frontmatter.push_str("---\n\n"); format!("{}{}", frontmatter, command.content) } @@ -267,6 +273,31 @@ mod tests { assert!(md.contains("Minimal content.")); } + #[test] + fn test_generate_command_markdown_emits_tags_as_json_array() { + // Mirrors the rule_writer fix: `tags` is read from the DB via + // `serde_json::from_str`, so if a scanner ever ingests a command + // frontmatter the value must be valid JSON, not comma-joined. + // Also pins against silent-drop: previously `command.tags` was not + // written to frontmatter at all. + let mut command = sample_minimal_command(); + command.tags = Some(vec!["triage".to_string(), "prs".to_string()]); + + let md = generate_command_markdown(&command); + + assert!(md.contains("tags: [\"triage\",\"prs\"]\n")); + } + + #[test] + fn test_generate_command_markdown_omits_empty_tags() { + let mut command = sample_minimal_command(); + command.tags = Some(vec![]); + + let md = generate_command_markdown(&command); + + assert!(!md.contains("tags:")); + } + // ========================================================================= // write_command_file tests // ========================================================================= diff --git a/src-tauri/src/services/skill_writer.rs b/src-tauri/src/services/skill_writer.rs index a6a3f24..be3a268 100644 --- a/src-tauri/src/services/skill_writer.rs +++ b/src-tauri/src/services/skill_writer.rs @@ -68,6 +68,12 @@ pub(crate) fn generate_skill_markdown(skill: &Skill) -> String { } } + if let Some(ref tags) = skill.tags { + if !tags.is_empty() { + frontmatter.push_str(&format!("tags: {}\n", serde_json::to_string(tags).unwrap())); + } + } + frontmatter.push_str("---\n\n"); format!("{}{}", frontmatter, skill.content) } @@ -271,6 +277,31 @@ mod tests { assert!(md.contains("name: minimal\n")); } + #[test] + fn test_generate_skill_markdown_emits_tags_as_json_array() { + // Mirrors the rule_writer fix: `tags` is read from the DB via + // `serde_json::from_str`, so if a scanner ever ingests a skill + // frontmatter the value must be valid JSON, not comma-joined. + // Also pins against silent-drop: previously `skill.tags` was not + // written to frontmatter at all. + let mut skill = sample_minimal_skill(); + skill.tags = Some(vec!["refactor".to_string(), "typescript".to_string()]); + + let md = generate_skill_markdown(&skill); + + assert!(md.contains("tags: [\"refactor\",\"typescript\"]\n")); + } + + #[test] + fn test_generate_skill_markdown_omits_empty_tags() { + let mut skill = sample_minimal_skill(); + skill.tags = Some(vec![]); + + let md = generate_skill_markdown(&skill); + + assert!(!md.contains("tags:")); + } + // ========================================================================= // write_skill_file tests (file system) // ========================================================================= diff --git a/src-tauri/src/services/subagent_writer.rs b/src-tauri/src/services/subagent_writer.rs index 35edecb..ed1880c 100644 --- a/src-tauri/src/services/subagent_writer.rs +++ b/src-tauri/src/services/subagent_writer.rs @@ -78,6 +78,12 @@ pub(crate) fn generate_subagent_markdown(subagent: &SubAgent) -> String { } } + if let Some(ref tags) = subagent.tags { + if !tags.is_empty() { + frontmatter.push_str(&format!("tags: {}\n", serde_json::to_string(tags).unwrap())); + } + } + frontmatter.push_str("---\n\n"); format!("{}{}", frontmatter, subagent.content) } @@ -320,6 +326,31 @@ mod tests { assert!(md.contains("---\n\nYou are a helpful assistant.")); } + #[test] + fn test_generate_subagent_markdown_emits_tags_as_json_array() { + // Mirrors the rule_writer fix: `tags` is read from the DB via + // `serde_json::from_str`, so if a scanner ever ingests a subagent + // frontmatter the value must be valid JSON, not comma-joined. + // Also pins against silent-drop: previously `subagent.tags` was not + // written to frontmatter at all. + let mut subagent = sample_minimal_subagent(); + subagent.tags = Some(vec!["review".to_string(), "quality".to_string()]); + + let md = generate_subagent_markdown(&subagent); + + assert!(md.contains("tags: [\"review\",\"quality\"]\n")); + } + + #[test] + fn test_generate_subagent_markdown_omits_empty_tags() { + let mut subagent = sample_minimal_subagent(); + subagent.tags = Some(vec![]); + + let md = generate_subagent_markdown(&subagent); + + assert!(!md.contains("tags:")); + } + #[test] fn test_generate_subagent_markdown_empty_tools_skipped() { let mut subagent = sample_full_subagent();