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}