1use serde::{Deserialize, Serialize};
25use sha2::{Digest, Sha256};
26use std::{fs, path::Path};
27
28#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
33#[serde(rename_all = "camelCase")]
34pub struct LockedDependency {
35 pub name: String,
37 pub registry: String,
39 pub resolved_version: String,
41 pub download_url: String,
43 pub sha256: String,
45 pub output_path: String,
47 pub group_id: String,
49 pub artifact_id: String,
51 pub version_spec: String,
53}
54
55#[derive(Serialize, Deserialize, Debug)]
60#[serde(rename_all = "camelCase")]
61pub struct LockFile {
62 pub locked_dependencies: Vec<LockedDependency>,
64 pub lockfile_version: u32,
66 pub config_hash: String,
68 pub generated_at: String,
70 pub config_modified: Option<String>,
72}
73
74impl LockFile {
75 pub fn load(path: &Path) -> anyhow::Result<Self> {
86 let data = fs::read_to_string(path)?;
87 let lf: LockFile = serde_yaml::from_str(&data)?;
88 Ok(lf)
89 }
90
91 pub fn save(&self, path: &Path) -> anyhow::Result<()> {
99 let data = serde_yaml::to_string(self)?;
100 fs::write(path, data)?;
101 Ok(())
102 }
103
104 #[allow(dead_code)]
110 pub fn new(locked_dependencies: Vec<LockedDependency>, config_hash: String) -> Self {
111 Self::with_config_modified(locked_dependencies, config_hash, None)
112 }
113
114 pub fn with_config_modified(
121 locked_dependencies: Vec<LockedDependency>,
122 config_hash: String,
123 config_modified: Option<String>,
124 ) -> Self {
125 let now = chrono::Utc::now()
126 .timestamp_nanos_opt()
127 .unwrap_or(0)
128 .to_string();
129
130 Self {
131 locked_dependencies,
132 lockfile_version: 1,
133 config_hash,
134 generated_at: now,
135 config_modified,
136 }
137 }
138
139 pub fn is_compatible_with_config(&self, config_hash: &str) -> bool {
141 self.config_hash == config_hash
142 }
143
144 pub fn is_newer_than_config(&self, config_path: &Path) -> anyhow::Result<bool> {
146 if let Some(config_modified_str) = &self.config_modified {
147 if let Ok(config_modified_nanos) = config_modified_str.parse::<i64>() {
148 if let Ok(metadata) = fs::metadata(config_path) {
149 if let Ok(actual_modified) = metadata.modified() {
150 let actual_nanos = chrono::DateTime::<chrono::Utc>::from(actual_modified)
151 .timestamp_nanos_opt()
152 .unwrap_or(0);
153 return Ok(config_modified_nanos >= actual_nanos);
154 }
155 }
156 }
157 }
158 Ok(false)
160 }
161
162 #[allow(dead_code)]
164 pub fn is_up_to_date(
165 &self,
166 config_path: &Path,
167 current_config_hash: &str,
168 dependencies: &[LockedDependency],
169 ) -> anyhow::Result<bool> {
170 if !self.is_compatible_with_config(current_config_hash) {
172 return Ok(false);
173 }
174
175 if !self.is_newer_than_config(config_path)? {
177 return Ok(false);
178 }
179
180 if !self.dependencies_match(dependencies) {
182 return Ok(false);
183 }
184
185 Ok(true)
186 }
187
188 #[allow(dead_code)]
190 pub fn dependencies_match(&self, other_deps: &[LockedDependency]) -> bool {
191 if self.locked_dependencies.len() != other_deps.len() {
192 return false;
193 }
194
195 let self_map: std::collections::HashMap<&str, &LockedDependency> = self
197 .locked_dependencies
198 .iter()
199 .map(|d| (d.name.as_str(), d))
200 .collect();
201 let other_map: std::collections::HashMap<&str, &LockedDependency> =
202 other_deps.iter().map(|d| (d.name.as_str(), d)).collect();
203
204 self_map.len() == other_map.len()
206 && self_map.iter().all(|(name, dep)| {
207 other_map
208 .get(name)
209 .is_some_and(|other_dep| **dep == **other_dep)
210 })
211 }
212
213 pub fn compute_config_hash(
216 config_content: &str,
217 dependencies: &[crate::config::DependencyConfig],
218 ) -> String {
219 let mut hasher = Sha256::new();
220
221 let mut dep_specs: Vec<String> = dependencies
224 .iter()
225 .map(|d| {
226 format!(
227 "{}:{}:{}:{}:{}:{}",
228 d.name, d.group_id, d.artifact_id, d.version, d.registry, d.output_path
229 )
230 })
231 .collect();
232 dep_specs.sort();
233
234 for spec in dep_specs {
235 hasher.update(spec.as_bytes());
236 }
237
238 if let Ok(config) = serde_yaml::from_str::<crate::config::RepoConfig>(config_content) {
241 let mut registry_specs: Vec<String> = config
243 .registries
244 .iter()
245 .map(|r| format!("{}:{}", r.name, r.url))
246 .collect();
247 registry_specs.sort();
248
249 for spec in registry_specs {
250 hasher.update(spec.as_bytes());
251 }
252
253 if let Some(ext_file) = &config.external_registries_file {
255 hasher.update(ext_file.as_bytes());
256 }
257 }
258
259 hex::encode(hasher.finalize())
260 }
261
262 pub fn get_config_modification_time(config_path: &Path) -> anyhow::Result<String> {
264 let metadata = fs::metadata(config_path)?;
265 let modified = metadata.modified()?;
266 let nanos = chrono::DateTime::<chrono::Utc>::from(modified)
267 .timestamp_nanos_opt()
268 .unwrap_or(0);
269 Ok(nanos.to_string())
270 }
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 fn create_test_config(dependencies: &[(&str, &str, &str, &str, &str, &str)]) -> String {
278 let mut deps = String::new();
279 for (name, group_id, artifact_id, version, registry, output_path) in dependencies {
280 deps.push_str(&format!(
281 r#"
282 - name: "{name}"
283 groupId: "{group_id}"
284 artifactId: "{artifact_id}"
285 version: "{version}"
286 registry: "{registry}"
287 outputPath: "{output_path}"
288"#
289 ));
290 }
291
292 format!(
293 r#"externalRegistriesFile: null
294registries: []
295dependencies:{deps}"#
296 )
297 }
298
299 fn create_test_locked_dependency(
300 name: &str,
301 registry: &str,
302 resolved_version: &str,
303 group_id: &str,
304 artifact_id: &str,
305 version_spec: &str,
306 ) -> LockedDependency {
307 LockedDependency {
308 name: name.to_string(),
309 registry: registry.to_string(),
310 resolved_version: resolved_version.to_string(),
311 download_url: format!(
312 "https://example.com/{group_id}/{artifact_id}/{resolved_version}"
313 ),
314 sha256: "dummy_hash".to_string(),
315 output_path: "./protos".to_string(),
316 group_id: group_id.to_string(),
317 artifact_id: artifact_id.to_string(),
318 version_spec: version_spec.to_string(),
319 }
320 }
321
322 #[test]
323 fn test_config_hash_computation() {
324 let config1 = create_test_config(&[(
325 "dep1",
326 "com.example",
327 "service1",
328 "1.0.0",
329 "registry1",
330 "./protos",
331 )]);
332
333 let config2 = create_test_config(&[(
334 "dep1",
335 "com.example",
336 "service1",
337 "1.0.0",
338 "registry1",
339 "./protos",
340 )]);
341
342 let config3 = create_test_config(&[(
343 "dep1",
344 "com.example",
345 "service1",
346 "1.1.0",
347 "registry1",
348 "./protos",
349 )]);
350
351 use crate::config::DependencyConfig;
352 let deps1 = vec![DependencyConfig {
353 name: "dep1".to_string(),
354 group_id: "com.example".to_string(),
355 artifact_id: "service1".to_string(),
356 version: "1.0.0".to_string(),
357 registry: "registry1".to_string(),
358 output_path: "./protos".to_string(),
359 }];
360
361 let deps3 = vec![DependencyConfig {
362 name: "dep1".to_string(),
363 group_id: "com.example".to_string(),
364 artifact_id: "service1".to_string(),
365 version: "1.1.0".to_string(),
366 registry: "registry1".to_string(),
367 output_path: "./protos".to_string(),
368 }];
369
370 let hash1 = LockFile::compute_config_hash(&config1, &deps1);
371 let hash2 = LockFile::compute_config_hash(&config2, &deps1);
372 let hash3 = LockFile::compute_config_hash(&config3, &deps3);
373
374 assert_eq!(hash1, hash2, "Same config should produce same hash");
375 assert_ne!(
376 hash1, hash3,
377 "Different config should produce different hash"
378 );
379 }
380
381 #[test]
382 fn test_dependencies_match_order_independence() {
383 let dep1 = create_test_locked_dependency(
384 "dep1",
385 "reg1",
386 "1.0.0",
387 "com.example",
388 "service1",
389 "^1.0",
390 );
391 let dep2 = create_test_locked_dependency(
392 "dep2",
393 "reg1",
394 "2.0.0",
395 "com.example",
396 "service2",
397 "^2.0",
398 );
399
400 let deps_order1 = vec![dep1.clone(), dep2.clone()];
401 let deps_order2 = vec![dep2.clone(), dep1.clone()];
402
403 let lockfile = LockFile::new(deps_order1.clone(), "test_hash".to_string());
404
405 assert!(lockfile.dependencies_match(&deps_order1));
406 assert!(
407 lockfile.dependencies_match(&deps_order2),
408 "Order should not matter"
409 );
410 }
411
412 #[test]
413 fn test_dependencies_match_different_content() {
414 let dep1 = create_test_locked_dependency(
415 "dep1",
416 "reg1",
417 "1.0.0",
418 "com.example",
419 "service1",
420 "^1.0",
421 );
422 let dep2 = create_test_locked_dependency(
423 "dep2",
424 "reg1",
425 "2.0.0",
426 "com.example",
427 "service2",
428 "^2.0",
429 );
430 let dep1_modified = create_test_locked_dependency(
431 "dep1",
432 "reg1",
433 "1.1.0",
434 "com.example",
435 "service1",
436 "^1.0",
437 );
438
439 let deps1 = vec![dep1.clone(), dep2.clone()];
440 let deps2 = vec![dep1_modified, dep2.clone()];
441
442 let lockfile = LockFile::new(deps1.clone(), "test_hash".to_string());
443
444 assert!(lockfile.dependencies_match(&deps1));
445 assert!(
446 !lockfile.dependencies_match(&deps2),
447 "Different versions should not match"
448 );
449 }
450
451 #[test]
452 fn test_config_compatibility() {
453 let dep1 = create_test_locked_dependency(
454 "dep1",
455 "reg1",
456 "1.0.0",
457 "com.example",
458 "service1",
459 "^1.0",
460 );
461
462 let lockfile = LockFile::new(vec![dep1], "test_hash".to_string());
463
464 assert!(lockfile.is_compatible_with_config("test_hash"));
465 assert!(!lockfile.is_compatible_with_config("different_hash"));
466 }
467
468 #[test]
469 fn test_lockfile_serialization() {
470 let dep1 = create_test_locked_dependency(
471 "dep1",
472 "reg1",
473 "1.0.0",
474 "com.example",
475 "service1",
476 "^1.0",
477 );
478 let lockfile = LockFile::new(vec![dep1], "test_hash".to_string());
479
480 let serialized = serde_yaml::to_string(&lockfile).unwrap();
481 let deserialized: LockFile = serde_yaml::from_str(&serialized).unwrap();
482
483 assert_eq!(lockfile.config_hash, deserialized.config_hash);
484 assert_eq!(lockfile.lockfile_version, deserialized.lockfile_version);
485 assert_eq!(
486 lockfile.locked_dependencies.len(),
487 deserialized.locked_dependencies.len()
488 );
489 assert!(lockfile.dependencies_match(&deserialized.locked_dependencies));
490 }
491
492 #[test]
493 fn test_empty_dependencies() {
494 let lockfile = LockFile::new(vec![], "test_hash".to_string());
495
496 assert!(lockfile.dependencies_match(&[]));
497 assert!(
498 !lockfile.dependencies_match(&[create_test_locked_dependency(
499 "dep1",
500 "reg1",
501 "1.0.0",
502 "com.example",
503 "service1",
504 "^1.0"
505 )])
506 );
507 }
508
509 #[test]
510 fn test_missing_dependency() {
511 let dep1 = create_test_locked_dependency(
512 "dep1",
513 "reg1",
514 "1.0.0",
515 "com.example",
516 "service1",
517 "^1.0",
518 );
519 let dep2 = create_test_locked_dependency(
520 "dep2",
521 "reg1",
522 "2.0.0",
523 "com.example",
524 "service2",
525 "^2.0",
526 );
527
528 let lockfile = LockFile::new(vec![dep1.clone(), dep2.clone()], "test_hash".to_string());
529
530 assert!(!lockfile.dependencies_match(&[dep1])); assert!(!lockfile.dependencies_match(&[dep2])); }
533
534 #[test]
535 fn test_config_hash_deterministic_ordering() {
536 let deps1 = vec![
538 crate::config::DependencyConfig {
539 name: "dep_a".to_string(),
540 group_id: "com.example".to_string(),
541 artifact_id: "service_a".to_string(),
542 version: "1.0.0".to_string(),
543 registry: "registry1".to_string(),
544 output_path: "./protos".to_string(),
545 },
546 crate::config::DependencyConfig {
547 name: "dep_b".to_string(),
548 group_id: "com.example".to_string(),
549 artifact_id: "service_b".to_string(),
550 version: "2.0.0".to_string(),
551 registry: "registry1".to_string(),
552 output_path: "./protos".to_string(),
553 },
554 ];
555
556 let deps2 = vec![deps1[1].clone(), deps1[0].clone()]; let config_content = "test config";
559 let hash1 = LockFile::compute_config_hash(config_content, &deps1);
560 let hash2 = LockFile::compute_config_hash(config_content, &deps2);
561
562 assert_eq!(hash1, hash2, "Config hash should be order-independent");
563 }
564
565 #[test]
566 fn test_enhanced_config_hash_ignores_formatting() {
567 let deps = vec![crate::config::DependencyConfig {
569 name: "dep1".to_string(),
570 group_id: "com.example".to_string(),
571 artifact_id: "service1".to_string(),
572 version: "1.0.0".to_string(),
573 registry: "registry1".to_string(),
574 output_path: "./protos".to_string(),
575 }];
576
577 let config1 = r#"
579externalRegistriesFile: null
580registries: []
581dependencies:
582 - name: dep1
583 groupId: com.example
584 artifactId: service1
585 version: "1.0.0"
586 registry: registry1
587 outputPath: ./protos
588"#;
589
590 let config2 = r#"
591externalRegistriesFile: null
592registries: []
593# This is a comment
594dependencies:
595 - name: dep1
596 groupId: com.example
597 artifactId: service1
598 version: "1.0.0"
599 registry: registry1
600 outputPath: ./protos
601# Another comment
602"#;
603
604 let hash1 = LockFile::compute_config_hash(config1, &deps);
605 let hash2 = LockFile::compute_config_hash(config2, &deps);
606
607 assert_eq!(
608 hash1, hash2,
609 "Config hash should ignore comments and formatting"
610 );
611 }
612
613 #[test]
614 fn test_with_config_modified() {
615 let dep1 = create_test_locked_dependency(
616 "dep1",
617 "reg1",
618 "1.0.0",
619 "com.example",
620 "service1",
621 "^1.0",
622 );
623 let config_modified = Some("1234567890123456789".to_string());
624
625 let lockfile = LockFile::with_config_modified(
626 vec![dep1],
627 "test_hash".to_string(),
628 config_modified.clone(),
629 );
630
631 assert_eq!(lockfile.config_modified, config_modified);
632 assert!(lockfile.generated_at.parse::<i64>().is_ok());
633 }
634
635 #[test]
636 fn test_is_newer_than_config_with_missing_data() {
637 let dep1 = create_test_locked_dependency(
638 "dep1",
639 "reg1",
640 "1.0.0",
641 "com.example",
642 "service1",
643 "^1.0",
644 );
645
646 let lockfile = LockFile::new(vec![dep1.clone()], "test_hash".to_string());
648 let result = lockfile
649 .is_newer_than_config(Path::new("nonexistent"))
650 .unwrap();
651 assert!(
652 !result,
653 "Should return false when config_modified is missing"
654 );
655
656 let mut lockfile_invalid = LockFile::new(vec![dep1], "test_hash".to_string());
658 lockfile_invalid.config_modified = Some("invalid_number".to_string());
659 let result = lockfile_invalid
660 .is_newer_than_config(Path::new("nonexistent"))
661 .unwrap();
662 assert!(
663 !result,
664 "Should return false when config_modified is invalid"
665 );
666 }
667
668 #[test]
669 fn test_lockfile_backwards_compatibility() {
670 let old_lockfile_yaml = r#"
672lockfileVersion: 1
673configHash: "test_hash"
674generatedAt: "1234567890"
675lockedDependencies:
676 - name: "dep1"
677 registry: "reg1"
678 resolvedVersion: "1.0.0"
679 downloadUrl: "https://example.com/dep1"
680 sha256: "dummy_hash"
681 outputPath: "./protos"
682 groupId: "com.example"
683 artifactId: "service1"
684 versionSpec: "^1.0"
685"#;
686
687 let lockfile: LockFile = serde_yaml::from_str(old_lockfile_yaml).unwrap();
688 assert!(lockfile.config_modified.is_none());
689 assert_eq!(lockfile.config_hash, "test_hash");
690 assert_eq!(lockfile.locked_dependencies.len(), 1);
691 }
692
693 #[test]
694 fn test_robust_dependency_matching() {
695 let dep1_v1 = create_test_locked_dependency(
696 "dep1",
697 "reg1",
698 "1.0.0",
699 "com.example",
700 "service1",
701 "^1.0",
702 );
703 let dep1_v2 = create_test_locked_dependency(
704 "dep1",
705 "reg1",
706 "1.0.1",
707 "com.example",
708 "service1",
709 "^1.0",
710 );
711 let dep2 = create_test_locked_dependency(
712 "dep2",
713 "reg1",
714 "2.0.0",
715 "com.example",
716 "service2",
717 "^2.0",
718 );
719
720 let lockfile = LockFile::new(vec![dep1_v1.clone(), dep2.clone()], "test_hash".to_string());
721
722 assert!(lockfile.dependencies_match(&[dep1_v1.clone(), dep2.clone()]));
724 assert!(lockfile.dependencies_match(&[dep2.clone(), dep1_v1.clone()])); assert!(!lockfile.dependencies_match(&[dep1_v2, dep2.clone()]));
728
729 assert!(!lockfile.dependencies_match(&[dep1_v1.clone()]));
731
732 let dep3 = create_test_locked_dependency(
734 "dep3",
735 "reg1",
736 "3.0.0",
737 "com.example",
738 "service3",
739 "^3.0",
740 );
741 assert!(!lockfile.dependencies_match(&[dep1_v1.clone(), dep2.clone(), dep3]));
742 }
743}