use crate::commands::auto_compact_if_needed;
use crate::format::*;
use crate::git::*;
use crate::prompt::*;
use std::io::{self, Write};
use yoagent::agent::Agent;
use yoagent::*;
#[derive(Debug, Clone, PartialEq)]
pub struct DiffStatEntry {
pub file: String,
pub insertions: u32,
pub deletions: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct DiffStatSummary {
pub entries: Vec<DiffStatEntry>,
pub total_insertions: u32,
pub total_deletions: u32,
}
pub fn parse_diff_stat(stat_output: &str) -> DiffStatSummary {
let mut entries = Vec::new();
let mut total_insertions: u32 = 0;
let mut total_deletions: u32 = 0;
for line in stat_output.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.contains("changed")
&& (trimmed.contains("insertion") || trimmed.contains("deletion"))
{
if let Some(ins_part) = trimmed.split("insertion").next() {
if let Some(num_str) = ins_part.split(',').next_back() {
if let Ok(n) = num_str.trim().parse::<u32>() {
total_insertions = n;
}
}
}
if let Some(del_part) = trimmed.split("deletion").next() {
if let Some(num_str) = del_part.split(',').next_back() {
if let Ok(n) = num_str.trim().parse::<u32>() {
total_deletions = n;
}
}
}
continue;
}
if let Some(pipe_pos) = trimmed.find('|') {
let file = trimmed[..pipe_pos].trim().to_string();
let stats_part = trimmed[pipe_pos + 1..].trim();
if file.is_empty() {
continue;
}
let insertions = stats_part.chars().filter(|&c| c == '+').count() as u32;
let deletions = stats_part.chars().filter(|&c| c == '-').count() as u32;
entries.push(DiffStatEntry {
file,
insertions,
deletions,
});
}
}
if total_insertions == 0 && total_deletions == 0 {
total_insertions = entries.iter().map(|e| e.insertions).sum();
total_deletions = entries.iter().map(|e| e.deletions).sum();
}
DiffStatSummary {
entries,
total_insertions,
total_deletions,
}
}
pub fn format_diff_stat(summary: &DiffStatSummary) -> String {
let mut output = String::new();
if summary.entries.is_empty() {
return output;
}
let max_name_len = summary
.entries
.iter()
.map(|e| e.file.len())
.max()
.unwrap_or(0);
output.push_str(&format!("{DIM} File summary:{RESET}\n"));
for entry in &summary.entries {
let total_changes = entry.insertions + entry.deletions;
let ins_str = if entry.insertions > 0 {
format!("{GREEN}+{}{RESET}", entry.insertions)
} else {
String::new()
};
let del_str = if entry.deletions > 0 {
format!("{RED}-{}{RESET}", entry.deletions)
} else {
String::new()
};
let sep = if entry.insertions > 0 && entry.deletions > 0 {
" "
} else {
""
};
output.push_str(&format!(
" {:<width$} {}{DIM}{:>4}{RESET} {ins_str}{sep}{del_str}\n",
entry.file,
"",
total_changes,
width = max_name_len,
));
}
let files_count = summary.entries.len();
output.push_str(&format!(
"\n {DIM}{files_count} file{s} changed{RESET}",
s = if files_count == 1 { "" } else { "s" }
));
if summary.total_insertions > 0 {
output.push_str(&format!(", {GREEN}+{}{RESET}", summary.total_insertions));
}
if summary.total_deletions > 0 {
output.push_str(&format!(", {RED}-{}{RESET}", summary.total_deletions));
}
output.push('\n');
output
}
#[derive(Debug, Clone, PartialEq)]
pub struct DiffOptions {
pub staged_only: bool,
pub name_only: bool,
pub file: Option<String>,
}
pub fn parse_diff_args(input: &str) -> DiffOptions {
let rest = input.strip_prefix("/diff").unwrap_or("").trim();
let parts: Vec<&str> = rest.split_whitespace().collect();
let mut staged_only = false;
let mut name_only = false;
let mut file = None;
for part in parts {
match part {
"--staged" | "--cached" => staged_only = true,
"--name-only" => name_only = true,
_ => file = Some(part.to_string()),
}
}
DiffOptions {
staged_only,
name_only,
file,
}
}
pub fn handle_diff(input: &str) {
let opts = parse_diff_args(input);
match run_git(&["status", "--short"]) {
Ok(status) if status.is_empty() => {
println!("{DIM} (no uncommitted changes){RESET}\n");
}
Ok(_status) => {
if opts.name_only {
let mut args = vec!["diff", "--name-only"];
if opts.staged_only {
args.push("--cached");
}
let file_ref;
if let Some(ref f) = opts.file {
args.push("--");
file_ref = f.as_str();
args.push(file_ref);
}
let names = run_git(&args).unwrap_or_default();
if !opts.staged_only {
let mut staged_args = vec!["diff", "--name-only", "--cached"];
let staged_file_ref;
if let Some(ref f) = opts.file {
staged_args.push("--");
staged_file_ref = f.as_str();
staged_args.push(staged_file_ref);
}
let staged_names = run_git(&staged_args).unwrap_or_default();
let mut all_files: Vec<&str> = names
.lines()
.chain(staged_names.lines())
.filter(|l| !l.trim().is_empty())
.collect();
all_files.sort();
all_files.dedup();
if all_files.is_empty() {
println!("{DIM} (no changed files){RESET}\n");
} else {
println!("{DIM} Changed files:{RESET}");
for f in &all_files {
println!(" {f}");
}
println!();
}
} else if names.trim().is_empty() {
println!("{DIM} (no staged files){RESET}\n");
} else {
println!("{DIM} Staged files:{RESET}");
for f in names.lines().filter(|l| !l.trim().is_empty()) {
println!(" {f}");
}
println!();
}
return;
}
if opts.staged_only {
let mut stat_args = vec!["diff", "--cached", "--stat"];
let stat_file_ref;
if let Some(ref f) = opts.file {
stat_args.push("--");
stat_file_ref = f.as_str();
stat_args.push(stat_file_ref);
}
let stat_text = run_git(&stat_args).unwrap_or_default();
if stat_text.trim().is_empty() {
println!("{DIM} (no staged changes){RESET}\n");
return;
}
let summary = parse_diff_stat(&stat_text);
let formatted = format_diff_stat(&summary);
if !formatted.is_empty() {
print!("{formatted}");
}
let mut diff_args = vec!["diff", "--cached"];
let diff_file_ref;
if let Some(ref f) = opts.file {
diff_args.push("--");
diff_file_ref = f.as_str();
diff_args.push(diff_file_ref);
}
let full_diff = run_git(&diff_args).unwrap_or_default();
if !full_diff.trim().is_empty() {
println!("\n{DIM} ── Staged diff ──{RESET}");
print!("{}", colorize_diff(&full_diff));
println!();
}
return;
}
if let Some(ref file) = opts.file {
let stat_text =
run_git(&["diff", "--stat", "--", file.as_str()]).unwrap_or_default();
let staged_stat_text =
run_git(&["diff", "--cached", "--stat", "--", file.as_str()])
.unwrap_or_default();
let combined_stat = combine_stats(&stat_text, &staged_stat_text);
if combined_stat.trim().is_empty() {
println!("{DIM} (no changes for {file}){RESET}\n");
return;
}
let summary = parse_diff_stat(&combined_stat);
let formatted = format_diff_stat(&summary);
if !formatted.is_empty() {
print!("{formatted}");
}
let full_diff = run_git(&["diff", "--", file.as_str()]).unwrap_or_default();
let staged_diff =
run_git(&["diff", "--cached", "--", file.as_str()]).unwrap_or_default();
let combined_diff = combine_stats(&full_diff, &staged_diff);
if !combined_diff.trim().is_empty() {
println!("\n{DIM} ── Diff for {file} ──{RESET}");
print!("{}", colorize_diff(&combined_diff));
println!();
}
return;
}
let stat_text = run_git(&["diff", "--stat"]).unwrap_or_default();
let staged_stat_text = run_git(&["diff", "--cached", "--stat"]).unwrap_or_default();
println!("{DIM} Changes:");
for line in _status.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let (color, rest) = if trimmed.len() >= 2 {
match trimmed.chars().next().unwrap_or(' ') {
'M' | 'A' | 'R' => (format!("{GREEN}"), trimmed),
'D' => (format!("{RED}"), trimmed),
'?' => (format!("{YELLOW}"), trimmed),
_ => (format!("{DIM}"), trimmed),
}
} else {
(format!("{DIM}"), trimmed)
};
println!(" {color}{rest}{RESET}");
}
println!("{RESET}");
let combined_stat = combine_stats(&stat_text, &staged_stat_text);
if !combined_stat.trim().is_empty() {
let summary = parse_diff_stat(&combined_stat);
let formatted = format_diff_stat(&summary);
if !formatted.is_empty() {
print!("{formatted}");
}
}
let full_diff = run_git(&["diff"]).unwrap_or_default();
if !full_diff.trim().is_empty() {
println!("\n{DIM} ── Full diff ──{RESET}");
print!("{}", colorize_diff(&full_diff));
println!();
}
}
_ => eprintln!("{RED} error: not in a git repository{RESET}\n"),
}
}
fn combine_stats(a: &str, b: &str) -> String {
if !a.trim().is_empty() && !b.trim().is_empty() {
format!("{}\n{}", a, b)
} else if !b.trim().is_empty() {
b.to_string()
} else {
a.to_string()
}
}
fn build_undo_context(actions: &[String]) -> String {
let count = actions.len();
let file_word = crate::format::pluralize(count, "file", "files");
let mut note =
format!("[System note: /undo reverted {count} {file_word} from a previous turn:\n");
for action in actions {
note.push_str(&format!("- {action}\n"));
}
note.push_str(
"⚠️ The code referenced in my previous response may no longer exist. \
Re-read affected files before making new changes. \
Verify current file state before continuing.]",
);
note
}
pub fn handle_undo(input: &str, history: &mut crate::prompt::TurnHistory) -> Option<String> {
let arg = input.strip_prefix("/undo").unwrap_or("").trim();
if arg == "--all" {
return handle_undo_all(history);
}
if arg == "--last-commit" {
return handle_undo_last_commit();
}
let count: usize = if arg.is_empty() {
1
} else if let Ok(n) = arg.parse::<usize>() {
if n == 0 {
println!("{DIM} (nothing to undo — count is 0){RESET}\n");
return None;
}
n
} else {
println!("{DIM} usage: /undo [N] | --all | --last-commit{RESET}\n");
return None;
};
if history.is_empty() {
let has_diff = !run_git(&["diff", "--stat"])
.unwrap_or_default()
.trim()
.is_empty();
let has_untracked = !run_git(&["ls-files", "--others", "--exclude-standard"])
.unwrap_or_default()
.trim()
.is_empty();
if has_diff || has_untracked {
println!("{DIM} no turn history available, but there are uncommitted changes.{RESET}");
println!("{DIM} use /undo --all to revert everything (nuclear option){RESET}\n");
} else {
println!("{DIM} (nothing to undo — no turn history){RESET}\n");
}
return None;
}
let available = history.len();
let actual = count.min(available);
let word = crate::format::pluralize(actual, "turn", "turns");
println!("{DIM} undoing last {actual} {word}...{RESET}");
let actions = history.undo_last(actual);
for action in &actions {
println!("{DIM} {action}{RESET}");
}
if actions.is_empty() {
println!("{DIM} (no files were modified in those turns){RESET}\n");
} else {
let file_word = crate::format::pluralize(actions.len(), "file", "files");
println!(
"{GREEN} ✓ undid {actual} {word} ({} {file_word} affected){RESET}\n",
actions.len()
);
}
if count > available {
println!(
"{DIM} (only {available} {} available, undid all){RESET}\n",
crate::format::pluralize(available, "turn was", "turns were")
);
}
if !actions.is_empty() {
Some(build_undo_context(&actions))
} else {
None
}
}
fn handle_undo_last_commit() -> Option<String> {
let log = run_git(&["log", "--oneline", "-1"]).unwrap_or_default();
if log.trim().is_empty() {
println!("{DIM} (no commits to undo){RESET}\n");
return None;
}
let files = run_git(&["diff", "--name-only", "HEAD~1", "HEAD"]).unwrap_or_default();
println!("{DIM} Reverting last commit: {}{RESET}", log.trim());
let result = run_git(&["revert", "HEAD", "--no-edit"]);
match result {
Ok(output) => {
println!("{GREEN} ✓ Reverted last commit{RESET}");
if !output.trim().is_empty() {
println!("{DIM} {}{RESET}", output.trim());
}
println!();
let mut actions = Vec::new();
for f in files.lines().filter(|l| !l.is_empty()) {
actions.push(format!("reverted changes to {f} (commit undone)"));
}
let mut note =
String::from("[System note: /undo --last-commit reverted a git commit.\n");
note.push_str(&format!("Reverted commit: {}\n", log.trim()));
note.push_str("Files affected:\n");
for action in &actions {
note.push_str(&format!("- {action}\n"));
}
note.push_str(
"⚠️ Earlier messages in this conversation may reference code from this commit \
that no longer exists. Verify current file state before continuing.\n",
);
note.push_str(
"Any journal entries about this commit describe work that has been undone.]",
);
Some(note)
}
Err(e) => {
eprintln!("{RED} ✗ Revert failed: {e}{RESET}");
eprintln!("{DIM} (the commit may have conflicts — try manual git revert){RESET}\n");
None
}
}
}
fn handle_undo_all(history: &mut crate::prompt::TurnHistory) -> Option<String> {
let diff_stat = run_git(&["diff", "--stat"]).unwrap_or_default();
let untracked_text =
run_git(&["ls-files", "--others", "--exclude-standard"]).unwrap_or_default();
let has_diff = !diff_stat.is_empty();
let untracked_files: Vec<String> = untracked_text
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect();
let has_untracked = !untracked_files.is_empty();
if !has_diff && !has_untracked {
println!("{DIM} (nothing to undo — no uncommitted changes){RESET}\n");
history.clear();
return None;
}
let mut actions = Vec::new();
if has_diff {
println!("{DIM}{diff_stat}{RESET}");
let stat = parse_diff_stat(&diff_stat);
for entry in &stat.entries {
actions.push(format!("restored {} (to last committed state)", entry.file));
}
}
if has_untracked {
println!("{DIM} untracked files:");
for f in &untracked_files {
println!(" {f}");
actions.push(format!("deleted {f} (was untracked)"));
}
println!("{RESET}");
}
if has_diff {
let _ = run_git(&["checkout", "--", "."]);
}
if has_untracked {
let _ = run_git(&["clean", "-fd"]);
}
println!("{GREEN} ✓ reverted all uncommitted changes{RESET}\n");
history.clear();
if !actions.is_empty() {
Some(build_undo_context(&actions))
} else {
None
}
}
pub fn handle_commit(input: &str) {
let arg = input.strip_prefix("/commit").unwrap_or("").trim();
if !arg.is_empty() {
let (ok, output) = run_git_commit_with_trailer(arg);
if ok {
println!("{GREEN} ✓ {}{RESET}\n", output.trim());
} else {
eprintln!("{RED} ✗ {}{RESET}\n", output.trim());
}
} else {
match get_staged_diff() {
None => {
eprintln!("{RED} error: not in a git repository{RESET}\n");
}
Some(diff) if diff.trim().is_empty() => {
println!("{DIM} nothing staged — use `git add` first{RESET}\n");
}
Some(diff) => {
let suggested = generate_commit_message(&diff);
println!("{DIM} Suggested commit message:{RESET}");
println!(" {BOLD}{suggested}{RESET}");
eprint!(
"\n {DIM}({GREEN}y{RESET}{DIM})es / ({RED}n{RESET}{DIM})o / ({CYAN}e{RESET}{DIM})dit: {RESET}"
);
io::stderr().flush().ok();
let mut response = String::new();
if io::stdin().read_line(&mut response).is_ok() {
let response = response.trim().to_lowercase();
match response.as_str() {
"y" | "yes" | "" => {
let (ok, output) = run_git_commit_with_trailer(&suggested);
if ok {
println!("{GREEN} ✓ {}{RESET}\n", output.trim());
} else {
eprintln!("{RED} ✗ {}{RESET}\n", output.trim());
}
}
"e" | "edit" => {
println!("{DIM} Enter your commit message:{RESET}");
eprint!(" > ");
io::stderr().flush().ok();
let mut custom_msg = String::new();
if io::stdin().read_line(&mut custom_msg).is_ok() {
let custom_msg = custom_msg.trim();
if custom_msg.is_empty() {
println!("{DIM} (commit cancelled — empty message){RESET}\n");
} else {
let (ok, output) = run_git_commit_with_trailer(custom_msg);
if ok {
println!("{GREEN} ✓ {}{RESET}\n", output.trim());
} else {
eprintln!("{RED} ✗ {}{RESET}\n", output.trim());
}
}
}
}
_ => {
println!("{DIM} (commit cancelled){RESET}\n");
}
}
}
}
}
}
}
#[derive(Debug, PartialEq)]
pub enum PrSubcommand {
List,
View(u32),
Diff(u32),
Comment(u32, String),
Checkout(u32),
Create { draft: bool },
Help,
}
pub fn parse_pr_args(arg: &str) -> PrSubcommand {
let arg = arg.trim();
if arg.is_empty() {
return PrSubcommand::List;
}
let parts: Vec<&str> = arg.splitn(3, char::is_whitespace).collect();
if parts[0].eq_ignore_ascii_case("create") {
let draft = parts
.get(1)
.map(|s| s.trim_start_matches('-').eq_ignore_ascii_case("draft"))
.unwrap_or(false);
return PrSubcommand::Create { draft };
}
let number = match parts[0].parse::<u32>() {
Ok(n) => n,
Err(_) => return PrSubcommand::Help,
};
if parts.len() == 1 {
return PrSubcommand::View(number);
}
match parts[1].to_lowercase().as_str() {
"diff" => PrSubcommand::Diff(number),
"checkout" => PrSubcommand::Checkout(number),
"comment" => {
let text = if parts.len() == 3 {
parts[2].trim().to_string()
} else {
String::new()
};
if text.is_empty() {
PrSubcommand::Help
} else {
PrSubcommand::Comment(number, text)
}
}
_ => PrSubcommand::Help,
}
}
pub async fn handle_pr(input: &str, agent: &mut Agent, session_total: &mut Usage, model: &str) {
let arg = input.strip_prefix("/pr").unwrap_or("").trim();
match parse_pr_args(arg) {
PrSubcommand::List => {
match std::process::Command::new("gh")
.args(["pr", "list", "--limit", "10"])
.output()
{
Ok(output) if output.status.success() => {
let text = String::from_utf8_lossy(&output.stdout);
if text.trim().is_empty() {
println!("{DIM} (no open pull requests){RESET}\n");
} else {
println!("{DIM} Open pull requests:");
for line in text.lines() {
println!(" {line}");
}
println!("{RESET}");
}
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("{RED} error: {}{RESET}\n", stderr.trim());
}
Err(_) => {
eprintln!("{RED} error: `gh` CLI not found. Install it from https://cli.github.com{RESET}\n");
}
}
}
PrSubcommand::View(number) => {
let num_str = number.to_string();
match std::process::Command::new("gh")
.args(["pr", "view", &num_str])
.output()
{
Ok(output) if output.status.success() => {
let text = String::from_utf8_lossy(&output.stdout);
println!("{DIM}{text}{RESET}");
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("{RED} error: {}{RESET}\n", stderr.trim());
}
Err(_) => {
eprintln!("{RED} error: `gh` CLI not found. Install it from https://cli.github.com{RESET}\n");
}
}
}
PrSubcommand::Diff(number) => {
let num_str = number.to_string();
match std::process::Command::new("gh")
.args(["pr", "diff", &num_str])
.output()
{
Ok(output) if output.status.success() => {
let text = String::from_utf8_lossy(&output.stdout);
if text.trim().is_empty() {
println!("{DIM} (no diff for PR #{number}){RESET}\n");
} else {
println!("{DIM}{text}{RESET}");
}
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("{RED} error: {}{RESET}\n", stderr.trim());
}
Err(_) => {
eprintln!("{RED} error: `gh` CLI not found. Install it from https://cli.github.com{RESET}\n");
}
}
}
PrSubcommand::Comment(number, text) => {
let num_str = number.to_string();
match std::process::Command::new("gh")
.args(["pr", "comment", &num_str, "--body", &text])
.output()
{
Ok(output) if output.status.success() => {
println!("{GREEN} ✓ comment added to PR #{number}{RESET}\n");
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("{RED} error: {}{RESET}\n", stderr.trim());
}
Err(_) => {
eprintln!("{RED} error: `gh` CLI not found. Install it from https://cli.github.com{RESET}\n");
}
}
}
PrSubcommand::Checkout(number) => {
let num_str = number.to_string();
match std::process::Command::new("gh")
.args(["pr", "checkout", &num_str])
.output()
{
Ok(output) if output.status.success() => {
println!("{GREEN} ✓ checked out PR #{number}{RESET}\n");
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("{RED} error: {}{RESET}\n", stderr.trim());
}
Err(_) => {
eprintln!("{RED} error: `gh` CLI not found. Install it from https://cli.github.com{RESET}\n");
}
}
}
PrSubcommand::Create { draft } => {
let branch = match git_branch() {
Some(b) => b,
None => {
eprintln!("{RED} error: not in a git repository{RESET}\n");
return;
}
};
let base = detect_base_branch();
if branch == base {
eprintln!(
"{RED} error: already on {base} — switch to a feature branch first{RESET}\n"
);
return;
}
let diff = get_branch_diff(&base).unwrap_or_default();
let commits = get_branch_commits(&base).unwrap_or_default();
if diff.trim().is_empty() && commits.trim().is_empty() {
println!(
"{DIM} (no changes between {branch} and {base} — nothing to create a PR for){RESET}\n"
);
return;
}
let commit_count = commits.lines().filter(|l| !l.is_empty()).count();
println!(
"{DIM} Branch: {branch} → {base} ({commit_count} commit{s}){RESET}",
s = if commit_count == 1 { "" } else { "s" }
);
println!("{DIM} Generating PR description with AI...{RESET}");
let prompt = build_pr_description_prompt(&branch, &base, &commits, &diff);
let response = run_prompt(agent, &prompt, session_total, model).await.text;
let (title, body) = match parse_pr_description(&response) {
Some(parsed) => parsed,
None => {
eprintln!(
"{RED} error: could not parse AI response into PR title/description{RESET}"
);
eprintln!("{DIM} (try again or create manually with `gh pr create`){RESET}\n");
return;
}
};
println!("{DIM} Title: {BOLD}{title}{RESET}");
println!("{DIM} Draft: {}{RESET}", if draft { "yes" } else { "no" });
let mut gh_args = vec![
"pr".to_string(),
"create".to_string(),
"--title".to_string(),
title.clone(),
"--body".to_string(),
body,
"--base".to_string(),
base.clone(),
];
if draft {
gh_args.push("--draft".to_string());
}
let gh_str_args: Vec<&str> = gh_args.iter().map(|s| s.as_str()).collect();
match std::process::Command::new("gh").args(&gh_str_args).output() {
Ok(output) if output.status.success() => {
let url = String::from_utf8_lossy(&output.stdout);
let url = url.trim();
if url.is_empty() {
println!("{GREEN} ✓ PR created: {title}{RESET}\n");
} else {
println!("{GREEN} ✓ PR created: {url}{RESET}\n");
}
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("{RED} error: {}{RESET}\n", stderr.trim());
}
Err(_) => {
eprintln!("{RED} error: `gh` CLI not found. Install it from https://cli.github.com{RESET}\n");
}
}
}
PrSubcommand::Help => {
println!("{DIM} usage: /pr List open pull requests");
println!(
" /pr create [--draft] Create PR with AI-generated description"
);
println!(" /pr <number> View details of a specific PR");
println!(" /pr <number> diff Show the diff of a PR");
println!(" /pr <number> comment <text> Add a comment to a PR");
println!(" /pr <number> checkout Checkout a PR locally{RESET}\n");
}
}
}
pub fn handle_git(input: &str) {
let arg = input.strip_prefix("/git").unwrap_or("").trim();
let subcmd = parse_git_args(arg);
run_git_subcommand(&subcmd);
}
pub fn build_review_content(arg: &str) -> Option<(String, String)> {
let arg = arg.trim();
if arg.is_empty() {
match get_staged_diff() {
None => {
eprintln!("{RED} error: not in a git repository{RESET}\n");
None
}
Some(diff) if diff.trim().is_empty() => {
let unstaged = run_git(&["diff"]).unwrap_or_default();
if unstaged.trim().is_empty() {
println!("{DIM} nothing to review — no staged or unstaged changes{RESET}\n");
None
} else {
println!("{DIM} reviewing unstaged changes...{RESET}");
Some(("unstaged changes".to_string(), unstaged))
}
}
Some(diff) => {
println!("{DIM} reviewing staged changes...{RESET}");
Some(("staged changes".to_string(), diff))
}
}
} else {
let path = std::path::Path::new(arg);
if !path.exists() {
eprintln!("{RED} error: file not found: {arg}{RESET}\n");
return None;
}
match std::fs::read_to_string(path) {
Ok(content) => {
if content.trim().is_empty() {
println!("{DIM} file is empty — nothing to review{RESET}\n");
None
} else {
println!("{DIM} reviewing {arg}...{RESET}");
Some((arg.to_string(), content))
}
}
Err(e) => {
eprintln!("{RED} error reading {arg}: {e}{RESET}\n");
None
}
}
}
}
pub fn build_review_prompt(label: &str, content: &str) -> String {
let max_chars = 30_000;
let content_preview = if content.len() > max_chars {
let truncated = safe_truncate(content, max_chars);
format!(
"{truncated}\n\n... (truncated, {} more chars)",
content.len() - max_chars
)
} else {
content.to_string()
};
format!(
r#"Review the following code ({label}). Look for:
1. **Bugs** — logic errors, off-by-one errors, null/None handling, race conditions
2. **Security** — injection vulnerabilities, unsafe operations, credential exposure
3. **Style** — naming, idiomatic patterns, unnecessary complexity, dead code
4. **Performance** — obvious inefficiencies, unnecessary allocations, N+1 patterns
5. **Suggestions** — improvements, missing error handling, better approaches
Be specific: reference line numbers or code snippets. Be concise — skip things that look fine.
If the code looks good overall, say so briefly and note any minor suggestions.
```
{content_preview}
```"#
)
}
pub async fn handle_review(
input: &str,
agent: &mut Agent,
session_total: &mut Usage,
model: &str,
) -> Option<String> {
let arg = input.strip_prefix("/review").unwrap_or("").trim();
match build_review_content(arg) {
Some((label, content)) => {
let prompt = build_review_prompt(&label, &content);
run_prompt(agent, &prompt, session_total, model).await;
auto_compact_if_needed(agent);
Some(prompt)
}
None => None,
}
}
#[derive(Debug, PartialEq)]
pub struct BlameArgs {
pub file: String,
pub range: Option<(usize, usize)>,
}
pub fn parse_blame_args(input: &str) -> Result<BlameArgs, String> {
let arg = input.strip_prefix("/blame").unwrap_or(input).trim();
if arg.is_empty() {
return Err("Usage: /blame <file> or /blame <file>:<start>-<end>".to_string());
}
if let Some(colon_pos) = arg.rfind(':') {
let file_part = &arg[..colon_pos];
let range_part = &arg[colon_pos + 1..];
if let Some(dash_pos) = range_part.find('-') {
let start_str = &range_part[..dash_pos];
let end_str = &range_part[dash_pos + 1..];
if let (Ok(start), Ok(end)) = (start_str.parse::<usize>(), end_str.parse::<usize>()) {
if start == 0 || end == 0 {
return Err("Line numbers must be >= 1".to_string());
}
if start > end {
return Err(format!("Invalid range: start ({start}) > end ({end})"));
}
if !file_part.is_empty() {
return Ok(BlameArgs {
file: file_part.to_string(),
range: Some((start, end)),
});
}
}
}
}
Ok(BlameArgs {
file: arg.to_string(),
range: None,
})
}
pub fn colorize_blame_line(line: &str) -> String {
let Some(paren_open) = line.find('(') else {
return line.to_string();
};
let Some(paren_close) = line.find(')') else {
return line.to_string();
};
if paren_close <= paren_open {
return line.to_string();
}
let hash = &line[..paren_open];
let annotation = &line[paren_open + 1..paren_close];
let code = if paren_close + 1 < line.len() {
&line[paren_close + 1..]
} else {
""
};
let mut author = annotation;
let mut date_and_lineno = "";
for (i, _) in annotation.char_indices() {
if i + 10 <= annotation.len() {
let slice = &annotation[i..];
if slice.len() >= 10
&& slice.as_bytes()[4] == b'-'
&& slice.as_bytes()[7] == b'-'
&& slice[..4].chars().all(|c| c.is_ascii_digit())
&& slice[5..7].chars().all(|c| c.is_ascii_digit())
&& slice[8..10].chars().all(|c| c.is_ascii_digit())
{
author = annotation[..i].trim_end();
date_and_lineno = &annotation[i..];
break;
}
}
}
let (date_part, lineno_part) =
if let Some(last_space) = date_and_lineno.rfind(char::is_whitespace) {
let candidate = date_and_lineno[last_space..].trim();
if candidate.chars().all(|c| c.is_ascii_digit()) && !candidate.is_empty() {
(&date_and_lineno[..last_space], candidate)
} else {
(date_and_lineno, "")
}
} else {
(date_and_lineno, "")
};
format!(
"{DIM}{hash}{RESET}({CYAN}{author}{RESET} {DIM}{date_part}{RESET} {YELLOW}{lineno_part}{RESET}){code}"
)
}
pub fn colorize_blame(output: &str) -> String {
output
.lines()
.map(colorize_blame_line)
.collect::<Vec<_>>()
.join("\n")
}
pub fn handle_blame(input: &str) {
let args = match parse_blame_args(input) {
Ok(a) => a,
Err(e) => {
println!(" {RED}✗{RESET} {e}");
return;
}
};
let mut cmd = vec!["blame".to_string()];
if let Some((start, end)) = args.range {
cmd.push(format!("-L{start},{end}"));
}
cmd.push(args.file.clone());
let cmd_refs: Vec<&str> = cmd.iter().map(|s| s.as_str()).collect();
match run_git(&cmd_refs) {
Ok(output) => {
if output.trim().is_empty() {
println!(" {DIM}(no blame output){RESET}");
} else {
println!();
println!("{}", colorize_blame(&output));
}
}
Err(e) => {
let msg = e.to_string();
if msg.contains("no such path") || msg.contains("No such file") {
println!(" {RED}✗{RESET} File not found: {DIM}{}{RESET}", args.file);
} else if msg.contains("not a git repository") || msg.contains("fatal: not a git") {
println!(" {RED}✗{RESET} Not in a git repository");
} else {
println!(" {RED}✗{RESET} {msg}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::{is_unknown_command, KNOWN_COMMANDS};
#[test]
fn parse_diff_stat_single_file() {
let input =
" src/main.rs | 10 +++++++---\n 1 file changed, 7 insertions(+), 3 deletions(-)\n";
let summary = parse_diff_stat(input);
assert_eq!(summary.entries.len(), 1);
assert_eq!(summary.entries[0].file, "src/main.rs");
assert_eq!(summary.entries[0].insertions, 7);
assert_eq!(summary.entries[0].deletions, 3);
assert_eq!(summary.total_insertions, 7);
assert_eq!(summary.total_deletions, 3);
}
#[test]
fn parse_diff_stat_multiple_files() {
let input = "\
src/commands.rs | 42 +++++++++++++++++++++---------------------
src/main.rs | 5 ++---
src/cli.rs | 12 ++++++++++++
3 files changed, 25 insertions(+), 10 deletions(-)
";
let summary = parse_diff_stat(input);
assert_eq!(summary.entries.len(), 3);
assert_eq!(summary.entries[0].file, "src/commands.rs");
assert_eq!(summary.entries[1].file, "src/main.rs");
assert_eq!(summary.entries[2].file, "src/cli.rs");
assert!(summary.entries[0].insertions > 0);
assert!(summary.entries[0].deletions > 0);
assert!(
summary.entries[2].deletions == 0,
"cli.rs is insertions only"
);
assert_eq!(summary.total_insertions, 25);
assert_eq!(summary.total_deletions, 10);
}
#[test]
fn parse_diff_stat_insertions_only() {
let input = " new_file.rs | 20 ++++++++++++++++++++\n 1 file changed, 20 insertions(+)\n";
let summary = parse_diff_stat(input);
assert_eq!(summary.entries.len(), 1);
assert_eq!(summary.entries[0].file, "new_file.rs");
assert_eq!(summary.entries[0].insertions, 20);
assert_eq!(summary.entries[0].deletions, 0);
assert_eq!(summary.total_insertions, 20);
assert_eq!(summary.total_deletions, 0);
}
#[test]
fn parse_diff_stat_deletions_only() {
let input = " old_file.rs | 8 --------\n 1 file changed, 8 deletions(-)\n";
let summary = parse_diff_stat(input);
assert_eq!(summary.entries.len(), 1);
assert_eq!(summary.entries[0].file, "old_file.rs");
assert_eq!(summary.entries[0].insertions, 0);
assert_eq!(summary.entries[0].deletions, 8);
assert_eq!(summary.total_insertions, 0);
assert_eq!(summary.total_deletions, 8);
}
#[test]
fn parse_diff_stat_empty_input() {
let summary = parse_diff_stat("");
assert_eq!(summary.entries.len(), 0);
assert_eq!(summary.total_insertions, 0);
assert_eq!(summary.total_deletions, 0);
}
#[test]
fn parse_diff_stat_whitespace_only() {
let summary = parse_diff_stat(" \n \n\n");
assert_eq!(summary.entries.len(), 0);
assert_eq!(summary.total_insertions, 0);
assert_eq!(summary.total_deletions, 0);
}
#[test]
fn parse_diff_stat_no_summary_line() {
let input = " src/lib.rs | 3 +++\n";
let summary = parse_diff_stat(input);
assert_eq!(summary.entries.len(), 1);
assert_eq!(summary.entries[0].insertions, 3);
assert_eq!(summary.entries[0].deletions, 0);
assert_eq!(summary.total_insertions, 3);
assert_eq!(summary.total_deletions, 0);
}
#[test]
fn parse_diff_stat_binary_file() {
let input = " assets/logo.png | Bin 0 -> 1234 bytes\n 1 file changed, 0 insertions(+), 0 deletions(-)\n";
let summary = parse_diff_stat(input);
assert_eq!(summary.entries.len(), 1);
assert_eq!(summary.entries[0].file, "assets/logo.png");
assert_eq!(summary.entries[0].insertions, 0);
assert_eq!(summary.entries[0].deletions, 1);
assert_eq!(summary.total_insertions, 0);
assert_eq!(summary.total_deletions, 1);
}
#[test]
fn format_diff_stat_empty_entries() {
let summary = DiffStatSummary {
entries: vec![],
total_insertions: 0,
total_deletions: 0,
};
let output = format_diff_stat(&summary);
assert!(
output.is_empty(),
"Empty entries should produce empty output"
);
}
#[test]
fn format_diff_stat_single_entry_insertions_only() {
let summary = DiffStatSummary {
entries: vec![DiffStatEntry {
file: "src/main.rs".to_string(),
insertions: 10,
deletions: 0,
}],
total_insertions: 10,
total_deletions: 0,
};
let output = format_diff_stat(&summary);
assert!(output.contains("src/main.rs"), "Should contain filename");
assert!(output.contains("+10"), "Should show insertions count");
assert!(!output.contains("-0"), "Should not show zero deletions");
assert!(output.contains("1 file changed"), "Should show summary");
assert!(output.contains("+10"), "Summary should show insertions");
}
#[test]
fn format_diff_stat_single_entry_deletions_only() {
let summary = DiffStatSummary {
entries: vec![DiffStatEntry {
file: "old.rs".to_string(),
insertions: 0,
deletions: 5,
}],
total_insertions: 0,
total_deletions: 5,
};
let output = format_diff_stat(&summary);
assert!(output.contains("old.rs"), "Should contain filename");
assert!(output.contains("-5"), "Should show deletions count");
assert!(!output.contains("+0"), "Should not show zero insertions");
}
#[test]
fn format_diff_stat_mixed_changes() {
let summary = DiffStatSummary {
entries: vec![
DiffStatEntry {
file: "src/a.rs".to_string(),
insertions: 20,
deletions: 5,
},
DiffStatEntry {
file: "src/b.rs".to_string(),
insertions: 3,
deletions: 0,
},
],
total_insertions: 23,
total_deletions: 5,
};
let output = format_diff_stat(&summary);
assert!(output.contains("src/a.rs"), "Should contain first file");
assert!(output.contains("src/b.rs"), "Should contain second file");
assert!(
output.contains("2 files changed"),
"Should pluralize 'files'"
);
assert!(
output.contains("+23"),
"Summary should show total insertions"
);
assert!(output.contains("-5"), "Summary should show total deletions");
}
#[test]
fn format_diff_stat_singular_file() {
let summary = DiffStatSummary {
entries: vec![DiffStatEntry {
file: "f.rs".to_string(),
insertions: 1,
deletions: 1,
}],
total_insertions: 1,
total_deletions: 1,
};
let output = format_diff_stat(&summary);
assert!(
output.contains("1 file changed"),
"Should use singular 'file' not 'files'"
);
}
#[test]
fn parse_pr_args_empty_is_list() {
assert_eq!(parse_pr_args(""), PrSubcommand::List);
assert_eq!(parse_pr_args(" "), PrSubcommand::List);
}
#[test]
fn parse_pr_args_number_is_view() {
assert_eq!(parse_pr_args("42"), PrSubcommand::View(42));
assert_eq!(parse_pr_args("1"), PrSubcommand::View(1));
assert_eq!(parse_pr_args(" 99 "), PrSubcommand::View(99));
}
#[test]
fn parse_pr_args_number_diff() {
assert_eq!(parse_pr_args("42 diff"), PrSubcommand::Diff(42));
}
#[test]
fn parse_pr_args_number_checkout() {
assert_eq!(parse_pr_args("7 checkout"), PrSubcommand::Checkout(7));
}
#[test]
fn parse_pr_args_number_comment() {
assert_eq!(
parse_pr_args("5 comment looks good!"),
PrSubcommand::Comment(5, "looks good!".to_string())
);
}
#[test]
fn parse_pr_args_comment_without_text_is_help() {
assert_eq!(parse_pr_args("5 comment"), PrSubcommand::Help);
}
#[test]
fn parse_pr_args_create() {
assert_eq!(
parse_pr_args("create"),
PrSubcommand::Create { draft: false }
);
}
#[test]
fn parse_pr_args_create_draft() {
assert_eq!(
parse_pr_args("create --draft"),
PrSubcommand::Create { draft: true }
);
}
#[test]
fn parse_pr_args_create_case_insensitive() {
assert_eq!(
parse_pr_args("CREATE"),
PrSubcommand::Create { draft: false }
);
assert_eq!(
parse_pr_args("Create --Draft"),
PrSubcommand::Create { draft: true }
);
assert_eq!(
parse_pr_args("create -draft"),
PrSubcommand::Create { draft: true }
);
}
#[test]
fn parse_pr_args_invalid_is_help() {
assert_eq!(parse_pr_args("foobar"), PrSubcommand::Help);
assert_eq!(parse_pr_args("abc 123"), PrSubcommand::Help);
}
#[test]
fn parse_pr_args_unknown_subcommand_is_help() {
assert_eq!(parse_pr_args("42 merge"), PrSubcommand::Help);
assert_eq!(parse_pr_args("42 close"), PrSubcommand::Help);
}
#[test]
fn build_review_prompt_contains_label() {
let prompt = build_review_prompt("staged changes", "fn main() {}");
assert!(
prompt.contains("staged changes"),
"Prompt should include the label"
);
}
#[test]
fn build_review_prompt_contains_content() {
let code = "fn add(a: i32, b: i32) -> i32 { a + b }";
let prompt = build_review_prompt("test.rs", code);
assert!(prompt.contains(code), "Prompt should include the code");
}
#[test]
fn build_review_prompt_contains_review_criteria() {
let prompt = build_review_prompt("file.rs", "let x = 1;");
assert!(prompt.contains("Bugs"), "Should mention bugs");
assert!(prompt.contains("Security"), "Should mention security");
assert!(prompt.contains("Style"), "Should mention style");
assert!(prompt.contains("Performance"), "Should mention performance");
assert!(prompt.contains("Suggestions"), "Should mention suggestions");
}
#[test]
fn build_review_prompt_truncates_large_content() {
let large_content = "x".repeat(50_000);
let prompt = build_review_prompt("big.rs", &large_content);
assert!(
prompt.contains("truncated"),
"Large content should be truncated"
);
assert!(
prompt.contains("20000 more chars"),
"Should show remaining char count"
);
assert!(
prompt.len() < large_content.len(),
"Prompt should be shorter than 50k"
);
}
#[test]
fn build_review_prompt_does_not_truncate_small_content() {
let small_content = "fn hello() { println!(\"hi\"); }";
let prompt = build_review_prompt("small.rs", small_content);
assert!(
!prompt.contains("truncated"),
"Small content should not be truncated"
);
assert!(
prompt.contains(small_content),
"Full content should be present"
);
}
#[test]
fn build_review_prompt_wraps_in_code_block() {
let prompt = build_review_prompt("test.rs", "let x = 42;");
assert!(prompt.contains("```"), "Content should be in a code block");
}
#[test]
fn diff_stat_entry_equality() {
let a = DiffStatEntry {
file: "a.rs".to_string(),
insertions: 5,
deletions: 3,
};
let b = DiffStatEntry {
file: "a.rs".to_string(),
insertions: 5,
deletions: 3,
};
assert_eq!(a, b);
}
#[test]
fn diff_stat_summary_round_trip() {
let input = "\
src/main.rs | 15 +++++++++------
Cargo.toml | 2 +-
2 files changed, 10 insertions(+), 5 deletions(-)
";
let summary = parse_diff_stat(input);
let formatted = format_diff_stat(&summary);
assert!(formatted.contains("src/main.rs"));
assert!(formatted.contains("Cargo.toml"));
assert!(formatted.contains("2 files changed"));
}
#[test]
fn test_parse_diff_args_empty() {
let opts = parse_diff_args("/diff");
assert!(!opts.staged_only);
assert!(!opts.name_only);
assert_eq!(opts.file, None);
}
#[test]
fn test_parse_diff_args_staged() {
let opts = parse_diff_args("/diff --staged");
assert!(opts.staged_only);
assert!(!opts.name_only);
assert_eq!(opts.file, None);
}
#[test]
fn test_parse_diff_args_cached() {
let opts = parse_diff_args("/diff --cached");
assert!(opts.staged_only, "--cached should be an alias for --staged");
assert!(!opts.name_only);
assert_eq!(opts.file, None);
}
#[test]
fn test_parse_diff_args_name_only() {
let opts = parse_diff_args("/diff --name-only");
assert!(!opts.staged_only);
assert!(opts.name_only);
assert_eq!(opts.file, None);
}
#[test]
fn test_parse_diff_args_file() {
let opts = parse_diff_args("/diff src/main.rs");
assert!(!opts.staged_only);
assert!(!opts.name_only);
assert_eq!(opts.file, Some("src/main.rs".to_string()));
}
#[test]
fn test_parse_diff_args_staged_and_file() {
let opts = parse_diff_args("/diff --staged src/main.rs");
assert!(opts.staged_only);
assert!(!opts.name_only);
assert_eq!(opts.file, Some("src/main.rs".to_string()));
}
#[test]
fn test_parse_diff_args_all_flags() {
let opts = parse_diff_args("/diff --staged --name-only src/main.rs");
assert!(opts.staged_only);
assert!(opts.name_only);
assert_eq!(opts.file, Some("src/main.rs".to_string()));
}
#[test]
fn test_pr_command_recognized() {
assert!(!is_unknown_command("/pr"));
assert!(!is_unknown_command("/pr 42"));
assert!(!is_unknown_command("/pr 123"));
}
#[test]
fn test_pr_command_matching() {
let pr_matches = |s: &str| s == "/pr" || s.starts_with("/pr ");
assert!(pr_matches("/pr"));
assert!(pr_matches("/pr 42"));
assert!(pr_matches("/pr 123"));
assert!(!pr_matches("/print"));
assert!(!pr_matches("/process"));
}
#[test]
fn test_pr_number_parsing() {
let input = "/pr 42";
let arg = input.strip_prefix("/pr").unwrap_or("").trim();
assert_eq!(arg, "42");
assert!(arg.parse::<u32>().is_ok());
assert_eq!(arg.parse::<u32>().unwrap(), 42);
let input_bare = "/pr";
let arg_bare = input_bare.strip_prefix("/pr").unwrap_or("").trim();
assert!(arg_bare.is_empty());
}
#[test]
fn test_pr_subcommand_list() {
assert_eq!(parse_pr_args(""), PrSubcommand::List);
assert_eq!(parse_pr_args(" "), PrSubcommand::List);
}
#[test]
fn test_pr_subcommand_view() {
assert_eq!(parse_pr_args("42"), PrSubcommand::View(42));
assert_eq!(parse_pr_args("123"), PrSubcommand::View(123));
assert_eq!(parse_pr_args("1"), PrSubcommand::View(1));
}
#[test]
fn test_pr_subcommand_diff() {
assert_eq!(parse_pr_args("42 diff"), PrSubcommand::Diff(42));
assert_eq!(parse_pr_args("7 diff"), PrSubcommand::Diff(7));
}
#[test]
fn test_pr_subcommand_checkout() {
assert_eq!(parse_pr_args("42 checkout"), PrSubcommand::Checkout(42));
assert_eq!(parse_pr_args("99 checkout"), PrSubcommand::Checkout(99));
}
#[test]
fn test_pr_subcommand_comment() {
assert_eq!(
parse_pr_args("42 comment looks good!"),
PrSubcommand::Comment(42, "looks good!".to_string())
);
assert_eq!(
parse_pr_args("10 comment LGTM, merging now"),
PrSubcommand::Comment(10, "LGTM, merging now".to_string())
);
}
#[test]
fn test_pr_subcommand_comment_requires_text() {
assert_eq!(parse_pr_args("42 comment"), PrSubcommand::Help);
assert_eq!(parse_pr_args("42 comment "), PrSubcommand::Help);
}
#[test]
fn test_pr_subcommand_invalid() {
assert_eq!(parse_pr_args("abc"), PrSubcommand::Help);
assert_eq!(parse_pr_args("42 unknown"), PrSubcommand::Help);
assert_eq!(parse_pr_args("42 merge"), PrSubcommand::Help);
}
#[test]
fn test_pr_subcommand_case_insensitive() {
assert_eq!(parse_pr_args("42 DIFF"), PrSubcommand::Diff(42));
assert_eq!(parse_pr_args("42 Checkout"), PrSubcommand::Checkout(42));
assert_eq!(
parse_pr_args("42 Comment nice work"),
PrSubcommand::Comment(42, "nice work".to_string())
);
}
#[test]
fn test_pr_subcommand_create() {
assert_eq!(
parse_pr_args("create"),
PrSubcommand::Create { draft: false }
);
assert_eq!(
parse_pr_args("CREATE"),
PrSubcommand::Create { draft: false }
);
assert_eq!(
parse_pr_args("Create"),
PrSubcommand::Create { draft: false }
);
}
#[test]
fn test_pr_subcommand_create_draft() {
assert_eq!(
parse_pr_args("create --draft"),
PrSubcommand::Create { draft: true }
);
assert_eq!(
parse_pr_args("create draft"),
PrSubcommand::Create { draft: true }
);
assert_eq!(
parse_pr_args("CREATE --DRAFT"),
PrSubcommand::Create { draft: true }
);
}
#[test]
fn test_pr_subcommand_create_no_flag() {
assert_eq!(
parse_pr_args("create --nodraft"),
PrSubcommand::Create { draft: false }
);
}
#[test]
fn test_pr_subcommand_recognized() {
assert!(!is_unknown_command("/pr 42 diff"));
assert!(!is_unknown_command("/pr 42 comment hello"));
assert!(!is_unknown_command("/pr 42 checkout"));
}
#[test]
fn test_review_command_recognized() {
assert!(!is_unknown_command("/review"));
assert!(!is_unknown_command("/review src/main.rs"));
assert!(
KNOWN_COMMANDS.contains(&"/review"),
"/review should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_review_command_matching() {
let review_matches = |s: &str| s == "/review" || s.starts_with("/review ");
assert!(review_matches("/review"));
assert!(review_matches("/review src/main.rs"));
assert!(review_matches("/review Cargo.toml"));
assert!(!review_matches("/reviewing"));
assert!(!review_matches("/reviewer"));
}
#[test]
fn test_build_review_prompt_contains_content() {
let prompt =
build_review_prompt("staged changes", "fn main() {\n println!(\"hello\");\n}");
assert!(
prompt.contains("staged changes"),
"Should mention the label"
);
assert!(prompt.contains("fn main()"), "Should contain the code");
assert!(prompt.contains("Bugs"), "Should ask for bug review");
assert!(
prompt.contains("Security"),
"Should ask for security review"
);
assert!(prompt.contains("Style"), "Should ask for style review");
assert!(
prompt.contains("Performance"),
"Should ask for performance review"
);
assert!(prompt.contains("Suggestions"), "Should ask for suggestions");
}
#[test]
fn test_build_review_prompt_truncates_large_content() {
let large_content = "x".repeat(40_000);
let prompt = build_review_prompt("big file", &large_content);
assert!(
prompt.contains("truncated"),
"Large content should be truncated"
);
assert!(
prompt.len() < 40_000,
"Prompt should be truncated, got {} chars",
prompt.len()
);
}
#[test]
fn test_build_review_content_nonexistent_file() {
let result = build_review_content("nonexistent_file_xyz_12345.rs");
assert!(result.is_none(), "Nonexistent file should return None");
}
#[test]
fn test_build_review_content_existing_file() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let cargo_toml = format!("{manifest_dir}/Cargo.toml");
let result = build_review_content(&cargo_toml);
assert!(result.is_some(), "Existing file should return Some");
let (label, content) = result.unwrap();
assert_eq!(label, cargo_toml);
assert!(!content.is_empty(), "Content should not be empty");
}
#[test]
fn test_build_review_content_empty_arg_in_git_repo() {
let result = build_review_content("");
if let Some((label, _content)) = result {
assert!(
label.contains("changes"),
"Label should describe what's being reviewed: {label}"
);
}
}
#[test]
fn test_review_help_text_present() {
assert!(KNOWN_COMMANDS.contains(&"/review"));
}
#[test]
fn test_init_command_recognized() {
assert!(!is_unknown_command("/init"));
assert!(
KNOWN_COMMANDS.contains(&"/init"),
"/init should be in KNOWN_COMMANDS"
);
}
#[test]
fn test_parse_diff_stat_basic() {
let stat_output = " src/commands.rs | 42 ++++++++++++++++++++++++++++--------------
src/main.rs | 8 +++++---
2 files changed, 30 insertions(+), 20 deletions(-)
";
let summary = parse_diff_stat(stat_output);
assert_eq!(summary.entries.len(), 2);
assert_eq!(summary.entries[0].file, "src/commands.rs");
assert_eq!(summary.entries[1].file, "src/main.rs");
assert_eq!(summary.total_insertions, 30);
assert_eq!(summary.total_deletions, 20);
}
#[test]
fn test_parse_diff_stat_single_file() {
let stat_output = " src/format.rs | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
";
let summary = parse_diff_stat(stat_output);
assert_eq!(summary.entries.len(), 1);
assert_eq!(summary.entries[0].file, "src/format.rs");
assert_eq!(summary.total_insertions, 7);
assert_eq!(summary.total_deletions, 3);
}
#[test]
fn test_parse_diff_stat_insertions_only() {
let stat_output = " new_file.rs | 25 +++++++++++++++++++++++++
1 file changed, 25 insertions(+)
";
let summary = parse_diff_stat(stat_output);
assert_eq!(summary.entries.len(), 1);
assert_eq!(summary.entries[0].file, "new_file.rs");
assert!(summary.entries[0].insertions > 0);
assert_eq!(summary.entries[0].deletions, 0);
assert_eq!(summary.total_insertions, 25);
assert_eq!(summary.total_deletions, 0);
}
#[test]
fn test_parse_diff_stat_deletions_only() {
let stat_output = " old_file.rs | 15 ---------------
1 file changed, 15 deletions(-)
";
let summary = parse_diff_stat(stat_output);
assert_eq!(summary.entries.len(), 1);
assert_eq!(summary.entries[0].file, "old_file.rs");
assert_eq!(summary.entries[0].insertions, 0);
assert!(summary.entries[0].deletions > 0);
assert_eq!(summary.total_insertions, 0);
assert_eq!(summary.total_deletions, 15);
}
#[test]
fn test_parse_diff_stat_empty() {
let summary = parse_diff_stat("");
assert!(summary.entries.is_empty());
assert_eq!(summary.total_insertions, 0);
assert_eq!(summary.total_deletions, 0);
}
#[test]
fn test_parse_diff_stat_no_summary_line() {
let stat_output = " src/main.rs | 5 +++--
";
let summary = parse_diff_stat(stat_output);
assert_eq!(summary.entries.len(), 1);
assert_eq!(summary.total_insertions, summary.entries[0].insertions);
assert_eq!(summary.total_deletions, summary.entries[0].deletions);
}
#[test]
fn test_parse_diff_stat_multiple_files() {
let stat_output = " Cargo.toml | 2 +-
src/cli.rs | 15 ++++++++-------
src/commands.rs | 88 +++++++++++++++++++++++++++++++++++++++++++++++++++++---
src/format.rs | 3 ++-
4 files changed, 78 insertions(+), 30 deletions(-)
";
let summary = parse_diff_stat(stat_output);
assert_eq!(summary.entries.len(), 4);
assert_eq!(summary.entries[0].file, "Cargo.toml");
assert_eq!(summary.entries[2].file, "src/commands.rs");
assert_eq!(summary.total_insertions, 78);
assert_eq!(summary.total_deletions, 30);
}
#[test]
fn test_format_diff_stat_empty() {
let summary = DiffStatSummary {
entries: vec![],
total_insertions: 0,
total_deletions: 0,
};
let formatted = format_diff_stat(&summary);
assert!(
formatted.is_empty(),
"Empty summary should produce empty output"
);
}
#[test]
fn test_format_diff_stat_single_entry() {
let summary = DiffStatSummary {
entries: vec![DiffStatEntry {
file: "src/main.rs".to_string(),
insertions: 5,
deletions: 2,
}],
total_insertions: 5,
total_deletions: 2,
};
let formatted = format_diff_stat(&summary);
assert!(formatted.contains("src/main.rs"), "Should contain filename");
assert!(
formatted.contains("1 file changed"),
"Should show file count"
);
assert!(formatted.contains("+5"), "Should show insertions");
assert!(formatted.contains("-2"), "Should show deletions");
}
#[test]
fn test_format_diff_stat_multiple_entries() {
let summary = DiffStatSummary {
entries: vec![
DiffStatEntry {
file: "src/a.rs".to_string(),
insertions: 10,
deletions: 0,
},
DiffStatEntry {
file: "src/b.rs".to_string(),
insertions: 0,
deletions: 5,
},
],
total_insertions: 10,
total_deletions: 5,
};
let formatted = format_diff_stat(&summary);
assert!(formatted.contains("src/a.rs"));
assert!(formatted.contains("src/b.rs"));
assert!(formatted.contains("2 files changed"));
}
#[test]
fn test_format_diff_stat_insertions_only_no_deletions_shown() {
let summary = DiffStatSummary {
entries: vec![DiffStatEntry {
file: "new.rs".to_string(),
insertions: 10,
deletions: 0,
}],
total_insertions: 10,
total_deletions: 0,
};
let formatted = format_diff_stat(&summary);
assert!(formatted.contains("+10"), "Should show insertions");
assert!(!formatted.contains("-0"), "Should not show zero deletions");
}
#[test]
fn build_undo_context_includes_all_actions() {
let actions = vec![
"restored src/main.rs".to_string(),
"deleted src/new_file.rs".to_string(),
];
let ctx = build_undo_context(&actions);
assert!(ctx.contains("restored src/main.rs"));
assert!(ctx.contains("deleted src/new_file.rs"));
assert!(ctx.contains("[System note:"));
assert!(ctx.contains("may no longer exist"));
assert!(ctx.contains("2 files"), "Context should include file count");
}
#[test]
fn build_undo_context_single_action() {
let actions = vec!["restored src/foo.rs".to_string()];
let ctx = build_undo_context(&actions);
assert!(ctx.contains("- restored src/foo.rs"));
assert!(ctx.contains("Verify current file state"));
assert!(
ctx.contains("1 file"),
"Context should use singular 'file' for single action"
);
}
#[test]
fn build_undo_context_warns_about_stale_references() {
let actions = vec!["restored src/lib.rs".to_string()];
let ctx = build_undo_context(&actions);
assert!(
ctx.contains("⚠️"),
"Context should contain ⚠️ warning about stale references"
);
assert!(
ctx.contains("may no longer exist"),
"Context should warn that referenced code may no longer exist"
);
}
#[test]
fn build_undo_context_recommends_rereading_files() {
let actions = vec![
"restored src/a.rs".to_string(),
"restored src/b.rs".to_string(),
];
let ctx = build_undo_context(&actions);
assert!(
ctx.contains("Re-read affected files"),
"Context should recommend re-reading affected files before new changes"
);
}
#[test]
fn handle_undo_returns_none_on_empty_history() {
let mut history = crate::prompt::TurnHistory::new();
let result = handle_undo("/undo", &mut history);
assert!(result.is_none(), "Should return None when history is empty");
}
#[test]
fn handle_undo_returns_some_when_files_reverted() {
use crate::prompt::{TurnHistory, TurnSnapshot};
use std::fs;
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test_undo.txt");
fs::write(&file_path, "original content").unwrap();
let path_str = file_path.to_str().unwrap();
let mut snap = TurnSnapshot::new();
snap.snapshot_file(path_str);
fs::write(&file_path, "modified content").unwrap();
let mut history = TurnHistory::new();
history.push(snap);
let result = handle_undo("/undo", &mut history);
assert!(
result.is_some(),
"Should return Some when files were reverted"
);
let ctx = result.unwrap();
assert!(
ctx.contains(path_str),
"Context should mention the reverted file path"
);
assert!(ctx.contains("[System note:"));
assert!(
ctx.contains("⚠️"),
"Context should contain ⚠️ stale-reference warning"
);
assert!(
ctx.contains("1 file"),
"Context should include the affected file count"
);
assert!(
ctx.contains("Re-read affected files"),
"Context should recommend re-reading files"
);
let restored = fs::read_to_string(&file_path).unwrap();
assert_eq!(restored, "original content");
}
#[test]
fn handle_undo_returns_none_on_zero_count() {
let mut history = crate::prompt::TurnHistory::new();
let result = handle_undo("/undo 0", &mut history);
assert!(result.is_none());
}
#[test]
fn handle_undo_returns_none_on_bad_arg() {
let mut history = crate::prompt::TurnHistory::new();
let result = handle_undo("/undo xyz", &mut history);
assert!(result.is_none());
}
#[test]
fn handle_undo_dispatches_last_commit() {
let arg = "/undo --last-commit";
let trimmed = arg.trim_start_matches("/undo").trim();
assert_eq!(trimmed, "--last-commit", "should parse --last-commit arg");
}
#[test]
fn undo_last_commit_context_format() {
let log_line = "abc1234 fix: something important";
let files = "src/main.rs\nsrc/tools.rs\n";
let mut actions = Vec::new();
for f in files.lines().filter(|l| !l.is_empty()) {
actions.push(format!("reverted changes to {f} (commit undone)"));
}
let mut note = String::from("[System note: /undo --last-commit reverted a git commit.\n");
note.push_str(&format!("Reverted commit: {}\n", log_line.trim()));
note.push_str("Files affected:\n");
for action in &actions {
note.push_str(&format!("- {action}\n"));
}
note.push_str(
"⚠️ Earlier messages in this conversation may reference code from this commit \
that no longer exists. Verify current file state before continuing.\n",
);
note.push_str("Any journal entries about this commit describe work that has been undone.]");
assert!(note.contains("abc1234 fix: something important"));
assert!(note.contains("reverted changes to src/main.rs"));
assert!(note.contains("reverted changes to src/tools.rs"));
assert!(note.contains("⚠️"));
assert!(note.contains("journal entries"));
assert!(note.contains("[System note: /undo --last-commit"));
assert!(note.contains("has been undone.]"));
}
#[test]
fn undo_last_commit_in_real_repo() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
let init = std::process::Command::new("git")
.args(["init"])
.current_dir(repo)
.output()
.unwrap();
assert!(init.status.success(), "git init failed");
let _ = std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(repo)
.output();
let _ = std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(repo)
.output();
let file_path = repo.join("hello.txt");
fs::write(&file_path, "initial").unwrap();
let _ = std::process::Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output();
let _ = std::process::Command::new("git")
.args(["commit", "-m", "initial commit"])
.current_dir(repo)
.output();
fs::write(&file_path, "changed").unwrap();
let _ = std::process::Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output();
let _ = std::process::Command::new("git")
.args(["commit", "-m", "change hello"])
.current_dir(repo)
.output();
assert_eq!(fs::read_to_string(&file_path).unwrap(), "changed");
let hash_output = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(repo)
.output()
.unwrap();
let commit_hash = String::from_utf8_lossy(&hash_output.stdout)
.trim()
.to_string();
use std::sync::Mutex;
static CWD_MUTEX: Mutex<()> = Mutex::new(());
let _lock = CWD_MUTEX.lock().unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(repo).unwrap();
let result = handle_undo_last_commit();
std::env::set_current_dir(&original_dir).unwrap();
assert!(
result.is_some(),
"handle_undo_last_commit should return Some"
);
let ctx = result.unwrap();
assert!(
ctx.contains("hello.txt"),
"Context should mention the reverted file"
);
assert!(ctx.contains("⚠️"), "Context should contain the warning");
assert!(
ctx.contains("journal entries"),
"Context should mention journal entries"
);
assert!(
ctx.contains("Reverted commit:"),
"Context should show the reverted commit"
);
assert!(
ctx.contains(&commit_hash),
"Context should include the commit hash '{commit_hash}'"
);
assert!(
ctx.contains("change hello"),
"Context should include the commit message"
);
assert!(
ctx.contains("[System note: /undo --last-commit"),
"Context should use --last-commit specific system note"
);
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(
content, "initial",
"File should be reverted to initial content"
);
}
#[test]
fn test_parse_blame_args_file_only() {
let result = parse_blame_args("/blame src/main.rs").unwrap();
assert_eq!(result.file, "src/main.rs");
assert_eq!(result.range, None);
}
#[test]
fn test_parse_blame_args_with_range() {
let result = parse_blame_args("/blame src/main.rs:10-20").unwrap();
assert_eq!(result.file, "src/main.rs");
assert_eq!(result.range, Some((10, 20)));
}
#[test]
fn test_parse_blame_args_single_line_range() {
let result = parse_blame_args("/blame foo.rs:5-5").unwrap();
assert_eq!(result.file, "foo.rs");
assert_eq!(result.range, Some((5, 5)));
}
#[test]
fn test_parse_blame_args_no_args() {
let result = parse_blame_args("/blame");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Usage"));
}
#[test]
fn test_parse_blame_args_no_args_with_spaces() {
let result = parse_blame_args("/blame ");
assert!(result.is_err());
}
#[test]
fn test_parse_blame_args_invalid_range_reversed() {
let result = parse_blame_args("/blame foo.rs:20-10");
assert!(result.is_err());
assert!(result.unwrap_err().contains("start"));
}
#[test]
fn test_parse_blame_args_zero_start() {
let result = parse_blame_args("/blame foo.rs:0-10");
assert!(result.is_err());
assert!(result.unwrap_err().contains(">= 1"));
}
#[test]
fn test_parse_blame_args_non_numeric_range_treated_as_file() {
let result = parse_blame_args("/blame some:file:thing").unwrap();
assert_eq!(result.file, "some:file:thing");
assert_eq!(result.range, None);
}
#[test]
fn test_colorize_blame_line_typical() {
let line = "abc1234f (John Doe 2024-01-15 10:30:00 +0000 42) fn main() {";
let colored = colorize_blame_line(line);
assert!(colored.contains("\x1b["));
assert!(colored.contains("John Doe"));
assert!(colored.contains("fn main()"));
assert!(colored.contains("abc1234f"));
}
#[test]
fn test_colorize_blame_line_no_paren() {
let line = "some weird line without parens";
assert_eq!(colorize_blame_line(line), line);
}
#[test]
fn test_colorize_blame_multiple_lines() {
let input = "abc123 (Alice 2024-01-15 10:00:00 +0000 1) line1\ndef456 (Bob 2024-01-15 10:00:00 +0000 2) line2";
let colored = colorize_blame(input);
let lines: Vec<&str> = colored.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("\x1b["));
assert!(lines[1].contains("\x1b["));
}
}