Skip to main content

runglass_core/
revert.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{anyhow, Context, Result};
5
6use crate::collectors::{hash_bytes, simple_unified_diff};
7use crate::storage::report_run_dir;
8use crate::{
9    FileChange, FileChangeType, RevertConflictPolicy, RevertFileState, RevertFileStatus,
10    RevertOptions, RevertPreview, RunReport,
11};
12
13pub fn render_reverse_patch(report: &RunReport) -> Result<String> {
14    let mut lines = vec![
15        "# RunGlass Reverse Patch".to_string(),
16        format!("# Receipt: {}", report.run.id),
17        format!("# Command: {}", report.run.command_display),
18        String::new(),
19    ];
20
21    let mut emitted = false;
22    for file in &report.files {
23        if !file.is_text {
24            lines.push(format!("# Skipped non-text file {}", file.path));
25            continue;
26        }
27
28        match file.change_type {
29            FileChangeType::Modified => {
30                let before = load_artifact_text(report, file.before_artifact_path.as_deref())?;
31                let after = load_artifact_text(report, file.after_artifact_path.as_deref())?;
32                let (Some(before), Some(after)) = (before, after) else {
33                    lines.push(format!("# Missing stored text snapshot for {}", file.path));
34                    continue;
35                };
36                lines.extend(git_style_patch(&file.path, Some(&after), Some(&before)));
37                emitted = true;
38            }
39            FileChangeType::Created => {
40                let after = load_artifact_text(report, file.after_artifact_path.as_deref())?;
41                let Some(after) = after else {
42                    lines.push(format!("# Missing stored text snapshot for {}", file.path));
43                    continue;
44                };
45                lines.extend(git_style_patch(&file.path, Some(&after), None));
46                emitted = true;
47            }
48            FileChangeType::Deleted => {
49                let before = load_artifact_text(report, file.before_artifact_path.as_deref())?;
50                let Some(before) = before else {
51                    lines.push(format!("# Missing stored text snapshot for {}", file.path));
52                    continue;
53                };
54                lines.extend(git_style_patch(&file.path, None, Some(&before)));
55                emitted = true;
56            }
57        }
58    }
59
60    if !emitted {
61        lines.push("# No reversible text patch content was available.".to_string());
62    }
63
64    Ok(lines.join("\n"))
65}
66
67pub fn preview_revert(
68    report: &RunReport,
69    selected_paths: Option<&[String]>,
70) -> Result<RevertPreview> {
71    let targets = select_revert_targets(report, selected_paths)?;
72    let cwd = PathBuf::from(&report.run.cwd);
73    let mut preview = RevertPreview {
74        receipt_id: report.run.id.clone(),
75        target_count: targets.len(),
76        restore_modified: 0,
77        delete_created: 0,
78        restore_deleted: 0,
79        safe: Vec::new(),
80        conflicts: Vec::new(),
81        already_reverted: Vec::new(),
82        missing_artifacts: Vec::new(),
83    };
84
85    for file in targets {
86        match file.change_type {
87            FileChangeType::Modified => preview.restore_modified += 1,
88            FileChangeType::Created => preview.delete_created += 1,
89            FileChangeType::Deleted => preview.restore_deleted += 1,
90        }
91
92        let status = evaluate_revert_status(file, &cwd)?;
93        match status.status {
94            RevertFileState::Safe => preview.safe.push(status),
95            RevertFileState::ChangedSinceReceipt => preview.conflicts.push(status),
96            RevertFileState::AlreadyReverted => preview.already_reverted.push(status),
97            RevertFileState::MissingArtifacts => preview.missing_artifacts.push(status),
98        }
99    }
100
101    Ok(preview)
102}
103
104pub fn apply_revert(
105    report: &RunReport,
106    selected_paths: Option<&[String]>,
107    options: RevertOptions,
108) -> Result<RevertPreview> {
109    let preview = preview_revert(report, selected_paths)?;
110    if !preview.missing_artifacts.is_empty() {
111        return Err(anyhow!(
112            "receipt is missing stored file snapshots for: {}",
113            preview
114                .missing_artifacts
115                .iter()
116                .map(|item| item.path.as_str())
117                .collect::<Vec<_>>()
118                .join(", ")
119        ));
120    }
121    if !preview.conflicts.is_empty() && matches!(options.policy, RevertConflictPolicy::Abort) {
122        return Err(anyhow!(
123            "some files changed after the receipt finished: {}",
124            preview
125                .conflicts
126                .iter()
127                .map(|item| item.path.as_str())
128                .collect::<Vec<_>>()
129                .join(", ")
130        ));
131    }
132
133    let targets = select_revert_targets(report, selected_paths)?;
134    let cwd = PathBuf::from(&report.run.cwd);
135    for file in targets {
136        let status = evaluate_revert_status(file, &cwd)?;
137        if matches!(status.status, RevertFileState::AlreadyReverted) {
138            continue;
139        }
140        if matches!(status.status, RevertFileState::ChangedSinceReceipt)
141            && matches!(options.policy, RevertConflictPolicy::SkipChanged)
142        {
143            continue;
144        }
145        apply_revert_file(report, file, &cwd)?;
146    }
147
148    preview_revert(report, selected_paths)
149}
150
151fn select_revert_targets<'a>(
152    report: &'a RunReport,
153    selected_paths: Option<&[String]>,
154) -> Result<Vec<&'a FileChange>> {
155    if let Some(paths) = selected_paths {
156        if paths.is_empty() {
157            return Ok(report.files.iter().collect());
158        }
159        let mut selected = Vec::new();
160        for path in paths {
161            let file = report
162                .files
163                .iter()
164                .find(|file| file.path == *path)
165                .ok_or_else(|| anyhow!("receipt does not include file change {}", path))?;
166            selected.push(file);
167        }
168        return Ok(selected);
169    }
170    Ok(report.files.iter().collect())
171}
172
173fn artifact_path(report: &RunReport, relative: &str) -> Result<PathBuf> {
174    let cwd_local = PathBuf::from(&report.run.cwd)
175        .join(".runglass")
176        .join("reports")
177        .join(&report.run.id)
178        .join(relative);
179    if cwd_local.exists() {
180        return Ok(cwd_local);
181    }
182    Ok(report_run_dir(&report.run.id)?.join(relative))
183}
184
185fn load_artifact_bytes(report: &RunReport, relative: Option<&str>) -> Result<Option<Vec<u8>>> {
186    let Some(relative) = relative else {
187        return Ok(None);
188    };
189    let path = artifact_path(report, relative)?;
190    Ok(Some(fs::read(&path).with_context(|| {
191        format!("failed to read artifact {}", path.display())
192    })?))
193}
194
195fn load_artifact_text(report: &RunReport, relative: Option<&str>) -> Result<Option<String>> {
196    let Some(bytes) = load_artifact_bytes(report, relative)? else {
197        return Ok(None);
198    };
199    Ok(String::from_utf8(bytes).ok())
200}
201
202fn current_file_hash(path: &Path) -> Result<Option<String>> {
203    if !path.exists() {
204        return Ok(None);
205    }
206    let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
207    Ok(Some(hash_bytes(&bytes)))
208}
209
210fn evaluate_revert_status(file: &FileChange, cwd: &Path) -> Result<RevertFileStatus> {
211    let current_path = cwd.join(&file.path);
212    let current_hash = current_file_hash(&current_path)?;
213
214    let status = match file.change_type {
215        FileChangeType::Modified => {
216            if file.before_artifact_path.is_none() {
217                RevertFileStatus {
218                    path: file.path.clone(),
219                    change_type: file.change_type.clone(),
220                    status: RevertFileState::MissingArtifacts,
221                    detail: "Missing stored before snapshot.".to_string(),
222                }
223            } else if current_hash.as_deref() == file.after_hash.as_deref() {
224                RevertFileStatus {
225                    path: file.path.clone(),
226                    change_type: file.change_type.clone(),
227                    status: RevertFileState::Safe,
228                    detail: "Current file still matches the receipt's after-run version."
229                        .to_string(),
230                }
231            } else if current_hash.as_deref() == file.before_hash.as_deref() {
232                RevertFileStatus {
233                    path: file.path.clone(),
234                    change_type: file.change_type.clone(),
235                    status: RevertFileState::AlreadyReverted,
236                    detail: "Current file already matches the stored before-run version."
237                        .to_string(),
238                }
239            } else {
240                RevertFileStatus {
241                    path: file.path.clone(),
242                    change_type: file.change_type.clone(),
243                    status: RevertFileState::ChangedSinceReceipt,
244                    detail: "File contents changed after the receipt finished.".to_string(),
245                }
246            }
247        }
248        FileChangeType::Created => {
249            if current_hash.is_none() {
250                RevertFileStatus {
251                    path: file.path.clone(),
252                    change_type: file.change_type.clone(),
253                    status: RevertFileState::AlreadyReverted,
254                    detail: "Created file is already gone.".to_string(),
255                }
256            } else if current_hash.as_deref() == file.after_hash.as_deref() {
257                RevertFileStatus {
258                    path: file.path.clone(),
259                    change_type: file.change_type.clone(),
260                    status: RevertFileState::Safe,
261                    detail:
262                        "Created file still matches the receipt version and can be deleted safely."
263                            .to_string(),
264                }
265            } else {
266                RevertFileStatus {
267                    path: file.path.clone(),
268                    change_type: file.change_type.clone(),
269                    status: RevertFileState::ChangedSinceReceipt,
270                    detail: "Created file changed after the receipt finished.".to_string(),
271                }
272            }
273        }
274        FileChangeType::Deleted => {
275            if file.before_artifact_path.is_none() {
276                RevertFileStatus {
277                    path: file.path.clone(),
278                    change_type: file.change_type.clone(),
279                    status: RevertFileState::MissingArtifacts,
280                    detail: "Missing stored before snapshot.".to_string(),
281                }
282            } else if current_hash.is_none() {
283                RevertFileStatus {
284                    path: file.path.clone(),
285                    change_type: file.change_type.clone(),
286                    status: RevertFileState::Safe,
287                    detail: "Deleted file is still absent and can be restored safely.".to_string(),
288                }
289            } else if current_hash.as_deref() == file.before_hash.as_deref() {
290                RevertFileStatus {
291                    path: file.path.clone(),
292                    change_type: file.change_type.clone(),
293                    status: RevertFileState::AlreadyReverted,
294                    detail: "Deleted file already matches the stored before-run version."
295                        .to_string(),
296                }
297            } else {
298                RevertFileStatus {
299                    path: file.path.clone(),
300                    change_type: file.change_type.clone(),
301                    status: RevertFileState::ChangedSinceReceipt,
302                    detail: "A newer file now exists at this path.".to_string(),
303                }
304            }
305        }
306    };
307
308    Ok(status)
309}
310
311fn apply_revert_file(report: &RunReport, file: &FileChange, cwd: &Path) -> Result<()> {
312    let path = cwd.join(&file.path);
313    match file.change_type {
314        FileChangeType::Modified | FileChangeType::Deleted => {
315            let bytes = load_artifact_bytes(report, file.before_artifact_path.as_deref())?
316                .ok_or_else(|| anyhow!("missing stored before snapshot for {}", file.path))?;
317            if let Some(parent) = path.parent() {
318                fs::create_dir_all(parent)?;
319            }
320            fs::write(&path, bytes)?;
321            set_executable_flag(&path, file.before_executable.unwrap_or(false))?;
322        }
323        FileChangeType::Created => {
324            if path.exists() {
325                fs::remove_file(&path)
326                    .with_context(|| format!("failed to delete {}", path.display()))?;
327            }
328        }
329    }
330    Ok(())
331}
332
333fn set_executable_flag(path: &Path, executable: bool) -> Result<()> {
334    #[cfg(unix)]
335    {
336        use std::os::unix::fs::PermissionsExt;
337        let mut perms = fs::metadata(path)?.permissions();
338        let mode = if executable { 0o755 } else { 0o644 };
339        perms.set_mode(mode);
340        fs::set_permissions(path, perms)?;
341    }
342
343    #[cfg(not(unix))]
344    {
345        let _ = (path, executable);
346    }
347
348    Ok(())
349}
350
351fn git_style_patch(path: &str, before: Option<&str>, after: Option<&str>) -> Vec<String> {
352    let mut lines = Vec::new();
353    lines.push(format!("diff --git a/{path} b/{path}"));
354    match (before, after) {
355        (Some(_), None) => {
356            lines.push("deleted file mode 100644".to_string());
357            lines.push(format!("--- a/{path}"));
358            lines.push("+++ /dev/null".to_string());
359            lines.push(simple_unified_diff(before.unwrap_or_default(), ""));
360        }
361        (None, Some(_)) => {
362            lines.push("new file mode 100644".to_string());
363            lines.push("--- /dev/null".to_string());
364            lines.push(format!("+++ b/{path}"));
365            lines.push(simple_unified_diff("", after.unwrap_or_default()));
366        }
367        (Some(before), Some(after)) => {
368            lines.push(format!("--- a/{path}"));
369            lines.push(format!("+++ b/{path}"));
370            lines.push(simple_unified_diff(before, after));
371        }
372        (None, None) => {}
373    }
374    lines.push(String::new());
375    lines
376}
377
378#[cfg(test)]
379mod tests {
380    use std::sync::{Mutex, OnceLock};
381    use std::time::{SystemTime, UNIX_EPOCH};
382    use std::{env, fs};
383
384    use chrono::Utc;
385
386    use super::{apply_revert, preview_revert, render_reverse_patch};
387    use crate::collectors::hash_bytes;
388    use crate::{
389        FileChange, FileChangeType, ObservationMode, RevertConflictPolicy, RevertOptions,
390        RiskLevel, RunMeta, RunReport, RunStatus, Summary,
391    };
392
393    fn env_lock() -> &'static Mutex<()> {
394        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
395        LOCK.get_or_init(|| Mutex::new(()))
396    }
397
398    #[test]
399    fn preview_and_apply_revert_cover_modified_created_and_deleted_files() {
400        let _guard = env_lock().lock().expect("lock test env");
401        let fixture = RevertFixture::new("revert-apply");
402
403        let preview = preview_revert(&fixture.report, None).expect("preview");
404        assert_eq!(preview.target_count, 3);
405        assert_eq!(preview.safe.len(), 3);
406        assert!(preview.conflicts.is_empty());
407
408        let after_apply = apply_revert(
409            &fixture.report,
410            None,
411            RevertOptions {
412                policy: RevertConflictPolicy::Force,
413            },
414        )
415        .expect("apply revert");
416
417        assert_eq!(after_apply.already_reverted.len(), 3);
418        assert_eq!(
419            fs::read_to_string(fixture.workspace.join("modified.txt")).expect("modified content"),
420            "before\n"
421        );
422        assert!(
423            !fixture.workspace.join("created.txt").exists(),
424            "created file should be removed"
425        );
426        assert_eq!(
427            fs::read_to_string(fixture.workspace.join("deleted.txt")).expect("deleted restored"),
428            "restore me\n"
429        );
430    }
431
432    #[test]
433    fn preview_detects_conflicts_and_skip_changed_leaves_newer_edits_in_place() {
434        let _guard = env_lock().lock().expect("lock test env");
435        let fixture = RevertFixture::new("revert-conflict");
436
437        fs::write(fixture.workspace.join("modified.txt"), "changed again\n")
438            .expect("write newer modified");
439        fs::write(fixture.workspace.join("created.txt"), "changed created\n")
440            .expect("write newer created");
441
442        let preview = preview_revert(&fixture.report, None).expect("preview");
443        assert_eq!(preview.conflicts.len(), 2);
444        assert_eq!(preview.safe.len(), 1);
445
446        let after_apply = apply_revert(
447            &fixture.report,
448            None,
449            RevertOptions {
450                policy: RevertConflictPolicy::SkipChanged,
451            },
452        )
453        .expect("apply skip changed");
454
455        assert_eq!(after_apply.conflicts.len(), 2);
456        assert_eq!(
457            fs::read_to_string(fixture.workspace.join("modified.txt")).expect("modified content"),
458            "changed again\n"
459        );
460        assert_eq!(
461            fs::read_to_string(fixture.workspace.join("created.txt")).expect("created content"),
462            "changed created\n"
463        );
464        assert_eq!(
465            fs::read_to_string(fixture.workspace.join("deleted.txt")).expect("deleted restored"),
466            "restore me\n"
467        );
468    }
469
470    #[test]
471    fn reverse_patch_contains_git_style_operations() {
472        let _guard = env_lock().lock().expect("lock test env");
473        let fixture = RevertFixture::new("reverse-patch");
474
475        let patch = render_reverse_patch(&fixture.report).expect("reverse patch");
476        assert!(patch.contains("# RunGlass Reverse Patch"));
477        assert!(patch.contains("diff --git a/modified.txt b/modified.txt"));
478        assert!(patch.contains("deleted file mode 100644"));
479        assert!(patch.contains("new file mode 100644"));
480    }
481
482    struct RevertFixture {
483        workspace: std::path::PathBuf,
484        report: RunReport,
485    }
486
487    impl RevertFixture {
488        fn new(name: &str) -> Self {
489            let root = unique_test_root(name);
490            let workspace = root.join("workspace");
491            let run_id = format!("{name}-{}", unique_suffix());
492            let run_dir = workspace.join(".runglass").join("reports").join(&run_id);
493            let artifacts_dir = run_dir.join("file-artifacts");
494
495            fs::create_dir_all(&workspace).expect("workspace dir");
496            fs::create_dir_all(&artifacts_dir).expect("artifacts dir");
497
498            fs::write(workspace.join("modified.txt"), "after\n").expect("modified workspace");
499            fs::write(workspace.join("created.txt"), "created\n").expect("created workspace");
500
501            fs::write(artifacts_dir.join("001_modified-txt.before"), "before\n")
502                .expect("modified before");
503            fs::write(artifacts_dir.join("001_modified-txt.after"), "after\n")
504                .expect("modified after");
505            fs::write(artifacts_dir.join("002_created-txt.after"), "created\n")
506                .expect("created after");
507            fs::write(artifacts_dir.join("003_deleted-txt.before"), "restore me\n")
508                .expect("deleted before");
509
510            let report = RunReport {
511                schema_version: "0.1.0".to_string(),
512                ci: None,
513                run: RunMeta {
514                    id: run_id.clone(),
515                    command_display: "sh -c 'test revert'".to_string(),
516                    argv: vec![
517                        "sh".to_string(),
518                        "-c".to_string(),
519                        "test revert".to_string(),
520                    ],
521                    cwd: workspace.display().to_string(),
522                    shell: Some("/bin/sh".to_string()),
523                    mode: ObservationMode::Normal,
524                    started_at: Utc::now(),
525                    ended_at: Some(Utc::now()),
526                    duration_ms: Some(250),
527                    exit_code: Some(0),
528                    status: RunStatus::Completed,
529                },
530                summary: Summary {
531                    files_changed: 3,
532                    files_created: 1,
533                    files_modified: 1,
534                    files_deleted: 1,
535                    processes_seen: 0,
536                    network_hosts: 0,
537                    ports_opened: 0,
538                    docker_containers_created: 0,
539                    docker_images_pulled: 0,
540                    docker_volumes_created: 0,
541                    risk_level: RiskLevel::Low,
542                },
543                events: Vec::new(),
544                processes: Vec::new(),
545                files: vec![
546                    file_change(
547                        "modified.txt",
548                        FileChangeType::Modified,
549                        Some("before\n"),
550                        Some("after\n"),
551                        Some("file-artifacts/001_modified-txt.before"),
552                        Some("file-artifacts/001_modified-txt.after"),
553                    ),
554                    file_change(
555                        "created.txt",
556                        FileChangeType::Created,
557                        None,
558                        Some("created\n"),
559                        None,
560                        Some("file-artifacts/002_created-txt.after"),
561                    ),
562                    file_change(
563                        "deleted.txt",
564                        FileChangeType::Deleted,
565                        Some("restore me\n"),
566                        None,
567                        Some("file-artifacts/003_deleted-txt.before"),
568                        None,
569                    ),
570                ],
571                network: Vec::new(),
572                docker: None,
573                risks: Vec::new(),
574                stdout_path: None,
575                stderr_path: None,
576                stdout: None,
577                stderr: None,
578                limitations: vec!["test receipt".to_string()],
579            };
580
581            fs::create_dir_all(&run_dir).expect("run dir");
582            fs::write(
583                run_dir.join("report.json"),
584                serde_json::to_vec_pretty(&report).expect("report json"),
585            )
586            .expect("write report json");
587
588            Self { workspace, report }
589        }
590    }
591
592    fn file_change(
593        path: &str,
594        change_type: FileChangeType,
595        before: Option<&str>,
596        after: Option<&str>,
597        before_artifact_path: Option<&str>,
598        after_artifact_path: Option<&str>,
599    ) -> FileChange {
600        FileChange {
601            path: path.to_string(),
602            change_type,
603            before_hash: before.map(|value| hash_bytes(value.as_bytes())),
604            after_hash: after.map(|value| hash_bytes(value.as_bytes())),
605            before_size: before.map(|value| value.len() as u64),
606            after_size: after.map(|value| value.len() as u64),
607            is_text: true,
608            diff: None,
609            risk_tags: Vec::new(),
610            before_artifact_path: before_artifact_path.map(ToString::to_string),
611            after_artifact_path: after_artifact_path.map(ToString::to_string),
612            before_executable: Some(false),
613            after_executable: Some(false),
614        }
615    }
616
617    fn unique_test_root(name: &str) -> std::path::PathBuf {
618        let root = env::temp_dir().join(format!("runglass-{name}-{}", unique_suffix()));
619        if root.exists() {
620            fs::remove_dir_all(&root).expect("remove stale test root");
621        }
622        fs::create_dir_all(&root).expect("create test root");
623        root
624    }
625
626    fn unique_suffix() -> String {
627        let nanos = SystemTime::now()
628            .duration_since(UNIX_EPOCH)
629            .expect("time")
630            .as_nanos();
631        format!("{}-{nanos}", std::process::id())
632    }
633}