agent-fleet 0.1.0

Autonomous OSS-repo health for solo maintainers (Rust port of @p-vbordei/agent-fleet)
Documentation
//! enroll — write the typescript-bun kit into a target repo (SPEC §3.1).

use std::path::{Path, PathBuf};

use thiserror::Error;

use crate::config::FleetEntry;

#[derive(Debug, Error)]
pub enum EnrollError {
    #[error("target path does not exist or is not a directory: {0}")]
    BadTarget(String),
    #[error("template not found: {template} (looked in {dir})")]
    MissingTemplate { template: String, dir: String },
    #[error("io error at {path}: {source}")]
    Io {
        path: String,
        #[source]
        source: std::io::Error,
    },
}

fn walk(root: &Path) -> Result<Vec<PathBuf>, EnrollError> {
    let mut out = Vec::new();
    visit(root, &mut out)?;
    out.sort();
    Ok(out)
}

fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), EnrollError> {
    let entries = std::fs::read_dir(dir).map_err(|e| EnrollError::Io {
        path: dir.display().to_string(),
        source: e,
    })?;
    for entry in entries {
        let entry = entry.map_err(|e| EnrollError::Io {
            path: dir.display().to_string(),
            source: e,
        })?;
        let p = entry.path();
        let ft = entry.file_type().map_err(|e| EnrollError::Io {
            path: p.display().to_string(),
            source: e,
        })?;
        if ft.is_dir() {
            visit(&p, out)?;
        } else {
            out.push(p);
        }
    }
    Ok(())
}

/// Render and write the kit. Returns a list of relative paths written.
pub fn enroll(entry: &FleetEntry, templates_root: &Path) -> Result<Vec<String>, EnrollError> {
    let target = Path::new(&entry.path);
    let meta = std::fs::metadata(target).ok();
    let is_dir = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
    if !is_dir {
        return Err(EnrollError::BadTarget(entry.path.clone()));
    }
    let tpl_dir = templates_root.join(&entry.template);
    if !tpl_dir.exists() {
        return Err(EnrollError::MissingTemplate {
            template: entry.template.clone(),
            dir: tpl_dir.display().to_string(),
        });
    }

    let vars = [("name", entry.name.as_str()), ("repo", entry.repo.as_str())];
    let mut written = Vec::new();
    for src in walk(&tpl_dir)? {
        let rel = src.strip_prefix(&tpl_dir).expect("walk under tpl_dir");
        let dest = target.join(rel);
        let raw = std::fs::read_to_string(&src).map_err(|e| EnrollError::Io {
            path: src.display().to_string(),
            source: e,
        })?;
        let mut rendered = raw;
        for (k, v) in &vars {
            let key = format!("{{{{{}}}}}", k);
            rendered = rendered.replace(&key, v);
        }
        if let Some(parent) = dest.parent() {
            std::fs::create_dir_all(parent).map_err(|e| EnrollError::Io {
                path: parent.display().to_string(),
                source: e,
            })?;
        }
        std::fs::write(&dest, &rendered).map_err(|e| EnrollError::Io {
            path: dest.display().to_string(),
            source: e,
        })?;
        written.push(rel.to_string_lossy().replace('\\', "/"));
    }

    // Bootstrap release-please manifest from target's current package.json version.
    let pkg_path = target.join("package.json");
    if pkg_path.exists() {
        if let Ok(text) = std::fs::read_to_string(&pkg_path) {
            if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&text) {
                let version = pkg
                    .get("version")
                    .and_then(|v| v.as_str())
                    .unwrap_or("0.0.0")
                    .to_string();
                let manifest_path = target.join(".release-please-manifest.json");
                let manifest = serde_json::json!({ ".": version });
                let body = serde_json::to_string_pretty(&manifest).unwrap_or_else(|_| "{}".into());
                let _ = std::fs::write(&manifest_path, format!("{}\n", body));
                written.push(".release-please-manifest.json".to_string());
            }
        }
    }

    Ok(written)
}