Skip to main content

packc/cli/
inspect.rs

1#![forbid(unsafe_code)]
2
3use std::{
4    collections::BTreeSet,
5    fs,
6    path::{Path, PathBuf},
7};
8
9use anyhow::{Context, Result, anyhow, bail};
10use clap::Parser;
11use greentic_pack::{PackLoad, SigningPolicy, open_pack};
12use greentic_types::component_source::ComponentSourceRef;
13use greentic_types::pack::extensions::component_sources::{
14    ArtifactLocationV1, ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
15};
16use greentic_types::pack_manifest::PackManifest;
17use greentic_types::provider::ProviderDecl;
18use tempfile::TempDir;
19
20use crate::build;
21use crate::runtime::RuntimeContext;
22
23#[derive(Debug, Parser)]
24pub struct InspectArgs {
25    /// Path to a pack (.gtpack or source dir). Defaults to current directory.
26    #[arg(value_name = "PATH")]
27    pub path: Option<PathBuf>,
28
29    /// Path to a compiled .gtpack archive
30    #[arg(long, value_name = "FILE", conflicts_with = "input")]
31    pub pack: Option<PathBuf>,
32
33    /// Path to a pack source directory containing pack.yaml
34    #[arg(long = "in", value_name = "DIR", conflicts_with = "pack")]
35    pub input: Option<PathBuf>,
36
37    /// Force archive inspection (disables auto-detection)
38    #[arg(long)]
39    pub archive: bool,
40
41    /// Force source inspection (disables auto-detection)
42    #[arg(long)]
43    pub source: bool,
44
45    /// Allow OCI component refs in extensions to be tag-based (default requires sha256 digest)
46    #[arg(long = "allow-oci-tags", default_value_t = false)]
47    pub allow_oci_tags: bool,
48}
49
50pub async fn handle(args: InspectArgs, json: bool, runtime: &RuntimeContext) -> Result<()> {
51    let mode = resolve_mode(&args)?;
52
53    let load = match mode {
54        InspectMode::Archive(path) => inspect_pack_file(&path)?,
55        InspectMode::Source(path) => {
56            inspect_source_dir(&path, runtime, args.allow_oci_tags).await?
57        }
58    };
59    validate_pack_files(&load)?;
60
61    if json {
62        let payload = serde_json::json!({
63            "manifest": load.manifest,
64            "report": {
65                "signature_ok": load.report.signature_ok,
66                "sbom_ok": load.report.sbom_ok,
67                "warnings": load.report.warnings,
68            },
69            "sbom": load.sbom,
70        });
71        println!("{}", serde_json::to_string_pretty(&payload)?);
72        return Ok(());
73    }
74
75    print_human(&load);
76    Ok(())
77}
78
79fn inspect_pack_file(path: &Path) -> Result<PackLoad> {
80    let load = open_pack(path, SigningPolicy::DevOk)
81        .map_err(|err| anyhow!(err.message))
82        .with_context(|| format!("failed to open pack {}", path.display()))?;
83    Ok(load)
84}
85
86enum InspectMode {
87    Archive(PathBuf),
88    Source(PathBuf),
89}
90
91fn resolve_mode(args: &InspectArgs) -> Result<InspectMode> {
92    if args.archive && args.source {
93        bail!("--archive and --source are mutually exclusive");
94    }
95    if args.pack.is_some() && args.input.is_some() {
96        bail!("exactly one of --pack or --in may be supplied");
97    }
98
99    if let Some(path) = &args.pack {
100        return Ok(InspectMode::Archive(path.clone()));
101    }
102    if let Some(path) = &args.input {
103        return Ok(InspectMode::Source(path.clone()));
104    }
105    if let Some(path) = &args.path {
106        let meta =
107            fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?;
108        if args.archive || (path.extension() == Some(std::ffi::OsStr::new("gtpack"))) {
109            return Ok(InspectMode::Archive(path.clone()));
110        }
111        if args.source || meta.is_dir() {
112            return Ok(InspectMode::Source(path.clone()));
113        }
114        if meta.is_file() {
115            return Ok(InspectMode::Archive(path.clone()));
116        }
117    }
118    Ok(InspectMode::Source(
119        std::env::current_dir().context("determine current directory")?,
120    ))
121}
122
123async fn inspect_source_dir(
124    dir: &Path,
125    runtime: &RuntimeContext,
126    allow_oci_tags: bool,
127) -> Result<PackLoad> {
128    let pack_dir = dir
129        .canonicalize()
130        .with_context(|| format!("failed to resolve pack dir {}", dir.display()))?;
131
132    let temp = TempDir::new().context("failed to allocate temp dir for inspect")?;
133    let manifest_out = temp.path().join("manifest.cbor");
134    let gtpack_out = temp.path().join("pack.gtpack");
135
136    let opts = build::BuildOptions {
137        pack_dir,
138        component_out: None,
139        manifest_out,
140        sbom_out: None,
141        gtpack_out: Some(gtpack_out.clone()),
142        lock_path: gtpack_out.with_extension("lock.json"), // use temp lock path under temp dir
143        bundle: build::BundleMode::Cache,
144        dry_run: false,
145        secrets_req: None,
146        default_secret_scope: None,
147        allow_oci_tags,
148        runtime: runtime.clone(),
149        skip_update: false,
150    };
151
152    build::run(&opts).await?;
153
154    inspect_pack_file(&gtpack_out)
155}
156
157fn print_human(load: &PackLoad) {
158    let manifest = &load.manifest;
159    let report = &load.report;
160    println!(
161        "Pack: {} ({})",
162        manifest.meta.pack_id, manifest.meta.version
163    );
164    println!("Name: {}", manifest.meta.name);
165    println!("Flows: {}", manifest.flows.len());
166    if manifest.flows.is_empty() {
167        println!("Flows list: none");
168    } else {
169        println!("Flows list:");
170        for flow in &manifest.flows {
171            println!(
172                "  - {} (entry: {}, kind: {})",
173                flow.id, flow.entry, flow.kind
174            );
175        }
176    }
177    println!("Components: {}", manifest.components.len());
178    if manifest.components.is_empty() {
179        println!("Components list: none");
180    } else {
181        println!("Components list:");
182        for component in &manifest.components {
183            println!("  - {} ({})", component.name, component.version);
184        }
185    }
186    if let Some(gmanifest) = load.gpack_manifest.as_ref()
187        && let Some(value) = gmanifest
188            .extensions
189            .as_ref()
190            .and_then(|m| m.get(EXT_COMPONENT_SOURCES_V1))
191            .and_then(|ext| ext.inline.as_ref())
192            .and_then(|inline| match inline {
193                greentic_types::ExtensionInline::Other(v) => Some(v),
194                _ => None,
195            })
196        && let Ok(cs) = ComponentSourcesV1::from_extension_value(value)
197    {
198        let mut inline = 0usize;
199        let mut remote = 0usize;
200        let mut oci = 0usize;
201        let mut repo = 0usize;
202        let mut store = 0usize;
203        let mut file = 0usize;
204        for entry in &cs.components {
205            match entry.artifact {
206                ArtifactLocationV1::Inline { .. } => inline += 1,
207                ArtifactLocationV1::Remote => remote += 1,
208            }
209            match entry.source {
210                ComponentSourceRef::Oci(_) => oci += 1,
211                ComponentSourceRef::Repo(_) => repo += 1,
212                ComponentSourceRef::Store(_) => store += 1,
213                ComponentSourceRef::File(_) => file += 1,
214            }
215        }
216        println!(
217            "Component sources: {} total (origins: oci {}, repo {}, store {}, file {}; artifacts: inline {}, remote {})",
218            cs.components.len(),
219            oci,
220            repo,
221            store,
222            file,
223            inline,
224            remote
225        );
226        if cs.components.is_empty() {
227            println!("Component source entries: none");
228        } else {
229            println!("Component source entries:");
230            for entry in &cs.components {
231                println!(
232                    "  - {} source={} artifact={}",
233                    entry.name,
234                    format_component_source(&entry.source),
235                    format_component_artifact(&entry.artifact)
236                );
237            }
238        }
239    } else {
240        println!("Component sources: none");
241    }
242
243    if let Some(gmanifest) = load.gpack_manifest.as_ref() {
244        let providers = providers_from_manifest(gmanifest);
245        if providers.is_empty() {
246            println!("Providers: none");
247        } else {
248            println!("Providers:");
249            for provider in providers {
250                println!(
251                    "  - {} ({}) {}",
252                    provider.provider_type,
253                    provider_kind(&provider),
254                    summarize_provider(&provider)
255                );
256            }
257        }
258    } else {
259        println!("Providers: none");
260    }
261
262    if !report.warnings.is_empty() {
263        println!("Warnings:");
264        for warning in &report.warnings {
265            println!("  - {}", warning);
266        }
267    }
268}
269
270fn validate_pack_files(load: &PackLoad) -> Result<()> {
271    let mut missing = BTreeSet::new();
272
273    for flow in &load.manifest.flows {
274        if !load.files.contains_key(&flow.file_yaml) {
275            missing.insert(flow.file_yaml.clone());
276        }
277        if !load.files.contains_key(&flow.file_json) {
278            missing.insert(flow.file_json.clone());
279        }
280    }
281
282    for component in &load.manifest.components {
283        if !load.files.contains_key(&component.file_wasm) {
284            missing.insert(component.file_wasm.clone());
285        }
286    }
287
288    if missing.is_empty() {
289        Ok(())
290    } else {
291        let items = missing.into_iter().collect::<Vec<_>>().join(", ");
292        bail!("pack is missing required files: {}", items);
293    }
294}
295
296fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
297    let mut providers = manifest
298        .provider_extension_inline()
299        .map(|inline| inline.providers.clone())
300        .unwrap_or_default();
301    providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
302    providers
303}
304
305fn provider_kind(provider: &ProviderDecl) -> String {
306    provider
307        .runtime
308        .world
309        .split('@')
310        .next()
311        .unwrap_or_default()
312        .to_string()
313}
314
315fn summarize_provider(provider: &ProviderDecl) -> String {
316    let caps = provider.capabilities.len();
317    let ops = provider.ops.len();
318    let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
319    parts.push(format!("config:{}", provider.config_schema_ref));
320    if let Some(docs) = provider.docs_ref.as_deref() {
321        parts.push(format!("docs:{docs}"));
322    }
323    parts.join(" ")
324}
325
326fn format_component_source(source: &ComponentSourceRef) -> String {
327    match source {
328        ComponentSourceRef::Oci(value) => format_source_ref("oci", value),
329        ComponentSourceRef::Repo(value) => format_source_ref("repo", value),
330        ComponentSourceRef::Store(value) => format_source_ref("store", value),
331        ComponentSourceRef::File(value) => format_source_ref("file", value),
332    }
333}
334
335fn format_source_ref(scheme: &str, value: &str) -> String {
336    if value.contains("://") {
337        value.to_string()
338    } else {
339        format!("{scheme}://{value}")
340    }
341}
342
343fn format_component_artifact(artifact: &ArtifactLocationV1) -> String {
344    match artifact {
345        ArtifactLocationV1::Inline { wasm_path, .. } => format!("inline ({})", wasm_path),
346        ArtifactLocationV1::Remote => "remote".to_string(),
347    }
348}