Skip to main content

faucet_cli/commands/
template.rs

1//! `faucet template` — the CLI half of the pipeline template registry (#444).
2//!
3//! Register a parameterized config once into a store (`sqlite:` / `postgres://` /
4//! `memory`), then list / inspect / delete versions, or materialize one with
5//! `--param` values and run it locally. Pointing `faucet serve --history` at the
6//! same URL makes the very same templates triggerable over HTTP — the CLI and
7//! the control plane share one registry, not two.
8
9use 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
21/// Execute the `template` subcommand.
22pub 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
36/// Load `.env` (so a `${env:…}` in a materialized template resolves) and connect
37/// the registry store.
38async 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
51/// Pick the wire format from a config path's extension.
52fn 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
125/// `--param name=<…>` hints for every required param, for the register / show
126/// "how do I run this" line.
127fn 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    // LIVE is what an unpinned run gets; NEWEST is the build tip. Showing both
180    // side by side is the whole point of the model — a nightly can sit at v7 while
181    // production still rides v4.
182    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
208/// Fetch one template, mapping "not found" to a typed error naming the id.
209async 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    let state = crate::templates::template_state(&store, &args.id).await?;
226    let launches = store
227        .template_launches(&args.id)
228        .await
229        .map_err(|e| CliError::Internal(format!("template launch read: {e}")))?;
230
231    if args.common.json {
232        println!(
233            "{}",
234            to_pretty(&serde_json::json!({
235                "template": record,
236                "state": state,
237                "is_stable": state.stable == Some(record.version),
238                "launches": launches,
239            }))?
240        );
241        return Ok(());
242    }
243    println!("template  {}   [{}]", record.id, state.status);
244    if let Some(name) = &record.name {
245        println!("name      {name}");
246    }
247    if let Some(d) = &record.description {
248        println!("about     {d}");
249    }
250    println!(
251        "created   {}{}",
252        record.created_at.format("%Y-%m-%dT%H:%M:%SZ"),
253        match &record.created_by {
254            Some(p) => format!(" by {p}"),
255            None => String::new(),
256        }
257    );
258    println!(
259        "showing   v{}{}",
260        record.version,
261        if state.stable == Some(record.version) {
262            "  (live)"
263        } else {
264            ""
265        }
266    );
267    // One row per version with its channels — the version-first view, which is
268    // how you actually think about "what is v3 tagged as?".
269    println!("\nversions:");
270    for v in &state.versions {
271        let mut marks: Vec<String> = Vec::new();
272        if state.stable == Some(*v) {
273            marks.push("live".into());
274        }
275        if state.previous == Some(*v) {
276            marks.push("previous".into());
277        }
278        if state.newest == Some(*v) {
279            marks.push("newest".into());
280        }
281        marks.extend(
282            state
283                .tags
284                .iter()
285                .filter(|(_, pointed)| *pointed == v)
286                .map(|(t, _)| t.clone()),
287        );
288        println!(
289            "  v{:<4} {}",
290            v,
291            if marks.is_empty() {
292                String::from("—")
293            } else {
294                marks.join(", ")
295            }
296        );
297    }
298    if let Some(d) = &state.deprecation {
299        println!(
300            "\ndeprecated {}{}",
301            d.deprecated_at.format("%Y-%m-%dT%H:%M:%SZ"),
302            match &d.reason {
303                Some(r) => format!("  — {r}"),
304                None => String::new(),
305            }
306        );
307    }
308    if !launches.is_empty() {
309        println!("\nlaunch history (newest first):");
310        for l in launches.iter().take(10) {
311            println!(
312                "  v{:<4} {}{}",
313                l.version,
314                l.launched_at.format("%Y-%m-%dT%H:%M:%SZ"),
315                match &l.launched_by {
316                    Some(by) => format!("  by {by}"),
317                    None => String::new(),
318                }
319            );
320        }
321    }
322    print_params(&record.summary());
323    println!("\nconfig ({:?}, stored verbatim):", record.format);
324    for line in record.body.lines() {
325        println!("  {line}");
326    }
327    Ok(())
328}
329
330async fn delete(args: TemplateDeleteArgs) -> CliResult<()> {
331    let store = connect(&args.common).await?;
332    // No `--version` deletes the whole template; a selector deletes one version.
333    let pinned = match args.version.as_deref() {
334        None => None,
335        // A selector always resolves to a concrete version, so `--version stable`
336        // removes just the launched one rather than the whole template.
337        Some(raw) => Some(
338            crate::templates::resolve_version(&store, &args.id, VersionSelector::parse(raw)?)
339                .await?,
340        ),
341    };
342    let removed = store
343        .template_delete(&args.id, pinned)
344        .await
345        .map_err(|e| CliError::Internal(format!("template registry write: {e}")))?;
346    if removed == 0 {
347        return Err(CliError::UnknownPipelineTemplate {
348            id: args.id.clone(),
349            version: pinned,
350        });
351    }
352    if args.common.json {
353        println!(
354            "{}",
355            to_pretty(&serde_json::json!({ "id": args.id, "deleted_versions": removed }))?
356        );
357        return Ok(());
358    }
359    println!("deleted {removed} version(s) of template '{}'", args.id);
360    Ok(())
361}
362
363/// Render a launch/rollback outcome.
364fn report_launch(
365    id: &str,
366    outcome: &crate::templates::LaunchOutcome,
367    json: bool,
368    verb: &str,
369) -> CliResult<()> {
370    if json {
371        println!(
372            "{}",
373            to_pretty(&serde_json::json!({
374                "id": id,
375                "version": outcome.version,
376                "replaced": outcome.replaced,
377                "already_launched": outcome.already_launched,
378                "first_launch": outcome.first_launch,
379            }))?
380        );
381        return Ok(());
382    }
383    if outcome.already_launched {
384        println!(
385            "template '{id}': v{} was already live — nothing changed",
386            outcome.version
387        );
388        return Ok(());
389    }
390    println!(
391        "template '{id}': {verb} v{}{}",
392        outcome.version,
393        match outcome.replaced {
394            Some(prev) => format!(" (was v{prev}; previous → v{prev})"),
395            None => String::from(" — first launch, template is now `launched`"),
396        }
397    );
398    Ok(())
399}
400
401async fn launch(args: TemplateLaunchArgs) -> CliResult<()> {
402    let store = connect(&args.common).await?;
403    let target = VersionSelector::parse(&args.version)?;
404    let outcome = crate::templates::launch(&store, &args.id, target, None).await?;
405    report_launch(&args.id, &outcome, args.common.json, "launched")
406}
407
408async fn rollback(args: TemplateRollbackArgs) -> CliResult<()> {
409    let store = connect(&args.common).await?;
410    let outcome = crate::templates::rollback(&store, &args.id, None).await?;
411    report_launch(&args.id, &outcome, args.common.json, "rolled back to")
412}
413
414async fn deprecate(args: TemplateDeprecateArgs) -> CliResult<()> {
415    let store = connect(&args.common).await?;
416    let status =
417        crate::templates::set_deprecated(&store, &args.id, args.reason.clone(), None, !args.undo)
418            .await?;
419    if args.common.json {
420        println!(
421            "{}",
422            to_pretty(&serde_json::json!({ "id": args.id, "status": status.as_str() }))?
423        );
424        return Ok(());
425    }
426    println!("template '{}' is now {status}", args.id);
427    if !args.undo {
428        println!(
429            "  existing callers keep working (pinned runs and `stable` still resolve) but every \
430             trigger warns — use `faucet template delete` for a hard stop"
431        );
432    }
433    Ok(())
434}
435
436async fn promote(args: TemplatePromoteArgs) -> CliResult<()> {
437    let store = connect(&args.common).await?;
438    let tag = VersionChannel::parse(&args.tag)?;
439    let target = VersionSelector::parse(&args.version)?;
440    let version = crate::templates::promote(&store, &args.id, tag, target).await?;
441    if args.common.json {
442        println!(
443            "{}",
444            to_pretty(&serde_json::json!({
445                "id": args.id, "tag": tag.as_str(), "version": version,
446            }))?
447        );
448        return Ok(());
449    }
450    println!("template '{}': {tag} → v{version}", args.id);
451    Ok(())
452}
453
454async fn run_template(args: TemplateRunArgs) -> CliResult<()> {
455    let store = connect(&args.common).await?;
456    let supplied = crate::params::collect_cli_params(&args.param)?;
457    let env = crate::params::collect_env_overrides(&args.param_env)?;
458    let selector = VersionSelector::parse(&args.version)?;
459    let want = crate::templates::resolve_version(&store, &args.id, selector).await?;
460    let materialized = crate::templates::materialize(
461        &store,
462        &args.id,
463        want,
464        &supplied,
465        &env,
466        // `faucet template run` executes locally; nothing is persisted.
467        crate::templates::Materialize::Local,
468    )
469    .await?;
470
471    tracing::info!(
472        template = %materialized.template_id,
473        version = materialized.version,
474        "materialized pipeline template"
475    );
476
477    // The materialized body is JSON with every `${param.*}` bound; `${env:…}`
478    // for overridden variables is bound too. Remaining directives (secrets,
479    // un-overridden env) resolve on the normal load path below.
480    let doc: serde_json::Value = serde_json::from_str(&materialized.body)
481        .map_err(|e| CliError::Internal(format!("re-parsing materialized template: {e}")))?;
482    let mut cfg = crate::config::PipelineConfig::from_value(doc)?;
483    crate::secrets::resolve_secrets(&mut cfg).await?;
484
485    if args.dry_run && args.common.json {
486        println!("{}", to_pretty(&cfg)?);
487        return Ok(());
488    }
489
490    // Run through the identical path as `faucet run`, so observability,
491    // lineage, notifications, the catalog, SLA evaluation, and row selection all
492    // behave the same as they would for the same config on disk.
493    let run_args = crate::cli::RunArgs {
494        dry_run: args.dry_run,
495        limit: args.limit,
496        no_env_file: true,
497        ..Default::default()
498    };
499    crate::commands::run::execute(cfg, run_args, None).await
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505    use crate::cli::TemplateStoreArgs;
506
507    fn common(store: &str, json: bool) -> TemplateStoreArgs {
508        TemplateStoreArgs {
509            store: store.to_string(),
510            env_file: None,
511            no_env_file: true,
512            json,
513        }
514    }
515
516    const BODY: &str = "\
517version: 1
518name: cli-tpl
519params:
520  tag: { required: true, description: Output tag }
521  page: { type: int, default: 5 }
522pipeline:
523  source:
524    type: csv
525    config:
526      path: IN_PATH
527  sink:
528    type: jsonl
529    config:
530      path: OUT_PATH
531";
532
533    /// A registered template needs a *persistent* store to be visible to a
534    /// second command, so the CLI round-trip test uses a temp SQLite file.
535    /// Without the SQL backend feature the whole test is skipped.
536    #[cfg(feature = "serve-history-sqlite")]
537    #[tokio::test]
538    async fn register_launch_promote_run_round_trip() {
539        let dir = tempfile::tempdir().unwrap();
540        let input = dir.path().join("in.csv");
541        std::fs::write(&input, "id,name\n1,alice\n2,bob\n").unwrap();
542        let output = dir.path().join("out.jsonl");
543        let cfg_path = dir.path().join("tpl.yaml");
544        std::fs::write(
545            &cfg_path,
546            BODY.replace("IN_PATH", &input.display().to_string())
547                .replace("OUT_PATH", &output.display().to_string()),
548        )
549        .unwrap();
550        let store = format!("sqlite:{}", dir.path().join("registry.db").display());
551        let reg = |launch: bool, tag: Vec<String>| TemplateRegisterArgs {
552            config: cfg_path.clone(),
553            id: None,
554            description: Some("round trip".into()),
555            tag,
556            launch,
557            common: common(&store, false),
558        };
559
560        // A plain register is inert: the template is a draft.
561        register(reg(false, vec!["dev".into()]))
562            .await
563            .expect("register v1");
564        list(TemplateListArgs {
565            common: common(&store, true),
566        })
567        .await
568        .expect("list");
569
570        // An unpinned run refuses, naming the launch command.
571        let err = run_template(TemplateRunArgs {
572            id: "cli-tpl".into(),
573            version: "stable".into(),
574            param: vec!["tag=alpha".into()],
575            param_env: vec![],
576            dry_run: true,
577            limit: None,
578            common: common(&store, false),
579        })
580        .await
581        .unwrap_err()
582        .to_string();
583        assert!(err.contains("no launched version"), "{err}");
584
585        // Launching makes it live; then an unpinned run works.
586        launch(TemplateLaunchArgs {
587            id: "cli-tpl".into(),
588            version: "newest".into(),
589            common: common(&store, false),
590        })
591        .await
592        .expect("launch");
593        run_template(TemplateRunArgs {
594            id: "cli-tpl".into(),
595            version: "stable".into(),
596            param: vec!["tag=alpha".into()],
597            param_env: vec![],
598            dry_run: false,
599            limit: None,
600            common: common(&store, false),
601        })
602        .await
603        .expect("run");
604        assert_eq!(
605            std::fs::read_to_string(&output).unwrap().lines().count(),
606            2,
607            "the launched version's pipeline wrote both records"
608        );
609
610        // Register v2 (a build) — the live version must not move.
611        register(reg(false, vec![])).await.expect("register v2");
612        show(TemplateShowArgs {
613            id: "cli-tpl".into(),
614            version: "stable".into(),
615            common: common(&store, false),
616        })
617        .await
618        .expect("show");
619        promote(TemplatePromoteArgs {
620            id: "cli-tpl".into(),
621            tag: "pre-prod".into(),
622            version: "newest".into(),
623            common: common(&store, false),
624        })
625        .await
626        .expect("promote");
627        // Launch from the channel, then roll back.
628        launch(TemplateLaunchArgs {
629            id: "cli-tpl".into(),
630            version: "pre-prod".into(),
631            common: common(&store, true),
632        })
633        .await
634        .expect("launch from channel");
635        rollback(TemplateRollbackArgs {
636            id: "cli-tpl".into(),
637            common: common(&store, false),
638        })
639        .await
640        .expect("rollback");
641
642        // Derived channels and invented names are refused on promote.
643        for tag in ["stable", "previous", "newest", "prd", "latest"] {
644            assert!(
645                promote(TemplatePromoteArgs {
646                    id: "cli-tpl".into(),
647                    tag: tag.into(),
648                    version: "1".into(),
649                    common: common(&store, false),
650                })
651                .await
652                .is_err(),
653                "`{tag}` must not be promotable"
654            );
655        }
656
657        // Deprecate → revive.
658        deprecate(TemplateDeprecateArgs {
659            id: "cli-tpl".into(),
660            reason: Some("superseded".into()),
661            undo: false,
662            common: common(&store, false),
663        })
664        .await
665        .expect("deprecate");
666        deprecate(TemplateDeprecateArgs {
667            id: "cli-tpl".into(),
668            reason: None,
669            undo: true,
670            common: common(&store, true),
671        })
672        .await
673        .expect("undeprecate");
674
675        // Delete a single version, then the whole template.
676        delete(TemplateDeleteArgs {
677            id: "cli-tpl".into(),
678            version: Some("newest".into()),
679            common: common(&store, false),
680        })
681        .await
682        .expect("delete newest");
683        delete(TemplateDeleteArgs {
684            id: "cli-tpl".into(),
685            version: None,
686            common: common(&store, true),
687        })
688        .await
689        .expect("delete all");
690        let err = delete(TemplateDeleteArgs {
691            id: "cli-tpl".into(),
692            version: None,
693            common: common(&store, false),
694        })
695        .await
696        .unwrap_err();
697        assert!(
698            matches!(err, CliError::UnknownPipelineTemplate { .. }),
699            "{err:?}"
700        );
701    }
702
703    #[tokio::test]
704    async fn show_and_run_report_an_unknown_template() {
705        let c = common("memory", false);
706        let store = connect(&c).await.unwrap();
707        let err = fetch(&store, "nope", None).await.unwrap_err();
708        assert!(
709            matches!(err, CliError::UnknownPipelineTemplate { ref id, .. } if id == "nope"),
710            "{err:?}"
711        );
712        // Promoting a channel on a template that does not exist is the same
713        // typed error, not a silently-created pointer.
714        let err = promote(TemplatePromoteArgs {
715            id: "nope".into(),
716            tag: "prod".into(),
717            version: "newest".into(),
718            common: common("memory", false),
719        })
720        .await
721        .unwrap_err();
722        assert!(
723            matches!(err, CliError::UnknownPipelineTemplate { .. }),
724            "{err:?}"
725        );
726    }
727
728    #[test]
729    fn format_is_taken_from_the_extension() {
730        assert_eq!(
731            format_of(std::path::Path::new("a.yaml")).unwrap(),
732            ConfigFormat::Yaml
733        );
734        assert_eq!(
735            format_of(std::path::Path::new("a.YML")).unwrap(),
736            ConfigFormat::Yaml
737        );
738        assert_eq!(
739            format_of(std::path::Path::new("a.json")).unwrap(),
740            ConfigFormat::Json
741        );
742        assert!(format_of(std::path::Path::new("a.toml")).is_err());
743        assert!(format_of(std::path::Path::new("a")).is_err());
744    }
745
746    #[test]
747    fn required_param_hint_lists_only_required_params() {
748        let mut params = crate::params::ParamsSpec::new();
749        params.insert(
750            "tag".into(),
751            crate::params::ParamSpec {
752                kind: crate::params::ParamType::String,
753                required: true,
754                default: None,
755                secret: false,
756                description: None,
757            },
758        );
759        params.insert(
760            "page".into(),
761            crate::params::ParamSpec {
762                kind: crate::params::ParamType::Int,
763                required: false,
764                default: Some(serde_json::json!(5)),
765                secret: false,
766                description: None,
767            },
768        );
769        let summary = TemplateSummary {
770            state: None,
771            id: "t".into(),
772            version: 1,
773            name: None,
774            description: None,
775            params,
776            created_at: chrono::Utc::now(),
777            created_by: None,
778        };
779        let hint = required_param_hint(&summary);
780        assert_eq!(hint, " --param tag=<string>");
781        // `print_params` renders both without panicking.
782        print_params(&summary);
783    }
784
785    #[tokio::test]
786    async fn register_rejects_a_bad_extension_and_a_missing_file() {
787        let dir = tempfile::tempdir().unwrap();
788        let bad = dir.path().join("cfg.toml");
789        std::fs::write(&bad, "x = 1").unwrap();
790        let err = register(TemplateRegisterArgs {
791            config: bad,
792            id: None,
793            description: None,
794            tag: vec![],
795            launch: false,
796            common: common("memory", false),
797        })
798        .await
799        .unwrap_err();
800        assert!(matches!(err, CliError::UnknownExtension { .. }), "{err:?}");
801
802        let err = register(TemplateRegisterArgs {
803            config: dir.path().join("nope.yaml"),
804            id: None,
805            description: None,
806            tag: vec![],
807            launch: false,
808            common: common("memory", false),
809        })
810        .await
811        .unwrap_err()
812        .to_string();
813        assert!(err.contains("reading template config"), "{err}");
814    }
815
816    #[tokio::test]
817    async fn list_reports_an_empty_store() {
818        list(TemplateListArgs {
819            common: common("memory", false),
820        })
821        .await
822        .expect("empty list is not an error");
823    }
824}