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_util::embedder().lockfile_basename,
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 basename = aube_util::embedder().lockfile_basename;
201 let (stem, ext) = basename.rsplit_once('.').unwrap_or((basename, "yaml"));
204 let resolved = if !git_branch_lockfile_enabled(project_dir) {
205 basename.to_string()
206 } else {
207 match current_git_branch(project_dir) {
208 Some(branch) => format!("{stem}.{}.{ext}", branch.replace('/', "!")),
209 None => basename.to_string(),
210 }
211 };
212 if let Ok(mut map) = cache.lock() {
213 map.insert(project_dir.to_path_buf(), resolved.clone());
214 }
215 resolved
216}
217
218pub fn pnpm_lock_filename(project_dir: &Path) -> String {
224 let aube_name = aube_lock_filename(project_dir);
225 let basename = aube_util::embedder().lockfile_basename;
228 let stem = basename.rsplit_once('.').map_or(basename, |(s, _)| s);
229 aube_name
230 .strip_prefix(&format!("{stem}."))
231 .map(|rest| format!("pnpm-lock.{rest}"))
232 .unwrap_or_else(|| "pnpm-lock.yaml".to_string())
233}
234
235fn git_branch_lockfile_enabled(project_dir: &Path) -> bool {
236 let Ok(raw) = aube_manifest::workspace::load_raw(project_dir) else {
244 return false;
245 };
246 let npmrc: Vec<(String, String)> = Vec::new();
247 let ctx = aube_settings::ResolveCtx::files_only(&npmrc, &raw);
248 aube_settings::resolved::git_branch_lockfile(&ctx)
249}
250
251pub(crate) fn current_git_branch(project_dir: &Path) -> Option<String> {
252 let out = std::process::Command::new("git")
253 .args(["-C"])
254 .arg(project_dir)
255 .args(["branch", "--show-current"])
256 .output()
257 .ok()?;
258 if !out.status.success() {
259 return None;
260 }
261 let branch = String::from_utf8(out.stdout).ok()?.trim().to_string();
262 if branch.is_empty() {
263 None
264 } else {
265 Some(branch)
266 }
267}
268
269pub fn parse_lockfile(
278 project_dir: &Path,
279 manifest: &aube_manifest::PackageJson,
280) -> Result<LockfileGraph, Error> {
281 let (graph, _kind) = parse_lockfile_with_kind(project_dir, manifest)?;
282 Ok(graph)
283}
284
285pub fn parse_lockfile_with_kind(
287 project_dir: &Path,
288 manifest: &aube_manifest::PackageJson,
289) -> Result<(LockfileGraph, LockfileKind), Error> {
290 reject_bun_binary(project_dir)?;
291 for (path, kind) in lockfile_candidates(project_dir, true) {
292 if !path.exists() {
293 continue;
294 }
295 let kind = refine_yarn_kind(&path, kind);
296 let graph = parse_one(&path, kind, manifest)?;
297 return Ok((graph, kind));
298 }
299 Err(Error::NotFound(project_dir.to_path_buf()))
300}
301
302pub fn parse_for_import(
309 project_dir: &Path,
310 manifest: &aube_manifest::PackageJson,
311) -> Result<(LockfileGraph, LockfileKind), Error> {
312 reject_bun_binary(project_dir)?;
313 for (path, kind) in lockfile_candidates(project_dir, false) {
314 if !path.exists() {
315 continue;
316 }
317 let kind = refine_yarn_kind(&path, kind);
318 let graph = parse_one(&path, kind, manifest)?;
319 return Ok((graph, kind));
320 }
321 Err(Error::NotFound(project_dir.to_path_buf()))
322}
323
324fn reject_bun_binary(project_dir: &Path) -> Result<(), Error> {
327 let lockb = project_dir.join("bun.lockb");
328 let text = project_dir.join("bun.lock");
329 if lockb.exists() && !text.exists() {
330 return Err(Error::parse(
331 &lockb,
332 "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",
333 ));
334 }
335 Ok(())
336}
337
338fn lockfile_candidates(project_dir: &Path, include_aube: bool) -> Vec<(PathBuf, LockfileKind)> {
339 let basename = aube_util::embedder().lockfile_basename;
340 let stem = basename.rsplit_once('.').map_or(basename, |(s, _)| s);
341
342 let mut aube_entries: Vec<(PathBuf, LockfileKind)> = Vec::new();
346 if include_aube {
347 let branch_name = aube_lock_filename(project_dir);
348 if branch_name != basename {
349 aube_entries.push((project_dir.join(&branch_name), LockfileKind::Aube));
350 }
351 aube_entries.push((project_dir.join(basename), LockfileKind::Aube));
352 }
353
354 let mut foreign: Vec<(PathBuf, LockfileKind)> = Vec::new();
359 let pnpm_branch = {
360 let mut s = aube_lock_filename(project_dir);
361 if let Some(rest) = s.strip_prefix(&format!("{stem}.")) {
362 s = format!("pnpm-lock.{rest}");
363 }
364 s
365 };
366 if pnpm_branch != "pnpm-lock.yaml" {
367 foreign.push((project_dir.join(&pnpm_branch), LockfileKind::Pnpm));
368 }
369 foreign.push((project_dir.join("pnpm-lock.yaml"), LockfileKind::Pnpm));
370 foreign.push((project_dir.join("bun.lock"), LockfileKind::Bun));
371 foreign.push((project_dir.join("yarn.lock"), LockfileKind::Yarn));
372 foreign.push((
373 project_dir.join("npm-shrinkwrap.json"),
374 LockfileKind::NpmShrinkwrap,
375 ));
376 foreign.push((project_dir.join("package-lock.json"), LockfileKind::Npm));
377
378 let mut out = Vec::with_capacity(aube_entries.len() + foreign.len());
384 if aube_util::embedder().canonical_lockfile_always_wins {
385 out.append(&mut aube_entries);
386 out.append(&mut foreign);
387 } else {
388 out.append(&mut foreign);
389 out.append(&mut aube_entries);
390 }
391 out
392}
393
394fn parse_one(
395 path: &Path,
396 kind: LockfileKind,
397 manifest: &aube_manifest::PackageJson,
398) -> Result<LockfileGraph, Error> {
399 let _diag = aube_util::diag::Span::new(aube_util::diag::Category::Lockfile, "parse_one")
400 .with_meta_fn(|| {
401 let display = path
404 .file_name()
405 .map(|n| n.to_string_lossy().into_owned())
406 .unwrap_or_default();
407 format!(
408 r#"{{"kind":{},"path":{}}}"#,
409 aube_util::diag::jstr(&format!("{:?}", kind)),
410 aube_util::diag::jstr(&display)
411 )
412 });
413 let graph = match kind {
414 LockfileKind::Aube | LockfileKind::Pnpm => pnpm::parse(path),
419 LockfileKind::Yarn | LockfileKind::YarnBerry => yarn::parse(path, manifest),
425 LockfileKind::Npm | LockfileKind::NpmShrinkwrap => npm::parse(path),
426 LockfileKind::Bun => bun::parse(path),
427 }?;
428 validate_resolution_shapes(path, &graph)?;
429 Ok(graph)
430}
431
432fn validate_resolution_shapes(path: &Path, graph: &LockfileGraph) -> Result<(), Error> {
433 validate_dependency_aliases(path, graph)?;
434 for (dep_path, pkg) in &graph.packages {
435 if pkg.local_source.is_some() && dep_path_has_registry_version(dep_path, &pkg.name) {
436 return Err(Error::ResolutionShapeMismatch(
437 path.to_path_buf(),
438 dep_path.clone(),
439 pkg.local_source
440 .as_ref()
441 .map(|source| source.kind_str())
442 .unwrap_or("unknown"),
443 ));
444 }
445 }
446 Ok(())
447}
448
449fn validate_dependency_aliases(path: &Path, graph: &LockfileGraph) -> Result<(), Error> {
450 for (importer_path, deps) in &graph.importers {
451 for dep in deps {
452 if !is_safe_package_alias(&dep.name) {
453 return Err(Error::parse(
454 path,
455 format!(
456 "importer {importer_path} has unsafe dependency alias `{}`",
457 dep.name
458 ),
459 ));
460 }
461 }
462 }
463 for (dep_path, pkg) in &graph.packages {
464 if !is_safe_package_alias(&pkg.name) {
465 return Err(Error::parse(
466 path,
467 format!("package {dep_path} has unsafe package name `{}`", pkg.name),
468 ));
469 }
470 for alias in pkg
471 .dependencies
472 .keys()
473 .chain(pkg.optional_dependencies.keys())
474 .chain(pkg.peer_dependencies.keys())
475 .chain(pkg.peer_dependencies_meta.keys())
476 .chain(pkg.declared_dependencies.keys())
477 {
478 if !is_safe_package_alias(alias) {
479 return Err(Error::parse(
480 path,
481 format!("package {dep_path} has unsafe dependency alias `{alias}`"),
482 ));
483 }
484 }
485 }
486 Ok(())
487}
488
489fn is_safe_package_alias(name: &str) -> bool {
490 if name.is_empty()
491 || name.contains('\0')
492 || name.contains('\\')
493 || name.starts_with('/')
494 || matches!(name, ".bin" | ".pnpm" | "node_modules")
495 {
496 return false;
497 }
498 let parts: Vec<&str> = name.split('/').collect();
499 match parts.as_slice() {
500 [bare] => is_safe_package_alias_component(bare),
501 [scope, bare] => {
502 scope.starts_with('@')
503 && scope.len() > 1
504 && is_safe_package_alias_component(scope)
505 && is_safe_package_alias_component(bare)
506 }
507 _ => false,
508 }
509}
510
511fn is_safe_package_alias_component(component: &str) -> bool {
512 if component.is_empty() || matches!(component, "." | "..") {
513 return false;
514 }
515 if component.len() >= 2 && component.as_bytes()[1] == b':' {
516 return false;
517 }
518 !std::path::Path::new(component).components().any(|c| {
519 matches!(
520 c,
521 std::path::Component::ParentDir
522 | std::path::Component::RootDir
523 | std::path::Component::Prefix(_)
524 )
525 })
526}
527
528fn dep_path_has_registry_version(dep_path: &str, name: &str) -> bool {
529 let Some(tail) = dep_path
530 .strip_prefix('/')
531 .unwrap_or(dep_path)
532 .strip_prefix(name)
533 .and_then(|rest| rest.strip_prefix('@'))
534 else {
535 return false;
536 };
537 let version = tail.split('(').next().unwrap_or(tail);
538 node_semver::Version::parse(version).is_ok()
539}
540
541#[cfg(test)]
542mod tests {
543 use super::{dep_path_has_registry_version, validate_dependency_aliases};
544 use crate::{
545 DepType, DirectDep, GitSource, LocalSource, LockedPackage, PeerDepMeta, RemoteTarballSource,
546 };
547 use proptest::prelude::*;
548 use std::collections::BTreeMap;
549 use std::path::{Path, PathBuf};
550
551 fn package_name() -> impl Strategy<Value = String> {
552 prop_oneof![
553 "[a-z][a-z0-9-]{0,20}".prop_map(|name| name),
554 ("[a-z][a-z0-9-]{0,10}", "[a-z][a-z0-9-]{0,20}")
555 .prop_map(|(scope, name)| format!("@{scope}/{name}")),
556 ]
557 }
558
559 fn semver() -> impl Strategy<Value = String> {
560 (0u16..1000, 0u16..1000, 0u16..1000)
561 .prop_map(|(major, minor, patch)| format!("{major}.{minor}.{patch}"))
562 }
563
564 fn path_source() -> impl Strategy<Value = LocalSource> {
565 ("[a-z][a-z0-9_-]{0,12}", prop_oneof![0u8..5, 5u8..10]).prop_map(|(path, kind)| {
566 let path = PathBuf::from(format!("./vendor/{path}"));
567 match kind {
568 0 => LocalSource::Directory(path),
569 1 => LocalSource::Tarball(path.with_extension("tgz")),
570 2 => LocalSource::Link(path),
571 3 => LocalSource::Portal(path),
572 _ => LocalSource::Exec(path),
573 }
574 })
575 }
576
577 fn local_source() -> impl Strategy<Value = LocalSource> {
578 prop_oneof![
579 path_source(),
580 "[a-z][a-z0-9-]{0,20}".prop_map(|repo| LocalSource::Git(GitSource {
581 url: format!("https://github.com/acme/{repo}.git"),
582 committish: None,
583 resolved: "0123456789abcdef0123456789abcdef01234567".to_string(),
584 integrity: None,
585 subpath: None,
586 })),
587 "[a-z][a-z0-9-]{0,20}".prop_map(|tarball| LocalSource::RemoteTarball(
588 RemoteTarballSource {
589 url: format!("https://registry.example/{tarball}.tgz"),
590 integrity: String::new(),
591 git_hosted: false,
592 },
593 )),
594 ]
595 }
596
597 #[test]
598 fn rejects_unsafe_importer_dependency_aliases() {
599 for alias in [
600 "../../../escape",
601 ".bin",
602 ".pnpm",
603 "node_modules",
604 "@scope/pkg/extra",
605 "\\evil",
606 "foo\0bar",
607 "/etc/passwd",
608 "C:pkg",
609 ] {
610 let mut graph = crate::LockfileGraph::default();
611 graph.importers.insert(
612 ".".into(),
613 vec![DirectDep {
614 name: alias.into(),
615 dep_path: "ok@1.0.0".into(),
616 dep_type: DepType::Production,
617 specifier: Some("1.0.0".into()),
618 }],
619 );
620
621 let err = validate_dependency_aliases(Path::new("pnpm-lock.yaml"), &graph)
622 .expect_err("unsafe alias must be rejected");
623 assert!(
624 err.to_string().contains("unsafe dependency alias"),
625 "unexpected error: {err}"
626 );
627 }
628 }
629
630 #[test]
631 fn rejects_unsafe_package_dependency_aliases() {
632 for package in [
633 LockedPackage {
634 name: "parent".into(),
635 version: "1.0.0".into(),
636 dep_path: "parent@1.0.0".into(),
637 dependencies: BTreeMap::from([("../escape".into(), "1.0.0".into())]),
638 ..LockedPackage::default()
639 },
640 LockedPackage {
641 name: "parent".into(),
642 version: "1.0.0".into(),
643 dep_path: "parent@1.0.0".into(),
644 declared_dependencies: BTreeMap::from([("../escape".into(), "^1.0.0".into())]),
645 ..LockedPackage::default()
646 },
647 LockedPackage {
648 name: "parent".into(),
649 version: "1.0.0".into(),
650 dep_path: "parent@1.0.0".into(),
651 peer_dependencies_meta: BTreeMap::from([(
652 "../escape".into(),
653 PeerDepMeta { optional: true },
654 )]),
655 ..LockedPackage::default()
656 },
657 ] {
658 let mut graph = crate::LockfileGraph::default();
659 graph.packages.insert("parent@1.0.0".into(), package);
660
661 let err = validate_dependency_aliases(Path::new("pnpm-lock.yaml"), &graph)
662 .expect_err("unsafe alias must be rejected");
663 assert!(
664 err.to_string()
665 .contains("package parent@1.0.0 has unsafe dependency alias `../escape`"),
666 "unexpected error: {err}"
667 );
668 }
669 }
670
671 #[test]
672 fn accepts_valid_scoped_and_unscoped_dependency_aliases() {
673 let mut graph = crate::LockfileGraph::default();
674 graph.importers.insert(
675 ".".into(),
676 vec![
677 DirectDep {
678 name: "left-pad".into(),
679 dep_path: "left-pad@1.3.0".into(),
680 dep_type: DepType::Production,
681 specifier: Some("1.3.0".into()),
682 },
683 DirectDep {
684 name: "@scope/pkg".into(),
685 dep_path: "@scope/pkg@1.0.0".into(),
686 dep_type: DepType::Dev,
687 specifier: Some("1.0.0".into()),
688 },
689 ],
690 );
691 graph.packages.insert(
692 "parent@1.0.0".into(),
693 LockedPackage {
694 name: "parent".into(),
695 version: "1.0.0".into(),
696 dep_path: "parent@1.0.0".into(),
697 dependencies: BTreeMap::from([
698 ("left-pad".into(), "1.3.0".into()),
699 ("@scope/pkg".into(), "1.0.0".into()),
700 ]),
701 ..LockedPackage::default()
702 },
703 );
704
705 validate_dependency_aliases(Path::new("pnpm-lock.yaml"), &graph)
706 .expect("valid aliases should pass");
707 }
708
709 proptest! {
710 #[test]
711 fn dep_path_registry_version_accepts_name_at_semver(name in package_name(), version in semver()) {
712 let dep_path = format!("{name}@{version}");
713 prop_assert!(dep_path_has_registry_version(&dep_path, &name));
714 }
715
716 #[test]
717 fn dep_path_registry_version_rejects_local_source_dep_paths(
718 name in package_name(),
719 source in local_source(),
720 ) {
721 let dep_path = source.dep_path(&name);
722 prop_assert!(!dep_path_has_registry_version(&dep_path, &name));
723 }
724 }
725}
726
727fn refine_yarn_kind(path: &Path, kind: LockfileKind) -> LockfileKind {
736 if kind == LockfileKind::Yarn && yarn::is_berry_path(path) {
737 LockfileKind::YarnBerry
738 } else {
739 kind
740 }
741}
742
743#[derive(Debug, thiserror::Error, miette::Diagnostic)]
744pub enum Error {
745 #[error("no lockfile found in {0}")]
746 #[diagnostic(code(ERR_AUBE_NO_LOCKFILE))]
747 NotFound(std::path::PathBuf),
748 #[error("unsupported lockfile format: {0}")]
749 #[diagnostic(code(ERR_AUBE_LOCKFILE_UNSUPPORTED_FORMAT))]
750 UnsupportedFormat(String),
751 #[error("failed to read lockfile {0}: {1}")]
752 Io(std::path::PathBuf, std::io::Error),
753 #[error("failed to parse lockfile {0}: {1}")]
758 #[diagnostic(code(ERR_AUBE_LOCKFILE_PARSE))]
759 Parse(std::path::PathBuf, String),
760 #[error("lockfile {0} has registry-style dependency path `{1}` backed by {2} resolution")]
761 #[diagnostic(
762 code(ERR_AUBE_RESOLUTION_SHAPE_MISMATCH),
763 help(
764 "run `aube install --no-frozen-lockfile` from a trusted manifest to regenerate the lockfile"
765 )
766 )]
767 ResolutionShapeMismatch(std::path::PathBuf, String, &'static str),
768 #[error(transparent)]
774 #[diagnostic(transparent)]
775 ParseDiag(Box<aube_manifest::ParseError>),
776}
777
778pub fn read_lockfile(path: &std::path::Path) -> Result<String, Error> {
780 std::fs::read_to_string(path).map_err(|e| Error::Io(path.to_path_buf(), e))
781}
782
783pub fn parse_json<T: serde::de::DeserializeOwned>(
786 path: &std::path::Path,
787 content: String,
788) -> Result<T, Error> {
789 match sonic_rs::from_slice(content.as_bytes()) {
792 Ok(v) => Ok(v),
793 Err(_) => match serde_json::from_str(&content) {
794 Ok(v) => Ok(v),
795 Err(e) => Err(Error::parse_json_err(path, content, &e)),
796 },
797 }
798}
799
800impl Error {
801 pub fn parse(path: &std::path::Path, msg: impl Into<String>) -> Self {
802 Error::Parse(path.to_path_buf(), msg.into())
803 }
804
805 pub fn parse_json_err(
806 path: &std::path::Path,
807 content: String,
808 err: &serde_json::Error,
809 ) -> Self {
810 Error::ParseDiag(Box::new(aube_manifest::ParseError::from_json_err(
811 path, content, err,
812 )))
813 }
814
815 pub fn parse_yaml_err(
816 path: &std::path::Path,
817 content: String,
818 err: &yaml_serde::Error,
819 ) -> Self {
820 Error::ParseDiag(Box::new(aube_manifest::ParseError::from_yaml_err(
821 path, content, err,
822 )))
823 }
824}
825
826#[cfg(test)]
827mod parse_diag_tests {
828 use super::*;
829 use crate::{LocalSource, LockedPackage};
830 use std::path::Path;
831
832 #[test]
836 fn parse_json_attaches_span_for_bad_input() {
837 let path = Path::new("package-lock.json");
838 let content = r#"{"name":"x","#.to_string();
839 let Err(Error::ParseDiag(pe)) = parse_json::<serde_json::Value>(path, content.clone())
840 else {
841 panic!("parse_json must produce ParseDiag on malformed input");
842 };
843 let offset: usize = pe.span.offset();
844 let len: usize = pe.span.len();
845 assert!(offset + len <= content.len());
846 assert_eq!(pe.path, path);
847 }
848
849 #[test]
850 fn validate_resolution_shapes_rejects_local_source_with_registry_dep_path() {
851 let mut graph = LockfileGraph::default();
852 graph.packages.insert(
853 "left-pad@1.3.0".to_string(),
854 LockedPackage {
855 name: "left-pad".to_string(),
856 version: "1.3.0".to_string(),
857 dep_path: "left-pad@1.3.0".to_string(),
858 local_source: Some(LocalSource::Directory("vendor/left-pad".into())),
859 ..Default::default()
860 },
861 );
862
863 let err = validate_resolution_shapes(Path::new("pnpm-lock.yaml"), &graph).unwrap_err();
864 assert!(matches!(
865 err,
866 Error::ResolutionShapeMismatch(_, dep_path, "file")
867 if dep_path == "left-pad@1.3.0"
868 ));
869 }
870
871 #[test]
872 fn validate_resolution_shapes_rejects_peer_suffixed_registry_dep_path() {
873 let mut graph = LockfileGraph::default();
874 graph.packages.insert(
875 "plugin@1.0.0(react@19.0.0)".to_string(),
876 LockedPackage {
877 name: "plugin".to_string(),
878 version: "1.0.0".to_string(),
879 dep_path: "plugin@1.0.0(react@19.0.0)".to_string(),
880 local_source: Some(LocalSource::RemoteTarball(crate::RemoteTarballSource {
881 url: "https://example.com/plugin.tgz".to_string(),
882 integrity: "sha512-test".to_string(),
883 git_hosted: false,
884 })),
885 ..Default::default()
886 },
887 );
888
889 let err = validate_resolution_shapes(Path::new("pnpm-lock.yaml"), &graph).unwrap_err();
890 assert!(matches!(
891 err,
892 Error::ResolutionShapeMismatch(_, dep_path, "url")
893 if dep_path == "plugin@1.0.0(react@19.0.0)"
894 ));
895 }
896
897 #[test]
898 fn validate_resolution_shapes_allows_local_source_dep_path() {
899 let source = LocalSource::Directory("vendor/left-pad".into());
900 let dep_path = source.dep_path("left-pad");
901 let mut graph = LockfileGraph::default();
902 graph.packages.insert(
903 dep_path.clone(),
904 LockedPackage {
905 name: "left-pad".to_string(),
906 version: "1.3.0".to_string(),
907 dep_path,
908 local_source: Some(source),
909 ..Default::default()
910 },
911 );
912
913 validate_resolution_shapes(Path::new("pnpm-lock.yaml"), &graph).unwrap();
914 }
915
916 #[test]
923 fn parse_yaml_err_attaches_span_for_bad_input() {
924 let path = Path::new("yarn.lock");
925 let content = "packages:\n\t- pkg\n".to_string();
926 let yaml_err: yaml_serde::Error = yaml_serde::from_str::<yaml_serde::Value>(&content)
927 .expect_err("tab-indented YAML must fail");
928 let Error::ParseDiag(pe) = Error::parse_yaml_err(path, content.clone(), &yaml_err) else {
929 panic!("parse_yaml_err must produce ParseDiag");
930 };
931 let offset: usize = pe.span.offset();
932 let len: usize = pe.span.len();
933 assert!(offset + len <= content.len());
934 assert_eq!(pe.path, path);
935 }
936}
937
938#[cfg(test)]
939mod filename_tests {
940 use super::*;
941
942 #[test]
943 fn defaults_to_plain_lockfile_when_setting_absent() {
944 let dir = tempfile::tempdir().unwrap();
945 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
946 assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.yaml");
947 }
948
949 #[test]
950 fn defaults_to_plain_lockfile_when_setting_explicit_false() {
951 let dir = tempfile::tempdir().unwrap();
952 std::fs::write(
953 dir.path().join("pnpm-workspace.yaml"),
954 "gitBranchLockfile: false\n",
955 )
956 .unwrap();
957 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.yaml");
958 }
959
960 #[test]
961 fn uses_branch_filename_when_enabled_inside_git_repo() {
962 let dir = tempfile::tempdir().unwrap();
963 std::fs::write(
964 dir.path().join("pnpm-workspace.yaml"),
965 "gitBranchLockfile: true\n",
966 )
967 .unwrap();
968 let run = |args: &[&str]| {
971 std::process::Command::new("git")
972 .args(["-C"])
973 .arg(dir.path())
974 .args(args)
975 .output()
976 .unwrap()
977 };
978 if run(&["init", "-q"]).status.success() {
979 run(&["checkout", "-q", "-b", "feature/x"]);
980 assert_eq!(aube_lock_filename(dir.path()), "aube-lock.feature!x.yaml");
981 assert_eq!(pnpm_lock_filename(dir.path()), "pnpm-lock.feature!x.yaml");
982 }
983 }
984}