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> {
66 let manifest_path = dir.join("manifest.json");
67 let content = serde_json::to_string_pretty(self)
68 .map_err(|e| format!("Failed to serialize manifest: {}", e))?;
69 fs::write(&manifest_path, content).map_err(|e| format!("Failed to write manifest: {}", e))
70 }
71
72 pub fn file_map(&self) -> HashMap<&str, &ManifestFile> {
74 self.files.iter().map(|f| (f.path.as_str(), f)).collect()
75 }
76}
77
78pub fn compute_file_hash(path: &Path) -> Result<String, String> {
80 let mut file = fs::File::open(path).map_err(|e| format!("Failed to open file: {}", e))?;
81 let mut hasher = Sha256::new();
82 let mut buffer = [0u8; 8192];
83
84 loop {
85 let bytes_read = file
86 .read(&mut buffer)
87 .map_err(|e| format!("Failed to read file: {}", e))?;
88 if bytes_read == 0 {
89 break;
90 }
91 hasher.update(&buffer[..bytes_read]);
92 }
93
94 Ok(format!("{:x}", hasher.finalize()))
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum FileStatus {
100 Unchanged,
102 Modified,
104 UserCreated,
106 Missing,
108}
109
110pub fn check_file_status(file_path: &Path, relative_path: &str, manifest: &Manifest) -> FileStatus {
112 let file_map = manifest.file_map();
113
114 if let Some(manifest_entry) = file_map.get(relative_path) {
115 if !file_path.exists() {
116 FileStatus::Missing
117 } else if let Ok(hash) = compute_file_hash(file_path) {
118 if hash == manifest_entry.sha256 {
119 FileStatus::Unchanged
120 } else {
121 FileStatus::Modified
122 }
123 } else {
124 FileStatus::Modified }
126 } else if file_path.exists() {
127 FileStatus::UserCreated
128 } else {
129 FileStatus::Missing
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use std::io::Write;
137 use tempfile::TempDir;
138
139 fn create_test_manifest() -> Manifest {
140 Manifest {
141 version: "0.2.0".to_string(),
142 generated: "2026-02-02T12:00:00Z".to_string(),
143 files: vec![
144 ManifestFile {
145 path: "test.glsl".to_string(),
146 sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
147 .to_string(), file_type: FileType::Shader,
149 category: Some("test".to_string()),
150 },
151 ManifestFile {
152 path: "cursor_glow.glsl".to_string(),
153 sha256: "abc123".to_string(),
154 file_type: FileType::CursorShader,
155 category: None,
156 },
157 ],
158 }
159 }
160
161 #[test]
162 fn test_manifest_file_map() {
163 let manifest = create_test_manifest();
164 let map = manifest.file_map();
165
166 assert_eq!(map.len(), 2);
167 assert!(map.contains_key("test.glsl"));
168 assert!(map.contains_key("cursor_glow.glsl"));
169 assert!(!map.contains_key("nonexistent.glsl"));
170 }
171
172 #[test]
173 fn test_manifest_serialization() {
174 let manifest = create_test_manifest();
175 let json = serde_json::to_string_pretty(&manifest).unwrap();
176
177 assert!(json.contains("\"version\": \"0.2.0\""));
178 assert!(json.contains("\"test.glsl\""));
179 assert!(json.contains("\"shader\""));
180 assert!(json.contains("\"cursor_shader\""));
181 }
182
183 #[test]
184 fn test_manifest_deserialization() {
185 let json = r#"{
186 "version": "0.2.0",
187 "generated": "2026-02-02T12:00:00Z",
188 "files": [
189 {
190 "path": "example.glsl",
191 "sha256": "abc123",
192 "type": "shader",
193 "category": "effects"
194 }
195 ]
196 }"#;
197
198 let manifest: Manifest = serde_json::from_str(json).unwrap();
199 assert_eq!(manifest.version, "0.2.0");
200 assert_eq!(manifest.files.len(), 1);
201 assert_eq!(manifest.files[0].path, "example.glsl");
202 assert_eq!(manifest.files[0].file_type, FileType::Shader);
203 assert_eq!(manifest.files[0].category, Some("effects".to_string()));
204 }
205
206 #[test]
207 fn test_compute_file_hash() {
208 let temp_dir = TempDir::new().unwrap();
209 let test_file = temp_dir.path().join("test.txt");
210
211 let mut file = fs::File::create(&test_file).unwrap();
213 file.write_all(b"hello world").unwrap();
214
215 let hash = compute_file_hash(&test_file).unwrap();
216 assert_eq!(
218 hash,
219 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
220 );
221 }
222
223 #[test]
224 fn test_compute_file_hash_empty_file() {
225 let temp_dir = TempDir::new().unwrap();
226 let test_file = temp_dir.path().join("empty.txt");
227
228 fs::File::create(&test_file).unwrap();
229
230 let hash = compute_file_hash(&test_file).unwrap();
231 assert_eq!(
233 hash,
234 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
235 );
236 }
237
238 #[test]
239 fn test_file_status_unchanged() {
240 let temp_dir = TempDir::new().unwrap();
241 let test_file = temp_dir.path().join("test.glsl");
242
243 fs::File::create(&test_file).unwrap();
245
246 let manifest = create_test_manifest();
247 let status = check_file_status(&test_file, "test.glsl", &manifest);
248
249 assert_eq!(status, FileStatus::Unchanged);
250 }
251
252 #[test]
253 fn test_file_status_modified() {
254 let temp_dir = TempDir::new().unwrap();
255 let test_file = temp_dir.path().join("test.glsl");
256
257 let mut file = fs::File::create(&test_file).unwrap();
259 file.write_all(b"modified content").unwrap();
260
261 let manifest = create_test_manifest();
262 let status = check_file_status(&test_file, "test.glsl", &manifest);
263
264 assert_eq!(status, FileStatus::Modified);
265 }
266
267 #[test]
268 fn test_file_status_missing() {
269 let temp_dir = TempDir::new().unwrap();
270 let test_file = temp_dir.path().join("nonexistent.glsl");
271
272 let manifest = create_test_manifest();
273 let status = check_file_status(&test_file, "test.glsl", &manifest);
274
275 assert_eq!(status, FileStatus::Missing);
276 }
277
278 #[test]
279 fn test_file_status_user_created() {
280 let temp_dir = TempDir::new().unwrap();
281 let test_file = temp_dir.path().join("user_shader.glsl");
282
283 let mut file = fs::File::create(&test_file).unwrap();
285 file.write_all(b"user shader content").unwrap();
286
287 let manifest = create_test_manifest();
288 let status = check_file_status(&test_file, "user_shader.glsl", &manifest);
289
290 assert_eq!(status, FileStatus::UserCreated);
291 }
292
293 #[test]
294 fn test_manifest_save_and_load() {
295 let temp_dir = TempDir::new().unwrap();
296 let manifest = create_test_manifest();
297
298 manifest.save(temp_dir.path()).unwrap();
300
301 let manifest_path = temp_dir.path().join("manifest.json");
303 assert!(manifest_path.exists());
304
305 let loaded = Manifest::load(temp_dir.path()).unwrap();
307 assert_eq!(loaded.version, manifest.version);
308 assert_eq!(loaded.files.len(), manifest.files.len());
309 assert_eq!(loaded.files[0].path, manifest.files[0].path);
310 }
311
312 #[test]
313 fn test_file_type_serialization() {
314 assert_eq!(
315 serde_json::to_string(&FileType::Shader).unwrap(),
316 "\"shader\""
317 );
318 assert_eq!(
319 serde_json::to_string(&FileType::CursorShader).unwrap(),
320 "\"cursor_shader\""
321 );
322 assert_eq!(
323 serde_json::to_string(&FileType::Texture).unwrap(),
324 "\"texture\""
325 );
326 assert_eq!(serde_json::to_string(&FileType::Doc).unwrap(), "\"doc\"");
327 assert_eq!(
328 serde_json::to_string(&FileType::Other).unwrap(),
329 "\"other\""
330 );
331 }
332}