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    if path.ends_with("manifest.json") {
581        return true;
582    }
583    false
584}
585
586enum InspectMode {
587    Archive(PathBuf),
588    Source(PathBuf),
589}
590
591fn resolve_mode(args: &InspectArgs) -> Result<InspectMode> {
592    if args.archive && args.source {
593        bail!("--archive and --source are mutually exclusive");
594    }
595    if args.pack.is_some() && args.input.is_some() {
596        bail!("exactly one of --pack or --in may be supplied");
597    }
598
599    if let Some(path) = &args.pack {
600        return Ok(InspectMode::Archive(path.clone()));
601    }
602    if let Some(path) = &args.input {
603        return Ok(InspectMode::Source(path.clone()));
604    }
605    if let Some(path) = &args.path {
606        let meta =
607            fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?;
608        if args.archive || (path.extension() == Some(std::ffi::OsStr::new("gtpack"))) {
609            return Ok(InspectMode::Archive(path.clone()));
610        }
611        if args.source || meta.is_dir() {
612            return Ok(InspectMode::Source(path.clone()));
613        }
614        if meta.is_file() {
615            return Ok(InspectMode::Archive(path.clone()));
616        }
617    }
618    Ok(InspectMode::Source(
619        std::env::current_dir().context("determine current directory")?,
620    ))
621}
622
623fn source_mode_pack_dir(mode: &InspectMode) -> Option<&Path> {
624    match mode {
625        InspectMode::Source(path) => Some(path.as_path()),
626        InspectMode::Archive(_) => None,
627    }
628}
629
630async fn inspect_source_dir(
631    dir: &Path,
632    runtime: &RuntimeContext,
633    allow_oci_tags: bool,
634) -> Result<PackLoad> {
635    let pack_dir = dir
636        .canonicalize()
637        .with_context(|| format!("failed to resolve pack dir {}", dir.display()))?;
638
639    let temp = TempDir::new().context("failed to allocate temp dir for inspect")?;
640    let manifest_out = temp.path().join("manifest.cbor");
641    let gtpack_out = temp.path().join("pack.gtpack");
642
643    let opts = build::BuildOptions {
644        pack_dir,
645        component_out: None,
646        manifest_out,
647        sbom_out: None,
648        gtpack_out: Some(gtpack_out.clone()),
649        lock_path: gtpack_out.with_extension("lock.json"), // use temp lock path under temp dir
650        bundle: build::BundleMode::Cache,
651        dry_run: false,
652        secrets_req: None,
653        default_secret_scope: None,
654        allow_oci_tags,
655        require_component_manifests: false,
656        no_extra_dirs: false,
657        dev: true,
658        runtime: runtime.clone(),
659        skip_update: false,
660        allow_pack_schema: false,
661        validate_extension_refs: false,
662    };
663
664    build::run(&opts).await?;
665
666    inspect_pack_file(&gtpack_out)
667}
668
669fn print_human(load: &PackLoad, validation: Option<&ValidationOutput>) {
670    let manifest = &load.manifest;
671    let report = &load.report;
672    println!(
673        "Pack: {} ({})",
674        manifest.meta.pack_id, manifest.meta.version
675    );
676    println!("Name: {}", manifest.meta.name);
677    println!("Flows: {}", manifest.flows.len());
678    if manifest.flows.is_empty() {
679        println!("Flows list: none");
680    } else {
681        println!("Flows list:");
682        for flow in &manifest.flows {
683            println!(
684                "  - {} (entry: {}, kind: {})",
685                flow.id, flow.entry, flow.kind
686            );
687        }
688    }
689    println!("Components: {}", manifest.components.len());
690    if manifest.components.is_empty() {
691        println!("Components list: none");
692    } else {
693        println!("Components list:");
694        for component in &manifest.components {
695            println!("  - {} ({})", component.name, component.version);
696        }
697    }
698    if let Some(gmanifest) = load.gpack_manifest.as_ref()
699        && let Some(value) = gmanifest
700            .extensions
701            .as_ref()
702            .and_then(|m| m.get(EXT_COMPONENT_SOURCES_V1))
703            .and_then(|ext| ext.inline.as_ref())
704            .and_then(|inline| match inline {
705                greentic_types::ExtensionInline::Other(v) => Some(v),
706                _ => None,
707            })
708        && let Ok(cs) = ComponentSourcesV1::from_extension_value(value)
709    {
710        let mut inline = 0usize;
711        let mut remote = 0usize;
712        let mut oci = 0usize;
713        let mut repo = 0usize;
714        let mut store = 0usize;
715        let mut file = 0usize;
716        for entry in &cs.components {
717            match entry.artifact {
718                ArtifactLocationV1::Inline { .. } => inline += 1,
719                ArtifactLocationV1::Remote => remote += 1,
720            }
721            match entry.source {
722                ComponentSourceRef::Oci(_) => oci += 1,
723                ComponentSourceRef::Repo(_) => repo += 1,
724                ComponentSourceRef::Store(_) => store += 1,
725                ComponentSourceRef::File(_) => file += 1,
726            }
727        }
728        println!(
729            "Component sources: {} total (origins: oci {}, repo {}, store {}, file {}; artifacts: inline {}, remote {})",
730            cs.components.len(),
731            oci,
732            repo,
733            store,
734            file,
735            inline,
736            remote
737        );
738        if cs.components.is_empty() {
739            println!("Component source entries: none");
740        } else {
741            println!("Component source entries:");
742            for entry in &cs.components {
743                println!(
744                    "  - {} source={} artifact={}",
745                    entry.name,
746                    format_component_source(&entry.source),
747                    format_component_artifact(&entry.artifact)
748                );
749            }
750        }
751    } else {
752        println!("Component sources: none");
753    }
754
755    if let Some(gmanifest) = load.gpack_manifest.as_ref() {
756        let providers = providers_from_manifest(gmanifest);
757        if providers.is_empty() {
758            println!("Providers: none");
759        } else {
760            println!("Providers:");
761            for provider in providers {
762                println!(
763                    "  - {} ({}) {}",
764                    provider.provider_type,
765                    provider_kind(&provider),
766                    summarize_provider(&provider)
767                );
768            }
769        }
770    } else {
771        println!("Providers: none");
772    }
773
774    let static_routes = load_static_routes(load);
775    if static_routes.is_empty() {
776        println!("Static routes: none");
777    } else {
778        println!("Static routes:");
779        for route in &static_routes {
780            println!(
781                "  - {} -> {} [{}]",
782                route.id, route.public_path, route.source_root
783            );
784            println!(
785                "    scope: tenant={} team={}",
786                route.scope.tenant, route.scope.team
787            );
788            println!(
789                "    index_file: {}",
790                route.index_file.as_deref().unwrap_or("none")
791            );
792            println!(
793                "    spa_fallback: {}",
794                route.spa_fallback.as_deref().unwrap_or("none")
795            );
796            println!(
797                "    cache: {}",
798                route
799                    .cache
800                    .as_ref()
801                    .map(|cache| match cache.max_age_seconds {
802                        Some(max_age) => format!("{} ({max_age}s)", cache.strategy),
803                        None => cache.strategy.clone(),
804                    })
805                    .unwrap_or_else(|| "none".to_string())
806            );
807            if route.exports.is_empty() {
808                println!("    exports: none");
809            } else {
810                let exports = route
811                    .exports
812                    .iter()
813                    .map(|(key, value)| format!("{key}={value}"))
814                    .collect::<Vec<_>>()
815                    .join(", ");
816                println!("    exports: {exports}");
817            }
818        }
819    }
820
821    if !report.warnings.is_empty() {
822        println!("Warnings:");
823        for warning in &report.warnings {
824            println!("  - {}", warning);
825        }
826    }
827
828    if let Some(report) = validation {
829        print_validation(report);
830    }
831}
832
833fn load_static_routes(load: &PackLoad) -> Vec<StaticRouteV1> {
834    load.gpack_manifest
835        .as_ref()
836        .and_then(|manifest| {
837            parse_static_routes_extension(&manifest.extensions)
838                .ok()
839                .flatten()
840        })
841        .map(|payload| payload.routes)
842        .unwrap_or_default()
843}
844
845#[derive(Clone, Debug, Serialize)]
846struct ValidationOutput {
847    #[serde(flatten)]
848    report: ValidationReport,
849    has_errors: bool,
850    sources: Vec<crate::validator::ValidatorSourceReport>,
851}
852
853fn has_error_diagnostics(diagnostics: &[Diagnostic]) -> bool {
854    diagnostics
855        .iter()
856        .any(|diag| matches!(diag.severity, Severity::Error))
857}
858
859async fn run_pack_validation(
860    load: &PackLoad,
861    source_pack_dir: Option<&Path>,
862    args: &InspectArgs,
863    runtime: &RuntimeContext,
864) -> Result<ValidationOutput> {
865    let ctx = ValidateCtx::from_pack_load(load);
866    let validators: Vec<Box<dyn greentic_types::validate::PackValidator>> = vec![
867        Box::new(ReferencedFilesExistValidator::new(ctx.clone())),
868        Box::new(SbomConsistencyValidator::new(ctx.clone())),
869        Box::new(ProviderReferencesExistValidator::new(ctx.clone())),
870        Box::new(SecretRequirementsValidator),
871        Box::new(StaticRoutesValidator::new(ctx.clone())),
872        Box::new(ComponentReferencesExistValidator),
873        Box::new(OauthCapabilityRequirementsValidator),
874    ];
875
876    let mut report = if let Some(manifest) = load.gpack_manifest.as_ref() {
877        run_validators(manifest, &ctx, &validators)
878    } else {
879        ValidationReport {
880            pack_id: None,
881            pack_version: None,
882            diagnostics: vec![Diagnostic {
883                severity: Severity::Warn,
884                code: "PACK_MANIFEST_UNSUPPORTED".to_string(),
885                message: "Pack manifest is not in the greentic-types format; skipping validation."
886                    .to_string(),
887                path: Some("manifest.cbor".to_string()),
888                hint: Some(
889                    "Rebuild the pack with greentic-pack build to enable validation.".to_string(),
890                ),
891                data: Value::Null,
892            }],
893        }
894    };
895
896    let config = ValidatorConfig {
897        validators_root: args.validators_root.clone(),
898        validator_packs: args.validator_pack.clone(),
899        validator_allow: args.validator_allow.clone(),
900        validator_cache_dir: args.validator_cache_dir.clone(),
901        policy: args.validator_policy,
902        local_validators: parse_validator_wasm_args(&args.validator_wasm)?,
903    };
904
905    let wasm_result = run_wasm_validators(load, &config, runtime).await?;
906    report.diagnostics.extend(wasm_result.diagnostics);
907    if let Some(pack_dir) = source_pack_dir {
908        report
909            .diagnostics
910            .extend(collect_extension_dependency_diagnostics(pack_dir));
911    }
912
913    let has_errors = has_error_diagnostics(&report.diagnostics) || wasm_result.missing_required;
914
915    Ok(ValidationOutput {
916        report,
917        has_errors,
918        sources: wasm_result.sources,
919    })
920}
921
922fn collect_extension_dependency_diagnostics(pack_dir: &Path) -> Vec<Diagnostic> {
923    let source_path = default_extensions_file_path(pack_dir);
924    let lock_path = default_extensions_lock_file_path(pack_dir);
925    let mut diagnostics = Vec::new();
926
927    let source = if source_path.exists() {
928        match read_extensions_file(&source_path) {
929            Ok(file) => Some(file),
930            Err(err) => {
931                diagnostics.push(Diagnostic {
932                    severity: Severity::Error,
933                    code: "PACK_EXTENSION_DEPENDENCY_SOURCE_INVALID".to_string(),
934                    message: err.to_string(),
935                    path: Some(path_display(pack_dir, &source_path)),
936                    hint: Some("fix pack.extensions.json and rerun doctor".to_string()),
937                    data: Value::Null,
938                });
939                None
940            }
941        }
942    } else {
943        None
944    };
945
946    let lock = if lock_path.exists() {
947        match read_extensions_lock_file(&lock_path) {
948            Ok(file) => Some(file),
949            Err(err) => {
950                diagnostics.push(Diagnostic {
951                    severity: Severity::Error,
952                    code: "PACK_EXTENSION_DEPENDENCY_LOCK_INVALID".to_string(),
953                    message: err.to_string(),
954                    path: Some(path_display(pack_dir, &lock_path)),
955                    hint: Some("rerun `greentic-pack extensions-lock --in <DIR>`".to_string()),
956                    data: Value::Null,
957                });
958                None
959            }
960        }
961    } else {
962        None
963    };
964
965    match (source.as_ref(), lock.as_ref()) {
966        (Some(_), None) => diagnostics.push(Diagnostic {
967            severity: Severity::Warn,
968            code: "PACK_EXTENSION_DEPENDENCY_LOCK_MISSING".to_string(),
969            message: "pack.extensions.json exists but pack.extensions.lock.json is missing"
970                .to_string(),
971            path: Some(path_display(pack_dir, &source_path)),
972            hint: Some("run `greentic-pack extensions-lock --in <DIR>`".to_string()),
973            data: Value::Null,
974        }),
975        (None, Some(_)) => diagnostics.push(Diagnostic {
976            severity: Severity::Warn,
977            code: "PACK_EXTENSION_DEPENDENCY_SOURCE_MISSING".to_string(),
978            message: "pack.extensions.lock.json exists but pack.extensions.json is missing"
979                .to_string(),
980            path: Some(path_display(pack_dir, &lock_path)),
981            hint: Some(
982                "restore pack.extensions.json or regenerate the lock from the intended source file"
983                    .to_string(),
984            ),
985            data: Value::Null,
986        }),
987        (Some(source), Some(lock)) => {
988            if let Err(err) = validate_extensions_lock_alignment(source, lock) {
989                diagnostics.push(Diagnostic {
990                    severity: Severity::Error,
991                    code: "PACK_EXTENSION_DEPENDENCY_LOCK_STALE".to_string(),
992                    message: err.to_string(),
993                    path: Some(path_display(pack_dir, &lock_path)),
994                    hint: Some("rerun `greentic-pack extensions-lock --in <DIR>` after editing pack.extensions.json".to_string()),
995                    data: Value::Null,
996                });
997            }
998        }
999        (None, None) => {}
1000    }
1001
1002    if let Some(source) = source.as_ref() {
1003        for extension in &source.extensions {
1004            if extension.id == DEPLOYER_EXTENSION_KEY && extension.role != "deployer" {
1005                diagnostics.push(Diagnostic {
1006                    severity: Severity::Error,
1007                    code: "PACK_DEPLOYER_EXTENSION_ROLE_INVALID".to_string(),
1008                    message: format!(
1009                        "extension `{}` must use role `deployer`, found `{}`",
1010                        extension.id, extension.role
1011                    ),
1012                    path: Some(path_display(pack_dir, &source_path)),
1013                    hint: Some("set the dependency role to `deployer`".to_string()),
1014                    data: Value::Null,
1015                });
1016            }
1017        }
1018    }
1019
1020    if let Some(lock) = lock.as_ref() {
1021        for extension in &lock.extensions {
1022            if extension.media_type.is_none() {
1023                diagnostics.push(Diagnostic {
1024                    severity: Severity::Warn,
1025                    code: "PACK_EXTENSION_DEPENDENCY_LOCK_MISSING_MEDIA_TYPE".to_string(),
1026                    message: format!(
1027                        "extension `{}` lock entry is missing media_type metadata",
1028                        extension.id
1029                    ),
1030                    path: Some(path_display(pack_dir, &lock_path)),
1031                    hint: Some("rerun `greentic-pack extensions-lock --in <DIR>` with a resolver that reports content type".to_string()),
1032                    data: Value::Null,
1033                });
1034            }
1035            if extension.size_bytes.is_none() {
1036                diagnostics.push(Diagnostic {
1037                    severity: Severity::Warn,
1038                    code: "PACK_EXTENSION_DEPENDENCY_LOCK_MISSING_SIZE".to_string(),
1039                    message: format!(
1040                        "extension `{}` lock entry is missing size metadata",
1041                        extension.id
1042                    ),
1043                    path: Some(path_display(pack_dir, &lock_path)),
1044                    hint: Some("rerun `greentic-pack extensions-lock --in <DIR>` with a resolver that reports content length".to_string()),
1045                    data: Value::Null,
1046                });
1047            }
1048        }
1049    }
1050
1051    diagnostics
1052}
1053
1054fn path_display(root: &Path, path: &Path) -> String {
1055    path.strip_prefix(root)
1056        .unwrap_or(path)
1057        .display()
1058        .to_string()
1059}
1060
1061fn print_validation(report: &ValidationOutput) {
1062    let (info, warn, error) = validation_counts(&report.report);
1063    println!("Validation:");
1064    println!("  Info: {info} Warn: {warn} Error: {error}");
1065    if report.report.diagnostics.is_empty() {
1066        println!("  - none");
1067        return;
1068    }
1069    for diag in &report.report.diagnostics {
1070        let sev = match diag.severity {
1071            Severity::Info => "INFO",
1072            Severity::Warn => "WARN",
1073            Severity::Error => "ERROR",
1074        };
1075        if let Some(path) = diag.path.as_deref() {
1076            println!("  - [{sev}] {} {} - {}", diag.code, path, diag.message);
1077        } else {
1078            println!("  - [{sev}] {} - {}", diag.code, diag.message);
1079        }
1080        if matches!(
1081            diag.code.as_str(),
1082            "PACK_FLOW_DOCTOR_FAILED" | "PACK_COMPONENT_DOCTOR_FAILED"
1083        ) {
1084            print_doctor_failure_details(&diag.data);
1085        }
1086        if let Some(hint) = diag.hint.as_deref() {
1087            println!("    hint: {hint}");
1088        }
1089    }
1090}
1091
1092fn parse_validator_wasm_args(args: &[String]) -> Result<Vec<LocalValidator>> {
1093    let mut local_validators = Vec::new();
1094    for entry in args {
1095        let mut segments = entry.splitn(2, '=');
1096        let component_id = segments.next().unwrap_or_default().trim().to_string();
1097        let path = segments
1098            .next()
1099            .map(|p| p.trim())
1100            .filter(|p| !p.is_empty())
1101            .ok_or_else(|| {
1102                anyhow!(
1103                    "invalid --validator-wasm argument `{}` (expected format COMPONENT_ID=FILE)",
1104                    entry
1105                )
1106            })?;
1107        if component_id.is_empty() {
1108            return Err(anyhow!(
1109                "validator component id must not be empty in `{}`",
1110                entry
1111            ));
1112        }
1113        local_validators.push(LocalValidator {
1114            component_id,
1115            path: PathBuf::from(path),
1116        });
1117    }
1118    Ok(local_validators)
1119}
1120
1121fn print_doctor_failure_details(data: &Value) {
1122    let Some(obj) = data.as_object() else {
1123        return;
1124    };
1125    let stdout = obj.get("stdout").and_then(|value| value.as_str());
1126    let stderr = obj.get("stderr").and_then(|value| value.as_str());
1127    let status = obj.get("status").and_then(|value| value.as_i64());
1128    if let Some(status) = status {
1129        println!("    status: {status}");
1130    }
1131    if let Some(stderr) = stderr {
1132        let trimmed = stderr.trim();
1133        if !trimmed.is_empty() {
1134            println!("    stderr: {trimmed}");
1135        }
1136    }
1137    if let Some(stdout) = stdout {
1138        let trimmed = stdout.trim();
1139        if !trimmed.is_empty() {
1140            println!("    stdout: {trimmed}");
1141        }
1142    }
1143}
1144
1145fn validation_counts(report: &ValidationReport) -> (usize, usize, usize) {
1146    let mut info = 0;
1147    let mut warn = 0;
1148    let mut error = 0;
1149    for diag in &report.diagnostics {
1150        match diag.severity {
1151            Severity::Info => info += 1,
1152            Severity::Warn => warn += 1,
1153            Severity::Error => error += 1,
1154        }
1155    }
1156    (info, warn, error)
1157}
1158
1159#[derive(Debug, Clone, Copy, clap::ValueEnum)]
1160pub enum InspectFormat {
1161    Human,
1162    Json,
1163}
1164
1165fn resolve_format(args: &InspectArgs, json: bool) -> InspectFormat {
1166    if json {
1167        InspectFormat::Json
1168    } else {
1169        args.format
1170    }
1171}
1172
1173fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
1174    let mut providers = manifest
1175        .provider_extension_inline()
1176        .map(|inline| inline.providers.clone())
1177        .unwrap_or_default();
1178    providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
1179    providers
1180}
1181
1182fn provider_kind(provider: &ProviderDecl) -> String {
1183    provider
1184        .runtime
1185        .world
1186        .split('@')
1187        .next()
1188        .unwrap_or_default()
1189        .to_string()
1190}
1191
1192fn summarize_provider(provider: &ProviderDecl) -> String {
1193    let caps = provider.capabilities.len();
1194    let ops = provider.ops.len();
1195    let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
1196    parts.push(format!("config:{}", provider.config_schema_ref));
1197    if let Some(docs) = provider.docs_ref.as_deref() {
1198        parts.push(format!("docs:{docs}"));
1199    }
1200    parts.join(" ")
1201}
1202
1203fn format_component_source(source: &ComponentSourceRef) -> String {
1204    match source {
1205        ComponentSourceRef::Oci(value) => format_source_ref("oci", value),
1206        ComponentSourceRef::Repo(value) => format_source_ref("repo", value),
1207        ComponentSourceRef::Store(value) => format_source_ref("store", value),
1208        ComponentSourceRef::File(value) => format_source_ref("file", value),
1209    }
1210}
1211
1212fn format_source_ref(scheme: &str, value: &str) -> String {
1213    if value.contains("://") {
1214        value.to_string()
1215    } else {
1216        format!("{scheme}://{value}")
1217    }
1218}
1219
1220fn format_component_artifact(artifact: &ArtifactLocationV1) -> String {
1221    match artifact {
1222        ArtifactLocationV1::Inline { wasm_path, .. } => format!("inline ({})", wasm_path),
1223        ArtifactLocationV1::Remote => "remote".to_string(),
1224    }
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229    use super::*;
1230    use std::collections::HashMap;
1231    use std::os::unix::process::ExitStatusExt;
1232    use std::path::PathBuf;
1233
1234    fn sample_args() -> InspectArgs {
1235        InspectArgs {
1236            path: None,
1237            pack: None,
1238            input: None,
1239            archive: false,
1240            source: false,
1241            allow_oci_tags: false,
1242            flow_doctor: true,
1243            component_doctor: true,
1244            format: InspectFormat::Human,
1245            validate: true,
1246            no_validate: false,
1247            validators_root: PathBuf::from(".greentic/validators"),
1248            validator_pack: Vec::new(),
1249            validator_wasm: Vec::new(),
1250            validator_allow: vec![DEFAULT_VALIDATOR_ALLOW.to_string()],
1251            validator_cache_dir: PathBuf::from(".greentic/cache/validators"),
1252            validator_policy: ValidatorPolicy::Optional,
1253            online: false,
1254            use_describe_cache: false,
1255        }
1256    }
1257
1258    #[test]
1259    fn sort_json_orders_object_keys_recursively() {
1260        let value = serde_json::json!({
1261            "z": 1,
1262            "a": { "b": 2, "a": 1 },
1263            "list": [{ "d": 4, "c": 3 }]
1264        });
1265
1266        let sorted = to_sorted_json(value).expect("json serialization should succeed");
1267        let root_a = sorted.find("\"a\"").expect("root a key");
1268        let root_z = sorted.find("\"z\"").expect("root z key");
1269        let nested_a = sorted.find("\"a\": 1").expect("nested a key");
1270        let nested_b = sorted.find("\"b\": 2").expect("nested b key");
1271
1272        assert!(root_a < root_z, "root keys should be sorted: {sorted}");
1273        assert!(
1274            nested_a < nested_b,
1275            "nested keys should be sorted: {sorted}"
1276        );
1277    }
1278
1279    #[test]
1280    fn flow_doctor_unsupported_detects_common_cli_errors() {
1281        let output = std::process::Output {
1282            status: std::process::ExitStatus::from_raw(256),
1283            stdout: Vec::new(),
1284            stderr: b"error: unexpected argument '--stdin' found".to_vec(),
1285        };
1286
1287        assert!(flow_doctor_unsupported(&output));
1288    }
1289
1290    #[test]
1291    fn sanitize_component_id_replaces_path_like_characters() {
1292        assert_eq!(
1293            sanitize_component_id("demo/component:beta@1"),
1294            "demo_component_beta_1"
1295        );
1296    }
1297
1298    #[test]
1299    fn forbidden_source_paths_match_dev_only_inputs() {
1300        assert!(is_forbidden_source_path("pack.yaml"));
1301        assert!(is_forbidden_source_path("flows/main.json"));
1302        assert!(is_forbidden_source_path("flows/main.ygtc"));
1303        assert!(is_forbidden_source_path("components/demo.manifest.json"));
1304        assert!(!is_forbidden_source_path("gui/assets/index.html"));
1305    }
1306
1307    #[test]
1308    fn find_forbidden_source_paths_returns_only_matching_entries() {
1309        let files = HashMap::from([
1310            ("pack.yaml".to_string(), Vec::new()),
1311            ("flows/main.ygtc".to_string(), Vec::new()),
1312            ("gui/assets/index.html".to_string(), Vec::new()),
1313        ]);
1314
1315        let forbidden = find_forbidden_source_paths(&files);
1316        assert_eq!(forbidden.len(), 2);
1317        assert!(forbidden.contains(&"pack.yaml".to_string()));
1318        assert!(forbidden.contains(&"flows/main.ygtc".to_string()));
1319    }
1320
1321    #[test]
1322    fn resolve_mode_prefers_pack_and_input_flags() {
1323        let pack_args = InspectArgs {
1324            pack: Some(PathBuf::from("demo.gtpack")),
1325            ..sample_args()
1326        };
1327        let source_args = InspectArgs {
1328            input: Some(PathBuf::from("demo")),
1329            ..sample_args()
1330        };
1331
1332        assert!(matches!(
1333            resolve_mode(&pack_args).expect("pack mode"),
1334            InspectMode::Archive(path) if path.as_path() == std::path::Path::new("demo.gtpack")
1335        ));
1336        assert!(matches!(
1337            resolve_mode(&source_args).expect("source mode"),
1338            InspectMode::Source(path) if path.as_path() == std::path::Path::new("demo")
1339        ));
1340    }
1341
1342    #[test]
1343    fn resolve_mode_auto_detects_dir_and_gtpack_file() {
1344        let temp = tempfile::tempdir().expect("tempdir");
1345        let dir = temp.path().join("pack");
1346        let file = temp.path().join("pack.gtpack");
1347        std::fs::create_dir_all(&dir).expect("dir");
1348        std::fs::write(&file, b"stub").expect("file");
1349
1350        let dir_args = InspectArgs {
1351            path: Some(dir.clone()),
1352            ..sample_args()
1353        };
1354        let file_args = InspectArgs {
1355            path: Some(file.clone()),
1356            ..sample_args()
1357        };
1358
1359        assert!(matches!(
1360            resolve_mode(&dir_args).expect("dir mode"),
1361            InspectMode::Source(path) if path == dir
1362        ));
1363        assert!(matches!(
1364            resolve_mode(&file_args).expect("file mode"),
1365            InspectMode::Archive(path) if path == file
1366        ));
1367    }
1368
1369    #[test]
1370    fn parse_validator_wasm_args_rejects_missing_paths() {
1371        let err = parse_validator_wasm_args(&["demo.component=".to_string()])
1372            .expect_err("missing validator path should fail");
1373        assert!(
1374            err.to_string()
1375                .contains("expected format COMPONENT_ID=FILE")
1376        );
1377    }
1378
1379    #[test]
1380    fn parse_validator_wasm_args_parses_component_pairs() {
1381        let validators = parse_validator_wasm_args(&[
1382            "demo.component=validators/demo.wasm".to_string(),
1383            "other.component = validators/other.wasm".to_string(),
1384        ])
1385        .expect("validator args should parse");
1386
1387        assert_eq!(validators.len(), 2);
1388        assert_eq!(validators[0].component_id, "demo.component");
1389        assert_eq!(validators[1].path, PathBuf::from("validators/other.wasm"));
1390    }
1391
1392    #[test]
1393    fn format_helpers_preserve_existing_schemes_and_inline_paths() {
1394        assert_eq!(format_source_ref("oci", "oci://example"), "oci://example");
1395        assert_eq!(
1396            format_source_ref("file", "components/demo.wasm"),
1397            "file://components/demo.wasm"
1398        );
1399        assert_eq!(
1400            format_component_artifact(&ArtifactLocationV1::Inline {
1401                wasm_path: "components/demo.wasm".to_string(),
1402                manifest_path: None,
1403            }),
1404            "inline (components/demo.wasm)"
1405        );
1406        assert_eq!(
1407            format_component_artifact(&ArtifactLocationV1::Remote),
1408            "remote"
1409        );
1410    }
1411}