use crate::error::{CliError, CliResult};
use crate::params::ParamsSpec;
use crate::serve::load::ConfigFormat;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
pub const MAX_ID_LEN: usize = 64;
pub const DEFAULT_CHANNEL: &str = "stable";
pub const REJECTED_LATEST: &str = "latest";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum VersionChannel {
#[default]
Stable,
Previous,
Newest,
Prod,
PreProd,
Staging,
Canary,
Test,
Dev,
}
impl VersionChannel {
pub const ALL: &'static [Self] = &[
Self::Stable,
Self::Previous,
Self::Newest,
Self::Dev,
Self::Test,
Self::Staging,
Self::PreProd,
Self::Canary,
Self::Prod,
];
pub const ASSIGNABLE: &'static [Self] = &[
Self::Dev,
Self::Test,
Self::Staging,
Self::PreProd,
Self::Canary,
Self::Prod,
];
pub fn as_str(self) -> &'static str {
match self {
Self::Stable => DEFAULT_CHANNEL,
Self::Previous => "previous",
Self::Newest => "newest",
Self::Prod => "prod",
Self::PreProd => "pre-prod",
Self::Staging => "staging",
Self::Canary => "canary",
Self::Test => "test",
Self::Dev => "dev",
}
}
pub fn is_derived(self) -> bool {
matches!(self, Self::Stable | Self::Previous | Self::Newest)
}
pub fn parse(raw: &str) -> CliResult<Self> {
let normalized = normalize(raw);
if normalized == REJECTED_LATEST {
return Err(CliError::Config(format!(
"`{REJECTED_LATEST}` is not a version channel here because it is ambiguous. \
Did you mean `{DEFAULT_CHANNEL}` (the launched version — also the default when \
no version is given), or `newest` (the highest version number, launched or not)?"
)));
}
Self::ALL
.iter()
.copied()
.find(|c| normalize(c.as_str()) == normalized)
.ok_or_else(|| {
CliError::Config(format!(
"unknown template version channel '{raw}' — the named channels are fixed: {}",
Self::ALL
.iter()
.map(|c| c.as_str())
.collect::<Vec<_>>()
.join(", ")
))
})
}
}
fn normalize(raw: &str) -> String {
raw.trim()
.chars()
.filter(char::is_ascii_alphanumeric)
.map(|c| c.to_ascii_lowercase())
.collect()
}
impl std::fmt::Display for VersionChannel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for VersionChannel {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for VersionChannel {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw = String::deserialize(d)?;
Self::parse(&raw).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TemplateStatus {
Draft,
Launched,
Deprecated,
}
impl TemplateStatus {
pub fn derive(has_launch: bool, deprecated: bool) -> Self {
match (deprecated, has_launch) {
(true, _) => Self::Deprecated,
(false, true) => Self::Launched,
(false, false) => Self::Draft,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Launched => "launched",
Self::Deprecated => "deprecated",
}
}
}
impl std::fmt::Display for TemplateStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VersionSelector {
Channel(VersionChannel),
Pinned(u32),
}
impl Default for VersionSelector {
fn default() -> Self {
Self::stable()
}
}
impl VersionSelector {
pub const fn stable() -> Self {
Self::Channel(VersionChannel::Stable)
}
pub const fn newest() -> Self {
Self::Channel(VersionChannel::Newest)
}
pub fn parse(raw: &str) -> CliResult<Self> {
let s = raw.trim();
if s.chars().all(|c| c.is_ascii_digit()) && !s.is_empty() {
return match s.parse::<u32>() {
Ok(0) | Err(_) => Err(CliError::Config(format!(
"invalid template version '{raw}' — versions are numbered from 1"
))),
Ok(n) => Ok(Self::Pinned(n)),
};
}
VersionChannel::parse(s).map(Self::Channel)
}
pub fn is_stable(self) -> bool {
matches!(self, Self::Channel(VersionChannel::Stable))
}
pub fn pinned(self) -> Option<u32> {
match self {
Self::Pinned(n) => Some(n),
Self::Channel(_) => None,
}
}
pub fn channel(self) -> Option<VersionChannel> {
match self {
Self::Channel(c) => Some(c),
Self::Pinned(_) => None,
}
}
}
impl std::fmt::Display for VersionSelector {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Channel(c) => f.write_str(c.as_str()),
Self::Pinned(n) => write!(f, "{n}"),
}
}
}
impl<'de> Deserialize<'de> for VersionSelector {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct V;
impl serde::de::Visitor<'_> for V {
type Value = VersionSelector;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "a named version channel or a version number")
}
fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<Self::Value, E> {
VersionSelector::parse(s).map_err(serde::de::Error::custom)
}
fn visit_u64<E: serde::de::Error>(self, n: u64) -> Result<Self::Value, E> {
VersionSelector::parse(&n.to_string()).map_err(serde::de::Error::custom)
}
fn visit_i64<E: serde::de::Error>(self, n: i64) -> Result<Self::Value, E> {
VersionSelector::parse(&n.to_string()).map_err(serde::de::Error::custom)
}
}
d.deserialize_any(V)
}
}
impl Serialize for VersionSelector {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct TemplateId(String);
impl TemplateId {
pub fn parse(raw: &str) -> CliResult<Self> {
let s = raw.trim();
if s.is_empty() {
return Err(CliError::Config(
"template id must not be empty — pass one with `--id`, or give the config a `name:`"
.into(),
));
}
if s.len() > MAX_ID_LEN {
return Err(CliError::Config(format!(
"template id '{s}' is longer than {MAX_ID_LEN} characters"
)));
}
let mut chars = s.chars();
let ok = match chars.next() {
Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit() => {
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
_ => false,
};
if !ok {
return Err(CliError::Config(format!(
"invalid template id '{s}' — ids must match ^[a-z0-9][a-z0-9_-]*$ (lowercase \
letters, digits, `-`, `_`; first character alphanumeric)"
)));
}
Ok(Self(s.to_string()))
}
pub fn from_config_name(name: &str) -> CliResult<Self> {
let mut slug = String::with_capacity(name.len());
for c in name.chars() {
if c.is_ascii_alphanumeric() {
slug.push(c.to_ascii_lowercase());
} else if !slug.ends_with('-') {
slug.push('-');
}
}
let trimmed = slug.trim_matches('-');
let capped: String = trimmed.chars().take(MAX_ID_LEN).collect();
Self::parse(capped.trim_matches('-'))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for TemplateId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for TemplateId {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::parse(&value).map_err(|e| e.to_string())
}
}
impl From<TemplateId> for String {
fn from(value: TemplateId) -> Self {
value.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateRecord {
pub id: String,
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub body: String,
pub format: ConfigFormat,
#[serde(default)]
pub params: ParamsSpec,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
}
impl TemplateRecord {
pub fn summary(&self) -> TemplateSummary {
TemplateSummary {
id: self.id.clone(),
version: self.version,
name: self.name.clone(),
description: self.description.clone(),
params: self.params.clone(),
created_at: self.created_at,
created_by: self.created_by.clone(),
state: None,
}
}
pub fn summary_with(&self, state: TemplateState) -> TemplateSummary {
TemplateSummary {
state: Some(state),
..self.summary()
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateSummary {
pub id: String,
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub params: ParamsSpec,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<TemplateState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LaunchRecord {
pub seq: u32,
pub version: u32,
pub launched_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub launched_by: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeprecationRecord {
pub deprecated_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deprecated_by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateState {
pub status: TemplateStatus,
pub versions: Vec<u32>,
pub stable: Option<u32>,
pub previous: Option<u32>,
pub newest: Option<u32>,
pub tags: BTreeMap<String, u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deprecation: Option<DeprecationRecord>,
}
impl TemplateState {
pub fn assemble(
versions: Vec<u32>,
launches: &[LaunchRecord],
tags: BTreeMap<String, u32>,
deprecation: Option<DeprecationRecord>,
) -> Self {
let stable = stable_version(launches);
Self {
status: TemplateStatus::derive(stable.is_some(), deprecation.is_some()),
newest: versions.first().copied(),
stable,
previous: previous_version(launches),
versions,
tags,
deprecation,
}
}
pub fn derived(&self, channel: VersionChannel) -> Option<u32> {
match channel {
VersionChannel::Stable => self.stable,
VersionChannel::Previous => self.previous,
VersionChannel::Newest => self.newest,
other => self.tags.get(other.as_str()).copied(),
}
}
}
pub fn stable_version(launches: &[LaunchRecord]) -> Option<u32> {
launches.first().map(|l| l.version)
}
pub fn previous_version(launches: &[LaunchRecord]) -> Option<u32> {
let current = stable_version(launches)?;
launches.iter().map(|l| l.version).find(|v| *v != current)
}
#[derive(Debug, Clone)]
pub struct TemplateDraft {
pub id: TemplateId,
pub name: Option<String>,
pub description: Option<String>,
pub body: String,
pub format: ConfigFormat,
pub params: ParamsSpec,
pub created_by: Option<String>,
}
pub const VERSION_RETAIN: usize = 20;
pub fn latest_per_id(mut records: Vec<TemplateRecord>) -> Vec<TemplateSummary> {
records.sort_by(|a, b| a.id.cmp(&b.id).then(b.version.cmp(&a.version)));
records.dedup_by(|a, b| a.id == b.id);
let mut out: Vec<TemplateSummary> = records.iter().map(TemplateRecord::summary).collect();
out.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(a.id.cmp(&b.id)));
out
}
pub fn versions_to_prune(mut versions: Vec<u32>) -> Vec<u32> {
if versions.len() <= VERSION_RETAIN {
return Vec::new();
}
versions.sort_unstable_by(|a, b| b.cmp(a)); versions.split_off(VERSION_RETAIN)
}
#[cfg(test)]
mod tests {
use super::*;
fn rec(id: &str, version: u32, secs: i64) -> TemplateRecord {
TemplateRecord {
id: id.into(),
version,
name: None,
description: None,
body: "version: 1".into(),
format: ConfigFormat::Yaml,
params: ParamsSpec::new(),
created_at: DateTime::from_timestamp(secs, 0).unwrap(),
created_by: None,
}
}
#[test]
fn latest_per_id_keeps_highest_version_newest_first() {
let out = latest_per_id(vec![
rec("a", 1, 10),
rec("a", 3, 30),
rec("a", 2, 20),
rec("b", 1, 40),
]);
assert_eq!(out.len(), 2);
assert_eq!(out[0].id, "b");
assert_eq!(out[1].id, "a");
assert_eq!(out[1].version, 3);
}
#[test]
fn latest_per_id_handles_empty() {
assert!(latest_per_id(Vec::new()).is_empty());
}
#[test]
fn summary_drops_the_body() {
let s = rec("a", 1, 1).summary();
let v = serde_json::to_value(&s).unwrap();
assert!(v.get("body").is_none());
assert_eq!(v["version"], 1);
}
#[test]
fn prunes_only_beyond_the_retain_window() {
assert!(versions_to_prune((1..=VERSION_RETAIN as u32).collect()).is_empty());
let prune = versions_to_prune((1..=(VERSION_RETAIN as u32 + 3)).collect());
assert_eq!(prune, vec![3, 2, 1]);
assert!(versions_to_prune(vec![]).is_empty());
}
#[test]
fn channels_are_a_closed_set_with_forgiving_spellings() {
for raw in ["pre-prod", "pre_prod", "PreProd", "PRE-PROD", " preprod "] {
assert_eq!(VersionChannel::parse(raw).unwrap(), VersionChannel::PreProd);
}
for (raw, want) in [
("stable", VersionChannel::Stable),
("previous", VersionChannel::Previous),
("newest", VersionChannel::Newest),
("prod", VersionChannel::Prod),
("staging", VersionChannel::Staging),
("canary", VersionChannel::Canary),
("test", VersionChannel::Test),
("dev", VersionChannel::Dev),
] {
assert_eq!(VersionChannel::parse(raw).unwrap(), want);
assert_eq!(want.as_str(), raw);
}
for bad in ["", "prd", "production", "my-channel", "v2", "PROD1"] {
let err = VersionChannel::parse(bad).unwrap_err().to_string();
assert!(err.contains("fixed:"), "{bad:?}: {err}");
assert!(err.contains("pre-prod"), "{bad:?}: {err}");
}
}
#[test]
fn latest_is_rejected_by_name_with_both_alternatives() {
for raw in ["latest", "LATEST", " Latest "] {
let err = VersionChannel::parse(raw).unwrap_err().to_string();
assert!(err.contains("ambiguous"), "{raw:?}: {err}");
assert!(err.contains("stable"), "{raw:?}: {err}");
assert!(err.contains("newest"), "{raw:?}: {err}");
}
assert!(VersionSelector::parse("latest").is_err());
}
#[test]
fn derived_channels_are_not_assignable() {
for c in [
VersionChannel::Stable,
VersionChannel::Previous,
VersionChannel::Newest,
] {
assert!(c.is_derived(), "{c} must be derived");
assert!(!VersionChannel::ASSIGNABLE.contains(&c), "{c}");
}
for c in VersionChannel::ASSIGNABLE {
assert!(!c.is_derived(), "{c} must be assignable");
}
assert_eq!(
VersionChannel::ALL.len(),
VersionChannel::ASSIGNABLE.len() + 3,
"ALL is ASSIGNABLE plus the three derived release pointers"
);
assert_eq!(VersionChannel::default(), VersionChannel::Stable);
assert_eq!(VersionChannel::default().as_str(), DEFAULT_CHANNEL);
}
#[test]
fn channel_serde_round_trips_by_name() {
use serde_json::json;
assert_eq!(
serde_json::to_value(VersionChannel::PreProd).unwrap(),
json!("pre-prod")
);
assert_eq!(
serde_json::from_value::<VersionChannel>(json!("pre_prod")).unwrap(),
VersionChannel::PreProd
);
assert!(serde_json::from_value::<VersionChannel>(json!("nope")).is_err());
assert!(serde_json::from_value::<VersionChannel>(json!("latest")).is_err());
assert!(serde_json::from_value::<VersionChannel>(json!(2)).is_err());
}
#[test]
fn selector_distinguishes_channels_from_pins() {
assert_eq!(
VersionSelector::parse("prod").unwrap(),
VersionSelector::Channel(VersionChannel::Prod)
);
assert_eq!(
VersionSelector::parse("prod").unwrap().channel(),
Some(VersionChannel::Prod)
);
for c in VersionChannel::ALL {
assert!(VersionSelector::Channel(*c).pinned().is_none(), "{c}");
}
assert!(VersionSelector::parse("stable").unwrap().is_stable());
assert!(VersionSelector::default().is_stable());
assert!(!VersionSelector::parse("newest").unwrap().is_stable());
assert_eq!(VersionSelector::parse("4").unwrap().pinned(), Some(4));
assert!(VersionSelector::parse("4").unwrap().channel().is_none());
assert_eq!(
VersionSelector::Channel(VersionChannel::Dev).to_string(),
"dev"
);
assert!(VersionSelector::parse("prd").is_err());
}
#[test]
fn version_selector_parses_channels_and_numbers() {
assert_eq!(
VersionSelector::parse("stable").unwrap(),
VersionSelector::stable()
);
assert_eq!(
VersionSelector::parse("newest").unwrap(),
VersionSelector::newest()
);
assert_eq!(
VersionSelector::parse("3").unwrap(),
VersionSelector::Pinned(3)
);
for bad in ["0", "-1", "", "v2", "1.5"] {
assert!(
VersionSelector::parse(bad).is_err(),
"{bad:?} should be rejected"
);
}
}
#[test]
fn version_selector_maps_to_a_lookup_and_back() {
assert_eq!(VersionSelector::stable().pinned(), None);
assert_eq!(VersionSelector::Pinned(7).pinned(), Some(7));
assert_eq!(VersionSelector::default(), VersionSelector::stable());
assert_eq!(VersionSelector::stable().to_string(), "stable");
assert_eq!(VersionSelector::newest().to_string(), "newest");
assert_eq!(VersionSelector::Pinned(2).to_string(), "2");
}
#[test]
fn version_selector_serde_accepts_every_wire_spelling() {
use serde_json::json;
for wire in [json!("stable"), json!("STABLE")] {
assert_eq!(
serde_json::from_value::<VersionSelector>(wire).unwrap(),
VersionSelector::stable()
);
}
assert_eq!(
serde_json::from_value::<VersionSelector>(json!("4")).unwrap(),
VersionSelector::Pinned(4)
);
assert_eq!(
serde_json::from_value::<VersionSelector>(json!(4)).unwrap(),
VersionSelector::Pinned(4)
);
assert!(serde_json::from_value::<VersionSelector>(json!(0)).is_err());
assert!(serde_json::from_value::<VersionSelector>(json!("nope")).is_err());
assert!(serde_json::from_value::<VersionSelector>(json!(true)).is_err());
assert_eq!(
serde_json::to_value(VersionSelector::stable()).unwrap(),
json!("stable")
);
assert_eq!(
serde_json::to_value(VersionSelector::Pinned(9)).unwrap(),
json!("9")
);
}
#[test]
fn template_status_is_derived_from_launch_and_deprecation() {
use TemplateStatus::*;
assert_eq!(TemplateStatus::derive(false, false), Draft);
assert_eq!(TemplateStatus::derive(true, false), Launched);
assert_eq!(TemplateStatus::derive(false, true), Deprecated);
assert_eq!(TemplateStatus::derive(true, true), Deprecated);
assert_eq!(Draft.as_str(), "draft");
assert_eq!(Launched.to_string(), "launched");
assert_eq!(
serde_json::to_value(Deprecated).unwrap(),
serde_json::json!("deprecated")
);
}
#[test]
fn id_parsing_accepts_slugs_and_rejects_the_rest() {
for good in ["a", "tenant-sync", "t1_2", "9lives"] {
assert_eq!(TemplateId::parse(good).unwrap().as_str(), good);
}
for bad in [
"",
" ",
"-lead",
"_lead",
"Upper",
"has space",
"has/slash",
"has.dot",
"../etc/passwd",
] {
assert!(
TemplateId::parse(bad).is_err(),
"{bad:?} should be rejected"
);
}
assert_eq!(TemplateId::parse(" ok ").unwrap().as_str(), "ok");
assert!(TemplateId::parse(&"a".repeat(MAX_ID_LEN + 1)).is_err());
assert!(TemplateId::parse(&"a".repeat(MAX_ID_LEN)).is_ok());
}
#[test]
fn id_derives_from_a_config_name() {
assert_eq!(
TemplateId::from_config_name("Tenant Sync (prod)")
.unwrap()
.as_str(),
"tenant-sync-prod"
);
assert_eq!(
TemplateId::from_config_name("already-fine")
.unwrap()
.as_str(),
"already-fine"
);
assert!(TemplateId::from_config_name("!!!").is_err());
assert!(TemplateId::from_config_name("").is_err());
let long = TemplateId::from_config_name(&format!("{} x", "a".repeat(MAX_ID_LEN))).unwrap();
assert_eq!(long.as_str().len(), MAX_ID_LEN);
}
#[test]
fn id_serde_round_trips_and_rejects_bad_values() {
let id = TemplateId::parse("ok-id").unwrap();
assert_eq!(
serde_json::to_value(&id).unwrap(),
serde_json::json!("ok-id")
);
let back: TemplateId = serde_json::from_value(serde_json::json!("ok-id")).unwrap();
assert_eq!(back, id);
assert!(serde_json::from_value::<TemplateId>(serde_json::json!("Bad Id")).is_err());
assert_eq!(id.to_string(), "ok-id");
assert_eq!(String::from(id), "ok-id");
}
#[test]
fn record_round_trips_through_json() {
let mut r = rec("a", 2, 5);
r.params
.insert("t".into(), crate::params::ParamSpec::string_default("v"));
let text = serde_json::to_string(&r).unwrap();
let back: TemplateRecord = serde_json::from_str(&text).unwrap();
assert_eq!(back.id, "a");
assert_eq!(back.version, 2);
assert_eq!(back.params["t"].default, Some(serde_json::json!("v")));
}
}