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