Skip to main content

packc/cli/
inspect.rs

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