use crate::{LockedPackage, LockfileGraph, bun, npm, pnpm, yarn};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LockfileKind {
Aube,
Pnpm,
Npm,
Yarn,
YarnBerry,
NpmShrinkwrap,
Bun,
}
impl LockfileKind {
pub fn filename(self) -> &'static str {
match self {
LockfileKind::Aube => "aube-lock.yaml",
LockfileKind::Pnpm => "pnpm-lock.yaml",
LockfileKind::Npm => "package-lock.json",
LockfileKind::Yarn | LockfileKind::YarnBerry => "yarn.lock",
LockfileKind::NpmShrinkwrap => "npm-shrinkwrap.json",
LockfileKind::Bun => "bun.lock",
}
}
}
pub(crate) fn atomic_write_lockfile(path: &Path, body: &[u8]) -> Result<(), Error> {
aube_util::fs_atomic::atomic_write(path, body).map_err(|e| Error::Io(path.to_path_buf(), e))
}
pub fn write_lockfile(
project_dir: &Path,
graph: &LockfileGraph,
manifest: &aube_manifest::PackageJson,
) -> Result<(), Error> {
write_lockfile_as(project_dir, graph, manifest, LockfileKind::Aube)?;
Ok(())
}
pub fn build_canonical_map(graph: &LockfileGraph) -> BTreeMap<String, &LockedPackage> {
let mut canonical: BTreeMap<String, &LockedPackage> = BTreeMap::new();
for pkg in graph.packages.values() {
canonical.entry(pkg.spec_key()).or_insert(pkg);
}
canonical
}
pub fn write_lockfile_preserving_existing(
project_dir: &Path,
graph: &LockfileGraph,
manifest: &aube_manifest::PackageJson,
) -> Result<PathBuf, Error> {
let kind = detect_existing_lockfile_kind(project_dir).unwrap_or(LockfileKind::Aube);
write_lockfile_as(project_dir, graph, manifest, kind)
}
pub fn write_lockfile_as(
project_dir: &Path,
graph: &LockfileGraph,
manifest: &aube_manifest::PackageJson,
kind: LockfileKind,
) -> Result<PathBuf, Error> {
let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Lockfile, "write")
.with_meta_fn(|| {
format!(
r#"{{"kind":{},"packages":{}}}"#,
aube_util::diag::jstr(&format!("{:?}", kind)),
graph.packages.len()
)
});
let filename = match kind {
LockfileKind::Aube => aube_lock_filename(project_dir),
LockfileKind::Pnpm => pnpm_lock_filename(project_dir),
other => other.filename().to_string(),
};
let path = project_dir.join(&filename);
match kind {
LockfileKind::Aube | LockfileKind::Pnpm => pnpm::write(&path, graph, manifest)?,
LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::write(&path, graph, manifest)?,
LockfileKind::Yarn => yarn::write_classic(&path, graph, manifest)?,
LockfileKind::YarnBerry => yarn::write_berry(&path, graph, manifest)?,
LockfileKind::Bun => bun::write(&path, graph, manifest)?,
}
Ok(path)
}
pub fn detect_existing_lockfile_kind(project_dir: &Path) -> Option<LockfileKind> {
for (path, kind) in lockfile_candidates(project_dir, true) {
if path.exists() {
return Some(refine_yarn_kind(&path, kind));
}
}
None
}
pub fn active_lockfile_has_conflict_markers(project_dir: &Path) -> bool {
for (path, _) in lockfile_candidates(project_dir, true) {
if !path.exists() {
continue;
}
return read_lockfile(&path)
.map(|content| has_conflict_markers(&content))
.unwrap_or(false);
}
false
}
fn has_conflict_markers(content: &str) -> bool {
content.lines().any(|line| {
line.starts_with("<<<<<<< ")
|| line.trim_end_matches('\r') == "======="
|| line.starts_with(">>>>>>> ")
})
}
pub fn aube_lock_filename(project_dir: &Path) -> String {
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<std::collections::HashMap<PathBuf, String>>> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
if let Ok(map) = cache.lock()
&& let Some(hit) = map.get(project_dir)
{
return hit.clone();
}
let resolved = if !git_branch_lockfile_enabled(project_dir) {
"aube-lock.yaml".to_string()
} else {
match current_git_branch(project_dir) {
Some(branch) => format!("aube-lock.{}.yaml", branch.replace('/', "!")),
None => "aube-lock.yaml".to_string(),
}
};
if let Ok(mut map) = cache.lock() {
map.insert(project_dir.to_path_buf(), resolved.clone());
}
resolved
}
pub fn pnpm_lock_filename(project_dir: &Path) -> String {
let aube_name = aube_lock_filename(project_dir);
aube_name
.strip_prefix("aube-lock.")
.map(|rest| format!("pnpm-lock.{rest}"))
.unwrap_or_else(|| "pnpm-lock.yaml".to_string())
}
fn git_branch_lockfile_enabled(project_dir: &Path) -> bool {
let Ok(raw) = aube_manifest::workspace::load_raw(project_dir) else {
return false;
};
let npmrc: Vec<(String, String)> = Vec::new();
let ctx = aube_settings::ResolveCtx::files_only(&npmrc, &raw);
aube_settings::resolved::git_branch_lockfile(&ctx)
}
pub(crate) fn current_git_branch(project_dir: &Path) -> Option<String> {
let out = std::process::Command::new("git")
.args(["-C"])
.arg(project_dir)
.args(["branch", "--show-current"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let branch = String::from_utf8(out.stdout).ok()?.trim().to_string();
if branch.is_empty() {
None
} else {
Some(branch)
}
}
pub fn parse_lockfile(
project_dir: &Path,
manifest: &aube_manifest::PackageJson,
) -> Result<LockfileGraph, Error> {
let (graph, _kind) = parse_lockfile_with_kind(project_dir, manifest)?;
Ok(graph)
}
pub fn parse_lockfile_with_kind(
project_dir: &Path,
manifest: &aube_manifest::PackageJson,
) -> Result<(LockfileGraph, LockfileKind), Error> {
reject_bun_binary(project_dir)?;
for (path, kind) in lockfile_candidates(project_dir, true) {
if !path.exists() {
continue;
}
let kind = refine_yarn_kind(&path, kind);
let graph = parse_one(&path, kind, manifest)?;
return Ok((graph, kind));
}
Err(Error::NotFound(project_dir.to_path_buf()))
}
pub fn parse_for_import(
project_dir: &Path,
manifest: &aube_manifest::PackageJson,
) -> Result<(LockfileGraph, LockfileKind), Error> {
reject_bun_binary(project_dir)?;
for (path, kind) in lockfile_candidates(project_dir, false) {
if !path.exists() {
continue;
}
let kind = refine_yarn_kind(&path, kind);
let graph = parse_one(&path, kind, manifest)?;
return Ok((graph, kind));
}
Err(Error::NotFound(project_dir.to_path_buf()))
}
fn reject_bun_binary(project_dir: &Path) -> Result<(), Error> {
let lockb = project_dir.join("bun.lockb");
let text = project_dir.join("bun.lock");
if lockb.exists() && !text.exists() {
return Err(Error::parse(
&lockb,
"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",
));
}
Ok(())
}
fn lockfile_candidates(project_dir: &Path, include_aube: bool) -> Vec<(PathBuf, LockfileKind)> {
let mut out = Vec::new();
if include_aube {
let branch_name = aube_lock_filename(project_dir);
if branch_name != "aube-lock.yaml" {
out.push((project_dir.join(&branch_name), LockfileKind::Aube));
}
out.push((project_dir.join("aube-lock.yaml"), LockfileKind::Aube));
}
let pnpm_branch = {
let mut s = aube_lock_filename(project_dir);
if let Some(rest) = s.strip_prefix("aube-lock.") {
s = format!("pnpm-lock.{rest}");
}
s
};
if pnpm_branch != "pnpm-lock.yaml" {
out.push((project_dir.join(&pnpm_branch), LockfileKind::Pnpm));
}
out.push((project_dir.join("pnpm-lock.yaml"), LockfileKind::Pnpm));
out.push((project_dir.join("bun.lock"), LockfileKind::Bun));
out.push((project_dir.join("yarn.lock"), LockfileKind::Yarn));
out.push((
project_dir.join("npm-shrinkwrap.json"),
LockfileKind::NpmShrinkwrap,
));
out.push((project_dir.join("package-lock.json"), LockfileKind::Npm));
out
}
fn parse_one(
path: &Path,
kind: LockfileKind,
manifest: &aube_manifest::PackageJson,
) -> Result<LockfileGraph, Error> {
let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Lockfile, "parse_one")
.with_meta_fn(|| {
let display = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
format!(
r#"{{"kind":{},"path":{}}}"#,
aube_util::diag::jstr(&format!("{:?}", kind)),
aube_util::diag::jstr(&display)
)
});
match kind {
LockfileKind::Aube | LockfileKind::Pnpm => pnpm::parse(path),
LockfileKind::Yarn | LockfileKind::YarnBerry => yarn::parse(path, manifest),
LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::parse(path),
LockfileKind::Bun => bun::parse(path),
}
}
fn refine_yarn_kind(path: &Path, kind: LockfileKind) -> LockfileKind {
if kind == LockfileKind::Yarn && yarn::is_berry_path(path) {
LockfileKind::YarnBerry
} else {
kind
}
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error {
#[error("no lockfile found in {0}")]
#[diagnostic(code(ERR_AUBE_NO_LOCKFILE))]
NotFound(std::path::PathBuf),
#[error("unsupported lockfile format: {0}")]
#[diagnostic(code(ERR_AUBE_LOCKFILE_UNSUPPORTED_FORMAT))]
UnsupportedFormat(String),
#[error("failed to read lockfile {0}: {1}")]
Io(std::path::PathBuf, std::io::Error),
#[error("failed to parse lockfile {0}: {1}")]
#[diagnostic(code(ERR_AUBE_LOCKFILE_PARSE))]
Parse(std::path::PathBuf, String),
#[error(transparent)]
#[diagnostic(transparent)]
ParseDiag(Box<aube_manifest::ParseError>),
}
pub fn read_lockfile(path: &std::path::Path) -> Result<String, Error> {
std::fs::read_to_string(path).map_err(|e| Error::Io(path.to_path_buf(), e))
}
pub fn parse_json<T: serde::de::DeserializeOwned>(
path: &std::path::Path,
content: String,
) -> Result<T, Error> {
match sonic_rs::from_slice(content.as_bytes()) {
Ok(v) => Ok(v),
Err(_) => match serde_json::from_str(&content) {
Ok(v) => Ok(v),
Err(e) => Err(Error::parse_json_err(path, content, &e)),
},
}
}
impl Error {
pub fn parse(path: &std::path::Path, msg: impl Into<String>) -> Self {
Error::Parse(path.to_path_buf(), msg.into())
}
pub fn parse_json_err(
path: &std::path::Path,
content: String,
err: &serde_json::Error,
) -> Self {
Error::ParseDiag(Box::new(aube_manifest::ParseError::from_json_err(
path, content, err,
)))
}
pub fn parse_yaml_err(
path: &std::path::Path,
content: String,
err: &yaml_serde::Error,
) -> Self {
Error::ParseDiag(Box::new(aube_manifest::ParseError::from_yaml_err(
path, content, err,
)))
}
}
#[cfg(test)]
mod parse_diag_tests {
use super::*;
use std::path::Path;
#[test]
fn parse_json_attaches_span_for_bad_input() {
let path = Path::new("package-lock.json");
let content = r#"{"name":"x","#.to_string();
let Err(Error::ParseDiag(pe)) = parse_json::<serde_json::Value>(path, content.clone())
else {
panic!("parse_json must produce ParseDiag on malformed input");
};
let offset: usize = pe.span.offset();
let len: usize = pe.span.len();
assert!(offset + len <= content.len());
assert_eq!(pe.path, path);
}
#[test]
fn parse_yaml_err_attaches_span_for_bad_input() {
let path = Path::new("yarn.lock");
let content = "packages:\n\t- pkg\n".to_string();
let yaml_err: yaml_serde::Error = yaml_serde::from_str::<yaml_serde::Value>(&content)
.expect_err("tab-indented YAML must fail");
let Error::ParseDiag(pe) = Error::parse_yaml_err(path, content.clone(), &yaml_err) else {
panic!("parse_yaml_err must produce ParseDiag");
};
let offset: usize = pe.span.offset();
let len: usize = pe.span.len();
assert!(offset + len <= content.len());
assert_eq!(pe.path, path);
}
}
#[cfg(test)]
mod filename_tests {
use super::*;
#[test]
fn defaults_to_plain_lockfile_when_setting_absent() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.yaml");
}
#[test]
fn defaults_to_plain_lockfile_when_setting_explicit_false() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("pnpm-workspace.yaml"),
"gitBranchLockfile: false\n",
)
.unwrap();
assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
}
#[test]
fn uses_branch_filename_when_enabled_inside_git_repo() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("pnpm-workspace.yaml"),
"gitBranchLockfile: true\n",
)
.unwrap();
let run = |args: &[&str]| {
std::process::Command::new("git")
.args(["-C"])
.arg(dir.path())
.args(args)
.output()
.unwrap()
};
if run(&["init", "-q"]).status.success() {
run(&["checkout", "-q", "-b", "feature/x"]);
assert_eq!(aube_lock_filename(dir.path()), "aube-lock.feature!x.yaml");
assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.feature!x.yaml");
}
}
}