1use std::collections::HashMap;
8use std::time::Instant;
9
10#[derive(Debug, Clone, PartialEq, Eq, Default)]
20pub enum ConfigScope {
21 #[default]
24 Global,
25 Project(String),
28}
29
30impl ConfigScope {
31 pub fn display_name(&self) -> &str {
33 match self {
34 ConfigScope::Global => "Global",
35 ConfigScope::Project(name) => name,
36 }
37 }
38
39 pub fn is_global(&self) -> bool {
41 matches!(self, ConfigScope::Global)
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum ConfigBoolField {
56 Review,
58 Commit,
60 PullRequest,
62 PullRequestDraft,
64 Worktree,
66 WorktreeCleanup,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum ConfigTextField {
75 WorktreePathPattern,
77}
78
79pub type BoolFieldChanges = Vec<(ConfigBoolField, bool)>;
81
82pub type TextFieldChanges = Vec<(ConfigTextField, String)>;
84
85#[derive(Debug, Default)]
90pub struct ConfigEditorActions {
91 pub create_project_config: Option<String>,
93 pub bool_changes: Vec<(ConfigBoolField, bool)>,
95 pub text_changes: Vec<(ConfigTextField, String)>,
97 pub is_global: bool,
99 pub project_name: Option<String>,
101 pub reset_to_defaults: bool,
103}
104
105pub const CONFIG_SCOPE_ROW_HEIGHT: f32 = 44.0;
111
112pub const CONFIG_SCOPE_ROW_PADDING_H: f32 = 12.0; pub const CONFIG_SCOPE_ROW_PADDING_V: f32 = 8.0; #[derive(Debug, Default)]
127pub struct ConfigTabState {
128 pub selected_scope: ConfigScope,
131
132 pub scope_projects: Vec<String>,
135
136 pub scope_has_config: HashMap<String, bool>,
139
140 pub cached_global_config: Option<crate::config::Config>,
143
144 pub global_config_error: Option<String>,
146
147 pub cached_project_config: Option<(String, crate::config::Config)>,
151
152 pub project_config_error: Option<String>,
154
155 pub last_modified: Option<Instant>,
159}
160
161impl ConfigTabState {
162 pub fn new() -> Self {
164 Self::default()
165 }
166
167 pub fn selected_scope(&self) -> &ConfigScope {
169 &self.selected_scope
170 }
171
172 pub fn set_selected_scope(&mut self, scope: ConfigScope) {
174 self.selected_scope = scope;
175 }
176
177 pub fn scope_projects(&self) -> &[String] {
179 &self.scope_projects
180 }
181
182 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 pub fn refresh_scope_data(&mut self) {
193 if let Ok(projects) = crate::config::list_projects() {
195 self.scope_projects = projects;
196
197 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 if self.selected_scope.is_global() && self.cached_global_config.is_none() {
209 self.load_global_config();
210 }
211
212 if let ConfigScope::Project(project_name) = &self.selected_scope {
214 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 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 pub fn cached_global_config(&self) -> Option<&crate::config::Config> {
243 self.cached_global_config.as_ref()
244 }
245
246 pub fn global_config_error(&self) -> Option<&str> {
248 self.global_config_error.as_deref()
249 }
250
251 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 pub fn project_config_error(&self) -> Option<&str> {
261 self.project_config_error.as_deref()
262 }
263
264 pub fn load_project_config(&mut self, project_name: &str) {
266 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 if !config_path.exists() {
278 self.cached_project_config = None;
279 self.project_config_error = None;
280 return;
281 }
282
283 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 pub fn create_project_config_from_global(&mut self, project_name: &str) -> Result<(), String> {
307 let global_config = self.cached_global_config.clone().unwrap_or_default();
309
310 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 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 self.last_modified = Some(Instant::now());
322
323 Ok(())
324 }
325
326 pub fn apply_bool_changes(
328 &mut self,
329 is_global: bool,
330 project_name: Option<&str>,
331 changes: &[(ConfigBoolField, bool)],
332 ) {
333 if changes.is_empty() {
335 return;
336 }
337
338 let config = if is_global {
340 self.cached_global_config.as_mut()
341 } else {
342 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 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 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 self.last_modified = Some(Instant::now());
385 }
386 }
387
388 pub fn apply_text_changes(
390 &mut self,
391 is_global: bool,
392 project_name: Option<&str>,
393 changes: &[(ConfigTextField, String)],
394 ) {
395 if changes.is_empty() {
397 return;
398 }
399
400 let config = if is_global {
402 self.cached_global_config.as_mut()
403 } else {
404 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 for (field, value) in changes {
419 match field {
420 ConfigTextField::WorktreePathPattern => {
421 config.worktree_path_pattern = value.clone();
422 }
423 }
424 }
425
426 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 self.last_modified = Some(Instant::now());
444 }
445 }
446
447 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 self.cached_global_config = Some(default_config.clone());
464
465 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 self.last_modified = Some(Instant::now());
471 }
472 } else if let Some(project) = project_name {
473 self.cached_project_config = Some((project.to_string(), default_config.clone()));
475
476 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 self.last_modified = Some(Instant::now());
482 }
483 }
484 }
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490
491 #[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 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 #[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")); }
577
578 #[test]
583 fn test_config_bool_field_enum_variants() {
584 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}