use crate::tools::ToolActivationStep;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
pub const LOCKFILE_VERSION: u32 = 3;
pub const LOCKFILE_NAME: &str = "cuenv.lock";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Lockfile {
pub version: u32,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub runtimes: BTreeMap<String, LockedRuntime>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub tools: BTreeMap<String, LockedTool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools_activation: Vec<ToolActivationStep>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub artifacts: Vec<LockedArtifact>,
}
impl Default for Lockfile {
fn default() -> Self {
Self {
version: LOCKFILE_VERSION,
runtimes: BTreeMap::new(),
tools: BTreeMap::new(),
tools_activation: Vec::new(),
artifacts: Vec::new(),
}
}
}
impl Lockfile {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn load(path: &Path) -> crate::Result<Option<Self>> {
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(path)
.map_err(|e| crate::Error::configuration(format!("Failed to read lockfile: {}", e)))?;
let lockfile: Self = toml::from_str(&content).map_err(|e| {
crate::Error::configuration(format!(
"Failed to parse lockfile at {}: {}",
path.display(),
e
))
})?;
if lockfile.version > LOCKFILE_VERSION {
return Err(crate::Error::configuration(format!(
"Lockfile version {} is newer than supported version {}. Please upgrade cuenv.",
lockfile.version, LOCKFILE_VERSION
)));
}
Ok(Some(lockfile))
}
pub fn save(&self, path: &Path) -> crate::Result<()> {
let content = toml::to_string_pretty(self).map_err(|e| {
crate::Error::configuration(format!("Failed to serialize lockfile: {}", e))
})?;
std::fs::write(path, content)
.map_err(|e| crate::Error::configuration(format!("Failed to write lockfile: {}", e)))?;
Ok(())
}
#[must_use]
pub fn find_image_artifact(&self, image: &str) -> Option<&LockedArtifact> {
self.artifacts
.iter()
.find(|a| matches!(&a.kind, ArtifactKind::Image { image: img } if img == image))
}
#[must_use]
pub fn find_tool(&self, name: &str) -> Option<&LockedTool> {
self.tools.get(name)
}
#[must_use]
pub fn find_runtime(&self, project_path: &str) -> Option<&LockedRuntime> {
self.runtimes.get(project_path)
}
#[must_use]
pub fn tool_names(&self) -> Vec<&str> {
self.tools.keys().map(String::as_str).collect()
}
pub fn upsert_tool(&mut self, name: String, tool: LockedTool) -> crate::Result<()> {
tool.validate().map_err(|msg| {
crate::Error::configuration(format!("Invalid tool '{}': {}", name, msg))
})?;
self.tools.insert(name, tool);
Ok(())
}
pub fn upsert_runtime(
&mut self,
project_path: String,
runtime: LockedRuntime,
) -> crate::Result<()> {
runtime.validate().map_err(|msg| {
crate::Error::configuration(format!(
"Invalid runtime for project '{}': {}",
project_path, msg
))
})?;
self.runtimes.insert(project_path, runtime);
Ok(())
}
pub fn upsert_tool_platform(
&mut self,
name: &str,
version: &str,
platform: &str,
data: LockedToolPlatform,
) -> crate::Result<()> {
if !data.digest.starts_with("sha256:") && !data.digest.starts_with("sha512:") {
return Err(crate::Error::configuration(format!(
"Invalid digest format for tool '{}' platform '{}': must start with 'sha256:' or 'sha512:'",
name, platform
)));
}
let tool = self
.tools
.entry(name.to_string())
.or_insert_with(|| LockedTool {
version: version.to_string(),
platforms: BTreeMap::new(),
});
if tool.version != version {
tool.version = version.to_string();
}
tool.platforms.insert(platform.to_string(), data);
Ok(())
}
pub fn upsert_artifact(&mut self, artifact: LockedArtifact) -> crate::Result<()> {
artifact
.validate()
.map_err(|msg| crate::Error::configuration(format!("Invalid artifact: {}", msg)))?;
let existing_idx = self
.artifacts
.iter()
.position(|a| match (&a.kind, &artifact.kind) {
(ArtifactKind::Image { image: i1 }, ArtifactKind::Image { image: i2 }) => i1 == i2,
});
if let Some(idx) = existing_idx {
self.artifacts[idx] = artifact;
} else {
self.artifacts.push(artifact);
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockedArtifact {
#[serde(flatten)]
pub kind: ArtifactKind,
pub platforms: BTreeMap<String, PlatformData>,
}
impl LockedArtifact {
#[must_use]
pub fn digest_for_current_platform(&self) -> Option<&str> {
let platform = current_platform();
self.platforms.get(&platform).map(|p| p.digest.as_str())
}
#[must_use]
pub fn platform_data(&self) -> Option<&PlatformData> {
let platform = current_platform();
self.platforms.get(&platform)
}
fn validate(&self) -> Result<(), String> {
if self.platforms.is_empty() {
return Err("Artifact must have at least one platform".to_string());
}
for (platform, data) in &self.platforms {
if !data.digest.starts_with("sha256:") && !data.digest.starts_with("sha512:") {
return Err(format!(
"Invalid digest format for platform '{}': must start with 'sha256:' or 'sha512:'",
platform
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum ArtifactKind {
Image {
image: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PlatformData {
pub digest: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum LockedRuntime {
Nix(LockedNixRuntime),
}
impl LockedRuntime {
fn validate(&self) -> Result<(), String> {
match self {
Self::Nix(runtime) => runtime.validate(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockedNixRuntime {
pub flake: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<String>,
pub digest: String,
pub lockfile: String,
}
impl LockedNixRuntime {
fn validate(&self) -> Result<(), String> {
if !self.digest.starts_with("sha256:") && !self.digest.starts_with("sha512:") {
return Err(
"digest must start with 'sha256:' or 'sha512:' for Nix runtime".to_string(),
);
}
if self.lockfile.trim().is_empty() {
return Err("lockfile path must not be empty".to_string());
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockedTool {
pub version: String,
pub platforms: BTreeMap<String, LockedToolPlatform>,
}
impl LockedTool {
#[must_use]
pub fn current_platform(&self) -> Option<&LockedToolPlatform> {
let platform = current_platform();
self.platforms.get(&platform)
}
fn validate(&self) -> Result<(), String> {
if self.platforms.is_empty() {
return Err("Tool must have at least one platform".to_string());
}
for (platform, data) in &self.platforms {
if !data.digest.starts_with("sha256:") && !data.digest.starts_with("sha512:") {
return Err(format!(
"Invalid digest format for platform '{}': must start with 'sha256:' or 'sha512:'",
platform
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockedToolPlatform {
pub provider: String,
pub digest: String,
pub source: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dependencies: Vec<String>,
}
#[must_use]
pub fn current_platform() -> String {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let arch = match arch {
"aarch64" => "arm64",
other => other,
};
format!("{}-{}", os, arch)
}
#[must_use]
pub fn normalize_platform(platform: &str) -> String {
let platform = platform.to_lowercase();
platform
.replace("macos", "darwin")
.replace("osx", "darwin")
.replace("amd64", "x86_64")
.replace("aarch64", "arm64")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lockfile_serialization() {
let mut lockfile = Lockfile::new();
lockfile
.upsert_runtime(
".".to_string(),
LockedRuntime::Nix(LockedNixRuntime {
flake: ".".to_string(),
output: None,
digest: "sha256:runtime123".to_string(),
lockfile: "flake.lock".to_string(),
}),
)
.unwrap();
lockfile.artifacts.push(LockedArtifact {
kind: ArtifactKind::Image {
image: "nginx:1.25-alpine".to_string(),
},
platforms: BTreeMap::from([
(
"darwin-arm64".to_string(),
PlatformData {
digest: "sha256:abc123".to_string(),
size: Some(1234567),
},
),
(
"linux-x86_64".to_string(),
PlatformData {
digest: "sha256:def456".to_string(),
size: Some(1345678),
},
),
]),
});
let toml_str = toml::to_string_pretty(&lockfile).unwrap();
assert!(toml_str.contains("version = 3"));
assert!(toml_str.contains("type = \"nix\""));
assert!(toml_str.contains("lockfile = \"flake.lock\""));
assert!(toml_str.contains("kind = \"image\""));
assert!(toml_str.contains("nginx:1.25-alpine"));
let parsed: Lockfile = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed, lockfile);
}
#[test]
fn test_find_image_artifact() {
let mut lockfile = Lockfile::new();
lockfile.artifacts.push(LockedArtifact {
kind: ArtifactKind::Image {
image: "nginx:1.25-alpine".to_string(),
},
platforms: BTreeMap::new(),
});
assert!(lockfile.find_image_artifact("nginx:1.25-alpine").is_some());
assert!(lockfile.find_image_artifact("nginx:1.24-alpine").is_none());
}
#[test]
fn test_upsert_artifact() {
let mut lockfile = Lockfile::new();
let artifact1 = LockedArtifact {
kind: ArtifactKind::Image {
image: "nginx:1.25-alpine".to_string(),
},
platforms: BTreeMap::from([(
"darwin-arm64".to_string(),
PlatformData {
digest: "sha256:old".to_string(),
size: None,
},
)]),
};
lockfile.upsert_artifact(artifact1).unwrap();
assert_eq!(lockfile.artifacts.len(), 1);
let artifact2 = LockedArtifact {
kind: ArtifactKind::Image {
image: "nginx:1.25-alpine".to_string(),
},
platforms: BTreeMap::from([(
"darwin-arm64".to_string(),
PlatformData {
digest: "sha256:new".to_string(),
size: Some(123),
},
)]),
};
lockfile.upsert_artifact(artifact2).unwrap();
assert_eq!(lockfile.artifacts.len(), 1);
assert_eq!(
lockfile.artifacts[0].platforms["darwin-arm64"].digest,
"sha256:new"
);
}
#[test]
fn test_current_platform() {
let platform = current_platform();
assert!(platform.contains('-'));
let parts: Vec<&str> = platform.split('-').collect();
assert_eq!(parts.len(), 2);
}
#[test]
fn test_normalize_platform() {
assert_eq!(normalize_platform("macos-amd64"), "darwin-x86_64");
assert_eq!(normalize_platform("linux-aarch64"), "linux-arm64");
assert_eq!(normalize_platform("Darwin-ARM64"), "darwin-arm64");
}
#[test]
fn test_upsert_artifact_validation_empty_platforms() {
let mut lockfile = Lockfile::new();
let artifact = LockedArtifact {
kind: ArtifactKind::Image {
image: "nginx:1.25-alpine".to_string(),
},
platforms: BTreeMap::new(), };
let result = lockfile.upsert_artifact(artifact);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("at least one platform")
);
}
#[test]
fn test_upsert_artifact_validation_invalid_digest() {
let mut lockfile = Lockfile::new();
let artifact = LockedArtifact {
kind: ArtifactKind::Image {
image: "nginx:1.25-alpine".to_string(),
},
platforms: BTreeMap::from([(
"darwin-arm64".to_string(),
PlatformData {
digest: "invalid-no-prefix".to_string(), size: None,
},
)]),
};
let result = lockfile.upsert_artifact(artifact);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Invalid digest format")
);
}
#[test]
fn test_artifact_validate_valid() {
let artifact = LockedArtifact {
kind: ArtifactKind::Image {
image: "nginx:1.25-alpine".to_string(),
},
platforms: BTreeMap::from([
(
"darwin-arm64".to_string(),
PlatformData {
digest: "sha256:abc123".to_string(),
size: Some(1234),
},
),
(
"linux-x86_64".to_string(),
PlatformData {
digest: "sha512:def456".to_string(),
size: None,
},
),
]),
};
assert!(artifact.validate().is_ok());
}
#[test]
fn test_tools_serialization() {
let mut lockfile = Lockfile::new();
lockfile
.upsert_tool_platform(
"jq",
"1.7.1",
"darwin-arm64",
LockedToolPlatform {
provider: "github".to_string(),
digest: "sha256:abc123".to_string(),
source: serde_json::json!({ "repo": "jqlang/jq", "tag": "jq-1.7.1", "asset": "jq-macos-arm64" }),
size: Some(1234567),
dependencies: vec![],
},
)
.unwrap();
lockfile
.upsert_tool_platform(
"jq",
"1.7.1",
"linux-x86_64",
LockedToolPlatform {
provider: "github".to_string(),
digest: "sha256:def456".to_string(),
source: serde_json::json!({ "repo": "jqlang/jq", "tag": "jq-1.7.1", "asset": "jq-linux-amd64" }),
size: Some(1345678),
dependencies: vec![],
},
)
.unwrap();
let toml_str = toml::to_string_pretty(&lockfile).unwrap();
assert!(toml_str.contains("version = 3"));
assert!(toml_str.contains("[tools.jq]"));
assert!(toml_str.contains("provider = \"github\""));
assert!(toml_str.contains("digest = \"sha256:abc123\""));
let parsed: Lockfile = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.tools.len(), 1);
assert_eq!(parsed.tools["jq"].version, "1.7.1");
assert_eq!(parsed.tools["jq"].platforms.len(), 2);
}
#[test]
fn test_tools_activation_serialization() {
use crate::tools::{ToolActivationOperation, ToolActivationSource, ToolActivationStep};
let mut lockfile = Lockfile::new();
lockfile.tools_activation.push(ToolActivationStep {
var: "PATH".to_string(),
op: ToolActivationOperation::Prepend,
separator: ":".to_string(),
from: ToolActivationSource::AllBinDirs,
});
let toml_str = toml::to_string_pretty(&lockfile).unwrap();
assert!(toml_str.contains("[[tools_activation]]"));
assert!(toml_str.contains("var = \"PATH\""));
assert!(toml_str.contains("op = \"prepend\""));
assert!(toml_str.contains("type = \"allBinDirs\""));
let parsed: Lockfile = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.tools_activation.len(), 1);
assert_eq!(parsed.tools_activation[0], lockfile.tools_activation[0]);
}
#[test]
fn test_find_runtime() {
let mut lockfile = Lockfile::new();
lockfile
.upsert_runtime(
".".to_string(),
LockedRuntime::Nix(LockedNixRuntime {
flake: ".".to_string(),
output: Some("devShells.x86_64-linux.default".to_string()),
digest: "sha256:abc123".to_string(),
lockfile: "flake.lock".to_string(),
}),
)
.unwrap();
assert!(lockfile.find_runtime(".").is_some());
assert!(lockfile.find_runtime("apps/api").is_none());
}
#[test]
fn test_upsert_runtime_validation_invalid_digest() {
let mut lockfile = Lockfile::new();
let result = lockfile.upsert_runtime(
".".to_string(),
LockedRuntime::Nix(LockedNixRuntime {
flake: ".".to_string(),
output: None,
digest: "invalid".to_string(),
lockfile: "flake.lock".to_string(),
}),
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("digest must start with")
);
}
#[test]
fn test_find_tool() {
let mut lockfile = Lockfile::new();
lockfile
.upsert_tool_platform(
"jq",
"1.7.1",
"darwin-arm64",
LockedToolPlatform {
provider: "github".to_string(),
digest: "sha256:abc123".to_string(),
source: serde_json::json!({ "repo": "jqlang/jq", "tag": "jq-1.7.1", "asset": "jq-macos-arm64" }),
size: None,
dependencies: vec![],
},
)
.unwrap();
assert!(lockfile.find_tool("jq").is_some());
assert!(lockfile.find_tool("yq").is_none());
}
#[test]
fn test_upsert_tool_platform() {
let mut lockfile = Lockfile::new();
lockfile
.upsert_tool_platform(
"bun",
"1.3.5",
"darwin-arm64",
LockedToolPlatform {
provider: "github".to_string(),
digest: "sha256:aaa".to_string(),
source: serde_json::json!({ "url": "https://..." }),
size: None,
dependencies: vec![],
},
)
.unwrap();
assert_eq!(lockfile.tools.len(), 1);
assert_eq!(lockfile.tools["bun"].platforms.len(), 1);
lockfile
.upsert_tool_platform(
"bun",
"1.3.5",
"linux-x86_64",
LockedToolPlatform {
provider: "oci".to_string(),
digest: "sha256:bbb".to_string(),
source: serde_json::json!({ "image": "oven/bun:1.3.5" }),
size: None,
dependencies: vec![],
},
)
.unwrap();
assert_eq!(lockfile.tools.len(), 1);
assert_eq!(lockfile.tools["bun"].platforms.len(), 2);
assert_eq!(
lockfile.tools["bun"].platforms["darwin-arm64"].provider,
"github"
);
assert_eq!(
lockfile.tools["bun"].platforms["linux-x86_64"].provider,
"oci"
);
}
#[test]
fn test_upsert_tool_platform_invalid_digest() {
let mut lockfile = Lockfile::new();
let result = lockfile.upsert_tool_platform(
"jq",
"1.7.1",
"darwin-arm64",
LockedToolPlatform {
provider: "github".to_string(),
digest: "invalid".to_string(), source: serde_json::json!({}),
size: None,
dependencies: vec![],
},
);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Invalid digest format")
);
}
#[test]
fn test_upsert_tool_validation_empty_platforms() {
let mut lockfile = Lockfile::new();
let tool = LockedTool {
version: "1.7.1".to_string(),
platforms: BTreeMap::new(), };
let result = lockfile.upsert_tool("jq".to_string(), tool);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("at least one platform")
);
}
#[test]
fn test_tool_names() {
let mut lockfile = Lockfile::new();
lockfile
.upsert_tool_platform(
"jq",
"1.7.1",
"darwin-arm64",
LockedToolPlatform {
provider: "github".to_string(),
digest: "sha256:abc".to_string(),
source: serde_json::json!({}),
size: None,
dependencies: vec![],
},
)
.unwrap();
lockfile
.upsert_tool_platform(
"yq",
"4.44.6",
"darwin-arm64",
LockedToolPlatform {
provider: "github".to_string(),
digest: "sha256:def".to_string(),
source: serde_json::json!({}),
size: None,
dependencies: vec![],
},
)
.unwrap();
let names = lockfile.tool_names();
assert_eq!(names.len(), 2);
assert!(names.contains(&"jq"));
assert!(names.contains(&"yq"));
}
}