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