Skip to main content

wt/
cx.rs

1//! Runtime context: injected I/O, environment, and working directory.
2//!
3//! [`Cx`] threads every side-effecting handle through command dispatch so the
4//! library is unit-testable: tests build a `Cx` over in-memory buffers and a
5//! fixed environment, run a command, then inspect what was written. The binary
6//! builds a `Cx` over real stdio and the process environment.
7//!
8//! The stdout/stderr split is the spec §5 contract: navigation paths, JSON, and
9//! data-command results go to [`Cx::out`]; all human-facing text, prompts,
10//! logs, and errors go to [`Cx::err`].
11
12use std::collections::HashMap;
13use std::io::Write;
14use std::path::PathBuf;
15use std::sync::Arc;
16
17use crate::agent::AgentClient;
18use crate::error::Result;
19use crate::gh::GhClient;
20use crate::git::cli::GitCli;
21
22/// A writable output stream (stdout or stderr) tagged with whether it is a TTY.
23pub struct Stream {
24    writer: Box<dyn Write + Send>,
25    is_tty: bool,
26}
27
28impl Stream {
29    /// Wraps a writer, recording whether it is connected to a terminal.
30    pub fn new(writer: Box<dyn Write + Send>, is_tty: bool) -> Self {
31        Self { writer, is_tty }
32    }
33
34    /// Returns `true` if this stream is connected to a terminal.
35    pub fn is_tty(&self) -> bool {
36        self.is_tty
37    }
38
39    /// Writes `s` followed by a newline.
40    pub fn line(&mut self, s: &str) -> Result<()> {
41        writeln!(self.writer, "{s}")?;
42        Ok(())
43    }
44
45    /// Writes `s` with no trailing newline.
46    pub fn text(&mut self, s: &str) -> Result<()> {
47        write!(self.writer, "{s}")?;
48        Ok(())
49    }
50
51    /// Flushes any buffered output.
52    pub fn flush(&mut self) -> Result<()> {
53        self.writer.flush()?;
54        Ok(())
55    }
56}
57
58/// A source of interactive input lines (e.g. `y/N` confirmations), injectable
59/// for testing.
60pub trait Input {
61    /// Reads one line of input, including any trailing newline. An empty string
62    /// signals end-of-input.
63    fn read_line(&mut self) -> Result<String>;
64}
65
66/// The production [`Input`] that reads from standard input.
67pub struct StdinInput;
68
69impl Input for StdinInput {
70    fn read_line(&mut self) -> Result<String> {
71        let mut line = String::new();
72        std::io::stdin().read_line(&mut line)?;
73        Ok(line)
74    }
75}
76
77/// An [`Input`] that always reports end-of-input (an empty line), so any prompt
78/// is auto-declined. Used by the TUI's background jobs (issue #46), whose
79/// command paths pass force/no-switch flags and never actually prompt.
80pub struct SilentInput;
81
82impl Input for SilentInput {
83    fn read_line(&mut self) -> Result<String> {
84        Ok(String::new())
85    }
86}
87
88/// A snapshot of environment variables, injectable for testing.
89#[derive(Clone)]
90pub struct Env {
91    vars: HashMap<String, String>,
92}
93
94impl Env {
95    /// Builds an `Env` from an explicit map (used in tests).
96    pub fn from_map(vars: HashMap<String, String>) -> Self {
97        Self { vars }
98    }
99
100    /// Captures the current process environment.
101    pub fn from_real() -> Self {
102        Self {
103            vars: std::env::vars().collect(),
104        }
105    }
106
107    /// Returns the value of `key`, if set.
108    pub fn get(&self, key: &str) -> Option<&str> {
109        self.vars.get(key).map(String::as_str)
110    }
111
112    /// Returns `true` if `key` is set to a non-empty value.
113    pub fn is_set_nonempty(&self, key: &str) -> bool {
114        self.get(key).is_some_and(|v| !v.is_empty())
115    }
116}
117
118/// The runtime context threaded through command dispatch.
119pub struct Cx {
120    /// Standard output: navigation paths, JSON, and data-command results only.
121    pub out: Stream,
122    /// Standard error: all human-facing text, prompts, logs, and errors.
123    pub err: Stream,
124    /// A snapshot of the process environment.
125    pub env: Env,
126    /// The effective working directory (after any `-C`).
127    pub cwd: PathBuf,
128    /// The `git` subprocess handle (real, or a fake in tests). Shared via `Arc`
129    /// so the TUI can clone it into async tasks.
130    pub git: Arc<dyn GitCli + Send + Sync>,
131    /// The `gh` subprocess handle (real, or a fake in tests).
132    pub gh: Arc<dyn GhClient + Send + Sync>,
133    /// The code-agent subprocess handle (real, or a fake in tests). Drives a
134    /// code agent (e.g. `claude`) to draft PR content; see [`AgentClient`].
135    pub agent: Arc<dyn AgentClient + Send + Sync>,
136    /// Interactive input source for confirmation prompts.
137    pub input: Box<dyn Input + Send>,
138    /// The `--color` flag value, if given (set during dispatch).
139    pub color_flag: Option<crate::output::color::ColorChoice>,
140    /// The `--no-pager` flag (set during dispatch).
141    pub no_pager: bool,
142    /// The `-v`/`--verbose` count: extra diagnostics to stderr (set during
143    /// dispatch). `0` is the default (quiet).
144    pub verbose: u8,
145    /// The `-y`/`--yes` flag (set during dispatch): auto-answer confirmation
146    /// prompts. Only prompts — the `--force` safety guards are unaffected.
147    pub assume_yes: bool,
148}
149
150impl Cx {
151    /// Builds a context from injected streams, environment, working dir, the
152    /// `git`/`gh`/`agent` handles, and the input source. The global flag fields
153    /// (`color_flag`, `no_pager`, `verbose`, `assume_yes`) default off and are
154    /// set during dispatch.
155    #[allow(clippy::too_many_arguments)]
156    pub fn new(
157        out: Stream,
158        err: Stream,
159        env: Env,
160        cwd: PathBuf,
161        git: Arc<dyn GitCli + Send + Sync>,
162        gh: Arc<dyn GhClient + Send + Sync>,
163        agent: Arc<dyn AgentClient + Send + Sync>,
164        input: Box<dyn Input + Send>,
165    ) -> Self {
166        Self {
167            out,
168            err,
169            env,
170            cwd,
171            git,
172            gh,
173            agent,
174            input,
175            color_flag: None,
176            no_pager: false,
177            verbose: 0,
178            assume_yes: false,
179        }
180    }
181
182    /// Resolves whether to emit color for stdout, given the resolved config's
183    /// `ui.color` (spec §11 precedence).
184    pub fn color_enabled(&self, ui_color: crate::output::color::ColorChoice) -> bool {
185        crate::output::color::resolve_color(
186            self.color_flag,
187            self.env.is_set_nonempty("NO_COLOR"),
188            Some(ui_color),
189            self.out.is_tty(),
190        )
191    }
192
193    /// Resolves whether to emit color for the TUI, which draws to the alternate
194    /// screen on stderr. The precedence (`--color`, `NO_COLOR`, `ui.color`) is
195    /// the same as [`Cx::color_enabled`], but `auto` follows stderr's TTY status
196    /// rather than stdout's (stdout is reserved for the chosen path and is
197    /// usually piped, e.g. `cd "$(wt)"`).
198    pub fn color_enabled_err(&self, ui_color: crate::output::color::ColorChoice) -> bool {
199        crate::output::color::resolve_color(
200            self.color_flag,
201            self.env.is_set_nonempty("NO_COLOR"),
202            Some(ui_color),
203            self.err.is_tty(),
204        )
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::testutil::{SharedBuf, test_cx};
212
213    #[test]
214    fn stream_writes_line_and_text() {
215        let buf = SharedBuf::new();
216        let mut s = Stream::new(Box::new(buf.clone()), false);
217        s.text("a").unwrap();
218        s.line("b").unwrap();
219        s.flush().unwrap();
220        assert_eq!(buf.contents(), "ab\n");
221        assert!(!s.is_tty());
222    }
223
224    #[test]
225    fn stream_reports_tty_flag() {
226        let s = Stream::new(Box::new(SharedBuf::new()), true);
227        assert!(s.is_tty());
228    }
229
230    #[test]
231    fn silent_input_reports_eof() {
232        assert_eq!(SilentInput.read_line().unwrap(), "");
233    }
234
235    #[test]
236    fn env_get_and_nonempty() {
237        let env = Env::from_map(
238            [
239                ("A".to_string(), "1".to_string()),
240                ("E".to_string(), String::new()),
241            ]
242            .into_iter()
243            .collect(),
244        );
245        assert_eq!(env.get("A"), Some("1"));
246        assert_eq!(env.get("MISSING"), None);
247        assert!(env.is_set_nonempty("A"));
248        assert!(!env.is_set_nonempty("E"));
249        assert!(!env.is_set_nonempty("MISSING"));
250    }
251
252    #[test]
253    fn color_enabled_err_follows_stderr_tty() {
254        use crate::output::color::ColorChoice;
255        // stdout not a TTY (piped), stderr a TTY (where the TUI draws).
256        let mut t = test_cx(&[], "/work");
257        t.cx.err = Stream::new(Box::new(SharedBuf::new()), true);
258        // `auto` resolves against stderr for the TUI, but stdout for the CLI.
259        assert!(t.cx.color_enabled_err(ColorChoice::Auto));
260        assert!(!t.cx.color_enabled(ColorChoice::Auto));
261        // `never`/`always` and NO_COLOR keep the usual precedence.
262        assert!(!t.cx.color_enabled_err(ColorChoice::Never));
263        t.cx.color_flag = Some(ColorChoice::Always);
264        assert!(t.cx.color_enabled_err(ColorChoice::Never));
265    }
266
267    #[test]
268    fn color_enabled_err_honors_no_color() {
269        use crate::output::color::ColorChoice;
270        let mut t = test_cx(&[("NO_COLOR", "1")], "/work");
271        t.cx.err = Stream::new(Box::new(SharedBuf::new()), true);
272        assert!(!t.cx.color_enabled_err(ColorChoice::Always));
273    }
274
275    #[test]
276    fn cx_exposes_streams_env_cwd() {
277        let mut t = test_cx(&[("X", "y")], "/work");
278        t.cx.out.line("path").unwrap();
279        t.cx.err.line("note").unwrap();
280        assert_eq!(t.out.contents(), "path\n");
281        assert_eq!(t.err.contents(), "note\n");
282        assert_eq!(t.cx.env.get("X"), Some("y"));
283        assert_eq!(t.cx.cwd, PathBuf::from("/work"));
284    }
285}