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 = 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
107async 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
131async 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 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 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 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 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 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 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 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 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 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}