packc/cli/
inspect.rs

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