declutter-diff 0.1.2

Review AI-generated diffs with comments, tests, imports, logging and formatting toggled in and out
Documentation
use std::io::IsTerminal;
use std::process::ExitCode;

use anyhow::{Result, bail};
use declutter::git::{load, repo_root};
use declutter::pr::CliLookup;
use declutter::project::LayerMode;
use declutter::review::{FileReview, Layers};
use declutter::store::{NoteStore, ReviewStore};
use declutter::target::resolve_target;
use declutter::upload::{CliPoster, offer_upload};
use declutter::{render, tui};

const USAGE: &str = "\
declutter — review a diff with comments and tests shown, hidden, or on their own

USAGE:
    declutter [OPTIONS]                   the branch you are on vs main, uncommitted work included
                                          (on main itself: just the uncommitted work)
    declutter [OPTIONS] <PR URL>          a GitHub or Azure DevOps pull request
    declutter [OPTIONS] pr <NUMBER>       a pull request in the repository origin points at
    declutter [OPTIONS] <BRANCH>          a branch vs the main branch, from where it split off
    declutter [OPTIONS] <A..B | A...B>    as in git diff
    declutter [OPTIONS] <REVISION>        a revision vs the working tree
    declutter notes [--clear]

    A pull request is fetched into refs/declutter/pr/<n>/ (no local branch is touched) and
    shown as its PR page shows it; if the remote publishes no merge ref for it, the `gh` or
    `az` CLI is asked for its branches. A branch that exists only on origin is fetched. The
    main branch is what origin/HEAD points at (else main or master); --base overrides it.
    `notes` prints the review notes left with `m` as one prompt for a coding agent;
    `--clear` deletes them. After reviewing a pull request, declutter lists the notes
    you left and asks before posting them to the PR as line comments (via `az` / `gh`).

OPTIONS:
    --staged             Compare against the index instead of the working tree
    --base <BRANCH>      Compare against BRANCH instead of the main branch
    --comments <MODE>    Start comments in MODE: hidden (default), only, shown
    --tests <MODE>       Start tests in MODE: shown (default), hidden, only
    --imports <MODE>     Start imports in MODE: shown (default), hidden, only
    --logging <MODE>     Start logging statements in MODE: shown (default), hidden, only
    --formatting <MODE>  Start whitespace-only changes in MODE: shown (default), hidden, only
    -p, --print          Print the diff instead of opening the viewer
    --no-syntax          Start with syntax colouring off (s toggles it)
    -h, --help           Show this help
    -V, --version        Show the version

KEYS (viewer; press ? for the full list):
    ↑ ↓  j k    move through the files, or the diff's line cursor
    ← →  Tab    switch between the file list and the diff
    ] [         next / previous change, on into the next file
    } {         next / previous file
    Space  b    page down / up; d u half a page; g G top / bottom
    /  n  N     search; next / previous match
    r           mark the file reviewed and go to the next one
    m  E  o     note a line; copy all notes as a prompt; open in $EDITOR
    c t i l f   hide / show comments, tests, imports, logging, formatting
    C T I L F   show only that layer (again to go back)
    a           all filters off, and back on
    M           collapse moved blocks
    s           syntax colouring on / off
    q           quit
";

struct Args {
    positionals: Vec<String>,
    base: Option<String>,
    staged: bool,
    layers: Layers,
    print: bool,
    clear: bool,
    syntax: bool,
}

fn parse_args(raw: impl Iterator<Item = String>) -> Result<Option<Args>> {
    let mut args = Args {
        positionals: Vec::new(),
        base: None,
        staged: false,
        layers: Layers::default(),
        print: false,
        clear: false,
        syntax: true,
    };
    let mut raw = raw.peekable();
    while let Some(arg) = raw.next() {
        let (flag, inline) = match arg.split_once('=') {
            Some((flag, value)) if flag.starts_with("--") => {
                (flag.to_string(), Some(value.to_string()))
            }
            _ => (arg.clone(), None),
        };
        match flag.as_str() {
            "-h" | "--help" => {
                print!("{USAGE}");
                return Ok(None);
            }
            "-V" | "--version" => {
                println!("declutter {}", env!("CARGO_PKG_VERSION"));
                return Ok(None);
            }
            "--staged" | "--cached" => args.staged = true,
            "-p" | "--print" => args.print = true,
            "--clear" => args.clear = true,
            "--no-syntax" => args.syntax = false,
            "--base" => {
                let Some(value) = inline.or_else(|| raw.next()) else {
                    bail!("--base needs a branch");
                };
                args.base = Some(value);
            }
            "--comments" | "--tests" | "--imports" | "--logging" | "--formatting" => {
                let Some(value) = inline.or_else(|| raw.next()) else {
                    bail!("{flag} needs a value: hidden, only or shown");
                };
                let Some(mode) = LayerMode::parse(&value) else {
                    bail!("unknown {flag} value `{value}`: use hidden, only or shown");
                };
                match flag.as_str() {
                    "--comments" => args.layers.comments = mode,
                    "--tests" => args.layers.tests = mode,
                    "--imports" => args.layers.imports = mode,
                    "--logging" => args.layers.logging = mode,
                    _ => args.layers.formatting = mode,
                }
            }
            _ if arg.starts_with('-') => bail!("unknown option `{arg}` (see --help)"),
            _ => args.positionals.push(arg),
        }
    }
    Ok(Some(args))
}

fn run() -> Result<()> {
    let Some(args) = parse_args(std::env::args().skip(1))? else {
        return Ok(());
    };
    let dir = std::env::current_dir()?;
    if args
        .positionals
        .first()
        .is_some_and(|first| first == "notes")
    {
        let mut notes = NoteStore::open(&dir);
        if args.clear {
            let count = notes.notes().len();
            notes.clear()?;
            println!("Cleared {count} note{}.", if count == 1 { "" } else { "s" });
        } else if notes.notes().is_empty() {
            println!("No review notes. Press m on a diff line in the viewer to add one.");
        } else {
            print!("{}", notes.prompt());
        }
        return Ok(());
    }
    if args.clear {
        bail!("--clear only applies to `declutter notes`");
    }
    let target = resolve_target(
        &dir,
        &args.positionals,
        args.staged,
        args.base.as_deref(),
        &CliLookup,
    )?;
    let files: Vec<FileReview> = load(&dir, &target.spec)?
        .into_iter()
        .map(FileReview::new)
        .collect();

    if args.print {
        print!("{}", render::plain(&files, args.layers));
        return Ok(());
    }
    if files.is_empty() {
        println!("No changes in {}.", target.label);
        return Ok(());
    }
    if !std::io::stdout().is_terminal() {
        bail!("stdout is not a terminal; use --print for text output");
    }
    let notes = NoteStore::open(&dir).scoped(target.review_key());
    let mut notes = tui::run(
        files,
        args.layers,
        ReviewStore::open(&dir),
        notes,
        repo_root(&dir)?,
        target.label.clone(),
        args.syntax,
    )?;
    if let Some(pr) = &target.pr
        && std::io::stdin().is_terminal()
    {
        let poster = CliPoster { dir: dir.clone() };
        offer_upload(
            &mut notes,
            pr,
            &poster,
            &mut std::io::stdin().lock(),
            &mut std::io::stdout(),
        )?;
    }
    Ok(())
}

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("declutter: {error:#}");
            ExitCode::from(2)
        }
    }
}