1use 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
22pub 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
31pub 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
39pub 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
49pub 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#[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
147fn 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
194fn 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
240fn 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
295fn 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
307fn replace_token(text: &str, old_ref: &str, new_ref: &str) -> Option<String> {
312 let folded_old = py_casefold(old_ref);
313 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
330fn 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
373fn 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
384fn 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; continue;
402 }
403 if let Some(cur) = ¤t {
404 if sections.contains(cur) && !stripped.is_empty() {
405 result.push((i + 1, *raw));
406 }
407 }
408 }
409 result
410}
411
412fn 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 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
469fn 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 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 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 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
570type LineEdit = (usize, String, String);
572
573fn 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
595fn 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; }
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
616fn 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; 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
652fn 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 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 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 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 Err(REASON_OLD_FILENAME_ONLY)
691}
692
693pub 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 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 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 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
1208pub 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 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}