Skip to main content

packc/cli/
inspect.rs

1#![forbid(unsafe_code)]
2
3use std::io::Write;
4use std::{
5    fs, io,
6    path::{Path, PathBuf},
7    process::{Command, Stdio},
8};
9
10use anyhow::{Context, Result, anyhow, bail};
11use clap::Parser;
12use greentic_pack::validate::{
13    ComponentReferencesExistValidator, ProviderReferencesExistValidator,
14    ReferencedFilesExistValidator, SbomConsistencyValidator, ValidateCtx, run_validators,
15};
16use greentic_pack::{PackLoad, SigningPolicy, open_pack};
17use greentic_types::component_source::ComponentSourceRef;
18use greentic_types::pack::extensions::component_sources::{
19    ArtifactLocationV1, ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
20};
21use greentic_types::pack_manifest::PackManifest;
22use greentic_types::provider::ProviderDecl;
23use greentic_types::validate::{Diagnostic, Severity, ValidationReport};
24use serde::Serialize;
25use serde_json::Value;
26use tempfile::TempDir;
27
28use crate::build;
29use crate::runtime::RuntimeContext;
30use crate::validator::{
31    DEFAULT_VALIDATOR_ALLOW, ValidatorConfig, ValidatorPolicy, run_wasm_validators,
32};
33
34#[derive(Debug, Parser)]
35pub struct InspectArgs {
36    /// Path to a pack (.gtpack or source dir). Defaults to current directory.
37    #[arg(value_name = "PATH")]
38    pub path: Option<PathBuf>,
39
40    /// Path to a compiled .gtpack archive
41    #[arg(long, value_name = "FILE", conflicts_with = "input")]
42    pub pack: Option<PathBuf>,
43
44    /// Path to a pack source directory containing pack.yaml
45    #[arg(long = "in", value_name = "DIR", conflicts_with = "pack")]
46    pub input: Option<PathBuf>,
47
48    /// Force archive inspection (disables auto-detection)
49    #[arg(long)]
50    pub archive: bool,
51
52    /// Force source inspection (disables auto-detection)
53    #[arg(long)]
54    pub source: bool,
55
56    /// Allow OCI component refs in extensions to be tag-based (default requires sha256 digest)
57    #[arg(long = "allow-oci-tags", default_value_t = false)]
58    pub allow_oci_tags: bool,
59
60    /// Disable per-flow doctor checks
61    #[arg(long = "no-flow-doctor", default_value_t = true, action = clap::ArgAction::SetFalse)]
62    pub flow_doctor: bool,
63
64    /// Disable per-component doctor checks
65    #[arg(long = "no-component-doctor", default_value_t = true, action = clap::ArgAction::SetFalse)]
66    pub component_doctor: bool,
67
68    /// Output format
69    #[arg(long, value_enum, default_value = "human")]
70    pub format: InspectFormat,
71
72    /// Enable validation (default)
73    #[arg(long, default_value_t = true)]
74    pub validate: bool,
75
76    /// Disable validation
77    #[arg(long = "no-validate", default_value_t = false)]
78    pub no_validate: bool,
79
80    /// Directory containing validator packs (.gtpack)
81    #[arg(long, value_name = "DIR", default_value = ".greentic/validators")]
82    pub validators_root: PathBuf,
83
84    /// Validator pack or component reference (path or oci://...)
85    #[arg(long, value_name = "REF")]
86    pub validator_pack: Vec<String>,
87
88    /// Allowed OCI prefixes for validator refs
89    #[arg(long, value_name = "PREFIX", default_value = DEFAULT_VALIDATOR_ALLOW)]
90    pub validator_allow: Vec<String>,
91
92    /// Validator cache directory
93    #[arg(long, value_name = "DIR", default_value = ".greentic/cache/validators")]
94    pub validator_cache_dir: PathBuf,
95
96    /// Validator loading policy
97    #[arg(long, value_enum, default_value = "optional")]
98    pub validator_policy: ValidatorPolicy,
99}
100
101pub async fn handle(args: InspectArgs, json: bool, runtime: &RuntimeContext) -> Result<()> {
102    let mode = resolve_mode(&args)?;
103    let format = resolve_format(&args, json);
104    let validate_enabled = if args.no_validate {
105        false
106    } else {
107        args.validate
108    };
109
110    let load = match mode {
111        InspectMode::Archive(path) => inspect_pack_file(&path)?,
112        InspectMode::Source(path) => {
113            inspect_source_dir(&path, runtime, args.allow_oci_tags).await?
114        }
115    };
116    let validation = if validate_enabled {
117        let mut output = run_pack_validation(&load, &args, runtime).await?;
118        let mut doctor_diagnostics = Vec::new();
119        let mut doctor_errors = false;
120        if args.flow_doctor {
121            doctor_errors |= run_flow_doctors(&load, &mut doctor_diagnostics)?;
122        }
123        if args.component_doctor {
124            doctor_errors |= run_component_doctors(&load, &mut doctor_diagnostics)?;
125        }
126        output.report.diagnostics.extend(doctor_diagnostics);
127        output.has_errors |= doctor_errors;
128        Some(output)
129    } else {
130        None
131    };
132
133    match format {
134        InspectFormat::Json => {
135            let mut payload = serde_json::json!({
136                "manifest": load.manifest,
137                "report": {
138                    "signature_ok": load.report.signature_ok,
139                    "sbom_ok": load.report.sbom_ok,
140                    "warnings": load.report.warnings,
141                },
142                "sbom": load.sbom,
143            });
144            if let Some(report) = validation.as_ref() {
145                payload["validation"] = serde_json::to_value(report)?;
146            }
147            println!("{}", serde_json::to_string_pretty(&payload)?);
148        }
149        InspectFormat::Human => {
150            print_human(&load, validation.as_ref());
151        }
152    }
153
154    if validate_enabled
155        && validation
156            .as_ref()
157            .map(|report| report.has_errors)
158            .unwrap_or(false)
159    {
160        bail!("pack validation failed");
161    }
162
163    Ok(())
164}
165
166fn run_flow_doctors(load: &PackLoad, diagnostics: &mut Vec<Diagnostic>) -> Result<bool> {
167    if load.manifest.flows.is_empty() {
168        return Ok(false);
169    }
170
171    let mut has_errors = false;
172
173    for flow in &load.manifest.flows {
174        let Some(bytes) = load.files.get(&flow.file_yaml) else {
175            diagnostics.push(Diagnostic {
176                severity: Severity::Error,
177                code: "PACK_FLOW_DOCTOR_MISSING_FLOW".to_string(),
178                message: "flow file missing from pack".to_string(),
179                path: Some(flow.file_yaml.clone()),
180                hint: Some("rebuild the pack to include flow sources".to_string()),
181                data: Value::Null,
182            });
183            has_errors = true;
184            continue;
185        };
186
187        let mut command = Command::new("greentic-flow");
188        command
189            .args(["doctor", "--json", "--stdin"])
190            .stdin(Stdio::piped())
191            .stdout(Stdio::piped())
192            .stderr(Stdio::piped());
193        let mut child = match command.spawn() {
194            Ok(child) => child,
195            Err(err) if err.kind() == io::ErrorKind::NotFound => {
196                diagnostics.push(Diagnostic {
197                    severity: Severity::Warn,
198                    code: "PACK_FLOW_DOCTOR_UNAVAILABLE".to_string(),
199                    message: "greentic-flow not available; skipping flow doctor checks".to_string(),
200                    path: None,
201                    hint: Some("install greentic-flow or pass --no-flow-doctor".to_string()),
202                    data: Value::Null,
203                });
204                return Ok(false);
205            }
206            Err(err) => return Err(err).context("run greentic-flow doctor"),
207        };
208        if let Some(mut stdin) = child.stdin.take() {
209            stdin
210                .write_all(bytes)
211                .context("write flow content to greentic-flow stdin")?;
212        }
213        let output = child
214            .wait_with_output()
215            .context("wait for greentic-flow doctor")?;
216
217        if !output.status.success() {
218            if flow_doctor_unsupported(&output) {
219                diagnostics.push(Diagnostic {
220                    severity: Severity::Warn,
221                    code: "PACK_FLOW_DOCTOR_UNAVAILABLE".to_string(),
222                    message: "greentic-flow does not support --stdin; skipping flow doctor checks"
223                        .to_string(),
224                    path: None,
225                    hint: Some("upgrade greentic-flow or pass --no-flow-doctor".to_string()),
226                    data: json_diagnostic_data(&output),
227                });
228                return Ok(false);
229            }
230            has_errors = true;
231            diagnostics.push(Diagnostic {
232                severity: Severity::Error,
233                code: "PACK_FLOW_DOCTOR_FAILED".to_string(),
234                message: "flow doctor failed".to_string(),
235                path: Some(flow.file_yaml.clone()),
236                hint: Some("run `greentic-flow doctor` for details".to_string()),
237                data: json_diagnostic_data(&output),
238            });
239        }
240    }
241
242    Ok(has_errors)
243}
244
245fn flow_doctor_unsupported(output: &std::process::Output) -> bool {
246    let mut combined = String::new();
247    combined.push_str(&String::from_utf8_lossy(&output.stdout));
248    combined.push_str(&String::from_utf8_lossy(&output.stderr));
249    let combined = combined.to_lowercase();
250    combined.contains("--stdin") && combined.contains("unknown")
251        || combined.contains("found argument '--stdin'")
252        || combined.contains("unexpected argument '--stdin'")
253        || combined.contains("unrecognized option '--stdin'")
254}
255
256fn run_component_doctors(load: &PackLoad, diagnostics: &mut Vec<Diagnostic>) -> Result<bool> {
257    if load.manifest.components.is_empty() {
258        return Ok(false);
259    }
260
261    let temp = TempDir::new().context("allocate temp dir for component doctor")?;
262    let mut has_errors = false;
263
264    let mut manifests = std::collections::HashMap::new();
265    if let Some(gpack_manifest) = load.gpack_manifest.as_ref() {
266        for component in &gpack_manifest.components {
267            if let Ok(bytes) = serde_json::to_vec_pretty(component) {
268                manifests.insert(component.id.to_string(), bytes);
269            }
270        }
271    }
272
273    for component in &load.manifest.components {
274        let Some(wasm_bytes) = load.files.get(&component.file_wasm) else {
275            diagnostics.push(Diagnostic {
276                severity: Severity::Warn,
277                code: "PACK_COMPONENT_DOCTOR_MISSING_WASM".to_string(),
278                message: "component wasm missing from pack; skipping component doctor".to_string(),
279                path: Some(component.file_wasm.clone()),
280                hint: Some("rebuild with --bundle=cache or supply cached artifacts".to_string()),
281                data: Value::Null,
282            });
283            continue;
284        };
285
286        let manifest_bytes = if let Some(bytes) = manifests.get(&component.name) {
287            Some(bytes.clone())
288        } else if let Some(path) = component.manifest_file.as_deref()
289            && let Some(bytes) = load.files.get(path)
290        {
291            Some(bytes.clone())
292        } else {
293            None
294        };
295
296        let Some(manifest_bytes) = manifest_bytes else {
297            diagnostics.push(Diagnostic {
298                severity: Severity::Warn,
299                code: "PACK_COMPONENT_DOCTOR_MISSING_MANIFEST".to_string(),
300                message: "component manifest missing; skipping component doctor".to_string(),
301                path: component.manifest_file.clone(),
302                hint: Some("rebuild the pack to include component manifests".to_string()),
303                data: Value::Null,
304            });
305            continue;
306        };
307
308        let component_dir = temp.path().join(sanitize_component_id(&component.name));
309        fs::create_dir_all(&component_dir)
310            .with_context(|| format!("create temp dir for {}", component.name))?;
311        let wasm_path = component_dir.join("component.wasm");
312        let manifest_path = component_dir.join("component.manifest.json");
313        fs::write(&wasm_path, wasm_bytes)?;
314        fs::write(&manifest_path, manifest_bytes)?;
315
316        let output = match Command::new("greentic-component")
317            .args(["doctor"])
318            .arg(&wasm_path)
319            .args(["--manifest"])
320            .arg(&manifest_path)
321            .output()
322        {
323            Ok(output) => output,
324            Err(err) if err.kind() == io::ErrorKind::NotFound => {
325                diagnostics.push(Diagnostic {
326                    severity: Severity::Warn,
327                    code: "PACK_COMPONENT_DOCTOR_UNAVAILABLE".to_string(),
328                    message: "greentic-component not available; skipping component doctor checks"
329                        .to_string(),
330                    path: None,
331                    hint: Some(
332                        "install greentic-component or pass --no-component-doctor".to_string(),
333                    ),
334                    data: Value::Null,
335                });
336                return Ok(false);
337            }
338            Err(err) => return Err(err).context("run greentic-component doctor"),
339        };
340
341        if !output.status.success() {
342            has_errors = true;
343            diagnostics.push(Diagnostic {
344                severity: Severity::Error,
345                code: "PACK_COMPONENT_DOCTOR_FAILED".to_string(),
346                message: "component doctor failed".to_string(),
347                path: Some(component.name.clone()),
348                hint: Some("run `greentic-component doctor` for details".to_string()),
349                data: json_diagnostic_data(&output),
350            });
351        }
352    }
353
354    Ok(has_errors)
355}
356
357fn json_diagnostic_data(output: &std::process::Output) -> Value {
358    serde_json::json!({
359        "status": output.status.code(),
360        "stdout": String::from_utf8_lossy(&output.stdout).trim_end(),
361        "stderr": String::from_utf8_lossy(&output.stderr).trim_end(),
362    })
363}
364
365fn sanitize_component_id(value: &str) -> String {
366    value
367        .chars()
368        .map(|ch| {
369            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
370                ch
371            } else {
372                '_'
373            }
374        })
375        .collect()
376}
377
378fn inspect_pack_file(path: &Path) -> Result<PackLoad> {
379    let load = open_pack(path, SigningPolicy::DevOk)
380        .map_err(|err| anyhow!(err.message))
381        .with_context(|| format!("failed to open pack {}", path.display()))?;
382    Ok(load)
383}
384
385enum InspectMode {
386    Archive(PathBuf),
387    Source(PathBuf),
388}
389
390fn resolve_mode(args: &InspectArgs) -> Result<InspectMode> {
391    if args.archive && args.source {
392        bail!("--archive and --source are mutually exclusive");
393    }
394    if args.pack.is_some() && args.input.is_some() {
395        bail!("exactly one of --pack or --in may be supplied");
396    }
397
398    if let Some(path) = &args.pack {
399        return Ok(InspectMode::Archive(path.clone()));
400    }
401    if let Some(path) = &args.input {
402        return Ok(InspectMode::Source(path.clone()));
403    }
404    if let Some(path) = &args.path {
405        let meta =
406            fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?;
407        if args.archive || (path.extension() == Some(std::ffi::OsStr::new("gtpack"))) {
408            return Ok(InspectMode::Archive(path.clone()));
409        }
410        if args.source || meta.is_dir() {
411            return Ok(InspectMode::Source(path.clone()));
412        }
413        if meta.is_file() {
414            return Ok(InspectMode::Archive(path.clone()));
415        }
416    }
417    Ok(InspectMode::Source(
418        std::env::current_dir().context("determine current directory")?,
419    ))
420}
421
422async fn inspect_source_dir(
423    dir: &Path,
424    runtime: &RuntimeContext,
425    allow_oci_tags: bool,
426) -> Result<PackLoad> {
427    let pack_dir = dir
428        .canonicalize()
429        .with_context(|| format!("failed to resolve pack dir {}", dir.display()))?;
430
431    let temp = TempDir::new().context("failed to allocate temp dir for inspect")?;
432    let manifest_out = temp.path().join("manifest.cbor");
433    let gtpack_out = temp.path().join("pack.gtpack");
434
435    let opts = build::BuildOptions {
436        pack_dir,
437        component_out: None,
438        manifest_out,
439        sbom_out: None,
440        gtpack_out: Some(gtpack_out.clone()),
441        lock_path: gtpack_out.with_extension("lock.json"), // use temp lock path under temp dir
442        bundle: build::BundleMode::Cache,
443        dry_run: false,
444        secrets_req: None,
445        default_secret_scope: None,
446        allow_oci_tags,
447        require_component_manifests: false,
448        no_extra_dirs: false,
449        runtime: runtime.clone(),
450        skip_update: false,
451    };
452
453    build::run(&opts).await?;
454
455    inspect_pack_file(&gtpack_out)
456}
457
458fn print_human(load: &PackLoad, validation: Option<&ValidationOutput>) {
459    let manifest = &load.manifest;
460    let report = &load.report;
461    println!(
462        "Pack: {} ({})",
463        manifest.meta.pack_id, manifest.meta.version
464    );
465    println!("Name: {}", manifest.meta.name);
466    println!("Flows: {}", manifest.flows.len());
467    if manifest.flows.is_empty() {
468        println!("Flows list: none");
469    } else {
470        println!("Flows list:");
471        for flow in &manifest.flows {
472            println!(
473                "  - {} (entry: {}, kind: {})",
474                flow.id, flow.entry, flow.kind
475            );
476        }
477    }
478    println!("Components: {}", manifest.components.len());
479    if manifest.components.is_empty() {
480        println!("Components list: none");
481    } else {
482        println!("Components list:");
483        for component in &manifest.components {
484            println!("  - {} ({})", component.name, component.version);
485        }
486    }
487    if let Some(gmanifest) = load.gpack_manifest.as_ref()
488        && let Some(value) = gmanifest
489            .extensions
490            .as_ref()
491            .and_then(|m| m.get(EXT_COMPONENT_SOURCES_V1))
492            .and_then(|ext| ext.inline.as_ref())
493            .and_then(|inline| match inline {
494                greentic_types::ExtensionInline::Other(v) => Some(v),
495                _ => None,
496            })
497        && let Ok(cs) = ComponentSourcesV1::from_extension_value(value)
498    {
499        let mut inline = 0usize;
500        let mut remote = 0usize;
501        let mut oci = 0usize;
502        let mut repo = 0usize;
503        let mut store = 0usize;
504        let mut file = 0usize;
505        for entry in &cs.components {
506            match entry.artifact {
507                ArtifactLocationV1::Inline { .. } => inline += 1,
508                ArtifactLocationV1::Remote => remote += 1,
509            }
510            match entry.source {
511                ComponentSourceRef::Oci(_) => oci += 1,
512                ComponentSourceRef::Repo(_) => repo += 1,
513                ComponentSourceRef::Store(_) => store += 1,
514                ComponentSourceRef::File(_) => file += 1,
515            }
516        }
517        println!(
518            "Component sources: {} total (origins: oci {}, repo {}, store {}, file {}; artifacts: inline {}, remote {})",
519            cs.components.len(),
520            oci,
521            repo,
522            store,
523            file,
524            inline,
525            remote
526        );
527        if cs.components.is_empty() {
528            println!("Component source entries: none");
529        } else {
530            println!("Component source entries:");
531            for entry in &cs.components {
532                println!(
533                    "  - {} source={} artifact={}",
534                    entry.name,
535                    format_component_source(&entry.source),
536                    format_component_artifact(&entry.artifact)
537                );
538            }
539        }
540    } else {
541        println!("Component sources: none");
542    }
543
544    if let Some(gmanifest) = load.gpack_manifest.as_ref() {
545        let providers = providers_from_manifest(gmanifest);
546        if providers.is_empty() {
547            println!("Providers: none");
548        } else {
549            println!("Providers:");
550            for provider in providers {
551                println!(
552                    "  - {} ({}) {}",
553                    provider.provider_type,
554                    provider_kind(&provider),
555                    summarize_provider(&provider)
556                );
557            }
558        }
559    } else {
560        println!("Providers: none");
561    }
562
563    if !report.warnings.is_empty() {
564        println!("Warnings:");
565        for warning in &report.warnings {
566            println!("  - {}", warning);
567        }
568    }
569
570    if let Some(report) = validation {
571        print_validation(report);
572    }
573}
574
575#[derive(Clone, Debug, Serialize)]
576struct ValidationOutput {
577    #[serde(flatten)]
578    report: ValidationReport,
579    has_errors: bool,
580    sources: Vec<crate::validator::ValidatorSourceReport>,
581}
582
583fn has_error_diagnostics(diagnostics: &[Diagnostic]) -> bool {
584    diagnostics
585        .iter()
586        .any(|diag| matches!(diag.severity, Severity::Error))
587}
588
589async fn run_pack_validation(
590    load: &PackLoad,
591    args: &InspectArgs,
592    runtime: &RuntimeContext,
593) -> Result<ValidationOutput> {
594    let ctx = ValidateCtx::from_pack_load(load);
595    let validators: Vec<Box<dyn greentic_types::validate::PackValidator>> = vec![
596        Box::new(ReferencedFilesExistValidator::new(ctx.clone())),
597        Box::new(SbomConsistencyValidator::new(ctx.clone())),
598        Box::new(ProviderReferencesExistValidator::new(ctx.clone())),
599        Box::new(ComponentReferencesExistValidator),
600    ];
601
602    let mut report = if let Some(manifest) = load.gpack_manifest.as_ref() {
603        run_validators(manifest, &ctx, &validators)
604    } else {
605        ValidationReport {
606            pack_id: None,
607            pack_version: None,
608            diagnostics: vec![Diagnostic {
609                severity: Severity::Warn,
610                code: "PACK_MANIFEST_UNSUPPORTED".to_string(),
611                message: "Pack manifest is not in the greentic-types format; skipping validation."
612                    .to_string(),
613                path: Some("manifest.cbor".to_string()),
614                hint: Some(
615                    "Rebuild the pack with greentic-pack build to enable validation.".to_string(),
616                ),
617                data: Value::Null,
618            }],
619        }
620    };
621
622    let config = ValidatorConfig {
623        validators_root: args.validators_root.clone(),
624        validator_packs: args.validator_pack.clone(),
625        validator_allow: args.validator_allow.clone(),
626        validator_cache_dir: args.validator_cache_dir.clone(),
627        policy: args.validator_policy,
628    };
629
630    let wasm_result = run_wasm_validators(load, &config, runtime).await?;
631    report.diagnostics.extend(wasm_result.diagnostics);
632
633    let has_errors = has_error_diagnostics(&report.diagnostics) || wasm_result.missing_required;
634
635    Ok(ValidationOutput {
636        report,
637        has_errors,
638        sources: wasm_result.sources,
639    })
640}
641
642fn print_validation(report: &ValidationOutput) {
643    let (info, warn, error) = validation_counts(&report.report);
644    println!("Validation:");
645    println!("  Info: {info} Warn: {warn} Error: {error}");
646    if report.report.diagnostics.is_empty() {
647        println!("  - none");
648        return;
649    }
650    for diag in &report.report.diagnostics {
651        let sev = match diag.severity {
652            Severity::Info => "INFO",
653            Severity::Warn => "WARN",
654            Severity::Error => "ERROR",
655        };
656        if let Some(path) = diag.path.as_deref() {
657            println!("  - [{sev}] {} {} - {}", diag.code, path, diag.message);
658        } else {
659            println!("  - [{sev}] {} - {}", diag.code, diag.message);
660        }
661        if matches!(
662            diag.code.as_str(),
663            "PACK_FLOW_DOCTOR_FAILED" | "PACK_COMPONENT_DOCTOR_FAILED"
664        ) {
665            print_doctor_failure_details(&diag.data);
666        }
667        if let Some(hint) = diag.hint.as_deref() {
668            println!("    hint: {hint}");
669        }
670    }
671}
672
673fn print_doctor_failure_details(data: &Value) {
674    let Some(obj) = data.as_object() else {
675        return;
676    };
677    let stdout = obj.get("stdout").and_then(|value| value.as_str());
678    let stderr = obj.get("stderr").and_then(|value| value.as_str());
679    let status = obj.get("status").and_then(|value| value.as_i64());
680    if let Some(status) = status {
681        println!("    status: {status}");
682    }
683    if let Some(stderr) = stderr {
684        let trimmed = stderr.trim();
685        if !trimmed.is_empty() {
686            println!("    stderr: {trimmed}");
687        }
688    }
689    if let Some(stdout) = stdout {
690        let trimmed = stdout.trim();
691        if !trimmed.is_empty() {
692            println!("    stdout: {trimmed}");
693        }
694    }
695}
696
697fn validation_counts(report: &ValidationReport) -> (usize, usize, usize) {
698    let mut info = 0;
699    let mut warn = 0;
700    let mut error = 0;
701    for diag in &report.diagnostics {
702        match diag.severity {
703            Severity::Info => info += 1,
704            Severity::Warn => warn += 1,
705            Severity::Error => error += 1,
706        }
707    }
708    (info, warn, error)
709}
710
711#[derive(Debug, Clone, Copy, clap::ValueEnum)]
712pub enum InspectFormat {
713    Human,
714    Json,
715}
716
717fn resolve_format(args: &InspectArgs, json: bool) -> InspectFormat {
718    if json {
719        InspectFormat::Json
720    } else {
721        args.format
722    }
723}
724
725fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
726    let mut providers = manifest
727        .provider_extension_inline()
728        .map(|inline| inline.providers.clone())
729        .unwrap_or_default();
730    providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
731    providers
732}
733
734fn provider_kind(provider: &ProviderDecl) -> String {
735    provider
736        .runtime
737        .world
738        .split('@')
739        .next()
740        .unwrap_or_default()
741        .to_string()
742}
743
744fn summarize_provider(provider: &ProviderDecl) -> String {
745    let caps = provider.capabilities.len();
746    let ops = provider.ops.len();
747    let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
748    parts.push(format!("config:{}", provider.config_schema_ref));
749    if let Some(docs) = provider.docs_ref.as_deref() {
750        parts.push(format!("docs:{docs}"));
751    }
752    parts.join(" ")
753}
754
755fn format_component_source(source: &ComponentSourceRef) -> String {
756    match source {
757        ComponentSourceRef::Oci(value) => format_source_ref("oci", value),
758        ComponentSourceRef::Repo(value) => format_source_ref("repo", value),
759        ComponentSourceRef::Store(value) => format_source_ref("store", value),
760        ComponentSourceRef::File(value) => format_source_ref("file", value),
761    }
762}
763
764fn format_source_ref(scheme: &str, value: &str) -> String {
765    if value.contains("://") {
766        value.to_string()
767    } else {
768        format!("{scheme}://{value}")
769    }
770}
771
772fn format_component_artifact(artifact: &ArtifactLocationV1) -> String {
773    match artifact {
774        ArtifactLocationV1::Inline { wasm_path, .. } => format!("inline ({})", wasm_path),
775        ArtifactLocationV1::Remote => "remote".to_string(),
776    }
777}