Skip to main content

mana/commands/
diff.rs

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
9/// Output mode for the diff command.
10pub enum DiffOutput {
11    /// Full unified diff (default).
12    Full,
13    /// --stat: file-level summary.
14    Stat,
15    /// --name-only: just filenames.
16    NameOnly,
17}
18
19/// Show git diff of what changed for a specific unit.
20///
21/// Strategy:
22/// 1. Look for commits with `unit-{id}` in the message (preferred auto-commit
23///    convention), plus legacy `Close unit {id}` messages. If found, show the
24///    combined diff for those commits.
25/// 2. Fall back to timestamp-based diffing: find the commit closest to
26///    claimed_at and diff to closed_at (or HEAD if still open).
27/// 3. If the unit has a checkpoint SHA, use that as the base.
28pub 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    // Ensure we're in a git repo
39    if !is_git_repo(project_root) {
40        anyhow::bail!("Not a git repository. bn diff requires git.");
41    }
42
43    // Strategy 1: Find commits by message convention (auto_commit)
44    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    // Strategy 2: Use checkpoint SHA if available (from fail-first claim)
50    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    // Strategy 3: Timestamp-based diffing
56    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
74/// Check if a directory is inside a git repository.
75fn 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
86/// Find commits whose message references a unit ID.
87///
88/// Looks for the preferred auto-commit convention `unit-{id}` plus legacy
89/// `Close unit {id}: ...` messages for backward compatibility.
90fn find_commits_for_unit(project_root: &Path, id: &str) -> Result<Vec<String>> {
91    // Search for commits mentioning this unit ID in the message
92    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
120/// Determine the end ref for a diff range.
121///
122/// - Closed units: use the commit at closed_at time (or HEAD).
123/// - Open/in-progress units: use HEAD (shows working tree changes).
124fn resolve_end_ref(unit: &Unit, project_root: &Path) -> Result<String> {
125    if let Some(closed_at) = &unit.closed_at {
126        // Find the commit closest to close time
127        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
136/// Find the commit closest to (at or before) a given timestamp.
137fn 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
161/// Show diff for specific commits (auto_commit mode).
162///
163/// When there's a single commit, shows that commit's diff.
164/// When there are multiple commits, shows the combined range.
165fn 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        // Single commit: show its diff
180        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()); // suppress commit header for clean diff
184        }
185        args.push(commits[0].clone());
186    } else {
187        // Multiple commits: find the range from earliest parent to latest
188        // Sort by commit date and diff from earliest^..latest
189        let earliest = commits.last().unwrap(); // git log returns newest first
190        let latest = &commits[0];
191        args.push(format!("{}^..{}", earliest, latest));
192    }
193
194    run_git_to_stdout(project_root, &args)
195}
196
197/// Show diff between two refs.
198fn 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
212/// Add output mode flags to a git command.
213fn 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
227/// Run a git command and pipe output to stdout.
228fn 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        // Non-zero exit from git diff usually means "no differences" — not an error
237        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// ---------------------------------------------------------------------------
246// Tests
247// ---------------------------------------------------------------------------
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use std::fs;
253    use tempfile::TempDir;
254
255    /// Set up a temp dir with a git repo and a .mana/ directory containing a unit.
256    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        // Init git repo
263        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        // Initial commit
268        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        // Create a commit with the auto_commit convention
309        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        // Should NOT match unrelated units
317        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        // Commit for unit 5 should NOT match unit 50
329        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        // Create unit
361        let unit = Unit::new("3", "Add login");
362        write_unit(&mana_dir, &unit);
363
364        // Make a change and commit with auto_commit convention
365        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        // Should succeed (output goes to stdout)
370        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        // Get current HEAD as checkpoint
382        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        // Create unit with checkpoint
390        let mut unit = Unit::new("7", "Refactor auth");
391        unit.checkpoint = Some(checkpoint);
392        write_unit(&mana_dir, &unit);
393
394        // Make a change and commit
395        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        // Far future — should still return the latest commit
416        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}