Skip to main content

greentic_setup/
capabilities.rs

1//! Capability validation and auto-upgrade for provider gtpacks.
2//!
3//! Provider gtpacks must contain a `greentic.ext.capabilities.v1` extension
4//! in their `manifest.cbor` for the operator to discover and mount them.
5//! Old gtpacks built before the capabilities extension was introduced will
6//! silently fail at runtime.
7//!
8//! This module provides validation during `gtc setup` and auto-upgrade from
9//! known source locations when a newer pack with capabilities is found.
10
11use std::io::Read;
12use std::path::{Path, PathBuf};
13
14use anyhow::Context;
15use zip::ZipArchive;
16
17use crate::discovery;
18
19const EXT_CAPABILITIES_V1: &str = "greentic.ext.capabilities.v1";
20
21fn canonicalize_or_path(path: &Path) -> PathBuf {
22    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
23}
24
25/// Result of validating and upgrading packs in a bundle.
26pub struct UpgradeReport {
27    pub checked: usize,
28    pub upgraded: Vec<UpgradedPack>,
29    pub warnings: Vec<PackWarning>,
30}
31
32pub struct UpgradedPack {
33    pub provider_id: String,
34    pub source_path: PathBuf,
35}
36
37pub struct PackWarning {
38    pub provider_id: String,
39    pub message: String,
40}
41
42/// Check whether a gtpack has the `greentic.ext.capabilities.v1` extension.
43pub fn has_capabilities_extension(pack_path: &Path) -> bool {
44    read_has_capabilities(pack_path).unwrap_or(false)
45}
46
47fn read_has_capabilities(pack_path: &Path) -> anyhow::Result<bool> {
48    let file = std::fs::File::open(pack_path)?;
49    let mut archive = ZipArchive::new(file)?;
50    let mut entry = match archive.by_name("manifest.cbor") {
51        Ok(e) => e,
52        Err(_) => return Ok(false),
53    };
54    let mut bytes = Vec::new();
55    entry.read_to_end(&mut bytes)?;
56    // Search for the capabilities extension key in the raw CBOR bytes.
57    // This avoids depending on the exact CBOR schema which may vary between
58    // greentic-types versions. The string is unique enough to be reliable.
59    Ok(bytes
60        .windows(EXT_CAPABILITIES_V1.len())
61        .any(|w| w == EXT_CAPABILITIES_V1.as_bytes()))
62}
63
64/// Search known source locations for a replacement gtpack that has capabilities.
65///
66/// Search order:
67/// 1. Sibling bundles in the same parent directory
68/// 2. `greentic-messaging-providers/target/packs/` in ancestor dirs
69fn find_replacement_pack(pack_filename: &str, bundle_path: &Path, domain: &str) -> Option<PathBuf> {
70    let bundle_abs = canonicalize_or_path(bundle_path);
71    let parent = bundle_abs.parent()?;
72
73    // 1. Sibling bundles: ../*/providers/{domain}/{filename}
74    if let Ok(entries) = std::fs::read_dir(parent) {
75        for entry in entries.flatten() {
76            let candidate_bundle = canonicalize_or_path(&entry.path());
77            if candidate_bundle == bundle_abs || !candidate_bundle.is_dir() {
78                continue;
79            }
80            let candidate = candidate_bundle
81                .join("providers")
82                .join(domain)
83                .join(pack_filename);
84            if candidate.is_file() && has_capabilities_extension(&candidate) {
85                return Some(candidate);
86            }
87        }
88    }
89
90    // 2. greentic-messaging-providers build output in ancestor dirs
91    for ancestor in parent.ancestors().take(4) {
92        let candidate = ancestor
93            .join("greentic-messaging-providers")
94            .join("target")
95            .join("packs")
96            .join(pack_filename);
97        if candidate.is_file() && has_capabilities_extension(&candidate) {
98            return Some(candidate);
99        }
100    }
101
102    None
103}
104
105/// Validate all provider gtpacks in a bundle and auto-upgrade those missing capabilities.
106pub fn validate_and_upgrade_packs(bundle_path: &Path) -> anyhow::Result<UpgradeReport> {
107    let discovered = discovery::discover(bundle_path)
108        .context("failed to discover providers for capability validation")?;
109
110    let mut report = UpgradeReport {
111        checked: 0,
112        upgraded: Vec::new(),
113        warnings: Vec::new(),
114    };
115
116    for provider in &discovered.providers {
117        report.checked += 1;
118
119        if has_capabilities_extension(&provider.pack_path) {
120            continue;
121        }
122
123        let pack_filename = provider
124            .pack_path
125            .file_name()
126            .and_then(|n| n.to_str())
127            .unwrap_or("");
128
129        if pack_filename.is_empty() {
130            continue;
131        }
132
133        // Try to find a replacement
134        if let Some(replacement) =
135            find_replacement_pack(pack_filename, bundle_path, &provider.domain)
136        {
137            // Backup original
138            let backup = provider.pack_path.with_extension("gtpack.bak");
139            std::fs::copy(&provider.pack_path, &backup).with_context(|| {
140                format!(
141                    "failed to backup {} before upgrade",
142                    provider.pack_path.display()
143                )
144            })?;
145
146            // Copy replacement
147            std::fs::copy(&replacement, &provider.pack_path).with_context(|| {
148                format!(
149                    "failed to copy replacement pack from {}",
150                    replacement.display()
151                )
152            })?;
153
154            println!(
155                "  [upgrade] {}: replaced with {} (capabilities extension added)",
156                provider.provider_id,
157                replacement.display()
158            );
159
160            report.upgraded.push(UpgradedPack {
161                provider_id: provider.provider_id.clone(),
162                source_path: replacement,
163            });
164        } else {
165            let msg = format!(
166                "pack missing greentic.ext.capabilities.v1 — operator will not detect this provider. \
167                 Replace with a newer build of {}",
168                pack_filename,
169            );
170            println!("  [warn] {}: {}", provider.provider_id, msg);
171            report.warnings.push(PackWarning {
172                provider_id: provider.provider_id.clone(),
173                message: msg,
174            });
175        }
176    }
177
178    Ok(report)
179}
180
181// ---------------------------------------------------------------------------
182// Dependency capability validation
183// ---------------------------------------------------------------------------
184
185/// Report of dependency capability validation across all packs in the bundle.
186pub struct DependencyReport {
187    pub satisfied: Vec<SatisfiedCapability>,
188    pub missing: Vec<MissingCapability>,
189}
190
191pub struct SatisfiedCapability {
192    pub capability: String,
193    pub required_by: String,
194    pub provided_by: String,
195}
196
197pub struct MissingCapability {
198    pub capability: String,
199    pub required_by: String,
200}
201
202/// Validate that all pack dependencies have their required_capabilities
203/// satisfied by other packs in the bundle.
204pub fn validate_dependency_capabilities(bundle_path: &Path) -> anyhow::Result<DependencyReport> {
205    let discovered = discovery::discover(bundle_path)
206        .context("failed to discover providers for dependency validation")?;
207
208    let mut report = DependencyReport {
209        satisfied: Vec::new(),
210        missing: Vec::new(),
211    };
212
213    // Build capability index: capability_name → provider_id.
214    let mut capability_providers: std::collections::BTreeMap<String, String> =
215        std::collections::BTreeMap::new();
216    for provider in &discovered.providers {
217        if let Ok(caps) = read_pack_capabilities(&provider.pack_path) {
218            for cap_name in caps {
219                capability_providers
220                    .entry(cap_name)
221                    .or_insert_with(|| provider.provider_id.clone());
222            }
223        }
224    }
225
226    // Check each pack's dependencies.
227    let mut pack_id_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
228    for provider in &discovered.providers {
229        pack_id_set.insert(provider.provider_id.clone());
230    }
231
232    for provider in &discovered.providers {
233        let deps = match read_pack_dependencies(&provider.pack_path) {
234            Ok(d) => d,
235            Err(_) => continue,
236        };
237        for (dep_pack_id, required_caps) in deps {
238            // Skip if dependency pack_id is present directly.
239            if pack_id_set.contains(&dep_pack_id) {
240                continue;
241            }
242            for cap in &required_caps {
243                if let Some(provided_by) = capability_providers.get(cap) {
244                    report.satisfied.push(SatisfiedCapability {
245                        capability: cap.clone(),
246                        required_by: provider.provider_id.clone(),
247                        provided_by: provided_by.clone(),
248                    });
249                } else {
250                    report.missing.push(MissingCapability {
251                        capability: cap.clone(),
252                        required_by: provider.provider_id.clone(),
253                    });
254                }
255            }
256        }
257    }
258
259    Ok(report)
260}
261
262/// Read capability names from a gtpack manifest.
263fn read_pack_capabilities(pack_path: &Path) -> anyhow::Result<Vec<String>> {
264    let file = std::fs::File::open(pack_path)?;
265    let mut archive = ZipArchive::new(file)?;
266    let mut entry = archive.by_name("manifest.cbor")?;
267    let mut bytes = Vec::new();
268    entry.read_to_end(&mut bytes)?;
269    let cbor: serde_cbor::Value = serde_cbor::from_slice(&bytes)?;
270
271    let mut caps = Vec::new();
272    if let serde_cbor::Value::Map(ref map) = cbor
273        && let Some(serde_cbor::Value::Array(arr)) =
274            map.get(&serde_cbor::Value::Text("capabilities".to_string()))
275    {
276        for item in arr {
277            if let serde_cbor::Value::Map(cap_map) = item
278                && let Some(serde_cbor::Value::Text(name)) =
279                    cap_map.get(&serde_cbor::Value::Text("name".to_string()))
280            {
281                caps.push(name.clone());
282            }
283        }
284    }
285    Ok(caps)
286}
287
288/// Read dependencies from a gtpack manifest.
289/// Returns Vec of (pack_id, required_capabilities).
290fn read_pack_dependencies(pack_path: &Path) -> anyhow::Result<Vec<(String, Vec<String>)>> {
291    let file = std::fs::File::open(pack_path)?;
292    let mut archive = ZipArchive::new(file)?;
293    let mut entry = archive.by_name("manifest.cbor")?;
294    let mut bytes = Vec::new();
295    entry.read_to_end(&mut bytes)?;
296    let cbor: serde_cbor::Value = serde_cbor::from_slice(&bytes)?;
297
298    let mut deps = Vec::new();
299    if let serde_cbor::Value::Map(ref map) = cbor
300        && let Some(serde_cbor::Value::Array(arr)) =
301            map.get(&serde_cbor::Value::Text("dependencies".to_string()))
302    {
303        for item in arr {
304            if let serde_cbor::Value::Map(dep_map) = item {
305                let pack_id = dep_map
306                    .get(&serde_cbor::Value::Text("pack_id".to_string()))
307                    .and_then(|v| {
308                        if let serde_cbor::Value::Text(s) = v {
309                            Some(s.clone())
310                        } else {
311                            None
312                        }
313                    })
314                    .unwrap_or_default();
315                let req_caps: Vec<String> = dep_map
316                    .get(&serde_cbor::Value::Text(
317                        "required_capabilities".to_string(),
318                    ))
319                    .and_then(|v| {
320                        if let serde_cbor::Value::Array(arr) = v {
321                            Some(
322                                arr.iter()
323                                    .filter_map(|item| {
324                                        if let serde_cbor::Value::Text(s) = item {
325                                            Some(s.clone())
326                                        } else {
327                                            None
328                                        }
329                                    })
330                                    .collect(),
331                            )
332                        } else {
333                            None
334                        }
335                    })
336                    .unwrap_or_default();
337                if !pack_id.is_empty() && !req_caps.is_empty() {
338                    deps.push((pack_id, req_caps));
339                }
340            }
341        }
342    }
343    Ok(deps)
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use std::collections::BTreeMap;
350    use std::fs::File;
351    use std::io::Write;
352    use zip::write::{FileOptions, ZipWriter};
353
354    use serde_cbor::value::Value as CV;
355
356    /// Write a minimal gtpack zip with a CBOR manifest.
357    fn write_test_gtpack(path: &Path, with_capabilities: bool) {
358        write_test_gtpack_manifest(path, "test-provider", with_capabilities, &[], &[], true);
359    }
360
361    fn write_test_gtpack_manifest(
362        path: &Path,
363        pack_id: &str,
364        with_extension: bool,
365        capabilities: &[&str],
366        dependencies: &[(&str, &[&str])],
367        include_manifest: bool,
368    ) {
369        let file = File::create(path).expect("create file");
370        let mut zip = ZipWriter::new(file);
371        if !include_manifest {
372            zip.start_file("README.txt", FileOptions::<()>::default())
373                .expect("start file");
374            zip.write_all(b"no manifest").expect("write placeholder");
375            zip.finish().expect("finish zip");
376            return;
377        }
378
379        let mut map = BTreeMap::new();
380        map.insert(
381            CV::Text("schema_version".into()),
382            CV::Text("pack-v1".into()),
383        );
384        map.insert(CV::Text("pack_id".into()), CV::Text(pack_id.into()));
385        map.insert(CV::Text("version".into()), CV::Text("0.1.0".into()));
386        map.insert(CV::Text("kind".into()), CV::Text("provider".into()));
387        map.insert(CV::Text("publisher".into()), CV::Text("test".into()));
388
389        if with_extension {
390            let mut ext_inner = BTreeMap::new();
391            ext_inner.insert(
392                CV::Text("kind".into()),
393                CV::Text(EXT_CAPABILITIES_V1.into()),
394            );
395            ext_inner.insert(CV::Text("version".into()), CV::Text("1.0.0".into()));
396
397            let mut exts = BTreeMap::new();
398            exts.insert(CV::Text(EXT_CAPABILITIES_V1.into()), CV::Map(ext_inner));
399            map.insert(CV::Text("extensions".into()), CV::Map(exts));
400        }
401
402        if !capabilities.is_empty() {
403            let caps = capabilities
404                .iter()
405                .map(|cap| {
406                    CV::Map(BTreeMap::from([(
407                        CV::Text("name".into()),
408                        CV::Text((*cap).into()),
409                    )]))
410                })
411                .collect();
412            map.insert(CV::Text("capabilities".into()), CV::Array(caps));
413        }
414
415        if !dependencies.is_empty() {
416            let deps = dependencies
417                .iter()
418                .map(|(pack, caps)| {
419                    let req_caps = caps
420                        .iter()
421                        .map(|cap| CV::Text((*cap).into()))
422                        .collect::<Vec<_>>();
423                    CV::Map(BTreeMap::from([
424                        (CV::Text("pack_id".into()), CV::Text((*pack).into())),
425                        (
426                            CV::Text("required_capabilities".into()),
427                            CV::Array(req_caps),
428                        ),
429                    ]))
430                })
431                .collect();
432            map.insert(CV::Text("dependencies".into()), CV::Array(deps));
433        }
434
435        let manifest = CV::Map(map);
436        let bytes = serde_cbor::to_vec(&manifest).expect("encode cbor");
437        zip.start_file("manifest.cbor", FileOptions::<()>::default())
438            .expect("start file");
439        zip.write_all(&bytes).expect("write manifest");
440        zip.finish().expect("finish zip");
441    }
442
443    #[test]
444    fn has_capabilities_returns_true_when_present() {
445        let dir = tempfile::tempdir().unwrap();
446        let pack = dir.path().join("test.gtpack");
447        write_test_gtpack(&pack, true);
448        assert!(has_capabilities_extension(&pack));
449    }
450
451    #[test]
452    fn has_capabilities_returns_false_when_missing() {
453        let dir = tempfile::tempdir().unwrap();
454        let pack = dir.path().join("test.gtpack");
455        write_test_gtpack(&pack, false);
456        assert!(!has_capabilities_extension(&pack));
457    }
458
459    #[test]
460    fn has_capabilities_returns_false_for_nonexistent() {
461        assert!(!has_capabilities_extension(Path::new(
462            "/nonexistent.gtpack"
463        )));
464    }
465
466    #[test]
467    fn has_capabilities_returns_false_without_manifest_entry() {
468        let dir = tempfile::tempdir().unwrap();
469        let pack = dir.path().join("test.gtpack");
470        write_test_gtpack_manifest(&pack, "test-provider", false, &[], &[], false);
471        assert!(!has_capabilities_extension(&pack));
472    }
473
474    #[test]
475    fn find_replacement_from_sibling_bundle() {
476        let root = tempfile::tempdir().unwrap();
477
478        // Bundle A: no capabilities
479        let bundle_a = root.path().join("bundle-a");
480        let providers_a = bundle_a.join("providers").join("messaging");
481        std::fs::create_dir_all(&providers_a).unwrap();
482        write_test_gtpack(&providers_a.join("messaging-test.gtpack"), false);
483
484        // Bundle B: has capabilities
485        let bundle_b = root.path().join("bundle-b");
486        let providers_b = bundle_b.join("providers").join("messaging");
487        std::fs::create_dir_all(&providers_b).unwrap();
488        write_test_gtpack(&providers_b.join("messaging-test.gtpack"), true);
489
490        let result = find_replacement_pack("messaging-test.gtpack", &bundle_a, "messaging");
491        assert!(result.is_some());
492        assert!(
493            canonicalize_or_path(&result.unwrap()).starts_with(canonicalize_or_path(&bundle_b))
494        );
495    }
496
497    #[test]
498    fn find_replacement_returns_none_when_no_better_pack() {
499        let root = tempfile::tempdir().unwrap();
500        let bundle = root.path().join("bundle");
501        std::fs::create_dir_all(bundle.join("providers").join("messaging")).unwrap();
502        write_test_gtpack(
503            &bundle
504                .join("providers")
505                .join("messaging")
506                .join("test.gtpack"),
507            false,
508        );
509
510        let result = find_replacement_pack("test.gtpack", &bundle, "messaging");
511        assert!(result.is_none());
512    }
513
514    #[test]
515    fn find_replacement_from_ancestor_pack_output() {
516        let root = tempfile::tempdir().unwrap();
517        let nested = root.path().join("workspace").join("team").join("bundle");
518        std::fs::create_dir_all(nested.join("providers").join("messaging")).unwrap();
519        write_test_gtpack(
520            &nested
521                .join("providers")
522                .join("messaging")
523                .join("messaging-test.gtpack"),
524            false,
525        );
526
527        let pack_output = root
528            .path()
529            .join("workspace")
530            .join("greentic-messaging-providers")
531            .join("target")
532            .join("packs");
533        std::fs::create_dir_all(&pack_output).unwrap();
534        let replacement = pack_output.join("messaging-test.gtpack");
535        write_test_gtpack(&replacement, true);
536
537        let result = find_replacement_pack("messaging-test.gtpack", &nested, "messaging");
538        assert_eq!(
539            result.as_deref().map(canonicalize_or_path),
540            Some(canonicalize_or_path(&replacement))
541        );
542    }
543
544    #[test]
545    fn validate_and_upgrade_packs_reports_zero_for_empty_bundle() {
546        let dir = tempfile::tempdir().unwrap();
547        let report = validate_and_upgrade_packs(dir.path()).unwrap();
548        assert_eq!(report.checked, 0);
549        assert!(report.upgraded.is_empty());
550        assert!(report.warnings.is_empty());
551    }
552
553    #[test]
554    fn validate_and_upgrade_packs_skips_packs_with_capabilities() {
555        let dir = tempfile::tempdir().unwrap();
556        let providers = dir.path().join("providers").join("messaging");
557        std::fs::create_dir_all(&providers).unwrap();
558        write_test_gtpack(&providers.join("good.gtpack"), true);
559
560        let report = validate_and_upgrade_packs(dir.path()).unwrap();
561        assert_eq!(report.checked, 1);
562        assert!(report.upgraded.is_empty());
563        assert!(report.warnings.is_empty());
564    }
565
566    #[test]
567    fn validate_and_upgrade_packs_warns_when_no_replacement_exists() {
568        let root = tempfile::tempdir().unwrap();
569        let bundle = root.path().join("bundle");
570        let providers = bundle.join("providers").join("messaging");
571        std::fs::create_dir_all(&providers).unwrap();
572        let pack = providers.join("messaging-test.gtpack");
573        write_test_gtpack_manifest(&pack, "messaging-test", false, &[], &[], true);
574
575        let report = validate_and_upgrade_packs(&bundle).unwrap();
576        assert_eq!(report.checked, 1);
577        assert!(report.upgraded.is_empty());
578        assert_eq!(report.warnings.len(), 1);
579        assert_eq!(report.warnings[0].provider_id, "messaging-test");
580        assert!(
581            report.warnings[0]
582                .message
583                .contains("Replace with a newer build")
584        );
585        assert!(!pack.with_extension("gtpack.bak").exists());
586    }
587
588    #[test]
589    fn validate_and_upgrade_packs_replaces_pack_and_writes_backup() {
590        let root = tempfile::tempdir().unwrap();
591        let bundle_a = root.path().join("bundle-a");
592        let providers_a = bundle_a.join("providers").join("messaging");
593        std::fs::create_dir_all(&providers_a).unwrap();
594        let original_pack = providers_a.join("messaging-test.gtpack");
595        write_test_gtpack_manifest(&original_pack, "messaging-test", false, &[], &[], true);
596
597        let bundle_b = root.path().join("bundle-b");
598        let providers_b = bundle_b.join("providers").join("messaging");
599        std::fs::create_dir_all(&providers_b).unwrap();
600        let replacement_pack = providers_b.join("messaging-test.gtpack");
601        write_test_gtpack_manifest(
602            &replacement_pack,
603            "messaging-test",
604            true,
605            &["cap.messaging"],
606            &[],
607            true,
608        );
609
610        let report = validate_and_upgrade_packs(&bundle_a).unwrap();
611        assert_eq!(report.checked, 1);
612        assert_eq!(report.upgraded.len(), 1);
613        assert!(report.warnings.is_empty());
614        assert_eq!(report.upgraded[0].provider_id, "messaging-test");
615        assert_eq!(
616            canonicalize_or_path(&report.upgraded[0].source_path),
617            canonicalize_or_path(&replacement_pack)
618        );
619        assert!(original_pack.with_extension("gtpack.bak").exists());
620        assert!(has_capabilities_extension(&original_pack));
621    }
622
623    #[test]
624    fn read_pack_capabilities_returns_declared_names() {
625        let dir = tempfile::tempdir().unwrap();
626        let pack = dir.path().join("provider.gtpack");
627        write_test_gtpack_manifest(
628            &pack,
629            "provider-a",
630            true,
631            &["cap.alpha", "cap.beta"],
632            &[],
633            true,
634        );
635
636        let caps = read_pack_capabilities(&pack).unwrap();
637        assert_eq!(caps, vec!["cap.alpha".to_string(), "cap.beta".to_string()]);
638    }
639
640    #[test]
641    fn read_pack_dependencies_ignores_incomplete_entries() {
642        let dir = tempfile::tempdir().unwrap();
643        let pack = dir.path().join("provider.gtpack");
644        write_test_gtpack_manifest(
645            &pack,
646            "provider-a",
647            false,
648            &[],
649            &[
650                ("pack-a", &["cap.alpha"]),
651                ("", &["cap.skip"]),
652                ("pack-b", &[]),
653            ],
654            true,
655        );
656
657        let deps = read_pack_dependencies(&pack).unwrap();
658        assert_eq!(
659            deps,
660            vec![("pack-a".to_string(), vec!["cap.alpha".to_string()])]
661        );
662    }
663
664    #[test]
665    fn validate_dependency_capabilities_tracks_satisfied_and_missing_caps() {
666        let root = tempfile::tempdir().unwrap();
667        let providers = root
668            .path()
669            .join("bundle")
670            .join("providers")
671            .join("messaging");
672        std::fs::create_dir_all(&providers).unwrap();
673
674        write_test_gtpack_manifest(
675            &providers.join("provider-a.gtpack"),
676            "provider-a",
677            true,
678            &["cap.shared"],
679            &[],
680            true,
681        );
682        write_test_gtpack_manifest(
683            &providers.join("provider-b.gtpack"),
684            "provider-b",
685            true,
686            &[],
687            &[("external-pack", &["cap.shared", "cap.missing"])],
688            true,
689        );
690        write_test_gtpack_manifest(
691            &providers.join("provider-c.gtpack"),
692            "provider-c",
693            true,
694            &[],
695            &[("provider-a", &["cap.shared"])],
696            true,
697        );
698
699        let report = validate_dependency_capabilities(&root.path().join("bundle")).unwrap();
700        assert_eq!(report.satisfied.len(), 1);
701        assert_eq!(report.satisfied[0].capability, "cap.shared");
702        assert_eq!(report.satisfied[0].required_by, "provider-b");
703        assert_eq!(report.satisfied[0].provided_by, "provider-a");
704        assert_eq!(report.missing.len(), 1);
705        assert_eq!(report.missing[0].capability, "cap.missing");
706        assert_eq!(report.missing[0].required_by, "provider-b");
707    }
708
709    #[test]
710    fn validate_dependency_capabilities_skips_dependency_present_in_bundle() {
711        let root = tempfile::tempdir().unwrap();
712        let providers = root
713            .path()
714            .join("bundle")
715            .join("providers")
716            .join("messaging");
717        std::fs::create_dir_all(&providers).unwrap();
718
719        // Pack X declares a dependency on pack-id "provider-y" — and provider-y
720        // is present in the bundle. The dep should be skipped entirely.
721        write_test_gtpack_manifest(
722            &providers.join("provider-x.gtpack"),
723            "provider-x",
724            true,
725            &[],
726            &[("provider-y", &["whatever.cap"])],
727            true,
728        );
729        write_test_gtpack_manifest(
730            &providers.join("provider-y.gtpack"),
731            "provider-y",
732            true,
733            &[],
734            &[],
735            true,
736        );
737
738        let report = validate_dependency_capabilities(&root.path().join("bundle")).unwrap();
739        assert!(report.satisfied.is_empty());
740        assert!(report.missing.is_empty());
741    }
742
743    #[test]
744    fn validate_dependency_capabilities_returns_empty_for_empty_bundle() {
745        let dir = tempfile::tempdir().unwrap();
746        let report = validate_dependency_capabilities(dir.path()).unwrap();
747        assert!(report.satisfied.is_empty());
748        assert!(report.missing.is_empty());
749    }
750}