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