use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::package_manifest::{PACKAGE_MANIFEST_FILE, PackageDependency, PackageManifest};
pub const PACKAGE_LOCK_FILE: &str = "inauguration.lock";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackageLock {
pub lock_version: String,
pub name: String,
pub version: String,
pub dependencies: BTreeMap<String, PackageDependency>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackageLockValidation {
pub valid: bool,
pub missing: Vec<String>,
pub extra: Vec<String>,
pub mismatched: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackageLockRoot {
pub root: PathBuf,
pub lock_path: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LockSection {
Dependencies,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LockDependencySubsection {
Targets,
Capabilities,
Build,
}
pub fn load_package_lock(path: &Path) -> Result<PackageLock, String> {
let lock_path = if path.is_dir() {
path.join(PACKAGE_LOCK_FILE)
} else {
path.to_path_buf()
};
let source = fs::read_to_string(&lock_path)
.map_err(|err| format!("failed to read {}: {err}", lock_path.display()))?;
parse_package_lock(&source)
}
pub fn discover_package_lock(path: &Path) -> Option<PackageLockRoot> {
let mut current = if path
.file_name()
.is_some_and(|name| name == PACKAGE_LOCK_FILE)
{
path.parent()
} else if path.is_dir() {
Some(path)
} else {
path.parent()
};
while let Some(dir) = current {
let lock_path = dir.join(PACKAGE_LOCK_FILE);
if lock_path.is_file() {
return Some(PackageLockRoot {
root: dir.to_path_buf(),
lock_path,
});
}
current = dir.parent();
}
None
}
pub fn load_package_lock_from_source(
source_path: &Path,
) -> Result<(PackageLockRoot, PackageLock), String> {
let root = discover_package_lock(source_path).ok_or_else(|| {
format!(
"could not find {PACKAGE_LOCK_FILE} for {}",
source_path.display()
)
})?;
let lock = load_package_lock(&root.lock_path)?;
Ok((root, lock))
}
pub fn resolve_package_lock(manifest: &PackageManifest) -> PackageLock {
PackageLock {
lock_version: "1".to_string(),
name: manifest.name.clone(),
version: manifest.version.clone(),
dependencies: manifest.dependencies.clone(),
}
}
pub fn format_package_lock(lock: &PackageLock) -> String {
let mut lines = vec![
format!("lock-version: {}", lock.lock_version),
format!("name: {}", lock.name),
format!("version: {}", lock.version),
];
if !lock.dependencies.is_empty() {
lines.push("dependencies:".to_string());
for (name, dependency) in &lock.dependencies {
lines.push(format!(" {name}:"));
lines.push(format!(" version: {}", dependency.version));
if let Some(kind) = &dependency.kind {
lines.push(format!(" kind: {kind}"));
}
if let Some(source) = &dependency.source {
lines.push(format!(" source: {source}"));
}
if let Some(rev) = &dependency.rev {
lines.push(format!(" rev: {rev}"));
}
if let Some(checksum) = &dependency.checksum {
lines.push(format!(" checksum: {checksum}"));
}
if let Some(install_path) = &dependency.install_path {
lines.push(format!(" install_path: {install_path}"));
}
if !dependency.targets.is_empty() {
lines.push(" targets:".to_string());
for target in &dependency.targets {
lines.push(format!(" - {target}"));
}
}
if !dependency.capabilities.is_empty() {
lines.push(" capabilities:".to_string());
for capability in &dependency.capabilities {
lines.push(format!(" - {capability}"));
}
}
if !dependency.build.is_empty() {
lines.push(" build:".to_string());
for (key, value) in &dependency.build {
lines.push(format!(" {key}: {value}"));
}
}
}
}
lines.push(String::new());
lines.join("\n")
}
pub fn write_package_lock(path: &Path, lock: &PackageLock) -> Result<(), String> {
let lock_path = if path.is_dir() {
path.join(PACKAGE_LOCK_FILE)
} else {
path.to_path_buf()
};
fs::write(&lock_path, format_package_lock(lock))
.map_err(|err| format!("failed to write {}: {err}", lock_path.display()))
}
pub fn validate_package_lock(
manifest: &PackageManifest,
lock: &PackageLock,
) -> PackageLockValidation {
let mut missing = Vec::new();
let mut mismatched = Vec::new();
for (name, dependency) in &manifest.dependencies {
match lock.dependencies.get(name) {
None => missing.push(name.clone()),
Some(locked) if !dependency_matches(dependency, locked) => {
mismatched.push(name.clone());
}
Some(_) => {}
}
}
let extra = lock
.dependencies
.keys()
.filter(|name| !manifest.dependencies.contains_key(*name))
.cloned()
.collect::<Vec<_>>();
PackageLockValidation {
valid: missing.is_empty() && mismatched.is_empty() && extra.is_empty(),
missing,
extra,
mismatched,
}
}
pub fn load_manifest_and_lock(
path: &Path,
) -> Result<(PackageManifest, Option<PackageLock>, PackageLockValidation), String> {
let manifest_path = if path.is_dir() {
path.join(PACKAGE_MANIFEST_FILE)
} else if path
.file_name()
.is_some_and(|name| name == PACKAGE_MANIFEST_FILE)
{
path.to_path_buf()
} else {
return Err(format!(
"expected directory, {PACKAGE_MANIFEST_FILE}, or {PACKAGE_LOCK_FILE}"
));
};
let manifest = crate::package_manifest::load_package_manifest(&manifest_path)?;
let lock = discover_package_lock(&manifest_path)
.and_then(|root| load_package_lock(&root.lock_path).ok());
let validation = lock
.as_ref()
.map(|entry| validate_package_lock(&manifest, entry))
.unwrap_or(PackageLockValidation {
valid: manifest.dependencies.is_empty(),
missing: manifest.dependencies.keys().cloned().collect(),
extra: Vec::new(),
mismatched: Vec::new(),
});
Ok((manifest, lock, validation))
}
fn dependency_matches(manifest: &PackageDependency, lock: &PackageDependency) -> bool {
if manifest.version != lock.version {
return false;
}
if manifest.kind != lock.kind {
return false;
}
if manifest.source != lock.source {
return false;
}
if manifest.rev != lock.rev {
return false;
}
if manifest.checksum.is_some() && manifest.checksum != lock.checksum {
return false;
}
if !manifest.targets.is_empty() && manifest.targets != lock.targets {
return false;
}
if !manifest.capabilities.is_empty() && manifest.capabilities != lock.capabilities {
return false;
}
if !manifest.build.is_empty() && manifest.build != lock.build {
return false;
}
true
}
pub fn parse_package_lock(source: &str) -> Result<PackageLock, String> {
let mut lock = PackageLock {
lock_version: String::new(),
name: String::new(),
version: String::new(),
dependencies: BTreeMap::new(),
};
let mut section = None;
let mut dependency_name: Option<String> = None;
let mut dependency_subsection: Option<LockDependencySubsection> = None;
for (index, raw_line) in source.lines().enumerate() {
let line_number = index + 1;
if raw_line.trim().is_empty() {
continue;
}
if raw_line.contains('\t') {
return Err(format!(
"line {line_number}: tabs are not valid indentation in inauguration.lock"
));
}
let indent = raw_line.len() - raw_line.trim_start_matches(' ').len();
let line = raw_line.trim_start_matches(' ');
match indent {
0 => {
dependency_name = None;
dependency_subsection = None;
section = parse_lock_top_level(line, line_number, &mut lock)?;
}
2 => match section {
Some(LockSection::Dependencies) => {
dependency_subsection = None;
dependency_name =
Some(parse_lock_dependency_header(line, line_number, &mut lock)?);
}
None => {
return Err(format!(
"line {line_number}: indentation is only valid inside a section"
));
}
},
4 => {
if section != Some(LockSection::Dependencies) {
return Err(format!(
"line {line_number}: indentation is only valid for dependency metadata"
));
}
let name = dependency_name.as_deref().ok_or_else(|| {
format!("line {line_number}: dependency metadata requires a dependency name")
})?;
dependency_subsection =
parse_lock_dependency_field(line, line_number, name, &mut lock)?;
}
6 => {
if section != Some(LockSection::Dependencies) {
return Err(format!(
"line {line_number}: indentation is only valid for dependency metadata"
));
}
let name = dependency_name.as_deref().ok_or_else(|| {
format!("line {line_number}: dependency metadata requires a dependency name")
})?;
parse_lock_dependency_nested_field(
line,
line_number,
name,
dependency_subsection,
&mut lock,
)?;
}
_ => {
return Err(format!(
"line {line_number}: malformed indentation; use 0, 2, 4, or 6 spaces"
));
}
}
}
if lock.lock_version.is_empty() {
return Err("missing required field `lock-version`".into());
}
if lock.name.is_empty() {
return Err("missing required field `name`".into());
}
if lock.version.is_empty() {
return Err("missing required field `version`".into());
}
for (name, dependency) in &lock.dependencies {
if dependency.version.is_empty() {
return Err(format!(
"dependency `{name}` is missing required field `version`"
));
}
}
Ok(lock)
}
fn parse_lock_top_level(
line: &str,
line_number: usize,
lock: &mut PackageLock,
) -> Result<Option<LockSection>, String> {
let (key, value) = split_lock_field(line, line_number)?;
match key {
"lock-version" => {
lock.lock_version =
required_lock_scalar(value, line_number, "lock-version")?.to_string();
Ok(None)
}
"name" => {
lock.name = required_lock_scalar(value, line_number, "name")?.to_string();
Ok(None)
}
"version" => {
lock.version = required_lock_scalar(value, line_number, "version")?.to_string();
Ok(None)
}
"dependencies" => parse_lock_section_header(
value,
line_number,
"dependencies",
LockSection::Dependencies,
),
other => Err(format!(
"line {line_number}: unknown top-level field `{other}`"
)),
}
}
fn parse_lock_section_header(
value: &str,
line_number: usize,
name: &str,
section: LockSection,
) -> Result<Option<LockSection>, String> {
if value.is_empty() {
Ok(Some(section))
} else {
Err(format!(
"line {line_number}: section `{name}` must not have an inline value"
))
}
}
fn parse_lock_dependency_header(
line: &str,
line_number: usize,
lock: &mut PackageLock,
) -> Result<String, String> {
let key = crate::package_ref::split_dependency_header(line, line_number)?;
if lock
.dependencies
.insert(key.clone(), PackageDependency::default())
.is_some()
{
return Err(format!("line {line_number}: duplicate dependency `{key}`"));
}
Ok(key)
}
fn parse_lock_dependency_field(
line: &str,
line_number: usize,
dependency_name: &str,
lock: &mut PackageLock,
) -> Result<Option<LockDependencySubsection>, String> {
let (key, value) = split_lock_field(line, line_number)?;
let dependency = lock
.dependencies
.get_mut(dependency_name)
.ok_or_else(|| format!("line {line_number}: unknown dependency `{dependency_name}`"))?;
match key {
"version" => {
let version = required_lock_scalar(value, line_number, "version")?;
if !dependency.version.is_empty() {
return Err(format!(
"line {line_number}: duplicate version for dependency `{dependency_name}`"
));
}
dependency.version = version.to_string();
Ok(None)
}
"kind" => assign_optional_dependency_field(
dependency,
line_number,
dependency_name,
"kind",
required_lock_scalar(value, line_number, "kind")?,
|dependency, value| dependency.kind = Some(value.to_string()),
),
"source" => assign_optional_dependency_field(
dependency,
line_number,
dependency_name,
"source",
required_lock_scalar(value, line_number, "source")?,
|dependency, value| dependency.source = Some(value.to_string()),
),
"rev" => assign_optional_dependency_field(
dependency,
line_number,
dependency_name,
"rev",
required_lock_scalar(value, line_number, "rev")?,
|dependency, value| dependency.rev = Some(value.to_string()),
),
"checksum" => assign_optional_dependency_field(
dependency,
line_number,
dependency_name,
"checksum",
required_lock_scalar(value, line_number, "checksum")?,
|dependency, value| dependency.checksum = Some(value.to_string()),
),
"install_path" => assign_optional_dependency_field(
dependency,
line_number,
dependency_name,
"install_path",
required_lock_scalar(value, line_number, "install_path")?,
|dependency, value| dependency.install_path = Some(value.to_string()),
),
"targets" => parse_lock_dependency_subsection_header(
value,
line_number,
"targets",
LockDependencySubsection::Targets,
),
"capabilities" => parse_lock_dependency_subsection_header(
value,
line_number,
"capabilities",
LockDependencySubsection::Capabilities,
),
"build" => parse_lock_dependency_subsection_header(
value,
line_number,
"build",
LockDependencySubsection::Build,
),
other => Err(format!(
"line {line_number}: unknown dependency field `{other}` for `{dependency_name}`"
)),
}
}
fn assign_optional_dependency_field(
dependency: &mut PackageDependency,
line_number: usize,
dependency_name: &str,
field: &str,
value: &str,
assign: impl FnOnce(&mut PackageDependency, &str),
) -> Result<Option<LockDependencySubsection>, String> {
match field {
"kind" if dependency.kind.is_some() => {
return Err(format!(
"line {line_number}: duplicate kind for dependency `{dependency_name}`"
));
}
"source" if dependency.source.is_some() => {
return Err(format!(
"line {line_number}: duplicate source for dependency `{dependency_name}`"
));
}
"rev" if dependency.rev.is_some() => {
return Err(format!(
"line {line_number}: duplicate rev for dependency `{dependency_name}`"
));
}
"checksum" if dependency.checksum.is_some() => {
return Err(format!(
"line {line_number}: duplicate checksum for dependency `{dependency_name}`"
));
}
"install_path" if dependency.install_path.is_some() => {
return Err(format!(
"line {line_number}: duplicate install_path for dependency `{dependency_name}`"
));
}
_ => {}
}
assign(dependency, value);
Ok(None)
}
fn parse_lock_dependency_subsection_header(
value: &str,
line_number: usize,
name: &str,
subsection: LockDependencySubsection,
) -> Result<Option<LockDependencySubsection>, String> {
if value.is_empty() {
Ok(Some(subsection))
} else {
Err(format!(
"line {line_number}: dependency subsection `{name}` must not have an inline value"
))
}
}
fn parse_lock_dependency_nested_field(
line: &str,
line_number: usize,
dependency_name: &str,
subsection: Option<LockDependencySubsection>,
lock: &mut PackageLock,
) -> Result<(), String> {
let subsection = subsection.ok_or_else(|| {
format!("line {line_number}: nested dependency metadata requires a subsection")
})?;
let dependency = lock
.dependencies
.get_mut(dependency_name)
.ok_or_else(|| format!("line {line_number}: unknown dependency `{dependency_name}`"))?;
match subsection {
LockDependencySubsection::Targets => parse_lock_list_item(
line,
line_number,
"dependency targets",
&mut dependency.targets,
),
LockDependencySubsection::Capabilities => parse_lock_list_item(
line,
line_number,
"dependency capabilities",
&mut dependency.capabilities,
),
LockDependencySubsection::Build => {
let (key, value) = split_lock_field(line, line_number)?;
let value = required_lock_scalar(value, line_number, key)?;
if dependency
.build
.insert(key.to_string(), value.to_string())
.is_some()
{
return Err(format!(
"line {line_number}: duplicate build field `{key}` for dependency `{dependency_name}`"
));
}
Ok(())
}
}
}
fn parse_lock_list_item(
line: &str,
line_number: usize,
section: &str,
values: &mut Vec<String>,
) -> Result<(), String> {
let Some(value) = line.strip_prefix("- ") else {
return Err(format!(
"line {line_number}: section `{section}` only supports list items"
));
};
let value = value.trim();
if value.is_empty() {
return Err(format!(
"line {line_number}: section `{section}` contains an empty list item"
));
}
values.push(value.to_string());
Ok(())
}
fn split_lock_field(line: &str, line_number: usize) -> Result<(&str, &str), String> {
let Some((key, value)) = line.split_once(':') else {
return Err(format!("line {line_number}: expected `key: value`"));
};
let key = key.trim();
if key.is_empty() {
return Err(format!("line {line_number}: empty key"));
}
Ok((key, value.trim()))
}
fn required_lock_scalar<'a>(
value: &'a str,
line_number: usize,
field: &str,
) -> Result<&'a str, String> {
if value.is_empty() {
Err(format!(
"line {line_number}: field `{field}` requires a value"
))
} else {
Ok(value)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static TEMP_COUNTER: AtomicUsize = AtomicUsize::new(0);
struct TempDirGuard {
path: PathBuf,
}
impl TempDirGuard {
fn new() -> Self {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before UNIX_EPOCH")
.as_nanos();
let path = std::env::temp_dir().join(format!(
"inauguration-package-lock-{}-{}-{}",
std::process::id(),
unique,
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&path).expect("create temp dir");
Self { path }
}
}
impl Drop for TempDirGuard {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
fn parse_text(source: &str) -> Result<PackageLock, String> {
parse_package_lock(source)
}
#[test]
fn resolves_lock_from_manifest() {
let manifest = crate::package_manifest::load_package_manifest_from_source(Path::new("."))
.ok()
.map(|(_, manifest)| manifest);
let manifest = manifest.unwrap_or_else(|| PackageManifest {
name: "hyperchat".into(),
version: "0.1.0".into(),
entry: None,
targets: BTreeMap::new(),
dependencies: BTreeMap::from([(
"postgres".into(),
PackageDependency {
version: "^1.0.0".into(),
kind: Some("registry".into()),
source: Some("https://registry.inauguration.dev".into()),
..Default::default()
},
)]),
capabilities: Vec::new(),
extensions: Vec::new(),
});
let lock = resolve_package_lock(&manifest);
assert_eq!(lock.lock_version, "1");
assert_eq!(lock.name, "hyperchat");
assert_eq!(lock.version, "0.1.0");
assert_eq!(
lock.dependencies
.get("postgres")
.map(|dep| dep.version.as_str()),
Some("^1.0.0")
);
}
#[test]
fn round_trips_package_lock_file() {
let lock = PackageLock {
lock_version: "1".into(),
name: "hyperchat".into(),
version: "0.1.0".into(),
dependencies: BTreeMap::from([(
"postgres".into(),
PackageDependency {
version: "1.2.3".into(),
kind: Some("registry".into()),
source: Some("https://registry.inauguration.dev".into()),
rev: Some("abc123".into()),
checksum: Some("sha256:deadbeef".into()),
targets: vec!["macos".into()],
capabilities: vec!["network.http".into()],
build: BTreeMap::from([("profile".into(), "release".into())]),
install_path: None,
},
)]),
};
let parsed = parse_text(&format_package_lock(&lock)).expect("parse formatted lock");
assert_eq!(parsed, lock);
}
#[test]
fn writes_and_loads_package_lock_from_directory() {
let temp = TempDirGuard::new();
let lock = PackageLock {
lock_version: "1".into(),
name: "sample".into(),
version: "0.1.0".into(),
dependencies: BTreeMap::new(),
};
write_package_lock(&temp.path, &lock).expect("write lock");
let loaded = load_package_lock(&temp.path).expect("load lock");
assert_eq!(loaded, lock);
}
#[test]
fn validates_lock_against_manifest() {
let manifest = PackageManifest {
name: "hyperchat".into(),
version: "0.1.0".into(),
entry: None,
targets: BTreeMap::new(),
dependencies: BTreeMap::from([(
"postgres".into(),
PackageDependency {
version: "^1.0.0".into(),
kind: Some("registry".into()),
..Default::default()
},
)]),
capabilities: Vec::new(),
extensions: Vec::new(),
};
let lock = PackageLock {
lock_version: "1".into(),
name: "hyperchat".into(),
version: "0.1.0".into(),
dependencies: BTreeMap::from([(
"postgres".into(),
PackageDependency {
version: "^1.0.0".into(),
kind: Some("registry".into()),
..Default::default()
},
)]),
};
let validation = validate_package_lock(&manifest, &lock);
assert!(validation.valid);
assert!(validation.missing.is_empty());
assert!(validation.extra.is_empty());
assert!(validation.mismatched.is_empty());
}
#[test]
fn reports_missing_and_mismatched_lock_entries() {
let manifest = PackageManifest {
name: "hyperchat".into(),
version: "0.1.0".into(),
entry: None,
targets: BTreeMap::new(),
dependencies: BTreeMap::from([
(
"postgres".into(),
PackageDependency {
version: "^1.0.0".into(),
..Default::default()
},
),
(
"redis".into(),
PackageDependency {
version: "latest".into(),
..Default::default()
},
),
]),
capabilities: Vec::new(),
extensions: Vec::new(),
};
let lock = PackageLock {
lock_version: "1".into(),
name: "hyperchat".into(),
version: "0.1.0".into(),
dependencies: BTreeMap::from([
(
"postgres".into(),
PackageDependency {
version: "1.0.0".into(),
..Default::default()
},
),
(
"cache".into(),
PackageDependency {
version: "0.2.0".into(),
..Default::default()
},
),
]),
};
let validation = validate_package_lock(&manifest, &lock);
assert!(!validation.valid);
assert_eq!(validation.missing, vec!["redis".to_string()]);
assert_eq!(validation.extra, vec!["cache".to_string()]);
assert_eq!(validation.mismatched, vec!["postgres".to_string()]);
}
#[test]
fn loads_manifest_and_lock_together() {
let temp = TempDirGuard::new();
fs::write(
temp.path.join(PACKAGE_MANIFEST_FILE),
r#"name: hyperchat
version: 0.1.0
dependencies:
postgres:
version: ^1.0.0
"#,
)
.expect("write manifest");
let manifest =
crate::package_manifest::load_package_manifest(&temp.path).expect("load manifest");
let lock = resolve_package_lock(&manifest);
write_package_lock(&temp.path, &lock).expect("write lock");
let (loaded_manifest, loaded_lock, validation) =
load_manifest_and_lock(&temp.path).expect("load manifest and lock");
assert_eq!(loaded_manifest.name, "hyperchat");
assert_eq!(
loaded_lock.as_ref().map(|entry| entry.name.as_str()),
Some("hyperchat")
);
assert!(validation.valid);
}
}