Skip to main content

bhtune_cli/commands/
template.rs

1//! `bhtune template list/show/import/export/delete`.
2
3use std::path::Path;
4
5use bhtune_core::DcsTemplate;
6use bhtune_db::SqlitePool;
7use bhtune_db::models::{DcsTemplateRow, TemplateOrigin};
8
9use crate::args::{TemplateCommand, TemplateFileFormat};
10
11pub async fn run(pool: &SqlitePool, command: TemplateCommand) -> anyhow::Result<()> {
12    match command {
13        TemplateCommand::List => list(pool).await,
14        TemplateCommand::Show { name } => show(pool, &name).await,
15        TemplateCommand::Import { path } => import(pool, &path).await,
16        TemplateCommand::Export { name, path, format } => export(pool, &name, &path, format).await,
17        TemplateCommand::Delete { name } => delete(pool, &name).await,
18    }
19}
20
21async fn list(pool: &SqlitePool) -> anyhow::Result<()> {
22    let templates = DcsTemplateRow::list(pool).await?;
23    if templates.is_empty() {
24        println!("No templates found.");
25        return Ok(());
26    }
27    println!(
28        "{:<28} {:<8} {:<20} {:<12} {:<11} {:<11}",
29        "NAME", "ORIGIN", "VERSIONS", "PROPORTIONAL", "INTEGRAL", "DERIVATIVE"
30    );
31    for row in templates {
32        let versions = if row.template.versions.is_empty() {
33            "-".to_string()
34        } else {
35            row.template.versions.join(", ")
36        };
37        println!(
38            "{:<28} {:<8} {:<20} {:<12} {:<11} {:<11}",
39            row.template.name,
40            format!("{:?}", row.origin),
41            versions,
42            format!("{:?}", row.template.proportional_type),
43            format!("{:?}", row.template.integral_type),
44            format!("{:?}", row.template.derivative_type),
45        );
46    }
47    Ok(())
48}
49
50async fn show(pool: &SqlitePool, name: &str) -> anyhow::Result<()> {
51    let row = DcsTemplateRow::get_by_name(pool, name)
52        .await?
53        .ok_or_else(|| anyhow::anyhow!("no template named '{name}'"))?;
54    println!("{}", serde_json::to_string_pretty(&row.template)?);
55    Ok(())
56}
57
58/// A file "looks like" single-template JSON if, ignoring leading whitespace, it starts with
59/// `{`. Real TOML catalog files always start with a comment (`#`), blank lines, or a
60/// `[[template]]` table header -- never a bare `{` at the document root, since that isn't
61/// valid top-level TOML syntax at all -- so this heuristic never misclassifies legitimate
62/// content of either format. It exists purely so a malformed file gets a format-specific
63/// parse error (matching what the user most likely intended to write) rather than always
64/// showing the TOML parser's complaint about content that was actually meant as JSON.
65fn looks_like_json_object(contents: &str) -> bool {
66    contents.trim_start().starts_with('{')
67}
68
69/// Parses either a single-template JSON document or a multi-template TOML catalog without
70/// touching the database. The shared helper is used by the import command and by the fuzz
71/// target under `fuzz/`, keeping the high-risk format boundary independently testable.
72pub fn parse_import_contents(contents: &str) -> anyhow::Result<Vec<DcsTemplate>> {
73    if looks_like_json_object(contents) {
74        let template: DcsTemplate = serde_json::from_str(contents)?;
75        template.validate()?;
76        Ok(vec![template])
77    } else {
78        Ok(bhtune_core::template::parse_catalog(contents)?)
79    }
80}
81
82async fn import(pool: &SqlitePool, path: &Path) -> anyhow::Result<()> {
83    let contents = tokio::fs::read_to_string(path)
84        .await
85        .map_err(|e| anyhow::anyhow!("failed to read '{}': {e}", path.display()))?;
86
87    if looks_like_json_object(&contents) {
88        let template = parse_import_contents(&contents)
89            .map_err(|e| {
90                anyhow::anyhow!(
91                    "'{}' is not a valid template JSON file: {e}",
92                    path.display()
93                )
94            })?
95            .remove(0);
96        return import_one(pool, template).await;
97    }
98
99    let templates = parse_import_contents(&contents).map_err(|e| {
100        anyhow::anyhow!(
101            "'{}' is not a valid template TOML catalog: {e}",
102            path.display()
103        )
104    })?;
105    import_catalog(pool, templates).await
106}
107
108/// Imports a single template (the JSON path). Hard-fails on a name collision, matching this
109/// command's existing behavior -- a JSON import is one deliberate template, so an existing
110/// row with that name is treated as a mistake to fix, not something to silently skip.
111async fn import_one(pool: &SqlitePool, template: DcsTemplate) -> anyhow::Result<()> {
112    template
113        .validate()
114        .map_err(|e| anyhow::anyhow!("'{}' is not a valid template: {e}", template.name))?;
115
116    if DcsTemplateRow::get_by_name(pool, &template.name)
117        .await?
118        .is_some()
119    {
120        anyhow::bail!(
121            "a template named '{}' already exists; rename it in the file or remove the existing one first",
122            template.name
123        );
124    }
125
126    let row =
127        DcsTemplateRow::insert(pool, &template, TemplateOrigin::User, chrono::Utc::now()).await?;
128    println!("Imported template '{}' (id {}).", row.template.name, row.id);
129    Ok(())
130}
131
132/// Imports a multi-template TOML catalog (a single-template TOML file is just a one-entry
133/// catalog, so it goes through this same path). Best-effort rather than all-or-nothing: a
134/// template whose name already exists is skipped and reported rather than aborting the
135/// whole import, since the expected use is re-importing an updated community catalog file
136/// that overlaps with templates already present -- the useful outcome is "add what's new",
137/// not "fail because some of this was already here".
138async fn import_catalog(pool: &SqlitePool, templates: Vec<DcsTemplate>) -> anyhow::Result<()> {
139    if templates.is_empty() {
140        println!("The catalog contained no templates.");
141        return Ok(());
142    }
143
144    let now = chrono::Utc::now();
145    let mut imported = Vec::new();
146    let mut skipped = Vec::new();
147
148    for template in templates {
149        if DcsTemplateRow::get_by_name(pool, &template.name)
150            .await?
151            .is_some()
152        {
153            skipped.push(template.name);
154            continue;
155        }
156        DcsTemplateRow::insert(pool, &template, TemplateOrigin::User, now).await?;
157        imported.push(template.name);
158    }
159
160    for message in catalog_import_messages(&imported, &skipped) {
161        println!("{message}");
162    }
163    Ok(())
164}
165
166fn catalog_import_messages(imported: &[String], skipped: &[String]) -> Vec<String> {
167    let mut messages = Vec::new();
168    if imported.is_empty() {
169        messages.push("Imported no new templates.".to_string());
170    } else {
171        messages.push(format!(
172            "Imported {} template{}: {}.",
173            imported.len(),
174            if imported.len() == 1 { "" } else { "s" },
175            imported.join(", ")
176        ));
177    }
178    if !skipped.is_empty() {
179        messages.push(format!(
180            "Skipped {} already-existing template{}: {}.",
181            skipped.len(),
182            if skipped.len() == 1 { "" } else { "s" },
183            skipped.join(", ")
184        ));
185    }
186    messages
187}
188
189async fn export(
190    pool: &SqlitePool,
191    name: &str,
192    path: &Path,
193    format: TemplateFileFormat,
194) -> anyhow::Result<()> {
195    let row = DcsTemplateRow::get_by_name(pool, name)
196        .await?
197        .ok_or_else(|| anyhow::anyhow!("no template named '{name}'"))?;
198
199    let contents = match format {
200        TemplateFileFormat::Json => serde_json::to_string_pretty(&row.template)?,
201        TemplateFileFormat::Toml => bhtune_core::template::to_catalog_toml(vec![row.template])
202            .map_err(|e| anyhow::anyhow!("failed to serialize template as TOML: {e}"))?,
203    };
204    tokio::fs::write(path, contents)
205        .await
206        .map_err(|e| anyhow::anyhow!("failed to write '{}': {e}", path.display()))?;
207    println!("Exported template '{name}' to '{}'.", path.display());
208    Ok(())
209}
210
211async fn delete(pool: &SqlitePool, name: &str) -> anyhow::Result<()> {
212    let row = DcsTemplateRow::get_by_name(pool, name)
213        .await?
214        .ok_or_else(|| anyhow::anyhow!("no template named '{name}'"))?;
215
216    match DcsTemplateRow::delete(pool, row.id).await {
217        Ok(true) => {
218            println!("Deleted template '{name}'.");
219            match row.origin {
220                TemplateOrigin::Builtin => println!(
221                    "Note: '{name}' ships as a Builtin template and will be re-added \
222                     automatically the next time bhtune starts."
223                ),
224                TemplateOrigin::Catalog => println!(
225                    "Note: '{name}' comes from your user template catalog and will be \
226                     re-added automatically the next time bhtune starts, unless it's also \
227                     removed from that catalog file."
228                ),
229                TemplateOrigin::User => {}
230            }
231            Ok(())
232        }
233        // TOCTOU: something else deleted the row between the lookup above and here.
234        Ok(false) => {
235            println!("Template '{name}' was already deleted.");
236            Ok(())
237        }
238        Err(e) => classify_delete_error(name, e),
239    }
240}
241
242fn classify_delete_error(name: &str, error: bhtune_db::DbError) -> anyhow::Result<()> {
243    match error {
244        bhtune_db::DbError::TemplateInUse { .. } => anyhow::bail!(
245            "cannot delete template '{name}': it is still referenced by one or more saved loops; delete or reassign those loops first"
246        ),
247        error => Err(error.into()),
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use proptest::prelude::*;
255
256    async fn seeded_pool() -> SqlitePool {
257        let pool = bhtune_db::connect_in_memory().await.unwrap();
258        bhtune_db::seed_builtin_templates(&pool, chrono::Utc::now())
259            .await
260            .unwrap();
261        pool
262    }
263
264    #[tokio::test]
265    async fn list_runs_against_a_seeded_database() {
266        let pool = seeded_pool().await;
267        list(&pool).await.unwrap();
268    }
269
270    #[tokio::test]
271    async fn list_handles_an_empty_database() {
272        let pool = bhtune_db::connect_in_memory().await.unwrap();
273        list(&pool).await.unwrap();
274    }
275
276    #[tokio::test]
277    async fn list_propagates_database_errors() {
278        let pool = bhtune_db::connect_in_memory().await.unwrap();
279        pool.close().await;
280
281        assert!(list(&pool).await.is_err());
282    }
283
284    #[tokio::test]
285    async fn list_shows_a_dash_for_a_template_with_no_recorded_versions() {
286        let pool = bhtune_db::connect_in_memory().await.unwrap();
287        let mut template = bhtune_core::built_in_templates().remove(0);
288        template.versions = Vec::new();
289        DcsTemplateRow::insert(&pool, &template, TemplateOrigin::User, chrono::Utc::now())
290            .await
291            .unwrap();
292
293        // Exercises the empty-`versions` "-" formatting branch; `list` itself only prints,
294        // so success here (rather than a panic on the empty-vec join) is what matters.
295        list(&pool).await.unwrap();
296    }
297
298    #[test]
299    fn catalog_import_messages_report_imports_and_skips() {
300        let imported = vec!["New Site".to_string()];
301        let skipped = vec!["Existing Site".to_string(), "Another Site".to_string()];
302        assert_eq!(
303            catalog_import_messages(&imported, &skipped),
304            vec![
305                "Imported 1 template: New Site.".to_string(),
306                "Skipped 2 already-existing templates: Existing Site, Another Site.".to_string(),
307            ]
308        );
309    }
310
311    #[test]
312    fn catalog_import_messages_omit_the_skip_line_when_nothing_was_skipped() {
313        assert_eq!(
314            catalog_import_messages(&[], &[]),
315            vec!["Imported no new templates.".to_string()]
316        );
317    }
318
319    #[test]
320    fn delete_passes_through_non_template_in_use_database_errors() {
321        let error = classify_delete_error(
322            "Broken",
323            bhtune_db::DbError::InvalidBackup("test error".to_string()),
324        )
325        .unwrap_err();
326
327        assert_eq!(
328            error.to_string(),
329            "not a valid bhtune backup file: test error"
330        );
331    }
332
333    #[tokio::test]
334    async fn show_prints_a_known_template() {
335        let pool = seeded_pool().await;
336        show(&pool, "Yokogawa CentumVP").await.unwrap();
337    }
338
339    #[tokio::test]
340    async fn show_errors_for_an_unknown_template() {
341        let pool = seeded_pool().await;
342        let err = show(&pool, "Nonexistent").await.unwrap_err();
343        assert!(err.to_string().contains("Nonexistent"));
344    }
345
346    #[tokio::test]
347    async fn export_then_import_round_trips_under_a_new_name() {
348        let pool = seeded_pool().await;
349        let dir = tempfile::tempdir().unwrap();
350        let path = dir.path().join("template.json");
351
352        export(&pool, "Yokogawa CentumVP", &path, TemplateFileFormat::Json)
353            .await
354            .unwrap();
355
356        // Rename before importing, since the name already exists (seeded as builtin).
357        let contents = std::fs::read_to_string(&path).unwrap();
358        let mut template: DcsTemplate = serde_json::from_str(&contents).unwrap();
359        template.name = "My Custom Site".to_string();
360        std::fs::write(&path, serde_json::to_string_pretty(&template).unwrap()).unwrap();
361
362        import(&pool, &path).await.unwrap();
363
364        let row = DcsTemplateRow::get_by_name(&pool, "My Custom Site")
365            .await
366            .unwrap()
367            .unwrap();
368        assert_eq!(row.origin, TemplateOrigin::User);
369        assert_eq!(
370            row.template.process_variable_suffix,
371            template.process_variable_suffix
372        );
373    }
374
375    #[tokio::test]
376    async fn import_rejects_a_colliding_name() {
377        let pool = seeded_pool().await;
378        let dir = tempfile::tempdir().unwrap();
379        let path = dir.path().join("template.json");
380        export(&pool, "Yokogawa CentumVP", &path, TemplateFileFormat::Json)
381            .await
382            .unwrap();
383
384        let err = import(&pool, &path).await.unwrap_err();
385        assert!(err.to_string().contains("already exists"));
386    }
387
388    #[tokio::test]
389    async fn import_rejects_invalid_json() {
390        let pool = seeded_pool().await;
391        let dir = tempfile::tempdir().unwrap();
392        let path = dir.path().join("bad.json");
393        std::fs::write(&path, "{ not json").unwrap();
394
395        let err = import(&pool, &path).await.unwrap_err();
396        assert!(err.to_string().contains("not a valid template"));
397    }
398
399    #[tokio::test]
400    async fn import_rejects_invalid_toml() {
401        let pool = seeded_pool().await;
402        let dir = tempfile::tempdir().unwrap();
403        let path = dir.path().join("bad.toml");
404        std::fs::write(&path, "this is not [[ valid toml").unwrap();
405
406        let err = import(&pool, &path).await.unwrap_err();
407        assert!(
408            err.to_string()
409                .contains("not a valid template TOML catalog")
410        );
411    }
412
413    #[tokio::test]
414    async fn import_rejects_a_json_template_that_fails_validation() {
415        let pool = seeded_pool().await;
416        let dir = tempfile::tempdir().unwrap();
417        let path = dir.path().join("invalid.json");
418
419        let mut template = bhtune_core::built_in_templates().remove(0);
420        template.name = "Incomplete Site".to_string();
421        template.process_variable_suffix = String::new();
422        std::fs::write(&path, serde_json::to_string_pretty(&template).unwrap()).unwrap();
423
424        let err = import(&pool, &path).await.unwrap_err();
425        assert!(err.to_string().contains("not a valid template"));
426        assert!(
427            DcsTemplateRow::get_by_name(&pool, "Incomplete Site")
428                .await
429                .unwrap()
430                .is_none()
431        );
432    }
433
434    #[tokio::test]
435    async fn import_reports_a_missing_file_clearly() {
436        let pool = seeded_pool().await;
437        let err = import(&pool, Path::new("/nonexistent/template.json"))
438            .await
439            .unwrap_err();
440        assert!(err.to_string().contains("failed to read"));
441    }
442
443    #[tokio::test]
444    async fn import_reports_a_non_file_path_clearly() {
445        let pool = seeded_pool().await;
446        let err = import(&pool, Path::new(".")).await.unwrap_err();
447        assert!(err.to_string().contains("failed to read '.'"));
448    }
449
450    #[tokio::test]
451    async fn export_errors_for_an_unknown_template() {
452        let pool = seeded_pool().await;
453        let dir = tempfile::tempdir().unwrap();
454        let path = dir.path().join("template.json");
455        let err = export(&pool, "Nonexistent", &path, TemplateFileFormat::Json)
456            .await
457            .unwrap_err();
458        assert!(err.to_string().contains("Nonexistent"));
459    }
460
461    #[tokio::test]
462    async fn export_reports_a_non_file_destination_clearly() {
463        let pool = seeded_pool().await;
464        let err = export(
465            &pool,
466            "Yokogawa CentumVP",
467            Path::new("."),
468            TemplateFileFormat::Json,
469        )
470        .await
471        .unwrap_err();
472        assert!(err.to_string().contains("failed to write '.'"));
473    }
474
475    #[tokio::test]
476    async fn export_toml_then_import_round_trips_under_a_new_name() {
477        let pool = seeded_pool().await;
478        let dir = tempfile::tempdir().unwrap();
479        let path = dir.path().join("template.toml");
480
481        export(&pool, "Yokogawa CentumVP", &path, TemplateFileFormat::Toml)
482            .await
483            .unwrap();
484
485        let contents = std::fs::read_to_string(&path).unwrap();
486        assert_eq!(contents.matches("[[template]]").count(), 1);
487
488        // Rename before importing, since the name already exists (seeded as builtin).
489        let renamed = contents.replacen("Yokogawa CentumVP", "My TOML Site", 1);
490        std::fs::write(&path, renamed).unwrap();
491
492        import(&pool, &path).await.unwrap();
493
494        let row = DcsTemplateRow::get_by_name(&pool, "My TOML Site")
495            .await
496            .unwrap()
497            .unwrap();
498        assert_eq!(row.origin, TemplateOrigin::User);
499    }
500
501    #[tokio::test]
502    async fn import_toml_catalog_adds_multiple_new_templates_and_skips_existing_ones() {
503        let pool = seeded_pool().await;
504        let dir = tempfile::tempdir().unwrap();
505        let path = dir.path().join("catalog.toml");
506
507        // One brand-new template plus one whose name collides with a seeded builtin --
508        // the catalog import must add the former and skip (not fail on) the latter.
509        let mut templates = bhtune_core::built_in_templates();
510        templates.truncate(1);
511        assert_eq!(templates[0].name, "Yokogawa CentumVP");
512        let mut new_template = templates[0].clone();
513        new_template.name = "Brand New Site".to_string();
514        templates.push(new_template);
515
516        let toml = bhtune_core::template::to_catalog_toml(templates).unwrap();
517        assert_eq!(toml.matches("[[template]]").count(), 2);
518        std::fs::write(&path, toml).unwrap();
519
520        import(&pool, &path).await.unwrap();
521
522        assert!(
523            DcsTemplateRow::get_by_name(&pool, "Brand New Site")
524                .await
525                .unwrap()
526                .is_some()
527        );
528        // The colliding "Yokogawa CentumVP" entry must not have been touched/duplicated.
529        let all = DcsTemplateRow::list(&pool).await.unwrap();
530        assert_eq!(
531            all.iter()
532                .filter(|row| row.template.name == "Yokogawa CentumVP")
533                .count(),
534            1
535        );
536    }
537
538    #[tokio::test]
539    async fn import_toml_catalog_with_no_templates_is_a_no_op() {
540        let pool = seeded_pool().await;
541        let dir = tempfile::tempdir().unwrap();
542        let path = dir.path().join("empty.toml");
543        std::fs::write(&path, "template = []").unwrap();
544
545        import(&pool, &path).await.unwrap();
546    }
547
548    #[tokio::test]
549    async fn import_toml_catalog_where_every_template_already_exists_imports_nothing() {
550        let pool = seeded_pool().await;
551        let dir = tempfile::tempdir().unwrap();
552        let path = dir.path().join("all_existing.toml");
553
554        // The seeded pool already has every built-in template by name, so re-importing the
555        // exact same catalog must skip every entry and import none -- exercising the "0
556        // imported" reporting branch distinctly from the "some imported, some skipped" case
557        // covered above.
558        let toml =
559            bhtune_core::template::to_catalog_toml(bhtune_core::built_in_templates()).unwrap();
560        std::fs::write(&path, toml).unwrap();
561
562        let before = DcsTemplateRow::list(&pool).await.unwrap().len();
563        import(&pool, &path).await.unwrap();
564        let after = DcsTemplateRow::list(&pool).await.unwrap().len();
565        assert_eq!(before, after);
566    }
567
568    #[tokio::test]
569    async fn delete_removes_an_unreferenced_user_template() {
570        let pool = seeded_pool().await;
571        let dir = tempfile::tempdir().unwrap();
572        let path = dir.path().join("template.json");
573        export(&pool, "Yokogawa CentumVP", &path, TemplateFileFormat::Json)
574            .await
575            .unwrap();
576        let contents = std::fs::read_to_string(&path).unwrap();
577        let mut template: DcsTemplate = serde_json::from_str(&contents).unwrap();
578        template.name = "Deletable Site".to_string();
579        std::fs::write(&path, serde_json::to_string_pretty(&template).unwrap()).unwrap();
580        import(&pool, &path).await.unwrap();
581
582        delete(&pool, "Deletable Site").await.unwrap();
583
584        assert!(
585            DcsTemplateRow::get_by_name(&pool, "Deletable Site")
586                .await
587                .unwrap()
588                .is_none()
589        );
590    }
591
592    #[tokio::test]
593    async fn delete_reports_a_row_removed_by_a_concurrent_delete_as_already_deleted() {
594        let pool = seeded_pool().await;
595        sqlx::query(
596            "CREATE TRIGGER remove_before_delete
597             BEFORE DELETE ON dcs_templates
598             BEGIN
599                 DELETE FROM dcs_templates WHERE name = 'Yokogawa CentumVP';
600             END",
601        )
602        .execute(&pool)
603        .await
604        .unwrap();
605
606        delete(&pool, "Yokogawa CentumVP").await.unwrap();
607        assert!(
608            DcsTemplateRow::get_by_name(&pool, "Yokogawa CentumVP")
609                .await
610                .unwrap()
611                .is_none()
612        );
613    }
614
615    #[tokio::test]
616    async fn delete_succeeds_for_a_builtin_template() {
617        let pool = seeded_pool().await;
618        delete(&pool, "Yokogawa CentumVP").await.unwrap();
619        assert!(
620            DcsTemplateRow::get_by_name(&pool, "Yokogawa CentumVP")
621                .await
622                .unwrap()
623                .is_none()
624        );
625    }
626
627    #[tokio::test]
628    async fn delete_succeeds_for_a_catalog_origin_template() {
629        let pool = bhtune_db::connect_in_memory().await.unwrap();
630        let mut template = bhtune_core::built_in_templates().remove(0);
631        template.name = "User Catalog Site".to_string();
632        DcsTemplateRow::insert(
633            &pool,
634            &template,
635            TemplateOrigin::Catalog,
636            chrono::Utc::now(),
637        )
638        .await
639        .unwrap();
640
641        // Exercises the Catalog-origin reseed note branch distinctly from Builtin/User.
642        delete(&pool, "User Catalog Site").await.unwrap();
643
644        assert!(
645            DcsTemplateRow::get_by_name(&pool, "User Catalog Site")
646                .await
647                .unwrap()
648                .is_none()
649        );
650    }
651
652    #[tokio::test]
653    async fn delete_errors_for_an_unknown_template() {
654        let pool = seeded_pool().await;
655        let err = delete(&pool, "Nonexistent").await.unwrap_err();
656        assert!(err.to_string().contains("Nonexistent"));
657    }
658
659    #[tokio::test]
660    async fn delete_refuses_a_template_still_referenced_by_a_loop() {
661        let pool = seeded_pool().await;
662        let row = DcsTemplateRow::get_by_name(&pool, "Yokogawa CentumVP")
663            .await
664            .unwrap()
665            .unwrap();
666        let now = chrono::Utc::now();
667        sqlx::query(
668            r"
669            INSERT INTO loops (
670                name, dcs_template_id, tags_json, process_type, controller_type,
671                relay_amp_percent, num_cycles_skip, num_cycles_count, noise_protection_secs,
672                mrft_delay_secs, created_at, updated_at
673            ) VALUES ('LIC101', ?, '{}', 'flow', 'pi', 5.0, 1, 2, 3, 0, ?, ?)
674            ",
675        )
676        .bind(row.id)
677        .bind(now)
678        .bind(now)
679        .execute(&pool)
680        .await
681        .unwrap();
682
683        let err = delete(&pool, "Yokogawa CentumVP").await.unwrap_err();
684        assert!(err.to_string().contains("still referenced"));
685        assert!(
686            DcsTemplateRow::get_by_name(&pool, "Yokogawa CentumVP")
687                .await
688                .unwrap()
689                .is_some()
690        );
691    }
692
693    #[tokio::test]
694    async fn run_dispatches_every_subcommand() {
695        let pool = seeded_pool().await;
696        let dir = tempfile::tempdir().unwrap();
697        let path = dir.path().join("template.json");
698
699        run(&pool, TemplateCommand::List).await.unwrap();
700        run(
701            &pool,
702            TemplateCommand::Show {
703                name: "Yokogawa CentumVP".to_string(),
704            },
705        )
706        .await
707        .unwrap();
708        run(
709            &pool,
710            TemplateCommand::Export {
711                name: "Yokogawa CentumVP".to_string(),
712                path: path.clone(),
713                format: TemplateFileFormat::Json,
714            },
715        )
716        .await
717        .unwrap();
718
719        // Rename before importing, since the name already exists (seeded as builtin).
720        let contents = std::fs::read_to_string(&path).unwrap();
721        let mut template: DcsTemplate = serde_json::from_str(&contents).unwrap();
722        template.name = "Dispatch Import Target".to_string();
723        std::fs::write(&path, serde_json::to_string_pretty(&template).unwrap()).unwrap();
724
725        run(&pool, TemplateCommand::Import { path }).await.unwrap();
726
727        assert!(
728            DcsTemplateRow::get_by_name(&pool, "Dispatch Import Target")
729                .await
730                .unwrap()
731                .is_some()
732        );
733
734        run(
735            &pool,
736            TemplateCommand::Delete {
737                name: "Dispatch Import Target".to_string(),
738            },
739        )
740        .await
741        .unwrap();
742        assert!(
743            DcsTemplateRow::get_by_name(&pool, "Dispatch Import Target")
744                .await
745                .unwrap()
746                .is_none()
747        );
748    }
749
750    proptest::proptest! {
751        #[test]
752        fn exported_json_templates_parse_as_imports(
753            name in "[A-Za-z][A-Za-z0-9 _-]{0,24}",
754        ) {
755            let mut template = bhtune_core::built_in_templates().remove(0);
756            template.name = name;
757            let encoded = serde_json::to_string(&template).unwrap();
758            prop_assert_eq!(parse_import_contents(&encoded).unwrap(), vec![template]);
759        }
760
761        #[test]
762        fn exported_toml_catalogs_parse_as_imports(
763            name in "[A-Za-z][A-Za-z0-9 _-]{0,24}",
764        ) {
765            let mut template = bhtune_core::built_in_templates().remove(0);
766            template.name = name;
767            let encoded = bhtune_core::template::to_catalog_toml(vec![template.clone()]).unwrap();
768            prop_assert_eq!(parse_import_contents(&encoded).unwrap(), vec![template]);
769        }
770
771        #[test]
772        fn arbitrary_import_text_never_panics(input in any::<String>()) {
773            let _ = parse_import_contents(&input);
774        }
775    }
776}