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 dir = std::env::temp_dir().join(format!(
774 "codelens-edit-tx-{}-{}-{}",
775 std::process::id(),
776 std::time::SystemTime::now()
777 .duration_since(std::time::UNIX_EPOCH)
778 .unwrap()
779 .as_nanos(),
780 n,
781 ));
782 std::fs::create_dir_all(&dir).unwrap();
783 ProjectRoot::new(dir.to_str().unwrap()).unwrap()
784 }
785
786 #[test]
787 fn noop_returns_evidence_with_status_noop() {
788 let project = empty_project();
789 let tx = WorkspaceEditTransaction::new(vec![], vec![]);
790 let evidence = tx.apply_with_evidence(&project).expect("noop apply ok");
791 assert_eq!(evidence.status, ApplyStatus::NoOp);
792 assert!(evidence.file_hashes_before.is_empty());
793 assert!(evidence.file_hashes_after.is_empty());
794 assert!(evidence.rollback_report.is_empty());
795 assert_eq!(evidence.modified_files, 0);
796 assert_eq!(evidence.edit_count, 0);
797 }
798
799 #[test]
800 fn full_writes_empty_returns_noop() {
801 let project = empty_project();
802 let evidence = apply_full_writes_with_evidence(&project, &[]).expect("noop ok");
803 assert_eq!(evidence.status, ApplyStatus::NoOp);
804 assert_eq!(evidence.modified_files, 0);
805 }
806
807 #[test]
808 fn full_writes_two_existing_files_succeeds_atomically() {
809 let project = empty_project();
813 let dir = project.as_path();
814 std::fs::write(dir.join("a.py"), "old_a\n").unwrap();
815 std::fs::write(dir.join("b.py"), "old_b\n").unwrap();
816 let writes: Vec<(&str, &str)> = vec![("a.py", "new_a\n"), ("b.py", "new_b\n")];
817 let evidence = apply_full_writes_with_evidence(&project, &writes).expect("apply ok");
818 assert_eq!(evidence.status, ApplyStatus::Applied);
819 assert_eq!(evidence.modified_files, 2);
820 assert_eq!(evidence.file_hashes_before.len(), 2);
821 assert_eq!(evidence.file_hashes_after.len(), 2);
822 assert!(evidence.rollback_report.is_empty());
823 assert_eq!(
824 std::fs::read_to_string(dir.join("a.py")).unwrap(),
825 "new_a\n"
826 );
827 assert_eq!(
828 std::fs::read_to_string(dir.join("b.py")).unwrap(),
829 "new_b\n"
830 );
831 }
832
833 #[test]
834 fn full_writes_creates_missing_target_when_source_exists() {
835 let project = empty_project();
838 let dir = project.as_path();
839 std::fs::write(dir.join("source.py"), "old\n").unwrap();
840 let writes: Vec<(&str, &str)> = vec![("source.py", "trimmed\n"), ("target.py", "fresh\n")];
841 let evidence = apply_full_writes_with_evidence(&project, &writes).expect("apply ok");
842 assert_eq!(evidence.status, ApplyStatus::Applied);
843 assert_eq!(evidence.modified_files, 2);
844 assert!(evidence.file_hashes_before.contains_key("source.py"));
846 assert!(!evidence.file_hashes_before.contains_key("target.py"));
847 assert!(evidence.file_hashes_after.contains_key("source.py"));
849 assert!(evidence.file_hashes_after.contains_key("target.py"));
850 }
851
852 #[test]
853 fn full_writes_pre_apply_hash_mismatch_aborts_before_write() {
854 let project = empty_project();
857 let dir = project.as_path();
858 std::fs::write(dir.join("a.py"), "captured\n").unwrap();
859 std::fs::write(dir.join("b.py"), "untouched\n").unwrap();
860
861 let dir_clone = dir.to_owned();
864 FULL_WRITE_INJECT_BETWEEN_CAPTURE_AND_VERIFY.with(|cell| {
865 *cell.borrow_mut() = Some(Box::new(move |_resolved| {
866 std::fs::write(dir_clone.join("a.py"), "drifted\n").unwrap();
867 }));
868 });
869 std::fs::write(dir.join("a.py"), "captured\n").unwrap();
874 let captured_hash = sha256_hex(b"captured\n");
875
876 let writes: Vec<(&str, &str)> = vec![("a.py", "x\n"), ("b.py", "y\n")];
880 let evidence = apply_full_writes_with_evidence(&project, &writes).expect("apply ok");
881 assert_eq!(
882 evidence.file_hashes_before.get("a.py").unwrap().sha256,
883 captured_hash,
884 "captured hash should match the pre-write content of a.py"
885 );
886 }
887
888 #[test]
889 fn full_writes_rollback_restores_prefix_when_later_write_fails() {
890 let project = empty_project();
896 let dir = project.as_path();
897 std::fs::write(dir.join("a.py"), "original_a\n").unwrap();
898
899 let writes: Vec<(&str, &str)> = vec![("a.py", "modified_a\n"), ("../escape.py", "evil\n")];
903 let result = apply_full_writes_with_evidence(&project, &writes);
904 assert!(result.is_err(), "escape path must be rejected");
905 assert_eq!(
908 std::fs::read_to_string(dir.join("a.py")).unwrap(),
909 "original_a\n"
910 );
911 }
912
913 #[test]
914 fn resource_ops_non_empty_returns_unsupported() {
915 let project = empty_project();
916 let tx = WorkspaceEditTransaction::new(
917 vec![],
918 vec![LspResourceOp {
919 kind: "create".to_owned(),
920 file_path: "new.txt".to_owned(),
921 old_file_path: None,
922 new_file_path: None,
923 }],
924 );
925 let result = tx.apply_with_evidence(&project);
926 assert!(matches!(result, Err(ApplyError::ResourceOpsUnsupported)));
927 }
928
929 #[test]
930 fn pre_read_fails_when_file_missing() {
931 let project = empty_project();
932 let tx = WorkspaceEditTransaction::new(
933 vec![RenameEdit {
934 file_path: "missing.txt".to_owned(),
935 line: 1,
936 column: 1,
937 old_text: "x".to_owned(),
938 new_text: "y".to_owned(),
939 }],
940 vec![],
941 );
942 let result = tx.apply_with_evidence(&project);
943 assert!(
944 matches!(result, Err(ApplyError::PreReadFailed { ref file_path, .. }) if file_path == "missing.txt"),
945 "expected PreReadFailed for missing.txt, got {:?}",
946 result.err()
947 );
948 }
949
950 fn write_file(project: &ProjectRoot, name: &str, content: &str) -> PathBuf {
951 let resolved = project.resolve(name).unwrap();
952 std::fs::create_dir_all(resolved.parent().unwrap()).ok();
953 std::fs::write(&resolved, content).unwrap();
954 resolved
955 }
956
957 #[test]
958 fn happy_path_two_files_apply_succeeds_with_evidence() {
959 let project = empty_project();
960 write_file(&project, "a.txt", "alpha\n");
961 write_file(&project, "b.txt", "beta\n");
962 let tx = WorkspaceEditTransaction::new(
963 vec![
964 RenameEdit {
965 file_path: "a.txt".to_owned(),
966 line: 1,
967 column: 1,
968 old_text: "alpha".to_owned(),
969 new_text: "ALPHA".to_owned(),
970 },
971 RenameEdit {
972 file_path: "b.txt".to_owned(),
973 line: 1,
974 column: 1,
975 old_text: "beta".to_owned(),
976 new_text: "BETA".to_owned(),
977 },
978 ],
979 vec![],
980 );
981 let evidence = tx
982 .apply_with_evidence(&project)
983 .expect("happy path apply ok");
984 assert_eq!(evidence.status, ApplyStatus::Applied);
985 assert_eq!(evidence.file_hashes_before.len(), 2);
986 assert_eq!(evidence.file_hashes_after.len(), 2);
987 assert!(evidence.rollback_report.is_empty());
988 assert_eq!(evidence.modified_files, 2);
989 assert_eq!(evidence.edit_count, 2);
990 for (path, before) in &evidence.file_hashes_before {
991 let after = evidence
992 .file_hashes_after
993 .get(path)
994 .expect("after entry exists");
995 assert_ne!(before.sha256, after.sha256, "hash for {path} should differ");
996 }
997 assert_eq!(
998 std::fs::read_to_string(project.resolve("a.txt").unwrap()).unwrap(),
999 "ALPHA\n"
1000 );
1001 assert_eq!(
1002 std::fs::read_to_string(project.resolve("b.txt").unwrap()).unwrap(),
1003 "BETA\n"
1004 );
1005 }
1006
1007 #[test]
1008 fn pre_apply_hash_is_deterministic_for_same_input() {
1009 let project = empty_project();
1010 write_file(&project, "x.txt", "stable\n");
1011 let tx_a = WorkspaceEditTransaction::new(
1012 vec![RenameEdit {
1013 file_path: "x.txt".to_owned(),
1014 line: 1,
1015 column: 1,
1016 old_text: "stable".to_owned(),
1017 new_text: "stable".to_owned(),
1018 }],
1019 vec![],
1020 );
1021 let ev_a = tx_a.apply_with_evidence(&project).unwrap();
1022 let tx_b = tx_a.clone();
1023 let ev_b = tx_b.apply_with_evidence(&project).unwrap();
1024 let hash_a = &ev_a.file_hashes_before["x.txt"].sha256;
1025 let hash_b = &ev_b.file_hashes_before["x.txt"].sha256;
1026 assert_eq!(hash_a, hash_b);
1027 }
1028
1029 #[cfg(unix)]
1030 #[test]
1031 fn rollback_restores_first_file_when_second_apply_fails() {
1032 use std::os::unix::fs::PermissionsExt;
1033 let project = empty_project();
1034 let path_a = write_file(&project, "ra.txt", "alpha\n");
1035 let path_b = write_file(&project, "rb.txt", "beta\n");
1036 let mut perms = std::fs::metadata(&path_b).unwrap().permissions();
1037 perms.set_mode(0o444);
1038 std::fs::set_permissions(&path_b, perms).unwrap();
1039
1040 let tx = WorkspaceEditTransaction::new(
1041 vec![
1042 RenameEdit {
1043 file_path: "ra.txt".to_owned(),
1044 line: 1,
1045 column: 1,
1046 old_text: "alpha".to_owned(),
1047 new_text: "ALPHA".to_owned(),
1048 },
1049 RenameEdit {
1050 file_path: "rb.txt".to_owned(),
1051 line: 1,
1052 column: 1,
1053 old_text: "beta".to_owned(),
1054 new_text: "BETA".to_owned(),
1055 },
1056 ],
1057 vec![],
1058 );
1059
1060 let result = tx.apply_with_evidence(&project);
1061 let evidence = match result {
1062 Err(ApplyError::ApplyFailed { evidence, .. }) => evidence,
1063 other => panic!("expected ApplyFailed, got {other:?}"),
1064 };
1065 assert_eq!(evidence.status, ApplyStatus::RolledBack);
1066 assert_eq!(evidence.modified_files, 0);
1067 assert_eq!(evidence.edit_count, 0);
1068 let ra_now = std::fs::read_to_string(&path_a).unwrap();
1069 assert_eq!(ra_now, "alpha\n", "ra.txt should be restored to alpha");
1070 let before = evidence.file_hashes_before.get("ra.txt").unwrap();
1071 let after = evidence.file_hashes_after.get("ra.txt").unwrap();
1072 assert_eq!(
1073 before.sha256, after.sha256,
1074 "ra.txt hash should match pre-apply after rollback"
1075 );
1076 let entry_a = evidence
1077 .rollback_report
1078 .iter()
1079 .find(|e| e.file_path == "ra.txt")
1080 .expect("rollback entry for ra.txt");
1081 assert!(entry_a.restored, "ra.txt restore should succeed");
1082 assert!(entry_a.reason.is_none());
1083 let entry_b = evidence
1084 .rollback_report
1085 .iter()
1086 .find(|e| e.file_path == "rb.txt");
1087 assert!(entry_b.is_some(), "rb.txt rollback entry should exist");
1088
1089 let mut restore = std::fs::metadata(&path_b).unwrap().permissions();
1091 restore.set_mode(0o644);
1092 let _ = std::fs::set_permissions(&path_b, restore);
1093 }
1094
1095 #[test]
1096 fn apply_full_write_happy_returns_evidence() {
1097 let project = empty_project();
1098 write_file(&project, "doc.txt", "old content\n");
1099 let evidence =
1100 apply_full_write_with_evidence(&project, "doc.txt", "new content\n").expect("apply ok");
1101 assert_eq!(evidence.status, ApplyStatus::Applied);
1102 assert_eq!(evidence.modified_files, 1);
1103 assert_eq!(evidence.edit_count, 1);
1104 assert!(evidence.rollback_report.is_empty());
1105 let before = evidence
1106 .file_hashes_before
1107 .get("doc.txt")
1108 .expect("before entry");
1109 let after = evidence
1110 .file_hashes_after
1111 .get("doc.txt")
1112 .expect("after entry");
1113 assert_ne!(before.sha256, after.sha256);
1114 assert_eq!(after.bytes, "new content\n".len());
1115 assert_eq!(
1116 std::fs::read_to_string(project.resolve("doc.txt").unwrap()).unwrap(),
1117 "new content\n"
1118 );
1119 }
1120
1121 #[test]
1122 fn toctou_recheck_detects_external_mutation_between_phases() {
1123 let project = empty_project();
1124 let path = write_file(&project, "tt.txt", "before\n");
1125 let tx = WorkspaceEditTransaction::new(
1126 vec![RenameEdit {
1127 file_path: "tt.txt".to_owned(),
1128 line: 1,
1129 column: 1,
1130 old_text: "before".to_owned(),
1131 new_text: "after".to_owned(),
1132 }],
1133 vec![],
1134 );
1135
1136 let (backups, hashes_before) = tx.capture_pre_apply(&project).expect("phase 1 capture ok");
1137 std::fs::write(&path, "TAMPERED\n").unwrap();
1139
1140 let result = tx.verify_pre_apply(&project, &backups, &hashes_before);
1141 assert!(
1142 matches!(result, Err(ApplyError::PreApplyHashMismatch { ref file_path, .. }) if file_path == "tt.txt"),
1143 "expected PreApplyHashMismatch for tt.txt, got {:?}",
1144 result.err()
1145 );
1146 assert_eq!(std::fs::read_to_string(&path).unwrap(), "TAMPERED\n");
1148 }
1149
1150 #[test]
1151 fn apply_full_write_pre_read_failed_on_unresolvable_path() {
1152 let project = empty_project();
1153 let result = apply_full_write_with_evidence(&project, "../escape.txt", "x");
1155 assert!(
1156 matches!(result, Err(ApplyError::PreReadFailed { ref file_path, .. }) if file_path == "../escape.txt"),
1157 "expected PreReadFailed for ../escape.txt, got {:?}",
1158 result.err()
1159 );
1160 }
1161
1162 #[test]
1163 fn apply_full_write_toctou_mismatch_via_inject_hook() {
1164 let project = empty_project();
1165 let path = write_file(&project, "drift.txt", "before\n");
1166 FULL_WRITE_INJECT_BETWEEN_CAPTURE_AND_VERIFY.with(|cell| {
1167 let hook: Box<dyn FnOnce(&std::path::Path)> = Box::new(|p: &std::path::Path| {
1168 std::fs::write(p, "TAMPERED\n").unwrap();
1169 });
1170 *cell.borrow_mut() = Some(hook);
1171 });
1172 let result = apply_full_write_with_evidence(&project, "drift.txt", "after\n");
1173 assert!(
1174 matches!(result, Err(ApplyError::PreApplyHashMismatch { ref file_path, .. }) if file_path == "drift.txt"),
1175 "expected PreApplyHashMismatch, got {:?}",
1176 result.err()
1177 );
1178 assert_eq!(std::fs::read_to_string(&path).unwrap(), "TAMPERED\n");
1180 }
1181
1182 #[cfg(unix)]
1183 #[test]
1184 fn apply_full_write_rollback_on_write_failure() {
1185 use std::os::unix::fs::PermissionsExt;
1186 let project = empty_project();
1187 let path = write_file(&project, "ro.txt", "original\n");
1188
1189 FULL_WRITE_INJECT_BETWEEN_CAPTURE_AND_VERIFY.with(|cell| {
1194 let p = path.clone();
1195 let hook: Box<dyn FnOnce(&std::path::Path)> = Box::new(move |_resolved| {
1196 let mut perms = std::fs::metadata(&p).unwrap().permissions();
1197 perms.set_mode(0o444);
1198 std::fs::set_permissions(&p, perms).unwrap();
1199 });
1200 *cell.borrow_mut() = Some(hook);
1201 });
1202
1203 FULL_WRITE_INJECT_BEFORE_ROLLBACK.with(|cell| {
1206 let p = path.clone();
1207 let hook: Box<dyn FnOnce(&std::path::Path)> = Box::new(move |_resolved| {
1208 let mut perms = std::fs::metadata(&p).unwrap().permissions();
1209 perms.set_mode(0o644);
1210 std::fs::set_permissions(&p, perms).unwrap();
1211 });
1212 *cell.borrow_mut() = Some(hook);
1213 });
1214
1215 let result = apply_full_write_with_evidence(&project, "ro.txt", "new\n");
1216
1217 let evidence = match result {
1221 Err(ApplyError::ApplyFailed { evidence, .. }) => evidence,
1222 other => panic!("expected ApplyFailed, got {other:?}"),
1223 };
1224 assert_eq!(evidence.status, ApplyStatus::RolledBack);
1225 assert_eq!(evidence.modified_files, 0);
1226 assert_eq!(evidence.edit_count, 0);
1227 assert_eq!(evidence.rollback_report.len(), 1);
1228 let entry = &evidence.rollback_report[0];
1229 assert_eq!(entry.file_path, "ro.txt");
1230 assert!(
1231 entry.restored,
1232 "expected restore success, got reason: {:?}",
1233 entry.reason
1234 );
1235 assert_eq!(std::fs::read_to_string(&path).unwrap(), "original\n");
1237 let before = evidence.file_hashes_before.get("ro.txt").unwrap();
1239 let after = evidence.file_hashes_after.get("ro.txt").unwrap();
1240 assert_eq!(before.sha256, after.sha256);
1241 }
1242
1243 #[test]
1244 fn apply_full_write_hash_determinism() {
1245 let project = empty_project();
1246 write_file(&project, "stable.txt", "stable content\n");
1247 let ev1 =
1248 apply_full_write_with_evidence(&project, "stable.txt", "new1\n").expect("first apply");
1249 write_file(&project, "stable.txt", "stable content\n"); let ev2 =
1251 apply_full_write_with_evidence(&project, "stable.txt", "new2\n").expect("second apply");
1252 let h1 = &ev1.file_hashes_before["stable.txt"].sha256;
1253 let h2 = &ev2.file_hashes_before["stable.txt"].sha256;
1254 assert_eq!(h1, h2, "same input bytes should yield identical sha256");
1255 }
1256
1257 #[test]
1258 fn apply_full_write_no_op_same_content() {
1259 let project = empty_project();
1260 write_file(&project, "noop.txt", "same\n");
1261 let evidence =
1262 apply_full_write_with_evidence(&project, "noop.txt", "same\n").expect("noop ok");
1263 assert_eq!(evidence.status, ApplyStatus::Applied);
1264 let before = &evidence.file_hashes_before["noop.txt"].sha256;
1265 let after = &evidence.file_hashes_after["noop.txt"].sha256;
1266 assert_eq!(before, after, "no-op should leave hash unchanged");
1267 assert_eq!(evidence.modified_files, 1);
1268 assert_eq!(evidence.edit_count, 1);
1269 }
1270}