diff --git a/Cargo.lock b/Cargo.lock index 58b7d53..718d78d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -540,7 +540,7 @@ dependencies = [ [[package]] name = "offcode" -version = "0.1.1" +version = "0.1.2" dependencies = [ "dirs", "ratatui", diff --git a/Makefile b/Makefile index cff4d86..1ede06f 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ NAME := offcode VERSION := $(shell grep '^version' Cargo.toml | head -1 | sed 's/.*= *"\(.*\)"/\1/') DIST := dist +BINDIR ?= /usr/local/bin # ── native build ────────────────────────────────────────────────────────────── @@ -84,8 +85,14 @@ dist: cross-all macos-universal .PHONY: install install: build - cp target/release/$(NAME) /usr/local/bin/$(NAME) - @echo "Installed $(NAME) to /usr/local/bin/$(NAME)" + cp target/release/$(NAME) $(BINDIR)/$(NAME) + @echo "Installed $(NAME) to $(BINDIR)/$(NAME)" + +.PHONY: symstall +symstall: build + rm -f $(BINDIR)/$(NAME) + ln -sf $(CURDIR)/target/release/$(NAME) $(BINDIR)/$(NAME) + @echo "Symlinked $(BINDIR)/$(NAME) -> $(CURDIR)/target/release/$(NAME)" .PHONY: install-user install-user: build @@ -98,7 +105,8 @@ help: @echo "offcode build targets:" @echo " make build Native release build" @echo " make run Run in dev mode" - @echo " make install Install to /usr/local/bin" + @echo " make install Install to \$$(BINDIR) (default /usr/local/bin)" + @echo " make symstall Symlink binary into \$$(BINDIR) (rebuild-friendly)" @echo " make install-user Install to ~/.local/bin" @echo " make cross-all Cross-compile all targets (needs cross + Docker)" @echo " make x86_64-unknown-linux-musl Linux x86_64 static" diff --git a/src/config.rs b/src/config.rs index a0cde3a..ae51a3a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,10 +10,14 @@ pub struct Config { pub num_ctx: u32, pub show_thinking: bool, pub max_tool_iters: u32, + #[serde(default = "default_yolo")] + pub yolo: bool, #[serde(skip)] pub no_ctx: bool, } +fn default_yolo() -> bool { false } + impl Default for Config { fn default() -> Self { Self { @@ -34,6 +38,7 @@ impl Default for Config { num_ctx: 16384, show_thinking: false, max_tool_iters: 30, + yolo: false, no_ctx: false, } } diff --git a/src/main.rs b/src/main.rs index 8a386fb..a18a8f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,13 @@ mod ui; use config::Config; use ollama::{ChatRequest, Client, Message, Options}; +pub enum ConfirmAction { + Accept, + Reject(String), + Modify(serde_json::Value), + Comment(String), +} + fn main() { let raw_args: Vec = std::env::args().collect(); let mut cfg = Config::load(); @@ -158,6 +165,11 @@ fn run_repl(cfg: Config, client: Client) { println!("{}History cleared.{}", ui::DIM, ui::RESET); } "/tools" => tools::print_list(), + "/yolo" => { + cfg.yolo = !cfg.yolo; + let state = if cfg.yolo { "on (tools run without prompting)" } else { "off (prompt before each tool call)" }; + println!("{}Yolo mode: {}{}", ui::DIM, state, ui::RESET); + } "/config" => { println!("{}", toml::to_string_pretty(&cfg).unwrap_or_default()); } @@ -230,53 +242,78 @@ fn run_turn(cfg: &Config, client: &Client, messages: &mut Vec, input: & ); match result { - Ok((content, Some(calls))) => { + Ok((content, Some(mut calls))) => { println!("{}", ui::RESET); + + // Per-call confirmation: may mutate args on Modify, or produce + // a comment appended after the tool result. + let mut actions: Vec = Vec::with_capacity(calls.len()); + for call in calls.iter_mut() { + print_tool_call(&call.function.name, &call.function.arguments); + let action = if cfg.yolo { + ConfirmAction::Accept + } else { + prompt_confirm_stdin() + }; + if let ConfirmAction::Modify(ref new_args) = action { + call.function.arguments = new_args.clone(); + println!("{} (args modified){}", ui::DIM, ui::RESET); + } + actions.push(action); + } + messages.push(Message { role: "assistant".to_string(), content: content.clone(), tool_calls: Some(calls.clone()), }); - for call in &calls { + for (call, action) in calls.iter().zip(actions.into_iter()) { let name = &call.function.name; let args = &call.function.arguments; - println!( - "\n{}{}⚙ {}{}{}{}", - ui::BOLD, - ui::BRIGHT_YELLOW, - ui::RESET, - ui::CYAN, - name, - ui::RESET - ); - if let Some(obj) = args.as_object() { - for (k, v) in obj { - let val = match v { - serde_json::Value::String(s) => { - let first: String = - s.lines().next().unwrap_or("").chars().take(80).collect(); - if s.lines().count() > 1 { - format!("{first}…") - } else { - first - } - } - other => other.to_string(), + + let (tool_result, extra_user) = match action { + ConfirmAction::Reject(reason) => { + let msg = if reason.is_empty() { + "Tool call rejected by user.".to_string() + } else { + format!("Tool call rejected by user: {reason}") }; - println!(" {} {k}: {}{}", ui::DIM, val, ui::RESET); + println!("{} ✗ rejected{}", ui::YELLOW, ui::RESET); + (msg, None) } - } - let tool_result = tools::execute(name, args); - let preview: Vec<&str> = tool_result.lines().take(4).collect(); - if !preview.is_empty() { - println!("{} → {}{}", ui::DIM, preview.join(" | "), ui::RESET); - } + ConfirmAction::Comment(text) => { + let r = tools::execute(name, args); + let preview: Vec<&str> = r.lines().take(4).collect(); + if !preview.is_empty() { + println!("{} → {}{}", ui::DIM, preview.join(" | "), ui::RESET); + } + (r, Some(text)) + } + ConfirmAction::Accept | ConfirmAction::Modify(_) => { + let r = tools::execute(name, args); + let preview: Vec<&str> = r.lines().take(4).collect(); + if !preview.is_empty() { + println!("{} → {}{}", ui::DIM, preview.join(" | "), ui::RESET); + } + (r, None) + } + }; + messages.push(Message { role: "tool".to_string(), content: tool_result, tool_calls: None, }); + + if let Some(text) = extra_user { + println!("{} + user note: {}{}", ui::DIM, text, ui::RESET); + messages.push(Message { + role: "user".to_string(), + content: text, + tool_calls: None, + }); + } } println!(); } @@ -365,6 +402,7 @@ fn print_repl_help() { println!(" {c}/model{r} {d}List available models (with capabilities){r}"); println!(" {c}/model {r} {d}Switch model{r}"); println!(" {c}/config{r} {d}Show config{r}"); + println!(" {c}/yolo{r} {d}Toggle yolo mode (auto-approve tool calls){r}"); println!(" {c}/exit{r} {d}Quit{r}"); } @@ -405,6 +443,91 @@ fn print_model_list(client: &Client, selected: &str) { ); } +fn print_tool_call(name: &str, args: &serde_json::Value) { + println!( + "\n{}{}⚙ {}{}{}{}", + ui::BOLD, + ui::BRIGHT_YELLOW, + ui::RESET, + ui::CYAN, + name, + ui::RESET + ); + if let Some(obj) = args.as_object() { + for (k, v) in obj { + let val = match v { + serde_json::Value::String(s) => { + let first: String = + s.lines().next().unwrap_or("").chars().take(80).collect(); + if s.lines().count() > 1 { + format!("{first}…") + } else { + first + } + } + other => other.to_string(), + }; + println!(" {} {k}: {}{}", ui::DIM, val, ui::RESET); + } + } +} + +fn prompt_confirm_stdin() -> ConfirmAction { + use std::io::BufRead; + let stdin = io::stdin(); + loop { + print!( + "{}[y]accept [n]reject [c ]comment [m ]modify ? {}", + ui::BRIGHT_GREEN, + ui::RESET + ); + io::stdout().flush().ok(); + + let mut line = String::new(); + if stdin.lock().read_line(&mut line).is_err() { + return ConfirmAction::Reject("stdin closed".into()); + } + let trimmed = line.trim(); + + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("y") || trimmed.eq_ignore_ascii_case("yes") { + return ConfirmAction::Accept; + } + if trimmed.eq_ignore_ascii_case("n") || trimmed.eq_ignore_ascii_case("no") { + return ConfirmAction::Reject(String::new()); + } + let (head, rest) = match trimmed.split_once(char::is_whitespace) { + Some((h, r)) => (h, r.trim()), + None => (trimmed, ""), + }; + match head { + "r" | "reject" => return ConfirmAction::Reject(rest.to_string()), + "c" | "comment" => { + if rest.is_empty() { + println!("{} (comment text required){}", ui::YELLOW, ui::RESET); + continue; + } + return ConfirmAction::Comment(rest.to_string()); + } + "m" | "modify" => { + if rest.is_empty() { + println!("{} (json args required){}", ui::YELLOW, ui::RESET); + continue; + } + match serde_json::from_str::(rest) { + Ok(v) => return ConfirmAction::Modify(v), + Err(e) => { + println!("{} invalid JSON: {}{}", ui::YELLOW, e, ui::RESET); + continue; + } + } + } + _ => { + println!("{} unknown response{}", ui::YELLOW, ui::RESET); + } + } + } +} + fn print_history(messages: &[Message]) { for msg in messages.iter().skip(1) { let (color, label): (&str, &str) = match msg.role.as_str() { diff --git a/src/tui.rs b/src/tui.rs index 9cb716e..be7f646 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -16,6 +16,7 @@ use serde_json::Value; use crate::config::Config; use crate::ollama::{ChatRequest, Client, Message, Options}; use crate::tools; +use crate::ConfirmAction; // ── line editor (input, cursor, history, completion) ───────────────────────── @@ -61,6 +62,13 @@ impl LineEdit { match key.code { KeyCode::Char('p') => { self.history_prev(); return KeyOutcome::Handled; } KeyCode::Char('n') => { self.history_next(); return KeyOutcome::Handled; } + KeyCode::Char('a') => { self.cursor = 0; return KeyOutcome::Handled; } + KeyCode::Char('e') => { self.cursor = self.input.len(); return KeyOutcome::Handled; } + KeyCode::Char('b') => { self.move_left(); return KeyOutcome::Handled; } + KeyCode::Char('f') => { self.move_right(); return KeyOutcome::Handled; } + KeyCode::Char('w') => { self.kill_word_back(); return KeyOutcome::Handled; } + KeyCode::Char('u') => { self.kill_to_start(); return KeyOutcome::Handled; } + KeyCode::Char('k') => { self.kill_to_end(); return KeyOutcome::Handled; } _ => {} } } @@ -80,20 +88,8 @@ impl LineEdit { if self.cursor < self.input.len() { self.input.remove(self.cursor); } KeyOutcome::Handled } - KeyCode::Left => { - if self.cursor > 0 { - self.cursor = self.input[..self.cursor] - .char_indices().next_back().map(|(i, _)| i).unwrap_or(0); - } - KeyOutcome::Handled - } - KeyCode::Right => { - if self.cursor < self.input.len() { - let c = self.input[self.cursor..].chars().next().unwrap(); - self.cursor += c.len_utf8(); - } - KeyOutcome::Handled - } + KeyCode::Left => { self.move_left(); KeyOutcome::Handled } + KeyCode::Right => { self.move_right(); KeyOutcome::Handled } KeyCode::Home => { self.cursor = 0; KeyOutcome::Handled } KeyCode::End => { self.cursor = self.input.len(); KeyOutcome::Handled } KeyCode::Char(c) => { @@ -138,6 +134,40 @@ impl LineEdit { self.history_idx = None; } + fn move_left(&mut self) { + if self.cursor > 0 { + self.cursor = self.input[..self.cursor] + .char_indices().next_back().map(|(i, _)| i).unwrap_or(0); + } + } + + fn move_right(&mut self) { + if self.cursor < self.input.len() { + let c = self.input[self.cursor..].chars().next().unwrap(); + self.cursor += c.len_utf8(); + } + } + + fn kill_word_back(&mut self) { + let head = &self.input[..self.cursor]; + let boundaries: Vec<(usize, char)> = head.char_indices().collect(); + let mut n = boundaries.len(); + while n > 0 && boundaries[n - 1].1.is_whitespace() { n -= 1; } + while n > 0 && !boundaries[n - 1].1.is_whitespace() { n -= 1; } + let start = boundaries.get(n).map(|(i, _)| *i).unwrap_or(self.cursor); + self.input.replace_range(start..self.cursor, ""); + self.cursor = start; + } + + fn kill_to_start(&mut self) { + self.input.replace_range(..self.cursor, ""); + self.cursor = 0; + } + + fn kill_to_end(&mut self) { + self.input.truncate(self.cursor); + } + fn history_prev(&mut self) { if self.history.is_empty() { return; } let new_idx = match self.history_idx { @@ -184,13 +214,17 @@ fn lcp_of(strs: &[String]) -> String { // ── worker ↔ app events ─────────────────────────────────────────────────────── -#[derive(Debug)] enum WorkerMsg { ThinkToken(String), Token(String), ToolBegin { name: String, args: Value }, ToolEnd { result_preview: String }, AddMessage(Message), + ConfirmRequest { + name: String, + args: Value, + reply: mpsc::SyncSender, + }, Done, Error(String), } @@ -238,7 +272,7 @@ impl Entry { // ── slash commands (used by /help and Tab completion) ──────────────────────── const COMMANDS: &[&str] = &[ - "/help", "/clear", "/reset", "/tools", "/think", + "/help", "/clear", "/reset", "/tools", "/think", "/yolo", "/model", "/models", "/exit", "/quit", ]; @@ -250,6 +284,14 @@ enum Mode { Generating, } +struct PendingConfirm { + #[allow(dead_code)] + name: String, + #[allow(dead_code)] + args: Value, + reply: mpsc::SyncSender, +} + pub struct App { cfg: Config, client: Client, @@ -266,6 +308,7 @@ pub struct App { pub should_quit: bool, tick: u64, model_names_cache: Option>, + pending_confirm: Option, } impl App { @@ -292,6 +335,7 @@ impl App { queued: None, cancel: Arc::new(AtomicBool::new(false)), model_names_cache: None, + pending_confirm: None, } } @@ -352,6 +396,11 @@ impl App { return; } if key.code == KeyCode::Esc { + if self.pending_confirm.is_some() { + // Esc during confirmation → reject with no reason + self.deliver_confirm(ConfirmAction::Reject(String::new())); + return; + } match self.mode { Mode::Generating => { // Cancel current generation, stay in app @@ -400,6 +449,26 @@ impl App { // ── submission ──────────────────────────────────────────────────────────── fn submit(&mut self) { + // Confirmation mode intercepts everything except slash commands. + if self.pending_confirm.is_some() { + let text = self.editor.take().unwrap_or_default(); + if text.starts_with('/') { + // Allow slash commands (e.g. /yolo) during confirmation. Re-queue + // the editor so the user can retry, then run the command. + self.handle_command(&text); + return; + } + let action = parse_confirm_input(&text); + match action { + Ok(a) => self.deliver_confirm(a), + Err(msg) => { + self.entries.push(Entry::error(msg)); + self.auto_scroll = true; + } + } + return; + } + let text = match self.editor.take() { Some(t) => t, None => return, @@ -455,6 +524,7 @@ impl App { /model list available models\n\ /model change model\n\ /think toggle thinking display\n\ + /yolo toggle yolo mode (auto-approve tools)\n\ /exit or Ctrl+C quit".into(), )), "/clear" | "/reset" => { @@ -479,6 +549,15 @@ impl App { let state = if self.cfg.show_thinking { "on" } else { "off" }; self.entries.push(Entry::info(format!("Thinking display: {state}"))); } + "/yolo" => { + self.cfg.yolo = !self.cfg.yolo; + let state = if self.cfg.yolo { + "on (tools run without prompting)" + } else { + "off (prompt before each tool call)" + }; + self.entries.push(Entry::info(format!("Yolo mode: {state}"))); + } "/exit" | "/quit" => self.should_quit = true, "/model" | "/models" => self.list_models_entry(), s if s.starts_with("/model ") => { @@ -521,6 +600,25 @@ impl App { self.entries.push(Entry::info(text)); } + // ── tool confirmation ───────────────────────────────────────────────────── + + fn deliver_confirm(&mut self, action: ConfirmAction) { + let Some(p) = self.pending_confirm.take() else { return; }; + let label = match &action { + ConfirmAction::Accept => "accepted".to_string(), + ConfirmAction::Reject(r) if r.is_empty() => "rejected".to_string(), + ConfirmAction::Reject(r) => format!("rejected: {r}"), + ConfirmAction::Modify(_) => "args modified".to_string(), + ConfirmAction::Comment(t) => format!("accepted + comment: {t}"), + }; + self.entries.push(Entry::info(format!("→ {label}"))); + self.auto_scroll = true; + if p.reply.send(action).is_err() { + self.entries + .push(Entry::error("worker gone — cannot deliver confirmation".into())); + } + } + // ── worker events ───────────────────────────────────────────────────────── pub fn poll_worker(&mut self) { @@ -562,6 +660,14 @@ impl App { WorkerMsg::AddMessage(msg) => { self.history.push(msg); } + WorkerMsg::ConfirmRequest { name, args, reply } => { + let arg_str = fmt_args(&args); + self.entries.push(Entry::info(format!( + "confirm tool: {name} ({arg_str})\n[Enter]/y accept n reject c comment m modify Esc reject" + ))); + self.pending_confirm = Some(PendingConfirm { name, args, reply }); + self.auto_scroll = true; + } WorkerMsg::Done => { if !self.cfg.no_ctx { crate::context::save(&self.history); } self.mode = Mode::Input; @@ -681,10 +787,16 @@ impl App { } fn render_input(&self, f: &mut Frame, area: ratatui::layout::Rect) { - let (border_style, label_color) = (Style::default().fg(Color::Cyan), Color::Green); + let (border_style, label_color) = if self.pending_confirm.is_some() { + (Style::default().fg(Color::Yellow), Color::Yellow) + } else { + (Style::default().fg(Color::Cyan), Color::Green) + }; + + let prompt = if self.pending_confirm.is_some() { "? " } else { "> " }; let content = Line::from(vec![ - Span::styled("> ", Style::default().fg(label_color).add_modifier(Modifier::BOLD)), + Span::styled(prompt, Style::default().fg(label_color).add_modifier(Modifier::BOLD)), Span::raw(self.editor.input.clone()), ]); @@ -696,20 +808,35 @@ impl App { } fn render_hints(&self, f: &mut Frame, area: ratatui::layout::Rect) { - let hints = Line::from(vec![ - Span::styled("Enter", Style::default().fg(Color::Cyan)), - Span::styled(" send ", Style::default().fg(Color::DarkGray)), - Span::styled("↑↓", Style::default().fg(Color::Cyan)), - Span::styled(" scroll ", Style::default().fg(Color::DarkGray)), - Span::styled("^P/^N", Style::default().fg(Color::Cyan)), - Span::styled(" history ", Style::default().fg(Color::DarkGray)), - Span::styled("Tab", Style::default().fg(Color::Cyan)), - Span::styled(" complete ", Style::default().fg(Color::DarkGray)), - Span::styled("/help", Style::default().fg(Color::Cyan)), - Span::styled(" commands ", Style::default().fg(Color::DarkGray)), - Span::styled("Ctrl+C", Style::default().fg(Color::Cyan)), - Span::styled(" quit", Style::default().fg(Color::DarkGray)), - ]); + let hints = if self.pending_confirm.is_some() { + Line::from(vec![ + Span::styled("Enter/y", Style::default().fg(Color::Yellow)), + Span::styled(" accept ", Style::default().fg(Color::DarkGray)), + Span::styled("n", Style::default().fg(Color::Yellow)), + Span::styled(" reject ", Style::default().fg(Color::DarkGray)), + Span::styled("c ", Style::default().fg(Color::Yellow)), + Span::styled(" comment ", Style::default().fg(Color::DarkGray)), + Span::styled("m ", Style::default().fg(Color::Yellow)), + Span::styled(" modify ", Style::default().fg(Color::DarkGray)), + Span::styled("Esc", Style::default().fg(Color::Yellow)), + Span::styled(" reject", Style::default().fg(Color::DarkGray)), + ]) + } else { + Line::from(vec![ + Span::styled("Enter", Style::default().fg(Color::Cyan)), + Span::styled(" send ", Style::default().fg(Color::DarkGray)), + Span::styled("↑↓", Style::default().fg(Color::Cyan)), + Span::styled(" scroll ", Style::default().fg(Color::DarkGray)), + Span::styled("^P/^N", Style::default().fg(Color::Cyan)), + Span::styled(" history ", Style::default().fg(Color::DarkGray)), + Span::styled("Tab", Style::default().fg(Color::Cyan)), + Span::styled(" complete ", Style::default().fg(Color::DarkGray)), + Span::styled("/help", Style::default().fg(Color::Cyan)), + Span::styled(" commands ", Style::default().fg(Color::DarkGray)), + Span::styled("Ctrl+C", Style::default().fg(Color::Cyan)), + Span::styled(" quit", Style::default().fg(Color::DarkGray)), + ]) + }; f.render_widget(Paragraph::new(hints), area); } } @@ -897,8 +1024,37 @@ fn run_worker( }); match result { - Ok((content, Some(calls))) => { - // Add assistant message with tool calls to history + Ok((content, Some(mut calls))) => { + // Resolve confirmations up-front so the assistant message we + // store carries the final (possibly modified) arguments. + let mut actions: Vec = Vec::with_capacity(calls.len()); + for call in calls.iter_mut() { + let action = if cfg.yolo { + ConfirmAction::Accept + } else { + let (reply_tx, reply_rx) = mpsc::sync_channel::(1); + if tx + .send(WorkerMsg::ConfirmRequest { + name: call.function.name.clone(), + args: call.function.arguments.clone(), + reply: reply_tx, + }) + .is_err() + { + return; + } + match reply_rx.recv() { + Ok(a) => a, + Err(_) => ConfirmAction::Reject("ui closed".into()), + } + }; + if let ConfirmAction::Modify(ref new_args) = action { + call.function.arguments = new_args.clone(); + } + actions.push(action); + } + + // Add assistant message with (final) tool calls to history let asst_msg = Message { role: "assistant".to_string(), content: content.clone(), @@ -907,7 +1063,7 @@ fn run_worker( history.push(asst_msg.clone()); let _ = tx.send(WorkerMsg::AddMessage(asst_msg)); - for call in &calls { + for (call, action) in calls.iter().zip(actions.into_iter()) { let name = &call.function.name; let args = &call.function.arguments; @@ -916,7 +1072,22 @@ fn run_worker( args: args.clone(), }); - let result_str = tools::execute(name, args); + let (result_str, extra_user) = match action { + ConfirmAction::Reject(reason) => { + let msg = if reason.is_empty() { + "Tool call rejected by user.".to_string() + } else { + format!("Tool call rejected by user: {reason}") + }; + (msg, None) + } + ConfirmAction::Comment(text) => { + (tools::execute(name, args), Some(text)) + } + ConfirmAction::Accept | ConfirmAction::Modify(_) => { + (tools::execute(name, args), None) + } + }; let preview: String = result_str .lines() @@ -934,6 +1105,16 @@ fn run_worker( }; history.push(tool_msg.clone()); let _ = tx.send(WorkerMsg::AddMessage(tool_msg)); + + if let Some(text) = extra_user { + let user_msg = Message { + role: "user".to_string(), + content: text, + tool_calls: None, + }; + history.push(user_msg.clone()); + let _ = tx.send(WorkerMsg::AddMessage(user_msg)); + } } // Loop to get model's response after tool calls } @@ -1055,6 +1236,90 @@ fn word_wrap(text: &str, width: usize) -> Vec { out } +/// Parse a confirmation input line into a ConfirmAction. +/// Returns Err with a user-facing message on invalid input. +fn parse_confirm_input(line: &str) -> Result { + let t = line.trim(); + if t.is_empty() || t.eq_ignore_ascii_case("y") || t.eq_ignore_ascii_case("yes") { + return Ok(ConfirmAction::Accept); + } + if t.eq_ignore_ascii_case("n") || t.eq_ignore_ascii_case("no") { + return Ok(ConfirmAction::Reject(String::new())); + } + let (head, rest) = match t.split_once(char::is_whitespace) { + Some((h, r)) => (h, r.trim()), + None => (t, ""), + }; + match head { + "r" | "reject" => Ok(ConfirmAction::Reject(rest.to_string())), + "c" | "comment" => { + if rest.is_empty() { + Err("comment requires text (e.g. `c please also lint`)".into()) + } else { + Ok(ConfirmAction::Comment(rest.to_string())) + } + } + "m" | "modify" => { + if rest.is_empty() { + Err("modify requires JSON args (e.g. `m {\"path\":\"foo\"}`)".into()) + } else { + serde_json::from_str::(rest) + .map(ConfirmAction::Modify) + .map_err(|e| format!("invalid JSON: {e}")) + } + } + _ => Err("unknown confirmation input; use y / n / c / m ".into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn confirm_empty_is_accept() { + assert!(matches!(parse_confirm_input(""), Ok(ConfirmAction::Accept))); + assert!(matches!(parse_confirm_input(" "), Ok(ConfirmAction::Accept))); + assert!(matches!(parse_confirm_input("y"), Ok(ConfirmAction::Accept))); + assert!(matches!(parse_confirm_input("YES"), Ok(ConfirmAction::Accept))); + } + + #[test] + fn confirm_n_is_reject() { + assert!(matches!(parse_confirm_input("n"), Ok(ConfirmAction::Reject(r)) if r.is_empty())); + match parse_confirm_input("r unsafe path") { + Ok(ConfirmAction::Reject(r)) => assert_eq!(r, "unsafe path"), + _ => panic!("expected reject with reason"), + } + } + + #[test] + fn confirm_comment_requires_text() { + assert!(parse_confirm_input("c").is_err()); + match parse_confirm_input("c also lint afterwards") { + Ok(ConfirmAction::Comment(t)) => assert_eq!(t, "also lint afterwards"), + _ => panic!("expected comment"), + } + } + + #[test] + fn confirm_modify_requires_valid_json() { + assert!(parse_confirm_input("m").is_err()); + assert!(parse_confirm_input("m {not json}").is_err()); + match parse_confirm_input(r#"m {"path":"x"}"#) { + Ok(ConfirmAction::Modify(v)) => { + assert_eq!(v.get("path").and_then(|x| x.as_str()), Some("x")); + } + _ => panic!("expected modify"), + } + } + + #[test] + fn confirm_unknown_is_error() { + assert!(parse_confirm_input("garbage").is_err()); + } +} + fn fmt_args(args: &Value) -> String { match args.as_object() { Some(obj) => obj