use std::io::{self, BufRead, BufReader, IsTerminal, Write};
use atuin_client::settings::Settings;
use colored::Colorize;
use eyre::Result;
use super::config::{EnableCmd, Feature};
use super::daemon;
use crate::i18n::fl;
pub struct Terminal {
input: Option<Box<dyn BufRead + Send>>,
output: Box<dyn Write + Send>,
live: bool,
#[cfg(unix)]
_cooked: Option<Cooked>,
}
impl Terminal {
pub fn stdio() -> Self {
let live = io::stdout().is_terminal();
let input =
(live && io::stdin().is_terminal()).then(|| Box::new(BufReader::new(io::stdin())) as _);
Self {
input,
output: Box::new(io::stdout()),
live,
#[cfg(unix)]
_cooked: None,
}
}
#[cfg(unix)]
pub fn tty() -> Option<Self> {
let tty = std::fs::OpenOptions::new().read(true).write(true).open("/dev/tty").ok()?;
Some(Self {
input: Some(Box::new(BufReader::new(tty.try_clone().ok()?))),
_cooked: Cooked::new(tty.try_clone().ok()?),
output: Box::new(tty),
live: true,
})
}
#[cfg(not(unix))]
pub fn tty() -> Option<Self> {
None
}
fn ask(&mut self, question: &str, description: &str, note: Option<&str>) -> Result<bool> {
let Some(input) = self.input.as_mut() else {
return Ok(false);
};
let q = "[Y/n]".bold();
writeln!(self.output, "> {}", question.bold().bright_blue())?;
match note {
Some(note) => write!(self.output, " {description}\n {note} {q} ")?,
None => write!(self.output, " {description} {q} ")?,
}
self.output.flush()?;
let mut answer = String::new();
if input.read_line(&mut answer)? == 0 {
writeln!(self.output)?;
return Ok(false);
}
Ok(matches!(answer.trim().to_lowercase().as_str(), "" | "y" | "yes"))
}
}
#[cfg(unix)]
struct Cooked {
tty: std::fs::File,
was: rustix::termios::Termios,
}
#[cfg(unix)]
impl Cooked {
fn new(tty: std::fs::File) -> Option<Self> {
use rustix::termios::{InputModes, LocalModes, OptionalActions, OutputModes};
let was = rustix::termios::tcgetattr(&tty).ok()?;
let mut cooked = was.clone();
cooked.local_modes |= LocalModes::ICANON | LocalModes::ECHO | LocalModes::ISIG;
cooked.input_modes |= InputModes::ICRNL;
cooked.output_modes |= OutputModes::OPOST | OutputModes::ONLCR;
rustix::termios::tcsetattr(&tty, OptionalActions::Now, &cooked).ok()?;
Some(Self { tty, was })
}
}
#[cfg(unix)]
impl Drop for Cooked {
fn drop(&mut self) {
let now = rustix::termios::OptionalActions::Now;
let _ = rustix::termios::tcsetattr(&self.tty, now, &self.was);
}
}
pub async fn offer(settings: &Settings, term: &mut Terminal) -> Option<Settings> {
let enabled = async {
if !term.ask(&fl!("threads-offer"), &fl!("threads-offer-description"), None)? {
return Ok(None);
}
enable(settings, term).await.map(Some)
}
.await;
match enabled {
Ok(settings) => settings,
Err(e) => {
let _ = writeln!(term.output, "{}", fl!("threads-failed", error = format!("{e:#}")));
None
}
}
}
pub async fn enable(settings: &Settings, term: &mut Terminal) -> Result<Settings> {
let config_file = Settings::get_config_path()?;
let config_str = tokio::fs::read_to_string(&config_file).await?;
let updated = EnableCmd {
feature: Feature::Threads,
}
.get_updated_config(&config_str, settings.daemon.enabled)?;
tokio::fs::write(&config_file, &updated).await?;
let settings = Settings::new()?;
if let Err(e) = daemon::ready_client(&settings).await {
writeln!(term.output, "{}", fl!("threads-no-daemon", error = format!("{e:#}")))?;
return Ok(settings);
}
writeln!(term.output, "{}", fl!("threads-enabled"))?;
let note = settings.threads.capture_tools.then(|| fl!("threads-import-tools"));
if term.ask(&fl!("threads-import"), &fl!("threads-import-description"), note.as_deref())? {
if let Err(e) = atuin_ai::commands::import_all(&settings, &mut term.output, term.live).await
{
let error = format!("{e:#}");
writeln!(term.output, "{}", fl!("threads-import-failed", error = error))?;
}
} else {
writeln!(term.output, "{}", fl!("threads-import-later"))?;
}
Ok(settings)
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
#[case::enter("\n", true)]
#[case::yes("y\n", true)]
#[case::no("n\n", false)]
#[case::ended("", false)]
fn an_answer_is_yes_by_default_and_no_once_input_ends(
#[case] typed: &'static str,
#[case] yes: bool,
) {
let mut term = Terminal {
input: Some(Box::new(typed.as_bytes())),
output: Box::new(io::sink()),
live: false,
#[cfg(unix)]
_cooked: None,
};
assert_eq!(term.ask("q", "d", None).unwrap(), yes);
}
}