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