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() -> anyhow::Result<()> {
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 markdown = MarkdownOps::parse_markdown_with_context(invalid_content, &md_path)?;
445 assert!(markdown.metadata.is_none());
447 assert!(markdown.content.contains("---"));
448 assert!(markdown.content.contains("title: \"Test Agent"));
449 assert!(markdown.content.contains("# Test Agent"));
450 Ok(())
451 }
452
453 #[test]
454 fn test_markdown_operations_read() {
455 let temp = TempDir::new().unwrap();
456 let md_path = temp.path().join("test_read.md");
457
458 let markdown_content = r#"---
460title: "Test Agent"
461version: "1.0.0"
462---
463
464# Test Agent
465
466This is a test agent for reading.
467"#;
468 fs::write(&md_path, markdown_content).unwrap();
469
470 let markdown = MarkdownOps::read_markdown_with_context(&md_path).unwrap();
472 assert_eq!(markdown.get_title().unwrap(), "Test Agent");
473 assert!(markdown.content.contains("This is a test agent for reading"));
474
475 let missing_path = temp.path().join("missing.md");
477 let result = MarkdownOps::read_markdown_with_context(&missing_path);
478 assert!(result.is_err());
479 let error_msg = result.unwrap_err().to_string();
480 assert!(error_msg.contains("Failed to read file"));
481 assert!(error_msg.contains("missing.md"));
482 }
483
484 #[test]
485 fn test_lockfile_operations_load() {
486 let temp = TempDir::new().unwrap();
487 let lockfile_path = temp.path().join("agpm.lock");
488
489 let lockfile = LockfileOps::load_lockfile_with_context(&lockfile_path).unwrap();
491 assert_eq!(lockfile.version, 1);
492 assert!(lockfile.sources.is_empty());
493
494 let lockfile_content = r#"# Auto-generated lockfile - DO NOT EDIT
496version = 1
497
498[[sources]]
499name = "test"
500url = "https://github.com/test/test.git"
501commit = "abc123"
502fetched_at = "2024-01-01T00:00:00Z"
503"#;
504 fs::write(&lockfile_path, lockfile_content).unwrap();
505
506 let loaded_lockfile = LockfileOps::load_lockfile_with_context(&lockfile_path).unwrap();
508 assert_eq!(loaded_lockfile.version, 1);
509 assert!(!loaded_lockfile.sources.is_empty());
510
511 let invalid_lockfile_path = temp.path().join("invalid.lock");
513 let invalid_content = "this is not valid toml [[[";
514 fs::write(&invalid_lockfile_path, invalid_content).unwrap();
515 let result = LockfileOps::load_lockfile_with_context(&invalid_lockfile_path);
516 assert!(result.is_err());
517 let error_msg = result.unwrap_err().to_string();
518 assert!(error_msg.contains("Failed to load lockfile"));
519 assert!(error_msg.contains("invalid.lock"));
520 }
521
522 #[test]
523 fn test_lockfile_operations_save() {
524 let temp = TempDir::new().unwrap();
525 let lockfile_path = temp.path().join("test_save.lock");
526
527 let lockfile = crate::lockfile::LockFile::new();
529
530 LockfileOps::save_lockfile_with_context(&lockfile, &lockfile_path).unwrap();
532 assert!(lockfile_path.exists());
533
534 let content = fs::read_to_string(&lockfile_path).unwrap();
536 assert!(content.contains("Auto-generated lockfile"));
537 assert!(content.contains("version = 1"));
538
539 let loaded_lockfile = LockfileOps::load_lockfile_with_context(&lockfile_path).unwrap();
541 assert_eq!(lockfile.version, loaded_lockfile.version);
542 }
543
544 #[test]
545 fn test_json_operations_error_cases() {
546 let temp = TempDir::new().unwrap();
547
548 let missing_json = temp.path().join("missing.json");
550 let result: Result<serde_json::Value> = JsonOps::read_json_with_context(&missing_json);
551 assert!(result.is_err());
552 let error_msg = result.unwrap_err().to_string();
553 assert!(error_msg.contains("Failed to read file"));
554 assert!(error_msg.contains("missing.json"));
555
556 let invalid_json_path = temp.path().join("invalid.json");
558 let invalid_json = r#"{ "field": "value" invalid json }"#;
559 fs::write(&invalid_json_path, invalid_json).unwrap();
560
561 let result: Result<serde_json::Value> = JsonOps::read_json_with_context(&invalid_json_path);
562 assert!(result.is_err());
563 let error_msg = result.unwrap_err().to_string();
564 assert!(error_msg.contains("Failed to parse JSON file"));
565 assert!(error_msg.contains("invalid.json"));
566
567 let json_path = temp.path().join("test_write_error.json");
571 let test_data = serde_json::json!({"test": "value"});
572
573 JsonOps::write_json_with_context(&test_data, &json_path).unwrap();
574 assert!(json_path.exists());
575
576 let loaded: serde_json::Value = JsonOps::read_json_with_context(&json_path).unwrap();
577 assert_eq!(loaded, test_data);
578 }
579
580 #[test]
581 fn test_file_operations_error_contexts() {
582 let temp = TempDir::new().unwrap();
583
584 let missing_file = temp.path().join("missing.txt");
586 let result = FileOps::read_file_with_context(&missing_file);
587 assert!(result.is_err());
588 let error_msg = result.unwrap_err().to_string();
589 assert!(error_msg.contains("Failed to read file"));
590 assert!(error_msg.contains("missing.txt"));
591
592 let readonly_dir = temp.path().join("readonly");
594 fs::create_dir(&readonly_dir).unwrap();
595 let mut perms = fs::metadata(&readonly_dir).unwrap().permissions();
596 perms.set_readonly(true);
597 fs::set_permissions(&readonly_dir, perms).unwrap();
598
599 let readonly_file = readonly_dir.join("test.txt");
600 let result = FileOps::write_file_with_context(&readonly_file, "test");
601
602 #[cfg(unix)]
604 {
605 use std::os::unix::fs::PermissionsExt;
606 let perms = fs::Permissions::from_mode(0o755);
607 fs::set_permissions(&readonly_dir, perms).unwrap();
608 }
609 #[cfg(not(unix))]
610 {
611 let mut perms = fs::metadata(&readonly_dir).unwrap().permissions();
612 #[allow(clippy::permissions_set_readonly_false)]
613 perms.set_readonly(false);
614 fs::set_permissions(&readonly_dir, perms).unwrap();
615 }
616
617 if let Err(err) = result {
618 let error_msg = err.to_string();
619 assert!(error_msg.contains("Failed to write file"));
620 }
621
622 let nested_dir = temp.path().join("nested").join("deep");
625 FileOps::create_dir_with_context(&nested_dir).unwrap();
626 assert!(nested_dir.exists());
627
628 let nonexistent_file = temp.path().join("nonexistent.txt");
630 let result = FileOps::remove_file_with_context(&nonexistent_file);
631 assert!(result.is_err());
632 let error_msg = result.unwrap_err().to_string();
633 assert!(error_msg.contains("Failed to remove file"));
634 assert!(error_msg.contains("nonexistent.txt"));
635
636 let existing_file = temp.path().join("existing.txt");
638 fs::write(&existing_file, "test").unwrap();
639 assert!(FileOps::check_exists_with_context(&existing_file).unwrap());
640 assert!(!FileOps::check_exists_with_context(&nonexistent_file).unwrap());
641 }
642}