Skip to main content

cascade_cli/config/
settings.rs

1use crate::config::auth::AuthConfig;
2use crate::errors::{CascadeError, Result};
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize, Default)]
8pub struct CascadeConfig {
9    pub bitbucket: Option<BitbucketConfig>,
10    pub git: GitConfig,
11    pub auth: AuthConfig,
12    pub cascade: CascadeSettings,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize, Default)]
16pub struct Settings {
17    pub bitbucket: BitbucketConfig,
18    pub git: GitConfig,
19    pub cascade: CascadeSettings,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct BitbucketConfig {
24    pub url: String,
25    pub project: String,
26    pub repo: String,
27    pub username: Option<String>,
28    pub token: Option<String>,
29    pub default_reviewers: Vec<String>,
30    /// Accept invalid TLS certificates (development only)
31    pub accept_invalid_certs: Option<bool>,
32    /// Path to custom CA certificate bundle
33    pub ca_bundle_path: Option<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct GitConfig {
38    pub default_branch: String,
39    pub author_name: Option<String>,
40    pub author_email: Option<String>,
41    pub auto_cleanup_merged: bool,
42    pub prefer_rebase: bool,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct CascadeSettings {
47    pub api_port: u16,
48    pub auto_cleanup: bool,
49    pub max_stack_size: usize,
50    pub enable_notifications: bool,
51    /// Default PR description template (markdown supported)
52    pub pr_description_template: Option<String>,
53    /// Veto message patterns to treat as advisory (non-blocking) during merge checks.
54    /// When the only remaining vetoes match these patterns, the PR is treated as mergeable.
55    /// Example: ["Code Owners"] to treat Code Owners checks as advisory.
56    #[serde(default)]
57    pub advisory_merge_checks: Vec<String>,
58    /// Rebase-specific settings
59    pub rebase: RebaseSettings,
60    /// DEPRECATED: Old sync strategy setting (ignored, kept for backward compatibility)
61    #[serde(default, skip_serializing)]
62    pub default_sync_strategy: Option<String>,
63}
64
65/// Settings specific to rebase operations
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct RebaseSettings {
68    /// Whether to auto-resolve simple conflicts
69    pub auto_resolve_conflicts: bool,
70    /// Maximum number of retry attempts for rebase operations
71    pub max_retry_attempts: usize,
72    /// Whether to preserve merge commits during rebase
73    pub preserve_merges: bool,
74    /// Whether to backup branches before rebasing (creates backup-* branches)
75    pub backup_before_rebase: bool,
76    /// DEPRECATED: Old version suffix pattern (ignored, kept for backward compatibility)
77    #[serde(default, skip_serializing)]
78    pub version_suffix_pattern: Option<String>,
79}
80
81impl Default for BitbucketConfig {
82    fn default() -> Self {
83        Self {
84            url: "https://bitbucket.example.com".to_string(),
85            project: "PROJECT".to_string(),
86            repo: "repo".to_string(),
87            username: None,
88            token: None,
89            default_reviewers: Vec::new(),
90            accept_invalid_certs: None,
91            ca_bundle_path: None,
92        }
93    }
94}
95
96impl Default for GitConfig {
97    fn default() -> Self {
98        Self {
99            default_branch: "main".to_string(),
100            author_name: None,
101            author_email: None,
102            auto_cleanup_merged: true,
103            prefer_rebase: true,
104        }
105    }
106}
107
108impl Default for CascadeSettings {
109    fn default() -> Self {
110        Self {
111            api_port: 8080,
112            auto_cleanup: true,
113            max_stack_size: 20,
114            enable_notifications: true,
115            pr_description_template: None,
116            advisory_merge_checks: Vec::new(),
117            rebase: RebaseSettings::default(),
118            default_sync_strategy: None, // Deprecated field
119        }
120    }
121}
122
123impl Default for RebaseSettings {
124    fn default() -> Self {
125        Self {
126            auto_resolve_conflicts: true,
127            max_retry_attempts: 3,
128            preserve_merges: true,
129            backup_before_rebase: true,
130            version_suffix_pattern: None, // Deprecated field
131        }
132    }
133}
134
135impl Settings {
136    /// Create default settings for a repository
137    pub fn default_for_repo(bitbucket_url: Option<String>) -> Self {
138        let mut settings = Self::default();
139        if let Some(url) = bitbucket_url {
140            settings.bitbucket.url = url;
141        }
142        settings
143    }
144
145    /// Load settings from a file
146    pub fn load_from_file(path: &Path) -> Result<Self> {
147        if !path.exists() {
148            return Ok(Self::default());
149        }
150
151        let content = fs::read_to_string(path)
152            .map_err(|e| CascadeError::config(format!("Failed to read config file: {e}")))?;
153
154        let settings: Settings = serde_json::from_str(&content)
155            .map_err(|e| CascadeError::config(format!("Failed to parse config file: {e}")))?;
156
157        Ok(settings)
158    }
159
160    /// Save settings to a file atomically
161    pub fn save_to_file(&self, path: &Path) -> Result<()> {
162        crate::utils::atomic_file::write_json(path, self)
163    }
164
165    /// Update a configuration value by key
166    pub fn set_value(&mut self, key: &str, value: &str) -> Result<()> {
167        let parts: Vec<&str> = key.split('.').collect();
168        if parts.len() != 2 {
169            return Err(CascadeError::config(format!(
170                "Invalid config key format: {key}"
171            )));
172        }
173
174        match (parts[0], parts[1]) {
175            ("bitbucket", "url") => self.bitbucket.url = value.to_string(),
176            ("bitbucket", "project") => self.bitbucket.project = value.to_string(),
177            ("bitbucket", "repo") => self.bitbucket.repo = value.to_string(),
178            ("bitbucket", "username") => self.bitbucket.username = Some(value.to_string()),
179            ("bitbucket", "token") => self.bitbucket.token = Some(value.to_string()),
180            ("bitbucket", "accept_invalid_certs") => {
181                self.bitbucket.accept_invalid_certs = Some(value.parse().map_err(|_| {
182                    CascadeError::config(format!("Invalid boolean value: {value}"))
183                })?);
184            }
185            ("bitbucket", "ca_bundle_path") => {
186                self.bitbucket.ca_bundle_path = Some(value.to_string());
187            }
188            ("git", "default_branch") => self.git.default_branch = value.to_string(),
189            ("git", "author_name") => self.git.author_name = Some(value.to_string()),
190            ("git", "author_email") => self.git.author_email = Some(value.to_string()),
191            ("git", "auto_cleanup_merged") => {
192                self.git.auto_cleanup_merged = value
193                    .parse()
194                    .map_err(|_| CascadeError::config(format!("Invalid boolean value: {value}")))?;
195            }
196            ("git", "prefer_rebase") => {
197                self.git.prefer_rebase = value
198                    .parse()
199                    .map_err(|_| CascadeError::config(format!("Invalid boolean value: {value}")))?;
200            }
201            ("cascade", "api_port") => {
202                self.cascade.api_port = value
203                    .parse()
204                    .map_err(|_| CascadeError::config(format!("Invalid port number: {value}")))?;
205            }
206            ("cascade", "auto_cleanup") => {
207                self.cascade.auto_cleanup = value
208                    .parse()
209                    .map_err(|_| CascadeError::config(format!("Invalid boolean value: {value}")))?;
210            }
211            ("cascade", "max_stack_size") => {
212                self.cascade.max_stack_size = value
213                    .parse()
214                    .map_err(|_| CascadeError::config(format!("Invalid number: {value}")))?;
215            }
216            ("cascade", "enable_notifications") => {
217                self.cascade.enable_notifications = value
218                    .parse()
219                    .map_err(|_| CascadeError::config(format!("Invalid boolean value: {value}")))?;
220            }
221            ("cascade", "pr_description_template") => {
222                self.cascade.pr_description_template = if value.is_empty() {
223                    None
224                } else {
225                    Some(value.to_string())
226                };
227            }
228            ("cascade", "advisory_merge_checks") => {
229                if value.is_empty() {
230                    self.cascade.advisory_merge_checks = Vec::new();
231                } else {
232                    // Accept JSON array or comma-separated values
233                    if let Ok(parsed) = serde_json::from_str::<Vec<String>>(value) {
234                        self.cascade.advisory_merge_checks = parsed;
235                    } else {
236                        self.cascade.advisory_merge_checks =
237                            value.split(',').map(|s| s.trim().to_string()).collect();
238                    }
239                }
240            }
241            ("rebase", "auto_resolve_conflicts") => {
242                self.cascade.rebase.auto_resolve_conflicts = value
243                    .parse()
244                    .map_err(|_| CascadeError::config(format!("Invalid boolean value: {value}")))?;
245            }
246            ("rebase", "max_retry_attempts") => {
247                self.cascade.rebase.max_retry_attempts = value
248                    .parse()
249                    .map_err(|_| CascadeError::config(format!("Invalid number: {value}")))?;
250            }
251            ("rebase", "preserve_merges") => {
252                self.cascade.rebase.preserve_merges = value
253                    .parse()
254                    .map_err(|_| CascadeError::config(format!("Invalid boolean value: {value}")))?;
255            }
256            ("rebase", "backup_before_rebase") => {
257                self.cascade.rebase.backup_before_rebase = value
258                    .parse()
259                    .map_err(|_| CascadeError::config(format!("Invalid boolean value: {value}")))?;
260            }
261            _ => return Err(CascadeError::config(format!("Unknown config key: {key}"))),
262        }
263
264        Ok(())
265    }
266
267    /// Get a configuration value by key
268    pub fn get_value(&self, key: &str) -> Result<String> {
269        let parts: Vec<&str> = key.split('.').collect();
270        if parts.len() != 2 {
271            return Err(CascadeError::config(format!(
272                "Invalid config key format: {key}"
273            )));
274        }
275
276        let value = match (parts[0], parts[1]) {
277            ("bitbucket", "url") => &self.bitbucket.url,
278            ("bitbucket", "project") => &self.bitbucket.project,
279            ("bitbucket", "repo") => &self.bitbucket.repo,
280            ("bitbucket", "username") => self.bitbucket.username.as_deref().unwrap_or(""),
281            ("bitbucket", "token") => self.bitbucket.token.as_deref().unwrap_or(""),
282            ("bitbucket", "accept_invalid_certs") => {
283                return Ok(self
284                    .bitbucket
285                    .accept_invalid_certs
286                    .unwrap_or(false)
287                    .to_string())
288            }
289            ("bitbucket", "ca_bundle_path") => {
290                self.bitbucket.ca_bundle_path.as_deref().unwrap_or("")
291            }
292            ("git", "default_branch") => &self.git.default_branch,
293            ("git", "author_name") => self.git.author_name.as_deref().unwrap_or(""),
294            ("git", "author_email") => self.git.author_email.as_deref().unwrap_or(""),
295            ("git", "auto_cleanup_merged") => return Ok(self.git.auto_cleanup_merged.to_string()),
296            ("git", "prefer_rebase") => return Ok(self.git.prefer_rebase.to_string()),
297            ("cascade", "api_port") => return Ok(self.cascade.api_port.to_string()),
298            ("cascade", "auto_cleanup") => return Ok(self.cascade.auto_cleanup.to_string()),
299            ("cascade", "max_stack_size") => return Ok(self.cascade.max_stack_size.to_string()),
300            ("cascade", "enable_notifications") => {
301                return Ok(self.cascade.enable_notifications.to_string())
302            }
303            ("cascade", "pr_description_template") => self
304                .cascade
305                .pr_description_template
306                .as_deref()
307                .unwrap_or(""),
308            ("cascade", "advisory_merge_checks") => {
309                return Ok(serde_json::to_string(&self.cascade.advisory_merge_checks)
310                    .unwrap_or_else(|_| "[]".to_string()))
311            }
312            ("rebase", "auto_resolve_conflicts") => {
313                return Ok(self.cascade.rebase.auto_resolve_conflicts.to_string())
314            }
315            ("rebase", "max_retry_attempts") => {
316                return Ok(self.cascade.rebase.max_retry_attempts.to_string())
317            }
318            ("rebase", "preserve_merges") => {
319                return Ok(self.cascade.rebase.preserve_merges.to_string())
320            }
321            ("rebase", "backup_before_rebase") => {
322                return Ok(self.cascade.rebase.backup_before_rebase.to_string())
323            }
324            _ => return Err(CascadeError::config(format!("Unknown config key: {key}"))),
325        };
326
327        Ok(value.to_string())
328    }
329
330    /// Validate the configuration
331    pub fn validate(&self) -> Result<()> {
332        // Validate Bitbucket configuration if provided
333        if !self.bitbucket.url.is_empty()
334            && !self.bitbucket.url.starts_with("http://")
335            && !self.bitbucket.url.starts_with("https://")
336        {
337            return Err(CascadeError::config(
338                "Bitbucket URL must start with http:// or https://",
339            ));
340        }
341
342        // Validate port
343        if self.cascade.api_port == 0 {
344            return Err(CascadeError::config("API port must be between 1 and 65535"));
345        }
346
347        // Validate sync strategy
348        // All rebase operations now use force-push strategy by default
349        // No validation needed for sync strategy
350        Ok(())
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    #[test]
359    fn test_backward_compatibility_with_old_config_format() {
360        // Simulate an old config file with deprecated fields
361        let old_config_json = r#"{
362            "bitbucket": {
363                "url": "https://bitbucket.example.com",
364                "project": "TEST",
365                "repo": "test-repo",
366                "username": null,
367                "token": null,
368                "default_reviewers": [],
369                "accept_invalid_certs": null,
370                "ca_bundle_path": null
371            },
372            "git": {
373                "default_branch": "main",
374                "author_name": null,
375                "author_email": null,
376                "auto_cleanup_merged": true,
377                "prefer_rebase": true
378            },
379            "cascade": {
380                "api_port": 8080,
381                "auto_cleanup": true,
382                "default_sync_strategy": "branch-versioning",
383                "max_stack_size": 20,
384                "enable_notifications": true,
385                "pr_description_template": null,
386                "rebase": {
387                    "auto_resolve_conflicts": true,
388                    "max_retry_attempts": 3,
389                    "preserve_merges": true,
390                    "version_suffix_pattern": "v{}",
391                    "backup_before_rebase": true
392                }
393            }
394        }"#;
395
396        // Should successfully parse old config format
397        let settings: Settings = serde_json::from_str(old_config_json)
398            .expect("Failed to parse old config format - backward compatibility broken!");
399
400        // Verify main settings are preserved
401        assert_eq!(settings.cascade.api_port, 8080);
402        assert!(settings.cascade.auto_cleanup);
403        assert_eq!(settings.cascade.max_stack_size, 20);
404
405        // Verify deprecated fields were loaded but are ignored
406        assert_eq!(
407            settings.cascade.default_sync_strategy,
408            Some("branch-versioning".to_string())
409        );
410        assert_eq!(
411            settings.cascade.rebase.version_suffix_pattern,
412            Some("v{}".to_string())
413        );
414
415        // Verify that when saved, deprecated fields are NOT included
416        let new_json =
417            serde_json::to_string_pretty(&settings).expect("Failed to serialize settings");
418
419        assert!(
420            !new_json.contains("default_sync_strategy"),
421            "Deprecated field should not appear in new config files"
422        );
423        assert!(
424            !new_json.contains("version_suffix_pattern"),
425            "Deprecated field should not appear in new config files"
426        );
427    }
428
429    #[test]
430    fn test_new_config_format_without_deprecated_fields() {
431        // Simulate a new config file without deprecated fields
432        let new_config_json = r#"{
433            "bitbucket": {
434                "url": "https://bitbucket.example.com",
435                "project": "TEST",
436                "repo": "test-repo",
437                "username": null,
438                "token": null,
439                "default_reviewers": [],
440                "accept_invalid_certs": null,
441                "ca_bundle_path": null
442            },
443            "git": {
444                "default_branch": "main",
445                "author_name": null,
446                "author_email": null,
447                "auto_cleanup_merged": true,
448                "prefer_rebase": true
449            },
450            "cascade": {
451                "api_port": 8080,
452                "auto_cleanup": true,
453                "max_stack_size": 20,
454                "enable_notifications": true,
455                "pr_description_template": null,
456                "rebase": {
457                    "auto_resolve_conflicts": true,
458                    "max_retry_attempts": 3,
459                    "preserve_merges": true,
460                    "backup_before_rebase": true
461                }
462            }
463        }"#;
464
465        // Should successfully parse new config format
466        let settings: Settings =
467            serde_json::from_str(new_config_json).expect("Failed to parse new config format!");
468
469        // Verify deprecated fields default to None
470        assert_eq!(settings.cascade.default_sync_strategy, None);
471        assert_eq!(settings.cascade.rebase.version_suffix_pattern, None);
472    }
473}