1use chrono::{DateTime, Utc};
12use flow_domain::graph::FlowGraph;
13use flow_storage::{Store, StoreError as FsStoreError, TemplateMembershipRow};
14use serde::{Deserialize, Serialize};
15use std::cmp::Reverse;
16use std::collections::HashMap;
17use std::path::Path;
18use thiserror::Error;
19
20pub const DEFAULT_COLLECTION_SLUG: &str = "default";
23pub const DEFAULT_COLLECTION_NAME: &str = "Default";
25
26#[derive(Debug, Error)]
27pub enum TemplateError {
28 #[error("template name is empty after slugification")]
29 EmptyName,
30 #[error("invalid template slug")]
31 InvalidSlug,
32 #[error("template '{0}' not found")]
33 NotFound(String),
34 #[error(transparent)]
35 Io(#[from] std::io::Error),
36 #[error(transparent)]
37 Json(#[from] serde_json::Error),
38 #[error(transparent)]
39 Store(#[from] FsStoreError),
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
46#[serde(rename_all = "camelCase")]
47pub struct TemplateSource {
48 pub hub_slug: String,
49 pub version: String,
50 pub installed_at: DateTime<Utc>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct TemplateRecord {
56 pub slug: String,
57 pub collection_slug: String,
60 pub name: String,
61 pub node_count: usize,
62 pub edge_count: usize,
63 pub updated_at: DateTime<Utc>,
64 #[serde(default)]
67 pub has_dsl: bool,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub source: Option<TemplateSource>,
72}
73
74const SUFFIX: &str = ".flow.json";
75const DSL_SUFFIX: &str = ".flow";
76
77pub fn ensure_default(store: &Store) -> Result<(), TemplateError> {
81 store.upsert_template_collection(DEFAULT_COLLECTION_SLUG, DEFAULT_COLLECTION_NAME, true)?;
82 Ok(())
83}
84
85pub fn list(dir: &Path, store: &Store) -> Result<Vec<TemplateRecord>, TemplateError> {
89 let memberships = build_membership_index(store)?;
90 list_filtered(dir, &memberships, None)
91}
92
93pub fn list_in(
96 dir: &Path,
97 store: &Store,
98 collection_slug: &str,
99) -> Result<Vec<TemplateRecord>, TemplateError> {
100 validate_slug(collection_slug)?;
101 let memberships = build_membership_index(store)?;
102 list_filtered(dir, &memberships, Some(collection_slug))
103}
104
105fn build_membership_index(
106 store: &Store,
107) -> Result<HashMap<String, TemplateMembershipRow>, TemplateError> {
108 let rows = store.list_template_memberships()?;
109 Ok(rows
110 .into_iter()
111 .map(|r| (r.template_slug.clone(), r))
112 .collect())
113}
114
115fn list_filtered(
116 dir: &Path,
117 memberships: &HashMap<String, TemplateMembershipRow>,
118 filter: Option<&str>,
119) -> Result<Vec<TemplateRecord>, TemplateError> {
120 if !dir.exists() {
121 return Ok(Vec::new());
122 }
123 let mut out = Vec::new();
124 for entry in std::fs::read_dir(dir)? {
125 let entry = entry?;
126 let path = entry.path();
127 let Some(file_name) = path.file_name().and_then(|s| s.to_str()) else {
128 continue;
129 };
130 if !file_name.ends_with(SUFFIX) {
131 continue;
132 }
133 let slug = file_name.trim_end_matches(SUFFIX).to_string();
134 let membership = memberships.get(&slug);
135 let collection_slug = membership
136 .map(|m| m.collection_slug.as_str())
137 .unwrap_or(DEFAULT_COLLECTION_SLUG);
138 if let Some(filter_slug) = filter {
139 if collection_slug != filter_slug {
140 continue;
141 }
142 }
143 if let Ok(rec) = read_record(&path, slug, membership) {
144 out.push(rec);
145 }
146 }
147 out.sort_by_key(|rec| Reverse(rec.updated_at));
148 Ok(out)
149}
150
151pub fn save(
155 dir: &Path,
156 store: &Store,
157 name: &str,
158 graph: &FlowGraph,
159 collection_slug: &str,
160 source: Option<TemplateSource>,
161) -> Result<TemplateRecord, TemplateError> {
162 let slug = slugify(name);
163 if slug.is_empty() {
164 return Err(TemplateError::EmptyName);
165 }
166 save_with_slug(dir, store, &slug, name, graph, collection_slug, source)
167}
168
169pub fn save_with_slug(
174 dir: &Path,
175 store: &Store,
176 slug: &str,
177 name: &str,
178 graph: &FlowGraph,
179 collection_slug: &str,
180 source: Option<TemplateSource>,
181) -> Result<TemplateRecord, TemplateError> {
182 validate_slug(slug)?;
183 validate_slug(collection_slug)?;
184 std::fs::create_dir_all(dir)?;
185 let path = dir.join(format!("{slug}{SUFFIX}"));
186 let body = serde_json::to_string_pretty(graph)?;
187 std::fs::write(&path, body)?;
188
189 let dsl_path = dir.join(format!("{slug}{DSL_SUFFIX}"));
191 let dsl_body = flow_dsl::serialize(graph);
192 let has_dsl = match std::fs::write(&dsl_path, dsl_body) {
193 Ok(()) => true,
194 Err(e) => {
195 tracing::warn!(?dsl_path, error = ?e, "failed to write sibling .flow file");
196 false
197 }
198 };
199
200 let needs_row = collection_slug != DEFAULT_COLLECTION_SLUG || source.is_some();
201 if needs_row {
202 store.upsert_template_membership(&TemplateMembershipRow {
203 template_slug: slug.to_string(),
204 collection_slug: collection_slug.into(),
205 hub_source_slug: source.as_ref().map(|s| s.hub_slug.clone()),
206 hub_version: source.as_ref().map(|s| s.version.clone()),
207 installed_at: source.as_ref().map(|s| s.installed_at),
208 })?;
209 } else {
210 store.delete_template_membership(slug)?;
213 }
214
215 Ok(TemplateRecord {
216 slug: slug.to_string(),
217 collection_slug: collection_slug.into(),
218 name: name.to_string(),
219 node_count: graph.nodes.len(),
220 edge_count: graph.edges.len(),
221 updated_at: Utc::now(),
222 has_dsl,
223 source,
224 })
225}
226
227pub fn load(dir: &Path, slug: &str) -> Result<FlowGraph, TemplateError> {
228 validate_slug(slug)?;
229 let path = dir.join(format!("{slug}{SUFFIX}"));
230 if !path.exists() {
231 return Err(TemplateError::NotFound(slug.to_string()));
232 }
233 let body = std::fs::read_to_string(&path)?;
234 Ok(serde_json::from_str::<FlowGraph>(&body)?)
235}
236
237pub fn delete(dir: &Path, store: &Store, slug: &str) -> Result<(), TemplateError> {
241 validate_slug(slug)?;
242 let path = dir.join(format!("{slug}{SUFFIX}"));
243 if !path.exists() {
244 return Err(TemplateError::NotFound(slug.to_string()));
245 }
246 std::fs::remove_file(path)?;
247
248 let dsl_path = dir.join(format!("{slug}{DSL_SUFFIX}"));
249 if dsl_path.exists() {
250 if let Err(e) = std::fs::remove_file(&dsl_path) {
251 tracing::warn!(?dsl_path, error = ?e, "failed to remove sibling .flow file");
252 }
253 }
254 if let Err(e) = store.delete_template_membership(slug) {
255 tracing::warn!(slug = %slug, error = ?e, "failed to remove template membership row");
256 }
257 Ok(())
258}
259
260fn read_record(
261 path: &Path,
262 slug: String,
263 membership: Option<&TemplateMembershipRow>,
264) -> Result<TemplateRecord, TemplateError> {
265 let body = std::fs::read_to_string(path)?;
266 let graph: FlowGraph = serde_json::from_str(&body)?;
267 let updated_at = path
268 .metadata()
269 .and_then(|m| m.modified())
270 .map(DateTime::<Utc>::from)
271 .unwrap_or_else(|_| Utc::now());
272 let dsl_path = path.with_file_name(format!("{slug}{DSL_SUFFIX}"));
273 let has_dsl = dsl_path.exists();
274 let (collection_slug, source) = match membership {
275 Some(m) => {
276 let source = match (&m.hub_source_slug, &m.hub_version, m.installed_at) {
277 (Some(hub_slug), Some(version), Some(installed_at)) => Some(TemplateSource {
278 hub_slug: hub_slug.clone(),
279 version: version.clone(),
280 installed_at,
281 }),
282 _ => None,
283 };
284 (m.collection_slug.clone(), source)
285 }
286 None => (DEFAULT_COLLECTION_SLUG.into(), None),
287 };
288 Ok(TemplateRecord {
289 slug,
290 collection_slug,
291 name: graph.name.clone(),
292 node_count: graph.nodes.len(),
293 edge_count: graph.edges.len(),
294 updated_at,
295 has_dsl,
296 source,
297 })
298}
299
300pub(crate) fn validate_slug(slug: &str) -> Result<(), TemplateError> {
307 if slug.is_empty() {
308 return Err(TemplateError::InvalidSlug);
309 }
310 if !slug
311 .chars()
312 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
313 {
314 return Err(TemplateError::InvalidSlug);
315 }
316 if slug.starts_with('-') || slug.ends_with('-') {
317 return Err(TemplateError::InvalidSlug);
318 }
319 Ok(())
320}
321
322pub(crate) fn slugify(input: &str) -> String {
323 let mut out = String::with_capacity(input.len());
324 let mut prev_dash = true; for c in input.chars() {
326 if c.is_ascii_alphanumeric() {
327 out.push(c.to_ascii_lowercase());
328 prev_dash = false;
329 } else if !prev_dash && !out.is_empty() {
330 out.push('-');
331 prev_dash = true;
332 }
333 }
334 while out.ends_with('-') {
335 out.pop();
336 }
337 out
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use flow_domain::graph::Position;
344
345 fn tmp_dir() -> std::path::PathBuf {
346 let p = std::env::temp_dir().join(format!("flow-templates-{}", uuid::Uuid::new_v4()));
347 std::fs::create_dir_all(&p).unwrap();
348 p
349 }
350
351 fn fresh_store() -> Store {
352 let store = Store::open_in_memory().expect("in-memory store");
353 ensure_default(&store).expect("ensure_default");
354 store
355 }
356
357 fn sample_graph(name: &str) -> FlowGraph {
358 FlowGraph {
359 subflows: Vec::new(),
360 id: "g".into(),
361 name: name.into(),
362 version: "0.1.0".into(),
363 description: None,
364 nodes: vec![flow_domain::graph::FlowNode {
365 id: "n1".into(),
366 node_type: "action".into(),
367 position: Position { x: 0.0, y: 0.0 },
368 data: serde_json::json!({"label": "n1"}),
369 }],
370 edges: vec![],
371 }
372 }
373
374 #[test]
375 fn slugify_normalizes() {
376 assert_eq!(slugify("Build Pipeline"), "build-pipeline");
377 assert_eq!(slugify(" hello!! world "), "hello-world");
378 assert_eq!(slugify("__only__symbols__"), "only-symbols");
379 }
380
381 #[test]
382 fn ensure_default_is_idempotent() {
383 let store = fresh_store();
384 ensure_default(&store).unwrap();
386 let collections = store.list_template_collections().unwrap();
387 assert_eq!(collections.len(), 1);
388 assert_eq!(collections[0].slug, DEFAULT_COLLECTION_SLUG);
389 assert!(collections[0].is_system);
390 }
391
392 #[test]
393 fn save_and_load_round_trip() {
394 let dir = tmp_dir();
395 let store = fresh_store();
396 let rec = save(
397 &dir,
398 &store,
399 "Build Pipeline",
400 &sample_graph("Build Pipeline"),
401 DEFAULT_COLLECTION_SLUG,
402 None,
403 )
404 .unwrap();
405 assert_eq!(rec.slug, "build-pipeline");
406 assert_eq!(rec.collection_slug, DEFAULT_COLLECTION_SLUG);
407 assert!(rec.source.is_none());
408 let loaded = load(&dir, "build-pipeline").unwrap();
409 assert_eq!(loaded.name, "Build Pipeline");
410 let _ = std::fs::remove_dir_all(&dir);
411 }
412
413 #[test]
414 fn save_in_default_with_no_source_writes_no_membership_row() {
415 let dir = tmp_dir();
416 let store = fresh_store();
417 save(
418 &dir,
419 &store,
420 "alpha",
421 &sample_graph("alpha"),
422 DEFAULT_COLLECTION_SLUG,
423 None,
424 )
425 .unwrap();
426 let rows = store.list_template_memberships().unwrap();
427 assert!(rows.is_empty(), "Default + no source must not write a row");
428 let _ = std::fs::remove_dir_all(&dir);
429 }
430
431 #[test]
432 fn save_in_user_collection_writes_membership_row() {
433 let dir = tmp_dir();
434 let store = fresh_store();
435 store
436 .upsert_template_collection("playbooks", "Playbooks", false)
437 .unwrap();
438 let rec = save(
439 &dir,
440 &store,
441 "alpha",
442 &sample_graph("alpha"),
443 "playbooks",
444 None,
445 )
446 .unwrap();
447 assert_eq!(rec.collection_slug, "playbooks");
448 let row = store
449 .get_template_membership("alpha")
450 .unwrap()
451 .expect("row exists");
452 assert_eq!(row.collection_slug, "playbooks");
453 assert!(row.hub_source_slug.is_none());
454 let _ = std::fs::remove_dir_all(&dir);
455 }
456
457 #[test]
458 fn save_moving_back_to_default_drops_membership_row() {
459 let dir = tmp_dir();
460 let store = fresh_store();
461 store
462 .upsert_template_collection("playbooks", "Playbooks", false)
463 .unwrap();
464 save(
465 &dir,
466 &store,
467 "alpha",
468 &sample_graph("alpha"),
469 "playbooks",
470 None,
471 )
472 .unwrap();
473 assert!(store.get_template_membership("alpha").unwrap().is_some());
474
475 save(
476 &dir,
477 &store,
478 "alpha",
479 &sample_graph("alpha"),
480 DEFAULT_COLLECTION_SLUG,
481 None,
482 )
483 .unwrap();
484 assert!(
485 store.get_template_membership("alpha").unwrap().is_none(),
486 "moving back to default must drop the membership row"
487 );
488 let _ = std::fs::remove_dir_all(&dir);
489 }
490
491 #[test]
492 fn save_with_hub_source_writes_membership_row_even_in_default() {
493 let dir = tmp_dir();
494 let store = fresh_store();
495 let installed_at = Utc::now();
496 let rec = save(
497 &dir,
498 &store,
499 "alpha",
500 &sample_graph("alpha"),
501 DEFAULT_COLLECTION_SLUG,
502 Some(TemplateSource {
503 hub_slug: "hub-alpha".into(),
504 version: "1.2.3".into(),
505 installed_at,
506 }),
507 )
508 .unwrap();
509 assert_eq!(rec.collection_slug, DEFAULT_COLLECTION_SLUG);
510 let row = store
511 .get_template_membership("alpha")
512 .unwrap()
513 .expect("row exists");
514 assert_eq!(row.hub_source_slug.as_deref(), Some("hub-alpha"));
515 assert_eq!(row.hub_version.as_deref(), Some("1.2.3"));
516 let _ = std::fs::remove_dir_all(&dir);
517 }
518
519 #[test]
520 fn list_returns_newest_first_and_includes_collection() {
521 let dir = tmp_dir();
522 let store = fresh_store();
523 store
524 .upsert_template_collection("playbooks", "Playbooks", false)
525 .unwrap();
526 save(
527 &dir,
528 &store,
529 "alpha",
530 &sample_graph("alpha"),
531 DEFAULT_COLLECTION_SLUG,
532 None,
533 )
534 .unwrap();
535 std::thread::sleep(std::time::Duration::from_millis(50));
536 save(
537 &dir,
538 &store,
539 "beta",
540 &sample_graph("beta"),
541 "playbooks",
542 None,
543 )
544 .unwrap();
545 let listed = list(&dir, &store).unwrap();
546 assert_eq!(listed.len(), 2);
547 assert_eq!(listed[0].slug, "beta");
548 assert_eq!(listed[0].collection_slug, "playbooks");
549 assert_eq!(listed[1].slug, "alpha");
550 assert_eq!(listed[1].collection_slug, DEFAULT_COLLECTION_SLUG);
551 let _ = std::fs::remove_dir_all(&dir);
552 }
553
554 #[test]
555 fn list_in_filters_by_collection() {
556 let dir = tmp_dir();
557 let store = fresh_store();
558 store
559 .upsert_template_collection("playbooks", "Playbooks", false)
560 .unwrap();
561 save(
562 &dir,
563 &store,
564 "alpha",
565 &sample_graph("alpha"),
566 DEFAULT_COLLECTION_SLUG,
567 None,
568 )
569 .unwrap();
570 save(
571 &dir,
572 &store,
573 "beta",
574 &sample_graph("beta"),
575 "playbooks",
576 None,
577 )
578 .unwrap();
579 let default_only = list_in(&dir, &store, DEFAULT_COLLECTION_SLUG).unwrap();
580 assert_eq!(default_only.len(), 1);
581 assert_eq!(default_only[0].slug, "alpha");
582 let user_only = list_in(&dir, &store, "playbooks").unwrap();
583 assert_eq!(user_only.len(), 1);
584 assert_eq!(user_only[0].slug, "beta");
585 let _ = std::fs::remove_dir_all(&dir);
586 }
587
588 #[test]
589 fn list_treats_files_without_membership_as_default() {
590 let dir = tmp_dir();
591 let store = fresh_store();
592 std::fs::write(
594 dir.join("standalone.flow.json"),
595 serde_json::to_vec(&sample_graph("standalone")).unwrap(),
596 )
597 .unwrap();
598 let listed = list_in(&dir, &store, DEFAULT_COLLECTION_SLUG).unwrap();
599 assert_eq!(listed.len(), 1);
600 assert_eq!(listed[0].slug, "standalone");
601 assert_eq!(listed[0].collection_slug, DEFAULT_COLLECTION_SLUG);
602 let _ = std::fs::remove_dir_all(&dir);
603 }
604
605 #[test]
606 fn delete_removes_file_and_membership_row() {
607 let dir = tmp_dir();
608 let store = fresh_store();
609 store
610 .upsert_template_collection("playbooks", "Playbooks", false)
611 .unwrap();
612 save(
613 &dir,
614 &store,
615 "to-delete",
616 &sample_graph("to-delete"),
617 "playbooks",
618 None,
619 )
620 .unwrap();
621 delete(&dir, &store, "to-delete").unwrap();
622 assert!(matches!(
623 load(&dir, "to-delete"),
624 Err(TemplateError::NotFound(_))
625 ));
626 assert!(
627 store.get_template_membership("to-delete").unwrap().is_none(),
628 "membership row must be cleaned up"
629 );
630 let _ = std::fs::remove_dir_all(&dir);
631 }
632
633 #[test]
634 fn load_missing_returns_not_found() {
635 let dir = tmp_dir();
636 assert!(matches!(
637 load(&dir, "nope"),
638 Err(TemplateError::NotFound(_))
639 ));
640 }
641
642 #[test]
643 fn load_rejects_path_traversal() {
644 let dir = tmp_dir();
645 for bad in [
646 "../etc/passwd",
647 "..",
648 "foo/bar",
649 "foo\\bar",
650 "with space",
651 "Caps",
652 "trailing-",
653 "-leading",
654 "",
655 ] {
656 assert!(
657 matches!(load(&dir, bad), Err(TemplateError::InvalidSlug)),
658 "expected InvalidSlug for {bad:?}"
659 );
660 }
661 }
662
663 #[test]
664 fn delete_rejects_path_traversal() {
665 let dir = tmp_dir();
666 let store = fresh_store();
667 let outside = dir.parent().unwrap().join("outside.flow.json");
668 std::fs::write(&outside, b"{}").unwrap();
669 let dir_name = dir.file_name().unwrap().to_string_lossy();
670 let traversal = format!("../{dir_name}/../outside");
671 assert!(matches!(
672 delete(&dir, &store, &traversal),
673 Err(TemplateError::InvalidSlug)
674 ));
675 assert!(outside.exists(), "traversal must not delete outside file");
676 let _ = std::fs::remove_file(&outside);
677 }
678
679 #[test]
680 fn save_writes_sibling_dsl_and_delete_removes_it() {
681 let dir = tmp_dir();
682 let store = fresh_store();
683 let rec = save(
684 &dir,
685 &store,
686 "Build Pipeline",
687 &sample_graph("Build Pipeline"),
688 DEFAULT_COLLECTION_SLUG,
689 None,
690 )
691 .unwrap();
692 assert!(rec.has_dsl, "save should report has_dsl = true");
693 let json = dir.join("build-pipeline.flow.json");
694 let dsl = dir.join("build-pipeline.flow");
695 assert!(json.exists());
696 assert!(dsl.exists(), ".flow sibling should exist");
697 let dsl_text = std::fs::read_to_string(&dsl).unwrap();
698 assert!(dsl_text.starts_with("flow \"Build Pipeline\""));
699
700 delete(&dir, &store, "build-pipeline").unwrap();
701 assert!(!json.exists());
702 assert!(!dsl.exists(), ".flow sibling should be removed too");
703 let _ = std::fs::remove_dir_all(&dir);
704 }
705
706 #[test]
707 fn list_reports_has_dsl() {
708 let dir = tmp_dir();
709 let store = fresh_store();
710 save(
711 &dir,
712 &store,
713 "alpha",
714 &sample_graph("alpha"),
715 DEFAULT_COLLECTION_SLUG,
716 None,
717 )
718 .unwrap();
719 let listed = list(&dir, &store).unwrap();
720 assert_eq!(listed.len(), 1);
721 assert!(listed[0].has_dsl);
722 let _ = std::fs::remove_dir_all(&dir);
723 }
724
725}