1use std::path::{Path, PathBuf};
5
6use serde::Serialize;
7use serde_cbor::Value as CborValue;
8use zip::result::ZipError;
9
10#[derive(Clone, Debug, Serialize)]
12pub struct DiscoveryResult {
13 pub domains: DetectedDomains,
14 pub providers: Vec<DetectedProvider>,
15 pub app_packs: Vec<DetectedProvider>,
16}
17
18#[derive(Clone, Debug, Serialize)]
20pub struct DetectedDomains {
21 pub messaging: bool,
22 pub events: bool,
23 pub oauth: bool,
24 pub state: bool,
25 pub secrets: bool,
26}
27
28#[derive(Clone, Debug, Serialize)]
30pub struct DetectedProvider {
31 pub provider_id: String,
32 pub display_name: Option<String>,
33 pub domain: String,
34 pub pack_path: PathBuf,
35 pub id_source: ProviderIdSource,
36 pub kind: DetectedPackKind,
37}
38
39#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42pub enum DetectedPackKind {
43 Provider,
44 App,
45}
46
47#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
49#[serde(rename_all = "lowercase")]
50pub enum ProviderIdSource {
51 Manifest,
52 Filename,
53}
54
55struct PackMeta {
57 pack_id: String,
58 display_name: Option<String>,
59}
60
61#[derive(Clone, Debug)]
63pub struct DiscoveredPackMeta {
64 pub pack_id: String,
65 pub display_name: Option<String>,
66}
67
68#[derive(Default)]
70pub struct DiscoveryOptions {
71 pub cbor_only: bool,
73}
74
75const DOMAIN_DIRS: &[(&str, &str)] = &[
77 ("messaging", "providers/messaging"),
78 ("events", "providers/events"),
79 ("oauth", "providers/oauth"),
80 ("state", "providers/state"),
81 ("secrets", "providers/secrets"),
82];
83
84pub fn discover(root: &Path) -> anyhow::Result<DiscoveryResult> {
86 discover_with_options(root, DiscoveryOptions::default())
87}
88
89pub fn discover_with_options(
91 root: &Path,
92 options: DiscoveryOptions,
93) -> anyhow::Result<DiscoveryResult> {
94 let mut providers = Vec::new();
95
96 for &(domain, dir) in DOMAIN_DIRS {
97 let providers_dir = root.join(dir);
98 if !providers_dir.exists() {
99 continue;
100 }
101 for entry in std::fs::read_dir(&providers_dir)? {
102 let entry = entry?;
103 if !entry.file_type()?.is_file() {
104 continue;
105 }
106 let path = entry.path();
107 if path.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
108 continue;
109 }
110
111 let (provider_id, display_name, id_source) = if options.cbor_only {
112 match read_pack_meta_cbor_only(&path)? {
113 Some(meta) => (meta.pack_id, meta.display_name, ProviderIdSource::Manifest),
114 None => return Err(missing_cbor_error(&path)),
115 }
116 } else {
117 match read_pack_meta_from_manifest(&path)? {
118 Some(meta) => (meta.pack_id, meta.display_name, ProviderIdSource::Manifest),
119 None => {
120 let stem = path
121 .file_stem()
122 .and_then(|v| v.to_str())
123 .unwrap_or_default()
124 .to_string();
125 (stem, None, ProviderIdSource::Filename)
126 }
127 }
128 };
129
130 providers.push(DetectedProvider {
131 provider_id,
132 display_name,
133 domain: domain.to_string(),
134 pack_path: path,
135 id_source,
136 kind: DetectedPackKind::Provider,
137 });
138 }
139 }
140
141 let mut app_packs = Vec::new();
142 let packs_dir = root.join("packs");
143 if packs_dir.exists() {
144 for entry in std::fs::read_dir(&packs_dir)? {
145 let entry = entry?;
146 if !entry.file_type()?.is_file() {
147 continue;
148 }
149 let path = entry.path();
150 if path.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
151 continue;
152 }
153
154 let (provider_id, display_name, id_source) = if options.cbor_only {
155 match read_pack_meta_cbor_only(&path)? {
156 Some(meta) => (meta.pack_id, meta.display_name, ProviderIdSource::Manifest),
157 None => return Err(missing_cbor_error(&path)),
158 }
159 } else {
160 match read_pack_meta_from_manifest(&path)? {
161 Some(meta) => (meta.pack_id, meta.display_name, ProviderIdSource::Manifest),
162 None => {
163 let stem = path
164 .file_stem()
165 .and_then(|v| v.to_str())
166 .unwrap_or_default()
167 .to_string();
168 (stem, None, ProviderIdSource::Filename)
169 }
170 }
171 };
172
173 app_packs.push(DetectedProvider {
174 provider_id,
175 display_name,
176 domain: "app".to_string(),
177 pack_path: path,
178 id_source,
179 kind: DetectedPackKind::App,
180 });
181 }
182 }
183
184 providers.sort_by(|a, b| a.pack_path.cmp(&b.pack_path));
185 app_packs.sort_by(|a, b| a.pack_path.cmp(&b.pack_path));
186
187 let domains = DetectedDomains {
188 messaging: providers.iter().any(|p| p.domain == "messaging"),
189 events: providers.iter().any(|p| p.domain == "events"),
190 oauth: providers.iter().any(|p| p.domain == "oauth"),
191 state: providers.iter().any(|p| p.domain == "state"),
192 secrets: providers.iter().any(|p| p.domain == "secrets"),
193 };
194
195 Ok(DiscoveryResult {
196 domains,
197 providers,
198 app_packs,
199 })
200}
201
202pub fn persist(root: &Path, tenant: &str, discovery: &DiscoveryResult) -> anyhow::Result<()> {
204 let runtime_root = root.join("state").join("runtime").join(tenant);
205 std::fs::create_dir_all(&runtime_root)?;
206 let domains_path = runtime_root.join("detected_domains.json");
207 let providers_path = runtime_root.join("detected_providers.json");
208 let app_packs_path = runtime_root.join("detected_app_packs.json");
209 write_json(&domains_path, &discovery.domains)?;
210 write_json(&providers_path, &discovery.providers)?;
211 write_json(&app_packs_path, &discovery.app_packs)?;
212 Ok(())
213}
214
215impl DiscoveryResult {
216 pub fn setup_targets(&self) -> Vec<&DetectedProvider> {
218 self.providers.iter().chain(self.app_packs.iter()).collect()
219 }
220
221 pub fn find_setup_target(&self, provider_id: &str) -> Option<&DetectedProvider> {
223 self.providers
224 .iter()
225 .chain(self.app_packs.iter())
226 .find(|pack| pack.provider_id == provider_id)
227 }
228}
229
230pub fn read_pack_meta(path: &Path) -> anyhow::Result<Option<DiscoveredPackMeta>> {
232 read_pack_meta_from_manifest(path).map(|meta| {
233 meta.map(|meta| DiscoveredPackMeta {
234 pack_id: meta.pack_id,
235 display_name: meta.display_name,
236 })
237 })
238}
239
240fn write_json<T: Serialize>(path: &Path, value: &T) -> anyhow::Result<()> {
241 if let Some(parent) = path.parent() {
242 std::fs::create_dir_all(parent)?;
243 }
244 let payload = serde_json::to_string_pretty(value)?;
245 std::fs::write(path, payload)?;
246 Ok(())
247}
248
249fn read_pack_meta_from_manifest(path: &Path) -> anyhow::Result<Option<PackMeta>> {
252 let file = std::fs::File::open(path)?;
253 match zip::ZipArchive::new(file) {
254 Ok(mut archive) => {
255 if let Some(meta) = read_manifest_cbor(&mut archive)? {
256 return Ok(Some(meta));
257 }
258 if let Some(meta) = read_manifest_json(&mut archive, "pack.manifest.json")? {
259 return Ok(Some(meta));
260 }
261 }
262 Err(_) => {
263 if let Some(meta) = read_manifest_cbor_from_tar(path)? {
264 return Ok(Some(meta));
265 }
266 }
267 }
268 Ok(None)
269}
270
271fn read_pack_meta_cbor_only(path: &Path) -> anyhow::Result<Option<PackMeta>> {
272 let file = std::fs::File::open(path)?;
273 match zip::ZipArchive::new(file) {
274 Ok(mut archive) => read_manifest_cbor(&mut archive),
275 Err(_) => read_manifest_cbor_from_tar(path),
276 }
277}
278
279fn read_manifest_cbor(
280 archive: &mut zip::ZipArchive<std::fs::File>,
281) -> anyhow::Result<Option<PackMeta>> {
282 let mut file = match archive.by_name("manifest.cbor") {
283 Ok(file) => file,
284 Err(ZipError::FileNotFound) => return Ok(None),
285 Err(err) => return Err(err.into()),
286 };
287 let mut bytes = Vec::new();
288 std::io::Read::read_to_end(&mut file, &mut bytes)?;
289 let value: CborValue = serde_cbor::from_slice(&bytes)?;
290 extract_pack_meta_from_cbor(&value)
291}
292
293fn read_manifest_json(
294 archive: &mut zip::ZipArchive<std::fs::File>,
295 name: &str,
296) -> anyhow::Result<Option<PackMeta>> {
297 let mut file = match archive.by_name(name) {
298 Ok(file) => file,
299 Err(ZipError::FileNotFound) => return Ok(None),
300 Err(err) => return Err(err.into()),
301 };
302 let mut contents = String::new();
303 std::io::Read::read_to_string(&mut file, &mut contents)?;
304 let parsed: serde_json::Value = serde_json::from_str(&contents)?;
305
306 let resolve_dn = |obj: &serde_json::Value| -> Option<String> {
307 obj.get("display_name")
308 .and_then(|v| v.as_str())
309 .or_else(|| obj.get("name").and_then(|v| v.as_str()))
310 .map(String::from)
311 };
312
313 let display_name = resolve_dn(&parsed);
314
315 if let Some(id) = parsed.get("pack_id").and_then(|v| v.as_str()) {
316 return Ok(Some(PackMeta {
317 pack_id: id.to_string(),
318 display_name,
319 }));
320 }
321 if let Some(meta) = parsed.get("meta")
322 && let Some(id) = meta.get("pack_id").and_then(|v| v.as_str())
323 {
324 let dn = resolve_dn(meta).or(display_name);
325 return Ok(Some(PackMeta {
326 pack_id: id.to_string(),
327 display_name: dn,
328 }));
329 }
330 Ok(None)
331}
332
333fn read_manifest_cbor_from_tar(path: &Path) -> anyhow::Result<Option<PackMeta>> {
334 let file = std::fs::File::open(path)?;
335 let mut archive = tar::Archive::new(file);
336 for entry in archive.entries()? {
337 let mut entry = entry?;
338 if entry.path()?.as_ref() != Path::new("manifest.cbor") {
339 continue;
340 }
341 let mut bytes = Vec::new();
342 std::io::Read::read_to_end(&mut entry, &mut bytes)?;
343 let value: CborValue = serde_cbor::from_slice(&bytes)?;
344 return extract_pack_meta_from_cbor(&value);
345 }
346 Ok(None)
347}
348
349fn extract_pack_meta_from_cbor(value: &CborValue) -> anyhow::Result<Option<PackMeta>> {
350 let CborValue::Map(map) = value else {
351 return Ok(None);
352 };
353 let symbols = match map_get(map, "symbols") {
354 Some(CborValue::Map(map)) => Some(map),
355 _ => None,
356 };
357
358 let resolve_display_name =
359 |source_map: &std::collections::BTreeMap<CborValue, CborValue>| -> Option<String> {
360 map_get(source_map, "display_name")
361 .and_then(|v| match v {
362 CborValue::Text(text) => Some(text.clone()),
363 _ => resolve_string_symbol(v, symbols, "display_names")
364 .ok()
365 .flatten(),
366 })
367 .or_else(|| {
368 map_get(source_map, "name").and_then(|v| match v {
369 CborValue::Text(text) => Some(text.clone()),
370 _ => resolve_string_symbol(v, symbols, "names").ok().flatten(),
371 })
372 })
373 };
374
375 if let Some(pack_id) = map_get(map, "pack_id")
376 && let Some(id) = resolve_string_symbol(pack_id, symbols, "pack_ids")?
377 {
378 return Ok(Some(PackMeta {
379 pack_id: id,
380 display_name: resolve_display_name(map),
381 }));
382 }
383
384 if let Some(CborValue::Map(meta)) = map_get(map, "meta")
385 && let Some(pack_id) = map_get(meta, "pack_id")
386 && let Some(id) = resolve_string_symbol(pack_id, symbols, "pack_ids")?
387 {
388 return Ok(Some(PackMeta {
389 pack_id: id,
390 display_name: resolve_display_name(meta).or_else(|| resolve_display_name(map)),
391 }));
392 }
393
394 Ok(None)
395}
396
397fn resolve_string_symbol(
398 value: &CborValue,
399 symbols: Option<&std::collections::BTreeMap<CborValue, CborValue>>,
400 symbol_key: &str,
401) -> anyhow::Result<Option<String>> {
402 match value {
403 CborValue::Text(text) => Ok(Some(text.clone())),
404 CborValue::Integer(idx) => {
405 let Some(symbols) = symbols else {
406 return Ok(Some(idx.to_string()));
407 };
408 let Some(CborValue::Array(values)) = map_get(symbols, symbol_key)
409 .or_else(|| map_get(symbols, symbol_key.strip_suffix('s').unwrap_or(symbol_key)))
410 else {
411 return Ok(Some(idx.to_string()));
412 };
413 let idx = usize::try_from(*idx).unwrap_or(usize::MAX);
414 match values.get(idx) {
415 Some(CborValue::Text(text)) => Ok(Some(text.clone())),
416 _ => Ok(Some(idx.to_string())),
417 }
418 }
419 _ => Ok(None),
420 }
421}
422
423fn map_get<'a>(
424 map: &'a std::collections::BTreeMap<CborValue, CborValue>,
425 key: &str,
426) -> Option<&'a CborValue> {
427 map.iter().find_map(|(k, v)| match k {
428 CborValue::Text(text) if text == key => Some(v),
429 _ => None,
430 })
431}
432
433fn missing_cbor_error(path: &Path) -> anyhow::Error {
434 anyhow::anyhow!(
435 "demo packs must be CBOR-only (.gtpack must contain manifest.cbor). \
436 Rebuild the pack with greentic-pack build (do not use --dev). Missing in {}",
437 path.display()
438 )
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use std::io::Write;
445 use zip::write::{FileOptions, ZipWriter};
446
447 fn write_test_pack(path: &Path, pack_id: &str, display_name: &str) -> anyhow::Result<()> {
448 let file = std::fs::File::create(path)?;
449 let mut writer = ZipWriter::new(file);
450 let options: FileOptions<'_, ()> =
451 FileOptions::default().compression_method(zip::CompressionMethod::Stored);
452 writer.start_file("pack.manifest.json", options)?;
453 writer.write_all(
454 serde_json::json!({
455 "pack_id": pack_id,
456 "display_name": display_name,
457 })
458 .to_string()
459 .as_bytes(),
460 )?;
461 writer.finish()?;
462 Ok(())
463 }
464
465 #[test]
466 fn discover_includes_app_packs_in_setup_targets() -> anyhow::Result<()> {
467 let temp = tempfile::tempdir()?;
468 let root = temp.path();
469 std::fs::create_dir_all(root.join("providers/messaging"))?;
470 std::fs::create_dir_all(root.join("packs"))?;
471
472 write_test_pack(
473 &root
474 .join("providers")
475 .join("messaging")
476 .join("messaging-telegram.gtpack"),
477 "messaging-telegram",
478 "Telegram",
479 )?;
480 write_test_pack(
481 &root.join("packs").join("weather-app.gtpack"),
482 "weather-app",
483 "Weather App",
484 )?;
485
486 let discovered = discover(root)?;
487 assert_eq!(discovered.providers.len(), 1);
488 assert_eq!(discovered.app_packs.len(), 1);
489 assert_eq!(discovered.setup_targets().len(), 2);
490 assert_eq!(discovered.app_packs[0].provider_id, "weather-app");
491 assert_eq!(discovered.app_packs[0].domain, "app");
492 assert_eq!(discovered.app_packs[0].kind, DetectedPackKind::App);
493 Ok(())
494 }
495}