1use crate::tools::ToolActivationStep;
50use serde::{Deserialize, Serialize};
51use std::collections::BTreeMap;
52use std::path::Path;
53
54pub const LOCKFILE_VERSION: u32 = 3;
56
57pub const LOCKFILE_NAME: &str = "cuenv.lock";
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct Lockfile {
63 pub version: u32,
65 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
67 pub tools: BTreeMap<String, LockedTool>,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub tools_activation: Vec<ToolActivationStep>,
71 #[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 #[must_use]
90 pub fn new() -> Self {
91 Self::default()
92 }
93
94 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 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 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 #[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 #[must_use]
147 pub fn find_tool(&self, name: &str) -> Option<&LockedTool> {
148 self.tools.get(name)
149 }
150
151 #[must_use]
153 pub fn tool_names(&self) -> Vec<&str> {
154 self.tools.keys().map(String::as_str).collect()
155 }
156
157 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 pub fn upsert_tool_platform(
180 &mut self,
181 name: &str,
182 version: &str,
183 platform: &str,
184 data: LockedToolPlatform,
185 ) -> crate::Result<()> {
186 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 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 pub fn upsert_artifact(&mut self, artifact: LockedArtifact) -> crate::Result<()> {
220 artifact
222 .validate()
223 .map_err(|msg| crate::Error::configuration(format!("Invalid artifact: {}", msg)))?;
224
225 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
245pub struct LockedArtifact {
246 #[serde(flatten)]
248 pub kind: ArtifactKind,
249 pub platforms: BTreeMap<String, PlatformData>,
252}
253
254impl LockedArtifact {
255 #[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 #[must_use]
264 pub fn platform_data(&self) -> Option<&PlatformData> {
265 let platform = current_platform();
266 self.platforms.get(&platform)
267 }
268
269 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
294#[serde(tag = "kind", rename_all = "lowercase")]
295pub enum ArtifactKind {
296 Image {
298 image: String,
300 },
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
305pub struct PlatformData {
306 pub digest: String,
308 #[serde(skip_serializing_if = "Option::is_none")]
310 pub size: Option<u64>,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
315pub struct LockedTool {
316 pub version: String,
318 pub platforms: BTreeMap<String, LockedToolPlatform>,
321}
322
323impl LockedTool {
324 #[must_use]
326 pub fn current_platform(&self) -> Option<&LockedToolPlatform> {
327 let platform = current_platform();
328 self.platforms.get(&platform)
329 }
330
331 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
352pub struct LockedToolPlatform {
353 pub provider: String,
355 pub digest: String,
357 pub source: serde_json::Value,
362 #[serde(skip_serializing_if = "Option::is_none")]
364 pub size: Option<u64>,
365 #[serde(default, skip_serializing_if = "Vec::is_empty")]
367 pub dependencies: Vec<String>,
368}
369
370#[must_use]
376pub fn current_platform() -> String {
377 let os = std::env::consts::OS;
378 let arch = std::env::consts::ARCH;
379
380 let arch = match arch {
382 "aarch64" => "arm64",
383 other => other,
384 };
385
386 format!("{}-{}", os, arch)
387}
388
389#[must_use]
391pub fn normalize_platform(platform: &str) -> String {
392 let platform = platform.to_lowercase();
393
394 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 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 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 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 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(), };
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(), 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 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 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 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(), 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(), };
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}