use std::fs::File;
use std::io::{self, BufWriter, Read, Write};
use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand, ValueEnum};
use termstate_core::limits::{MAX_MSG_DECODED, MAX_TITLE_DECODED};
use termstate_core::scan::{Event, Scanner};
use termstate_core::{Id, Kind, Report, State, Terminator, sanitize, write_clear};
use termstate_store::Store;
mod json;
#[cfg(unix)]
mod tty;
#[derive(Parser)]
#[command(name = "termstate", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Emit(EmitArgs),
Detect {
#[arg(long, default_value_t = 1000)]
timeout_ms: u64,
#[arg(long)]
json: bool,
},
Decode {
file: Option<PathBuf>,
#[arg(long)]
records: bool,
},
Strip {
file: Option<PathBuf>,
},
}
#[derive(Clone, Copy, ValueEnum)]
enum StateArg {
Idle,
Working,
Done,
Blocked,
Error,
Clear,
}
#[derive(Clone, Copy, ValueEnum)]
enum KindArg {
Permission,
Question,
Auth,
}
#[derive(clap::Args)]
struct EmitArgs {
state: StateArg,
#[arg(long)]
id: Option<String>,
#[arg(long, value_enum)]
kind: Option<KindArg>,
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=100))]
progress: Option<u8>,
#[arg(long)]
app: Option<String>,
#[arg(long)]
title: Option<String>,
#[arg(long)]
msg: Option<String>,
#[arg(long)]
strict: bool,
#[arg(long)]
bel: bool,
#[arg(long)]
tty: bool,
#[arg(long)]
tmux: bool,
}
fn main() -> ExitCode {
let cli = Cli::parse();
let result = match cli.command {
Command::Emit(args) => emit(args).map(|()| ExitCode::SUCCESS),
Command::Detect { timeout_ms, json } => detect(timeout_ms, json),
Command::Decode { file, records } => decode(file, records).map(|()| ExitCode::SUCCESS),
Command::Strip { file } => strip(file).map(|()| ExitCode::SUCCESS),
};
result.unwrap_or_else(|e| {
eprintln!("termstate: {e}");
ExitCode::from(2)
})
}
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn emit(a: EmitArgs) -> Result<()> {
let term = if a.bel {
Terminator::Bel
} else {
Terminator::St
};
let mut seq = String::new();
let state = match a.state {
StateArg::Idle => State::Idle,
StateArg::Working => State::Working,
StateArg::Done => State::Done,
StateArg::Blocked => State::Blocked,
StateArg::Error => State::Error,
StateArg::Clear => {
let id = a.id.as_deref().map(Id::new).transpose()?;
write_clear(&mut seq, id, term)?;
return write_out(&seq, a.tty, a.tmux);
}
};
let clean = |s: &Option<String>, max| match s {
Some(s) if !a.strict => Some(sanitize(s, max)),
other => other.clone(),
};
let (title, msg) = (
clean(&a.title, MAX_TITLE_DECODED),
clean(&a.msg, MAX_MSG_DECODED),
);
let mut b = Report::builder(state);
if let Some(id) = &a.id {
b = b.id(id);
}
if let Some(k) = a.kind {
b = b.kind(match k {
KindArg::Permission => Kind::Permission,
KindArg::Question => Kind::Question,
KindArg::Auth => Kind::Auth,
});
}
if let Some(p) = a.progress {
b = b.progress(p);
}
if let Some(app) = &a.app {
b = b.app(app);
}
if let Some(t) = &title {
b = b.title(t);
}
if let Some(m) = &msg {
b = b.msg(m);
}
b.build()?.write(&mut seq, term)?;
write_out(&seq, a.tty, a.tmux)
}
fn write_out(seq: &str, tty: bool, tmux: bool) -> Result<()> {
let bytes = if tmux {
format!("\x1bPtmux;{}\x1b\\", seq.replace('\x1b', "\x1b\x1b"))
} else {
seq.to_owned()
};
if tty {
let mut f = File::options().write(true).open("/dev/tty")?;
f.write_all(bytes.as_bytes())?;
f.flush()?;
} else {
let mut out = io::stdout().lock();
out.write_all(bytes.as_bytes())?;
out.flush()?;
}
Ok(())
}
#[cfg(unix)]
fn detect(timeout_ms: u64, as_json: bool) -> Result<ExitCode> {
use termstate_core::detect::Outcome;
let outcome = tty::detect(std::time::Duration::from_millis(timeout_ms))?;
let supported = matches!(outcome, Outcome::Supported(_));
if as_json {
println!("{}", json::outcome(&outcome));
} else {
match outcome {
Outcome::Supported(c) => {
let states: Vec<_> = c.states().map(State::as_str).collect();
let kinds: Vec<_> = c.kinds().map(Kind::as_str).collect();
println!(
"supported states={} kinds={}",
states.join(","),
kinds.join(",")
);
}
Outcome::Unsupported => println!("unsupported"),
}
}
Ok(if supported {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
})
}
#[cfg(not(unix))]
fn detect(_: u64, _: bool) -> Result<ExitCode> {
Err("detect needs a Unix terminal".into())
}
fn input(file: Option<PathBuf>) -> Result<Box<dyn Read>> {
Ok(match file {
Some(p) => Box::new(File::open(p)?),
None => Box::new(io::stdin().lock()),
})
}
fn scan(mut r: Box<dyn Read>, mut on: impl FnMut(Event<'_>) -> io::Result<()>) -> Result<()> {
let mut scanner = Scanner::new();
let mut buf = vec![0u8; 64 * 1024];
let mut err = Ok(());
loop {
let n = match r.read(&mut buf) {
Ok(0) => break,
Ok(n) => n,
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e.into()),
};
scanner.feed(&buf[..n], |e| {
if err.is_ok() {
err = on(e);
}
});
std::mem::replace(&mut err, Ok(()))?;
}
scanner.finish(|e| {
if err.is_ok() {
err = on(e);
}
});
Ok(err?)
}
fn decode(file: Option<PathBuf>, records: bool) -> Result<()> {
let mut out = BufWriter::new(io::stdout().lock());
let mut store = Store::new();
scan(input(file)?, |e| match e {
Event::Passthrough(_) => Ok(()),
Event::Sequence {
message,
terminator,
} => {
if records {
if let Ok(m) = &message {
store.apply(m);
}
Ok(())
} else {
writeln!(out, "{}", json::sequence(&message, terminator))
}
}
})?;
if records {
writeln!(
out,
"{}",
serde_json::to_string_pretty(&json::records(&store))?
)?;
}
out.flush()?;
Ok(())
}
fn strip(file: Option<PathBuf>) -> Result<()> {
let mut out = BufWriter::new(io::stdout().lock());
scan(input(file)?, |e| match e {
Event::Passthrough(b) => out.write_all(b),
Event::Sequence { .. } => Ok(()),
})?;
out.flush()?;
Ok(())
}