Skip to main content

mars_agents/source/
path.rs

1//! Path source adapter — resolves local filesystem paths.
2//!
3//! Path sources are always "live" — no caching, no copying.
4//! Returns the resolved path directly.
5
6use std::path::{Path, PathBuf};
7
8use crate::error::MarsError;
9use crate::source::ResolvedRef;
10use crate::types::SourceName;
11
12/// Fetch a path source: resolve relative paths against project root, verify exists.
13///
14/// - Relative paths are resolved against `project_root`.
15/// - Absolute paths are used as-is.
16/// - The path must exist and be a directory.
17/// - Returns `ResolvedRef` with no version/commit (path sources are unversioned).
18pub fn fetch_path(
19    path: &Path,
20    project_root: &Path,
21    source_name: &str,
22) -> Result<ResolvedRef, MarsError> {
23    let resolved = if path.is_absolute() {
24        path.to_path_buf()
25    } else {
26        project_root.join(path)
27    };
28
29    // Canonicalize to resolve symlinks and `..` components
30    let resolved = canonicalize_path(&resolved, source_name)?;
31
32    // Verify the path exists and is a directory
33    if !resolved.exists() {
34        return Err(MarsError::Source {
35            source_name: source_name.to_string(),
36            message: format!("path does not exist: {}", resolved.display()),
37        });
38    }
39
40    if !resolved.is_dir() {
41        return Err(MarsError::Source {
42            source_name: source_name.to_string(),
43            message: format!("path is not a directory: {}", resolved.display()),
44        });
45    }
46
47    Ok(ResolvedRef {
48        source_name: SourceName::from(source_name),
49        version: None,
50        version_tag: None,
51        commit: None,
52        tree_path: resolved,
53    })
54}
55
56/// Canonicalize a path, providing a helpful error on failure.
57fn canonicalize_path(path: &Path, source_name: &str) -> Result<PathBuf, MarsError> {
58    path.canonicalize().map_err(|e| MarsError::Source {
59        source_name: source_name.to_string(),
60        message: format!("failed to resolve path `{}`: {e}", path.display()),
61    })
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use tempfile::TempDir;
68
69    #[test]
70    fn fetch_absolute_path() {
71        let dir = TempDir::new().unwrap();
72        let source_dir = dir.path().join("my-agents");
73        std::fs::create_dir_all(&source_dir).unwrap();
74
75        let resolved = fetch_path(&source_dir, dir.path(), "local-source").unwrap();
76
77        assert_eq!(resolved.source_name, "local-source");
78        assert!(resolved.version.is_none());
79        assert!(resolved.version_tag.is_none());
80        assert!(resolved.commit.is_none());
81        assert_eq!(
82            resolved.tree_path.canonicalize().unwrap(),
83            source_dir.canonicalize().unwrap()
84        );
85    }
86
87    #[test]
88    fn fetch_relative_path() {
89        let dir = TempDir::new().unwrap();
90        let project_root = dir.path().join("project");
91        let source_dir = dir.path().join("project").join("local-agents");
92        std::fs::create_dir_all(&project_root).unwrap();
93        std::fs::create_dir_all(&source_dir).unwrap();
94
95        let resolved = fetch_path(Path::new("local-agents"), &project_root, "local").unwrap();
96
97        assert_eq!(
98            resolved.tree_path.canonicalize().unwrap(),
99            source_dir.canonicalize().unwrap()
100        );
101    }
102
103    #[test]
104    fn fetch_relative_path_with_dotdot() {
105        let dir = TempDir::new().unwrap();
106        let project_root = dir.path().join("project");
107        let source_dir = dir.path().join("external-agents");
108        std::fs::create_dir_all(&project_root).unwrap();
109        std::fs::create_dir_all(&source_dir).unwrap();
110
111        let resolved =
112            fetch_path(Path::new("../external-agents"), &project_root, "external").unwrap();
113
114        assert_eq!(
115            resolved.tree_path.canonicalize().unwrap(),
116            source_dir.canonicalize().unwrap()
117        );
118    }
119
120    #[test]
121    fn fetch_nonexistent_path_returns_error() {
122        let dir = TempDir::new().unwrap();
123        let result = fetch_path(&dir.path().join("nonexistent"), dir.path(), "bad-source");
124
125        assert!(result.is_err());
126        let err = result.unwrap_err().to_string();
127        assert!(
128            err.contains("bad-source"),
129            "error should mention source name: {err}"
130        );
131        assert!(
132            err.contains("nonexistent"),
133            "error should mention the path: {err}"
134        );
135    }
136
137    #[test]
138    fn fetch_file_not_directory_returns_error() {
139        let dir = TempDir::new().unwrap();
140        let file_path = dir.path().join("not-a-dir.txt");
141        std::fs::write(&file_path, "content").unwrap();
142
143        let result = fetch_path(&file_path, dir.path(), "file-source");
144
145        assert!(result.is_err());
146        let err = result.unwrap_err().to_string();
147        assert!(
148            err.contains("not a directory"),
149            "error should mention 'not a directory': {err}"
150        );
151    }
152}