1use crate::lockfile::ManagedSpanRecord;
2use crate::resolve::ResolvedLocalFile;
3use std::collections::BTreeMap;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
6pub struct PositionDriftSummary {
7 pub target_path: String,
8 pub marker_count: usize,
9 pub uniform_delta: Option<isize>,
10 pub first_id: String,
11 pub lock_open: usize,
12 pub lock_close: usize,
13 pub file_open: usize,
14 pub file_close: usize,
15 pub cause: Option<String>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19struct DriftedMarker {
20 id: String,
21 lock_open: usize,
22 lock_close: usize,
23 file_open: usize,
24 file_close: usize,
25}
26
27pub fn summarize_position_drift(
28 target_path: &str,
29 spans: &[&ManagedSpanRecord],
30 markers: &BTreeMap<String, (usize, usize, String)>,
31 on_disk_lines: &[&str],
32 fresh_lines: Option<&[&str]>,
33 local_files: &[ResolvedLocalFile],
34) -> Option<PositionDriftSummary> {
35 let mut drifted = Vec::new();
36 for span in spans {
37 let Some((open_line, close_line, checksum)) = markers.get(&span.id) else {
38 continue;
39 };
40 if checksum != &span.ideal_checksum {
41 continue;
42 }
43 if *open_line == span.open_line && *close_line == span.close_line {
44 continue;
45 }
46 drifted.push(DriftedMarker {
47 id: span.id.clone(),
48 lock_open: span.open_line,
49 lock_close: span.close_line,
50 file_open: *open_line,
51 file_close: *close_line,
52 });
53 }
54 if drifted.is_empty() {
55 return None;
56 }
57 drifted.sort_by_key(|marker| marker.file_open);
58 let first = &drifted[0];
59 let deltas: Vec<isize> = drifted
60 .iter()
61 .map(|marker| marker.file_open as isize - marker.lock_open as isize)
62 .collect();
63 let uniform_delta = deltas
64 .iter()
65 .all(|delta| *delta == deltas[0])
66 .then_some(deltas[0]);
67 let cause = fresh_lines
68 .and_then(|fresh| unmarked_drift_cause(target_path, on_disk_lines, fresh, local_files));
69 Some(PositionDriftSummary {
70 target_path: target_path.to_string(),
71 marker_count: drifted.len(),
72 uniform_delta,
73 first_id: first.id.clone(),
74 lock_open: first.lock_open,
75 lock_close: first.lock_close,
76 file_open: first.file_open,
77 file_close: first.file_close,
78 cause,
79 })
80}
81
82pub fn format_position_drift_message(summary: &PositionDriftSummary) -> String {
83 let shift = match summary.uniform_delta {
84 Some(delta) if delta != 0 => format!(" ({delta:+} lines)"),
85 _ => String::new(),
86 };
87 let marker_word = if summary.marker_count == 1 {
88 "marker"
89 } else {
90 "markers"
91 };
92 let mut parts = vec![
93 format!(
94 "`{}` position drift{shift} across {} {marker_word}",
95 summary.target_path, summary.marker_count
96 ),
97 format!(
98 "first marker `{}` lock {}:{}, file {}:{}",
99 summary.first_id,
100 summary.lock_open,
101 summary.lock_close,
102 summary.file_open,
103 summary.file_close
104 ),
105 ];
106 if let Some(cause) = &summary.cause {
107 parts.push(format!("cause: {cause}"));
108 }
109 parts.push(
110 "Align unmarked text with compose sources, or run `pray install` to refresh lock positions."
111 .to_string(),
112 );
113 parts.join("; ")
114}
115
116fn unmarked_drift_cause(
117 target_path: &str,
118 on_disk_lines: &[&str],
119 fresh_lines: &[&str],
120 local_files: &[ResolvedLocalFile],
121) -> Option<String> {
122 let disk_preamble = preamble_lines(on_disk_lines);
123 let fresh_preamble = preamble_lines(fresh_lines);
124 let (index, disk_line, fresh_line) = first_line_diff(&disk_preamble, &fresh_preamble)?;
125 let target_line = index + 1;
126 if let Some((path, line)) = locate_line_in_locals(local_files, fresh_line) {
127 return Some(format!(
128 "`{target_path}:{target_line}` unmarked text differs from `{path}:{line}`"
129 ));
130 }
131 if let Some((path, line)) = locate_line_in_locals(local_files, disk_line) {
132 return Some(format!(
133 "`{target_path}:{target_line}` unmarked text differs from `{path}:{line}`"
134 ));
135 }
136 Some(format!(
137 "`{target_path}:{target_line}` unmarked text differs from fresh composition"
138 ))
139}
140
141fn preamble_lines<'a>(lines: &[&'a str]) -> Vec<&'a str> {
142 let mut preamble = Vec::new();
143 for line in lines {
144 if is_managed_marker(line) {
145 break;
146 }
147 preamble.push(*line);
148 }
149 preamble
150}
151
152fn is_managed_marker(line: &str) -> bool {
153 let trimmed = line.trim();
154 let Some(remainder) = trimmed.strip_prefix("<!-- pray:") else {
155 return false;
156 };
157 let Some(id) = remainder.strip_suffix(" -->") else {
158 return false;
159 };
160 id != "0 ignore-comments"
161 && id
162 .chars()
163 .all(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
164}
165
166fn first_line_diff<'a>(left: &[&'a str], right: &[&'a str]) -> Option<(usize, &'a str, &'a str)> {
167 let shared = left.len().min(right.len());
168 for index in 0..shared {
169 if left[index] != right[index] {
170 return Some((index, left[index], right[index]));
171 }
172 }
173 if left.len() == right.len() {
174 return None;
175 }
176 let index = shared;
177 Some((
178 index,
179 left.get(index).copied().unwrap_or(""),
180 right.get(index).copied().unwrap_or(""),
181 ))
182}
183
184fn locate_line_in_locals<'a>(
185 local_files: &'a [ResolvedLocalFile],
186 line: &str,
187) -> Option<(&'a str, usize)> {
188 if line.is_empty() {
189 return None;
190 }
191 for local in local_files {
192 for (index, candidate) in local.content.lines().enumerate() {
193 if candidate == line {
194 return Some((local.manifest_path.as_str(), index + 1));
195 }
196 }
197 }
198 None
199}