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
226    // `--clean`: emit ONLY the pure template config — comments stripped, canonical
227    // YAML — so it pipes to a file. Skips the metadata report (and the extra store
228    // reads below). `--json` takes precedence.
229    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    // One row per version with its channels — the version-first view, which is
277    // how you actually think about "what is v3 tagged as?".
278    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    // No `--version` deletes the whole template; a selector deletes one version.
342    let pinned = match args.version.as_deref() {
343        None => None,
344        // A selector always resolves to a concrete version, so `--version stable`
345        // removes just the launched one rather than the whole template.
346        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
372/// Render a launch/rollback outcome.
373fn 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        // `faucet template run` executes locally; nothing is persisted.
476        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    // The materialized body is JSON with every `${param.*}` bound; `${env:…}`
487    // for overridden variables is bound too. Remaining directives (secrets,
488    // un-overridden env) resolve on the normal load path below.
489    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    // Run through the identical path as `faucet run`, so observability,
500    // lineage, notifications, the catalog, SLA evaluation, and row selection all
501    // behave the same as they would for the same config on disk.
502    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        // comments are gone
528        assert!(!out.contains('#'), "comments must be stripped: {out}");
529        // param placeholders survive (they're plain strings)
530        assert!(
531            out.contains("${param.p}"),
532            "param token must survive: {out}"
533        );
534        // round-trips to the same parsed value
535        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        // A JSON-format template body normalizes to YAML too (JSON ⊂ YAML).
543        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    /// A registered template needs a *persistent* store to be visible to a
579    /// second command, so the CLI round-trip test uses a temp SQLite file.
580    /// Without the SQL backend feature the whole test is skipped.
581    #[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        // A plain register is inert: the template is a draft.
606        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        // An unpinned run refuses, naming the launch command.
616        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        // Launching makes it live; then an unpinned run works.
631        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 v2 (a build) — the live version must not move.
656        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 from the channel, then roll back.
674        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        // Derived channels and invented names are refused on promote.
689        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 → revive.
704        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 a single version, then the whole template.
722        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        // Promoting a channel on a template that does not exist is the same
759        // typed error, not a silently-created pointer.
760        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` renders both without panicking.
830        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}