1use anyhow::{Context, Result};
7use std::path::Path;
8
9use crate::manifest::Manifest;
10use crate::markdown::MarkdownFile;
11
12pub trait FileOperations {
14 fn read_file_with_context(path: impl AsRef<Path>) -> Result<String> {
16 let path = path.as_ref();
17 std::fs::read_to_string(path)
18 .with_context(|| format!("Failed to read file: {}", path.display()))
19 }
20
21 fn write_file_with_context(path: impl AsRef<Path>, content: impl AsRef<str>) -> Result<()> {
23 let path = path.as_ref();
24 std::fs::write(path, content.as_ref())
25 .with_context(|| format!("Failed to write file: {}", path.display()))
26 }
27
28 fn create_dir_with_context(path: impl AsRef<Path>) -> Result<()> {
30 let path = path.as_ref();
31 std::fs::create_dir_all(path)
32 .with_context(|| format!("Failed to create directory: {}", path.display()))
33 }
34
35 fn read_bytes_with_context(path: impl AsRef<Path>) -> Result<Vec<u8>> {
37 let path = path.as_ref();
38 std::fs::read(path).with_context(|| format!("Failed to read file: {}", path.display()))
39 }
40
41 fn write_bytes_with_context(path: impl AsRef<Path>, content: impl AsRef<[u8]>) -> Result<()> {
43 let path = path.as_ref();
44 std::fs::write(path, content.as_ref())
45 .with_context(|| format!("Failed to write file: {}", path.display()))
46 }
47
48 fn copy_file_with_context(from: impl AsRef<Path>, to: impl AsRef<Path>) -> Result<u64> {
50 let from = from.as_ref();
51 let to = to.as_ref();
52 std::fs::copy(from, to).with_context(|| {
53 format!("Failed to copy file from {} to {}", from.display(), to.display())
54 })
55 }
56
57 fn remove_file_with_context(path: impl AsRef<Path>) -> Result<()> {
59 let path = path.as_ref();
60 std::fs::remove_file(path)
61 .with_context(|| format!("Failed to remove file: {}", path.display()))
62 }
63
64 fn remove_dir_all_with_context(path: impl AsRef<Path>) -> Result<()> {
66 let path = path.as_ref();
67 std::fs::remove_dir_all(path)
68 .with_context(|| format!("Failed to remove directory: {}", path.display()))
69 }
70
71 fn check_exists_with_context(path: impl AsRef<Path>) -> Result<bool> {
73 let path = path.as_ref();
74 path.try_exists()
75 .with_context(|| format!("Failed to check if path exists: {}", path.display()))
76 }
77}
78
79pub struct FileOps;
81impl FileOperations for FileOps {}
82
83pub trait ManifestOperations {
85 fn load_manifest_with_context(path: impl AsRef<Path>) -> Result<Manifest> {
87 let path = path.as_ref();
88 Manifest::load(path)
89 .with_context(|| format!("Failed to parse manifest file: {}", path.display()))
90 }
91
92 fn save_manifest_with_context(manifest: &Manifest, path: impl AsRef<Path>) -> Result<()> {
94 let path = path.as_ref();
95 let content =
96 toml::to_string_pretty(manifest).with_context(|| "Failed to serialize manifest")?;
97 FileOps::write_file_with_context(path, content)
98 }
99}
100
101pub struct ManifestOps;
103impl ManifestOperations for ManifestOps {}
104
105pub trait MarkdownOperations {
107 fn parse_markdown_with_context(
109 content: impl AsRef<str>,
110 path: impl AsRef<Path>,
111 ) -> Result<MarkdownFile> {
112 let path = path.as_ref();
113 MarkdownFile::parse(content.as_ref())
114 .with_context(|| format!("Invalid markdown file: {}", path.display()))
115 }
116
117 fn read_markdown_with_context(path: impl AsRef<Path>) -> Result<MarkdownFile> {
119 let path = path.as_ref();
120 let content = FileOps::read_file_with_context(path)?;
121 Self::parse_markdown_with_context(content, path)
122 }
123}
124
125pub struct MarkdownOps;
127impl MarkdownOperations for MarkdownOps {}
128
129pub trait LockfileOperations {
131 fn load_lockfile_with_context(path: impl AsRef<Path>) -> Result<crate::lockfile::LockFile> {
133 let path = path.as_ref();
134 crate::lockfile::LockFile::load(path)
135 .with_context(|| format!("Failed to load lockfile: {}", path.display()))
136 }
137
138 fn save_lockfile_with_context(
140 lockfile: &crate::lockfile::LockFile,
141 path: impl AsRef<Path>,
142 ) -> Result<()> {
143 let path = path.as_ref();
144 lockfile.save(path).with_context(|| format!("Failed to save lockfile: {}", path.display()))
145 }
146}
147
148pub struct LockfileOps;
150impl LockfileOperations for LockfileOps {}
151
152pub trait JsonOperations {
154 fn read_json_with_context<T: serde::de::DeserializeOwned>(path: impl AsRef<Path>) -> Result<T> {
156 let path = path.as_ref();
157 let content = FileOps::read_file_with_context(path)?;
158 serde_json::from_str(&content)
159 .with_context(|| format!("Failed to parse JSON file: {}", path.display()))
160 }
161
162 fn write_json_with_context<T: serde::Serialize>(
164 value: &T,
165 path: impl AsRef<Path>,
166 ) -> Result<()> {
167 let path = path.as_ref();
168 let content =
169 serde_json::to_string_pretty(value).with_context(|| "Failed to serialize to JSON")?;
170 FileOps::write_file_with_context(path, content)
171 }
172}
173
174pub struct JsonOps;
176impl JsonOperations for JsonOps {}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use std::fs;
182 use tempfile::TempDir;
183
184 #[test]
185 fn test_file_operations() {
186 let temp = TempDir::new().unwrap();
187 let file_path = temp.path().join("test.txt");
188
189 FileOps::write_file_with_context(&file_path, "test content").unwrap();
191 let content = FileOps::read_file_with_context(&file_path).unwrap();
192 assert_eq!(content, "test content");
193
194 assert!(FileOps::check_exists_with_context(&file_path).unwrap());
196
197 FileOps::remove_file_with_context(&file_path).unwrap();
199 assert!(!FileOps::check_exists_with_context(&file_path).unwrap());
200 }
201
202 #[test]
203 fn test_directory_operations() {
204 let temp = TempDir::new().unwrap();
205 let dir_path = temp.path().join("test_dir").join("nested");
206
207 FileOps::create_dir_with_context(&dir_path).unwrap();
209 assert!(dir_path.exists());
210
211 let parent = temp.path().join("test_dir");
213 FileOps::remove_dir_all_with_context(&parent).unwrap();
214 assert!(!parent.exists());
215 }
216
217 #[test]
218 fn test_json_operations() {
219 let temp = TempDir::new().unwrap();
220 let json_path = temp.path().join("test.json");
221
222 #[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
223 struct TestStruct {
224 field: String,
225 number: i32,
226 }
227
228 let test_data = TestStruct {
229 field: "test".to_string(),
230 number: 42,
231 };
232
233 JsonOps::write_json_with_context(&test_data, &json_path).unwrap();
235 let loaded: TestStruct = JsonOps::read_json_with_context(&json_path).unwrap();
236 assert_eq!(loaded, test_data);
237 }
238
239 #[test]
240 fn test_read_bytes_with_context() {
241 let temp = TempDir::new().unwrap();
242 let file_path = temp.path().join("test_bytes.bin");
243 let test_bytes = b"binary\x00\x01\x02\x03data";
244
245 fs::write(&file_path, test_bytes).unwrap();
247
248 let read_bytes = FileOps::read_bytes_with_context(&file_path).unwrap();
250 assert_eq!(read_bytes, test_bytes);
251
252 let missing_path = temp.path().join("missing.bin");
254 let result = FileOps::read_bytes_with_context(&missing_path);
255 assert!(result.is_err());
256 let error_msg = result.unwrap_err().to_string();
257 assert!(error_msg.contains("Failed to read file"));
258 assert!(error_msg.contains("missing.bin"));
259 }
260
261 #[test]
262 fn test_write_bytes_with_context() {
263 let temp = TempDir::new().unwrap();
264 let file_path = temp.path().join("test_write_bytes.bin");
265 let test_bytes = b"binary\x00\x01\x02\x03data";
266
267 FileOps::write_bytes_with_context(&file_path, test_bytes).unwrap();
269
270 let read_bytes = fs::read(&file_path).unwrap();
272 assert_eq!(read_bytes, test_bytes);
273
274 let readonly_dir = temp.path().join("readonly");
276 fs::create_dir(&readonly_dir).unwrap();
277 let mut perms = fs::metadata(&readonly_dir).unwrap().permissions();
278 perms.set_readonly(true);
279 fs::set_permissions(&readonly_dir, perms).unwrap();
280
281 let readonly_file = readonly_dir.join("test.bin");
282 let result = FileOps::write_bytes_with_context(&readonly_file, test_bytes);
283
284 #[cfg(unix)]
286 {
287 use std::os::unix::fs::PermissionsExt;
288 let perms = fs::Permissions::from_mode(0o755);
289 fs::set_permissions(&readonly_dir, perms).unwrap();
290 }
291 #[cfg(not(unix))]
292 {
293 let mut perms = fs::metadata(&readonly_dir).unwrap().permissions();
294 #[allow(clippy::permissions_set_readonly_false)]
295 perms.set_readonly(false);
296 fs::set_permissions(&readonly_dir, perms).unwrap();
297 }
298
299 if let Err(err) = result {
302 let error_msg = err.to_string();
303 assert!(error_msg.contains("Failed to write file"));
304 }
305 }
306
307 #[test]
308 fn test_copy_file_with_context() {
309 let temp = TempDir::new().unwrap();
310 let source_path = temp.path().join("source.txt");
311 let dest_path = temp.path().join("destination.txt");
312 let test_content = "file copy test content";
313
314 fs::write(&source_path, test_content).unwrap();
316
317 let bytes_copied = FileOps::copy_file_with_context(&source_path, &dest_path).unwrap();
319 assert_eq!(bytes_copied, test_content.len() as u64);
320
321 let copied_content = fs::read_to_string(&dest_path).unwrap();
323 assert_eq!(copied_content, test_content);
324
325 let missing_source = temp.path().join("missing_source.txt");
327 let another_dest = temp.path().join("another_dest.txt");
328 let result = FileOps::copy_file_with_context(&missing_source, &another_dest);
329 assert!(result.is_err());
330 let error_msg = result.unwrap_err().to_string();
331 assert!(error_msg.contains("Failed to copy file"));
332 assert!(error_msg.contains("missing_source.txt"));
333
334 let nonexistent_dest = temp.path().join("nonexistent").join("dest.txt");
336 let result = FileOps::copy_file_with_context(&source_path, &nonexistent_dest);
337 assert!(result.is_err());
338 let error_msg = result.unwrap_err().to_string();
339 assert!(error_msg.contains("Failed to copy file"));
340 }
341
342 #[test]
343 fn test_manifest_operations_load() {
344 let temp = TempDir::new().unwrap();
345 let manifest_path = temp.path().join("agpm.toml");
346
347 let manifest_content = r#"
349[sources]
350test = "https://github.com/test/test.git"
351
352[agents]
353test-agent = { source = "test", path = "agents/test.md", version = "v1.0.0" }
354"#;
355 fs::write(&manifest_path, manifest_content).unwrap();
356
357 let manifest = ManifestOps::load_manifest_with_context(&manifest_path).unwrap();
359 assert!(manifest.sources.contains_key("test"));
360 assert!(manifest.agents.contains_key("test-agent"));
361
362 let missing_path = temp.path().join("missing.toml");
364 let result = ManifestOps::load_manifest_with_context(&missing_path);
365 assert!(result.is_err());
366 let error_msg = result.unwrap_err().to_string();
367 assert!(error_msg.contains("Failed to parse manifest file"));
368 assert!(error_msg.contains("missing.toml"));
369
370 let invalid_manifest_path = temp.path().join("invalid.toml");
372 let invalid_content = "this is not valid toml [[[";
373 fs::write(&invalid_manifest_path, invalid_content).unwrap();
374 let result = ManifestOps::load_manifest_with_context(&invalid_manifest_path);
375 assert!(result.is_err());
376 let error_msg = result.unwrap_err().to_string();
377 assert!(error_msg.contains("Failed to parse manifest file"));
378 }
379
380 #[test]
381 fn test_manifest_operations_save() {
382 let temp = TempDir::new().unwrap();
383 let manifest_path = temp.path().join("test_save.toml");
384
385 let manifest = crate::manifest::Manifest::new();
387
388 ManifestOps::save_manifest_with_context(&manifest, &manifest_path).unwrap();
390 assert!(manifest_path.exists());
391
392 let loaded_manifest = ManifestOps::load_manifest_with_context(&manifest_path).unwrap();
394 assert_eq!(manifest.sources.len(), loaded_manifest.sources.len());
395 assert_eq!(manifest.agents.len(), loaded_manifest.agents.len());
396 }
397
398 #[test]
399 fn test_markdown_operations_parse() {
400 let temp = TempDir::new().unwrap();
401 let md_path = temp.path().join("test.md");
402
403 let markdown_content = r#"---
405title: "Test Agent"
406version: "1.0.0"
407---
408
409# Test Agent
410
411This is a test agent.
412"#;
413
414 let markdown =
415 MarkdownOps::parse_markdown_with_context(markdown_content, &md_path).unwrap();
416 assert_eq!(markdown.content.trim(), "# Test Agent\n\nThis is a test agent.");
417 assert!(markdown.get_title().is_some());
418 assert_eq!(markdown.get_title().unwrap(), "Test Agent");
419
420 let simple_content = "# Simple Agent\n\nThis is simple.";
422 let simple_markdown =
423 MarkdownOps::parse_markdown_with_context(simple_content, &md_path).unwrap();
424 assert_eq!(simple_markdown.content.trim(), "# Simple Agent\n\nThis is simple.");
425 assert_eq!(simple_markdown.get_title().unwrap(), "Simple Agent");
427
428 let plain_content = "This is plain content without headings.";
430 let plain_markdown =
431 MarkdownOps::parse_markdown_with_context(plain_content, &md_path).unwrap();
432 assert_eq!(plain_markdown.content.trim(), "This is plain content without headings.");
433 assert!(plain_markdown.get_title().is_none());
434
435 let invalid_content = r#"---
437title: "Test Agent
438invalid yaml here
439---
440
441# Test Agent
442"#;
443 let result = MarkdownOps::parse_markdown_with_context(invalid_content, &md_path);
445 assert!(result.is_ok());
446 let markdown = result.unwrap();
447 assert!(markdown.metadata.is_none());
449 assert!(markdown.content.contains("---"));
450 assert!(markdown.content.contains("title: \"Test Agent"));
451 assert!(markdown.content.contains("# Test Agent"));
452 }
453
454 #[test]
455 fn test_markdown_operations_read() {
456 let temp = TempDir::new().unwrap();
457 let md_path = temp.path().join("test_read.md");
458
459 let markdown_content = r#"---
461title: "Test Agent"
462version: "1.0.0"
463---
464
465# Test Agent
466
467This is a test agent for reading.
468"#;
469 fs::write(&md_path, markdown_content).unwrap();
470
471 let markdown = MarkdownOps::read_markdown_with_context(&md_path).unwrap();
473 assert_eq!(markdown.get_title().unwrap(), "Test Agent");
474 assert!(markdown.content.contains("This is a test agent for reading"));
475
476 let missing_path = temp.path().join("missing.md");
478 let result = MarkdownOps::read_markdown_with_context(&missing_path);
479 assert!(result.is_err());
480 let error_msg = result.unwrap_err().to_string();
481 assert!(error_msg.contains("Failed to read file"));
482 assert!(error_msg.contains("missing.md"));
483 }
484
485 #[test]
486 fn test_lockfile_operations_load() {
487 let temp = TempDir::new().unwrap();
488 let lockfile_path = temp.path().join("agpm.lock");
489
490 let lockfile = LockfileOps::load_lockfile_with_context(&lockfile_path).unwrap();
492 assert_eq!(lockfile.version, 1);
493 assert!(lockfile.sources.is_empty());
494
495 let lockfile_content = r#"# Auto-generated lockfile - DO NOT EDIT
497version = 1
498
499[[sources]]
500name = "test"
501url = "https://github.com/test/test.git"
502commit = "abc123"
503fetched_at = "2024-01-01T00:00:00Z"
504"#;
505 fs::write(&lockfile_path, lockfile_content).unwrap();
506
507 let loaded_lockfile = LockfileOps::load_lockfile_with_context(&lockfile_path).unwrap();
509 assert_eq!(loaded_lockfile.version, 1);
510 assert!(!loaded_lockfile.sources.is_empty());
511
512 let invalid_lockfile_path = temp.path().join("invalid.lock");
514 let invalid_content = "this is not valid toml [[[";
515 fs::write(&invalid_lockfile_path, invalid_content).unwrap();
516 let result = LockfileOps::load_lockfile_with_context(&invalid_lockfile_path);
517 assert!(result.is_err());
518 let error_msg = result.unwrap_err().to_string();
519 assert!(error_msg.contains("Failed to load lockfile"));
520 assert!(error_msg.contains("invalid.lock"));
521 }
522
523 #[test]
524 fn test_lockfile_operations_save() {
525 let temp = TempDir::new().unwrap();
526 let lockfile_path = temp.path().join("test_save.lock");
527
528 let lockfile = crate::lockfile::LockFile::new();
530
531 LockfileOps::save_lockfile_with_context(&lockfile, &lockfile_path).unwrap();
533 assert!(lockfile_path.exists());
534
535 let content = fs::read_to_string(&lockfile_path).unwrap();
537 assert!(content.contains("Auto-generated lockfile"));
538 assert!(content.contains("version = 1"));
539
540 let loaded_lockfile = LockfileOps::load_lockfile_with_context(&lockfile_path).unwrap();
542 assert_eq!(lockfile.version, loaded_lockfile.version);
543 }
544
545 #[test]
546 fn test_json_operations_error_cases() {
547 let temp = TempDir::new().unwrap();
548
549 let missing_json = temp.path().join("missing.json");
551 let result: Result<serde_json::Value> = JsonOps::read_json_with_context(&missing_json);
552 assert!(result.is_err());
553 let error_msg = result.unwrap_err().to_string();
554 assert!(error_msg.contains("Failed to read file"));
555 assert!(error_msg.contains("missing.json"));
556
557 let invalid_json_path = temp.path().join("invalid.json");
559 let invalid_json = r#"{ "field": "value" invalid json }"#;
560 fs::write(&invalid_json_path, invalid_json).unwrap();
561
562 let result: Result<serde_json::Value> = JsonOps::read_json_with_context(&invalid_json_path);
563 assert!(result.is_err());
564 let error_msg = result.unwrap_err().to_string();
565 assert!(error_msg.contains("Failed to parse JSON file"));
566 assert!(error_msg.contains("invalid.json"));
567
568 let json_path = temp.path().join("test_write_error.json");
572 let test_data = serde_json::json!({"test": "value"});
573
574 JsonOps::write_json_with_context(&test_data, &json_path).unwrap();
575 assert!(json_path.exists());
576
577 let loaded: serde_json::Value = JsonOps::read_json_with_context(&json_path).unwrap();
578 assert_eq!(loaded, test_data);
579 }
580
581 #[test]
582 fn test_file_operations_error_contexts() {
583 let temp = TempDir::new().unwrap();
584
585 let missing_file = temp.path().join("missing.txt");
587 let result = FileOps::read_file_with_context(&missing_file);
588 assert!(result.is_err());
589 let error_msg = result.unwrap_err().to_string();
590 assert!(error_msg.contains("Failed to read file"));
591 assert!(error_msg.contains("missing.txt"));
592
593 let readonly_dir = temp.path().join("readonly");
595 fs::create_dir(&readonly_dir).unwrap();
596 let mut perms = fs::metadata(&readonly_dir).unwrap().permissions();
597 perms.set_readonly(true);
598 fs::set_permissions(&readonly_dir, perms).unwrap();
599
600 let readonly_file = readonly_dir.join("test.txt");
601 let result = FileOps::write_file_with_context(&readonly_file, "test");
602
603 #[cfg(unix)]
605 {
606 use std::os::unix::fs::PermissionsExt;
607 let perms = fs::Permissions::from_mode(0o755);
608 fs::set_permissions(&readonly_dir, perms).unwrap();
609 }
610 #[cfg(not(unix))]
611 {
612 let mut perms = fs::metadata(&readonly_dir).unwrap().permissions();
613 #[allow(clippy::permissions_set_readonly_false)]
614 perms.set_readonly(false);
615 fs::set_permissions(&readonly_dir, perms).unwrap();
616 }
617
618 if let Err(err) = result {
619 let error_msg = err.to_string();
620 assert!(error_msg.contains("Failed to write file"));
621 }
622
623 let nested_dir = temp.path().join("nested").join("deep");
626 FileOps::create_dir_with_context(&nested_dir).unwrap();
627 assert!(nested_dir.exists());
628
629 let nonexistent_file = temp.path().join("nonexistent.txt");
631 let result = FileOps::remove_file_with_context(&nonexistent_file);
632 assert!(result.is_err());
633 let error_msg = result.unwrap_err().to_string();
634 assert!(error_msg.contains("Failed to remove file"));
635 assert!(error_msg.contains("nonexistent.txt"));
636
637 let existing_file = temp.path().join("existing.txt");
639 fs::write(&existing_file, "test").unwrap();
640 assert!(FileOps::check_exists_with_context(&existing_file).unwrap());
641 assert!(!FileOps::check_exists_with_context(&nonexistent_file).unwrap());
642 }
643}