1mod format;
2mod integrity;
3pub mod position;
4mod provisioned;
5
6use crate::hashing::{checksum_managed_body_line_refs, normalize_line_endings};
7use crate::lockfile::{Lockfile, ManagedSpanRecord};
8use crate::render::render_project;
9use crate::resolve::ResolvedProject;
10use crate::{PrayError, PrayResult};
11use format::format_drift_report;
12pub use format::format_verification_report;
13use integrity::push_package_lock_findings;
14use position::{format_position_drift_message, summarize_position_drift};
15use std::collections::{BTreeMap, BTreeSet, HashSet};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct VerificationFinding {
19 pub kind: String,
20 pub message: String,
21}
22
23#[derive(Debug, Clone, Default)]
24pub struct VerificationReport {
25 pub findings: Vec<VerificationFinding>,
26}
27
28impl VerificationReport {
29 pub fn is_clean(&self) -> bool {
30 self.findings.is_empty()
31 }
32
33 pub fn has_warnings(&self) -> bool {
34 self.findings.iter().any(VerificationFinding::is_warning)
35 }
36
37 pub fn has_errors(&self) -> bool {
38 self.findings.iter().any(VerificationFinding::is_error)
39 }
40}
41
42impl VerificationFinding {
43 pub fn is_warning(&self) -> bool {
44 matches!(self.kind.as_str(), "orphan_marker")
45 }
46
47 pub fn is_error(&self) -> bool {
48 !self.is_warning()
49 }
50}
51
52pub fn inspect_project(
53 project: &ResolvedProject,
54 lockfile: &Lockfile,
55) -> PrayResult<VerificationReport> {
56 let (report, _, _) = collect_verification_report(project, lockfile)?;
57 Ok(report)
58}
59
60pub fn verify_project(
61 project: &ResolvedProject,
62 lockfile: &Lockfile,
63 strict: bool,
64) -> PrayResult<VerificationReport> {
65 let report = inspect_project(project, lockfile)?;
66 if report.is_clean() {
67 return Ok(report);
68 }
69
70 if strict || report.has_errors() {
71 Err(PrayError::Verify(format_verification_report(&report)))
72 } else {
73 Ok(report)
74 }
75}
76
77type CollectedVerification = (
78 VerificationReport,
79 BTreeMap<String, String>,
80 BTreeMap<String, String>,
81);
82
83fn collect_verification_report(
84 project: &ResolvedProject,
85 lockfile: &Lockfile,
86) -> PrayResult<CollectedVerification> {
87 let mut report = VerificationReport::default();
88 let mut rendered_targets = BTreeMap::new();
89 let fresh_targets: BTreeMap<String, String> = render_project(project)?
90 .into_iter()
91 .map(|target| (target.path.to_string_lossy().to_string(), target.content))
92 .collect();
93 if project.manifest_hash != lockfile.manifest_hash {
94 report.findings.push(VerificationFinding {
95 kind: "verify_error".to_string(),
96 message:
97 "Prayfile changed since `Prayfile.lock` was generated. Run `pray install` to refresh the lockfile."
98 .to_string(),
99 });
100 }
101
102 push_package_lock_findings(project, lockfile, &mut report.findings);
103
104 let mut target_spans: BTreeMap<String, Vec<&ManagedSpanRecord>> = BTreeMap::new();
105 for span in &lockfile.managed_span {
106 target_spans
107 .entry(span.target.clone())
108 .or_default()
109 .push(span);
110 }
111
112 for (target_path, spans) in target_spans {
113 let absolute_path = project.project_root.join(&target_path);
114 if !absolute_path.exists() {
115 report.findings.push(VerificationFinding {
116 kind: "verify_error".to_string(),
117 message: format!(
118 "Rendered file `{}` is missing. Run `pray install` to generate it.",
119 target_path
120 ),
121 });
122 continue;
123 }
124 let text = crate::render_file::read_destination_text(&absolute_path)?;
125 rendered_targets.insert(target_path.clone(), text.clone());
126 let lines: Vec<&str> = text.lines().collect();
127 let markers = marker_positions(&lines);
128 for span in &spans {
129 match markers.get(&span.id) {
130 None => report.findings.push(VerificationFinding {
131 kind: "removed_prayer".to_string(),
132 message: format!(
133 "`{}` is missing managed marker `{}` for `{}::{}`. Run `pray install` to restore the managed span.",
134 target_path, span.id, span.package, span.export
135 ),
136 }),
137 Some((_, _, checksum)) => {
138 if checksum != &span.ideal_checksum {
139 report.findings.push(VerificationFinding {
140 kind: "custom_implementation".to_string(),
141 message: format!(
142 "`{}` marker `{}` (`{}::{}`) was edited. Restore the managed block or run `pray install` to regenerate it.",
143 target_path, span.id, span.package, span.export
144 ),
145 });
146 }
147 }
148 }
149 }
150 let fresh_lines: Vec<&str> = fresh_targets
151 .get(&target_path)
152 .map(|fresh| fresh.lines().collect())
153 .unwrap_or_default();
154 if let Some(summary) = summarize_position_drift(
155 &target_path,
156 &spans,
157 &markers,
158 &lines,
159 fresh_targets
160 .contains_key(&target_path)
161 .then_some(fresh_lines.as_slice()),
162 &project.local_files,
163 ) {
164 report.findings.push(VerificationFinding {
165 kind: "position_drift".to_string(),
166 message: format_position_drift_message(&summary),
167 });
168 }
169 for finding in find_orphan_marker_findings_from_markers(&spans, &markers, &target_path) {
170 report.findings.push(finding);
171 }
172 }
173
174 provisioned::push_provisioned_and_local_findings(project, lockfile, &mut report.findings)?;
175
176 Ok((report, rendered_targets, fresh_targets))
177}
178
179pub fn find_orphan_marker_findings(
180 spans: &[&ManagedSpanRecord],
181 lines: &[&str],
182 target_path: &str,
183) -> Vec<VerificationFinding> {
184 let markers = marker_positions(lines);
185 find_orphan_marker_findings_from_markers(spans, &markers, target_path)
186}
187
188fn find_orphan_marker_findings_from_markers(
189 spans: &[&ManagedSpanRecord],
190 markers: &BTreeMap<String, (usize, usize, String)>,
191 target_path: &str,
192) -> Vec<VerificationFinding> {
193 let tracked_ids: HashSet<&str> = spans.iter().map(|span| span.id.as_str()).collect();
194 let mut findings = Vec::new();
195 for marker_id in markers.keys() {
196 if marker_id != "0" && !tracked_ids.contains(marker_id.as_str()) {
197 findings.push(VerificationFinding {
198 kind: "orphan_marker".to_string(),
199 message: format!(
200 "`{}` contains marker `{}` that is not tracked in `Prayfile.lock`. Remove the marker or run `pray install` to reconcile.",
201 target_path, marker_id
202 ),
203 });
204 }
205 }
206 findings
207}
208
209pub fn drift_project(
210 project: &ResolvedProject,
211 lockfile: &Lockfile,
212) -> PrayResult<VerificationReport> {
213 let (mut report, rendered_targets, fresh_targets) =
214 collect_verification_report(project, lockfile)?;
215
216 let lock_targets = lockfile_targets(lockfile);
217 for (path, fresh_content) in &fresh_targets {
218 let normalized_fresh = normalize_line_endings(fresh_content);
219 let on_disk = rendered_targets
220 .get(path)
221 .map(|text| normalize_line_endings(text));
222 let matches = on_disk.as_ref() == Some(&normalized_fresh);
223 if !matches {
224 report.findings.push(VerificationFinding {
225 kind: "renderer_drift".to_string(),
226 message: format!("{path} differs from fresh render"),
227 });
228 }
229 if !lock_targets.contains(path) {
230 report.findings.push(VerificationFinding {
231 kind: "renderer_drift".to_string(),
232 message: format!("{path} is not tracked in lockfile"),
233 });
234 }
235 }
236
237 if report.findings.is_empty() {
238 Ok(report)
239 } else {
240 Err(PrayError::Verify(format_drift_report(&report)))
241 }
242}
243
244fn marker_positions(lines: &[&str]) -> BTreeMap<String, (usize, usize, String)> {
245 let mut markers = BTreeMap::new();
246 let mut active: Option<(String, usize, Vec<&str>)> = None;
247 for (index, line) in lines.iter().enumerate() {
248 match parse_marker(line) {
249 None => {
250 if let Some((_, _, body)) = active.as_mut() {
251 body.push(line);
252 }
253 }
254 Some(ParsedMarker::Ignore) => {}
255 Some(ParsedMarker::Id(id)) => match active.take() {
256 None => {
257 active = Some((id.to_string(), index + 1, Vec::new()));
258 }
259 Some((open_id, open_line, body)) if open_id == id => {
260 let checksum = checksum_managed_body_line_refs(&body);
261 markers.insert(open_id, (open_line, index + 1, checksum));
262 }
263 Some(previous) => {
264 active = Some(previous);
265 }
266 },
267 }
268 }
269 markers
270}
271
272enum ParsedMarker<'a> {
273 Ignore,
274 Id(&'a str),
275}
276
277fn parse_marker(line: &str) -> Option<ParsedMarker<'_>> {
278 let trimmed = line.trim();
279 let remainder = trimmed.strip_prefix("<!-- pray:")?;
280 let id = remainder.strip_suffix(" -->")?;
281 if id == "0 ignore-comments" {
282 return Some(ParsedMarker::Ignore);
283 }
284 if id
285 .chars()
286 .all(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
287 {
288 return Some(ParsedMarker::Id(id));
289 }
290 None
291}
292
293fn lockfile_targets(lockfile: &Lockfile) -> BTreeSet<String> {
294 lockfile
295 .target
296 .iter()
297 .flat_map(|target| target.outputs.iter().cloned())
298 .collect()
299}