opencode-ralph-loop-cli 0.1.0

Scaffolder CLI for OpenCode Ralph Loop plugin — one command setup
Documentation
use std::path::Path;

use chrono::Utc;
use serde::{Deserialize, Serialize};

use crate::error::CliError;

pub const MANIFEST_FILENAME: &str = ".manifest.json";
pub const SCHEMA_VERSION: u32 = 1;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Manifest {
    pub schema_version: u32,
    pub cli_version: String,
    pub plugin_version: String,
    pub template_version: String,
    pub generated_at: String,
    pub generator: String,
    pub files: Vec<ManifestFile>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManifestFile {
    pub path: String,
    pub sha256: String,
    pub size_bytes: u64,
    pub canonical: bool,
    pub action: String,
}

impl Manifest {
    pub fn new(plugin_version: impl Into<String>) -> Self {
        Self {
            schema_version: SCHEMA_VERSION,
            cli_version: env!("CARGO_PKG_VERSION").to_string(),
            plugin_version: plugin_version.into(),
            template_version: crate::templates::TEMPLATE_VERSION.to_string(),
            generated_at: Utc::now().to_rfc3339(),
            generator: "opencode-ralph-loop-cli".to_string(),
            files: Vec::new(),
        }
    }

    pub fn add_file(&mut self, file: ManifestFile) {
        self.files.push(file);
        self.files.sort_by(|a, b| a.path.cmp(&b.path));
    }

    pub fn find_file(&self, path: &str) -> Option<&ManifestFile> {
        self.files.iter().find(|f| f.path == path)
    }
}

impl ManifestFile {
    pub fn new(
        path: impl Into<String>,
        sha256: impl Into<String>,
        size_bytes: u64,
        canonical: bool,
        action: impl Into<String>,
    ) -> Self {
        Self {
            path: path.into(),
            sha256: sha256.into(),
            size_bytes,
            canonical,
            action: action.into(),
        }
    }
}

/// Loads the manifest from .opencode/.manifest.json in the target directory.
pub fn load(opencode_dir: &Path) -> Result<Manifest, CliError> {
    let manifest_path = opencode_dir.join(MANIFEST_FILENAME);

    if !manifest_path.exists() {
        return Err(CliError::ManifestMissing);
    }

    let content = std::fs::read_to_string(&manifest_path)
        .map_err(|e| CliError::io(manifest_path.to_string_lossy().into_owned(), e))?;

    serde_json::from_str(&content)
        .map_err(|e| CliError::ConfigParse(format!("invalid manifest: {e}")))
}

/// Saves the manifest to .opencode/.manifest.json with 2-space pretty-print and trailing newline.
pub fn save(opencode_dir: &Path, manifest: &Manifest) -> Result<(), CliError> {
    let manifest_path = opencode_dir.join(MANIFEST_FILENAME);

    let mut content = serde_json::to_string_pretty(manifest)
        .map_err(|e| CliError::Generic(format!("failed to serialize manifest: {e}")))?;
    content.push('\n');

    crate::fs_atomic::write_atomic(&manifest_path, content.as_bytes())
}

/// Removes the manifest from disk.
pub fn remove(opencode_dir: &Path) -> Result<(), CliError> {
    let manifest_path = opencode_dir.join(MANIFEST_FILENAME);
    if manifest_path.exists() {
        std::fs::remove_file(&manifest_path)
            .map_err(|e| CliError::io(manifest_path.to_string_lossy().into_owned(), e))?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[test]
    fn saves_and_loads_manifest() {
        let dir = TempDir::new().unwrap();
        let opencode_dir = dir.path().join(".opencode");
        std::fs::create_dir_all(&opencode_dir).unwrap();

        let mut manifest = Manifest::new("1.4.7");
        manifest.add_file(ManifestFile::new(
            "plugins/ralph.ts",
            "abc123",
            1234,
            true,
            "created",
        ));

        save(&opencode_dir, &manifest).unwrap();

        let loaded = load(&opencode_dir).unwrap();
        assert_eq!(loaded.plugin_version, "1.4.7");
        assert_eq!(loaded.files.len(), 1);
        assert_eq!(loaded.files[0].path, "plugins/ralph.ts");
    }

    #[test]
    fn load_returns_missing_when_absent() {
        let dir = TempDir::new().unwrap();
        let resultado = load(dir.path());
        assert!(matches!(resultado, Err(CliError::ManifestMissing)));
    }

    #[test]
    fn files_sorted_alphabetically() {
        let mut manifest = Manifest::new("1.4.7");
        manifest.add_file(ManifestFile::new("z.ts", "h1", 1, true, "created"));
        manifest.add_file(ManifestFile::new("a.md", "h2", 1, true, "created"));
        assert_eq!(manifest.files[0].path, "a.md");
        assert_eq!(manifest.files[1].path, "z.ts");
    }
}