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::BTreeMap;
374        use std::collections::HashMap;
375        use std::path::PathBuf;
376
377        // Build a mock circular scenario: pkg-a imports pkg-b, pkg-b imports pkg-a
378        struct CircularMock {
379            packages: HashMap<String, ResolvedPackage>,
380        }
381
382        impl ImportResolver for CircularMock {
383            fn resolve(
384                &self,
385                import: &ValidatedImport,
386            ) -> Result<ResolvedPackage, crate::error::AgmError> {
387                self.packages.get(import.package()).cloned().ok_or_else(|| {
388                    crate::error::AgmError::new(
389                        crate::error::ErrorCode::I001,
390                        format!("not found: {}", import.package()),
391                        crate::error::ErrorLocation::default(),
392                    )
393                })
394            }
395        }
396
397        fn make_pkg(name: &str, imports: Vec<ImportEntry>) -> ResolvedPackage {
398            ResolvedPackage {
399                package: name.to_owned(),
400                version: semver::Version::parse("1.0.0").unwrap(),
401                path: PathBuf::from(format!("{name}.agm")),
402                file: AgmFile {
403                    header: Header {
404                        agm: "1.0".to_owned(),
405                        package: name.to_owned(),
406                        version: "1.0.0".to_owned(),
407                        title: None,
408                        owner: None,
409                        imports: if imports.is_empty() {
410                            None
411                        } else {
412                            Some(imports)
413                        },
414                        default_load: None,
415                        description: None,
416                        tags: None,
417                        status: None,
418                        load_profiles: None,
419                        target_runtime: None,
420                    },
421                    nodes: vec![Node {
422                        id: format!("{name}.node"),
423                        node_type: NodeType::Facts,
424                        summary: "node".to_owned(),
425                        priority: None,
426                        stability: None,
427                        confidence: None,
428                        status: None,
429                        depends: None,
430                        related_to: None,
431                        replaces: None,
432                        conflicts: None,
433                        see_also: None,
434                        items: None,
435                        steps: None,
436                        fields: None,
437                        input: None,
438                        output: None,
439                        detail: None,
440                        rationale: None,
441                        tradeoffs: None,
442                        resolution: None,
443                        examples: None,
444                        notes: None,
445                        code: None,
446                        code_blocks: None,
447                        verify: None,
448                        agent_context: None,
449                        target: None,
450                        execution_status: None,
451                        executed_by: None,
452                        executed_at: None,
453                        execution_log: None,
454                        retry_count: None,
455                        parallel_groups: None,
456                        memory: None,
457                        scope: None,
458                        applies_when: None,
459                        valid_from: None,
460                        valid_until: None,
461                        tags: None,
462                        aliases: None,
463                        keywords: None,
464                        extra_fields: BTreeMap::new(),
465                        span: Span::new(1, 1),
466                    }],
467                },
468            }
469        }
470
471        let entry_a = ImportEntry::new("pkg-a".to_owned(), None);
472        let entry_b = ImportEntry::new("pkg-b".to_owned(), None);
473
474        let pkg_b = make_pkg("pkg-b", vec![entry_a.clone()]);
475        let pkg_a_as_dep = make_pkg("pkg-a", vec![entry_b.clone()]);
476
477        let mut packages = HashMap::new();
478        packages.insert("pkg-b".to_owned(), pkg_b);
479        packages.insert("pkg-a".to_owned(), pkg_a_as_dep);
480
481        let mock = CircularMock { packages };
482
483        let root_imports = vec![validate_import(&entry_b).unwrap()];
484        let err = detect_circular_imports("pkg-a", &root_imports, &mock).unwrap_err();
485        assert_eq!(err.code, crate::error::ErrorCode::I003);
486    }
487}