Skip to main content

opencode_ralph_loop_cli/manifest/
mod.rs

1use std::path::Path;
2
3use chrono::Utc;
4use serde::{Deserialize, Serialize};
5
6use crate::error::CliError;
7
8pub const MANIFEST_FILENAME: &str = ".manifest.json";
9pub const SCHEMA_VERSION: u32 = 1;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Manifest {
13    pub schema_version: u32,
14    pub cli_version: String,
15    pub plugin_version: String,
16    pub template_version: String,
17    pub generated_at: String,
18    pub generator: String,
19    pub files: Vec<ManifestFile>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ManifestFile {
24    pub path: String,
25    pub sha256: String,
26    pub size_bytes: u64,
27    pub canonical: bool,
28    pub action: String,
29}
30
31impl Manifest {
32    pub fn new(plugin_version: impl Into<String>) -> Self {
33        Self {
34            schema_version: SCHEMA_VERSION,
35            cli_version: env!("CARGO_PKG_VERSION").to_string(),
36            plugin_version: plugin_version.into(),
37            template_version: crate::templates::TEMPLATE_VERSION.to_string(),
38            generated_at: Utc::now().to_rfc3339(),
39            generator: "opencode-ralph-loop-cli".to_string(),
40            files: Vec::new(),
41        }
42    }
43
44    pub fn add_file(&mut self, file: ManifestFile) {
45        self.files.push(file);
46        self.files.sort_by(|a, b| a.path.cmp(&b.path));
47    }
48
49    pub fn find_file(&self, path: &str) -> Option<&ManifestFile> {
50        self.files.iter().find(|f| f.path == path)
51    }
52}
53
54impl ManifestFile {
55    pub fn new(
56        path: impl Into<String>,
57        sha256: impl Into<String>,
58        size_bytes: u64,
59        canonical: bool,
60        action: impl Into<String>,
61    ) -> Self {
62        Self {
63            path: path.into(),
64            sha256: sha256.into(),
65            size_bytes,
66            canonical,
67            action: action.into(),
68        }
69    }
70}
71
72/// Loads the manifest from .opencode/.manifest.json in the target directory.
73pub fn load(opencode_dir: &Path) -> Result<Manifest, CliError> {
74    let manifest_path = opencode_dir.join(MANIFEST_FILENAME);
75
76    if !manifest_path.exists() {
77        return Err(CliError::ManifestMissing);
78    }
79
80    let content = std::fs::read_to_string(&manifest_path)
81        .map_err(|e| CliError::io(manifest_path.to_string_lossy().into_owned(), e))?;
82
83    serde_json::from_str(&content)
84        .map_err(|e| CliError::ConfigParse(format!("invalid manifest: {e}")))
85}
86
87/// Saves the manifest to .opencode/.manifest.json with 2-space pretty-print and trailing newline.
88pub fn save(opencode_dir: &Path, manifest: &Manifest) -> Result<(), CliError> {
89    let manifest_path = opencode_dir.join(MANIFEST_FILENAME);
90
91    let mut content = serde_json::to_string_pretty(manifest)
92        .map_err(|e| CliError::Generic(format!("failed to serialize manifest: {e}")))?;
93    content.push('\n');
94
95    crate::fs_atomic::write_atomic(&manifest_path, content.as_bytes())
96}
97
98/// Removes the manifest from disk.
99pub fn remove(opencode_dir: &Path) -> Result<(), CliError> {
100    let manifest_path = opencode_dir.join(MANIFEST_FILENAME);
101    if manifest_path.exists() {
102        std::fs::remove_file(&manifest_path)
103            .map_err(|e| CliError::io(manifest_path.to_string_lossy().into_owned(), e))?;
104    }
105    Ok(())
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use tempfile::TempDir;
112
113    #[test]
114    fn saves_and_loads_manifest() {
115        let dir = TempDir::new().unwrap();
116        let opencode_dir = dir.path().join(".opencode");
117        std::fs::create_dir_all(&opencode_dir).unwrap();
118
119        let mut manifest = Manifest::new("1.4.7");
120        manifest.add_file(ManifestFile::new(
121            "plugins/ralph.ts",
122            "abc123",
123            1234,
124            true,
125            "created",
126        ));
127
128        save(&opencode_dir, &manifest).unwrap();
129
130        let loaded = load(&opencode_dir).unwrap();
131        assert_eq!(loaded.plugin_version, "1.4.7");
132        assert_eq!(loaded.files.len(), 1);
133        assert_eq!(loaded.files[0].path, "plugins/ralph.ts");
134    }
135
136    #[test]
137    fn load_returns_missing_when_absent() {
138        let dir = TempDir::new().unwrap();
139        let resultado = load(dir.path());
140        assert!(matches!(resultado, Err(CliError::ManifestMissing)));
141    }
142
143    #[test]
144    fn files_sorted_alphabetically() {
145        let mut manifest = Manifest::new("1.4.7");
146        manifest.add_file(ManifestFile::new("z.ts", "h1", 1, true, "created"));
147        manifest.add_file(ManifestFile::new("a.md", "h2", 1, true, "created"));
148        assert_eq!(manifest.files[0].path, "a.md");
149        assert_eq!(manifest.files[1].path, "z.ts");
150    }
151}