1use std::path::Path;
2use std::process::Command;
3
4use anyhow::{Context, Result};
5
6use crate::discovery::find_unit_file;
7use crate::unit::Unit;
8
9pub enum DiffOutput {
11 Full,
13 Stat,
15 NameOnly,
17}
18
19pub fn cmd_diff(mana_dir: &Path, id: &str, output: DiffOutput, no_color: bool) -> Result<()> {
29 let unit_path =
30 find_unit_file(mana_dir, id).with_context(|| format!("Unit not found: {}", id))?;
31 let unit =
32 Unit::from_file(&unit_path).with_context(|| format!("Failed to load unit: {}", id))?;
33
34 let project_root = mana_dir
35 .parent()
36 .ok_or_else(|| anyhow::anyhow!("Cannot determine project root from .mana/ dir"))?;
37
38 if !is_git_repo(project_root) {
40 anyhow::bail!("Not a git repository. bn diff requires git.");
41 }
42
43 let tagged_commits = find_commits_for_unit(project_root, id)?;
45 if !tagged_commits.is_empty() {
46 return show_commit_diff(project_root, &tagged_commits, &output, no_color);
47 }
48
49 if let Some(ref checkpoint) = unit.checkpoint {
51 let end_ref = resolve_end_ref(&unit, project_root)?;
52 return show_range_diff(project_root, checkpoint, &end_ref, &output, no_color);
53 }
54
55 let start_time = unit
57 .claimed_at
58 .or(Some(unit.created_at))
59 .ok_or_else(|| anyhow::anyhow!("Unit has no claim or creation timestamp"))?;
60
61 let start_commit = find_commit_at_time(project_root, &start_time.to_rfc3339())?;
62 match start_commit {
63 Some(sha) => {
64 let end_ref = resolve_end_ref(&unit, project_root)?;
65 show_range_diff(project_root, &sha, &end_ref, &output, no_color)
66 }
67 None => {
68 eprintln!("No changes found for unit {}", id);
69 Ok(())
70 }
71 }
72}
73
74fn is_git_repo(dir: &Path) -> bool {
76 Command::new("git")
77 .args(["rev-parse", "--git-dir"])
78 .current_dir(dir)
79 .stdout(std::process::Stdio::null())
80 .stderr(std::process::Stdio::null())
81 .status()
82 .map(|s| s.success())
83 .unwrap_or(false)
84}
85
86fn find_commits_for_unit(project_root: &Path, id: &str) -> Result<Vec<String>> {
91 let patterns = [
93 format!("Close unit {}: ", id),
94 format!("Close unit {}:", id),
95 format!("unit-{}", id),
96 ];
97
98 let mut commits = Vec::new();
99 for pattern in &patterns {
100 let output = Command::new("git")
101 .args(["log", "--all", "--format=%H", "--grep", pattern])
102 .current_dir(project_root)
103 .output()
104 .context("Failed to run git log")?;
105
106 if output.status.success() {
107 let stdout = String::from_utf8_lossy(&output.stdout);
108 for line in stdout.lines() {
109 let sha = line.trim();
110 if !sha.is_empty() && !commits.contains(&sha.to_string()) {
111 commits.push(sha.to_string());
112 }
113 }
114 }
115 }
116
117 Ok(commits)
118}
119
120fn resolve_end_ref(unit: &Unit, project_root: &Path) -> Result<String> {
125 if let Some(closed_at) = &unit.closed_at {
126 match find_commit_at_time(project_root, &closed_at.to_rfc3339())? {
128 Some(sha) => Ok(sha),
129 None => Ok("HEAD".to_string()),
130 }
131 } else {
132 Ok("HEAD".to_string())
133 }
134}
135
136fn find_commit_at_time(project_root: &Path, timestamp: &str) -> Result<Option<String>> {
138 let output = Command::new("git")
139 .args([
140 "log",
141 "-1",
142 "--format=%H",
143 &format!("--before={}", timestamp),
144 ])
145 .current_dir(project_root)
146 .output()
147 .context("Failed to run git log")?;
148
149 if output.status.success() {
150 let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
151 if sha.is_empty() {
152 Ok(None)
153 } else {
154 Ok(Some(sha))
155 }
156 } else {
157 Ok(None)
158 }
159}
160
161fn show_commit_diff(
166 project_root: &Path,
167 commits: &[String],
168 output: &DiffOutput,
169 no_color: bool,
170) -> Result<()> {
171 if commits.is_empty() {
172 return Ok(());
173 }
174
175 let mut args = vec!["diff".to_string()];
176 add_output_flags(&mut args, output, no_color);
177
178 if commits.len() == 1 {
179 args = vec!["show".to_string()];
181 add_output_flags(&mut args, output, no_color);
182 if matches!(output, DiffOutput::Full) {
183 args.push("--format=".to_string()); }
185 args.push(commits[0].clone());
186 } else {
187 let earliest = commits.last().unwrap(); let latest = &commits[0];
191 args.push(format!("{}^..{}", earliest, latest));
192 }
193
194 run_git_to_stdout(project_root, &args)
195}
196
197fn show_range_diff(
199 project_root: &Path,
200 from: &str,
201 to: &str,
202 output: &DiffOutput,
203 no_color: bool,
204) -> Result<()> {
205 let mut args = vec!["diff".to_string()];
206 add_output_flags(&mut args, output, no_color);
207 args.push(from.to_string());
208 args.push(to.to_string());
209 run_git_to_stdout(project_root, &args)
210}
211
212fn add_output_flags(args: &mut Vec<String>, output: &DiffOutput, no_color: bool) {
214 match output {
215 DiffOutput::Stat => args.push("--stat".to_string()),
216 DiffOutput::NameOnly => args.push("--name-only".to_string()),
217 DiffOutput::Full => {}
218 }
219
220 if no_color {
221 args.push("--no-color".to_string());
222 } else {
223 args.push("--color=auto".to_string());
224 }
225}
226
227fn run_git_to_stdout(project_root: &Path, args: &[String]) -> Result<()> {
229 let status = Command::new("git")
230 .args(args)
231 .current_dir(project_root)
232 .status()
233 .context("Failed to run git")?;
234
235 if !status.success() {
236 if status.code() == Some(1) {
238 return Ok(());
239 }
240 anyhow::bail!("git exited with code {}", status.code().unwrap_or(-1));
241 }
242 Ok(())
243}
244
245#[cfg(test)]
250mod tests {
251 use super::*;
252 use std::fs;
253 use tempfile::TempDir;
254
255 fn setup_git_repo() -> (TempDir, std::path::PathBuf) {
257 let dir = TempDir::new().unwrap();
258 let project_root = dir.path();
259 let mana_dir = project_root.join(".mana");
260 fs::create_dir(&mana_dir).unwrap();
261
262 run_git(project_root, &["init"]);
264 run_git(project_root, &["config", "user.email", "test@test.com"]);
265 run_git(project_root, &["config", "user.name", "Test"]);
266
267 fs::write(project_root.join("initial.txt"), "initial").unwrap();
269 run_git(project_root, &["add", "-A"]);
270 run_git(project_root, &["commit", "-m", "Initial commit"]);
271
272 (dir, mana_dir)
273 }
274
275 fn run_git(dir: &Path, args: &[&str]) {
276 let status = Command::new("git")
277 .args(args)
278 .current_dir(dir)
279 .stdout(std::process::Stdio::null())
280 .stderr(std::process::Stdio::null())
281 .status()
282 .unwrap();
283 assert!(status.success(), "git {:?} failed", args);
284 }
285
286 fn write_unit(mana_dir: &Path, unit: &Unit) {
287 let path = mana_dir.join(format!("{}-test.md", unit.id));
288 unit.to_file(&path).unwrap();
289 }
290
291 #[test]
292 fn is_git_repo_true_for_git_dir() {
293 let (dir, _) = setup_git_repo();
294 assert!(is_git_repo(dir.path()));
295 }
296
297 #[test]
298 fn is_git_repo_false_for_non_git_dir() {
299 let dir = TempDir::new().unwrap();
300 assert!(!is_git_repo(dir.path()));
301 }
302
303 #[test]
304 fn find_commits_for_unit_finds_matching_commits() {
305 let (dir, mana_dir) = setup_git_repo();
306 let project_root = mana_dir.parent().unwrap();
307
308 fs::write(project_root.join("feature.txt"), "new feature").unwrap();
310 run_git(project_root, &["add", "-A"]);
311 run_git(project_root, &["commit", "-m", "feat(unit-5): add feature"]);
312
313 let commits = find_commits_for_unit(project_root, "5").unwrap();
314 assert_eq!(commits.len(), 1);
315
316 let commits_other = find_commits_for_unit(project_root, "99").unwrap();
318 assert!(commits_other.is_empty());
319
320 drop(dir);
321 }
322
323 #[test]
324 fn find_commits_ignores_partial_id_matches() {
325 let (dir, mana_dir) = setup_git_repo();
326 let project_root = mana_dir.parent().unwrap();
327
328 fs::write(project_root.join("f.txt"), "content").unwrap();
330 run_git(project_root, &["add", "-A"]);
331 run_git(project_root, &["commit", "-m", "feat(unit-5): something"]);
332
333 let commits = find_commits_for_unit(project_root, "50").unwrap();
334 assert!(commits.is_empty());
335
336 drop(dir);
337 }
338
339 #[test]
340 fn cmd_diff_no_git_repo_fails() {
341 let dir = TempDir::new().unwrap();
342 let mana_dir = dir.path().join(".mana");
343 fs::create_dir(&mana_dir).unwrap();
344
345 let unit = Unit::new("1", "Test");
346 let path = mana_dir.join("1-test.md");
347 unit.to_file(&path).unwrap();
348
349 let result = cmd_diff(&mana_dir, "1", DiffOutput::Full, false);
350 assert!(result.is_err());
351 let err = result.unwrap_err().to_string();
352 assert!(err.contains("git"), "Expected git error, got: {}", err);
353 }
354
355 #[test]
356 fn cmd_diff_with_tagged_commit_succeeds() {
357 let (dir, mana_dir) = setup_git_repo();
358 let project_root = mana_dir.parent().unwrap();
359
360 let unit = Unit::new("3", "Add login");
362 write_unit(&mana_dir, &unit);
363
364 fs::write(project_root.join("login.rs"), "fn login() {}").unwrap();
366 run_git(project_root, &["add", "-A"]);
367 run_git(project_root, &["commit", "-m", "feat(unit-3): Add login"]);
368
369 let result = cmd_diff(&mana_dir, "3", DiffOutput::Stat, true);
371 assert!(result.is_ok());
372
373 drop(dir);
374 }
375
376 #[test]
377 fn cmd_diff_with_checkpoint_succeeds() {
378 let (dir, mana_dir) = setup_git_repo();
379 let project_root = mana_dir.parent().unwrap();
380
381 let head = Command::new("git")
383 .args(["rev-parse", "HEAD"])
384 .current_dir(project_root)
385 .output()
386 .unwrap();
387 let checkpoint = String::from_utf8_lossy(&head.stdout).trim().to_string();
388
389 let mut unit = Unit::new("7", "Refactor auth");
391 unit.checkpoint = Some(checkpoint);
392 write_unit(&mana_dir, &unit);
393
394 fs::write(project_root.join("auth.rs"), "fn auth() {}").unwrap();
396 run_git(project_root, &["add", "-A"]);
397 run_git(project_root, &["commit", "-m", "refactor auth"]);
398
399 let result = cmd_diff(&mana_dir, "7", DiffOutput::Full, true);
400 assert!(result.is_ok());
401
402 drop(dir);
403 }
404
405 #[test]
406 fn cmd_diff_nonexistent_unit_fails() {
407 let (_dir, mana_dir) = setup_git_repo();
408 let result = cmd_diff(&mana_dir, "999", DiffOutput::Full, false);
409 assert!(result.is_err());
410 }
411
412 #[test]
413 fn find_commit_at_time_returns_none_for_future() {
414 let (dir, _) = setup_git_repo();
415 let result = find_commit_at_time(dir.path(), "2099-01-01T00:00:00Z").unwrap();
417 assert!(result.is_some());
418
419 drop(dir);
420 }
421
422 #[test]
423 fn add_output_flags_stat() {
424 let mut args = Vec::new();
425 add_output_flags(&mut args, &DiffOutput::Stat, false);
426 assert!(args.contains(&"--stat".to_string()));
427 assert!(args.contains(&"--color=auto".to_string()));
428 }
429
430 #[test]
431 fn add_output_flags_name_only_no_color() {
432 let mut args = Vec::new();
433 add_output_flags(&mut args, &DiffOutput::NameOnly, true);
434 assert!(args.contains(&"--name-only".to_string()));
435 assert!(args.contains(&"--no-color".to_string()));
436 }
437
438 #[test]
439 fn add_output_flags_full_default() {
440 let mut args = Vec::new();
441 add_output_flags(&mut args, &DiffOutput::Full, false);
442 assert!(!args.contains(&"--stat".to_string()));
443 assert!(!args.contains(&"--name-only".to_string()));
444 assert!(args.contains(&"--color=auto".to_string()));
445 }
446}