Skip to main content

dvcli/
config.rs

1//! Configuration loading and merging for the CLI.
2//!
3//! Configuration is loaded from multiple sources with the following priority:
4//! 1. CLI arguments (highest)
5//! 2. Environment variables
6//! 3. Config file (~/.develocity/config.toml)
7//! 4. Built-in defaults (lowest)
8
9use crate::error::{Error, Result};
10use crate::models::TestOutcome;
11use serde::Deserialize;
12use std::path::{Path, PathBuf};
13
14/// The default config file path relative to the user's home directory.
15const DEFAULT_CONFIG_DIR: &str = ".develocity";
16const DEFAULT_CONFIG_FILE: &str = "config.toml";
17
18/// Configuration loaded from the TOML config file.
19#[derive(Debug, Default, Deserialize)]
20#[serde(default)]
21pub struct ConfigFile {
22    /// Develocity server URL.
23    pub server: Option<String>,
24    /// Access token for authentication.
25    pub token: Option<String>,
26    /// Default output format.
27    pub output_format: Option<String>,
28    /// Default verbose setting.
29    pub verbose: Option<bool>,
30    /// Default timeout in seconds.
31    pub timeout: Option<u64>,
32}
33
34impl ConfigFile {
35    /// Load configuration from the default config file path.
36    pub fn load_default() -> Result<Self> {
37        let path = Self::default_config_path();
38        if path.exists() {
39            Self::load(&path)
40        } else {
41            Ok(Self::default())
42        }
43    }
44
45    /// Load configuration from a specific file path.
46    pub fn load(path: &Path) -> Result<Self> {
47        let content = std::fs::read_to_string(path).map_err(|e| Error::ConfigRead {
48            path: path.display().to_string(),
49            source: e,
50        })?;
51
52        toml::from_str(&content).map_err(|e| Error::ConfigParse {
53            path: path.display().to_string(),
54            source: e,
55        })
56    }
57
58    /// Returns the default config file path (~/.develocity/config.toml).
59    pub fn default_config_path() -> PathBuf {
60        dirs::home_dir()
61            .unwrap_or_else(|| PathBuf::from("."))
62            .join(DEFAULT_CONFIG_DIR)
63            .join(DEFAULT_CONFIG_FILE)
64    }
65}
66
67/// Resolved configuration after merging all sources.
68#[derive(Debug, Clone)]
69pub struct Config {
70    /// Develocity server URL.
71    pub server: String,
72    /// Access token for authentication.
73    pub token: String,
74    /// Output format.
75    pub output_format: OutputFormat,
76    /// What data to include in the output.
77    pub include: IncludeOptions,
78    /// Whether to show verbose output (stacktraces, etc.).
79    pub verbose: bool,
80    /// Request timeout in seconds.
81    pub timeout: u64,
82    /// Filter test results by these outcomes (server-side). Empty means all.
83    pub test_outcomes: Vec<TestOutcome>,
84}
85
86/// Output format for the CLI.
87#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
88pub enum OutputFormat {
89    /// JSON output for machine consumption.
90    Json,
91    /// Human-readable colored terminal output.
92    #[default]
93    Human,
94}
95
96impl std::str::FromStr for OutputFormat {
97    type Err = String;
98
99    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
100        match s.to_lowercase().as_str() {
101            "json" => Ok(OutputFormat::Json),
102            "human" => Ok(OutputFormat::Human),
103            other => Err(format!(
104                "Invalid output format '{}'. Valid options: json, human",
105                other
106            )),
107        }
108    }
109}
110
111impl std::fmt::Display for OutputFormat {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            OutputFormat::Json => write!(f, "json"),
115            OutputFormat::Human => write!(f, "human"),
116        }
117    }
118}
119
120/// Options for what data to include in the output.
121#[derive(Debug, Clone, Default)]
122pub struct IncludeOptions {
123    /// Include build result information.
124    pub result: bool,
125    /// Include deprecation information.
126    pub deprecations: bool,
127    /// Include failure information.
128    pub failures: bool,
129    /// Include test execution results.
130    pub tests: bool,
131    /// Include task execution / build cache performance data.
132    pub task_execution: bool,
133    /// Include network activity data.
134    pub network_activity: bool,
135    /// Include resolved dependencies.
136    pub dependencies: bool,
137}
138
139impl IncludeOptions {
140    /// Create options that include everything.
141    pub fn all() -> Self {
142        Self {
143            result: true,
144            deprecations: true,
145            failures: true,
146            tests: true,
147            task_execution: true,
148            network_activity: true,
149            dependencies: true,
150        }
151    }
152
153    /// Parse include options from a comma-separated string.
154    pub fn parse(s: &str) -> std::result::Result<Self, String> {
155        let s = s.trim().to_lowercase();
156
157        if s == "all" {
158            return Ok(Self::all());
159        }
160
161        let mut opts = Self::default();
162
163        for part in s.split(',') {
164            match part.trim() {
165                "result" => opts.result = true,
166                "deprecations" => opts.deprecations = true,
167                "failures" => opts.failures = true,
168                "tests" => opts.tests = true,
169                "task-execution" => opts.task_execution = true,
170                "network-activity" => opts.network_activity = true,
171                "dependencies" => opts.dependencies = true,
172                "all" => return Ok(Self::all()),
173                other if !other.is_empty() => {
174                    return Err(format!(
175                        "Invalid include option '{}'. Valid options: result, deprecations, failures, tests, task-execution, network-activity, dependencies, all",
176                        other
177                    ));
178                }
179                _ => {}
180            }
181        }
182
183        // If nothing was specified, include everything
184        if !opts.result
185            && !opts.deprecations
186            && !opts.failures
187            && !opts.tests
188            && !opts.task_execution
189            && !opts.network_activity
190            && !opts.dependencies
191        {
192            return Ok(Self::all());
193        }
194
195        Ok(opts)
196    }
197
198    /// Returns true if any option is enabled.
199    pub fn any(&self) -> bool {
200        self.result
201            || self.deprecations
202            || self.failures
203            || self.tests
204            || self.task_execution
205            || self.network_activity
206            || self.dependencies
207    }
208}
209
210/// Builder for creating a resolved Config from multiple sources.
211#[derive(Debug, Default)]
212pub struct ConfigBuilder {
213    server: Option<String>,
214    token: Option<String>,
215    output_format: Option<OutputFormat>,
216    include: Option<IncludeOptions>,
217    verbose: Option<bool>,
218    timeout: Option<u64>,
219    config_file_path: Option<PathBuf>,
220    test_outcomes: Vec<TestOutcome>,
221}
222
223impl ConfigBuilder {
224    /// Create a new config builder.
225    pub fn new() -> Self {
226        Self::default()
227    }
228
229    /// Set the server URL (from CLI arg).
230    pub fn server(mut self, server: Option<String>) -> Self {
231        if server.is_some() {
232            self.server = server;
233        }
234        self
235    }
236
237    /// Set the access token (from CLI arg).
238    pub fn token(mut self, token: Option<String>) -> Self {
239        if token.is_some() {
240            self.token = token;
241        }
242        self
243    }
244
245    /// Set the output format (from CLI arg).
246    pub fn output_format(mut self, format: Option<OutputFormat>) -> Self {
247        if format.is_some() {
248            self.output_format = format;
249        }
250        self
251    }
252
253    /// Set the include options (from CLI arg).
254    pub fn include(mut self, include: Option<IncludeOptions>) -> Self {
255        if include.is_some() {
256            self.include = include;
257        }
258        self
259    }
260
261    /// Set verbose mode (from CLI arg).
262    pub fn verbose(mut self, verbose: bool) -> Self {
263        if verbose {
264            self.verbose = Some(true);
265        }
266        self
267    }
268
269    /// Set timeout (from CLI arg).
270    pub fn timeout(mut self, timeout: Option<u64>) -> Self {
271        if timeout.is_some() {
272            self.timeout = timeout;
273        }
274        self
275    }
276
277    /// Set the config file path.
278    pub fn config_file(mut self, path: Option<PathBuf>) -> Self {
279        self.config_file_path = path;
280        self
281    }
282
283    /// Set the test outcome filters.
284    pub fn test_outcomes(mut self, outcomes: Vec<TestOutcome>) -> Self {
285        self.test_outcomes = outcomes;
286        self
287    }
288
289    /// Build the resolved configuration by merging all sources.
290    pub fn build(self) -> Result<Config> {
291        // Load config file
292        let config_file = match &self.config_file_path {
293            Some(path) => ConfigFile::load(path)?,
294            None => ConfigFile::load_default()?,
295        };
296
297        // Resolve server: CLI > env > config file
298        let server = self
299            .server
300            .or_else(|| std::env::var("DEVELOCITY_SERVER").ok())
301            .or(config_file.server)
302            .ok_or(Error::MissingServer)?;
303
304        // Resolve token: CLI > env > config file
305        let token = self
306            .token
307            .or_else(|| std::env::var("DEVELOCITY_API_KEY").ok())
308            .or(config_file.token)
309            .ok_or(Error::MissingToken)?;
310
311        // Resolve output format: CLI > config file > default
312        let output_format = self.output_format.unwrap_or_else(|| {
313            config_file
314                .output_format
315                .and_then(|s| s.parse().ok())
316                .unwrap_or_default()
317        });
318
319        // Resolve include options: CLI > default (all)
320        let include = self.include.unwrap_or_else(IncludeOptions::all);
321
322        // Resolve verbose: CLI > config file > default (false)
323        let verbose = self
324            .verbose
325            .unwrap_or_else(|| config_file.verbose.unwrap_or(false));
326
327        // Resolve timeout: CLI > config file > default (30)
328        let timeout = self
329            .timeout
330            .unwrap_or_else(|| config_file.timeout.unwrap_or(30));
331
332        Ok(Config {
333            server,
334            token,
335            output_format,
336            include,
337            verbose,
338            timeout,
339            test_outcomes: self.test_outcomes,
340        })
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_include_options_parse_all() {
350        let opts = IncludeOptions::parse("all").unwrap();
351        assert!(opts.result);
352        assert!(opts.deprecations);
353        assert!(opts.failures);
354        assert!(opts.tests);
355        assert!(opts.task_execution);
356        assert!(opts.network_activity);
357        assert!(opts.dependencies);
358    }
359
360    #[test]
361    fn test_include_options_parse_single() {
362        let opts = IncludeOptions::parse("result").unwrap();
363        assert!(opts.result);
364        assert!(!opts.deprecations);
365        assert!(!opts.failures);
366        assert!(!opts.tests);
367        assert!(!opts.task_execution);
368    }
369
370    #[test]
371    fn test_include_options_parse_multiple() {
372        let opts = IncludeOptions::parse("result,failures").unwrap();
373        assert!(opts.result);
374        assert!(!opts.deprecations);
375        assert!(opts.failures);
376        assert!(!opts.tests);
377        assert!(!opts.task_execution);
378    }
379
380    #[test]
381    fn test_include_options_parse_tests() {
382        let opts = IncludeOptions::parse("tests").unwrap();
383        assert!(!opts.result);
384        assert!(!opts.deprecations);
385        assert!(!opts.failures);
386        assert!(opts.tests);
387        assert!(!opts.task_execution);
388    }
389
390    #[test]
391    fn test_include_options_parse_multiple_with_tests() {
392        let opts = IncludeOptions::parse("result,tests").unwrap();
393        assert!(opts.result);
394        assert!(!opts.deprecations);
395        assert!(!opts.failures);
396        assert!(opts.tests);
397        assert!(!opts.task_execution);
398    }
399
400    #[test]
401    fn test_include_options_parse_task_execution() {
402        let opts = IncludeOptions::parse("task-execution").unwrap();
403        assert!(!opts.result);
404        assert!(!opts.deprecations);
405        assert!(!opts.failures);
406        assert!(!opts.tests);
407        assert!(opts.task_execution);
408    }
409
410    #[test]
411    fn test_include_options_parse_network_activity() {
412        let opts = IncludeOptions::parse("network-activity").unwrap();
413        assert!(!opts.result);
414        assert!(!opts.deprecations);
415        assert!(!opts.failures);
416        assert!(!opts.tests);
417        assert!(!opts.task_execution);
418        assert!(opts.network_activity);
419    }
420
421    #[test]
422    fn test_include_options_parse_dependencies() {
423        let opts = IncludeOptions::parse("dependencies").unwrap();
424        assert!(!opts.result);
425        assert!(!opts.deprecations);
426        assert!(!opts.failures);
427        assert!(!opts.tests);
428        assert!(!opts.task_execution);
429        assert!(!opts.network_activity);
430        assert!(opts.dependencies);
431    }
432
433    #[test]
434    fn test_include_options_parse_empty() {
435        let opts = IncludeOptions::parse("").unwrap();
436        // Empty means all
437        assert!(opts.result);
438        assert!(opts.deprecations);
439        assert!(opts.failures);
440        assert!(opts.tests);
441        assert!(opts.task_execution);
442        assert!(opts.network_activity);
443        assert!(opts.dependencies);
444    }
445
446    #[test]
447    fn test_include_options_parse_invalid() {
448        let result = IncludeOptions::parse("invalid");
449        assert!(result.is_err());
450    }
451
452    #[test]
453    fn test_output_format_parse() {
454        assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
455        assert_eq!("JSON".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
456        assert_eq!(
457            "human".parse::<OutputFormat>().unwrap(),
458            OutputFormat::Human
459        );
460        assert!("invalid".parse::<OutputFormat>().is_err());
461    }
462}