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(),
}
}
}
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}")))
}
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())
}
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");
}
}