1pub 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#[must_use]
27pub fn qualify_node_id(package: &str, node_id: &str) -> String {
28 format!("{package}.{node_id}")
29}
30
31pub fn resolve_cross_package_ref<'a>(
47 ref_id: &str,
48 imported_packages: &'a [ResolvedPackage],
49) -> Result<&'a Node, AgmError> {
50 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 for pkg_name in &package_names {
60 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..]; let resolved = imported_packages
66 .iter()
67 .find(|p| p.package == *pkg_name)
68 .unwrap(); for node in &resolved.file.nodes {
72 if node.id == local_node_id {
73 return Ok(node);
74 }
75 }
76
77 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 Err(AgmError::new(
88 ErrorCode::I004,
89 format!("Cross-package reference to non-existent node: `{ref_id}`"),
90 ErrorLocation::default(),
91 ))
92}
93
94pub 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 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(()); }
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 match resolver.resolve(import) {
166 Ok(resolved) => {
167 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 dfs(
178 &dep_package,
179 &dep_imports,
180 resolver,
181 visited,
182 in_stack,
183 path,
184 )?;
185 }
186 Err(_) => {
187 continue;
190 }
191 }
192 }
193
194 path.pop();
195 in_stack.remove(package);
196 Ok(())
197}
198
199pub 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#[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 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 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 #[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 #[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 let shared_node = make_node("security.auth.rules"); let security_node = make_node("auth.rules"); 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 #[test]
450 fn test_detect_circular_imports_no_cycle_returns_ok() {
451 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 let entry_a = ImportEntry::new("A".to_owned(), None);
471 let entry_b = ImportEntry::new("B".to_owned(), None);
472
473 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 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 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 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}