1use 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#[derive(Debug, Clone, PartialEq)]
18pub struct ResolvedPackage {
19 pub package: String,
21 pub version: semver::Version,
23 pub path: PathBuf,
25 pub file: AgmFile,
27}
28
29pub trait ImportResolver {
35 fn resolve(&self, import: &ValidatedImport) -> Result<ResolvedPackage, AgmError>;
40}
41
42#[derive(Debug, Clone)]
53pub struct FileSystemResolver {
54 search_paths: Vec<PathBuf>,
56}
57
58impl FileSystemResolver {
59 #[must_use]
61 pub fn new(search_paths: Vec<PathBuf>) -> Self {
62 Self { search_paths }
63 }
64
65 #[must_use]
67 pub fn single(path: impl Into<PathBuf>) -> Self {
68 Self {
69 search_paths: vec![path.into()],
70 }
71 }
72
73 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 let file = match parser::parse(&source) {
119 Ok(f) => f,
120 Err(_) => continue,
121 };
122
123 if file.header.package == package_name {
125 candidates.push((path, file));
126 }
127 }
128 }
129 }
130
131 Ok(candidates)
132 }
133
134 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 let version = match semver::Version::parse(&file.header.version) {
145 Ok(v) => v,
146 Err(_) => continue, };
148
149 if !import.matches_version(&version) {
151 continue;
152 }
153
154 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 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#[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 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 #[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 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_agm_file(&pkg_dir, "valid.agm", "shared.http", "1.0.0");
317
318 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 #[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 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 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 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}