Skip to main content

sova_tasks/
console.rs

1//! CLI-only console IO for job handlers (`tasks run`).
2//!
3//! Outside an interactive CLI scope, prompts fail / use defaults and output is quiet.
4
5use std::cell::Cell;
6use std::fmt::Display;
7use std::io::{self, BufRead, IsTerminal, Write};
8
9thread_local! {
10    static INTERACTIVE: Cell<bool> = const { Cell::new(false) };
11}
12
13/// RAII guard: enables [`is_interactive`] until dropped.
14pub struct ConsoleGuard {
15    _priv: (),
16}
17
18impl Drop for ConsoleGuard {
19    fn drop(&mut self) {
20        INTERACTIVE.with(|c| c.set(false));
21    }
22}
23
24/// Enable interactive console for the current thread (CLI `tasks run`).
25pub fn enter_cli() -> ConsoleGuard {
26    INTERACTIVE.with(|c| c.set(true));
27    ConsoleGuard { _priv: () }
28}
29
30/// True while inside [`enter_cli`].
31pub fn is_interactive() -> bool {
32    INTERACTIVE.with(|c| c.get())
33}
34
35/// Print an info line when interactive; otherwise no-op.
36pub fn info(msg: impl Display) {
37    if is_interactive() {
38        println!("{msg}");
39    }
40}
41
42/// Print a warning when interactive.
43pub fn warn(msg: impl Display) {
44    if is_interactive() {
45        eprintln!("warn: {msg}");
46    }
47}
48
49/// Print an error when interactive.
50pub fn error(msg: impl Display) {
51    if is_interactive() {
52        eprintln!("error: {msg}");
53    }
54}
55
56/// Print a blank or custom line when interactive.
57pub fn line(msg: impl Display) {
58    if is_interactive() {
59        println!("{msg}");
60    }
61}
62
63/// Print a simple ASCII table when interactive.
64pub fn table(headers: &[&str], rows: &[Vec<String>]) {
65    if !is_interactive() {
66        return;
67    }
68    let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
69    for row in rows {
70        for (i, cell) in row.iter().enumerate() {
71            if let Some(w) = widths.get_mut(i) {
72                *w = (*w).max(cell.len());
73            }
74        }
75    }
76    let fmt_row = |cols: &[&str]| {
77        cols.iter()
78            .enumerate()
79            .map(|(i, c)| format!("{:<width$}", c, width = widths.get(i).copied().unwrap_or(0)))
80            .collect::<Vec<_>>()
81            .join("  ")
82    };
83    println!("{}", fmt_row(headers));
84    let sep: String = widths
85        .iter()
86        .map(|w| "-".repeat(*w))
87        .collect::<Vec<_>>()
88        .join("  ");
89    println!("{sep}");
90    for row in rows {
91        let refs: Vec<&str> = row.iter().map(String::as_str).collect();
92        println!("{}", fmt_row(&refs));
93    }
94}
95
96/// Prompt for a line of input. Errors when not interactive.
97pub fn ask(prompt: impl Display) -> Result<String, String> {
98    if !is_interactive() {
99        return Err("console ask is only available during `tasks run`".into());
100    }
101    print!("{prompt} ");
102    let _ = io::stdout().flush();
103    let mut line = String::new();
104    io::stdin()
105        .lock()
106        .read_line(&mut line)
107        .map_err(|e| e.to_string())?;
108    // Piped stdin does not echo the newline; keep subsequent output on its own line.
109    if !io::stdin().is_terminal() {
110        println!();
111    }
112    Ok(line.trim_end_matches(['\r', '\n']).to_string())
113}
114
115/// Yes/no prompt. When not interactive, returns `default`.
116pub fn confirm(prompt: impl Display, default: bool) -> bool {
117    if !is_interactive() {
118        return default;
119    }
120    let hint = if default { "Y/n" } else { "y/N" };
121    print!("{prompt} [{hint}] ");
122    let _ = io::stdout().flush();
123    let mut line = String::new();
124    if io::stdin().lock().read_line(&mut line).is_err() {
125        return default;
126    }
127    if !io::stdin().is_terminal() {
128        println!();
129    }
130    let t = line.trim().to_ascii_lowercase();
131    if t.is_empty() {
132        return default;
133    }
134    matches!(t.as_str(), "y" | "yes")
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn quiet_outside_cli() {
143        assert!(!is_interactive());
144        assert!(ask("x").is_err());
145        assert!(!confirm("x", false));
146        assert!(confirm("x", true));
147        info("should not panic");
148        table(&["a"], &[vec!["1".into()]]);
149    }
150
151    #[test]
152    fn guard_enables_interactive() {
153        {
154            let _g = enter_cli();
155            assert!(is_interactive());
156        }
157        assert!(!is_interactive());
158    }
159}