1use crate::hashing::sha256_prefixed;
2use crate::render::RenderedTarget;
3use crate::{PrayError, PrayResult};
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::path::{Component, Path, PathBuf};
7
8#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
9pub struct Lockfile {
10 pub prayfile_lock: String,
11 pub spec: String,
12 pub generated_by: String,
13 pub manifest_hash: String,
14 #[serde(default, skip_serializing_if = "Option::is_none")]
15 pub environment: Option<String>,
16 pub source: Vec<LockSource>,
17 pub package: Vec<LockedPackage>,
18 pub target: Vec<LockedTarget>,
19 pub managed_span: Vec<ManagedSpanRecord>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct LockSource {
24 pub name: String,
25 pub kind: String,
26 pub url: String,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub revision: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub host_key_fingerprint: Option<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct LockedPackage {
35 pub name: String,
36 pub version: String,
37 pub source: Option<String>,
38 pub path: String,
39 pub tree_hash: String,
40 pub artifact_hash: String,
41 pub artifact: String,
42 pub exports: Vec<String>,
43 pub dependencies: Vec<String>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub signer_fingerprint: Option<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct LockedTarget {
50 pub name: String,
51 pub outputs: Vec<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub struct ManagedSpanRecord {
56 pub id: String,
57 pub target: String,
58 pub open_line: usize,
59 pub close_line: usize,
60 pub ideal_checksum: String,
61 pub package: String,
62 pub export: String,
63 pub source_checksum: String,
64 pub silenced: bool,
65}
66
67impl Lockfile {
68 pub fn canonicalized(&self) -> Self {
69 let mut lockfile = self.clone();
70 lockfile
71 .source
72 .sort_by(|left, right| left.name.cmp(&right.name));
73 lockfile.package.sort_by(|left, right| {
74 left.name
75 .cmp(&right.name)
76 .then(left.source.cmp(&right.source))
77 .then(left.version.cmp(&right.version))
78 });
79 lockfile
80 .target
81 .sort_by(|left, right| left.name.cmp(&right.name));
82 lockfile.managed_span.sort_by(|left, right| {
83 left.target
84 .cmp(&right.target)
85 .then(left.open_line.cmp(&right.open_line))
86 .then(left.id.cmp(&right.id))
87 });
88 lockfile
89 }
90
91 pub fn serialized(&self) -> PrayResult<String> {
92 let bytes = toml::to_string_pretty(&self.canonicalized())
93 .map_err(|error| PrayError::Manifest(error.to_string()))?;
94 Ok(bytes)
95 }
96
97 pub fn file_hash(&self) -> PrayResult<String> {
98 let text = self.serialized()?;
99 Ok(sha256_prefixed(text.as_bytes()))
100 }
101
102 pub fn equivalent_to(&self, other: &Self) -> bool {
103 self == &other.canonicalized()
104 }
105}
106
107pub fn lockfiles_equivalent(canonical: &Lockfile, other: &Lockfile) -> bool {
108 canonical.equivalent_to(other)
109}
110
111pub fn write_lockfile_if_changed(path: &Path, lockfile: &Lockfile) -> PrayResult<()> {
112 let serialized = lockfile.serialized()?;
113 if path.exists() {
114 if let Ok(existing) = fs::read(path) {
115 if existing == serialized.as_bytes() {
116 return Ok(());
117 }
118 }
119 }
120 fs::write(path, serialized)?;
121 Ok(())
122}
123
124pub fn write_lockfile(path: &Path, lockfile: &Lockfile) -> PrayResult<()> {
125 let serialized = lockfile.serialized()?;
126 fs::write(path, serialized)?;
127 Ok(())
128}
129
130pub fn read_lockfile(path: &Path) -> PrayResult<Lockfile> {
131 let text = fs::read_to_string(path)?;
132 let lockfile = toml::from_str(&text).map_err(|error| PrayError::Parse {
133 kind: "lockfile",
134 message: error.to_string(),
135 })?;
136 Ok(lockfile)
137}
138
139pub fn relative_lockfile_path(project_root: &Path, path: &Path) -> String {
140 let absolute = if path.is_absolute() {
141 path.to_path_buf()
142 } else {
143 project_root.join(path)
144 };
145 let normalized_root = lexical_normalize_path(project_root);
146 let normalized_absolute = lexical_normalize_path(&absolute);
147 let relative = normalized_absolute
148 .strip_prefix(&normalized_root)
149 .map(Path::to_path_buf)
150 .unwrap_or_else(|_| {
151 if path.is_absolute() {
152 path.to_path_buf()
153 } else {
154 lexical_normalize_path(path)
155 }
156 });
157 format_relative_lockfile_path(&relative)
158}
159
160fn format_relative_lockfile_path(relative: &Path) -> String {
161 let text = relative.to_string_lossy().replace('\\', "/");
162 if text == "." || text.starts_with("./") {
163 text
164 } else {
165 format!("./{text}")
166 }
167}
168
169fn lexical_normalize_path(path: &Path) -> PathBuf {
170 let mut normalized = PathBuf::new();
171 for component in path.components() {
172 match component {
173 Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
174 Component::RootDir => normalized.push(std::path::MAIN_SEPARATOR_STR),
175 Component::CurDir => {}
176 Component::ParentDir => {
177 let _ = normalized.pop();
178 }
179 Component::Normal(segment) => normalized.push(segment),
180 }
181 }
182 normalized
183}
184
185fn normalize_lockfile_artifact(project_root: &Path, artifact: &str, package_root: &Path) -> String {
186 if let Some(path_text) = artifact.strip_prefix("path:") {
187 let path = Path::new(path_text);
188 let relative = if path.is_absolute() {
189 relative_lockfile_path(project_root, path)
190 } else {
191 relative_lockfile_path(project_root, package_root)
192 };
193 format!("path:{relative}")
194 } else {
195 artifact.to_string()
196 }
197}
198
199#[allow(clippy::too_many_arguments)]
200pub fn build_lockfile(
201 manifest_hash: String,
202 environment: Option<String>,
203 project_root: &Path,
204 manifest_sources: &[crate::manifest::ManifestSource],
205 manifest_targets: &[crate::manifest::ManifestTarget],
206 rendered: &[RenderedTarget],
207 packages: &[crate::resolve::ResolvedPackage],
208 source_revisions: &std::collections::BTreeMap<String, String>,
209 source_host_keys: &std::collections::BTreeMap<String, String>,
210) -> Lockfile {
211 Lockfile {
212 prayfile_lock: "1".to_string(),
213 spec: "0.1".to_string(),
214 generated_by: format!("pray {}", env!("CARGO_PKG_VERSION")),
215 manifest_hash,
216 environment,
217 source: manifest_sources
218 .iter()
219 .map(|source| LockSource {
220 name: source.name.clone(),
221 kind: source.kind.clone(),
222 url: source.url.clone(),
223 revision: source_revisions.get(&source.name).cloned(),
224 host_key_fingerprint: source_host_keys.get(&source.name).cloned(),
225 })
226 .collect(),
227 package: packages
228 .iter()
229 .map(|package| LockedPackage {
230 name: package.declaration.name.clone(),
231 version: package.spec.version.clone(),
232 source: package.declaration.source.clone(),
233 path: relative_lockfile_path(project_root, &package.root),
234 tree_hash: package.tree_hash.clone(),
235 artifact_hash: package.artifact_hash.clone(),
236 artifact: normalize_lockfile_artifact(
237 project_root,
238 &package.artifact,
239 &package.root,
240 ),
241 exports: package.selected_exports.clone(),
242 dependencies: package
243 .spec
244 .dependencies
245 .iter()
246 .map(|dependency| dependency.name.clone())
247 .collect(),
248 signer_fingerprint: package.signer_fingerprint.clone(),
249 })
250 .collect(),
251 target: manifest_targets
252 .iter()
253 .map(|target| LockedTarget {
254 name: target.name.clone(),
255 outputs: target.outputs.clone(),
256 })
257 .collect(),
258 managed_span: rendered
259 .iter()
260 .flat_map(|target| target.managed_spans.iter().cloned())
261 .collect(),
262 }
263 .canonicalized()
264}
265
266#[cfg(test)]
267mod tests {
268 use super::{
269 build_lockfile, lockfiles_equivalent, normalize_lockfile_artifact, relative_lockfile_path,
270 LockSource, LockedPackage, Lockfile,
271 };
272 use std::collections::BTreeMap;
273 use std::path::Path;
274
275 #[test]
276 fn build_lockfile_records_git_source_revision() {
277 let mut source_revisions = BTreeMap::new();
278 source_revisions.insert(
279 "dist".to_string(),
280 "abc123def4567890abc123def4567890abc123de".to_string(),
281 );
282 let lockfile = build_lockfile(
283 "sha256:manifest".to_string(),
284 None,
285 Path::new("."),
286 &[crate::manifest::ManifestSource {
287 name: "dist".to_string(),
288 kind: "git".to_string(),
289 url: "git+https://example.com/dist.git".to_string(),
290 subdir: None,
291 rev: None,
292 tag: None,
293 }],
294 &[],
295 &[],
296 &[],
297 &source_revisions,
298 &BTreeMap::new(),
299 );
300 assert_eq!(
301 lockfile.source,
302 vec![LockSource {
303 name: "dist".to_string(),
304 kind: "git".to_string(),
305 url: "git+https://example.com/dist.git".to_string(),
306 revision: Some("abc123def4567890abc123def4567890abc123de".to_string()),
307 host_key_fingerprint: None,
308 }]
309 );
310 let serialized = lockfile.serialized().expect("serialize lockfile");
311 assert!(serialized.contains("revision ="));
312 }
313
314 #[test]
315 fn lockfiles_equivalent_ignores_field_order() {
316 let left = Lockfile {
317 manifest_hash: "sha256:manifest".to_string(),
318 package: vec![LockedPackage {
319 name: "alpha".to_string(),
320 version: "1.0.0".to_string(),
321 source: None,
322 path: "packages/alpha".to_string(),
323 tree_hash: "sha256:tree".to_string(),
324 artifact_hash: "sha256:artifact".to_string(),
325 artifact: "alpha-1.0.0.praypkg".to_string(),
326 exports: vec!["SKILL.md".to_string()],
327 dependencies: Vec::new(),
328 signer_fingerprint: None,
329 }],
330 ..Lockfile::default()
331 };
332 let mut right = left.clone();
333 right.package.reverse();
334 assert!(lockfiles_equivalent(&left.canonicalized(), &right));
335 }
336
337 #[test]
338 fn relative_lockfile_path_strips_absolute_project_prefix() {
339 let project_root = Path::new("/tmp/project");
340 let package_root = Path::new("/tmp/project/./packages/base");
341 assert_eq!(
342 relative_lockfile_path(project_root, package_root),
343 "./packages/base"
344 );
345 }
346
347 #[test]
348 fn normalize_lockfile_artifact_relativizes_path_artifacts() {
349 let project_root = Path::new("/tmp/project");
350 let package_root = Path::new("/tmp/project/packages/base");
351 assert_eq!(
352 normalize_lockfile_artifact(
353 project_root,
354 "path:/tmp/project/./packages/base",
355 package_root,
356 ),
357 "path:./packages/base"
358 );
359 assert_eq!(
360 normalize_lockfile_artifact(
361 project_root,
362 "v1/artifacts/sample/base/1.0.0/package.praypkg",
363 package_root,
364 ),
365 "v1/artifacts/sample/base/1.0.0/package.praypkg"
366 );
367 }
368}