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