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 fn compute_merged_dependencies(&self) -> Option<BTreeMap<String, Vec<DependencySpec>>> {
208 let mut merged: BTreeMap<String, Vec<DependencySpec>> = BTreeMap::new();
209 let mut seen_paths: std::collections::HashSet<String> = std::collections::HashSet::new();
210
211 if let Some(root_deps) = &self.dependencies {
213 for (resource_type, specs) in root_deps {
214 let filtered_specs: Vec<DependencySpec> = specs
215 .iter()
216 .filter(|spec| seen_paths.insert(spec.path.clone()))
217 .cloned()
218 .collect();
219
220 if !filtered_specs.is_empty() {
221 merged.insert(resource_type.clone(), filtered_specs);
222 }
223 }
224 }
225
226 if let Some(agpm) = &self.agpm {
228 if let Some(nested_deps) = &agpm.dependencies {
229 for (resource_type, specs) in nested_deps {
230 let existing_specs = merged.entry(resource_type.clone()).or_default();
231 let filtered_specs: Vec<DependencySpec> = specs
232 .iter()
233 .filter(|spec| seen_paths.insert(spec.path.clone()))
234 .cloned()
235 .collect();
236
237 existing_specs.extend(filtered_specs);
238
239 if existing_specs.is_empty() {
241 merged.remove(resource_type);
242 }
243 }
244 }
245 }
246
247 if merged.is_empty() {
249 None
250 } else {
251 Some(merged)
252 }
253 }
254
255 pub fn has_dependencies(&self) -> bool {
257 self.get_dependencies()
258 .is_some_and(|deps| !deps.is_empty() && deps.values().any(|v| !v.is_empty()))
259 }
260
261 pub fn dependency_count(&self) -> usize {
263 self.get_dependencies().map_or(0, |deps| deps.values().map(std::vec::Vec::len).sum())
264 }
265
266 pub fn merge(&mut self, other: Self) {
270 self.merged_cache = std::cell::OnceCell::new();
272
273 if let Some(other_deps) = other.dependencies {
274 let deps = self.dependencies.get_or_insert_with(BTreeMap::new);
275 for (resource_type, specs) in other_deps {
276 deps.entry(resource_type).or_default().extend(specs);
277 }
278 }
279
280 if let Some(other_agpm) = other.agpm {
282 if let Some(other_agpm_deps) = other_agpm.dependencies {
283 let agpm = self.agpm.get_or_insert(AgpmMetadata {
284 templating: None,
285 dependencies: None,
286 });
287 let agpm_deps = agpm.dependencies.get_or_insert_with(BTreeMap::new);
288 for (resource_type, specs) in other_agpm_deps {
289 agpm_deps.entry(resource_type).or_default().extend(specs);
290 }
291 }
292 }
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn test_dependency_spec_serialization() {
302 let spec = DependencySpec {
303 path: "agents/helper.md".to_string(),
304 name: None,
305 version: Some("v1.0.0".to_string()),
306 tool: None,
307 flatten: None,
308 install: None,
309 };
310
311 let yaml = serde_yaml::to_string(&spec).unwrap();
312 assert!(yaml.contains("path: agents/helper.md"));
313 assert!(yaml.contains("version: v1.0.0"));
314
315 let deserialized: DependencySpec = serde_yaml::from_str(&yaml).unwrap();
316 assert_eq!(spec, deserialized);
317 }
318
319 #[test]
320 fn test_dependency_spec_with_tool() {
321 let spec = DependencySpec {
322 path: "agents/helper.md".to_string(),
323 name: None,
324 version: Some("v1.0.0".to_string()),
325 tool: Some("opencode".to_string()),
326 flatten: None,
327 install: None,
328 };
329
330 let yaml = serde_yaml::to_string(&spec).unwrap();
331 assert!(yaml.contains("path: agents/helper.md"));
332 assert!(yaml.contains("version: v1.0.0"));
333 assert!(yaml.contains("tool: opencode"));
334
335 let deserialized: DependencySpec = serde_yaml::from_str(&yaml).unwrap();
336 assert_eq!(spec, deserialized);
337 assert_eq!(deserialized.tool, Some("opencode".to_string()));
338 }
339
340 #[test]
341 fn test_dependency_metadata_has_dependencies() {
342 let metadata = DependencyMetadata::default();
343 assert!(!metadata.has_dependencies());
344
345 let metadata = DependencyMetadata::new(Some(BTreeMap::new()), None);
346 assert!(!metadata.has_dependencies());
347
348 let mut deps = BTreeMap::new();
349 deps.insert("agents".to_string(), vec![]);
350 let metadata = DependencyMetadata::new(Some(deps), None);
351 assert!(!metadata.has_dependencies());
352
353 let mut deps = BTreeMap::new();
354 deps.insert(
355 "agents".to_string(),
356 vec![DependencySpec {
357 path: "test.md".to_string(),
358 name: None,
359 version: None,
360 tool: None,
361 flatten: None,
362 install: None,
363 }],
364 );
365 let metadata = DependencyMetadata::new(Some(deps), None);
366 assert!(metadata.has_dependencies());
367 }
368
369 #[test]
370 fn test_dependency_metadata_merge() {
371 let mut metadata1 = DependencyMetadata::default();
372 let mut deps1 = BTreeMap::new();
373 deps1.insert(
374 "agents".to_string(),
375 vec![DependencySpec {
376 path: "agent1.md".to_string(),
377 name: None,
378 version: None,
379 tool: None,
380 flatten: None,
381 install: None,
382 }],
383 );
384 metadata1.dependencies = Some(deps1);
385
386 let mut metadata2 = DependencyMetadata::default();
387 let mut deps2 = BTreeMap::new();
388 deps2.insert(
389 "agents".to_string(),
390 vec![DependencySpec {
391 path: "agent2.md".to_string(),
392 name: None,
393 version: None,
394 tool: None,
395 flatten: None,
396 install: None,
397 }],
398 );
399 deps2.insert(
400 "snippets".to_string(),
401 vec![DependencySpec {
402 path: "snippet1.md".to_string(),
403 name: None,
404 version: Some("v1.0.0".to_string()),
405 tool: None,
406 flatten: None,
407 install: None,
408 }],
409 );
410 metadata2.dependencies = Some(deps2);
411
412 metadata1.merge(metadata2);
413
414 assert_eq!(metadata1.dependency_count(), 3);
415 let deps = metadata1.get_dependencies().unwrap();
416 assert_eq!(deps["agents"].len(), 2);
417 assert_eq!(deps["snippets"].len(), 1);
418 }
419
420 #[test]
421 fn test_merged_dependencies_root_only() {
422 let mut root_deps = BTreeMap::new();
423 root_deps.insert(
424 "agents".to_string(),
425 vec![DependencySpec {
426 path: "agent1.md".to_string(),
427 name: None,
428 version: Some("v1.0.0".to_string()),
429 tool: None,
430 flatten: None,
431 install: None,
432 }],
433 );
434 let metadata = DependencyMetadata::new(Some(root_deps), None);
435
436 let merged = metadata.get_dependencies().unwrap();
437 assert_eq!(merged.len(), 1);
438 assert_eq!(merged["agents"].len(), 1);
439 assert_eq!(merged["agents"][0].path, "agent1.md");
440 assert_eq!(metadata.dependency_count(), 1);
441 assert!(metadata.has_dependencies());
442 }
443
444 #[test]
445 fn test_merged_dependencies_nested_only() {
446 let mut nested_deps = BTreeMap::new();
447 nested_deps.insert(
448 "snippets".to_string(),
449 vec![DependencySpec {
450 path: "utils.md".to_string(),
451 name: Some("utils".to_string()),
452 version: Some("v2.0.0".to_string()),
453 tool: None,
454 flatten: None,
455 install: None,
456 }],
457 );
458 let agpm = AgpmMetadata {
459 templating: Some(true),
460 dependencies: Some(nested_deps),
461 };
462 let metadata = DependencyMetadata::new(None, Some(agpm));
463
464 let merged = metadata.get_dependencies().unwrap();
465 assert_eq!(merged.len(), 1);
466 assert_eq!(merged["snippets"].len(), 1);
467 assert_eq!(merged["snippets"][0].path, "utils.md");
468 assert_eq!(merged["snippets"][0].name, Some("utils".to_string()));
469 assert_eq!(metadata.dependency_count(), 1);
470 assert!(metadata.has_dependencies());
471 }
472
473 #[test]
474 fn test_merged_dependencies_both_sources() {
475 let mut root_deps = BTreeMap::new();
477 root_deps.insert(
478 "agents".to_string(),
479 vec![
480 DependencySpec {
481 path: "agent1.md".to_string(),
482 name: None,
483 version: Some("v1.0.0".to_string()),
484 tool: None,
485 flatten: None,
486 install: None,
487 },
488 DependencySpec {
489 path: "shared.md".to_string(),
490 name: Some("shared_root".to_string()),
491 version: Some("v1.0.0".to_string()),
492 tool: None,
493 flatten: None,
494 install: None,
495 },
496 ],
497 );
498
499 let mut nested_deps = BTreeMap::new();
501 nested_deps.insert(
502 "snippets".to_string(),
503 vec![DependencySpec {
504 path: "utils.md".to_string(),
505 name: None,
506 version: Some("v2.0.0".to_string()),
507 tool: None,
508 flatten: None,
509 install: None,
510 }],
511 );
512 nested_deps.insert(
513 "agents".to_string(),
514 vec![
515 DependencySpec {
516 path: "agent2.md".to_string(),
517 name: None,
518 version: Some("v2.0.0".to_string()),
519 tool: None,
520 flatten: None,
521 install: None,
522 },
523 DependencySpec {
525 path: "shared.md".to_string(),
526 name: Some("shared_nested".to_string()),
527 version: Some("v2.0.0".to_string()),
528 tool: None,
529 flatten: None,
530 install: None,
531 },
532 ],
533 );
534 let agpm = AgpmMetadata {
535 templating: Some(true),
536 dependencies: Some(nested_deps),
537 };
538 let metadata = DependencyMetadata::new(Some(root_deps), Some(agpm));
539
540 let merged = metadata.get_dependencies().unwrap();
541
542 assert_eq!(merged.len(), 2);
544
545 assert_eq!(merged["agents"].len(), 3);
547 assert_eq!(merged["agents"][0].path, "agent1.md");
548 assert_eq!(merged["agents"][1].path, "shared.md");
549 assert_eq!(merged["agents"][1].name, Some("shared_root".to_string()));
550 assert_eq!(merged["agents"][2].path, "agent2.md");
551
552 assert_eq!(merged["snippets"].len(), 1);
554 assert_eq!(merged["snippets"][0].path, "utils.md");
555
556 assert_eq!(metadata.dependency_count(), 4);
557 assert!(metadata.has_dependencies());
558 }
559
560 #[test]
561 fn test_merged_dependencies_no_duplicates() {
562 let mut root_deps = BTreeMap::new();
564 root_deps.insert(
565 "agents".to_string(),
566 vec![
567 DependencySpec {
568 path: "agent.md".to_string(),
569 name: None,
570 version: Some("v1.0.0".to_string()),
571 tool: None,
572 flatten: None,
573 install: None,
574 },
575 DependencySpec {
576 path: "agent.md".to_string(),
577 name: Some("custom".to_string()),
578 version: Some("v1.0.0".to_string()),
579 tool: None,
580 flatten: None,
581 install: None,
582 },
583 ],
584 );
585
586 let mut nested_deps = BTreeMap::new();
588 nested_deps.insert(
589 "agents".to_string(),
590 vec![DependencySpec {
591 path: "agent.md".to_string(),
592 name: Some("nested".to_string()),
593 version: Some("v2.0.0".to_string()),
594 tool: None,
595 flatten: None,
596 install: None,
597 }],
598 );
599 let agpm = AgpmMetadata {
600 templating: None,
601 dependencies: Some(nested_deps),
602 };
603 let metadata = DependencyMetadata::new(Some(root_deps), Some(agpm));
604
605 let merged = metadata.get_dependencies().unwrap();
606
607 assert_eq!(merged.len(), 1);
609 assert_eq!(merged["agents"].len(), 1);
610 assert_eq!(merged["agents"][0].path, "agent.md");
611 assert_eq!(merged["agents"][0].name, None); assert_eq!(metadata.dependency_count(), 1);
614 }
615
616 #[test]
617 fn test_merged_dependencies_empty() {
618 let metadata = DependencyMetadata::default();
619
620 assert!(metadata.get_dependencies().is_none());
621 assert_eq!(metadata.dependency_count(), 0);
622 assert!(!metadata.has_dependencies());
623 }
624
625 #[test]
626 fn test_merged_dependencies_empty_maps() {
627 let agpm = AgpmMetadata {
628 templating: None,
629 dependencies: Some(BTreeMap::new()),
630 };
631 let metadata = DependencyMetadata::new(Some(BTreeMap::new()), Some(agpm));
632
633 assert!(metadata.get_dependencies().is_none());
634 assert_eq!(metadata.dependency_count(), 0);
635 assert!(!metadata.has_dependencies());
636 }
637
638 #[test]
639 fn test_merged_dependencies_with_agpm_merge() {
640 let mut metadata1 = DependencyMetadata::default();
641 let mut root_deps = BTreeMap::new();
642 root_deps.insert(
643 "agents".to_string(),
644 vec![DependencySpec {
645 path: "agent1.md".to_string(),
646 name: None,
647 version: None,
648 tool: None,
649 flatten: None,
650 install: None,
651 }],
652 );
653 metadata1.dependencies = Some(root_deps);
654
655 let mut metadata2 = DependencyMetadata::default();
656 let mut nested_deps = BTreeMap::new();
657 nested_deps.insert(
658 "snippets".to_string(),
659 vec![DependencySpec {
660 path: "snippet1.md".to_string(),
661 name: None,
662 version: None,
663 tool: None,
664 flatten: None,
665 install: None,
666 }],
667 );
668 metadata2.agpm = Some(AgpmMetadata {
669 templating: Some(true),
670 dependencies: Some(nested_deps),
671 });
672
673 metadata1.merge(metadata2);
674
675 let merged = metadata1.get_dependencies().unwrap();
676 assert_eq!(merged.len(), 2); assert_eq!(metadata1.dependency_count(), 2);
678 assert!(metadata1.agpm.is_some());
679 assert!(metadata1.agpm.unwrap().dependencies.is_some());
680 }
681}