Skip to main content

cuenv_core/lockfile/
mod.rs

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