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 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 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 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 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 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 assert_eq!(config.debounce_seconds, 5);
455 assert_eq!(config.remote.unwrap(), "file_remote");
456
457 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}