Skip to main content

rac_engine/
rename.rs

1//! Safe artifact-id rename — `decided rename` (PORT-CONTRACT.d/16 §4).
2//!
3//! Port of `src/asdecided/services/rename.py` (`compute_rename`, `apply_rename`):
4//! the deterministic, reversible corpus-wide edit set for renaming one
5//! artifact identity. Resolution reuses the relationship validation alias
6//! index; the raw reference TEXT is the source of truth — an edit replaces
7//! exactly the `old_ref` token inside a relationship list line, preserving
8//! everything around it, and only where the line NAMES that token (a line
9//! naming a different alias of the same target is left untouched).
10
11use std::collections::HashSet;
12use std::fs::{self, OpenOptions};
13use std::io::Write;
14use std::path::{Path, PathBuf};
15
16use crate::pycompat::{py_casefold, py_is_space, py_splitlines, py_strip};
17use crate::relationships::{
18    corpus_items, resolution_index_from_rows, validation_row, CorpusItem, ValidationRow,
19};
20use crate::spec::RELATIONSHIP_SECTIONS;
21
22// Stable reason codes for an invalid plan (part of the JSON contract).
23pub const REASON_OLD_NOT_FOUND: &str = "old-ref-not-found";
24pub const REASON_OLD_AMBIGUOUS: &str = "old-ref-ambiguous";
25pub const REASON_NEW_COLLIDES: &str = "new-ref-collides";
26pub const REASON_NEW_INVALID: &str = "new-ref-invalid";
27pub const REASON_OLD_FILENAME_ONLY: &str = "old-ref-filename-only";
28pub const REASON_SYMLINK_PATH: &str = "symlink-path";
29pub const REASON_PATH_OUTSIDE_ROOT: &str = "path-outside-root";
30
31// Where the rewritten identity token lived in the target file.
32pub const IDENTITY_FRONTMATTER: &str = "frontmatter_id";
33pub const IDENTITY_ID_SECTION: &str = "id_section";
34pub const IDENTITY_ID_FIELD: &str = "id_field";
35
36pub const KIND_REFERENCE: &str = "reference";
37pub const KIND_IDENTITY: &str = "identity";
38
39/// One line-level replacement (1-based `line`; exact line text without the
40/// trailing newline).
41pub struct RenameEdit {
42    pub path: String,
43    pub line: i64,
44    pub old_line: String,
45    pub new_line: String,
46    pub kind: &'static str,
47}
48
49/// A deterministic, reversible corpus-wide rename edit set (ADR-007).
50pub struct RenamePlan {
51    pub directory: String,
52    pub recursive: bool,
53    pub old_ref: String,
54    pub new_ref: String,
55    pub ok: bool,
56    pub target_path: Option<String>,
57    pub identity_field: Option<&'static str>,
58    pub reason: Option<&'static str>,
59    pub edits: Vec<RenameEdit>,
60}
61
62impl RenamePlan {
63    pub fn reference_edits(&self) -> usize {
64        self.edits.iter().filter(|e| e.kind == KIND_REFERENCE).count()
65    }
66
67    pub fn identity_edits(&self) -> usize {
68        self.edits.iter().filter(|e| e.kind == KIND_IDENTITY).count()
69    }
70
71    pub fn files_changed(&self) -> usize {
72        self.edits
73            .iter()
74            .map(|e| e.path.as_str())
75            .collect::<HashSet<_>>()
76            .len()
77    }
78}
79
80/// The outcome of applying a plan to disk.
81#[derive(Debug)]
82pub struct RenameResult {
83    pub directory: String,
84    pub old_ref: String,
85    pub new_ref: String,
86    pub applied: bool,
87    pub files_changed: usize,
88    pub reference_edits: usize,
89    pub identity_edits: usize,
90    pub target_path: Option<String>,
91}
92
93#[derive(Debug)]
94struct PathIssue {
95    reason: &'static str,
96    path: String,
97    detail: String,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101enum FailurePoint {
102    Stage(usize),
103    Backup(usize),
104    Replace(usize),
105}
106
107#[derive(Debug, Clone, Copy)]
108struct FailureInjector(Option<FailurePoint>);
109
110impl FailureInjector {
111    fn none() -> Self {
112        Self(None)
113    }
114
115    #[cfg(test)]
116    fn at(point: FailurePoint) -> Self {
117        Self(Some(point))
118    }
119
120    fn should_fail(self, point: FailurePoint) -> bool {
121        self.0 == Some(point)
122    }
123}
124
125struct PreparedRenameFile {
126    path: String,
127    text: String,
128    permissions: fs::Permissions,
129}
130
131struct StagedRenameFile {
132    path: String,
133    staged: PathBuf,
134    backup: PathBuf,
135    backup_moved: bool,
136    installed: bool,
137}
138
139fn path_issue(reason: &'static str, path: &str, detail: impl Into<String>) -> PathIssue {
140    PathIssue {
141        reason,
142        path: path.to_string(),
143        detail: detail.into(),
144    }
145}
146
147/// Check one path that a rename may mutate. The canonical root is resolved
148/// once for the plan and again for application; the final path itself must
149/// not be a symlink, and its resolved target must remain under that root.
150///
151/// The walker intentionally yields symlinked Markdown files for read-only
152/// parity, but rename is a mutation surface: following one here could write
153/// through to an unrelated file outside the requested corpus.
154fn check_mutation_path(root: &Path, path: &str) -> Result<(), PathIssue> {
155    let candidate = Path::new(path);
156    let metadata = fs::symlink_metadata(candidate).map_err(|error| PathIssue {
157        reason: REASON_PATH_OUTSIDE_ROOT,
158        path: path.to_string(),
159        detail: format!("cannot inspect path: {error}"),
160    })?;
161    if metadata.file_type().is_symlink() {
162        return Err(PathIssue {
163            reason: REASON_SYMLINK_PATH,
164            path: path.to_string(),
165            detail: "symlinked mutation paths are not permitted".to_string(),
166        });
167    }
168    if !metadata.file_type().is_file() {
169        return Err(PathIssue {
170            reason: REASON_PATH_OUTSIDE_ROOT,
171            path: path.to_string(),
172            detail: "mutation paths must be regular files".to_string(),
173        });
174    }
175    let canonical = fs::canonicalize(candidate).map_err(|error| PathIssue {
176        reason: REASON_PATH_OUTSIDE_ROOT,
177        path: path.to_string(),
178        detail: format!("cannot resolve path: {error}"),
179    })?;
180    if !canonical.starts_with(root) {
181        return Err(PathIssue {
182            reason: REASON_PATH_OUTSIDE_ROOT,
183            path: path.to_string(),
184            detail: format!(
185                "resolved path {} is outside corpus root {}",
186                canonical.display(),
187                root.display()
188            ),
189        });
190    }
191    Ok(())
192}
193
194/// Check the parent directory of a temporary sibling or replacement path.
195/// The final entry may not exist yet, so this is the root-boundary check that
196/// applies before staging or restoring a transaction file.
197fn check_sibling_parent(root: &Path, path: &Path) -> Result<(), PathIssue> {
198    let parent = path.parent().ok_or_else(|| {
199        path_issue(
200            REASON_PATH_OUTSIDE_ROOT,
201            &path.to_string_lossy(),
202            "path has no parent directory",
203        )
204    })?;
205    let metadata = fs::metadata(parent).map_err(|error| {
206        path_issue(
207            REASON_PATH_OUTSIDE_ROOT,
208            &path.to_string_lossy(),
209            format!("cannot inspect parent directory: {error}"),
210        )
211    })?;
212    if !metadata.is_dir() {
213        return Err(path_issue(
214            REASON_PATH_OUTSIDE_ROOT,
215            &path.to_string_lossy(),
216            "parent path is not a directory",
217        ));
218    }
219    let canonical = fs::canonicalize(parent).map_err(|error| {
220        path_issue(
221            REASON_PATH_OUTSIDE_ROOT,
222            &path.to_string_lossy(),
223            format!("cannot resolve parent directory: {error}"),
224        )
225    })?;
226    if !canonical.starts_with(root) {
227        return Err(path_issue(
228            REASON_PATH_OUTSIDE_ROOT,
229            &path.to_string_lossy(),
230            format!(
231                "parent directory {} is outside corpus root {}",
232                canonical.display(),
233                root.display()
234            ),
235        ));
236    }
237    Ok(())
238}
239
240/// After the original has been moved to its backup, the destination must be
241/// absent before the staged sibling is installed. A reappeared entry is
242/// rejected rather than silently overwritten.
243fn check_replacement_path(root: &Path, path: &str) -> Result<(), PathIssue> {
244    let candidate = Path::new(path);
245    match fs::symlink_metadata(candidate) {
246        Ok(metadata) if metadata.file_type().is_symlink() => Err(path_issue(
247            REASON_SYMLINK_PATH,
248            path,
249            "destination became a symlink during rename",
250        )),
251        Ok(_) => Err(path_issue(
252            REASON_PATH_OUTSIDE_ROOT,
253            path,
254            "destination reappeared during rename",
255        )),
256        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
257            check_sibling_parent(root, candidate)
258        }
259        Err(error) => Err(path_issue(
260            REASON_PATH_OUTSIDE_ROOT,
261            path,
262            format!("cannot inspect replacement path: {error}"),
263        )),
264    }
265}
266
267fn path_issue_message(phase: &str, issue: &PathIssue) -> String {
268    format!(
269        "rename: refusing to {phase} {}: {}",
270        issue.path, issue.detail
271    )
272}
273
274fn refused(
275    directory: &str,
276    recursive: bool,
277    old_ref: &str,
278    new_ref: &str,
279    target_path: Option<String>,
280    reason: &'static str,
281) -> RenamePlan {
282    RenamePlan {
283        directory: directory.to_string(),
284        recursive,
285        old_ref: old_ref.to_string(),
286        new_ref: new_ref.to_string(),
287        ok: false,
288        target_path,
289        identity_field: None,
290        reason: Some(reason),
291        edits: Vec::new(),
292    }
293}
294
295/// `_NEW_REF_RE = ^[A-Za-z][\w.-]*$` — an ASCII letter, then Unicode word
296/// chars / `.` / `-`. (`new_ref` is stripped first, so the Python `$`
297/// trailing-newline allowance cannot fire.)
298fn valid_new_ref(new_ref: &str) -> bool {
299    let mut chars = new_ref.chars();
300    match chars.next() {
301        Some(c) if c.is_ascii_alphabetic() => {}
302        _ => return false,
303    }
304    chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-'))
305}
306
307/// `_replace_token(text, old_ref, new_ref)` — replace the LEADING token,
308/// case-insensitively, whole-token (the next char must not be an
309/// identifier char), preserving everything after it. `old_ref` arrives
310/// pre-casefolded on the identity paths, exactly like the oracle.
311fn replace_token(text: &str, old_ref: &str, new_ref: &str) -> Option<String> {
312    let folded_old = py_casefold(old_ref);
313    // `text[: len(old_ref)]` — Python slices by CHARS and simply yields a
314    // shorter prefix when text is shorter; casefold-expansion can still
315    // match it (the deliberate length quirk the oracle documents).
316    let n = old_ref.chars().count();
317    let byte_len: usize = text.chars().take(n).map(char::len_utf8).sum();
318    if py_casefold(&text[..byte_len]) != folded_old {
319        return None;
320    }
321    let rest = &text[byte_len..];
322    if let Some(c) = rest.chars().next() {
323        if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') {
324            return None;
325        }
326    }
327    Some(format!("{new_ref}{rest}"))
328}
329
330/// `_LIST_MARKER_RE = ^(\s*(?:[-*+]|\d+\.)\s+)(.*)$` — returns the byte
331/// length of group 1 (marker prefix incl. surrounding whitespace), or None.
332fn list_marker_prefix_len(raw: &str) -> Option<usize> {
333    let mut i = 0;
334    for c in raw.chars() {
335        if py_is_space(c) {
336            i += c.len_utf8();
337        } else {
338            break;
339        }
340    }
341    let rest = &raw[i..];
342    let mut marker_len = 0;
343    let mut chars = rest.chars();
344    match chars.next() {
345        Some(c @ ('-' | '*' | '+')) => marker_len += c.len_utf8(),
346        Some(c) if crate::pycompat::is_re_digit(c) => {
347            marker_len += c.len_utf8();
348            loop {
349                match rest[marker_len..].chars().next() {
350                    Some(d) if crate::pycompat::is_re_digit(d) => marker_len += d.len_utf8(),
351                    Some('.') => {
352                        marker_len += 1;
353                        break;
354                    }
355                    _ => return None,
356                }
357            }
358        }
359        _ => return None,
360    }
361    let tail = &rest[marker_len..];
362    let ws: usize = tail
363        .chars()
364        .take_while(|c| py_is_space(*c))
365        .map(char::len_utf8)
366        .sum();
367    if ws == 0 {
368        return None;
369    }
370    Some(i + marker_len + ws)
371}
372
373/// Raw file text for one corpus path. The oracle's strict
374/// `read_text(encoding="utf-8")` would traceback on invalid UTF-8; the
375/// walk already decoded these files leniently, so a lossy decode here is
376/// the same documented divergence class (stderr-only).
377fn read_raw(path: &str) -> String {
378    match std::fs::read(path) {
379        Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
380        Err(_) => String::new(),
381    }
382}
383
384/// `_relationship_reference_lines(raw_lines, sections)` — 1-based
385/// `(line_no, raw_line)` for every non-empty line inside a relevant
386/// relationship section, tracking the current `##` heading.
387fn relationship_reference_lines<'a>(
388    raw_lines: &[&'a str],
389    sections: &HashSet<String>,
390) -> Vec<(usize, &'a str)> {
391    let mut result = Vec::new();
392    let mut current: Option<String> = None;
393    for (i, raw) in raw_lines.iter().enumerate() {
394        let stripped = py_strip(raw);
395        if let Some(rest) = stripped.strip_prefix("## ") {
396            current = Some(py_casefold(py_strip(rest)));
397            continue;
398        }
399        if stripped.starts_with('#') {
400            current = None; // any other heading ends the section
401            continue;
402        }
403        if let Some(cur) = &current {
404            if sections.contains(cur) && !stripped.is_empty() {
405                result.push((i + 1, *raw));
406            }
407        }
408    }
409    result
410}
411
412/// `_reference_edits(items, target_path, old_ref, new_ref)` — every inbound
413/// relationship line whose leading reference token equals `old_ref`.
414fn reference_edits(
415    items: &[CorpusItem],
416    root: &Path,
417    old_ref: &str,
418    new_ref: &str,
419) -> Result<Vec<RenameEdit>, PathIssue> {
420    let mut edits = Vec::new();
421    for item in items {
422        let Some(spec) = item.spec else { continue };
423        let present: HashSet<String> = spec
424            .optional
425            .iter()
426            .filter(|section| {
427                RELATIONSHIP_SECTIONS.iter().any(|(name, _)| name == section)
428                    && item
429                        .artifact
430                        .section(section)
431                        .map(|body| !body.is_empty())
432                        .unwrap_or(false)
433            })
434            .cloned()
435            .collect();
436        if present.is_empty() {
437            continue;
438        }
439        check_mutation_path(root, &item.path)?;
440        let raw = read_raw(&item.path);
441        let raw_lines = py_splitlines(&raw);
442        for (line_no, raw_line) in relationship_reference_lines(&raw_lines, &present) {
443            let prefix_len = list_marker_prefix_len(raw_line).unwrap_or(0);
444            let ref_text = &raw_line[prefix_len..];
445            let Some(rewritten) = replace_token(py_strip(ref_text), old_ref, new_ref) else {
446                continue;
447            };
448            // Preserve the marker and surrounding whitespace by rebuilding
449            // only the reference portion (first occurrence in the tail).
450            let new_line = format!(
451                "{}{}",
452                &raw_line[..prefix_len],
453                raw_line[prefix_len..].replacen(py_strip(ref_text), &rewritten, 1)
454            );
455            if new_line != raw_line {
456                edits.push(RenameEdit {
457                    path: item.path.clone(),
458                    line: line_no as i64,
459                    old_line: raw_line.to_string(),
460                    new_line,
461                    kind: KIND_REFERENCE,
462                });
463            }
464        }
465    }
466    Ok(edits)
467}
468
469/// One matched frontmatter `id:` line: `(g1 prefix, g2 quote, g3 value,
470/// g5 suffix)` per `_FRONTMATTER_ID_RE` — value may be quoted, a trailing
471/// `#` comment is preserved.
472fn frontmatter_id_line(line: &str) -> Option<(String, String, String, String)> {
473    let mut i = 0;
474    for c in line.chars() {
475        if py_is_space(c) {
476            i += c.len_utf8();
477        } else {
478            break;
479        }
480    }
481    let after_ws = &line[i..];
482    if !after_ws.starts_with("id") {
483        return None;
484    }
485    i += 2;
486    for c in line[i..].chars() {
487        if py_is_space(c) {
488            i += c.len_utf8();
489        } else {
490            break;
491        }
492    }
493    if !line[i..].starts_with(':') {
494        return None;
495    }
496    i += 1;
497    for c in line[i..].chars() {
498        if py_is_space(c) {
499            i += c.len_utf8();
500        } else {
501            break;
502        }
503    }
504    let g1 = line[..i].to_string();
505    let rest = &line[i..];
506    let quote = match rest.chars().next() {
507        Some(q @ ('\'' | '"')) => Some(q),
508        _ => None,
509    };
510    if let Some(q) = quote {
511        // Quoted: value is [^'"#]+ up to the SAME quote, then ws + optional
512        // comment. Any quote or '#' inside the value fails the whole match.
513        let body = &rest[q.len_utf8()..];
514        let mut vlen = 0;
515        let mut closed = false;
516        for c in body.chars() {
517            if c == q {
518                closed = true;
519                break;
520            }
521            if matches!(c, '\'' | '"' | '#') {
522                return None;
523            }
524            vlen += c.len_utf8();
525        }
526        if !closed || vlen == 0 {
527            return None;
528        }
529        let after = &body[vlen + q.len_utf8()..];
530        if !ws_then_optional_comment(after) {
531            return None;
532        }
533        Some((g1, q.to_string(), body[..vlen].to_string(), after.to_string()))
534    } else {
535        // Unquoted: a quote anywhere before the comment fails; the lazy
536        // group pushes trailing whitespace into the suffix.
537        let mut vlen = 0;
538        let mut has_hash = false;
539        for c in rest.chars() {
540            if c == '#' {
541                has_hash = true;
542                break;
543            }
544            if matches!(c, '\'' | '"') {
545                return None;
546            }
547            vlen += c.len_utf8();
548        }
549        let run = &rest[..vlen];
550        let value = run.trim_end_matches(py_is_space);
551        if value.is_empty() {
552            // A pure-whitespace value strips to "", which can never equal a
553            // non-empty old_ref — no edit either way.
554            return None;
555        }
556        let suffix = format!(
557            "{}{}",
558            &run[value.len()..],
559            if has_hash { &rest[vlen..] } else { "" }
560        );
561        Some((g1, String::new(), value.to_string(), suffix))
562    }
563}
564
565fn ws_then_optional_comment(s: &str) -> bool {
566    let rest = s.trim_start_matches(py_is_space);
567    rest.is_empty() || rest.starts_with('#')
568}
569
570/// Partial edit: (1-based line, old text, new text).
571type LineEdit = (usize, String, String);
572
573/// `_frontmatter_id_edit(raw_lines, old_ref, new_ref)` — rewrite the value
574/// of the `id:` line inside the LEADING `---` block.
575fn frontmatter_id_edit(raw_lines: &[&str], old_ref: &str, new_ref: &str) -> Option<LineEdit> {
576    if raw_lines.first().map(|l| py_strip(l)) != Some("---") {
577        return None;
578    }
579    for (i, raw) in raw_lines.iter().enumerate().skip(1) {
580        if py_strip(raw) == "---" {
581            break;
582        }
583        if let Some((g1, g2, g3, g5)) = frontmatter_id_line(raw) {
584            if py_casefold(py_strip(&g3)) == py_casefold(old_ref) {
585                let new_line = format!("{g1}{g2}{new_ref}{g2}{g5}");
586                if new_line != *raw {
587                    return Some((i + 1, (*raw).to_string(), new_line));
588                }
589            }
590        }
591    }
592    None
593}
594
595/// `^\s*##\s+<name>\s*$` (IGNORECASE) — the `## ID` / `## <id_field>`
596/// heading matcher.
597fn heading_matches(raw: &str, name: &str) -> bool {
598    let after_ws = raw.trim_start_matches(py_is_space);
599    let Some(rest) = after_ws.strip_prefix("##") else {
600        return false;
601    };
602    let after_hash_ws = rest.trim_start_matches(py_is_space);
603    if after_hash_ws.len() == rest.len() {
604        return false; // \s+ requires at least one space after ##
605    }
606    let n = name.chars().count();
607    let byte_len: usize = after_hash_ws.chars().take(n).map(char::len_utf8).sum();
608    if after_hash_ws.chars().count() < n
609        || py_casefold(&after_hash_ws[..byte_len]) != py_casefold(name)
610    {
611        return false;
612    }
613    after_hash_ws[byte_len..].chars().all(py_is_space)
614}
615
616/// `_section_first_value_edit` — rewrite the first value line under a
617/// matching heading; only the FIRST value line of each matching section is
618/// the identity (scanning continues after a non-rewriting first value).
619fn section_first_value_edit(
620    raw_lines: &[&str],
621    section_name: &str,
622    folded_old: &str,
623    new_ref: &str,
624) -> Option<LineEdit> {
625    let mut in_section = false;
626    for (i, raw) in raw_lines.iter().enumerate() {
627        let stripped = py_strip(raw);
628        if stripped.starts_with('#') {
629            in_section = heading_matches(raw, section_name);
630            continue;
631        }
632        if !in_section || stripped.is_empty() {
633            continue;
634        }
635        let prefix_len = list_marker_prefix_len(raw).unwrap_or(0);
636        let value = &raw[prefix_len..];
637        let rewritten = replace_token(py_strip(value), folded_old, new_ref);
638        in_section = false; // only the first value line is the identity
639        let Some(rewritten) = rewritten else { continue };
640        let new_line = format!(
641            "{}{}",
642            &raw[..prefix_len],
643            raw[prefix_len..].replacen(py_strip(value), &rewritten, 1)
644        );
645        if new_line != *raw {
646            return Some((i + 1, (*raw).to_string(), new_line));
647        }
648    }
649    None
650}
651
652/// `_identity_edit(target_path, product, spec, old_ref, new_ref)` —
653/// `(edit, identity_field)` on success, or the filename-only refusal.
654fn identity_edit(
655    item: &CorpusItem,
656    old_ref: &str,
657    new_ref: &str,
658) -> Result<(LineEdit, &'static str), &'static str> {
659    let raw = read_raw(&item.path);
660    let raw_lines = py_splitlines(&raw);
661    let folded_old = py_casefold(old_ref);
662
663    // 1. Canonical frontmatter `id` — only when old_ref IS it.
664    if let Some(meta) = &item.artifact.metadata {
665        if let Some(id) = meta.id.as_deref().filter(|s| !s.is_empty()) {
666            if py_casefold(id) == folded_old {
667                if let Some(edit) = frontmatter_id_edit(&raw_lines, old_ref, new_ref) {
668                    return Ok((edit, IDENTITY_FRONTMATTER));
669                }
670            }
671        }
672    }
673
674    // 2. `## ID` section value.
675    if let Some(edit) = section_first_value_edit(&raw_lines, "id", &folded_old, new_ref) {
676        return Ok((edit, IDENTITY_ID_SECTION));
677    }
678
679    // 3. The type's `spec.id_field` section (no spec sets one — ported for
680    //    fidelity, dead today).
681    if let Some(spec) = item.spec {
682        if let Some(field) = spec.id_field.as_deref().filter(|f| !f.is_empty()) {
683            if let Some(edit) = section_first_value_edit(&raw_lines, field, &folded_old, new_ref) {
684                return Ok((edit, IDENTITY_ID_FIELD));
685            }
686        }
687    }
688
689    // 4. Filename-derived alias only — nothing editable in-file.
690    Err(REASON_OLD_FILENAME_ONLY)
691}
692
693/// `compute_rename(directory, old_ref, new_ref, recursive)`.
694pub fn compute_rename(
695    directory: &str,
696    old_ref: &str,
697    new_ref: &str,
698    recursive: bool,
699) -> RenamePlan {
700    let new_ref = py_strip(new_ref).to_string();
701    if !valid_new_ref(&new_ref) {
702        return refused(directory, recursive, old_ref, &new_ref, None, REASON_NEW_INVALID);
703    }
704
705    let root = match fs::canonicalize(directory) {
706        Ok(root) => root,
707        Err(_) => {
708            return refused(
709                directory,
710                recursive,
711                old_ref,
712                &new_ref,
713                Some(directory.to_string()),
714                REASON_PATH_OUTSIDE_ROOT,
715            )
716        }
717    };
718
719    let items = corpus_items(directory, recursive);
720    let rows: Vec<ValidationRow> = items
721        .iter()
722        .map(|item| validation_row(&item.path, &item.artifact, item.spec))
723        .collect();
724    let index = resolution_index_from_rows(&rows);
725    let mut targets: Vec<&str> = index
726        .get(&py_casefold(old_ref))
727        .iter()
728        .map(|(path, _)| path.as_str())
729        .collect::<HashSet<_>>()
730        .into_iter()
731        .collect();
732    targets.sort_unstable();
733    let target_path = match targets.as_slice() {
734        [] => {
735            return refused(directory, recursive, old_ref, &new_ref, None, REASON_OLD_NOT_FOUND)
736        }
737        [one] => (*one).to_string(),
738        _ => {
739            return refused(directory, recursive, old_ref, &new_ref, None, REASON_OLD_AMBIGUOUS)
740        }
741    };
742
743    // A no-op rename (new == old case-insensitively) skips the collision
744    // check; otherwise a `new_ref` naming ANOTHER artifact refuses.
745    if py_casefold(&new_ref) != py_casefold(old_ref) {
746        let folded_new = py_casefold(&new_ref);
747        let collides = rows.iter().any(|row| {
748            row.path != target_path
749                && row.identifiers.iter().any(|i| py_casefold(i) == folded_new)
750        });
751        if collides {
752            return refused(
753                directory,
754                recursive,
755                old_ref,
756                &new_ref,
757                Some(target_path),
758                REASON_NEW_COLLIDES,
759            );
760        }
761    }
762
763    let target_item = items
764        .iter()
765        .find(|item| item.path == target_path)
766        .expect("resolved target is in the walked corpus");
767    if let Err(issue) = check_mutation_path(&root, &target_path) {
768        return refused(
769            directory,
770            recursive,
771            old_ref,
772            &new_ref,
773            Some(issue.path),
774            issue.reason,
775        );
776    }
777    let (identity, identity_field) = match identity_edit(target_item, old_ref, &new_ref) {
778        Ok(pair) => pair,
779        Err(reason) => {
780            return refused(directory, recursive, old_ref, &new_ref, Some(target_path), reason)
781        }
782    };
783
784    let mut edits = match reference_edits(&items, &root, old_ref, &new_ref) {
785        Ok(edits) => edits,
786        Err(issue) => {
787            return refused(
788                directory,
789                recursive,
790                old_ref,
791                &new_ref,
792                Some(issue.path),
793                issue.reason,
794            )
795        }
796    };
797    let (line, old_line, new_line) = identity;
798    edits.push(RenameEdit {
799        path: target_path.clone(),
800        line: line as i64,
801        old_line,
802        new_line,
803        kind: KIND_IDENTITY,
804    });
805    edits.sort_by(|a, b| (a.path.as_str(), a.line).cmp(&(b.path.as_str(), b.line)));
806
807    RenamePlan {
808        directory: directory.to_string(),
809        recursive,
810        old_ref: old_ref.to_string(),
811        new_ref,
812        ok: true,
813        target_path: Some(target_path),
814        identity_field: Some(identity_field),
815        reason: None,
816        edits,
817    }
818}
819
820fn transaction_token() -> String {
821    let nanos = std::time::SystemTime::now()
822        .duration_since(std::time::UNIX_EPOCH)
823        .map(|duration| duration.as_nanos())
824        .unwrap_or_default();
825    format!("{}-{nanos}", std::process::id())
826}
827
828fn temporary_sibling(
829    root: &Path,
830    path: &str,
831    token: &str,
832    index: usize,
833    kind: &str,
834) -> Result<PathBuf, String> {
835    let destination = Path::new(path);
836    check_sibling_parent(root, destination)
837        .map_err(|issue| path_issue_message("stage", &issue))?;
838    let parent = destination
839        .parent()
840        .ok_or_else(|| format!("rename: cannot stage {path}: path has no parent directory"))?;
841    for attempt in 0..100 {
842        let candidate = parent.join(format!(
843            ".asdecided-rename-{token}-{index}-{kind}-{attempt}.tmp"
844        ));
845        match fs::symlink_metadata(&candidate) {
846            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
847                return Ok(candidate)
848            }
849            Ok(_) => continue,
850            Err(error) => {
851                return Err(format!(
852                    "rename: cannot reserve {kind} path for {path}: {error}"
853                ))
854            }
855        }
856    }
857    Err(format!(
858        "rename: cannot reserve a unique {kind} path for {path}"
859    ))
860}
861
862fn stage_text(
863    root: &Path,
864    prepared: &PreparedRenameFile,
865    token: &str,
866    index: usize,
867    injector: FailureInjector,
868) -> Result<PathBuf, String> {
869    if injector.should_fail(FailurePoint::Stage(index)) {
870        return Err(format!(
871            "injected staging failure for {}",
872            prepared.path
873        ));
874    }
875    let staged = temporary_sibling(root, &prepared.path, token, index, "stage")?;
876    let mut options = OpenOptions::new();
877    options.write(true).create_new(true);
878    #[cfg(unix)]
879    {
880        use std::os::unix::fs::OpenOptionsExt;
881        options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
882    }
883    let mut file = match options.open(&staged) {
884        Ok(file) => file,
885        Err(error) => {
886            return Err(format!(
887                "rename: cannot create staging file for {}: {error}",
888                prepared.path
889            ))
890        }
891    };
892    if let Err(error) = file.write_all(prepared.text.as_bytes()) {
893        let _ = fs::remove_file(&staged);
894        return Err(format!(
895            "rename: cannot write staging file for {}: {error}",
896            prepared.path
897        ));
898    }
899    if let Err(error) = file.sync_all() {
900        let _ = fs::remove_file(&staged);
901        return Err(format!(
902            "rename: cannot flush staging file for {}: {error}",
903            prepared.path
904        ));
905    }
906    if let Err(error) = fs::set_permissions(&staged, prepared.permissions.clone()) {
907        let _ = fs::remove_file(&staged);
908        return Err(format!(
909            "rename: cannot preserve permissions for {}: {error}",
910            prepared.path
911        ));
912    }
913    Ok(staged)
914}
915
916fn remove_temp(path: &Path, errors: &mut Vec<String>) {
917    match fs::symlink_metadata(path) {
918        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
919        Err(error) => errors.push(format!("{}: {error}", path.display())),
920        Ok(metadata) if metadata.file_type().is_file() || metadata.file_type().is_symlink() => {
921            if let Err(error) = fs::remove_file(path) {
922                errors.push(format!("{}: {error}", path.display()));
923            }
924        }
925        Ok(_) => errors.push(format!("{}: temporary path is not a file", path.display())),
926    }
927}
928
929fn remove_installed_path(root: &Path, path: &str) -> Result<(), String> {
930    match fs::symlink_metadata(path) {
931        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
932        Err(error) => Err(format!("cannot inspect installed path {path}: {error}")),
933        Ok(metadata) if metadata.file_type().is_symlink() => {
934            fs::remove_file(path).map_err(|error| format!("cannot remove {path}: {error}"))
935        }
936        Ok(_) => {
937            check_mutation_path(root, path)
938                .map_err(|issue| path_issue_message("remove during rollback", &issue))?;
939            fs::remove_file(path).map_err(|error| format!("cannot remove {path}: {error}"))
940        }
941    }
942}
943
944fn restore_backup(root: &Path, entry: &StagedRenameFile) -> Result<(), String> {
945    match fs::symlink_metadata(&entry.path) {
946        Ok(_) => return Err(format!("destination {} is occupied during rollback", entry.path)),
947        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
948        Err(error) => {
949            return Err(format!(
950                "cannot inspect {} during rollback: {error}",
951                entry.path
952            ))
953        }
954    }
955    check_sibling_parent(root, Path::new(&entry.path))
956        .map_err(|issue| path_issue_message("restore", &issue))?;
957    fs::rename(&entry.backup, &entry.path)
958        .map_err(|error| format!("cannot restore {}: {error}", entry.path))
959}
960
961fn rollback_transaction(root: &Path, entries: &mut [StagedRenameFile]) -> Vec<String> {
962    let mut errors = Vec::new();
963    for entry in entries.iter_mut().rev() {
964        if entry.installed {
965            if let Err(error) = remove_installed_path(root, &entry.path) {
966                errors.push(error);
967            } else {
968                entry.installed = false;
969            }
970        }
971        if entry.backup_moved {
972            if let Err(error) = restore_backup(root, entry) {
973                errors.push(error);
974            } else {
975                entry.backup_moved = false;
976            }
977        }
978        remove_temp(&entry.staged, &mut errors);
979        if !entry.backup_moved {
980            remove_temp(&entry.backup, &mut errors);
981        }
982    }
983    errors
984}
985
986fn transaction_failure(
987    root: &Path,
988    entries: &mut [StagedRenameFile],
989    reason: impl Into<String>,
990) -> String {
991    let reason = reason.into();
992    let rollback_errors = rollback_transaction(root, entries);
993    if rollback_errors.is_empty() {
994        format!("rename: transaction aborted: {reason}; corpus restored")
995    } else {
996        format!(
997            "rename: transaction aborted: {reason}; rollback incomplete: {}",
998            rollback_errors.join("; ")
999        )
1000    }
1001}
1002
1003fn cleanup_staging(entries: &[StagedRenameFile]) -> Vec<String> {
1004    let mut errors = Vec::new();
1005    for entry in entries {
1006        remove_temp(&entry.staged, &mut errors);
1007        remove_temp(&entry.backup, &mut errors);
1008    }
1009    errors
1010}
1011
1012fn apply_rename_transaction(
1013    plan: &RenamePlan,
1014    injector: FailureInjector,
1015) -> Result<RenameResult, String> {
1016    if !plan.ok {
1017        return Ok(RenameResult {
1018            directory: plan.directory.clone(),
1019            old_ref: plan.old_ref.clone(),
1020            new_ref: plan.new_ref.clone(),
1021            applied: false,
1022            files_changed: 0,
1023            reference_edits: 0,
1024            identity_edits: 0,
1025            target_path: plan.target_path.clone(),
1026        });
1027    }
1028
1029    let root = fs::canonicalize(&plan.directory)
1030        .map_err(|e| format!("rename: cannot resolve corpus root {}: {e}", plan.directory))?;
1031
1032    // Group by path, first-seen order (Python dict setdefault), then complete
1033    // the entire read/stale/render preflight before creating any replacement.
1034    let mut order: Vec<&str> = Vec::new();
1035    for edit in &plan.edits {
1036        if !order.contains(&edit.path.as_str()) {
1037            order.push(&edit.path);
1038        }
1039    }
1040    let mut prepared = Vec::with_capacity(order.len());
1041    for path in order {
1042        check_mutation_path(&root, path).map_err(|issue| path_issue_message("read", &issue))?;
1043        let permissions = fs::metadata(path)
1044            .map_err(|error| format!("rename: cannot inspect {path}: {error}"))?
1045            .permissions();
1046        if permissions.readonly() {
1047            return Err(format!(
1048                "rename: cannot write {path}: file is read-only"
1049            ));
1050        }
1051        let original = std::fs::read_to_string(path)
1052            .map_err(|e| format!("rename: cannot read {path}: {e}"))?;
1053        let had_final_newline = original.ends_with('\n');
1054        let mut lines: Vec<String> = py_splitlines(&original)
1055            .into_iter()
1056            .map(str::to_string)
1057            .collect();
1058        for edit in plan.edits.iter().filter(|e| e.path == path) {
1059            let idx = edit.line - 1;
1060            let in_range = idx >= 0 && (idx as usize) < lines.len();
1061            if !in_range || lines[idx as usize] != edit.old_line {
1062                return Err(format!(
1063                    "rename: stale plan for {path} line {}: file changed since the plan was computed",
1064                    edit.line
1065                ));
1066            }
1067            lines[idx as usize] = edit.new_line.clone();
1068        }
1069        let mut text = lines.join("\n");
1070        if had_final_newline {
1071            text.push('\n');
1072        }
1073        prepared.push(PreparedRenameFile {
1074            path: path.to_string(),
1075            text,
1076            permissions,
1077        });
1078    }
1079
1080    let token = transaction_token();
1081    let mut entries = Vec::with_capacity(prepared.len());
1082    for (index, prepared) in prepared.iter().enumerate() {
1083        let staged = match stage_text(&root, prepared, &token, index, injector) {
1084            Ok(staged) => staged,
1085            Err(error) => {
1086                let cleanup_errors = cleanup_staging(&entries);
1087                return if cleanup_errors.is_empty() {
1088                    Err(format!("rename: staging failed: {error}; no corpus files were replaced"))
1089                } else {
1090                    Err(format!(
1091                        "rename: staging failed: {error}; temporary cleanup failed: {}",
1092                        cleanup_errors.join("; ")
1093                    ))
1094                };
1095            }
1096        };
1097        let backup = match temporary_sibling(&root, &prepared.path, &token, index, "backup") {
1098            Ok(backup) => backup,
1099            Err(error) => {
1100                let mut cleanup_errors = Vec::new();
1101                remove_temp(&staged, &mut cleanup_errors);
1102                cleanup_errors.extend(cleanup_staging(&entries));
1103                return if cleanup_errors.is_empty() {
1104                    Err(format!("rename: staging failed: {error}; no corpus files were replaced"))
1105                } else {
1106                    Err(format!(
1107                        "rename: staging failed: {error}; temporary cleanup failed: {}",
1108                        cleanup_errors.join("; ")
1109                    ))
1110                };
1111            }
1112        };
1113        entries.push(StagedRenameFile {
1114            path: prepared.path.clone(),
1115            staged,
1116            backup,
1117            backup_moved: false,
1118            installed: false,
1119        });
1120    }
1121
1122    // Each replacement is a same-directory rename. Originals move to sibling
1123    // backups first; any failure rolls committed entries back in reverse order.
1124    for index in 0..entries.len() {
1125        let path = entries[index].path.clone();
1126        if let Err(issue) = check_mutation_path(&root, &path) {
1127            return Err(transaction_failure(
1128                &root,
1129                &mut entries,
1130                path_issue_message("replace", &issue),
1131            ));
1132        }
1133        if let Err(issue) = check_sibling_parent(&root, &entries[index].backup) {
1134            return Err(transaction_failure(
1135                &root,
1136                &mut entries,
1137                path_issue_message("backup", &issue),
1138            ));
1139        }
1140        if injector.should_fail(FailurePoint::Backup(index)) {
1141            return Err(transaction_failure(
1142                &root,
1143                &mut entries,
1144                format!("injected backup failure for {path}"),
1145            ));
1146        }
1147        if let Err(error) = fs::rename(&path, &entries[index].backup) {
1148            return Err(transaction_failure(
1149                &root,
1150                &mut entries,
1151                format!("cannot move {path} to its transaction backup: {error}"),
1152            ));
1153        }
1154        entries[index].backup_moved = true;
1155
1156        if injector.should_fail(FailurePoint::Replace(index)) {
1157            return Err(transaction_failure(
1158                &root,
1159                &mut entries,
1160                format!("injected replacement failure for {path}"),
1161            ));
1162        }
1163        if let Err(issue) = check_replacement_path(&root, &path) {
1164            return Err(transaction_failure(
1165                &root,
1166                &mut entries,
1167                path_issue_message("replace", &issue),
1168            ));
1169        }
1170        let staged_path = entries[index].staged.to_string_lossy().to_string();
1171        if let Err(issue) = check_mutation_path(&root, &staged_path) {
1172            return Err(transaction_failure(
1173                &root,
1174                &mut entries,
1175                path_issue_message("replace", &issue),
1176            ));
1177        }
1178        if let Err(error) = fs::rename(&entries[index].staged, &path) {
1179            return Err(transaction_failure(
1180                &root,
1181                &mut entries,
1182                format!("cannot install replacement for {path}: {error}"),
1183            ));
1184        }
1185        entries[index].installed = true;
1186    }
1187
1188    let cleanup_errors = cleanup_staging(&entries);
1189    if !cleanup_errors.is_empty() {
1190        return Err(format!(
1191            "rename: transaction committed but temporary cleanup failed: {}",
1192            cleanup_errors.join("; ")
1193        ));
1194    }
1195
1196    Ok(RenameResult {
1197        directory: plan.directory.clone(),
1198        old_ref: plan.old_ref.clone(),
1199        new_ref: plan.new_ref.clone(),
1200        applied: true,
1201        files_changed: plan.files_changed(),
1202        reference_edits: plan.reference_edits(),
1203        identity_edits: plan.identity_edits(),
1204        target_path: plan.target_path.clone(),
1205    })
1206}
1207
1208/// `apply_rename(plan)` — exact line replacements, original final-newline
1209/// shape preserved. All files are preflighted and staged before replacement;
1210/// a later failure rolls earlier replacements back or reports incomplete
1211/// recovery explicitly.
1212pub fn apply_rename(plan: &RenamePlan) -> Result<RenameResult, String> {
1213    apply_rename_transaction(plan, FailureInjector::none())
1214}
1215
1216#[cfg(test)]
1217mod tests {
1218    use super::*;
1219
1220    static NEXT_TRANSACTION_ROOT: std::sync::atomic::AtomicU64 =
1221        std::sync::atomic::AtomicU64::new(0);
1222
1223    fn transaction_root() -> PathBuf {
1224        let nonce = std::time::SystemTime::now()
1225            .duration_since(std::time::UNIX_EPOCH)
1226            .expect("clock after epoch")
1227            .as_nanos();
1228        let sequence = NEXT_TRANSACTION_ROOT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1229        let root = std::env::temp_dir().join(format!(
1230            "asdecided-rename-transaction-{}-{nonce}-{sequence}",
1231            std::process::id(),
1232        ));
1233        fs::create_dir_all(&root).expect("create transaction root");
1234        root
1235    }
1236
1237    fn transaction_plan(root: &Path) -> RenamePlan {
1238        let first = root.join("first.md");
1239        let second = root.join("second.md");
1240        fs::write(&first, "old\n").expect("write first transaction file");
1241        fs::write(&second, "old\n").expect("write second transaction file");
1242        RenamePlan {
1243            directory: root.to_string_lossy().into_owned(),
1244            recursive: true,
1245            old_ref: "old".to_string(),
1246            new_ref: "new".to_string(),
1247            ok: true,
1248            target_path: Some(first.to_string_lossy().into_owned()),
1249            identity_field: Some(IDENTITY_FRONTMATTER),
1250            reason: None,
1251            edits: vec![
1252                RenameEdit {
1253                    path: first.to_string_lossy().into_owned(),
1254                    line: 1,
1255                    old_line: "old".to_string(),
1256                    new_line: "new".to_string(),
1257                    kind: KIND_IDENTITY,
1258                },
1259                RenameEdit {
1260                    path: second.to_string_lossy().into_owned(),
1261                    line: 1,
1262                    old_line: "old".to_string(),
1263                    new_line: "new".to_string(),
1264                    kind: KIND_REFERENCE,
1265                },
1266            ],
1267        }
1268    }
1269
1270    fn assert_no_transaction_temps(root: &Path) {
1271        for entry in fs::read_dir(root).expect("read transaction root") {
1272            let name = entry
1273                .expect("read transaction entry")
1274                .file_name()
1275                .to_string_lossy()
1276                .into_owned();
1277            assert!(
1278                !name.starts_with(".asdecided-rename-"),
1279                "transaction temporary remains: {name}"
1280            );
1281        }
1282    }
1283
1284    #[test]
1285    fn new_ref_grammar() {
1286        assert!(valid_new_ref("ADR-099"));
1287        assert!(valid_new_ref("RAC-ZZZZZZZZZZZZ"));
1288        assert!(valid_new_ref("a.b-c_d1"));
1289        assert!(!valid_new_ref(""));
1290        assert!(!valid_new_ref("1AD"));
1291        assert!(!valid_new_ref("bad id!"));
1292        assert!(!valid_new_ref("-x"));
1293    }
1294
1295    #[test]
1296    fn token_replacement_is_whole_token_and_case_insensitive() {
1297        assert_eq!(
1298            replace_token("ADR-001 (blocked)", "adr-001", "ADR-099").as_deref(),
1299            Some("ADR-099 (blocked)")
1300        );
1301        assert_eq!(replace_token("ADR-10", "ADR-1", "X"), None);
1302        assert_eq!(replace_token("ADR-1.5", "ADR-1", "X"), None);
1303        assert_eq!(replace_token("zzz", "ADR-1", "X"), None);
1304    }
1305
1306    #[test]
1307    fn frontmatter_id_line_shapes() {
1308        assert_eq!(
1309            frontmatter_id_line("id: RAC-A"),
1310            Some(("id: ".into(), "".into(), "RAC-A".into(), "".into()))
1311        );
1312        assert_eq!(
1313            frontmatter_id_line("  id:  'RAC-A'  # note"),
1314            Some(("  id:  ".into(), "'".into(), "RAC-A".into(), "  # note".into()))
1315        );
1316        assert_eq!(frontmatter_id_line("id: 'RAC"), None);
1317        // "ident" begins with the literal `id`, but the regex then demands
1318        // `\s*:` and finds 'e' — no match. Same for a missing colon.
1319        assert_eq!(frontmatter_id_line("ident: RAC-A"), None);
1320        assert_eq!(frontmatter_id_line("id RAC-A"), None);
1321    }
1322
1323    #[test]
1324    fn stale_plan_is_detected_before_any_replacement() {
1325        let root = transaction_root();
1326        let plan = transaction_plan(&root);
1327        let second = root.join("second.md");
1328        fs::write(&second, "changed\n").expect("make second file stale");
1329
1330        let error = apply_rename(&plan).expect_err("stale plan must fail");
1331        assert!(error.contains("stale plan"), "{error}");
1332        assert_eq!(fs::read_to_string(root.join("first.md")).unwrap(), "old\n");
1333        assert_eq!(fs::read_to_string(second).unwrap(), "changed\n");
1334        assert_no_transaction_temps(&root);
1335        fs::remove_dir_all(root).expect("remove transaction root");
1336    }
1337
1338    #[test]
1339    fn replacement_failure_rolls_back_all_committed_files() {
1340        let root = transaction_root();
1341        let plan = transaction_plan(&root);
1342
1343        let error = apply_rename_transaction(
1344            &plan,
1345            FailureInjector::at(FailurePoint::Replace(1)),
1346        )
1347        .expect_err("injected replacement must fail");
1348        assert!(error.contains("corpus restored"), "{error}");
1349        assert_eq!(fs::read_to_string(root.join("first.md")).unwrap(), "old\n");
1350        assert_eq!(fs::read_to_string(root.join("second.md")).unwrap(), "old\n");
1351        assert_no_transaction_temps(&root);
1352        fs::remove_dir_all(root).expect("remove transaction root");
1353    }
1354
1355    #[test]
1356    fn staging_failure_leaves_corpus_untouched() {
1357        let root = transaction_root();
1358        let plan = transaction_plan(&root);
1359
1360        let error = apply_rename_transaction(&plan, FailureInjector::at(FailurePoint::Stage(1)))
1361            .expect_err("injected staging must fail");
1362        assert!(error.contains("staging failed"), "{error}");
1363        assert!(error.contains("no corpus files were replaced"), "{error}");
1364        assert_eq!(fs::read_to_string(root.join("first.md")).unwrap(), "old\n");
1365        assert_eq!(fs::read_to_string(root.join("second.md")).unwrap(), "old\n");
1366        assert_no_transaction_temps(&root);
1367        fs::remove_dir_all(root).expect("remove transaction root");
1368    }
1369
1370    #[test]
1371    fn backup_failure_rolls_back_prior_replacements() {
1372        let root = transaction_root();
1373        let plan = transaction_plan(&root);
1374
1375        let error = apply_rename_transaction(&plan, FailureInjector::at(FailurePoint::Backup(1)))
1376            .expect_err("injected backup must fail");
1377        assert!(error.contains("corpus restored"), "{error}");
1378        assert_eq!(fs::read_to_string(root.join("first.md")).unwrap(), "old\n");
1379        assert_eq!(fs::read_to_string(root.join("second.md")).unwrap(), "old\n");
1380        assert_no_transaction_temps(&root);
1381        fs::remove_dir_all(root).expect("remove transaction root");
1382    }
1383
1384    #[test]
1385    fn successful_transaction_replaces_all_files_and_cleans_backups() {
1386        let root = transaction_root();
1387        let plan = transaction_plan(&root);
1388
1389        let result = apply_rename(&plan).expect("transaction succeeds");
1390        assert!(result.applied);
1391        assert_eq!(result.files_changed, 2);
1392        assert_eq!(fs::read_to_string(root.join("first.md")).unwrap(), "new\n");
1393        assert_eq!(fs::read_to_string(root.join("second.md")).unwrap(), "new\n");
1394        assert_no_transaction_temps(&root);
1395        fs::remove_dir_all(root).expect("remove transaction root");
1396    }
1397
1398    #[cfg(unix)]
1399    #[test]
1400    fn read_only_file_fails_before_staging() {
1401        if unsafe { libc::geteuid() } == 0 {
1402            return;
1403        }
1404        use std::os::unix::fs::PermissionsExt;
1405
1406        let root = transaction_root();
1407        let plan = transaction_plan(&root);
1408        let first = root.join("first.md");
1409        fs::set_permissions(&first, fs::Permissions::from_mode(0o444))
1410            .expect("make first file read-only");
1411
1412        let error = apply_rename(&plan).expect_err("read-only file must fail");
1413        fs::set_permissions(&first, fs::Permissions::from_mode(0o644))
1414            .expect("restore first file permissions");
1415        assert!(error.contains("read-only"), "{error}");
1416        assert_eq!(fs::read_to_string(first).unwrap(), "old\n");
1417        assert_eq!(fs::read_to_string(root.join("second.md")).unwrap(), "old\n");
1418        assert_no_transaction_temps(&root);
1419        fs::remove_dir_all(root).expect("remove transaction root");
1420    }
1421}