Skip to main content

apicurio_cli/
config.rs

1//! Configuration management for Apicurio CLI
2//!
3//! This module handles loading, parsing, and merging of configuration files including:
4//! - Repository configuration (`apicurioconfig.yaml`)
5//! - Global registries configuration
6//! - Environment variable expansion
7//! - Configuration validation
8//!
9//! ## Configuration Files
10//!
11//! ### Repository Configuration
12//! The main project configuration file that defines dependencies, registries, and publishing settings.
13//!
14//! ### Global Registries
15//! Shared registry definitions stored in `~/.config/apicurio/registries.yaml` or
16//! the path specified by `APICURIO_REGISTRIES_PATH`.
17//!
18//! ## Environment Variable Expansion
19//!
20//! Configuration files support environment variable expansion with the following syntax:
21//! - `${VAR}` - Simple substitution
22//! - `${VAR:-default}` - Use default if VAR is unset or empty
23//! - `${VAR-default}` - Use default if VAR is unset
24//! - `${VAR:+alt}` - Use alt if VAR is set and non-empty
25//! - `${VAR+alt}` - Use alt if VAR is set
26
27use anyhow::Context;
28use regex::Regex;
29use serde::{Deserialize, Serialize};
30use std::path::Path;
31use std::{env, fs, path::PathBuf};
32
33/// Configuration for automatic reference resolution
34///
35/// Controls how transitive dependencies (references) are automatically resolved
36/// and where they are stored when not explicitly declared in dependencies.
37#[derive(Deserialize, Serialize, Debug, Clone)]
38#[serde(rename_all = "camelCase")]
39pub struct ReferenceResolutionConfig {
40    /// Whether to automatically resolve references
41    #[serde(default = "default_true")]
42    pub enabled: bool,
43    /// Output path pattern for resolved references
44    /// Variables: {groupId}, {artifactId}, {version}, {ext}
45    /// Advanced variables: {artifactParts[0]}, {artifactParts[1]}, etc.
46    #[serde(default = "default_reference_pattern")]
47    pub output_pattern: String,
48    /// Maximum depth for reference resolution (prevents infinite loops)
49    #[serde(default = "default_max_depth")]
50    pub max_depth: u32,
51    /// Explicit output path overrides for specific artifacts
52    /// Key format: "groupId/artifactId" or "registry/groupId/artifactId"
53    /// Value: exact output path to use, or null to skip resolution entirely
54    #[serde(default)]
55    pub output_overrides: std::collections::HashMap<String, Option<String>>,
56}
57
58impl Default for ReferenceResolutionConfig {
59    fn default() -> Self {
60        Self {
61            enabled: true,
62            output_pattern: default_reference_pattern(),
63            max_depth: default_max_depth(),
64            output_overrides: std::collections::HashMap::new(),
65        }
66    }
67}
68
69fn default_true() -> bool {
70    true
71}
72
73fn default_reference_pattern() -> String {
74    "references/{groupId}/{artifactId}/{version}.{ext}".to_string()
75}
76
77fn default_max_depth() -> u32 {
78    5
79}
80
81/// Repository-specific configuration loaded from `apicurioconfig.yaml`
82///
83/// This is the main configuration file for a project, containing:
84/// - Registry definitions (can be merged with global registries)
85/// - Dependencies to fetch from registries
86/// - Publishing configuration for uploading artifacts
87///
88/// # Example
89///
90/// ```yaml
91/// externalRegistriesFile: ${APICURIO_REGISTRIES_PATH:-}
92/// registries:
93///   - name: production
94///     url: https://registry.example.com
95///     auth:
96///       type: bearer
97///       tokenEnv: APICURIO_TOKEN
98/// dependencies:
99///   # Smart resolution from name (groupId: com.example, artifactId: user-service)
100///   - name: com.example/user-service
101///     version: ^1.0.0
102///     registry: production
103///     outputPath: protos/user-service.proto
104/// ```
105#[derive(Deserialize, Serialize, Debug)]
106#[serde(rename_all = "camelCase")]
107pub struct RepoConfig {
108    /// Optional path to external registries file for additional registry definitions
109    pub external_registries_file: Option<String>,
110    /// Registry definitions specific to this repository
111    #[serde(default)]
112    pub registries: Vec<RegistryConfig>,
113    /// Dependencies to fetch from registries
114    #[serde(default)]
115    pub dependencies: Vec<DependencyConfig>,
116    /// Configuration for automatic reference resolution
117    #[serde(default)]
118    pub reference_resolution: ReferenceResolutionConfig,
119    /// Artifacts to publish to registries
120    #[serde(default)]
121    pub publishes: Vec<PublishConfig>,
122}
123
124/// Registry configuration defining connection details and authentication
125///
126/// Registries can be defined globally or per-repository. Repository-specific
127/// registries override global registries with the same name.
128#[derive(Deserialize, Serialize, Debug, Clone, Default)]
129#[serde(rename_all = "camelCase")]
130pub struct RegistryConfig {
131    /// Unique name for this registry (used as reference in dependencies)
132    pub name: String,
133    /// Base URL of the Apicurio Registry API
134    pub url: String,
135    /// Authentication configuration
136    #[serde(default)]
137    pub auth: AuthConfig,
138}
139
140/// Authentication configuration for registry access
141///
142/// Supports multiple authentication methods commonly used with Apicurio Registry.
143/// Credentials are always sourced from environment variables for security.
144#[derive(Deserialize, Serialize, Debug, Clone)]
145#[serde(rename_all = "camelCase")]
146#[serde(tag = "type")]
147#[derive(Default)]
148pub enum AuthConfig {
149    /// No authentication (anonymous access)
150    #[default]
151    None,
152    /// HTTP Basic authentication
153    Basic {
154        /// Username for basic auth
155        username: String,
156        /// Environment variable containing the password
157        password_env: String,
158    },
159    /// Token-based authentication (custom header)
160    Token {
161        /// Environment variable containing the token
162        token_env: String,
163    },
164    /// Bearer token authentication (Authorization header)
165    Bearer {
166        /// Environment variable containing the bearer token
167        token_env: String,
168    },
169}
170
171/// Dependency configuration for artifacts to fetch from registries
172///
173/// Dependencies support semantic version ranges and are resolved to exact
174/// versions in the lock file for reproducible builds.
175///
176/// Smart resolution is supported for `group_id` and `artifact_id`:
177/// - If `name` is in "group/artifact" format, group_id defaults to "group" and artifact_id to "artifact"
178/// - If `name` is simple, group_id defaults to "default" and artifact_id to the name
179/// - Explicit `group_id` and `artifact_id` override the smart defaults
180#[derive(Deserialize, Serialize, Debug, Clone)]
181#[serde(rename_all = "camelCase")]
182pub struct DependencyConfig {
183    /// Local name/alias for this dependency (supports group/artifact format for smart resolution)
184    pub name: String,
185    /// Group ID of the artifact in the registry (optional - resolved from name if not provided)
186    #[serde(default)]
187    pub group_id: Option<String>,
188    /// Artifact ID in the registry (optional - resolved from name if not provided)
189    #[serde(default)]
190    pub artifact_id: Option<String>,
191    /// Version specification (supports semver ranges like ^1.0.0, ~2.1.0)
192    pub version: String,
193    /// Name of the registry to fetch from (must match a registry name)
194    pub registry: String,
195    /// Local path where the artifact should be saved
196    pub output_path: String,
197    /// Override reference resolution for this specific dependency
198    #[serde(default)]
199    pub resolve_references: Option<bool>,
200}
201
202/// Publishing configuration for uploading artifacts to registries
203///
204/// Defines how local artifacts should be published to registries, including
205/// metadata, references, and conflict resolution behavior.
206#[derive(Deserialize, Serialize, Debug, Clone)]
207#[serde(rename_all = "camelCase")]
208pub struct PublishConfig {
209    /// Name/identifier for this publish configuration
210    pub name: String,
211    /// Local path to the file to publish
212    pub input_path: String,
213    /// Exact version to publish (no semver ranges allowed)
214    pub version: String,
215    /// Target registry name
216    pub registry: String,
217
218    // Optional fields with smart defaults
219    /// Group ID (defaults from name if contains /)
220    #[serde(default)]
221    pub group_id: Option<String>,
222    /// Artifact ID (defaults from name)
223    #[serde(default)]
224    pub artifact_id: Option<String>,
225    /// Artifact type (auto-detected from file extension if not specified)
226    #[serde(default)]
227    pub r#type: Option<ArtifactType>,
228    /// Behavior when artifact already exists
229    #[serde(default)]
230    pub if_exists: IfExistsAction,
231    /// Human-readable description
232    #[serde(default)]
233    pub description: Option<String>,
234    /// Key-value labels for metadata
235    #[serde(default)]
236    pub labels: std::collections::HashMap<String, String>,
237    /// References to other artifacts
238    #[serde(default)]
239    pub references: Vec<ArtifactReference>,
240}
241
242/// Supported artifact types for publishing
243///
244/// The CLI can auto-detect most types from file extensions, but explicit
245/// specification is supported for edge cases.
246#[derive(Deserialize, Serialize, Debug, Clone)]
247#[serde(rename_all = "kebab-case")]
248pub enum ArtifactType {
249    /// Protocol Buffers (.proto files)
250    Protobuf,
251    /// Apache Avro schemas
252    Avro,
253    /// JSON Schema definitions
254    JsonSchema,
255    /// OpenAPI specifications
256    Openapi,
257    /// AsyncAPI specifications
258    AsyncApi,
259    /// GraphQL schemas
260    GraphQL,
261    /// XML schemas
262    Xml,
263    /// WSDL definitions
264    Wsdl,
265}
266
267/// Behavior when publishing an artifact that already exists
268#[derive(Deserialize, Serialize, Debug, Clone)]
269#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
270#[derive(Default)]
271pub enum IfExistsAction {
272    /// Fail if artifact already exists
273    #[default]
274    Fail,
275    /// Create a new version if artifact exists
276    CreateVersion,
277    /// Find existing version or create new one
278    FindOrCreateVersion,
279}
280
281/// Reference to another artifact (used in publishing)
282///
283/// Artifacts can reference other artifacts to establish dependencies.
284/// References must use exact versions (no semver ranges) to ensure
285/// deterministic builds.
286#[derive(Deserialize, Serialize, Debug, Clone)]
287#[serde(rename_all = "camelCase")]
288pub struct ArtifactReference {
289    // Either use name (group/artifact format) or explicit groupId/artifactId
290    /// Name in group/artifact format (alternative to explicit groupId/artifactId)
291    #[serde(default)]
292    pub name: Option<String>,
293    /// Explicit group ID (alternative to name)
294    #[serde(default)]
295    pub group_id: Option<String>,
296    /// Explicit artifact ID (alternative to name)
297    #[serde(default)]
298    pub artifact_id: Option<String>,
299
300    /// EXACT version only (e.g., "1.2.3"), no ranges
301    pub version: String,
302
303    /// Optional alias for imports (e.g., "text_message.proto")
304    #[serde(default)]
305    pub name_alias: Option<String>,
306}
307
308/// Global configuration for shared registry definitions
309///
310/// This configuration is loaded from `~/.config/apicurio/registries.yaml`
311/// or the path specified by `APICURIO_REGISTRIES_PATH` environment variable.
312#[derive(Deserialize, Serialize, Debug, Clone)]
313#[serde(rename_all = "camelCase")]
314pub struct GlobalConfig {
315    /// Shared registry definitions
316    #[serde(default)]
317    pub registries: Vec<RegistryConfig>,
318}
319
320impl RepoConfig {
321    /// Merge global, external, and repo-local registries
322    ///
323    /// Registry definitions are merged in the following order (later wins):
324    /// 1. Global registries from `~/.config/apicurio/registries.yaml`
325    /// 2. External registries from file specified in `externalRegistriesFile`
326    /// 3. Repository-local registries from `apicurioconfig.yaml`
327    ///
328    /// # Arguments
329    /// * `global` - Global configuration containing shared registries
330    ///
331    /// # Returns
332    /// Vector of merged registry configurations with duplicates resolved
333    ///
334    /// # Errors
335    /// Returns error if external registries file cannot be read or parsed
336    pub fn merge_registries(&self, global: GlobalConfig) -> anyhow::Result<Vec<RegistryConfig>> {
337        let mut map = std::collections::HashMap::new();
338        // 1) global
339        for reg in global.registries {
340            map.insert(reg.name.clone(), reg);
341        }
342        // 2) external file
343        if let Some(path) = &self.external_registries_file {
344            let contents = fs::read_to_string(path)
345                .with_context(|| format!("reading external registries from {path}"))?;
346            let ext: GlobalConfig = serde_yaml::from_str(&contents)?;
347            for reg in ext.registries {
348                map.insert(reg.name.clone(), reg);
349            }
350        }
351        // 3) repo-local
352        for reg in &self.registries {
353            map.insert(reg.name.clone(), reg.clone());
354        }
355        Ok(map.into_values().collect())
356    }
357}
358
359impl PublishConfig {
360    /// Get the resolved group ID for this publish configuration
361    ///
362    /// If `group_id` is explicitly set, uses that value. Otherwise:
363    /// - If `name` contains '/', uses the part before '/' as group ID
364    /// - Otherwise defaults to "default"
365    ///
366    /// # Examples
367    /// - name: "com.example/my-service" → group_id: "com.example"
368    /// - name: "my-service" → group_id: "default"
369    pub fn resolved_group_id(&self) -> String {
370        self.group_id.clone().unwrap_or_else(|| {
371            if let Some((group, _)) = self.name.split_once('/') {
372                group.to_string()
373            } else {
374                "default".to_string()
375            }
376        })
377    }
378
379    pub fn resolved_artifact_id(&self) -> String {
380        self.artifact_id.clone().unwrap_or_else(|| {
381            if let Some((_, artifact)) = self.name.split_once('/') {
382                artifact.to_string()
383            } else {
384                self.name.clone()
385            }
386        })
387    }
388
389    pub fn resolved_content_type(&self) -> String {
390        if let Some(ref artifact_type) = self.r#type {
391            match artifact_type {
392                ArtifactType::Protobuf => "application/x-protobuf".to_string(),
393                ArtifactType::Avro => "application/json".to_string(),
394                ArtifactType::JsonSchema => "application/json".to_string(),
395                ArtifactType::Openapi => "application/json".to_string(),
396                ArtifactType::AsyncApi => "application/json".to_string(),
397                ArtifactType::GraphQL => "application/graphql".to_string(),
398                ArtifactType::Xml => "application/xml".to_string(),
399                ArtifactType::Wsdl => "application/xml".to_string(),
400            }
401        } else {
402            // Auto-detect from file extension
403            let path = std::path::Path::new(&self.input_path);
404            match path.extension().and_then(|e| e.to_str()) {
405                Some("proto") => "application/x-protobuf".to_string(),
406                Some("avsc") => "application/json".to_string(),
407                Some("json") => "application/json".to_string(),
408                Some("yaml") | Some("yml") => "application/yaml".to_string(),
409                Some("xml") => "application/xml".to_string(),
410                Some("graphql") | Some("gql") => "application/graphql".to_string(),
411                _ => "application/octet-stream".to_string(),
412            }
413        }
414    }
415
416    pub fn resolved_artifact_type(&self) -> String {
417        if let Some(ref artifact_type) = self.r#type {
418            match artifact_type {
419                ArtifactType::Protobuf => "PROTOBUF".to_string(),
420                ArtifactType::Avro => "AVRO".to_string(),
421                ArtifactType::JsonSchema => "JSON".to_string(),
422                ArtifactType::Openapi => "OPENAPI".to_string(),
423                ArtifactType::AsyncApi => "ASYNCAPI".to_string(),
424                ArtifactType::GraphQL => "GRAPHQL".to_string(),
425                ArtifactType::Xml => "XML".to_string(),
426                ArtifactType::Wsdl => "WSDL".to_string(),
427            }
428        } else {
429            // Auto-detect from file extension
430            let path = std::path::Path::new(&self.input_path);
431            match path.extension().and_then(|e| e.to_str()) {
432                Some("proto") => "PROTOBUF".to_string(),
433                Some("avsc") => "AVRO".to_string(),
434                Some("json") => "JSON".to_string(),
435                Some("yaml") | Some("yml") => "JSON".to_string(),
436                Some("xml") => "XML".to_string(),
437                Some("graphql") | Some("gql") => "GRAPHQL".to_string(),
438                _ => "JSON".to_string(),
439            }
440        }
441    }
442}
443
444impl DependencyConfig {
445    /// Get the resolved group ID for this dependency configuration
446    ///
447    /// If `group_id` is explicitly set, uses that value. Otherwise:
448    /// - If `name` contains '/', uses the part before '/' as group ID
449    /// - Otherwise defaults to "default"
450    ///
451    /// # Examples
452    /// - name: "com.example/my-service" → group_id: "com.example"
453    /// - name: "my-service" → group_id: "default"
454    pub fn resolved_group_id(&self) -> String {
455        self.group_id.clone().unwrap_or_else(|| {
456            if let Some((group, _)) = self.name.split_once('/') {
457                group.to_string()
458            } else {
459                "default".to_string()
460            }
461        })
462    }
463
464    /// Get the resolved artifact ID for this dependency configuration
465    ///
466    /// If `artifact_id` is explicitly set, uses that value. Otherwise:
467    /// - If `name` contains '/', uses the part after '/' as artifact ID
468    /// - Otherwise uses the entire `name` as artifact ID
469    ///
470    /// # Examples
471    /// - name: "com.example/my-service" → artifact_id: "my-service"
472    /// - name: "my-service" → artifact_id: "my-service"
473    pub fn resolved_artifact_id(&self) -> String {
474        self.artifact_id.clone().unwrap_or_else(|| {
475            if let Some((_, artifact)) = self.name.split_once('/') {
476                artifact.to_string()
477            } else {
478                self.name.clone()
479            }
480        })
481    }
482}
483
484impl ArtifactReference {
485    /// Validate that the version is exact (no semver ranges)
486    pub fn validate_exact_version(&self) -> anyhow::Result<()> {
487        if self.version.contains('^')
488            || self.version.contains('~')
489            || self.version.contains('*')
490            || self.version.contains('>')
491            || self.version.contains('<')
492        {
493            anyhow::bail!(
494                "Reference version must be exact, got '{}'. Use exact version like '1.2.3'",
495                self.version
496            );
497        }
498        Ok(())
499    }
500
501    pub fn resolved_group_id(&self) -> String {
502        self.group_id.clone().unwrap_or_else(|| {
503            if let Some(name) = &self.name {
504                if let Some((group, _)) = name.split_once('/') {
505                    group.to_string()
506                } else {
507                    "default".to_string()
508                }
509            } else {
510                "default".to_string()
511            }
512        })
513    }
514
515    pub fn resolved_artifact_id(&self) -> String {
516        self.artifact_id.clone().unwrap_or_else(|| {
517            if let Some(name) = &self.name {
518                if let Some((_, artifact)) = name.split_once('/') {
519                    artifact.to_string()
520                } else {
521                    name.clone()
522                }
523            } else {
524                panic!("Either name or artifactId must be specified for reference")
525            }
526        })
527    }
528}
529
530pub fn load_repo_config(path: &Path) -> anyhow::Result<RepoConfig> {
531    let preprocessed_data = preprocess_config(path)?; // Preprocess the YAML file to expand environment variables
532    let cfg: RepoConfig = serde_yaml::from_str(&preprocessed_data)?;
533    Ok(cfg)
534}
535
536pub fn load_global_config() -> anyhow::Result<GlobalConfig> {
537    let path = env::var("APICURIO_REGISTRIES_PATH")
538        .map(PathBuf::from)
539        .unwrap_or_else(|_| {
540            let mut p = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
541            p.push("apicurio/registries.yaml");
542            p
543        });
544    if !path.exists() {
545        return Ok(GlobalConfig { registries: vec![] });
546    }
547    let data = fs::read_to_string(&path)
548        .with_context(|| format!("reading global registries {}", path.display()))?;
549    let cfg: GlobalConfig = serde_yaml::from_str(&data)?;
550    Ok(cfg)
551}
552
553pub fn save_global_config(cfg: &GlobalConfig) -> anyhow::Result<()> {
554    // same path logic as load_global_config
555    let path = env::var("APICURIO_REGISTRIES_PATH")
556        .map(PathBuf::from)
557        .unwrap_or_else(|_| {
558            let mut p = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
559            p.push("apicurio/registries.yaml");
560            p
561        });
562    if let Some(parent) = path.parent() {
563        fs::create_dir_all(parent)?;
564    }
565    let data = serde_yaml::to_string(cfg)?;
566    fs::write(&path, data)?;
567    println!("Saved global registries to {}", path.display());
568    Ok(())
569}
570
571pub fn expand_env_placeholders(input: &str) -> String {
572    let re = Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-+])([^}]*))?\}").unwrap();
573    re.replace_all(input, |caps: &regex::Captures| {
574        let var_name = &caps[1];
575        let op = caps.get(2).map_or("", |m| m.as_str());
576        let val = caps.get(3).map_or("", |m| m.as_str());
577        let var = env::var(var_name).ok();
578
579        match (var.as_deref(), op) {
580            (Some(v), _) if op.is_empty() => v.to_string(), // ${VAR}
581            (Some(v), ":-") if !v.is_empty() => v.to_string(), // ${VAR:-default}
582            (None, ":-") => val.to_string(),
583            (Some(v), "-") => {
584                if v.is_empty() {
585                    val.to_string()
586                } else {
587                    v.to_string()
588                }
589            } // ${VAR-default}
590            (None, "-") => val.to_string(),
591            (Some(v), ":+") if !v.is_empty() => val.to_string(), // ${VAR:+alt}
592            (Some(_), "+") => val.to_string(),                   // ${VAR+alt}
593            _ => "".to_string(),
594        }
595    })
596    .to_string()
597}
598
599pub fn preprocess_config(path: &Path) -> anyhow::Result<String> {
600    let raw_data = fs::read_to_string(path)?;
601    Ok(expand_env_placeholders(&raw_data))
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    #[test]
609    fn test_dependency_smart_resolution() {
610        // Test group/artifact format
611        let dep_with_slash = DependencyConfig {
612            name: "com.example/user-service".to_string(),
613            group_id: None,
614            artifact_id: None,
615            version: "1.0.0".to_string(),
616            registry: "test".to_string(),
617            output_path: "out.proto".to_string(),
618            resolve_references: None,
619        };
620
621        assert_eq!(dep_with_slash.resolved_group_id(), "com.example");
622        assert_eq!(dep_with_slash.resolved_artifact_id(), "user-service");
623
624        // Test simple name format
625        let dep_simple = DependencyConfig {
626            name: "user-service".to_string(),
627            group_id: None,
628            artifact_id: None,
629            version: "1.0.0".to_string(),
630            registry: "test".to_string(),
631            output_path: "out.proto".to_string(),
632            resolve_references: None,
633        };
634
635        assert_eq!(dep_simple.resolved_group_id(), "default");
636        assert_eq!(dep_simple.resolved_artifact_id(), "user-service");
637
638        // Test explicit values override smart resolution
639        let dep_explicit = DependencyConfig {
640            name: "com.example/user-service".to_string(),
641            group_id: Some("custom.group".to_string()),
642            artifact_id: Some("custom-artifact".to_string()),
643            version: "1.0.0".to_string(),
644            registry: "test".to_string(),
645            output_path: "out.proto".to_string(),
646            resolve_references: None,
647        };
648
649        assert_eq!(dep_explicit.resolved_group_id(), "custom.group");
650        assert_eq!(dep_explicit.resolved_artifact_id(), "custom-artifact");
651
652        // Test the example from the attachment
653        let dep_nprod = DependencyConfig {
654            name: "nprod/sp.frame.Frame".to_string(),
655            group_id: None,
656            artifact_id: None,
657            version: "4.3.1".to_string(),
658            registry: "nprod-apicurio".to_string(),
659            output_path: "protos/sp/frame/frame.proto".to_string(),
660            resolve_references: None,
661        };
662
663        assert_eq!(dep_nprod.resolved_group_id(), "nprod");
664        assert_eq!(dep_nprod.resolved_artifact_id(), "sp.frame.Frame");
665    }
666
667    #[test]
668    fn test_dependency_smart_resolution_edge_cases() {
669        // Test with multiple slashes (only first split should be used)
670        let dep_multi_slash = DependencyConfig {
671            name: "com.example/nested/artifact".to_string(),
672            group_id: None,
673            artifact_id: None,
674            version: "1.0.0".to_string(),
675            registry: "test".to_string(),
676            output_path: "out.proto".to_string(),
677            resolve_references: None,
678        };
679
680        assert_eq!(dep_multi_slash.resolved_group_id(), "com.example");
681        assert_eq!(dep_multi_slash.resolved_artifact_id(), "nested/artifact");
682
683        // Test with empty group part
684        let dep_empty_group = DependencyConfig {
685            name: "/artifact-only".to_string(),
686            group_id: None,
687            artifact_id: None,
688            version: "1.0.0".to_string(),
689            registry: "test".to_string(),
690            output_path: "out.proto".to_string(),
691            resolve_references: None,
692        };
693
694        assert_eq!(dep_empty_group.resolved_group_id(), "");
695        assert_eq!(dep_empty_group.resolved_artifact_id(), "artifact-only");
696
697        // Test with empty artifact part
698        let dep_empty_artifact = DependencyConfig {
699            name: "group.only/".to_string(),
700            group_id: None,
701            artifact_id: None,
702            version: "1.0.0".to_string(),
703            registry: "test".to_string(),
704            output_path: "out.proto".to_string(),
705            resolve_references: None,
706        };
707
708        assert_eq!(dep_empty_artifact.resolved_group_id(), "group.only");
709        assert_eq!(dep_empty_artifact.resolved_artifact_id(), "");
710
711        // Test partial override (only group_id specified)
712        let dep_partial_override = DependencyConfig {
713            name: "com.example/user-service".to_string(),
714            group_id: Some("override.group".to_string()),
715            artifact_id: None, // Should use smart resolution from name
716            version: "1.0.0".to_string(),
717            registry: "test".to_string(),
718            output_path: "out.proto".to_string(),
719            resolve_references: None,
720        };
721
722        assert_eq!(dep_partial_override.resolved_group_id(), "override.group");
723        assert_eq!(dep_partial_override.resolved_artifact_id(), "user-service");
724
725        // Test partial override (only artifact_id specified)
726        let dep_partial_override2 = DependencyConfig {
727            name: "com.example/user-service".to_string(),
728            group_id: None, // Should use smart resolution from name
729            artifact_id: Some("override-artifact".to_string()),
730            version: "1.0.0".to_string(),
731            registry: "test".to_string(),
732            output_path: "out.proto".to_string(),
733            resolve_references: None,
734        };
735
736        assert_eq!(dep_partial_override2.resolved_group_id(), "com.example");
737        assert_eq!(
738            dep_partial_override2.resolved_artifact_id(),
739            "override-artifact"
740        );
741    }
742
743    #[test]
744    fn test_dependency_resolution_consistency_with_publish() {
745        // Ensure DependencyConfig and PublishConfig resolve identically
746        let name = "com.example/user-service";
747
748        let dep = DependencyConfig {
749            name: name.to_string(),
750            group_id: None,
751            artifact_id: None,
752            version: "1.0.0".to_string(),
753            registry: "test".to_string(),
754            output_path: "out.proto".to_string(),
755            resolve_references: None,
756        };
757
758        let publish = PublishConfig {
759            name: name.to_string(),
760            input_path: "input.proto".to_string(),
761            version: "1.0.0".to_string(),
762            registry: "test".to_string(),
763            group_id: None,
764            artifact_id: None,
765            r#type: None,
766            if_exists: IfExistsAction::Fail,
767            description: None,
768            labels: std::collections::HashMap::new(),
769            references: Vec::new(),
770        };
771
772        assert_eq!(dep.resolved_group_id(), publish.resolved_group_id());
773        assert_eq!(dep.resolved_artifact_id(), publish.resolved_artifact_id());
774    }
775}