agent-fleet 0.1.0

Autonomous OSS-repo health for solo maintainers (Rust port of @p-vbordei/agent-fleet)
Documentation
//! fleet.yaml parser + strict validator (SPEC ยง2.1, C5).

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>,
}

/// Allowed top-level keys.
const ROOT_KEYS: &[&str] = &["fleet"];
/// Allowed per-entry keys.
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(),
    })
}

// Allow serde derive in case downstream callers want it; not used internally.
#[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,
}