Skip to main content

cuenv_core/lockfile/
mod.rs

1//! Lockfile types for cuenv tool management.
2//!
3//! The lockfile (`cuenv.lock`) stores resolved runtime and tool digests for
4//! reproducible, hermetic builds. It supports multiple sources:
5//! Nix flakes, GitHub releases, and OCI images.
6//!
7//! ## Structure (v3)
8//!
9//! ```toml
10//! version = 3
11//!
12//! # Runtime section - project runtime state
13//! [runtimes."."]
14//! type = "nix"
15//! flake = "."
16//! digest = "sha256:runtime..."
17//! lockfile = "flake.lock"
18//!
19//! # Tools section - multi-source tool management
20//! [tools.jq]
21//! version = "1.7.1"
22//!
23//!   [tools.jq.platforms."darwin-arm64"]
24//!   provider = "github"
25//!   digest = "sha256:abc..."
26//!   source = { repo = "jqlang/jq", tag = "jq-1.7.1", asset = "jq-macos-arm64" }
27//!
28//!   [tools.jq.platforms."linux-x86_64"]
29//!   provider = "github"
30//!   digest = "sha256:def..."
31//!   source = { repo = "jqlang/jq", tag = "jq-1.7.1", asset = "jq-linux-amd64" }
32//!
33//! [tools.rust]
34//! version = "1.83.0"
35//!
36//!   [tools.rust.platforms."darwin-arm64"]
37//!   provider = "nix"
38//!   digest = "sha256:ghi..."
39//!   source = { flake = "nixpkgs", package = "rustc" }
40//!
41//! [[tools_activation]]
42//! var = "PATH"
43//! op = "prepend"
44//! separator = ":"
45//! from = { type = "allBinDirs" }
46//!
47//! # Legacy artifacts section (for OCI images)
48//! [[artifacts]]
49//! kind = "image"
50//! image = "nginx:1.25-alpine"
51//!
52//!   [artifacts.platforms]
53//!   "linux-x86_64" = { digest = "sha256:abc...", size = 1234567 }
54//! ```
55
56use crate::tools::ToolActivationStep;
57use serde::{Deserialize, Serialize};
58use std::collections::BTreeMap;
59use std::path::Path;
60
61/// Current lockfile format version.
62pub const LOCKFILE_VERSION: u32 = 3;
63
64/// Filename for the lockfile.
65pub const LOCKFILE_NAME: &str = "cuenv.lock";
66
67/// The root lockfile structure.
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub struct Lockfile {
70    /// Lockfile format version (for future migrations).
71    pub version: u32,
72    /// Locked project runtimes keyed by project path.
73    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
74    pub runtimes: BTreeMap<String, LockedRuntime>,
75    /// Locked tools with per-platform resolution (v2+).
76    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
77    pub tools: BTreeMap<String, LockedTool>,
78    /// Tool activation operations shared by CLI/CI execution paths (v3+).
79    #[serde(default, skip_serializing_if = "Vec::is_empty")]
80    pub tools_activation: Vec<ToolActivationStep>,
81    /// Legacy OCI artifacts (for backward compatibility with v1).
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    pub artifacts: Vec<LockedArtifact>,
84}
85
86impl Default for Lockfile {
87    fn default() -> Self {
88        Self {
89            version: LOCKFILE_VERSION,
90            runtimes: BTreeMap::new(),
91            tools: BTreeMap::new(),
92            tools_activation: Vec::new(),
93            artifacts: Vec::new(),
94        }
95    }
96}
97
98impl Lockfile {
99    /// Create a new empty lockfile.
100    #[must_use]
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Load a lockfile from a TOML file.
106    ///
107    /// Returns `None` if the file doesn't exist.
108    /// Returns an error if the file exists but is invalid.
109    pub fn load(path: &Path) -> crate::Result<Option<Self>> {
110        if !path.exists() {
111            return Ok(None);
112        }
113
114        let content = std::fs::read_to_string(path)
115            .map_err(|e| crate::Error::configuration(format!("Failed to read lockfile: {}", e)))?;
116
117        let lockfile: Self = toml::from_str(&content).map_err(|e| {
118            crate::Error::configuration(format!(
119                "Failed to parse lockfile at {}: {}",
120                path.display(),
121                e
122            ))
123        })?;
124
125        // Version check for future migrations
126        if lockfile.version > LOCKFILE_VERSION {
127            return Err(crate::Error::configuration(format!(
128                "Lockfile version {} is newer than supported version {}. Please upgrade cuenv.",
129                lockfile.version, LOCKFILE_VERSION
130            )));
131        }
132
133        Ok(Some(lockfile))
134    }
135
136    /// Save the lockfile to a TOML file.
137    pub fn save(&self, path: &Path) -> crate::Result<()> {
138        let content = toml::to_string_pretty(self).map_err(|e| {
139            crate::Error::configuration(format!("Failed to serialize lockfile: {}", e))
140        })?;
141
142        std::fs::write(path, content)
143            .map_err(|e| crate::Error::configuration(format!("Failed to write lockfile: {}", e)))?;
144
145        Ok(())
146    }
147
148    /// Find an image artifact by image reference.
149    #[must_use]
150    pub fn find_image_artifact(&self, image: &str) -> Option<&LockedArtifact> {
151        self.artifacts
152            .iter()
153            .find(|a| matches!(&a.kind, ArtifactKind::Image { image: img } if img == image))
154    }
155
156    /// Find a tool by name.
157    #[must_use]
158    pub fn find_tool(&self, name: &str) -> Option<&LockedTool> {
159        self.tools.get(name)
160    }
161
162    /// Find a locked runtime by project path.
163    #[must_use]
164    pub fn find_runtime(&self, project_path: &str) -> Option<&LockedRuntime> {
165        self.runtimes.get(project_path)
166    }
167
168    /// Get all tool names.
169    #[must_use]
170    pub fn tool_names(&self) -> Vec<&str> {
171        self.tools.keys().map(String::as_str).collect()
172    }
173
174    /// Add or update a tool in the lockfile.
175    ///
176    /// # Errors
177    ///
178    /// Returns an error if the tool fails validation (empty platforms or
179    /// invalid digest format).
180    pub fn upsert_tool(&mut self, name: String, tool: LockedTool) -> crate::Result<()> {
181        tool.validate().map_err(|msg| {
182            crate::Error::configuration(format!("Invalid tool '{}': {}", name, msg))
183        })?;
184
185        self.tools.insert(name, tool);
186        Ok(())
187    }
188
189    /// Add or update a runtime in the lockfile.
190    ///
191    /// # Errors
192    ///
193    /// Returns an error if the runtime fails validation.
194    pub fn upsert_runtime(
195        &mut self,
196        project_path: String,
197        runtime: LockedRuntime,
198    ) -> crate::Result<()> {
199        runtime.validate().map_err(|msg| {
200            crate::Error::configuration(format!(
201                "Invalid runtime for project '{}': {}",
202                project_path, msg
203            ))
204        })?;
205
206        self.runtimes.insert(project_path, runtime);
207        Ok(())
208    }
209
210    /// Add or update a single platform for a tool.
211    ///
212    /// Creates the tool entry if it doesn't exist.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if the platform data is invalid.
217    pub fn upsert_tool_platform(
218        &mut self,
219        name: &str,
220        version: &str,
221        platform: &str,
222        data: LockedToolPlatform,
223    ) -> crate::Result<()> {
224        // Validate digest format
225        if !data.digest.starts_with("sha256:") && !data.digest.starts_with("sha512:") {
226            return Err(crate::Error::configuration(format!(
227                "Invalid digest format for tool '{}' platform '{}': must start with 'sha256:' or 'sha512:'",
228                name, platform
229            )));
230        }
231
232        let tool = self
233            .tools
234            .entry(name.to_string())
235            .or_insert_with(|| LockedTool {
236                version: version.to_string(),
237                platforms: BTreeMap::new(),
238            });
239
240        // Update version if it changed
241        if tool.version != version {
242            tool.version = version.to_string();
243        }
244
245        tool.platforms.insert(platform.to_string(), data);
246        Ok(())
247    }
248
249    /// Add or update an artifact in the lockfile (legacy v1 format).
250    ///
251    /// Matches artifacts by image reference.
252    ///
253    /// # Errors
254    ///
255    /// Returns an error if the artifact fails validation (empty platforms or
256    /// invalid digest format).
257    pub fn upsert_artifact(&mut self, artifact: LockedArtifact) -> crate::Result<()> {
258        // Validate the artifact before inserting
259        artifact
260            .validate()
261            .map_err(|msg| crate::Error::configuration(format!("Invalid artifact: {}", msg)))?;
262
263        // Find existing artifact with same identity (match Image by full reference)
264        let existing_idx = self
265            .artifacts
266            .iter()
267            .position(|a| match (&a.kind, &artifact.kind) {
268                (ArtifactKind::Image { image: i1 }, ArtifactKind::Image { image: i2 }) => i1 == i2,
269            });
270
271        if let Some(idx) = existing_idx {
272            self.artifacts[idx] = artifact;
273        } else {
274            self.artifacts.push(artifact);
275        }
276
277        Ok(())
278    }
279}
280
281/// A locked OCI artifact with platform-specific digests.
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283pub struct LockedArtifact {
284    /// The kind of artifact (registry package or container image).
285    #[serde(flatten)]
286    pub kind: ArtifactKind,
287    /// Platform-specific resolution data.
288    /// Keys are platform strings like "darwin-arm64", "linux-x86_64".
289    pub platforms: BTreeMap<String, PlatformData>,
290}
291
292impl LockedArtifact {
293    /// Get the digest for the current platform.
294    #[must_use]
295    pub fn digest_for_current_platform(&self) -> Option<&str> {
296        let platform = current_platform();
297        self.platforms.get(&platform).map(|p| p.digest.as_str())
298    }
299
300    /// Get platform data for the current platform.
301    #[must_use]
302    pub fn platform_data(&self) -> Option<&PlatformData> {
303        let platform = current_platform();
304        self.platforms.get(&platform)
305    }
306
307    /// Validate the artifact has valid data.
308    ///
309    /// Checks:
310    /// - At least one platform is present
311    /// - All digests have valid format (sha256: or sha512: prefix)
312    fn validate(&self) -> Result<(), String> {
313        if self.platforms.is_empty() {
314            return Err("Artifact must have at least one platform".to_string());
315        }
316
317        for (platform, data) in &self.platforms {
318            if !data.digest.starts_with("sha256:") && !data.digest.starts_with("sha512:") {
319                return Err(format!(
320                    "Invalid digest format for platform '{}': must start with 'sha256:' or 'sha512:'",
321                    platform
322                ));
323            }
324        }
325
326        Ok(())
327    }
328}
329
330/// The kind of OCI artifact.
331#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
332#[serde(tag = "kind", rename_all = "lowercase")]
333pub enum ArtifactKind {
334    /// An OCI image (container images).
335    Image {
336        /// Full image reference (e.g., "nginx:1.25-alpine").
337        image: String,
338    },
339}
340
341/// Platform-specific artifact data.
342#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
343pub struct PlatformData {
344    /// Content-addressable digest (e.g., "sha256:abc123...").
345    pub digest: String,
346    /// Size in bytes (for progress reporting).
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub size: Option<u64>,
349}
350
351/// A locked project runtime keyed by project path.
352#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
353#[serde(tag = "type", rename_all = "lowercase")]
354pub enum LockedRuntime {
355    /// Locked Nix runtime derived from a local flake.lock file.
356    Nix(LockedNixRuntime),
357}
358
359impl LockedRuntime {
360    fn validate(&self) -> Result<(), String> {
361        match self {
362            Self::Nix(runtime) => runtime.validate(),
363        }
364    }
365}
366
367/// Locked metadata for a Nix runtime.
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
369pub struct LockedNixRuntime {
370    /// Flake reference from the manifest runtime.
371    pub flake: String,
372    /// Selected shell or package output.
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub output: Option<String>,
375    /// Deterministic digest derived from the local flake.lock content.
376    pub digest: String,
377    /// Relative path to the flake.lock file used for this digest.
378    pub lockfile: String,
379}
380
381impl LockedNixRuntime {
382    fn validate(&self) -> Result<(), String> {
383        if !self.digest.starts_with("sha256:") && !self.digest.starts_with("sha512:") {
384            return Err(
385                "digest must start with 'sha256:' or 'sha512:' for Nix runtime".to_string(),
386            );
387        }
388
389        if self.lockfile.trim().is_empty() {
390            return Err("lockfile path must not be empty".to_string());
391        }
392
393        Ok(())
394    }
395}
396
397/// A locked tool with version and per-platform resolution (v2+).
398#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
399pub struct LockedTool {
400    /// Version string (e.g., "1.7.1").
401    pub version: String,
402    /// Platform-specific resolution data.
403    /// Keys are platform strings like "darwin-arm64", "linux-x86_64".
404    pub platforms: BTreeMap<String, LockedToolPlatform>,
405}
406
407impl LockedTool {
408    /// Get platform data for the current platform.
409    #[must_use]
410    pub fn current_platform(&self) -> Option<&LockedToolPlatform> {
411        let platform = current_platform();
412        self.platforms.get(&platform)
413    }
414
415    /// Validate the locked tool has valid data.
416    fn validate(&self) -> Result<(), String> {
417        if self.platforms.is_empty() {
418            return Err("Tool must have at least one platform".to_string());
419        }
420
421        for (platform, data) in &self.platforms {
422            if !data.digest.starts_with("sha256:") && !data.digest.starts_with("sha512:") {
423                return Err(format!(
424                    "Invalid digest format for platform '{}': must start with 'sha256:' or 'sha512:'",
425                    platform
426                ));
427            }
428        }
429
430        Ok(())
431    }
432}
433
434/// Platform-specific tool resolution data (v2+).
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
436pub struct LockedToolPlatform {
437    /// Provider that resolved this tool (e.g., "github", "nix", "oci").
438    pub provider: String,
439    /// Content-addressable digest (e.g., "sha256:abc123...").
440    pub digest: String,
441    /// Provider-specific source data (serialized as inline table).
442    /// For GitHub: `{ repo = "...", tag = "...", asset = "..." }`
443    /// For Nix: `{ flake = "...", package = "..." }`
444    /// For OCI: `{ image = "...", path = "..." }`
445    pub source: serde_json::Value,
446    /// Size in bytes (for progress reporting).
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub size: Option<u64>,
449    /// Runtime dependencies (other tool names).
450    #[serde(default, skip_serializing_if = "Vec::is_empty")]
451    pub dependencies: Vec<String>,
452}
453
454/// Get the current platform string.
455///
456/// Format: `{os}-{arch}` where:
457/// - os: "darwin", "linux", "windows"
458/// - arch: "x86_64", "arm64", "aarch64"
459#[must_use]
460pub fn current_platform() -> String {
461    let os = std::env::consts::OS;
462    let arch = std::env::consts::ARCH;
463
464    // Normalize arch names
465    let arch = match arch {
466        "aarch64" => "arm64",
467        other => other,
468    };
469
470    format!("{}-{}", os, arch)
471}
472
473/// Normalize a platform string to our canonical format.
474#[must_use]
475pub fn normalize_platform(platform: &str) -> String {
476    let platform = platform.to_lowercase();
477
478    // Handle various platform formats
479    platform
480        .replace("macos", "darwin")
481        .replace("osx", "darwin")
482        .replace("amd64", "x86_64")
483        .replace("aarch64", "arm64")
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn test_lockfile_serialization() {
492        let mut lockfile = Lockfile::new();
493
494        lockfile
495            .upsert_runtime(
496                ".".to_string(),
497                LockedRuntime::Nix(LockedNixRuntime {
498                    flake: ".".to_string(),
499                    output: None,
500                    digest: "sha256:runtime123".to_string(),
501                    lockfile: "flake.lock".to_string(),
502                }),
503            )
504            .unwrap();
505
506        // OCI image artifact
507        lockfile.artifacts.push(LockedArtifact {
508            kind: ArtifactKind::Image {
509                image: "nginx:1.25-alpine".to_string(),
510            },
511            platforms: BTreeMap::from([
512                (
513                    "darwin-arm64".to_string(),
514                    PlatformData {
515                        digest: "sha256:abc123".to_string(),
516                        size: Some(1234567),
517                    },
518                ),
519                (
520                    "linux-x86_64".to_string(),
521                    PlatformData {
522                        digest: "sha256:def456".to_string(),
523                        size: Some(1345678),
524                    },
525                ),
526            ]),
527        });
528
529        let toml_str = toml::to_string_pretty(&lockfile).unwrap();
530        assert!(toml_str.contains("version = 3"));
531        assert!(toml_str.contains("type = \"nix\""));
532        assert!(toml_str.contains("lockfile = \"flake.lock\""));
533        assert!(toml_str.contains("kind = \"image\""));
534        assert!(toml_str.contains("nginx:1.25-alpine"));
535
536        // Round-trip test
537        let parsed: Lockfile = toml::from_str(&toml_str).unwrap();
538        assert_eq!(parsed, lockfile);
539    }
540
541    #[test]
542    fn test_find_image_artifact() {
543        let mut lockfile = Lockfile::new();
544        lockfile.artifacts.push(LockedArtifact {
545            kind: ArtifactKind::Image {
546                image: "nginx:1.25-alpine".to_string(),
547            },
548            platforms: BTreeMap::new(),
549        });
550
551        assert!(lockfile.find_image_artifact("nginx:1.25-alpine").is_some());
552        assert!(lockfile.find_image_artifact("nginx:1.24-alpine").is_none());
553    }
554
555    #[test]
556    fn test_upsert_artifact() {
557        let mut lockfile = Lockfile::new();
558
559        let artifact1 = LockedArtifact {
560            kind: ArtifactKind::Image {
561                image: "nginx:1.25-alpine".to_string(),
562            },
563            platforms: BTreeMap::from([(
564                "darwin-arm64".to_string(),
565                PlatformData {
566                    digest: "sha256:old".to_string(),
567                    size: None,
568                },
569            )]),
570        };
571
572        lockfile.upsert_artifact(artifact1).unwrap();
573        assert_eq!(lockfile.artifacts.len(), 1);
574
575        // Update with new digest
576        let artifact2 = LockedArtifact {
577            kind: ArtifactKind::Image {
578                image: "nginx:1.25-alpine".to_string(),
579            },
580            platforms: BTreeMap::from([(
581                "darwin-arm64".to_string(),
582                PlatformData {
583                    digest: "sha256:new".to_string(),
584                    size: Some(123),
585                },
586            )]),
587        };
588
589        lockfile.upsert_artifact(artifact2).unwrap();
590        assert_eq!(lockfile.artifacts.len(), 1);
591        assert_eq!(
592            lockfile.artifacts[0].platforms["darwin-arm64"].digest,
593            "sha256:new"
594        );
595    }
596
597    #[test]
598    fn test_current_platform() {
599        let platform = current_platform();
600        // Should contain OS and arch
601        assert!(platform.contains('-'));
602        let parts: Vec<&str> = platform.split('-').collect();
603        assert_eq!(parts.len(), 2);
604    }
605
606    #[test]
607    fn test_normalize_platform() {
608        assert_eq!(normalize_platform("macos-amd64"), "darwin-x86_64");
609        assert_eq!(normalize_platform("linux-aarch64"), "linux-arm64");
610        assert_eq!(normalize_platform("Darwin-ARM64"), "darwin-arm64");
611    }
612
613    #[test]
614    fn test_upsert_artifact_validation_empty_platforms() {
615        let mut lockfile = Lockfile::new();
616
617        let artifact = LockedArtifact {
618            kind: ArtifactKind::Image {
619                image: "nginx:1.25-alpine".to_string(),
620            },
621            platforms: BTreeMap::new(), // Empty - should fail
622        };
623
624        let result = lockfile.upsert_artifact(artifact);
625        assert!(result.is_err());
626        assert!(
627            result
628                .unwrap_err()
629                .to_string()
630                .contains("at least one platform")
631        );
632    }
633
634    #[test]
635    fn test_upsert_artifact_validation_invalid_digest() {
636        let mut lockfile = Lockfile::new();
637
638        let artifact = LockedArtifact {
639            kind: ArtifactKind::Image {
640                image: "nginx:1.25-alpine".to_string(),
641            },
642            platforms: BTreeMap::from([(
643                "darwin-arm64".to_string(),
644                PlatformData {
645                    digest: "invalid-no-prefix".to_string(), // Missing sha256: prefix
646                    size: None,
647                },
648            )]),
649        };
650
651        let result = lockfile.upsert_artifact(artifact);
652        assert!(result.is_err());
653        assert!(
654            result
655                .unwrap_err()
656                .to_string()
657                .contains("Invalid digest format")
658        );
659    }
660
661    #[test]
662    fn test_artifact_validate_valid() {
663        let artifact = LockedArtifact {
664            kind: ArtifactKind::Image {
665                image: "nginx:1.25-alpine".to_string(),
666            },
667            platforms: BTreeMap::from([
668                (
669                    "darwin-arm64".to_string(),
670                    PlatformData {
671                        digest: "sha256:abc123".to_string(),
672                        size: Some(1234),
673                    },
674                ),
675                (
676                    "linux-x86_64".to_string(),
677                    PlatformData {
678                        digest: "sha512:def456".to_string(),
679                        size: None,
680                    },
681                ),
682            ]),
683        };
684
685        assert!(artifact.validate().is_ok());
686    }
687
688    #[test]
689    fn test_tools_serialization() {
690        let mut lockfile = Lockfile::new();
691
692        lockfile
693            .upsert_tool_platform(
694                "jq",
695                "1.7.1",
696                "darwin-arm64",
697                LockedToolPlatform {
698                    provider: "github".to_string(),
699                    digest: "sha256:abc123".to_string(),
700                    source: serde_json::json!({ "repo": "jqlang/jq", "tag": "jq-1.7.1", "asset": "jq-macos-arm64" }),
701                    size: Some(1234567),
702                    dependencies: vec![],
703                },
704            )
705            .unwrap();
706
707        lockfile
708            .upsert_tool_platform(
709                "jq",
710                "1.7.1",
711                "linux-x86_64",
712                LockedToolPlatform {
713                    provider: "github".to_string(),
714                    digest: "sha256:def456".to_string(),
715                    source: serde_json::json!({ "repo": "jqlang/jq", "tag": "jq-1.7.1", "asset": "jq-linux-amd64" }),
716                    size: Some(1345678),
717                    dependencies: vec![],
718                },
719            )
720            .unwrap();
721
722        let toml_str = toml::to_string_pretty(&lockfile).unwrap();
723        assert!(toml_str.contains("version = 3"));
724        assert!(toml_str.contains("[tools.jq]"));
725        assert!(toml_str.contains("provider = \"github\""));
726        assert!(toml_str.contains("digest = \"sha256:abc123\""));
727
728        // Round-trip test
729        let parsed: Lockfile = toml::from_str(&toml_str).unwrap();
730        assert_eq!(parsed.tools.len(), 1);
731        assert_eq!(parsed.tools["jq"].version, "1.7.1");
732        assert_eq!(parsed.tools["jq"].platforms.len(), 2);
733    }
734
735    #[test]
736    fn test_tools_activation_serialization() {
737        use crate::tools::{ToolActivationOperation, ToolActivationSource, ToolActivationStep};
738
739        let mut lockfile = Lockfile::new();
740        lockfile.tools_activation.push(ToolActivationStep {
741            var: "PATH".to_string(),
742            op: ToolActivationOperation::Prepend,
743            separator: ":".to_string(),
744            from: ToolActivationSource::AllBinDirs,
745        });
746
747        let toml_str = toml::to_string_pretty(&lockfile).unwrap();
748        assert!(toml_str.contains("[[tools_activation]]"));
749        assert!(toml_str.contains("var = \"PATH\""));
750        assert!(toml_str.contains("op = \"prepend\""));
751        assert!(toml_str.contains("type = \"allBinDirs\""));
752
753        let parsed: Lockfile = toml::from_str(&toml_str).unwrap();
754        assert_eq!(parsed.tools_activation.len(), 1);
755        assert_eq!(parsed.tools_activation[0], lockfile.tools_activation[0]);
756    }
757
758    #[test]
759    fn test_find_runtime() {
760        let mut lockfile = Lockfile::new();
761        lockfile
762            .upsert_runtime(
763                ".".to_string(),
764                LockedRuntime::Nix(LockedNixRuntime {
765                    flake: ".".to_string(),
766                    output: Some("devShells.x86_64-linux.default".to_string()),
767                    digest: "sha256:abc123".to_string(),
768                    lockfile: "flake.lock".to_string(),
769                }),
770            )
771            .unwrap();
772
773        assert!(lockfile.find_runtime(".").is_some());
774        assert!(lockfile.find_runtime("apps/api").is_none());
775    }
776
777    #[test]
778    fn test_upsert_runtime_validation_invalid_digest() {
779        let mut lockfile = Lockfile::new();
780
781        let result = lockfile.upsert_runtime(
782            ".".to_string(),
783            LockedRuntime::Nix(LockedNixRuntime {
784                flake: ".".to_string(),
785                output: None,
786                digest: "invalid".to_string(),
787                lockfile: "flake.lock".to_string(),
788            }),
789        );
790
791        assert!(result.is_err());
792        assert!(
793            result
794                .unwrap_err()
795                .to_string()
796                .contains("digest must start with")
797        );
798    }
799
800    #[test]
801    fn test_find_tool() {
802        let mut lockfile = Lockfile::new();
803        lockfile
804            .upsert_tool_platform(
805                "jq",
806                "1.7.1",
807                "darwin-arm64",
808                LockedToolPlatform {
809                    provider: "github".to_string(),
810                    digest: "sha256:abc123".to_string(),
811                    source: serde_json::json!({ "repo": "jqlang/jq", "tag": "jq-1.7.1", "asset": "jq-macos-arm64" }),
812                    size: None,
813                    dependencies: vec![],
814                },
815            )
816            .unwrap();
817
818        assert!(lockfile.find_tool("jq").is_some());
819        assert!(lockfile.find_tool("yq").is_none());
820    }
821
822    #[test]
823    fn test_upsert_tool_platform() {
824        let mut lockfile = Lockfile::new();
825
826        // Add first platform
827        lockfile
828            .upsert_tool_platform(
829                "bun",
830                "1.3.5",
831                "darwin-arm64",
832                LockedToolPlatform {
833                    provider: "github".to_string(),
834                    digest: "sha256:aaa".to_string(),
835                    source: serde_json::json!({ "url": "https://..." }),
836                    size: None,
837                    dependencies: vec![],
838                },
839            )
840            .unwrap();
841
842        assert_eq!(lockfile.tools.len(), 1);
843        assert_eq!(lockfile.tools["bun"].platforms.len(), 1);
844
845        // Add second platform
846        lockfile
847            .upsert_tool_platform(
848                "bun",
849                "1.3.5",
850                "linux-x86_64",
851                LockedToolPlatform {
852                    provider: "oci".to_string(),
853                    digest: "sha256:bbb".to_string(),
854                    source: serde_json::json!({ "image": "oven/bun:1.3.5" }),
855                    size: None,
856                    dependencies: vec![],
857                },
858            )
859            .unwrap();
860
861        assert_eq!(lockfile.tools.len(), 1);
862        assert_eq!(lockfile.tools["bun"].platforms.len(), 2);
863        assert_eq!(
864            lockfile.tools["bun"].platforms["darwin-arm64"].provider,
865            "github"
866        );
867        assert_eq!(
868            lockfile.tools["bun"].platforms["linux-x86_64"].provider,
869            "oci"
870        );
871    }
872
873    #[test]
874    fn test_upsert_tool_platform_invalid_digest() {
875        let mut lockfile = Lockfile::new();
876
877        let result = lockfile.upsert_tool_platform(
878            "jq",
879            "1.7.1",
880            "darwin-arm64",
881            LockedToolPlatform {
882                provider: "github".to_string(),
883                digest: "invalid".to_string(), // Missing sha256: prefix
884                source: serde_json::json!({}),
885                size: None,
886                dependencies: vec![],
887            },
888        );
889
890        assert!(result.is_err());
891        assert!(
892            result
893                .unwrap_err()
894                .to_string()
895                .contains("Invalid digest format")
896        );
897    }
898
899    #[test]
900    fn test_upsert_tool_validation_empty_platforms() {
901        let mut lockfile = Lockfile::new();
902
903        let tool = LockedTool {
904            version: "1.7.1".to_string(),
905            platforms: BTreeMap::new(), // Empty - should fail
906        };
907
908        let result = lockfile.upsert_tool("jq".to_string(), tool);
909        assert!(result.is_err());
910        assert!(
911            result
912                .unwrap_err()
913                .to_string()
914                .contains("at least one platform")
915        );
916    }
917
918    #[test]
919    fn test_tool_names() {
920        let mut lockfile = Lockfile::new();
921
922        lockfile
923            .upsert_tool_platform(
924                "jq",
925                "1.7.1",
926                "darwin-arm64",
927                LockedToolPlatform {
928                    provider: "github".to_string(),
929                    digest: "sha256:abc".to_string(),
930                    source: serde_json::json!({}),
931                    size: None,
932                    dependencies: vec![],
933                },
934            )
935            .unwrap();
936
937        lockfile
938            .upsert_tool_platform(
939                "yq",
940                "4.44.6",
941                "darwin-arm64",
942                LockedToolPlatform {
943                    provider: "github".to_string(),
944                    digest: "sha256:def".to_string(),
945                    source: serde_json::json!({}),
946                    size: None,
947                    dependencies: vec![],
948                },
949            )
950            .unwrap();
951
952        let names = lockfile.tool_names();
953        assert_eq!(names.len(), 2);
954        assert!(names.contains(&"jq"));
955        assert!(names.contains(&"yq"));
956    }
957}