1use 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
58fn looks_like_json_object(contents: &str) -> bool {
66 contents.trim_start().starts_with('{')
67}
68
69pub 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
108async 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
132async 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 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 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 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 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 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 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 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 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 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}