Skip to main content

autom8/ui/gui/
config.rs

1//! Config Tab module for the GUI.
2//!
3//! This module contains the types, state, and logic for the Config tab,
4//! which allows users to view and edit both global and project-specific
5//! configuration settings.
6
7use std::collections::HashMap;
8use std::time::Instant;
9
10// ============================================================================
11// Config Scope Types (Config Tab - US-002)
12// ============================================================================
13
14/// Represents the scope of configuration being edited.
15///
16/// The Config tab supports editing both global configuration and
17/// per-project configuration. This enum represents which scope is
18/// currently selected.
19#[derive(Debug, Clone, PartialEq, Eq, Default)]
20pub enum ConfigScope {
21    /// Global configuration (`~/.config/autom8/config.toml`).
22    /// This is the default selection when the Config tab is opened.
23    #[default]
24    Global,
25    /// Project-specific configuration (`~/.config/autom8/<project>/config.toml`).
26    /// Contains the project name.
27    Project(String),
28}
29
30impl ConfigScope {
31    /// Returns the display name for this scope.
32    pub fn display_name(&self) -> &str {
33        match self {
34            ConfigScope::Global => "Global",
35            ConfigScope::Project(name) => name,
36        }
37    }
38
39    /// Returns whether this scope is the global scope.
40    pub fn is_global(&self) -> bool {
41        matches!(self, ConfigScope::Global)
42    }
43}
44
45// ============================================================================
46// Config Field Change Types (Config Tab - US-006)
47// ============================================================================
48
49/// Represents a change to a boolean config field (US-006).
50///
51/// When a toggle is clicked, the render method returns this change to indicate
52/// which field was modified and its new value. The change is then processed
53/// by the parent method which has mutable access to save the config.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum ConfigBoolField {
56    /// The `review` field.
57    Review,
58    /// The `commit` field.
59    Commit,
60    /// The `pull_request` field.
61    PullRequest,
62    /// The `pull_request_draft` field.
63    PullRequestDraft,
64    /// The `worktree` field.
65    Worktree,
66    /// The `worktree_cleanup` field.
67    WorktreeCleanup,
68}
69
70/// Identifier for text config fields (US-007).
71///
72/// Used to track which text field changed when processing editor actions.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum ConfigTextField {
75    /// The `worktree_path_pattern` field.
76    WorktreePathPattern,
77}
78
79/// Type alias for a collection of boolean field changes (US-006).
80pub type BoolFieldChanges = Vec<(ConfigBoolField, bool)>;
81
82/// Type alias for a collection of text field changes (US-007).
83pub type TextFieldChanges = Vec<(ConfigTextField, String)>;
84
85/// Actions that can be returned from config editor rendering (US-006, US-007, US-009).
86///
87/// This struct collects all actions that require mutation, allowing the
88/// render methods to remain `&self` while the parent processes mutations.
89#[derive(Debug, Default)]
90pub struct ConfigEditorActions {
91    /// If set, create a project config from global (US-005).
92    pub create_project_config: Option<String>,
93    /// Boolean field changes with (field, new_value) (US-006).
94    pub bool_changes: Vec<(ConfigBoolField, bool)>,
95    /// Text field changes with (field, new_value) (US-007).
96    pub text_changes: Vec<(ConfigTextField, String)>,
97    /// Whether we're editing global (true) or project (false) config.
98    pub is_global: bool,
99    /// Project name if editing project config.
100    pub project_name: Option<String>,
101    /// If true, reset the config to defaults (US-009).
102    pub reset_to_defaults: bool,
103}
104
105// ============================================================================
106// Config Scope Constants (Config Tab - US-002)
107// ============================================================================
108
109/// Height of each row in the config scope list.
110pub const CONFIG_SCOPE_ROW_HEIGHT: f32 = 44.0;
111
112/// Horizontal padding within config scope rows (uses MD from spacing scale).
113pub const CONFIG_SCOPE_ROW_PADDING_H: f32 = 12.0; // spacing::MD
114
115/// Vertical padding within config scope rows (uses SM from spacing scale).
116pub const CONFIG_SCOPE_ROW_PADDING_V: f32 = 8.0; // spacing::SM
117
118// ============================================================================
119// Config Tab State
120// ============================================================================
121
122/// State for the Config tab.
123///
124/// This struct holds all the state needed for the Config tab, including
125/// the currently selected scope, cached configurations, and UI state.
126#[derive(Debug, Default)]
127pub struct ConfigTabState {
128    /// Currently selected config scope in the Config tab.
129    /// Defaults to Global when the Config tab is first opened.
130    pub selected_scope: ConfigScope,
131
132    /// Cached list of project names for the config scope selector.
133    /// Loaded from `~/.config/autom8/*/` directories.
134    pub scope_projects: Vec<String>,
135
136    /// Cached information about which projects have their own config file.
137    /// Maps project name to whether it has a `config.toml` file.
138    pub scope_has_config: HashMap<String, bool>,
139
140    /// Cached global configuration for editing.
141    /// Loaded via `config::load_global_config()` when Global scope is selected.
142    pub cached_global_config: Option<crate::config::Config>,
143
144    /// Error message if global config failed to load.
145    pub global_config_error: Option<String>,
146
147    /// Cached project configuration for editing.
148    /// Loaded when a project with its own config file is selected.
149    /// Key is the project name, value is the loaded config.
150    pub cached_project_config: Option<(String, crate::config::Config)>,
151
152    /// Error message if project config failed to load.
153    pub project_config_error: Option<String>,
154
155    /// Timestamp of the last config modification.
156    /// Used to show the "Changes take effect on next run" notice.
157    /// Set to Some(Instant) when a config field is modified, cleared after timeout.
158    pub last_modified: Option<Instant>,
159}
160
161impl ConfigTabState {
162    /// Create a new ConfigTabState with default values.
163    pub fn new() -> Self {
164        Self::default()
165    }
166
167    /// Returns the currently selected config scope.
168    pub fn selected_scope(&self) -> &ConfigScope {
169        &self.selected_scope
170    }
171
172    /// Sets the selected config scope.
173    pub fn set_selected_scope(&mut self, scope: ConfigScope) {
174        self.selected_scope = scope;
175    }
176
177    /// Returns the cached list of project names for config scope selection.
178    pub fn scope_projects(&self) -> &[String] {
179        &self.scope_projects
180    }
181
182    /// Returns whether a project has its own config file.
183    pub fn project_has_config(&self, project_name: &str) -> bool {
184        self.scope_has_config
185            .get(project_name)
186            .copied()
187            .unwrap_or(false)
188    }
189
190    /// Refresh the config scope data (project list and config file status).
191    /// Called when the Config tab is rendered or data needs to be refreshed.
192    pub fn refresh_scope_data(&mut self) {
193        // Load project list from config directory
194        if let Ok(projects) = crate::config::list_projects() {
195            self.scope_projects = projects;
196
197            // Check which projects have their own config file
198            self.scope_has_config.clear();
199            for project in &self.scope_projects {
200                if let Ok(config_path) = crate::config::project_config_path_for(project) {
201                    self.scope_has_config
202                        .insert(project.clone(), config_path.exists());
203                }
204            }
205        }
206
207        // Load global config when Global scope is selected
208        if self.selected_scope.is_global() && self.cached_global_config.is_none() {
209            self.load_global_config();
210        }
211
212        // Load project config when a project scope is selected (US-004)
213        if let ConfigScope::Project(project_name) = &self.selected_scope {
214            // Only load if not already cached for this project
215            let needs_load = match &self.cached_project_config {
216                Some((cached_name, _)) => cached_name != project_name,
217                None => self.project_has_config(project_name),
218            };
219            if needs_load {
220                let project_name = project_name.clone();
221                self.load_project_config(&project_name);
222            }
223        }
224    }
225
226    /// Load the global configuration from disk.
227    /// Called when Global scope is selected in the Config tab.
228    pub fn load_global_config(&mut self) {
229        match crate::config::load_global_config() {
230            Ok(config) => {
231                self.cached_global_config = Some(config);
232                self.global_config_error = None;
233            }
234            Err(e) => {
235                self.cached_global_config = None;
236                self.global_config_error = Some(format!("Failed to load config: {}", e));
237            }
238        }
239    }
240
241    /// Returns the cached global config, if loaded.
242    pub fn cached_global_config(&self) -> Option<&crate::config::Config> {
243        self.cached_global_config.as_ref()
244    }
245
246    /// Returns the global config error, if any.
247    pub fn global_config_error(&self) -> Option<&str> {
248        self.global_config_error.as_deref()
249    }
250
251    /// Returns the cached project config for a specific project, if loaded.
252    pub fn cached_project_config(&self, project_name: &str) -> Option<&crate::config::Config> {
253        self.cached_project_config
254            .as_ref()
255            .filter(|(name, _)| name == project_name)
256            .map(|(_, config)| config)
257    }
258
259    /// Returns the project config error, if any.
260    pub fn project_config_error(&self) -> Option<&str> {
261        self.project_config_error.as_deref()
262    }
263
264    /// Load project configuration for a specific project.
265    pub fn load_project_config(&mut self, project_name: &str) {
266        // Get the config file path for this project
267        let config_path = match crate::config::project_config_path_for(project_name) {
268            Ok(path) => path,
269            Err(e) => {
270                self.cached_project_config = None;
271                self.project_config_error = Some(format!("Failed to get config path: {}", e));
272                return;
273            }
274        };
275
276        // Check if the config file exists
277        if !config_path.exists() {
278            self.cached_project_config = None;
279            self.project_config_error = None;
280            return;
281        }
282
283        // Read and parse the config file
284        match std::fs::read_to_string(&config_path) {
285            Ok(content) => match toml::from_str::<crate::config::Config>(&content) {
286                Ok(config) => {
287                    self.cached_project_config = Some((project_name.to_string(), config));
288                    self.project_config_error = None;
289                }
290                Err(e) => {
291                    self.cached_project_config = None;
292                    self.project_config_error = Some(format!("Failed to parse config: {}", e));
293                }
294            },
295            Err(e) => {
296                self.cached_project_config = None;
297                self.project_config_error = Some(format!("Failed to read config: {}", e));
298            }
299        }
300    }
301
302    /// Create a project config from the global config (US-005).
303    ///
304    /// Copies the global configuration values to create a new project-specific
305    /// config file, then updates the UI state to reflect the new config.
306    pub fn create_project_config_from_global(&mut self, project_name: &str) -> Result<(), String> {
307        // Get the global config values (or defaults if not loaded)
308        let global_config = self.cached_global_config.clone().unwrap_or_default();
309
310        // Save as project config
311        if let Err(e) = crate::config::save_project_config_for(project_name, &global_config) {
312            return Err(format!("Failed to create project config: {}", e));
313        }
314
315        // Update our state to reflect the new config
316        self.scope_has_config.insert(project_name.to_string(), true);
317        self.cached_project_config = Some((project_name.to_string(), global_config));
318        self.project_config_error = None;
319
320        // Update modification timestamp to show notice
321        self.last_modified = Some(Instant::now());
322
323        Ok(())
324    }
325
326    /// Apply boolean field changes to the config (US-006).
327    pub fn apply_bool_changes(
328        &mut self,
329        is_global: bool,
330        project_name: Option<&str>,
331        changes: &[(ConfigBoolField, bool)],
332    ) {
333        // Early return if no changes
334        if changes.is_empty() {
335            return;
336        }
337
338        // Get mutable reference to the appropriate config
339        let config = if is_global {
340            self.cached_global_config.as_mut()
341        } else {
342            // For project config, check that the project name matches
343            match (&mut self.cached_project_config, project_name) {
344                (Some((cached_name, config)), Some(project)) if cached_name == project => {
345                    Some(config)
346                }
347                _ => None,
348            }
349        };
350
351        let Some(config) = config else {
352            return;
353        };
354
355        // Apply each change
356        for (field, value) in changes {
357            match field {
358                ConfigBoolField::Review => config.review = *value,
359                ConfigBoolField::Commit => config.commit = *value,
360                ConfigBoolField::PullRequest => config.pull_request = *value,
361                ConfigBoolField::PullRequestDraft => config.pull_request_draft = *value,
362                ConfigBoolField::Worktree => config.worktree = *value,
363                ConfigBoolField::WorktreeCleanup => config.worktree_cleanup = *value,
364            }
365        }
366
367        // Save the config
368        let save_result = if is_global {
369            crate::config::save_global_config(config)
370        } else if let Some(project) = project_name {
371            crate::config::save_project_config_for(project, config)
372        } else {
373            return;
374        };
375
376        if let Err(e) = save_result {
377            if is_global {
378                self.global_config_error = Some(format!("Failed to save config: {}", e));
379            } else {
380                self.project_config_error = Some(format!("Failed to save config: {}", e));
381            }
382        } else {
383            // Update modification timestamp to show notice
384            self.last_modified = Some(Instant::now());
385        }
386    }
387
388    /// Apply text field changes to the config (US-007).
389    pub fn apply_text_changes(
390        &mut self,
391        is_global: bool,
392        project_name: Option<&str>,
393        changes: &[(ConfigTextField, String)],
394    ) {
395        // Early return if no changes
396        if changes.is_empty() {
397            return;
398        }
399
400        // Get mutable reference to the appropriate config
401        let config = if is_global {
402            self.cached_global_config.as_mut()
403        } else {
404            // For project config, check that the project name matches
405            match (&mut self.cached_project_config, project_name) {
406                (Some((cached_name, config)), Some(project)) if cached_name == project => {
407                    Some(config)
408                }
409                _ => None,
410            }
411        };
412
413        let Some(config) = config else {
414            return;
415        };
416
417        // Apply each change
418        for (field, value) in changes {
419            match field {
420                ConfigTextField::WorktreePathPattern => {
421                    config.worktree_path_pattern = value.clone();
422                }
423            }
424        }
425
426        // Save the config
427        let save_result = if is_global {
428            crate::config::save_global_config(config)
429        } else if let Some(project) = project_name {
430            crate::config::save_project_config_for(project, config)
431        } else {
432            return;
433        };
434
435        if let Err(e) = save_result {
436            if is_global {
437                self.global_config_error = Some(format!("Failed to save config: {}", e));
438            } else {
439                self.project_config_error = Some(format!("Failed to save config: {}", e));
440            }
441        } else {
442            // Update modification timestamp to show notice
443            self.last_modified = Some(Instant::now());
444        }
445    }
446
447    /// Reset config to application defaults (US-009).
448    ///
449    /// Replaces the current config with `Config::default()` values:
450    /// - review = true
451    /// - commit = true
452    /// - pull_request = true
453    /// - worktree = true
454    /// - worktree_path_pattern = "{repo}-wt-{branch}"
455    /// - worktree_cleanup = false
456    ///
457    /// The config is saved immediately and the UI updates to reflect the new values.
458    pub fn reset_to_defaults(&mut self, is_global: bool, project_name: Option<&str>) {
459        let default_config = crate::config::Config::default();
460
461        if is_global {
462            // Reset global config
463            self.cached_global_config = Some(default_config.clone());
464
465            // Save to disk
466            if let Err(e) = crate::config::save_global_config(&default_config) {
467                self.global_config_error = Some(format!("Failed to save config: {}", e));
468            } else {
469                // Update modification timestamp to show notice
470                self.last_modified = Some(Instant::now());
471            }
472        } else if let Some(project) = project_name {
473            // Reset project config
474            self.cached_project_config = Some((project.to_string(), default_config.clone()));
475
476            // Save to disk
477            if let Err(e) = crate::config::save_project_config_for(project, &default_config) {
478                self.project_config_error = Some(format!("Failed to save config: {}", e));
479            } else {
480                // Update modification timestamp to show notice
481                self.last_modified = Some(Instant::now());
482            }
483        }
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    // ========================================================================
492    // Config Tab Tests (US-001)
493    // ========================================================================
494
495    #[test]
496    fn test_config_scope_enum_global_default() {
497        let scope = ConfigScope::default();
498        assert!(matches!(scope, ConfigScope::Global));
499    }
500
501    #[test]
502    fn test_config_scope_enum_display_names() {
503        assert_eq!(ConfigScope::Global.display_name(), "Global");
504        assert_eq!(
505            ConfigScope::Project("my-project".to_string()).display_name(),
506            "my-project"
507        );
508    }
509
510    #[test]
511    fn test_config_scope_is_global() {
512        assert!(ConfigScope::Global.is_global());
513        assert!(!ConfigScope::Project("test".to_string()).is_global());
514    }
515
516    #[test]
517    fn test_config_scope_equality() {
518        assert_eq!(ConfigScope::Global, ConfigScope::Global);
519        assert_eq!(
520            ConfigScope::Project("a".to_string()),
521            ConfigScope::Project("a".to_string())
522        );
523        assert_ne!(
524            ConfigScope::Project("a".to_string()),
525            ConfigScope::Project("b".to_string())
526        );
527        assert_ne!(ConfigScope::Global, ConfigScope::Project("a".to_string()));
528    }
529
530    #[test]
531    fn test_config_scope_constants_exist() {
532        // Verify constants are accessible and have reasonable values
533        assert!(CONFIG_SCOPE_ROW_HEIGHT > 0.0);
534        assert!(CONFIG_SCOPE_ROW_PADDING_H > 0.0);
535        assert!(CONFIG_SCOPE_ROW_PADDING_V > 0.0);
536    }
537
538    // ========================================================================
539    // ConfigTabState Tests (US-003)
540    // ========================================================================
541
542    #[test]
543    fn test_config_tab_state_default() {
544        let state = ConfigTabState::new();
545        assert!(matches!(state.selected_scope, ConfigScope::Global));
546        assert!(state.scope_projects.is_empty());
547        assert!(state.scope_has_config.is_empty());
548        assert!(state.cached_global_config.is_none());
549        assert!(state.global_config_error.is_none());
550        assert!(state.cached_project_config.is_none());
551        assert!(state.project_config_error.is_none());
552        assert!(state.last_modified.is_none());
553    }
554
555    #[test]
556    fn test_config_tab_state_set_selected_scope() {
557        let mut state = ConfigTabState::new();
558        state.set_selected_scope(ConfigScope::Project("test-project".to_string()));
559        assert!(matches!(
560            state.selected_scope(),
561            ConfigScope::Project(name) if name == "test-project"
562        ));
563    }
564
565    #[test]
566    fn test_config_tab_state_project_has_config() {
567        let mut state = ConfigTabState::new();
568        state.scope_has_config.insert("project-a".to_string(), true);
569        state
570            .scope_has_config
571            .insert("project-b".to_string(), false);
572
573        assert!(state.project_has_config("project-a"));
574        assert!(!state.project_has_config("project-b"));
575        assert!(!state.project_has_config("project-c")); // Not in map
576    }
577
578    // ========================================================================
579    // Config Field Change Tests (US-006)
580    // ========================================================================
581
582    #[test]
583    fn test_config_bool_field_enum_variants() {
584        // Verify all variants can be created
585        let _ = ConfigBoolField::Review;
586        let _ = ConfigBoolField::Commit;
587        let _ = ConfigBoolField::PullRequest;
588        let _ = ConfigBoolField::PullRequestDraft;
589        let _ = ConfigBoolField::Worktree;
590        let _ = ConfigBoolField::WorktreeCleanup;
591    }
592
593    #[test]
594    fn test_config_editor_actions_default() {
595        let actions = ConfigEditorActions::default();
596        assert!(actions.create_project_config.is_none());
597        assert!(actions.bool_changes.is_empty());
598        assert!(actions.text_changes.is_empty());
599        assert!(!actions.is_global);
600        assert!(actions.project_name.is_none());
601        assert!(!actions.reset_to_defaults);
602    }
603}