Skip to main content

faucet_cli/serve/history/
templates.rs

1//! Pipeline-template registry records (#444) — the persistent, versioned store
2//! behind `faucet template …`, the `/v1/templates` endpoints, and the MCP
3//! template tools.
4//!
5//! A template is a **config document registered once** plus the typed `params:`
6//! it declares. Thereafter a caller triggers runs by `{id, params}` instead of
7//! re-sending (and re-validating) the whole config. Storage rides the
8//! `RunHistory` backends, like the Data Movement Catalog: an in-memory map for
9//! the default backend, a `faucet_templates` table for the SQL ones, forwarded
10//! by `FallbackHistory`.
11//!
12//! **Nothing secret is persisted.** The body is stored *verbatim* as submitted,
13//! so `${env:…}` / `${vault:…}` remain unresolved tokens that are resolved at
14//! trigger time, on the instance that runs the pipeline — the same privilege
15//! surface as any other submitted config. Caller-supplied `secret: true` param
16//! values are never written here at all: they exist only for the duration of one
17//! trigger.
18
19use crate::error::{CliError, CliResult};
20use crate::params::ParamsSpec;
21use crate::serve::load::ConfigFormat;
22use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24use std::collections::BTreeMap;
25
26/// Maximum length of a template id. Long enough for `team-service-purpose`,
27/// short enough to stay readable in a URL path and a CLI table.
28pub const MAX_ID_LEN: usize = 64;
29
30/// The channel an unpinned request resolves to: the **launched** version.
31pub const DEFAULT_CHANNEL: &str = "stable";
32
33/// Rejected spelling. `latest` is ambiguous in this model — it could mean the
34/// blessed release (`stable`) or the highest build number (`newest`) — so it is
35/// refused rather than silently picking one.
36pub const REJECTED_LATEST: &str = "latest";
37
38/// A **named version channel** — a pointer at one numeric version.
39///
40/// Versions are numeric builds; channels are the human-facing names a build is
41/// promoted *into*, like npm dist-tags. Three are **derived** (computed, never
42/// assignable):
43///
44/// - [`Self::Stable`] — the **launched** version, and what an unpinned request
45///   resolves to. Moved only by an explicit `launch`, so a newly registered
46///   build (a nightly, a feature branch) never drags existing callers with it.
47/// - [`Self::Previous`] — the version launched *before* the current one, for a
48///   one-step rollback. Empty until a second launch has happened.
49/// - [`Self::Newest`] — the highest version number, launched or not. The "run
50///   what I just pushed" selector for development and CI.
51///
52/// The rest are assignable environment pointers moved with `promote`. The set is
53/// deliberately **closed**: an open tag namespace becomes a second, unreviewable
54/// naming system in which a typo (`prd`) silently creates a channel nobody
55/// watches. Free-form labels belong on the *run*, not in the registry.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
57pub enum VersionChannel {
58    /// The launched version — the default for an unpinned request. Derived from
59    /// the launch log; moved by `launch`, never by `promote`.
60    #[default]
61    Stable,
62    /// The previously launched version. Derived; the rollback target.
63    Previous,
64    /// The highest registered version number, launched or not. Derived.
65    Newest,
66    /// Production.
67    Prod,
68    /// Pre-production / release-candidate soak.
69    PreProd,
70    /// Staging.
71    Staging,
72    /// A partial-traffic canary ahead of `prod`.
73    Canary,
74    /// QA / integration testing.
75    Test,
76    /// Day-to-day development.
77    Dev,
78}
79
80impl VersionChannel {
81    /// Every channel: the derived release pointers first, then the assignable
82    /// environments in promotion order (`dev` → … → `prod`). Drives help text,
83    /// error messages, and `--tag` completion.
84    pub const ALL: &'static [Self] = &[
85        Self::Stable,
86        Self::Previous,
87        Self::Newest,
88        Self::Dev,
89        Self::Test,
90        Self::Staging,
91        Self::PreProd,
92        Self::Canary,
93        Self::Prod,
94    ];
95
96    /// The channels a caller may `promote`. Excludes the three derived release
97    /// pointers — `stable` moves via `launch`, and `previous` / `newest` are
98    /// computed.
99    pub const ASSIGNABLE: &'static [Self] = &[
100        Self::Dev,
101        Self::Test,
102        Self::Staging,
103        Self::PreProd,
104        Self::Canary,
105        Self::Prod,
106    ];
107
108    pub fn as_str(self) -> &'static str {
109        match self {
110            Self::Stable => DEFAULT_CHANNEL,
111            Self::Previous => "previous",
112            Self::Newest => "newest",
113            Self::Prod => "prod",
114            Self::PreProd => "pre-prod",
115            Self::Staging => "staging",
116            Self::Canary => "canary",
117            Self::Test => "test",
118            Self::Dev => "dev",
119        }
120    }
121
122    /// Whether this channel is computed rather than assigned. A derived channel
123    /// can never be the target of `promote`.
124    pub fn is_derived(self) -> bool {
125        matches!(self, Self::Stable | Self::Previous | Self::Newest)
126    }
127
128    /// Parse a channel name. Case-insensitive, and `-`/`_` separators are
129    /// interchangeable, so `pre-prod`, `pre_prod`, `PreProd`, and `preprod` all
130    /// name the same channel.
131    ///
132    /// `latest` gets a bespoke error rather than the generic one: it is the most
133    /// likely thing a newcomer types, and both plausible meanings exist under
134    /// other names, so naming them is more useful than listing all nine.
135    pub fn parse(raw: &str) -> CliResult<Self> {
136        let normalized = normalize(raw);
137        if normalized == REJECTED_LATEST {
138            return Err(CliError::Config(format!(
139                "`{REJECTED_LATEST}` is not a version channel here because it is ambiguous. \
140                 Did you mean `{DEFAULT_CHANNEL}` (the launched version — also the default when \
141                 no version is given), or `newest` (the highest version number, launched or not)?"
142            )));
143        }
144        Self::ALL
145            .iter()
146            .copied()
147            .find(|c| normalize(c.as_str()) == normalized)
148            .ok_or_else(|| {
149                CliError::Config(format!(
150                    "unknown template version channel '{raw}' — the named channels are fixed: {}",
151                    Self::ALL
152                        .iter()
153                        .map(|c| c.as_str())
154                        .collect::<Vec<_>>()
155                        .join(", ")
156                ))
157            })
158    }
159}
160
161/// Lowercase and strip separators so every spelling of a channel compares equal.
162fn normalize(raw: &str) -> String {
163    raw.trim()
164        .chars()
165        .filter(char::is_ascii_alphanumeric)
166        .map(|c| c.to_ascii_lowercase())
167        .collect()
168}
169
170impl std::fmt::Display for VersionChannel {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        f.write_str(self.as_str())
173    }
174}
175
176impl Serialize for VersionChannel {
177    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
178        s.serialize_str(self.as_str())
179    }
180}
181
182impl<'de> Deserialize<'de> for VersionChannel {
183    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
184        let raw = String::deserialize(d)?;
185        Self::parse(&raw).map_err(serde::de::Error::custom)
186    }
187}
188
189/// The lifecycle state of a **template** (not of an individual version).
190///
191/// Derived, so it can never disagree with the registry's actual contents: only
192/// the deprecation marker is stored, and `draft` vs `launched` falls out of
193/// whether anything has been launched yet.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub enum TemplateStatus {
197    /// Registered but never launched — the work-in-progress state. An unpinned
198    /// request fails (there is no blessed version); explicit selectors still
199    /// work, so a draft template is fully testable.
200    Draft,
201    /// A version has been launched; `stable` points at it and unpinned requests
202    /// resolve to it.
203    Launched,
204    /// Explicitly retired. Unpinned requests still resolve `stable` — retiring
205    /// must not hard-break existing callers — but they warn, and listings mark
206    /// it. `delete` is the hard stop.
207    Deprecated,
208}
209
210impl TemplateStatus {
211    /// Derive the status from the two facts that determine it.
212    pub fn derive(has_launch: bool, deprecated: bool) -> Self {
213        match (deprecated, has_launch) {
214            (true, _) => Self::Deprecated,
215            (false, true) => Self::Launched,
216            (false, false) => Self::Draft,
217        }
218    }
219
220    pub fn as_str(self) -> &'static str {
221        match self {
222            Self::Draft => "draft",
223            Self::Launched => "launched",
224            Self::Deprecated => "deprecated",
225        }
226    }
227}
228
229impl std::fmt::Display for TemplateStatus {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        f.write_str(self.as_str())
232    }
233}
234
235/// Which version of a template to use: a named [`VersionChannel`] or an exact
236/// number.
237///
238/// Omitting the selector is the same as `stable`, so a caller that never mentions
239/// versions rides the **launched** version — not whatever was registered most
240/// recently. Registering a nightly therefore moves nobody; only an explicit
241/// `launch` does.
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum VersionSelector {
244    /// A named channel — `stable` (the default), another derived pointer, or a
245    /// promoted environment.
246    Channel(VersionChannel),
247    /// An exact version, for a pin or a rollback.
248    Pinned(u32),
249}
250
251impl Default for VersionSelector {
252    fn default() -> Self {
253        Self::stable()
254    }
255}
256
257impl VersionSelector {
258    /// The default selector: the launched version.
259    pub const fn stable() -> Self {
260        Self::Channel(VersionChannel::Stable)
261    }
262
263    /// The highest registered version, launched or not.
264    pub const fn newest() -> Self {
265        Self::Channel(VersionChannel::Newest)
266    }
267
268    /// Parse a channel name or a positive integer. A numeric string is a pin; a
269    /// name must be one of the closed channel set.
270    pub fn parse(raw: &str) -> CliResult<Self> {
271        let s = raw.trim();
272        // Digits are always a pin — never confused with a channel name.
273        if s.chars().all(|c| c.is_ascii_digit()) && !s.is_empty() {
274            return match s.parse::<u32>() {
275                Ok(0) | Err(_) => Err(CliError::Config(format!(
276                    "invalid template version '{raw}' — versions are numbered from 1"
277                ))),
278                Ok(n) => Ok(Self::Pinned(n)),
279            };
280        }
281        VersionChannel::parse(s).map(Self::Channel)
282    }
283
284    /// True when this selector means "the launched version" — i.e. the default.
285    pub fn is_stable(self) -> bool {
286        matches!(self, Self::Channel(VersionChannel::Stable))
287    }
288
289    /// The exact version, when the selector already names one. **Every** channel
290    /// — derived or assigned — needs a registry lookup, so callers must go
291    /// through [`crate::templates::resolve_version`] rather than treating a
292    /// `None` here as "the newest".
293    pub fn pinned(self) -> Option<u32> {
294        match self {
295            Self::Pinned(n) => Some(n),
296            Self::Channel(_) => None,
297        }
298    }
299
300    /// The channel this selector names, if any.
301    pub fn channel(self) -> Option<VersionChannel> {
302        match self {
303            Self::Channel(c) => Some(c),
304            Self::Pinned(_) => None,
305        }
306    }
307}
308
309impl std::fmt::Display for VersionSelector {
310    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
311        match self {
312            Self::Channel(c) => f.write_str(c.as_str()),
313            Self::Pinned(n) => write!(f, "{n}"),
314        }
315    }
316}
317
318// Accepts a JSON/query value in any of the natural spellings — `"latest"`,
319// `"pre-prod"`, `"3"`, or the bare number `3` — so an HTTP query string, a JSON
320// body, and an MCP tool argument all deserialize the same way.
321impl<'de> Deserialize<'de> for VersionSelector {
322    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
323        struct V;
324        impl serde::de::Visitor<'_> for V {
325            type Value = VersionSelector;
326            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327                write!(f, "a named version channel or a version number")
328            }
329            fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<Self::Value, E> {
330                VersionSelector::parse(s).map_err(serde::de::Error::custom)
331            }
332            fn visit_u64<E: serde::de::Error>(self, n: u64) -> Result<Self::Value, E> {
333                VersionSelector::parse(&n.to_string()).map_err(serde::de::Error::custom)
334            }
335            fn visit_i64<E: serde::de::Error>(self, n: i64) -> Result<Self::Value, E> {
336                VersionSelector::parse(&n.to_string()).map_err(serde::de::Error::custom)
337            }
338        }
339        d.deserialize_any(V)
340    }
341}
342
343impl Serialize for VersionSelector {
344    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
345        s.serialize_str(&self.to_string())
346    }
347}
348
349/// A validated template id: lowercase kebab/snake slug, `^[a-z0-9][a-z0-9_-]*$`.
350///
351/// Ids appear in URL paths (`/v1/templates/{id}`) and as CLI arguments, so the
352/// charset is deliberately narrow — no slashes, dots, whitespace, or uppercase,
353/// which rules out path traversal and case-collision surprises across backends.
354#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
355#[serde(try_from = "String", into = "String")]
356pub struct TemplateId(String);
357
358impl TemplateId {
359    pub fn parse(raw: &str) -> CliResult<Self> {
360        let s = raw.trim();
361        if s.is_empty() {
362            return Err(CliError::Config(
363                "template id must not be empty — pass one with `--id`, or give the config a `name:`"
364                    .into(),
365            ));
366        }
367        if s.len() > MAX_ID_LEN {
368            return Err(CliError::Config(format!(
369                "template id '{s}' is longer than {MAX_ID_LEN} characters"
370            )));
371        }
372        let mut chars = s.chars();
373        let ok = match chars.next() {
374            Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {
375                chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
376            }
377            _ => false,
378        };
379        if !ok {
380            return Err(CliError::Config(format!(
381                "invalid template id '{s}' — ids must match ^[a-z0-9][a-z0-9_-]*$ (lowercase \
382                 letters, digits, `-`, `_`; first character alphanumeric)"
383            )));
384        }
385        Ok(Self(s.to_string()))
386    }
387
388    /// Derive an id from a config's `name:` — lowercased, with runs of
389    /// unsupported characters collapsed to `-`. Used when the caller registers
390    /// without an explicit id.
391    pub fn from_config_name(name: &str) -> CliResult<Self> {
392        let mut slug = String::with_capacity(name.len());
393        for c in name.chars() {
394            if c.is_ascii_alphanumeric() {
395                slug.push(c.to_ascii_lowercase());
396            } else if !slug.ends_with('-') {
397                slug.push('-');
398            }
399        }
400        let trimmed = slug.trim_matches('-');
401        let capped: String = trimmed.chars().take(MAX_ID_LEN).collect();
402        Self::parse(capped.trim_matches('-'))
403    }
404
405    pub fn as_str(&self) -> &str {
406        &self.0
407    }
408}
409
410impl std::fmt::Display for TemplateId {
411    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        f.write_str(&self.0)
413    }
414}
415
416impl TryFrom<String> for TemplateId {
417    type Error = String;
418    fn try_from(value: String) -> Result<Self, Self::Error> {
419        Self::parse(&value).map_err(|e| e.to_string())
420    }
421}
422
423impl From<TemplateId> for String {
424    fn from(value: TemplateId) -> Self {
425        value.0
426    }
427}
428
429/// One registered version of a template.
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct TemplateRecord {
432    /// Stable registry id (a slug — see [`TemplateId`]).
433    pub id: String,
434    /// Monotonic version, starting at 1. `register` always appends a new one.
435    pub version: u32,
436    /// The config's own `name:`, if it has one (informational).
437    #[serde(default, skip_serializing_if = "Option::is_none")]
438    pub name: Option<String>,
439    /// Free-text description supplied at registration.
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub description: Option<String>,
442    /// The config document, stored **verbatim** (unresolved directives intact).
443    pub body: String,
444    /// Wire format of `body`, so the trigger path parses it the same way.
445    pub format: ConfigFormat,
446    /// The declared `params:` block, extracted at registration so callers can
447    /// discover the trigger surface without parsing the body.
448    #[serde(default)]
449    pub params: ParamsSpec,
450    pub created_at: DateTime<Utc>,
451    /// Principal that registered this version (`None` for CLI registration).
452    #[serde(default, skip_serializing_if = "Option::is_none")]
453    pub created_by: Option<String>,
454}
455
456impl TemplateRecord {
457    /// The summary view — everything except the (potentially large) body, with no
458    /// release state attached. Use [`Self::summary_with`] where the state is known.
459    pub fn summary(&self) -> TemplateSummary {
460        TemplateSummary {
461            id: self.id.clone(),
462            version: self.version,
463            name: self.name.clone(),
464            description: self.description.clone(),
465            params: self.params.clone(),
466            created_at: self.created_at,
467            created_by: self.created_by.clone(),
468            state: None,
469        }
470    }
471
472    /// The summary view carrying the template's release state.
473    pub fn summary_with(&self, state: TemplateState) -> TemplateSummary {
474        TemplateSummary {
475            state: Some(state),
476            ..self.summary()
477        }
478    }
479}
480
481/// Body-free view of a template version, used by list endpoints.
482#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct TemplateSummary {
484    pub id: String,
485    /// The newest registered version (the build tip). Present for continuity with
486    /// the per-version record; `state.stable` is what an unpinned run uses.
487    pub version: u32,
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub name: Option<String>,
490    #[serde(default, skip_serializing_if = "Option::is_none")]
491    pub description: Option<String>,
492    #[serde(default)]
493    pub params: ParamsSpec,
494    pub created_at: DateTime<Utc>,
495    #[serde(default, skip_serializing_if = "Option::is_none")]
496    pub created_by: Option<String>,
497    /// Release state of the template as a whole (status, `stable` / `previous` /
498    /// `newest`, channel pointers). Populated by the read paths; `None` on a
499    /// record that was built without it.
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub state: Option<TemplateState>,
502}
503
504/// One entry in a template's append-only **launch log**.
505///
506/// The log is the single source of truth for the release pointers: `stable` is
507/// the newest entry's version and `previous` is the one before it. Because it is
508/// append-only it doubles as the launch/rollback audit trail — who blessed which
509/// build, and when.
510#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct LaunchRecord {
512    /// Monotonic per-template sequence, from 1.
513    pub seq: u32,
514    /// The version that was launched.
515    pub version: u32,
516    pub launched_at: DateTime<Utc>,
517    /// Principal that launched it (`None` for a CLI launch).
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub launched_by: Option<String>,
520}
521
522/// Why and when a template was retired. Stored only while deprecated; clearing it
523/// is the `--undo`.
524#[derive(Debug, Clone, Serialize, Deserialize)]
525pub struct DeprecationRecord {
526    pub deprecated_at: DateTime<Utc>,
527    #[serde(default, skip_serializing_if = "Option::is_none")]
528    pub deprecated_by: Option<String>,
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub reason: Option<String>,
531}
532
533/// Everything a surface needs to describe a template's release state, read in one
534/// go so the CLI, HTTP handlers, MCP tools, and UI can never assemble it
535/// inconsistently from separate calls.
536#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct TemplateState {
538    /// Derived lifecycle status of the **template**.
539    pub status: TemplateStatus,
540    /// Every stored version, newest first.
541    pub versions: Vec<u32>,
542    /// The launched version — what `stable` and an unpinned request resolve to.
543    /// `None` while the template is a draft.
544    pub stable: Option<u32>,
545    /// The version launched before the current one; the rollback target.
546    pub previous: Option<u32>,
547    /// Highest version number, launched or not.
548    pub newest: Option<u32>,
549    /// Assignable channel pointers (`{channel: version}`), excluding the derived
550    /// ones.
551    pub tags: BTreeMap<String, u32>,
552    /// Present only when `status` is `deprecated`.
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub deprecation: Option<DeprecationRecord>,
555}
556
557impl TemplateState {
558    /// Assemble the state from the raw facts. Pure, so the memory and SQL
559    /// backends cannot disagree about what a set of rows means.
560    pub fn assemble(
561        versions: Vec<u32>,
562        launches: &[LaunchRecord],
563        tags: BTreeMap<String, u32>,
564        deprecation: Option<DeprecationRecord>,
565    ) -> Self {
566        let stable = stable_version(launches);
567        Self {
568            status: TemplateStatus::derive(stable.is_some(), deprecation.is_some()),
569            newest: versions.first().copied(),
570            stable,
571            previous: previous_version(launches),
572            versions,
573            tags,
574            deprecation,
575        }
576    }
577
578    /// Resolve a derived channel against this state.
579    pub fn derived(&self, channel: VersionChannel) -> Option<u32> {
580        match channel {
581            VersionChannel::Stable => self.stable,
582            VersionChannel::Previous => self.previous,
583            VersionChannel::Newest => self.newest,
584            other => self.tags.get(other.as_str()).copied(),
585        }
586    }
587}
588
589/// The launched version: the newest launch-log entry. `launches` must be ordered
590/// newest-first (highest `seq` at index 0).
591pub fn stable_version(launches: &[LaunchRecord]) -> Option<u32> {
592    launches.first().map(|l| l.version)
593}
594
595/// The version launched *before* the current one — the rollback target.
596///
597/// Relies on the store never appending a launch for the version that is already
598/// stable (a re-launch is a no-op), so entry 1 is genuinely the prior release
599/// rather than a duplicate of the current one. Defensively skips any leading
600/// duplicates anyway, so a hand-edited log can't make `previous == stable`.
601pub fn previous_version(launches: &[LaunchRecord]) -> Option<u32> {
602    let current = stable_version(launches)?;
603    launches.iter().map(|l| l.version).find(|v| *v != current)
604}
605
606/// A registration request, after validation. `version` is assigned by the store.
607#[derive(Debug, Clone)]
608pub struct TemplateDraft {
609    pub id: TemplateId,
610    pub name: Option<String>,
611    pub description: Option<String>,
612    pub body: String,
613    pub format: ConfigFormat,
614    pub params: ParamsSpec,
615    pub created_by: Option<String>,
616}
617
618/// How many versions of one template the store keeps. Older versions are pruned
619/// on register, so a template re-registered on every deploy can't grow the table
620/// without bound while the recent history (for pinning / rollback) stays.
621pub const VERSION_RETAIN: usize = 20;
622
623/// Reduce a full version list to the latest version per id, preserving the
624/// caller's ordering intent (newest-created first). Shared by the memory and SQL
625/// backends so `template_list` can never disagree between them.
626pub fn latest_per_id(mut records: Vec<TemplateRecord>) -> Vec<TemplateSummary> {
627    // Highest version wins per id; ties are impossible (version is unique).
628    records.sort_by(|a, b| a.id.cmp(&b.id).then(b.version.cmp(&a.version)));
629    records.dedup_by(|a, b| a.id == b.id);
630    let mut out: Vec<TemplateSummary> = records.iter().map(TemplateRecord::summary).collect();
631    out.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(a.id.cmp(&b.id)));
632    out
633}
634
635/// The version numbers to delete so at most [`VERSION_RETAIN`] remain for an id.
636pub fn versions_to_prune(mut versions: Vec<u32>) -> Vec<u32> {
637    if versions.len() <= VERSION_RETAIN {
638        return Vec::new();
639    }
640    versions.sort_unstable_by(|a, b| b.cmp(a)); // newest first
641    versions.split_off(VERSION_RETAIN)
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    fn rec(id: &str, version: u32, secs: i64) -> TemplateRecord {
649        TemplateRecord {
650            id: id.into(),
651            version,
652            name: None,
653            description: None,
654            body: "version: 1".into(),
655            format: ConfigFormat::Yaml,
656            params: ParamsSpec::new(),
657            created_at: DateTime::from_timestamp(secs, 0).unwrap(),
658            created_by: None,
659        }
660    }
661
662    #[test]
663    fn latest_per_id_keeps_highest_version_newest_first() {
664        let out = latest_per_id(vec![
665            rec("a", 1, 10),
666            rec("a", 3, 30),
667            rec("a", 2, 20),
668            rec("b", 1, 40),
669        ]);
670        assert_eq!(out.len(), 2);
671        // `b` was created most recently, so it leads.
672        assert_eq!(out[0].id, "b");
673        assert_eq!(out[1].id, "a");
674        assert_eq!(out[1].version, 3);
675    }
676
677    #[test]
678    fn latest_per_id_handles_empty() {
679        assert!(latest_per_id(Vec::new()).is_empty());
680    }
681
682    #[test]
683    fn summary_drops_the_body() {
684        let s = rec("a", 1, 1).summary();
685        let v = serde_json::to_value(&s).unwrap();
686        assert!(v.get("body").is_none());
687        assert_eq!(v["version"], 1);
688    }
689
690    #[test]
691    fn prunes_only_beyond_the_retain_window() {
692        assert!(versions_to_prune((1..=VERSION_RETAIN as u32).collect()).is_empty());
693        let prune = versions_to_prune((1..=(VERSION_RETAIN as u32 + 3)).collect());
694        // The three oldest go.
695        assert_eq!(prune, vec![3, 2, 1]);
696        assert!(versions_to_prune(vec![]).is_empty());
697    }
698
699    #[test]
700    fn channels_are_a_closed_set_with_forgiving_spellings() {
701        // Every spelling of the same channel normalizes identically.
702        for raw in ["pre-prod", "pre_prod", "PreProd", "PRE-PROD", "  preprod "] {
703            assert_eq!(VersionChannel::parse(raw).unwrap(), VersionChannel::PreProd);
704        }
705        for (raw, want) in [
706            ("stable", VersionChannel::Stable),
707            ("previous", VersionChannel::Previous),
708            ("newest", VersionChannel::Newest),
709            ("prod", VersionChannel::Prod),
710            ("staging", VersionChannel::Staging),
711            ("canary", VersionChannel::Canary),
712            ("test", VersionChannel::Test),
713            ("dev", VersionChannel::Dev),
714        ] {
715            assert_eq!(VersionChannel::parse(raw).unwrap(), want);
716            assert_eq!(want.as_str(), raw);
717        }
718        // The point of the closed set: an invented or mistyped channel is
719        // rejected, and the error lists the valid ones.
720        for bad in ["", "prd", "production", "my-channel", "v2", "PROD1"] {
721            let err = VersionChannel::parse(bad).unwrap_err().to_string();
722            assert!(err.contains("fixed:"), "{bad:?}: {err}");
723            assert!(err.contains("pre-prod"), "{bad:?}: {err}");
724        }
725    }
726
727    #[test]
728    fn latest_is_rejected_by_name_with_both_alternatives() {
729        // `latest` is the most likely thing a newcomer types and both meanings
730        // exist under other names, so it gets a bespoke error rather than being
731        // silently resolved to one of them.
732        for raw in ["latest", "LATEST", " Latest "] {
733            let err = VersionChannel::parse(raw).unwrap_err().to_string();
734            assert!(err.contains("ambiguous"), "{raw:?}: {err}");
735            assert!(err.contains("stable"), "{raw:?}: {err}");
736            assert!(err.contains("newest"), "{raw:?}: {err}");
737        }
738        assert!(VersionSelector::parse("latest").is_err());
739    }
740
741    #[test]
742    fn derived_channels_are_not_assignable() {
743        // `stable` moves via `launch`; `previous` / `newest` are computed. None
744        // of the three may be a `promote` target.
745        for c in [
746            VersionChannel::Stable,
747            VersionChannel::Previous,
748            VersionChannel::Newest,
749        ] {
750            assert!(c.is_derived(), "{c} must be derived");
751            assert!(!VersionChannel::ASSIGNABLE.contains(&c), "{c}");
752        }
753        for c in VersionChannel::ASSIGNABLE {
754            assert!(!c.is_derived(), "{c} must be assignable");
755        }
756        assert_eq!(
757            VersionChannel::ALL.len(),
758            VersionChannel::ASSIGNABLE.len() + 3,
759            "ALL is ASSIGNABLE plus the three derived release pointers"
760        );
761        // The default channel is `stable` — an unpinned request rides the
762        // launched version, not the newest build.
763        assert_eq!(VersionChannel::default(), VersionChannel::Stable);
764        assert_eq!(VersionChannel::default().as_str(), DEFAULT_CHANNEL);
765    }
766
767    #[test]
768    fn channel_serde_round_trips_by_name() {
769        use serde_json::json;
770        assert_eq!(
771            serde_json::to_value(VersionChannel::PreProd).unwrap(),
772            json!("pre-prod")
773        );
774        assert_eq!(
775            serde_json::from_value::<VersionChannel>(json!("pre_prod")).unwrap(),
776            VersionChannel::PreProd
777        );
778        assert!(serde_json::from_value::<VersionChannel>(json!("nope")).is_err());
779        assert!(serde_json::from_value::<VersionChannel>(json!("latest")).is_err());
780        assert!(serde_json::from_value::<VersionChannel>(json!(2)).is_err());
781    }
782
783    #[test]
784    fn selector_distinguishes_channels_from_pins() {
785        assert_eq!(
786            VersionSelector::parse("prod").unwrap(),
787            VersionSelector::Channel(VersionChannel::Prod)
788        );
789        assert_eq!(
790            VersionSelector::parse("prod").unwrap().channel(),
791            Some(VersionChannel::Prod)
792        );
793        // Every channel needs a registry lookup — `pinned()` is None for all of
794        // them, including the derived ones.
795        for c in VersionChannel::ALL {
796            assert!(VersionSelector::Channel(*c).pinned().is_none(), "{c}");
797        }
798        assert!(VersionSelector::parse("stable").unwrap().is_stable());
799        assert!(VersionSelector::default().is_stable());
800        assert!(!VersionSelector::parse("newest").unwrap().is_stable());
801        assert_eq!(VersionSelector::parse("4").unwrap().pinned(), Some(4));
802        assert!(VersionSelector::parse("4").unwrap().channel().is_none());
803        assert_eq!(
804            VersionSelector::Channel(VersionChannel::Dev).to_string(),
805            "dev"
806        );
807        // A channel-shaped typo is rejected, not silently treated as a pin.
808        assert!(VersionSelector::parse("prd").is_err());
809    }
810
811    #[test]
812    fn version_selector_parses_channels_and_numbers() {
813        assert_eq!(
814            VersionSelector::parse("stable").unwrap(),
815            VersionSelector::stable()
816        );
817        assert_eq!(
818            VersionSelector::parse("newest").unwrap(),
819            VersionSelector::newest()
820        );
821        assert_eq!(
822            VersionSelector::parse("3").unwrap(),
823            VersionSelector::Pinned(3)
824        );
825        // A version is 1-based; 0, negatives, and junk are all rejected.
826        for bad in ["0", "-1", "", "v2", "1.5"] {
827            assert!(
828                VersionSelector::parse(bad).is_err(),
829                "{bad:?} should be rejected"
830            );
831        }
832    }
833
834    #[test]
835    fn version_selector_maps_to_a_lookup_and_back() {
836        assert_eq!(VersionSelector::stable().pinned(), None);
837        assert_eq!(VersionSelector::Pinned(7).pinned(), Some(7));
838        assert_eq!(VersionSelector::default(), VersionSelector::stable());
839        assert_eq!(VersionSelector::stable().to_string(), "stable");
840        assert_eq!(VersionSelector::newest().to_string(), "newest");
841        assert_eq!(VersionSelector::Pinned(2).to_string(), "2");
842    }
843
844    #[test]
845    fn version_selector_serde_accepts_every_wire_spelling() {
846        use serde_json::json;
847        for wire in [json!("stable"), json!("STABLE")] {
848            assert_eq!(
849                serde_json::from_value::<VersionSelector>(wire).unwrap(),
850                VersionSelector::stable()
851            );
852        }
853        // A string (query string / CLI) and a bare number (JSON body) agree.
854        assert_eq!(
855            serde_json::from_value::<VersionSelector>(json!("4")).unwrap(),
856            VersionSelector::Pinned(4)
857        );
858        assert_eq!(
859            serde_json::from_value::<VersionSelector>(json!(4)).unwrap(),
860            VersionSelector::Pinned(4)
861        );
862        assert!(serde_json::from_value::<VersionSelector>(json!(0)).is_err());
863        assert!(serde_json::from_value::<VersionSelector>(json!("nope")).is_err());
864        assert!(serde_json::from_value::<VersionSelector>(json!(true)).is_err());
865        // Round-trips as the channel name / the number.
866        assert_eq!(
867            serde_json::to_value(VersionSelector::stable()).unwrap(),
868            json!("stable")
869        );
870        assert_eq!(
871            serde_json::to_value(VersionSelector::Pinned(9)).unwrap(),
872            json!("9")
873        );
874    }
875
876    #[test]
877    fn template_status_is_derived_from_launch_and_deprecation() {
878        use TemplateStatus::*;
879        // Only two facts determine it, so the status can never disagree with the
880        // registry's contents.
881        assert_eq!(TemplateStatus::derive(false, false), Draft);
882        assert_eq!(TemplateStatus::derive(true, false), Launched);
883        assert_eq!(TemplateStatus::derive(false, true), Deprecated);
884        // Deprecation wins over having a launched version.
885        assert_eq!(TemplateStatus::derive(true, true), Deprecated);
886        assert_eq!(Draft.as_str(), "draft");
887        assert_eq!(Launched.to_string(), "launched");
888        assert_eq!(
889            serde_json::to_value(Deprecated).unwrap(),
890            serde_json::json!("deprecated")
891        );
892    }
893
894    #[test]
895    fn id_parsing_accepts_slugs_and_rejects_the_rest() {
896        for good in ["a", "tenant-sync", "t1_2", "9lives"] {
897            assert_eq!(TemplateId::parse(good).unwrap().as_str(), good);
898        }
899        for bad in [
900            "",
901            "  ",
902            "-lead",
903            "_lead",
904            "Upper",
905            "has space",
906            "has/slash",
907            "has.dot",
908            "../etc/passwd",
909        ] {
910            assert!(
911                TemplateId::parse(bad).is_err(),
912                "{bad:?} should be rejected"
913            );
914        }
915        // Trimmed, and length-capped.
916        assert_eq!(TemplateId::parse("  ok  ").unwrap().as_str(), "ok");
917        assert!(TemplateId::parse(&"a".repeat(MAX_ID_LEN + 1)).is_err());
918        assert!(TemplateId::parse(&"a".repeat(MAX_ID_LEN)).is_ok());
919    }
920
921    #[test]
922    fn id_derives_from_a_config_name() {
923        assert_eq!(
924            TemplateId::from_config_name("Tenant Sync (prod)")
925                .unwrap()
926                .as_str(),
927            "tenant-sync-prod"
928        );
929        assert_eq!(
930            TemplateId::from_config_name("already-fine")
931                .unwrap()
932                .as_str(),
933            "already-fine"
934        );
935        // Nothing usable → a clear error rather than an empty id.
936        assert!(TemplateId::from_config_name("!!!").is_err());
937        assert!(TemplateId::from_config_name("").is_err());
938        // Over-long names are capped without leaving a trailing separator.
939        let long = TemplateId::from_config_name(&format!("{} x", "a".repeat(MAX_ID_LEN))).unwrap();
940        assert_eq!(long.as_str().len(), MAX_ID_LEN);
941    }
942
943    #[test]
944    fn id_serde_round_trips_and_rejects_bad_values() {
945        let id = TemplateId::parse("ok-id").unwrap();
946        assert_eq!(
947            serde_json::to_value(&id).unwrap(),
948            serde_json::json!("ok-id")
949        );
950        let back: TemplateId = serde_json::from_value(serde_json::json!("ok-id")).unwrap();
951        assert_eq!(back, id);
952        assert!(serde_json::from_value::<TemplateId>(serde_json::json!("Bad Id")).is_err());
953        assert_eq!(id.to_string(), "ok-id");
954        assert_eq!(String::from(id), "ok-id");
955    }
956
957    #[test]
958    fn record_round_trips_through_json() {
959        let mut r = rec("a", 2, 5);
960        r.params
961            .insert("t".into(), crate::params::ParamSpec::string_default("v"));
962        let text = serde_json::to_string(&r).unwrap();
963        let back: TemplateRecord = serde_json::from_str(&text).unwrap();
964        assert_eq!(back.id, "a");
965        assert_eq!(back.version, 2);
966        assert_eq!(back.params["t"].default, Some(serde_json::json!("v")));
967    }
968}