use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::agents::AgentId;
use crate::error::RepographError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Scope {
User,
Project,
}
#[derive(Debug)]
pub enum ArtifactResult {
Written { agent: AgentId, path: PathBuf },
Unchanged { agent: AgentId, path: PathBuf },
Skipped {
agent: AgentId,
reason: &'static str,
},
Failed {
agent: AgentId,
error: RepographError,
},
}
impl ArtifactResult {
#[must_use]
pub const fn agent(&self) -> AgentId {
match self {
Self::Written { agent, .. }
| Self::Unchanged { agent, .. }
| Self::Skipped { agent, .. }
| Self::Failed { agent, .. } => *agent,
}
}
}
pub const REASON_COPILOT_DEFERRED: &str = "no writer in v1";
pub const DELIMITER_BEGIN: &str = "<!-- repograph:begin -->";
pub const DELIMITER_END: &str = "<!-- repograph:end -->";
pub const SUMMARY: &str = "Cross-repo context for AI agents";
pub const BODY: &str = include_str!("agent_artifact_body.md");
#[must_use]
pub const fn writer_summary() -> &'static str {
SUMMARY
}
#[must_use]
pub const fn has_artifact_writer(agent: AgentId) -> bool {
!matches!(agent, AgentId::Copilot)
}
#[must_use]
pub const fn wholly_owned_file(agent: AgentId) -> bool {
matches!(agent, AgentId::ClaudeCode | AgentId::Cursor)
}
#[must_use]
pub fn resolve_path(agent: AgentId, scope: Scope, home: &Path, cwd: &Path) -> PathBuf {
match agent {
AgentId::ClaudeCode => match scope {
Scope::User => home.join(".claude/skills/repograph/SKILL.md"),
Scope::Project => cwd.join(".claude/skills/repograph/SKILL.md"),
},
AgentId::AgentsMd | AgentId::Aider | AgentId::Cursor => {
match agent {
AgentId::AgentsMd => cwd.join("AGENTS.md"),
AgentId::Aider => cwd.join("CONVENTIONS.md"),
AgentId::Cursor => cwd.join(".cursor/rules/repograph.mdc"),
_ => unreachable!(),
}
}
AgentId::Windsurf => match scope {
Scope::User => home.join(".codeium/windsurf/memories/repograph.md"),
Scope::Project => cwd.join(".windsurfrules"),
},
AgentId::Copilot => {
unreachable!("resolve_path: copilot has no writer; check has_artifact_writer first")
}
}
}
#[must_use]
pub fn scope_is_meaningful(agent: AgentId) -> bool {
if !has_artifact_writer(agent) {
return false;
}
let home = Path::new("/__home__");
let cwd = Path::new("/__cwd__");
resolve_path(agent, Scope::User, home, cwd) != resolve_path(agent, Scope::Project, home, cwd)
}
#[must_use]
pub fn render_artifact(agent: AgentId) -> String {
match agent {
AgentId::ClaudeCode => format!(
"---\nname: repograph\ndescription: {summary}\n---\n\n\
{begin}\n{body}\n{end}\n",
summary = writer_summary(),
begin = DELIMITER_BEGIN,
body = BODY,
end = DELIMITER_END,
),
AgentId::Cursor => format!(
"---\ndescription: {summary}\nglobs: []\n---\n\n\
{begin}\n{body}\n{end}\n",
summary = writer_summary(),
begin = DELIMITER_BEGIN,
body = BODY,
end = DELIMITER_END,
),
AgentId::AgentsMd | AgentId::Aider | AgentId::Windsurf => {
format!("{DELIMITER_BEGIN}\n# repograph\n\n{BODY}\n{DELIMITER_END}\n")
}
AgentId::Copilot => {
unreachable!("render_artifact: copilot has no writer; check has_artifact_writer first")
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum SpliceOutcome {
Identical,
Replaced(String),
Appended(String),
FreshWrite(String),
}
#[must_use]
pub fn splice_managed_section(existing: Option<&str>, new_block_body: &str) -> SpliceOutcome {
let full_block = format!("{DELIMITER_BEGIN}\n{new_block_body}\n{DELIMITER_END}\n");
let Some(existing) = existing else {
return SpliceOutcome::FreshWrite(full_block);
};
if let Some(begin_idx) = existing.find(DELIMITER_BEGIN) {
let after_begin = begin_idx + DELIMITER_BEGIN.len();
let inner_start = if existing[after_begin..].starts_with('\n') {
after_begin + 1
} else {
after_begin
};
if let Some(end_rel) = existing[inner_start..].find(DELIMITER_END) {
let inner_end = inner_start + end_rel;
let inner_with_trailing_nl = &existing[inner_start..inner_end];
let inner = inner_with_trailing_nl
.strip_suffix('\n')
.unwrap_or(inner_with_trailing_nl);
if inner == new_block_body {
return SpliceOutcome::Identical;
}
let suffix_start = inner_end + DELIMITER_END.len();
let mut out = String::with_capacity(existing.len() + new_block_body.len());
out.push_str(&existing[..begin_idx]);
out.push_str(DELIMITER_BEGIN);
out.push('\n');
out.push_str(new_block_body);
out.push('\n');
out.push_str(DELIMITER_END);
out.push_str(&existing[suffix_start..]);
return SpliceOutcome::Replaced(out);
}
}
let needs_sep = !existing.is_empty() && !existing.ends_with('\n');
let mut out = String::with_capacity(existing.len() + full_block.len() + usize::from(needs_sep));
out.push_str(existing);
if !existing.is_empty() {
if needs_sep {
out.push('\n');
}
out.push('\n');
}
out.push_str(&full_block);
SpliceOutcome::Appended(out)
}
#[must_use]
pub fn install_one(agent: AgentId, path: &Path, force: bool) -> ArtifactResult {
debug_assert!(
has_artifact_writer(agent),
"install_one called for an agent without a writer: {agent:?}"
);
let full_artifact = render_artifact(agent);
let existing = if force {
None
} else {
match fs_err::read_to_string(path) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
return ArtifactResult::Failed {
agent,
error: RepographError::Io(e),
};
}
}
};
let to_write = if wholly_owned_file(agent) {
if let Some(ref existing_body) = existing {
if existing_body == &full_artifact && !force {
return ArtifactResult::Unchanged {
agent,
path: path.to_path_buf(),
};
}
}
full_artifact
} else {
let new_block_body = rendered_inner_body(&full_artifact);
let outcome = splice_managed_section(existing.as_deref(), &new_block_body);
match outcome {
SpliceOutcome::Identical if !force => {
return ArtifactResult::Unchanged {
agent,
path: path.to_path_buf(),
};
}
SpliceOutcome::Identical => {
format!("{DELIMITER_BEGIN}\n{new_block_body}\n{DELIMITER_END}\n")
}
SpliceOutcome::Replaced(s)
| SpliceOutcome::Appended(s)
| SpliceOutcome::FreshWrite(s) => s,
}
};
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
if let Err(e) = fs_err::create_dir_all(parent) {
return ArtifactResult::Failed {
agent,
error: RepographError::Io(e),
};
}
}
}
match fs_err::write(path, to_write) {
Ok(()) => ArtifactResult::Written {
agent,
path: path.to_path_buf(),
},
Err(e) => ArtifactResult::Failed {
agent,
error: RepographError::Io(e),
},
}
}
fn rendered_inner_body(rendered: &str) -> String {
let Some(begin_idx) = rendered.find(DELIMITER_BEGIN) else {
return rendered.to_string();
};
let after_begin = begin_idx + DELIMITER_BEGIN.len();
let inner_start = if rendered[after_begin..].starts_with('\n') {
after_begin + 1
} else {
after_begin
};
let Some(end_idx_rel) = rendered[inner_start..].find(DELIMITER_END) else {
return rendered.to_string();
};
let inner = &rendered[inner_start..inner_start + end_idx_rel];
inner.strip_suffix('\n').unwrap_or(inner).to_string()
}
#[must_use]
pub fn install_artifacts(
agents: &[AgentId],
scope: Scope,
home: &Path,
cwd: &Path,
force: bool,
) -> Vec<ArtifactResult> {
let mut results = Vec::with_capacity(agents.len());
for &agent in agents {
if !has_artifact_writer(agent) {
results.push(ArtifactResult::Skipped {
agent,
reason: REASON_COPILOT_DEFERRED,
});
continue;
}
let path = resolve_path(agent, scope, home, cwd);
results.push(install_one(agent, &path, force));
}
results
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use tempfile::TempDir;
mod body {
use super::*;
fn commands_section() -> &'static str {
let start = BODY
.find("## Commands")
.expect("body has a Commands section");
let after = start + "## Commands".len();
let end_rel = BODY[after..].find("\n## ").unwrap_or(BODY.len() - after);
&BODY[start..after + end_rel]
}
#[test]
fn body_does_not_reference_mutating_commands_in_commands_section() {
let section = commands_section();
for forbidden in [
"repograph add",
"repograph remove",
"repograph workspace",
"repograph init",
] {
assert!(
!section.contains(forbidden),
"Commands section mentions mutating command: {forbidden}\n---\n{section}",
);
}
}
#[test]
fn body_mentions_every_required_read_command() {
for required in [
"repograph context",
"repograph list",
"repograph status",
"repograph switch",
"repograph doctor",
] {
assert!(
BODY.contains(required),
"BODY missing required command reference: {required}",
);
}
}
#[test]
fn body_warns_against_running_mutating_commands_automatically() {
assert!(
BODY.contains("Do not run mutating commands"),
"BODY missing the don't-mutate guidance"
);
}
}
mod path {
use super::*;
fn fixed_roots() -> (PathBuf, PathBuf) {
(PathBuf::from("/home/u"), PathBuf::from("/proj"))
}
#[test]
fn path_matrix_v1() {
let (home, cwd) = fixed_roots();
assert_eq!(
resolve_path(AgentId::ClaudeCode, Scope::User, &home, &cwd),
PathBuf::from("/home/u/.claude/skills/repograph/SKILL.md"),
);
assert_eq!(
resolve_path(AgentId::ClaudeCode, Scope::Project, &home, &cwd),
PathBuf::from("/proj/.claude/skills/repograph/SKILL.md"),
);
assert_eq!(
resolve_path(AgentId::AgentsMd, Scope::Project, &home, &cwd),
PathBuf::from("/proj/AGENTS.md"),
);
assert_eq!(
resolve_path(AgentId::Cursor, Scope::Project, &home, &cwd),
PathBuf::from("/proj/.cursor/rules/repograph.mdc"),
);
assert_eq!(
resolve_path(AgentId::Aider, Scope::Project, &home, &cwd),
PathBuf::from("/proj/CONVENTIONS.md"),
);
assert_eq!(
resolve_path(AgentId::Windsurf, Scope::User, &home, &cwd),
PathBuf::from("/home/u/.codeium/windsurf/memories/repograph.md"),
);
assert_eq!(
resolve_path(AgentId::Windsurf, Scope::Project, &home, &cwd),
PathBuf::from("/proj/.windsurfrules"),
);
}
#[test]
fn project_only_agents_fall_through_under_user_scope() {
let (home, cwd) = fixed_roots();
for agent in [AgentId::AgentsMd, AgentId::Aider, AgentId::Cursor] {
assert_eq!(
resolve_path(agent, Scope::User, &home, &cwd),
resolve_path(agent, Scope::Project, &home, &cwd),
"{agent:?} should fall through under Scope::User",
);
}
}
#[test]
fn has_artifact_writer_matches_matrix() {
assert!(!has_artifact_writer(AgentId::Copilot));
for agent in [
AgentId::ClaudeCode,
AgentId::AgentsMd,
AgentId::Cursor,
AgentId::Aider,
AgentId::Windsurf,
] {
assert!(has_artifact_writer(agent), "{agent:?} should have a writer");
}
}
#[test]
fn scope_is_meaningful_returns_true_only_for_dual_scope_agents() {
assert!(scope_is_meaningful(AgentId::ClaudeCode));
assert!(scope_is_meaningful(AgentId::Windsurf));
assert!(!scope_is_meaningful(AgentId::AgentsMd));
assert!(!scope_is_meaningful(AgentId::Aider));
assert!(!scope_is_meaningful(AgentId::Cursor));
assert!(!scope_is_meaningful(AgentId::Copilot));
}
}
mod render {
use super::*;
#[test]
fn render_artifact_claude_code_has_yaml_frontmatter() {
let out = render_artifact(AgentId::ClaudeCode);
assert!(out.starts_with("---\nname: repograph\n"), "got: {out:?}");
assert!(
out.contains(&format!("description: {SUMMARY}\n")),
"summary in frontmatter, got: {out:?}",
);
assert!(out.contains(DELIMITER_BEGIN));
assert!(out.contains(DELIMITER_END));
assert!(out.contains("repograph context"));
}
#[test]
fn render_artifact_cursor_has_mdc_frontmatter() {
let out = render_artifact(AgentId::Cursor);
assert!(out.starts_with("---\ndescription:"), "got: {out:?}");
assert!(out.contains("globs: []"), "MDC frontmatter, got: {out:?}");
assert!(out.contains(DELIMITER_BEGIN));
}
#[test]
fn render_artifact_agents_md_has_no_frontmatter() {
let out = render_artifact(AgentId::AgentsMd);
let expected_prefix = format!("{DELIMITER_BEGIN}\n# repograph");
assert!(out.starts_with(&expected_prefix), "got: {out:?}");
assert!(!out.starts_with("---"), "must not have YAML frontmatter");
}
#[test]
fn render_artifact_aider_and_windsurf_have_no_frontmatter() {
for agent in [AgentId::Aider, AgentId::Windsurf] {
let out = render_artifact(agent);
assert!(
out.starts_with(DELIMITER_BEGIN),
"{agent:?} should start with the begin-delimiter",
);
assert!(!out.starts_with("---"));
}
}
#[test]
fn render_artifact_is_deterministic() {
for agent in [
AgentId::ClaudeCode,
AgentId::Cursor,
AgentId::AgentsMd,
AgentId::Aider,
AgentId::Windsurf,
] {
let a = render_artifact(agent);
let b = render_artifact(agent);
assert_eq!(a, b, "{agent:?} output must be byte-stable across calls");
}
}
#[test]
#[should_panic(expected = "copilot has no writer")]
fn render_artifact_copilot_panics() {
let _ = render_artifact(AgentId::Copilot);
}
}
mod splice {
use super::*;
fn block(inner: &str) -> String {
format!("{DELIMITER_BEGIN}\n{inner}\n{DELIMITER_END}\n")
}
#[test]
fn fresh_write() {
let outcome = splice_managed_section(None, "BODY");
assert_eq!(outcome, SpliceOutcome::FreshWrite(block("BODY")));
}
#[test]
fn identical_returns_identical() {
let existing = block("BODY");
let outcome = splice_managed_section(Some(&existing), "BODY");
assert_eq!(outcome, SpliceOutcome::Identical);
}
#[test]
fn differing_inner_rewrites_block() {
let existing = block("OLD");
let outcome = splice_managed_section(Some(&existing), "NEW");
match outcome {
SpliceOutcome::Replaced(s) => assert_eq!(s, block("NEW")),
other => panic!("expected Replaced, got {other:?}"),
}
}
#[test]
fn no_delimiters_appends() {
let existing = "# My project\n\nCustom prose.\n";
let outcome = splice_managed_section(Some(existing), "BODY");
match outcome {
SpliceOutcome::Appended(s) => {
let expected = format!("{existing}\n{}", block("BODY"));
assert_eq!(s, expected);
}
other => panic!("expected Appended, got {other:?}"),
}
}
#[test]
fn user_content_outside_delimiters_preserved() {
let existing = format!("pre\n{}post\n", block("old"));
let outcome = splice_managed_section(Some(&existing), "new");
match outcome {
SpliceOutcome::Replaced(s) => {
assert_eq!(s, format!("pre\n{}post\n", block("new")));
}
other => panic!("expected Replaced, got {other:?}"),
}
}
#[test]
fn empty_existing_file_appends_with_no_leading_newline() {
let outcome = splice_managed_section(Some(""), "BODY");
match outcome {
SpliceOutcome::Appended(s) => assert_eq!(s, block("BODY")),
other => panic!("expected Appended for empty file, got {other:?}"),
}
}
#[test]
fn existing_without_trailing_newline_gets_separator() {
let existing = "no-newline";
let outcome = splice_managed_section(Some(existing), "BODY");
match outcome {
SpliceOutcome::Appended(s) => {
assert_eq!(s, format!("no-newline\n\n{}", block("BODY")));
}
other => panic!("expected Appended, got {other:?}"),
}
}
}
mod install_one {
use super::*;
fn read(path: &Path) -> String {
fs_err::read_to_string(path).unwrap()
}
#[test]
fn fresh_install_writes_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nested/AGENTS.md");
let r = install_one(AgentId::AgentsMd, &path, false);
match r {
ArtifactResult::Written { path: p, .. } => assert_eq!(p, path),
other => panic!("expected Written, got {other:?}"),
}
assert_eq!(read(&path), render_artifact(AgentId::AgentsMd));
}
#[test]
fn re_run_with_identical_body_returns_unchanged() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("AGENTS.md");
let _ = install_one(AgentId::AgentsMd, &path, false);
let first = read(&path);
let r = install_one(AgentId::AgentsMd, &path, false);
match r {
ArtifactResult::Unchanged { .. } => (),
other => panic!("expected Unchanged on re-run, got {other:?}"),
}
assert_eq!(
read(&path),
first,
"file must be byte-stable across re-runs"
);
}
#[test]
fn force_on_identical_returns_written() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("AGENTS.md");
let _ = install_one(AgentId::AgentsMd, &path, false);
let first = read(&path);
let r = install_one(AgentId::AgentsMd, &path, true);
match r {
ArtifactResult::Written { .. } => (),
other => panic!("expected Written under force, got {other:?}"),
}
assert_eq!(
read(&path),
first,
"force on identical content rewrites but byte content is the same"
);
}
#[test]
fn force_overwrites_user_content() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("AGENTS.md");
fs_err::write(&path, "# My project\n\nCustom prose.\n").unwrap();
let r = install_one(AgentId::AgentsMd, &path, true);
match r {
ArtifactResult::Written { .. } => (),
other => panic!("expected Written under force, got {other:?}"),
}
let after = read(&path);
assert!(after.starts_with(DELIMITER_BEGIN), "force replaced content");
assert!(
!after.contains("Custom prose."),
"force dropped user content"
);
}
#[test]
fn fresh_install_for_whole_file_owner_includes_frontmatter() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nested/SKILL.md");
let r = install_one(AgentId::ClaudeCode, &path, false);
assert!(matches!(r, ArtifactResult::Written { .. }));
let body = read(&path);
assert!(
body.starts_with("---\nname: repograph\n"),
"claude-code fresh install must include YAML frontmatter, got:\n{body}",
);
assert!(body.contains(DELIMITER_BEGIN));
assert!(body.contains(DELIMITER_END));
}
#[test]
fn re_run_whole_file_owner_is_unchanged() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("SKILL.md");
let _ = install_one(AgentId::ClaudeCode, &path, false);
let first = read(&path);
let r = install_one(AgentId::ClaudeCode, &path, false);
assert!(matches!(r, ArtifactResult::Unchanged { .. }));
assert_eq!(read(&path), first);
}
#[test]
fn non_force_preserves_user_content_around_block() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("AGENTS.md");
fs_err::write(&path, "# My project\n\nCustom prose.\n").unwrap();
let r = install_one(AgentId::AgentsMd, &path, false);
assert!(matches!(r, ArtifactResult::Written { .. }));
let after = read(&path);
assert!(after.starts_with("# My project\n\nCustom prose.\n"));
assert!(after.contains(DELIMITER_BEGIN));
assert!(after.contains(DELIMITER_END));
}
}
mod install_artifacts {
use super::*;
#[test]
fn returns_one_result_per_agent_in_order() {
let dir = TempDir::new().unwrap();
let home = dir.path().join("home");
let cwd = dir.path().join("proj");
fs_err::create_dir_all(&home).unwrap();
fs_err::create_dir_all(&cwd).unwrap();
let agents = vec![AgentId::AgentsMd, AgentId::ClaudeCode];
let results = install_artifacts(&agents, Scope::User, &home, &cwd, false);
assert_eq!(results.len(), 2);
assert_eq!(results[0].agent(), AgentId::AgentsMd);
assert_eq!(results[1].agent(), AgentId::ClaudeCode);
}
#[test]
fn copilot_is_skipped() {
let dir = TempDir::new().unwrap();
let home = dir.path().join("home");
let cwd = dir.path().join("proj");
fs_err::create_dir_all(&home).unwrap();
fs_err::create_dir_all(&cwd).unwrap();
let results = install_artifacts(&[AgentId::Copilot], Scope::User, &home, &cwd, false);
match &results[0] {
ArtifactResult::Skipped { agent, reason } => {
assert_eq!(*agent, AgentId::Copilot);
assert_eq!(*reason, REASON_COPILOT_DEFERRED);
}
other => panic!("expected Skipped for Copilot, got {other:?}"),
}
}
#[test]
fn per_agent_failure_does_not_abort_subsequent_agents() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let home = dir.path().join("home");
let cwd = dir.path().join("proj");
fs_err::create_dir_all(&home).unwrap();
fs_err::create_dir_all(&cwd).unwrap();
fs_err::create_dir_all(cwd.join("AGENTS.md")).unwrap();
let results = install_artifacts(
&[AgentId::AgentsMd, AgentId::ClaudeCode],
Scope::User,
&home,
&cwd,
false,
);
assert_eq!(results.len(), 2);
assert!(matches!(results[0], ArtifactResult::Failed { .. }));
assert!(matches!(
results[1],
ArtifactResult::Written { .. } | ArtifactResult::Unchanged { .. }
));
let mut perms = fs_err::metadata(cwd.join("AGENTS.md"))
.unwrap()
.permissions();
perms.set_mode(0o755);
fs_err::set_permissions(cwd.join("AGENTS.md"), perms).unwrap();
}
}
#[test]
fn copilot_in_mixed_selection_does_not_block_others() {
let dir = TempDir::new().unwrap();
let home = dir.path().join("home");
let cwd = dir.path().join("proj");
fs_err::create_dir_all(&home).unwrap();
fs_err::create_dir_all(&cwd).unwrap();
let results = install_artifacts(
&[AgentId::Copilot, AgentId::AgentsMd, AgentId::ClaudeCode],
Scope::User,
&home,
&cwd,
false,
);
assert_eq!(results.len(), 3);
assert!(matches!(results[0], ArtifactResult::Skipped { .. }));
assert!(matches!(results[1], ArtifactResult::Written { .. }));
assert!(matches!(results[2], ArtifactResult::Written { .. }));
}
}
}