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}
16
17#[derive(Clone, Debug, Serialize)]
19pub struct DetectedDomains {
20 pub messaging: bool,
21 pub events: bool,
22 pub oauth: bool,
23 pub state: bool,
24 pub secrets: bool,
25}
26
27#[derive(Clone, Debug, Serialize)]
29pub struct DetectedProvider {
30 pub provider_id: String,
31 pub display_name: Option<String>,
32 pub domain: String,
33 pub pack_path: PathBuf,
34 pub id_source: ProviderIdSource,
35}
36
37#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)]
39#[serde(rename_all = "lowercase")]
40pub enum ProviderIdSource {
41 Manifest,
42 Filename,
43}
44
45struct PackMeta {
47 pack_id: String,
48 display_name: Option<String>,
49}
50
51#[derive(Default)]
53pub struct DiscoveryOptions {
54 pub cbor_only: bool,
56}
57
58const DOMAIN_DIRS: &[(&str, &str)] = &[
60 ("messaging", "providers/messaging"),
61 ("events", "providers/events"),
62 ("oauth", "providers/oauth"),
63 ("state", "providers/state"),
64 ("secrets", "providers/secrets"),
65];
66
67pub fn discover(root: &Path) -> anyhow::Result<DiscoveryResult> {
69 discover_with_options(root, DiscoveryOptions::default())
70}
71
72pub fn discover_with_options(
74 root: &Path,
75 options: DiscoveryOptions,
76) -> anyhow::Result<DiscoveryResult> {
77 let mut providers = Vec::new();
78
79 for &(domain, dir) in DOMAIN_DIRS {
80 let providers_dir = root.join(dir);
81 if !providers_dir.exists() {
82 continue;
83 }
84 for entry in std::fs::read_dir(&providers_dir)? {
85 let entry = entry?;
86 if !entry.file_type()?.is_file() {
87 continue;
88 }
89 let path = entry.path();
90 if path.extension().and_then(|ext| ext.to_str()) != Some("gtpack") {
91 continue;
92 }
93
94 let (provider_id, display_name, id_source) = if options.cbor_only {
95 match read_pack_meta_cbor_only(&path)? {
96 Some(meta) => (meta.pack_id, meta.display_name, ProviderIdSource::Manifest),
97 None => return Err(missing_cbor_error(&path)),
98 }
99 } else {
100 match read_pack_meta_from_manifest(&path)? {
101 Some(meta) => (meta.pack_id, meta.display_name, ProviderIdSource::Manifest),
102 None => {
103 let stem = path
104 .file_stem()
105 .and_then(|v| v.to_str())
106 .unwrap_or_default()
107 .to_string();
108 (stem, None, ProviderIdSource::Filename)
109 }
110 }
111 };
112
113 providers.push(DetectedProvider {
114 provider_id,
115 display_name,
116 domain: domain.to_string(),
117 pack_path: path,
118 id_source,
119 });
120 }
121 }
122
123 providers.sort_by(|a, b| a.pack_path.cmp(&b.pack_path));
124
125 let domains = DetectedDomains {
126 messaging: providers.iter().any(|p| p.domain == "messaging"),
127 events: providers.iter().any(|p| p.domain == "events"),
128 oauth: providers.iter().any(|p| p.domain == "oauth"),
129 state: providers.iter().any(|p| p.domain == "state"),
130 secrets: providers.iter().any(|p| p.domain == "secrets"),
131 };
132
133 Ok(DiscoveryResult { domains, providers })
134}
135
136pub fn persist(root: &Path, tenant: &str, discovery: &DiscoveryResult) -> anyhow::Result<()> {
138 let runtime_root = root.join("state").join("runtime").join(tenant);
139 std::fs::create_dir_all(&runtime_root)?;
140 let domains_path = runtime_root.join("detected_domains.json");
141 let providers_path = runtime_root.join("detected_providers.json");
142 write_json(&domains_path, &discovery.domains)?;
143 write_json(&providers_path, &discovery.providers)?;
144 Ok(())
145}
146
147fn write_json<T: Serialize>(path: &Path, value: &T) -> anyhow::Result<()> {
148 if let Some(parent) = path.parent() {
149 std::fs::create_dir_all(parent)?;
150 }
151 let payload = serde_json::to_string_pretty(value)?;
152 std::fs::write(path, payload)?;
153 Ok(())
154}
155
156fn read_pack_meta_from_manifest(path: &Path) -> anyhow::Result<Option<PackMeta>> {
159 let file = std::fs::File::open(path)?;
160 match zip::ZipArchive::new(file) {
161 Ok(mut archive) => {
162 if let Some(meta) = read_manifest_cbor(&mut archive)? {
163 return Ok(Some(meta));
164 }
165 if let Some(meta) = read_manifest_json(&mut archive, "pack.manifest.json")? {
166 return Ok(Some(meta));
167 }
168 }
169 Err(_) => {
170 if let Some(meta) = read_manifest_cbor_from_tar(path)? {
171 return Ok(Some(meta));
172 }
173 }
174 }
175 Ok(None)
176}
177
178fn read_pack_meta_cbor_only(path: &Path) -> anyhow::Result<Option<PackMeta>> {
179 let file = std::fs::File::open(path)?;
180 match zip::ZipArchive::new(file) {
181 Ok(mut archive) => read_manifest_cbor(&mut archive),
182 Err(_) => read_manifest_cbor_from_tar(path),
183 }
184}
185
186fn read_manifest_cbor(
187 archive: &mut zip::ZipArchive<std::fs::File>,
188) -> anyhow::Result<Option<PackMeta>> {
189 let mut file = match archive.by_name("manifest.cbor") {
190 Ok(file) => file,
191 Err(ZipError::FileNotFound) => return Ok(None),
192 Err(err) => return Err(err.into()),
193 };
194 let mut bytes = Vec::new();
195 std::io::Read::read_to_end(&mut file, &mut bytes)?;
196 let value: CborValue = serde_cbor::from_slice(&bytes)?;
197 extract_pack_meta_from_cbor(&value)
198}
199
200fn read_manifest_json(
201 archive: &mut zip::ZipArchive<std::fs::File>,
202 name: &str,
203) -> anyhow::Result<Option<PackMeta>> {
204 let mut file = match archive.by_name(name) {
205 Ok(file) => file,
206 Err(ZipError::FileNotFound) => return Ok(None),
207 Err(err) => return Err(err.into()),
208 };
209 let mut contents = String::new();
210 std::io::Read::read_to_string(&mut file, &mut contents)?;
211 let parsed: serde_json::Value = serde_json::from_str(&contents)?;
212
213 let resolve_dn = |obj: &serde_json::Value| -> Option<String> {
214 obj.get("display_name")
215 .and_then(|v| v.as_str())
216 .or_else(|| obj.get("name").and_then(|v| v.as_str()))
217 .map(String::from)
218 };
219
220 let display_name = resolve_dn(&parsed);
221
222 if let Some(id) = parsed.get("pack_id").and_then(|v| v.as_str()) {
223 return Ok(Some(PackMeta {
224 pack_id: id.to_string(),
225 display_name,
226 }));
227 }
228 if let Some(meta) = parsed.get("meta")
229 && let Some(id) = meta.get("pack_id").and_then(|v| v.as_str())
230 {
231 let dn = resolve_dn(meta).or(display_name);
232 return Ok(Some(PackMeta {
233 pack_id: id.to_string(),
234 display_name: dn,
235 }));
236 }
237 Ok(None)
238}
239
240fn read_manifest_cbor_from_tar(path: &Path) -> anyhow::Result<Option<PackMeta>> {
241 let file = std::fs::File::open(path)?;
242 let mut archive = tar::Archive::new(file);
243 for entry in archive.entries()? {
244 let mut entry = entry?;
245 if entry.path()?.as_ref() != Path::new("manifest.cbor") {
246 continue;
247 }
248 let mut bytes = Vec::new();
249 std::io::Read::read_to_end(&mut entry, &mut bytes)?;
250 let value: CborValue = serde_cbor::from_slice(&bytes)?;
251 return extract_pack_meta_from_cbor(&value);
252 }
253 Ok(None)
254}
255
256fn extract_pack_meta_from_cbor(value: &CborValue) -> anyhow::Result<Option<PackMeta>> {
257 let CborValue::Map(map) = value else {
258 return Ok(None);
259 };
260 let symbols = match map_get(map, "symbols") {
261 Some(CborValue::Map(map)) => Some(map),
262 _ => None,
263 };
264
265 let resolve_display_name =
266 |source_map: &std::collections::BTreeMap<CborValue, CborValue>| -> Option<String> {
267 map_get(source_map, "display_name")
268 .and_then(|v| match v {
269 CborValue::Text(text) => Some(text.clone()),
270 _ => resolve_string_symbol(v, symbols, "display_names")
271 .ok()
272 .flatten(),
273 })
274 .or_else(|| {
275 map_get(source_map, "name").and_then(|v| match v {
276 CborValue::Text(text) => Some(text.clone()),
277 _ => resolve_string_symbol(v, symbols, "names").ok().flatten(),
278 })
279 })
280 };
281
282 if let Some(pack_id) = map_get(map, "pack_id")
283 && let Some(id) = resolve_string_symbol(pack_id, symbols, "pack_ids")?
284 {
285 return Ok(Some(PackMeta {
286 pack_id: id,
287 display_name: resolve_display_name(map),
288 }));
289 }
290
291 if let Some(CborValue::Map(meta)) = map_get(map, "meta")
292 && let Some(pack_id) = map_get(meta, "pack_id")
293 && let Some(id) = resolve_string_symbol(pack_id, symbols, "pack_ids")?
294 {
295 return Ok(Some(PackMeta {
296 pack_id: id,
297 display_name: resolve_display_name(meta).or_else(|| resolve_display_name(map)),
298 }));
299 }
300
301 Ok(None)
302}
303
304fn resolve_string_symbol(
305 value: &CborValue,
306 symbols: Option<&std::collections::BTreeMap<CborValue, CborValue>>,
307 symbol_key: &str,
308) -> anyhow::Result<Option<String>> {
309 match value {
310 CborValue::Text(text) => Ok(Some(text.clone())),
311 CborValue::Integer(idx) => {
312 let Some(symbols) = symbols else {
313 return Ok(Some(idx.to_string()));
314 };
315 let Some(CborValue::Array(values)) = map_get(symbols, symbol_key)
316 .or_else(|| map_get(symbols, symbol_key.strip_suffix('s').unwrap_or(symbol_key)))
317 else {
318 return Ok(Some(idx.to_string()));
319 };
320 let idx = usize::try_from(*idx).unwrap_or(usize::MAX);
321 match values.get(idx) {
322 Some(CborValue::Text(text)) => Ok(Some(text.clone())),
323 _ => Ok(Some(idx.to_string())),
324 }
325 }
326 _ => Ok(None),
327 }
328}
329
330fn map_get<'a>(
331 map: &'a std::collections::BTreeMap<CborValue, CborValue>,
332 key: &str,
333) -> Option<&'a CborValue> {
334 map.iter().find_map(|(k, v)| match k {
335 CborValue::Text(text) if text == key => Some(v),
336 _ => None,
337 })
338}
339
340fn missing_cbor_error(path: &Path) -> anyhow::Error {
341 anyhow::anyhow!(
342 "demo packs must be CBOR-only (.gtpack must contain manifest.cbor). \
343 Rebuild the pack with greentic-pack build (do not use --dev). Missing in {}",
344 path.display()
345 )
346}