git_editor/utils/
validator.rs

1use crate::args::Args;
2use crate::utils::types::Result;
3use colored::*;
4use regex::Regex;
5use std::process;
6use url::Url;
7
8pub fn validate_inputs(args: &Args) -> Result<()> {
9    // Always validate repo_path since it's required for all operations
10    let repo_path = args.repo_path.as_ref().unwrap();
11
12    if repo_path.is_empty() {
13        eprintln!("Repository path cannot be empty");
14        process::exit(1);
15    }
16    if Url::parse(repo_path).is_err() && !std::path::Path::new(repo_path).exists() {
17        eprintln!(
18            "{} {}",
19            "Invalid repository path or URL".red().bold(),
20            repo_path.yellow()
21        );
22        process::exit(1);
23    }
24    if std::path::Path::new(repo_path).exists() {
25        if !std::path::Path::new(repo_path).is_dir() {
26            eprintln!("Repository path is not a directory {repo_path}");
27            process::exit(1);
28        }
29        if !std::path::Path::new(repo_path).join(".git").exists() {
30            eprintln!("Repository path does not contain a valid Git repository {repo_path}");
31            process::exit(1);
32        }
33    }
34
35    // Skip validation for email, name, start, end if using show_history, pic_specific_commits, or range
36    if args.show_history || args.pic_specific_commits || args.range {
37        return Ok(());
38    }
39
40    // Validate email, name, start, end only for full rewrite operations
41    let email = args.email.as_ref().unwrap();
42    let name = args.name.as_ref().unwrap();
43    let start = args.start.as_ref().unwrap();
44    let end = args.end.as_ref().unwrap();
45
46    let email_re = Regex::new(r"(?i)^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$")?;
47    if !email_re.is_match(email) {
48        eprintln!("{} {}", "Invalid email format".red().bold(), email.yellow());
49        process::exit(1);
50    }
51
52    if name.trim().is_empty() {
53        eprintln!("{}", "Name cannot be empty".red().bold());
54        process::exit(1);
55    }
56
57    let start_re = Regex::new(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$")?;
58    if !start_re.is_match(start) {
59        eprintln!(
60            "{} {}",
61            "Invalid start date format".red().bold(),
62            start.yellow()
63        );
64        process::exit(1);
65    }
66
67    let end_re = Regex::new(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$")?;
68    if !end_re.is_match(end) {
69        eprintln!(
70            "{} {}",
71            "Invalid end date format".red().bold(),
72            end.yellow()
73        );
74        process::exit(1);
75    }
76
77    if start >= end {
78        eprintln!("Start date must be before end date");
79        process::exit(1);
80    }
81
82    Ok(())
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use std::fs;
89    use tempfile::TempDir;
90
91    fn create_test_repo() -> (TempDir, String) {
92        let temp_dir = TempDir::new().unwrap();
93        let repo_path = temp_dir.path().to_str().unwrap().to_string();
94
95        // Initialize git repo
96        let repo = git2::Repository::init(&repo_path).unwrap();
97
98        // Create a test file
99        let file_path = temp_dir.path().join("test.txt");
100        fs::write(&file_path, "test content").unwrap();
101
102        // Add and commit file
103        let mut index = repo.index().unwrap();
104        index.add_path(std::path::Path::new("test.txt")).unwrap();
105        index.write().unwrap();
106
107        let tree_id = index.write_tree().unwrap();
108        let tree = repo.find_tree(tree_id).unwrap();
109
110        let sig = git2::Signature::new(
111            "Test User",
112            "test@example.com",
113            &git2::Time::new(1234567890, 0),
114        )
115        .unwrap();
116        repo.commit(Some("HEAD"), &sig, &sig, "Initial commit", &tree, &[])
117            .unwrap();
118
119        (temp_dir, repo_path)
120    }
121
122    #[test]
123    fn test_validate_inputs_show_history_mode() {
124        let (_temp_dir, repo_path) = create_test_repo();
125        let args = Args {
126            repo_path: Some(repo_path),
127            email: None,
128            name: None,
129            start: None,
130            end: None,
131            show_history: true,
132            pic_specific_commits: false,
133            range: false,
134        };
135
136        let result = validate_inputs(&args);
137        assert!(result.is_ok());
138    }
139
140    #[test]
141    fn test_validate_inputs_pick_specific_commits_mode() {
142        let (_temp_dir, repo_path) = create_test_repo();
143        let args = Args {
144            repo_path: Some(repo_path),
145            email: None,
146            name: None,
147            start: None,
148            end: None,
149            show_history: false,
150            pic_specific_commits: true,
151            range: false,
152        };
153
154        let result = validate_inputs(&args);
155        assert!(result.is_ok());
156    }
157
158    #[test]
159    fn test_validate_inputs_full_rewrite_valid() {
160        let (_temp_dir, repo_path) = create_test_repo();
161        let args = Args {
162            repo_path: Some(repo_path),
163            email: Some("test@example.com".to_string()),
164            name: Some("Test User".to_string()),
165            start: Some("2023-01-01 00:00:00".to_string()),
166            end: Some("2023-01-02 00:00:00".to_string()),
167            show_history: false,
168            pic_specific_commits: false,
169            range: false,
170        };
171
172        let result = validate_inputs(&args);
173        assert!(result.is_ok());
174    }
175
176    #[test]
177    fn test_validate_inputs_invalid_email() {
178        let (_temp_dir, repo_path) = create_test_repo();
179        let _args = Args {
180            repo_path: Some(repo_path),
181            email: Some("invalid-email".to_string()),
182            name: Some("Test User".to_string()),
183            start: Some("2023-01-01 00:00:00".to_string()),
184            end: Some("2023-01-02 00:00:00".to_string()),
185            show_history: false,
186            pic_specific_commits: false,
187            range: false,
188        };
189
190        // This test would normally call process::exit, so we can't test it directly
191        // without mocking. We'll test the regex separately.
192        let email_re = Regex::new(r"(?i)^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$").unwrap();
193        assert!(!email_re.is_match("invalid-email"));
194        assert!(email_re.is_match("test@example.com"));
195    }
196
197    #[test]
198    fn test_validate_inputs_invalid_date_format() {
199        let (_temp_dir, repo_path) = create_test_repo();
200        let _args = Args {
201            repo_path: Some(repo_path),
202            email: Some("test@example.com".to_string()),
203            name: Some("Test User".to_string()),
204            start: Some("invalid-date".to_string()),
205            end: Some("2023-01-02 00:00:00".to_string()),
206            show_history: false,
207            pic_specific_commits: false,
208            range: false,
209        };
210
211        let start_re = Regex::new(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$").unwrap();
212        assert!(!start_re.is_match("invalid-date"));
213        assert!(start_re.is_match("2023-01-01 00:00:00"));
214    }
215
216    #[test]
217    fn test_validate_inputs_nonexistent_repo() {
218        let _args = Args {
219            repo_path: Some("/nonexistent/path".to_string()),
220            email: Some("test@example.com".to_string()),
221            name: Some("Test User".to_string()),
222            start: Some("2023-01-01 00:00:00".to_string()),
223            end: Some("2023-01-02 00:00:00".to_string()),
224            show_history: false,
225            pic_specific_commits: false,
226            range: false,
227        };
228
229        // This would normally call process::exit, so we test the path validation logic
230        let repo_path = "/nonexistent/path";
231        assert!(!std::path::Path::new(repo_path).exists());
232    }
233
234    #[test]
235    fn test_email_regex_patterns() {
236        let email_re = Regex::new(r"(?i)^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$").unwrap();
237
238        // Valid emails
239        assert!(email_re.is_match("test@example.com"));
240        assert!(email_re.is_match("user.name@domain.org"));
241        assert!(email_re.is_match("user+tag@example.co.uk"));
242        assert!(email_re.is_match("123@test.io"));
243
244        // Invalid emails
245        assert!(!email_re.is_match("invalid-email"));
246        assert!(!email_re.is_match("@domain.com"));
247        assert!(!email_re.is_match("user@"));
248        assert!(!email_re.is_match("user@domain"));
249        assert!(!email_re.is_match("user@domain."));
250    }
251
252    #[test]
253    fn test_datetime_regex_patterns() {
254        let datetime_re = Regex::new(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$").unwrap();
255
256        // Valid datetime formats
257        assert!(datetime_re.is_match("2023-01-01 00:00:00"));
258        assert!(datetime_re.is_match("2023-12-31 23:59:59"));
259        assert!(datetime_re.is_match("2023-06-15 12:30:45"));
260
261        // Invalid datetime formats
262        assert!(!datetime_re.is_match("2023-1-1 0:0:0"));
263        assert!(!datetime_re.is_match("2023/01/01 00:00:00"));
264        assert!(!datetime_re.is_match("2023-01-01T00:00:00"));
265        assert!(!datetime_re.is_match("23-01-01 00:00:00"));
266        assert!(!datetime_re.is_match("2023-01-01 00:00"));
267    }
268
269    fn create_test_repo_with_commits() -> (TempDir, String) {
270        let temp_dir = TempDir::new().unwrap();
271        let repo_path = temp_dir.path().to_str().unwrap().to_string();
272
273        // Initialize git repo
274        let repo = git2::Repository::init(&repo_path).unwrap();
275
276        // Create multiple commits
277        for i in 1..=3 {
278            let file_path = temp_dir.path().join(format!("test{i}.txt"));
279            std::fs::write(&file_path, format!("test content {i}")).unwrap();
280
281            let mut index = repo.index().unwrap();
282            index
283                .add_path(std::path::Path::new(&format!("test{i}.txt")))
284                .unwrap();
285            index.write().unwrap();
286
287            let tree_id = index.write_tree().unwrap();
288            let tree = repo.find_tree(tree_id).unwrap();
289
290            let sig = git2::Signature::new(
291                "Test User",
292                "test@example.com",
293                &git2::Time::new(1234567890 + i as i64 * 3600, 0),
294            )
295            .unwrap();
296
297            let parents = if i == 1 {
298                vec![]
299            } else {
300                let head = repo.head().unwrap();
301                let parent_commit = head.peel_to_commit().unwrap();
302                vec![parent_commit]
303            };
304
305            repo.commit(
306                Some("HEAD"),
307                &sig,
308                &sig,
309                &format!("Commit {i}"),
310                &tree,
311                &parents.iter().collect::<Vec<_>>(),
312            )
313            .unwrap();
314        }
315
316        (temp_dir, repo_path)
317    }
318
319    #[test]
320    fn test_validate_inputs_range_mode() {
321        let (_temp_dir, repo_path) = create_test_repo_with_commits();
322        let args = Args {
323            repo_path: Some(repo_path),
324            email: None,
325            name: None,
326            start: None,
327            end: None,
328            show_history: false,
329            pic_specific_commits: false,
330            range: true,
331        };
332
333        let result = validate_inputs(&args);
334        assert!(result.is_ok());
335    }
336}