Skip to main content

mars_agents/
local_source.rs

1use 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, &current_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}