Skip to main content

agm_core/import/
resolver.rs

1//! Import resolver trait and filesystem implementation (spec S10.3).
2
3use std::path::PathBuf;
4
5use crate::error::{AgmError, ErrorCode, ErrorLocation};
6use crate::model::file::AgmFile;
7use crate::parser;
8
9use super::constraint::ValidatedImport;
10
11// ---------------------------------------------------------------------------
12// ResolvedPackage
13// ---------------------------------------------------------------------------
14
15/// A successfully resolved import: the package name, its version, its source
16/// path, and the parsed AGM file contents.
17#[derive(Debug, Clone, PartialEq)]
18pub struct ResolvedPackage {
19    /// Package name (matches the `package` field in the header).
20    pub package: String,
21    /// The resolved version of the package.
22    pub version: semver::Version,
23    /// Path to the `.agm` file that was resolved.
24    pub path: PathBuf,
25    /// The parsed AGM file.
26    pub file: AgmFile,
27}
28
29// ---------------------------------------------------------------------------
30// ImportResolver trait
31// ---------------------------------------------------------------------------
32
33/// Trait for resolving AGM package imports to their on-disk (or registry) representations.
34pub trait ImportResolver {
35    /// Resolve a validated import to a package.
36    ///
37    /// Returns `Err` with I001 if the package cannot be found, or I002 if the
38    /// package is found but no version satisfies the constraint.
39    fn resolve(&self, import: &ValidatedImport) -> Result<ResolvedPackage, AgmError>;
40}
41
42// ---------------------------------------------------------------------------
43// FileSystemResolver
44// ---------------------------------------------------------------------------
45
46/// Resolves imports by searching directories on the local filesystem.
47///
48/// Search paths are checked in order. Within each search path, a directory
49/// matching the package name is expected. Inside that directory, `.agm` files
50/// are parsed to extract their version. The highest version satisfying the
51/// constraint is selected.
52#[derive(Debug, Clone)]
53pub struct FileSystemResolver {
54    /// Ordered list of directories to search (e.g., [".agm/packages/"]).
55    search_paths: Vec<PathBuf>,
56}
57
58impl FileSystemResolver {
59    /// Creates a new FileSystemResolver with the given search paths.
60    #[must_use]
61    pub fn new(search_paths: Vec<PathBuf>) -> Self {
62        Self { search_paths }
63    }
64
65    /// Convenience constructor for a single search path.
66    #[must_use]
67    pub fn single(path: impl Into<PathBuf>) -> Self {
68        Self {
69            search_paths: vec![path.into()],
70        }
71    }
72
73    /// Finds all candidate `.agm` files in the package directory across
74    /// all search paths. Returns (path, parsed_file) pairs.
75    ///
76    /// This is an internal helper; it does NOT filter by version.
77    fn find_candidates(&self, package_name: &str) -> Result<Vec<(PathBuf, AgmFile)>, AgmError> {
78        let mut candidates = Vec::new();
79
80        for search_path in &self.search_paths {
81            let package_dir = search_path.join(package_name);
82
83            if !package_dir.is_dir() {
84                continue;
85            }
86
87            let dir_entries = std::fs::read_dir(&package_dir).map_err(|e| {
88                AgmError::new(
89                    ErrorCode::I001,
90                    format!(
91                        "Failed to read package directory `{}`: {e}",
92                        package_dir.display()
93                    ),
94                    ErrorLocation::default(),
95                )
96            })?;
97
98            for dir_entry in dir_entries {
99                let entry = dir_entry.map_err(|e| {
100                    AgmError::new(
101                        ErrorCode::I001,
102                        format!("Failed to read directory entry: {e}"),
103                        ErrorLocation::default(),
104                    )
105                })?;
106                let path = entry.path();
107
108                if path.extension().is_some_and(|ext| ext == "agm") {
109                    let source = std::fs::read_to_string(&path).map_err(|e| {
110                        AgmError::new(
111                            ErrorCode::I001,
112                            format!("Failed to read package file `{}`: {e}", path.display()),
113                            ErrorLocation::default(),
114                        )
115                    })?;
116
117                    // Skip files with parse errors (they are invalid packages)
118                    let file = match parser::parse(&source) {
119                        Ok(f) => f,
120                        Err(_) => continue,
121                    };
122
123                    // Verify the package field matches
124                    if file.header.package == package_name {
125                        candidates.push((path, file));
126                    }
127                }
128            }
129        }
130
131        Ok(candidates)
132    }
133
134    /// From a list of candidates, selects the one whose version best
135    /// matches the constraint. Returns the highest matching version.
136    fn select_best_match(
137        candidates: &[(PathBuf, AgmFile)],
138        import: &ValidatedImport,
139    ) -> Option<(PathBuf, AgmFile, semver::Version)> {
140        let mut best: Option<(PathBuf, AgmFile, semver::Version)> = None;
141
142        for (path, file) in candidates {
143            // Parse the version from the file header
144            let version = match semver::Version::parse(&file.header.version) {
145                Ok(v) => v,
146                Err(_) => continue, // skip files with invalid versions
147            };
148
149            // Check if version satisfies the constraint
150            if !import.matches_version(&version) {
151                continue;
152            }
153
154            // Keep the highest matching version
155            match &best {
156                None => best = Some((path.clone(), file.clone(), version)),
157                Some((_, _, best_version)) => {
158                    if version > *best_version {
159                        best = Some((path.clone(), file.clone(), version));
160                    }
161                }
162            }
163        }
164
165        best
166    }
167}
168
169impl ImportResolver for FileSystemResolver {
170    fn resolve(&self, import: &ValidatedImport) -> Result<ResolvedPackage, AgmError> {
171        let package_name = import.package();
172
173        let candidates = self.find_candidates(package_name)?;
174
175        if candidates.is_empty() {
176            return Err(AgmError::new(
177                ErrorCode::I001,
178                format!("Unresolved import: `{package_name}`"),
179                ErrorLocation::default(),
180            ));
181        }
182
183        match Self::select_best_match(&candidates, import) {
184            Some((path, file, version)) => Ok(ResolvedPackage {
185                package: package_name.to_owned(),
186                version,
187                path,
188                file,
189            }),
190            None => {
191                // Candidates exist but none satisfy the version constraint
192                let found_versions: Vec<String> = candidates
193                    .iter()
194                    .filter_map(|(_, f)| semver::Version::parse(&f.header.version).ok())
195                    .map(|v| v.to_string())
196                    .collect();
197
198                Err(AgmError::new(
199                    ErrorCode::I002,
200                    format!(
201                        "Import version constraint not satisfied: `{pkg}@{constraint}` (found {found})",
202                        pkg = package_name,
203                        constraint = import.entry.version_constraint.as_deref().unwrap_or("*"),
204                        found = if found_versions.is_empty() {
205                            "no valid versions".to_owned()
206                        } else {
207                            found_versions.join(", ")
208                        },
209                    ),
210                    ErrorLocation::default(),
211                ))
212            }
213        }
214    }
215}
216
217// ---------------------------------------------------------------------------
218// Tests
219// ---------------------------------------------------------------------------
220
221#[cfg(test)]
222mod tests {
223    use std::path::Path;
224
225    use super::*;
226    use crate::model::imports::ImportEntry;
227
228    use constraint::validate_import;
229
230    use super::super::constraint;
231
232    // -----------------------------------------------------------------------
233    // Helpers
234    // -----------------------------------------------------------------------
235
236    fn write_agm_file(dir: &Path, filename: &str, package: &str, version: &str) {
237        let content = format!(
238            "agm: 1.0\npackage: {package}\nversion: {version}\n\nnode {package}.example\ntype: facts\nsummary: Example node\n"
239        );
240        std::fs::write(dir.join(filename), content).unwrap();
241    }
242
243    fn setup_package_dir(base: &Path, package_name: &str, versions: &[&str]) -> PathBuf {
244        let pkg_dir = base.join(package_name);
245        std::fs::create_dir_all(&pkg_dir).unwrap();
246        for (i, version) in versions.iter().enumerate() {
247            write_agm_file(&pkg_dir, &format!("v{i}.agm"), package_name, version);
248        }
249        pkg_dir
250    }
251
252    // -----------------------------------------------------------------------
253    // Category G: FileSystemResolver
254    // -----------------------------------------------------------------------
255
256    #[test]
257    fn test_fs_resolver_finds_package_in_search_path() {
258        let tmp = tempfile::TempDir::new().unwrap();
259        setup_package_dir(tmp.path(), "shared.security", &["1.0.0"]);
260
261        let resolver = FileSystemResolver::single(tmp.path().to_path_buf());
262        let entry = ImportEntry::new("shared.security".to_owned(), None);
263        let import = validate_import(&entry).unwrap();
264
265        let result = resolver.resolve(&import).unwrap();
266        assert_eq!(result.package, "shared.security");
267        assert_eq!(result.version, semver::Version::parse("1.0.0").unwrap());
268    }
269
270    #[test]
271    fn test_fs_resolver_missing_package_returns_i001() {
272        let tmp = tempfile::TempDir::new().unwrap();
273        // No package directory created
274
275        let resolver = FileSystemResolver::single(tmp.path().to_path_buf());
276        let entry = ImportEntry::new("shared.security".to_owned(), None);
277        let import = validate_import(&entry).unwrap();
278
279        let err = resolver.resolve(&import).unwrap_err();
280        assert_eq!(err.code, crate::error::ErrorCode::I001);
281    }
282
283    #[test]
284    fn test_fs_resolver_version_mismatch_returns_i002() {
285        let tmp = tempfile::TempDir::new().unwrap();
286        setup_package_dir(tmp.path(), "shared.security", &["3.0.0"]);
287
288        let resolver = FileSystemResolver::single(tmp.path().to_path_buf());
289        let entry = ImportEntry::new("shared.security".to_owned(), Some("^1.0.0".to_owned()));
290        let import = validate_import(&entry).unwrap();
291
292        let err = resolver.resolve(&import).unwrap_err();
293        assert_eq!(err.code, crate::error::ErrorCode::I002);
294    }
295
296    #[test]
297    fn test_fs_resolver_selects_highest_matching_version() {
298        let tmp = tempfile::TempDir::new().unwrap();
299        setup_package_dir(tmp.path(), "shared.security", &["1.0.0", "1.2.0"]);
300
301        let resolver = FileSystemResolver::single(tmp.path().to_path_buf());
302        let entry = ImportEntry::new("shared.security".to_owned(), Some("^1.0.0".to_owned()));
303        let import = validate_import(&entry).unwrap();
304
305        let result = resolver.resolve(&import).unwrap();
306        assert_eq!(result.version, semver::Version::parse("1.2.0").unwrap());
307    }
308
309    #[test]
310    fn test_fs_resolver_skips_files_with_parse_errors() {
311        let tmp = tempfile::TempDir::new().unwrap();
312        let pkg_dir = tmp.path().join("shared.http");
313        std::fs::create_dir_all(&pkg_dir).unwrap();
314
315        // Write a valid file
316        write_agm_file(&pkg_dir, "valid.agm", "shared.http", "1.0.0");
317
318        // Write a malformed file
319        std::fs::write(pkg_dir.join("bad.agm"), "this is not valid agm content\n").unwrap();
320
321        let resolver = FileSystemResolver::single(tmp.path().to_path_buf());
322        let entry = ImportEntry::new("shared.http".to_owned(), None);
323        let import = validate_import(&entry).unwrap();
324
325        let result = resolver.resolve(&import).unwrap();
326        assert_eq!(result.package, "shared.http");
327        assert_eq!(result.version, semver::Version::parse("1.0.0").unwrap());
328    }
329
330    #[test]
331    fn test_fs_resolver_no_constraint_picks_highest() {
332        let tmp = tempfile::TempDir::new().unwrap();
333        setup_package_dir(tmp.path(), "core.utils", &["1.0.0", "2.5.0"]);
334
335        let resolver = FileSystemResolver::single(tmp.path().to_path_buf());
336        let entry = ImportEntry::new("core.utils".to_owned(), None);
337        let import = validate_import(&entry).unwrap();
338
339        let result = resolver.resolve(&import).unwrap();
340        assert_eq!(result.version, semver::Version::parse("2.5.0").unwrap());
341    }
342
343    // -----------------------------------------------------------------------
344    // Category H: Integration tests
345    // -----------------------------------------------------------------------
346
347    #[test]
348    fn test_full_import_pipeline_validate_resolve_succeeds() {
349        let tmp = tempfile::TempDir::new().unwrap();
350        setup_package_dir(tmp.path(), "shared.security", &["1.2.0"]);
351
352        let resolver = FileSystemResolver::single(tmp.path().to_path_buf());
353
354        // Validate the import entry
355        let entry = ImportEntry::new("shared.security".to_owned(), Some("^1.0.0".to_owned()));
356        let validated = validate_import(&entry).unwrap();
357        assert_eq!(validated.package(), "shared.security");
358
359        // Resolve it
360        let resolved = resolver.resolve(&validated).unwrap();
361        assert_eq!(resolved.package, "shared.security");
362        assert_eq!(resolved.version, semver::Version::parse("1.2.0").unwrap());
363        assert_eq!(resolved.file.header.package, "shared.security");
364    }
365
366    #[test]
367    fn test_full_import_pipeline_circular_detection_rejects() {
368        use crate::import::{ImportResolver, ValidatedImport, detect_circular_imports};
369        use crate::model::fields::{NodeType, Span};
370        use crate::model::file::{AgmFile, Header};
371        use crate::model::imports::ImportEntry;
372        use crate::model::node::Node;
373        use std::collections::HashMap;
374        use std::path::PathBuf;
375
376        // Build a mock circular scenario: pkg-a imports pkg-b, pkg-b imports pkg-a
377        struct CircularMock {
378            packages: HashMap<String, ResolvedPackage>,
379        }
380
381        impl ImportResolver for CircularMock {
382            fn resolve(
383                &self,
384                import: &ValidatedImport,
385            ) -> Result<ResolvedPackage, crate::error::AgmError> {
386                self.packages.get(import.package()).cloned().ok_or_else(|| {
387                    crate::error::AgmError::new(
388                        crate::error::ErrorCode::I001,
389                        format!("not found: {}", import.package()),
390                        crate::error::ErrorLocation::default(),
391                    )
392                })
393            }
394        }
395
396        fn make_pkg(name: &str, imports: Vec<ImportEntry>) -> ResolvedPackage {
397            ResolvedPackage {
398                package: name.to_owned(),
399                version: semver::Version::parse("1.0.0").unwrap(),
400                path: PathBuf::from(format!("{name}.agm")),
401                file: AgmFile {
402                    header: Header {
403                        agm: "1.0".to_owned(),
404                        package: name.to_owned(),
405                        version: "1.0.0".to_owned(),
406                        title: None,
407                        owner: None,
408                        imports: if imports.is_empty() {
409                            None
410                        } else {
411                            Some(imports)
412                        },
413                        default_load: None,
414                        description: None,
415                        tags: None,
416                        status: None,
417                        load_profiles: None,
418                        target_runtime: None,
419                    },
420                    nodes: vec![Node {
421                        id: format!("{name}.node"),
422                        node_type: NodeType::Facts,
423                        summary: "node".to_owned(),
424                        span: Span::new(1, 1),
425                        ..Default::default()
426                    }],
427                },
428            }
429        }
430
431        let entry_a = ImportEntry::new("pkg-a".to_owned(), None);
432        let entry_b = ImportEntry::new("pkg-b".to_owned(), None);
433
434        let pkg_b = make_pkg("pkg-b", vec![entry_a.clone()]);
435        let pkg_a_as_dep = make_pkg("pkg-a", vec![entry_b.clone()]);
436
437        let mut packages = HashMap::new();
438        packages.insert("pkg-b".to_owned(), pkg_b);
439        packages.insert("pkg-a".to_owned(), pkg_a_as_dep);
440
441        let mock = CircularMock { packages };
442
443        let root_imports = vec![validate_import(&entry_b).unwrap()];
444        let err = detect_circular_imports("pkg-a", &root_imports, &mock).unwrap_err();
445        assert_eq!(err.code, crate::error::ErrorCode::I003);
446    }
447}