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 = "Cross-repo context for AI agents";
120
121pub const BODY: &str = include_str!("agent_artifact_body.md");
131
132#[must_use]
135pub const fn writer_summary() -> &'static str {
136 SUMMARY
137}
138
139#[must_use]
145pub const fn has_artifact_writer(agent: AgentId) -> bool {
146 !matches!(agent, AgentId::Copilot)
147}
148
149#[must_use]
161pub const fn wholly_owned_file(agent: AgentId) -> bool {
162 matches!(agent, AgentId::ClaudeCode | AgentId::Cursor)
163}
164
165#[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 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 unreachable!("resolve_path: copilot has no writer; check has_artifact_writer first")
199 }
200 }
201}
202
203#[must_use]
210pub fn scope_is_meaningful(agent: AgentId) -> bool {
211 if !has_artifact_writer(agent) {
212 return false;
213 }
214 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#[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#[derive(Debug, PartialEq, Eq)]
264pub enum SpliceOutcome {
265 Identical,
268 Replaced(String),
272 Appended(String),
276 FreshWrite(String),
279}
280
281#[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 if let Some(begin_idx) = existing.find(DELIMITER_BEGIN) {
300 let after_begin = begin_idx + DELIMITER_BEGIN.len();
302 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 let inner_end = inner_start + end_rel;
313 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 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 }
341
342 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#[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 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 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
461fn 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#[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 mod body {
541 use super::*;
542
543 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 assert!(
594 BODY.contains("Do not run mutating commands"),
595 "BODY missing the don't-mutate guidance"
596 );
597 }
598 }
599
600 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 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 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 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 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 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 #[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 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 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}