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}
146
147impl Cx {
148    /// Builds a context from injected streams, environment, working dir, the
149    /// `git`/`gh`/`agent` handles, and the input source. The global flag fields
150    /// (`color_flag`, `no_pager`) default off and are set during dispatch.
151    #[allow(clippy::too_many_arguments)]
152    pub fn new(
153        out: Stream,
154        err: Stream,
155        env: Env,
156        cwd: PathBuf,
157        git: Arc<dyn GitCli + Send + Sync>,
158        gh: Arc<dyn GhClient + Send + Sync>,
159        agent: Arc<dyn AgentClient + Send + Sync>,
160        input: Box<dyn Input + Send>,
161    ) -> Self {
162        Self {
163            out,
164            err,
165            env,
166            cwd,
167            git,
168            gh,
169            agent,
170            input,
171            color_flag: None,
172            no_pager: false,
173            verbose: 0,
174        }
175    }
176
177    /// Resolves whether to emit color for stdout, given the resolved config's
178    /// `ui.color` (spec §11 precedence).
179    pub fn color_enabled(&self, ui_color: crate::output::color::ColorChoice) -> bool {
180        crate::output::color::resolve_color(
181            self.color_flag,
182            self.env.is_set_nonempty("NO_COLOR"),
183            Some(ui_color),
184            self.out.is_tty(),
185        )
186    }
187
188    /// Resolves whether to emit color for the TUI, which draws to the alternate
189    /// screen on stderr. The precedence (`--color`, `NO_COLOR`, `ui.color`) is
190    /// the same as [`Cx::color_enabled`], but `auto` follows stderr's TTY status
191    /// rather than stdout's (stdout is reserved for the chosen path and is
192    /// usually piped, e.g. `cd "$(wt)"`).
193    pub fn color_enabled_err(&self, ui_color: crate::output::color::ColorChoice) -> bool {
194        crate::output::color::resolve_color(
195            self.color_flag,
196            self.env.is_set_nonempty("NO_COLOR"),
197            Some(ui_color),
198            self.err.is_tty(),
199        )
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::testutil::{SharedBuf, test_cx};
207
208    #[test]
209    fn stream_writes_line_and_text() {
210        let buf = SharedBuf::new();
211        let mut s = Stream::new(Box::new(buf.clone()), false);
212        s.text("a").unwrap();
213        s.line("b").unwrap();
214        s.flush().unwrap();
215        assert_eq!(buf.contents(), "ab\n");
216        assert!(!s.is_tty());
217    }
218
219    #[test]
220    fn stream_reports_tty_flag() {
221        let s = Stream::new(Box::new(SharedBuf::new()), true);
222        assert!(s.is_tty());
223    }
224
225    #[test]
226    fn silent_input_reports_eof() {
227        assert_eq!(SilentInput.read_line().unwrap(), "");
228    }
229
230    #[test]
231    fn env_get_and_nonempty() {
232        let env = Env::from_map(
233            [
234                ("A".to_string(), "1".to_string()),
235                ("E".to_string(), String::new()),
236            ]
237            .into_iter()
238            .collect(),
239        );
240        assert_eq!(env.get("A"), Some("1"));
241        assert_eq!(env.get("MISSING"), None);
242        assert!(env.is_set_nonempty("A"));
243        assert!(!env.is_set_nonempty("E"));
244        assert!(!env.is_set_nonempty("MISSING"));
245    }
246
247    #[test]
248    fn color_enabled_err_follows_stderr_tty() {
249        use crate::output::color::ColorChoice;
250        // stdout not a TTY (piped), stderr a TTY (where the TUI draws).
251        let mut t = test_cx(&[], "/work");
252        t.cx.err = Stream::new(Box::new(SharedBuf::new()), true);
253        // `auto` resolves against stderr for the TUI, but stdout for the CLI.
254        assert!(t.cx.color_enabled_err(ColorChoice::Auto));
255        assert!(!t.cx.color_enabled(ColorChoice::Auto));
256        // `never`/`always` and NO_COLOR keep the usual precedence.
257        assert!(!t.cx.color_enabled_err(ColorChoice::Never));
258        t.cx.color_flag = Some(ColorChoice::Always);
259        assert!(t.cx.color_enabled_err(ColorChoice::Never));
260    }
261
262    #[test]
263    fn color_enabled_err_honors_no_color() {
264        use crate::output::color::ColorChoice;
265        let mut t = test_cx(&[("NO_COLOR", "1")], "/work");
266        t.cx.err = Stream::new(Box::new(SharedBuf::new()), true);
267        assert!(!t.cx.color_enabled_err(ColorChoice::Always));
268    }
269
270    #[test]
271    fn cx_exposes_streams_env_cwd() {
272        let mut t = test_cx(&[("X", "y")], "/work");
273        t.cx.out.line("path").unwrap();
274        t.cx.err.line("note").unwrap();
275        assert_eq!(t.out.contents(), "path\n");
276        assert_eq!(t.err.contents(), "note\n");
277        assert_eq!(t.cx.env.get("X"), Some("y"));
278        assert_eq!(t.cx.cwd, PathBuf::from("/work"));
279    }
280}