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/// Which generated skill a given artifact carries.
69///
70/// `Consumer` is the read-only surface (`list`/`status`/`context`/`switch`);
71/// `Setup` is the mutating surface (`add`/`remove`/`edit`/`workspace …`).
72/// Wholly-owned-file agents (Claude, Cursor) receive one artifact per
73/// capability; flat-file agents inline both capabilities into a single block.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
75#[serde(rename_all = "lowercase")]
76pub enum Capability {
77    /// The read-only consumer skill (`repograph`).
78    Consumer,
79    /// The mutating registry-management skill (`repograph-setup`).
80    Setup,
81}
82
83impl Capability {
84    /// The skill name (and frontmatter `name:`) for this capability.
85    #[must_use]
86    pub const fn skill_name(self) -> &'static str {
87        match self {
88            Self::Consumer => "repograph",
89            Self::Setup => "repograph-setup",
90        }
91    }
92}
93
94/// Per-artifact outcome of an install. The orchestrator returns one of these
95/// per (agent, capability) artifact actually targeted, in selection order.
96///
97/// `RepographError` is not `Clone`, so this enum is `Debug`-only on purpose.
98#[derive(Debug)]
99pub enum ArtifactResult {
100    /// File was created or its delimited block was rewritten.
101    Written {
102        agent: AgentId,
103        capability: Capability,
104        path: PathBuf,
105    },
106    /// File already exists with a delimited block whose body is byte-identical
107    /// to the canonical content; no I/O write occurred.
108    Unchanged {
109        agent: AgentId,
110        capability: Capability,
111        path: PathBuf,
112    },
113    /// Agent has no writer (today: only `Copilot`); the install layer skipped
114    /// it with no file write attempted.
115    Skipped {
116        agent: AgentId,
117        reason: &'static str,
118    },
119    /// Per-artifact failure (read or write I/O error). Reported on stderr; does
120    /// not abort the surrounding run.
121    Failed {
122        agent: AgentId,
123        capability: Capability,
124        error: RepographError,
125    },
126}
127
128impl ArtifactResult {
129    /// The agent this result pertains to. Useful in summary logs and tests.
130    #[must_use]
131    pub const fn agent(&self) -> AgentId {
132        match self {
133            Self::Written { agent, .. }
134            | Self::Unchanged { agent, .. }
135            | Self::Skipped { agent, .. }
136            | Self::Failed { agent, .. } => *agent,
137        }
138    }
139
140    /// The capability this result pertains to, or `None` for a `Skipped` agent
141    /// (which has no per-capability artifact).
142    #[must_use]
143    pub const fn capability(&self) -> Option<Capability> {
144        match self {
145            Self::Written { capability, .. }
146            | Self::Unchanged { capability, .. }
147            | Self::Failed { capability, .. } => Some(*capability),
148            Self::Skipped { .. } => None,
149        }
150    }
151}
152
153/// Reason strings used in [`ArtifactResult::Skipped`]. Stable: agents may
154/// observe them in `repograph doctor` output or log scraping.
155pub const REASON_COPILOT_DEFERRED: &str = "no writer in v1";
156
157/// Monotonic version of the managed artifact body.
158///
159/// Bump this whenever the rendered body content changes so installed artifacts
160/// can be detected as stale (see [`installed_version`] and the `doctor`
161/// freshness check). Kept in sync with the literal in [`DELIMITER_BEGIN`] by a
162/// unit test.
163pub const ARTIFACT_BODY_VERSION: u32 = 1;
164
165/// Version-agnostic prefix of the begin marker. Splice detection matches on
166/// this so an older-version block is recognized and rewritten in place rather
167/// than appended as a duplicate.
168pub const DELIMITER_BEGIN_PREFIX: &str = "<!-- repograph:begin";
169
170/// HTML-comment marker opening the repograph-managed region of an artifact,
171/// carrying the current [`ARTIFACT_BODY_VERSION`] stamp.
172pub const DELIMITER_BEGIN: &str = "<!-- repograph:begin v1 -->";
173
174/// HTML-comment marker closing the repograph-managed region of an artifact.
175pub const DELIMITER_END: &str = "<!-- repograph:end -->";
176
177/// Parse the body-version stamp from an installed file's managed block.
178///
179/// Returns `None` when the file has no recognizable begin marker. Used by
180/// `doctor` to compare an installed artifact against the running binary's
181/// [`ARTIFACT_BODY_VERSION`] without rewriting anything.
182#[must_use]
183pub fn installed_version(existing: &str) -> Option<u32> {
184    let begin = existing.find(DELIMITER_BEGIN_PREFIX)?;
185    let after_prefix = &existing[begin + DELIMITER_BEGIN_PREFIX.len()..];
186    // Marker shape: ` v<N> -->`. Take up to the closing `-->`, find the `v<N>`.
187    let line_end = after_prefix.find("-->")?;
188    let marker_tail = &after_prefix[..line_end];
189    let token = marker_tail
190        .split_whitespace()
191        .find(|t| t.starts_with('v'))?;
192    token[1..].parse().ok()
193}
194
195/// Skill `description` rendered into the YAML frontmatter of the agents that
196/// have it (Claude `SKILL.md`, Cursor `.mdc`).
197///
198/// This string is the *only* signal the host sees when deciding whether to
199/// invoke the skill — the body (`BODY`) is loaded only *after* invocation, so
200/// trigger phrasing must live here. It therefore leads with concrete user
201/// phrasings ("switch to", "what's dirty", …) and an explicit prefer-over-
202/// `find`/`git` instruction rather than an abstract one-liner.
203///
204/// Rendered as a folded block scalar (`>-`, see [`render_artifact`]) so it can
205/// safely contain colons, quotes, and slashes without YAML-escaping. Keep it
206/// under the 1024-char skill-spec limit. Frontmatter-less writers (AGENTS.md,
207/// CONVENTIONS.md, .windsurfrules) use a `# repograph` heading instead and do
208/// not embed this string.
209pub 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).";
210
211/// Skill `description` for the `repograph-setup` capability — the mutating
212/// surface.
213///
214/// Distinct from [`SUMMARY`]: its triggers name registering, grouping, and
215/// updating registry entries so the host invokes it (not the read-only consumer
216/// skill) when the user wants to change their registry. Rendered into the setup
217/// artifact's frontmatter for wholly-owned-file agents.
218pub const SETUP_SUMMARY: &str = "Use when the user wants to set up or change their repograph registry: register a local git repo (\"add this repo\", \"track /path/to/project\"), group repos into a workspace (\"create a workspace for acme\", \"put api and web together\"), update an existing entry (\"rename that repo\", \"change its description\", \"retag it\", \"point it at the new path\"), or deregister a repo or workspace (\"remove that repo\", \"delete the acme workspace\"). Drives the mutating commands `add`, `edit`, `remove`, and `workspace …` behind a plan→confirm→execute→verify workflow. Use this for changing the registry; use the read-only `repograph` skill for resolving, listing, or reading it.";
219
220/// The single canonical instructional body, shared by every per-agent writer.
221///
222/// Owned by `repograph-core` so the CLI surface is documented in exactly one
223/// place. Per-agent writers (see [`render_artifact`]) wrap this string in
224/// native-format frontmatter or headers but never edit its content.
225///
226/// Content stability: this string is byte-stable for a given crate version. A
227/// body update bumps the in-file content for users on re-`init`; the spliced
228/// install layer rewrites only the delimited region.
229pub const BODY: &str = include_str!("agent_artifact_body.md");
230
231/// The canonical instructional body for the `repograph-setup` capability — the
232/// mutating surface.
233///
234/// Owned by `repograph-core` so the CLI mutation surface is documented in
235/// exactly one place, mirroring [`BODY`] for the consumer skill.
236pub const SETUP_BODY: &str = include_str!("agent_artifact_setup_body.md");
237
238/// The instructional body for `capability`.
239#[must_use]
240pub const fn body_for(capability: Capability) -> &'static str {
241    match capability {
242        Capability::Consumer => BODY,
243        Capability::Setup => SETUP_BODY,
244    }
245}
246
247/// The frontmatter `description:` summary for `capability`.
248#[must_use]
249pub const fn summary_for(capability: Capability) -> &'static str {
250    match capability {
251        Capability::Consumer => SUMMARY,
252        Capability::Setup => SETUP_SUMMARY,
253    }
254}
255
256/// Convenience accessor for the consumer writer-side summary. Mirrors `SUMMARY`;
257/// this exists so writers don't reach into module-level constants directly.
258#[must_use]
259pub const fn writer_summary() -> &'static str {
260    SUMMARY
261}
262
263/// The capabilities that should be emitted for `agent`, in install order.
264///
265/// Wholly-owned-file agents (Claude, Cursor) emit a discrete artifact per
266/// capability. Flat-file agents (AGENTS.md, Aider, Windsurf) inline both bodies
267/// into a single block, so they emit one combined artifact tagged `Consumer`.
268#[must_use]
269pub const fn capabilities_for(agent: AgentId) -> &'static [Capability] {
270    if wholly_owned_file(agent) {
271        &[Capability::Consumer, Capability::Setup]
272    } else {
273        &[Capability::Consumer]
274    }
275}
276
277/// Is there an installed-artifact writer for this agent in v1?
278///
279/// `Copilot` returns `false` because its instruction format varies across
280/// surfaces and no single converged path exists today (see module docs).
281/// Every other v1 agent returns `true`.
282#[must_use]
283pub const fn has_artifact_writer(agent: AgentId) -> bool {
284    !matches!(agent, AgentId::Copilot)
285}
286
287/// Does this agent's artifact occupy the whole file (frontmatter included),
288/// with no expectation of pre-existing user content to preserve?
289///
290/// `true` for `claude-code` (`SKILL.md` is wholly repograph's) and `cursor`
291/// (`.cursor/rules/repograph.mdc` is rule-engine-specific). For these agents
292/// the install layer writes the full [`render_artifact`] output — including
293/// the YAML frontmatter — rather than splicing only the delimited region.
294///
295/// `false` for `agents-md`, `aider`, and `windsurf`, whose target files may
296/// already contain user-authored prose that the install layer must preserve
297/// outside the delimited block.
298#[must_use]
299pub const fn wholly_owned_file(agent: AgentId) -> bool {
300    matches!(agent, AgentId::ClaudeCode | AgentId::Cursor)
301}
302
303/// Resolve the target install path for `(agent, scope)`.
304///
305/// Pass `home` and `cwd` explicitly so callers (and tests) control where the
306/// roots come from — this module never calls `dirs::home_dir()` or
307/// `std::env::current_dir()` itself.
308///
309/// Agents whose path is project-only by convention (AGENTS.md, CONVENTIONS.md,
310/// Cursor `.cursor/rules/*`) ignore `Scope::User` and return the project path.
311/// See [`scope_is_meaningful`] for the symmetric predicate the init command
312/// uses to decide whether to require a `--scope` flag under `--no-prompt`.
313#[must_use]
314pub fn resolve_path(
315    agent: AgentId,
316    capability: Capability,
317    scope: Scope,
318    home: &Path,
319    cwd: &Path,
320) -> PathBuf {
321    // Flat-file agents (AGENTS.md, Aider, Windsurf) inline both capabilities
322    // into one file, so their path is capability-independent. Wholly-owned-file
323    // agents get a discrete path per capability, keyed by the skill name.
324    let skill = capability.skill_name();
325    match agent {
326        AgentId::ClaudeCode => {
327            let rel = format!(".claude/skills/{skill}/SKILL.md");
328            match scope {
329                Scope::User => home.join(rel),
330                Scope::Project => cwd.join(rel),
331            }
332        }
333        AgentId::Cursor => cwd.join(format!(".cursor/rules/{skill}.mdc")),
334        AgentId::AgentsMd => cwd.join("AGENTS.md"),
335        AgentId::Aider => cwd.join("CONVENTIONS.md"),
336        AgentId::Windsurf => match scope {
337            Scope::User => home.join(".codeium/windsurf/memories/repograph.md"),
338            Scope::Project => cwd.join(".windsurfrules"),
339        },
340        AgentId::Copilot => {
341            // `has_artifact_writer` returns false; install layer skips before
342            // calling `resolve_path`. Returning a path here would mislead.
343            unreachable!("resolve_path: copilot has no writer; check has_artifact_writer first")
344        }
345    }
346}
347
348/// Does the choice between `Scope::User` and `Scope::Project` change the
349/// resolved path for this agent?
350///
351/// `false` for project-only agents (their user path equals their project path)
352/// and for agents without a writer. The init command uses this to decide
353/// whether `--scope` is required under `--no-prompt`.
354#[must_use]
355pub fn scope_is_meaningful(agent: AgentId) -> bool {
356    if !has_artifact_writer(agent) {
357        return false;
358    }
359    // Compare paths using two distinct dummy roots so we detect a real
360    // dependency on `scope`. If the resolver returns the same path under both,
361    // scope doesn't matter for this agent.
362    let home = Path::new("/__home__");
363    let cwd = Path::new("/__cwd__");
364    // Scope-dependence is identical across capabilities; Consumer is representative.
365    resolve_path(agent, Capability::Consumer, Scope::User, home, cwd)
366        != resolve_path(agent, Capability::Consumer, Scope::Project, home, cwd)
367}
368
369/// Compose the full file contents for `agent`: per-agent frontmatter (if any)
370/// followed by the managed-section delimiters wrapping [`BODY`], plus a
371/// trailing newline.
372///
373/// Centralizes the wrapping logic so every install path produces byte-stable,
374/// deterministic output (no timestamps, no host-specific strings).
375///
376/// # Panics
377///
378/// Panics with `unreachable!` if called for `AgentId::Copilot`. Callers MUST
379/// gate on [`has_artifact_writer`] first; reaching this branch is a logic bug.
380#[must_use]
381pub fn render_artifact(agent: AgentId, capability: Capability) -> String {
382    match agent {
383        AgentId::ClaudeCode => format!(
384            "---\nname: {name}\ndescription: >-\n  {summary}\n---\n\n\
385             {begin}\n{body}\n{end}\n",
386            name = capability.skill_name(),
387            summary = summary_for(capability),
388            begin = DELIMITER_BEGIN,
389            body = body_for(capability),
390            end = DELIMITER_END,
391        ),
392        AgentId::Cursor => format!(
393            "---\ndescription: >-\n  {summary}\nglobs: []\n---\n\n\
394             {begin}\n{body}\n{end}\n",
395            summary = summary_for(capability),
396            begin = DELIMITER_BEGIN,
397            body = body_for(capability),
398            end = DELIMITER_END,
399        ),
400        AgentId::AgentsMd | AgentId::Aider | AgentId::Windsurf => {
401            // Flat-file agents inline BOTH capabilities into one managed block:
402            // the consumer body followed by the setup body. `capability` is
403            // ignored — these agents only ever request the single combined file.
404            format!(
405                "{DELIMITER_BEGIN}\n# repograph\n\n{BODY}\n\n# repograph-setup\n\n{SETUP_BODY}\n{DELIMITER_END}\n"
406            )
407        }
408        AgentId::Copilot => {
409            unreachable!("render_artifact: copilot has no writer; check has_artifact_writer first")
410        }
411    }
412}
413
414/// Outcome of [`splice_managed_section`] — describes how the install layer
415/// should reconcile the new body against the existing file contents.
416#[derive(Debug, PartialEq, Eq)]
417pub enum SpliceOutcome {
418    /// Existing file contains the delimited block and its inner body matches
419    /// the new body byte-for-byte. No write needed.
420    Identical,
421    /// Existing file contains the delimited block but the inner body differs.
422    /// The carried string is the full new file contents — only the delimited
423    /// region was rewritten; everything outside is byte-preserved.
424    Replaced(String),
425    /// Existing file has no delimited block. The carried string is the
426    /// existing contents (with a separating newline if non-empty) plus a
427    /// freshly-appended delimited block.
428    Appended(String),
429    /// Existing file does not exist. The carried string is the bare delimited
430    /// block.
431    FreshWrite(String),
432}
433
434/// Pure-string idempotent splice: read the existing file (or `None`), produce
435/// the [`SpliceOutcome`] that tells the install layer what to write.
436///
437/// `new_block_body` is the canonical body that should land *between* the
438/// delimiters — typically the full output of [`render_artifact`] minus its
439/// frontmatter. For files that always own the whole content (Claude SKILL.md,
440/// Cursor .mdc), pass [`render_artifact`] in full; the delimiter pair appears
441/// as the entire body and the function still routes correctly.
442///
443/// I/O-free: testable as a string transformation.
444#[must_use]
445pub fn splice_managed_section(existing: Option<&str>, new_block_body: &str) -> SpliceOutcome {
446    let full_block = format!("{DELIMITER_BEGIN}\n{new_block_body}\n{DELIMITER_END}\n");
447    let Some(existing) = existing else {
448        return SpliceOutcome::FreshWrite(full_block);
449    };
450
451    // Locate the begin marker by its version-agnostic prefix, so an
452    // older-version block (e.g. `… begin v1 …` when the current is `v2`) is
453    // still recognized and rewritten in place rather than duplicated.
454    if let Some(begin_idx) = existing.find(DELIMITER_BEGIN_PREFIX) {
455        // The begin marker spans from `begin_idx` to the end of its `-->`.
456        let rest = &existing[begin_idx..];
457        if let Some(marker_rel_end) = rest.find("-->") {
458            let begin_marker_end = begin_idx + marker_rel_end + "-->".len();
459            let matched_begin = &existing[begin_idx..begin_marker_end];
460            // The body starts after the begin-marker line.
461            // Skip a single newline immediately after the marker, if present.
462            let inner_start = if existing[begin_marker_end..].starts_with('\n') {
463                begin_marker_end + 1
464            } else {
465                begin_marker_end
466            };
467            if let Some(end_rel) = existing[inner_start..].find(DELIMITER_END) {
468                // `inner_end` is the index of the first byte of DELIMITER_END.
469                let inner_end = inner_start + end_rel;
470                // The inner body sits between `inner_start` and `inner_end`.
471                // It typically ends with a `\n` we wrote on the last install; we
472                // compare the body without that trailing newline so callers
473                // don't have to think about it.
474                let inner_with_trailing_nl = &existing[inner_start..inner_end];
475                let inner = inner_with_trailing_nl
476                    .strip_suffix('\n')
477                    .unwrap_or(inner_with_trailing_nl);
478                // Identical only when both the body AND the marker version match
479                // the current ones — a version bump alone forces a rewrite.
480                if inner == new_block_body && matched_begin == DELIMITER_BEGIN {
481                    return SpliceOutcome::Identical;
482                }
483                // Build the replaced output: prefix + DELIMITER_BEGIN + \n + body
484                // + \n + DELIMITER_END + suffix (where suffix begins at
485                // `inner_end + DELIMITER_END.len()`).
486                let suffix_start = inner_end + DELIMITER_END.len();
487                let mut out = String::with_capacity(existing.len() + new_block_body.len());
488                out.push_str(&existing[..begin_idx]);
489                out.push_str(DELIMITER_BEGIN);
490                out.push('\n');
491                out.push_str(new_block_body);
492                out.push('\n');
493                out.push_str(DELIMITER_END);
494                out.push_str(&existing[suffix_start..]);
495                return SpliceOutcome::Replaced(out);
496            }
497        }
498        // Begin without end (or without a closing `-->`) is malformed; treat as
499        // no-block-present and append a fresh block. User content stays intact.
500    }
501
502    // No delimiter pair: append the full block after a separating newline.
503    let needs_sep = !existing.is_empty() && !existing.ends_with('\n');
504    let mut out = String::with_capacity(existing.len() + full_block.len() + usize::from(needs_sep));
505    out.push_str(existing);
506    if !existing.is_empty() {
507        if needs_sep {
508            out.push('\n');
509        }
510        out.push('\n');
511    }
512    out.push_str(&full_block);
513    SpliceOutcome::Appended(out)
514}
515
516/// Install a single artifact at `path` for `agent`.
517///
518/// Reads the existing file (if any), splices the canonical body in via
519/// [`splice_managed_section`] (or short-circuits to a fresh write when
520/// `force = true`), and writes the result through `fs_err`.
521///
522/// Returns a typed [`ArtifactResult`]:
523///
524/// - [`Written`](ArtifactResult::Written) — file created or delimited region
525///   updated.
526/// - [`Unchanged`](ArtifactResult::Unchanged) — existing file already
527///   contained the canonical body byte-for-byte.
528/// - [`Failed`](ArtifactResult::Failed) — read or write I/O error. Surrounding
529///   orchestration (see [`install_artifacts`]) does not abort on `Failed`.
530///
531/// Caller MUST gate on [`has_artifact_writer`] first; this function calls
532/// [`render_artifact`] which panics for `Copilot`.
533#[must_use]
534pub fn install_one(
535    agent: AgentId,
536    capability: Capability,
537    path: &Path,
538    force: bool,
539) -> ArtifactResult {
540    debug_assert!(
541        has_artifact_writer(agent),
542        "install_one called for an agent without a writer: {agent:?}"
543    );
544
545    let full_artifact = render_artifact(agent, capability);
546
547    let existing = if force {
548        None
549    } else {
550        match fs_err::read_to_string(path) {
551            Ok(s) => Some(s),
552            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
553            Err(e) => {
554                return ArtifactResult::Failed {
555                    agent,
556                    capability,
557                    error: RepographError::Io(e),
558                };
559            }
560        }
561    };
562
563    // Two install models:
564    //
565    // - Whole-file owners (claude-code SKILL.md, cursor .mdc): repograph owns
566    //   the entire file. Write [`render_artifact`] verbatim — including any
567    //   YAML frontmatter — and treat byte-identical existing content as
568    //   `Unchanged`. The splice contract doesn't apply because there's no
569    //   user content to preserve around the delimited region.
570    // - Shared-file agents (agents-md, aider, windsurf): the target file may
571    //   already contain user-authored prose. Splice the canonical body into
572    //   the delimited region and leave everything outside untouched.
573    let to_write = if wholly_owned_file(agent) {
574        if let Some(ref existing_body) = existing {
575            if existing_body == &full_artifact && !force {
576                return ArtifactResult::Unchanged {
577                    agent,
578                    capability,
579                    path: path.to_path_buf(),
580                };
581            }
582        }
583        full_artifact
584    } else {
585        let new_block_body = rendered_inner_body(&full_artifact);
586        let outcome = splice_managed_section(existing.as_deref(), &new_block_body);
587        match outcome {
588            SpliceOutcome::Identical if !force => {
589                return ArtifactResult::Unchanged {
590                    agent,
591                    capability,
592                    path: path.to_path_buf(),
593                };
594            }
595            SpliceOutcome::Identical => {
596                // force=true and content matched: rewrite anyway for the
597                // documented `Written` outcome.
598                format!("{DELIMITER_BEGIN}\n{new_block_body}\n{DELIMITER_END}\n")
599            }
600            SpliceOutcome::Replaced(s)
601            | SpliceOutcome::Appended(s)
602            | SpliceOutcome::FreshWrite(s) => s,
603        }
604    };
605
606    if let Some(parent) = path.parent() {
607        if !parent.as_os_str().is_empty() {
608            if let Err(e) = fs_err::create_dir_all(parent) {
609                return ArtifactResult::Failed {
610                    agent,
611                    capability,
612                    error: RepographError::Io(e),
613                };
614            }
615        }
616    }
617
618    match fs_err::write(path, to_write) {
619        Ok(()) => ArtifactResult::Written {
620            agent,
621            capability,
622            path: path.to_path_buf(),
623        },
624        Err(e) => ArtifactResult::Failed {
625            agent,
626            capability,
627            error: RepographError::Io(e),
628        },
629    }
630}
631
632/// Extract the inner-body portion of `render_artifact`'s output — what should
633/// land between `DELIMITER_BEGIN` and `DELIMITER_END`.
634///
635/// For agents with frontmatter (Claude SKILL.md, Cursor .mdc) the frontmatter
636/// is stripped and the inner delimited body is the rest; for frontmatter-less
637/// writers (AGENTS.md, CONVENTIONS.md, .windsurfrules) the inner body is the
638/// body between the delimiters in `render_artifact`'s output.
639///
640/// This indirection exists so the splice contract is uniform: the install
641/// layer always treats `new_block_body` as the substring between delimiters,
642/// regardless of frontmatter shape.
643///
644/// Returns the full `rendered` string back as-is if the delimiters can't be
645/// located. That can only happen if `render_artifact` is mis-implemented; the
646/// install layer would then write a malformed file rather than panic — the
647/// next `cargo test` run would surface the regression because every render
648/// test asserts the delimiters are present.
649fn rendered_inner_body(rendered: &str) -> String {
650    let Some(begin_idx) = rendered.find(DELIMITER_BEGIN) else {
651        return rendered.to_string();
652    };
653    let after_begin = begin_idx + DELIMITER_BEGIN.len();
654    let inner_start = if rendered[after_begin..].starts_with('\n') {
655        after_begin + 1
656    } else {
657        after_begin
658    };
659    let Some(end_idx_rel) = rendered[inner_start..].find(DELIMITER_END) else {
660        return rendered.to_string();
661    };
662    let inner = &rendered[inner_start..inner_start + end_idx_rel];
663    inner.strip_suffix('\n').unwrap_or(inner).to_string()
664}
665
666/// Render frontmatter (if any) and a managed-section block for `agent`, then
667/// install it under the resolved `(scope, home, cwd)` path. The result vector
668/// has one entry per input agent in selection order.
669///
670/// Agents without a writer (see [`has_artifact_writer`]) produce
671/// [`ArtifactResult::Skipped`] without touching the filesystem. Per-agent
672/// errors are captured as [`ArtifactResult::Failed`] and do NOT abort the
673/// remaining agents.
674///
675/// `force = true` overwrites the target file fresh (see module docs).
676///
677/// This function is log-free by design (`repograph-core` is pure-value domain
678/// code per `.claude/rules/logging.md`). The binary-side caller iterates the
679/// returned vector and emits one `tracing` line per result on stderr.
680#[must_use]
681pub fn install_artifacts(
682    agents: &[AgentId],
683    scope: Scope,
684    home: &Path,
685    cwd: &Path,
686    force: bool,
687) -> Vec<ArtifactResult> {
688    let mut results = Vec::with_capacity(agents.len());
689    for &agent in agents {
690        if !has_artifact_writer(agent) {
691            results.push(ArtifactResult::Skipped {
692                agent,
693                reason: REASON_COPILOT_DEFERRED,
694            });
695            continue;
696        }
697        // Wholly-owned-file agents emit one artifact per capability (Consumer
698        // then Setup); flat-file agents emit a single combined artifact.
699        for &capability in capabilities_for(agent) {
700            let path = resolve_path(agent, capability, scope, home, cwd);
701            results.push(install_one(agent, capability, &path, force));
702        }
703    }
704    results
705}
706
707#[cfg(test)]
708mod tests {
709    #![allow(clippy::unwrap_used, clippy::expect_used)]
710    use super::*;
711    use tempfile::TempDir;
712
713    // ---- body ----
714
715    mod body {
716        use super::*;
717
718        /// Locate the body's "## Commands" section — the table that tells the
719        /// agent which commands to invoke. Returns the section text up to the
720        /// next `## ` heading or end-of-body. Mutating commands MUST NOT
721        /// appear here; negative-guidance prose in the "Things to avoid"
722        /// appendix is allowed to name them.
723        fn commands_section() -> &'static str {
724            let start = BODY
725                .find("## Commands")
726                .expect("body has a Commands section");
727            let after = start + "## Commands".len();
728            let end_rel = BODY[after..].find("\n## ").unwrap_or(BODY.len() - after);
729            &BODY[start..after + end_rel]
730        }
731
732        #[test]
733        fn body_does_not_reference_mutating_commands_in_commands_section() {
734            let section = commands_section();
735            for forbidden in [
736                "repograph add",
737                "repograph remove",
738                "repograph workspace",
739                "repograph init",
740            ] {
741                assert!(
742                    !section.contains(forbidden),
743                    "Commands section mentions mutating command: {forbidden}\n---\n{section}",
744                );
745            }
746        }
747
748        #[test]
749        fn body_mentions_every_required_read_command() {
750            for required in [
751                "repograph context",
752                "repograph list",
753                "repograph status",
754                "repograph switch",
755                "repograph doctor",
756            ] {
757                assert!(
758                    BODY.contains(required),
759                    "BODY missing required command reference: {required}",
760                );
761            }
762        }
763
764        #[test]
765        fn body_warns_against_running_mutating_commands_automatically() {
766            // The "Things to avoid" appendix must remind the agent not to run
767            // mutating commands on its own initiative.
768            assert!(
769                BODY.contains("Do not run mutating commands"),
770                "BODY missing the don't-mutate guidance"
771            );
772        }
773
774        #[test]
775        fn consumer_body_delegates_mutation_to_setup_skill() {
776            // The don't-mutate guidance must hand off to the setup skill by
777            // name, not dead-end at "ask the user".
778            assert!(
779                BODY.contains("repograph-setup"),
780                "consumer BODY must name the repograph-setup skill for mutation"
781            );
782        }
783
784        #[test]
785        fn setup_body_covers_the_mutating_surface() {
786            for required in [
787                "repograph add",
788                "repograph edit",
789                "repograph remove",
790                "repograph workspace",
791            ] {
792                assert!(
793                    SETUP_BODY.contains(required),
794                    "SETUP_BODY missing mutating command reference: {required}",
795                );
796            }
797        }
798
799        #[test]
800        fn setup_body_instructs_a_confirm_before_write_workflow() {
801            // The plan → confirm → execute → verify discipline must be present.
802            for required in ["Plan", "Confirm", "Execute", "Verify"] {
803                assert!(
804                    SETUP_BODY.contains(required),
805                    "SETUP_BODY missing workflow step: {required}",
806                );
807            }
808        }
809
810        #[test]
811        fn setup_summary_is_distinct_and_names_mutation_triggers() {
812            assert_ne!(SETUP_SUMMARY, SUMMARY, "summaries must differ");
813            for trigger in ["register", "workspace", "update"] {
814                assert!(
815                    SETUP_SUMMARY.contains(trigger),
816                    "SETUP_SUMMARY missing trigger phrasing: {trigger}",
817                );
818            }
819        }
820    }
821
822    // ---- path matrix ----
823
824    mod path {
825        use super::*;
826
827        fn fixed_roots() -> (PathBuf, PathBuf) {
828            (PathBuf::from("/home/u"), PathBuf::from("/proj"))
829        }
830
831        #[test]
832        fn path_matrix_v1() {
833            let (home, cwd) = fixed_roots();
834            let cap = Capability::Consumer;
835            assert_eq!(
836                resolve_path(AgentId::ClaudeCode, cap, Scope::User, &home, &cwd),
837                PathBuf::from("/home/u/.claude/skills/repograph/SKILL.md"),
838            );
839            assert_eq!(
840                resolve_path(AgentId::ClaudeCode, cap, Scope::Project, &home, &cwd),
841                PathBuf::from("/proj/.claude/skills/repograph/SKILL.md"),
842            );
843            assert_eq!(
844                resolve_path(AgentId::AgentsMd, cap, Scope::Project, &home, &cwd),
845                PathBuf::from("/proj/AGENTS.md"),
846            );
847            assert_eq!(
848                resolve_path(AgentId::Cursor, cap, Scope::Project, &home, &cwd),
849                PathBuf::from("/proj/.cursor/rules/repograph.mdc"),
850            );
851            assert_eq!(
852                resolve_path(AgentId::Aider, cap, Scope::Project, &home, &cwd),
853                PathBuf::from("/proj/CONVENTIONS.md"),
854            );
855            assert_eq!(
856                resolve_path(AgentId::Windsurf, cap, Scope::User, &home, &cwd),
857                PathBuf::from("/home/u/.codeium/windsurf/memories/repograph.md"),
858            );
859            assert_eq!(
860                resolve_path(AgentId::Windsurf, cap, Scope::Project, &home, &cwd),
861                PathBuf::from("/proj/.windsurfrules"),
862            );
863        }
864
865        #[test]
866        fn setup_capability_resolves_to_discrete_paths() {
867            let (home, cwd) = fixed_roots();
868            let cap = Capability::Setup;
869            assert_eq!(
870                resolve_path(AgentId::ClaudeCode, cap, Scope::User, &home, &cwd),
871                PathBuf::from("/home/u/.claude/skills/repograph-setup/SKILL.md"),
872            );
873            assert_eq!(
874                resolve_path(AgentId::Cursor, cap, Scope::Project, &home, &cwd),
875                PathBuf::from("/proj/.cursor/rules/repograph-setup.mdc"),
876            );
877            // Flat-file agents are capability-independent: one shared path.
878            assert_eq!(
879                resolve_path(AgentId::AgentsMd, cap, Scope::Project, &home, &cwd),
880                resolve_path(
881                    AgentId::AgentsMd,
882                    Capability::Consumer,
883                    Scope::Project,
884                    &home,
885                    &cwd
886                ),
887            );
888        }
889
890        #[test]
891        fn project_only_agents_fall_through_under_user_scope() {
892            let (home, cwd) = fixed_roots();
893            let cap = Capability::Consumer;
894            for agent in [AgentId::AgentsMd, AgentId::Aider, AgentId::Cursor] {
895                assert_eq!(
896                    resolve_path(agent, cap, Scope::User, &home, &cwd),
897                    resolve_path(agent, cap, Scope::Project, &home, &cwd),
898                    "{agent:?} should fall through under Scope::User",
899                );
900            }
901        }
902
903        #[test]
904        fn has_artifact_writer_matches_matrix() {
905            assert!(!has_artifact_writer(AgentId::Copilot));
906            for agent in [
907                AgentId::ClaudeCode,
908                AgentId::AgentsMd,
909                AgentId::Cursor,
910                AgentId::Aider,
911                AgentId::Windsurf,
912            ] {
913                assert!(has_artifact_writer(agent), "{agent:?} should have a writer");
914            }
915        }
916
917        #[test]
918        fn scope_is_meaningful_returns_true_only_for_dual_scope_agents() {
919            assert!(scope_is_meaningful(AgentId::ClaudeCode));
920            assert!(scope_is_meaningful(AgentId::Windsurf));
921            assert!(!scope_is_meaningful(AgentId::AgentsMd));
922            assert!(!scope_is_meaningful(AgentId::Aider));
923            assert!(!scope_is_meaningful(AgentId::Cursor));
924            assert!(!scope_is_meaningful(AgentId::Copilot));
925        }
926    }
927
928    // ---- render ----
929
930    mod render {
931        use super::*;
932
933        #[test]
934        fn render_artifact_claude_code_has_yaml_frontmatter() {
935            let out = render_artifact(AgentId::ClaudeCode, Capability::Consumer);
936            assert!(out.starts_with("---\nname: repograph\n"), "got: {out:?}");
937            assert!(
938                out.contains(&format!("description: >-\n  {SUMMARY}\n")),
939                "summary rendered as a folded block scalar in frontmatter, got: {out:?}",
940            );
941            assert!(out.contains(DELIMITER_BEGIN));
942            assert!(out.contains(DELIMITER_END));
943            assert!(out.contains("repograph context"));
944        }
945
946        #[test]
947        fn render_artifact_cursor_has_mdc_frontmatter() {
948            let out = render_artifact(AgentId::Cursor, Capability::Consumer);
949            assert!(out.starts_with("---\ndescription:"), "got: {out:?}");
950            assert!(out.contains("globs: []"), "MDC frontmatter, got: {out:?}");
951            assert!(out.contains(DELIMITER_BEGIN));
952        }
953
954        #[test]
955        fn render_artifact_agents_md_has_no_frontmatter() {
956            let out = render_artifact(AgentId::AgentsMd, Capability::Consumer);
957            let expected_prefix = format!("{DELIMITER_BEGIN}\n# repograph");
958            assert!(out.starts_with(&expected_prefix), "got: {out:?}");
959            assert!(!out.starts_with("---"), "must not have YAML frontmatter");
960            // Flat-file agents inline both capabilities into one block.
961            assert!(
962                out.contains("# repograph-setup"),
963                "AGENTS.md must inline the setup body, got: {out:?}"
964            );
965        }
966
967        #[test]
968        fn render_artifact_aider_and_windsurf_have_no_frontmatter() {
969            for agent in [AgentId::Aider, AgentId::Windsurf] {
970                let out = render_artifact(agent, Capability::Consumer);
971                assert!(
972                    out.starts_with(DELIMITER_BEGIN),
973                    "{agent:?} should start with the begin-delimiter",
974                );
975                assert!(!out.starts_with("---"));
976            }
977        }
978
979        #[test]
980        fn render_artifact_is_deterministic() {
981            for agent in [
982                AgentId::ClaudeCode,
983                AgentId::Cursor,
984                AgentId::AgentsMd,
985                AgentId::Aider,
986                AgentId::Windsurf,
987            ] {
988                let a = render_artifact(agent, Capability::Consumer);
989                let b = render_artifact(agent, Capability::Consumer);
990                assert_eq!(a, b, "{agent:?} output must be byte-stable across calls");
991            }
992        }
993
994        #[test]
995        #[should_panic(expected = "copilot has no writer")]
996        fn render_artifact_copilot_panics() {
997            let _ = render_artifact(AgentId::Copilot, Capability::Consumer);
998        }
999    }
1000
1001    // ---- splice ----
1002
1003    mod splice {
1004        use super::*;
1005
1006        fn block(inner: &str) -> String {
1007            format!("{DELIMITER_BEGIN}\n{inner}\n{DELIMITER_END}\n")
1008        }
1009
1010        #[test]
1011        fn begin_marker_carries_the_current_version_stamp() {
1012            assert!(
1013                DELIMITER_BEGIN.contains(&format!("v{ARTIFACT_BODY_VERSION} ")),
1014                "DELIMITER_BEGIN must embed v{ARTIFACT_BODY_VERSION}, got {DELIMITER_BEGIN}"
1015            );
1016        }
1017
1018        #[test]
1019        fn fresh_write_emits_versioned_marker() {
1020            match splice_managed_section(None, "BODY") {
1021                SpliceOutcome::FreshWrite(s) => {
1022                    assert!(s.starts_with(DELIMITER_BEGIN), "fresh write stamps version");
1023                    assert_eq!(s, block("BODY"));
1024                }
1025                other => panic!("expected FreshWrite, got {other:?}"),
1026            }
1027        }
1028
1029        #[test]
1030        fn older_version_block_is_rewritten_in_place() {
1031            // An existing block stamped with an older version, surrounded by user
1032            // content, must be rewritten to the current marker — not duplicated.
1033            let existing = format!(
1034                "user-prefix\n<!-- repograph:begin v0 -->\nBODY\n{DELIMITER_END}\nuser-suffix\n"
1035            );
1036            match splice_managed_section(Some(&existing), "BODY") {
1037                SpliceOutcome::Replaced(s) => {
1038                    assert_eq!(s, format!("user-prefix\n{}user-suffix\n", block("BODY")));
1039                    assert_eq!(
1040                        s.matches("repograph:begin").count(),
1041                        1,
1042                        "no duplicate block"
1043                    );
1044                }
1045                other => panic!("expected Replaced for an older-version block, got {other:?}"),
1046            }
1047        }
1048
1049        #[test]
1050        fn installed_version_parses_the_stamp() {
1051            let installed = block("BODY");
1052            assert_eq!(installed_version(&installed), Some(ARTIFACT_BODY_VERSION));
1053            assert_eq!(installed_version("# no managed block here\n"), None);
1054            assert_eq!(
1055                installed_version("<!-- repograph:begin v7 -->\nx\n<!-- repograph:end -->\n"),
1056                Some(7)
1057            );
1058        }
1059
1060        #[test]
1061        fn fresh_write() {
1062            let outcome = splice_managed_section(None, "BODY");
1063            assert_eq!(outcome, SpliceOutcome::FreshWrite(block("BODY")));
1064        }
1065
1066        #[test]
1067        fn identical_returns_identical() {
1068            let existing = block("BODY");
1069            let outcome = splice_managed_section(Some(&existing), "BODY");
1070            assert_eq!(outcome, SpliceOutcome::Identical);
1071        }
1072
1073        #[test]
1074        fn differing_inner_rewrites_block() {
1075            let existing = block("OLD");
1076            let outcome = splice_managed_section(Some(&existing), "NEW");
1077            match outcome {
1078                SpliceOutcome::Replaced(s) => assert_eq!(s, block("NEW")),
1079                other => panic!("expected Replaced, got {other:?}"),
1080            }
1081        }
1082
1083        #[test]
1084        fn no_delimiters_appends() {
1085            let existing = "# My project\n\nCustom prose.\n";
1086            let outcome = splice_managed_section(Some(existing), "BODY");
1087            match outcome {
1088                SpliceOutcome::Appended(s) => {
1089                    let expected = format!("{existing}\n{}", block("BODY"));
1090                    assert_eq!(s, expected);
1091                }
1092                other => panic!("expected Appended, got {other:?}"),
1093            }
1094        }
1095
1096        #[test]
1097        fn user_content_outside_delimiters_preserved() {
1098            let existing = format!("pre\n{}post\n", block("old"));
1099            let outcome = splice_managed_section(Some(&existing), "new");
1100            match outcome {
1101                SpliceOutcome::Replaced(s) => {
1102                    assert_eq!(s, format!("pre\n{}post\n", block("new")));
1103                }
1104                other => panic!("expected Replaced, got {other:?}"),
1105            }
1106        }
1107
1108        #[test]
1109        fn empty_existing_file_appends_with_no_leading_newline() {
1110            let outcome = splice_managed_section(Some(""), "BODY");
1111            match outcome {
1112                SpliceOutcome::Appended(s) => assert_eq!(s, block("BODY")),
1113                other => panic!("expected Appended for empty file, got {other:?}"),
1114            }
1115        }
1116
1117        #[test]
1118        fn existing_without_trailing_newline_gets_separator() {
1119            // Existing file: no trailing newline → splice should add one
1120            // before the block.
1121            let existing = "no-newline";
1122            let outcome = splice_managed_section(Some(existing), "BODY");
1123            match outcome {
1124                SpliceOutcome::Appended(s) => {
1125                    assert_eq!(s, format!("no-newline\n\n{}", block("BODY")));
1126                }
1127                other => panic!("expected Appended, got {other:?}"),
1128            }
1129        }
1130    }
1131
1132    // ---- install_one ----
1133
1134    mod install_one {
1135        use super::*;
1136
1137        fn read(path: &Path) -> String {
1138            fs_err::read_to_string(path).unwrap()
1139        }
1140
1141        #[test]
1142        fn fresh_install_writes_file() {
1143            let dir = TempDir::new().unwrap();
1144            let path = dir.path().join("nested/AGENTS.md");
1145            let r = install_one(AgentId::AgentsMd, Capability::Consumer, &path, false);
1146            match r {
1147                ArtifactResult::Written { path: p, .. } => assert_eq!(p, path),
1148                other => panic!("expected Written, got {other:?}"),
1149            }
1150            assert_eq!(
1151                read(&path),
1152                render_artifact(AgentId::AgentsMd, Capability::Consumer)
1153            );
1154        }
1155
1156        #[test]
1157        fn re_run_with_identical_body_returns_unchanged() {
1158            let dir = TempDir::new().unwrap();
1159            let path = dir.path().join("AGENTS.md");
1160            let _ = install_one(AgentId::AgentsMd, Capability::Consumer, &path, false);
1161            let first = read(&path);
1162            let r = install_one(AgentId::AgentsMd, Capability::Consumer, &path, false);
1163            match r {
1164                ArtifactResult::Unchanged { .. } => (),
1165                other => panic!("expected Unchanged on re-run, got {other:?}"),
1166            }
1167            assert_eq!(
1168                read(&path),
1169                first,
1170                "file must be byte-stable across re-runs"
1171            );
1172        }
1173
1174        #[test]
1175        fn force_on_identical_returns_written() {
1176            let dir = TempDir::new().unwrap();
1177            let path = dir.path().join("AGENTS.md");
1178            let _ = install_one(AgentId::AgentsMd, Capability::Consumer, &path, false);
1179            let first = read(&path);
1180            let r = install_one(AgentId::AgentsMd, Capability::Consumer, &path, true);
1181            match r {
1182                ArtifactResult::Written { .. } => (),
1183                other => panic!("expected Written under force, got {other:?}"),
1184            }
1185            assert_eq!(
1186                read(&path),
1187                first,
1188                "force on identical content rewrites but byte content is the same"
1189            );
1190        }
1191
1192        #[test]
1193        fn force_overwrites_user_content() {
1194            let dir = TempDir::new().unwrap();
1195            let path = dir.path().join("AGENTS.md");
1196            fs_err::write(&path, "# My project\n\nCustom prose.\n").unwrap();
1197            let r = install_one(AgentId::AgentsMd, Capability::Consumer, &path, true);
1198            match r {
1199                ArtifactResult::Written { .. } => (),
1200                other => panic!("expected Written under force, got {other:?}"),
1201            }
1202            let after = read(&path);
1203            assert!(after.starts_with(DELIMITER_BEGIN), "force replaced content");
1204            assert!(
1205                !after.contains("Custom prose."),
1206                "force dropped user content"
1207            );
1208        }
1209
1210        #[test]
1211        fn fresh_install_for_whole_file_owner_includes_frontmatter() {
1212            let dir = TempDir::new().unwrap();
1213            let path = dir.path().join("nested/SKILL.md");
1214            let r = install_one(AgentId::ClaudeCode, Capability::Consumer, &path, false);
1215            assert!(matches!(r, ArtifactResult::Written { .. }));
1216            let body = read(&path);
1217            assert!(
1218                body.starts_with("---\nname: repograph\n"),
1219                "claude-code fresh install must include YAML frontmatter, got:\n{body}",
1220            );
1221            assert!(body.contains(DELIMITER_BEGIN));
1222            assert!(body.contains(DELIMITER_END));
1223        }
1224
1225        #[test]
1226        fn re_run_whole_file_owner_is_unchanged() {
1227            let dir = TempDir::new().unwrap();
1228            let path = dir.path().join("SKILL.md");
1229            let _ = install_one(AgentId::ClaudeCode, Capability::Consumer, &path, false);
1230            let first = read(&path);
1231            let r = install_one(AgentId::ClaudeCode, Capability::Consumer, &path, false);
1232            assert!(matches!(r, ArtifactResult::Unchanged { .. }));
1233            assert_eq!(read(&path), first);
1234        }
1235
1236        #[test]
1237        fn non_force_preserves_user_content_around_block() {
1238            let dir = TempDir::new().unwrap();
1239            let path = dir.path().join("AGENTS.md");
1240            fs_err::write(&path, "# My project\n\nCustom prose.\n").unwrap();
1241            let r = install_one(AgentId::AgentsMd, Capability::Consumer, &path, false);
1242            assert!(matches!(r, ArtifactResult::Written { .. }));
1243            let after = read(&path);
1244            assert!(after.starts_with("# My project\n\nCustom prose.\n"));
1245            assert!(after.contains(DELIMITER_BEGIN));
1246            assert!(after.contains(DELIMITER_END));
1247        }
1248    }
1249
1250    // ---- install_artifacts ----
1251
1252    mod install_artifacts {
1253        use super::*;
1254
1255        #[test]
1256        fn emits_per_capability_in_selection_then_capability_order() {
1257            let dir = TempDir::new().unwrap();
1258            let home = dir.path().join("home");
1259            let cwd = dir.path().join("proj");
1260            fs_err::create_dir_all(&home).unwrap();
1261            fs_err::create_dir_all(&cwd).unwrap();
1262            let agents = vec![AgentId::AgentsMd, AgentId::ClaudeCode];
1263            let results = install_artifacts(&agents, Scope::User, &home, &cwd, false);
1264            // Flat-file AgentsMd → 1 combined artifact; wholly-owned ClaudeCode
1265            // → 2 (Consumer then Setup). Selection order is preserved.
1266            assert_eq!(results.len(), 3);
1267            assert_eq!(results[0].agent(), AgentId::AgentsMd);
1268            assert_eq!(results[0].capability(), Some(Capability::Consumer));
1269            assert_eq!(results[1].agent(), AgentId::ClaudeCode);
1270            assert_eq!(results[1].capability(), Some(Capability::Consumer));
1271            assert_eq!(results[2].agent(), AgentId::ClaudeCode);
1272            assert_eq!(results[2].capability(), Some(Capability::Setup));
1273        }
1274
1275        #[test]
1276        fn wholly_owned_agent_writes_a_discrete_setup_file() {
1277            let dir = TempDir::new().unwrap();
1278            let home = dir.path().join("home");
1279            let cwd = dir.path().join("proj");
1280            fs_err::create_dir_all(&home).unwrap();
1281            fs_err::create_dir_all(&cwd).unwrap();
1282            let results =
1283                install_artifacts(&[AgentId::ClaudeCode], Scope::User, &home, &cwd, false);
1284            assert_eq!(results.len(), 2);
1285            // The setup skill lands at its own discrete path.
1286            let setup_path = home.join(".claude/skills/repograph-setup/SKILL.md");
1287            assert!(setup_path.exists(), "setup SKILL.md should be written");
1288            let body = fs_err::read_to_string(&setup_path).unwrap();
1289            assert!(
1290                body.starts_with("---\nname: repograph-setup\n"),
1291                "setup artifact carries its own frontmatter, got:\n{body}"
1292            );
1293        }
1294
1295        #[test]
1296        fn copilot_is_skipped() {
1297            let dir = TempDir::new().unwrap();
1298            let home = dir.path().join("home");
1299            let cwd = dir.path().join("proj");
1300            fs_err::create_dir_all(&home).unwrap();
1301            fs_err::create_dir_all(&cwd).unwrap();
1302            let results = install_artifacts(&[AgentId::Copilot], Scope::User, &home, &cwd, false);
1303            match &results[0] {
1304                ArtifactResult::Skipped { agent, reason } => {
1305                    assert_eq!(*agent, AgentId::Copilot);
1306                    assert_eq!(*reason, REASON_COPILOT_DEFERRED);
1307                }
1308                other => panic!("expected Skipped for Copilot, got {other:?}"),
1309            }
1310        }
1311
1312        #[test]
1313        fn per_agent_failure_does_not_abort_subsequent_agents() {
1314            // Strategy: make the AgentsMd target unwritable, then install
1315            // AgentsMd followed by ClaudeCode. Unix-only (skip on Windows).
1316            #[cfg(unix)]
1317            {
1318                use std::os::unix::fs::PermissionsExt;
1319                let dir = TempDir::new().unwrap();
1320                let home = dir.path().join("home");
1321                let cwd = dir.path().join("proj");
1322                fs_err::create_dir_all(&home).unwrap();
1323                fs_err::create_dir_all(&cwd).unwrap();
1324                // Create AGENTS.md as a directory to force the write to fail.
1325                fs_err::create_dir_all(cwd.join("AGENTS.md")).unwrap();
1326                let results = install_artifacts(
1327                    &[AgentId::AgentsMd, AgentId::ClaudeCode],
1328                    Scope::User,
1329                    &home,
1330                    &cwd,
1331                    false,
1332                );
1333                // AgentsMd → 1 (Failed); ClaudeCode → 2 (Consumer, Setup).
1334                assert_eq!(results.len(), 3);
1335                assert!(matches!(results[0], ArtifactResult::Failed { .. }));
1336                assert!(matches!(
1337                    results[1],
1338                    ArtifactResult::Written { .. } | ArtifactResult::Unchanged { .. }
1339                ));
1340                assert!(matches!(
1341                    results[2],
1342                    ArtifactResult::Written { .. } | ArtifactResult::Unchanged { .. }
1343                ));
1344                // Restore mode so TempDir can clean up.
1345                let mut perms = fs_err::metadata(cwd.join("AGENTS.md"))
1346                    .unwrap()
1347                    .permissions();
1348                perms.set_mode(0o755);
1349                fs_err::set_permissions(cwd.join("AGENTS.md"), perms).unwrap();
1350            }
1351        }
1352
1353        #[test]
1354        fn copilot_in_mixed_selection_does_not_block_others() {
1355            let dir = TempDir::new().unwrap();
1356            let home = dir.path().join("home");
1357            let cwd = dir.path().join("proj");
1358            fs_err::create_dir_all(&home).unwrap();
1359            fs_err::create_dir_all(&cwd).unwrap();
1360            let results = install_artifacts(
1361                &[AgentId::Copilot, AgentId::AgentsMd, AgentId::ClaudeCode],
1362                Scope::User,
1363                &home,
1364                &cwd,
1365                false,
1366            );
1367            // Copilot → 1 Skipped; AgentsMd → 1 Written; ClaudeCode → 2 Written.
1368            assert_eq!(results.len(), 4);
1369            assert!(matches!(results[0], ArtifactResult::Skipped { .. }));
1370            assert!(matches!(results[1], ArtifactResult::Written { .. }));
1371            assert!(matches!(results[2], ArtifactResult::Written { .. }));
1372            assert!(matches!(results[3], ArtifactResult::Written { .. }));
1373        }
1374    }
1375}