1use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::collections::HashMap;
9use std::fs;
10use std::io::Read;
11use std::path::Path;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Manifest {
16 pub version: String,
18 pub generated: String,
20 pub files: Vec<ManifestFile>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ManifestFile {
27 pub path: String,
29 pub sha256: String,
31 #[serde(rename = "type")]
33 pub file_type: FileType,
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub category: Option<String>,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum FileType {
43 Shader,
45 CursorShader,
47 Texture,
49 Doc,
51 Other,
53}
54
55impl Manifest {
56 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 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 fs::write(&temp_path, content)
77 .map_err(|e| format!("Failed to write manifest temp file: {}", e))?;
78
79 fs::rename(&temp_path, &manifest_path)
81 .map_err(|e| format!("Failed to rename manifest temp file: {}", e))?;
82
83 Ok(())
84 }
85
86 pub fn file_map(&self) -> HashMap<&str, &ManifestFile> {
88 self.files.iter().map(|f| (f.path.as_str(), f)).collect()
89 }
90}
91
92pub 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#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum FileStatus {
114 Unchanged,
116 Modified,
118 UserCreated,
120 Missing,
122}
123
124pub 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 }
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(), 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 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 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 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 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 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 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 manifest.save(temp_dir.path()).unwrap();
314
315 let manifest_path = temp_dir.path().join("manifest.json");
317 assert!(manifest_path.exists());
318
319 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}