travelagent 1.10.3

Agent-first TUI code review tool
//! Handler slices for the reviewer-mode toggles:
//!
//! - `:blind` / `:unblind` — blind-tests filter (Phase I3b)
//! - `:reload-review-config` — re-read `.travelagent/review.toml`
//! - `:spar` / `:unspar` — Sparring Review mode (Phase I1+)
//! - `:spec` / `:specs` — Spec comment kind, for spec→test workflows
//!
//! Extracted out of `handle_command_action` in v1.7 as part of the
//! standing "peel mechanical slices out of the 4,000-line handler
//! file" policy (ROADMAP #785). Each handler is a free function that
//! takes `&mut App` so the command-action dispatch collapses to a
//! one-line `handle_blind(app)`. See `handler/mod.rs` for the
//! surrounding match.
//!
//! **Companion module:** `handler/sparring_panel.rs` owns the
//! Sparring *panel's* action-level dispatch (`r`/`d`/`a` inside the
//! panel). This module owns the *command-mode* toggles that enter /
//! leave the modes. Together they cover the Sparring Review surface.

use crate::app::{App, DiffSource};

/// `:unblind` — turn the blind-tests filter off and restore the full
/// diff. Reloads the diff (remote or local) so previously-hidden files
/// reappear without a second `:reload`.
pub(super) fn handle_unblind(app: &mut App) {
    if !app.blind_mode {
        app.set_message("Blind-tests mode already off");
        return;
    }
    app.blind_mode = false;
    if matches!(app.diff_source, DiffSource::Remote { .. }) {
        match app.refresh_remote() {
            Ok(()) => app.set_message(format!(
                "Blind-tests off: restored {} file(s)",
                app.diff_files.len()
            )),
            Err(e) => app.set_error(format!("Unblind refresh failed: {e}")),
        }
    } else {
        match app.reload_diff_files() {
            Ok(n) => app.set_message(format!("Blind-tests off: restored {n} file(s)")),
            Err(e) => app.set_error(format!("Unblind reload failed: {e}")),
        }
    }
}

/// `:blind` — turn the blind-tests filter on. No-op (with a warning)
/// when `hidden_from_reviewer` is empty so reviewers notice a
/// misconfiguration rather than silently seeing the full tree.
pub(super) fn handle_blind(app: &mut App) {
    if app.blind_mode {
        app.set_message("Blind-tests mode already on");
    } else if app.blind_patterns.is_empty() {
        app.set_warning(
            ":blind is a no-op (hidden_from_reviewer empty in .travelagent/review.toml)",
        );
    } else {
        app.blind_mode = true;
        let hidden = app.apply_blind_filter();
        app.set_message(format!("Blind-tests on: hiding {hidden} file(s)"));
    }
}

/// `:reload-review-config` — re-read `.travelagent/review.toml` without
/// restarting the TUI. If blind-mode is currently active, rebuild the
/// diff and reapply the filter so the new patterns take effect
/// immediately; otherwise just stage them for the next `:blind`.
pub(super) fn handle_reload_review_config(app: &mut App) {
    let root = app.vcs_info.root_path.clone();
    if !root.is_absolute() {
        app.set_warning(
            ":reload-review-config has no effect without a repo root (demo / remote-only mode)",
        );
        return;
    }
    match travelagent_core::review_config::load_review_config(&root) {
        Ok(outcome) => {
            for w in &outcome.warnings {
                app.set_warning(w.clone());
            }
            app.blind_patterns = outcome.config.hidden_from_reviewer;
            if app.blind_mode {
                // Rebuild the diff set from scratch so files hidden by
                // the previous rules but now visible come back, then
                // reapply the filter with the fresh patterns.
                // `refresh_remote` and `reload_diff_files` return
                // different error types (anyhow vs TrvError), so each
                // branch surfaces its own error rather than unifying.
                let refreshed = if matches!(app.diff_source, DiffSource::Remote { .. }) {
                    app.refresh_remote().map_err(|e| format!("{e}"))
                } else {
                    app.reload_diff_files()
                        .map(|_| ())
                        .map_err(|e| format!("{e}"))
                };
                match refreshed {
                    Ok(()) => {
                        let hidden = app.apply_blind_filter();
                        app.set_message(format!("review.toml reloaded; hiding {hidden} file(s)"));
                    }
                    Err(e) => {
                        app.set_error(format!(
                            "Reload failed (patterns updated, diff refresh error): {e}"
                        ));
                    }
                }
            } else {
                app.set_message(format!(
                    "review.toml reloaded; {} pattern(s) staged (run :blind to apply)",
                    app.blind_patterns.len()
                ));
            }
        }
        Err(e) => {
            app.set_error(format!("Failed to reload .travelagent/review.toml: {e}"));
        }
    }
}

/// `:spar` — enter Sparring Review mode mid-session. Uses the same
/// create-or-resume flow as the `--spar` launch flag so startup and
/// mid-session paths converge. Refuses on a dirty tree; falls back to
/// flag-only on unsupported VCS. (Prior behavior was flag-only only,
/// which surprised users into committing on the wrong branch —
/// addressed by the post-v1.3.0 arch review.)
pub(super) fn handle_spar(app: &mut App) {
    if app.spar_mode {
        app.set_message("Sparring Review mode already on");
        return;
    }
    match crate::enter_spar_mode(app) {
        Ok(crate::SparEntryOutcome::Created(branch)) => {
            app.spar_mode = true;
            app.set_message(format!("Sparring mode on: created branch {branch}"));
        }
        Ok(crate::SparEntryOutcome::Resumed(branch)) => {
            app.spar_mode = true;
            app.set_message(format!("Sparring mode on: resumed branch {branch}"));
        }
        Ok(crate::SparEntryOutcome::FlagOnly(reason)) => {
            app.spar_mode = true;
            app.set_warning(format!(
                "Sparring mode flag-only — {}",
                reason.user_message()
            ));
        }
        Err(e) => {
            app.set_error(format!("Could not enter sparring mode: {e}"));
        }
    }
}

/// `:unspar` — leave Sparring Review mode. Doesn't touch the sparring
/// branch; the reviewer can switch back with `:spar`.
pub(super) fn handle_unspar(app: &mut App) {
    if !app.spar_mode {
        app.set_message("Sparring Review mode already off");
    } else {
        app.spar_mode = false;
        app.set_message("Sparring Review mode off");
    }
}

/// `:spec` — set the default comment type to Spec so the next new
/// comment (or the currently-open comment draft) lands as a test
/// specification. Gated on Sparring mode: spec-to-test without the
/// sparring branch is just a suggestion comment with extra steps.
pub(super) fn handle_spec(app: &mut App) {
    if !app.spar_mode {
        app.set_warning(":spec requires Sparring Review mode (use --spar or :spar first)");
    } else {
        use travelagent_core::model::CommentType;
        app.comment.comment_type = CommentType::Spec;
        app.set_message("Next comment will be a Spec; use :specs to list existing ones");
    }
}

/// `:specs` — one-line summary of spec comments on this session. The
/// agent-side detail (scope/line/body) is available via
/// `trv_list_spec_comments`; the TUI surface is just a counter to keep
/// the status bar reading compact.
pub(super) fn handle_specs(app: &mut App) {
    let n = app.engine.session().spec_count();
    if n == 0 {
        app.set_message("No specs yet — use :spec then add a comment (requires :spar)");
    } else {
        app.set_message(format!(
            "{n} spec{} on this session (see `trv_list_spec_comments` for detail)",
            if n == 1 { "" } else { "s" }
        ));
    }
}