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