Skip to main content

agm_core/import/
mod.rs

1//! Import resolution for AGM packages (spec S10).
2//!
3//! Provides version constraint validation, filesystem-based package resolution,
4//! cross-package reference resolution, and circular import detection.
5
6pub mod constraint;
7pub mod resolver;
8
9pub use constraint::{
10    ValidatedImport, parse_version_constraint, validate_all_imports, validate_import,
11};
12pub use resolver::{FileSystemResolver, ImportResolver, ResolvedPackage};
13
14use std::collections::HashSet;
15
16use crate::error::{AgmError, ErrorCode, ErrorLocation};
17use crate::model::node::Node;
18
19// ---------------------------------------------------------------------------
20// qualify_node_id
21// ---------------------------------------------------------------------------
22
23/// Produces a fully qualified node ID: `"{package}.{node_id}"`.
24///
25/// Example: `qualify_node_id("shared.security", "auth.rules")` -> `"shared.security.auth.rules"`
26#[must_use]
27pub fn qualify_node_id(package: &str, node_id: &str) -> String {
28    format!("{package}.{node_id}")
29}
30
31// ---------------------------------------------------------------------------
32// resolve_cross_package_ref
33// ---------------------------------------------------------------------------
34
35/// Resolves a cross-package reference to the target node.
36///
37/// Given a fully qualified reference like `"shared.security.auth.rules"` and a
38/// list of resolved packages, this function:
39/// 1. Identifies which imported package the reference belongs to (longest prefix match).
40/// 2. Strips the package prefix to get the local node ID.
41/// 3. Searches the imported package's nodes for that ID.
42///
43/// Returns `Err(I004)` if the reference cannot be resolved.
44///
45/// `imported_packages` is the list of all successfully resolved packages.
46pub fn resolve_cross_package_ref<'a>(
47    ref_id: &str,
48    imported_packages: &'a [ResolvedPackage],
49) -> Result<&'a Node, AgmError> {
50    // Step 1: Collect known package names, sorted by length descending
51    //         (longest prefix match wins)
52    let mut package_names: Vec<&str> = imported_packages
53        .iter()
54        .map(|p| p.package.as_str())
55        .collect();
56    package_names.sort_by_key(|b| std::cmp::Reverse(b.len()));
57
58    // Step 2: Find which package the ref_id belongs to
59    for pkg_name in &package_names {
60        // The ref must start with "{pkg_name}." to be a cross-package ref
61        if ref_id.starts_with(pkg_name) && ref_id[pkg_name.len()..].starts_with('.') {
62            let local_node_id = &ref_id[pkg_name.len() + 1..]; // strip "pkg_name."
63
64            // Step 3: Find the ResolvedPackage
65            let resolved = imported_packages
66                .iter()
67                .find(|p| p.package == *pkg_name)
68                .unwrap(); // safe: pkg_name came from this list
69
70            // Step 4: Find the node in the resolved package
71            for node in &resolved.file.nodes {
72                if node.id == local_node_id {
73                    return Ok(node);
74                }
75            }
76
77            // Package found but node not found
78            return Err(AgmError::new(
79                ErrorCode::I004,
80                format!("Cross-package reference to non-existent node: `{ref_id}`"),
81                ErrorLocation::default(),
82            ));
83        }
84    }
85
86    // No imported package prefix matched
87    Err(AgmError::new(
88        ErrorCode::I004,
89        format!("Cross-package reference to non-existent node: `{ref_id}`"),
90        ErrorLocation::default(),
91    ))
92}
93
94// ---------------------------------------------------------------------------
95// detect_circular_imports
96// ---------------------------------------------------------------------------
97
98/// Detects circular imports starting from the root package.
99///
100/// Performs a DFS traversal of the import graph. For each package encountered,
101/// its header is parsed to discover its own imports, which are then recursively
102/// checked.
103///
104/// Returns `Err(I003)` with the cycle path if a cycle is found.
105/// Returns `Ok(())` if no cycles exist.
106///
107/// `root_package` is the name of the top-level package being validated.
108/// `root_imports` is the list of that package's validated imports.
109/// `resolver` is used to resolve each package and inspect its imports.
110pub fn detect_circular_imports(
111    root_package: &str,
112    root_imports: &[ValidatedImport],
113    resolver: &dyn ImportResolver,
114) -> Result<(), AgmError> {
115    let mut visited: HashSet<String> = HashSet::new();
116    let mut in_stack: HashSet<String> = HashSet::new();
117    let mut path: Vec<String> = Vec::new();
118
119    dfs(
120        root_package,
121        root_imports,
122        resolver,
123        &mut visited,
124        &mut in_stack,
125        &mut path,
126    )
127}
128
129fn dfs(
130    package: &str,
131    imports: &[ValidatedImport],
132    resolver: &dyn ImportResolver,
133    visited: &mut HashSet<String>,
134    in_stack: &mut HashSet<String>,
135    path: &mut Vec<String>,
136) -> Result<(), AgmError> {
137    if in_stack.contains(package) {
138        // Found a cycle — build the cycle path string
139        let cycle_start = path.iter().position(|p| p == package).unwrap();
140        let cycle_path = path[cycle_start..]
141            .iter()
142            .chain(std::iter::once(&package.to_owned()))
143            .cloned()
144            .collect::<Vec<_>>()
145            .join(" -> ");
146        return Err(AgmError::new(
147            ErrorCode::I003,
148            format!("Circular import detected: `{cycle_path}`"),
149            ErrorLocation::default(),
150        ));
151    }
152
153    if visited.contains(package) {
154        return Ok(()); // already fully explored, no cycle through here
155    }
156
157    visited.insert(package.to_owned());
158    in_stack.insert(package.to_owned());
159    path.push(package.to_owned());
160
161    for import in imports {
162        let dep_package = import.package().to_owned();
163
164        // Try to resolve the dependency to get its imports
165        match resolver.resolve(import) {
166            Ok(resolved) => {
167                // Extract the resolved package's own imports
168                let dep_imports = match &resolved.file.header.imports {
169                    Some(entries) => {
170                        let (validated, _errors) = validate_all_imports(entries);
171                        validated
172                    }
173                    None => vec![],
174                };
175
176                // Recurse into the dependency
177                dfs(
178                    &dep_package,
179                    &dep_imports,
180                    resolver,
181                    visited,
182                    in_stack,
183                    path,
184                )?;
185            }
186            Err(_) => {
187                // If resolution fails (I001/I002), skip this edge.
188                // The resolver error will be reported separately.
189                continue;
190            }
191        }
192    }
193
194    path.pop();
195    in_stack.remove(package);
196    Ok(())
197}
198
199// ---------------------------------------------------------------------------
200// check_deprecated
201// ---------------------------------------------------------------------------
202
203/// Checks if a resolved package is deprecated and returns an I005 warning if so.
204pub fn check_deprecated(resolved: &ResolvedPackage) -> Option<AgmError> {
205    if resolved.file.header.status.as_deref() == Some("deprecated") {
206        Some(AgmError::new(
207            ErrorCode::I005,
208            format!("Import `{}` is deprecated", resolved.package),
209            ErrorLocation::default(),
210        ))
211    } else {
212        None
213    }
214}
215
216// ---------------------------------------------------------------------------
217// Tests
218// ---------------------------------------------------------------------------
219
220#[cfg(test)]
221mod tests {
222    use std::collections::BTreeMap;
223    use std::collections::HashMap;
224    use std::path::PathBuf;
225
226    use super::*;
227    use crate::error::ErrorCode;
228    use crate::model::fields::{NodeType, Span};
229    use crate::model::file::{AgmFile, Header};
230    use crate::model::imports::ImportEntry;
231    use crate::model::node::Node;
232
233    // -----------------------------------------------------------------------
234    // Helpers
235    // -----------------------------------------------------------------------
236
237    fn make_agm_file(package: &str, version: &str, nodes: Vec<Node>) -> AgmFile {
238        AgmFile {
239            header: Header {
240                agm: "1.0".to_owned(),
241                package: package.to_owned(),
242                version: version.to_owned(),
243                title: None,
244                owner: None,
245                imports: None,
246                default_load: None,
247                description: None,
248                tags: None,
249                status: None,
250                load_profiles: None,
251                target_runtime: None,
252            },
253            nodes,
254        }
255    }
256
257    fn make_agm_file_with_imports(
258        package: &str,
259        version: &str,
260        nodes: Vec<Node>,
261        imports: Vec<ImportEntry>,
262    ) -> AgmFile {
263        AgmFile {
264            header: Header {
265                agm: "1.0".to_owned(),
266                package: package.to_owned(),
267                version: version.to_owned(),
268                title: None,
269                owner: None,
270                imports: Some(imports),
271                default_load: None,
272                description: None,
273                tags: None,
274                status: None,
275                load_profiles: None,
276                target_runtime: None,
277            },
278            nodes,
279        }
280    }
281
282    fn make_node(id: &str) -> Node {
283        Node {
284            id: id.to_owned(),
285            node_type: NodeType::Facts,
286            summary: format!("Test node {id}"),
287            priority: None,
288            stability: None,
289            confidence: None,
290            status: None,
291            depends: None,
292            related_to: None,
293            replaces: None,
294            conflicts: None,
295            see_also: None,
296            items: None,
297            steps: None,
298            fields: None,
299            input: None,
300            output: None,
301            detail: None,
302            rationale: None,
303            tradeoffs: None,
304            resolution: None,
305            examples: None,
306            notes: None,
307            code: None,
308            code_blocks: None,
309            verify: None,
310            agent_context: None,
311            target: None,
312            execution_status: None,
313            executed_by: None,
314            executed_at: None,
315            execution_log: None,
316            retry_count: None,
317            parallel_groups: None,
318            memory: None,
319            scope: None,
320            applies_when: None,
321            valid_from: None,
322            valid_until: None,
323            tags: None,
324            aliases: None,
325            keywords: None,
326            extra_fields: BTreeMap::new(),
327            span: Span::new(1, 1),
328        }
329    }
330
331    fn make_resolved(package: &str, version: &str, nodes: Vec<Node>) -> ResolvedPackage {
332        ResolvedPackage {
333            package: package.to_owned(),
334            version: semver::Version::parse(version).unwrap(),
335            path: PathBuf::from(format!(".agm/packages/{package}/pkg.agm")),
336            file: make_agm_file(package, version, nodes),
337        }
338    }
339
340    fn make_resolved_with_imports(
341        package: &str,
342        version: &str,
343        nodes: Vec<Node>,
344        imports: Vec<ImportEntry>,
345    ) -> ResolvedPackage {
346        ResolvedPackage {
347            package: package.to_owned(),
348            version: semver::Version::parse(version).unwrap(),
349            path: PathBuf::from(format!(".agm/packages/{package}/pkg.agm")),
350            file: make_agm_file_with_imports(package, version, nodes, imports),
351        }
352    }
353
354    // -----------------------------------------------------------------------
355    // MockResolver for circular import tests
356    // -----------------------------------------------------------------------
357
358    struct MockResolver {
359        packages: HashMap<String, ResolvedPackage>,
360    }
361
362    impl MockResolver {
363        fn new() -> Self {
364            Self {
365                packages: HashMap::new(),
366            }
367        }
368
369        fn add(&mut self, package: ResolvedPackage) {
370            self.packages.insert(package.package.clone(), package);
371        }
372    }
373
374    impl ImportResolver for MockResolver {
375        fn resolve(&self, import: &ValidatedImport) -> Result<ResolvedPackage, AgmError> {
376            self.packages.get(import.package()).cloned().ok_or_else(|| {
377                AgmError::new(
378                    ErrorCode::I001,
379                    format!("Unresolved import: `{}`", import.package()),
380                    ErrorLocation::default(),
381                )
382            })
383        }
384    }
385
386    // -----------------------------------------------------------------------
387    // Category D: Namespace qualification
388    // -----------------------------------------------------------------------
389
390    #[test]
391    fn test_qualify_node_id_produces_dotted_id() {
392        assert_eq!(
393            qualify_node_id("shared.security", "auth.rules"),
394            "shared.security.auth.rules"
395        );
396    }
397
398    #[test]
399    fn test_qualify_node_id_simple_names() {
400        assert_eq!(qualify_node_id("core", "setup"), "core.setup");
401    }
402
403    // -----------------------------------------------------------------------
404    // Category E: Cross-package reference resolution
405    // -----------------------------------------------------------------------
406
407    #[test]
408    fn test_resolve_cross_package_ref_finds_node() {
409        let node = make_node("auth.rules");
410        let packages = vec![make_resolved("shared.security", "1.0.0", vec![node])];
411        let result = resolve_cross_package_ref("shared.security.auth.rules", &packages).unwrap();
412        assert_eq!(result.id, "auth.rules");
413    }
414
415    #[test]
416    fn test_resolve_cross_package_ref_nonexistent_node_returns_i004() {
417        let node = make_node("auth.rules");
418        let packages = vec![make_resolved("shared.security", "1.0.0", vec![node])];
419        let err = resolve_cross_package_ref("shared.security.nonexistent", &packages).unwrap_err();
420        assert_eq!(err.code, ErrorCode::I004);
421    }
422
423    #[test]
424    fn test_resolve_cross_package_ref_no_matching_package_returns_i004() {
425        let node = make_node("auth.rules");
426        let packages = vec![make_resolved("shared.security", "1.0.0", vec![node])];
427        let err = resolve_cross_package_ref("unknown.pkg.node", &packages).unwrap_err();
428        assert_eq!(err.code, ErrorCode::I004);
429    }
430
431    #[test]
432    fn test_resolve_cross_package_ref_longest_prefix_wins() {
433        // Packages: "shared" and "shared.security"
434        // Reference: "shared.security.auth.rules" should resolve in "shared.security"
435        let shared_node = make_node("security.auth.rules"); // would match if "shared" pkg was chosen
436        let security_node = make_node("auth.rules"); // correct node in "shared.security"
437        let packages = vec![
438            make_resolved("shared", "1.0.0", vec![shared_node]),
439            make_resolved("shared.security", "1.0.0", vec![security_node]),
440        ];
441        let result = resolve_cross_package_ref("shared.security.auth.rules", &packages).unwrap();
442        assert_eq!(result.id, "auth.rules");
443    }
444
445    // -----------------------------------------------------------------------
446    // Category F: Circular import detection
447    // -----------------------------------------------------------------------
448
449    #[test]
450    fn test_detect_circular_imports_no_cycle_returns_ok() {
451        // A -> B, B -> C, no cycle
452        let entry_b = ImportEntry::new("B".to_owned(), None);
453        let entry_c = ImportEntry::new("C".to_owned(), None);
454
455        let pkg_b = make_resolved_with_imports("B", "1.0.0", vec![], vec![entry_c]);
456        let pkg_c = make_resolved("C", "1.0.0", vec![]);
457
458        let mut mock = MockResolver::new();
459        mock.add(pkg_b);
460        mock.add(pkg_c);
461
462        let root_imports = vec![validate_import(&entry_b).unwrap()];
463        let result = detect_circular_imports("A", &root_imports, &mock);
464        assert!(result.is_ok());
465    }
466
467    #[test]
468    fn test_detect_circular_imports_direct_cycle_returns_i003() {
469        // A -> B, B -> A
470        let entry_a = ImportEntry::new("A".to_owned(), None);
471        let entry_b = ImportEntry::new("B".to_owned(), None);
472
473        // B imports A; A (as a resolved package) imports B (to complete the cycle)
474        let pkg_b = make_resolved_with_imports("B", "1.0.0", vec![], vec![entry_a]);
475        let pkg_a = make_resolved_with_imports("A", "1.0.0", vec![], vec![entry_b.clone()]);
476
477        let mut mock = MockResolver::new();
478        mock.add(pkg_b);
479        mock.add(pkg_a);
480
481        let root_imports = vec![validate_import(&entry_b).unwrap()];
482        let err = detect_circular_imports("A", &root_imports, &mock).unwrap_err();
483        assert_eq!(err.code, ErrorCode::I003);
484        assert!(
485            err.message.contains("A -> B -> A"),
486            "message: {}",
487            err.message
488        );
489    }
490
491    #[test]
492    fn test_detect_circular_imports_transitive_cycle_returns_i003() {
493        // A -> B -> C -> A
494        let entry_a = ImportEntry::new("A".to_owned(), None);
495        let entry_b = ImportEntry::new("B".to_owned(), None);
496        let entry_c = ImportEntry::new("C".to_owned(), None);
497
498        // B imports C; C imports A; A (as a resolved package) imports B (to complete the cycle)
499        let pkg_b = make_resolved_with_imports("B", "1.0.0", vec![], vec![entry_c]);
500        let pkg_c = make_resolved_with_imports("C", "1.0.0", vec![], vec![entry_a]);
501        let pkg_a = make_resolved_with_imports("A", "1.0.0", vec![], vec![entry_b.clone()]);
502
503        let mut mock = MockResolver::new();
504        mock.add(pkg_b);
505        mock.add(pkg_c);
506        mock.add(pkg_a);
507
508        let root_imports = vec![validate_import(&entry_b).unwrap()];
509        let err = detect_circular_imports("A", &root_imports, &mock).unwrap_err();
510        assert_eq!(err.code, ErrorCode::I003);
511        assert!(
512            err.message.contains("A -> B -> C -> A"),
513            "message: {}",
514            err.message
515        );
516    }
517
518    #[test]
519    fn test_detect_circular_imports_diamond_no_cycle_returns_ok() {
520        // A -> B, A -> C, B -> D, C -> D (diamond, not a cycle)
521        let entry_b = ImportEntry::new("B".to_owned(), None);
522        let entry_c = ImportEntry::new("C".to_owned(), None);
523        let entry_d = ImportEntry::new("D".to_owned(), None);
524
525        let pkg_b = make_resolved_with_imports("B", "1.0.0", vec![], vec![entry_d.clone()]);
526        let pkg_c = make_resolved_with_imports("C", "1.0.0", vec![], vec![entry_d]);
527        let pkg_d = make_resolved("D", "1.0.0", vec![]);
528
529        let mut mock = MockResolver::new();
530        mock.add(pkg_b);
531        mock.add(pkg_c);
532        mock.add(pkg_d);
533
534        let root_imports = vec![
535            validate_import(&entry_b).unwrap(),
536            validate_import(&entry_c).unwrap(),
537        ];
538        let result = detect_circular_imports("A", &root_imports, &mock);
539        assert!(result.is_ok());
540    }
541}