apicurio_cli/
lockfile.rs

1//! Lock file management for reproducible builds
2//!
3//! This module handles the creation, loading, and validation of lock files that ensure
4//! reproducible builds by recording exact versions, download URLs, and content hashes
5//! of all dependencies.
6//!
7//! ## Lock File Format
8//!
9//! The lock file (`apicuriolock.yaml`) contains:
10//! - Exact resolved versions of all dependencies
11//! - Download URLs used to fetch artifacts
12//! - SHA256 checksums for integrity verification
13//! - Metadata about when the lock was generated
14//! - Hash of the configuration that generated the lock
15//!
16//! ## Integrity Verification
17//!
18//! Lock files include multiple layers of integrity verification:
19//! - Configuration hash to detect config changes
20//! - File modification timestamps
21//! - SHA256 checksums of downloaded content
22//! - Lockfile format version for compatibility
23
24use convert_case::{Case, Casing};
25use serde::{Deserialize, Serialize};
26use sha2::{Digest, Sha256};
27use std::{fs, path::Path};
28
29/// Generate output path for a transitive dependency using the pattern
30pub fn generate_transitive_output_path(
31    pattern: &str,
32    group_id: &str,
33    artifact_id: &str,
34    version: &str,
35    artifact_type: &str,
36) -> String {
37    let ext = match artifact_type.to_lowercase().as_str() {
38        "protobuf" => "proto",
39        "avro" => "avsc",
40        "json" => "json",
41        "openapi" => "yaml",
42        "asyncapi" => "yaml",
43        "graphql" => "graphql",
44        "xml" => "xsd",
45        "wsdl" => "wsdl",
46        _ => "txt",
47    };
48
49    expand_output_pattern(pattern, group_id, artifact_id, version, ext)
50}
51
52/// Advanced pattern expansion with support for complex artifact ID transformations
53pub fn expand_output_pattern(
54    pattern: &str,
55    group_id: &str,
56    artifact_id: &str,
57    version: &str,
58    ext: &str,
59) -> String {
60    let mut result = pattern.to_string();
61
62    // Basic replacements
63    result = result.replace("{groupId}", group_id);
64    result = result.replace("{artifactId}", artifact_id);
65    result = result.replace("{version}", version);
66    result = result.replace("{ext}", ext);
67
68    // Advanced artifact ID transformations
69    // Split artifact ID by dots for path-like transformations
70    let artifact_parts: Vec<&str> = artifact_id.split('.').collect();
71
72    // Replace {artifactId.path} with dot-separated parts as path segments, excluding the last part
73    // e.g., "sp.frame.Frame" -> "sp/frame"
74    if result.contains("{artifactId.path}") {
75        let path_version = if artifact_parts.len() > 1 {
76            artifact_parts[..artifact_parts.len() - 1].join("/")
77        } else {
78            String::new() // If no dots, path is empty
79        };
80        result = result.replace("{artifactId.path}", &path_version);
81    }
82
83    // Replace {artifactId.fullPath} with all dot-separated parts as path segments
84    // e.g., "sp.frame.Frame" -> "sp/frame/Frame"
85    if result.contains("{artifactId.fullPath}") {
86        let full_path_version = artifact_parts.join("/");
87        result = result.replace("{artifactId.fullPath}", &full_path_version);
88    }
89
90    // Replace {artifactId.snake_case} with snake_case version
91    // e.g., "sp.frame.Frame" -> "sp_frame_frame"
92    if result.contains("{artifactId.snake_case}") {
93        let snake_case = artifact_id.replace('.', "_").to_lowercase();
94        result = result.replace("{artifactId.snake_case}", &snake_case);
95    }
96
97    // Replace {artifactId.kebab_case} with kebab-case version
98    // e.g., "sp.frame.Frame" -> "sp-frame-frame"
99    if result.contains("{artifactId.kebab_case}") {
100        let kebab_case = artifact_id.replace('.', "-").to_lowercase();
101        result = result.replace("{artifactId.kebab_case}", &kebab_case);
102    }
103
104    // Replace {artifactId.lowercase} with lowercase version
105    if result.contains("{artifactId.lowercase}") {
106        result = result.replace("{artifactId.lowercase}", &artifact_id.to_lowercase());
107    }
108
109    // Replace {artifactId.last} with the last part after final dot
110    // e.g., "sp.frame.Frame" -> "Frame"
111    if result.contains("{artifactId.last}") {
112        let last_part = artifact_parts.last().unwrap_or(&artifact_id);
113        result = result.replace("{artifactId.last}", last_part);
114    }
115
116    // Replace {artifactId.lastLowercase} with the last part in lowercase
117    if result.contains("{artifactId.lastLowercase}") {
118        let last_part = artifact_parts.last().unwrap_or(&artifact_id).to_lowercase();
119        result = result.replace("{artifactId.lastLowercase}", &last_part);
120    }
121
122    // Replace {artifactId.lastSnakeCase} with the last part converted to snake_case
123    if result.contains("{artifactId.lastSnakeCase}") {
124        let last_part = artifact_parts.last().unwrap_or(&artifact_id);
125        let snake_case_part = last_part.to_case(Case::Snake);
126        result = result.replace("{artifactId.lastSnakeCase}", &snake_case_part);
127    }
128
129    // Replace indexed artifact parts {artifactParts[0]}, {artifactParts[1]}, etc.
130    for (i, part) in artifact_parts.iter().enumerate() {
131        let placeholder = format!("{{artifactParts[{i}]}}");
132        result = result.replace(&placeholder, part);
133    }
134
135    result
136}
137
138/// Check output overrides and mappings to determine the final output path
139/// Returns None if the artifact should be skipped (mapped to null)
140pub fn resolve_output_path(
141    base_pattern: &str,
142    output_overrides: &std::collections::HashMap<String, Option<String>>,
143    registry: &str,
144    group_id: &str,
145    artifact_id: &str,
146    version: &str,
147    artifact_type: &str,
148) -> Option<String> {
149    // Check for exact matches in order of specificity:
150    // 1. registry:groupId/artifactId
151    // 2. groupId/artifactId
152
153    let registry_key = format!("{registry}:{group_id}/{artifact_id}");
154    let group_key = format!("{group_id}/{artifact_id}");
155
156    if let Some(override_pattern) = output_overrides.get(&registry_key) {
157        override_pattern.as_ref().map(|pattern| {
158            expand_output_pattern(
159                pattern,
160                group_id,
161                artifact_id,
162                version,
163                &get_extension_for_type(artifact_type),
164            )
165        })
166    } else if let Some(override_pattern) = output_overrides.get(&group_key) {
167        override_pattern.as_ref().map(|pattern| {
168            expand_output_pattern(
169                pattern,
170                group_id,
171                artifact_id,
172                version,
173                &get_extension_for_type(artifact_type),
174            )
175        })
176    } else {
177        Some(generate_transitive_output_path(
178            base_pattern,
179            group_id,
180            artifact_id,
181            version,
182            artifact_type,
183        ))
184    }
185}
186
187fn get_extension_for_type(artifact_type: &str) -> String {
188    match artifact_type.to_lowercase().as_str() {
189        "protobuf" => "proto".to_string(),
190        "avro" => "avsc".to_string(),
191        "json" => "json".to_string(),
192        "openapi" => "yaml".to_string(),
193        "asyncapi" => "yaml".to_string(),
194        "graphql" => "graphql".to_string(),
195        "xml" => "xsd".to_string(),
196        "wsdl" => "wsdl".to_string(),
197        _ => "txt".to_string(),
198    }
199}
200
201/// A locked dependency with exact version and integrity information
202///
203/// Represents a dependency that has been resolved to an exact version
204/// with all information needed for reproducible fetching.
205#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
206#[serde(rename_all = "camelCase")]
207pub struct LockedDependency {
208    /// Local name/alias of the dependency
209    pub name: String,
210    /// Registry name where this dependency was resolved
211    pub registry: String,
212    /// Exact resolved version (no semver ranges)
213    pub resolved_version: String,
214    /// Full URL used to download the artifact
215    pub download_url: String,
216    /// SHA256 checksum of the downloaded content
217    pub sha256: String,
218    /// Local path where the artifact is stored
219    pub output_path: String,
220    /// Group ID of the artifact
221    pub group_id: String,
222    /// Artifact ID in the registry
223    pub artifact_id: String,
224    /// Original version specification from config (e.g., "^1.0.0")
225    pub version_spec: String,
226    /// Whether this dependency was resolved transitively from references
227    #[serde(default)]
228    pub is_transitive: bool,
229}
230
231/// Lock file containing all resolved dependencies and metadata
232///
233/// The lock file ensures reproducible builds by recording exact versions
234/// and integrity information for all dependencies.
235#[derive(Serialize, Deserialize, Debug)]
236#[serde(rename_all = "camelCase")]
237pub struct LockFile {
238    /// List of all locked dependencies
239    pub locked_dependencies: Vec<LockedDependency>,
240    /// Version of the lockfile format for compatibility
241    pub lockfile_version: u32,
242    /// Hash of the configuration that generated this lock
243    pub config_hash: String,
244    /// Timestamp when this lock was generated (nanoseconds since epoch)
245    pub generated_at: String,
246    /// Configuration file modification time (nanoseconds since epoch)
247    pub config_modified: Option<String>,
248}
249
250impl LockFile {
251    /// Load a lock file from disk
252    ///
253    /// # Arguments
254    /// * `path` - Path to the lock file
255    ///
256    /// # Returns
257    /// Parsed lock file structure
258    ///
259    /// # Errors
260    /// Returns error if file cannot be read or parsed as valid YAML
261    pub fn load(path: &Path) -> anyhow::Result<Self> {
262        let data = fs::read_to_string(path)?;
263        let lf: LockFile = serde_yaml::from_str(&data)?;
264        Ok(lf)
265    }
266
267    /// Save the lock file to disk
268    ///
269    /// # Arguments
270    /// * `path` - Path where to save the lock file
271    ///
272    /// # Errors
273    /// Returns error if file cannot be written or serialized
274    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
275        let data = serde_yaml::to_string(self)?;
276        fs::write(path, data)?;
277        Ok(())
278    }
279
280    /// Create a new lockfile with current timestamp and version
281    ///
282    /// # Arguments
283    /// * `locked_dependencies` - Vector of resolved dependencies
284    /// * `config_hash` - Hash of the configuration that generated this lock
285    #[allow(dead_code)]
286    pub fn new(locked_dependencies: Vec<LockedDependency>, config_hash: String) -> Self {
287        Self::with_config_modified(locked_dependencies, config_hash, None)
288    }
289
290    /// Create a new lockfile with config modification time
291    ///
292    /// # Arguments
293    /// * `locked_dependencies` - Vector of resolved dependencies
294    /// * `config_hash` - Hash of the configuration
295    /// * `config_modified` - Optional config file modification timestamp
296    pub fn with_config_modified(
297        locked_dependencies: Vec<LockedDependency>,
298        config_hash: String,
299        config_modified: Option<String>,
300    ) -> Self {
301        let now = chrono::Utc::now()
302            .timestamp_nanos_opt()
303            .unwrap_or(0)
304            .to_string();
305
306        Self {
307            locked_dependencies,
308            lockfile_version: 1,
309            config_hash,
310            generated_at: now,
311            config_modified,
312        }
313    }
314
315    /// Check if this lockfile is compatible with the given config hash
316    pub fn is_compatible_with_config(&self, config_hash: &str) -> bool {
317        self.config_hash == config_hash
318    }
319
320    /// Check if the lockfile is up-to-date based on config file modification time
321    pub fn is_newer_than_config(&self, config_path: &Path) -> anyhow::Result<bool> {
322        if let Some(config_modified_str) = &self.config_modified {
323            if let Ok(config_modified_nanos) = config_modified_str.parse::<i64>() {
324                if let Ok(metadata) = fs::metadata(config_path) {
325                    if let Ok(actual_modified) = metadata.modified() {
326                        let actual_nanos = chrono::DateTime::<chrono::Utc>::from(actual_modified)
327                            .timestamp_nanos_opt()
328                            .unwrap_or(0);
329                        return Ok(config_modified_nanos >= actual_nanos);
330                    }
331                }
332            }
333        }
334        // If we can't determine modification times, fall back to hash comparison
335        Ok(false)
336    }
337
338    /// Enhanced lockfile validation that checks multiple criteria
339    #[allow(dead_code)]
340    pub fn is_up_to_date(
341        &self,
342        config_path: &Path,
343        current_config_hash: &str,
344        dependencies: &[LockedDependency],
345    ) -> anyhow::Result<bool> {
346        // 1. Check config hash compatibility
347        if !self.is_compatible_with_config(current_config_hash) {
348            return Ok(false);
349        }
350
351        // 2. Check if config file was modified after lockfile was generated
352        if !self.is_newer_than_config(config_path)? {
353            return Ok(false);
354        }
355
356        // 3. Check that dependencies match exactly
357        if !self.dependencies_match(dependencies) {
358            return Ok(false);
359        }
360
361        Ok(true)
362    }
363
364    /// Compare two sets of locked dependencies, accounting for order independence
365    #[allow(dead_code)]
366    pub fn dependencies_match(&self, other_deps: &[LockedDependency]) -> bool {
367        if self.locked_dependencies.len() != other_deps.len() {
368            return false;
369        }
370
371        // Create maps for order-independent comparison
372        let self_map: std::collections::HashMap<&str, &LockedDependency> = self
373            .locked_dependencies
374            .iter()
375            .map(|d| (d.name.as_str(), d))
376            .collect();
377        let other_map: std::collections::HashMap<&str, &LockedDependency> =
378            other_deps.iter().map(|d| (d.name.as_str(), d)).collect();
379
380        // Check that all dependencies match exactly
381        self_map.len() == other_map.len()
382            && self_map.iter().all(|(name, dep)| {
383                other_map
384                    .get(name)
385                    .is_some_and(|other_dep| **dep == **other_dep)
386            })
387    }
388
389    /// Compute a hash of the relevant configuration that affects locking
390    /// This focuses only on the dependency specifications, not formatting/comments
391    pub fn compute_config_hash(
392        config_content: &str,
393        dependencies: &[crate::config::DependencyConfig],
394    ) -> String {
395        let mut hasher = Sha256::new();
396
397        // Only hash the dependency specifications in a deterministic order
398        // This avoids regeneration due to formatting/comment changes
399        let mut dep_specs: Vec<String> = dependencies
400            .iter()
401            .map(|d| {
402                format!(
403                    "{}:{}:{}:{}:{}:{}",
404                    d.name,
405                    d.resolved_group_id(),
406                    d.resolved_artifact_id(),
407                    d.version,
408                    d.registry,
409                    d.output_path
410                )
411            })
412            .collect();
413        dep_specs.sort();
414
415        for spec in dep_specs {
416            hasher.update(spec.as_bytes());
417        }
418
419        // Also include a simplified version of other config that affects dependency resolution
420        // Parse the config to extract only relevant fields
421        if let Ok(config) = serde_yaml::from_str::<crate::config::RepoConfig>(config_content) {
422            // Include registry configurations as they affect resolution
423            let mut registry_specs: Vec<String> = config
424                .registries
425                .iter()
426                .map(|r| format!("{}:{}", r.name, r.url))
427                .collect();
428            registry_specs.sort();
429
430            for spec in registry_specs {
431                hasher.update(spec.as_bytes());
432            }
433
434            // Include external registries file path if present
435            if let Some(ext_file) = &config.external_registries_file {
436                hasher.update(ext_file.as_bytes());
437            }
438        }
439
440        hex::encode(hasher.finalize())
441    }
442
443    /// Get the modification time of a config file as nanoseconds since epoch
444    pub fn get_config_modification_time(config_path: &Path) -> anyhow::Result<String> {
445        let metadata = fs::metadata(config_path)?;
446        let modified = metadata.modified()?;
447        let nanos = chrono::DateTime::<chrono::Utc>::from(modified)
448            .timestamp_nanos_opt()
449            .unwrap_or(0);
450        Ok(nanos.to_string())
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    fn create_test_config(dependencies: &[(&str, &str, &str, &str, &str, &str)]) -> String {
459        let mut deps = String::new();
460        for (name, group_id, artifact_id, version, registry, output_path) in dependencies {
461            deps.push_str(&format!(
462                r#"
463  - name: "{name}"
464    groupId: "{group_id}"
465    artifactId: "{artifact_id}"
466    version: "{version}"
467    registry: "{registry}"
468    outputPath: "{output_path}"
469"#
470            ));
471        }
472
473        format!(
474            r#"externalRegistriesFile: null
475registries: []
476dependencies:{deps}"#
477        )
478    }
479
480    fn create_test_locked_dependency(
481        name: &str,
482        registry: &str,
483        resolved_version: &str,
484        group_id: &str,
485        artifact_id: &str,
486        version_spec: &str,
487    ) -> LockedDependency {
488        LockedDependency {
489            name: name.to_string(),
490            registry: registry.to_string(),
491            resolved_version: resolved_version.to_string(),
492            download_url: format!(
493                "https://example.com/{group_id}/{artifact_id}/{resolved_version}"
494            ),
495            sha256: "dummy_hash".to_string(),
496            output_path: "./protos".to_string(),
497            group_id: group_id.to_string(),
498            artifact_id: artifact_id.to_string(),
499            version_spec: version_spec.to_string(),
500            is_transitive: false,
501        }
502    }
503
504    #[test]
505    fn test_config_hash_computation() {
506        let config1 = create_test_config(&[(
507            "dep1",
508            "com.example",
509            "service1",
510            "1.0.0",
511            "registry1",
512            "./protos",
513        )]);
514
515        let config2 = create_test_config(&[(
516            "dep1",
517            "com.example",
518            "service1",
519            "1.0.0",
520            "registry1",
521            "./protos",
522        )]);
523
524        let config3 = create_test_config(&[(
525            "dep1",
526            "com.example",
527            "service1",
528            "1.1.0",
529            "registry1",
530            "./protos",
531        )]);
532
533        use crate::config::DependencyConfig;
534        let deps1 = vec![DependencyConfig {
535            name: "dep1".to_string(),
536            group_id: Some("com.example".to_string()),
537            artifact_id: Some("service1".to_string()),
538            version: "1.0.0".to_string(),
539            registry: "registry1".to_string(),
540            output_path: "./protos".to_string(),
541            resolve_references: None,
542        }];
543
544        let deps3 = vec![DependencyConfig {
545            name: "dep1".to_string(),
546            group_id: Some("com.example".to_string()),
547            artifact_id: Some("service1".to_string()),
548            version: "1.1.0".to_string(),
549            registry: "registry1".to_string(),
550            output_path: "./protos".to_string(),
551            resolve_references: None,
552        }];
553
554        let hash1 = LockFile::compute_config_hash(&config1, &deps1);
555        let hash2 = LockFile::compute_config_hash(&config2, &deps1);
556        let hash3 = LockFile::compute_config_hash(&config3, &deps3);
557
558        assert_eq!(hash1, hash2, "Same config should produce same hash");
559        assert_ne!(
560            hash1, hash3,
561            "Different config should produce different hash"
562        );
563    }
564
565    #[test]
566    fn test_dependencies_match_order_independence() {
567        let dep1 = create_test_locked_dependency(
568            "dep1",
569            "reg1",
570            "1.0.0",
571            "com.example",
572            "service1",
573            "^1.0",
574        );
575        let dep2 = create_test_locked_dependency(
576            "dep2",
577            "reg1",
578            "2.0.0",
579            "com.example",
580            "service2",
581            "^2.0",
582        );
583
584        let deps_order1 = vec![dep1.clone(), dep2.clone()];
585        let deps_order2 = vec![dep2.clone(), dep1.clone()];
586
587        let lockfile = LockFile::new(deps_order1.clone(), "test_hash".to_string());
588
589        assert!(lockfile.dependencies_match(&deps_order1));
590        assert!(
591            lockfile.dependencies_match(&deps_order2),
592            "Order should not matter"
593        );
594    }
595
596    #[test]
597    fn test_dependencies_match_different_content() {
598        let dep1 = create_test_locked_dependency(
599            "dep1",
600            "reg1",
601            "1.0.0",
602            "com.example",
603            "service1",
604            "^1.0",
605        );
606        let dep2 = create_test_locked_dependency(
607            "dep2",
608            "reg1",
609            "2.0.0",
610            "com.example",
611            "service2",
612            "^2.0",
613        );
614        let dep1_modified = create_test_locked_dependency(
615            "dep1",
616            "reg1",
617            "1.1.0",
618            "com.example",
619            "service1",
620            "^1.0",
621        );
622
623        let deps1 = vec![dep1.clone(), dep2.clone()];
624        let deps2 = vec![dep1_modified, dep2.clone()];
625
626        let lockfile = LockFile::new(deps1.clone(), "test_hash".to_string());
627
628        assert!(lockfile.dependencies_match(&deps1));
629        assert!(
630            !lockfile.dependencies_match(&deps2),
631            "Different versions should not match"
632        );
633    }
634
635    #[test]
636    fn test_config_compatibility() {
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], "test_hash".to_string());
647
648        assert!(lockfile.is_compatible_with_config("test_hash"));
649        assert!(!lockfile.is_compatible_with_config("different_hash"));
650    }
651
652    #[test]
653    fn test_lockfile_serialization() {
654        let dep1 = create_test_locked_dependency(
655            "dep1",
656            "reg1",
657            "1.0.0",
658            "com.example",
659            "service1",
660            "^1.0",
661        );
662        let lockfile = LockFile::new(vec![dep1], "test_hash".to_string());
663
664        let serialized = serde_yaml::to_string(&lockfile).unwrap();
665        let deserialized: LockFile = serde_yaml::from_str(&serialized).unwrap();
666
667        assert_eq!(lockfile.config_hash, deserialized.config_hash);
668        assert_eq!(lockfile.lockfile_version, deserialized.lockfile_version);
669        assert_eq!(
670            lockfile.locked_dependencies.len(),
671            deserialized.locked_dependencies.len()
672        );
673        assert!(lockfile.dependencies_match(&deserialized.locked_dependencies));
674    }
675
676    #[test]
677    fn test_empty_dependencies() {
678        let lockfile = LockFile::new(vec![], "test_hash".to_string());
679
680        assert!(lockfile.dependencies_match(&[]));
681        assert!(
682            !lockfile.dependencies_match(&[create_test_locked_dependency(
683                "dep1",
684                "reg1",
685                "1.0.0",
686                "com.example",
687                "service1",
688                "^1.0"
689            )])
690        );
691    }
692
693    #[test]
694    fn test_missing_dependency() {
695        let dep1 = create_test_locked_dependency(
696            "dep1",
697            "reg1",
698            "1.0.0",
699            "com.example",
700            "service1",
701            "^1.0",
702        );
703        let dep2 = create_test_locked_dependency(
704            "dep2",
705            "reg1",
706            "2.0.0",
707            "com.example",
708            "service2",
709            "^2.0",
710        );
711
712        let lockfile = LockFile::new(vec![dep1.clone(), dep2.clone()], "test_hash".to_string());
713
714        assert!(!lockfile.dependencies_match(&[dep1])); // Missing dep2
715        assert!(!lockfile.dependencies_match(&[dep2])); // Missing dep1
716    }
717
718    #[test]
719    fn test_config_hash_deterministic_ordering() {
720        // Test that dependency order in config doesn't affect hash
721        let deps1 = vec![
722            crate::config::DependencyConfig {
723                name: "dep_a".to_string(),
724                group_id: Some("com.example".to_string()),
725                artifact_id: Some("service_a".to_string()),
726                version: "1.0.0".to_string(),
727                registry: "registry1".to_string(),
728                output_path: "./protos".to_string(),
729                resolve_references: None,
730            },
731            crate::config::DependencyConfig {
732                name: "dep_b".to_string(),
733                group_id: Some("com.example".to_string()),
734                artifact_id: Some("service_b".to_string()),
735                version: "2.0.0".to_string(),
736                registry: "registry1".to_string(),
737                output_path: "./protos".to_string(),
738                resolve_references: None,
739            },
740        ];
741
742        let deps2 = vec![deps1[1].clone(), deps1[0].clone()]; // Reverse order
743
744        let config_content = "test config";
745        let hash1 = LockFile::compute_config_hash(config_content, &deps1);
746        let hash2 = LockFile::compute_config_hash(config_content, &deps2);
747
748        assert_eq!(hash1, hash2, "Config hash should be order-independent");
749    }
750
751    #[test]
752    fn test_enhanced_config_hash_ignores_formatting() {
753        // Test that the improved hash function ignores formatting differences
754        let deps = vec![crate::config::DependencyConfig {
755            name: "dep1".to_string(),
756            group_id: Some("com.example".to_string()),
757            artifact_id: Some("service1".to_string()),
758            version: "1.0.0".to_string(),
759            registry: "registry1".to_string(),
760            output_path: "./protos".to_string(),
761            resolve_references: None,
762        }];
763
764        // These configs have different formatting but same semantic content
765        let config1 = r#"
766externalRegistriesFile: null
767registries: []
768dependencies:
769  - name: dep1
770    groupId: com.example
771    artifactId: service1
772    version: "1.0.0"
773    registry: registry1
774    outputPath: ./protos
775"#;
776
777        let config2 = r#"
778externalRegistriesFile: null
779registries: []
780# This is a comment
781dependencies:
782  - name: dep1
783    groupId: com.example
784    artifactId: service1
785    version: "1.0.0"
786    registry: registry1
787    outputPath: ./protos
788# Another comment
789"#;
790
791        let hash1 = LockFile::compute_config_hash(config1, &deps);
792        let hash2 = LockFile::compute_config_hash(config2, &deps);
793
794        assert_eq!(
795            hash1, hash2,
796            "Config hash should ignore comments and formatting"
797        );
798    }
799
800    #[test]
801    fn test_with_config_modified() {
802        let dep1 = create_test_locked_dependency(
803            "dep1",
804            "reg1",
805            "1.0.0",
806            "com.example",
807            "service1",
808            "^1.0",
809        );
810        let config_modified = Some("1234567890123456789".to_string());
811
812        let lockfile = LockFile::with_config_modified(
813            vec![dep1],
814            "test_hash".to_string(),
815            config_modified.clone(),
816        );
817
818        assert_eq!(lockfile.config_modified, config_modified);
819        assert!(lockfile.generated_at.parse::<i64>().is_ok());
820    }
821
822    #[test]
823    fn test_is_newer_than_config_with_missing_data() {
824        let dep1 = create_test_locked_dependency(
825            "dep1",
826            "reg1",
827            "1.0.0",
828            "com.example",
829            "service1",
830            "^1.0",
831        );
832
833        // Test lockfile without config_modified
834        let lockfile = LockFile::new(vec![dep1.clone()], "test_hash".to_string());
835        let result = lockfile
836            .is_newer_than_config(Path::new("nonexistent"))
837            .unwrap();
838        assert!(
839            !result,
840            "Should return false when config_modified is missing"
841        );
842
843        // Test lockfile with invalid config_modified
844        let mut lockfile_invalid = LockFile::new(vec![dep1], "test_hash".to_string());
845        lockfile_invalid.config_modified = Some("invalid_number".to_string());
846        let result = lockfile_invalid
847            .is_newer_than_config(Path::new("nonexistent"))
848            .unwrap();
849        assert!(
850            !result,
851            "Should return false when config_modified is invalid"
852        );
853    }
854
855    #[test]
856    fn test_lockfile_backwards_compatibility() {
857        // Test that old lockfiles without config_modified still work
858        let old_lockfile_yaml = r#"
859lockfileVersion: 1
860configHash: "test_hash"
861generatedAt: "1234567890"
862lockedDependencies:
863  - name: "dep1"
864    registry: "reg1"
865    resolvedVersion: "1.0.0"
866    downloadUrl: "https://example.com/dep1"
867    sha256: "dummy_hash"
868    outputPath: "./protos"
869    groupId: "com.example"
870    artifactId: "service1"
871    versionSpec: "^1.0"
872"#;
873
874        let lockfile: LockFile = serde_yaml::from_str(old_lockfile_yaml).unwrap();
875        assert!(lockfile.config_modified.is_none());
876        assert_eq!(lockfile.config_hash, "test_hash");
877        assert_eq!(lockfile.locked_dependencies.len(), 1);
878    }
879
880    #[test]
881    fn test_robust_dependency_matching() {
882        let dep1_v1 = create_test_locked_dependency(
883            "dep1",
884            "reg1",
885            "1.0.0",
886            "com.example",
887            "service1",
888            "^1.0",
889        );
890        let dep1_v2 = create_test_locked_dependency(
891            "dep1",
892            "reg1",
893            "1.0.1",
894            "com.example",
895            "service1",
896            "^1.0",
897        );
898        let dep2 = create_test_locked_dependency(
899            "dep2",
900            "reg1",
901            "2.0.0",
902            "com.example",
903            "service2",
904            "^2.0",
905        );
906
907        let lockfile = LockFile::new(vec![dep1_v1.clone(), dep2.clone()], "test_hash".to_string());
908
909        // Exact match should work
910        assert!(lockfile.dependencies_match(&[dep1_v1.clone(), dep2.clone()]));
911        assert!(lockfile.dependencies_match(&[dep2.clone(), dep1_v1.clone()])); // Order independence
912
913        // Different version should fail
914        assert!(!lockfile.dependencies_match(&[dep1_v2, dep2.clone()]));
915
916        // Missing dependency should fail
917        assert!(!lockfile.dependencies_match(&[dep1_v1.clone()]));
918
919        // Extra dependency should fail
920        let dep3 = create_test_locked_dependency(
921            "dep3",
922            "reg1",
923            "3.0.0",
924            "com.example",
925            "service3",
926            "^3.0",
927        );
928        assert!(!lockfile.dependencies_match(&[dep1_v1.clone(), dep2.clone(), dep3]));
929    }
930}
931
932#[cfg(test)]
933mod pattern_tests {
934    use super::*;
935
936    #[test]
937    fn test_artifact_id_path_transformations() {
938        // Test artifactId.path (excludes last part)
939        let result = expand_output_pattern(
940            "protos/{artifactId.path}/{artifactId.lastLowercase}.{ext}",
941            "nprod",
942            "sp.frame.Frame",
943            "4.3.1",
944            "proto",
945        );
946        assert_eq!(result, "protos/sp/frame/frame.proto");
947
948        // Test artifactId.fullPath (includes last part)
949        let result = expand_output_pattern(
950            "schemas/{artifactId.fullPath}.{ext}",
951            "nprod",
952            "sp.frame.Frame",
953            "4.3.1",
954            "avsc",
955        );
956        assert_eq!(result, "schemas/sp/frame/Frame.avsc");
957
958        // Test single part artifact ID
959        let result = expand_output_pattern(
960            "protos/{artifactId.path}/{artifactId.lastLowercase}.{ext}",
961            "default",
962            "SimpleMessage",
963            "1.0.0",
964            "proto",
965        );
966        assert_eq!(result, "protos//simplemessage.proto"); // Empty path when no dots
967
968        // Test empty artifact ID edge case
969        let result = expand_output_pattern(
970            "protos/{artifactId.path}/{artifactId.lastLowercase}.{ext}",
971            "default",
972            "",
973            "1.0.0",
974            "proto",
975        );
976        assert_eq!(result, "protos//.proto");
977
978        // Test artifactId.lastSnakeCase conversion
979        let result = expand_output_pattern(
980            "protos/{artifactId.path}/{artifactId.lastSnakeCase}.{ext}",
981            "default",
982            "sp.frame.PingService",
983            "1.0.0",
984            "proto",
985        );
986        assert_eq!(result, "protos/sp/frame/ping_service.proto");
987
988        // Test snake_case with already snake_case name
989        let result = expand_output_pattern(
990            "protos/{artifactId.lastSnakeCase}.{ext}",
991            "default",
992            "already_snake_case",
993            "1.0.0",
994            "proto",
995        );
996        assert_eq!(result, "protos/already_snake_case.proto");
997
998        // Test snake_case with mixed case
999        let result = expand_output_pattern(
1000            "protos/{artifactId.lastSnakeCase}.{ext}",
1001            "default",
1002            "com.example.XMLHttpRequest",
1003            "1.0.0",
1004            "proto",
1005        );
1006        assert_eq!(result, "protos/xml_http_request.proto");
1007    }
1008
1009    #[test]
1010    fn test_resolve_output_path_with_null_override() {
1011        use std::collections::HashMap;
1012
1013        let mut overrides = HashMap::new();
1014        overrides.insert(
1015            "nprod/sp.frame.Frame".to_string(),
1016            Some("protos/sp/frame/frame.{ext}".to_string()),
1017        );
1018        overrides.insert("nprod/sp.internal.Debug".to_string(), None); // Skip this one
1019
1020        // Should return mapped path
1021        let result = resolve_output_path(
1022            "references/{groupId}/{artifactId}.{ext}",
1023            &overrides,
1024            "nprod-apicurio",
1025            "nprod",
1026            "sp.frame.Frame",
1027            "4.3.1",
1028            "PROTOBUF",
1029        );
1030        assert_eq!(result, Some("protos/sp/frame/frame.proto".to_string()));
1031
1032        // Should return None for null override
1033        let result = resolve_output_path(
1034            "references/{groupId}/{artifactId}.{ext}",
1035            &overrides,
1036            "nprod-apicurio",
1037            "nprod",
1038            "sp.internal.Debug",
1039            "1.0.0",
1040            "PROTOBUF",
1041        );
1042        assert_eq!(result, None);
1043
1044        // Should use default pattern when no override
1045        let result = resolve_output_path(
1046            "references/{groupId}/{artifactId}.{ext}",
1047            &overrides,
1048            "nprod-apicurio",
1049            "nprod",
1050            "sp.other.Service",
1051            "2.0.0",
1052            "PROTOBUF",
1053        );
1054        assert_eq!(
1055            result,
1056            Some("references/nprod/sp.other.Service.proto".to_string())
1057        );
1058    }
1059}