1use std::path::{Path, PathBuf};
42
43use serde::Serialize;
44
45use crate::agents::AgentId;
46use crate::error::RepographError;
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
60#[serde(rename_all = "lowercase")]
61pub enum Scope {
62 User,
64 Project,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
75#[serde(rename_all = "lowercase")]
76pub enum Capability {
77 Consumer,
79 Setup,
81}
82
83impl Capability {
84 #[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#[derive(Debug)]
99pub enum ArtifactResult {
100 Written {
102 agent: AgentId,
103 capability: Capability,
104 path: PathBuf,
105 },
106 Unchanged {
109 agent: AgentId,
110 capability: Capability,
111 path: PathBuf,
112 },
113 Skipped {
116 agent: AgentId,
117 reason: &'static str,
118 },
119 Failed {
122 agent: AgentId,
123 capability: Capability,
124 error: RepographError,
125 },
126}
127
128impl ArtifactResult {
129 #[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 #[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
153pub const REASON_COPILOT_DEFERRED: &str = "no writer in v1";
156
157pub const ARTIFACT_BODY_VERSION: u32 = 1;
164
165pub const DELIMITER_BEGIN_PREFIX: &str = "<!-- repograph:begin";
169
170pub const DELIMITER_BEGIN: &str = "<!-- repograph:begin v1 -->";
173
174pub const DELIMITER_END: &str = "<!-- repograph:end -->";
176
177#[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 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
195pub 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
211pub 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
220pub const BODY: &str = include_str!("agent_artifact_body.md");
230
231pub const SETUP_BODY: &str = include_str!("agent_artifact_setup_body.md");
237
238#[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#[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#[must_use]
259pub const fn writer_summary() -> &'static str {
260 SUMMARY
261}
262
263#[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#[must_use]
283pub const fn has_artifact_writer(agent: AgentId) -> bool {
284 !matches!(agent, AgentId::Copilot)
285}
286
287#[must_use]
299pub const fn wholly_owned_file(agent: AgentId) -> bool {
300 matches!(agent, AgentId::ClaudeCode | AgentId::Cursor)
301}
302
303#[must_use]
314pub fn resolve_path(
315 agent: AgentId,
316 capability: Capability,
317 scope: Scope,
318 home: &Path,
319 cwd: &Path,
320) -> PathBuf {
321 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 unreachable!("resolve_path: copilot has no writer; check has_artifact_writer first")
344 }
345 }
346}
347
348#[must_use]
355pub fn scope_is_meaningful(agent: AgentId) -> bool {
356 if !has_artifact_writer(agent) {
357 return false;
358 }
359 let home = Path::new("/__home__");
363 let cwd = Path::new("/__cwd__");
364 resolve_path(agent, Capability::Consumer, Scope::User, home, cwd)
366 != resolve_path(agent, Capability::Consumer, Scope::Project, home, cwd)
367}
368
369#[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 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#[derive(Debug, PartialEq, Eq)]
417pub enum SpliceOutcome {
418 Identical,
421 Replaced(String),
425 Appended(String),
429 FreshWrite(String),
432}
433
434#[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 if let Some(begin_idx) = existing.find(DELIMITER_BEGIN_PREFIX) {
455 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 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 let inner_end = inner_start + end_rel;
470 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 if inner == new_block_body && matched_begin == DELIMITER_BEGIN {
481 return SpliceOutcome::Identical;
482 }
483 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 }
501
502 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#[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 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 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
632fn 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#[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 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 mod body {
716 use super::*;
717
718 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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}