mars_agents/
local_source.rs1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use crate::diagnostic::DiagnosticCollector;
5use crate::discover::{self, DiscoveredItem};
6use crate::error::MarsError;
7use crate::lock::ItemId;
8
9pub const LOCAL_SOURCE_DIR: &str = ".mars-src";
10
11#[derive(Debug, Clone)]
12pub struct LocalDiscoveredItem {
13 pub discovered: DiscoveredItem,
14 pub root: PathBuf,
15}
16
17impl LocalDiscoveredItem {
18 pub fn disk_path(&self) -> PathBuf {
19 self.root.join(&self.discovered.source_path)
20 }
21}
22
23pub fn preferred_local_source_root(project_root: &Path) -> PathBuf {
24 project_root.join(LOCAL_SOURCE_DIR)
25}
26
27pub fn local_discovery_roots(project_root: &Path, include_legacy_root: bool) -> Vec<PathBuf> {
28 let mut roots = vec![preferred_local_source_root(project_root)];
29 if include_legacy_root {
30 roots.push(project_root.to_path_buf());
31 }
32 roots
33}
34
35pub fn discover_local_items(
36 project_root: &Path,
37 include_legacy_root: bool,
38 source_name: Option<&str>,
39 diag: &mut DiagnosticCollector,
40) -> Result<Vec<LocalDiscoveredItem>, MarsError> {
41 let mut seen: HashMap<ItemId, PathBuf> = HashMap::new();
42 let mut merged = Vec::new();
43
44 for root in local_discovery_roots(project_root, include_legacy_root) {
45 let discovered = discover::discover_source(&root, source_name)?;
46 for item in discovered {
47 let current_path = root.join(&item.source_path);
48 if let Some(existing_path) = seen.get(&item.id) {
49 diag.warn(
50 "duplicate-local-definition",
51 format!(
52 "local {} `{}` is defined in both `{}` and `{}` — using `{}`",
53 item.id.kind,
54 item.id.name,
55 relative_display(project_root, existing_path),
56 relative_display(project_root, ¤t_path),
57 LOCAL_SOURCE_DIR,
58 ),
59 );
60 continue;
61 }
62
63 seen.insert(item.id.clone(), current_path);
64 merged.push(LocalDiscoveredItem {
65 discovered: item,
66 root: root.clone(),
67 });
68 }
69 }
70
71 Ok(merged)
72}
73fn relative_display(project_root: &Path, path: &Path) -> String {
74 path.strip_prefix(project_root)
75 .unwrap_or(path)
76 .display()
77 .to_string()
78}
79
80#[cfg(test)]
81mod tests {
82 use super::*;
83 use crate::types::ItemKind;
84 use tempfile::TempDir;
85
86 #[test]
87 fn prefers_mars_src_over_repo_root() {
88 let dir = TempDir::new().unwrap();
89 let project_root = dir.path();
90
91 std::fs::create_dir_all(project_root.join("skills").join("planning")).unwrap();
92 std::fs::write(
93 project_root
94 .join("skills")
95 .join("planning")
96 .join("SKILL.md"),
97 "# Legacy",
98 )
99 .unwrap();
100
101 let preferred = preferred_local_source_root(project_root)
102 .join("skills")
103 .join("planning");
104 std::fs::create_dir_all(&preferred).unwrap();
105 std::fs::write(preferred.join("SKILL.md"), "# Preferred").unwrap();
106
107 let mut diag = DiagnosticCollector::new();
108 let items = discover_local_items(project_root, true, Some("_self"), &mut diag).unwrap();
109
110 assert_eq!(items.len(), 1);
111 assert_eq!(items[0].discovered.id.kind, ItemKind::Skill);
112 assert_eq!(items[0].discovered.id.name.as_str(), "planning");
113 assert_eq!(items[0].root, preferred_local_source_root(project_root));
114
115 let diagnostics = diag.drain();
116 assert_eq!(diagnostics.len(), 1);
117 assert_eq!(diagnostics[0].code, "duplicate-local-definition");
118 assert!(diagnostics[0].message.contains(".mars-src"));
119 }
120
121 #[test]
122 fn includes_repo_root_when_preferred_root_is_empty() {
123 let dir = TempDir::new().unwrap();
124 let project_root = dir.path();
125
126 std::fs::create_dir_all(project_root.join("agents")).unwrap();
127 std::fs::write(project_root.join("agents").join("coder.md"), "# Coder").unwrap();
128
129 let mut diag = DiagnosticCollector::new();
130 let items = discover_local_items(project_root, true, Some("_self"), &mut diag).unwrap();
131
132 assert_eq!(items.len(), 1);
133 assert_eq!(items[0].discovered.id.kind, ItemKind::Agent);
134 assert_eq!(items[0].discovered.id.name.as_str(), "coder");
135 assert_eq!(items[0].root, project_root);
136 assert!(diag.is_empty());
137 }
138
139 #[test]
140 fn skips_legacy_repo_root_without_package_gate() {
141 let dir = TempDir::new().unwrap();
142 let project_root = dir.path();
143
144 std::fs::create_dir_all(project_root.join("skills").join("planning")).unwrap();
145 std::fs::write(
146 project_root
147 .join("skills")
148 .join("planning")
149 .join("SKILL.md"),
150 "# Legacy",
151 )
152 .unwrap();
153
154 let mut diag = DiagnosticCollector::new();
155 let items = discover_local_items(project_root, false, Some("_self"), &mut diag).unwrap();
156
157 assert!(items.is_empty());
158 assert!(diag.is_empty());
159 }
160}