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 pub assume_yes: bool,
148}
149
150impl Cx {
151 #[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 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 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 let mut t = test_cx(&[], "/work");
257 t.cx.err = Stream::new(Box::new(SharedBuf::new()), true);
258 assert!(t.cx.color_enabled_err(ColorChoice::Auto));
260 assert!(!t.cx.color_enabled(ColorChoice::Auto));
261 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}