gitwatch_rs/
app_config.rs

1use crate::{cli::CliOptions, config_file::ConfigFile, util::normalize_path};
2use anyhow::{bail, Context, Result};
3use regex::Regex;
4use std::path::PathBuf;
5
6#[derive(Clone, Debug, Default)]
7pub struct AppConfig {
8    pub commit_message: Option<String>,
9    pub commit_message_script: Option<PathBuf>,
10    pub commit_on_start: bool,
11    pub debounce_seconds: u64,
12    pub dry_run: bool,
13    pub ignore_regex: Option<Regex>,
14    pub remote: Option<String>,
15    pub repository: PathBuf,
16    pub retries: i32,
17    pub watch: bool,
18}
19
20impl AppConfig {
21    pub fn new(cli_config: CliOptions) -> Result<Self> {
22        // load config file if it exists
23        let file_config = ConfigFile::load(&cli_config.repository).unwrap_or_default();
24
25        let repository = normalize_path(&cli_config.repository).context(format!(
26            "Invalid repository path '{}'",
27            cli_config.repository.display()
28        ))?;
29
30        let config = Self::merge_configs(repository, cli_config, file_config)?;
31
32        config.validate()?;
33        Ok(config)
34    }
35
36    // merge with precedence: config file > cli flags
37    fn merge_configs(
38        repository: PathBuf,
39        cli_config: CliOptions,
40        file_config: ConfigFile,
41    ) -> Result<Self> {
42        let commit_message = file_config
43            .commit_message
44            .or(cli_config.commit_message.message);
45
46        let commit_message_script = file_config
47            .commit_message_script
48            .or(cli_config.commit_message.script)
49            .map(|script_path| {
50                let script_path = if script_path.is_relative() {
51                    // if relative path, interpret it relative to repository root
52                    repository.join(script_path)
53                } else {
54                    script_path
55                };
56                normalize_path(&script_path).context(format!(
57                    "Invalid commit message script path '{}'",
58                    script_path.display()
59                ))
60            })
61            .transpose()?;
62
63        let commit_on_start = file_config
64            .commit_on_start
65            .unwrap_or(cli_config.commit_on_start);
66
67        let debounce_seconds = file_config
68            .debounce_seconds
69            .unwrap_or(cli_config.debounce_seconds);
70
71        let dry_run = file_config.dry_run.unwrap_or(cli_config.dry_run);
72
73        let ignore_regex = if let Some(regex) = file_config.ignore_regex {
74            Some(regex)
75        } else {
76            cli_config.ignore_regex
77        };
78
79        let remote = if let Some(remote) = file_config.remote {
80            Some(remote)
81        } else {
82            cli_config.remote
83        };
84
85        let retries = file_config.retries.unwrap_or(cli_config.retries);
86
87        let watch = file_config.watch.unwrap_or(cli_config.watch);
88
89        Ok(Self {
90            repository,
91            commit_message,
92            commit_message_script,
93            commit_on_start,
94            debounce_seconds,
95            dry_run,
96            ignore_regex,
97            remote,
98            retries,
99            watch,
100        })
101    }
102
103    fn validate(&self) -> Result<()> {
104        if self.retries < -1 {
105            bail!("Retry count must be >= -1");
106        }
107
108        if !self.repository.exists() {
109            bail!(
110                "Repository path does not exist: {}",
111                self.repository.display()
112            );
113        }
114
115        match (&self.commit_message, &self.commit_message_script) {
116            (None, None) => {
117                bail!("Either commit-message or commit-message-script must be set")
118            }
119            (Some(_), Some(_)) => {
120                bail!("Only one of commit-message or commit-message-script can be set")
121            }
122            (None, Some(script_path)) => {
123                if !script_path.exists() {
124                    bail!(
125                        "Commit message script does not exist: {}",
126                        script_path.display()
127                    );
128                }
129                if !script_path.is_file() {
130                    bail!(
131                        "Commit message script path is not a file: {}",
132                        script_path.display()
133                    );
134                }
135                Ok(())
136            }
137            (Some(_), None) => Ok(()),
138        }
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use std::{env, fs, path::Path, str::FromStr};
145
146    use clap::Parser;
147    use testresult::TestResult;
148
149    use super::*;
150    use crate::{
151        cli::{CommitMessageOptions, LogLevel},
152        test_support::constants::TEST_COMMIT_MESSAGE,
153    };
154
155    impl PartialEq for AppConfig {
156        fn eq(&self, other: &Self) -> bool {
157            self.repository
158                .as_path()
159                .canonicalize()
160                .unwrap_or(self.repository.clone())
161                == other
162                    .repository
163                    .as_path()
164                    .canonicalize()
165                    .unwrap_or(other.repository.clone())
166                && self.ignore_regex.as_ref().map(|r| r.as_str())
167                    == other.ignore_regex.as_ref().map(|r| r.as_str())
168                && self.commit_message == other.commit_message
169                && self.commit_message_script == other.commit_message_script
170                && self.debounce_seconds == other.debounce_seconds
171                && self.dry_run == other.dry_run
172                && self.retries == other.retries
173                && self.commit_on_start == other.commit_on_start
174                && self.watch == other.watch
175        }
176    }
177
178    #[test]
179    fn test_config_from_cli() -> TestResult {
180        let temp_dir = tempfile::tempdir()?;
181
182        let watch_opts = CliOptions::parse_from([
183            "gitwatch",
184            temp_dir.path().to_str().unwrap(),
185            "--commit-message",
186            TEST_COMMIT_MESSAGE,
187            "--debounce-seconds=0",
188            "--ignore-regex=/ignore-me/.*",
189            "--retries=2",
190            "--commit-on-start=false",
191            "--watch=true",
192            "--dry-run",
193            "--remote=origin",
194        ]);
195
196        let config = AppConfig::new(watch_opts)?;
197
198        let expected = AppConfig {
199            repository: temp_dir.path().to_path_buf(),
200            commit_message: Some(TEST_COMMIT_MESSAGE.to_string()),
201            commit_message_script: None,
202            debounce_seconds: 0,
203            ignore_regex: Some(Regex::new("/ignore-me/.*")?),
204            dry_run: true,
205            retries: 2,
206            commit_on_start: false,
207            watch: true,
208            remote: Some("origin".to_string()),
209        };
210
211        assert_eq!(config, expected);
212        Ok(())
213    }
214
215    #[test]
216    fn test_config_from_cli_invalid() -> TestResult {
217        let temp_dir = tempfile::tempdir()?;
218        let invalid_watch_opts = CliOptions::parse_from([
219            "gitwatch",
220            temp_dir.path().to_str().unwrap(),
221            "--commit-message",
222            TEST_COMMIT_MESSAGE,
223            "--retries=-2",
224        ]);
225
226        let result = AppConfig::new(invalid_watch_opts);
227        assert!(result.is_err());
228        assert!(result
229            .unwrap_err()
230            .to_string()
231            .contains("Retry count must be >= -1"));
232
233        Ok(())
234    }
235
236    #[test]
237    fn test_config_validation() -> TestResult {
238        let temp_dir = tempfile::tempdir()?;
239        let repo_path = temp_dir.path().to_path_buf();
240
241        let valid_script_path = temp_dir.path().join("commit-msg.sh");
242        fs::write(&valid_script_path, "#!/bin/sh\necho 'test commit'")?;
243
244        let valid_config = AppConfig {
245            repository: repo_path.clone(),
246            commit_message: Some("test".to_string()),
247            commit_message_script: None,
248            commit_on_start: true,
249            debounce_seconds: 0,
250            ignore_regex: None,
251            watch: true,
252            retries: 3,
253            dry_run: false,
254            remote: None,
255        };
256        assert!(valid_config.validate().is_ok());
257
258        let config_missing_commit_message_options = AppConfig {
259            commit_message: None,
260            commit_message_script: None,
261            ..valid_config.clone()
262        };
263        assert_eq!(
264            config_missing_commit_message_options
265                .validate()
266                .unwrap_err()
267                .to_string(),
268            "Either commit-message or commit-message-script must be set"
269        );
270
271        let config_with_both_commit_message_options = AppConfig {
272            commit_message: Some("test".into()),
273            commit_message_script: Some(valid_script_path.clone()),
274            ..valid_config.clone()
275        };
276        assert_eq!(
277            config_with_both_commit_message_options
278                .validate()
279                .unwrap_err()
280                .to_string(),
281            "Only one of commit-message or commit-message-script can be set"
282        );
283
284        let valid_config_with_script = AppConfig {
285            commit_message: None,
286            commit_message_script: Some(valid_script_path.clone()),
287            ..valid_config.clone()
288        };
289        assert!(valid_config_with_script.validate().is_ok());
290
291        let invalid_retry_count = AppConfig {
292            retries: -2,
293            ..valid_config.clone()
294        };
295        assert!(invalid_retry_count
296            .validate()
297            .unwrap_err()
298            .to_string()
299            .contains("Retry count must be >= -1"));
300
301        let nonexistent_script_path = AppConfig {
302            commit_message: None,
303            commit_message_script: Some(temp_dir.path().join("nonexistent.sh")),
304            ..valid_config.clone()
305        };
306        assert!(nonexistent_script_path
307            .validate()
308            .unwrap_err()
309            .to_string()
310            .contains("Commit message script does not exist"));
311
312        let script_path_is_directory = AppConfig {
313            commit_message: None,
314            commit_message_script: Some(repo_path),
315            ..valid_config.clone()
316        };
317        assert!(script_path_is_directory
318            .validate()
319            .unwrap_err()
320            .to_string()
321            .contains("Commit message script path is not a file"));
322
323        let nonexistent_repo_path = AppConfig {
324            repository: PathBuf::from("/nonexistent/path"),
325            ..valid_config.clone()
326        };
327        assert!(nonexistent_repo_path
328            .validate()
329            .unwrap_err()
330            .to_string()
331            .contains("Repository path does not exist"));
332
333        Ok(())
334    }
335
336    #[test]
337    fn test_relative_paths() -> TestResult {
338        let temp_dir = tempfile::tempdir()?;
339        let repo_path = temp_dir.path();
340        let _ = create_test_commit_message_script(repo_path)?;
341        env::set_current_dir(repo_path)?;
342
343        let cli_opts = CliOptions {
344            repository: PathBuf::from_str(".")?,
345            commit_message: CommitMessageOptions {
346                message: None,
347                script: Some(PathBuf::from_str("./commit-msg.sh")?),
348            },
349            commit_on_start: true,
350            debounce_seconds: 0,
351            ignore_regex: None,
352            watch: true,
353            retries: 3,
354            dry_run: false,
355            remote: None,
356            log_level: LogLevel::Info,
357        };
358
359        let config = AppConfig::new(cli_opts)?;
360
361        assert!(config.repository.exists());
362        assert!(config.commit_message_script.unwrap().exists());
363
364        Ok(())
365    }
366
367    #[test]
368    fn test_absolute_paths() -> TestResult {
369        let temp_dir = tempfile::tempdir()?;
370        let repo_path = temp_dir.path();
371        let commit_message_script_path = create_test_commit_message_script(repo_path)?;
372        env::set_current_dir(repo_path)?;
373
374        let cli_opts = CliOptions {
375            repository: repo_path.to_path_buf(),
376            commit_message: CommitMessageOptions {
377                message: None,
378                script: Some(commit_message_script_path.clone()),
379            },
380            commit_on_start: true,
381            debounce_seconds: 0,
382            ignore_regex: None,
383            watch: true,
384            retries: 3,
385            dry_run: false,
386            remote: None,
387            log_level: LogLevel::Info,
388        };
389
390        let config = AppConfig::new(cli_opts)?;
391
392        assert!(config.repository.exists());
393        assert!(config.commit_message_script.unwrap().exists());
394
395        Ok(())
396    }
397
398    #[test]
399    fn test_config_precedence_cli_only() -> TestResult {
400        let temp_dir = tempfile::tempdir()?;
401        let cli_opts = create_test_cli_options(temp_dir.path())?;
402        let config = AppConfig::new(cli_opts)?;
403
404        assert_eq!(config.commit_message.unwrap(), "cli message");
405        assert_eq!(None, config.commit_message_script);
406        assert!(config.commit_on_start);
407        assert_eq!(config.debounce_seconds, 1);
408        assert!(!config.dry_run);
409        assert_eq!(config.ignore_regex.unwrap().as_str(), "cli_ignore.*");
410        assert_eq!(config.remote.unwrap(), "cli_remote");
411        assert_eq!(config.retries, 3);
412        assert!(config.watch);
413
414        Ok(())
415    }
416
417    #[test]
418    fn test_config_precedence_with_file() -> TestResult {
419        let temp_dir = tempfile::tempdir()?;
420        create_test_config_file(temp_dir.path())?;
421        let cli_opts = create_test_cli_options(temp_dir.path())?;
422        let config = AppConfig::new(cli_opts)?;
423
424        assert_eq!(None, config.commit_message_script);
425
426        // file should take precedence
427        assert_eq!(config.commit_message.unwrap(), "file message");
428        assert!(!config.commit_on_start);
429        assert_eq!(config.debounce_seconds, 5);
430        assert!(config.dry_run);
431        assert_eq!(config.ignore_regex.unwrap().as_str(), "file_ignore.*");
432        assert_eq!(config.remote.unwrap(), "file_remote");
433        assert_eq!(config.retries, 5);
434        assert!(!config.watch);
435
436        Ok(())
437    }
438
439    #[test]
440    fn test_config_partial_file() -> TestResult {
441        let temp_dir = tempfile::tempdir()?;
442
443        // Create config file with only some fields
444        let partial_config = r#"
445        debounce_seconds: 5
446        remote: "file_remote"
447        "#;
448        fs::write(temp_dir.path().join("gitwatch.yaml"), partial_config)?;
449
450        let cli_opts = create_test_cli_options(temp_dir.path())?;
451        let config = AppConfig::new(cli_opts)?;
452
453        // file values should be used where present
454        assert_eq!(config.debounce_seconds, 5);
455        assert_eq!(config.remote.unwrap(), "file_remote");
456
457        // CLI values should be used for missing fields
458        assert!(config.commit_on_start);
459        assert!(!config.dry_run);
460        assert_eq!(config.ignore_regex.unwrap().as_str(), "cli_ignore.*");
461        assert_eq!(config.retries, 3);
462
463        Ok(())
464    }
465
466    fn create_test_cli_options(repo_path: &Path) -> Result<CliOptions> {
467        Ok(CliOptions {
468            repository: repo_path.to_path_buf(),
469            commit_message: CommitMessageOptions {
470                message: Some("cli message".to_string()),
471                script: None,
472            },
473            commit_on_start: true,
474            debounce_seconds: 1,
475            dry_run: false,
476            ignore_regex: Some(Regex::new("cli_ignore.*").unwrap()),
477            log_level: LogLevel::Info,
478            remote: Some("cli_remote".to_string()),
479            retries: 3,
480            watch: true,
481        })
482    }
483
484    fn create_test_config_file(dir: &Path) -> Result<()> {
485        let config_content = r#"
486        commit_message: "file message"
487        commit_on_start: false
488        debounce_seconds: 5
489        dry_run: true
490        ignore_regex: "file_ignore.*"
491        log_level: "debug"
492        remote: "file_remote"
493        retries: 5
494        watch: false
495        "#;
496
497        fs::write(dir.join("gitwatch.yaml"), config_content)?;
498        Ok(())
499    }
500
501    fn create_test_commit_message_script(repo_path: &Path) -> Result<PathBuf> {
502        let script_path = repo_path.join("commit-msg.sh");
503        fs::write(&script_path, "#!/bin/sh\necho 'test commit'")?;
504        Ok(script_path)
505    }
506}