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!("No providers declared.");
88        return Ok(());
89    }
90
91    println!("{:<24} {:<28} {:<16} DETAILS", "ID", "RUNTIME", "KIND");
92    for provider in providers {
93        let runtime = format!(
94            "{}::{}",
95            provider.runtime.component_ref, provider.runtime.export
96        );
97        let kind = provider_kind(&provider);
98        let details = summarize_provider(&provider);
99        println!(
100            "{:<24} {:<28} {:<16} {}",
101            provider.provider_type, runtime, kind, details
102        );
103    }
104
105    Ok(())
106}
107
108pub fn info(args: &InfoArgs) -> Result<()> {
109    let pack = load_pack(args.pack.as_deref())?;
110    let inline = match pack.manifest.provider_extension_inline() {
111        Some(value) => value,
112        None => bail!("provider extension not present"),
113    };
114    let Some(provider) = inline
115        .providers
116        .iter()
117        .find(|p| p.provider_type == args.provider_id)
118    else {
119        bail!("provider `{}` not found", args.provider_id);
120    };
121
122    if args.json {
123        println!("{}", serde_json::to_string_pretty(provider)?);
124    } else {
125        let yaml = serde_yaml_bw::to_string(provider)?;
126        println!("{yaml}");
127    }
128
129    Ok(())
130}
131
132pub fn validate(args: &ValidateArgs) -> Result<()> {
133    let pack = load_pack(args.pack.as_deref())?;
134    let Some(inline) = pack.manifest.provider_extension_inline() else {
135        if args.json {
136            println!(
137                "{}",
138                serde_json::to_string_pretty(&serde_json::json!({
139                    "status": "ok",
140                    "providers_present": false,
141                    "warnings": [],
142                }))?
143            );
144        } else {
145            println!("providers valid (extension not present)");
146        }
147        return Ok(());
148    };
149
150    if let Err(err) = inline.validate_basic() {
151        return Err(anyhow!(err.to_string()));
152    }
153
154    let warnings = validate_local_refs(inline, &pack);
155    if args.strict && !warnings.is_empty() {
156        let message = warnings.join("; ");
157        return Err(anyhow!(message));
158    }
159
160    if args.json {
161        println!(
162            "{}",
163            serde_json::to_string_pretty(&serde_json::json!({
164                "status": "ok",
165                "providers_present": true,
166                "warnings": warnings,
167            }))?
168        );
169    } else if warnings.is_empty() {
170        println!("providers valid");
171    } else {
172        println!("providers valid with warnings:");
173        for warning in warnings {
174            println!("  - {warning}");
175        }
176    }
177
178    Ok(())
179}
180
181#[derive(Debug)]
182struct LoadedPack {
183    manifest: PackManifest,
184    root_dir: Option<PathBuf>,
185    entries: HashSet<String>,
186    _temp: Option<TempDir>,
187}
188
189fn load_pack(pack: Option<&Path>) -> Result<LoadedPack> {
190    let input = pack.unwrap_or_else(|| Path::new("."));
191    let root_dir = if input.is_dir() {
192        Some(
193            input
194                .canonicalize()
195                .with_context(|| format!("failed to canonicalize {}", input.display()))?,
196        )
197    } else {
198        None
199    };
200    let (temp, pack_path) = materialize_pack_path(input, false)?;
201    let (manifest, entries) = read_manifest(&pack_path)?;
202    Ok(LoadedPack {
203        manifest,
204        root_dir,
205        entries,
206        _temp: temp,
207    })
208}
209
210fn read_manifest(path: &Path) -> Result<(PackManifest, HashSet<String>)> {
211    let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
212    let mut archive = ZipArchive::new(file)
213        .with_context(|| format!("{} is not a valid gtpack archive", path.display()))?;
214    let mut entries = HashSet::new();
215    for i in 0..archive.len() {
216        let name = archive
217            .by_index(i)
218            .context("failed to read archive entry")?
219            .name()
220            .to_string();
221        entries.insert(name);
222    }
223
224    let mut manifest_entry = archive
225        .by_name("manifest.cbor")
226        .context("manifest.cbor missing from archive")?;
227    let mut buf = Vec::new();
228    manifest_entry.read_to_end(&mut buf)?;
229    let manifest = match decode_pack_manifest(&buf) {
230        Ok(manifest) => manifest,
231        Err(err) => {
232            // Fallback to legacy greentic-pack manifest to keep older packs usable.
233            let legacy: greentic_pack::builder::PackManifest =
234                serde_cbor::from_slice(&buf).map_err(|_| err)?;
235            downgrade_legacy_manifest(&legacy)?
236        }
237    };
238
239    Ok((manifest, entries))
240}
241
242fn downgrade_legacy_manifest(
243    manifest: &greentic_pack::builder::PackManifest,
244) -> Result<PackManifest> {
245    let pack_id =
246        PackId::new(manifest.meta.pack_id.clone()).context("legacy manifest pack_id is invalid")?;
247    Ok(PackManifest {
248        schema_version: "pack-v1".to_string(),
249        pack_id,
250        version: manifest.meta.version.clone(),
251        kind: PackKind::Application,
252        publisher: manifest.meta.authors.first().cloned().unwrap_or_default(),
253        components: Vec::new(),
254        flows: Vec::new(),
255        dependencies: Vec::new(),
256        capabilities: Vec::new(),
257        secret_requirements: Vec::new(),
258        signatures: PackSignatures::default(),
259        bootstrap: None,
260        extensions: None,
261    })
262}
263
264fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
265    let mut providers = manifest
266        .provider_extension_inline()
267        .map(|inline| inline.providers.clone())
268        .unwrap_or_default();
269    providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
270    providers
271}
272
273fn provider_kind(provider: &ProviderDecl) -> String {
274    provider
275        .runtime
276        .world
277        .split('@')
278        .next()
279        .unwrap_or_default()
280        .to_string()
281}
282
283fn summarize_provider(provider: &ProviderDecl) -> String {
284    let caps = provider.capabilities.len();
285    let ops = provider.ops.len();
286    let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
287    parts.push(format!("config:{}", provider.config_schema_ref));
288    if let Some(docs) = provider.docs_ref.as_deref() {
289        parts.push(format!("docs:{docs}"));
290    }
291    parts.join(" ")
292}
293
294fn validate_local_refs(inline: &ProviderExtensionInline, pack: &LoadedPack) -> Vec<String> {
295    let mut warnings = Vec::new();
296    for provider in &inline.providers {
297        for (label, value) in referenced_paths(provider) {
298            if !is_local_ref(value) {
299                continue;
300            }
301            if !ref_exists(value, pack) {
302                warnings.push(format!(
303                    "provider `{}` {} reference `{}` missing",
304                    provider.provider_type, label, value
305                ));
306            }
307        }
308    }
309    warnings
310}
311
312fn referenced_paths(provider: &ProviderDecl) -> Vec<(&'static str, &str)> {
313    let mut refs = Vec::new();
314    refs.push(("config_schema_ref", provider.config_schema_ref.as_str()));
315    if let Some(state) = provider.state_schema_ref.as_deref() {
316        refs.push(("state_schema_ref", state));
317    }
318    if let Some(docs) = provider.docs_ref.as_deref() {
319        refs.push(("docs_ref", docs));
320    }
321    refs
322}
323
324fn is_local_ref(value: &str) -> bool {
325    !value.contains("://")
326}
327
328fn ref_exists(value: &str, pack: &LoadedPack) -> bool {
329    if let Some(root) = pack.root_dir.as_ref() {
330        let candidate = root.join(value);
331        if candidate.exists() {
332            return true;
333        }
334    }
335
336    pack.entries.contains(&normalize_entry(value))
337}
338
339fn normalize_entry(value: &str) -> String {
340    value
341        .split(std::path::MAIN_SEPARATOR)
342        .flat_map(|part| part.split('/'))
343        .filter(|part| !part.is_empty())
344        .collect::<Vec<_>>()
345        .join("/")
346}