Skip to main content

bears/
scaffold.rs

1//! Scaffolding framework for coding-harness integration files.
2//!
3//! Each harness (Claude, Copilot, Codex) is described by a static registry
4//! entry: a list of `(target relative path, embedded content)` pairs and an
5//! MCP-registration strategy.  Adding a new harness is a data change — no new
6//! control flow required.
7
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use serde_json::Value;
12
13use crate::error::Result;
14
15// ── Embedded templates ───────────────────────────────────────────────────────
16
17/// Embedded template content for the Claude `CLAUDE.md` instruction file.
18const CLAUDE_MD: &str = include_str!("../templates/claude/CLAUDE.md");
19
20/// Embedded template for the Claude MCP server entry (used as a seed when no
21/// `.mcp.json` exists; for existing files we merge only the `bears` entry).
22const CLAUDE_MCP_SEED: &str = include_str!("../templates/claude/mcp.json");
23
24/// Embedded template for the Claude bears-planning skill.
25const CLAUDE_SKILL_MD: &str = include_str!("../templates/claude/skills/bears-planning/SKILL.md");
26
27/// Embedded CLI fallback reference for the Claude bears-planning skill.
28const CLAUDE_SKILL_CLI_FALLBACK: &str =
29    include_str!("../templates/claude/skills/bears-planning/references/cli-fallback.md");
30
31/// Embedded template for the Claude planner agent.
32const CLAUDE_AGENT_PLANNER: &str = include_str!("../templates/claude/agents/planner.md");
33
34/// Embedded template content for the Copilot instruction file.
35const COPILOT_MD: &str = include_str!("../templates/copilot/copilot-instructions.md");
36
37/// Embedded template for the Copilot MCP server entry seed.
38const COPILOT_MCP_SEED: &str = include_str!("../templates/copilot/mcp.json");
39
40/// Embedded template for the Copilot bears-planning skill.
41const COPILOT_SKILL_MD: &str = include_str!("../templates/copilot/skills/bears-planning/SKILL.md");
42
43/// Embedded CLI fallback reference for the Copilot bears-planning skill.
44const COPILOT_SKILL_CLI_FALLBACK: &str =
45    include_str!("../templates/copilot/skills/bears-planning/references/cli-fallback.md");
46
47/// Embedded template for the Copilot planner agent.
48const COPILOT_AGENT_PLANNER: &str = include_str!("../templates/copilot/agents/planner.agent.md");
49
50/// Embedded template content for the Codex `AGENTS.md` instruction file.
51const CODEX_MD: &str = include_str!("../templates/codex/AGENTS.md");
52
53// ── MCP strategy ─────────────────────────────────────────────────────────────
54
55/// How a harness registers its MCP server.
56#[derive(Clone)]
57pub enum McpStrategy {
58    /// Merge the `bears` entry under the given key path (e.g. `mcpServers`)
59    /// into the JSON file at `target`.
60    ///
61    /// `seed_json` is the full seed document from the embedded template; its
62    /// `server_key` object will be extracted and merged into any existing file.
63    MergeJson {
64        /// Path of the JSON config file relative to the project root.
65        target: &'static str,
66        /// Top-level key in the JSON object that contains the server map
67        /// (e.g. `"mcpServers"` for Claude, `"servers"` for Copilot).
68        server_key: &'static str,
69        /// Embedded seed JSON document (full file content).
70        seed_json: &'static str,
71    },
72    /// No MCP registration needed (harness finds servers another way).
73    None,
74}
75
76// ── Harness descriptor ────────────────────────────────────────────────────────
77
78/// A single file to scaffold: a target path (relative to the project root) and
79/// its embedded content.
80pub struct ScaffoldFile {
81    /// Target path relative to the project root (e.g. `"CLAUDE.md"`).
82    pub target: &'static str,
83    /// Embedded file content.
84    pub content: &'static str,
85}
86
87/// Describes a complete coding-harness integration.
88pub struct Harness {
89    /// The single top-level instruction file (`CLAUDE.md`, `AGENTS.md`,
90    /// `.github/copilot-instructions.md`).
91    pub instruction: ScaffoldFile,
92    /// Skill, reference, and planner-agent files (may be empty, e.g. Codex).
93    pub skills: &'static [ScaffoldFile],
94    /// How to register the MCP server.
95    pub mcp: McpStrategy,
96}
97
98/// Which subset of a harness's files to scaffold.
99#[derive(Clone, Copy, PartialEq, Eq, Debug)]
100pub enum Category {
101    /// The instruction file only.
102    Instructions,
103    /// Skills/references/agent files plus the MCP merge.
104    Skills,
105    /// Everything (instruction + skills + MCP).
106    All,
107}
108
109/// How to resolve a plain file that already exists on disk.
110///
111/// The MCP merge is always performed regardless of policy (it is safe and
112/// idempotent — only the `bears` key is touched).
113#[derive(Clone, Copy, PartialEq, Eq, Debug)]
114pub enum WritePolicy {
115    /// Overwrite every plain file unconditionally.
116    Force,
117    /// Write only files that do not yet exist; leave existing ones untouched.
118    SkipExisting,
119    /// Append template content to an existing instruction file; non-instruction
120    /// files are treated as `Force`.
121    Append,
122}
123
124// ── Registry ──────────────────────────────────────────────────────────────────
125
126const CLAUDE_INSTRUCTION: ScaffoldFile = ScaffoldFile {
127    target: "CLAUDE.md",
128    content: CLAUDE_MD,
129};
130
131static CLAUDE_SKILLS: &[ScaffoldFile] = &[
132    ScaffoldFile {
133        target: ".claude/skills/bears-planning/SKILL.md",
134        content: CLAUDE_SKILL_MD,
135    },
136    ScaffoldFile {
137        target: ".claude/skills/bears-planning/references/cli-fallback.md",
138        content: CLAUDE_SKILL_CLI_FALLBACK,
139    },
140    ScaffoldFile {
141        target: ".claude/agents/planner.md",
142        content: CLAUDE_AGENT_PLANNER,
143    },
144];
145
146const COPILOT_INSTRUCTION: ScaffoldFile = ScaffoldFile {
147    target: ".github/copilot-instructions.md",
148    content: COPILOT_MD,
149};
150
151static COPILOT_SKILLS: &[ScaffoldFile] = &[
152    ScaffoldFile {
153        target: ".github/skills/bears-planning/SKILL.md",
154        content: COPILOT_SKILL_MD,
155    },
156    ScaffoldFile {
157        target: ".github/skills/bears-planning/references/cli-fallback.md",
158        content: COPILOT_SKILL_CLI_FALLBACK,
159    },
160    ScaffoldFile {
161        target: ".github/agents/planner.agent.md",
162        content: COPILOT_AGENT_PLANNER,
163    },
164];
165
166const CODEX_INSTRUCTION: ScaffoldFile = ScaffoldFile {
167    target: "AGENTS.md",
168    content: CODEX_MD,
169};
170
171/// All supported harnesses.  Each entry is a `(&str label, &Harness)` pair
172/// where `label` corresponds to the CLI flag name (`claude`, `copilot`,
173/// `codex`).
174pub static REGISTRY: &[(&str, &Harness)] = &[
175    (
176        "claude",
177        &Harness {
178            instruction: CLAUDE_INSTRUCTION,
179            skills: CLAUDE_SKILLS,
180            mcp: McpStrategy::MergeJson {
181                target: ".mcp.json",
182                server_key: "mcpServers",
183                seed_json: CLAUDE_MCP_SEED,
184            },
185        },
186    ),
187    (
188        "copilot",
189        &Harness {
190            instruction: COPILOT_INSTRUCTION,
191            skills: COPILOT_SKILLS,
192            mcp: McpStrategy::MergeJson {
193                target: ".github/mcp.json",
194                server_key: "servers",
195                seed_json: COPILOT_MCP_SEED,
196            },
197        },
198    ),
199    (
200        "codex",
201        &Harness {
202            instruction: CODEX_INSTRUCTION,
203            skills: &[],
204            mcp: McpStrategy::None,
205        },
206    ),
207];
208
209// ── Core helpers ──────────────────────────────────────────────────────────────
210
211/// Marker separating an existing instruction file from appended bears content.
212/// An HTML comment so it stays invisible in the rendered Markdown, and a stable
213/// anchor that makes `--append` idempotent (re-running detects it and skips).
214const APPEND_MARKER: &str = "<!-- bears:begin -->";
215
216/// Write `content` to `path` according to `policy`, creating parent directories
217/// as needed.
218///
219/// Returns `Ok(true)` when the file was created or modified, `Ok(false)` when it
220/// was left untouched (i.e. `SkipExisting` and the file already exists).
221///
222/// `WritePolicy::Append` is only meaningful for instruction files (handled via
223/// [`append_instruction`]); here it behaves like `Force` so non-instruction
224/// files (skills/agents) are refreshed.
225pub fn write_file(path: &Path, content: &str, policy: WritePolicy) -> Result<bool> {
226    if policy == WritePolicy::SkipExisting && path.exists() {
227        return Ok(false);
228    }
229    if let Some(parent) = path.parent() {
230        fs::create_dir_all(parent)?;
231    }
232    fs::write(path, content)?;
233    Ok(true)
234}
235
236/// Append `content` to the instruction file at `path`, separated by
237/// [`APPEND_MARKER`].
238///
239/// - If the file does not exist, it is created with `content` verbatim.
240/// - If the marker is already present, this is a no-op (idempotent re-append).
241///
242/// Returns `Ok(true)` when the file was created or modified.
243fn append_instruction(path: &Path, content: &str) -> Result<bool> {
244    if path.exists() {
245        let existing = fs::read_to_string(path)?;
246        if existing.contains(APPEND_MARKER) {
247            return Ok(false); // already appended — keep idempotent
248        }
249        let combined = format!("{existing}\n\n{APPEND_MARKER}\n{content}");
250        fs::write(path, combined)?;
251        return Ok(true);
252    }
253    if let Some(parent) = path.parent() {
254        fs::create_dir_all(parent)?;
255    }
256    fs::write(path, content)?;
257    Ok(true)
258}
259
260/// Merge the `bears` MCP server entry into the JSON file at `path`.
261///
262/// - If the file does not exist, it is created from `seed_json` verbatim.
263/// - If it exists, we parse it, add/replace **only** the `bears` key inside
264///   `server_key`, preserve every other key, and write back pretty-printed.
265///
266/// Returns the path that was written.
267pub fn merge_mcp_json(path: &Path, server_key: &str, seed_json: &str) -> Result<()> {
268    // Parse the seed to extract the bears server entry.
269    let seed: Value = serde_json::from_str(seed_json)?;
270    let bears_entry = seed
271        .get(server_key)
272        .and_then(|s| s.get("bears"))
273        .cloned()
274        .unwrap_or(Value::Object(Default::default()));
275
276    // Load or start from an empty object.
277    let mut doc: Value = if path.exists() {
278        let raw = fs::read_to_string(path)?;
279        serde_json::from_str(&raw).unwrap_or(Value::Object(Default::default()))
280    } else {
281        Value::Object(Default::default())
282    };
283
284    // Ensure the server map exists, then insert/replace the `bears` entry.
285    let servers = doc
286        .as_object_mut()
287        .expect("top-level JSON must be an object")
288        .entry(server_key)
289        .or_insert_with(|| Value::Object(Default::default()));
290
291    servers
292        .as_object_mut()
293        .expect("server key must be a JSON object")
294        .insert("bears".to_string(), bears_entry);
295
296    // Write back pretty-printed with a trailing newline.
297    if let Some(parent) = path.parent() {
298        fs::create_dir_all(parent)?;
299    }
300    let pretty = serde_json::to_string_pretty(&doc)?;
301    fs::write(path, format!("{pretty}\n"))?;
302    Ok(())
303}
304
305// ── Top-level scaffold entry point ────────────────────────────────────────────
306
307/// Whether `category` includes the instruction file.
308fn category_has_instruction(category: Category) -> bool {
309    matches!(category, Category::Instructions | Category::All)
310}
311
312/// Whether `category` includes the skill files and MCP merge.
313fn category_has_skills(category: Category) -> bool {
314    matches!(category, Category::Skills | Category::All)
315}
316
317/// The plain-file targets (absolute paths) that scaffolding `category` for the
318/// given harness labels would touch.
319///
320/// Excludes the MCP JSON file, which always merges safely and should never gate
321/// an overwrite prompt. Used by callers to decide whether anything already
322/// exists before prompting.
323pub fn category_targets(base: &Path, harness_labels: &[&str], category: Category) -> Vec<PathBuf> {
324    let mut targets = Vec::new();
325    for label in harness_labels {
326        let Some((_, harness)) = REGISTRY.iter().find(|(l, _)| l == label) else {
327            continue;
328        };
329        if category_has_instruction(category) {
330            targets.push(base.join(harness.instruction.target));
331        }
332        if category_has_skills(category) {
333            for f in harness.skills {
334                targets.push(base.join(f.target));
335            }
336        }
337    }
338    targets
339}
340
341/// Scaffold the files belonging to `category` for the given harness labels into
342/// `base`, resolving on-disk collisions per `policy`.
343///
344/// The MCP merge always runs for skill-bearing categories (it is safe and
345/// idempotent). Returns the list of paths that were actually created or
346/// modified (skipped files are omitted).
347pub fn scaffold_category(
348    base: &Path,
349    harness_labels: &[&str],
350    category: Category,
351    policy: WritePolicy,
352) -> Result<Vec<PathBuf>> {
353    let mut written: Vec<PathBuf> = Vec::new();
354
355    for label in harness_labels {
356        let Some((_, harness)) = REGISTRY.iter().find(|(l, _)| l == label) else {
357            // Unknown label — skip silently (CLI validation should prevent this)
358            continue;
359        };
360
361        if category_has_instruction(category) {
362            let target = base.join(harness.instruction.target);
363            let changed = if policy == WritePolicy::Append {
364                append_instruction(&target, harness.instruction.content)?
365            } else {
366                write_file(&target, harness.instruction.content, policy)?
367            };
368            if changed {
369                written.push(target);
370            }
371        }
372
373        if category_has_skills(category) {
374            // Append has no meaning for generated skill/agent files — overwrite.
375            let skill_policy = if policy == WritePolicy::Append {
376                WritePolicy::Force
377            } else {
378                policy
379            };
380            for f in harness.skills {
381                let target = base.join(f.target);
382                if write_file(&target, f.content, skill_policy)? {
383                    written.push(target);
384                }
385            }
386
387            // MCP registration belongs to the skills category (runtime wiring for
388            // the planner skill); always merge.
389            if let McpStrategy::MergeJson {
390                target,
391                server_key,
392                seed_json,
393            } = &harness.mcp
394            {
395                let target_path = base.join(target);
396                merge_mcp_json(&target_path, server_key, seed_json)?;
397                written.push(target_path);
398            }
399        }
400    }
401
402    Ok(written)
403}
404
405/// Scaffold all files for the given harness labels into `base`, overwriting any
406/// that already exist.
407///
408/// Thin back-compat wrapper over [`scaffold_category`] preserving the original
409/// "everything, force overwrite" semantics. Used by the scaffold test suite.
410#[allow(dead_code)]
411pub fn scaffold(base: &Path, harness_labels: &[&str]) -> Result<Vec<PathBuf>> {
412    scaffold_category(base, harness_labels, Category::All, WritePolicy::Force)
413}
414
415// ── Tests ─────────────────────────────────────────────────────────────────────
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use tempfile::TempDir;
421
422    /// Merging preserves other servers already present in the file.
423    #[test]
424    fn test_merge_mcp_json_preserves_other_servers() {
425        let tmp = TempDir::new().unwrap();
426        let path = tmp.path().join(".mcp.json");
427
428        // Pre-populate with an unrelated server.
429        let existing = serde_json::json!({
430            "mcpServers": {
431                "other-tool": {
432                    "command": "other",
433                    "args": ["serve"]
434                }
435            }
436        });
437        fs::write(&path, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
438
439        merge_mcp_json(&path, "mcpServers", CLAUDE_MCP_SEED).unwrap();
440
441        let result: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
442        let servers = result["mcpServers"].as_object().unwrap();
443
444        // Both entries must be present.
445        assert!(servers.contains_key("bears"), "bears entry missing");
446        assert!(
447            servers.contains_key("other-tool"),
448            "other-tool entry must be preserved"
449        );
450    }
451
452    /// Running the merge twice produces the same file (idempotent).
453    #[test]
454    fn test_merge_mcp_json_idempotent() {
455        let tmp = TempDir::new().unwrap();
456        let path = tmp.path().join(".mcp.json");
457
458        merge_mcp_json(&path, "mcpServers", CLAUDE_MCP_SEED).unwrap();
459        let after_first = fs::read_to_string(&path).unwrap();
460
461        merge_mcp_json(&path, "mcpServers", CLAUDE_MCP_SEED).unwrap();
462        let after_second = fs::read_to_string(&path).unwrap();
463
464        assert_eq!(after_first, after_second, "merge must be idempotent");
465    }
466
467    /// Fresh-create path: when no file exists, the result is a valid JSON file
468    /// that contains the `bears` server entry.
469    #[test]
470    fn test_merge_mcp_json_fresh_create() {
471        let tmp = TempDir::new().unwrap();
472        let path = tmp.path().join(".mcp.json");
473
474        assert!(!path.exists());
475        merge_mcp_json(&path, "mcpServers", CLAUDE_MCP_SEED).unwrap();
476
477        assert!(path.exists());
478        let result: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
479        let servers = result["mcpServers"].as_object().unwrap();
480        assert!(
481            servers.contains_key("bears"),
482            "fresh-created file must have bears entry"
483        );
484        let bears = &servers["bears"];
485        assert_eq!(bears["command"], "bea");
486    }
487
488    /// `bea init --claude` on an already-initialized dir succeeds and writes
489    /// the expected files.
490    #[test]
491    fn test_scaffold_claude_idempotent() {
492        let tmp = TempDir::new().unwrap();
493
494        // First scaffold
495        let written = scaffold(tmp.path(), &["claude"]).unwrap();
496        assert!(
497            written.iter().any(|p| p.ends_with("CLAUDE.md")),
498            "CLAUDE.md must be in written list"
499        );
500        assert!(
501            written.iter().any(|p| p.ends_with(".mcp.json")),
502            ".mcp.json must be in written list"
503        );
504        assert!(tmp.path().join("CLAUDE.md").exists());
505        assert!(tmp.path().join(".mcp.json").exists());
506
507        // Second scaffold (re-init on existing dir) must succeed
508        let written2 = scaffold(tmp.path(), &["claude"]).unwrap();
509        assert_eq!(
510            written.len(),
511            written2.len(),
512            "same number of files on re-scaffold"
513        );
514
515        // Contents must be stable
516        let md = fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
517        assert!(md.contains("Bears"), "CLAUDE.md must reference Bears");
518
519        let mcp: Value =
520            serde_json::from_str(&fs::read_to_string(tmp.path().join(".mcp.json")).unwrap())
521                .unwrap();
522        assert!(mcp["mcpServers"]["bears"].is_object());
523    }
524
525    /// `bea init --claude` writes the skill, cli-fallback reference, and agent
526    /// files, with the production `bea mcp` MCP server form.
527    #[test]
528    fn test_scaffold_claude_skill_and_agent() {
529        let tmp = TempDir::new().unwrap();
530        let written = scaffold(tmp.path(), &["claude"]).unwrap();
531
532        // Skill file
533        let skill_path = tmp.path().join(".claude/skills/bears-planning/SKILL.md");
534        assert!(
535            written.iter().any(|p| p == &skill_path),
536            "SKILL.md must be in written list"
537        );
538        assert!(skill_path.exists(), "SKILL.md must be created");
539        let skill = fs::read_to_string(&skill_path).unwrap();
540        assert!(
541            skill.contains("bears-planning"),
542            "SKILL.md must contain skill name"
543        );
544        assert!(
545            skill.contains("mcp__bears__"),
546            "SKILL.md must reference MCP tools"
547        );
548
549        // CLI fallback reference
550        let cli_ref_path = tmp
551            .path()
552            .join(".claude/skills/bears-planning/references/cli-fallback.md");
553        assert!(
554            written.iter().any(|p| p == &cli_ref_path),
555            "cli-fallback.md must be in written list"
556        );
557        assert!(cli_ref_path.exists(), "cli-fallback.md must be created");
558        let cli_ref = fs::read_to_string(&cli_ref_path).unwrap();
559        assert!(
560            cli_ref.contains("bea create"),
561            "cli-fallback.md must contain bea create"
562        );
563
564        // Agent file
565        let agent_path = tmp.path().join(".claude/agents/planner.md");
566        assert!(
567            written.iter().any(|p| p == &agent_path),
568            "planner.md must be in written list"
569        );
570        assert!(agent_path.exists(), "planner.md must be created");
571        let agent = fs::read_to_string(&agent_path).unwrap();
572        assert!(agent.contains("planner"), "agent must have name");
573        assert!(
574            agent.contains("mcp__bears__"),
575            "agent must reference MCP tools"
576        );
577
578        // .mcp.json uses production form: command="bea", args=["mcp"]
579        let mcp: Value =
580            serde_json::from_str(&fs::read_to_string(tmp.path().join(".mcp.json")).unwrap())
581                .unwrap();
582        let bears = &mcp["mcpServers"]["bears"];
583        assert_eq!(bears["command"], "bea", "must use production binary form");
584        assert_eq!(bears["args"][0], "mcp", "must use 'mcp' subcommand");
585    }
586
587    /// `bea init --copilot` writes the skill, cli-fallback reference, agent,
588    /// and MCP registration files under `.github/`.
589    #[test]
590    fn test_scaffold_copilot_skill_and_agent() {
591        let tmp = TempDir::new().unwrap();
592        let written = scaffold(tmp.path(), &["copilot"]).unwrap();
593
594        // copilot-instructions.md
595        let instr_path = tmp.path().join(".github/copilot-instructions.md");
596        assert!(
597            instr_path.exists(),
598            "copilot-instructions.md must be created"
599        );
600
601        // Skill file
602        let skill_path = tmp.path().join(".github/skills/bears-planning/SKILL.md");
603        assert!(
604            written.iter().any(|p| p == &skill_path),
605            "SKILL.md must be in written list"
606        );
607        assert!(skill_path.exists(), "SKILL.md must be created");
608        let skill = fs::read_to_string(&skill_path).unwrap();
609        assert!(
610            skill.contains("bears-planning"),
611            "SKILL.md must contain skill name"
612        );
613        assert!(
614            skill.contains("bears/*"),
615            "SKILL.md must reference Copilot MCP tool prefix"
616        );
617
618        // CLI fallback reference
619        let cli_ref_path = tmp
620            .path()
621            .join(".github/skills/bears-planning/references/cli-fallback.md");
622        assert!(
623            written.iter().any(|p| p == &cli_ref_path),
624            "cli-fallback.md must be in written list"
625        );
626        assert!(cli_ref_path.exists(), "cli-fallback.md must be created");
627        let cli_ref = fs::read_to_string(&cli_ref_path).unwrap();
628        assert!(
629            cli_ref.contains("bea create"),
630            "cli-fallback.md must contain bea create"
631        );
632
633        // Agent file
634        let agent_path = tmp.path().join(".github/agents/planner.agent.md");
635        assert!(
636            written.iter().any(|p| p == &agent_path),
637            "planner.agent.md must be in written list"
638        );
639        assert!(agent_path.exists(), "planner.agent.md must be created");
640        let agent = fs::read_to_string(&agent_path).unwrap();
641        assert!(
642            agent.contains("bears/*"),
643            "agent must reference Copilot MCP tool prefix"
644        );
645
646        // .github/mcp.json — merged with production bears server entry
647        let mcp_path = tmp.path().join(".github/mcp.json");
648        assert!(
649            written.iter().any(|p| p == &mcp_path),
650            ".github/mcp.json must be in written list"
651        );
652        assert!(mcp_path.exists(), ".github/mcp.json must be created");
653        let mcp: Value = serde_json::from_str(&fs::read_to_string(&mcp_path).unwrap()).unwrap();
654        let bears = &mcp["servers"]["bears"];
655        assert_eq!(bears["command"], "bea", "must use production binary form");
656        assert_eq!(bears["args"][0], "mcp", "must use 'mcp' subcommand");
657    }
658
659    /// Unknown harness labels are silently skipped — no panic or error.
660    #[test]
661    fn test_scaffold_unknown_label_skipped() {
662        let tmp = TempDir::new().unwrap();
663        let result = scaffold(tmp.path(), &["nonexistent-harness"]);
664        assert!(result.is_ok());
665        let written = result.unwrap();
666        assert!(written.is_empty());
667    }
668
669    // ── Write-policy + category tests ──────────────────────────────────────────
670
671    /// `Force` overwrites an existing file and reports it as written.
672    #[test]
673    fn test_write_file_force_overwrites() {
674        let tmp = TempDir::new().unwrap();
675        let path = tmp.path().join("f.md");
676        fs::write(&path, "old").unwrap();
677
678        let changed = write_file(&path, "new", WritePolicy::Force).unwrap();
679        assert!(changed);
680        assert_eq!(fs::read_to_string(&path).unwrap(), "new");
681    }
682
683    /// `SkipExisting` leaves an existing file untouched and reports `false`,
684    /// but still creates a missing file.
685    #[test]
686    fn test_write_file_skip_existing() {
687        let tmp = TempDir::new().unwrap();
688        let existing = tmp.path().join("exists.md");
689        fs::write(&existing, "keep").unwrap();
690
691        let changed = write_file(&existing, "new", WritePolicy::SkipExisting).unwrap();
692        assert!(!changed, "existing file must be skipped");
693        assert_eq!(fs::read_to_string(&existing).unwrap(), "keep");
694
695        let missing = tmp.path().join("missing.md");
696        let created = write_file(&missing, "new", WritePolicy::SkipExisting).unwrap();
697        assert!(created, "missing file must be created under SkipExisting");
698        assert_eq!(fs::read_to_string(&missing).unwrap(), "new");
699    }
700
701    /// `append_instruction` preserves user content and adds the template under a
702    /// marker; a second append is a no-op.
703    #[test]
704    fn test_append_instruction_idempotent() {
705        let tmp = TempDir::new().unwrap();
706        let path = tmp.path().join("CLAUDE.md");
707        fs::write(&path, "USER TEXT").unwrap();
708
709        let changed = append_instruction(&path, "TEMPLATE").unwrap();
710        assert!(changed);
711        let after_first = fs::read_to_string(&path).unwrap();
712        assert!(after_first.contains("USER TEXT"));
713        assert!(after_first.contains(APPEND_MARKER));
714        assert!(after_first.contains("TEMPLATE"));
715
716        let changed_again = append_instruction(&path, "TEMPLATE").unwrap();
717        assert!(!changed_again, "second append must be a no-op");
718        assert_eq!(
719            fs::read_to_string(&path).unwrap(),
720            after_first,
721            "file must be unchanged on re-append"
722        );
723    }
724
725    /// `append_instruction` on a missing file just creates it.
726    #[test]
727    fn test_append_instruction_creates_missing() {
728        let tmp = TempDir::new().unwrap();
729        let path = tmp.path().join("CLAUDE.md");
730        let changed = append_instruction(&path, "TEMPLATE").unwrap();
731        assert!(changed);
732        assert_eq!(fs::read_to_string(&path).unwrap(), "TEMPLATE");
733    }
734
735    /// `Category::Instructions` writes only the instruction file — no skills,
736    /// no `.mcp.json`.
737    #[test]
738    fn test_scaffold_category_instructions_only() {
739        let tmp = TempDir::new().unwrap();
740        let written = scaffold_category(
741            tmp.path(),
742            &["claude"],
743            Category::Instructions,
744            WritePolicy::Force,
745        )
746        .unwrap();
747
748        assert!(tmp.path().join("CLAUDE.md").exists());
749        assert!(!tmp.path().join(".mcp.json").exists());
750        assert!(
751            !tmp.path()
752                .join(".claude/skills/bears-planning/SKILL.md")
753                .exists()
754        );
755        assert!(written.iter().all(|p| !p.ends_with(".mcp.json")));
756    }
757
758    /// `Category::Skills` writes the skill files and merges `.mcp.json`, but not
759    /// the instruction file.
760    #[test]
761    fn test_scaffold_category_skills_includes_mcp() {
762        let tmp = TempDir::new().unwrap();
763        scaffold_category(
764            tmp.path(),
765            &["claude"],
766            Category::Skills,
767            WritePolicy::Force,
768        )
769        .unwrap();
770
771        assert!(!tmp.path().join("CLAUDE.md").exists());
772        assert!(tmp.path().join(".mcp.json").exists());
773        assert!(
774            tmp.path()
775                .join(".claude/skills/bears-planning/SKILL.md")
776                .exists()
777        );
778    }
779
780    /// `All` + `SkipExisting` keeps an edited instruction file but still creates
781    /// missing skill files and merges `.mcp.json`.
782    #[test]
783    fn test_scaffold_category_all_skip_existing() {
784        let tmp = TempDir::new().unwrap();
785        fs::write(tmp.path().join("CLAUDE.md"), "MY EDITS").unwrap();
786
787        scaffold_category(
788            tmp.path(),
789            &["claude"],
790            Category::All,
791            WritePolicy::SkipExisting,
792        )
793        .unwrap();
794
795        assert_eq!(
796            fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap(),
797            "MY EDITS",
798            "existing instruction file must be preserved"
799        );
800        assert!(
801            tmp.path()
802                .join(".claude/skills/bears-planning/SKILL.md")
803                .exists()
804        );
805        assert!(tmp.path().join(".mcp.json").exists());
806    }
807
808    /// `All` + `Append` keeps user instruction text (appending the template) and
809    /// overwrites the generated skill files.
810    #[test]
811    fn test_scaffold_category_all_append() {
812        let tmp = TempDir::new().unwrap();
813        fs::write(tmp.path().join("CLAUDE.md"), "MY EDITS").unwrap();
814
815        scaffold_category(tmp.path(), &["claude"], Category::All, WritePolicy::Append).unwrap();
816
817        let md = fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap();
818        assert!(md.contains("MY EDITS"));
819        assert!(md.contains(APPEND_MARKER));
820        assert!(md.contains("Bears"), "template content must be appended");
821        assert!(
822            tmp.path()
823                .join(".claude/skills/bears-planning/SKILL.md")
824                .exists()
825        );
826    }
827
828    /// `category_targets` lists plain files but never `.mcp.json`.
829    #[test]
830    fn test_category_targets_excludes_mcp_json() {
831        let tmp = TempDir::new().unwrap();
832        let targets = category_targets(tmp.path(), &["claude"], Category::All);
833        assert!(targets.iter().any(|p| p.ends_with("CLAUDE.md")));
834        assert!(
835            targets.iter().all(|p| !p.ends_with(".mcp.json")),
836            ".mcp.json must be excluded from prompt-trigger targets"
837        );
838    }
839}