Skip to main content

pray_core/
verify.rs

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}