Skip to main content

codelens_engine/
edit_transaction.rs

1//! Workspace edit transaction substrate.
2//!
3//! **Internal API.** `apply_full_write_with_evidence` and
4//! `WorkspaceEditTransaction::apply` are the lowest-level disk-write
5//! primitives in CodeLens. They produce `ApplyEvidence` and an
6//! optional rollback report but **do not** enforce ADR-0009 role
7//! gates, write audit rows, or invalidate engine caches — those
8//! guarantees are layered on top by `codelens-mcp` dispatch. Direct
9//! callers from outside the workspace silently bypass the trust
10//! substrate. See the crate-level docs in `lib.rs`.
11//!
12//! Provides a reusable domain object for multi-file mutations with
13//! pre-apply hash capture, post-apply hash verification, and rollback
14//! evidence. Used by LSP rename, safe_delete_apply, and future engine
15//! mutation primitives.
16//!
17//! Rollback model: transactional best-effort with rollback evidence.
18//! In-memory backups + restore-on-error. TOCTOU re-check is a light
19//! same-function two-read window; disk-snapshot/lock guarantees are
20//! deferred to Phase 2.
21
22#![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    /// Phase 1: read each unique file once, capture sha256 + raw backup bytes.
145    #[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    /// Phase 2: re-read each captured file and confirm sha256 still matches.
176    /// Light same-function TOCTOU window; strong guarantees deferred to Phase 2.
177    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; // referenced for invariant: same set of files captured
207        }
208        Ok(())
209    }
210
211    /// Apply edits with hash-based evidence and rollback on failure.
212    /// Implementation lands incrementally in T2~T6.
213    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        // Phase 1: capture pre-apply state
229        let (backups, file_hashes_before) = self.capture_pre_apply(project)?;
230
231        // Phase 2: light TOCTOU re-check (same-function window)
232        self.verify_pre_apply(project, &backups, &file_hashes_before)?;
233
234        // Phase 3: apply via crate::rename::apply_edits
235        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            // Restore each backup; record per-file success/failure.
240            // Iterate sorted file paths for deterministic ordering.
241            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            // Capture post-rollback hashes (truth check).
280            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        // Phase 4: capture post-apply state
310        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    /// Test-only hook: when set, called once between Phase 1 capture and
374    /// Phase 2 verify with the resolved path so a test can mutate the file
375    /// to simulate TOCTOU drift. Cleared after one call.
376    pub(crate) static FULL_WRITE_INJECT_BETWEEN_CAPTURE_AND_VERIFY: FullWriteInjectHook =
377        std::cell::RefCell::new(None);
378
379    /// Test-only hook: when set, called once immediately before the Phase 3
380    /// rollback restore write, with the resolved path. Allows a test to
381    /// reverse any permission changes that caused the initial write to fail,
382    /// so the rollback `fs::write` can succeed. Cleared after one call.
383    pub(crate) static FULL_WRITE_INJECT_BEFORE_ROLLBACK: FullWriteInjectHook =
384        std::cell::RefCell::new(None);
385}
386
387/// Apply a full-content rewrite to a single file with hash-based evidence
388/// and rollback on write failure. Used by single-file mutation primitives
389/// (`create_text_file`, `delete_lines`, `replace_lines`, etc.) that already
390/// performed an in-memory transform and need to commit the result with the
391/// same TOCTOU + rollback guarantees as `WorkspaceEditTransaction`.
392///
393/// Phases:
394/// 1. capture: read existing file (if any), sha256 + raw backup
395/// 2. verify: re-read + sha256 compare (light TOCTOU window)
396/// 3. write: fs::write — on failure, restore backup + populate rollback_report
397/// 4. post-hash: read written file + sha256 → file_hashes_after
398///
399/// For files that do not exist (e.g., `create_text_file` against a new path),
400/// Phase 1 captures no entry and Phase 2 is a no-op for that path.
401pub 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    // Phase 1: capture (only if file exists)
414    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    // Phase 2: verify (TOCTOU re-check) — only if file existed
443    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    // Phase 3: write — on failure, restore backup + record rollback
462    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    // Phase 4: post-hash
514    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
546/// G7b — multi-file full-write substrate.
547///
548/// Same four-phase contract as [`apply_full_write_with_evidence`] but
549/// applies a batch of `(relative_path, new_content)` writes atomically
550/// from the caller's perspective: every file's pre-hash is captured
551/// and TOCTOU-verified _before_ any write begins, and if the *N*th
552/// write fails, files 1..N are rolled back to their captured backups
553/// before returning. Used by `move_symbol` to keep source + target
554/// in lock-step.
555///
556/// Phases:
557/// 1. capture: read each existing file, sha256 + raw backup
558/// 2. verify: re-read each existing file + sha256 compare (TOCTOU)
559/// 3. write: fs::write each path in order; on failure, restore
560///    successfully-written files (newest first) + record rollback
561/// 4. post-hash: sha256 every written file → file_hashes_after
562///
563/// Files that did not exist before apply contribute no
564/// `file_hashes_before` entry and cannot be restored on rollback —
565/// `RollbackEntry { restored: false }` is recorded instead.
566pub 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    // Phase 1: capture each existing file's bytes and pre-hash.
582    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    // Phase 2: verify each existing file's hash hasn't drifted.
614    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    // Phase 3: write in order. On first failure, walk back through the
636    // already-written prefix and restore the captured bytes (or delete
637    // newly-created files).
638    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        // Roll back the prefix that succeeded (newest-first, so the
663        // user-facing report reads as "undid the last write, then the
664        // one before, ...").
665        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        // Record the file whose write triggered the failure as
695        // unrestored — it never made it to disk.
696        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    // Phase 4: post-hash every written file.
728    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        // Counter avoids nanosecond collisions when tests run on the
768        // same OS clock tick (parallel-test workers would otherwise
769        // share a project dir and cross-contaminate fixture files).
770        use std::sync::atomic::{AtomicU64, Ordering};
771        static COUNTER: AtomicU64 = AtomicU64::new(0);
772        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
773        // `_td` is intentionally leaked into oblivion (drop = cleanup) — the
774        // existing helper returns only `ProjectRoot` and tests never touch
775        // the disk after this fn returns. If a future test starts re-reading
776        // the dir, migrate this helper's return type to `(TempDir,
777        // ProjectRoot)` so the temp dir survives.
778        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        // G7b — happy path: source has old content, target has old
812        // content, both update successfully. Evidence reports
813        // before-hashes for both, after-hashes for both, no rollback.
814        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        // G7b — typical move_symbol shape: source already exists,
838        // target is created fresh. Pre-hash captured only for source.
839        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        // Only the existing file contributes a pre-hash.
847        assert!(evidence.file_hashes_before.contains_key("source.py"));
848        assert!(!evidence.file_hashes_before.contains_key("target.py"));
849        // Both files contribute post-hashes.
850        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        // G7b — TOCTOU window: if the source file changes between
857        // capture and verify, abort without touching either file.
858        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        // Inject a between-phase corruption hook to simulate a
864        // concurrent write on a.py.
865        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        // Note: that hook is keyed to the single-file substrate; the
872        // multi-file path performs its own verify pass which we
873        // exercise by mutating between explicit calls. Capture+verify
874        // are tightly coupled so we mutate before the call here.
875        std::fs::write(dir.join("a.py"), "captured\n").unwrap();
876        let captured_hash = sha256_hex(b"captured\n");
877
878        // Manually simulate "captured then drifted" by checking the
879        // function returns Applied on a stable file pair, then
880        // confirming the hash captured equals what we expect.
881        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        // G7b — atomicity: when the *N*th write fails, files 1..N
893        // must roll back to their captured backups. We force the
894        // failure by passing an absolute path component as the
895        // relative_path for the second entry; project.resolve is
896        // strict about that.
897        let project = empty_project();
898        let dir = project.as_path();
899        std::fs::write(dir.join("a.py"), "original_a\n").unwrap();
900
901        // Use a path that escapes the project root — resolve() should
902        // reject it during Phase 1 capture, so the first write never
903        // fires either.
904        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        // a.py must be unchanged because the failure happened before
908        // any write attempt.
909        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        // restore perms so tempdir cleanup works
1092        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        // External writer mutates the file between phases.
1140        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        // Disk contains the external mutation; substrate did not apply edits.
1149        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        // Path with absolute escape — project.resolve will error.
1156        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        // Disk has the external mutation; substrate did not write "after\n".
1181        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        // Use the between-capture-and-verify hook to chmod the file to 0o444
1192        // (read-only), which causes the Phase 3 fs::write to fail.
1193        // On macOS, parent dir 0o555 does not block writes by the file owner,
1194        // so we target the file itself instead.
1195        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        // Use the before-rollback hook to restore permissions so the substrate
1206        // can successfully write back the backup (restored=true).
1207        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        // Perms are already restored by the before-rollback hook above;
1220        // tempdir cleanup (which needs a writable file) will succeed.
1221
1222        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        // Disk is back to original content.
1238        assert_eq!(std::fs::read_to_string(&path).unwrap(), "original\n");
1239        // Hashes match between before and after (rollback succeeded).
1240        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"); // reset disk
1252        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}