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