use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use cabin_core::PackageName;
use cabin_fs::write_atomic;
use serde::Deserialize;
use crate::error::LockfileError;
use crate::model::{
LockedPackage, LockedPatch, LockedPatchKind, LockedSource, LockedSourceLocatorKind,
LockedSourceReplacement, Lockfile,
};
use crate::validate::validate;
pub fn read_lockfile(path: impl AsRef<Path>) -> Result<Lockfile, LockfileError> {
let path = path.as_ref();
let body = std::fs::read_to_string(path).map_err(|source| LockfileError::Io {
path: path.to_path_buf(),
source,
})?;
parse_lockfile_str(&body)
}
pub fn parse_lockfile_str(input: &str) -> Result<Lockfile, LockfileError> {
let raw: RawLockfile = toml::from_str(input)?;
let lockfile = lockfile_from_raw(raw)?;
validate(&lockfile)?;
Ok(lockfile)
}
pub fn write_lockfile(path: impl AsRef<Path>, lockfile: &Lockfile) -> Result<(), LockfileError> {
let path = path.as_ref();
let body = render_lockfile(lockfile)?;
write_atomic(path, &body).map_err(|source| LockfileError::Io {
path: path.to_path_buf(),
source,
})
}
pub fn render_lockfile(lockfile: &Lockfile) -> Result<String, LockfileError> {
validate(lockfile)?;
let mut out = String::new();
out.push_str("# This file is automatically generated by Cabin.\n");
out.push_str("# Do not edit it manually.\n\n");
writeln!(out, "version = {}", lockfile.version)?;
out.push('\n');
let mut packages: Vec<&LockedPackage> = lockfile.packages.iter().collect();
packages.sort_by(|a, b| {
a.name
.as_str()
.cmp(b.name.as_str())
.then_with(|| a.version.cmp(&b.version))
});
for pkg in packages {
out.push_str("[[package]]\n");
writeln!(out, "name = {}", quote_string(pkg.name.as_str()))?;
writeln!(out, "version = {}", quote_string(&pkg.version.to_string()))?;
writeln!(out, "source = {}", quote_string(pkg.source.as_str()))?;
if let Some(checksum) = &pkg.checksum {
writeln!(out, "checksum = {}", quote_string(checksum))?;
}
if !pkg.dependencies.is_empty() {
let mut names: Vec<&str> = pkg.dependencies.iter().map(PackageName::as_str).collect();
names.sort_unstable();
out.push_str("dependencies = [");
for (i, n) in names.iter().enumerate() {
if i > 0 {
out.push_str(", ");
}
out.push_str("e_string(n));
}
out.push_str("]\n");
}
out.push('\n');
}
let mut patches: Vec<&LockedPatch> = lockfile.patches.iter().collect();
patches.sort_by(|a, b| {
a.package
.as_str()
.cmp(b.package.as_str())
.then_with(|| a.version.cmp(&b.version))
});
for patch in patches {
out.push_str("[[patch]]\n");
writeln!(out, "package = {}", quote_string(patch.package.as_str()))?;
writeln!(
out,
"version = {}",
quote_string(&patch.version.to_string())
)?;
writeln!(out, "kind = {}", quote_string(patch.kind.as_str()))?;
writeln!(out, "provenance = {}", quote_string(&patch.provenance))?;
writeln!(
out,
"path = {}",
quote_string(&patch.path.display().to_string())
)?;
out.push('\n');
}
let mut replacements: Vec<&LockedSourceReplacement> =
lockfile.source_replacements.iter().collect();
replacements.sort_by(|a, b| a.original.cmp(&b.original));
for replacement in replacements {
out.push_str("[[source-replacement]]\n");
writeln!(out, "original = {}", quote_string(&replacement.original))?;
writeln!(
out,
"original-kind = {}",
quote_string(replacement.original_kind.as_str())
)?;
writeln!(
out,
"replacement = {}",
quote_string(&replacement.replacement)
)?;
writeln!(
out,
"replacement-kind = {}",
quote_string(replacement.replacement_kind.as_str())
)?;
writeln!(
out,
"provenance = {}",
quote_string(&replacement.provenance)
)?;
out.push('\n');
}
Ok(out)
}
fn quote_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 || c == '\u{7f}' => {
let _ = write!(out, "\\u{:04X}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
fn lockfile_from_raw(raw: RawLockfile) -> Result<Lockfile, LockfileError> {
let RawLockfile {
version,
packages,
patches,
source_replacements,
} = raw;
let mut models: Vec<LockedPackage> = Vec::with_capacity(packages.len());
for raw_pkg in packages {
models.push(package_from_raw(raw_pkg)?);
}
let mut patch_models: Vec<LockedPatch> = Vec::with_capacity(patches.len());
for raw_patch in patches {
patch_models.push(patch_from_raw(raw_patch)?);
}
let mut replacement_models: Vec<LockedSourceReplacement> =
Vec::with_capacity(source_replacements.len());
for raw_replacement in source_replacements {
replacement_models.push(source_replacement_from_raw(raw_replacement)?);
}
Ok(Lockfile {
version,
packages: models,
patches: patch_models,
source_replacements: replacement_models,
})
}
fn patch_from_raw(raw: RawPatch) -> Result<LockedPatch, LockfileError> {
let RawPatch {
package,
version,
kind,
provenance,
path,
} = raw;
let package_name =
PackageName::new(package.clone()).map_err(|err| LockfileError::InvalidPackageName {
name: package.clone(),
message: err.to_string(),
})?;
let parsed_version =
semver::Version::parse(&version).map_err(|source| LockfileError::InvalidVersion {
name: package.clone(),
value: version,
source,
})?;
let kind_value = match kind.as_str() {
"path" => LockedPatchKind::Path,
other => {
return Err(LockfileError::UnknownPatchKind {
package,
value: other.to_owned(),
});
}
};
Ok(LockedPatch {
package: package_name,
version: parsed_version,
kind: kind_value,
provenance,
path: PathBuf::from(path),
})
}
fn source_replacement_from_raw(
raw: RawSourceReplacement,
) -> Result<LockedSourceReplacement, LockfileError> {
let RawSourceReplacement {
original,
original_kind,
replacement,
replacement_kind,
provenance,
} = raw;
let original_kind_value = locator_kind_from_str(&original_kind)?;
let replacement_kind_value = locator_kind_from_str(&replacement_kind)?;
Ok(LockedSourceReplacement {
original,
original_kind: original_kind_value,
replacement,
replacement_kind: replacement_kind_value,
provenance,
})
}
fn locator_kind_from_str(value: &str) -> Result<LockedSourceLocatorKind, LockfileError> {
match value {
"index-path" => Ok(LockedSourceLocatorKind::IndexPath),
"index-url" => Ok(LockedSourceLocatorKind::IndexUrl),
other => Err(LockfileError::UnknownSourceLocatorKind {
value: other.to_owned(),
}),
}
}
fn package_from_raw(raw: RawPackage) -> Result<LockedPackage, LockfileError> {
let RawPackage {
name,
version,
source,
checksum,
dependencies,
} = raw;
let package_name =
PackageName::new(name.clone()).map_err(|err| LockfileError::InvalidPackageName {
name: name.clone(),
message: err.to_string(),
})?;
let parsed_version =
semver::Version::parse(&version).map_err(|source| LockfileError::InvalidVersion {
name: name.clone(),
value: version,
source,
})?;
let source_kind = match source.as_str() {
"index" => LockedSource::Index,
other => {
return Err(LockfileError::UnknownSource {
name,
value: other.to_owned(),
});
}
};
let mut deps: Vec<PackageName> = Vec::with_capacity(dependencies.len());
for d in dependencies {
deps.push(PackageName::new(d.clone()).map_err(|err| {
LockfileError::InvalidPackageName {
name: d,
message: err.to_string(),
}
})?);
}
Ok(LockedPackage {
name: package_name,
version: parsed_version,
source: source_kind,
checksum,
dependencies: deps,
})
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawLockfile {
version: u32,
#[serde(default, rename = "package")]
packages: Vec<RawPackage>,
#[serde(default, rename = "patch")]
patches: Vec<RawPatch>,
#[serde(default, rename = "source-replacement")]
source_replacements: Vec<RawSourceReplacement>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPackage {
name: String,
version: String,
source: String,
#[serde(default)]
checksum: Option<String>,
#[serde(default)]
dependencies: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPatch {
package: String,
version: String,
kind: String,
provenance: String,
path: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawSourceReplacement {
original: String,
#[serde(rename = "original-kind")]
original_kind: String,
replacement: String,
#[serde(rename = "replacement-kind")]
replacement_kind: String,
provenance: String,
}
#[cfg(test)]
mod tests {
use super::*;
fn pkg(name: &str) -> PackageName {
PackageName::new(name).unwrap()
}
fn ver(s: &str) -> semver::Version {
semver::Version::parse(s).unwrap()
}
#[test]
fn parses_single_package_lockfile() {
let body = r#"
version = 1
[[package]]
name = "fmt"
version = "10.2.1"
source = "index"
checksum = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
"#;
let lock = parse_lockfile_str(body).unwrap();
assert_eq!(lock.version, 1);
assert_eq!(lock.packages.len(), 1);
let p = &lock.packages[0];
assert_eq!(p.name.as_str(), "fmt");
assert_eq!(p.version, ver("10.2.1"));
assert_eq!(p.source, LockedSource::Index);
assert_eq!(
p.checksum.as_deref(),
Some("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
);
assert!(p.dependencies.is_empty());
}
#[test]
fn parses_multi_package_lockfile_with_deps() {
let body = r#"
version = 1
[[package]]
name = "fmt"
version = "10.2.1"
source = "index"
[[package]]
name = "spdlog"
version = "1.13.0"
source = "index"
dependencies = ["fmt"]
"#;
let lock = parse_lockfile_str(body).unwrap();
assert_eq!(lock.packages.len(), 2);
let spdlog = lock.find(&pkg("spdlog")).unwrap();
assert_eq!(spdlog.dependencies, vec![pkg("fmt")]);
}
#[test]
fn unsupported_version_errors() {
let body = r#"
version = 2
[[package]]
name = "fmt"
version = "10.2.1"
source = "index"
"#;
let err = parse_lockfile_str(body).unwrap_err();
assert!(matches!(
err,
LockfileError::UnsupportedVersion { version: 2, .. }
));
}
#[test]
fn duplicate_package_errors() {
let body = r#"
version = 1
[[package]]
name = "fmt"
version = "10.2.1"
source = "index"
[[package]]
name = "fmt"
version = "10.1.0"
source = "index"
"#;
let err = parse_lockfile_str(body).unwrap_err();
assert!(matches!(err, LockfileError::DuplicatePackage { name } if name == "fmt"));
}
#[test]
fn missing_version_errors() {
let body = r#"
version = 1
[[package]]
name = "fmt"
source = "index"
"#;
let err = parse_lockfile_str(body).unwrap_err();
assert!(matches!(err, LockfileError::Toml(_)));
}
#[test]
fn invalid_semver_errors() {
let body = r#"
version = 1
[[package]]
name = "fmt"
version = "abc"
source = "index"
"#;
let err = parse_lockfile_str(body).unwrap_err();
assert!(matches!(err, LockfileError::InvalidVersion { name, .. } if name == "fmt"));
}
#[test]
fn unknown_field_errors() {
let body = r#"
version = 1
[[package]]
name = "fmt"
version = "10.2.1"
source = "index"
extras = "nope"
"#;
let err = parse_lockfile_str(body).unwrap_err();
assert!(matches!(err, LockfileError::Toml(_)));
}
#[test]
fn unknown_source_errors() {
let body = r#"
version = 1
[[package]]
name = "fmt"
version = "10.2.1"
source = "git"
"#;
let err = parse_lockfile_str(body).unwrap_err();
assert!(
matches!(err, LockfileError::UnknownSource { name, value } if name == "fmt" && value == "git")
);
}
fn sample_lockfile() -> Lockfile {
Lockfile {
version: 1,
packages: vec![
LockedPackage {
name: pkg("spdlog"),
version: ver("1.13.0"),
source: LockedSource::Index,
checksum: Some("sha256:zzz".into()),
dependencies: vec![pkg("fmt")],
},
LockedPackage {
name: pkg("fmt"),
version: ver("10.2.1"),
source: LockedSource::Index,
checksum: Some("sha256:xxx".into()),
dependencies: Vec::new(),
},
],
patches: Vec::new(),
source_replacements: Vec::new(),
}
}
#[test]
fn render_sorts_packages_by_name() {
let body = render_lockfile(&sample_lockfile()).unwrap();
let fmt_pos = body.find("name = \"fmt\"").unwrap();
let spdlog_pos = body.find("name = \"spdlog\"").unwrap();
assert!(fmt_pos < spdlog_pos, "got: {body}");
}
#[test]
fn render_is_deterministic() {
let lock = sample_lockfile();
let a = render_lockfile(&lock).unwrap();
let b = render_lockfile(&lock).unwrap();
assert_eq!(a, b);
}
#[test]
fn render_includes_header_comment_and_version() {
let body = render_lockfile(&sample_lockfile()).unwrap();
assert!(body.starts_with("# This file is automatically generated by Cabin."));
assert!(body.contains("version = 1"));
}
#[test]
fn round_trip_parse_render_parse() {
let lock = sample_lockfile();
let body = render_lockfile(&lock).unwrap();
let parsed = parse_lockfile_str(&body).unwrap();
let mut expected = lock;
expected
.packages
.sort_by(|a, b| a.name.as_str().cmp(b.name.as_str()));
assert_eq!(parsed, expected);
}
#[test]
fn old_lockfile_without_patch_arrays_remains_valid() {
let body = r#"
version = 1
[[package]]
name = "fmt"
version = "10.2.1"
source = "index"
"#;
let parsed = parse_lockfile_str(body).unwrap();
assert!(parsed.patches.is_empty());
assert!(parsed.source_replacements.is_empty());
}
#[test]
fn render_emits_sorted_patch_array_then_source_replacement_array() {
let lock = Lockfile {
version: 1,
packages: Vec::new(),
patches: vec![
LockedPatch {
package: pkg("spdlog"),
version: ver("1.13.0"),
kind: LockedPatchKind::Path,
provenance: "manifest".into(),
path: PathBuf::from("../spdlog"),
},
LockedPatch {
package: pkg("fmt"),
version: ver("10.2.1"),
kind: LockedPatchKind::Path,
provenance: "workspace-config".into(),
path: PathBuf::from("../fmt"),
},
],
source_replacements: vec![LockedSourceReplacement {
original: "https://example.com/index".into(),
original_kind: LockedSourceLocatorKind::IndexUrl,
replacement: "../mirror".into(),
replacement_kind: LockedSourceLocatorKind::IndexPath,
provenance: "user-config".into(),
}],
};
let body = render_lockfile(&lock).unwrap();
let fmt_pos = body.find("package = \"fmt\"").unwrap();
let spdlog_pos = body.find("package = \"spdlog\"").unwrap();
assert!(fmt_pos < spdlog_pos, "patches must sort by package name");
assert!(body.contains("[[source-replacement]]"));
let parsed = parse_lockfile_str(&body).unwrap();
assert_eq!(parsed.patches.len(), 2);
assert_eq!(parsed.patches[0].package.as_str(), "fmt");
assert_eq!(parsed.source_replacements.len(), 1);
}
#[test]
fn render_matches_golden_for_full_lockfile() {
let lock = Lockfile {
version: 1,
packages: vec![
LockedPackage {
name: pkg("spdlog"),
version: ver("1.13.0"),
source: LockedSource::Index,
checksum: Some("sha256:zzz".into()),
dependencies: vec![pkg("fmt")],
},
LockedPackage {
name: pkg("fmt"),
version: ver("10.2.1"),
source: LockedSource::Index,
checksum: Some("sha256:xxx".into()),
dependencies: Vec::new(),
},
],
patches: vec![
LockedPatch {
package: pkg("spdlog"),
version: ver("1.13.0"),
kind: LockedPatchKind::Path,
provenance: "manifest".into(),
path: PathBuf::from("../spdlog"),
},
LockedPatch {
package: pkg("fmt"),
version: ver("10.2.1"),
kind: LockedPatchKind::Path,
provenance: "workspace-config".into(),
path: PathBuf::from("../fmt"),
},
],
source_replacements: vec![
LockedSourceReplacement {
original: "https://example.com/index".into(),
original_kind: LockedSourceLocatorKind::IndexUrl,
replacement: "../mirror".into(),
replacement_kind: LockedSourceLocatorKind::IndexPath,
provenance: "user-config".into(),
},
LockedSourceReplacement {
original: "../primary".into(),
original_kind: LockedSourceLocatorKind::IndexPath,
replacement: "https://example.org/mirror".into(),
replacement_kind: LockedSourceLocatorKind::IndexUrl,
provenance: "workspace-config".into(),
},
],
};
let expected = "# This file is automatically generated by Cabin.\n\
# Do not edit it manually.\n\
\n\
version = 1\n\
\n\
[[package]]\n\
name = \"fmt\"\n\
version = \"10.2.1\"\n\
source = \"index\"\n\
checksum = \"sha256:xxx\"\n\
\n\
[[package]]\n\
name = \"spdlog\"\n\
version = \"1.13.0\"\n\
source = \"index\"\n\
checksum = \"sha256:zzz\"\n\
dependencies = [\"fmt\"]\n\
\n\
[[patch]]\n\
package = \"fmt\"\n\
version = \"10.2.1\"\n\
kind = \"path\"\n\
provenance = \"workspace-config\"\n\
path = \"../fmt\"\n\
\n\
[[patch]]\n\
package = \"spdlog\"\n\
version = \"1.13.0\"\n\
kind = \"path\"\n\
provenance = \"manifest\"\n\
path = \"../spdlog\"\n\
\n\
[[source-replacement]]\n\
original = \"../primary\"\n\
original-kind = \"index-path\"\n\
replacement = \"https://example.org/mirror\"\n\
replacement-kind = \"index-url\"\n\
provenance = \"workspace-config\"\n\
\n\
[[source-replacement]]\n\
original = \"https://example.com/index\"\n\
original-kind = \"index-url\"\n\
replacement = \"../mirror\"\n\
replacement-kind = \"index-path\"\n\
provenance = \"user-config\"\n\
\n";
let body = render_lockfile(&lock).unwrap();
assert_eq!(body, expected);
}
#[test]
fn unknown_patch_kind_in_lockfile_yields_clear_error() {
let body = r#"
version = 1
[[patch]]
package = "fmt"
version = "10.2.1"
kind = "git"
provenance = "manifest"
path = "../fmt"
"#;
let err = parse_lockfile_str(body).unwrap_err();
match err {
LockfileError::UnknownPatchKind { value, .. } => assert_eq!(value, "git"),
other => panic!("expected UnknownPatchKind, got {other:?}"),
}
}
fn deterministic_sample() -> Lockfile {
Lockfile {
version: 1,
packages: vec![LockedPackage {
name: pkg("fmt"),
version: ver("10.2.1"),
source: LockedSource::Index,
checksum: Some("sha256:xxx".into()),
dependencies: Vec::new(),
}],
patches: Vec::new(),
source_replacements: Vec::new(),
}
}
#[test]
fn write_lockfile_creates_file_with_rendered_body() {
let dir = assert_fs::TempDir::new().unwrap();
let path = dir.path().join("cabin.lock");
let lock = deterministic_sample();
write_lockfile(&path, &lock).unwrap();
let body = std::fs::read_to_string(&path).unwrap();
assert_eq!(body, render_lockfile(&lock).unwrap());
}
#[test]
fn write_lockfile_replaces_existing_contents() {
let dir = assert_fs::TempDir::new().unwrap();
let path = dir.path().join("cabin.lock");
std::fs::write(&path, "stale\n").unwrap();
let lock = deterministic_sample();
write_lockfile(&path, &lock).unwrap();
let body = std::fs::read_to_string(&path).unwrap();
assert_eq!(body, render_lockfile(&lock).unwrap());
}
#[test]
fn write_lockfile_reports_destination_when_parent_directory_missing() {
let dir = assert_fs::TempDir::new().unwrap();
let missing_parent = dir.path().join("nonexistent").join("cabin.lock");
let lock = deterministic_sample();
let err = write_lockfile(&missing_parent, &lock).unwrap_err();
match err {
LockfileError::Io { path, .. } => assert_eq!(path, missing_parent),
other => panic!("expected LockfileError::Io, got {other:?}"),
}
}
#[test]
fn unknown_source_locator_kind_in_lockfile_yields_clear_error() {
let body = r#"
version = 1
[[source-replacement]]
original = "https://example.com/index"
original-kind = "index-url"
replacement = "../mirror"
replacement-kind = "git"
provenance = "user-config"
"#;
let err = parse_lockfile_str(body).unwrap_err();
match err {
LockfileError::UnknownSourceLocatorKind { value } => assert_eq!(value, "git"),
other => panic!("expected UnknownSourceLocatorKind, got {other:?}"),
}
}
}