1use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15#[serde(deny_unknown_fields)]
16pub struct DependencySpec {
17 pub path: String,
23
24 #[serde(skip_serializing_if = "Option::is_none")]
38 pub name: Option<String>,
39
40 #[serde(skip_serializing_if = "Option::is_none")]
49 pub version: Option<String>,
50
51 #[serde(skip_serializing_if = "Option::is_none")]
59 pub tool: Option<String>,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
71 pub flatten: Option<bool>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
95 pub install: Option<bool>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
104pub struct DependencyMetadata {
105 #[serde(skip_serializing_if = "Option::is_none")]
120 pub dependencies: Option<BTreeMap<String, Vec<DependencySpec>>>,
121
122 #[serde(skip_serializing_if = "Option::is_none")]
133 pub agpm: Option<AgpmMetadata>,
134
135 #[serde(skip)]
138 merged_cache: std::cell::OnceCell<BTreeMap<String, Vec<DependencySpec>>>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
143pub struct AgpmMetadata {
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub templating: Option<bool>,
147
148 #[serde(skip_serializing_if = "Option::is_none")]
159 pub dependencies: Option<BTreeMap<String, Vec<DependencySpec>>>,
160}
161
162impl DependencyMetadata {
163 pub fn new(
165 dependencies: Option<BTreeMap<String, Vec<DependencySpec>>>,
166 agpm: Option<AgpmMetadata>,
167 ) -> Self {
168 Self {
169 dependencies,
170 agpm,
171 merged_cache: std::cell::OnceCell::new(),
172 }
173 }
174
175 pub fn get_dependencies(&self) -> Option<&BTreeMap<String, Vec<DependencySpec>>> {
181 let has_root_deps = self.dependencies.is_some();
183 let has_nested_deps =
184 self.agpm.as_ref().and_then(|agpm| agpm.dependencies.as_ref()).is_some();
185
186 if !has_root_deps && !has_nested_deps {
187 return None;
188 }
189
190 let merged = self
192 .merged_cache
193 .get_or_init(|| self.compute_merged_dependencies().unwrap_or_default());
194
195 if merged.is_empty() {
197 None
198 } else {
199 Some(merged)
200 }
201 }
202
203 pub fn get_dependencies_typed(
213 &self,
214 ) -> Option<std::collections::HashMap<crate::core::ResourceType, Vec<DependencySpec>>> {
215 let deps = self.get_dependencies()?;
216 let mut result = std::collections::HashMap::new();
217
218 for (resource_type_str, specs) in deps {
219 if let Ok(resource_type) = resource_type_str.parse::<crate::core::ResourceType>() {
221 result.insert(resource_type, specs.clone());
222 } else {
223 tracing::warn!("Unknown resource type in dependencies: {}", resource_type_str);
224 }
225 }
226
227 if result.is_empty() {
228 None
229 } else {
230 Some(result)
231 }
232 }
233
234 fn compute_merged_dependencies(&self) -> Option<BTreeMap<String, Vec<DependencySpec>>> {
239 let mut merged: BTreeMap<String, Vec<DependencySpec>> = BTreeMap::new();
240 let mut seen_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
241
242 if let Some(root_deps) = &self.dependencies {
244 for (resource_type, specs) in root_deps {
245 let filtered_specs: Vec<DependencySpec> = specs
246 .iter()
247 .filter(|spec| seen_paths.insert(spec.path.clone()))
248 .cloned()
249 .collect();
250
251 if !filtered_specs.is_empty() {
252 merged.insert(resource_type.clone(), filtered_specs);
253 }
254 }
255 }
256
257 if let Some(agpm) = &self.agpm {
259 if let Some(nested_deps) = &agpm.dependencies {
260 for (resource_type, specs) in nested_deps {
261 let existing_specs = merged.entry(resource_type.clone()).or_default();
262 let filtered_specs: Vec<DependencySpec> = specs
263 .iter()
264 .filter(|spec| seen_paths.insert(spec.path.clone()))
265 .cloned()
266 .collect();
267
268 existing_specs.extend(filtered_specs);
269
270 if existing_specs.is_empty() {
272 merged.remove(resource_type);
273 }
274 }
275 }
276 }
277
278 if merged.is_empty() {
280 None
281 } else {
282 Some(merged)
283 }
284 }
285
286 pub fn has_dependencies(&self) -> bool {
288 self.get_dependencies()
289 .is_some_and(|deps| !deps.is_empty() && deps.values().any(|v| !v.is_empty()))
290 }
291
292 pub fn dependency_count(&self) -> usize {
294 self.get_dependencies().map_or(0, |deps| deps.values().map(std::vec::Vec::len).sum())
295 }
296
297 pub fn merge(&mut self, other: Self) {
301 self.merged_cache = std::cell::OnceCell::new();
303
304 if let Some(other_deps) = other.dependencies {
305 let deps = self.dependencies.get_or_insert_with(BTreeMap::new);
306 for (resource_type, specs) in other_deps {
307 deps.entry(resource_type).or_default().extend(specs);
308 }
309 }
310
311 if let Some(other_agpm) = other.agpm {
313 if let Some(other_agpm_deps) = other_agpm.dependencies {
314 let agpm = self.agpm.get_or_insert(AgpmMetadata {
315 templating: None,
316 dependencies: None,
317 });
318 let agpm_deps = agpm.dependencies.get_or_insert_with(BTreeMap::new);
319 for (resource_type, specs) in other_agpm_deps {
320 agpm_deps.entry(resource_type).or_default().extend(specs);
321 }
322 }
323 }
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn test_dependency_spec_serialization() {
333 let spec = DependencySpec {
334 path: "agents/helper.md".to_string(),
335 name: None,
336 version: Some("v1.0.0".to_string()),
337 tool: None,
338 flatten: None,
339 install: None,
340 };
341
342 let yaml = serde_yaml::to_string(&spec).unwrap();
343 assert!(yaml.contains("path: agents/helper.md"));
344 assert!(yaml.contains("version: v1.0.0"));
345
346 let deserialized: DependencySpec = serde_yaml::from_str(&yaml).unwrap();
347 assert_eq!(spec, deserialized);
348 }
349
350 #[test]
351 fn test_dependency_spec_with_tool() {
352 let spec = DependencySpec {
353 path: "agents/helper.md".to_string(),
354 name: None,
355 version: Some("v1.0.0".to_string()),
356 tool: Some("opencode".to_string()),
357 flatten: None,
358 install: None,
359 };
360
361 let yaml = serde_yaml::to_string(&spec).unwrap();
362 assert!(yaml.contains("path: agents/helper.md"));
363 assert!(yaml.contains("version: v1.0.0"));
364 assert!(yaml.contains("tool: opencode"));
365
366 let deserialized: DependencySpec = serde_yaml::from_str(&yaml).unwrap();
367 assert_eq!(spec, deserialized);
368 assert_eq!(deserialized.tool, Some("opencode".to_string()));
369 }
370
371 #[test]
372 fn test_dependency_metadata_has_dependencies() {
373 let metadata = DependencyMetadata::default();
374 assert!(!metadata.has_dependencies());
375
376 let metadata = DependencyMetadata::new(Some(BTreeMap::new()), None);
377 assert!(!metadata.has_dependencies());
378
379 let mut deps = BTreeMap::new();
380 deps.insert("agents".to_string(), vec![]);
381 let metadata = DependencyMetadata::new(Some(deps), None);
382 assert!(!metadata.has_dependencies());
383
384 let mut deps = BTreeMap::new();
385 deps.insert(
386 "agents".to_string(),
387 vec![DependencySpec {
388 path: "test.md".to_string(),
389 name: None,
390 version: None,
391 tool: None,
392 flatten: None,
393 install: None,
394 }],
395 );
396 let metadata = DependencyMetadata::new(Some(deps), None);
397 assert!(metadata.has_dependencies());
398 }
399
400 #[test]
401 fn test_dependency_metadata_merge() {
402 let mut metadata1 = DependencyMetadata::default();
403 let mut deps1 = BTreeMap::new();
404 deps1.insert(
405 "agents".to_string(),
406 vec![DependencySpec {
407 path: "agent1.md".to_string(),
408 name: None,
409 version: None,
410 tool: None,
411 flatten: None,
412 install: None,
413 }],
414 );
415 metadata1.dependencies = Some(deps1);
416
417 let mut metadata2 = DependencyMetadata::default();
418 let mut deps2 = BTreeMap::new();
419 deps2.insert(
420 "agents".to_string(),
421 vec![DependencySpec {
422 path: "agent2.md".to_string(),
423 name: None,
424 version: None,
425 tool: None,
426 flatten: None,
427 install: None,
428 }],
429 );
430 deps2.insert(
431 "snippets".to_string(),
432 vec![DependencySpec {
433 path: "snippet1.md".to_string(),
434 name: None,
435 version: Some("v1.0.0".to_string()),
436 tool: None,
437 flatten: None,
438 install: None,
439 }],
440 );
441 metadata2.dependencies = Some(deps2);
442
443 metadata1.merge(metadata2);
444
445 assert_eq!(metadata1.dependency_count(), 3);
446 let deps = metadata1.get_dependencies().unwrap();
447 assert_eq!(deps["agents"].len(), 2);
448 assert_eq!(deps["snippets"].len(), 1);
449 }
450
451 #[test]
452 fn test_merged_dependencies_root_only() {
453 let mut root_deps = BTreeMap::new();
454 root_deps.insert(
455 "agents".to_string(),
456 vec![DependencySpec {
457 path: "agent1.md".to_string(),
458 name: None,
459 version: Some("v1.0.0".to_string()),
460 tool: None,
461 flatten: None,
462 install: None,
463 }],
464 );
465 let metadata = DependencyMetadata::new(Some(root_deps), None);
466
467 let merged = metadata.get_dependencies().unwrap();
468 assert_eq!(merged.len(), 1);
469 assert_eq!(merged["agents"].len(), 1);
470 assert_eq!(merged["agents"][0].path, "agent1.md");
471 assert_eq!(metadata.dependency_count(), 1);
472 assert!(metadata.has_dependencies());
473 }
474
475 #[test]
476 fn test_merged_dependencies_nested_only() {
477 let mut nested_deps = BTreeMap::new();
478 nested_deps.insert(
479 "snippets".to_string(),
480 vec![DependencySpec {
481 path: "utils.md".to_string(),
482 name: Some("utils".to_string()),
483 version: Some("v2.0.0".to_string()),
484 tool: None,
485 flatten: None,
486 install: None,
487 }],
488 );
489 let agpm = AgpmMetadata {
490 templating: Some(true),
491 dependencies: Some(nested_deps),
492 };
493 let metadata = DependencyMetadata::new(None, Some(agpm));
494
495 let merged = metadata.get_dependencies().unwrap();
496 assert_eq!(merged.len(), 1);
497 assert_eq!(merged["snippets"].len(), 1);
498 assert_eq!(merged["snippets"][0].path, "utils.md");
499 assert_eq!(merged["snippets"][0].name, Some("utils".to_string()));
500 assert_eq!(metadata.dependency_count(), 1);
501 assert!(metadata.has_dependencies());
502 }
503
504 #[test]
505 fn test_merged_dependencies_both_sources() {
506 let mut root_deps = BTreeMap::new();
508 root_deps.insert(
509 "agents".to_string(),
510 vec![
511 DependencySpec {
512 path: "agent1.md".to_string(),
513 name: None,
514 version: Some("v1.0.0".to_string()),
515 tool: None,
516 flatten: None,
517 install: None,
518 },
519 DependencySpec {
520 path: "shared.md".to_string(),
521 name: Some("shared_root".to_string()),
522 version: Some("v1.0.0".to_string()),
523 tool: None,
524 flatten: None,
525 install: None,
526 },
527 ],
528 );
529
530 let mut nested_deps = BTreeMap::new();
532 nested_deps.insert(
533 "snippets".to_string(),
534 vec![DependencySpec {
535 path: "utils.md".to_string(),
536 name: None,
537 version: Some("v2.0.0".to_string()),
538 tool: None,
539 flatten: None,
540 install: None,
541 }],
542 );
543 nested_deps.insert(
544 "agents".to_string(),
545 vec![
546 DependencySpec {
547 path: "agent2.md".to_string(),
548 name: None,
549 version: Some("v2.0.0".to_string()),
550 tool: None,
551 flatten: None,
552 install: None,
553 },
554 DependencySpec {
556 path: "shared.md".to_string(),
557 name: Some("shared_nested".to_string()),
558 version: Some("v2.0.0".to_string()),
559 tool: None,
560 flatten: None,
561 install: None,
562 },
563 ],
564 );
565 let agpm = AgpmMetadata {
566 templating: Some(true),
567 dependencies: Some(nested_deps),
568 };
569 let metadata = DependencyMetadata::new(Some(root_deps), Some(agpm));
570
571 let merged = metadata.get_dependencies().unwrap();
572
573 assert_eq!(merged.len(), 2);
575
576 assert_eq!(merged["agents"].len(), 3);
578 assert_eq!(merged["agents"][0].path, "agent1.md");
579 assert_eq!(merged["agents"][1].path, "shared.md");
580 assert_eq!(merged["agents"][1].name, Some("shared_root".to_string()));
581 assert_eq!(merged["agents"][2].path, "agent2.md");
582
583 assert_eq!(merged["snippets"].len(), 1);
585 assert_eq!(merged["snippets"][0].path, "utils.md");
586
587 assert_eq!(metadata.dependency_count(), 4);
588 assert!(metadata.has_dependencies());
589 }
590
591 #[test]
592 fn test_merged_dependencies_no_duplicates() {
593 let mut root_deps = BTreeMap::new();
595 root_deps.insert(
596 "agents".to_string(),
597 vec![
598 DependencySpec {
599 path: "agent.md".to_string(),
600 name: None,
601 version: Some("v1.0.0".to_string()),
602 tool: None,
603 flatten: None,
604 install: None,
605 },
606 DependencySpec {
607 path: "agent.md".to_string(),
608 name: Some("custom".to_string()),
609 version: Some("v1.0.0".to_string()),
610 tool: None,
611 flatten: None,
612 install: None,
613 },
614 ],
615 );
616
617 let mut nested_deps = BTreeMap::new();
619 nested_deps.insert(
620 "agents".to_string(),
621 vec![DependencySpec {
622 path: "agent.md".to_string(),
623 name: Some("nested".to_string()),
624 version: Some("v2.0.0".to_string()),
625 tool: None,
626 flatten: None,
627 install: None,
628 }],
629 );
630 let agpm = AgpmMetadata {
631 templating: None,
632 dependencies: Some(nested_deps),
633 };
634 let metadata = DependencyMetadata::new(Some(root_deps), Some(agpm));
635
636 let merged = metadata.get_dependencies().unwrap();
637
638 assert_eq!(merged.len(), 1);
640 assert_eq!(merged["agents"].len(), 1);
641 assert_eq!(merged["agents"][0].path, "agent.md");
642 assert_eq!(merged["agents"][0].name, None); assert_eq!(metadata.dependency_count(), 1);
645 }
646
647 #[test]
648 fn test_merged_dependencies_empty() {
649 let metadata = DependencyMetadata::default();
650
651 assert!(metadata.get_dependencies().is_none());
652 assert_eq!(metadata.dependency_count(), 0);
653 assert!(!metadata.has_dependencies());
654 }
655
656 #[test]
657 fn test_merged_dependencies_empty_maps() {
658 let agpm = AgpmMetadata {
659 templating: None,
660 dependencies: Some(BTreeMap::new()),
661 };
662 let metadata = DependencyMetadata::new(Some(BTreeMap::new()), Some(agpm));
663
664 assert!(metadata.get_dependencies().is_none());
665 assert_eq!(metadata.dependency_count(), 0);
666 assert!(!metadata.has_dependencies());
667 }
668
669 #[test]
670 fn test_merged_dependencies_with_agpm_merge() {
671 let mut metadata1 = DependencyMetadata::default();
672 let mut root_deps = BTreeMap::new();
673 root_deps.insert(
674 "agents".to_string(),
675 vec![DependencySpec {
676 path: "agent1.md".to_string(),
677 name: None,
678 version: None,
679 tool: None,
680 flatten: None,
681 install: None,
682 }],
683 );
684 metadata1.dependencies = Some(root_deps);
685
686 let mut metadata2 = DependencyMetadata::default();
687 let mut nested_deps = BTreeMap::new();
688 nested_deps.insert(
689 "snippets".to_string(),
690 vec![DependencySpec {
691 path: "snippet1.md".to_string(),
692 name: None,
693 version: None,
694 tool: None,
695 flatten: None,
696 install: None,
697 }],
698 );
699 metadata2.agpm = Some(AgpmMetadata {
700 templating: Some(true),
701 dependencies: Some(nested_deps),
702 });
703
704 metadata1.merge(metadata2);
705
706 let merged = metadata1.get_dependencies().unwrap();
707 assert_eq!(merged.len(), 2); assert_eq!(metadata1.dependency_count(), 2);
709 assert!(metadata1.agpm.is_some());
710 assert!(metadata1.agpm.unwrap().dependencies.is_some());
711 }
712}