git_worktree_manager/prompt_source.rs
1//! Resolve the two mutually-exclusive `gw new` / `gw spawn` prompt sources
2//! (`--prompt`, `--prompt-file`) into a single optional string.
3//!
4//! Mutual exclusion is enforced at parse time by `clap` (`ArgGroup` in `cli.rs`),
5//! so this helper assumes at most one source is active.
6//!
7//! `--prompt -` is the stdin form: when the inline value is exactly `-`, the
8//! prompt is read from stdin via the injected reader. This is the conventional
9//! Unix idiom (heredoc / pipe input) and replaces the older `--prompt-stdin`
10//! flag.
11
12use std::path::Path;
13
14use crate::error::{CwError, Result};
15
16/// Sentinel value that means "read prompt from stdin" when passed as the
17/// `--prompt` value.
18pub const STDIN_SENTINEL: &str = "-";
19
20/// Collapse the two prompt sources (`--prompt`, `--prompt-file`) into a
21/// single optional string.
22///
23/// `--prompt -` reads from stdin. `stdin_reader` and `stdin_is_tty` are
24/// injected so tests can drive both without touching real stdin. In
25/// production `main` passes closures that wrap `std::io::stdin()` and
26/// `IsTerminal::is_terminal()` respectively.
27///
28/// When `--prompt -` is used and stdin is a TTY, this errors instead of
29/// blocking forever waiting for the user to type EOF — that's almost never
30/// what was intended (typically the user forgot to pipe input or attach a
31/// heredoc).
32///
33/// Trailing newline characters (`\r`, `\n`, or any mix thereof) are stripped
34/// from the end of the resolved string — editors and heredocs routinely
35/// append one or more, and the AI tool doesn't want them.
36/// If the resolved string is empty or whitespace-only after stripping, `None`
37/// is returned so downstream code behaves as if no prompt was given (avoids
38/// passing an empty argv entry like `claude ""`).
39pub fn resolve_prompt(
40 inline: Option<String>,
41 file: Option<&Path>,
42 stdin_is_tty: impl FnOnce() -> bool,
43 stdin_reader: impl FnOnce() -> std::io::Result<String>,
44) -> Result<Option<String>> {
45 let raw: Option<String> = if let Some(s) = inline {
46 if s == STDIN_SENTINEL {
47 // `--prompt -`: read from stdin. Reject TTY up-front so the
48 // process doesn't hang forever waiting for an EOF the user
49 // isn't going to send.
50 if stdin_is_tty() {
51 return Err(CwError::Other(
52 "--prompt - reads from standard input, but stdin is a terminal. \
53 Pipe content (e.g. `echo 'hi' | gw new feat-x --prompt -`) \
54 or use a heredoc."
55 .to_string(),
56 ));
57 }
58 Some(
59 stdin_reader()
60 .map_err(|e| CwError::Other(format!("failed to read --prompt -: {e}")))?,
61 )
62 } else {
63 Some(s)
64 }
65 } else if let Some(p) = file {
66 Some(std::fs::read_to_string(p).map_err(|e| {
67 CwError::Other(format!(
68 "failed to read --prompt-file '{}': {e}",
69 p.display()
70 ))
71 })?)
72 } else {
73 None
74 };
75
76 Ok(raw.and_then(|s| {
77 let trimmed = s.trim_end_matches(['\n', '\r']);
78 if trimmed.trim().is_empty() {
79 None
80 } else {
81 Some(trimmed.to_string())
82 }
83 }))
84}