Skip to main content

repograph_core/
agent_artifact.rs

1//! Per-agent native instruction artifacts.
2//!
3//! Installs a small file at a well-known path for each selected agent so the
4//! agent's runtime picks it up automatically and learns when to invoke
5//! `repograph` CLI commands.
6//!
7//! ## Surface
8//!
9//! - [`Scope`] — user-scope vs project-scope target root.
10//! - [`ArtifactResult`] — per-agent outcome of an install (Written, Unchanged,
11//!   Skipped, Failed).
12//! - [`BODY`] — the canonical instructional prose, shared across every
13//!   per-agent writer so the CLI surface is documented in exactly one place.
14//! - [`install_artifacts`] — entry point that iterates a selection and returns
15//!   one result per agent in selection order.
16//!
17//! ## Delimiter contract
18//!
19//! Each artifact wraps the canonical body in [`DELIMITER_BEGIN`] /
20//! [`DELIMITER_END`] HTML comments. This lets a single file mix user-authored
21//! content with the repograph-managed block (relevant for `AGENTS.md` /
22//! `CONVENTIONS.md`, which users may already maintain). Re-runs only touch
23//! the delimited region; everything outside is byte-preserved.
24//!
25//! ## Force-bypass
26//!
27//! Passing `force = true` to the install layer skips the delimiter check and
28//! writes the file fresh with only the delimited block. Any prior file
29//! contents (including user content outside the delimited region) are
30//! discarded. This is the escape hatch for re-asserting the canonical body
31//! after local edits drift.
32//!
33//! ## Skipped agents
34//!
35//! Not every selected agent has a writer. [`AgentId::Copilot`] is deferred in
36//! v1 because its instruction format varies across surfaces (repo-level,
37//! editor-level, Copilot Workspace) and no single converged path covers them.
38//! Selecting Copilot is fine — it just produces a [`ArtifactResult::Skipped`]
39//! with no file write.
40
41use std::path::{Path, PathBuf};
42
43use serde::Serialize;
44
45use crate::agents::AgentId;
46use crate::error::RepographError;
47
48/// Where on disk an artifact should be installed.
49///
50/// `User` resolves to a path under the host's home directory; `Project`
51/// resolves to a path under the current working directory. Some agents are
52/// project-scope only by convention; for them, `User` silently falls through
53/// to the project path (see [`scope_is_meaningful`] and the v1 matrix in
54/// [`resolve_path`]).
55///
56/// Named `Scope` and kept at module scope (not re-exported at the crate root)
57/// to coexist with the existing `repograph_core::context::Scope`, which is a
58/// different concept (context-aggregation scope).
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
60#[serde(rename_all = "lowercase")]
61pub enum Scope {
62    /// Install under the host's home directory (`~/`).
63    User,
64    /// Install under the current working directory (the project root).
65    Project,
66}
67
68/// Per-agent outcome of an install. The orchestrator returns one of these per
69/// input agent in selection order.
70///
71/// `RepographError` is not `Clone`, so this enum is `Debug`-only on purpose.
72#[derive(Debug)]
73pub enum ArtifactResult {
74    /// File was created or its delimited block was rewritten.
75    Written { agent: AgentId, path: PathBuf },
76    /// File already exists with a delimited block whose body is byte-identical
77    /// to the canonical content; no I/O write occurred.
78    Unchanged { agent: AgentId, path: PathBuf },
79    /// Agent has no writer (today: only `Copilot`); the install layer skipped
80    /// it with no file write attempted.
81    Skipped {
82        agent: AgentId,
83        reason: &'static str,
84    },
85    /// Per-agent failure (read or write I/O error). Reported on stderr; does
86    /// not abort the surrounding run.
87    Failed {
88        agent: AgentId,
89        error: RepographError,
90    },
91}
92
93impl ArtifactResult {
94    /// The agent this result pertains to. Useful in summary logs and tests.
95    #[must_use]
96    pub const fn agent(&self) -> AgentId {
97        match self {
98            Self::Written { agent, .. }
99            | Self::Unchanged { agent, .. }
100            | Self::Skipped { agent, .. }
101            | Self::Failed { agent, .. } => *agent,
102        }
103    }
104}
105
106/// Reason strings used in [`ArtifactResult::Skipped`]. Stable: agents may
107/// observe them in `repograph doctor` output or log scraping.
108pub const REASON_COPILOT_DEFERRED: &str = "no writer in v1";
109
110/// HTML-comment marker opening the repograph-managed region of an artifact.
111pub const DELIMITER_BEGIN: &str = "<!-- repograph:begin -->";
112
113/// HTML-comment marker closing the repograph-managed region of an artifact.
114pub const DELIMITER_END: &str = "<!-- repograph:end -->";
115
116/// Skill `description` rendered into the YAML frontmatter of the agents that
117/// have it (Claude `SKILL.md`, Cursor `.mdc`).
118///
119/// This string is the *only* signal the host sees when deciding whether to
120/// invoke the skill — the body (`BODY`) is loaded only *after* invocation, so
121/// trigger phrasing must live here. It therefore leads with concrete user
122/// phrasings ("switch to", "what's dirty", …) and an explicit prefer-over-
123/// `find`/`git` instruction rather than an abstract one-liner.
124///
125/// Rendered as a folded block scalar (`>-`, see [`render_artifact`]) so it can
126/// safely contain colons, quotes, and slashes without YAML-escaping. Keep it
127/// under the 1024-char skill-spec limit. Frontmatter-less writers (AGENTS.md,
128/// CONVENTIONS.md, .windsurfrules) use a `# repograph` heading instead and do
129/// not embed this string.
130pub const SUMMARY: &str = "Use when the user refers to one of their own git projects/repos by name and wants to act on it: switch / open / \"cd into\" a repo (\"switch to taverne\", \"open the api repo\", \"cd into <name>\"), list or compare their registered repos, check cross-repo git status (\"what's dirty\", \"what's in flight across my projects\", \"which repos have uncommitted changes\"), or pull a repo's CLAUDE.md / AGENTS.md content into the conversation. Maintains a local registry of git repositories and exposes their paths, branches, status, and agent docs as structured JSON. ALWAYS prefer this over manual `find` / `git` to resolve a named project to a filesystem path. Use it for which-repo / across-repos questions, not for the current directory's own `git status` (use plain `git` for that).";
131
132/// The single canonical instructional body, shared by every per-agent writer.
133///
134/// Owned by `repograph-core` so the CLI surface is documented in exactly one
135/// place. Per-agent writers (see [`render_artifact`]) wrap this string in
136/// native-format frontmatter or headers but never edit its content.
137///
138/// Content stability: this string is byte-stable for a given crate version. A
139/// body update bumps the in-file content for users on re-`init`; the spliced
140/// install layer rewrites only the delimited region.
141pub const BODY: &str = include_str!("agent_artifact_body.md");
142
143/// Convenience accessor for the writer-side summary. Mirrors `SUMMARY`; this
144/// exists so writers don't reach into module-level constants directly.
145#[must_use]
146pub const fn writer_summary() -> &'static str {
147    SUMMARY
148}
149
150/// Is there an installed-artifact writer for this agent in v1?
151///
152/// `Copilot` returns `false` because its instruction format varies across
153/// surfaces and no single converged path exists today (see module docs).
154/// Every other v1 agent returns `true`.
155#[must_use]
156pub const fn has_artifact_writer(agent: AgentId) -> bool {
157    !matches!(agent, AgentId::Copilot)
158}
159
160/// Does this agent's artifact occupy the whole file (frontmatter included),
161/// with no expectation of pre-existing user content to preserve?
162///
163/// `true` for `claude-code` (`SKILL.md` is wholly repograph's) and `cursor`
164/// (`.cursor/rules/repograph.mdc` is rule-engine-specific). For these agents
165/// the install layer writes the full [`render_artifact`] output — including
166/// the YAML frontmatter — rather than splicing only the delimited region.
167///
168/// `false` for `agents-md`, `aider`, and `windsurf`, whose target files may
169/// already contain user-authored prose that the install layer must preserve
170/// outside the delimited block.
171#[must_use]
172pub const fn wholly_owned_file(agent: AgentId) -> bool {
173    matches!(agent, AgentId::ClaudeCode | AgentId::Cursor)
174}
175
176/// Resolve the target install path for `(agent, scope)`.
177///
178/// Pass `home` and `cwd` explicitly so callers (and tests) control where the
179/// roots come from — this module never calls `dirs::home_dir()` or
180/// `std::env::current_dir()` itself.
181///
182/// Agents whose path is project-only by convention (AGENTS.md, CONVENTIONS.md,
183/// Cursor `.cursor/rules/*`) ignore `Scope::User` and return the project path.
184/// See [`scope_is_meaningful`] for the symmetric predicate the init command
185/// uses to decide whether to require a `--scope` flag under `--no-prompt`.
186#[must_use]
187pub fn resolve_path(agent: AgentId, scope: Scope, home: &Path, cwd: &Path) -> PathBuf {
188    match agent {
189        AgentId::ClaudeCode => match scope {
190            Scope::User => home.join(".claude/skills/repograph/SKILL.md"),
191            Scope::Project => cwd.join(".claude/skills/repograph/SKILL.md"),
192        },
193        AgentId::AgentsMd | AgentId::Aider | AgentId::Cursor => {
194            // Project-only agents: scope falls through to project root.
195            match agent {
196                AgentId::AgentsMd => cwd.join("AGENTS.md"),
197                AgentId::Aider => cwd.join("CONVENTIONS.md"),
198                AgentId::Cursor => cwd.join(".cursor/rules/repograph.mdc"),
199                _ => unreachable!(),
200            }
201        }
202        AgentId::Windsurf => match scope {
203            Scope::User => home.join(".codeium/windsurf/memories/repograph.md"),
204            Scope::Project => cwd.join(".windsurfrules"),
205        },
206        AgentId::Copilot => {
207            // `has_artifact_writer` returns false; install layer skips before
208            // calling `resolve_path`. Returning a path here would mislead.
209            unreachable!("resolve_path: copilot has no writer; check has_artifact_writer first")
210        }
211    }
212}
213
214/// Does the choice between `Scope::User` and `Scope::Project` change the
215/// resolved path for this agent?
216///
217/// `false` for project-only agents (their user path equals their project path)
218/// and for agents without a writer. The init command uses this to decide
219/// whether `--scope` is required under `--no-prompt`.
220#[must_use]
221pub fn scope_is_meaningful(agent: AgentId) -> bool {
222    if !has_artifact_writer(agent) {
223        return false;
224    }
225    // Compare paths using two distinct dummy roots so we detect a real
226    // dependency on `scope`. If the resolver returns the same path under both,
227    // scope doesn't matter for this agent.
228    let home = Path::new("/__home__");
229    let cwd = Path::new("/__cwd__");
230    resolve_path(agent, Scope::User, home, cwd) != resolve_path(agent, Scope::Project, home, cwd)
231}
232
233/// Compose the full file contents for `agent`: per-agent frontmatter (if any)
234/// followed by the managed-section delimiters wrapping [`BODY`], plus a
235/// trailing newline.
236///
237/// Centralizes the wrapping logic so every install path produces byte-stable,
238/// deterministic output (no timestamps, no host-specific strings).
239///
240/// # Panics
241///
242/// Panics with `unreachable!` if called for `AgentId::Copilot`. Callers MUST
243/// gate on [`has_artifact_writer`] first; reaching this branch is a logic bug.
244#[must_use]
245pub fn render_artifact(agent: AgentId) -> String {
246    match agent {
247        AgentId::ClaudeCode => format!(
248            "---\nname: repograph\ndescription: >-\n  {summary}\n---\n\n\
249             {begin}\n{body}\n{end}\n",
250            summary = writer_summary(),
251            begin = DELIMITER_BEGIN,
252            body = BODY,
253            end = DELIMITER_END,
254        ),
255        AgentId::Cursor => format!(
256            "---\ndescription: >-\n  {summary}\nglobs: []\n---\n\n\
257             {begin}\n{body}\n{end}\n",
258            summary = writer_summary(),
259            begin = DELIMITER_BEGIN,
260            body = BODY,
261            end = DELIMITER_END,
262        ),
263        AgentId::AgentsMd | AgentId::Aider | AgentId::Windsurf => {
264            format!("{DELIMITER_BEGIN}\n# repograph\n\n{BODY}\n{DELIMITER_END}\n")
265        }
266        AgentId::Copilot => {
267            unreachable!("render_artifact: copilot has no writer; check has_artifact_writer first")
268        }
269    }
270}
271
272/// Outcome of [`splice_managed_section`] — describes how the install layer
273/// should reconcile the new body against the existing file contents.
274#[derive(Debug, PartialEq, Eq)]
275pub enum SpliceOutcome {
276    /// Existing file contains the delimited block and its inner body matches
277    /// the new body byte-for-byte. No write needed.
278    Identical,
279    /// Existing file contains the delimited block but the inner body differs.
280    /// The carried string is the full new file contents — only the delimited
281    /// region was rewritten; everything outside is byte-preserved.
282    Replaced(String),
283    /// Existing file has no delimited block. The carried string is the
284    /// existing contents (with a separating newline if non-empty) plus a
285    /// freshly-appended delimited block.
286    Appended(String),
287    /// Existing file does not exist. The carried string is the bare delimited
288    /// block.
289    FreshWrite(String),
290}
291
292/// Pure-string idempotent splice: read the existing file (or `None`), produce
293/// the [`SpliceOutcome`] that tells the install layer what to write.
294///
295/// `new_block_body` is the canonical body that should land *between* the
296/// delimiters — typically the full output of [`render_artifact`] minus its
297/// frontmatter. For files that always own the whole content (Claude SKILL.md,
298/// Cursor .mdc), pass [`render_artifact`] in full; the delimiter pair appears
299/// as the entire body and the function still routes correctly.
300///
301/// I/O-free: testable as a string transformation.
302#[must_use]
303pub fn splice_managed_section(existing: Option<&str>, new_block_body: &str) -> SpliceOutcome {
304    let full_block = format!("{DELIMITER_BEGIN}\n{new_block_body}\n{DELIMITER_END}\n");
305    let Some(existing) = existing else {
306        return SpliceOutcome::FreshWrite(full_block);
307    };
308
309    // Locate the delimiter pair within the existing file.
310    if let Some(begin_idx) = existing.find(DELIMITER_BEGIN) {
311        // The body starts after the begin-delimiter line.
312        let after_begin = begin_idx + DELIMITER_BEGIN.len();
313        // Skip a single newline immediately after the begin delimiter, if
314        // present, so the captured inner-body string doesn't carry that
315        // separator. (We re-add it on emit.)
316        let inner_start = if existing[after_begin..].starts_with('\n') {
317            after_begin + 1
318        } else {
319            after_begin
320        };
321        if let Some(end_rel) = existing[inner_start..].find(DELIMITER_END) {
322            // `inner_end` is the index of the first byte of DELIMITER_END.
323            let inner_end = inner_start + end_rel;
324            // The inner body sits between `inner_start` and `inner_end`.
325            // It typically ends with a `\n` we wrote on the last install; we
326            // compare the body without that trailing newline so callers don't
327            // have to think about it.
328            let inner_with_trailing_nl = &existing[inner_start..inner_end];
329            let inner = inner_with_trailing_nl
330                .strip_suffix('\n')
331                .unwrap_or(inner_with_trailing_nl);
332            if inner == new_block_body {
333                return SpliceOutcome::Identical;
334            }
335            // Build the replaced output: prefix + DELIMITER_BEGIN + \n + body
336            // + \n + DELIMITER_END + suffix (where suffix begins at
337            // `inner_end + DELIMITER_END.len()`).
338            let suffix_start = inner_end + DELIMITER_END.len();
339            let mut out = String::with_capacity(existing.len() + new_block_body.len());
340            out.push_str(&existing[..begin_idx]);
341            out.push_str(DELIMITER_BEGIN);
342            out.push('\n');
343            out.push_str(new_block_body);
344            out.push('\n');
345            out.push_str(DELIMITER_END);
346            out.push_str(&existing[suffix_start..]);
347            return SpliceOutcome::Replaced(out);
348        }
349        // Begin without end is malformed; treat as no-block-present and append
350        // a fresh block. The user content stays intact.
351    }
352
353    // No delimiter pair: append the full block after a separating newline.
354    let needs_sep = !existing.is_empty() && !existing.ends_with('\n');
355    let mut out = String::with_capacity(existing.len() + full_block.len() + usize::from(needs_sep));
356    out.push_str(existing);
357    if !existing.is_empty() {
358        if needs_sep {
359            out.push('\n');
360        }
361        out.push('\n');
362    }
363    out.push_str(&full_block);
364    SpliceOutcome::Appended(out)
365}
366
367/// Install a single artifact at `path` for `agent`.
368///
369/// Reads the existing file (if any), splices the canonical body in via
370/// [`splice_managed_section`] (or short-circuits to a fresh write when
371/// `force = true`), and writes the result through `fs_err`.
372///
373/// Returns a typed [`ArtifactResult`]:
374///
375/// - [`Written`](ArtifactResult::Written) — file created or delimited region
376///   updated.
377/// - [`Unchanged`](ArtifactResult::Unchanged) — existing file already
378///   contained the canonical body byte-for-byte.
379/// - [`Failed`](ArtifactResult::Failed) — read or write I/O error. Surrounding
380///   orchestration (see [`install_artifacts`]) does not abort on `Failed`.
381///
382/// Caller MUST gate on [`has_artifact_writer`] first; this function calls
383/// [`render_artifact`] which panics for `Copilot`.
384#[must_use]
385pub fn install_one(agent: AgentId, path: &Path, force: bool) -> ArtifactResult {
386    debug_assert!(
387        has_artifact_writer(agent),
388        "install_one called for an agent without a writer: {agent:?}"
389    );
390
391    let full_artifact = render_artifact(agent);
392
393    let existing = if force {
394        None
395    } else {
396        match fs_err::read_to_string(path) {
397            Ok(s) => Some(s),
398            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
399            Err(e) => {
400                return ArtifactResult::Failed {
401                    agent,
402                    error: RepographError::Io(e),
403                };
404            }
405        }
406    };
407
408    // Two install models:
409    //
410    // - Whole-file owners (claude-code SKILL.md, cursor .mdc): repograph owns
411    //   the entire file. Write [`render_artifact`] verbatim — including any
412    //   YAML frontmatter — and treat byte-identical existing content as
413    //   `Unchanged`. The splice contract doesn't apply because there's no
414    //   user content to preserve around the delimited region.
415    // - Shared-file agents (agents-md, aider, windsurf): the target file may
416    //   already contain user-authored prose. Splice the canonical body into
417    //   the delimited region and leave everything outside untouched.
418    let to_write = if wholly_owned_file(agent) {
419        if let Some(ref existing_body) = existing {
420            if existing_body == &full_artifact && !force {
421                return ArtifactResult::Unchanged {
422                    agent,
423                    path: path.to_path_buf(),
424                };
425            }
426        }
427        full_artifact
428    } else {
429        let new_block_body = rendered_inner_body(&full_artifact);
430        let outcome = splice_managed_section(existing.as_deref(), &new_block_body);
431        match outcome {
432            SpliceOutcome::Identical if !force => {
433                return ArtifactResult::Unchanged {
434                    agent,
435                    path: path.to_path_buf(),
436                };
437            }
438            SpliceOutcome::Identical => {
439                // force=true and content matched: rewrite anyway for the
440                // documented `Written` outcome.
441                format!("{DELIMITER_BEGIN}\n{new_block_body}\n{DELIMITER_END}\n")
442            }
443            SpliceOutcome::Replaced(s)
444            | SpliceOutcome::Appended(s)
445            | SpliceOutcome::FreshWrite(s) => s,
446        }
447    };
448
449    if let Some(parent) = path.parent() {
450        if !parent.as_os_str().is_empty() {
451            if let Err(e) = fs_err::create_dir_all(parent) {
452                return ArtifactResult::Failed {
453                    agent,
454                    error: RepographError::Io(e),
455                };
456            }
457        }
458    }
459
460    match fs_err::write(path, to_write) {
461        Ok(()) => ArtifactResult::Written {
462            agent,
463            path: path.to_path_buf(),
464        },
465        Err(e) => ArtifactResult::Failed {
466            agent,
467            error: RepographError::Io(e),
468        },
469    }
470}
471
472/// Extract the inner-body portion of `render_artifact`'s output — what should
473/// land between `DELIMITER_BEGIN` and `DELIMITER_END`.
474///
475/// For agents with frontmatter (Claude SKILL.md, Cursor .mdc) the frontmatter
476/// is stripped and the inner delimited body is the rest; for frontmatter-less
477/// writers (AGENTS.md, CONVENTIONS.md, .windsurfrules) the inner body is the
478/// body between the delimiters in `render_artifact`'s output.
479///
480/// This indirection exists so the splice contract is uniform: the install
481/// layer always treats `new_block_body` as the substring between delimiters,
482/// regardless of frontmatter shape.
483///
484/// Returns the full `rendered` string back as-is if the delimiters can't be
485/// located. That can only happen if `render_artifact` is mis-implemented; the
486/// install layer would then write a malformed file rather than panic — the
487/// next `cargo test` run would surface the regression because every render
488/// test asserts the delimiters are present.
489fn rendered_inner_body(rendered: &str) -> String {
490    let Some(begin_idx) = rendered.find(DELIMITER_BEGIN) else {
491        return rendered.to_string();
492    };
493    let after_begin = begin_idx + DELIMITER_BEGIN.len();
494    let inner_start = if rendered[after_begin..].starts_with('\n') {
495        after_begin + 1
496    } else {
497        after_begin
498    };
499    let Some(end_idx_rel) = rendered[inner_start..].find(DELIMITER_END) else {
500        return rendered.to_string();
501    };
502    let inner = &rendered[inner_start..inner_start + end_idx_rel];
503    inner.strip_suffix('\n').unwrap_or(inner).to_string()
504}
505
506/// Render frontmatter (if any) and a managed-section block for `agent`, then
507/// install it under the resolved `(scope, home, cwd)` path. The result vector
508/// has one entry per input agent in selection order.
509///
510/// Agents without a writer (see [`has_artifact_writer`]) produce
511/// [`ArtifactResult::Skipped`] without touching the filesystem. Per-agent
512/// errors are captured as [`ArtifactResult::Failed`] and do NOT abort the
513/// remaining agents.
514///
515/// `force = true` overwrites the target file fresh (see module docs).
516///
517/// This function is log-free by design (`repograph-core` is pure-value domain
518/// code per `.claude/rules/logging.md`). The binary-side caller iterates the
519/// returned vector and emits one `tracing` line per result on stderr.
520#[must_use]
521pub fn install_artifacts(
522    agents: &[AgentId],
523    scope: Scope,
524    home: &Path,
525    cwd: &Path,
526    force: bool,
527) -> Vec<ArtifactResult> {
528    let mut results = Vec::with_capacity(agents.len());
529    for &agent in agents {
530        if !has_artifact_writer(agent) {
531            results.push(ArtifactResult::Skipped {
532                agent,
533                reason: REASON_COPILOT_DEFERRED,
534            });
535            continue;
536        }
537        let path = resolve_path(agent, scope, home, cwd);
538        results.push(install_one(agent, &path, force));
539    }
540    results
541}
542
543#[cfg(test)]
544mod tests {
545    #![allow(clippy::unwrap_used, clippy::expect_used)]
546    use super::*;
547    use tempfile::TempDir;
548
549    // ---- body ----
550
551    mod body {
552        use super::*;
553
554        /// Locate the body's "## Commands" section — the table that tells the
555        /// agent which commands to invoke. Returns the section text up to the
556        /// next `## ` heading or end-of-body. Mutating commands MUST NOT
557        /// appear here; negative-guidance prose in the "Things to avoid"
558        /// appendix is allowed to name them.
559        fn commands_section() -> &'static str {
560            let start = BODY
561                .find("## Commands")
562                .expect("body has a Commands section");
563            let after = start + "## Commands".len();
564            let end_rel = BODY[after..].find("\n## ").unwrap_or(BODY.len() - after);
565            &BODY[start..after + end_rel]
566        }
567
568        #[test]
569        fn body_does_not_reference_mutating_commands_in_commands_section() {
570            let section = commands_section();
571            for forbidden in [
572                "repograph add",
573                "repograph remove",
574                "repograph workspace",
575                "repograph init",
576            ] {
577                assert!(
578                    !section.contains(forbidden),
579                    "Commands section mentions mutating command: {forbidden}\n---\n{section}",
580                );
581            }
582        }
583
584        #[test]
585        fn body_mentions_every_required_read_command() {
586            for required in [
587                "repograph context",
588                "repograph list",
589                "repograph status",
590                "repograph switch",
591                "repograph doctor",
592            ] {
593                assert!(
594                    BODY.contains(required),
595                    "BODY missing required command reference: {required}",
596                );
597            }
598        }
599
600        #[test]
601        fn body_warns_against_running_mutating_commands_automatically() {
602            // The "Things to avoid" appendix must remind the agent not to run
603            // mutating commands on its own initiative.
604            assert!(
605                BODY.contains("Do not run mutating commands"),
606                "BODY missing the don't-mutate guidance"
607            );
608        }
609    }
610
611    // ---- path matrix ----
612
613    mod path {
614        use super::*;
615
616        fn fixed_roots() -> (PathBuf, PathBuf) {
617            (PathBuf::from("/home/u"), PathBuf::from("/proj"))
618        }
619
620        #[test]
621        fn path_matrix_v1() {
622            let (home, cwd) = fixed_roots();
623            assert_eq!(
624                resolve_path(AgentId::ClaudeCode, Scope::User, &home, &cwd),
625                PathBuf::from("/home/u/.claude/skills/repograph/SKILL.md"),
626            );
627            assert_eq!(
628                resolve_path(AgentId::ClaudeCode, Scope::Project, &home, &cwd),
629                PathBuf::from("/proj/.claude/skills/repograph/SKILL.md"),
630            );
631            assert_eq!(
632                resolve_path(AgentId::AgentsMd, Scope::Project, &home, &cwd),
633                PathBuf::from("/proj/AGENTS.md"),
634            );
635            assert_eq!(
636                resolve_path(AgentId::Cursor, Scope::Project, &home, &cwd),
637                PathBuf::from("/proj/.cursor/rules/repograph.mdc"),
638            );
639            assert_eq!(
640                resolve_path(AgentId::Aider, Scope::Project, &home, &cwd),
641                PathBuf::from("/proj/CONVENTIONS.md"),
642            );
643            assert_eq!(
644                resolve_path(AgentId::Windsurf, Scope::User, &home, &cwd),
645                PathBuf::from("/home/u/.codeium/windsurf/memories/repograph.md"),
646            );
647            assert_eq!(
648                resolve_path(AgentId::Windsurf, Scope::Project, &home, &cwd),
649                PathBuf::from("/proj/.windsurfrules"),
650            );
651        }
652
653        #[test]
654        fn project_only_agents_fall_through_under_user_scope() {
655            let (home, cwd) = fixed_roots();
656            for agent in [AgentId::AgentsMd, AgentId::Aider, AgentId::Cursor] {
657                assert_eq!(
658                    resolve_path(agent, Scope::User, &home, &cwd),
659                    resolve_path(agent, Scope::Project, &home, &cwd),
660                    "{agent:?} should fall through under Scope::User",
661                );
662            }
663        }
664
665        #[test]
666        fn has_artifact_writer_matches_matrix() {
667            assert!(!has_artifact_writer(AgentId::Copilot));
668            for agent in [
669                AgentId::ClaudeCode,
670                AgentId::AgentsMd,
671                AgentId::Cursor,
672                AgentId::Aider,
673                AgentId::Windsurf,
674            ] {
675                assert!(has_artifact_writer(agent), "{agent:?} should have a writer");
676            }
677        }
678
679        #[test]
680        fn scope_is_meaningful_returns_true_only_for_dual_scope_agents() {
681            assert!(scope_is_meaningful(AgentId::ClaudeCode));
682            assert!(scope_is_meaningful(AgentId::Windsurf));
683            assert!(!scope_is_meaningful(AgentId::AgentsMd));
684            assert!(!scope_is_meaningful(AgentId::Aider));
685            assert!(!scope_is_meaningful(AgentId::Cursor));
686            assert!(!scope_is_meaningful(AgentId::Copilot));
687        }
688    }
689
690    // ---- render ----
691
692    mod render {
693        use super::*;
694
695        #[test]
696        fn render_artifact_claude_code_has_yaml_frontmatter() {
697            let out = render_artifact(AgentId::ClaudeCode);
698            assert!(out.starts_with("---\nname: repograph\n"), "got: {out:?}");
699            assert!(
700                out.contains(&format!("description: >-\n  {SUMMARY}\n")),
701                "summary rendered as a folded block scalar in frontmatter, got: {out:?}",
702            );
703            assert!(out.contains(DELIMITER_BEGIN));
704            assert!(out.contains(DELIMITER_END));
705            assert!(out.contains("repograph context"));
706        }
707
708        #[test]
709        fn render_artifact_cursor_has_mdc_frontmatter() {
710            let out = render_artifact(AgentId::Cursor);
711            assert!(out.starts_with("---\ndescription:"), "got: {out:?}");
712            assert!(out.contains("globs: []"), "MDC frontmatter, got: {out:?}");
713            assert!(out.contains(DELIMITER_BEGIN));
714        }
715
716        #[test]
717        fn render_artifact_agents_md_has_no_frontmatter() {
718            let out = render_artifact(AgentId::AgentsMd);
719            let expected_prefix = format!("{DELIMITER_BEGIN}\n# repograph");
720            assert!(out.starts_with(&expected_prefix), "got: {out:?}");
721            assert!(!out.starts_with("---"), "must not have YAML frontmatter");
722        }
723
724        #[test]
725        fn render_artifact_aider_and_windsurf_have_no_frontmatter() {
726            for agent in [AgentId::Aider, AgentId::Windsurf] {
727                let out = render_artifact(agent);
728                assert!(
729                    out.starts_with(DELIMITER_BEGIN),
730                    "{agent:?} should start with the begin-delimiter",
731                );
732                assert!(!out.starts_with("---"));
733            }
734        }
735
736        #[test]
737        fn render_artifact_is_deterministic() {
738            for agent in [
739                AgentId::ClaudeCode,
740                AgentId::Cursor,
741                AgentId::AgentsMd,
742                AgentId::Aider,
743                AgentId::Windsurf,
744            ] {
745                let a = render_artifact(agent);
746                let b = render_artifact(agent);
747                assert_eq!(a, b, "{agent:?} output must be byte-stable across calls");
748            }
749        }
750
751        #[test]
752        #[should_panic(expected = "copilot has no writer")]
753        fn render_artifact_copilot_panics() {
754            let _ = render_artifact(AgentId::Copilot);
755        }
756    }
757
758    // ---- splice ----
759
760    mod splice {
761        use super::*;
762
763        fn block(inner: &str) -> String {
764            format!("{DELIMITER_BEGIN}\n{inner}\n{DELIMITER_END}\n")
765        }
766
767        #[test]
768        fn fresh_write() {
769            let outcome = splice_managed_section(None, "BODY");
770            assert_eq!(outcome, SpliceOutcome::FreshWrite(block("BODY")));
771        }
772
773        #[test]
774        fn identical_returns_identical() {
775            let existing = block("BODY");
776            let outcome = splice_managed_section(Some(&existing), "BODY");
777            assert_eq!(outcome, SpliceOutcome::Identical);
778        }
779
780        #[test]
781        fn differing_inner_rewrites_block() {
782            let existing = block("OLD");
783            let outcome = splice_managed_section(Some(&existing), "NEW");
784            match outcome {
785                SpliceOutcome::Replaced(s) => assert_eq!(s, block("NEW")),
786                other => panic!("expected Replaced, got {other:?}"),
787            }
788        }
789
790        #[test]
791        fn no_delimiters_appends() {
792            let existing = "# My project\n\nCustom prose.\n";
793            let outcome = splice_managed_section(Some(existing), "BODY");
794            match outcome {
795                SpliceOutcome::Appended(s) => {
796                    let expected = format!("{existing}\n{}", block("BODY"));
797                    assert_eq!(s, expected);
798                }
799                other => panic!("expected Appended, got {other:?}"),
800            }
801        }
802
803        #[test]
804        fn user_content_outside_delimiters_preserved() {
805            let existing = format!("pre\n{}post\n", block("old"));
806            let outcome = splice_managed_section(Some(&existing), "new");
807            match outcome {
808                SpliceOutcome::Replaced(s) => {
809                    assert_eq!(s, format!("pre\n{}post\n", block("new")));
810                }
811                other => panic!("expected Replaced, got {other:?}"),
812            }
813        }
814
815        #[test]
816        fn empty_existing_file_appends_with_no_leading_newline() {
817            let outcome = splice_managed_section(Some(""), "BODY");
818            match outcome {
819                SpliceOutcome::Appended(s) => assert_eq!(s, block("BODY")),
820                other => panic!("expected Appended for empty file, got {other:?}"),
821            }
822        }
823
824        #[test]
825        fn existing_without_trailing_newline_gets_separator() {
826            // Existing file: no trailing newline → splice should add one
827            // before the block.
828            let existing = "no-newline";
829            let outcome = splice_managed_section(Some(existing), "BODY");
830            match outcome {
831                SpliceOutcome::Appended(s) => {
832                    assert_eq!(s, format!("no-newline\n\n{}", block("BODY")));
833                }
834                other => panic!("expected Appended, got {other:?}"),
835            }
836        }
837    }
838
839    // ---- install_one ----
840
841    mod install_one {
842        use super::*;
843
844        fn read(path: &Path) -> String {
845            fs_err::read_to_string(path).unwrap()
846        }
847
848        #[test]
849        fn fresh_install_writes_file() {
850            let dir = TempDir::new().unwrap();
851            let path = dir.path().join("nested/AGENTS.md");
852            let r = install_one(AgentId::AgentsMd, &path, false);
853            match r {
854                ArtifactResult::Written { path: p, .. } => assert_eq!(p, path),
855                other => panic!("expected Written, got {other:?}"),
856            }
857            assert_eq!(read(&path), render_artifact(AgentId::AgentsMd));
858        }
859
860        #[test]
861        fn re_run_with_identical_body_returns_unchanged() {
862            let dir = TempDir::new().unwrap();
863            let path = dir.path().join("AGENTS.md");
864            let _ = install_one(AgentId::AgentsMd, &path, false);
865            let first = read(&path);
866            let r = install_one(AgentId::AgentsMd, &path, false);
867            match r {
868                ArtifactResult::Unchanged { .. } => (),
869                other => panic!("expected Unchanged on re-run, got {other:?}"),
870            }
871            assert_eq!(
872                read(&path),
873                first,
874                "file must be byte-stable across re-runs"
875            );
876        }
877
878        #[test]
879        fn force_on_identical_returns_written() {
880            let dir = TempDir::new().unwrap();
881            let path = dir.path().join("AGENTS.md");
882            let _ = install_one(AgentId::AgentsMd, &path, false);
883            let first = read(&path);
884            let r = install_one(AgentId::AgentsMd, &path, true);
885            match r {
886                ArtifactResult::Written { .. } => (),
887                other => panic!("expected Written under force, got {other:?}"),
888            }
889            assert_eq!(
890                read(&path),
891                first,
892                "force on identical content rewrites but byte content is the same"
893            );
894        }
895
896        #[test]
897        fn force_overwrites_user_content() {
898            let dir = TempDir::new().unwrap();
899            let path = dir.path().join("AGENTS.md");
900            fs_err::write(&path, "# My project\n\nCustom prose.\n").unwrap();
901            let r = install_one(AgentId::AgentsMd, &path, true);
902            match r {
903                ArtifactResult::Written { .. } => (),
904                other => panic!("expected Written under force, got {other:?}"),
905            }
906            let after = read(&path);
907            assert!(after.starts_with(DELIMITER_BEGIN), "force replaced content");
908            assert!(
909                !after.contains("Custom prose."),
910                "force dropped user content"
911            );
912        }
913
914        #[test]
915        fn fresh_install_for_whole_file_owner_includes_frontmatter() {
916            let dir = TempDir::new().unwrap();
917            let path = dir.path().join("nested/SKILL.md");
918            let r = install_one(AgentId::ClaudeCode, &path, false);
919            assert!(matches!(r, ArtifactResult::Written { .. }));
920            let body = read(&path);
921            assert!(
922                body.starts_with("---\nname: repograph\n"),
923                "claude-code fresh install must include YAML frontmatter, got:\n{body}",
924            );
925            assert!(body.contains(DELIMITER_BEGIN));
926            assert!(body.contains(DELIMITER_END));
927        }
928
929        #[test]
930        fn re_run_whole_file_owner_is_unchanged() {
931            let dir = TempDir::new().unwrap();
932            let path = dir.path().join("SKILL.md");
933            let _ = install_one(AgentId::ClaudeCode, &path, false);
934            let first = read(&path);
935            let r = install_one(AgentId::ClaudeCode, &path, false);
936            assert!(matches!(r, ArtifactResult::Unchanged { .. }));
937            assert_eq!(read(&path), first);
938        }
939
940        #[test]
941        fn non_force_preserves_user_content_around_block() {
942            let dir = TempDir::new().unwrap();
943            let path = dir.path().join("AGENTS.md");
944            fs_err::write(&path, "# My project\n\nCustom prose.\n").unwrap();
945            let r = install_one(AgentId::AgentsMd, &path, false);
946            assert!(matches!(r, ArtifactResult::Written { .. }));
947            let after = read(&path);
948            assert!(after.starts_with("# My project\n\nCustom prose.\n"));
949            assert!(after.contains(DELIMITER_BEGIN));
950            assert!(after.contains(DELIMITER_END));
951        }
952    }
953
954    // ---- install_artifacts ----
955
956    mod install_artifacts {
957        use super::*;
958
959        #[test]
960        fn returns_one_result_per_agent_in_order() {
961            let dir = TempDir::new().unwrap();
962            let home = dir.path().join("home");
963            let cwd = dir.path().join("proj");
964            fs_err::create_dir_all(&home).unwrap();
965            fs_err::create_dir_all(&cwd).unwrap();
966            let agents = vec![AgentId::AgentsMd, AgentId::ClaudeCode];
967            let results = install_artifacts(&agents, Scope::User, &home, &cwd, false);
968            assert_eq!(results.len(), 2);
969            assert_eq!(results[0].agent(), AgentId::AgentsMd);
970            assert_eq!(results[1].agent(), AgentId::ClaudeCode);
971        }
972
973        #[test]
974        fn copilot_is_skipped() {
975            let dir = TempDir::new().unwrap();
976            let home = dir.path().join("home");
977            let cwd = dir.path().join("proj");
978            fs_err::create_dir_all(&home).unwrap();
979            fs_err::create_dir_all(&cwd).unwrap();
980            let results = install_artifacts(&[AgentId::Copilot], Scope::User, &home, &cwd, false);
981            match &results[0] {
982                ArtifactResult::Skipped { agent, reason } => {
983                    assert_eq!(*agent, AgentId::Copilot);
984                    assert_eq!(*reason, REASON_COPILOT_DEFERRED);
985                }
986                other => panic!("expected Skipped for Copilot, got {other:?}"),
987            }
988        }
989
990        #[test]
991        fn per_agent_failure_does_not_abort_subsequent_agents() {
992            // Strategy: make the AgentsMd target unwritable, then install
993            // AgentsMd followed by ClaudeCode. Unix-only (skip on Windows).
994            #[cfg(unix)]
995            {
996                use std::os::unix::fs::PermissionsExt;
997                let dir = TempDir::new().unwrap();
998                let home = dir.path().join("home");
999                let cwd = dir.path().join("proj");
1000                fs_err::create_dir_all(&home).unwrap();
1001                fs_err::create_dir_all(&cwd).unwrap();
1002                // Create AGENTS.md as a directory to force the write to fail.
1003                fs_err::create_dir_all(cwd.join("AGENTS.md")).unwrap();
1004                let results = install_artifacts(
1005                    &[AgentId::AgentsMd, AgentId::ClaudeCode],
1006                    Scope::User,
1007                    &home,
1008                    &cwd,
1009                    false,
1010                );
1011                assert_eq!(results.len(), 2);
1012                assert!(matches!(results[0], ArtifactResult::Failed { .. }));
1013                assert!(matches!(
1014                    results[1],
1015                    ArtifactResult::Written { .. } | ArtifactResult::Unchanged { .. }
1016                ));
1017                // Restore mode so TempDir can clean up.
1018                let mut perms = fs_err::metadata(cwd.join("AGENTS.md"))
1019                    .unwrap()
1020                    .permissions();
1021                perms.set_mode(0o755);
1022                fs_err::set_permissions(cwd.join("AGENTS.md"), perms).unwrap();
1023            }
1024        }
1025
1026        #[test]
1027        fn copilot_in_mixed_selection_does_not_block_others() {
1028            let dir = TempDir::new().unwrap();
1029            let home = dir.path().join("home");
1030            let cwd = dir.path().join("proj");
1031            fs_err::create_dir_all(&home).unwrap();
1032            fs_err::create_dir_all(&cwd).unwrap();
1033            let results = install_artifacts(
1034                &[AgentId::Copilot, AgentId::AgentsMd, AgentId::ClaudeCode],
1035                Scope::User,
1036                &home,
1037                &cwd,
1038                false,
1039            );
1040            assert_eq!(results.len(), 3);
1041            assert!(matches!(results[0], ArtifactResult::Skipped { .. }));
1042            assert!(matches!(results[1], ArtifactResult::Written { .. }));
1043            assert!(matches!(results[2], ArtifactResult::Written { .. }));
1044        }
1045    }
1046}