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)]
73pub enum ArtifactResult {
74 Written { agent: AgentId, path: PathBuf },
76 Unchanged { agent: AgentId, path: PathBuf },
79 Skipped {
82 agent: AgentId,
83 reason: &'static str,
84 },
85 Failed {
88 agent: AgentId,
89 error: RepographError,
90 },
91}
92
93impl ArtifactResult {
94 #[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
106pub const REASON_COPILOT_DEFERRED: &str = "no writer in v1";
109
110pub const DELIMITER_BEGIN: &str = "<!-- repograph:begin -->";
112
113pub const DELIMITER_END: &str = "<!-- repograph:end -->";
115
116pub const SUMMARY: &str = "Use when the user refers to one of their own git projects/repos by name and wants to act on it: switch / open / \"cd into\" a repo (\"switch to taverne\", \"open the api repo\", \"cd into <name>\"), list or compare their registered repos, check cross-repo git status (\"what's dirty\", \"what's in flight across my projects\", \"which repos have uncommitted changes\"), or pull a repo's CLAUDE.md / AGENTS.md content into the conversation. Maintains a local registry of git repositories and exposes their paths, branches, status, and agent docs as structured JSON. ALWAYS prefer this over manual `find` / `git` to resolve a named project to a filesystem path. Use it for which-repo / across-repos questions, not for the current directory's own `git status` (use plain `git` for that).";
131
132pub const BODY: &str = include_str!("agent_artifact_body.md");
142
143#[must_use]
146pub const fn writer_summary() -> &'static str {
147 SUMMARY
148}
149
150#[must_use]
156pub const fn has_artifact_writer(agent: AgentId) -> bool {
157 !matches!(agent, AgentId::Copilot)
158}
159
160#[must_use]
172pub const fn wholly_owned_file(agent: AgentId) -> bool {
173 matches!(agent, AgentId::ClaudeCode | AgentId::Cursor)
174}
175
176#[must_use]
187pub fn resolve_path(agent: AgentId, scope: Scope, home: &Path, cwd: &Path) -> PathBuf {
188 match agent {
189 AgentId::ClaudeCode => match scope {
190 Scope::User => home.join(".claude/skills/repograph/SKILL.md"),
191 Scope::Project => cwd.join(".claude/skills/repograph/SKILL.md"),
192 },
193 AgentId::AgentsMd | AgentId::Aider | AgentId::Cursor => {
194 match agent {
196 AgentId::AgentsMd => cwd.join("AGENTS.md"),
197 AgentId::Aider => cwd.join("CONVENTIONS.md"),
198 AgentId::Cursor => cwd.join(".cursor/rules/repograph.mdc"),
199 _ => unreachable!(),
200 }
201 }
202 AgentId::Windsurf => match scope {
203 Scope::User => home.join(".codeium/windsurf/memories/repograph.md"),
204 Scope::Project => cwd.join(".windsurfrules"),
205 },
206 AgentId::Copilot => {
207 unreachable!("resolve_path: copilot has no writer; check has_artifact_writer first")
210 }
211 }
212}
213
214#[must_use]
221pub fn scope_is_meaningful(agent: AgentId) -> bool {
222 if !has_artifact_writer(agent) {
223 return false;
224 }
225 let home = Path::new("/__home__");
229 let cwd = Path::new("/__cwd__");
230 resolve_path(agent, Scope::User, home, cwd) != resolve_path(agent, Scope::Project, home, cwd)
231}
232
233#[must_use]
245pub fn render_artifact(agent: AgentId) -> String {
246 match agent {
247 AgentId::ClaudeCode => format!(
248 "---\nname: repograph\ndescription: >-\n {summary}\n---\n\n\
249 {begin}\n{body}\n{end}\n",
250 summary = writer_summary(),
251 begin = DELIMITER_BEGIN,
252 body = BODY,
253 end = DELIMITER_END,
254 ),
255 AgentId::Cursor => format!(
256 "---\ndescription: >-\n {summary}\nglobs: []\n---\n\n\
257 {begin}\n{body}\n{end}\n",
258 summary = writer_summary(),
259 begin = DELIMITER_BEGIN,
260 body = BODY,
261 end = DELIMITER_END,
262 ),
263 AgentId::AgentsMd | AgentId::Aider | AgentId::Windsurf => {
264 format!("{DELIMITER_BEGIN}\n# repograph\n\n{BODY}\n{DELIMITER_END}\n")
265 }
266 AgentId::Copilot => {
267 unreachable!("render_artifact: copilot has no writer; check has_artifact_writer first")
268 }
269 }
270}
271
272#[derive(Debug, PartialEq, Eq)]
275pub enum SpliceOutcome {
276 Identical,
279 Replaced(String),
283 Appended(String),
287 FreshWrite(String),
290}
291
292#[must_use]
303pub fn splice_managed_section(existing: Option<&str>, new_block_body: &str) -> SpliceOutcome {
304 let full_block = format!("{DELIMITER_BEGIN}\n{new_block_body}\n{DELIMITER_END}\n");
305 let Some(existing) = existing else {
306 return SpliceOutcome::FreshWrite(full_block);
307 };
308
309 if let Some(begin_idx) = existing.find(DELIMITER_BEGIN) {
311 let after_begin = begin_idx + DELIMITER_BEGIN.len();
313 let inner_start = if existing[after_begin..].starts_with('\n') {
317 after_begin + 1
318 } else {
319 after_begin
320 };
321 if let Some(end_rel) = existing[inner_start..].find(DELIMITER_END) {
322 let inner_end = inner_start + end_rel;
324 let inner_with_trailing_nl = &existing[inner_start..inner_end];
329 let inner = inner_with_trailing_nl
330 .strip_suffix('\n')
331 .unwrap_or(inner_with_trailing_nl);
332 if inner == new_block_body {
333 return SpliceOutcome::Identical;
334 }
335 let suffix_start = inner_end + DELIMITER_END.len();
339 let mut out = String::with_capacity(existing.len() + new_block_body.len());
340 out.push_str(&existing[..begin_idx]);
341 out.push_str(DELIMITER_BEGIN);
342 out.push('\n');
343 out.push_str(new_block_body);
344 out.push('\n');
345 out.push_str(DELIMITER_END);
346 out.push_str(&existing[suffix_start..]);
347 return SpliceOutcome::Replaced(out);
348 }
349 }
352
353 let needs_sep = !existing.is_empty() && !existing.ends_with('\n');
355 let mut out = String::with_capacity(existing.len() + full_block.len() + usize::from(needs_sep));
356 out.push_str(existing);
357 if !existing.is_empty() {
358 if needs_sep {
359 out.push('\n');
360 }
361 out.push('\n');
362 }
363 out.push_str(&full_block);
364 SpliceOutcome::Appended(out)
365}
366
367#[must_use]
385pub fn install_one(agent: AgentId, path: &Path, force: bool) -> ArtifactResult {
386 debug_assert!(
387 has_artifact_writer(agent),
388 "install_one called for an agent without a writer: {agent:?}"
389 );
390
391 let full_artifact = render_artifact(agent);
392
393 let existing = if force {
394 None
395 } else {
396 match fs_err::read_to_string(path) {
397 Ok(s) => Some(s),
398 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
399 Err(e) => {
400 return ArtifactResult::Failed {
401 agent,
402 error: RepographError::Io(e),
403 };
404 }
405 }
406 };
407
408 let to_write = if wholly_owned_file(agent) {
419 if let Some(ref existing_body) = existing {
420 if existing_body == &full_artifact && !force {
421 return ArtifactResult::Unchanged {
422 agent,
423 path: path.to_path_buf(),
424 };
425 }
426 }
427 full_artifact
428 } else {
429 let new_block_body = rendered_inner_body(&full_artifact);
430 let outcome = splice_managed_section(existing.as_deref(), &new_block_body);
431 match outcome {
432 SpliceOutcome::Identical if !force => {
433 return ArtifactResult::Unchanged {
434 agent,
435 path: path.to_path_buf(),
436 };
437 }
438 SpliceOutcome::Identical => {
439 format!("{DELIMITER_BEGIN}\n{new_block_body}\n{DELIMITER_END}\n")
442 }
443 SpliceOutcome::Replaced(s)
444 | SpliceOutcome::Appended(s)
445 | SpliceOutcome::FreshWrite(s) => s,
446 }
447 };
448
449 if let Some(parent) = path.parent() {
450 if !parent.as_os_str().is_empty() {
451 if let Err(e) = fs_err::create_dir_all(parent) {
452 return ArtifactResult::Failed {
453 agent,
454 error: RepographError::Io(e),
455 };
456 }
457 }
458 }
459
460 match fs_err::write(path, to_write) {
461 Ok(()) => ArtifactResult::Written {
462 agent,
463 path: path.to_path_buf(),
464 },
465 Err(e) => ArtifactResult::Failed {
466 agent,
467 error: RepographError::Io(e),
468 },
469 }
470}
471
472fn rendered_inner_body(rendered: &str) -> String {
490 let Some(begin_idx) = rendered.find(DELIMITER_BEGIN) else {
491 return rendered.to_string();
492 };
493 let after_begin = begin_idx + DELIMITER_BEGIN.len();
494 let inner_start = if rendered[after_begin..].starts_with('\n') {
495 after_begin + 1
496 } else {
497 after_begin
498 };
499 let Some(end_idx_rel) = rendered[inner_start..].find(DELIMITER_END) else {
500 return rendered.to_string();
501 };
502 let inner = &rendered[inner_start..inner_start + end_idx_rel];
503 inner.strip_suffix('\n').unwrap_or(inner).to_string()
504}
505
506#[must_use]
521pub fn install_artifacts(
522 agents: &[AgentId],
523 scope: Scope,
524 home: &Path,
525 cwd: &Path,
526 force: bool,
527) -> Vec<ArtifactResult> {
528 let mut results = Vec::with_capacity(agents.len());
529 for &agent in agents {
530 if !has_artifact_writer(agent) {
531 results.push(ArtifactResult::Skipped {
532 agent,
533 reason: REASON_COPILOT_DEFERRED,
534 });
535 continue;
536 }
537 let path = resolve_path(agent, scope, home, cwd);
538 results.push(install_one(agent, &path, force));
539 }
540 results
541}
542
543#[cfg(test)]
544mod tests {
545 #![allow(clippy::unwrap_used, clippy::expect_used)]
546 use super::*;
547 use tempfile::TempDir;
548
549 mod body {
552 use super::*;
553
554 fn commands_section() -> &'static str {
560 let start = BODY
561 .find("## Commands")
562 .expect("body has a Commands section");
563 let after = start + "## Commands".len();
564 let end_rel = BODY[after..].find("\n## ").unwrap_or(BODY.len() - after);
565 &BODY[start..after + end_rel]
566 }
567
568 #[test]
569 fn body_does_not_reference_mutating_commands_in_commands_section() {
570 let section = commands_section();
571 for forbidden in [
572 "repograph add",
573 "repograph remove",
574 "repograph workspace",
575 "repograph init",
576 ] {
577 assert!(
578 !section.contains(forbidden),
579 "Commands section mentions mutating command: {forbidden}\n---\n{section}",
580 );
581 }
582 }
583
584 #[test]
585 fn body_mentions_every_required_read_command() {
586 for required in [
587 "repograph context",
588 "repograph list",
589 "repograph status",
590 "repograph switch",
591 "repograph doctor",
592 ] {
593 assert!(
594 BODY.contains(required),
595 "BODY missing required command reference: {required}",
596 );
597 }
598 }
599
600 #[test]
601 fn body_warns_against_running_mutating_commands_automatically() {
602 assert!(
605 BODY.contains("Do not run mutating commands"),
606 "BODY missing the don't-mutate guidance"
607 );
608 }
609 }
610
611 mod path {
614 use super::*;
615
616 fn fixed_roots() -> (PathBuf, PathBuf) {
617 (PathBuf::from("/home/u"), PathBuf::from("/proj"))
618 }
619
620 #[test]
621 fn path_matrix_v1() {
622 let (home, cwd) = fixed_roots();
623 assert_eq!(
624 resolve_path(AgentId::ClaudeCode, Scope::User, &home, &cwd),
625 PathBuf::from("/home/u/.claude/skills/repograph/SKILL.md"),
626 );
627 assert_eq!(
628 resolve_path(AgentId::ClaudeCode, Scope::Project, &home, &cwd),
629 PathBuf::from("/proj/.claude/skills/repograph/SKILL.md"),
630 );
631 assert_eq!(
632 resolve_path(AgentId::AgentsMd, Scope::Project, &home, &cwd),
633 PathBuf::from("/proj/AGENTS.md"),
634 );
635 assert_eq!(
636 resolve_path(AgentId::Cursor, Scope::Project, &home, &cwd),
637 PathBuf::from("/proj/.cursor/rules/repograph.mdc"),
638 );
639 assert_eq!(
640 resolve_path(AgentId::Aider, Scope::Project, &home, &cwd),
641 PathBuf::from("/proj/CONVENTIONS.md"),
642 );
643 assert_eq!(
644 resolve_path(AgentId::Windsurf, Scope::User, &home, &cwd),
645 PathBuf::from("/home/u/.codeium/windsurf/memories/repograph.md"),
646 );
647 assert_eq!(
648 resolve_path(AgentId::Windsurf, Scope::Project, &home, &cwd),
649 PathBuf::from("/proj/.windsurfrules"),
650 );
651 }
652
653 #[test]
654 fn project_only_agents_fall_through_under_user_scope() {
655 let (home, cwd) = fixed_roots();
656 for agent in [AgentId::AgentsMd, AgentId::Aider, AgentId::Cursor] {
657 assert_eq!(
658 resolve_path(agent, Scope::User, &home, &cwd),
659 resolve_path(agent, Scope::Project, &home, &cwd),
660 "{agent:?} should fall through under Scope::User",
661 );
662 }
663 }
664
665 #[test]
666 fn has_artifact_writer_matches_matrix() {
667 assert!(!has_artifact_writer(AgentId::Copilot));
668 for agent in [
669 AgentId::ClaudeCode,
670 AgentId::AgentsMd,
671 AgentId::Cursor,
672 AgentId::Aider,
673 AgentId::Windsurf,
674 ] {
675 assert!(has_artifact_writer(agent), "{agent:?} should have a writer");
676 }
677 }
678
679 #[test]
680 fn scope_is_meaningful_returns_true_only_for_dual_scope_agents() {
681 assert!(scope_is_meaningful(AgentId::ClaudeCode));
682 assert!(scope_is_meaningful(AgentId::Windsurf));
683 assert!(!scope_is_meaningful(AgentId::AgentsMd));
684 assert!(!scope_is_meaningful(AgentId::Aider));
685 assert!(!scope_is_meaningful(AgentId::Cursor));
686 assert!(!scope_is_meaningful(AgentId::Copilot));
687 }
688 }
689
690 mod render {
693 use super::*;
694
695 #[test]
696 fn render_artifact_claude_code_has_yaml_frontmatter() {
697 let out = render_artifact(AgentId::ClaudeCode);
698 assert!(out.starts_with("---\nname: repograph\n"), "got: {out:?}");
699 assert!(
700 out.contains(&format!("description: >-\n {SUMMARY}\n")),
701 "summary rendered as a folded block scalar in frontmatter, got: {out:?}",
702 );
703 assert!(out.contains(DELIMITER_BEGIN));
704 assert!(out.contains(DELIMITER_END));
705 assert!(out.contains("repograph context"));
706 }
707
708 #[test]
709 fn render_artifact_cursor_has_mdc_frontmatter() {
710 let out = render_artifact(AgentId::Cursor);
711 assert!(out.starts_with("---\ndescription:"), "got: {out:?}");
712 assert!(out.contains("globs: []"), "MDC frontmatter, got: {out:?}");
713 assert!(out.contains(DELIMITER_BEGIN));
714 }
715
716 #[test]
717 fn render_artifact_agents_md_has_no_frontmatter() {
718 let out = render_artifact(AgentId::AgentsMd);
719 let expected_prefix = format!("{DELIMITER_BEGIN}\n# repograph");
720 assert!(out.starts_with(&expected_prefix), "got: {out:?}");
721 assert!(!out.starts_with("---"), "must not have YAML frontmatter");
722 }
723
724 #[test]
725 fn render_artifact_aider_and_windsurf_have_no_frontmatter() {
726 for agent in [AgentId::Aider, AgentId::Windsurf] {
727 let out = render_artifact(agent);
728 assert!(
729 out.starts_with(DELIMITER_BEGIN),
730 "{agent:?} should start with the begin-delimiter",
731 );
732 assert!(!out.starts_with("---"));
733 }
734 }
735
736 #[test]
737 fn render_artifact_is_deterministic() {
738 for agent in [
739 AgentId::ClaudeCode,
740 AgentId::Cursor,
741 AgentId::AgentsMd,
742 AgentId::Aider,
743 AgentId::Windsurf,
744 ] {
745 let a = render_artifact(agent);
746 let b = render_artifact(agent);
747 assert_eq!(a, b, "{agent:?} output must be byte-stable across calls");
748 }
749 }
750
751 #[test]
752 #[should_panic(expected = "copilot has no writer")]
753 fn render_artifact_copilot_panics() {
754 let _ = render_artifact(AgentId::Copilot);
755 }
756 }
757
758 mod splice {
761 use super::*;
762
763 fn block(inner: &str) -> String {
764 format!("{DELIMITER_BEGIN}\n{inner}\n{DELIMITER_END}\n")
765 }
766
767 #[test]
768 fn fresh_write() {
769 let outcome = splice_managed_section(None, "BODY");
770 assert_eq!(outcome, SpliceOutcome::FreshWrite(block("BODY")));
771 }
772
773 #[test]
774 fn identical_returns_identical() {
775 let existing = block("BODY");
776 let outcome = splice_managed_section(Some(&existing), "BODY");
777 assert_eq!(outcome, SpliceOutcome::Identical);
778 }
779
780 #[test]
781 fn differing_inner_rewrites_block() {
782 let existing = block("OLD");
783 let outcome = splice_managed_section(Some(&existing), "NEW");
784 match outcome {
785 SpliceOutcome::Replaced(s) => assert_eq!(s, block("NEW")),
786 other => panic!("expected Replaced, got {other:?}"),
787 }
788 }
789
790 #[test]
791 fn no_delimiters_appends() {
792 let existing = "# My project\n\nCustom prose.\n";
793 let outcome = splice_managed_section(Some(existing), "BODY");
794 match outcome {
795 SpliceOutcome::Appended(s) => {
796 let expected = format!("{existing}\n{}", block("BODY"));
797 assert_eq!(s, expected);
798 }
799 other => panic!("expected Appended, got {other:?}"),
800 }
801 }
802
803 #[test]
804 fn user_content_outside_delimiters_preserved() {
805 let existing = format!("pre\n{}post\n", block("old"));
806 let outcome = splice_managed_section(Some(&existing), "new");
807 match outcome {
808 SpliceOutcome::Replaced(s) => {
809 assert_eq!(s, format!("pre\n{}post\n", block("new")));
810 }
811 other => panic!("expected Replaced, got {other:?}"),
812 }
813 }
814
815 #[test]
816 fn empty_existing_file_appends_with_no_leading_newline() {
817 let outcome = splice_managed_section(Some(""), "BODY");
818 match outcome {
819 SpliceOutcome::Appended(s) => assert_eq!(s, block("BODY")),
820 other => panic!("expected Appended for empty file, got {other:?}"),
821 }
822 }
823
824 #[test]
825 fn existing_without_trailing_newline_gets_separator() {
826 let existing = "no-newline";
829 let outcome = splice_managed_section(Some(existing), "BODY");
830 match outcome {
831 SpliceOutcome::Appended(s) => {
832 assert_eq!(s, format!("no-newline\n\n{}", block("BODY")));
833 }
834 other => panic!("expected Appended, got {other:?}"),
835 }
836 }
837 }
838
839 mod install_one {
842 use super::*;
843
844 fn read(path: &Path) -> String {
845 fs_err::read_to_string(path).unwrap()
846 }
847
848 #[test]
849 fn fresh_install_writes_file() {
850 let dir = TempDir::new().unwrap();
851 let path = dir.path().join("nested/AGENTS.md");
852 let r = install_one(AgentId::AgentsMd, &path, false);
853 match r {
854 ArtifactResult::Written { path: p, .. } => assert_eq!(p, path),
855 other => panic!("expected Written, got {other:?}"),
856 }
857 assert_eq!(read(&path), render_artifact(AgentId::AgentsMd));
858 }
859
860 #[test]
861 fn re_run_with_identical_body_returns_unchanged() {
862 let dir = TempDir::new().unwrap();
863 let path = dir.path().join("AGENTS.md");
864 let _ = install_one(AgentId::AgentsMd, &path, false);
865 let first = read(&path);
866 let r = install_one(AgentId::AgentsMd, &path, false);
867 match r {
868 ArtifactResult::Unchanged { .. } => (),
869 other => panic!("expected Unchanged on re-run, got {other:?}"),
870 }
871 assert_eq!(
872 read(&path),
873 first,
874 "file must be byte-stable across re-runs"
875 );
876 }
877
878 #[test]
879 fn force_on_identical_returns_written() {
880 let dir = TempDir::new().unwrap();
881 let path = dir.path().join("AGENTS.md");
882 let _ = install_one(AgentId::AgentsMd, &path, false);
883 let first = read(&path);
884 let r = install_one(AgentId::AgentsMd, &path, true);
885 match r {
886 ArtifactResult::Written { .. } => (),
887 other => panic!("expected Written under force, got {other:?}"),
888 }
889 assert_eq!(
890 read(&path),
891 first,
892 "force on identical content rewrites but byte content is the same"
893 );
894 }
895
896 #[test]
897 fn force_overwrites_user_content() {
898 let dir = TempDir::new().unwrap();
899 let path = dir.path().join("AGENTS.md");
900 fs_err::write(&path, "# My project\n\nCustom prose.\n").unwrap();
901 let r = install_one(AgentId::AgentsMd, &path, true);
902 match r {
903 ArtifactResult::Written { .. } => (),
904 other => panic!("expected Written under force, got {other:?}"),
905 }
906 let after = read(&path);
907 assert!(after.starts_with(DELIMITER_BEGIN), "force replaced content");
908 assert!(
909 !after.contains("Custom prose."),
910 "force dropped user content"
911 );
912 }
913
914 #[test]
915 fn fresh_install_for_whole_file_owner_includes_frontmatter() {
916 let dir = TempDir::new().unwrap();
917 let path = dir.path().join("nested/SKILL.md");
918 let r = install_one(AgentId::ClaudeCode, &path, false);
919 assert!(matches!(r, ArtifactResult::Written { .. }));
920 let body = read(&path);
921 assert!(
922 body.starts_with("---\nname: repograph\n"),
923 "claude-code fresh install must include YAML frontmatter, got:\n{body}",
924 );
925 assert!(body.contains(DELIMITER_BEGIN));
926 assert!(body.contains(DELIMITER_END));
927 }
928
929 #[test]
930 fn re_run_whole_file_owner_is_unchanged() {
931 let dir = TempDir::new().unwrap();
932 let path = dir.path().join("SKILL.md");
933 let _ = install_one(AgentId::ClaudeCode, &path, false);
934 let first = read(&path);
935 let r = install_one(AgentId::ClaudeCode, &path, false);
936 assert!(matches!(r, ArtifactResult::Unchanged { .. }));
937 assert_eq!(read(&path), first);
938 }
939
940 #[test]
941 fn non_force_preserves_user_content_around_block() {
942 let dir = TempDir::new().unwrap();
943 let path = dir.path().join("AGENTS.md");
944 fs_err::write(&path, "# My project\n\nCustom prose.\n").unwrap();
945 let r = install_one(AgentId::AgentsMd, &path, false);
946 assert!(matches!(r, ArtifactResult::Written { .. }));
947 let after = read(&path);
948 assert!(after.starts_with("# My project\n\nCustom prose.\n"));
949 assert!(after.contains(DELIMITER_BEGIN));
950 assert!(after.contains(DELIMITER_END));
951 }
952 }
953
954 mod install_artifacts {
957 use super::*;
958
959 #[test]
960 fn returns_one_result_per_agent_in_order() {
961 let dir = TempDir::new().unwrap();
962 let home = dir.path().join("home");
963 let cwd = dir.path().join("proj");
964 fs_err::create_dir_all(&home).unwrap();
965 fs_err::create_dir_all(&cwd).unwrap();
966 let agents = vec![AgentId::AgentsMd, AgentId::ClaudeCode];
967 let results = install_artifacts(&agents, Scope::User, &home, &cwd, false);
968 assert_eq!(results.len(), 2);
969 assert_eq!(results[0].agent(), AgentId::AgentsMd);
970 assert_eq!(results[1].agent(), AgentId::ClaudeCode);
971 }
972
973 #[test]
974 fn copilot_is_skipped() {
975 let dir = TempDir::new().unwrap();
976 let home = dir.path().join("home");
977 let cwd = dir.path().join("proj");
978 fs_err::create_dir_all(&home).unwrap();
979 fs_err::create_dir_all(&cwd).unwrap();
980 let results = install_artifacts(&[AgentId::Copilot], Scope::User, &home, &cwd, false);
981 match &results[0] {
982 ArtifactResult::Skipped { agent, reason } => {
983 assert_eq!(*agent, AgentId::Copilot);
984 assert_eq!(*reason, REASON_COPILOT_DEFERRED);
985 }
986 other => panic!("expected Skipped for Copilot, got {other:?}"),
987 }
988 }
989
990 #[test]
991 fn per_agent_failure_does_not_abort_subsequent_agents() {
992 #[cfg(unix)]
995 {
996 use std::os::unix::fs::PermissionsExt;
997 let dir = TempDir::new().unwrap();
998 let home = dir.path().join("home");
999 let cwd = dir.path().join("proj");
1000 fs_err::create_dir_all(&home).unwrap();
1001 fs_err::create_dir_all(&cwd).unwrap();
1002 fs_err::create_dir_all(cwd.join("AGENTS.md")).unwrap();
1004 let results = install_artifacts(
1005 &[AgentId::AgentsMd, AgentId::ClaudeCode],
1006 Scope::User,
1007 &home,
1008 &cwd,
1009 false,
1010 );
1011 assert_eq!(results.len(), 2);
1012 assert!(matches!(results[0], ArtifactResult::Failed { .. }));
1013 assert!(matches!(
1014 results[1],
1015 ArtifactResult::Written { .. } | ArtifactResult::Unchanged { .. }
1016 ));
1017 let mut perms = fs_err::metadata(cwd.join("AGENTS.md"))
1019 .unwrap()
1020 .permissions();
1021 perms.set_mode(0o755);
1022 fs_err::set_permissions(cwd.join("AGENTS.md"), perms).unwrap();
1023 }
1024 }
1025
1026 #[test]
1027 fn copilot_in_mixed_selection_does_not_block_others() {
1028 let dir = TempDir::new().unwrap();
1029 let home = dir.path().join("home");
1030 let cwd = dir.path().join("proj");
1031 fs_err::create_dir_all(&home).unwrap();
1032 fs_err::create_dir_all(&cwd).unwrap();
1033 let results = install_artifacts(
1034 &[AgentId::Copilot, AgentId::AgentsMd, AgentId::ClaudeCode],
1035 Scope::User,
1036 &home,
1037 &cwd,
1038 false,
1039 );
1040 assert_eq!(results.len(), 3);
1041 assert!(matches!(results[0], ArtifactResult::Skipped { .. }));
1042 assert!(matches!(results[1], ArtifactResult::Written { .. }));
1043 assert!(matches!(results[2], ArtifactResult::Written { .. }));
1044 }
1045 }
1046}