Skip to main content

packc/cli/
inspect.rs

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