Skip to main content

ralph_workflow/files/io/
backup.rs

1//! Backup management for PROMPT.md.
2//!
3//! This module handles creation and rotation of PROMPT.md backups to protect
4//! against accidental deletion or modification.
5
6use std::fs;
7use std::io;
8use std::path::Path;
9
10use super::integrity;
11use crate::workspace::Workspace;
12
13/// Create a backup of PROMPT.md to protect against accidental deletion.
14///
15/// This function copies PROMPT.md to `.agent/PROMPT.md.backup` and sets
16/// the backup file to read-only permissions to make accidental deletion harder.
17///
18/// With backup rotation enabled (the default), this maintains up to 3 backup
19/// versions: `.agent/PROMPT.md.backup`, `.agent/PROMPT.md.backup.1`, and
20/// `.agent/PROMPT.md.backup.2`.
21///
22/// # Behavior
23///
24/// - If PROMPT.md doesn't exist, returns `Ok(())` (nothing to backup)
25/// - Creates the `.agent` directory if it doesn't exist
26/// - Rotates existing backups: backup.2 → deleted, backup.1 → backup.2, backup → backup.1
27/// - Uses atomic write to ensure backup file integrity
28/// - Sets all backup files to read-only (best-effort; failures don't error)
29/// - Returns a warning string in the Ok variant if read-only setting fails
30///
31/// # Returns
32///
33/// Returns `io::Result<Option<String>>` where:
34/// - `Ok(None)` - backup created and read-only set successfully
35/// - `Ok(Some(warning))` - backup created but read-only couldn't be set
36/// - `Err(e)` - if the backup cannot be created
37///
38/// **Note:** This function uses the current working directory for paths.
39/// For explicit path control, use [`create_prompt_backup_at`] instead.
40pub fn create_prompt_backup() -> io::Result<Option<String>> {
41    create_prompt_backup_at(Path::new("."))
42}
43
44/// Create a backup of PROMPT.md at a specific repository path.
45///
46/// # Arguments
47///
48/// * `repo_root` - Path to the repository root
49///
50/// # Returns
51///
52/// Returns `io::Result<Option<String>>` where:
53/// - `Ok(None)` - backup created and read-only set successfully
54/// - `Ok(Some(warning))` - backup created but read-only couldn't be set
55/// - `Err(e)` - if the backup cannot be created
56pub fn create_prompt_backup_at(repo_root: &Path) -> io::Result<Option<String>> {
57    let prompt_path = repo_root.join("PROMPT.md");
58
59    // If PROMPT.md doesn't exist, that's fine - nothing to backup
60    if !prompt_path.exists() {
61        return Ok(None);
62    }
63
64    // Ensure .agent directory exists
65    let agent_dir = repo_root.join(".agent");
66    let backup_base = agent_dir.join("PROMPT.md.backup");
67    fs::create_dir_all(&agent_dir)?;
68
69    // Read PROMPT.md content
70    let content = fs::read_to_string(&prompt_path).map_err(|e| {
71        io::Error::new(
72            e.kind(),
73            format!("Failed to read PROMPT.md for backup: {e}"),
74        )
75    })?;
76
77    // Backup rotation: .2 → deleted, .1 → .2, .backup → .1
78    let backup_2 = agent_dir.join("PROMPT.md.backup.2");
79    let backup_1 = agent_dir.join("PROMPT.md.backup.1");
80
81    // Delete oldest backup if it exists
82    let _ = fs::remove_file(&backup_2);
83
84    // Rotate .1 → .2
85    if backup_1.exists() {
86        let _ = fs::rename(&backup_1, &backup_2);
87    }
88
89    // Rotate .backup → .1
90    if backup_base.exists() {
91        let _ = fs::rename(&backup_base, &backup_1);
92    }
93
94    // Write new backup atomically
95    integrity::write_file_atomic(&backup_base, &content)
96        .map_err(|e| io::Error::new(e.kind(), format!("Failed to write PROMPT.md backup: {e}")))?;
97
98    // Set read-only permissions on all backups and track any failure
99    let mut readonly_warning = None;
100
101    // Helper to set read-only permissions on a file
102    let set_readonly = |path: &Path| -> io::Result<()> {
103        #[cfg(unix)]
104        {
105            use std::os::unix::fs::PermissionsExt;
106            if let Ok(metadata) = fs::metadata(path) {
107                let mut perms = metadata.permissions();
108                perms.set_mode(0o444);
109                if fs::set_permissions(path, perms).is_err() {
110                    return Err(io::Error::new(
111                        io::ErrorKind::PermissionDenied,
112                        format!("Failed to set read-only on {}", path.display()),
113                    ));
114                }
115            }
116        }
117
118        #[cfg(windows)]
119        {
120            if let Ok(metadata) = fs::metadata(path) {
121                let mut perms = metadata.permissions();
122                perms.set_readonly(true);
123                if fs::set_permissions(path, perms).is_err() {
124                    return Err(io::Error::new(
125                        io::ErrorKind::PermissionDenied,
126                        format!("Failed to set read-only on {:?}", path),
127                    ));
128                }
129            }
130        }
131        Ok(())
132    };
133
134    // Try to set read-only on all backup files (best-effort)
135    for backup_path in [&backup_base, &backup_1, &backup_2] {
136        if backup_path.exists() {
137            if let Err(e) = set_readonly(backup_path) {
138                if readonly_warning.is_none() {
139                    readonly_warning = Some(e.to_string());
140                }
141            }
142        }
143    }
144
145    Ok(readonly_warning)
146}
147
148/// Make PROMPT.md read-only to protect against accidental deletion.
149///
150/// This function sets read-only permissions on PROMPT.md to make accidental
151/// deletion harder. This is a best-effort protection - agents with shell
152/// access could potentially chmod the file.
153///
154/// # Behavior
155///
156/// - If PROMPT.md doesn't exist, returns `Ok(None)` (nothing to protect)
157/// - Uses platform-specific permission setting
158/// - Returns a warning string if setting permissions fails (best-effort)
159///
160/// # Returns
161///
162/// Returns `Ok(Option<String>)` where:
163/// - `Ok(None)` - permissions set successfully or file doesn't exist
164/// - `Ok(Some(warning))` - couldn't set read-only permissions
165///
166/// **Note:** This function uses the current working directory for paths.
167/// For explicit path control, use [`make_prompt_read_only_at`] instead.
168pub fn make_prompt_read_only() -> Option<String> {
169    make_prompt_read_only_at(Path::new("."))
170}
171
172/// Make PROMPT.md read-only at a specific repository path.
173///
174/// # Arguments
175///
176/// * `repo_root` - Path to the repository root
177///
178/// # Returns
179///
180/// Returns `Option<String>` where:
181/// - `None` - permissions set successfully or file doesn't exist
182/// - `Some(warning)` - couldn't set read-only permissions
183pub fn make_prompt_read_only_at(repo_root: &Path) -> Option<String> {
184    let prompt_path = repo_root.join("PROMPT.md");
185
186    // If PROMPT.md doesn't exist, that's fine - nothing to protect
187    if !prompt_path.exists() {
188        return None;
189    }
190
191    // Try to set read-only permissions and track any failure
192    let mut readonly_warning = None;
193
194    #[cfg(unix)]
195    {
196        use std::os::unix::fs::PermissionsExt;
197        match fs::metadata(&prompt_path) {
198            Ok(metadata) => {
199                let mut perms = metadata.permissions();
200                perms.set_mode(0o444); // Read-only for all
201                if fs::set_permissions(&prompt_path, perms).is_err() {
202                    readonly_warning =
203                        Some("Failed to set read-only permissions on PROMPT.md".to_string());
204                }
205            }
206            Err(_) => {
207                readonly_warning = Some("Failed to read PROMPT.md metadata".to_string());
208            }
209        }
210    }
211
212    #[cfg(windows)]
213    {
214        match fs::metadata(&prompt_path) {
215            Ok(metadata) => {
216                let mut perms = metadata.permissions();
217                perms.set_readonly(true);
218                if fs::set_permissions(&prompt_path, perms).is_err() {
219                    readonly_warning =
220                        Some("Failed to set read-only permissions on PROMPT.md".to_string());
221                }
222            }
223            Err(_) => {
224                readonly_warning = Some("Failed to read PROMPT.md metadata".to_string());
225            }
226        }
227    }
228
229    readonly_warning
230}
231
232/// Make PROMPT.md writable again after pipeline completion.
233///
234/// This function restores write permissions on PROMPT.md after Ralph exits.
235/// It reverses the `make_prompt_read_only` operation so users can edit the file
236/// normally when Ralph is not running.
237///
238/// # Behavior
239///
240/// - If PROMPT.md doesn't exist, returns `None` (nothing to modify)
241/// - Uses platform-specific permission setting
242/// - Returns a warning string if setting permissions fails (best-effort)
243///
244/// # Returns
245///
246/// Returns `Option<String>` where:
247/// - `None` - permissions restored successfully or file doesn't exist
248/// - `Some(warning)` - couldn't restore write permissions
249///
250/// **Note:** This function uses the current working directory for paths.
251/// For explicit path control, use [`make_prompt_writable_at`] instead.
252pub fn make_prompt_writable() -> Option<String> {
253    make_prompt_writable_at(Path::new("."))
254}
255
256/// Make PROMPT.md writable again at a specific repository path.
257///
258/// # Arguments
259///
260/// * `repo_root` - Path to the repository root
261///
262/// # Returns
263///
264/// Returns `Option<String>` where:
265/// - `None` - permissions restored successfully or file doesn't exist
266/// - `Some(warning)` - couldn't restore write permissions
267pub fn make_prompt_writable_at(repo_root: &Path) -> Option<String> {
268    let prompt_path = repo_root.join("PROMPT.md");
269
270    // If PROMPT.md doesn't exist, that's fine - nothing to modify
271    if !prompt_path.exists() {
272        return None;
273    }
274
275    // Try to restore write permissions and track any failure
276    let mut writable_warning = None;
277
278    #[cfg(unix)]
279    {
280        use std::os::unix::fs::PermissionsExt;
281        match fs::metadata(&prompt_path) {
282            Ok(metadata) => {
283                let mut perms = metadata.permissions();
284                perms.set_mode(0o644); // Owner read-write, group/others read-only
285                if fs::set_permissions(&prompt_path, perms).is_err() {
286                    writable_warning =
287                        Some("Failed to set write permissions on PROMPT.md".to_string());
288                }
289            }
290            Err(_) => {
291                writable_warning = Some("Failed to read PROMPT.md metadata".to_string());
292            }
293        }
294    }
295
296    #[cfg(windows)]
297    {
298        match fs::metadata(&prompt_path) {
299            Ok(metadata) => {
300                let mut perms = metadata.permissions();
301                perms.set_readonly(false);
302                if fs::set_permissions(&prompt_path, perms).is_err() {
303                    writable_warning =
304                        Some("Failed to set write permissions on PROMPT.md".to_string());
305                }
306            }
307            Err(_) => {
308                writable_warning = Some("Failed to read PROMPT.md metadata".to_string());
309            }
310        }
311    }
312
313    writable_warning
314}
315
316// ============================================================================
317// Workspace-based backup functions
318// ============================================================================
319
320/// Create a backup of PROMPT.md using the Workspace abstraction.
321///
322/// This function mirrors `create_prompt_backup_at` but uses the `Workspace` trait
323/// for all file operations, allowing tests to use `MemoryWorkspace` instead of
324/// real filesystem access.
325///
326/// With backup rotation enabled, this maintains up to 3 backup versions:
327/// `.agent/PROMPT.md.backup`, `.agent/PROMPT.md.backup.1`, and `.agent/PROMPT.md.backup.2`.
328///
329/// # Behavior
330///
331/// - If PROMPT.md doesn't exist, returns `Ok(None)` (nothing to backup)
332/// - Creates the `.agent` directory if it doesn't exist
333/// - Rotates existing backups: backup.2 → deleted, backup.1 → backup.2, backup → backup.1
334/// - Sets all backup files to read-only (best-effort; failures don't error)
335/// - Returns a warning string in the Ok variant if read-only setting fails
336///
337/// # Returns
338///
339/// Returns `io::Result<Option<String>>` where:
340/// - `Ok(None)` - backup created and read-only set successfully
341/// - `Ok(Some(warning))` - backup created but read-only couldn't be set
342/// - `Err(e)` - if the backup cannot be created
343pub fn create_prompt_backup_with_workspace(
344    workspace: &dyn Workspace,
345) -> io::Result<Option<String>> {
346    let prompt_path = Path::new("PROMPT.md");
347
348    // If PROMPT.md doesn't exist, that's fine - nothing to backup
349    if !workspace.exists(prompt_path) {
350        return Ok(None);
351    }
352
353    // Ensure .agent directory exists
354    let agent_dir = Path::new(".agent");
355    let backup_base = Path::new(".agent/PROMPT.md.backup");
356    let backup_1 = Path::new(".agent/PROMPT.md.backup.1");
357    let backup_2 = Path::new(".agent/PROMPT.md.backup.2");
358
359    workspace.create_dir_all(agent_dir)?;
360
361    // Read PROMPT.md content
362    let content = workspace.read(prompt_path).map_err(|e| {
363        io::Error::new(
364            e.kind(),
365            format!("Failed to read PROMPT.md for backup: {e}"),
366        )
367    })?;
368
369    // Backup rotation: .2 → deleted, .1 → .2, .backup → .1
370    // Delete oldest backup if it exists
371    let _ = workspace.remove_if_exists(backup_2);
372
373    // Rotate .1 → .2
374    if workspace.exists(backup_1) {
375        let _ = workspace.rename(backup_1, backup_2);
376    }
377
378    // Rotate .backup → .1
379    if workspace.exists(backup_base) {
380        let _ = workspace.rename(backup_base, backup_1);
381    }
382
383    // Write new backup atomically to prevent corruption
384    workspace
385        .write_atomic(backup_base, &content)
386        .map_err(|e| io::Error::new(e.kind(), format!("Failed to write PROMPT.md backup: {e}")))?;
387
388    // Set read-only permissions on all backups (best-effort)
389    let mut readonly_warning = None;
390
391    for backup_path in [backup_base, backup_1, backup_2] {
392        if workspace.exists(backup_path) {
393            if let Err(e) = workspace.set_readonly(backup_path) {
394                if readonly_warning.is_none() {
395                    readonly_warning = Some(e.to_string());
396                }
397            }
398        }
399    }
400
401    Ok(readonly_warning)
402}
403
404/// Make PROMPT.md read-only using the Workspace abstraction.
405///
406/// This function mirrors `make_prompt_read_only_at` but uses the `Workspace` trait
407/// for all file operations.
408///
409/// # Returns
410///
411/// Returns `Option<String>` where:
412/// - `None` - permissions set successfully or file doesn't exist
413/// - `Some(warning)` - couldn't set read-only permissions
414pub fn make_prompt_read_only_with_workspace(workspace: &dyn Workspace) -> Option<String> {
415    let prompt_path = Path::new("PROMPT.md");
416
417    // If PROMPT.md doesn't exist, that's fine - nothing to protect
418    if !workspace.exists(prompt_path) {
419        return None;
420    }
421
422    // Try to set read-only permissions
423    match workspace.set_readonly(prompt_path) {
424        Ok(()) => None,
425        Err(e) => Some(format!(
426            "Failed to set read-only permissions on PROMPT.md: {e}"
427        )),
428    }
429}
430
431/// Make PROMPT.md writable again using the Workspace abstraction.
432///
433/// This function mirrors `make_prompt_writable_at` but uses the `Workspace` trait
434/// for all file operations.
435///
436/// # Returns
437///
438/// Returns `Option<String>` where:
439/// - `None` - permissions restored successfully or file doesn't exist
440/// - `Some(warning)` - couldn't restore write permissions
441pub fn make_prompt_writable_with_workspace(workspace: &dyn Workspace) -> Option<String> {
442    let prompt_path = Path::new("PROMPT.md");
443
444    // If PROMPT.md doesn't exist, that's fine - nothing to modify
445    if !workspace.exists(prompt_path) {
446        return None;
447    }
448
449    // Try to restore write permissions
450    match workspace.set_writable(prompt_path) {
451        Ok(()) => None,
452        Err(e) => Some(format!("Failed to set write permissions on PROMPT.md: {e}")),
453    }
454}
455
456// ============================================================================
457// Diff backup functions for oversized content
458// ============================================================================
459
460/// Path for diff backup file.
461const DIFF_BACKUP_PATH: &str = ".agent/DIFF.backup";
462
463/// Write oversized diff content to a backup file.
464///
465/// When a diff exceeds the inline size limit, this function writes it
466/// to `.agent/DIFF.backup` so agents can read it if needed.
467///
468/// # Arguments
469///
470/// * `workspace` - Workspace for file operations
471/// * `diff_content` - The diff content to write
472///
473/// # Returns
474///
475/// Returns `Ok(PathBuf)` with the backup path on success, or an error.
476pub fn write_diff_backup_with_workspace(
477    workspace: &dyn Workspace,
478    diff_content: &str,
479) -> io::Result<std::path::PathBuf> {
480    let backup_path = Path::new(DIFF_BACKUP_PATH);
481
482    // Ensure .agent directory exists
483    workspace.create_dir_all(Path::new(".agent"))?;
484
485    // Write the diff content
486    workspace.write(backup_path, diff_content)?;
487
488    Ok(backup_path.to_path_buf())
489}
490
491// Note: Old tests using with_temp_cwd have been removed since production
492// code now uses workspace-based functions (_with_workspace variants).
493// The old std::fs functions are kept for backward compatibility but are
494// not tested here. See workspace_tests module below for the active tests.
495
496/// Tests for workspace-based backup functions
497#[cfg(all(test, feature = "test-utils"))]
498mod workspace_tests {
499    use super::*;
500    use crate::workspace::{MemoryWorkspace, Workspace};
501
502    #[test]
503    fn test_create_prompt_backup_with_workspace_creates_file() {
504        let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "# Test Content\n");
505
506        let result = create_prompt_backup_with_workspace(&workspace);
507        assert!(result.is_ok());
508
509        // Backup should exist with same content
510        assert!(workspace.exists(Path::new(".agent/PROMPT.md.backup")));
511        assert_eq!(
512            workspace.get_file(".agent/PROMPT.md.backup").unwrap(),
513            "# Test Content\n"
514        );
515    }
516
517    #[test]
518    fn test_create_prompt_backup_with_workspace_missing_prompt() {
519        let workspace = MemoryWorkspace::new_test();
520        // No PROMPT.md exists
521
522        let result = create_prompt_backup_with_workspace(&workspace);
523        assert!(result.is_ok());
524        assert!(result.unwrap().is_none()); // No warning
525
526        // No backup should be created
527        assert!(!workspace.exists(Path::new(".agent/PROMPT.md.backup")));
528    }
529
530    #[test]
531    fn test_create_prompt_backup_with_workspace_rotation() {
532        let workspace = MemoryWorkspace::new_test()
533            .with_file("PROMPT.md", "# Version 1\n")
534            .with_dir(".agent");
535
536        // First backup
537        create_prompt_backup_with_workspace(&workspace).unwrap();
538        assert_eq!(
539            workspace.get_file(".agent/PROMPT.md.backup").unwrap(),
540            "# Version 1\n"
541        );
542
543        // Update PROMPT.md and create second backup
544        workspace
545            .write(Path::new("PROMPT.md"), "# Version 2\n")
546            .unwrap();
547        create_prompt_backup_with_workspace(&workspace).unwrap();
548
549        // Check rotation: .backup has v2, .backup.1 has v1
550        assert_eq!(
551            workspace.get_file(".agent/PROMPT.md.backup").unwrap(),
552            "# Version 2\n"
553        );
554        assert_eq!(
555            workspace.get_file(".agent/PROMPT.md.backup.1").unwrap(),
556            "# Version 1\n"
557        );
558
559        // Third backup
560        workspace
561            .write(Path::new("PROMPT.md"), "# Version 3\n")
562            .unwrap();
563        create_prompt_backup_with_workspace(&workspace).unwrap();
564
565        // Check: .backup=v3, .backup.1=v2, .backup.2=v1
566        assert_eq!(
567            workspace.get_file(".agent/PROMPT.md.backup").unwrap(),
568            "# Version 3\n"
569        );
570        assert_eq!(
571            workspace.get_file(".agent/PROMPT.md.backup.1").unwrap(),
572            "# Version 2\n"
573        );
574        assert_eq!(
575            workspace.get_file(".agent/PROMPT.md.backup.2").unwrap(),
576            "# Version 1\n"
577        );
578    }
579
580    #[test]
581    fn test_create_prompt_backup_with_workspace_deletes_oldest() {
582        let workspace = MemoryWorkspace::new_test().with_dir(".agent");
583
584        // Create 4 backups - oldest (v1) should be deleted
585        for i in 1..=4 {
586            workspace
587                .write(Path::new("PROMPT.md"), &format!("# Version {i}\n"))
588                .unwrap();
589            create_prompt_backup_with_workspace(&workspace).unwrap();
590        }
591
592        // Only 3 backups should exist
593        assert!(workspace.exists(Path::new(".agent/PROMPT.md.backup")));
594        assert!(workspace.exists(Path::new(".agent/PROMPT.md.backup.1")));
595        assert!(workspace.exists(Path::new(".agent/PROMPT.md.backup.2")));
596
597        // Content: .backup=v4, .backup.1=v3, .backup.2=v2 (v1 deleted)
598        assert_eq!(
599            workspace.get_file(".agent/PROMPT.md.backup").unwrap(),
600            "# Version 4\n"
601        );
602        assert_eq!(
603            workspace.get_file(".agent/PROMPT.md.backup.1").unwrap(),
604            "# Version 3\n"
605        );
606        assert_eq!(
607            workspace.get_file(".agent/PROMPT.md.backup.2").unwrap(),
608            "# Version 2\n"
609        );
610    }
611
612    #[test]
613    fn test_make_prompt_read_only_with_workspace() {
614        let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "# Test\n");
615
616        // Should succeed (no-op for in-memory workspace, but function exists)
617        let result = make_prompt_read_only_with_workspace(&workspace);
618        assert!(result.is_none());
619    }
620
621    #[test]
622    fn test_make_prompt_read_only_with_workspace_missing() {
623        let workspace = MemoryWorkspace::new_test();
624        // No PROMPT.md
625
626        let result = make_prompt_read_only_with_workspace(&workspace);
627        assert!(result.is_none()); // No warning when file doesn't exist
628    }
629
630    #[test]
631    fn test_make_prompt_writable_with_workspace() {
632        let workspace = MemoryWorkspace::new_test().with_file("PROMPT.md", "# Test\n");
633
634        let result = make_prompt_writable_with_workspace(&workspace);
635        assert!(result.is_none());
636    }
637
638    #[test]
639    fn test_write_diff_backup_with_workspace() {
640        let workspace = MemoryWorkspace::new_test();
641        let diff = "+added\n-removed";
642
643        let result = write_diff_backup_with_workspace(&workspace, diff);
644        assert!(result.is_ok());
645
646        let path = result.unwrap();
647        assert_eq!(path, Path::new(".agent/DIFF.backup"));
648        assert_eq!(workspace.get_file(".agent/DIFF.backup").unwrap(), diff);
649    }
650
651    #[test]
652    fn test_write_diff_backup_creates_agent_dir() {
653        let workspace = MemoryWorkspace::new_test();
654        // No .agent directory exists
655
656        let diff = "some diff content";
657        let result = write_diff_backup_with_workspace(&workspace, diff);
658        assert!(result.is_ok());
659
660        // Verify .agent directory was created and file exists
661        assert!(workspace.exists(Path::new(".agent")));
662        assert!(workspace.exists(Path::new(".agent/DIFF.backup")));
663        assert_eq!(workspace.get_file(".agent/DIFF.backup").unwrap(), diff);
664    }
665
666    #[test]
667    fn test_write_diff_backup_overwrites_existing() {
668        let workspace = MemoryWorkspace::new_test().with_file(".agent/DIFF.backup", "old content");
669
670        let new_diff = "new diff content";
671        let result = write_diff_backup_with_workspace(&workspace, new_diff);
672        assert!(result.is_ok());
673
674        // Should have overwritten the old content
675        assert_eq!(workspace.get_file(".agent/DIFF.backup").unwrap(), new_diff);
676    }
677}