1#![forbid(unsafe_code)]
2
3use std::collections::HashSet;
4use std::fs::File;
5use std::io::Read;
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result, anyhow, bail};
9use clap::{Args, Subcommand};
10use greentic_types::pack_manifest::{PackManifest, PackSignatures};
11use greentic_types::provider::{ProviderDecl, ProviderExtensionInline};
12use greentic_types::{PackId, PackKind, decode_pack_manifest};
13use tempfile::TempDir;
14use zip::ZipArchive;
15
16use crate::cli::input::materialize_pack_path;
17
18#[derive(Debug, Subcommand)]
19pub enum ProvidersCommand {
20 List(ListArgs),
22 Info(InfoArgs),
24 Validate(ValidateArgs),
26}
27
28#[derive(Debug, Args)]
29pub struct ListArgs {
30 #[arg(long = "pack", value_name = "PATH")]
32 pub pack: Option<PathBuf>,
33
34 #[arg(long)]
36 pub json: bool,
37}
38
39#[derive(Debug, Args)]
40pub struct InfoArgs {
41 #[arg(value_name = "PROVIDER_ID")]
43 pub provider_id: String,
44
45 #[arg(long = "pack", value_name = "PATH")]
47 pub pack: Option<PathBuf>,
48
49 #[arg(long)]
51 pub json: bool,
52}
53
54#[derive(Debug, Args)]
55pub struct ValidateArgs {
56 #[arg(long = "pack", value_name = "PATH")]
58 pub pack: Option<PathBuf>,
59
60 #[arg(long)]
62 pub strict: bool,
63
64 #[arg(long)]
66 pub json: bool,
67}
68
69pub fn run(cmd: ProvidersCommand) -> Result<()> {
70 match cmd {
71 ProvidersCommand::List(args) => list(&args),
72 ProvidersCommand::Info(args) => info(&args),
73 ProvidersCommand::Validate(args) => validate(&args),
74 }
75}
76
77pub fn list(args: &ListArgs) -> Result<()> {
78 let pack = load_pack(args.pack.as_deref())?;
79 let providers = providers_from_manifest(&pack.manifest);
80
81 if args.json {
82 println!("{}", serde_json::to_string_pretty(&providers)?);
83 return Ok(());
84 }
85
86 if providers.is_empty() {
87 println!(
88 "{}",
89 crate::cli_i18n::t("cli.providers.no_providers_declared")
90 );
91 return Ok(());
92 }
93
94 println!("{}", crate::cli_i18n::t("cli.providers.table_header"));
95 for provider in providers {
96 let runtime = format!(
97 "{}::{}",
98 provider.runtime.component_ref, provider.runtime.export
99 );
100 let kind = provider_kind(&provider);
101 let details = summarize_provider(&provider);
102 println!(
103 "{:<24} {:<28} {:<16} {}",
104 provider.provider_type, runtime, kind, details
105 );
106 }
107
108 Ok(())
109}
110
111pub fn info(args: &InfoArgs) -> Result<()> {
112 let pack = load_pack(args.pack.as_deref())?;
113 let inline = match pack.manifest.provider_extension_inline() {
114 Some(value) => value,
115 None => bail!(
116 "{}",
117 crate::cli_i18n::t("cli.providers.error.extension_not_present")
118 ),
119 };
120 let Some(provider) = inline
121 .providers
122 .iter()
123 .find(|p| p.provider_type == args.provider_id)
124 else {
125 bail!(
126 "{}",
127 crate::cli_i18n::tf(
128 "cli.providers.error.provider_not_found",
129 &[&args.provider_id]
130 )
131 );
132 };
133
134 if args.json {
135 println!("{}", serde_json::to_string_pretty(provider)?);
136 } else {
137 let yaml = serde_yaml_bw::to_string(provider)?;
138 println!("{yaml}");
139 }
140
141 Ok(())
142}
143
144pub fn validate(args: &ValidateArgs) -> Result<()> {
145 let pack = load_pack(args.pack.as_deref())?;
146 let Some(inline) = pack.manifest.provider_extension_inline() else {
147 if args.json {
148 println!(
149 "{}",
150 serde_json::to_string_pretty(&serde_json::json!({
151 "status": crate::cli_i18n::t("cli.status.ok"),
152 "providers_present": false,
153 "warnings": [],
154 }))?
155 );
156 } else {
157 println!(
158 "{}",
159 crate::cli_i18n::t("cli.providers.valid_extension_not_present")
160 );
161 }
162 return Ok(());
163 };
164
165 if let Err(err) = inline.validate_basic() {
166 return Err(anyhow!(err.to_string()));
167 }
168
169 let warnings = validate_local_refs(inline, &pack);
170 if args.strict && !warnings.is_empty() {
171 let message = warnings.join("; ");
172 return Err(anyhow!(message));
173 }
174
175 if args.json {
176 println!(
177 "{}",
178 serde_json::to_string_pretty(&serde_json::json!({
179 "status": crate::cli_i18n::t("cli.status.ok"),
180 "providers_present": true,
181 "warnings": warnings,
182 }))?
183 );
184 } else if warnings.is_empty() {
185 println!("{}", crate::cli_i18n::t("cli.providers.valid"));
186 } else {
187 println!(
188 "{}",
189 crate::cli_i18n::t("cli.providers.valid_with_warnings")
190 );
191 for warning in warnings {
192 println!(
193 "{}",
194 crate::cli_i18n::tf("cli.providers.warning_item", &[&warning])
195 );
196 }
197 }
198
199 Ok(())
200}
201
202#[derive(Debug)]
203struct LoadedPack {
204 manifest: PackManifest,
205 root_dir: Option<PathBuf>,
206 entries: HashSet<String>,
207 _temp: Option<TempDir>,
208}
209
210fn load_pack(pack: Option<&Path>) -> Result<LoadedPack> {
211 let input = pack.unwrap_or_else(|| Path::new("."));
212 let root_dir = if input.is_dir() {
213 Some(
214 input
215 .canonicalize()
216 .with_context(|| format!("failed to canonicalize {}", input.display()))?,
217 )
218 } else {
219 None
220 };
221 let (temp, pack_path) = materialize_pack_path(input, false)?;
222 let (manifest, entries) = read_manifest(&pack_path)?;
223 Ok(LoadedPack {
224 manifest,
225 root_dir,
226 entries,
227 _temp: temp,
228 })
229}
230
231fn read_manifest(path: &Path) -> Result<(PackManifest, HashSet<String>)> {
232 let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
233 let mut archive = ZipArchive::new(file)
234 .with_context(|| format!("{} is not a valid gtpack archive", path.display()))?;
235 let mut entries = HashSet::new();
236 for i in 0..archive.len() {
237 let name = archive
238 .by_index(i)
239 .context("failed to read archive entry")?
240 .name()
241 .to_string();
242 entries.insert(name);
243 }
244
245 let mut manifest_entry = archive
246 .by_name("manifest.cbor")
247 .context("manifest.cbor missing from archive")?;
248 let mut buf = Vec::new();
249 manifest_entry.read_to_end(&mut buf)?;
250 let manifest = match decode_pack_manifest(&buf) {
251 Ok(manifest) => manifest,
252 Err(err) => {
253 let legacy: greentic_pack::builder::PackManifest =
255 serde_cbor::from_slice(&buf).map_err(|_| err)?;
256 downgrade_legacy_manifest(&legacy)?
257 }
258 };
259
260 Ok((manifest, entries))
261}
262
263fn downgrade_legacy_manifest(
264 manifest: &greentic_pack::builder::PackManifest,
265) -> Result<PackManifest> {
266 let pack_id =
267 PackId::new(manifest.meta.pack_id.clone()).context("legacy manifest pack_id is invalid")?;
268 Ok(PackManifest {
269 schema_version: "pack-v1".to_string(),
270 pack_id,
271 name: Some(manifest.meta.name.clone()),
272 version: manifest.meta.version.clone(),
273 kind: PackKind::Application,
274 publisher: manifest.meta.authors.first().cloned().unwrap_or_default(),
275 components: Vec::new(),
276 flows: Vec::new(),
277 dependencies: Vec::new(),
278 capabilities: Vec::new(),
279 secret_requirements: Vec::new(),
280 signatures: PackSignatures::default(),
281 bootstrap: None,
282 extensions: None,
283 })
284}
285
286fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
287 let mut providers = manifest
288 .provider_extension_inline()
289 .map(|inline| inline.providers.clone())
290 .unwrap_or_default();
291 providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
292 providers
293}
294
295fn provider_kind(provider: &ProviderDecl) -> String {
296 provider
297 .runtime
298 .world
299 .split('@')
300 .next()
301 .unwrap_or_default()
302 .to_string()
303}
304
305fn summarize_provider(provider: &ProviderDecl) -> String {
306 let caps = provider.capabilities.len();
307 let ops = provider.ops.len();
308 let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
309 parts.push(format!("config:{}", provider.config_schema_ref));
310 if let Some(docs) = provider.docs_ref.as_deref() {
311 parts.push(format!("docs:{docs}"));
312 }
313 parts.join(" ")
314}
315
316fn validate_local_refs(inline: &ProviderExtensionInline, pack: &LoadedPack) -> Vec<String> {
317 let mut warnings = Vec::new();
318 for provider in &inline.providers {
319 for (label, value) in referenced_paths(provider) {
320 if !is_local_ref(value) {
321 continue;
322 }
323 if !ref_exists(value, pack) {
324 warnings.push(format!(
325 "provider `{}` {} reference `{}` missing",
326 provider.provider_type, label, value
327 ));
328 }
329 }
330 }
331 warnings
332}
333
334fn referenced_paths(provider: &ProviderDecl) -> Vec<(&'static str, &str)> {
335 let mut refs = Vec::new();
336 refs.push(("config_schema_ref", provider.config_schema_ref.as_str()));
337 if let Some(state) = provider.state_schema_ref.as_deref() {
338 refs.push(("state_schema_ref", state));
339 }
340 if let Some(docs) = provider.docs_ref.as_deref() {
341 refs.push(("docs_ref", docs));
342 }
343 refs
344}
345
346fn is_local_ref(value: &str) -> bool {
347 !value.contains("://")
348}
349
350fn ref_exists(value: &str, pack: &LoadedPack) -> bool {
351 if let Some(root) = pack.root_dir.as_ref() {
352 let candidate = root.join(value);
353 if candidate.exists() {
354 return true;
355 }
356 }
357
358 pack.entries.contains(&normalize_entry(value))
359}
360
361fn normalize_entry(value: &str) -> String {
362 value
363 .split(std::path::MAIN_SEPARATOR)
364 .flat_map(|part| part.split(['/', '\\']))
365 .filter(|part| !part.is_empty())
366 .collect::<Vec<_>>()
367 .join("/")
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use greentic_types::pack_manifest::{ExtensionInline, ExtensionRef};
374 use greentic_types::provider::{PROVIDER_EXTENSION_ID, ProviderRuntimeRef};
375 use semver::Version;
376
377 fn provider(provider_type: &str) -> ProviderDecl {
378 ProviderDecl {
379 provider_type: provider_type.to_string(),
380 capabilities: vec!["send".to_string(), "receive".to_string()],
381 ops: vec!["send".to_string()],
382 config_schema_ref: "schemas/provider.json".to_string(),
383 state_schema_ref: Some("schemas/state.json".to_string()),
384 runtime: ProviderRuntimeRef {
385 component_ref: "provider.component".to_string(),
386 export: "provider".to_string(),
387 world: "greentic:provider/schema-core@1.0.0".to_string(),
388 },
389 docs_ref: Some("docs/provider.md".to_string()),
390 }
391 }
392
393 fn manifest_with_providers(providers: Vec<ProviderDecl>) -> PackManifest {
394 PackManifest {
395 schema_version: "pack-v1".to_string(),
396 pack_id: PackId::new("dev.local.providers").expect("pack id"),
397 name: Some("providers".to_string()),
398 version: Version::parse("0.1.0").expect("version"),
399 kind: PackKind::Application,
400 publisher: "test".to_string(),
401 components: Vec::new(),
402 flows: Vec::new(),
403 dependencies: Vec::new(),
404 capabilities: Vec::new(),
405 secret_requirements: Vec::new(),
406 signatures: PackSignatures::default(),
407 bootstrap: None,
408 extensions: Some(std::collections::BTreeMap::from([(
409 PROVIDER_EXTENSION_ID.to_string(),
410 ExtensionRef {
411 kind: PROVIDER_EXTENSION_ID.to_string(),
412 version: "1.0.0".to_string(),
413 digest: None,
414 location: None,
415 inline: Some(ExtensionInline::Provider(ProviderExtensionInline {
416 providers,
417 additional_fields: Default::default(),
418 })),
419 },
420 )])),
421 }
422 }
423
424 #[test]
425 fn providers_from_manifest_returns_sorted_entries() {
426 let manifest = manifest_with_providers(vec![provider("zeta"), provider("alpha")]);
427 let sorted = providers_from_manifest(&manifest);
428 assert_eq!(sorted[0].provider_type, "alpha");
429 assert_eq!(sorted[1].provider_type, "zeta");
430 }
431
432 #[test]
433 fn provider_helpers_summarize_runtime_and_docs() {
434 let provider = provider("messaging.demo");
435 assert_eq!(provider_kind(&provider), "greentic:provider/schema-core");
436
437 let summary = summarize_provider(&provider);
438 assert!(summary.contains("caps:2"));
439 assert!(summary.contains("ops:1"));
440 assert!(summary.contains("docs:docs/provider.md"));
441 }
442
443 #[test]
444 fn validate_local_refs_reports_missing_local_files_only() {
445 let temp = tempfile::tempdir().expect("tempdir");
446 std::fs::create_dir_all(temp.path().join("schemas")).expect("create schemas dir");
447 std::fs::write(temp.path().join("schemas/provider.json"), "{}").expect("write schema");
448 let inline = ProviderExtensionInline {
449 providers: vec![provider("messaging.demo")],
450 additional_fields: Default::default(),
451 };
452 let pack = LoadedPack {
453 manifest: manifest_with_providers(Vec::new()),
454 root_dir: Some(temp.path().to_path_buf()),
455 entries: HashSet::from(["docs/provider.md".to_string()]),
456 _temp: None,
457 };
458
459 let warnings = validate_local_refs(&inline, &pack);
460 assert_eq!(warnings.len(), 1);
461 assert!(warnings[0].contains("state_schema_ref"));
462 assert!(warnings[0].contains("schemas/state.json"));
463 }
464
465 #[test]
466 fn normalize_entry_and_is_local_ref_handle_mixed_paths() {
467 assert_eq!(
468 normalize_entry("schemas\\\\provider.json"),
469 "schemas/provider.json"
470 );
471 assert!(is_local_ref("docs/provider.md"));
472 assert!(!is_local_ref("oci://registry/provider"));
473 }
474}