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(¤t_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}