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