Skip to main content

pray_core/verify/
position.rs

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}