Skip to main content

flow_application/
templates.rs

1//! Flow templates - versionable, named flow graphs persisted to disk, grouped
2//! into collections by SQLite metadata.
3//!
4//! Filesystem layout is intentionally flat: one JSON file per template at
5//! `<dir>/<slug>.flow.json` (plus an optional sibling `<slug>.flow` text
6//! projection). Collections do not affect filesystem layout - they're held in
7//! the `template_collections` / `template_membership` tables on
8//! `flow_storage::Store`. A template with no `template_membership` row is in
9//! the system-managed Default collection.
10
11use 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
20/// Slug of the system-managed Default collection. Every template whose
21/// `template_membership` row is absent is reported as belonging here.
22pub const DEFAULT_COLLECTION_SLUG: &str = "default";
23/// Display name of the Default collection, used by [`ensure_default`].
24pub 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/// Source provenance for a template installed from the Template Hub. Plain
43/// struct rather than an enum: there is exactly one source today; widen
44/// later if another lands.
45#[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    /// Always set. `DEFAULT_COLLECTION_SLUG` when no `template_membership`
58    /// row exists for this template.
59    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    /// True if a sibling `<slug>.flow` text file exists alongside the JSON.
65    /// Written automatically by `save`; surfaced to the UI as a small indicator.
66    #[serde(default)]
67    pub has_dsl: bool,
68    /// `Some` only when the template was installed via the Template Hub and
69    /// the source columns on the membership row are populated.
70    #[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
77/// Idempotently insert the Default collection row at boot. Called by
78/// `FlowApp::new`. Safe to call repeatedly: the upsert never downgrades
79/// `is_system` once set.
80pub fn ensure_default(store: &Store) -> Result<(), TemplateError> {
81    store.upsert_template_collection(DEFAULT_COLLECTION_SLUG, DEFAULT_COLLECTION_NAME, true)?;
82    Ok(())
83}
84
85/// List every template on disk, annotated with its collection + source from
86/// the membership index. Files without a membership row report
87/// `DEFAULT_COLLECTION_SLUG` and `source: None`.
88pub 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
93/// List templates in one collection only. Files in other collections are
94/// skipped. Files with no membership row count as Default.
95pub 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
151/// Save a template into `collection_slug`. Membership is written only when
152/// the placement is non-default OR a hub `source` is supplied - keeps the
153/// SQLite skinny: in-default user-authored templates have no row.
154pub 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
169/// Lower-level save that takes an explicit slug instead of deriving one.
170/// Used by the Hub install path, where the catalog slug must stay stable
171/// across versions even when the display name changes. `slug` is validated
172/// against the same `[a-z0-9-]+` rule as the renderer-facing routes.
173pub 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    // Sibling DSL text - non-fatal on write failure (JSON is canonical).
190    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        // Moving an existing template back to Default means the row is no
211        // longer needed. Idempotent on the Store side.
212        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
237/// File-first deletion: removes the canonical JSON, then the sibling DSL,
238/// then the membership row. Membership cleanup is best-effort and logs on
239/// failure - the file is the source of truth.
240pub 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
300/// Defense-in-depth check for slugs that arrive from the Tauri renderer.
301/// `save` always slugifies the user-supplied name; `load`/`delete` take a
302/// slug string from the frontend and join it onto the templates directory.
303/// A buggy or compromised renderer could otherwise pass `../foo` and read
304/// or delete files outside the templates dir. Reject anything that isn't
305/// the same `[a-z0-9-]+` shape `slugify` produces.
306pub(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; // suppress leading dashes
325    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        // Call again - should be a no-op (upsert with is_system=true).
385        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        // Flat file with no membership row.
593        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}