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/// Repository-specific configuration loaded from `apicurioconfig.yaml`
34///
35/// This is the main configuration file for a project, containing:
36/// - Registry definitions (can be merged with global registries)
37/// - Dependencies to fetch from registries
38/// - Publishing configuration for uploading artifacts
39///
40/// # Example
41///
42/// ```yaml
43/// externalRegistriesFile: ${APICURIO_REGISTRIES_PATH:-}
44/// registries:
45///   - name: production
46///     url: https://registry.example.com
47///     auth:
48///       type: bearer
49///       tokenEnv: APICURIO_TOKEN
50/// dependencies:
51///   - name: user-service
52///     groupId: com.example
53///     artifactId: user-service
54///     version: ^1.0.0
55///     registry: production
56///     outputPath: protos/user-service.proto
57/// ```
58#[derive(Deserialize, Serialize, Debug)]
59#[serde(rename_all = "camelCase")]
60pub struct RepoConfig {
61    /// Optional path to external registries file for additional registry definitions
62    pub external_registries_file: Option<String>,
63    /// Registry definitions specific to this repository
64    #[serde(default)]
65    pub registries: Vec<RegistryConfig>,
66    /// Dependencies to fetch from registries
67    #[serde(default)]
68    pub dependencies: Vec<DependencyConfig>,
69    /// Artifacts to publish to registries
70    #[serde(default)]
71    pub publishes: Vec<PublishConfig>,
72}
73
74/// Registry configuration defining connection details and authentication
75///
76/// Registries can be defined globally or per-repository. Repository-specific
77/// registries override global registries with the same name.
78#[derive(Deserialize, Serialize, Debug, Clone, Default)]
79#[serde(rename_all = "camelCase")]
80pub struct RegistryConfig {
81    /// Unique name for this registry (used as reference in dependencies)
82    pub name: String,
83    /// Base URL of the Apicurio Registry API
84    pub url: String,
85    /// Authentication configuration
86    #[serde(default)]
87    pub auth: AuthConfig,
88}
89
90/// Authentication configuration for registry access
91///
92/// Supports multiple authentication methods commonly used with Apicurio Registry.
93/// Credentials are always sourced from environment variables for security.
94#[derive(Deserialize, Serialize, Debug, Clone)]
95#[serde(rename_all = "camelCase")]
96#[serde(tag = "type")]
97#[derive(Default)]
98pub enum AuthConfig {
99    /// No authentication (anonymous access)
100    #[default]
101    None,
102    /// HTTP Basic authentication
103    Basic {
104        /// Username for basic auth
105        username: String,
106        /// Environment variable containing the password
107        password_env: String,
108    },
109    /// Token-based authentication (custom header)
110    Token {
111        /// Environment variable containing the token
112        token_env: String,
113    },
114    /// Bearer token authentication (Authorization header)
115    Bearer {
116        /// Environment variable containing the bearer token
117        token_env: String,
118    },
119}
120
121/// Dependency configuration for artifacts to fetch from registries
122///
123/// Dependencies support semantic version ranges and are resolved to exact
124/// versions in the lock file for reproducible builds.
125#[derive(Deserialize, Serialize, Debug, Clone)]
126#[serde(rename_all = "camelCase")]
127pub struct DependencyConfig {
128    /// Local name/alias for this dependency
129    pub name: String,
130    /// Group ID of the artifact in the registry
131    pub group_id: String,
132    /// Artifact ID in the registry
133    pub artifact_id: String,
134    /// Version specification (supports semver ranges like ^1.0.0, ~2.1.0)
135    pub version: String,
136    /// Name of the registry to fetch from (must match a registry name)
137    pub registry: String,
138    /// Local path where the artifact should be saved
139    pub output_path: String,
140}
141
142/// Publishing configuration for uploading artifacts to registries
143///
144/// Defines how local artifacts should be published to registries, including
145/// metadata, references, and conflict resolution behavior.
146#[derive(Deserialize, Serialize, Debug, Clone)]
147#[serde(rename_all = "camelCase")]
148pub struct PublishConfig {
149    /// Name/identifier for this publish configuration
150    pub name: String,
151    /// Local path to the file to publish
152    pub input_path: String,
153    /// Exact version to publish (no semver ranges allowed)
154    pub version: String,
155    /// Target registry name
156    pub registry: String,
157
158    // Optional fields with smart defaults
159    /// Group ID (defaults from name if contains /)
160    #[serde(default)]
161    pub group_id: Option<String>,
162    /// Artifact ID (defaults from name)
163    #[serde(default)]
164    pub artifact_id: Option<String>,
165    /// Artifact type (auto-detected from file extension if not specified)
166    #[serde(default)]
167    pub r#type: Option<ArtifactType>,
168    /// Behavior when artifact already exists
169    #[serde(default)]
170    pub if_exists: IfExistsAction,
171    /// Human-readable description
172    #[serde(default)]
173    pub description: Option<String>,
174    /// Key-value labels for metadata
175    #[serde(default)]
176    pub labels: std::collections::HashMap<String, String>,
177    /// References to other artifacts
178    #[serde(default)]
179    pub references: Vec<ArtifactReference>,
180}
181
182/// Supported artifact types for publishing
183///
184/// The CLI can auto-detect most types from file extensions, but explicit
185/// specification is supported for edge cases.
186#[derive(Deserialize, Serialize, Debug, Clone)]
187#[serde(rename_all = "kebab-case")]
188pub enum ArtifactType {
189    /// Protocol Buffers (.proto files)
190    Protobuf,
191    /// Apache Avro schemas
192    Avro,
193    /// JSON Schema definitions
194    JsonSchema,
195    /// OpenAPI specifications
196    Openapi,
197    /// AsyncAPI specifications
198    AsyncApi,
199    /// GraphQL schemas
200    GraphQL,
201    /// XML schemas
202    Xml,
203    /// WSDL definitions
204    Wsdl,
205}
206
207/// Behavior when publishing an artifact that already exists
208#[derive(Deserialize, Serialize, Debug, Clone)]
209#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
210#[derive(Default)]
211pub enum IfExistsAction {
212    /// Fail if artifact already exists
213    #[default]
214    Fail,
215    /// Create a new version if artifact exists
216    CreateVersion,
217    /// Find existing version or create new one
218    FindOrCreateVersion,
219}
220
221/// Reference to another artifact (used in publishing)
222///
223/// Artifacts can reference other artifacts to establish dependencies.
224/// References must use exact versions (no semver ranges) to ensure
225/// deterministic builds.
226#[derive(Deserialize, Serialize, Debug, Clone)]
227#[serde(rename_all = "camelCase")]
228pub struct ArtifactReference {
229    // Either use name (group/artifact format) or explicit groupId/artifactId
230    /// Name in group/artifact format (alternative to explicit groupId/artifactId)
231    #[serde(default)]
232    pub name: Option<String>,
233    /// Explicit group ID (alternative to name)
234    #[serde(default)]
235    pub group_id: Option<String>,
236    /// Explicit artifact ID (alternative to name)
237    #[serde(default)]
238    pub artifact_id: Option<String>,
239
240    /// EXACT version only (e.g., "1.2.3"), no ranges
241    pub version: String,
242
243    /// Optional alias for imports (e.g., "text_message.proto")
244    #[serde(default)]
245    pub name_alias: Option<String>,
246}
247
248/// Global configuration for shared registry definitions
249///
250/// This configuration is loaded from `~/.config/apicurio/registries.yaml`
251/// or the path specified by `APICURIO_REGISTRIES_PATH` environment variable.
252#[derive(Deserialize, Serialize, Debug, Clone)]
253#[serde(rename_all = "camelCase")]
254pub struct GlobalConfig {
255    /// Shared registry definitions
256    #[serde(default)]
257    pub registries: Vec<RegistryConfig>,
258}
259
260impl RepoConfig {
261    /// Merge global, external, and repo-local registries
262    ///
263    /// Registry definitions are merged in the following order (later wins):
264    /// 1. Global registries from `~/.config/apicurio/registries.yaml`
265    /// 2. External registries from file specified in `externalRegistriesFile`
266    /// 3. Repository-local registries from `apicurioconfig.yaml`
267    ///
268    /// # Arguments
269    /// * `global` - Global configuration containing shared registries
270    ///
271    /// # Returns
272    /// Vector of merged registry configurations with duplicates resolved
273    ///
274    /// # Errors
275    /// Returns error if external registries file cannot be read or parsed
276    pub fn merge_registries(&self, global: GlobalConfig) -> anyhow::Result<Vec<RegistryConfig>> {
277        let mut map = std::collections::HashMap::new();
278        // 1) global
279        for reg in global.registries {
280            map.insert(reg.name.clone(), reg);
281        }
282        // 2) external file
283        if let Some(path) = &self.external_registries_file {
284            let contents = fs::read_to_string(path)
285                .with_context(|| format!("reading external registries from {path}"))?;
286            let ext: GlobalConfig = serde_yaml::from_str(&contents)?;
287            for reg in ext.registries {
288                map.insert(reg.name.clone(), reg);
289            }
290        }
291        // 3) repo-local
292        for reg in &self.registries {
293            map.insert(reg.name.clone(), reg.clone());
294        }
295        Ok(map.into_values().collect())
296    }
297}
298
299impl PublishConfig {
300    /// Get the resolved group ID for this publish configuration
301    ///
302    /// If `group_id` is explicitly set, uses that value. Otherwise:
303    /// - If `name` contains '/', uses the part before '/' as group ID
304    /// - Otherwise defaults to "default"
305    ///
306    /// # Examples
307    /// - name: "com.example/my-service" → group_id: "com.example"
308    /// - name: "my-service" → group_id: "default"
309    pub fn resolved_group_id(&self) -> String {
310        self.group_id.clone().unwrap_or_else(|| {
311            if let Some((group, _)) = self.name.split_once('/') {
312                group.to_string()
313            } else {
314                "default".to_string()
315            }
316        })
317    }
318
319    pub fn resolved_artifact_id(&self) -> String {
320        self.artifact_id.clone().unwrap_or_else(|| {
321            if let Some((_, artifact)) = self.name.split_once('/') {
322                artifact.to_string()
323            } else {
324                self.name.clone()
325            }
326        })
327    }
328
329    pub fn resolved_content_type(&self) -> String {
330        if let Some(ref artifact_type) = self.r#type {
331            match artifact_type {
332                ArtifactType::Protobuf => "application/x-protobuf".to_string(),
333                ArtifactType::Avro => "application/json".to_string(),
334                ArtifactType::JsonSchema => "application/json".to_string(),
335                ArtifactType::Openapi => "application/json".to_string(),
336                ArtifactType::AsyncApi => "application/json".to_string(),
337                ArtifactType::GraphQL => "application/graphql".to_string(),
338                ArtifactType::Xml => "application/xml".to_string(),
339                ArtifactType::Wsdl => "application/xml".to_string(),
340            }
341        } else {
342            // Auto-detect from file extension
343            let path = std::path::Path::new(&self.input_path);
344            match path.extension().and_then(|e| e.to_str()) {
345                Some("proto") => "application/x-protobuf".to_string(),
346                Some("avsc") => "application/json".to_string(),
347                Some("json") => "application/json".to_string(),
348                Some("yaml") | Some("yml") => "application/yaml".to_string(),
349                Some("xml") => "application/xml".to_string(),
350                Some("graphql") | Some("gql") => "application/graphql".to_string(),
351                _ => "application/octet-stream".to_string(),
352            }
353        }
354    }
355
356    pub fn resolved_artifact_type(&self) -> String {
357        if let Some(ref artifact_type) = self.r#type {
358            match artifact_type {
359                ArtifactType::Protobuf => "PROTOBUF".to_string(),
360                ArtifactType::Avro => "AVRO".to_string(),
361                ArtifactType::JsonSchema => "JSON".to_string(),
362                ArtifactType::Openapi => "OPENAPI".to_string(),
363                ArtifactType::AsyncApi => "ASYNCAPI".to_string(),
364                ArtifactType::GraphQL => "GRAPHQL".to_string(),
365                ArtifactType::Xml => "XML".to_string(),
366                ArtifactType::Wsdl => "WSDL".to_string(),
367            }
368        } else {
369            // Auto-detect from file extension
370            let path = std::path::Path::new(&self.input_path);
371            match path.extension().and_then(|e| e.to_str()) {
372                Some("proto") => "PROTOBUF".to_string(),
373                Some("avsc") => "AVRO".to_string(),
374                Some("json") => "JSON".to_string(),
375                Some("yaml") | Some("yml") => "JSON".to_string(),
376                Some("xml") => "XML".to_string(),
377                Some("graphql") | Some("gql") => "GRAPHQL".to_string(),
378                _ => "JSON".to_string(),
379            }
380        }
381    }
382}
383
384impl ArtifactReference {
385    /// Validate that the version is exact (no semver ranges)
386    pub fn validate_exact_version(&self) -> anyhow::Result<()> {
387        if self.version.contains('^')
388            || self.version.contains('~')
389            || self.version.contains('*')
390            || self.version.contains('>')
391            || self.version.contains('<')
392        {
393            anyhow::bail!(
394                "Reference version must be exact, got '{}'. Use exact version like '1.2.3'",
395                self.version
396            );
397        }
398        Ok(())
399    }
400
401    pub fn resolved_group_id(&self) -> String {
402        self.group_id.clone().unwrap_or_else(|| {
403            if let Some(name) = &self.name {
404                if let Some((group, _)) = name.split_once('/') {
405                    group.to_string()
406                } else {
407                    "default".to_string()
408                }
409            } else {
410                "default".to_string()
411            }
412        })
413    }
414
415    pub fn resolved_artifact_id(&self) -> String {
416        self.artifact_id.clone().unwrap_or_else(|| {
417            if let Some(name) = &self.name {
418                if let Some((_, artifact)) = name.split_once('/') {
419                    artifact.to_string()
420                } else {
421                    name.clone()
422                }
423            } else {
424                panic!("Either name or artifactId must be specified for reference")
425            }
426        })
427    }
428}
429
430pub fn load_repo_config(path: &Path) -> anyhow::Result<RepoConfig> {
431    let preprocessed_data = preprocess_config(path)?; // Preprocess the YAML file to expand environment variables
432    let cfg: RepoConfig = serde_yaml::from_str(&preprocessed_data)?;
433    Ok(cfg)
434}
435
436pub fn load_global_config() -> anyhow::Result<GlobalConfig> {
437    let path = env::var("APICURIO_REGISTRIES_PATH")
438        .map(PathBuf::from)
439        .unwrap_or_else(|_| {
440            let mut p = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
441            p.push("apicurio/registries.yaml");
442            p
443        });
444    if !path.exists() {
445        return Ok(GlobalConfig { registries: vec![] });
446    }
447    let data = fs::read_to_string(&path)
448        .with_context(|| format!("reading global registries {}", path.display()))?;
449    let cfg: GlobalConfig = serde_yaml::from_str(&data)?;
450    Ok(cfg)
451}
452
453pub fn save_global_config(cfg: &GlobalConfig) -> anyhow::Result<()> {
454    // same path logic as load_global_config
455    let path = env::var("APICURIO_REGISTRIES_PATH")
456        .map(PathBuf::from)
457        .unwrap_or_else(|_| {
458            let mut p = dirs::config_dir().unwrap_or_else(|| PathBuf::from("."));
459            p.push("apicurio/registries.yaml");
460            p
461        });
462    if let Some(parent) = path.parent() {
463        fs::create_dir_all(parent)?;
464    }
465    let data = serde_yaml::to_string(cfg)?;
466    fs::write(&path, data)?;
467    println!("Saved global registries to {}", path.display());
468    Ok(())
469}
470
471pub fn expand_env_placeholders(input: &str) -> String {
472    let re = Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?[-+])([^}]*))?\}").unwrap();
473    re.replace_all(input, |caps: &regex::Captures| {
474        let var_name = &caps[1];
475        let op = caps.get(2).map_or("", |m| m.as_str());
476        let val = caps.get(3).map_or("", |m| m.as_str());
477        let var = env::var(var_name).ok();
478
479        match (var.as_deref(), op) {
480            (Some(v), _) if op.is_empty() => v.to_string(), // ${VAR}
481            (Some(v), ":-") if !v.is_empty() => v.to_string(), // ${VAR:-default}
482            (None, ":-") => val.to_string(),
483            (Some(v), "-") => {
484                if v.is_empty() {
485                    val.to_string()
486                } else {
487                    v.to_string()
488                }
489            } // ${VAR-default}
490            (None, "-") => val.to_string(),
491            (Some(v), ":+") if !v.is_empty() => val.to_string(), // ${VAR:+alt}
492            (Some(_), "+") => val.to_string(),                   // ${VAR+alt}
493            _ => "".to_string(),
494        }
495    })
496    .to_string()
497}
498
499pub fn preprocess_config(path: &Path) -> anyhow::Result<String> {
500    let raw_data = fs::read_to_string(path)?;
501    Ok(expand_env_placeholders(&raw_data))
502}