1use std::sync::OnceLock;
17
18use chrono::Utc;
19use flow_domain::graph::FlowGraph;
20use flow_storage::{Store, TemplateMembershipRow};
21use serde::{Deserialize, Serialize};
22
23use crate::templates::{self, TemplateError, TemplateRecord, TemplateSource};
24
25const FALLBACK_VERSION: &str = "0.0.0";
26
27const CATALOG_JSON: &str = include_str!("../../../mocks/template_catalog.json");
28
29#[derive(Debug, Clone, Deserialize)]
32#[serde(rename_all = "camelCase")]
33struct TemplateCatalogEntry {
34 slug: String,
35 name: String,
36 #[serde(default)]
37 description: String,
38 #[serde(default)]
39 tags: Vec<String>,
40 #[serde(default = "default_version")]
44 version: String,
45 graph: FlowGraph,
46}
47
48fn default_version() -> String {
49 FALLBACK_VERSION.to_string()
50}
51
52#[derive(Deserialize)]
55struct CatalogEnvelope {
56 #[serde(default)]
57 api_version: String,
58 templates: Vec<TemplateCatalogEntry>,
59}
60
61#[derive(Debug, Clone, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct TemplateHubEntry {
66 pub slug: String,
67 pub name: String,
68 pub description: String,
69 pub tags: Vec<String>,
70 pub version: String,
71 pub node_count: usize,
72 pub edge_count: usize,
73}
74
75#[derive(Debug, Clone, Serialize)]
78#[serde(rename_all = "camelCase")]
79pub struct HubInstalledStatus {
80 pub template_slug: String,
81 pub collection_slug: String,
82 pub installed_version: String,
83}
84
85#[derive(Debug, Clone, Serialize)]
88#[serde(rename_all = "camelCase")]
89pub struct TemplateHubEntryWithStatus {
90 pub slug: String,
91 pub name: String,
92 pub description: String,
93 pub tags: Vec<String>,
94 pub version: String,
95 pub node_count: usize,
96 pub edge_count: usize,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub installed_as: Option<HubInstalledStatus>,
99 pub update_available: bool,
100}
101
102fn entries() -> &'static [TemplateCatalogEntry] {
106 static CATALOG: OnceLock<Vec<TemplateCatalogEntry>> = OnceLock::new();
107 CATALOG
108 .get_or_init(|| {
109 let env: CatalogEnvelope =
110 serde_json::from_str(CATALOG_JSON).expect("template_catalog.json is malformed");
111 let _ = env.api_version;
112 env.templates
113 })
114 .as_slice()
115}
116
117pub fn catalog() -> Vec<TemplateHubEntry> {
120 entries().iter().map(entry_to_browse).collect()
121}
122
123fn entry_to_browse(e: &TemplateCatalogEntry) -> TemplateHubEntry {
124 TemplateHubEntry {
125 slug: e.slug.clone(),
126 name: e.name.clone(),
127 description: e.description.clone(),
128 tags: e.tags.clone(),
129 version: e.version.clone(),
130 node_count: e.graph.nodes.len(),
131 edge_count: e.graph.edges.len(),
132 }
133}
134
135pub fn catalog_with_status(
146 store: &Store,
147 templates_dir: &std::path::Path,
148) -> Result<Vec<TemplateHubEntryWithStatus>, TemplateError> {
149 let memberships = store.list_template_memberships()?;
150 let mut by_hub: std::collections::HashMap<String, &flow_storage::TemplateMembershipRow> =
151 std::collections::HashMap::new();
152 for row in &memberships {
153 if let Some(hub_slug) = row.hub_source_slug.as_deref() {
154 by_hub.insert(hub_slug.to_string(), row);
155 }
156 }
157 let mut out = Vec::with_capacity(entries().len());
158 for e in entries() {
159 let installed = by_hub
160 .get(&e.slug)
161 .map(|row| HubInstalledStatus {
162 template_slug: row.template_slug.clone(),
163 collection_slug: row.collection_slug.clone(),
164 installed_version: row
165 .hub_version
166 .clone()
167 .unwrap_or_else(|| FALLBACK_VERSION.into()),
168 })
169 .or_else(|| {
170 let file_path = templates_dir.join(format!("{}.flow.json", e.slug));
172 if file_path.exists() {
173 Some(HubInstalledStatus {
174 template_slug: e.slug.clone(),
175 collection_slug: crate::templates::DEFAULT_COLLECTION_SLUG.into(),
176 installed_version: FALLBACK_VERSION.into(),
177 })
178 } else {
179 None
180 }
181 });
182 let update_available = installed
183 .as_ref()
184 .map(|status| {
185 compare_versions(&e.version, &status.installed_version)
186 == std::cmp::Ordering::Greater
187 })
188 .unwrap_or(false);
189 out.push(TemplateHubEntryWithStatus {
190 slug: e.slug.clone(),
191 name: e.name.clone(),
192 description: e.description.clone(),
193 tags: e.tags.clone(),
194 version: e.version.clone(),
195 node_count: e.graph.nodes.len(),
196 edge_count: e.graph.edges.len(),
197 installed_as: installed,
198 update_available,
199 });
200 }
201 Ok(out)
202}
203
204pub fn add_to_collection(
217 templates_dir: &std::path::Path,
218 store: &Store,
219 hub_slug: &str,
220 collection_slug: &str,
221) -> Result<TemplateRecord, TemplateError> {
222 let entry = entries()
223 .iter()
224 .find(|e| e.slug == hub_slug)
225 .ok_or_else(|| TemplateError::NotFound(hub_slug.to_string()))?;
226 let file_path = templates_dir.join(format!("{}.flow.json", entry.slug));
227
228 if file_path.exists() {
229 let already_tracked = store
230 .list_template_memberships()?
231 .into_iter()
232 .any(|r| r.hub_source_slug.as_deref() == Some(entry.slug.as_str()));
233 if already_tracked {
234 return Err(TemplateError::Store(
235 flow_storage::StoreError::Refused(format!(
236 "template `{}` is already installed; use Update to overwrite",
237 entry.slug
238 )),
239 ));
240 }
241 return heal_membership(templates_dir, store, entry, collection_slug);
242 }
243
244 let source = TemplateSource {
245 hub_slug: entry.slug.clone(),
246 version: entry.version.clone(),
247 installed_at: Utc::now(),
248 };
249 templates::save_with_slug(
250 templates_dir,
251 store,
252 &entry.slug,
253 &entry.name,
254 &entry.graph,
255 collection_slug,
256 Some(source),
257 )
258}
259
260fn heal_membership(
264 templates_dir: &std::path::Path,
265 store: &Store,
266 entry: &TemplateCatalogEntry,
267 collection_slug: &str,
268) -> Result<TemplateRecord, TemplateError> {
269 let installed_at = Utc::now();
270 store.upsert_template_membership(&TemplateMembershipRow {
271 template_slug: entry.slug.clone(),
272 collection_slug: collection_slug.into(),
273 hub_source_slug: Some(entry.slug.clone()),
274 hub_version: Some(entry.version.clone()),
275 installed_at: Some(installed_at),
276 })?;
277 let graph = templates::load(templates_dir, &entry.slug)?;
278 let dsl_path = templates_dir.join(format!("{}.flow", entry.slug));
279 Ok(TemplateRecord {
280 slug: entry.slug.clone(),
281 collection_slug: collection_slug.to_string(),
282 name: entry.name.clone(),
283 node_count: graph.nodes.len(),
284 edge_count: graph.edges.len(),
285 updated_at: installed_at,
286 has_dsl: dsl_path.exists(),
287 source: Some(TemplateSource {
288 hub_slug: entry.slug.clone(),
289 version: entry.version.clone(),
290 installed_at,
291 }),
292 })
293}
294
295pub fn update_installed(
300 templates_dir: &std::path::Path,
301 store: &Store,
302 hub_slug: &str,
303 force: bool,
304) -> Result<TemplateRecord, TemplateError> {
305 let entry = entries()
306 .iter()
307 .find(|e| e.slug == hub_slug)
308 .ok_or_else(|| TemplateError::NotFound(hub_slug.to_string()))?;
309
310 let existing_row = store
313 .list_template_memberships()?
314 .into_iter()
315 .find(|r| r.hub_source_slug.as_deref() == Some(hub_slug))
316 .ok_or_else(|| TemplateError::NotFound(format!("hub template `{hub_slug}` not installed")))?;
317
318 if !force {
319 let file_path = templates_dir.join(format!("{}.flow.json", existing_row.template_slug));
320 if let (Ok(meta), Some(installed_at)) =
321 (std::fs::metadata(&file_path), existing_row.installed_at)
322 {
323 if let Ok(modified) = meta.modified() {
324 let modified_dt: chrono::DateTime<Utc> = modified.into();
325 if modified_dt > installed_at {
326 return Err(TemplateError::Store(flow_storage::StoreError::Refused(
327 format!(
328 "template `{}` has local edits since install; pass force=true to overwrite",
329 existing_row.template_slug
330 ),
331 )));
332 }
333 }
334 }
335 }
336
337 let source = TemplateSource {
338 hub_slug: entry.slug.clone(),
339 version: entry.version.clone(),
340 installed_at: Utc::now(),
341 };
342 templates::save_with_slug(
343 templates_dir,
344 store,
345 &existing_row.template_slug,
346 &entry.name,
347 &entry.graph,
348 &existing_row.collection_slug,
349 Some(source),
350 )
351}
352
353fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
357 let parse = |s: &str| -> (u32, u32, u32) {
358 let mut it = s.split('.').map(|p| p.parse::<u32>().unwrap_or(0));
359 (it.next().unwrap_or(0), it.next().unwrap_or(0), it.next().unwrap_or(0))
360 };
361 parse(a).cmp(&parse(b))
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn catalog_parses_and_is_well_formed() {
370 for e in &catalog() {
375 assert!(!e.slug.is_empty());
376 assert!(!e.name.is_empty());
377 assert!(!e.version.is_empty(), "{} has a version", e.slug);
378 assert!(e.node_count > 0, "{} has nodes", e.slug);
379 }
380 }
381
382 fn first_catalog_entry() -> Option<TemplateHubEntry> {
383 catalog().into_iter().next()
384 }
385
386 #[test]
387 fn add_to_collection_writes_a_loadable_local_template() {
388 let Some(first) = first_catalog_entry() else {
389 return;
393 };
394 let dir = std::env::temp_dir().join(format!("flow-thub-{}", uuid::Uuid::new_v4()));
395 std::fs::create_dir_all(&dir).unwrap();
396
397 let store = flow_storage::Store::open_in_memory().expect("in-memory store");
398 templates::ensure_default(&store).expect("ensure_default");
399 let rec = add_to_collection(&dir, &store, &first.slug, "default").expect("install");
400 assert_eq!(rec.slug, first.slug);
401 assert_eq!(rec.collection_slug, "default");
402 assert!(rec.source.is_some(), "hub install records its source");
403 let graph = templates::load(&dir, &first.slug).expect("loads");
404 assert!(!graph.nodes.is_empty());
405
406 assert!(add_to_collection(&dir, &store, "does-not-exist", "default").is_err());
407
408 let _ = std::fs::remove_dir_all(&dir);
409 }
410
411 #[test]
412 fn add_to_collection_refuses_when_already_installed() {
413 let Some(first) = first_catalog_entry() else { return };
414 let dir = std::env::temp_dir().join(format!("flow-thub-{}", uuid::Uuid::new_v4()));
415 std::fs::create_dir_all(&dir).unwrap();
416 let store = flow_storage::Store::open_in_memory().unwrap();
417 templates::ensure_default(&store).unwrap();
418 add_to_collection(&dir, &store, &first.slug, "default").unwrap();
419 let err =
420 add_to_collection(&dir, &store, &first.slug, "default").expect_err("re-add refused");
421 assert!(err.to_string().contains("already installed"), "got {err}");
422 let _ = std::fs::remove_dir_all(&dir);
423 }
424
425 #[test]
426 fn catalog_with_status_marks_installed_for_orphan_files_on_disk() {
427 let Some(first) = first_catalog_entry() else { return };
428 let dir = std::env::temp_dir().join(format!("flow-thub-{}", uuid::Uuid::new_v4()));
429 std::fs::create_dir_all(&dir).unwrap();
430 let store = flow_storage::Store::open_in_memory().unwrap();
431 templates::ensure_default(&store).unwrap();
432
433 add_to_collection(&dir, &store, &first.slug, "default").unwrap();
435 store.delete_template_membership(&first.slug).unwrap();
436
437 let annotated = catalog_with_status(&store, &dir).unwrap();
441 let our_row = annotated
442 .iter()
443 .find(|e| e.slug == first.slug)
444 .expect("entry");
445 assert!(
446 our_row.installed_as.is_some(),
447 "orphan file should count as installed"
448 );
449
450 let _ = std::fs::remove_dir_all(&dir);
451 }
452
453 #[test]
454 fn add_to_collection_heals_orphan_file_when_membership_row_missing() {
455 let Some(first) = first_catalog_entry() else { return };
456 let dir = std::env::temp_dir().join(format!("flow-thub-{}", uuid::Uuid::new_v4()));
457 std::fs::create_dir_all(&dir).unwrap();
458 let store = flow_storage::Store::open_in_memory().unwrap();
459 templates::ensure_default(&store).unwrap();
460
461 add_to_collection(&dir, &store, &first.slug, "default").unwrap();
463 store.delete_template_membership(&first.slug).unwrap();
466 let file_path = dir.join(format!("{}.flow.json", first.slug));
467 let original_bytes = std::fs::read(&file_path).unwrap();
468 std::fs::write(&file_path, &original_bytes).unwrap(); let rec = add_to_collection(&dir, &store, &first.slug, "default")
472 .expect("orphan file heals into a tracked install");
473 assert_eq!(rec.slug, first.slug);
474 assert!(rec.source.is_some(), "heal sets source provenance");
475 assert_eq!(std::fs::read(&file_path).unwrap(), original_bytes);
477 let row = store
479 .get_template_membership(&first.slug)
480 .unwrap()
481 .expect("row");
482 assert_eq!(row.hub_source_slug.as_deref(), Some(first.slug.as_str()));
483
484 let _ = std::fs::remove_dir_all(&dir);
485 }
486
487 #[test]
488 fn catalog_with_status_marks_installed_and_update_available() {
489 let Some(first) = first_catalog_entry() else { return };
490 let dir = std::env::temp_dir().join(format!("flow-thub-{}", uuid::Uuid::new_v4()));
491 std::fs::create_dir_all(&dir).unwrap();
492 let store = flow_storage::Store::open_in_memory().unwrap();
493 templates::ensure_default(&store).unwrap();
494 add_to_collection(&dir, &store, &first.slug, "default").unwrap();
495
496 let mut row = store
498 .get_template_membership(&first.slug)
499 .unwrap()
500 .expect("row");
501 row.hub_version = Some("0.0.1".into());
502 store.upsert_template_membership(&row).unwrap();
503
504 let annotated = catalog_with_status(&store, &dir).unwrap();
505 let our_row = annotated
506 .iter()
507 .find(|e| e.slug == first.slug)
508 .expect("entry");
509 assert!(our_row.installed_as.is_some());
510 assert!(
511 our_row.update_available,
512 "catalog version > 0.0.1 should be update_available"
513 );
514 let _ = std::fs::remove_dir_all(&dir);
515 }
516
517 #[test]
518 fn compare_versions_orders_dotted_triples() {
519 use std::cmp::Ordering::*;
520 assert_eq!(compare_versions("1.0.0", "1.0.0"), Equal);
521 assert_eq!(compare_versions("1.0.1", "1.0.0"), Greater);
522 assert_eq!(compare_versions("0.9.9", "1.0.0"), Less);
523 assert_eq!(compare_versions("2.0.0", "1.99.0"), Greater);
524 assert_eq!(compare_versions("0.0.0", "0.0.0"), Equal);
525 assert_eq!(compare_versions("1", "1.0.0"), Equal);
527 }
528}