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