1use 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
22pub struct Stream {
24 writer: Box<dyn Write + Send>,
25 is_tty: bool,
26}
27
28impl Stream {
29 pub fn new(writer: Box<dyn Write + Send>, is_tty: bool) -> Self {
31 Self { writer, is_tty }
32 }
33
34 pub fn is_tty(&self) -> bool {
36 self.is_tty
37 }
38
39 pub fn line(&mut self, s: &str) -> Result<()> {
41 writeln!(self.writer, "{s}")?;
42 Ok(())
43 }
44
45 pub fn text(&mut self, s: &str) -> Result<()> {
47 write!(self.writer, "{s}")?;
48 Ok(())
49 }
50
51 pub fn flush(&mut self) -> Result<()> {
53 self.writer.flush()?;
54 Ok(())
55 }
56}
57
58pub trait Input {
61 fn read_line(&mut self) -> Result<String>;
64}
65
66pub 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
77pub struct SilentInput;
81
82impl Input for SilentInput {
83 fn read_line(&mut self) -> Result<String> {
84 Ok(String::new())
85 }
86}
87
88#[derive(Clone)]
90pub struct Env {
91 vars: HashMap<String, String>,
92}
93
94impl Env {
95 pub fn from_map(vars: HashMap<String, String>) -> Self {
97 Self { vars }
98 }
99
100 pub fn from_real() -> Self {
102 Self {
103 vars: std::env::vars().collect(),
104 }
105 }
106
107 pub fn get(&self, key: &str) -> Option<&str> {
109 self.vars.get(key).map(String::as_str)
110 }
111
112 pub fn is_set_nonempty(&self, key: &str) -> bool {
114 self.get(key).is_some_and(|v| !v.is_empty())
115 }
116}
117
118pub struct Cx {
120 pub out: Stream,
122 pub err: Stream,
124 pub env: Env,
126 pub cwd: PathBuf,
128 pub git: Arc<dyn GitCli + Send + Sync>,
131 pub gh: Arc<dyn GhClient + Send + Sync>,
133 pub agent: Arc<dyn AgentClient + Send + Sync>,
136 pub input: Box<dyn Input + Send>,
138 pub color_flag: Option<crate::output::color::ColorChoice>,
140 pub no_pager: bool,
142 pub verbose: u8,
145}
146
147impl Cx {
148 #[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 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 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 let mut t = test_cx(&[], "/work");
252 t.cx.err = Stream::new(Box::new(SharedBuf::new()), true);
253 assert!(t.cx.color_enabled_err(ColorChoice::Auto));
255 assert!(!t.cx.color_enabled(ColorChoice::Auto));
256 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}