1use 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
26pub const MAX_ID_LEN: usize = 64;
29
30pub const DEFAULT_CHANNEL: &str = "stable";
32
33pub const REJECTED_LATEST: &str = "latest";
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
57pub enum VersionChannel {
58 #[default]
61 Stable,
62 Previous,
64 Newest,
66 Prod,
68 PreProd,
70 Staging,
72 Canary,
74 Test,
76 Dev,
78}
79
80impl VersionChannel {
81 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 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 pub fn is_derived(self) -> bool {
125 matches!(self, Self::Stable | Self::Previous | Self::Newest)
126 }
127
128 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
161fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub enum TemplateStatus {
197 Draft,
201 Launched,
204 Deprecated,
208}
209
210impl TemplateStatus {
211 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243pub enum VersionSelector {
244 Channel(VersionChannel),
247 Pinned(u32),
249}
250
251impl Default for VersionSelector {
252 fn default() -> Self {
253 Self::stable()
254 }
255}
256
257impl VersionSelector {
258 pub const fn stable() -> Self {
260 Self::Channel(VersionChannel::Stable)
261 }
262
263 pub const fn newest() -> Self {
265 Self::Channel(VersionChannel::Newest)
266 }
267
268 pub fn parse(raw: &str) -> CliResult<Self> {
271 let s = raw.trim();
272 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 pub fn is_stable(self) -> bool {
286 matches!(self, Self::Channel(VersionChannel::Stable))
287 }
288
289 pub fn pinned(self) -> Option<u32> {
294 match self {
295 Self::Pinned(n) => Some(n),
296 Self::Channel(_) => None,
297 }
298 }
299
300 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
318impl<'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#[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct TemplateRecord {
432 pub id: String,
434 pub version: u32,
436 #[serde(default, skip_serializing_if = "Option::is_none")]
438 pub name: Option<String>,
439 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub description: Option<String>,
442 pub body: String,
444 pub format: ConfigFormat,
446 #[serde(default)]
449 pub params: ParamsSpec,
450 pub created_at: DateTime<Utc>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub created_by: Option<String>,
454}
455
456impl TemplateRecord {
457 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 pub fn summary_with(&self, state: TemplateState) -> TemplateSummary {
474 TemplateSummary {
475 state: Some(state),
476 ..self.summary()
477 }
478 }
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
483pub struct TemplateSummary {
484 pub id: String,
485 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 #[serde(default, skip_serializing_if = "Option::is_none")]
501 pub state: Option<TemplateState>,
502}
503
504#[derive(Debug, Clone, Serialize, Deserialize)]
511pub struct LaunchRecord {
512 pub seq: u32,
514 pub version: u32,
516 pub launched_at: DateTime<Utc>,
517 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub launched_by: Option<String>,
520}
521
522#[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#[derive(Debug, Clone, Serialize, Deserialize)]
537pub struct TemplateState {
538 pub status: TemplateStatus,
540 pub versions: Vec<u32>,
542 pub stable: Option<u32>,
545 pub previous: Option<u32>,
547 pub newest: Option<u32>,
549 pub tags: BTreeMap<String, u32>,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub deprecation: Option<DeprecationRecord>,
555}
556
557impl TemplateState {
558 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 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
589pub fn stable_version(launches: &[LaunchRecord]) -> Option<u32> {
592 launches.first().map(|l| l.version)
593}
594
595pub 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#[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
618pub const VERSION_RETAIN: usize = 20;
622
623pub fn latest_per_id(mut records: Vec<TemplateRecord>) -> Vec<TemplateSummary> {
627 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
635pub 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)); 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 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 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 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 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 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 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 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 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 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 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 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 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 assert_eq!(TemplateStatus::derive(false, false), Draft);
882 assert_eq!(TemplateStatus::derive(true, false), Launched);
883 assert_eq!(TemplateStatus::derive(false, true), Deprecated);
884 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 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 assert!(TemplateId::from_config_name("!!!").is_err());
937 assert!(TemplateId::from_config_name("").is_err());
938 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}