Skip to main content

par_term_update/
manifest.rs

1//! Manifest system for tracking bundled files.
2//!
3//! Used by shader and shell integration installers to track which files
4//! are part of the bundle vs user-created, and detect modifications.
5
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::collections::HashMap;
9use std::fs;
10use std::io::Read;
11use std::path::Path;
12
13/// Manifest tracking bundled files
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Manifest {
16    /// Version of par-term that created this manifest
17    pub version: String,
18    /// ISO 8601 timestamp when manifest was generated
19    pub generated: String,
20    /// List of bundled files
21    pub files: Vec<ManifestFile>,
22}
23
24/// A file entry in the manifest
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ManifestFile {
27    /// Relative path from install directory
28    pub path: String,
29    /// SHA256 hash of file contents
30    pub sha256: String,
31    /// File type for categorization
32    #[serde(rename = "type")]
33    pub file_type: FileType,
34    /// Optional category for organization
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub category: Option<String>,
37}
38
39/// Type of file in the manifest
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum FileType {
43    /// Background shader (.glsl)
44    Shader,
45    /// Cursor effect shader
46    CursorShader,
47    /// Texture/image used by shaders
48    Texture,
49    /// Documentation file
50    Doc,
51    /// Other file type
52    Other,
53}
54
55impl Manifest {
56    /// Load manifest from a directory
57    pub fn load(dir: &Path) -> Result<Self, String> {
58        let manifest_path = dir.join("manifest.json");
59        let content = fs::read_to_string(&manifest_path)
60            .map_err(|e| format!("Failed to read manifest: {}", e))?;
61        serde_json::from_str(&content).map_err(|e| format!("Failed to parse manifest: {}", e))
62    }
63
64    /// Save manifest to a directory
65    ///
66    /// Uses atomic write pattern: writes to a temp file first, then renames to final path.
67    /// This ensures the manifest is never left in a corrupted state if writing fails.
68    pub fn save(&self, dir: &Path) -> Result<(), String> {
69        let manifest_path = dir.join("manifest.json");
70        let temp_path = dir.join("manifest.json.tmp");
71
72        let content = serde_json::to_string_pretty(self)
73            .map_err(|e| format!("Failed to serialize manifest: {}", e))?;
74
75        // Write to temp file first
76        fs::write(&temp_path, content)
77            .map_err(|e| format!("Failed to write manifest temp file: {}", e))?;
78
79        // Atomically rename to final path
80        fs::rename(&temp_path, &manifest_path)
81            .map_err(|e| format!("Failed to rename manifest temp file: {}", e))?;
82
83        Ok(())
84    }
85
86    /// Build a lookup map from path to file entry
87    pub fn file_map(&self) -> HashMap<&str, &ManifestFile> {
88        self.files.iter().map(|f| (f.path.as_str(), f)).collect()
89    }
90}
91
92/// Compute SHA256 hash of a file
93pub fn compute_file_hash(path: &Path) -> Result<String, String> {
94    let mut file = fs::File::open(path).map_err(|e| format!("Failed to open file: {}", e))?;
95    let mut hasher = Sha256::new();
96    let mut buffer = [0u8; 8192];
97
98    loop {
99        let bytes_read = file
100            .read(&mut buffer)
101            .map_err(|e| format!("Failed to read file: {}", e))?;
102        if bytes_read == 0 {
103            break;
104        }
105        hasher.update(&buffer[..bytes_read]);
106    }
107
108    Ok(format!("{:x}", hasher.finalize()))
109}
110
111/// Result of comparing a file against the manifest
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum FileStatus {
114    /// File matches manifest (unchanged bundled file)
115    Unchanged,
116    /// File exists but hash differs from manifest (modified bundled file)
117    Modified,
118    /// File not in manifest (user-created)
119    UserCreated,
120    /// File in manifest but doesn't exist on disk
121    Missing,
122}
123
124/// Check the status of a file against the manifest
125pub fn check_file_status(file_path: &Path, relative_path: &str, manifest: &Manifest) -> FileStatus {
126    let file_map = manifest.file_map();
127
128    if let Some(manifest_entry) = file_map.get(relative_path) {
129        if !file_path.exists() {
130            FileStatus::Missing
131        } else if let Ok(hash) = compute_file_hash(file_path) {
132            if hash == manifest_entry.sha256 {
133                FileStatus::Unchanged
134            } else {
135                FileStatus::Modified
136            }
137        } else {
138            FileStatus::Modified // Can't read = treat as modified
139        }
140    } else if file_path.exists() {
141        FileStatus::UserCreated
142    } else {
143        FileStatus::Missing
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use std::io::Write;
151    use tempfile::TempDir;
152
153    fn create_test_manifest() -> Manifest {
154        Manifest {
155            version: "0.2.0".to_string(),
156            generated: "2026-02-02T12:00:00Z".to_string(),
157            files: vec![
158                ManifestFile {
159                    path: "test.glsl".to_string(),
160                    sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
161                        .to_string(), // SHA256 of empty file
162                    file_type: FileType::Shader,
163                    category: Some("test".to_string()),
164                },
165                ManifestFile {
166                    path: "cursor_glow.glsl".to_string(),
167                    sha256: "abc123".to_string(),
168                    file_type: FileType::CursorShader,
169                    category: None,
170                },
171            ],
172        }
173    }
174
175    #[test]
176    fn test_manifest_file_map() {
177        let manifest = create_test_manifest();
178        let map = manifest.file_map();
179
180        assert_eq!(map.len(), 2);
181        assert!(map.contains_key("test.glsl"));
182        assert!(map.contains_key("cursor_glow.glsl"));
183        assert!(!map.contains_key("nonexistent.glsl"));
184    }
185
186    #[test]
187    fn test_manifest_serialization() {
188        let manifest = create_test_manifest();
189        let json = serde_json::to_string_pretty(&manifest).unwrap();
190
191        assert!(json.contains("\"version\": \"0.2.0\""));
192        assert!(json.contains("\"test.glsl\""));
193        assert!(json.contains("\"shader\""));
194        assert!(json.contains("\"cursor_shader\""));
195    }
196
197    #[test]
198    fn test_manifest_deserialization() {
199        let json = r#"{
200            "version": "0.2.0",
201            "generated": "2026-02-02T12:00:00Z",
202            "files": [
203                {
204                    "path": "example.glsl",
205                    "sha256": "abc123",
206                    "type": "shader",
207                    "category": "effects"
208                }
209            ]
210        }"#;
211
212        let manifest: Manifest = serde_json::from_str(json).unwrap();
213        assert_eq!(manifest.version, "0.2.0");
214        assert_eq!(manifest.files.len(), 1);
215        assert_eq!(manifest.files[0].path, "example.glsl");
216        assert_eq!(manifest.files[0].file_type, FileType::Shader);
217        assert_eq!(manifest.files[0].category, Some("effects".to_string()));
218    }
219
220    #[test]
221    fn test_compute_file_hash() {
222        let temp_dir = TempDir::new().unwrap();
223        let test_file = temp_dir.path().join("test.txt");
224
225        // Create file with known content
226        let mut file = fs::File::create(&test_file).unwrap();
227        file.write_all(b"hello world").unwrap();
228
229        let hash = compute_file_hash(&test_file).unwrap();
230        // SHA256 of "hello world"
231        assert_eq!(
232            hash,
233            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
234        );
235    }
236
237    #[test]
238    fn test_compute_file_hash_empty_file() {
239        let temp_dir = TempDir::new().unwrap();
240        let test_file = temp_dir.path().join("empty.txt");
241
242        fs::File::create(&test_file).unwrap();
243
244        let hash = compute_file_hash(&test_file).unwrap();
245        // SHA256 of empty file
246        assert_eq!(
247            hash,
248            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
249        );
250    }
251
252    #[test]
253    fn test_file_status_unchanged() {
254        let temp_dir = TempDir::new().unwrap();
255        let test_file = temp_dir.path().join("test.glsl");
256
257        // Create empty file (matches manifest hash)
258        fs::File::create(&test_file).unwrap();
259
260        let manifest = create_test_manifest();
261        let status = check_file_status(&test_file, "test.glsl", &manifest);
262
263        assert_eq!(status, FileStatus::Unchanged);
264    }
265
266    #[test]
267    fn test_file_status_modified() {
268        let temp_dir = TempDir::new().unwrap();
269        let test_file = temp_dir.path().join("test.glsl");
270
271        // Create file with different content
272        let mut file = fs::File::create(&test_file).unwrap();
273        file.write_all(b"modified content").unwrap();
274
275        let manifest = create_test_manifest();
276        let status = check_file_status(&test_file, "test.glsl", &manifest);
277
278        assert_eq!(status, FileStatus::Modified);
279    }
280
281    #[test]
282    fn test_file_status_missing() {
283        let temp_dir = TempDir::new().unwrap();
284        let test_file = temp_dir.path().join("nonexistent.glsl");
285
286        let manifest = create_test_manifest();
287        let status = check_file_status(&test_file, "test.glsl", &manifest);
288
289        assert_eq!(status, FileStatus::Missing);
290    }
291
292    #[test]
293    fn test_file_status_user_created() {
294        let temp_dir = TempDir::new().unwrap();
295        let test_file = temp_dir.path().join("user_shader.glsl");
296
297        // Create file not in manifest
298        let mut file = fs::File::create(&test_file).unwrap();
299        file.write_all(b"user shader content").unwrap();
300
301        let manifest = create_test_manifest();
302        let status = check_file_status(&test_file, "user_shader.glsl", &manifest);
303
304        assert_eq!(status, FileStatus::UserCreated);
305    }
306
307    #[test]
308    fn test_manifest_save_and_load() {
309        let temp_dir = TempDir::new().unwrap();
310        let manifest = create_test_manifest();
311
312        // Save manifest
313        manifest.save(temp_dir.path()).unwrap();
314
315        // Verify file was created
316        let manifest_path = temp_dir.path().join("manifest.json");
317        assert!(manifest_path.exists());
318
319        // Load and verify
320        let loaded = Manifest::load(temp_dir.path()).unwrap();
321        assert_eq!(loaded.version, manifest.version);
322        assert_eq!(loaded.files.len(), manifest.files.len());
323        assert_eq!(loaded.files[0].path, manifest.files[0].path);
324    }
325
326    #[test]
327    fn test_file_type_serialization() {
328        assert_eq!(
329            serde_json::to_string(&FileType::Shader).unwrap(),
330            "\"shader\""
331        );
332        assert_eq!(
333            serde_json::to_string(&FileType::CursorShader).unwrap(),
334            "\"cursor_shader\""
335        );
336        assert_eq!(
337            serde_json::to_string(&FileType::Texture).unwrap(),
338            "\"texture\""
339        );
340        assert_eq!(serde_json::to_string(&FileType::Doc).unwrap(), "\"doc\"");
341        assert_eq!(
342            serde_json::to_string(&FileType::Other).unwrap(),
343            "\"other\""
344        );
345    }
346}