use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::error::{CliError, CliResult};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SyncFile {
pub version: u32,
pub origins: Vec<Origin>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Origin {
pub name: String,
pub source: OriginSource,
#[serde(default)]
pub prefix: String,
#[serde(default)]
pub launch: LaunchPolicy,
#[serde(default)]
pub prune: PrunePolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interval_secs: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", content = "config", rename_all = "snake_case")]
pub enum OriginSource {
Github(GithubSource),
S3(S3Source),
Gcs(GcsSource),
AzureBlob(AzureBlobSource),
}
impl OriginSource {
pub fn kind(&self) -> &'static str {
match self {
Self::Github(_) => "github",
Self::S3(_) => "s3",
Self::Gcs(_) => "gcs",
Self::AzureBlob(_) => "azure_blob",
}
}
pub fn needs_object_store(&self) -> bool {
!matches!(self, Self::Github(_))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GithubSource {
pub repo: String,
#[serde(default = "default_ref")]
pub r#ref: String,
#[serde(default)]
pub path: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub paths: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(default = "default_github_api")]
pub api_base: String,
}
fn default_ref() -> String {
"main".into()
}
fn default_github_api() -> String {
"https://api.github.com".into()
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct S3Source {
pub bucket: String,
#[serde(default)]
pub prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GcsSource {
pub bucket: String,
#[serde(default)]
pub prefix: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct AzureBlobSource {
pub container: String,
#[serde(default)]
pub prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum LaunchPolicy {
#[default]
Ignore,
Follow,
Always,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PrunePolicy {
#[default]
Keep,
Deprecate,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Sidecar {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub launch: bool,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stable: Option<u32>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub deprecated: std::collections::BTreeMap<u32, String>,
}
impl SyncFile {
pub fn validate(&self) -> CliResult<()> {
if self.version != 1 {
return Err(CliError::Config(format!(
"templates-sync: unsupported version {} (expected 1)",
self.version
)));
}
if self.origins.is_empty() {
return Err(CliError::Config(
"templates-sync: `origins` is empty — nothing to sync".into(),
));
}
let mut names: HashMap<&str, usize> = HashMap::new();
for (i, o) in self.origins.iter().enumerate() {
if o.name.trim().is_empty() {
return Err(CliError::Config(format!(
"templates-sync: origins[{i}] has an empty `name`"
)));
}
if let Some(prev) = names.insert(&o.name, i) {
return Err(CliError::Config(format!(
"templates-sync: origin name '{}' is used twice (origins[{prev}] and [{i}])",
o.name
)));
}
if let OriginSource::Github(g) = &o.source
&& !g.path.is_empty()
&& !g.paths.is_empty()
{
return Err(CliError::Config(format!(
"templates-sync: origin '{}': set `path` or `paths`, not both",
o.name
)));
}
if o.interval_secs == Some(0) {
return Err(CliError::Config(format!(
"templates-sync: origin '{}': `interval_secs` must be > 0 (omit it to disable)",
o.name
)));
}
}
for (i, a) in self.origins.iter().enumerate() {
for b in &self.origins[i + 1..] {
if a.prefix.starts_with(&b.prefix) || b.prefix.starts_with(&a.prefix) {
return Err(CliError::Config(format!(
"templates-sync: origins '{}' (prefix '{}') and '{}' (prefix '{}') own \
overlapping id namespaces — every template must have exactly one owner",
a.name, a.prefix, b.name, b.prefix
)));
}
}
}
Ok(())
}
pub fn origin(&self, name: &str) -> CliResult<&Origin> {
self.origins.iter().find(|o| o.name == name).ok_or_else(|| {
let known: Vec<&str> = self.origins.iter().map(|o| o.name.as_str()).collect();
CliError::Config(format!(
"templates-sync: no origin named '{name}' (known: {})",
known.join(", ")
))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_github_origin_takes_path_or_paths_not_both() {
let mut file = SyncFile {
version: 1,
origins: vec![origin("hub", "")],
};
let OriginSource::Github(g) = &mut file.origins[0].source else {
unreachable!()
};
g.paths = vec!["source-templates".into(), "sink-templates".into()];
file.validate().expect("paths alone is fine");
let OriginSource::Github(g) = &mut file.origins[0].source else {
unreachable!()
};
g.path = "templates".into();
let err = file.validate().unwrap_err().to_string();
assert!(err.contains("set `path` or `paths`, not both"), "{err}");
}
fn origin(name: &str, prefix: &str) -> Origin {
Origin {
name: name.into(),
source: OriginSource::Github(GithubSource {
repo: "acme/tpl".into(),
r#ref: default_ref(),
path: String::new(),
paths: Vec::new(),
token: None,
api_base: default_github_api(),
}),
prefix: prefix.into(),
launch: LaunchPolicy::default(),
prune: PrunePolicy::default(),
interval_secs: None,
}
}
#[test]
fn parses_the_rfc_example_shape() {
let yaml = r#"
version: 1
origins:
- name: platform
source:
type: github
config: { repo: acme/data-templates, ref: main, path: templates/ }
prefix: platform-
- name: partner
source:
type: s3
config: { bucket: acme-templates, prefix: shared/, region: us-east-1 }
prefix: partner-
launch: follow
"#;
let f: SyncFile = serde_yaml::from_str(yaml).expect("parse");
f.validate().expect("valid");
assert_eq!(f.origins.len(), 2);
assert_eq!(f.origins[0].source.kind(), "github");
assert_eq!(f.origins[1].launch, LaunchPolicy::Follow);
assert!(f.origins[1].source.needs_object_store());
assert!(!f.origins[0].source.needs_object_store());
}
#[test]
fn overlapping_prefixes_are_rejected() {
let f = SyncFile {
version: 1,
origins: vec![origin("a", "team-"), origin("b", "team-eu-")],
};
let err = f.validate().expect_err("overlap must fail");
assert!(
err.to_string().contains("overlapping id namespaces"),
"{err}"
);
let f = SyncFile {
version: 1,
origins: vec![origin("a", "x-"), origin("b", "x-")],
};
assert!(f.validate().is_err());
let f = SyncFile {
version: 1,
origins: vec![origin("a", ""), origin("b", "x-")],
};
assert!(f.validate().is_err());
}
#[test]
fn disjoint_prefixes_are_fine_and_a_lone_empty_prefix_is_allowed() {
SyncFile {
version: 1,
origins: vec![origin("a", "plat-"), origin("b", "part-")],
}
.validate()
.expect("disjoint");
SyncFile {
version: 1,
origins: vec![origin("only", "")],
}
.validate()
.expect("a single unprefixed origin owns the whole namespace");
}
#[test]
fn structural_errors_name_the_problem() {
assert!(
SyncFile {
version: 2,
origins: vec![origin("a", "")]
}
.validate()
.unwrap_err()
.to_string()
.contains("version 2")
);
assert!(
SyncFile {
version: 1,
origins: vec![]
}
.validate()
.unwrap_err()
.to_string()
.contains("empty")
);
assert!(
SyncFile {
version: 1,
origins: vec![origin("dup", "a-"), origin("dup", "b-")]
}
.validate()
.unwrap_err()
.to_string()
.contains("used twice")
);
let mut zero = origin("z", "z-");
zero.interval_secs = Some(0);
assert!(
SyncFile {
version: 1,
origins: vec![zero]
}
.validate()
.unwrap_err()
.to_string()
.contains("interval_secs")
);
}
#[test]
fn origin_lookup_lists_known_names_on_miss() {
let f = SyncFile {
version: 1,
origins: vec![origin("plat", "p-")],
};
assert!(f.origin("plat").is_ok());
let err = f.origin("nope").unwrap_err().to_string();
assert!(err.contains("nope") && err.contains("plat"), "{err}");
}
#[test]
fn sidecar_defaults_are_inert() {
let s: Sidecar = serde_yaml::from_str("{}").unwrap();
assert_eq!(s, Sidecar::default());
assert!(!s.launch);
let s: Sidecar = serde_yaml::from_str("launch: true\ntags: [dev]").unwrap();
assert!(s.launch);
assert_eq!(s.tags, vec!["dev"]);
}
#[test]
fn a_template_hub_sidecar_parses() {
let s: Sidecar =
serde_yaml::from_str("launch: false\nstable: 4\ndeprecated:\n 2: \"use v3+\"\n")
.unwrap();
assert_eq!(s.stable, Some(4));
assert_eq!(s.deprecated.get(&2).map(String::as_str), Some("use v3+"));
assert!(
serde_yaml::from_str::<Sidecar>("stabel: 1").is_err(),
"typos still fail"
);
}
}