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::static_routes::{StaticRouteV1, parse_static_routes_extension};
14use greentic_pack::validate::{
15    ComponentReferencesExistValidator, OauthCapabilityRequirementsValidator,
16    ProviderReferencesExistValidator, ReferencedFilesExistValidator, SbomConsistencyValidator,
17    SecretRequirementsValidator, StaticRoutesValidator, ValidateCtx, run_validators,
18};
19use greentic_pack::{PackLoad, SigningPolicy, open_pack};
20use greentic_types::component_source::ComponentSourceRef;
21use greentic_types::pack::extensions::component_manifests::{
22    ComponentManifestIndexV1, EXT_COMPONENT_MANIFEST_INDEX_V1,
23};
24use greentic_types::pack::extensions::component_sources::{
25    ArtifactLocationV1, ComponentSourcesV1, EXT_COMPONENT_SOURCES_V1,
26};
27use greentic_types::pack_manifest::{ExtensionInline as PackManifestExtensionInline, PackManifest};
28use greentic_types::provider::ProviderDecl;
29use greentic_types::validate::{Diagnostic, Severity, ValidationReport};
30use serde::Serialize;
31use serde_cbor;
32use serde_json::Value;
33use tempfile::TempDir;
34
35use crate::build;
36use crate::extension_refs::{
37    default_extensions_file_path, default_extensions_lock_file_path, read_extensions_file,
38    read_extensions_lock_file, validate_extensions_lock_alignment,
39};
40use crate::extensions::DEPLOYER_EXTENSION_KEY;
41use crate::pack_lock_doctor::{PackLockDoctorInput, run_pack_lock_doctor};
42use crate::runtime::RuntimeContext;
43use crate::validator::{
44    DEFAULT_VALIDATOR_ALLOW, LocalValidator, ValidatorConfig, ValidatorPolicy, run_wasm_validators,
45};
46
47const EXT_BUILD_MODE_ID: &str = "greentic.pack-mode.v1";
48
49#[derive(Clone, Copy, PartialEq, Eq)]
50enum PackBuildMode {
51    Prod,
52    Dev,
53}
54
55#[derive(Debug, Parser)]
56pub struct InspectArgs {
57    /// Path to a pack (.gtpack or source dir). Defaults to current directory.
58    #[arg(value_name = "PATH")]
59    pub path: Option<PathBuf>,
60
61    /// Path to a compiled .gtpack archive
62    #[arg(long, value_name = "FILE", conflicts_with = "input")]
63    pub pack: Option<PathBuf>,
64
65    /// Path to a pack source directory containing pack.yaml
66    #[arg(long = "in", value_name = "DIR", conflicts_with = "pack")]
67    pub input: Option<PathBuf>,
68
69    /// Force archive inspection (disables auto-detection)
70    #[arg(long)]
71    pub archive: bool,
72
73    /// Force source inspection (disables auto-detection)
74    #[arg(long)]
75    pub source: bool,
76
77    /// Allow OCI component refs in extensions to be tag-based (default requires sha256 digest)
78    #[arg(long = "allow-oci-tags", default_value_t = false)]
79    pub allow_oci_tags: bool,
80
81    /// Disable per-flow doctor checks
82    #[arg(long = "no-flow-doctor", default_value_t = true, action = clap::ArgAction::SetFalse)]
83    pub flow_doctor: bool,
84
85    /// Disable per-component doctor checks
86    #[arg(long = "no-component-doctor", default_value_t = true, action = clap::ArgAction::SetFalse)]
87    pub component_doctor: bool,
88
89    /// Output format
90    #[arg(long, value_enum, default_value = "human")]
91    pub format: InspectFormat,
92
93    /// Enable validation (default)
94    #[arg(long, default_value_t = true)]
95    pub validate: bool,
96
97    /// Disable validation
98    #[arg(long = "no-validate", default_value_t = false)]
99    pub no_validate: bool,
100
101    /// Directory containing validator packs (.gtpack)
102    #[arg(long, value_name = "DIR", default_value = ".greentic/validators")]
103    pub validators_root: PathBuf,
104
105    /// Validator pack or component reference (path or oci://...)
106    #[arg(long, value_name = "REF")]
107    pub validator_pack: Vec<String>,
108
109    /// Validator component wasm (format: <COMPONENT_ID>=<FILE>)
110    #[arg(long, value_name = "COMPONENT=FILE")]
111    pub validator_wasm: Vec<String>,
112
113    /// Allowed OCI prefixes for validator refs
114    #[arg(long, value_name = "PREFIX", default_value = DEFAULT_VALIDATOR_ALLOW)]
115    pub validator_allow: Vec<String>,
116
117    /// Validator cache directory
118    #[arg(long, value_name = "DIR", default_value = ".greentic/cache/validators")]
119    pub validator_cache_dir: PathBuf,
120
121    /// Validator loading policy
122    #[arg(long, value_enum, default_value = "optional")]
123    pub validator_policy: ValidatorPolicy,
124
125    /// Allow online resolution of component refs during pack lock checks (default: offline)
126    #[arg(long, default_value_t = false)]
127    pub online: bool,
128
129    /// Allow describe cache fallback when components cannot execute describe()
130    #[arg(long = "use-describe-cache", default_value_t = false)]
131    pub use_describe_cache: bool,
132}
133
134pub async fn handle(args: InspectArgs, json: bool, runtime: &RuntimeContext) -> Result<()> {
135    let mode = resolve_mode(&args)?;
136    let format = resolve_format(&args, json);
137    let validate_enabled = if args.no_validate {
138        false
139    } else {
140        args.validate
141    };
142
143    let load = match &mode {
144        InspectMode::Archive(path) => inspect_pack_file(path)?,
145        InspectMode::Source(path) => inspect_source_dir(path, runtime, args.allow_oci_tags).await?,
146    };
147    let build_mode = detect_pack_build_mode(&load);
148    if matches!(mode, InspectMode::Archive(_)) && build_mode == PackBuildMode::Prod {
149        let forbidden = find_forbidden_source_paths(&load.files);
150        if !forbidden.is_empty() {
151            bail!(
152                "production pack contains forbidden source files: {}",
153                forbidden.join(", ")
154            );
155        }
156    }
157    let validation = if validate_enabled {
158        let mut output =
159            run_pack_validation(&load, source_mode_pack_dir(&mode), &args, runtime).await?;
160        let mut doctor_diagnostics = Vec::new();
161        let mut doctor_errors = false;
162        if args.component_doctor {
163            let use_describe_cache = args.use_describe_cache
164                || std::env::var("GREENTIC_PACK_USE_DESCRIBE_CACHE").is_ok()
165                || cfg!(test);
166            let pack_dir = match &mode {
167                InspectMode::Source(path) => Some(path.as_path()),
168                InspectMode::Archive(_) => None,
169            };
170            let pack_lock_output = run_pack_lock_doctor(PackLockDoctorInput {
171                load: &load,
172                pack_dir,
173                runtime,
174                allow_oci_tags: args.allow_oci_tags,
175                use_describe_cache,
176                online: args.online,
177            })?;
178            doctor_errors |= pack_lock_output.has_errors;
179            doctor_diagnostics.extend(pack_lock_output.diagnostics);
180        }
181        if args.flow_doctor {
182            doctor_errors |= run_flow_doctors(&load, &mut doctor_diagnostics, build_mode)?;
183        }
184        if args.component_doctor {
185            doctor_errors |= run_component_doctors(&load, &mut doctor_diagnostics)?;
186        }
187        output.report.diagnostics.extend(doctor_diagnostics);
188        output.has_errors |= doctor_errors;
189        Some(output)
190    } else {
191        None
192    };
193
194    match format {
195        InspectFormat::Json => {
196            let mut payload = serde_json::json!({
197                "manifest": load.manifest,
198                "report": {
199                    "signature_ok": load.report.signature_ok,
200                    "sbom_ok": load.report.sbom_ok,
201                    "warnings": load.report.warnings,
202                },
203                "sbom": load.sbom,
204                "static_routes": load_static_routes(&load),
205            });
206            if let Some(report) = validation.as_ref() {
207                payload["validation"] = serde_json::to_value(report)?;
208            }
209            println!("{}", to_sorted_json(payload)?);
210        }
211        InspectFormat::Human => {
212            print_human(&load, validation.as_ref());
213        }
214    }
215
216    if validate_enabled
217        && validation
218            .as_ref()
219            .map(|report| report.has_errors)
220            .unwrap_or(false)
221    {
222        bail!("pack validation failed");
223    }
224
225    Ok(())
226}
227
228fn to_sorted_json(value: Value) -> Result<String> {
229    let sorted = sort_json(value);
230    Ok(serde_json::to_string_pretty(&sorted)?)
231}
232
233pub(crate) fn sort_json(value: Value) -> Value {
234    match value {
235        Value::Object(map) => {
236            let mut entries: Vec<(String, Value)> = map.into_iter().collect();
237            entries.sort_by(|a, b| a.0.cmp(&b.0));
238            let mut sorted = serde_json::Map::new();
239            for (key, value) in entries {
240                sorted.insert(key, sort_json(value));
241            }
242            Value::Object(sorted)
243        }
244        Value::Array(values) => Value::Array(values.into_iter().map(sort_json).collect()),
245        other => other,
246    }
247}
248
249fn run_flow_doctors(
250    load: &PackLoad,
251    diagnostics: &mut Vec<Diagnostic>,
252    build_mode: PackBuildMode,
253) -> Result<bool> {
254    if load.manifest.flows.is_empty() {
255        return Ok(false);
256    }
257
258    let mut has_errors = false;
259
260    for flow in &load.manifest.flows {
261        let Some(bytes) = load.files.get(&flow.file_yaml) else {
262            if build_mode == PackBuildMode::Prod {
263                continue;
264            }
265            diagnostics.push(Diagnostic {
266                severity: Severity::Error,
267                code: "PACK_FLOW_DOCTOR_MISSING_FLOW".to_string(),
268                message: "flow file missing from pack".to_string(),
269                path: Some(flow.file_yaml.clone()),
270                hint: Some("rebuild the pack to include flow sources".to_string()),
271                data: Value::Null,
272            });
273            has_errors = true;
274            continue;
275        };
276
277        let flow_bin = crate::external_tools::resolve("greentic-flow")
278            .unwrap_or_else(|| PathBuf::from("greentic-flow"));
279        let mut command = Command::new(&flow_bin);
280        command
281            .args(["doctor", "--json", "--stdin"])
282            .stdin(Stdio::piped())
283            .stdout(Stdio::piped())
284            .stderr(Stdio::piped());
285        let mut child = match command.spawn() {
286            Ok(child) => child,
287            Err(err) if err.kind() == io::ErrorKind::NotFound => {
288                diagnostics.push(Diagnostic {
289                    severity: Severity::Warn,
290                    code: "PACK_FLOW_DOCTOR_UNAVAILABLE".to_string(),
291                    message: "greentic-flow not available; skipping flow doctor checks".to_string(),
292                    path: None,
293                    hint: Some("install greentic-flow or pass --no-flow-doctor".to_string()),
294                    data: Value::Null,
295                });
296                return Ok(false);
297            }
298            Err(err) => {
299                return Err(err).with_context(|| format!("run {} doctor", flow_bin.display()));
300            }
301        };
302        if let Some(mut stdin) = child.stdin.take() {
303            stdin
304                .write_all(bytes)
305                .context("write flow content to greentic-flow stdin")?;
306        }
307        let output = child
308            .wait_with_output()
309            .context("wait for greentic-flow doctor")?;
310
311        if !output.status.success() {
312            if flow_doctor_unsupported(&output) {
313                diagnostics.push(Diagnostic {
314                    severity: Severity::Warn,
315                    code: "PACK_FLOW_DOCTOR_UNAVAILABLE".to_string(),
316                    message: "greentic-flow does not support --stdin; skipping flow doctor checks"
317                        .to_string(),
318                    path: None,
319                    hint: Some("update greentic-flow or pass --no-flow-doctor".to_string()),
320                    data: json_diagnostic_data(&output),
321                });
322                return Ok(false);
323            }
324            has_errors = true;
325            diagnostics.push(Diagnostic {
326                severity: Severity::Error,
327                code: "PACK_FLOW_DOCTOR_FAILED".to_string(),
328                message: "flow doctor failed".to_string(),
329                path: Some(flow.file_yaml.clone()),
330                hint: Some("run `greentic-flow doctor` for details".to_string()),
331                data: json_diagnostic_data(&output),
332            });
333        }
334    }
335
336    Ok(has_errors)
337}
338
339fn flow_doctor_unsupported(output: &std::process::Output) -> bool {
340    let mut combined = String::new();
341    combined.push_str(&String::from_utf8_lossy(&output.stdout));
342    combined.push_str(&String::from_utf8_lossy(&output.stderr));
343    let combined = combined.to_lowercase();
344    combined.contains("--stdin") && combined.contains("unknown")
345        || combined.contains("found argument '--stdin'")
346        || combined.contains("unexpected argument '--stdin'")
347        || combined.contains("unrecognized option '--stdin'")
348}
349
350fn run_component_doctors(load: &PackLoad, diagnostics: &mut Vec<Diagnostic>) -> Result<bool> {
351    if load.manifest.components.is_empty() {
352        return Ok(false);
353    }
354
355    let temp = TempDir::new().context("allocate temp dir for component doctor")?;
356    let mut has_errors = false;
357
358    let mut manifest_paths = std::collections::HashMap::new();
359    if let Some(gpack_manifest) = load.gpack_manifest.as_ref()
360        && let Some(manifest_extension) = gpack_manifest
361            .extensions
362            .as_ref()
363            .and_then(|map| map.get(EXT_COMPONENT_MANIFEST_INDEX_V1))
364            .and_then(|entry| entry.inline.as_ref())
365            .and_then(|inline| match inline {
366                PackManifestExtensionInline::Other(value) => Some(value),
367                _ => None,
368            })
369            .and_then(|value| ComponentManifestIndexV1::from_extension_value(value).ok())
370    {
371        for entry in manifest_extension.entries {
372            manifest_paths.insert(entry.component_id, entry.manifest_file);
373        }
374    }
375
376    for component in &load.manifest.components {
377        let Some(wasm_bytes) = load.files.get(&component.file_wasm) else {
378            diagnostics.push(Diagnostic {
379                severity: Severity::Warn,
380                code: "PACK_COMPONENT_DOCTOR_MISSING_WASM".to_string(),
381                message: "component wasm missing from pack; skipping component doctor".to_string(),
382                path: Some(component.file_wasm.clone()),
383                hint: Some("rebuild with --bundle=cache or supply cached artifacts".to_string()),
384                data: Value::Null,
385            });
386            continue;
387        };
388
389        if component.manifest_file.is_none() {
390            if manifest_paths.contains_key(&component.name) {
391                continue;
392            }
393            diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
394            continue;
395        }
396
397        let manifest_bytes = if let Some(path) = component.manifest_file.as_deref()
398            && let Some(bytes) = load.files.get(path)
399        {
400            bytes.clone()
401        } else {
402            diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
403            continue;
404        };
405
406        let component_dir = temp.path().join(sanitize_component_id(&component.name));
407        fs::create_dir_all(&component_dir)
408            .with_context(|| format!("create temp dir for {}", component.name))?;
409        let wasm_path = component_dir.join("component.wasm");
410        let manifest_value = match serde_json::from_slice::<Value>(&manifest_bytes) {
411            Ok(value) => value,
412            Err(_) => match serde_cbor::from_slice::<Value>(&manifest_bytes) {
413                Ok(value) => value,
414                Err(err) => {
415                    diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
416                    tracing::debug!(
417                        manifest = %component.name,
418                        "failed to parse component manifest for doctor: {err}"
419                    );
420                    continue;
421                }
422            },
423        };
424
425        if !component_manifest_has_required_fields(&manifest_value) {
426            diagnostics.push(component_manifest_missing_diag(&component.manifest_file));
427            continue;
428        }
429
430        let manifest_bytes =
431            serde_json::to_vec_pretty(&manifest_value).context("serialize component manifest")?;
432
433        let manifest_path = component_dir.join("component.manifest.json");
434        fs::write(&wasm_path, wasm_bytes)?;
435        fs::write(&manifest_path, manifest_bytes)?;
436
437        let component_bin = crate::external_tools::resolve("greentic-component")
438            .unwrap_or_else(|| PathBuf::from("greentic-component"));
439        let output = match Command::new(&component_bin)
440            .args(["doctor"])
441            .arg(&wasm_path)
442            .args(["--manifest"])
443            .arg(&manifest_path)
444            .output()
445        {
446            Ok(output) => output,
447            Err(err) if err.kind() == io::ErrorKind::NotFound => {
448                diagnostics.push(Diagnostic {
449                    severity: Severity::Warn,
450                    code: "PACK_COMPONENT_DOCTOR_UNAVAILABLE".to_string(),
451                    message: "greentic-component not available; skipping component doctor checks"
452                        .to_string(),
453                    path: None,
454                    hint: Some(
455                        "install greentic-component or pass --no-component-doctor".to_string(),
456                    ),
457                    data: Value::Null,
458                });
459                return Ok(false);
460            }
461            Err(err) => {
462                return Err(err).with_context(|| format!("run {} doctor", component_bin.display()));
463            }
464        };
465
466        if !output.status.success() {
467            has_errors = true;
468            diagnostics.push(Diagnostic {
469                severity: Severity::Error,
470                code: "PACK_COMPONENT_DOCTOR_FAILED".to_string(),
471                message: "component doctor failed".to_string(),
472                path: Some(component.name.clone()),
473                hint: Some("run `greentic-component doctor` for details".to_string()),
474                data: json_diagnostic_data(&output),
475            });
476        }
477    }
478
479    Ok(has_errors)
480}
481
482fn json_diagnostic_data(output: &std::process::Output) -> Value {
483    serde_json::json!({
484        "status": output.status.code(),
485        "stdout": String::from_utf8_lossy(&output.stdout).trim_end(),
486        "stderr": String::from_utf8_lossy(&output.stderr).trim_end(),
487    })
488}
489
490fn component_manifest_missing_diag(manifest_file: &Option<String>) -> Diagnostic {
491    Diagnostic {
492        severity: Severity::Warn,
493        code: "PACK_COMPONENT_DOCTOR_MISSING_MANIFEST".to_string(),
494        message: "component manifest missing or incomplete; skipping component doctor".to_string(),
495        path: manifest_file.clone(),
496        hint: Some("rebuild the pack to include component manifests".to_string()),
497        data: Value::Null,
498    }
499}
500
501fn component_manifest_has_required_fields(manifest: &Value) -> bool {
502    manifest.get("name").is_some()
503        && manifest.get("artifacts").is_some()
504        && manifest.get("hashes").is_some()
505        && manifest.get("describe_export").is_some()
506        && manifest.get("config_schema").is_some()
507}
508
509fn sanitize_component_id(value: &str) -> String {
510    value
511        .chars()
512        .map(|ch| {
513            if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
514                ch
515            } else {
516                '_'
517            }
518        })
519        .collect()
520}
521
522fn inspect_pack_file(path: &Path) -> Result<PackLoad> {
523    let load = open_pack(path, SigningPolicy::DevOk)
524        .map_err(|err| anyhow!(err.message))
525        .with_context(|| format!("failed to open pack {}", path.display()))?;
526    Ok(load)
527}
528
529fn detect_pack_build_mode(load: &PackLoad) -> PackBuildMode {
530    if let Some(manifest) = load.gpack_manifest.as_ref()
531        && let Some(mode) = manifest_build_mode(manifest)
532    {
533        return mode;
534    }
535    if load.files.keys().any(|path| path.ends_with(".ygtc")) {
536        return PackBuildMode::Dev;
537    }
538    PackBuildMode::Prod
539}
540
541fn manifest_build_mode(manifest: &PackManifest) -> Option<PackBuildMode> {
542    let extensions = manifest.extensions.as_ref()?;
543    let entry = extensions.get(EXT_BUILD_MODE_ID)?;
544    let inline = entry.inline.as_ref()?;
545    if let PackManifestExtensionInline::Other(value) = inline
546        && let Some(mode) = value.get("mode").and_then(|value| value.as_str())
547    {
548        if mode.eq_ignore_ascii_case("dev") {
549            return Some(PackBuildMode::Dev);
550        }
551        return Some(PackBuildMode::Prod);
552    }
553    None
554}
555
556fn find_forbidden_source_paths(files: &HashMap<String, Vec<u8>>) -> Vec<String> {
557    files
558        .keys()
559        .filter(|path| is_forbidden_source_path(path))
560        .cloned()
561        .collect()
562}
563
564fn is_forbidden_source_path(path: &str) -> bool {
565    if matches!(path, "pack.yaml" | "pack.manifest.json") {
566        return true;
567    }
568    if matches!(
569        path,
570        "secret-requirements.json" | "secrets_requirements.json"
571    ) {
572        return true;
573    }
574    if path.ends_with(".ygtc") {
575        return true;
576    }
577    if path.starts_with("flows/") && path.ends_with(".json") {
578        return true;
579    }
580    // Component manifest sources live under `components/<id>/component.manifest.json`
581    // or `components/<id>.manifest.json`. Earlier the rule was a blanket
582    // `path.ends_with("manifest.json")`, which incorrectly flagged generic asset
583    // index files like `assets/i18n/_manifest.json` as forbidden.
584    if path.starts_with("components/")
585        && (path.ends_with("/component.manifest.json") || path.ends_with(".manifest.json"))
586    {
587        return true;
588    }
589    false
590}
591
592enum InspectMode {
593    Archive(PathBuf),
594    Source(PathBuf),
595}
596
597fn resolve_mode(args: &InspectArgs) -> Result<InspectMode> {
598    if args.archive && args.source {
599        bail!("--archive and --source are mutually exclusive");
600    }
601    if args.pack.is_some() && args.input.is_some() {
602        bail!("exactly one of --pack or --in may be supplied");
603    }
604
605    if let Some(path) = &args.pack {
606        return Ok(InspectMode::Archive(path.clone()));
607    }
608    if let Some(path) = &args.input {
609        return Ok(InspectMode::Source(path.clone()));
610    }
611    if let Some(path) = &args.path {
612        let meta =
613            fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?;
614        if args.archive || (path.extension() == Some(std::ffi::OsStr::new("gtpack"))) {
615            return Ok(InspectMode::Archive(path.clone()));
616        }
617        if args.source || meta.is_dir() {
618            return Ok(InspectMode::Source(path.clone()));
619        }
620        if meta.is_file() {
621            return Ok(InspectMode::Archive(path.clone()));
622        }
623    }
624    Ok(InspectMode::Source(
625        std::env::current_dir().context("determine current directory")?,
626    ))
627}
628
629fn source_mode_pack_dir(mode: &InspectMode) -> Option<&Path> {
630    match mode {
631        InspectMode::Source(path) => Some(path.as_path()),
632        InspectMode::Archive(_) => None,
633    }
634}
635
636async fn inspect_source_dir(
637    dir: &Path,
638    runtime: &RuntimeContext,
639    allow_oci_tags: bool,
640) -> Result<PackLoad> {
641    let pack_dir = dir
642        .canonicalize()
643        .with_context(|| format!("failed to resolve pack dir {}", dir.display()))?;
644
645    let temp = TempDir::new().context("failed to allocate temp dir for inspect")?;
646    let manifest_out = temp.path().join("manifest.cbor");
647    let gtpack_out = temp.path().join("pack.gtpack");
648
649    let opts = build::BuildOptions {
650        pack_dir,
651        component_out: None,
652        manifest_out,
653        sbom_out: None,
654        gtpack_out: Some(gtpack_out.clone()),
655        lock_path: gtpack_out.with_extension("lock.json"), // use temp lock path under temp dir
656        bundle: build::BundleMode::Cache,
657        dry_run: false,
658        secrets_req: None,
659        default_secret_scope: None,
660        allow_oci_tags,
661        require_component_manifests: false,
662        no_extra_dirs: false,
663        dev: true,
664        runtime: runtime.clone(),
665        skip_update: false,
666        allow_pack_schema: false,
667        validate_extension_refs: false,
668    };
669
670    build::run(&opts).await?;
671
672    inspect_pack_file(&gtpack_out)
673}
674
675fn print_human(load: &PackLoad, validation: Option<&ValidationOutput>) {
676    let manifest = &load.manifest;
677    let report = &load.report;
678    println!(
679        "Pack: {} ({})",
680        manifest.meta.pack_id, manifest.meta.version
681    );
682    println!("Name: {}", manifest.meta.name);
683    println!("Flows: {}", manifest.flows.len());
684    if manifest.flows.is_empty() {
685        println!("Flows list: none");
686    } else {
687        println!("Flows list:");
688        for flow in &manifest.flows {
689            println!(
690                "  - {} (entry: {}, kind: {})",
691                flow.id, flow.entry, flow.kind
692            );
693        }
694    }
695    println!("Components: {}", manifest.components.len());
696    if manifest.components.is_empty() {
697        println!("Components list: none");
698    } else {
699        println!("Components list:");
700        for component in &manifest.components {
701            println!("  - {} ({})", component.name, component.version);
702        }
703    }
704    if let Some(gmanifest) = load.gpack_manifest.as_ref()
705        && let Some(value) = gmanifest
706            .extensions
707            .as_ref()
708            .and_then(|m| m.get(EXT_COMPONENT_SOURCES_V1))
709            .and_then(|ext| ext.inline.as_ref())
710            .and_then(|inline| match inline {
711                greentic_types::ExtensionInline::Other(v) => Some(v),
712                _ => None,
713            })
714        && let Ok(cs) = ComponentSourcesV1::from_extension_value(value)
715    {
716        let mut inline = 0usize;
717        let mut remote = 0usize;
718        let mut oci = 0usize;
719        let mut repo = 0usize;
720        let mut store = 0usize;
721        let mut file = 0usize;
722        for entry in &cs.components {
723            match entry.artifact {
724                ArtifactLocationV1::Inline { .. } => inline += 1,
725                ArtifactLocationV1::Remote => remote += 1,
726            }
727            match entry.source {
728                ComponentSourceRef::Oci(_) => oci += 1,
729                ComponentSourceRef::Repo(_) => repo += 1,
730                ComponentSourceRef::Store(_) => store += 1,
731                ComponentSourceRef::File(_) => file += 1,
732            }
733        }
734        println!(
735            "Component sources: {} total (origins: oci {}, repo {}, store {}, file {}; artifacts: inline {}, remote {})",
736            cs.components.len(),
737            oci,
738            repo,
739            store,
740            file,
741            inline,
742            remote
743        );
744        if cs.components.is_empty() {
745            println!("Component source entries: none");
746        } else {
747            println!("Component source entries:");
748            for entry in &cs.components {
749                println!(
750                    "  - {} source={} artifact={}",
751                    entry.name,
752                    format_component_source(&entry.source),
753                    format_component_artifact(&entry.artifact)
754                );
755            }
756        }
757    } else {
758        println!("Component sources: none");
759    }
760
761    if let Some(gmanifest) = load.gpack_manifest.as_ref() {
762        let providers = providers_from_manifest(gmanifest);
763        if providers.is_empty() {
764            println!("Providers: none");
765        } else {
766            println!("Providers:");
767            for provider in providers {
768                println!(
769                    "  - {} ({}) {}",
770                    provider.provider_type,
771                    provider_kind(&provider),
772                    summarize_provider(&provider)
773                );
774            }
775        }
776    } else {
777        println!("Providers: none");
778    }
779
780    let static_routes = load_static_routes(load);
781    if static_routes.is_empty() {
782        println!("Static routes: none");
783    } else {
784        println!("Static routes:");
785        for route in &static_routes {
786            println!(
787                "  - {} -> {} [{}]",
788                route.id, route.public_path, route.source_root
789            );
790            println!(
791                "    scope: tenant={} team={}",
792                route.scope.tenant, route.scope.team
793            );
794            println!(
795                "    index_file: {}",
796                route.index_file.as_deref().unwrap_or("none")
797            );
798            println!(
799                "    spa_fallback: {}",
800                route.spa_fallback.as_deref().unwrap_or("none")
801            );
802            println!(
803                "    cache: {}",
804                route
805                    .cache
806                    .as_ref()
807                    .map(|cache| match cache.max_age_seconds {
808                        Some(max_age) => format!("{} ({max_age}s)", cache.strategy),
809                        None => cache.strategy.clone(),
810                    })
811                    .unwrap_or_else(|| "none".to_string())
812            );
813            if route.exports.is_empty() {
814                println!("    exports: none");
815            } else {
816                let exports = route
817                    .exports
818                    .iter()
819                    .map(|(key, value)| format!("{key}={value}"))
820                    .collect::<Vec<_>>()
821                    .join(", ");
822                println!("    exports: {exports}");
823            }
824        }
825    }
826
827    if !report.warnings.is_empty() {
828        println!("Warnings:");
829        for warning in &report.warnings {
830            println!("  - {}", warning);
831        }
832    }
833
834    if let Some(report) = validation {
835        print_validation(report);
836    }
837}
838
839fn load_static_routes(load: &PackLoad) -> Vec<StaticRouteV1> {
840    load.gpack_manifest
841        .as_ref()
842        .and_then(|manifest| {
843            parse_static_routes_extension(&manifest.extensions)
844                .ok()
845                .flatten()
846        })
847        .map(|payload| payload.routes)
848        .unwrap_or_default()
849}
850
851#[derive(Clone, Debug, Serialize)]
852struct ValidationOutput {
853    #[serde(flatten)]
854    report: ValidationReport,
855    has_errors: bool,
856    sources: Vec<crate::validator::ValidatorSourceReport>,
857}
858
859fn has_error_diagnostics(diagnostics: &[Diagnostic]) -> bool {
860    diagnostics
861        .iter()
862        .any(|diag| matches!(diag.severity, Severity::Error))
863}
864
865async fn run_pack_validation(
866    load: &PackLoad,
867    source_pack_dir: Option<&Path>,
868    args: &InspectArgs,
869    runtime: &RuntimeContext,
870) -> Result<ValidationOutput> {
871    let ctx = ValidateCtx::from_pack_load(load);
872    let validators: Vec<Box<dyn greentic_types::validate::PackValidator>> = vec![
873        Box::new(ReferencedFilesExistValidator::new(ctx.clone())),
874        Box::new(SbomConsistencyValidator::new(ctx.clone())),
875        Box::new(ProviderReferencesExistValidator::new(ctx.clone())),
876        Box::new(SecretRequirementsValidator),
877        Box::new(StaticRoutesValidator::new(ctx.clone())),
878        Box::new(ComponentReferencesExistValidator),
879        Box::new(OauthCapabilityRequirementsValidator),
880    ];
881
882    let mut report = if let Some(manifest) = load.gpack_manifest.as_ref() {
883        run_validators(manifest, &ctx, &validators)
884    } else {
885        ValidationReport {
886            pack_id: None,
887            pack_version: None,
888            diagnostics: vec![Diagnostic {
889                severity: Severity::Warn,
890                code: "PACK_MANIFEST_UNSUPPORTED".to_string(),
891                message: "Pack manifest is not in the greentic-types format; skipping validation."
892                    .to_string(),
893                path: Some("manifest.cbor".to_string()),
894                hint: Some(
895                    "Rebuild the pack with greentic-pack build to enable validation.".to_string(),
896                ),
897                data: Value::Null,
898            }],
899        }
900    };
901
902    let config = ValidatorConfig {
903        validators_root: args.validators_root.clone(),
904        validator_packs: args.validator_pack.clone(),
905        validator_allow: args.validator_allow.clone(),
906        validator_cache_dir: args.validator_cache_dir.clone(),
907        policy: args.validator_policy,
908        local_validators: parse_validator_wasm_args(&args.validator_wasm)?,
909    };
910
911    let wasm_result = run_wasm_validators(load, &config, runtime).await?;
912    report.diagnostics.extend(wasm_result.diagnostics);
913    if let Some(pack_dir) = source_pack_dir {
914        report
915            .diagnostics
916            .extend(collect_extension_dependency_diagnostics(pack_dir));
917    }
918
919    let has_errors = has_error_diagnostics(&report.diagnostics) || wasm_result.missing_required;
920
921    Ok(ValidationOutput {
922        report,
923        has_errors,
924        sources: wasm_result.sources,
925    })
926}
927
928fn collect_extension_dependency_diagnostics(pack_dir: &Path) -> Vec<Diagnostic> {
929    let source_path = default_extensions_file_path(pack_dir);
930    let lock_path = default_extensions_lock_file_path(pack_dir);
931    let mut diagnostics = Vec::new();
932
933    let source = if source_path.exists() {
934        match read_extensions_file(&source_path) {
935            Ok(file) => Some(file),
936            Err(err) => {
937                diagnostics.push(Diagnostic {
938                    severity: Severity::Error,
939                    code: "PACK_EXTENSION_DEPENDENCY_SOURCE_INVALID".to_string(),
940                    message: err.to_string(),
941                    path: Some(path_display(pack_dir, &source_path)),
942                    hint: Some("fix pack.extensions.json and rerun doctor".to_string()),
943                    data: Value::Null,
944                });
945                None
946            }
947        }
948    } else {
949        None
950    };
951
952    let lock = if lock_path.exists() {
953        match read_extensions_lock_file(&lock_path) {
954            Ok(file) => Some(file),
955            Err(err) => {
956                diagnostics.push(Diagnostic {
957                    severity: Severity::Error,
958                    code: "PACK_EXTENSION_DEPENDENCY_LOCK_INVALID".to_string(),
959                    message: err.to_string(),
960                    path: Some(path_display(pack_dir, &lock_path)),
961                    hint: Some("rerun `greentic-pack extensions-lock --in <DIR>`".to_string()),
962                    data: Value::Null,
963                });
964                None
965            }
966        }
967    } else {
968        None
969    };
970
971    match (source.as_ref(), lock.as_ref()) {
972        (Some(_), None) => diagnostics.push(Diagnostic {
973            severity: Severity::Warn,
974            code: "PACK_EXTENSION_DEPENDENCY_LOCK_MISSING".to_string(),
975            message: "pack.extensions.json exists but pack.extensions.lock.json is missing"
976                .to_string(),
977            path: Some(path_display(pack_dir, &source_path)),
978            hint: Some("run `greentic-pack extensions-lock --in <DIR>`".to_string()),
979            data: Value::Null,
980        }),
981        (None, Some(_)) => diagnostics.push(Diagnostic {
982            severity: Severity::Warn,
983            code: "PACK_EXTENSION_DEPENDENCY_SOURCE_MISSING".to_string(),
984            message: "pack.extensions.lock.json exists but pack.extensions.json is missing"
985                .to_string(),
986            path: Some(path_display(pack_dir, &lock_path)),
987            hint: Some(
988                "restore pack.extensions.json or regenerate the lock from the intended source file"
989                    .to_string(),
990            ),
991            data: Value::Null,
992        }),
993        (Some(source), Some(lock)) => {
994            if let Err(err) = validate_extensions_lock_alignment(source, lock) {
995                diagnostics.push(Diagnostic {
996                    severity: Severity::Error,
997                    code: "PACK_EXTENSION_DEPENDENCY_LOCK_STALE".to_string(),
998                    message: err.to_string(),
999                    path: Some(path_display(pack_dir, &lock_path)),
1000                    hint: Some("rerun `greentic-pack extensions-lock --in <DIR>` after editing pack.extensions.json".to_string()),
1001                    data: Value::Null,
1002                });
1003            }
1004        }
1005        (None, None) => {}
1006    }
1007
1008    if let Some(source) = source.as_ref() {
1009        for extension in &source.extensions {
1010            if extension.id == DEPLOYER_EXTENSION_KEY && extension.role != "deployer" {
1011                diagnostics.push(Diagnostic {
1012                    severity: Severity::Error,
1013                    code: "PACK_DEPLOYER_EXTENSION_ROLE_INVALID".to_string(),
1014                    message: format!(
1015                        "extension `{}` must use role `deployer`, found `{}`",
1016                        extension.id, extension.role
1017                    ),
1018                    path: Some(path_display(pack_dir, &source_path)),
1019                    hint: Some("set the dependency role to `deployer`".to_string()),
1020                    data: Value::Null,
1021                });
1022            }
1023        }
1024    }
1025
1026    if let Some(lock) = lock.as_ref() {
1027        for extension in &lock.extensions {
1028            if extension.media_type.is_none() {
1029                diagnostics.push(Diagnostic {
1030                    severity: Severity::Warn,
1031                    code: "PACK_EXTENSION_DEPENDENCY_LOCK_MISSING_MEDIA_TYPE".to_string(),
1032                    message: format!(
1033                        "extension `{}` lock entry is missing media_type metadata",
1034                        extension.id
1035                    ),
1036                    path: Some(path_display(pack_dir, &lock_path)),
1037                    hint: Some("rerun `greentic-pack extensions-lock --in <DIR>` with a resolver that reports content type".to_string()),
1038                    data: Value::Null,
1039                });
1040            }
1041            if extension.size_bytes.is_none() {
1042                diagnostics.push(Diagnostic {
1043                    severity: Severity::Warn,
1044                    code: "PACK_EXTENSION_DEPENDENCY_LOCK_MISSING_SIZE".to_string(),
1045                    message: format!(
1046                        "extension `{}` lock entry is missing size metadata",
1047                        extension.id
1048                    ),
1049                    path: Some(path_display(pack_dir, &lock_path)),
1050                    hint: Some("rerun `greentic-pack extensions-lock --in <DIR>` with a resolver that reports content length".to_string()),
1051                    data: Value::Null,
1052                });
1053            }
1054        }
1055    }
1056
1057    diagnostics
1058}
1059
1060fn path_display(root: &Path, path: &Path) -> String {
1061    path.strip_prefix(root)
1062        .unwrap_or(path)
1063        .display()
1064        .to_string()
1065}
1066
1067fn print_validation(report: &ValidationOutput) {
1068    let (info, warn, error) = validation_counts(&report.report);
1069    println!("Validation:");
1070    println!("  Info: {info} Warn: {warn} Error: {error}");
1071    if report.report.diagnostics.is_empty() {
1072        println!("  - none");
1073        return;
1074    }
1075    for diag in &report.report.diagnostics {
1076        let sev = match diag.severity {
1077            Severity::Info => "INFO",
1078            Severity::Warn => "WARN",
1079            Severity::Error => "ERROR",
1080        };
1081        if let Some(path) = diag.path.as_deref() {
1082            println!("  - [{sev}] {} {} - {}", diag.code, path, diag.message);
1083        } else {
1084            println!("  - [{sev}] {} - {}", diag.code, diag.message);
1085        }
1086        if matches!(
1087            diag.code.as_str(),
1088            "PACK_FLOW_DOCTOR_FAILED" | "PACK_COMPONENT_DOCTOR_FAILED"
1089        ) {
1090            print_doctor_failure_details(&diag.data);
1091        }
1092        if let Some(hint) = diag.hint.as_deref() {
1093            println!("    hint: {hint}");
1094        }
1095    }
1096}
1097
1098fn parse_validator_wasm_args(args: &[String]) -> Result<Vec<LocalValidator>> {
1099    let mut local_validators = Vec::new();
1100    for entry in args {
1101        let mut segments = entry.splitn(2, '=');
1102        let component_id = segments.next().unwrap_or_default().trim().to_string();
1103        let path = segments
1104            .next()
1105            .map(|p| p.trim())
1106            .filter(|p| !p.is_empty())
1107            .ok_or_else(|| {
1108                anyhow!(
1109                    "invalid --validator-wasm argument `{}` (expected format COMPONENT_ID=FILE)",
1110                    entry
1111                )
1112            })?;
1113        if component_id.is_empty() {
1114            return Err(anyhow!(
1115                "validator component id must not be empty in `{}`",
1116                entry
1117            ));
1118        }
1119        local_validators.push(LocalValidator {
1120            component_id,
1121            path: PathBuf::from(path),
1122        });
1123    }
1124    Ok(local_validators)
1125}
1126
1127fn print_doctor_failure_details(data: &Value) {
1128    let Some(obj) = data.as_object() else {
1129        return;
1130    };
1131    let stdout = obj.get("stdout").and_then(|value| value.as_str());
1132    let stderr = obj.get("stderr").and_then(|value| value.as_str());
1133    let status = obj.get("status").and_then(|value| value.as_i64());
1134    if let Some(status) = status {
1135        println!("    status: {status}");
1136    }
1137    if let Some(stderr) = stderr {
1138        let trimmed = stderr.trim();
1139        if !trimmed.is_empty() {
1140            println!("    stderr: {trimmed}");
1141        }
1142    }
1143    if let Some(stdout) = stdout {
1144        let trimmed = stdout.trim();
1145        if !trimmed.is_empty() {
1146            println!("    stdout: {trimmed}");
1147        }
1148    }
1149}
1150
1151fn validation_counts(report: &ValidationReport) -> (usize, usize, usize) {
1152    let mut info = 0;
1153    let mut warn = 0;
1154    let mut error = 0;
1155    for diag in &report.diagnostics {
1156        match diag.severity {
1157            Severity::Info => info += 1,
1158            Severity::Warn => warn += 1,
1159            Severity::Error => error += 1,
1160        }
1161    }
1162    (info, warn, error)
1163}
1164
1165#[derive(Debug, Clone, Copy, clap::ValueEnum)]
1166pub enum InspectFormat {
1167    Human,
1168    Json,
1169}
1170
1171fn resolve_format(args: &InspectArgs, json: bool) -> InspectFormat {
1172    if json {
1173        InspectFormat::Json
1174    } else {
1175        args.format
1176    }
1177}
1178
1179fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
1180    let mut providers = manifest
1181        .provider_extension_inline()
1182        .map(|inline| inline.providers.clone())
1183        .unwrap_or_default();
1184    providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
1185    providers
1186}
1187
1188fn provider_kind(provider: &ProviderDecl) -> String {
1189    provider
1190        .runtime
1191        .world
1192        .split('@')
1193        .next()
1194        .unwrap_or_default()
1195        .to_string()
1196}
1197
1198fn summarize_provider(provider: &ProviderDecl) -> String {
1199    let caps = provider.capabilities.len();
1200    let ops = provider.ops.len();
1201    let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
1202    parts.push(format!("config:{}", provider.config_schema_ref));
1203    if let Some(docs) = provider.docs_ref.as_deref() {
1204        parts.push(format!("docs:{docs}"));
1205    }
1206    parts.join(" ")
1207}
1208
1209fn format_component_source(source: &ComponentSourceRef) -> String {
1210    match source {
1211        ComponentSourceRef::Oci(value) => format_source_ref("oci", value),
1212        ComponentSourceRef::Repo(value) => format_source_ref("repo", value),
1213        ComponentSourceRef::Store(value) => format_source_ref("store", value),
1214        ComponentSourceRef::File(value) => format_source_ref("file", value),
1215    }
1216}
1217
1218fn format_source_ref(scheme: &str, value: &str) -> String {
1219    if value.contains("://") {
1220        value.to_string()
1221    } else {
1222        format!("{scheme}://{value}")
1223    }
1224}
1225
1226fn format_component_artifact(artifact: &ArtifactLocationV1) -> String {
1227    match artifact {
1228        ArtifactLocationV1::Inline { wasm_path, .. } => format!("inline ({})", wasm_path),
1229        ArtifactLocationV1::Remote => "remote".to_string(),
1230    }
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235    use super::*;
1236    use std::collections::HashMap;
1237    use std::os::unix::process::ExitStatusExt;
1238    use std::path::PathBuf;
1239
1240    fn sample_args() -> InspectArgs {
1241        InspectArgs {
1242            path: None,
1243            pack: None,
1244            input: None,
1245            archive: false,
1246            source: false,
1247            allow_oci_tags: false,
1248            flow_doctor: true,
1249            component_doctor: true,
1250            format: InspectFormat::Human,
1251            validate: true,
1252            no_validate: false,
1253            validators_root: PathBuf::from(".greentic/validators"),
1254            validator_pack: Vec::new(),
1255            validator_wasm: Vec::new(),
1256            validator_allow: vec![DEFAULT_VALIDATOR_ALLOW.to_string()],
1257            validator_cache_dir: PathBuf::from(".greentic/cache/validators"),
1258            validator_policy: ValidatorPolicy::Optional,
1259            online: false,
1260            use_describe_cache: false,
1261        }
1262    }
1263
1264    #[test]
1265    fn sort_json_orders_object_keys_recursively() {
1266        let value = serde_json::json!({
1267            "z": 1,
1268            "a": { "b": 2, "a": 1 },
1269            "list": [{ "d": 4, "c": 3 }]
1270        });
1271
1272        let sorted = to_sorted_json(value).expect("json serialization should succeed");
1273        let root_a = sorted.find("\"a\"").expect("root a key");
1274        let root_z = sorted.find("\"z\"").expect("root z key");
1275        let nested_a = sorted.find("\"a\": 1").expect("nested a key");
1276        let nested_b = sorted.find("\"b\": 2").expect("nested b key");
1277
1278        assert!(root_a < root_z, "root keys should be sorted: {sorted}");
1279        assert!(
1280            nested_a < nested_b,
1281            "nested keys should be sorted: {sorted}"
1282        );
1283    }
1284
1285    #[test]
1286    fn flow_doctor_unsupported_detects_common_cli_errors() {
1287        let output = std::process::Output {
1288            status: std::process::ExitStatus::from_raw(256),
1289            stdout: Vec::new(),
1290            stderr: b"error: unexpected argument '--stdin' found".to_vec(),
1291        };
1292
1293        assert!(flow_doctor_unsupported(&output));
1294    }
1295
1296    #[test]
1297    fn sanitize_component_id_replaces_path_like_characters() {
1298        assert_eq!(
1299            sanitize_component_id("demo/component:beta@1"),
1300            "demo_component_beta_1"
1301        );
1302    }
1303
1304    #[test]
1305    fn forbidden_source_paths_match_dev_only_inputs() {
1306        assert!(is_forbidden_source_path("pack.yaml"));
1307        assert!(is_forbidden_source_path("pack.manifest.json"));
1308        assert!(is_forbidden_source_path("flows/main.json"));
1309        assert!(is_forbidden_source_path("flows/main.ygtc"));
1310        assert!(is_forbidden_source_path("components/demo.manifest.json"));
1311        assert!(is_forbidden_source_path(
1312            "components/demo/component.manifest.json"
1313        ));
1314        assert!(!is_forbidden_source_path("gui/assets/index.html"));
1315        // Asset index files are not source-only; the bundle/runtime rely on them.
1316        assert!(!is_forbidden_source_path("assets/i18n/_manifest.json"));
1317        assert!(!is_forbidden_source_path("assets/i18n/en/_manifest.json"));
1318        assert!(!is_forbidden_source_path("assets/cards/_manifest.json"));
1319    }
1320
1321    #[test]
1322    fn find_forbidden_source_paths_returns_only_matching_entries() {
1323        let files = HashMap::from([
1324            ("pack.yaml".to_string(), Vec::new()),
1325            ("flows/main.ygtc".to_string(), Vec::new()),
1326            ("gui/assets/index.html".to_string(), Vec::new()),
1327        ]);
1328
1329        let forbidden = find_forbidden_source_paths(&files);
1330        assert_eq!(forbidden.len(), 2);
1331        assert!(forbidden.contains(&"pack.yaml".to_string()));
1332        assert!(forbidden.contains(&"flows/main.ygtc".to_string()));
1333    }
1334
1335    #[test]
1336    fn resolve_mode_prefers_pack_and_input_flags() {
1337        let pack_args = InspectArgs {
1338            pack: Some(PathBuf::from("demo.gtpack")),
1339            ..sample_args()
1340        };
1341        let source_args = InspectArgs {
1342            input: Some(PathBuf::from("demo")),
1343            ..sample_args()
1344        };
1345
1346        assert!(matches!(
1347            resolve_mode(&pack_args).expect("pack mode"),
1348            InspectMode::Archive(path) if path.as_path() == std::path::Path::new("demo.gtpack")
1349        ));
1350        assert!(matches!(
1351            resolve_mode(&source_args).expect("source mode"),
1352            InspectMode::Source(path) if path.as_path() == std::path::Path::new("demo")
1353        ));
1354    }
1355
1356    #[test]
1357    fn resolve_mode_auto_detects_dir_and_gtpack_file() {
1358        let temp = tempfile::tempdir().expect("tempdir");
1359        let dir = temp.path().join("pack");
1360        let file = temp.path().join("pack.gtpack");
1361        std::fs::create_dir_all(&dir).expect("dir");
1362        std::fs::write(&file, b"stub").expect("file");
1363
1364        let dir_args = InspectArgs {
1365            path: Some(dir.clone()),
1366            ..sample_args()
1367        };
1368        let file_args = InspectArgs {
1369            path: Some(file.clone()),
1370            ..sample_args()
1371        };
1372
1373        assert!(matches!(
1374            resolve_mode(&dir_args).expect("dir mode"),
1375            InspectMode::Source(path) if path == dir
1376        ));
1377        assert!(matches!(
1378            resolve_mode(&file_args).expect("file mode"),
1379            InspectMode::Archive(path) if path == file
1380        ));
1381    }
1382
1383    #[test]
1384    fn parse_validator_wasm_args_rejects_missing_paths() {
1385        let err = parse_validator_wasm_args(&["demo.component=".to_string()])
1386            .expect_err("missing validator path should fail");
1387        assert!(
1388            err.to_string()
1389                .contains("expected format COMPONENT_ID=FILE")
1390        );
1391    }
1392
1393    #[test]
1394    fn parse_validator_wasm_args_parses_component_pairs() {
1395        let validators = parse_validator_wasm_args(&[
1396            "demo.component=validators/demo.wasm".to_string(),
1397            "other.component = validators/other.wasm".to_string(),
1398        ])
1399        .expect("validator args should parse");
1400
1401        assert_eq!(validators.len(), 2);
1402        assert_eq!(validators[0].component_id, "demo.component");
1403        assert_eq!(validators[1].path, PathBuf::from("validators/other.wasm"));
1404    }
1405
1406    #[test]
1407    fn format_helpers_preserve_existing_schemes_and_inline_paths() {
1408        assert_eq!(format_source_ref("oci", "oci://example"), "oci://example");
1409        assert_eq!(
1410            format_source_ref("file", "components/demo.wasm"),
1411            "file://components/demo.wasm"
1412        );
1413        assert_eq!(
1414            format_component_artifact(&ArtifactLocationV1::Inline {
1415                wasm_path: "components/demo.wasm".to_string(),
1416                manifest_path: None,
1417            }),
1418            "inline (components/demo.wasm)"
1419        );
1420        assert_eq!(
1421            format_component_artifact(&ArtifactLocationV1::Remote),
1422            "remote"
1423        );
1424    }
1425}