Skip to main content

packc/cli/
providers.rs

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