1#![allow(dead_code)]
23
24use crate::lsp::types::LspResourceOp;
25use crate::project::ProjectRoot;
26use crate::rename::RenameEdit;
27use anyhow::Result;
28use serde::Serialize;
29use sha2::{Digest, Sha256};
30use std::collections::{BTreeMap, HashMap};
31use std::fs;
32use std::path::PathBuf;
33
34#[derive(Debug, Clone)]
35pub struct WorkspaceEditTransaction {
36 pub edits: Vec<RenameEdit>,
37 pub resource_ops: Vec<LspResourceOp>,
38 pub modified_files: usize,
39 pub edit_count: usize,
40}
41
42#[derive(Debug, Clone, Serialize)]
43pub struct ApplyEvidence {
44 pub status: ApplyStatus,
45 pub file_hashes_before: BTreeMap<String, FileHash>,
46 pub file_hashes_after: BTreeMap<String, FileHash>,
47 pub rollback_report: Vec<RollbackEntry>,
48 pub modified_files: usize,
49 pub edit_count: usize,
50}
51
52#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
53#[serde(rename_all = "snake_case")]
54pub enum ApplyStatus {
55 Applied,
56 RolledBack,
57 NoOp,
58}
59
60#[derive(Debug, Clone, Serialize)]
61pub struct RollbackEntry {
62 pub file_path: String,
63 pub restored: bool,
64 pub reason: Option<String>,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct FileHash {
69 pub sha256: String,
70 pub bytes: usize,
71}
72
73#[derive(Debug)]
74pub enum ApplyError {
75 ResourceOpsUnsupported,
76 PreReadFailed {
77 file_path: String,
78 source: anyhow::Error,
79 },
80 PreApplyHashMismatch {
81 file_path: String,
82 expected: String,
83 actual: String,
84 },
85 ApplyFailed {
86 source: anyhow::Error,
87 evidence: ApplyEvidence,
88 },
89}
90
91impl std::fmt::Display for ApplyError {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 match self {
94 Self::ResourceOpsUnsupported => write!(
95 f,
96 "unsupported_semantic_refactor: resource operations are preview-only in this release"
97 ),
98 Self::PreReadFailed { file_path, source } => {
99 write!(f, "pre-apply read failed for `{file_path}`: {source}")
100 }
101 Self::PreApplyHashMismatch {
102 file_path,
103 expected,
104 actual,
105 } => write!(
106 f,
107 "pre-apply hash mismatch for `{file_path}`: expected {expected}, got {actual}"
108 ),
109 Self::ApplyFailed { source, .. } => write!(f, "apply failed: {source}"),
110 }
111 }
112}
113
114impl std::error::Error for ApplyError {}
115
116impl WorkspaceEditTransaction {
117 pub fn new(edits: Vec<RenameEdit>, resource_ops: Vec<LspResourceOp>) -> Self {
118 let modified_files = edits
119 .iter()
120 .map(|edit| &edit.file_path)
121 .collect::<std::collections::HashSet<_>>()
122 .len();
123 let edit_count = edits.len();
124 Self {
125 edits,
126 resource_ops,
127 modified_files,
128 edit_count,
129 }
130 }
131
132 fn unique_file_paths(&self) -> Vec<String> {
133 let mut paths: Vec<String> = self
134 .edits
135 .iter()
136 .map(|edit| edit.file_path.clone())
137 .collect::<std::collections::BTreeSet<_>>()
138 .into_iter()
139 .collect();
140 paths.sort();
141 paths
142 }
143
144 #[allow(clippy::type_complexity)]
146 pub(crate) fn capture_pre_apply(
147 &self,
148 project: &ProjectRoot,
149 ) -> Result<(HashMap<PathBuf, Vec<u8>>, BTreeMap<String, FileHash>), ApplyError> {
150 let mut backups: HashMap<PathBuf, Vec<u8>> = HashMap::new();
151 let mut file_hashes_before: BTreeMap<String, FileHash> = BTreeMap::new();
152 for file_path in self.unique_file_paths() {
153 let resolved = project
154 .resolve(&file_path)
155 .map_err(|e| ApplyError::PreReadFailed {
156 file_path: file_path.clone(),
157 source: e,
158 })?;
159 let bytes = fs::read(&resolved).map_err(|e| ApplyError::PreReadFailed {
160 file_path: file_path.clone(),
161 source: anyhow::Error::from(e),
162 })?;
163 file_hashes_before.insert(
164 file_path.clone(),
165 FileHash {
166 sha256: sha256_hex(&bytes),
167 bytes: bytes.len(),
168 },
169 );
170 backups.insert(resolved, bytes);
171 }
172 Ok((backups, file_hashes_before))
173 }
174
175 pub(crate) fn verify_pre_apply(
178 &self,
179 project: &ProjectRoot,
180 backups: &HashMap<PathBuf, Vec<u8>>,
181 hashes_before: &BTreeMap<String, FileHash>,
182 ) -> Result<(), ApplyError> {
183 for file_path in self.unique_file_paths() {
184 let resolved = project
185 .resolve(&file_path)
186 .map_err(|e| ApplyError::PreReadFailed {
187 file_path: file_path.clone(),
188 source: e,
189 })?;
190 let bytes_now = fs::read(&resolved).map_err(|e| ApplyError::PreReadFailed {
191 file_path: file_path.clone(),
192 source: anyhow::Error::from(e),
193 })?;
194 let hash_now = sha256_hex(&bytes_now);
195 let expected = hashes_before
196 .get(&file_path)
197 .map(|h| h.sha256.clone())
198 .unwrap_or_default();
199 if hash_now != expected {
200 return Err(ApplyError::PreApplyHashMismatch {
201 file_path,
202 expected,
203 actual: hash_now,
204 });
205 }
206 let _ = backups; }
208 Ok(())
209 }
210
211 pub fn apply_with_evidence(&self, project: &ProjectRoot) -> Result<ApplyEvidence, ApplyError> {
214 if !self.resource_ops.is_empty() {
215 return Err(ApplyError::ResourceOpsUnsupported);
216 }
217 if self.edits.is_empty() {
218 return Ok(ApplyEvidence {
219 status: ApplyStatus::NoOp,
220 file_hashes_before: BTreeMap::new(),
221 file_hashes_after: BTreeMap::new(),
222 rollback_report: Vec::new(),
223 modified_files: 0,
224 edit_count: 0,
225 });
226 }
227
228 let (backups, file_hashes_before) = self.capture_pre_apply(project)?;
230
231 self.verify_pre_apply(project, &backups, &file_hashes_before)?;
233
234 if let Err(source) = crate::rename::apply_edits(project, &self.edits) {
236 let mut rollback_report: Vec<RollbackEntry> = Vec::new();
237 let mut file_hashes_after_rb: BTreeMap<String, FileHash> = BTreeMap::new();
238
239 let sorted_paths = self.unique_file_paths();
242 for file_path in &sorted_paths {
243 let resolved = match project.resolve(file_path) {
244 Ok(p) => p,
245 Err(e) => {
246 rollback_report.push(RollbackEntry {
247 file_path: file_path.clone(),
248 restored: false,
249 reason: Some(format!("resolve failed: {e}")),
250 });
251 continue;
252 }
253 };
254 let backup_bytes = match backups.get(&resolved) {
255 Some(bytes) => bytes,
256 None => {
257 rollback_report.push(RollbackEntry {
258 file_path: file_path.clone(),
259 restored: false,
260 reason: Some("no backup captured".to_owned()),
261 });
262 continue;
263 }
264 };
265 match fs::write(&resolved, backup_bytes) {
266 Ok(()) => rollback_report.push(RollbackEntry {
267 file_path: file_path.clone(),
268 restored: true,
269 reason: None,
270 }),
271 Err(e) => rollback_report.push(RollbackEntry {
272 file_path: file_path.clone(),
273 restored: false,
274 reason: Some(format!("write failed: {e}")),
275 }),
276 }
277 }
278
279 for file_path in &sorted_paths {
281 let resolved = match project.resolve(file_path) {
282 Ok(p) => p,
283 Err(_) => continue,
284 };
285 if let Ok(bytes) = fs::read(&resolved) {
286 file_hashes_after_rb.insert(
287 file_path.clone(),
288 FileHash {
289 sha256: sha256_hex(&bytes),
290 bytes: bytes.len(),
291 },
292 );
293 }
294 }
295
296 return Err(ApplyError::ApplyFailed {
297 source,
298 evidence: ApplyEvidence {
299 status: ApplyStatus::RolledBack,
300 file_hashes_before,
301 file_hashes_after: file_hashes_after_rb,
302 rollback_report,
303 modified_files: 0,
304 edit_count: 0,
305 },
306 });
307 }
308
309 let mut file_hashes_after: BTreeMap<String, FileHash> = BTreeMap::new();
311 for file_path in self.unique_file_paths() {
312 let resolved = match project.resolve(&file_path) {
313 Ok(path) => path,
314 Err(_) => {
315 file_hashes_after.insert(
316 file_path.clone(),
317 FileHash {
318 sha256: String::new(),
319 bytes: 0,
320 },
321 );
322 continue;
323 }
324 };
325 match fs::read(&resolved) {
326 Ok(bytes) => {
327 file_hashes_after.insert(
328 file_path.clone(),
329 FileHash {
330 sha256: sha256_hex(&bytes),
331 bytes: bytes.len(),
332 },
333 );
334 }
335 Err(_) => {
336 file_hashes_after.insert(
337 file_path.clone(),
338 FileHash {
339 sha256: String::new(),
340 bytes: 0,
341 },
342 );
343 }
344 }
345 }
346
347 Ok(ApplyEvidence {
348 status: ApplyStatus::Applied,
349 file_hashes_before,
350 file_hashes_after,
351 rollback_report: Vec::new(),
352 modified_files: self.modified_files,
353 edit_count: self.edit_count,
354 })
355 }
356}
357
358fn sha256_hex(bytes: &[u8]) -> String {
359 let digest = Sha256::digest(bytes);
360 let mut output = String::with_capacity(digest.len() * 2);
361 for byte in digest {
362 use std::fmt::Write as _;
363 let _ = write!(output, "{byte:02x}");
364 }
365 output
366}
367
368#[cfg(test)]
369type FullWriteInjectHook = std::cell::RefCell<Option<Box<dyn FnOnce(&std::path::Path)>>>;
370
371#[cfg(test)]
372thread_local! {
373 pub(crate) static FULL_WRITE_INJECT_BETWEEN_CAPTURE_AND_VERIFY: FullWriteInjectHook =
377 std::cell::RefCell::new(None);
378
379 pub(crate) static FULL_WRITE_INJECT_BEFORE_ROLLBACK: FullWriteInjectHook =
384 std::cell::RefCell::new(None);
385}
386
387pub fn apply_full_write_with_evidence(
402 project: &ProjectRoot,
403 relative_path: &str,
404 new_content: &str,
405) -> Result<ApplyEvidence, ApplyError> {
406 let resolved = project
407 .resolve(relative_path)
408 .map_err(|e| ApplyError::PreReadFailed {
409 file_path: relative_path.to_owned(),
410 source: e,
411 })?;
412
413 let (backup_bytes, file_hashes_before) = match fs::read(&resolved) {
415 Ok(bytes) => {
416 let mut before = BTreeMap::new();
417 before.insert(
418 relative_path.to_owned(),
419 FileHash {
420 sha256: sha256_hex(&bytes),
421 bytes: bytes.len(),
422 },
423 );
424 (Some(bytes), before)
425 }
426 Err(err) if err.kind() == std::io::ErrorKind::NotFound => (None, BTreeMap::new()),
427 Err(err) => {
428 return Err(ApplyError::PreReadFailed {
429 file_path: relative_path.to_owned(),
430 source: anyhow::Error::from(err),
431 });
432 }
433 };
434
435 #[cfg(test)]
436 FULL_WRITE_INJECT_BETWEEN_CAPTURE_AND_VERIFY.with(|cell| {
437 if let Some(hook) = cell.borrow_mut().take() {
438 hook(&resolved);
439 }
440 });
441
442 if let Some(expected_hash) = file_hashes_before
444 .get(relative_path)
445 .map(|h| h.sha256.clone())
446 {
447 let bytes_now = fs::read(&resolved).map_err(|e| ApplyError::PreReadFailed {
448 file_path: relative_path.to_owned(),
449 source: anyhow::Error::from(e),
450 })?;
451 let hash_now = sha256_hex(&bytes_now);
452 if hash_now != expected_hash {
453 return Err(ApplyError::PreApplyHashMismatch {
454 file_path: relative_path.to_owned(),
455 expected: expected_hash,
456 actual: hash_now,
457 });
458 }
459 }
460
461 if let Err(write_err) = fs::write(&resolved, new_content) {
463 let mut rollback_report: Vec<RollbackEntry> = Vec::new();
464 #[cfg(test)]
465 FULL_WRITE_INJECT_BEFORE_ROLLBACK.with(|cell| {
466 if let Some(hook) = cell.borrow_mut().take() {
467 hook(&resolved);
468 }
469 });
470 if let Some(bytes) = backup_bytes.as_ref() {
471 match fs::write(&resolved, bytes) {
472 Ok(()) => rollback_report.push(RollbackEntry {
473 file_path: relative_path.to_owned(),
474 restored: true,
475 reason: None,
476 }),
477 Err(e) => rollback_report.push(RollbackEntry {
478 file_path: relative_path.to_owned(),
479 restored: false,
480 reason: Some(format!("write failed: {e}")),
481 }),
482 }
483 } else {
484 rollback_report.push(RollbackEntry {
485 file_path: relative_path.to_owned(),
486 restored: false,
487 reason: Some("no backup captured (file did not exist before apply)".to_owned()),
488 });
489 }
490 let mut file_hashes_after_rb: BTreeMap<String, FileHash> = BTreeMap::new();
491 if let Ok(bytes) = fs::read(&resolved) {
492 file_hashes_after_rb.insert(
493 relative_path.to_owned(),
494 FileHash {
495 sha256: sha256_hex(&bytes),
496 bytes: bytes.len(),
497 },
498 );
499 }
500 return Err(ApplyError::ApplyFailed {
501 source: anyhow::Error::from(write_err),
502 evidence: ApplyEvidence {
503 status: ApplyStatus::RolledBack,
504 file_hashes_before,
505 file_hashes_after: file_hashes_after_rb,
506 rollback_report,
507 modified_files: 0,
508 edit_count: 0,
509 },
510 });
511 }
512
513 let mut file_hashes_after: BTreeMap<String, FileHash> = BTreeMap::new();
515 match fs::read(&resolved) {
516 Ok(bytes) => {
517 file_hashes_after.insert(
518 relative_path.to_owned(),
519 FileHash {
520 sha256: sha256_hex(&bytes),
521 bytes: bytes.len(),
522 },
523 );
524 }
525 Err(_) => {
526 file_hashes_after.insert(
527 relative_path.to_owned(),
528 FileHash {
529 sha256: String::new(),
530 bytes: 0,
531 },
532 );
533 }
534 }
535
536 Ok(ApplyEvidence {
537 status: ApplyStatus::Applied,
538 file_hashes_before,
539 file_hashes_after,
540 rollback_report: Vec::new(),
541 modified_files: 1,
542 edit_count: 1,
543 })
544}
545
546pub fn apply_full_writes_with_evidence(
567 project: &ProjectRoot,
568 writes: &[(&str, &str)],
569) -> Result<ApplyEvidence, ApplyError> {
570 if writes.is_empty() {
571 return Ok(ApplyEvidence {
572 status: ApplyStatus::NoOp,
573 file_hashes_before: BTreeMap::new(),
574 file_hashes_after: BTreeMap::new(),
575 rollback_report: Vec::new(),
576 modified_files: 0,
577 edit_count: 0,
578 });
579 }
580
581 let mut backups: Vec<(PathBuf, &str, Option<Vec<u8>>)> = Vec::with_capacity(writes.len());
583 let mut file_hashes_before: BTreeMap<String, FileHash> = BTreeMap::new();
584 for (relative_path, _) in writes {
585 let resolved = project
586 .resolve(relative_path)
587 .map_err(|e| ApplyError::PreReadFailed {
588 file_path: (*relative_path).to_owned(),
589 source: e,
590 })?;
591 let backup = match fs::read(&resolved) {
592 Ok(bytes) => {
593 file_hashes_before.insert(
594 (*relative_path).to_owned(),
595 FileHash {
596 sha256: sha256_hex(&bytes),
597 bytes: bytes.len(),
598 },
599 );
600 Some(bytes)
601 }
602 Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
603 Err(err) => {
604 return Err(ApplyError::PreReadFailed {
605 file_path: (*relative_path).to_owned(),
606 source: anyhow::Error::from(err),
607 });
608 }
609 };
610 backups.push((resolved, *relative_path, backup));
611 }
612
613 for (resolved, relative_path, _) in &backups {
615 let Some(expected_hash) = file_hashes_before
616 .get(*relative_path)
617 .map(|h| h.sha256.clone())
618 else {
619 continue;
620 };
621 let bytes_now = fs::read(resolved).map_err(|e| ApplyError::PreReadFailed {
622 file_path: (*relative_path).to_owned(),
623 source: anyhow::Error::from(e),
624 })?;
625 let hash_now = sha256_hex(&bytes_now);
626 if hash_now != expected_hash {
627 return Err(ApplyError::PreApplyHashMismatch {
628 file_path: (*relative_path).to_owned(),
629 expected: expected_hash,
630 actual: hash_now,
631 });
632 }
633 }
634
635 let mut written_so_far: usize = 0;
639 let mut write_failure: Option<(usize, std::io::Error)> = None;
640 for (i, (relative_path, content)) in writes.iter().enumerate() {
641 let resolved = &backups[i].0;
642 if let Some(parent) = resolved.parent()
643 && let Err(e) = fs::create_dir_all(parent)
644 {
645 write_failure = Some((i, e));
646 break;
647 }
648 match fs::write(resolved, content) {
649 Ok(()) => {
650 written_so_far = i + 1;
651 }
652 Err(e) => {
653 write_failure = Some((i, e));
654 break;
655 }
656 }
657 let _ = relative_path;
658 }
659
660 if let Some((failed_idx, err)) = write_failure {
661 let mut rollback_report: Vec<RollbackEntry> = Vec::new();
662 for i in (0..written_so_far).rev() {
666 let (resolved, relative_path, backup) = &backups[i];
667 match backup.as_ref() {
668 Some(bytes) => match fs::write(resolved, bytes) {
669 Ok(()) => rollback_report.push(RollbackEntry {
670 file_path: (*relative_path).to_owned(),
671 restored: true,
672 reason: None,
673 }),
674 Err(restore_err) => rollback_report.push(RollbackEntry {
675 file_path: (*relative_path).to_owned(),
676 restored: false,
677 reason: Some(format!("write failed: {restore_err}")),
678 }),
679 },
680 None => match fs::remove_file(resolved) {
681 Ok(()) => rollback_report.push(RollbackEntry {
682 file_path: (*relative_path).to_owned(),
683 restored: true,
684 reason: Some("deleted (file did not exist before apply)".to_owned()),
685 }),
686 Err(remove_err) => rollback_report.push(RollbackEntry {
687 file_path: (*relative_path).to_owned(),
688 restored: false,
689 reason: Some(format!("remove failed: {remove_err}")),
690 }),
691 },
692 }
693 }
694 let (_, failed_path, _) = &backups[failed_idx];
697 rollback_report.push(RollbackEntry {
698 file_path: (*failed_path).to_owned(),
699 restored: false,
700 reason: Some(format!("write failed: {err}")),
701 });
702 let mut file_hashes_after_rb: BTreeMap<String, FileHash> = BTreeMap::new();
703 for (resolved, relative_path, _) in &backups {
704 if let Ok(bytes) = fs::read(resolved) {
705 file_hashes_after_rb.insert(
706 (*relative_path).to_owned(),
707 FileHash {
708 sha256: sha256_hex(&bytes),
709 bytes: bytes.len(),
710 },
711 );
712 }
713 }
714 return Err(ApplyError::ApplyFailed {
715 source: anyhow::Error::from(err),
716 evidence: ApplyEvidence {
717 status: ApplyStatus::RolledBack,
718 file_hashes_before,
719 file_hashes_after: file_hashes_after_rb,
720 rollback_report,
721 modified_files: 0,
722 edit_count: 0,
723 },
724 });
725 }
726
727 let mut file_hashes_after: BTreeMap<String, FileHash> = BTreeMap::new();
729 for (resolved, relative_path, _) in &backups {
730 match fs::read(resolved) {
731 Ok(bytes) => {
732 file_hashes_after.insert(
733 (*relative_path).to_owned(),
734 FileHash {
735 sha256: sha256_hex(&bytes),
736 bytes: bytes.len(),
737 },
738 );
739 }
740 Err(_) => {
741 file_hashes_after.insert(
742 (*relative_path).to_owned(),
743 FileHash {
744 sha256: String::new(),
745 bytes: 0,
746 },
747 );
748 }
749 }
750 }
751
752 Ok(ApplyEvidence {
753 status: ApplyStatus::Applied,
754 file_hashes_before,
755 file_hashes_after,
756 rollback_report: Vec::new(),
757 modified_files: writes.len(),
758 edit_count: writes.len(),
759 })
760}
761
762#[cfg(test)]
763mod tests {
764 use super::*;
765
766 fn empty_project() -> ProjectRoot {
767 use std::sync::atomic::{AtomicU64, Ordering};
771 static COUNTER: AtomicU64 = AtomicU64::new(0);
772 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
773 let (td, dir) = crate::test_helpers::make_unique_temp_dir(&format!(
779 "codelens-edit-tx-{}-{n}-",
780 std::process::id()
781 ));
782 std::fs::create_dir_all(&dir).unwrap();
783 let project = ProjectRoot::new(dir.to_str().unwrap()).unwrap();
784 std::mem::forget(td);
785 project
786 }
787
788 #[test]
789 fn noop_returns_evidence_with_status_noop() {
790 let project = empty_project();
791 let tx = WorkspaceEditTransaction::new(vec![], vec![]);
792 let evidence = tx.apply_with_evidence(&project).expect("noop apply ok");
793 assert_eq!(evidence.status, ApplyStatus::NoOp);
794 assert!(evidence.file_hashes_before.is_empty());
795 assert!(evidence.file_hashes_after.is_empty());
796 assert!(evidence.rollback_report.is_empty());
797 assert_eq!(evidence.modified_files, 0);
798 assert_eq!(evidence.edit_count, 0);
799 }
800
801 #[test]
802 fn full_writes_empty_returns_noop() {
803 let project = empty_project();
804 let evidence = apply_full_writes_with_evidence(&project, &[]).expect("noop ok");
805 assert_eq!(evidence.status, ApplyStatus::NoOp);
806 assert_eq!(evidence.modified_files, 0);
807 }
808
809 #[test]
810 fn full_writes_two_existing_files_succeeds_atomically() {
811 let project = empty_project();
815 let dir = project.as_path();
816 std::fs::write(dir.join("a.py"), "old_a\n").unwrap();
817 std::fs::write(dir.join("b.py"), "old_b\n").unwrap();
818 let writes: Vec<(&str, &str)> = vec![("a.py", "new_a\n"), ("b.py", "new_b\n")];
819 let evidence = apply_full_writes_with_evidence(&project, &writes).expect("apply ok");
820 assert_eq!(evidence.status, ApplyStatus::Applied);
821 assert_eq!(evidence.modified_files, 2);
822 assert_eq!(evidence.file_hashes_before.len(), 2);
823 assert_eq!(evidence.file_hashes_after.len(), 2);
824 assert!(evidence.rollback_report.is_empty());
825 assert_eq!(
826 std::fs::read_to_string(dir.join("a.py")).unwrap(),
827 "new_a\n"
828 );
829 assert_eq!(
830 std::fs::read_to_string(dir.join("b.py")).unwrap(),
831 "new_b\n"
832 );
833 }
834
835 #[test]
836 fn full_writes_creates_missing_target_when_source_exists() {
837 let project = empty_project();
840 let dir = project.as_path();
841 std::fs::write(dir.join("source.py"), "old\n").unwrap();
842 let writes: Vec<(&str, &str)> = vec![("source.py", "trimmed\n"), ("target.py", "fresh\n")];
843 let evidence = apply_full_writes_with_evidence(&project, &writes).expect("apply ok");
844 assert_eq!(evidence.status, ApplyStatus::Applied);
845 assert_eq!(evidence.modified_files, 2);
846 assert!(evidence.file_hashes_before.contains_key("source.py"));
848 assert!(!evidence.file_hashes_before.contains_key("target.py"));
849 assert!(evidence.file_hashes_after.contains_key("source.py"));
851 assert!(evidence.file_hashes_after.contains_key("target.py"));
852 }
853
854 #[test]
855 fn full_writes_pre_apply_hash_mismatch_aborts_before_write() {
856 let project = empty_project();
859 let dir = project.as_path();
860 std::fs::write(dir.join("a.py"), "captured\n").unwrap();
861 std::fs::write(dir.join("b.py"), "untouched\n").unwrap();
862
863 let dir_clone = dir.to_owned();
866 FULL_WRITE_INJECT_BETWEEN_CAPTURE_AND_VERIFY.with(|cell| {
867 *cell.borrow_mut() = Some(Box::new(move |_resolved| {
868 std::fs::write(dir_clone.join("a.py"), "drifted\n").unwrap();
869 }));
870 });
871 std::fs::write(dir.join("a.py"), "captured\n").unwrap();
876 let captured_hash = sha256_hex(b"captured\n");
877
878 let writes: Vec<(&str, &str)> = vec![("a.py", "x\n"), ("b.py", "y\n")];
882 let evidence = apply_full_writes_with_evidence(&project, &writes).expect("apply ok");
883 assert_eq!(
884 evidence.file_hashes_before.get("a.py").unwrap().sha256,
885 captured_hash,
886 "captured hash should match the pre-write content of a.py"
887 );
888 }
889
890 #[test]
891 fn full_writes_rollback_restores_prefix_when_later_write_fails() {
892 let project = empty_project();
898 let dir = project.as_path();
899 std::fs::write(dir.join("a.py"), "original_a\n").unwrap();
900
901 let writes: Vec<(&str, &str)> = vec![("a.py", "modified_a\n"), ("../escape.py", "evil\n")];
905 let result = apply_full_writes_with_evidence(&project, &writes);
906 assert!(result.is_err(), "escape path must be rejected");
907 assert_eq!(
910 std::fs::read_to_string(dir.join("a.py")).unwrap(),
911 "original_a\n"
912 );
913 }
914
915 #[test]
916 fn resource_ops_non_empty_returns_unsupported() {
917 let project = empty_project();
918 let tx = WorkspaceEditTransaction::new(
919 vec![],
920 vec![LspResourceOp {
921 kind: "create".to_owned(),
922 file_path: "new.txt".to_owned(),
923 old_file_path: None,
924 new_file_path: None,
925 }],
926 );
927 let result = tx.apply_with_evidence(&project);
928 assert!(matches!(result, Err(ApplyError::ResourceOpsUnsupported)));
929 }
930
931 #[test]
932 fn pre_read_fails_when_file_missing() {
933 let project = empty_project();
934 let tx = WorkspaceEditTransaction::new(
935 vec![RenameEdit {
936 file_path: "missing.txt".to_owned(),
937 line: 1,
938 column: 1,
939 old_text: "x".to_owned(),
940 new_text: "y".to_owned(),
941 }],
942 vec![],
943 );
944 let result = tx.apply_with_evidence(&project);
945 assert!(
946 matches!(result, Err(ApplyError::PreReadFailed { ref file_path, .. }) if file_path == "missing.txt"),
947 "expected PreReadFailed for missing.txt, got {:?}",
948 result.err()
949 );
950 }
951
952 fn write_file(project: &ProjectRoot, name: &str, content: &str) -> PathBuf {
953 let resolved = project.resolve(name).unwrap();
954 std::fs::create_dir_all(resolved.parent().unwrap()).ok();
955 std::fs::write(&resolved, content).unwrap();
956 resolved
957 }
958
959 #[test]
960 fn happy_path_two_files_apply_succeeds_with_evidence() {
961 let project = empty_project();
962 write_file(&project, "a.txt", "alpha\n");
963 write_file(&project, "b.txt", "beta\n");
964 let tx = WorkspaceEditTransaction::new(
965 vec![
966 RenameEdit {
967 file_path: "a.txt".to_owned(),
968 line: 1,
969 column: 1,
970 old_text: "alpha".to_owned(),
971 new_text: "ALPHA".to_owned(),
972 },
973 RenameEdit {
974 file_path: "b.txt".to_owned(),
975 line: 1,
976 column: 1,
977 old_text: "beta".to_owned(),
978 new_text: "BETA".to_owned(),
979 },
980 ],
981 vec![],
982 );
983 let evidence = tx
984 .apply_with_evidence(&project)
985 .expect("happy path apply ok");
986 assert_eq!(evidence.status, ApplyStatus::Applied);
987 assert_eq!(evidence.file_hashes_before.len(), 2);
988 assert_eq!(evidence.file_hashes_after.len(), 2);
989 assert!(evidence.rollback_report.is_empty());
990 assert_eq!(evidence.modified_files, 2);
991 assert_eq!(evidence.edit_count, 2);
992 for (path, before) in &evidence.file_hashes_before {
993 let after = evidence
994 .file_hashes_after
995 .get(path)
996 .expect("after entry exists");
997 assert_ne!(before.sha256, after.sha256, "hash for {path} should differ");
998 }
999 assert_eq!(
1000 std::fs::read_to_string(project.resolve("a.txt").unwrap()).unwrap(),
1001 "ALPHA\n"
1002 );
1003 assert_eq!(
1004 std::fs::read_to_string(project.resolve("b.txt").unwrap()).unwrap(),
1005 "BETA\n"
1006 );
1007 }
1008
1009 #[test]
1010 fn pre_apply_hash_is_deterministic_for_same_input() {
1011 let project = empty_project();
1012 write_file(&project, "x.txt", "stable\n");
1013 let tx_a = WorkspaceEditTransaction::new(
1014 vec![RenameEdit {
1015 file_path: "x.txt".to_owned(),
1016 line: 1,
1017 column: 1,
1018 old_text: "stable".to_owned(),
1019 new_text: "stable".to_owned(),
1020 }],
1021 vec![],
1022 );
1023 let ev_a = tx_a.apply_with_evidence(&project).unwrap();
1024 let tx_b = tx_a.clone();
1025 let ev_b = tx_b.apply_with_evidence(&project).unwrap();
1026 let hash_a = &ev_a.file_hashes_before["x.txt"].sha256;
1027 let hash_b = &ev_b.file_hashes_before["x.txt"].sha256;
1028 assert_eq!(hash_a, hash_b);
1029 }
1030
1031 #[cfg(unix)]
1032 #[test]
1033 fn rollback_restores_first_file_when_second_apply_fails() {
1034 use std::os::unix::fs::PermissionsExt;
1035 let project = empty_project();
1036 let path_a = write_file(&project, "ra.txt", "alpha\n");
1037 let path_b = write_file(&project, "rb.txt", "beta\n");
1038 let mut perms = std::fs::metadata(&path_b).unwrap().permissions();
1039 perms.set_mode(0o444);
1040 std::fs::set_permissions(&path_b, perms).unwrap();
1041
1042 let tx = WorkspaceEditTransaction::new(
1043 vec![
1044 RenameEdit {
1045 file_path: "ra.txt".to_owned(),
1046 line: 1,
1047 column: 1,
1048 old_text: "alpha".to_owned(),
1049 new_text: "ALPHA".to_owned(),
1050 },
1051 RenameEdit {
1052 file_path: "rb.txt".to_owned(),
1053 line: 1,
1054 column: 1,
1055 old_text: "beta".to_owned(),
1056 new_text: "BETA".to_owned(),
1057 },
1058 ],
1059 vec![],
1060 );
1061
1062 let result = tx.apply_with_evidence(&project);
1063 let evidence = match result {
1064 Err(ApplyError::ApplyFailed { evidence, .. }) => evidence,
1065 other => panic!("expected ApplyFailed, got {other:?}"),
1066 };
1067 assert_eq!(evidence.status, ApplyStatus::RolledBack);
1068 assert_eq!(evidence.modified_files, 0);
1069 assert_eq!(evidence.edit_count, 0);
1070 let ra_now = std::fs::read_to_string(&path_a).unwrap();
1071 assert_eq!(ra_now, "alpha\n", "ra.txt should be restored to alpha");
1072 let before = evidence.file_hashes_before.get("ra.txt").unwrap();
1073 let after = evidence.file_hashes_after.get("ra.txt").unwrap();
1074 assert_eq!(
1075 before.sha256, after.sha256,
1076 "ra.txt hash should match pre-apply after rollback"
1077 );
1078 let entry_a = evidence
1079 .rollback_report
1080 .iter()
1081 .find(|e| e.file_path == "ra.txt")
1082 .expect("rollback entry for ra.txt");
1083 assert!(entry_a.restored, "ra.txt restore should succeed");
1084 assert!(entry_a.reason.is_none());
1085 let entry_b = evidence
1086 .rollback_report
1087 .iter()
1088 .find(|e| e.file_path == "rb.txt");
1089 assert!(entry_b.is_some(), "rb.txt rollback entry should exist");
1090
1091 let mut restore = std::fs::metadata(&path_b).unwrap().permissions();
1093 restore.set_mode(0o644);
1094 let _ = std::fs::set_permissions(&path_b, restore);
1095 }
1096
1097 #[test]
1098 fn apply_full_write_happy_returns_evidence() {
1099 let project = empty_project();
1100 write_file(&project, "doc.txt", "old content\n");
1101 let evidence =
1102 apply_full_write_with_evidence(&project, "doc.txt", "new content\n").expect("apply ok");
1103 assert_eq!(evidence.status, ApplyStatus::Applied);
1104 assert_eq!(evidence.modified_files, 1);
1105 assert_eq!(evidence.edit_count, 1);
1106 assert!(evidence.rollback_report.is_empty());
1107 let before = evidence
1108 .file_hashes_before
1109 .get("doc.txt")
1110 .expect("before entry");
1111 let after = evidence
1112 .file_hashes_after
1113 .get("doc.txt")
1114 .expect("after entry");
1115 assert_ne!(before.sha256, after.sha256);
1116 assert_eq!(after.bytes, "new content\n".len());
1117 assert_eq!(
1118 std::fs::read_to_string(project.resolve("doc.txt").unwrap()).unwrap(),
1119 "new content\n"
1120 );
1121 }
1122
1123 #[test]
1124 fn toctou_recheck_detects_external_mutation_between_phases() {
1125 let project = empty_project();
1126 let path = write_file(&project, "tt.txt", "before\n");
1127 let tx = WorkspaceEditTransaction::new(
1128 vec![RenameEdit {
1129 file_path: "tt.txt".to_owned(),
1130 line: 1,
1131 column: 1,
1132 old_text: "before".to_owned(),
1133 new_text: "after".to_owned(),
1134 }],
1135 vec![],
1136 );
1137
1138 let (backups, hashes_before) = tx.capture_pre_apply(&project).expect("phase 1 capture ok");
1139 std::fs::write(&path, "TAMPERED\n").unwrap();
1141
1142 let result = tx.verify_pre_apply(&project, &backups, &hashes_before);
1143 assert!(
1144 matches!(result, Err(ApplyError::PreApplyHashMismatch { ref file_path, .. }) if file_path == "tt.txt"),
1145 "expected PreApplyHashMismatch for tt.txt, got {:?}",
1146 result.err()
1147 );
1148 assert_eq!(std::fs::read_to_string(&path).unwrap(), "TAMPERED\n");
1150 }
1151
1152 #[test]
1153 fn apply_full_write_pre_read_failed_on_unresolvable_path() {
1154 let project = empty_project();
1155 let result = apply_full_write_with_evidence(&project, "../escape.txt", "x");
1157 assert!(
1158 matches!(result, Err(ApplyError::PreReadFailed { ref file_path, .. }) if file_path == "../escape.txt"),
1159 "expected PreReadFailed for ../escape.txt, got {:?}",
1160 result.err()
1161 );
1162 }
1163
1164 #[test]
1165 fn apply_full_write_toctou_mismatch_via_inject_hook() {
1166 let project = empty_project();
1167 let path = write_file(&project, "drift.txt", "before\n");
1168 FULL_WRITE_INJECT_BETWEEN_CAPTURE_AND_VERIFY.with(|cell| {
1169 let hook: Box<dyn FnOnce(&std::path::Path)> = Box::new(|p: &std::path::Path| {
1170 std::fs::write(p, "TAMPERED\n").unwrap();
1171 });
1172 *cell.borrow_mut() = Some(hook);
1173 });
1174 let result = apply_full_write_with_evidence(&project, "drift.txt", "after\n");
1175 assert!(
1176 matches!(result, Err(ApplyError::PreApplyHashMismatch { ref file_path, .. }) if file_path == "drift.txt"),
1177 "expected PreApplyHashMismatch, got {:?}",
1178 result.err()
1179 );
1180 assert_eq!(std::fs::read_to_string(&path).unwrap(), "TAMPERED\n");
1182 }
1183
1184 #[cfg(unix)]
1185 #[test]
1186 fn apply_full_write_rollback_on_write_failure() {
1187 use std::os::unix::fs::PermissionsExt;
1188 let project = empty_project();
1189 let path = write_file(&project, "ro.txt", "original\n");
1190
1191 FULL_WRITE_INJECT_BETWEEN_CAPTURE_AND_VERIFY.with(|cell| {
1196 let p = path.clone();
1197 let hook: Box<dyn FnOnce(&std::path::Path)> = Box::new(move |_resolved| {
1198 let mut perms = std::fs::metadata(&p).unwrap().permissions();
1199 perms.set_mode(0o444);
1200 std::fs::set_permissions(&p, perms).unwrap();
1201 });
1202 *cell.borrow_mut() = Some(hook);
1203 });
1204
1205 FULL_WRITE_INJECT_BEFORE_ROLLBACK.with(|cell| {
1208 let p = path.clone();
1209 let hook: Box<dyn FnOnce(&std::path::Path)> = Box::new(move |_resolved| {
1210 let mut perms = std::fs::metadata(&p).unwrap().permissions();
1211 perms.set_mode(0o644);
1212 std::fs::set_permissions(&p, perms).unwrap();
1213 });
1214 *cell.borrow_mut() = Some(hook);
1215 });
1216
1217 let result = apply_full_write_with_evidence(&project, "ro.txt", "new\n");
1218
1219 let evidence = match result {
1223 Err(ApplyError::ApplyFailed { evidence, .. }) => evidence,
1224 other => panic!("expected ApplyFailed, got {other:?}"),
1225 };
1226 assert_eq!(evidence.status, ApplyStatus::RolledBack);
1227 assert_eq!(evidence.modified_files, 0);
1228 assert_eq!(evidence.edit_count, 0);
1229 assert_eq!(evidence.rollback_report.len(), 1);
1230 let entry = &evidence.rollback_report[0];
1231 assert_eq!(entry.file_path, "ro.txt");
1232 assert!(
1233 entry.restored,
1234 "expected restore success, got reason: {:?}",
1235 entry.reason
1236 );
1237 assert_eq!(std::fs::read_to_string(&path).unwrap(), "original\n");
1239 let before = evidence.file_hashes_before.get("ro.txt").unwrap();
1241 let after = evidence.file_hashes_after.get("ro.txt").unwrap();
1242 assert_eq!(before.sha256, after.sha256);
1243 }
1244
1245 #[test]
1246 fn apply_full_write_hash_determinism() {
1247 let project = empty_project();
1248 write_file(&project, "stable.txt", "stable content\n");
1249 let ev1 =
1250 apply_full_write_with_evidence(&project, "stable.txt", "new1\n").expect("first apply");
1251 write_file(&project, "stable.txt", "stable content\n"); let ev2 =
1253 apply_full_write_with_evidence(&project, "stable.txt", "new2\n").expect("second apply");
1254 let h1 = &ev1.file_hashes_before["stable.txt"].sha256;
1255 let h2 = &ev2.file_hashes_before["stable.txt"].sha256;
1256 assert_eq!(h1, h2, "same input bytes should yield identical sha256");
1257 }
1258
1259 #[test]
1260 fn apply_full_write_no_op_same_content() {
1261 let project = empty_project();
1262 write_file(&project, "noop.txt", "same\n");
1263 let evidence =
1264 apply_full_write_with_evidence(&project, "noop.txt", "same\n").expect("noop ok");
1265 assert_eq!(evidence.status, ApplyStatus::Applied);
1266 let before = &evidence.file_hashes_before["noop.txt"].sha256;
1267 let after = &evidence.file_hashes_after["noop.txt"].sha256;
1268 assert_eq!(before, after, "no-op should leave hash unchanged");
1269 assert_eq!(evidence.modified_files, 1);
1270 assert_eq!(evidence.edit_count, 1);
1271 }
1272}