1pub use crate::filter::{filter_staged_files, FilterMode};
2
3#[derive(Debug, thiserror::Error)]
4pub enum PreflightError {
5 #[error("Not a git repository")]
6 NotGitRepo,
7 #[error("Git command failed: {command}")]
8 GitCommandFailed {
9 command: String,
10 source: std::io::Error,
11 },
12 #[error("No staged files")]
13 NoStagedFiles { unstaged: Vec<UnstagedFile> },
14 #[error("Working tree clean — nothing to commit")]
15 WorkingTreeClean,
16 #[error("Diff too large: {size} chars")]
17 DiffTooLarge { size: usize },
18}
19
20#[derive(Debug, Clone)]
21pub struct UnstagedFile {
22 pub status: String,
23 pub path: String,
24}
25
26#[derive(Debug)]
27pub struct PreflightSuccess {
28 pub diff_content: String,
29 pub is_static_message: bool,
30}
31
32fn is_git_repo() -> bool {
33 std::process::Command::new("git")
34 .args(["rev-parse", "--is-inside-work-tree"])
35 .output()
36 .map(|output| {
37 let stdout = String::from_utf8_lossy(&output.stdout);
38 stdout.trim() == "true"
39 })
40 .unwrap_or(false)
41}
42
43fn get_staged_files() -> Result<Vec<String>, std::io::Error> {
44 let output = std::process::Command::new("git")
45 .args(["diff", "--cached", "--name-only"])
46 .output()?;
47 Ok(String::from_utf8_lossy(&output.stdout)
48 .lines()
49 .map(String::from)
50 .collect())
51}
52
53fn get_unstaged_files() -> Result<Vec<UnstagedFile>, std::io::Error> {
54 let output = std::process::Command::new("git")
55 .args(["status", "-s"])
56 .output()?;
57 Ok(String::from_utf8_lossy(&output.stdout)
58 .lines()
59 .filter_map(|line| {
60 let bytes = line.as_bytes();
61 if bytes.len() < 4 {
62 return None; }
64 let c1 = bytes[0] as char;
68 let c2 = bytes[1] as char;
69 let path = line[3..].to_string();
70 if (c1 == ' ' && c2 == ' ') || path.is_empty() {
72 return None;
73 }
74 Some(UnstagedFile {
75 status: format!("{}{}", c1, c2),
76 path,
77 })
78 })
79 .collect())
80}
81
82fn get_diff_content() -> Result<String, std::io::Error> {
83 let output = std::process::Command::new("git")
84 .args(["diff", "--cached"])
85 .output()?;
86 Ok(String::from_utf8_lossy(&output.stdout).to_string())
87}
88
89fn is_working_tree_clean() -> Result<bool, std::io::Error> {
90 let output = std::process::Command::new("git")
91 .args(["status", "--porcelain"])
92 .output()?;
93 let clean = String::from_utf8_lossy(&output.stdout)
94 .lines()
95 .all(|line| line.trim().is_empty());
96 Ok(clean)
97}
98
99pub fn check_diff_size(diff: &str, limit: usize) -> Result<(), PreflightError> {
103 if diff.len() > limit {
104 Err(PreflightError::DiffTooLarge { size: diff.len() })
105 } else {
106 Ok(())
107 }
108}
109
110pub fn run(limit: usize) -> Result<PreflightSuccess, PreflightError> {
115 run_with_filter(FilterMode::Smart, limit)
116}
117
118pub fn run_with_filter(mode: FilterMode, limit: usize) -> Result<PreflightSuccess, PreflightError> {
120 if !is_git_repo() {
121 return Err(PreflightError::NotGitRepo);
122 }
123
124 if is_working_tree_clean().map_err(|e| PreflightError::GitCommandFailed {
125 command: "git status --porcelain".into(),
126 source: e,
127 })? {
128 return Err(PreflightError::WorkingTreeClean);
129 }
130
131 let staged = get_staged_files().map_err(|e| PreflightError::GitCommandFailed {
132 command: "git diff --cached --name-only".into(),
133 source: e,
134 })?;
135
136 if staged.is_empty() {
137 let unstaged = get_unstaged_files().map_err(|e| PreflightError::GitCommandFailed {
138 command: "git status -s".into(),
139 source: e,
140 })?;
141 return Err(PreflightError::NoStagedFiles { unstaged });
142 }
143
144 let filter_result = filter_staged_files(mode).map_err(|e| {
146 PreflightError::GitCommandFailed {
147 command: "git diff --cached --numstat".into(),
148 source: std::io::Error::new(std::io::ErrorKind::Other, e.to_string()),
149 }
150 })?;
151
152 if filter_result.all_machine_generated() {
154 return Ok(PreflightSuccess {
155 diff_content: "chore: update dependencies".to_string(),
156 is_static_message: true,
157 });
158 }
159
160 let diff_content = if filter_result.all_excluded {
163 get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
164 command: "git diff --cached".into(),
165 source: e,
166 })?
167 } else if filter_result.excluded.is_empty() {
168 get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
169 command: "git diff --cached".into(),
170 source: e,
171 })?
172 } else {
173 let exclude_args = crate::filter::build_git_exclude_args(&filter_result.excluded);
174 get_filtered_diff_content(&exclude_args).map_err(|e| PreflightError::GitCommandFailed {
175 command: "git diff --cached :(exclude)".into(),
176 source: e,
177 })?
178 };
179
180 check_diff_size(&diff_content, limit)?;
182
183 Ok(PreflightSuccess {
184 diff_content,
185 is_static_message: false,
186 })
187}
188
189fn get_filtered_diff_content(exclude_args: &[String]) -> Result<String, std::io::Error> {
191 let mut cmd = std::process::Command::new("git");
192 cmd.arg("diff").arg("--cached");
193 for arg in exclude_args {
194 cmd.arg(arg);
195 }
196 let output = cmd.output()?;
197 Ok(String::from_utf8_lossy(&output.stdout).to_string())
198}
199
200pub fn run_with_diff_bypass(limit: usize) -> Result<PreflightSuccess, PreflightError> {
203 match run_with_filter(FilterMode::NoFilter, limit) {
205 Ok(s) => Ok(s),
206 Err(PreflightError::DiffTooLarge { .. }) => {
207 let diff_content = get_diff_content().map_err(|e| PreflightError::GitCommandFailed {
208 command: "git diff --cached".into(),
209 source: e,
210 })?;
211 Ok(PreflightSuccess { diff_content, is_static_message: false })
212 }
213 Err(e) => Err(e),
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn test_unstaged_file_parse_status_m() {
223 let file = UnstagedFile {
224 status: "M".to_string(),
225 path: "src/main.rs".to_string(),
226 };
227 assert_eq!(file.status, "M");
228 assert_eq!(file.path, "src/main.rs");
229 }
230
231 #[test]
232 fn test_unstaged_file_parse_status_uu() {
233 let file = UnstagedFile {
234 status: "??".to_string(),
235 path: ".env.example".to_string(),
236 };
237 assert_eq!(file.status, "??");
238 assert_eq!(file.path, ".env.example");
239 }
240
241 #[test]
242 fn test_preflight_error_display() {
243 let err = PreflightError::NotGitRepo;
244 assert_eq!(err.to_string(), "Not a git repository");
245 }
246
247 #[test]
248 fn test_diff_too_large_error_display() {
249 let err = PreflightError::DiffTooLarge { size: 23450 };
250 assert_eq!(err.to_string(), "Diff too large: 23450 chars");
251 }
252}