1use crate::hashing::{checksum_managed_body_line_refs, normalize_line_endings, sha256_prefixed};
2use crate::lockfile::{Lockfile, ManagedSpanRecord};
3use crate::render::{expected_provisioned_bytes, render_project};
4use crate::resolve::{missing_local_embed_guidance, ResolvedProject};
5use crate::{PrayError, PrayResult};
6use std::collections::{BTreeMap, BTreeSet, HashSet};
7use std::fs;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct VerificationFinding {
11 pub kind: String,
12 pub message: String,
13}
14
15#[derive(Debug, Clone, Default)]
16pub struct VerificationReport {
17 pub findings: Vec<VerificationFinding>,
18}
19
20impl VerificationReport {
21 pub fn is_clean(&self) -> bool {
22 self.findings.is_empty()
23 }
24
25 pub fn has_warnings(&self) -> bool {
26 self.findings.iter().any(VerificationFinding::is_warning)
27 }
28
29 pub fn has_errors(&self) -> bool {
30 self.findings.iter().any(VerificationFinding::is_error)
31 }
32}
33
34impl VerificationFinding {
35 pub fn is_warning(&self) -> bool {
36 matches!(self.kind.as_str(), "orphan_marker")
37 }
38
39 pub fn is_error(&self) -> bool {
40 !self.is_warning()
41 }
42}
43
44pub fn inspect_project(
45 project: &ResolvedProject,
46 lockfile: &Lockfile,
47) -> PrayResult<VerificationReport> {
48 let (report, _) = collect_verification_report(project, lockfile)?;
49 Ok(report)
50}
51
52pub fn verify_project(
53 project: &ResolvedProject,
54 lockfile: &Lockfile,
55 strict: bool,
56) -> PrayResult<VerificationReport> {
57 let report = inspect_project(project, lockfile)?;
58 if report.is_clean() {
59 return Ok(report);
60 }
61
62 if strict || report.has_errors() {
63 Err(PrayError::Verify(format_verification_report(&report)))
64 } else {
65 Ok(report)
66 }
67}
68
69fn collect_verification_report(
70 project: &ResolvedProject,
71 lockfile: &Lockfile,
72) -> PrayResult<(VerificationReport, BTreeMap<String, String>)> {
73 let mut report = VerificationReport::default();
74 let mut rendered_targets = BTreeMap::new();
75 if project.manifest_hash != lockfile.manifest_hash {
76 report.findings.push(VerificationFinding {
77 kind: "verify_error".to_string(),
78 message:
79 "Prayfile changed since `Prayfile.lock` was generated. Run `pray install` to refresh the lockfile."
80 .to_string(),
81 });
82 }
83
84 let mut locked_packages: BTreeMap<String, &crate::lockfile::LockedPackage> = lockfile
85 .package
86 .iter()
87 .map(|package| (package.name.clone(), package))
88 .collect();
89 for package in &project.packages {
90 match locked_packages.remove(&package.declaration.name) {
91 Some(locked) => {
92 if locked.tree_hash != package.tree_hash {
93 report.findings.push(VerificationFinding {
94 kind: "package_integrity".to_string(),
95 message: format!(
96 "Package `{}` no longer matches the locked tree hash. Run `pray install` to re-resolve packages.",
97 package.declaration.name
98 ),
99 });
100 }
101 if locked.version != package.spec.version {
102 report.findings.push(VerificationFinding {
103 kind: "verify_error".to_string(),
104 message: format!(
105 "Package `{}` resolved to version {} but `Prayfile.lock` has {}. Run `pray install` to refresh the lockfile.",
106 package.declaration.name, package.spec.version, locked.version
107 ),
108 });
109 }
110 }
111 None => report.findings.push(VerificationFinding {
112 kind: "verify_error".to_string(),
113 message: format!(
114 "Package `{}` is declared in Prayfile but missing from `Prayfile.lock`. Run `pray install` to update the lockfile.",
115 package.declaration.name
116 ),
117 }),
118 }
119 }
120 for locked in locked_packages.values() {
121 report.findings.push(VerificationFinding {
122 kind: "verify_error".to_string(),
123 message: format!(
124 "Package `{}` is in `Prayfile.lock` but not declared in Prayfile. Remove it from the lockfile with `pray install` or add it back to Prayfile.",
125 locked.name
126 ),
127 });
128 }
129
130 let mut target_spans: BTreeMap<String, Vec<&ManagedSpanRecord>> = BTreeMap::new();
131 for span in &lockfile.managed_span {
132 target_spans
133 .entry(span.target.clone())
134 .or_default()
135 .push(span);
136 }
137
138 for (target_path, spans) in target_spans {
139 let absolute_path = project.project_root.join(&target_path);
140 if !absolute_path.exists() {
141 report.findings.push(VerificationFinding {
142 kind: "verify_error".to_string(),
143 message: format!(
144 "Rendered file `{}` is missing. Run `pray install` to generate it.",
145 target_path
146 ),
147 });
148 continue;
149 }
150 let text = fs::read_to_string(&absolute_path)?;
151 rendered_targets.insert(target_path.clone(), text.clone());
152 let lines: Vec<&str> = text.lines().collect();
153 let markers = marker_positions(&lines);
154 for span in &spans {
155 match markers.get(&span.id) {
156 None => report.findings.push(VerificationFinding {
157 kind: "removed_prayer".to_string(),
158 message: format!(
159 "`{}` is missing managed marker `{}` for `{}::{}`. Run `pray install` to restore the managed span.",
160 target_path, span.id, span.package, span.export
161 ),
162 }),
163 Some((open_line, close_line, checksum)) => {
164 if checksum != &span.ideal_checksum {
165 report.findings.push(VerificationFinding {
166 kind: "custom_implementation".to_string(),
167 message: format!(
168 "`{}` marker `{}` (`{}::{}`) was edited. Restore the managed block or run `pray install` to regenerate it.",
169 target_path, span.id, span.package, span.export
170 ),
171 });
172 }
173 if *open_line != span.open_line || *close_line != span.close_line {
174 report.findings.push(VerificationFinding {
175 kind: "position_drift".to_string(),
176 message: format!(
177 "`{}` marker `{}` (`{}::{}`) moved to different lines. Run `pray install` to restore expected positions.",
178 target_path, span.id, span.package, span.export
179 ),
180 });
181 }
182 }
183 }
184 }
185 for finding in find_orphan_marker_findings_from_markers(&spans, &markers, &target_path) {
186 report.findings.push(finding);
187 }
188 }
189
190 for package in &project.packages {
191 let Some(destination) = &package.declaration.file else {
192 continue;
193 };
194 let absolute = project.project_root.join(destination);
195 let Some(export_name) = package.selected_exports.iter().find(|name| {
196 package
197 .spec
198 .exports
199 .get(*name)
200 .is_some_and(|export| export.kind == "file")
201 }) else {
202 report.findings.push(VerificationFinding {
203 kind: "verify_error".to_string(),
204 message: format!(
205 "Package `{}` declares file: \"{}\" but has no selected file export.",
206 package.declaration.name, destination
207 ),
208 });
209 continue;
210 };
211 let source = package.root.join(&package.spec.exports[export_name].path);
212 if !absolute.exists() {
213 report.findings.push(VerificationFinding {
214 kind: "verify_error".to_string(),
215 message: format!(
216 "Exclusive file `{}` from `{}` is missing. Run `pray install` to materialize it.",
217 destination, package.declaration.name
218 ),
219 });
220 continue;
221 }
222 let destination_bytes = fs::read(&absolute)?;
223 let expected_bytes = expected_provisioned_bytes(&source, &project.manifest.symbols)?;
224 if sha256_prefixed(&destination_bytes) != sha256_prefixed(&expected_bytes) {
225 report.findings.push(VerificationFinding {
226 kind: "package_integrity".to_string(),
227 message: format!(
228 "Exclusive file `{}` no longer matches package `{}`. Run `pray install` to restore it.",
229 destination, package.declaration.name
230 ),
231 });
232 }
233 }
234
235 for local in &project.local_files {
236 if local.optional {
237 continue;
238 }
239 if !project.project_root.join(&local.path).exists() {
240 report.findings.push(VerificationFinding {
241 kind: "verify_error".to_string(),
242 message: missing_local_embed_guidance(&local.manifest_path),
243 });
244 }
245 }
246
247 Ok((report, rendered_targets))
248}
249
250pub fn find_orphan_marker_findings(
251 spans: &[&ManagedSpanRecord],
252 lines: &[&str],
253 target_path: &str,
254) -> Vec<VerificationFinding> {
255 let markers = marker_positions(lines);
256 find_orphan_marker_findings_from_markers(spans, &markers, target_path)
257}
258
259fn find_orphan_marker_findings_from_markers(
260 spans: &[&ManagedSpanRecord],
261 markers: &BTreeMap<String, (usize, usize, String)>,
262 target_path: &str,
263) -> Vec<VerificationFinding> {
264 let tracked_ids: HashSet<&str> = spans.iter().map(|span| span.id.as_str()).collect();
265 let mut findings = Vec::new();
266 for marker_id in markers.keys() {
267 if marker_id != "0" && !tracked_ids.contains(marker_id.as_str()) {
268 findings.push(VerificationFinding {
269 kind: "orphan_marker".to_string(),
270 message: format!(
271 "`{}` contains marker `{}` that is not tracked in `Prayfile.lock`. Remove the marker or run `pray install` to reconcile.",
272 target_path, marker_id
273 ),
274 });
275 }
276 }
277 findings
278}
279
280pub fn drift_project(
281 project: &ResolvedProject,
282 lockfile: &Lockfile,
283) -> PrayResult<VerificationReport> {
284 let (mut report, rendered_targets) = collect_verification_report(project, lockfile)?;
285
286 let rendered = render_project(project)?;
287 let lock_targets = lockfile_targets(lockfile);
288 for target in rendered {
289 let normalized_fresh = normalize_line_endings(&target.content);
290 let on_disk = rendered_targets
291 .get(target.path.to_string_lossy().as_ref())
292 .map(|text| normalize_line_endings(text));
293 let matches = on_disk.as_ref() == Some(&normalized_fresh);
294 if !matches {
295 report.findings.push(VerificationFinding {
296 kind: "renderer_drift".to_string(),
297 message: format!("{} differs from fresh render", target.path.display()),
298 });
299 }
300 if !lock_targets.contains(&target.path.to_string_lossy().to_string()) {
301 report.findings.push(VerificationFinding {
302 kind: "renderer_drift".to_string(),
303 message: format!("{} is not tracked in lockfile", target.path.display()),
304 });
305 }
306 }
307
308 if report.findings.is_empty() {
309 Ok(report)
310 } else {
311 Err(PrayError::Verify(format_drift_report(&report)))
312 }
313}
314
315fn marker_positions(lines: &[&str]) -> BTreeMap<String, (usize, usize, String)> {
316 let mut markers = BTreeMap::new();
317 let mut active: Option<(String, usize, Vec<&str>)> = None;
318 for (index, line) in lines.iter().enumerate() {
319 match parse_marker(line) {
320 None => {
321 if let Some((_, _, body)) = active.as_mut() {
322 body.push(line);
323 }
324 }
325 Some(ParsedMarker::Ignore) => {}
326 Some(ParsedMarker::Id(id)) => match active.take() {
327 None => {
328 active = Some((id.to_string(), index + 1, Vec::new()));
329 }
330 Some((open_id, open_line, body)) if open_id == id => {
331 let checksum = checksum_managed_body_line_refs(&body);
332 markers.insert(open_id, (open_line, index + 1, checksum));
333 }
334 Some(previous) => {
335 active = Some(previous);
336 }
337 },
338 }
339 }
340 markers
341}
342
343enum ParsedMarker<'a> {
344 Ignore,
345 Id(&'a str),
346}
347
348fn parse_marker(line: &str) -> Option<ParsedMarker<'_>> {
349 let trimmed = line.trim();
350 let remainder = trimmed.strip_prefix("<!-- pray:")?;
351 let id = remainder.strip_suffix(" -->")?;
352 if id == "0 ignore-comments" {
353 return Some(ParsedMarker::Ignore);
354 }
355 if id
356 .chars()
357 .all(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
358 {
359 return Some(ParsedMarker::Id(id));
360 }
361 None
362}
363
364fn lockfile_targets(lockfile: &Lockfile) -> BTreeSet<String> {
365 lockfile
366 .target
367 .iter()
368 .flat_map(|target| target.outputs.iter().cloned())
369 .collect()
370}
371
372pub fn format_verification_report(report: &VerificationReport) -> String {
373 report
374 .findings
375 .iter()
376 .map(|finding| format!("{}: {}", finding.kind, finding.message))
377 .collect::<Vec<_>>()
378 .join("\n")
379}
380
381fn format_drift_report(report: &VerificationReport) -> String {
382 let mut sections: BTreeMap<&'static str, Vec<&VerificationFinding>> = BTreeMap::new();
383 for finding in &report.findings {
384 sections
385 .entry(drift_section_for_kind(&finding.kind))
386 .or_default()
387 .push(finding);
388 }
389
390 let ordered_sections = [
391 "Lockfile changes",
392 "Package changes",
393 "Managed span changes",
394 "Rendered file changes",
395 "Warnings",
396 ];
397 let mut lines = Vec::new();
398 for section in ordered_sections {
399 let Some(findings) = sections.get(section) else {
400 continue;
401 };
402 lines.push(section.to_string());
403 for finding in findings {
404 lines.push(format!(" {}: {}", finding.kind, finding.message));
405 }
406 }
407 lines.join("\n")
408}
409
410fn drift_section_for_kind(kind: &str) -> &'static str {
411 match kind {
412 "verify_error" => "Lockfile changes",
413 "package_integrity" => "Package changes",
414 "custom_implementation" | "removed_prayer" | "position_drift" | "orphan_marker" => {
415 "Managed span changes"
416 }
417 "renderer_drift" => "Rendered file changes",
418 _ => "Warnings",
419 }
420}