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!("No providers declared.");
88 return Ok(());
89 }
90
91 println!("{:<24} {:<28} {:<16} DETAILS", "ID", "RUNTIME", "KIND");
92 for provider in providers {
93 let runtime = format!(
94 "{}::{}",
95 provider.runtime.component_ref, provider.runtime.export
96 );
97 let kind = provider_kind(&provider);
98 let details = summarize_provider(&provider);
99 println!(
100 "{:<24} {:<28} {:<16} {}",
101 provider.provider_type, runtime, kind, details
102 );
103 }
104
105 Ok(())
106}
107
108pub fn info(args: &InfoArgs) -> Result<()> {
109 let pack = load_pack(args.pack.as_deref())?;
110 let inline = match pack.manifest.provider_extension_inline() {
111 Some(value) => value,
112 None => bail!("provider extension not present"),
113 };
114 let Some(provider) = inline
115 .providers
116 .iter()
117 .find(|p| p.provider_type == args.provider_id)
118 else {
119 bail!("provider `{}` not found", args.provider_id);
120 };
121
122 if args.json {
123 println!("{}", serde_json::to_string_pretty(provider)?);
124 } else {
125 let yaml = serde_yaml_bw::to_string(provider)?;
126 println!("{yaml}");
127 }
128
129 Ok(())
130}
131
132pub fn validate(args: &ValidateArgs) -> Result<()> {
133 let pack = load_pack(args.pack.as_deref())?;
134 let Some(inline) = pack.manifest.provider_extension_inline() else {
135 if args.json {
136 println!(
137 "{}",
138 serde_json::to_string_pretty(&serde_json::json!({
139 "status": "ok",
140 "providers_present": false,
141 "warnings": [],
142 }))?
143 );
144 } else {
145 println!("providers valid (extension not present)");
146 }
147 return Ok(());
148 };
149
150 if let Err(err) = inline.validate_basic() {
151 return Err(anyhow!(err.to_string()));
152 }
153
154 let warnings = validate_local_refs(inline, &pack);
155 if args.strict && !warnings.is_empty() {
156 let message = warnings.join("; ");
157 return Err(anyhow!(message));
158 }
159
160 if args.json {
161 println!(
162 "{}",
163 serde_json::to_string_pretty(&serde_json::json!({
164 "status": "ok",
165 "providers_present": true,
166 "warnings": warnings,
167 }))?
168 );
169 } else if warnings.is_empty() {
170 println!("providers valid");
171 } else {
172 println!("providers valid with warnings:");
173 for warning in warnings {
174 println!(" - {warning}");
175 }
176 }
177
178 Ok(())
179}
180
181#[derive(Debug)]
182struct LoadedPack {
183 manifest: PackManifest,
184 root_dir: Option<PathBuf>,
185 entries: HashSet<String>,
186 _temp: Option<TempDir>,
187}
188
189fn load_pack(pack: Option<&Path>) -> Result<LoadedPack> {
190 let input = pack.unwrap_or_else(|| Path::new("."));
191 let root_dir = if input.is_dir() {
192 Some(
193 input
194 .canonicalize()
195 .with_context(|| format!("failed to canonicalize {}", input.display()))?,
196 )
197 } else {
198 None
199 };
200 let (temp, pack_path) = materialize_pack_path(input, false)?;
201 let (manifest, entries) = read_manifest(&pack_path)?;
202 Ok(LoadedPack {
203 manifest,
204 root_dir,
205 entries,
206 _temp: temp,
207 })
208}
209
210fn read_manifest(path: &Path) -> Result<(PackManifest, HashSet<String>)> {
211 let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?;
212 let mut archive = ZipArchive::new(file)
213 .with_context(|| format!("{} is not a valid gtpack archive", path.display()))?;
214 let mut entries = HashSet::new();
215 for i in 0..archive.len() {
216 let name = archive
217 .by_index(i)
218 .context("failed to read archive entry")?
219 .name()
220 .to_string();
221 entries.insert(name);
222 }
223
224 let mut manifest_entry = archive
225 .by_name("manifest.cbor")
226 .context("manifest.cbor missing from archive")?;
227 let mut buf = Vec::new();
228 manifest_entry.read_to_end(&mut buf)?;
229 let manifest = match decode_pack_manifest(&buf) {
230 Ok(manifest) => manifest,
231 Err(err) => {
232 let legacy: greentic_pack::builder::PackManifest =
234 serde_cbor::from_slice(&buf).map_err(|_| err)?;
235 downgrade_legacy_manifest(&legacy)?
236 }
237 };
238
239 Ok((manifest, entries))
240}
241
242fn downgrade_legacy_manifest(
243 manifest: &greentic_pack::builder::PackManifest,
244) -> Result<PackManifest> {
245 let pack_id =
246 PackId::new(manifest.meta.pack_id.clone()).context("legacy manifest pack_id is invalid")?;
247 Ok(PackManifest {
248 schema_version: "pack-v1".to_string(),
249 pack_id,
250 name: Some(manifest.meta.name.clone()),
251 version: manifest.meta.version.clone(),
252 kind: PackKind::Application,
253 publisher: manifest.meta.authors.first().cloned().unwrap_or_default(),
254 components: Vec::new(),
255 flows: Vec::new(),
256 dependencies: Vec::new(),
257 capabilities: Vec::new(),
258 secret_requirements: Vec::new(),
259 signatures: PackSignatures::default(),
260 bootstrap: None,
261 extensions: None,
262 })
263}
264
265fn providers_from_manifest(manifest: &PackManifest) -> Vec<ProviderDecl> {
266 let mut providers = manifest
267 .provider_extension_inline()
268 .map(|inline| inline.providers.clone())
269 .unwrap_or_default();
270 providers.sort_by(|a, b| a.provider_type.cmp(&b.provider_type));
271 providers
272}
273
274fn provider_kind(provider: &ProviderDecl) -> String {
275 provider
276 .runtime
277 .world
278 .split('@')
279 .next()
280 .unwrap_or_default()
281 .to_string()
282}
283
284fn summarize_provider(provider: &ProviderDecl) -> String {
285 let caps = provider.capabilities.len();
286 let ops = provider.ops.len();
287 let mut parts = vec![format!("caps:{caps}"), format!("ops:{ops}")];
288 parts.push(format!("config:{}", provider.config_schema_ref));
289 if let Some(docs) = provider.docs_ref.as_deref() {
290 parts.push(format!("docs:{docs}"));
291 }
292 parts.join(" ")
293}
294
295fn validate_local_refs(inline: &ProviderExtensionInline, pack: &LoadedPack) -> Vec<String> {
296 let mut warnings = Vec::new();
297 for provider in &inline.providers {
298 for (label, value) in referenced_paths(provider) {
299 if !is_local_ref(value) {
300 continue;
301 }
302 if !ref_exists(value, pack) {
303 warnings.push(format!(
304 "provider `{}` {} reference `{}` missing",
305 provider.provider_type, label, value
306 ));
307 }
308 }
309 }
310 warnings
311}
312
313fn referenced_paths(provider: &ProviderDecl) -> Vec<(&'static str, &str)> {
314 let mut refs = Vec::new();
315 refs.push(("config_schema_ref", provider.config_schema_ref.as_str()));
316 if let Some(state) = provider.state_schema_ref.as_deref() {
317 refs.push(("state_schema_ref", state));
318 }
319 if let Some(docs) = provider.docs_ref.as_deref() {
320 refs.push(("docs_ref", docs));
321 }
322 refs
323}
324
325fn is_local_ref(value: &str) -> bool {
326 !value.contains("://")
327}
328
329fn ref_exists(value: &str, pack: &LoadedPack) -> bool {
330 if let Some(root) = pack.root_dir.as_ref() {
331 let candidate = root.join(value);
332 if candidate.exists() {
333 return true;
334 }
335 }
336
337 pack.entries.contains(&normalize_entry(value))
338}
339
340fn normalize_entry(value: &str) -> String {
341 value
342 .split(std::path::MAIN_SEPARATOR)
343 .flat_map(|part| part.split('/'))
344 .filter(|part| !part.is_empty())
345 .collect::<Vec<_>>()
346 .join("/")
347}