1use crate::{LockedPackage, LockfileGraph, bun, npm, pnpm, yarn};
2use std::collections::BTreeMap;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum LockfileKind {
8 Aube,
12 Pnpm,
15 Npm,
16 Yarn,
19 YarnBerry,
25 NpmShrinkwrap,
26 Bun,
27}
28
29impl LockfileKind {
30 pub fn filename(self) -> &'static str {
31 match self {
32 LockfileKind::Aube => "aube-lock.yaml",
33 LockfileKind::Pnpm => "pnpm-lock.yaml",
34 LockfileKind::Npm => "package-lock.json",
35 LockfileKind::Yarn | LockfileKind::YarnBerry => "yarn.lock",
36 LockfileKind::NpmShrinkwrap => "npm-shrinkwrap.json",
37 LockfileKind::Bun => "bun.lock",
38 }
39 }
40}
41
42pub(crate) fn atomic_write_lockfile(path: &Path, body: &[u8]) -> Result<(), Error> {
49 aube_util::fs_atomic::atomic_write(path, body).map_err(|e| Error::Io(path.to_path_buf(), e))
50}
51
52pub fn write_lockfile(
56 project_dir: &Path,
57 graph: &LockfileGraph,
58 manifest: &aube_manifest::PackageJson,
59) -> Result<(), Error> {
60 write_lockfile_as(project_dir, graph, manifest, LockfileKind::Aube)?;
61 Ok(())
62}
63
64pub fn build_canonical_map(graph: &LockfileGraph) -> BTreeMap<String, &LockedPackage> {
70 let mut canonical: BTreeMap<String, &LockedPackage> = BTreeMap::new();
71 for pkg in graph.packages.values() {
72 canonical.entry(pkg.spec_key()).or_insert(pkg);
73 }
74 canonical
75}
76
77pub fn write_lockfile_preserving_existing(
83 project_dir: &Path,
84 graph: &LockfileGraph,
85 manifest: &aube_manifest::PackageJson,
86) -> Result<PathBuf, Error> {
87 let kind = detect_existing_lockfile_kind(project_dir).unwrap_or(LockfileKind::Aube);
88 write_lockfile_as(project_dir, graph, manifest, kind)
89}
90
91pub fn write_lockfile_as(
104 project_dir: &Path,
105 graph: &LockfileGraph,
106 manifest: &aube_manifest::PackageJson,
107 kind: LockfileKind,
108) -> Result<PathBuf, Error> {
109 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Lockfile, "write")
110 .with_meta_fn(|| {
111 format!(
112 r#"{{"kind":{},"packages":{}}}"#,
113 aube_util::diag::jstr(&format!("{:?}", kind)),
114 graph.packages.len()
115 )
116 });
117 let filename = match kind {
118 LockfileKind::Aube => aube_lock_filename(project_dir),
119 LockfileKind::Pnpm => pnpm_lock_filename(project_dir),
120 other => other.filename().to_string(),
121 };
122 let path = project_dir.join(&filename);
123 match kind {
124 LockfileKind::Aube | LockfileKind::Pnpm => pnpm::write(&path, graph, manifest)?,
125 LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::write(&path, graph, manifest)?,
126 LockfileKind::Yarn => yarn::write_classic(&path, graph, manifest)?,
127 LockfileKind::YarnBerry => yarn::write_berry(&path, graph, manifest)?,
128 LockfileKind::Bun => bun::write(&path, graph, manifest)?,
129 }
130 Ok(path)
131}
132
133pub fn detect_existing_lockfile_kind(project_dir: &Path) -> Option<LockfileKind> {
142 for (path, kind) in lockfile_candidates(project_dir, true) {
143 if path.exists() {
144 return Some(refine_yarn_kind(&path, kind));
145 }
146 }
147 None
148}
149
150pub fn active_lockfile_has_conflict_markers(project_dir: &Path) -> bool {
157 for (path, _) in lockfile_candidates(project_dir, true) {
158 if !path.exists() {
159 continue;
160 }
161 return read_lockfile(&path)
162 .map(|content| has_conflict_markers(&content))
163 .unwrap_or(false);
164 }
165 false
166}
167
168fn has_conflict_markers(content: &str) -> bool {
169 content.lines().any(|line| {
170 line.starts_with("<<<<<<< ")
171 || line.trim_end_matches('\r') == "======="
172 || line.starts_with(">>>>>>> ")
173 })
174}
175
176pub fn aube_lock_filename(project_dir: &Path) -> String {
192 use std::sync::{Mutex, OnceLock};
193 static CACHE: OnceLock<Mutex<std::collections::HashMap<PathBuf, String>>> = OnceLock::new();
194 let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
195 if let Ok(map) = cache.lock()
196 && let Some(hit) = map.get(project_dir)
197 {
198 return hit.clone();
199 }
200 let resolved = if !git_branch_lockfile_enabled(project_dir) {
201 "aube-lock.yaml".to_string()
202 } else {
203 match current_git_branch(project_dir) {
204 Some(branch) => format!("aube-lock.{}.yaml", branch.replace('/', "!")),
205 None => "aube-lock.yaml".to_string(),
206 }
207 };
208 if let Ok(mut map) = cache.lock() {
209 map.insert(project_dir.to_path_buf(), resolved.clone());
210 }
211 resolved
212}
213
214pub fn pnpm_lock_filename(project_dir: &Path) -> String {
220 let aube_name = aube_lock_filename(project_dir);
221 aube_name
224 .strip_prefix("aube-lock.")
225 .map(|rest| format!("pnpm-lock.{rest}"))
226 .unwrap_or_else(|| "pnpm-lock.yaml".to_string())
227}
228
229fn git_branch_lockfile_enabled(project_dir: &Path) -> bool {
230 let Ok(raw) = aube_manifest::workspace::load_raw(project_dir) else {
238 return false;
239 };
240 let npmrc: Vec<(String, String)> = Vec::new();
241 let ctx = aube_settings::ResolveCtx::files_only(&npmrc, &raw);
242 aube_settings::resolved::git_branch_lockfile(&ctx)
243}
244
245pub(crate) fn current_git_branch(project_dir: &Path) -> Option<String> {
246 let out = std::process::Command::new("git")
247 .args(["-C"])
248 .arg(project_dir)
249 .args(["branch", "--show-current"])
250 .output()
251 .ok()?;
252 if !out.status.success() {
253 return None;
254 }
255 let branch = String::from_utf8(out.stdout).ok()?.trim().to_string();
256 if branch.is_empty() {
257 None
258 } else {
259 Some(branch)
260 }
261}
262
263pub fn parse_lockfile(
272 project_dir: &Path,
273 manifest: &aube_manifest::PackageJson,
274) -> Result<LockfileGraph, Error> {
275 let (graph, _kind) = parse_lockfile_with_kind(project_dir, manifest)?;
276 Ok(graph)
277}
278
279pub fn parse_lockfile_with_kind(
281 project_dir: &Path,
282 manifest: &aube_manifest::PackageJson,
283) -> Result<(LockfileGraph, LockfileKind), Error> {
284 reject_bun_binary(project_dir)?;
285 for (path, kind) in lockfile_candidates(project_dir, true) {
286 if !path.exists() {
287 continue;
288 }
289 let kind = refine_yarn_kind(&path, kind);
290 let graph = parse_one(&path, kind, manifest)?;
291 return Ok((graph, kind));
292 }
293 Err(Error::NotFound(project_dir.to_path_buf()))
294}
295
296pub fn parse_for_import(
303 project_dir: &Path,
304 manifest: &aube_manifest::PackageJson,
305) -> Result<(LockfileGraph, LockfileKind), Error> {
306 reject_bun_binary(project_dir)?;
307 for (path, kind) in lockfile_candidates(project_dir, false) {
308 if !path.exists() {
309 continue;
310 }
311 let kind = refine_yarn_kind(&path, kind);
312 let graph = parse_one(&path, kind, manifest)?;
313 return Ok((graph, kind));
314 }
315 Err(Error::NotFound(project_dir.to_path_buf()))
316}
317
318fn reject_bun_binary(project_dir: &Path) -> Result<(), Error> {
321 let lockb = project_dir.join("bun.lockb");
322 let text = project_dir.join("bun.lock");
323 if lockb.exists() && !text.exists() {
324 return Err(Error::parse(
325 &lockb,
326 "bun.lockb (binary format) is not supported — run `bun install --save-text-lockfile` to generate a bun.lock text file first, or upgrade to bun 1.2+ where text is the default",
327 ));
328 }
329 Ok(())
330}
331
332fn lockfile_candidates(project_dir: &Path, include_aube: bool) -> Vec<(PathBuf, LockfileKind)> {
333 let mut out = Vec::new();
334 if include_aube {
335 let branch_name = aube_lock_filename(project_dir);
339 if branch_name != "aube-lock.yaml" {
340 out.push((project_dir.join(&branch_name), LockfileKind::Aube));
341 }
342 out.push((project_dir.join("aube-lock.yaml"), LockfileKind::Aube));
343 }
344 let pnpm_branch = {
349 let mut s = aube_lock_filename(project_dir);
350 if let Some(rest) = s.strip_prefix("aube-lock.") {
351 s = format!("pnpm-lock.{rest}");
352 }
353 s
354 };
355 if pnpm_branch != "pnpm-lock.yaml" {
356 out.push((project_dir.join(&pnpm_branch), LockfileKind::Pnpm));
357 }
358 out.push((project_dir.join("pnpm-lock.yaml"), LockfileKind::Pnpm));
359 out.push((project_dir.join("bun.lock"), LockfileKind::Bun));
360 out.push((project_dir.join("yarn.lock"), LockfileKind::Yarn));
361 out.push((
362 project_dir.join("npm-shrinkwrap.json"),
363 LockfileKind::NpmShrinkwrap,
364 ));
365 out.push((project_dir.join("package-lock.json"), LockfileKind::Npm));
366 out
367}
368
369fn parse_one(
370 path: &Path,
371 kind: LockfileKind,
372 manifest: &aube_manifest::PackageJson,
373) -> Result<LockfileGraph, Error> {
374 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Lockfile, "parse_one")
375 .with_meta_fn(|| {
376 let display = path
379 .file_name()
380 .map(|n| n.to_string_lossy().into_owned())
381 .unwrap_or_default();
382 format!(
383 r#"{{"kind":{},"path":{}}}"#,
384 aube_util::diag::jstr(&format!("{:?}", kind)),
385 aube_util::diag::jstr(&display)
386 )
387 });
388 match kind {
389 LockfileKind::Aube | LockfileKind::Pnpm => pnpm::parse(path),
394 LockfileKind::Yarn | LockfileKind::YarnBerry => yarn::parse(path, manifest),
400 LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::parse(path),
401 LockfileKind::Bun => bun::parse(path),
402 }
403}
404
405fn refine_yarn_kind(path: &Path, kind: LockfileKind) -> LockfileKind {
414 if kind == LockfileKind::Yarn && yarn::is_berry_path(path) {
415 LockfileKind::YarnBerry
416 } else {
417 kind
418 }
419}
420
421#[derive(Debug, thiserror::Error, miette::Diagnostic)]
422pub enum Error {
423 #[error("no lockfile found in {0}")]
424 #[diagnostic(code(ERR_AUBE_NO_LOCKFILE))]
425 NotFound(std::path::PathBuf),
426 #[error("unsupported lockfile format: {0}")]
427 #[diagnostic(code(ERR_AUBE_LOCKFILE_UNSUPPORTED_FORMAT))]
428 UnsupportedFormat(String),
429 #[error("failed to read lockfile {0}: {1}")]
430 Io(std::path::PathBuf, std::io::Error),
431 #[error("failed to parse lockfile {0}: {1}")]
436 #[diagnostic(code(ERR_AUBE_LOCKFILE_PARSE))]
437 Parse(std::path::PathBuf, String),
438 #[error(transparent)]
444 #[diagnostic(transparent)]
445 ParseDiag(Box<aube_manifest::ParseError>),
446}
447
448pub fn read_lockfile(path: &std::path::Path) -> Result<String, Error> {
450 std::fs::read_to_string(path).map_err(|e| Error::Io(path.to_path_buf(), e))
451}
452
453pub fn parse_json<T: serde::de::DeserializeOwned>(
456 path: &std::path::Path,
457 content: String,
458) -> Result<T, Error> {
459 match sonic_rs::from_slice(content.as_bytes()) {
462 Ok(v) => Ok(v),
463 Err(_) => match serde_json::from_str(&content) {
464 Ok(v) => Ok(v),
465 Err(e) => Err(Error::parse_json_err(path, content, &e)),
466 },
467 }
468}
469
470impl Error {
471 pub fn parse(path: &std::path::Path, msg: impl Into<String>) -> Self {
472 Error::Parse(path.to_path_buf(), msg.into())
473 }
474
475 pub fn parse_json_err(
476 path: &std::path::Path,
477 content: String,
478 err: &serde_json::Error,
479 ) -> Self {
480 Error::ParseDiag(Box::new(aube_manifest::ParseError::from_json_err(
481 path, content, err,
482 )))
483 }
484
485 pub fn parse_yaml_err(
486 path: &std::path::Path,
487 content: String,
488 err: &yaml_serde::Error,
489 ) -> Self {
490 Error::ParseDiag(Box::new(aube_manifest::ParseError::from_yaml_err(
491 path, content, err,
492 )))
493 }
494}
495
496#[cfg(test)]
497mod parse_diag_tests {
498 use super::*;
499 use std::path::Path;
500
501 #[test]
505 fn parse_json_attaches_span_for_bad_input() {
506 let path = Path::new("package-lock.json");
507 let content = r#"{"name":"x","#.to_string();
508 let Err(Error::ParseDiag(pe)) = parse_json::<serde_json::Value>(path, content.clone())
509 else {
510 panic!("parse_json must produce ParseDiag on malformed input");
511 };
512 let offset: usize = pe.span.offset();
513 let len: usize = pe.span.len();
514 assert!(offset + len <= content.len());
515 assert_eq!(pe.path, path);
516 }
517
518 #[test]
525 fn parse_yaml_err_attaches_span_for_bad_input() {
526 let path = Path::new("yarn.lock");
527 let content = "packages:\n\t- pkg\n".to_string();
528 let yaml_err: yaml_serde::Error = yaml_serde::from_str::<yaml_serde::Value>(&content)
529 .expect_err("tab-indented YAML must fail");
530 let Error::ParseDiag(pe) = Error::parse_yaml_err(path, content.clone(), &yaml_err) else {
531 panic!("parse_yaml_err must produce ParseDiag");
532 };
533 let offset: usize = pe.span.offset();
534 let len: usize = pe.span.len();
535 assert!(offset + len <= content.len());
536 assert_eq!(pe.path, path);
537 }
538}
539
540#[cfg(test)]
541mod filename_tests {
542 use super::*;
543
544 #[test]
545 fn defaults_to_plain_lockfile_when_setting_absent() {
546 let dir = tempfile::tempdir().unwrap();
547 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
548 assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.yaml");
549 }
550
551 #[test]
552 fn defaults_to_plain_lockfile_when_setting_explicit_false() {
553 let dir = tempfile::tempdir().unwrap();
554 std::fs::write(
555 dir.path().join("pnpm-workspace.yaml"),
556 "gitBranchLockfile: false\n",
557 )
558 .unwrap();
559 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
560 }
561
562 #[test]
563 fn uses_branch_filename_when_enabled_inside_git_repo() {
564 let dir = tempfile::tempdir().unwrap();
565 std::fs::write(
566 dir.path().join("pnpm-workspace.yaml"),
567 "gitBranchLockfile: true\n",
568 )
569 .unwrap();
570 let run = |args: &[&str]| {
573 std::process::Command::new("git")
574 .args(["-C"])
575 .arg(dir.path())
576 .args(args)
577 .output()
578 .unwrap()
579 };
580 if run(&["init", "-q"]).status.success() {
581 run(&["checkout", "-q", "-b", "feature/x"]);
582 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.feature!x.yaml");
583 assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.feature!x.yaml");
584 }
585 }
586}