1use serde::{Deserialize, Serialize};
44use std::collections::BTreeMap;
45use std::path::Path;
46
47pub const LOCKFILE_VERSION: u32 = 2;
49
50pub const LOCKFILE_NAME: &str = "cuenv.lock";
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55pub struct Lockfile {
56 pub version: u32,
58 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
60 pub tools: BTreeMap<String, LockedTool>,
61 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 pub artifacts: Vec<LockedArtifact>,
64}
65
66impl Default for Lockfile {
67 fn default() -> Self {
68 Self {
69 version: LOCKFILE_VERSION,
70 tools: BTreeMap::new(),
71 artifacts: Vec::new(),
72 }
73 }
74}
75
76impl Lockfile {
77 #[must_use]
79 pub fn new() -> Self {
80 Self::default()
81 }
82
83 pub fn load(path: &Path) -> crate::Result<Option<Self>> {
88 if !path.exists() {
89 return Ok(None);
90 }
91
92 let content = std::fs::read_to_string(path)
93 .map_err(|e| crate::Error::configuration(format!("Failed to read lockfile: {}", e)))?;
94
95 let lockfile: Self = toml::from_str(&content).map_err(|e| {
96 crate::Error::configuration(format!(
97 "Failed to parse lockfile at {}: {}",
98 path.display(),
99 e
100 ))
101 })?;
102
103 if lockfile.version > LOCKFILE_VERSION {
105 return Err(crate::Error::configuration(format!(
106 "Lockfile version {} is newer than supported version {}. Please upgrade cuenv.",
107 lockfile.version, LOCKFILE_VERSION
108 )));
109 }
110
111 Ok(Some(lockfile))
112 }
113
114 pub fn save(&self, path: &Path) -> crate::Result<()> {
116 let content = toml::to_string_pretty(self).map_err(|e| {
117 crate::Error::configuration(format!("Failed to serialize lockfile: {}", e))
118 })?;
119
120 std::fs::write(path, content)
121 .map_err(|e| crate::Error::configuration(format!("Failed to write lockfile: {}", e)))?;
122
123 Ok(())
124 }
125
126 #[must_use]
128 pub fn find_image_artifact(&self, image: &str) -> Option<&LockedArtifact> {
129 self.artifacts
130 .iter()
131 .find(|a| matches!(&a.kind, ArtifactKind::Image { image: img } if img == image))
132 }
133
134 #[must_use]
136 pub fn find_tool(&self, name: &str) -> Option<&LockedTool> {
137 self.tools.get(name)
138 }
139
140 #[must_use]
142 pub fn tool_names(&self) -> Vec<&str> {
143 self.tools.keys().map(String::as_str).collect()
144 }
145
146 pub fn upsert_tool(&mut self, name: String, tool: LockedTool) -> crate::Result<()> {
153 tool.validate().map_err(|msg| {
154 crate::Error::configuration(format!("Invalid tool '{}': {}", name, msg))
155 })?;
156
157 self.tools.insert(name, tool);
158 Ok(())
159 }
160
161 pub fn upsert_tool_platform(
169 &mut self,
170 name: &str,
171 version: &str,
172 platform: &str,
173 data: LockedToolPlatform,
174 ) -> crate::Result<()> {
175 if !data.digest.starts_with("sha256:") && !data.digest.starts_with("sha512:") {
177 return Err(crate::Error::configuration(format!(
178 "Invalid digest format for tool '{}' platform '{}': must start with 'sha256:' or 'sha512:'",
179 name, platform
180 )));
181 }
182
183 let tool = self
184 .tools
185 .entry(name.to_string())
186 .or_insert_with(|| LockedTool {
187 version: version.to_string(),
188 platforms: BTreeMap::new(),
189 });
190
191 if tool.version != version {
193 tool.version = version.to_string();
194 }
195
196 tool.platforms.insert(platform.to_string(), data);
197 Ok(())
198 }
199
200 pub fn upsert_artifact(&mut self, artifact: LockedArtifact) -> crate::Result<()> {
209 artifact
211 .validate()
212 .map_err(|msg| crate::Error::configuration(format!("Invalid artifact: {}", msg)))?;
213
214 let existing_idx = self
216 .artifacts
217 .iter()
218 .position(|a| match (&a.kind, &artifact.kind) {
219 (ArtifactKind::Image { image: i1 }, ArtifactKind::Image { image: i2 }) => i1 == i2,
220 });
221
222 if let Some(idx) = existing_idx {
223 self.artifacts[idx] = artifact;
224 } else {
225 self.artifacts.push(artifact);
226 }
227
228 Ok(())
229 }
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
234pub struct LockedArtifact {
235 #[serde(flatten)]
237 pub kind: ArtifactKind,
238 pub platforms: BTreeMap<String, PlatformData>,
241}
242
243impl LockedArtifact {
244 #[must_use]
246 pub fn digest_for_current_platform(&self) -> Option<&str> {
247 let platform = current_platform();
248 self.platforms.get(&platform).map(|p| p.digest.as_str())
249 }
250
251 #[must_use]
253 pub fn platform_data(&self) -> Option<&PlatformData> {
254 let platform = current_platform();
255 self.platforms.get(&platform)
256 }
257
258 fn validate(&self) -> Result<(), String> {
264 if self.platforms.is_empty() {
265 return Err("Artifact must have at least one platform".to_string());
266 }
267
268 for (platform, data) in &self.platforms {
269 if !data.digest.starts_with("sha256:") && !data.digest.starts_with("sha512:") {
270 return Err(format!(
271 "Invalid digest format for platform '{}': must start with 'sha256:' or 'sha512:'",
272 platform
273 ));
274 }
275 }
276
277 Ok(())
278 }
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283#[serde(tag = "kind", rename_all = "lowercase")]
284pub enum ArtifactKind {
285 Image {
287 image: String,
289 },
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
294pub struct PlatformData {
295 pub digest: String,
297 #[serde(skip_serializing_if = "Option::is_none")]
299 pub size: Option<u64>,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
304pub struct LockedTool {
305 pub version: String,
307 pub platforms: BTreeMap<String, LockedToolPlatform>,
310}
311
312impl LockedTool {
313 #[must_use]
315 pub fn current_platform(&self) -> Option<&LockedToolPlatform> {
316 let platform = current_platform();
317 self.platforms.get(&platform)
318 }
319
320 fn validate(&self) -> Result<(), String> {
322 if self.platforms.is_empty() {
323 return Err("Tool must have at least one platform".to_string());
324 }
325
326 for (platform, data) in &self.platforms {
327 if !data.digest.starts_with("sha256:") && !data.digest.starts_with("sha512:") {
328 return Err(format!(
329 "Invalid digest format for platform '{}': must start with 'sha256:' or 'sha512:'",
330 platform
331 ));
332 }
333 }
334
335 Ok(())
336 }
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
341pub struct LockedToolPlatform {
342 pub provider: String,
344 pub digest: String,
346 pub source: serde_json::Value,
351 #[serde(skip_serializing_if = "Option::is_none")]
353 pub size: Option<u64>,
354 #[serde(default, skip_serializing_if = "Vec::is_empty")]
356 pub dependencies: Vec<String>,
357}
358
359#[must_use]
365pub fn current_platform() -> String {
366 let os = std::env::consts::OS;
367 let arch = std::env::consts::ARCH;
368
369 let arch = match arch {
371 "aarch64" => "arm64",
372 other => other,
373 };
374
375 format!("{}-{}", os, arch)
376}
377
378#[must_use]
380pub fn normalize_platform(platform: &str) -> String {
381 let platform = platform.to_lowercase();
382
383 platform
385 .replace("macos", "darwin")
386 .replace("osx", "darwin")
387 .replace("amd64", "x86_64")
388 .replace("aarch64", "arm64")
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn test_lockfile_serialization() {
397 let mut lockfile = Lockfile::new();
398
399 lockfile.artifacts.push(LockedArtifact {
401 kind: ArtifactKind::Image {
402 image: "nginx:1.25-alpine".to_string(),
403 },
404 platforms: BTreeMap::from([
405 (
406 "darwin-arm64".to_string(),
407 PlatformData {
408 digest: "sha256:abc123".to_string(),
409 size: Some(1234567),
410 },
411 ),
412 (
413 "linux-x86_64".to_string(),
414 PlatformData {
415 digest: "sha256:def456".to_string(),
416 size: Some(1345678),
417 },
418 ),
419 ]),
420 });
421
422 let toml_str = toml::to_string_pretty(&lockfile).unwrap();
423 assert!(toml_str.contains("version = 2"));
424 assert!(toml_str.contains("kind = \"image\""));
425 assert!(toml_str.contains("nginx:1.25-alpine"));
426
427 let parsed: Lockfile = toml::from_str(&toml_str).unwrap();
429 assert_eq!(parsed, lockfile);
430 }
431
432 #[test]
433 fn test_find_image_artifact() {
434 let mut lockfile = Lockfile::new();
435 lockfile.artifacts.push(LockedArtifact {
436 kind: ArtifactKind::Image {
437 image: "nginx:1.25-alpine".to_string(),
438 },
439 platforms: BTreeMap::new(),
440 });
441
442 assert!(lockfile.find_image_artifact("nginx:1.25-alpine").is_some());
443 assert!(lockfile.find_image_artifact("nginx:1.24-alpine").is_none());
444 }
445
446 #[test]
447 fn test_upsert_artifact() {
448 let mut lockfile = Lockfile::new();
449
450 let artifact1 = LockedArtifact {
451 kind: ArtifactKind::Image {
452 image: "nginx:1.25-alpine".to_string(),
453 },
454 platforms: BTreeMap::from([(
455 "darwin-arm64".to_string(),
456 PlatformData {
457 digest: "sha256:old".to_string(),
458 size: None,
459 },
460 )]),
461 };
462
463 lockfile.upsert_artifact(artifact1).unwrap();
464 assert_eq!(lockfile.artifacts.len(), 1);
465
466 let artifact2 = LockedArtifact {
468 kind: ArtifactKind::Image {
469 image: "nginx:1.25-alpine".to_string(),
470 },
471 platforms: BTreeMap::from([(
472 "darwin-arm64".to_string(),
473 PlatformData {
474 digest: "sha256:new".to_string(),
475 size: Some(123),
476 },
477 )]),
478 };
479
480 lockfile.upsert_artifact(artifact2).unwrap();
481 assert_eq!(lockfile.artifacts.len(), 1);
482 assert_eq!(
483 lockfile.artifacts[0].platforms["darwin-arm64"].digest,
484 "sha256:new"
485 );
486 }
487
488 #[test]
489 fn test_current_platform() {
490 let platform = current_platform();
491 assert!(platform.contains('-'));
493 let parts: Vec<&str> = platform.split('-').collect();
494 assert_eq!(parts.len(), 2);
495 }
496
497 #[test]
498 fn test_normalize_platform() {
499 assert_eq!(normalize_platform("macos-amd64"), "darwin-x86_64");
500 assert_eq!(normalize_platform("linux-aarch64"), "linux-arm64");
501 assert_eq!(normalize_platform("Darwin-ARM64"), "darwin-arm64");
502 }
503
504 #[test]
505 fn test_upsert_artifact_validation_empty_platforms() {
506 let mut lockfile = Lockfile::new();
507
508 let artifact = LockedArtifact {
509 kind: ArtifactKind::Image {
510 image: "nginx:1.25-alpine".to_string(),
511 },
512 platforms: BTreeMap::new(), };
514
515 let result = lockfile.upsert_artifact(artifact);
516 assert!(result.is_err());
517 assert!(
518 result
519 .unwrap_err()
520 .to_string()
521 .contains("at least one platform")
522 );
523 }
524
525 #[test]
526 fn test_upsert_artifact_validation_invalid_digest() {
527 let mut lockfile = Lockfile::new();
528
529 let artifact = LockedArtifact {
530 kind: ArtifactKind::Image {
531 image: "nginx:1.25-alpine".to_string(),
532 },
533 platforms: BTreeMap::from([(
534 "darwin-arm64".to_string(),
535 PlatformData {
536 digest: "invalid-no-prefix".to_string(), size: None,
538 },
539 )]),
540 };
541
542 let result = lockfile.upsert_artifact(artifact);
543 assert!(result.is_err());
544 assert!(
545 result
546 .unwrap_err()
547 .to_string()
548 .contains("Invalid digest format")
549 );
550 }
551
552 #[test]
553 fn test_artifact_validate_valid() {
554 let artifact = LockedArtifact {
555 kind: ArtifactKind::Image {
556 image: "nginx:1.25-alpine".to_string(),
557 },
558 platforms: BTreeMap::from([
559 (
560 "darwin-arm64".to_string(),
561 PlatformData {
562 digest: "sha256:abc123".to_string(),
563 size: Some(1234),
564 },
565 ),
566 (
567 "linux-x86_64".to_string(),
568 PlatformData {
569 digest: "sha512:def456".to_string(),
570 size: None,
571 },
572 ),
573 ]),
574 };
575
576 assert!(artifact.validate().is_ok());
577 }
578
579 #[test]
580 fn test_tools_serialization() {
581 let mut lockfile = Lockfile::new();
582
583 lockfile
584 .upsert_tool_platform(
585 "jq",
586 "1.7.1",
587 "darwin-arm64",
588 LockedToolPlatform {
589 provider: "github".to_string(),
590 digest: "sha256:abc123".to_string(),
591 source: serde_json::json!({ "repo": "jqlang/jq", "tag": "jq-1.7.1", "asset": "jq-macos-arm64" }),
592 size: Some(1234567),
593 dependencies: vec![],
594 },
595 )
596 .unwrap();
597
598 lockfile
599 .upsert_tool_platform(
600 "jq",
601 "1.7.1",
602 "linux-x86_64",
603 LockedToolPlatform {
604 provider: "github".to_string(),
605 digest: "sha256:def456".to_string(),
606 source: serde_json::json!({ "repo": "jqlang/jq", "tag": "jq-1.7.1", "asset": "jq-linux-amd64" }),
607 size: Some(1345678),
608 dependencies: vec![],
609 },
610 )
611 .unwrap();
612
613 let toml_str = toml::to_string_pretty(&lockfile).unwrap();
614 assert!(toml_str.contains("version = 2"));
615 assert!(toml_str.contains("[tools.jq]"));
616 assert!(toml_str.contains("provider = \"github\""));
617 assert!(toml_str.contains("digest = \"sha256:abc123\""));
618
619 let parsed: Lockfile = toml::from_str(&toml_str).unwrap();
621 assert_eq!(parsed.tools.len(), 1);
622 assert_eq!(parsed.tools["jq"].version, "1.7.1");
623 assert_eq!(parsed.tools["jq"].platforms.len(), 2);
624 }
625
626 #[test]
627 fn test_find_tool() {
628 let mut lockfile = Lockfile::new();
629 lockfile
630 .upsert_tool_platform(
631 "jq",
632 "1.7.1",
633 "darwin-arm64",
634 LockedToolPlatform {
635 provider: "github".to_string(),
636 digest: "sha256:abc123".to_string(),
637 source: serde_json::json!({ "repo": "jqlang/jq", "tag": "jq-1.7.1", "asset": "jq-macos-arm64" }),
638 size: None,
639 dependencies: vec![],
640 },
641 )
642 .unwrap();
643
644 assert!(lockfile.find_tool("jq").is_some());
645 assert!(lockfile.find_tool("yq").is_none());
646 }
647
648 #[test]
649 fn test_upsert_tool_platform() {
650 let mut lockfile = Lockfile::new();
651
652 lockfile
654 .upsert_tool_platform(
655 "bun",
656 "1.3.5",
657 "darwin-arm64",
658 LockedToolPlatform {
659 provider: "github".to_string(),
660 digest: "sha256:aaa".to_string(),
661 source: serde_json::json!({ "url": "https://..." }),
662 size: None,
663 dependencies: vec![],
664 },
665 )
666 .unwrap();
667
668 assert_eq!(lockfile.tools.len(), 1);
669 assert_eq!(lockfile.tools["bun"].platforms.len(), 1);
670
671 lockfile
673 .upsert_tool_platform(
674 "bun",
675 "1.3.5",
676 "linux-x86_64",
677 LockedToolPlatform {
678 provider: "oci".to_string(),
679 digest: "sha256:bbb".to_string(),
680 source: serde_json::json!({ "image": "oven/bun:1.3.5" }),
681 size: None,
682 dependencies: vec![],
683 },
684 )
685 .unwrap();
686
687 assert_eq!(lockfile.tools.len(), 1);
688 assert_eq!(lockfile.tools["bun"].platforms.len(), 2);
689 assert_eq!(
690 lockfile.tools["bun"].platforms["darwin-arm64"].provider,
691 "github"
692 );
693 assert_eq!(
694 lockfile.tools["bun"].platforms["linux-x86_64"].provider,
695 "oci"
696 );
697 }
698
699 #[test]
700 fn test_upsert_tool_platform_invalid_digest() {
701 let mut lockfile = Lockfile::new();
702
703 let result = lockfile.upsert_tool_platform(
704 "jq",
705 "1.7.1",
706 "darwin-arm64",
707 LockedToolPlatform {
708 provider: "github".to_string(),
709 digest: "invalid".to_string(), source: serde_json::json!({}),
711 size: None,
712 dependencies: vec![],
713 },
714 );
715
716 assert!(result.is_err());
717 assert!(
718 result
719 .unwrap_err()
720 .to_string()
721 .contains("Invalid digest format")
722 );
723 }
724
725 #[test]
726 fn test_upsert_tool_validation_empty_platforms() {
727 let mut lockfile = Lockfile::new();
728
729 let tool = LockedTool {
730 version: "1.7.1".to_string(),
731 platforms: BTreeMap::new(), };
733
734 let result = lockfile.upsert_tool("jq".to_string(), tool);
735 assert!(result.is_err());
736 assert!(
737 result
738 .unwrap_err()
739 .to_string()
740 .contains("at least one platform")
741 );
742 }
743
744 #[test]
745 fn test_tool_names() {
746 let mut lockfile = Lockfile::new();
747
748 lockfile
749 .upsert_tool_platform(
750 "jq",
751 "1.7.1",
752 "darwin-arm64",
753 LockedToolPlatform {
754 provider: "github".to_string(),
755 digest: "sha256:abc".to_string(),
756 source: serde_json::json!({}),
757 size: None,
758 dependencies: vec![],
759 },
760 )
761 .unwrap();
762
763 lockfile
764 .upsert_tool_platform(
765 "yq",
766 "4.44.6",
767 "darwin-arm64",
768 LockedToolPlatform {
769 provider: "github".to_string(),
770 digest: "sha256:def".to_string(),
771 source: serde_json::json!({}),
772 size: None,
773 dependencies: vec![],
774 },
775 )
776 .unwrap();
777
778 let names = lockfile.tool_names();
779 assert_eq!(names.len(), 2);
780 assert!(names.contains(&"jq"));
781 assert!(names.contains(&"yq"));
782 }
783}