1use crate::tools::ToolActivationStep;
57use serde::{Deserialize, Serialize};
58use std::collections::BTreeMap;
59use std::path::Path;
60
61pub const LOCKFILE_VERSION: u32 = 3;
63
64pub const LOCKFILE_NAME: &str = "cuenv.lock";
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub struct Lockfile {
70 pub version: u32,
72 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
74 pub runtimes: BTreeMap<String, LockedRuntime>,
75 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
77 pub tools: BTreeMap<String, LockedTool>,
78 #[serde(default, skip_serializing_if = "Vec::is_empty")]
80 pub tools_activation: Vec<ToolActivationStep>,
81 #[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 #[must_use]
101 pub fn new() -> Self {
102 Self::default()
103 }
104
105 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 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 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 #[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 #[must_use]
158 pub fn find_tool(&self, name: &str) -> Option<&LockedTool> {
159 self.tools.get(name)
160 }
161
162 #[must_use]
164 pub fn find_runtime(&self, project_path: &str) -> Option<&LockedRuntime> {
165 self.runtimes.get(project_path)
166 }
167
168 #[must_use]
170 pub fn tool_names(&self) -> Vec<&str> {
171 self.tools.keys().map(String::as_str).collect()
172 }
173
174 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 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 pub fn upsert_tool_platform(
218 &mut self,
219 name: &str,
220 version: &str,
221 platform: &str,
222 data: LockedToolPlatform,
223 ) -> crate::Result<()> {
224 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 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 pub fn upsert_artifact(&mut self, artifact: LockedArtifact) -> crate::Result<()> {
258 artifact
260 .validate()
261 .map_err(|msg| crate::Error::configuration(format!("Invalid artifact: {}", msg)))?;
262
263 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283pub struct LockedArtifact {
284 #[serde(flatten)]
286 pub kind: ArtifactKind,
287 pub platforms: BTreeMap<String, PlatformData>,
290}
291
292impl LockedArtifact {
293 #[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 #[must_use]
302 pub fn platform_data(&self) -> Option<&PlatformData> {
303 let platform = current_platform();
304 self.platforms.get(&platform)
305 }
306
307 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
332#[serde(tag = "kind", rename_all = "lowercase")]
333pub enum ArtifactKind {
334 Image {
336 image: String,
338 },
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
343pub struct PlatformData {
344 pub digest: String,
346 #[serde(skip_serializing_if = "Option::is_none")]
348 pub size: Option<u64>,
349}
350
351#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
353#[serde(tag = "type", rename_all = "lowercase")]
354pub enum LockedRuntime {
355 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
369pub struct LockedNixRuntime {
370 pub flake: String,
372 #[serde(skip_serializing_if = "Option::is_none")]
374 pub output: Option<String>,
375 pub digest: String,
377 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
399pub struct LockedTool {
400 pub version: String,
402 pub platforms: BTreeMap<String, LockedToolPlatform>,
405}
406
407impl LockedTool {
408 #[must_use]
410 pub fn current_platform(&self) -> Option<&LockedToolPlatform> {
411 let platform = current_platform();
412 self.platforms.get(&platform)
413 }
414
415 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
436pub struct LockedToolPlatform {
437 pub provider: String,
439 pub digest: String,
441 pub source: serde_json::Value,
446 #[serde(skip_serializing_if = "Option::is_none")]
448 pub size: Option<u64>,
449 #[serde(default, skip_serializing_if = "Vec::is_empty")]
451 pub dependencies: Vec<String>,
452}
453
454#[must_use]
460pub fn current_platform() -> String {
461 let os = std::env::consts::OS;
462 let arch = std::env::consts::ARCH;
463
464 let arch = match arch {
466 "aarch64" => "arm64",
467 other => other,
468 };
469
470 format!("{}-{}", os, arch)
471}
472
473#[must_use]
475pub fn normalize_platform(platform: &str) -> String {
476 let platform = platform.to_lowercase();
477
478 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 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 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 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 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(), };
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(), 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 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 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 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(), 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(), };
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}