use crate::git::{self, Repo};
use crate::prompt;
use anyhow::{Context, Result};
use std::fmt::Display;
use std::fs;
use std::io::{self, BufRead, IsTerminal, Write};
use std::path::Path;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
const SUBJECT_LIMIT: usize = 72;
const EDIT_HELP: &str = "\
# Edit the commit message above. Lines starting with '#' are ignored.
# Save and close the editor to return to git-agent.
# An empty message keeps the previous one.
";
const PULL_REQUEST_EDIT_HELP: &str = "\
<!-- Edit the pull request above: the first line is the title, the rest is the
description. Save and close the editor to return to git-agent. An empty file
keeps the previous version. -->
";
pub struct Review<T: 'static> {
pub heading: &'static str,
pub first_line: &'static str,
pub noun: &'static str,
pub what: &'static str,
pub edit: &'static str,
pub accept: &'static [Choice<T>],
pub quit: &'static str,
}
pub struct Choice<T> {
pub word: &'static str,
pub help: &'static str,
pub value: T,
}
fn color_enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| {
std::env::var_os("NO_COLOR").is_none()
&& io::stderr().is_terminal()
&& io::stdout().is_terminal()
})
}
fn paint(code: &str, text: &str) -> String {
if color_enabled() {
format!("\x1b[{code}m{text}\x1b[0m")
} else {
text.to_owned()
}
}
pub fn bold(text: &str) -> String {
paint("1", text)
}
pub fn dim(text: &str) -> String {
paint("2", text)
}
pub fn red(text: &str) -> String {
paint("31", text)
}
pub fn green(text: &str) -> String {
paint("32", text)
}
pub fn yellow(text: &str) -> String {
paint("33", text)
}
pub fn thousands(number: usize) -> String {
let digits = number.to_string();
let mut out = String::new();
for (index, digit) in digits.chars().enumerate() {
if index > 0 && (digits.len() - index).is_multiple_of(3) {
out.push(',');
}
out.push(digit);
}
out
}
pub fn bytes(count: u64) -> String {
const UNITS: [&str; 4] = ["bytes", "KB", "MB", "GB"];
let mut value = count as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
match unit {
0 => format!("{count} bytes"),
_ if value >= 10.0 => format!("{value:.0} {}", UNITS[unit]),
_ => format!("{value:.1} {}", UNITS[unit]),
}
}
pub fn info(message: impl Display) {
eprintln!("{message}");
}
pub fn warn(message: impl Display) {
eprintln!("{} {message}", yellow("warning:"));
}
pub fn error(err: &anyhow::Error) {
eprintln!("{} {err:#}", red("error:"));
}
pub fn show_proposal<T>(review: &Review<T>, text: &str) {
show_framed(review.heading, text);
let first_len = text.lines().next().unwrap_or_default().chars().count();
if first_len > SUBJECT_LIMIT {
warn(format!(
"the {} is {first_len} characters; {SUBJECT_LIMIT} or fewer is conventional",
review.first_line
));
}
}
pub fn show_framed(heading: &str, text: &str) {
const WIDTH: usize = 64;
let title = format!("── {heading} ");
let title_rule = "─".repeat(WIDTH.saturating_sub(title.chars().count()));
println!("\n{}", dim(&format!("{title}{title_rule}")));
println!("{text}");
println!("{}", dim(&"─".repeat(WIDTH)));
}
pub fn show_answer(heading: &str, text: &str) {
if io::stdout().is_terminal() {
show_framed(heading, text);
} else {
println!("{text}");
}
}
pub fn ask_follow_up() -> Result<Option<String>> {
let question = read_line(&format!(
"Follow-up question {} ›",
dim("(Enter to finish)")
))?;
Ok(question.filter(|question| !question.is_empty()))
}
pub struct Spinner(Option<tokio::task::JoinHandle<()>>);
impl Spinner {
pub fn start(label: String) -> Self {
if !io::stderr().is_terminal() {
info(&label);
return Self(None);
}
let task = tokio::spawn(async move {
const FRAMES: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let started = Instant::now();
let mut ticks = tokio::time::interval(Duration::from_millis(100));
for frame in FRAMES.iter().cycle() {
ticks.tick().await;
let elapsed = started.elapsed().as_secs();
eprint!("\r{frame} {label} {}", dim(&format!("{elapsed}s")));
}
});
Self(Some(task))
}
}
impl Drop for Spinner {
fn drop(&mut self) {
if let Some(task) = self.0.take() {
task.abort();
eprint!("\r\x1b[2K");
}
}
}
pub enum Action<T> {
Accept(T),
Edit,
Regenerate,
Refine(String),
Quit,
}
pub fn ask_action<T: Copy>(review: &Review<T>) -> Result<Action<T>> {
let words = review.accept.iter().map(|choice| choice.word);
let mut items: Vec<String> = words
.chain(["edit", "regenerate", "feedback", "quit"])
.map(|word| format!("{}{}", bold(&format!("[{}]", &word[..1])), &word[1..]))
.collect();
items.push(dim("[?]"));
let menu = format!("{} ›", items.join(" "));
loop {
let Some(input) = read_line(&menu)? else {
return Ok(Action::Quit);
};
let (choice, rest) = input.split_once(' ').unwrap_or((&input, ""));
let choice = choice.to_lowercase();
if let Some(value) = accepted(review, &choice) {
return Ok(Action::Accept(value));
}
match choice.as_str() {
"e" | "edit" => return Ok(Action::Edit),
"r" | "regenerate" => return Ok(Action::Regenerate),
"f" | "feedback" => {
let feedback = match rest.trim() {
"" => read_line("What should change? ›")?.unwrap_or_default(),
rest => rest.to_owned(),
};
if !feedback.is_empty() {
return Ok(Action::Refine(feedback));
}
}
"q" | "quit" | "n" | "no" => return Ok(Action::Quit),
"" => {}
"?" | "h" | "help" => eprintln!("{}", help(review)),
other => eprintln!("Unknown choice {other:?}. Type ? for help."),
}
}
}
fn accepted<T: Copy>(review: &Review<T>, choice: &str) -> Option<T> {
if matches!(choice, "y" | "yes") {
return review.accept.first().map(|first| first.value);
}
review
.accept
.iter()
.find(|accept| {
choice == accept.word
|| choice == &accept.word[..1]
|| accept.word.split(' ').next() == Some(choice)
})
.map(|accept| accept.value)
}
fn help<T>(review: &Review<T>) -> String {
let mut lines: Vec<String> = review
.accept
.iter()
.map(|choice| format!(" {} {}", &choice.word[..1], choice.help))
.collect();
lines.push(format!(" e {}", review.edit));
lines.push(format!(" r generate a new {}", review.noun));
lines.push(" f tell the model what to change, e.g. `f mention the API change`".to_owned());
lines.push(format!(" q {}", review.quit));
lines.join("\n")
}
fn read_line(prompt: &str) -> Result<Option<String>> {
eprint!("{prompt} ");
io::stderr().flush()?;
let mut line = String::new();
let read = io::stdin()
.lock()
.read_line(&mut line)
.context("failed to read input")?;
if read == 0 {
eprintln!();
return Ok(None);
}
Ok(Some(line.trim().to_owned()))
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum UntrackedChoice {
Ignore,
Commit,
Quit,
}
pub fn ask_untracked() -> Result<UntrackedChoice> {
loop {
let question = format!(
"Add them to .gitignore? {}",
dim("[Y]es [n]o, commit them too [q]uit ›")
);
let Some(answer) = read_line(&question)? else {
return Ok(UntrackedChoice::Quit);
};
match answer.to_lowercase().as_str() {
"" | "y" | "yes" => return Ok(UntrackedChoice::Ignore),
"n" | "no" => return Ok(UntrackedChoice::Commit),
"q" | "quit" => return Ok(UntrackedChoice::Quit),
_ => eprintln!("Please answer y, n or q."),
}
}
}
pub fn confirm(question: &str) -> Result<bool> {
loop {
let Some(answer) = read_line(&format!("{question} {}", dim("[Y/n] ›")))? else {
return Ok(false);
};
match answer.to_lowercase().as_str() {
"" | "y" | "yes" => return Ok(true),
"n" | "no" => return Ok(false),
_ => eprintln!("Please answer y or n."),
}
}
}
pub fn edit_message(repo: &Repo, message: &str) -> Result<Option<String>> {
let path = repo.git_path("COMMIT_EDITMSG")?;
let edited = edit(&path, &format!("{message}\n\n{EDIT_HELP}"))?;
let edited = strip_comments(&edited);
Ok((!edited.is_empty()).then_some(edited))
}
pub fn edit_line(label: &str, text: &str) -> Result<Option<String>> {
let prompt = inquire::Text::new(label).with_initial_value(text);
let edited = match prompt.prompt_skippable() {
Err(inquire::InquireError::OperationInterrupted) => std::process::exit(130),
result => result?,
};
Ok(edited
.map(|edited| edited.trim().to_owned())
.filter(|edited| !edited.is_empty()))
}
pub fn edit_plan(repo: &Repo, name: &str, text: &str) -> Result<String> {
edit(&repo.git_path(name)?, text)
}
pub fn edit_pull_request(repo: &Repo, text: &str) -> Result<Option<String>> {
let path = repo.git_path("GIT_AGENT_PR_EDITMSG.md")?;
let edited = edit(&path, &format!("{text}\n\n{PULL_REQUEST_EDIT_HELP}"))?;
let edited = strip_edit_help(&edited);
Ok((!edited.is_empty()).then_some(edited))
}
fn edit(path: &Path, text: &str) -> Result<String> {
fs::write(path, text).with_context(|| format!("failed to write {}", path.display()))?;
git::edit_file(path)?;
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))
}
fn strip_edit_help(text: &str) -> String {
let start = PULL_REQUEST_EDIT_HELP.lines().next().unwrap_or_default();
let kept = text.find(start).map_or(text, |index| &text[..index]);
prompt::normalize(kept)
}
fn strip_comments(text: &str) -> String {
let kept: Vec<&str> = text.lines().filter(|line| !line.starts_with('#')).collect();
prompt::normalize(&kept.join("\n"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn separates_thousands() {
assert_eq!(thousands(0), "0");
assert_eq!(thousands(999), "999");
assert_eq!(thousands(4096), "4,096");
assert_eq!(thousands(131072), "131,072");
assert_eq!(thousands(1234567), "1,234,567");
}
#[test]
fn formats_sizes() {
assert_eq!(bytes(40), "40 bytes");
assert_eq!(bytes(1536), "1.5 KB");
assert_eq!(bytes(220 * 1024 * 1024), "220 MB");
assert_eq!(bytes(3 * 1024 * 1024 * 1024 / 2), "1.5 GB");
}
#[test]
fn strips_comment_lines_and_padding() {
let edited = format!("fix: thing \n\nbody line\n\n{EDIT_HELP}");
assert_eq!(strip_comments(&edited), "fix: thing\n\nbody line");
assert_eq!(strip_comments(EDIT_HELP), "");
}
#[test]
fn keeps_markdown_headings_but_strips_the_pull_request_help() {
let edited = format!("feat: x\n\nWhy.\n\n## Changes\n- a\n\n{PULL_REQUEST_EDIT_HELP}");
assert_eq!(
strip_edit_help(&edited),
"feat: x\n\nWhy.\n\n## Changes\n- a"
);
assert_eq!(strip_edit_help(PULL_REQUEST_EDIT_HELP), "");
}
const REVIEW: Review<u8> = Review {
heading: "Proposed thing",
first_line: "title",
noun: "thing",
what: "thing",
edit: "edit the thing in your editor",
accept: &[
Choice {
word: "create",
help: "create it",
value: 1,
},
Choice {
word: "draft",
help: "create a draft",
value: 2,
},
],
quit: "quit without creating it",
};
#[test]
fn accepts_choices_by_letter_or_word() {
assert_eq!(accepted(&REVIEW, "c"), Some(1));
assert_eq!(accepted(&REVIEW, "draft"), Some(2));
assert_eq!(accepted(&REVIEW, "y"), Some(1));
assert_eq!(accepted(&REVIEW, "e"), None);
let several = Review {
accept: &[Choice {
word: "all commits",
help: "make them all",
value: 3,
}],
..REVIEW
};
assert_eq!(accepted(&several, "a"), Some(3));
assert_eq!(accepted(&several, "all"), Some(3));
assert_eq!(accepted(&several, "commits"), None);
let help = help(&REVIEW);
assert!(help.starts_with(" c create it\n d create a draft\n e edit the thing"));
assert!(help.ends_with(" q quit without creating it"));
}
}