use std::collections::BTreeSet;
use std::path::Path;
use regex::Regex;
use serde::Deserialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum FleetConfigError {
#[error("failed to read or parse {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("failed to read or parse {path}: {message}")]
Parse { path: String, message: String },
#[error("{0}")]
Validation(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FleetEntry {
pub name: String,
pub repo: String,
pub path: String,
pub template: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FleetConfig {
pub fleet: Vec<FleetEntry>,
}
const ROOT_KEYS: &[&str] = &["fleet"];
const ENTRY_KEYS: &[&str] = &["name", "repo", "path", "template"];
const VALID_TEMPLATES: &[&str] = &["typescript-bun"];
fn name_re() -> Regex {
Regex::new(r"^[a-z0-9][a-z0-9-]*$").expect("name regex compiles")
}
fn repo_re() -> Regex {
Regex::new(r"^[^/]+/[^/]+$").expect("repo regex compiles")
}
pub fn load_fleet_config<P: AsRef<Path>>(path: P) -> Result<FleetConfig, FleetConfigError> {
let p = path.as_ref();
let p_str = p.display().to_string();
let text = std::fs::read_to_string(p).map_err(|e| FleetConfigError::Io {
path: p_str.clone(),
source: e,
})?;
let raw: serde_yaml::Value =
serde_yaml::from_str(&text).map_err(|e| FleetConfigError::Parse {
path: p_str.clone(),
message: e.to_string(),
})?;
validate(raw)
}
fn validate(raw: serde_yaml::Value) -> Result<FleetConfig, FleetConfigError> {
let map = match raw {
serde_yaml::Value::Mapping(m) => m,
_ => {
return Err(FleetConfigError::Validation(
"invalid fleet.yaml: root must be a mapping".into(),
))
}
};
let mut top_keys: BTreeSet<String> = BTreeSet::new();
for (k, _) in map.iter() {
if let Some(s) = k.as_str() {
top_keys.insert(s.to_string());
}
}
let allowed_top: BTreeSet<String> = ROOT_KEYS.iter().map(|s| s.to_string()).collect();
let extra: Vec<String> = top_keys.difference(&allowed_top).cloned().collect();
if !extra.is_empty() {
return Err(FleetConfigError::Validation(format!(
"invalid fleet.yaml: unrecognized extra key(s): {}",
extra.join(", ")
)));
}
if !top_keys.contains("fleet") {
return Err(FleetConfigError::Validation(
"invalid fleet.yaml: missing required key 'fleet'".into(),
));
}
let fleet_v = map.get("fleet").ok_or_else(|| {
FleetConfigError::Validation("invalid fleet.yaml: missing required key 'fleet'".into())
})?;
let seq = match fleet_v {
serde_yaml::Value::Sequence(s) => s,
_ => {
return Err(FleetConfigError::Validation(
"invalid fleet.yaml: 'fleet' must be a list".into(),
))
}
};
if seq.is_empty() {
return Err(FleetConfigError::Validation(
"invalid fleet.yaml: fleet must contain at least one entry".into(),
));
}
let mut entries = Vec::with_capacity(seq.len());
for (i, item) in seq.iter().enumerate() {
entries.push(validate_entry(item, i)?);
}
Ok(FleetConfig { fleet: entries })
}
fn validate_entry(item: &serde_yaml::Value, index: usize) -> Result<FleetEntry, FleetConfigError> {
let map = match item {
serde_yaml::Value::Mapping(m) => m,
_ => {
return Err(FleetConfigError::Validation(format!(
"fleet.{}: entry must be a mapping",
index
)))
}
};
let mut keys: BTreeSet<String> = BTreeSet::new();
for (k, _) in map.iter() {
if let Some(s) = k.as_str() {
keys.insert(s.to_string());
}
}
let allowed: BTreeSet<String> = ENTRY_KEYS.iter().map(|s| s.to_string()).collect();
let missing: Vec<String> = allowed.difference(&keys).cloned().collect();
if !missing.is_empty() {
return Err(FleetConfigError::Validation(format!(
"fleet.{}: missing required field(s): {}",
index,
missing.join(", ")
)));
}
let extra: Vec<String> = keys.difference(&allowed).cloned().collect();
if !extra.is_empty() {
return Err(FleetConfigError::Validation(format!(
"fleet.{}: unrecognized extra field(s): {}",
index,
extra.join(", ")
)));
}
fn get_str<'a>(
m: &'a serde_yaml::Mapping,
key: &str,
) -> Option<&'a str> {
m.get(key).and_then(|v| v.as_str())
}
let name = get_str(map, "name").ok_or_else(|| {
FleetConfigError::Validation(format!("fleet.{}.name: must be a string", index))
})?;
let repo = get_str(map, "repo").ok_or_else(|| {
FleetConfigError::Validation(format!("fleet.{}.repo: must be a string", index))
})?;
let path = get_str(map, "path").ok_or_else(|| {
FleetConfigError::Validation(format!("fleet.{}.path: must be a string", index))
})?;
let template = get_str(map, "template").ok_or_else(|| {
FleetConfigError::Validation(format!("fleet.{}.template: must be a string", index))
})?;
if !name_re().is_match(name) {
return Err(FleetConfigError::Validation(format!(
"fleet.{}.name: name must match ^[a-z0-9][a-z0-9-]*$",
index
)));
}
if !repo_re().is_match(repo) {
return Err(FleetConfigError::Validation(format!(
r#"fleet.{}.repo: repo must be "owner/name""#,
index
)));
}
if path.is_empty() {
return Err(FleetConfigError::Validation(format!(
"fleet.{}.path: path must be a non-empty string",
index
)));
}
if !VALID_TEMPLATES.contains(&template) {
return Err(FleetConfigError::Validation(format!(
"fleet.{}.template: template must be one of {:?}",
index, VALID_TEMPLATES
)));
}
Ok(FleetEntry {
name: name.to_string(),
repo: repo.to_string(),
path: path.to_string(),
template: template.to_string(),
})
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
#[allow(dead_code)]
struct _SchemaShape {
fleet: Vec<_EntryShape>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
#[allow(dead_code)]
struct _EntryShape {
name: String,
repo: String,
path: String,
template: String,
}