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