1use colored::Colorize;
2use inquire::ui::{Color, RenderConfig, StyleSheet, Styled};
3use std::sync::atomic::{AtomicBool, Ordering};
4use supports_color::Stream;
5
6use crate::has_auth;
7use crate::{Paths, command_name};
8
9static PLAIN: AtomicBool = AtomicBool::new(false);
10
11pub const CANCELLED_MESSAGE: &str = "Cancelled.";
12
13pub fn set_plain(value: bool) {
14 PLAIN.store(value, Ordering::Relaxed);
15}
16
17pub fn is_plain() -> bool {
18 PLAIN.load(Ordering::Relaxed)
19}
20
21pub fn use_color_stdout() -> bool {
22 supports_color(Stream::Stdout)
23}
24
25pub fn use_color_stderr() -> bool {
26 supports_color(Stream::Stderr)
27}
28
29pub fn use_tty_stderr() -> bool {
30 use_color_stderr()
31}
32
33pub fn terminal_width() -> Option<usize> {
34 if is_plain() {
35 return None;
36 }
37 std::env::var("COLUMNS")
38 .ok()
39 .and_then(|value| value.parse::<usize>().ok())
40}
41
42fn supports_color(stream: Stream) -> bool {
43 if is_plain() {
44 return false;
45 }
46 if std::env::var_os("NO_COLOR").is_some() {
47 return false;
48 }
49 supports_color::on(stream).is_some()
50}
51
52pub fn style_text<F>(text: &str, use_color: bool, style: F) -> String
53where
54 F: FnOnce(colored::ColoredString) -> colored::ColoredString,
55{
56 if use_color && !is_plain() {
57 style(text.normal()).to_string()
58 } else {
59 text.to_string()
60 }
61}
62
63pub fn format_cmd(command: &str, use_color: bool) -> String {
64 let text = format!("`{command}`");
65 style_text(&text, use_color, |text| text.yellow().bold())
66}
67
68pub fn format_action(message: &str, use_color: bool) -> String {
69 let text = format!("✅ {message}");
70 style_text(&text, use_color, |text| text.green().bold())
71}
72
73pub fn format_warning(message: &str, use_color: bool) -> String {
74 let text = if is_plain() {
75 format!("WARNING: {message}")
76 } else {
77 format!("Warning: {message}")
78 };
79 style_text(&text, use_color, |text| text.yellow().dimmed().italic())
80}
81
82pub fn format_cancel(use_color: bool) -> String {
83 style_text(CANCELLED_MESSAGE, use_color, |text| text.dimmed().italic())
84}
85
86pub fn format_hint(message: &str, use_color: bool) -> String {
87 if is_plain() {
88 format!("INFO: {message}")
89 } else {
90 let message = format!("\n\n{message}");
91 style_text(&message, use_color, |text| text.italic())
92 }
93}
94
95pub fn format_no_profiles(paths: &Paths, use_color: bool) -> String {
96 let hint = format_save_hint(
97 paths,
98 use_color,
99 "Run {save} to save this profile.",
100 "Run {login} • then {save}.",
101 );
102 format!("No saved profiles. {hint}")
103}
104
105pub fn format_save_before_load(paths: &Paths, use_color: bool) -> String {
106 format_save_hint(
107 paths,
108 use_color,
109 "Run {save} before loading.",
110 "Run {login}, then {save} before loading.",
111 )
112}
113
114pub fn format_unsaved_warning(use_color: bool) -> Vec<String> {
115 let warning = "WARNING: This profile is not saved yet.";
116 let save_line = format!(
117 "Run {} to save this profile.",
118 format_command("save", false)
119 );
120 if !use_color {
121 return vec![warning.to_string(), save_line];
122 }
123 vec![
124 style_text(warning, use_color, |text| text.yellow().dimmed().italic()),
125 style_text(&save_line, use_color, |text| text.dimmed().italic()),
126 ]
127}
128
129pub fn format_list_hint(use_color: bool) -> String {
130 let list = format_command("list", use_color);
131 format_hint(&format!("Run {list} to see saved profiles."), use_color)
132}
133
134pub fn normalize_error(message: &str) -> String {
135 let message = message.strip_prefix("Error: ").unwrap_or(message);
136 if message.contains("codex login") {
137 if message.contains("not found") {
138 return "Not logged in. Run `codex login`.".to_string();
139 }
140 if message.contains("invalid JSON") {
141 return "Auth file is invalid. Run `codex login`.".to_string();
142 }
143 return "Auth is incomplete. Run `codex login`.".to_string();
144 }
145 message.to_string()
146}
147
148pub fn format_error(message: &str) -> String {
149 let normalized = normalize_error(message);
150 let prefix = if use_color_stdout() {
151 "Error:".red().bold().blink().to_string()
152 } else {
153 "Error:".to_string()
154 };
155 format!("{prefix} {normalized}")
156}
157
158pub fn format_profile_display(
159 email: Option<String>,
160 plan: Option<String>,
161 label: Option<String>,
162 is_current: bool,
163 use_color: bool,
164) -> String {
165 let label = label.as_deref();
166 if email
167 .as_deref()
168 .map(|value| value.eq_ignore_ascii_case("Key"))
169 .unwrap_or(false)
170 && plan
171 .as_deref()
172 .map(|value| value.eq_ignore_ascii_case("Key"))
173 .unwrap_or(false)
174 {
175 let badge = format_plan_badge("Key", is_current, use_color);
176 let label_suffix = format_label(label, use_color);
177 return format!("{badge}{label_suffix}");
178 }
179 let label_suffix = format_label(label, use_color);
180 match email {
181 Some(email) => {
182 let plan = plan.unwrap_or_else(|| "Unknown".to_string());
183 let plan_is_free = crate::is_free_plan(Some(&plan));
184 let badge = format_plan_badge(&plan, is_current, use_color);
185 if use_color {
186 let email_badge = format_email_badge(&email, plan_is_free, is_current);
187 format!("{badge}{email_badge}{label_suffix}")
188 } else {
189 format!("{badge} {email}{label_suffix}")
190 }
191 }
192 None => format!("Unknown profile{label_suffix}"),
193 }
194}
195
196pub fn format_entry_header(
197 display: &str,
198 last_used: &str,
199 is_current: bool,
200 use_color: bool,
201) -> String {
202 let mut base = if use_color {
203 display.bold().to_string()
204 } else {
205 display.to_string()
206 };
207 if !is_current && !last_used.is_empty() {
208 base.push_str(&format_last_used_badge(last_used, use_color));
209 }
210 base
211}
212
213fn format_plan_badge(plan: &str, is_current: bool, use_color: bool) -> String {
214 let plan_upper = plan.to_uppercase();
215 let text = format!(" {} ", plan_upper);
216 let plan_is_free = crate::is_free_plan(Some(plan));
217 if use_color {
218 if plan_is_free {
219 text.white().on_bright_red().bold().to_string()
220 } else if is_current {
221 text.white().on_bright_green().bold().to_string()
222 } else {
223 text.white().on_bright_magenta().bold().to_string()
224 }
225 } else {
226 format!("[{plan_upper}]")
227 }
228}
229
230fn format_last_used_badge(last_used: &str, use_color: bool) -> String {
231 if use_color {
232 let text = format!(" {last_used}");
233 style_text(&text, use_color, |text| text.dimmed().italic())
234 } else {
235 format!(" ({last_used})")
236 }
237}
238
239fn format_label(label: Option<&str>, use_color: bool) -> String {
240 match label {
241 Some(value) if use_color => format!(" {value} ").white().on_bright_black().to_string(),
242 Some(value) => format!(" ({value})"),
243 None => String::new(),
244 }
245}
246
247fn format_email_badge(email: &str, plan_is_free: bool, is_current: bool) -> String {
248 if plan_is_free {
249 format!(" {email} ").white().on_red().to_string()
250 } else if is_current {
251 format!(" {email} ").white().on_green().to_string()
252 } else {
253 format!(" {email} ").white().on_magenta().to_string()
254 }
255}
256
257pub fn inquire_select_render_config() -> RenderConfig<'static> {
258 let mut config = if use_color_stderr() {
259 let mut config = RenderConfig::default_colored();
260 config.help_message = StyleSheet::new().with_fg(Color::DarkGrey);
261 config
262 } else {
263 RenderConfig::empty()
264 };
265 config.prompt_prefix = Styled::new("");
266 config.answered_prompt_prefix = Styled::new("");
267 config
268}
269
270pub fn is_inquire_cancel(err: &inquire::error::InquireError) -> bool {
271 matches!(
272 err,
273 inquire::error::InquireError::OperationCanceled
274 | inquire::error::InquireError::OperationInterrupted
275 )
276}
277
278const OUTPUT_INDENT: &str = " ";
279
280pub fn print_output_block(message: &str) {
281 let message = if is_plain() {
282 message.to_string()
283 } else {
284 indent_output(message)
285 };
286 println!("\n{message}\n");
287}
288
289pub fn print_output_block_with_frame(message: &str, separator: &str) {
290 if is_plain() {
291 print_output_block(message);
292 return;
293 }
294 let message = indent_output(message);
295 let separator = indent_output(separator);
296 println!("\n{separator}\n{message}\n{separator}\n");
297}
298
299fn indent_output(message: &str) -> String {
300 message
301 .lines()
302 .map(|line| {
303 if line.is_empty() {
304 String::new()
305 } else {
306 format!("{OUTPUT_INDENT}{line}")
307 }
308 })
309 .collect::<Vec<_>>()
310 .join("\n")
311}
312
313fn format_command(cmd: &str, use_color: bool) -> String {
314 let name = command_name();
315 let full = if cmd.is_empty() {
316 name.to_string()
317 } else {
318 format!("{name} {cmd}")
319 };
320 format_cmd(&full, use_color)
321}
322
323fn format_save_hint(paths: &Paths, use_color: bool, save_only: &str, with_login: &str) -> String {
324 let save = format_command("save", use_color);
325 let message = if has_auth(&paths.auth) {
326 save_only.replace("{save}", &save)
327 } else {
328 let login = format_cmd("codex login", use_color);
329 with_login
330 .replace("{login}", &login)
331 .replace("{save}", &save)
332 };
333 format_hint(&message, use_color)
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use crate::test_utils::{make_paths, set_env_guard, set_plain_guard};
340 use std::fs;
341
342 #[test]
343 fn plain_toggle_affects_output() {
344 {
345 let _plain = set_plain_guard(true);
346 assert!(is_plain());
347 let warning = format_warning("oops", false);
348 assert!(warning.contains("WARNING"));
349 }
350 assert!(!is_plain());
351 }
352
353 #[test]
354 fn terminal_width_parses_columns() {
355 let _plain = set_plain_guard(false);
356 let _env = set_env_guard("COLUMNS", Some("80"));
357 assert_eq!(terminal_width(), Some(80));
358 }
359
360 #[test]
361 fn terminal_width_none_when_plain() {
362 let _plain = set_plain_guard(true);
363 assert_eq!(terminal_width(), None);
364 }
365
366 #[test]
367 fn supports_color_respects_no_color() {
368 let _env = set_env_guard("NO_COLOR", Some("1"));
369 assert!(!use_color_stdout());
370 assert!(!use_color_stderr());
371 }
372
373 #[test]
374 fn format_helpers_basic() {
375 let _plain = set_plain_guard(false);
376 let cmd = format_cmd("codex login", false);
377 assert!(cmd.contains("codex login"));
378 let action = format_action("done", false);
379 assert!(action.contains("done"));
380 let hint = format_hint("hint", false);
381 assert!(hint.contains("hint"));
382 let cancel = format_cancel(false);
383 assert_eq!(cancel, CANCELLED_MESSAGE);
384 }
385
386 #[test]
387 fn format_no_profiles_and_save_before_load() {
388 let dir = tempfile::tempdir().expect("tempdir");
389 let paths = make_paths(dir.path());
390 let msg = format_no_profiles(&paths, false);
391 assert!(msg.contains("No saved profiles"));
392 let msg = format_save_before_load(&paths, false);
393 assert!(msg.contains("save"));
394 }
395
396 #[test]
397 fn format_unsaved_warning_plain() {
398 let lines = format_unsaved_warning(false);
399 assert_eq!(lines.len(), 2);
400 assert!(lines[0].contains("WARNING"));
401 }
402
403 #[test]
404 fn normalize_error_variants() {
405 assert_eq!(
406 normalize_error("Error: Codex auth file not found. Run `codex login` first."),
407 "Not logged in. Run `codex login`."
408 );
409 assert_eq!(
410 normalize_error(
411 "Error: invalid JSON in auth.json: oops. Run `codex login` to regenerate it."
412 ),
413 "Auth file is invalid. Run `codex login`."
414 );
415 assert_eq!(
416 normalize_error(
417 "Error: auth.json is missing tokens.account_id. Run `codex login` to reauthenticate."
418 ),
419 "Auth is incomplete. Run `codex login`."
420 );
421 assert_eq!(normalize_error("other"), "other");
422 }
423
424 #[test]
425 fn format_error_plain() {
426 let _env = set_env_guard("NO_COLOR", Some("1"));
427 let err = format_error("oops");
428 assert!(err.contains("Error:"));
429 }
430
431 #[test]
432 fn format_profile_display_variants() {
433 let key = format_profile_display(
434 Some("Key".to_string()),
435 Some("Key".to_string()),
436 Some("label".to_string()),
437 false,
438 false,
439 );
440 assert!(key.to_lowercase().contains("key"));
441 let display = format_profile_display(
442 Some("me@example.com".to_string()),
443 Some("Free".to_string()),
444 None,
445 true,
446 false,
447 );
448 assert!(display.contains("me@example.com"));
449 let unknown = format_profile_display(None, None, None, false, false);
450 assert!(unknown.contains("Unknown"));
451 }
452
453 #[test]
454 fn format_entry_header_and_separator() {
455 let header = format_entry_header("Display", "1d", false, false);
456 assert!(header.contains("Display"));
457 let indented = super::indent_output("line\n\nline2");
458 assert!(indented.contains("line2"));
459 }
460
461 #[test]
462 fn render_config_and_cancel() {
463 let _env = set_env_guard("NO_COLOR", Some("1"));
464 let config = inquire_select_render_config();
465 assert_eq!(config.prompt_prefix.content, "");
466 let err = inquire::error::InquireError::OperationCanceled;
467 assert!(is_inquire_cancel(&err));
468 }
469
470 #[test]
471 fn print_output_blocks() {
472 let _plain = set_plain_guard(true);
473 print_output_block("hi");
474 print_output_block_with_frame("hi", "-");
475 }
476
477 #[test]
478 fn format_command_uses_name() {
479 let cmd = super::format_command("list", false);
480 assert!(cmd.contains("list"));
481 }
482
483 #[test]
484 fn format_save_hint_with_auth() {
485 let dir = tempfile::tempdir().expect("tempdir");
486 let paths = make_paths(dir.path());
487 fs::write(&paths.auth, "{}").expect("write auth");
488 let hint = super::format_save_hint(&paths, false, "Run {save}", "Run {login} {save}");
489 assert!(hint.contains("save"));
490 }
491}