Skip to main content

pray_core/verify/
mod.rs

1mod format;
2mod integrity;
3mod locked_dest;
4pub mod position;
5mod provisioned;
6
7use crate::hashing::normalize_line_endings;
8use crate::lockfile::{Lockfile, ManagedSpanRecord};
9use crate::render::render_project;
10use crate::resolve::ResolvedProject;
11use crate::{PrayError, PrayResult};
12use format::format_drift_report;
13pub use format::format_verification_report;
14use integrity::push_package_lock_findings;
15pub use locked_dest::{find_orphan_marker_findings, inspect_locked_destinations};
16use locked_dest::{find_orphan_marker_findings_from_markers, marker_positions};
17use position::{format_position_drift_message, summarize_position_drift};
18use std::collections::{BTreeMap, BTreeSet};
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct VerificationFinding {
22    pub kind: String,
23    pub message: String,
24}
25
26#[derive(Debug, Clone, Default)]
27pub struct VerificationReport {
28    pub findings: Vec<VerificationFinding>,
29}
30
31impl VerificationReport {
32    pub fn is_clean(&self) -> bool {
33        self.findings.is_empty()
34    }
35
36    pub fn has_warnings(&self) -> bool {
37        self.findings.iter().any(VerificationFinding::is_warning)
38    }
39
40    pub fn has_errors(&self) -> bool {
41        self.findings.iter().any(VerificationFinding::is_error)
42    }
43}
44
45impl VerificationFinding {
46    pub fn is_warning(&self) -> bool {
47        matches!(self.kind.as_str(), "orphan_marker")
48    }
49
50    pub fn is_error(&self) -> bool {
51        !self.is_warning()
52    }
53}
54
55pub fn inspect_project(
56    project: &ResolvedProject,
57    lockfile: &Lockfile,
58) -> PrayResult<VerificationReport> {
59    let (report, _, _) = collect_verification_report(project, lockfile)?;
60    Ok(report)
61}
62
63pub fn verify_project(
64    project: &ResolvedProject,
65    lockfile: &Lockfile,
66    strict: bool,
67) -> PrayResult<VerificationReport> {
68    let report = inspect_project(project, lockfile)?;
69    if report.is_clean() {
70        return Ok(report);
71    }
72
73    if strict || report.has_errors() {
74        Err(PrayError::Verify(format_verification_report(&report)))
75    } else {
76        Ok(report)
77    }
78}
79
80type CollectedVerification = (
81    VerificationReport,
82    BTreeMap<String, String>,
83    BTreeMap<String, String>,
84);
85
86fn collect_verification_report(
87    project: &ResolvedProject,
88    lockfile: &Lockfile,
89) -> PrayResult<CollectedVerification> {
90    let mut report = VerificationReport::default();
91    let mut rendered_targets = BTreeMap::new();
92    let fresh_targets: BTreeMap<String, String> = render_project(project)?
93        .into_iter()
94        .map(|target| (target.path.to_string_lossy().to_string(), target.content))
95        .collect();
96    if project.manifest_hash != lockfile.manifest_hash {
97        report.findings.push(VerificationFinding {
98            kind: "verify_error".to_string(),
99            message:
100                "Prayfile changed since `Prayfile.lock` was generated. Run `pray install` to refresh the lockfile."
101                    .to_string(),
102        });
103    }
104
105    push_package_lock_findings(project, lockfile, &mut report.findings);
106
107    let mut target_spans: BTreeMap<String, Vec<&ManagedSpanRecord>> = BTreeMap::new();
108    for span in &lockfile.managed_span {
109        target_spans
110            .entry(span.target.clone())
111            .or_default()
112            .push(span);
113    }
114
115    for (target_path, spans) in target_spans {
116        let absolute_path = project.project_root.join(&target_path);
117        if !absolute_path.exists() {
118            report.findings.push(VerificationFinding {
119                kind: "verify_error".to_string(),
120                message: format!(
121                    "Rendered file `{}` is missing. Run `pray install` to generate it.",
122                    target_path
123                ),
124            });
125            continue;
126        }
127        let text = crate::render_file::read_destination_text(&absolute_path)?;
128        rendered_targets.insert(target_path.clone(), text.clone());
129        let lines: Vec<&str> = text.lines().collect();
130        let markers = marker_positions(&lines);
131        for span in &spans {
132            match markers.get(&span.id) {
133                None => report.findings.push(VerificationFinding {
134                    kind: "removed_prayer".to_string(),
135                    message: format!(
136                        "`{}` is missing managed marker `{}` for `{}::{}`. Run `pray install` to restore the managed span.",
137                        target_path, span.id, span.package, span.export
138                    ),
139                }),
140                Some((_, _, checksum)) => {
141                    if checksum != &span.ideal_checksum {
142                        report.findings.push(VerificationFinding {
143                            kind: "custom_implementation".to_string(),
144                            message: format!(
145                                "`{}` marker `{}` (`{}::{}`) was edited. Restore the managed block or run `pray install` to regenerate it.",
146                                target_path, span.id, span.package, span.export
147                            ),
148                        });
149                    }
150                }
151            }
152        }
153        let fresh_lines: Vec<&str> = fresh_targets
154            .get(&target_path)
155            .map(|fresh| fresh.lines().collect())
156            .unwrap_or_default();
157        if let Some(summary) = summarize_position_drift(
158            &target_path,
159            &spans,
160            &markers,
161            &lines,
162            fresh_targets
163                .contains_key(&target_path)
164                .then_some(fresh_lines.as_slice()),
165            &project.local_files,
166        ) {
167            report.findings.push(VerificationFinding {
168                kind: "position_drift".to_string(),
169                message: format_position_drift_message(&summary),
170            });
171        }
172        for finding in find_orphan_marker_findings_from_markers(&spans, &markers, &target_path) {
173            report.findings.push(finding);
174        }
175    }
176
177    provisioned::push_provisioned_and_local_findings(project, lockfile, &mut report.findings)?;
178
179    Ok((report, rendered_targets, fresh_targets))
180}
181
182pub fn drift_project(
183    project: &ResolvedProject,
184    lockfile: &Lockfile,
185) -> PrayResult<VerificationReport> {
186    let (mut report, rendered_targets, fresh_targets) =
187        collect_verification_report(project, lockfile)?;
188
189    let lock_targets = lockfile_targets(lockfile);
190    for (path, fresh_content) in &fresh_targets {
191        let normalized_fresh = normalize_line_endings(fresh_content);
192        let on_disk = rendered_targets
193            .get(path)
194            .map(|text| normalize_line_endings(text));
195        let matches = on_disk.as_ref() == Some(&normalized_fresh);
196        if !matches {
197            report.findings.push(VerificationFinding {
198                kind: "renderer_drift".to_string(),
199                message: format!("{path} differs from fresh render"),
200            });
201        }
202        if !lock_targets.contains(path) {
203            report.findings.push(VerificationFinding {
204                kind: "renderer_drift".to_string(),
205                message: format!("{path} is not tracked in lockfile"),
206            });
207        }
208    }
209
210    if report.findings.is_empty() {
211        Ok(report)
212    } else {
213        Err(PrayError::Verify(format_drift_report(&report)))
214    }
215}
216
217fn lockfile_targets(lockfile: &Lockfile) -> BTreeSet<String> {
218    lockfile
219        .target
220        .iter()
221        .flat_map(|target| target.outputs.iter().cloned())
222        .collect()
223}