jany 0.5.1

jev x any command: turn loosely ordered words into a command line, defined per command by a schema
//! What goes to stderr: the `--explain` table, the jev line, the preview. stdout gets only the one command line.

use crate::color::{self, C, paint};
use crate::jev::Usage;
use crate::token::{Source, Token};
use std::io::{IsTerminal, Write};

pub fn explain(tokens: &[Token], jev: Option<&JevInfo>) {
    eprint!("{}", explain_text(tokens, jev, color::stderr_enabled()));
}

pub fn explain_text(tokens: &[Token], jev: Option<&JevInfo>, on: bool) -> String {
    use std::fmt::Write as _;
    let mut e = String::new();
    let w = tokens.iter().map(|t| t.text.chars().count()).max().unwrap_or(4).clamp(4, 40);
    let rw = tokens.iter().map(|t| t.role_str().len()).max().unwrap_or(4).clamp(4, 20);
    let _ = writeln!(e, "{}", paint(on, C::Dim, &format!("{:<w$}  {:<rw$} {:<5} {:<4}  note", "word", "role", "conf", "by", w = w, rw = rw)));
    for t in tokens {
        let role_s = t.role_str();
        let role_c = if t.role.is_none() { C::Red } else if t.source == Source::Jev { C::Green } else { C::Cyan };
        let role = format!("{}{}", paint(on, role_c, role_s), " ".repeat(rw.saturating_sub(role_s.len())));
        let by = match t.source {
            Source::Rule => paint(on, C::Dim, "rule"),
            Source::Jev => paint(on, C::Blue, "jev "),
        };
        let mut note = t.note.clone().unwrap_or_default();
        if let Some(f) = &t.fixed
            && f != &t.text
        {
            note = format!("→ {f}{}{note}", if note.is_empty() { "" } else { "; " });
        }
        if let Some(a) = &t.amount
            && t.resolved()
        {
            let dir = match a.at_least {
                Some(true) => "≥",
                Some(false) => "≤",
                None => "?",
            };
            note = format!("{dir} {} {}{}{note}", a.n, a.unit.as_deref().unwrap_or("?"), if note.is_empty() { "" } else { "; " });
        }
        let pad = w.saturating_sub(t.text.chars().count());
        let _ = writeln!(e, "{}{}  {} {}  {}  {}", t.text, " ".repeat(pad), role, color::conf(on, t.confidence), by, paint(on, C::Dim, &note));
    }
    let _ = match jev {
        Some(j) => writeln!(e, "{}", paint(on, C::Dim, &j.line())),
        None => writeln!(e, "{}", paint(on, C::Dim, "jev: not called (fast path)")),
    };
    e
}

pub struct JevInfo {
    pub model: String,
    pub questions: usize,
    pub ms: u128,
    pub usage: Option<Usage>,
}

impl JevInfo {
    pub fn line(&self) -> String {
        let u = self.usage.clone().unwrap_or_default();
        format!(
            "jev: {} · {} questions · {} ms · {} in / {} out tokens · ${}",
            self.model,
            self.questions,
            self.ms,
            u.input_tokens,
            u.output_tokens,
            u.cost.map(|c| format!("{c:.6}")).unwrap_or_else(|| "?".into())
        )
    }
}

/// y/N prompt, used only to confirm overwriting in `jany --setup`.
pub fn confirm_no(prompt: &str) -> bool {
    if !std::io::stdin().is_terminal() {
        return false;
    }
    eprint!("{} {} ", paint(color::stderr_enabled(), C::Yellow, prompt), paint(color::stderr_enabled(), C::Dim, "[y/N]"));
    let _ = std::io::stderr().flush();
    let mut line = String::new();
    if std::io::stdin().read_line(&mut line).is_err() {
        return false;
    }
    matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}

/// Write to stdout. `print!` panics when the reader has gone away (`jany --list | head -1`);
/// here a closed pipe ends the process quietly, and any other write error exits non-zero.
pub fn stdout(s: &str) {
    let mut out = std::io::stdout().lock();
    if let Err(e) = out.write_all(s.as_bytes()).and_then(|_| out.flush()) {
        std::process::exit(if e.kind() == std::io::ErrorKind::BrokenPipe { 0 } else { 1 });
    }
}

/// argv (+ pipe) as one line for a POSIX shell.
pub fn render(argv: &[String], pipe: Option<&[String]>) -> String {
    let mut s = shell_words::join(argv);
    if let Some(p) = pipe
        && !p.is_empty()
    {
        s.push_str(" | ");
        s.push_str(&shell_words::join(p));
    }
    s
}