1use crate::cli::{
10 TemplateArgs, TemplateCommand, TemplateDeleteArgs, TemplateDeprecateArgs, TemplateLaunchArgs,
11 TemplateListArgs, TemplatePromoteArgs, TemplateRegisterArgs, TemplateRollbackArgs,
12 TemplateRunArgs, TemplateShowArgs, TemplateStoreArgs,
13};
14use crate::error::{CliError, CliResult};
15use crate::serve::history::templates::{
16 TemplateRecord, TemplateSummary, VersionChannel, VersionSelector,
17};
18use crate::serve::load::ConfigFormat;
19use crate::templates::{RegisterRequest, TemplateStore};
20
21pub async fn run(args: TemplateArgs) -> CliResult<()> {
23 match args.command {
24 TemplateCommand::Register(a) => register(a).await,
25 TemplateCommand::List(a) => list(a).await,
26 TemplateCommand::Show(a) => show(a).await,
27 TemplateCommand::Launch(a) => launch(a).await,
28 TemplateCommand::Rollback(a) => rollback(a).await,
29 TemplateCommand::Deprecate(a) => deprecate(a).await,
30 TemplateCommand::Promote(a) => promote(a).await,
31 TemplateCommand::Delete(a) => delete(a).await,
32 TemplateCommand::Run(a) => run_template(a).await,
33 }
34}
35
36async fn connect(common: &TemplateStoreArgs) -> CliResult<TemplateStore> {
39 let cwd = std::env::current_dir()?;
40 let env_path =
41 crate::env_loader::resolve_env_file(common.env_file.as_deref(), common.no_env_file, &cwd)?;
42 crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
43 crate::templates::resolve_store_url(&common.store).await
44}
45
46fn to_pretty<T: serde::Serialize>(value: &T) -> CliResult<String> {
47 serde_json::to_string_pretty(value)
48 .map_err(|e| CliError::Internal(format!("rendering template JSON: {e}")))
49}
50
51fn format_of(path: &std::path::Path) -> CliResult<ConfigFormat> {
53 match path
54 .extension()
55 .and_then(|e| e.to_str())
56 .map(str::to_ascii_lowercase)
57 .as_deref()
58 {
59 Some("yaml" | "yml") => Ok(ConfigFormat::Yaml),
60 Some("json") => Ok(ConfigFormat::Json),
61 _ => Err(CliError::UnknownExtension {
62 path: path.to_path_buf(),
63 }),
64 }
65}
66
67async fn register(args: TemplateRegisterArgs) -> CliResult<()> {
68 let store = connect(&args.common).await?;
69 let format = format_of(&args.config)?;
70 let body = std::fs::read_to_string(&args.config).map_err(|e| {
71 CliError::Config(format!(
72 "reading template config '{}': {e}",
73 args.config.display()
74 ))
75 })?;
76 let tags = args
77 .tag
78 .iter()
79 .map(|t| VersionChannel::parse(t))
80 .collect::<CliResult<Vec<_>>>()?;
81 let record = crate::templates::register(
82 &store,
83 RegisterRequest {
84 id: args.id.clone(),
85 body,
86 format,
87 description: args.description.clone(),
88 tags: tags.clone(),
89 launch: args.launch,
90 created_by: None,
91 },
92 )
93 .await?;
94
95 if args.common.json {
96 println!("{}", to_pretty(&record.summary())?);
97 return Ok(());
98 }
99 println!(
100 "registered template '{}' version {}{}",
101 record.id,
102 record.version,
103 if tags.is_empty() {
104 String::new()
105 } else {
106 format!(
107 " (channels: {})",
108 tags.iter()
109 .map(|c| c.as_str())
110 .collect::<Vec<_>>()
111 .join(", ")
112 )
113 }
114 );
115 print_params(&record.summary());
116 println!(
117 "\ntrigger it with:\n faucet template run {} --store {}{}",
118 record.id,
119 args.common.store,
120 required_param_hint(&record.summary())
121 );
122 Ok(())
123}
124
125fn required_param_hint(summary: &TemplateSummary) -> String {
128 summary
129 .params
130 .iter()
131 .filter(|(_, p)| p.required)
132 .map(|(name, p)| format!(" --param {name}=<{}>", p.kind.as_str()))
133 .collect()
134}
135
136fn print_params(summary: &TemplateSummary) {
137 if summary.params.is_empty() {
138 println!("params: (none — this template takes no overrides)");
139 return;
140 }
141 println!("\nparams:");
142 for (name, p) in &summary.params {
143 let requirement = if p.required {
144 "required".to_string()
145 } else {
146 match &p.default {
147 Some(d) => format!("default {d}"),
148 None => "optional".to_string(),
149 }
150 };
151 println!(
152 " {:<20} {:<7} {}{}{}",
153 name,
154 p.kind.as_str(),
155 requirement,
156 if p.secret { " [secret]" } else { "" },
157 match &p.description {
158 Some(d) => format!(" — {d}"),
159 None => String::new(),
160 }
161 );
162 }
163}
164
165async fn list(args: TemplateListArgs) -> CliResult<()> {
166 let store = connect(&args.common).await?;
167 let templates = crate::templates::list_with_state(&store).await?;
168 if args.common.json {
169 println!(
170 "{}",
171 to_pretty(&serde_json::json!({ "templates": templates }))?
172 );
173 return Ok(());
174 }
175 if templates.is_empty() {
176 println!("no templates registered in this store — add one with `faucet template register`");
177 return Ok(());
178 }
179 println!(
183 "{:<26} {:<11} {:<6} {:<7} {:>6} DESCRIPTION",
184 "ID", "STATUS", "LIVE", "NEWEST", "PARAMS"
185 );
186 for t in &templates {
187 let (status, live, newest) = match &t.state {
188 Some(st) => (
189 st.status.to_string(),
190 st.stable.map(|v| format!("v{v}")).unwrap_or("—".into()),
191 st.newest.map(|v| format!("v{v}")).unwrap_or("—".into()),
192 ),
193 None => ("?".into(), "?".into(), format!("v{}", t.version)),
194 };
195 println!(
196 "{:<26} {:<11} {:<6} {:<7} {:>6} {}",
197 t.id,
198 status,
199 live,
200 newest,
201 t.params.len(),
202 t.description.as_deref().unwrap_or("")
203 );
204 }
205 Ok(())
206}
207
208async fn fetch(store: &TemplateStore, id: &str, version: Option<u32>) -> CliResult<TemplateRecord> {
210 store
211 .template_get(id, version)
212 .await
213 .map_err(|e| CliError::Internal(format!("template registry read: {e}")))?
214 .ok_or_else(|| CliError::UnknownPipelineTemplate {
215 id: id.to_string(),
216 version,
217 })
218}
219
220async fn show(args: TemplateShowArgs) -> CliResult<()> {
221 let store = connect(&args.common).await?;
222 let selector = VersionSelector::parse(&args.version)?;
223 let want = crate::templates::resolve_version(&store, &args.id, selector).await?;
224 let record = fetch(&store, &args.id, Some(want)).await?;
225
226 if args.clean && !args.common.json {
230 print!("{}", crate::templates::clean_config_yaml(&record.body)?);
231 return Ok(());
232 }
233
234 let state = crate::templates::template_state(&store, &args.id).await?;
235 let launches = store
236 .template_launches(&args.id)
237 .await
238 .map_err(|e| CliError::Internal(format!("template launch read: {e}")))?;
239
240 if args.common.json {
241 println!(
242 "{}",
243 to_pretty(&serde_json::json!({
244 "template": record,
245 "state": state,
246 "is_stable": state.stable == Some(record.version),
247 "launches": launches,
248 }))?
249 );
250 return Ok(());
251 }
252 println!("template {} [{}]", record.id, state.status);
253 if let Some(name) = &record.name {
254 println!("name {name}");
255 }
256 if let Some(d) = &record.description {
257 println!("about {d}");
258 }
259 println!(
260 "created {}{}",
261 record.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
262 match &record.created_by {
263 Some(p) => format!(" by {p}"),
264 None => String::new(),
265 }
266 );
267 println!(
268 "showing v{}{}",
269 record.version,
270 if state.stable == Some(record.version) {
271 " (live)"
272 } else {
273 ""
274 }
275 );
276 println!("\nversions:");
279 for v in &state.versions {
280 let mut marks: Vec<String> = Vec::new();
281 if state.stable == Some(*v) {
282 marks.push("live".into());
283 }
284 if state.previous == Some(*v) {
285 marks.push("previous".into());
286 }
287 if state.newest == Some(*v) {
288 marks.push("newest".into());
289 }
290 marks.extend(
291 state
292 .tags
293 .iter()
294 .filter(|(_, pointed)| *pointed == v)
295 .map(|(t, _)| t.clone()),
296 );
297 println!(
298 " v{:<4} {}",
299 v,
300 if marks.is_empty() {
301 String::from("—")
302 } else {
303 marks.join(", ")
304 }
305 );
306 }
307 if let Some(d) = &state.deprecation {
308 println!(
309 "\ndeprecated {}{}",
310 d.deprecated_at.format("%Y-%m-%dT%H:%M:%SZ"),
311 match &d.reason {
312 Some(r) => format!(" — {r}"),
313 None => String::new(),
314 }
315 );
316 }
317 if !launches.is_empty() {
318 println!("\nlaunch history (newest first):");
319 for l in launches.iter().take(10) {
320 println!(
321 " v{:<4} {}{}",
322 l.version,
323 l.launched_at.format("%Y-%m-%dT%H:%M:%SZ"),
324 match &l.launched_by {
325 Some(by) => format!(" by {by}"),
326 None => String::new(),
327 }
328 );
329 }
330 }
331 print_params(&record.summary());
332 println!("\nconfig ({:?}, stored verbatim):", record.format);
333 for line in record.body.lines() {
334 println!(" {line}");
335 }
336 Ok(())
337}
338
339async fn delete(args: TemplateDeleteArgs) -> CliResult<()> {
340 let store = connect(&args.common).await?;
341 let pinned = match args.version.as_deref() {
343 None => None,
344 Some(raw) => Some(
347 crate::templates::resolve_version(&store, &args.id, VersionSelector::parse(raw)?)
348 .await?,
349 ),
350 };
351 let removed = store
352 .template_delete(&args.id, pinned)
353 .await
354 .map_err(|e| CliError::Internal(format!("template registry write: {e}")))?;
355 if removed == 0 {
356 return Err(CliError::UnknownPipelineTemplate {
357 id: args.id.clone(),
358 version: pinned,
359 });
360 }
361 if args.common.json {
362 println!(
363 "{}",
364 to_pretty(&serde_json::json!({ "id": args.id, "deleted_versions": removed }))?
365 );
366 return Ok(());
367 }
368 println!("deleted {removed} version(s) of template '{}'", args.id);
369 Ok(())
370}
371
372fn report_launch(
374 id: &str,
375 outcome: &crate::templates::LaunchOutcome,
376 json: bool,
377 verb: &str,
378) -> CliResult<()> {
379 if json {
380 println!(
381 "{}",
382 to_pretty(&serde_json::json!({
383 "id": id,
384 "version": outcome.version,
385 "replaced": outcome.replaced,
386 "already_launched": outcome.already_launched,
387 "first_launch": outcome.first_launch,
388 }))?
389 );
390 return Ok(());
391 }
392 if outcome.already_launched {
393 println!(
394 "template '{id}': v{} was already live — nothing changed",
395 outcome.version
396 );
397 return Ok(());
398 }
399 println!(
400 "template '{id}': {verb} v{}{}",
401 outcome.version,
402 match outcome.replaced {
403 Some(prev) => format!(" (was v{prev}; previous → v{prev})"),
404 None => String::from(" — first launch, template is now `launched`"),
405 }
406 );
407 Ok(())
408}
409
410async fn launch(args: TemplateLaunchArgs) -> CliResult<()> {
411 let store = connect(&args.common).await?;
412 let target = VersionSelector::parse(&args.version)?;
413 let outcome = crate::templates::launch(&store, &args.id, target, None).await?;
414 report_launch(&args.id, &outcome, args.common.json, "launched")
415}
416
417async fn rollback(args: TemplateRollbackArgs) -> CliResult<()> {
418 let store = connect(&args.common).await?;
419 let outcome = crate::templates::rollback(&store, &args.id, None).await?;
420 report_launch(&args.id, &outcome, args.common.json, "rolled back to")
421}
422
423async fn deprecate(args: TemplateDeprecateArgs) -> CliResult<()> {
424 let store = connect(&args.common).await?;
425 let status =
426 crate::templates::set_deprecated(&store, &args.id, args.reason.clone(), None, !args.undo)
427 .await?;
428 if args.common.json {
429 println!(
430 "{}",
431 to_pretty(&serde_json::json!({ "id": args.id, "status": status.as_str() }))?
432 );
433 return Ok(());
434 }
435 println!("template '{}' is now {status}", args.id);
436 if !args.undo {
437 println!(
438 " existing callers keep working (pinned runs and `stable` still resolve) but every \
439 trigger warns — use `faucet template delete` for a hard stop"
440 );
441 }
442 Ok(())
443}
444
445async fn promote(args: TemplatePromoteArgs) -> CliResult<()> {
446 let store = connect(&args.common).await?;
447 let tag = VersionChannel::parse(&args.tag)?;
448 let target = VersionSelector::parse(&args.version)?;
449 let version = crate::templates::promote(&store, &args.id, tag, target).await?;
450 if args.common.json {
451 println!(
452 "{}",
453 to_pretty(&serde_json::json!({
454 "id": args.id, "tag": tag.as_str(), "version": version,
455 }))?
456 );
457 return Ok(());
458 }
459 println!("template '{}': {tag} → v{version}", args.id);
460 Ok(())
461}
462
463async fn run_template(args: TemplateRunArgs) -> CliResult<()> {
464 let store = connect(&args.common).await?;
465 let supplied = crate::params::collect_cli_params(&args.param)?;
466 let env = crate::params::collect_env_overrides(&args.param_env)?;
467 let selector = VersionSelector::parse(&args.version)?;
468 let want = crate::templates::resolve_version(&store, &args.id, selector).await?;
469 let materialized = crate::templates::materialize(
470 &store,
471 &args.id,
472 want,
473 &supplied,
474 &env,
475 crate::templates::Materialize::Local,
477 )
478 .await?;
479
480 tracing::info!(
481 template = %materialized.template_id,
482 version = materialized.version,
483 "materialized pipeline template"
484 );
485
486 let doc: serde_json::Value = serde_json::from_str(&materialized.body)
490 .map_err(|e| CliError::Internal(format!("re-parsing materialized template: {e}")))?;
491 let mut cfg = crate::config::PipelineConfig::from_value(doc)?;
492 crate::secrets::resolve_secrets(&mut cfg).await?;
493
494 if args.dry_run && args.common.json {
495 println!("{}", to_pretty(&cfg)?);
496 return Ok(());
497 }
498
499 let run_args = crate::cli::RunArgs {
503 dry_run: args.dry_run,
504 limit: args.limit,
505 no_env_file: true,
506 ..Default::default()
507 };
508 crate::commands::run::execute(cfg, run_args, None).await
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use crate::cli::TemplateStoreArgs;
515
516 #[test]
517 fn clean_config_strips_comments_and_preserves_params() {
518 let body = "\
519version: 1 # trailing comment
520# a top-level comment
521name: orders
522pipeline:
523 source: { type: csv, config: { path: \"${param.p}\" } } # inline
524 sink: { type: jsonl, config: { path: ./out.jsonl } }
525";
526 let out = crate::templates::clean_config_yaml(body).unwrap();
527 assert!(!out.contains('#'), "comments must be stripped: {out}");
529 assert!(
531 out.contains("${param.p}"),
532 "param token must survive: {out}"
533 );
534 let before: serde_yaml::Value = serde_yaml::from_str(body).unwrap();
536 let after: serde_yaml::Value = serde_yaml::from_str(&out).unwrap();
537 assert_eq!(before, after, "clean output must parse to the same config");
538 }
539
540 #[test]
541 fn clean_config_normalizes_json_body_to_yaml() {
542 let body = r#"{"version":1,"name":"j","pipeline":{"source":{"type":"csv"}}}"#;
544 let out = crate::templates::clean_config_yaml(body).unwrap();
545 assert!(out.contains("version: 1"), "should be YAML now: {out}");
546 assert!(
547 !out.contains('{'),
548 "no JSON braces in canonical YAML: {out}"
549 );
550 }
551
552 fn common(store: &str, json: bool) -> TemplateStoreArgs {
553 TemplateStoreArgs {
554 store: store.to_string(),
555 env_file: None,
556 no_env_file: true,
557 json,
558 }
559 }
560
561 const BODY: &str = "\
562version: 1
563name: cli-tpl
564params:
565 tag: { required: true, description: Output tag }
566 page: { type: int, default: 5 }
567pipeline:
568 source:
569 type: csv
570 config:
571 path: IN_PATH
572 sink:
573 type: jsonl
574 config:
575 path: OUT_PATH
576";
577
578 #[cfg(feature = "serve-history-sqlite")]
582 #[tokio::test]
583 async fn register_launch_promote_run_round_trip() {
584 let dir = tempfile::tempdir().unwrap();
585 let input = dir.path().join("in.csv");
586 std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
587 let output = dir.path().join("out.jsonl");
588 let cfg_path = dir.path().join("tpl.yaml");
589 std::fs::write(
590 &cfg_path,
591 BODY.replace("IN_PATH", &input.display().to_string())
592 .replace("OUT_PATH", &output.display().to_string()),
593 )
594 .unwrap();
595 let store = format!("sqlite:{}", dir.path().join("registry.db").display());
596 let reg = |launch: bool, tag: Vec<String>| TemplateRegisterArgs {
597 config: cfg_path.clone(),
598 id: None,
599 description: Some("round trip".into()),
600 tag,
601 launch,
602 common: common(&store, false),
603 };
604
605 register(reg(false, vec!["dev".into()]))
607 .await
608 .expect("register v1");
609 list(TemplateListArgs {
610 common: common(&store, true),
611 })
612 .await
613 .expect("list");
614
615 let err = run_template(TemplateRunArgs {
617 id: "cli-tpl".into(),
618 version: "stable".into(),
619 param: vec!["tag=alpha".into()],
620 param_env: vec![],
621 dry_run: true,
622 limit: None,
623 common: common(&store, false),
624 })
625 .await
626 .unwrap_err()
627 .to_string();
628 assert!(err.contains("no launched version"), "{err}");
629
630 launch(TemplateLaunchArgs {
632 id: "cli-tpl".into(),
633 version: "newest".into(),
634 common: common(&store, false),
635 })
636 .await
637 .expect("launch");
638 run_template(TemplateRunArgs {
639 id: "cli-tpl".into(),
640 version: "stable".into(),
641 param: vec!["tag=alpha".into()],
642 param_env: vec![],
643 dry_run: false,
644 limit: None,
645 common: common(&store, false),
646 })
647 .await
648 .expect("run");
649 assert_eq!(
650 std::fs::read_to_string(&output).unwrap().lines().count(),
651 2,
652 "the launched version's pipeline wrote both records"
653 );
654
655 register(reg(false, vec![])).await.expect("register v2");
657 show(TemplateShowArgs {
658 id: "cli-tpl".into(),
659 version: "stable".into(),
660 clean: false,
661 common: common(&store, false),
662 })
663 .await
664 .expect("show");
665 promote(TemplatePromoteArgs {
666 id: "cli-tpl".into(),
667 tag: "pre-prod".into(),
668 version: "newest".into(),
669 common: common(&store, false),
670 })
671 .await
672 .expect("promote");
673 launch(TemplateLaunchArgs {
675 id: "cli-tpl".into(),
676 version: "pre-prod".into(),
677 common: common(&store, true),
678 })
679 .await
680 .expect("launch from channel");
681 rollback(TemplateRollbackArgs {
682 id: "cli-tpl".into(),
683 common: common(&store, false),
684 })
685 .await
686 .expect("rollback");
687
688 for tag in ["stable", "previous", "newest", "prd", "latest"] {
690 assert!(
691 promote(TemplatePromoteArgs {
692 id: "cli-tpl".into(),
693 tag: tag.into(),
694 version: "1".into(),
695 common: common(&store, false),
696 })
697 .await
698 .is_err(),
699 "`{tag}` must not be promotable"
700 );
701 }
702
703 deprecate(TemplateDeprecateArgs {
705 id: "cli-tpl".into(),
706 reason: Some("superseded".into()),
707 undo: false,
708 common: common(&store, false),
709 })
710 .await
711 .expect("deprecate");
712 deprecate(TemplateDeprecateArgs {
713 id: "cli-tpl".into(),
714 reason: None,
715 undo: true,
716 common: common(&store, true),
717 })
718 .await
719 .expect("undeprecate");
720
721 delete(TemplateDeleteArgs {
723 id: "cli-tpl".into(),
724 version: Some("newest".into()),
725 common: common(&store, false),
726 })
727 .await
728 .expect("delete newest");
729 delete(TemplateDeleteArgs {
730 id: "cli-tpl".into(),
731 version: None,
732 common: common(&store, true),
733 })
734 .await
735 .expect("delete all");
736 let err = delete(TemplateDeleteArgs {
737 id: "cli-tpl".into(),
738 version: None,
739 common: common(&store, false),
740 })
741 .await
742 .unwrap_err();
743 assert!(
744 matches!(err, CliError::UnknownPipelineTemplate { .. }),
745 "{err:?}"
746 );
747 }
748
749 #[tokio::test]
750 async fn show_and_run_report_an_unknown_template() {
751 let c = common("memory", false);
752 let store = connect(&c).await.unwrap();
753 let err = fetch(&store, "nope", None).await.unwrap_err();
754 assert!(
755 matches!(err, CliError::UnknownPipelineTemplate { ref id, .. } if id == "nope"),
756 "{err:?}"
757 );
758 let err = promote(TemplatePromoteArgs {
761 id: "nope".into(),
762 tag: "prod".into(),
763 version: "newest".into(),
764 common: common("memory", false),
765 })
766 .await
767 .unwrap_err();
768 assert!(
769 matches!(err, CliError::UnknownPipelineTemplate { .. }),
770 "{err:?}"
771 );
772 }
773
774 #[test]
775 fn format_is_taken_from_the_extension() {
776 assert_eq!(
777 format_of(std::path::Path::new("a.yaml")).unwrap(),
778 ConfigFormat::Yaml
779 );
780 assert_eq!(
781 format_of(std::path::Path::new("a.YML")).unwrap(),
782 ConfigFormat::Yaml
783 );
784 assert_eq!(
785 format_of(std::path::Path::new("a.json")).unwrap(),
786 ConfigFormat::Json
787 );
788 assert!(format_of(std::path::Path::new("a.toml")).is_err());
789 assert!(format_of(std::path::Path::new("a")).is_err());
790 }
791
792 #[test]
793 fn required_param_hint_lists_only_required_params() {
794 let mut params = crate::params::ParamsSpec::new();
795 params.insert(
796 "tag".into(),
797 crate::params::ParamSpec {
798 kind: crate::params::ParamType::String,
799 required: true,
800 default: None,
801 secret: false,
802 description: None,
803 computed: None,
804 },
805 );
806 params.insert(
807 "page".into(),
808 crate::params::ParamSpec {
809 kind: crate::params::ParamType::Int,
810 required: false,
811 default: Some(serde_json::json!(5)),
812 secret: false,
813 computed: None,
814 description: None,
815 },
816 );
817 let summary = TemplateSummary {
818 state: None,
819 id: "t".into(),
820 version: 1,
821 name: None,
822 description: None,
823 params,
824 created_at: chrono::Utc::now(),
825 created_by: None,
826 };
827 let hint = required_param_hint(&summary);
828 assert_eq!(hint, " --param tag=<string>");
829 print_params(&summary);
831 }
832
833 #[tokio::test]
834 async fn register_rejects_a_bad_extension_and_a_missing_file() {
835 let dir = tempfile::tempdir().unwrap();
836 let bad = dir.path().join("cfg.toml");
837 std::fs::write(&bad, "x = 1").unwrap();
838 let err = register(TemplateRegisterArgs {
839 config: bad,
840 id: None,
841 description: None,
842 tag: vec![],
843 launch: false,
844 common: common("memory", false),
845 })
846 .await
847 .unwrap_err();
848 assert!(matches!(err, CliError::UnknownExtension { .. }), "{err:?}");
849
850 let err = register(TemplateRegisterArgs {
851 config: dir.path().join("nope.yaml"),
852 id: None,
853 description: None,
854 tag: vec![],
855 launch: false,
856 common: common("memory", false),
857 })
858 .await
859 .unwrap_err()
860 .to_string();
861 assert!(err.contains("reading template config"), "{err}");
862 }
863
864 #[tokio::test]
865 async fn list_reports_an_empty_store() {
866 list(TemplateListArgs {
867 common: common("memory", false),
868 })
869 .await
870 .expect("empty list is not an error");
871 }
872}