1use std::collections::HashSet;
12
13use crate::pycompat::{py_casefold, py_is_space, py_splitlines, py_strip};
14use crate::relationships::{
15 corpus_items, resolution_index_from_rows, validation_row, CorpusItem, ValidationRow,
16};
17use crate::spec::RELATIONSHIP_SECTIONS;
18
19pub const REASON_OLD_NOT_FOUND: &str = "old-ref-not-found";
21pub const REASON_OLD_AMBIGUOUS: &str = "old-ref-ambiguous";
22pub const REASON_NEW_COLLIDES: &str = "new-ref-collides";
23pub const REASON_NEW_INVALID: &str = "new-ref-invalid";
24pub const REASON_OLD_FILENAME_ONLY: &str = "old-ref-filename-only";
25
26pub const IDENTITY_FRONTMATTER: &str = "frontmatter_id";
28pub const IDENTITY_ID_SECTION: &str = "id_section";
29pub const IDENTITY_ID_FIELD: &str = "id_field";
30
31pub const KIND_REFERENCE: &str = "reference";
32pub const KIND_IDENTITY: &str = "identity";
33
34pub struct RenameEdit {
37 pub path: String,
38 pub line: i64,
39 pub old_line: String,
40 pub new_line: String,
41 pub kind: &'static str,
42}
43
44pub struct RenamePlan {
46 pub directory: String,
47 pub recursive: bool,
48 pub old_ref: String,
49 pub new_ref: String,
50 pub ok: bool,
51 pub target_path: Option<String>,
52 pub identity_field: Option<&'static str>,
53 pub reason: Option<&'static str>,
54 pub edits: Vec<RenameEdit>,
55}
56
57impl RenamePlan {
58 pub fn reference_edits(&self) -> usize {
59 self.edits.iter().filter(|e| e.kind == KIND_REFERENCE).count()
60 }
61
62 pub fn identity_edits(&self) -> usize {
63 self.edits.iter().filter(|e| e.kind == KIND_IDENTITY).count()
64 }
65
66 pub fn files_changed(&self) -> usize {
67 self.edits
68 .iter()
69 .map(|e| e.path.as_str())
70 .collect::<HashSet<_>>()
71 .len()
72 }
73}
74
75pub struct RenameResult {
77 pub directory: String,
78 pub old_ref: String,
79 pub new_ref: String,
80 pub applied: bool,
81 pub files_changed: usize,
82 pub reference_edits: usize,
83 pub identity_edits: usize,
84 pub target_path: Option<String>,
85}
86
87fn refused(
88 directory: &str,
89 recursive: bool,
90 old_ref: &str,
91 new_ref: &str,
92 target_path: Option<String>,
93 reason: &'static str,
94) -> RenamePlan {
95 RenamePlan {
96 directory: directory.to_string(),
97 recursive,
98 old_ref: old_ref.to_string(),
99 new_ref: new_ref.to_string(),
100 ok: false,
101 target_path,
102 identity_field: None,
103 reason: Some(reason),
104 edits: Vec::new(),
105 }
106}
107
108fn valid_new_ref(new_ref: &str) -> bool {
112 let mut chars = new_ref.chars();
113 match chars.next() {
114 Some(c) if c.is_ascii_alphabetic() => {}
115 _ => return false,
116 }
117 chars.all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '-'))
118}
119
120fn replace_token(text: &str, old_ref: &str, new_ref: &str) -> Option<String> {
125 let folded_old = py_casefold(old_ref);
126 let n = old_ref.chars().count();
130 let byte_len: usize = text.chars().take(n).map(char::len_utf8).sum();
131 if py_casefold(&text[..byte_len]) != folded_old {
132 return None;
133 }
134 let rest = &text[byte_len..];
135 if let Some(c) = rest.chars().next() {
136 if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') {
137 return None;
138 }
139 }
140 Some(format!("{new_ref}{rest}"))
141}
142
143fn list_marker_prefix_len(raw: &str) -> Option<usize> {
146 let mut i = 0;
147 for c in raw.chars() {
148 if py_is_space(c) {
149 i += c.len_utf8();
150 } else {
151 break;
152 }
153 }
154 let rest = &raw[i..];
155 let mut marker_len = 0;
156 let mut chars = rest.chars();
157 match chars.next() {
158 Some(c @ ('-' | '*' | '+')) => marker_len += c.len_utf8(),
159 Some(c) if crate::pycompat::is_re_digit(c) => {
160 marker_len += c.len_utf8();
161 loop {
162 match rest[marker_len..].chars().next() {
163 Some(d) if crate::pycompat::is_re_digit(d) => marker_len += d.len_utf8(),
164 Some('.') => {
165 marker_len += 1;
166 break;
167 }
168 _ => return None,
169 }
170 }
171 }
172 _ => return None,
173 }
174 let tail = &rest[marker_len..];
175 let ws: usize = tail
176 .chars()
177 .take_while(|c| py_is_space(*c))
178 .map(char::len_utf8)
179 .sum();
180 if ws == 0 {
181 return None;
182 }
183 Some(i + marker_len + ws)
184}
185
186fn read_raw(path: &str) -> String {
191 match std::fs::read(path) {
192 Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
193 Err(_) => String::new(),
194 }
195}
196
197fn relationship_reference_lines<'a>(
201 raw_lines: &[&'a str],
202 sections: &HashSet<String>,
203) -> Vec<(usize, &'a str)> {
204 let mut result = Vec::new();
205 let mut current: Option<String> = None;
206 for (i, raw) in raw_lines.iter().enumerate() {
207 let stripped = py_strip(raw);
208 if let Some(rest) = stripped.strip_prefix("## ") {
209 current = Some(py_casefold(py_strip(rest)));
210 continue;
211 }
212 if stripped.starts_with('#') {
213 current = None; continue;
215 }
216 if let Some(cur) = ¤t {
217 if sections.contains(cur) && !stripped.is_empty() {
218 result.push((i + 1, *raw));
219 }
220 }
221 }
222 result
223}
224
225fn reference_edits(items: &[CorpusItem], old_ref: &str, new_ref: &str) -> Vec<RenameEdit> {
228 let mut edits = Vec::new();
229 for item in items {
230 let Some(spec) = item.spec else { continue };
231 let present: HashSet<String> = spec
232 .optional
233 .iter()
234 .filter(|section| {
235 RELATIONSHIP_SECTIONS.iter().any(|(name, _)| name == section)
236 && item
237 .artifact
238 .section(section)
239 .map(|body| !body.is_empty())
240 .unwrap_or(false)
241 })
242 .cloned()
243 .collect();
244 if present.is_empty() {
245 continue;
246 }
247 let raw = read_raw(&item.path);
248 let raw_lines = py_splitlines(&raw);
249 for (line_no, raw_line) in relationship_reference_lines(&raw_lines, &present) {
250 let prefix_len = list_marker_prefix_len(raw_line).unwrap_or(0);
251 let ref_text = &raw_line[prefix_len..];
252 let Some(rewritten) = replace_token(py_strip(ref_text), old_ref, new_ref) else {
253 continue;
254 };
255 let new_line = format!(
258 "{}{}",
259 &raw_line[..prefix_len],
260 raw_line[prefix_len..].replacen(py_strip(ref_text), &rewritten, 1)
261 );
262 if new_line != raw_line {
263 edits.push(RenameEdit {
264 path: item.path.clone(),
265 line: line_no as i64,
266 old_line: raw_line.to_string(),
267 new_line,
268 kind: KIND_REFERENCE,
269 });
270 }
271 }
272 }
273 edits
274}
275
276fn frontmatter_id_line(line: &str) -> Option<(String, String, String, String)> {
280 let mut i = 0;
281 for c in line.chars() {
282 if py_is_space(c) {
283 i += c.len_utf8();
284 } else {
285 break;
286 }
287 }
288 let after_ws = &line[i..];
289 if !after_ws.starts_with("id") {
290 return None;
291 }
292 i += 2;
293 for c in line[i..].chars() {
294 if py_is_space(c) {
295 i += c.len_utf8();
296 } else {
297 break;
298 }
299 }
300 if !line[i..].starts_with(':') {
301 return None;
302 }
303 i += 1;
304 for c in line[i..].chars() {
305 if py_is_space(c) {
306 i += c.len_utf8();
307 } else {
308 break;
309 }
310 }
311 let g1 = line[..i].to_string();
312 let rest = &line[i..];
313 let quote = match rest.chars().next() {
314 Some(q @ ('\'' | '"')) => Some(q),
315 _ => None,
316 };
317 if let Some(q) = quote {
318 let body = &rest[q.len_utf8()..];
321 let mut vlen = 0;
322 let mut closed = false;
323 for c in body.chars() {
324 if c == q {
325 closed = true;
326 break;
327 }
328 if matches!(c, '\'' | '"' | '#') {
329 return None;
330 }
331 vlen += c.len_utf8();
332 }
333 if !closed || vlen == 0 {
334 return None;
335 }
336 let after = &body[vlen + q.len_utf8()..];
337 if !ws_then_optional_comment(after) {
338 return None;
339 }
340 Some((g1, q.to_string(), body[..vlen].to_string(), after.to_string()))
341 } else {
342 let mut vlen = 0;
345 let mut has_hash = false;
346 for c in rest.chars() {
347 if c == '#' {
348 has_hash = true;
349 break;
350 }
351 if matches!(c, '\'' | '"') {
352 return None;
353 }
354 vlen += c.len_utf8();
355 }
356 let run = &rest[..vlen];
357 let value = run.trim_end_matches(py_is_space);
358 if value.is_empty() {
359 return None;
362 }
363 let suffix = format!(
364 "{}{}",
365 &run[value.len()..],
366 if has_hash { &rest[vlen..] } else { "" }
367 );
368 Some((g1, String::new(), value.to_string(), suffix))
369 }
370}
371
372fn ws_then_optional_comment(s: &str) -> bool {
373 let rest = s.trim_start_matches(py_is_space);
374 rest.is_empty() || rest.starts_with('#')
375}
376
377type LineEdit = (usize, String, String);
379
380fn frontmatter_id_edit(raw_lines: &[&str], old_ref: &str, new_ref: &str) -> Option<LineEdit> {
383 if raw_lines.first().map(|l| py_strip(l)) != Some("---") {
384 return None;
385 }
386 for (i, raw) in raw_lines.iter().enumerate().skip(1) {
387 if py_strip(raw) == "---" {
388 break;
389 }
390 if let Some((g1, g2, g3, g5)) = frontmatter_id_line(raw) {
391 if py_casefold(py_strip(&g3)) == py_casefold(old_ref) {
392 let new_line = format!("{g1}{g2}{new_ref}{g2}{g5}");
393 if new_line != *raw {
394 return Some((i + 1, (*raw).to_string(), new_line));
395 }
396 }
397 }
398 }
399 None
400}
401
402fn heading_matches(raw: &str, name: &str) -> bool {
405 let after_ws = raw.trim_start_matches(py_is_space);
406 let Some(rest) = after_ws.strip_prefix("##") else {
407 return false;
408 };
409 let after_hash_ws = rest.trim_start_matches(py_is_space);
410 if after_hash_ws.len() == rest.len() {
411 return false; }
413 let n = name.chars().count();
414 let byte_len: usize = after_hash_ws.chars().take(n).map(char::len_utf8).sum();
415 if after_hash_ws.chars().count() < n
416 || py_casefold(&after_hash_ws[..byte_len]) != py_casefold(name)
417 {
418 return false;
419 }
420 after_hash_ws[byte_len..].chars().all(py_is_space)
421}
422
423fn section_first_value_edit(
427 raw_lines: &[&str],
428 section_name: &str,
429 folded_old: &str,
430 new_ref: &str,
431) -> Option<LineEdit> {
432 let mut in_section = false;
433 for (i, raw) in raw_lines.iter().enumerate() {
434 let stripped = py_strip(raw);
435 if stripped.starts_with('#') {
436 in_section = heading_matches(raw, section_name);
437 continue;
438 }
439 if !in_section || stripped.is_empty() {
440 continue;
441 }
442 let prefix_len = list_marker_prefix_len(raw).unwrap_or(0);
443 let value = &raw[prefix_len..];
444 let rewritten = replace_token(py_strip(value), folded_old, new_ref);
445 in_section = false; let Some(rewritten) = rewritten else { continue };
447 let new_line = format!(
448 "{}{}",
449 &raw[..prefix_len],
450 raw[prefix_len..].replacen(py_strip(value), &rewritten, 1)
451 );
452 if new_line != *raw {
453 return Some((i + 1, (*raw).to_string(), new_line));
454 }
455 }
456 None
457}
458
459fn identity_edit(
462 item: &CorpusItem,
463 old_ref: &str,
464 new_ref: &str,
465) -> Result<(LineEdit, &'static str), &'static str> {
466 let raw = read_raw(&item.path);
467 let raw_lines = py_splitlines(&raw);
468 let folded_old = py_casefold(old_ref);
469
470 if let Some(meta) = &item.artifact.metadata {
472 if let Some(id) = meta.id.as_deref().filter(|s| !s.is_empty()) {
473 if py_casefold(id) == folded_old {
474 if let Some(edit) = frontmatter_id_edit(&raw_lines, old_ref, new_ref) {
475 return Ok((edit, IDENTITY_FRONTMATTER));
476 }
477 }
478 }
479 }
480
481 if let Some(edit) = section_first_value_edit(&raw_lines, "id", &folded_old, new_ref) {
483 return Ok((edit, IDENTITY_ID_SECTION));
484 }
485
486 if let Some(spec) = item.spec {
489 if let Some(field) = spec.id_field.as_deref().filter(|f| !f.is_empty()) {
490 if let Some(edit) = section_first_value_edit(&raw_lines, field, &folded_old, new_ref) {
491 return Ok((edit, IDENTITY_ID_FIELD));
492 }
493 }
494 }
495
496 Err(REASON_OLD_FILENAME_ONLY)
498}
499
500pub fn compute_rename(
502 directory: &str,
503 old_ref: &str,
504 new_ref: &str,
505 recursive: bool,
506) -> RenamePlan {
507 let new_ref = py_strip(new_ref).to_string();
508 if !valid_new_ref(&new_ref) {
509 return refused(directory, recursive, old_ref, &new_ref, None, REASON_NEW_INVALID);
510 }
511
512 let items = corpus_items(directory, recursive);
513 let rows: Vec<ValidationRow> = items
514 .iter()
515 .map(|item| validation_row(&item.path, &item.artifact, item.spec))
516 .collect();
517 let index = resolution_index_from_rows(&rows);
518 let mut targets: Vec<&str> = index
519 .get(&py_casefold(old_ref))
520 .iter()
521 .map(|(path, _)| path.as_str())
522 .collect::<HashSet<_>>()
523 .into_iter()
524 .collect();
525 targets.sort_unstable();
526 let target_path = match targets.as_slice() {
527 [] => {
528 return refused(directory, recursive, old_ref, &new_ref, None, REASON_OLD_NOT_FOUND)
529 }
530 [one] => (*one).to_string(),
531 _ => {
532 return refused(directory, recursive, old_ref, &new_ref, None, REASON_OLD_AMBIGUOUS)
533 }
534 };
535
536 if py_casefold(&new_ref) != py_casefold(old_ref) {
539 let folded_new = py_casefold(&new_ref);
540 let collides = rows.iter().any(|row| {
541 row.path != target_path
542 && row.identifiers.iter().any(|i| py_casefold(i) == folded_new)
543 });
544 if collides {
545 return refused(
546 directory,
547 recursive,
548 old_ref,
549 &new_ref,
550 Some(target_path),
551 REASON_NEW_COLLIDES,
552 );
553 }
554 }
555
556 let target_item = items
557 .iter()
558 .find(|item| item.path == target_path)
559 .expect("resolved target is in the walked corpus");
560 let (identity, identity_field) = match identity_edit(target_item, old_ref, &new_ref) {
561 Ok(pair) => pair,
562 Err(reason) => {
563 return refused(directory, recursive, old_ref, &new_ref, Some(target_path), reason)
564 }
565 };
566
567 let mut edits = reference_edits(&items, old_ref, &new_ref);
568 let (line, old_line, new_line) = identity;
569 edits.push(RenameEdit {
570 path: target_path.clone(),
571 line: line as i64,
572 old_line,
573 new_line,
574 kind: KIND_IDENTITY,
575 });
576 edits.sort_by(|a, b| (a.path.as_str(), a.line).cmp(&(b.path.as_str(), b.line)));
577
578 RenamePlan {
579 directory: directory.to_string(),
580 recursive,
581 old_ref: old_ref.to_string(),
582 new_ref,
583 ok: true,
584 target_path: Some(target_path),
585 identity_field: Some(identity_field),
586 reason: None,
587 edits,
588 }
589}
590
591pub fn apply_rename(plan: &RenamePlan) -> Result<RenameResult, String> {
596 if !plan.ok {
597 return Ok(RenameResult {
598 directory: plan.directory.clone(),
599 old_ref: plan.old_ref.clone(),
600 new_ref: plan.new_ref.clone(),
601 applied: false,
602 files_changed: 0,
603 reference_edits: 0,
604 identity_edits: 0,
605 target_path: plan.target_path.clone(),
606 });
607 }
608
609 let mut order: Vec<&str> = Vec::new();
611 for edit in &plan.edits {
612 if !order.contains(&edit.path.as_str()) {
613 order.push(&edit.path);
614 }
615 }
616 for path in order {
617 let original = std::fs::read_to_string(path)
618 .map_err(|e| format!("rename: cannot read {path}: {e}"))?;
619 let had_final_newline = original.ends_with('\n');
620 let mut lines: Vec<String> = py_splitlines(&original)
621 .into_iter()
622 .map(str::to_string)
623 .collect();
624 for edit in plan.edits.iter().filter(|e| e.path == path) {
625 let idx = edit.line - 1;
626 let in_range = idx >= 0 && (idx as usize) < lines.len();
627 if !in_range || lines[idx as usize] != edit.old_line {
628 return Err(format!(
629 "rename: stale plan for {path} line {}: file changed since the plan was computed",
630 edit.line
631 ));
632 }
633 lines[idx as usize] = edit.new_line.clone();
634 }
635 let mut text = lines.join("\n");
636 if had_final_newline {
637 text.push('\n');
638 }
639 std::fs::write(path, text).map_err(|e| format!("rename: cannot write {path}: {e}"))?;
640 }
641
642 Ok(RenameResult {
643 directory: plan.directory.clone(),
644 old_ref: plan.old_ref.clone(),
645 new_ref: plan.new_ref.clone(),
646 applied: true,
647 files_changed: plan.files_changed(),
648 reference_edits: plan.reference_edits(),
649 identity_edits: plan.identity_edits(),
650 target_path: plan.target_path.clone(),
651 })
652}
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657
658 #[test]
659 fn new_ref_grammar() {
660 assert!(valid_new_ref("ADR-099"));
661 assert!(valid_new_ref("RAC-ZZZZZZZZZZZZ"));
662 assert!(valid_new_ref("a.b-c_d1"));
663 assert!(!valid_new_ref(""));
664 assert!(!valid_new_ref("1AD"));
665 assert!(!valid_new_ref("bad id!"));
666 assert!(!valid_new_ref("-x"));
667 }
668
669 #[test]
670 fn token_replacement_is_whole_token_and_case_insensitive() {
671 assert_eq!(
672 replace_token("ADR-001 (blocked)", "adr-001", "ADR-099").as_deref(),
673 Some("ADR-099 (blocked)")
674 );
675 assert_eq!(replace_token("ADR-10", "ADR-1", "X"), None);
676 assert_eq!(replace_token("ADR-1.5", "ADR-1", "X"), None);
677 assert_eq!(replace_token("zzz", "ADR-1", "X"), None);
678 }
679
680 #[test]
681 fn frontmatter_id_line_shapes() {
682 assert_eq!(
683 frontmatter_id_line("id: RAC-A"),
684 Some(("id: ".into(), "".into(), "RAC-A".into(), "".into()))
685 );
686 assert_eq!(
687 frontmatter_id_line(" id: 'RAC-A' # note"),
688 Some((" id: ".into(), "'".into(), "RAC-A".into(), " # note".into()))
689 );
690 assert_eq!(frontmatter_id_line("id: 'RAC"), None);
691 assert_eq!(frontmatter_id_line("ident: RAC-A"), None);
694 assert_eq!(frontmatter_id_line("id RAC-A"), None);
695 }
696}