Skip to main content

packc/cli/
providers.rs

1#![forbid(unsafe_code)]
2
3use std::collections::{BTreeMap, HashSet};
4use std::fs::File;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result, anyhow, bail};
9use clap::{Args, Subcommand};
10use greentic_pack::archive_shape::{CANONICAL_MANIFEST_ENTRY, non_canonical_archive_message};
11use greentic_types::pack_manifest::{PackManifest, PackSignatures};
12use greentic_types::provider::{ProviderDecl, ProviderExtensionInline};
13use greentic_types::{PackId, PackKind, decode_pack_manifest};
14use tempfile::TempDir;
15use zip::ZipArchive;
16
17use crate::cli::input::materialize_pack_path;
18
19#[derive(Debug, Subcommand)]
20pub enum ProvidersCommand {
21    /// List providers declared in the provider extension.
22    List(ListArgs),
23    /// Show details for a specific provider id.
24    Info(InfoArgs),
25    /// Validate provider extension contents.
26    Validate(ValidateArgs),
27}
28
29#[derive(Debug, Args)]
30pub struct ListArgs {
31    /// Path to a .gtpack archive or pack source directory (defaults to current dir).
32    #[arg(long = "pack", value_name = "PATH")]
33    pub pack: Option<PathBuf>,
34
35    /// Emit JSON output
36    #[arg(long)]
37    pub json: bool,
38}
39
40#[derive(Debug, Args)]
41pub struct InfoArgs {
42    /// Provider identifier to inspect.
43    #[arg(value_name = "PROVIDER_ID")]
44    pub provider_id: String,
45
46    /// Path to a .gtpack archive or pack source directory (defaults to current dir).
47    #[arg(long = "pack", value_name = "PATH")]
48    pub pack: Option<PathBuf>,
49
50    /// Emit JSON output
51    #[arg(long)]
52    pub json: bool,
53}
54
55#[derive(Debug, Args)]
56pub struct ValidateArgs {
57    /// Path to a .gtpack archive or pack source directory (defaults to current dir).
58    #[arg(long = "pack", value_name = "PATH")]
59    pub pack: Option<PathBuf>,
60
61    /// Treat warnings as errors (e.g. missing local references).
62    #[arg(long)]
63    pub strict: bool,
64
65    /// Emit JSON output
66    #[arg(long)]
67    pub json: bool,
68}
69
70pub fn run(cmd: ProvidersCommand) -> Result<()> {
71    match cmd {
72        ProvidersCommand::List(args) => list(&args),
73        ProvidersCommand::Info(args) => info(&args),
74        ProvidersCommand::Validate(args) => validate(&args),
75    }
76}
77
78pub fn list(args: &ListArgs) -> Result<()> {
79    let pack = load_pack(args.pack.as_deref())?;
80    let providers = providers_from_manifest(&pack.manifest);
81
82    if args.json {
83        println!("{}", serde_json::to_string_pretty(&providers)?);
84        return Ok(());
85    }
86
87    if providers.is_empty() {
88        println!(
89            "{}",
90            crate::cli_i18n::t("cli.providers.no_providers_declared")
91        );
92        return Ok(());
93    }
94
95    println!("{}", crate::cli_i18n::t("cli.providers.table_header"));
96    for provider in providers {
97        let runtime = format!(
98            "{}::{}",
99            provider.runtime.component_ref, provider.runtime.export
100        );
101        let kind = provider_kind(&provider);
102        let details = summarize_provider(&provider);
103        println!(
104            "{:<24} {:<28} {:<16} {}",
105            provider.provider_type, runtime, kind, details
106        );
107    }
108
109    Ok(())
110}
111
112pub fn info(args: &InfoArgs) -> Result<()> {
113    let pack = load_pack(args.pack.as_deref())?;
114    let inline = match pack.manifest.provider_extension_inline() {
115        Some(value) => value,
116        None => bail!(
117            "{}",
118            crate::cli_i18n::t("cli.providers.error.extension_not_present")
119        ),
120    };
121    let Some(provider) = inline
122        .providers
123        .iter()
124        .find(|p| p.provider_type == args.provider_id)
125    else {
126        bail!(
127            "{}",
128            crate::cli_i18n::tf(
129                "cli.providers.error.provider_not_found",
130                &[&args.provider_id]
131            )
132        );
133    };
134
135    if args.json {
136        println!("{}", serde_json::to_string_pretty(provider)?);
137    } else {
138        let yaml = serde_yaml_bw::to_string(provider)?;
139        println!("{yaml}");
140    }
141
142    Ok(())
143}
144
145pub fn validate(args: &ValidateArgs) -> Result<()> {
146    let pack = load_pack(args.pack.as_deref())?;
147    let Some(inline) = pack.manifest.provider_extension_inline() else {
148        if args.json {
149            println!(
150                "{}",
151                serde_json::to_string_pretty(&serde_json::json!({
152                    "status": crate::cli_i18n::t("cli.status.ok"),
153                    "providers_present": false,
154                    "warnings": [],
155                }))?
156            );
157        } else {
158            println!(
159                "{}",
160                crate::cli_i18n::t("cli.providers.valid_extension_not_present")
161            );
162        }
163        return Ok(());
164    };
165
166    if let Err(err) = inline.validate_basic() {
167        return Err(anyhow!(err.to_string()));
168    }
169
170    let warnings = validate_local_refs(inline, &pack);
171    if args.strict && !warnings.is_empty() {
172        let message = warnings.join("; ");
173        return Err(anyhow!(message));
174    }
175
176    if args.json {
177        println!(
178            "{}",
179            serde_json::to_string_pretty(&serde_json::json!({
180                "status": crate::cli_i18n::t("cli.status.ok"),
181                "providers_present": true,
182                "warnings": warnings,
183            }))?
184        );
185    } else if warnings.is_empty() {
186        println!("{}", crate::cli_i18n::t("cli.providers.valid"));
187    } else {
188        println!(
189            "{}",
190            crate::cli_i18n::t("cli.providers.valid_with_warnings")
191        );
192        for warning in warnings {
193            println!(
194                "{}",
195                crate::cli_i18n::tf("cli.providers.warning_item", &[&warning])
196            );
197        }
198    }
199
200    Ok(())
201}
202
203#[derive(Debug)]
204struct LoadedPack {
205    manifest: PackManifest,
206    root_dir: Option<PathBuf>,
207    entries: HashSet<String>,
208    _temp: Option<TempDir>,
209}
210
211fn load_pack(pack: Option<&Path>) -> Result<LoadedPack> {
212    let input = pack.unwrap_or_else(|| Path::new("."));
213    let root_dir = if input.is_dir() {
214        Some(
215            input
216                .canonicalize()
217                .with_context(|| format!("failed to canonicalize {}", input.display()))?,
218        )
219    } else {
220        None
221    };
222    let (temp, pack_path) = materialize_pack_path(input, false)?;
223    let (manifest, entries) = read_manifest(&pack_path)?;
224    Ok(LoadedPack {
225        manifest,
226        root_dir,
227        entries,
228        _temp: temp,
229    })
230}
231
232fn read_manifest(path: &Path) -> Result<(PackManifest, HashSet<String>)> {
233    let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
234    let mut archive = ZipArchive::new(file)
235        .with_context(|| format!("{} is not a valid gtpack archive", path.display()))?;
236    let mut entries = HashSet::new();
237    for i in 0..archive.len() {
238        let name = archive
239            .by_index(i)
240            .context("failed to read archive entry")?
241            .name()
242            .to_string();
243        entries.insert(name);
244    }
245
246    let mut manifest_entry = archive
247        .by_name(CANONICAL_MANIFEST_ENTRY)
248        .with_context(|| non_canonical_archive_message(&entries.iter().cloned().collect()))?;
249    let mut buf = Vec::new();
250    manifest_entry.read_to_end(&mut buf)?;
251    let manifest = match decode_pack_manifest(&buf) {
252        Ok(manifest) => manifest,
253        Err(err) => {
254            // Fallback to legacy greentic-pack manifest to keep older packs usable.
255            let legacy: greentic_pack::builder::PackManifest =
256                serde_cbor::from_slice(&buf).map_err(|_| err)?;
257            downgrade_legacy_manifest(&legacy)?
258        }
259    };
260
261    Ok((manifest, entries))
262}
263
264fn downgrade_legacy_manifest(
265    manifest: &greentic_pack::builder::PackManifest,
266) -> Result<PackManifest> {
267    let pack_id =
268        PackId::new(manifest.meta.pack_id.clone()).context("legacy manifest pack_id is invalid")?;
269    Ok(PackManifest {
270        schema_version: "pack-v1".to_string(),
271        pack_id,
272        name: Some(manifest.meta.name.clone()),
273        version: manifest.meta.version.clone(),
274        kind: PackKind::Application,
275        publisher: manifest.meta.authors.first().cloned().unwrap_or_default(),
276        components: Vec::new(),
277        flows: Vec::new(),
278        dependencies: Vec::new(),
279        capabilities: Vec::new(),
280        secret_requirements: Vec::new(),
281        signatures: PackSignatures::default(),
282        bootstrap: None,
283        extensions: None,
284        agents: BTreeMap::new(),
285    })
286}
287
288fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
289    let mut providers = manifest
290        .provider_extension_inline()
291        .map(|inline| inline.providers.clone())
292        .unwrap_or_default();
293    providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
294    providers
295}
296
297fn provider_kind(provider: &ProviderDecl) -> String {
298    provider
299        .runtime
300        .world
301        .split('@')
302        .next()
303        .unwrap_or_default()
304        .to_string()
305}
306
307fn summarize_provider(provider: &ProviderDecl) -> String {
308    let caps = provider.capabilities.len();
309    let ops = provider.ops.len();
310    let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
311    parts.push(format!("config:{}", provider.config_schema_ref));
312    if let Some(docs) = provider.docs_ref.as_deref() {
313        parts.push(format!("docs:{docs}"));
314    }
315    parts.join(" ")
316}
317
318fn validate_local_refs(inline: &ProviderExtensionInline, pack: &LoadedPack) -> Vec<String> {
319    let mut warnings = Vec::new();
320    for provider in &inline.providers {
321        for (label, value) in referenced_paths(provider) {
322            if !is_local_ref(value) {
323                continue;
324            }
325            if !ref_exists(value, pack) {
326                warnings.push(format!(
327                    "provider `{}` {} reference `{}` missing",
328                    provider.provider_type, label, value
329                ));
330            }
331        }
332    }
333    warnings
334}
335
336fn referenced_paths(provider: &ProviderDecl) -> Vec<(&'static str, &str)> {
337    let mut refs = Vec::new();
338    refs.push(("config_schema_ref", provider.config_schema_ref.as_str()));
339    if let Some(state) = provider.state_schema_ref.as_deref() {
340        refs.push(("state_schema_ref", state));
341    }
342    if let Some(docs) = provider.docs_ref.as_deref() {
343        refs.push(("docs_ref", docs));
344    }
345    refs
346}
347
348fn is_local_ref(value: &str) -> bool {
349    !value.contains("://")
350}
351
352fn ref_exists(value: &str, pack: &LoadedPack) -> bool {
353    if let Some(root) = pack.root_dir.as_ref() {
354        let candidate = root.join(value);
355        if candidate.exists() {
356            return true;
357        }
358    }
359
360    pack.entries.contains(&normalize_entry(value))
361}
362
363fn normalize_entry(value: &str) -> String {
364    value
365        .split(std::path::MAIN_SEPARATOR)
366        .flat_map(|part| part.split(['/', '\\']))
367        .filter(|part| !part.is_empty())
368        .collect::<Vec<_>>()
369        .join("/")
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use greentic_types::pack_manifest::{ExtensionInline, ExtensionRef};
376    use greentic_types::provider::{PROVIDER_EXTENSION_ID, ProviderRuntimeRef};
377    use semver::Version;
378
379    fn provider(provider_type: &str) -> ProviderDecl {
380        ProviderDecl {
381            provider_type: provider_type.to_string(),
382            capabilities: vec!["send".to_string(), "receive".to_string()],
383            ops: vec!["send".to_string()],
384            config_schema_ref: "schemas/provider.json".to_string(),
385            state_schema_ref: Some("schemas/state.json".to_string()),
386            runtime: ProviderRuntimeRef {
387                component_ref: "provider.component".to_string(),
388                export: "provider".to_string(),
389                world: "greentic:provider/schema-core@1.0.0".to_string(),
390            },
391            docs_ref: Some("docs/provider.md".to_string()),
392        }
393    }
394
395    fn manifest_with_providers(providers: Vec<ProviderDecl>) -> PackManifest {
396        PackManifest {
397            schema_version: "pack-v1".to_string(),
398            pack_id: PackId::new("dev.local.providers").expect("pack id"),
399            name: Some("providers".to_string()),
400            version: Version::parse("0.1.0").expect("version"),
401            kind: PackKind::Application,
402            publisher: "test".to_string(),
403            components: Vec::new(),
404            flows: Vec::new(),
405            dependencies: Vec::new(),
406            capabilities: Vec::new(),
407            secret_requirements: Vec::new(),
408            signatures: PackSignatures::default(),
409            bootstrap: None,
410            extensions: Some(std::collections::BTreeMap::from([(
411                PROVIDER_EXTENSION_ID.to_string(),
412                ExtensionRef {
413                    kind: PROVIDER_EXTENSION_ID.to_string(),
414                    version: "1.0.0".to_string(),
415                    digest: None,
416                    location: None,
417                    inline: Some(ExtensionInline::Provider(ProviderExtensionInline {
418                        providers,
419                        additional_fields: Default::default(),
420                    })),
421                },
422            )])),
423            agents: BTreeMap::new(),
424        }
425    }
426
427    #[test]
428    fn providers_from_manifest_returns_sorted_entries() {
429        let manifest = manifest_with_providers(vec![provider("zeta"), provider("alpha")]);
430        let sorted = providers_from_manifest(&manifest);
431        assert_eq!(sorted[0].provider_type, "alpha");
432        assert_eq!(sorted[1].provider_type, "zeta");
433    }
434
435    #[test]
436    fn provider_helpers_summarize_runtime_and_docs() {
437        let provider = provider("messaging.demo");
438        assert_eq!(provider_kind(&provider), "greentic:provider/schema-core");
439
440        let summary = summarize_provider(&provider);
441        assert!(summary.contains("caps:2"));
442        assert!(summary.contains("ops:1"));
443        assert!(summary.contains("docs:docs/provider.md"));
444    }
445
446    #[test]
447    fn validate_local_refs_reports_missing_local_files_only() {
448        let temp = tempfile::tempdir().expect("tempdir");
449        std::fs::create_dir_all(temp.path().join("schemas")).expect("create schemas dir");
450        std::fs::write(temp.path().join("schemas/provider.json"), "{}").expect("write schema");
451        let inline = ProviderExtensionInline {
452            providers: vec![provider("messaging.demo")],
453            additional_fields: Default::default(),
454        };
455        let pack = LoadedPack {
456            manifest: manifest_with_providers(Vec::new()),
457            root_dir: Some(temp.path().to_path_buf()),
458            entries: HashSet::from(["docs/provider.md".to_string()]),
459            _temp: None,
460        };
461
462        let warnings = validate_local_refs(&inline, &pack);
463        assert_eq!(warnings.len(), 1);
464        assert!(warnings[0].contains("state_schema_ref"));
465        assert!(warnings[0].contains("schemas/state.json"));
466    }
467
468    #[test]
469    fn normalize_entry_and_is_local_ref_handle_mixed_paths() {
470        assert_eq!(
471            normalize_entry("schemas\\\\provider.json"),
472            "schemas/provider.json"
473        );
474        assert!(is_local_ref("docs/provider.md"));
475        assert!(!is_local_ref("oci://registry/provider"));
476    }
477}