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