use std::io::Write;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use super::card::{self, Card, Verdict};
use super::log::Transcript;
use super::select::{self, KeyState, LineRead, Select, SelectOutcome};
use super::term::{Caps, Mode};
use super::{paint, style, wrap};
use crate::error::OlError;
pub enum Out {
Term(console::Term),
Buffer(Arc<Mutex<Vec<String>>>),
}
impl Out {
fn line(&self, text: &str) {
match self {
Self::Term(t) => {
let _ = t.write_line(text);
}
Self::Buffer(b) => lock(b).extend(text.split('\n').map(str::to_string)),
}
}
fn prompt(&self, text: &str) {
match self {
Self::Term(t) => {
let mut t = t.clone();
let _ = t.write_all(text.as_bytes());
let _ = t.flush();
}
Self::Buffer(b) => lock(b).push(text.to_string()),
}
}
fn clear(&self, n: usize) {
match self {
Self::Term(t) => {
let _ = t.clear_last_lines(n);
}
Self::Buffer(b) => {
let mut b = lock(b);
let keep = b.len().saturating_sub(n);
b.truncate(keep);
}
}
}
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
enum Held {
Note(String),
Hint(String),
Link(String, String),
Detail(String),
}
struct Stage {
index: usize,
name: String,
running: String,
shown: String,
done: String,
held: Vec<Held>,
announced: bool,
meter: Option<(Option<f64>, String)>,
milestone: u8,
spinner: Option<indicatif::ProgressBar>,
}
const METER: usize = 24;
const METER_SHORT: usize = 10;
pub struct Rail {
caps: Caps,
out: Out,
log: Option<Transcript>,
total: usize,
count: usize,
stage: Option<Stage>,
}
impl Rail {
pub fn new(caps: Caps, out: Out, log: Option<Transcript>, total: usize) -> Self {
Self {
caps,
out,
log,
total,
count: 0,
stage: None,
}
}
pub fn buffer(caps: Caps, total: usize) -> (Self, Arc<Mutex<Vec<String>>>) {
let screen = Arc::new(Mutex::new(Vec::new()));
(
Self::new(caps, Out::Buffer(screen.clone()), None, total),
screen,
)
}
pub fn caps(&self) -> &Caps {
&self.caps
}
pub fn log_path(&self) -> Option<&std::path::Path> {
self.log.as_ref().map(Transcript::path)
}
pub fn take_back_log(&mut self, dest_dir: &std::path::Path) {
if let Some(log) = self.log.as_mut() {
log.take_back(dest_dir);
}
}
fn record(&mut self, kind: &str, text: &str) {
if let Some(log) = self.log.as_mut() {
log.write(kind, text);
}
}
fn emit(&self, line: &str) {
self.out.line(&super::fit_glyphs(line, &self.caps));
}
fn dim(&self, text: &str) -> String {
paint::dim(text, self.caps.color)
}
fn white(&self, text: &str) -> String {
paint::white(text, self.caps.color)
}
pub(crate) fn gutter(&self) -> String {
match self.caps.mode {
Mode::Plain => " ".to_string(),
_ => format!("{} ", self.dim(self.caps.glyphs().bar)),
}
}
fn active_gutter(&self) -> String {
match self.caps.mode {
Mode::Plain => " ".to_string(),
_ => format!(
"{} ",
paint::accent(self.caps.glyphs().bar, self.caps.color)
),
}
}
fn under(&self, text: &str, dim: bool) -> Vec<String> {
let tone = |part: &str| {
let part = style(part, self.caps.color);
if dim {
self.dim(&part)
} else {
part
}
};
self.under_gutter(&self.gutter(), text, tone)
}
fn under_gutter(&self, gutter: &str, text: &str, tone: impl Fn(&str) -> String) -> Vec<String> {
let room = self
.caps
.width
.saturating_sub(console::measure_text_width(gutter));
wrap(text, room)
.into_iter()
.map(|part| format!("{gutter}{}", tone(&part)))
.collect()
}
pub fn intro(&mut self, title: &str) {
self.record("intro", title);
let line = match self.caps.mode {
Mode::Plain => title.to_string(),
_ => format!(
"{} {}",
self.dim(self.caps.glyphs().start),
self.white(title)
),
};
self.emit(&line);
}
pub fn stage(&mut self, running: &str, done: &str) {
self.stage_named(running, running, done);
}
pub fn stage_named(&mut self, name: &str, running: &str, done: &str) {
if self.stage.is_some() {
self.done("");
}
self.count += 1;
let index = self.count;
self.record("stage", &format!("{index}/{} {running}", self.total));
let g = self.caps.glyphs();
let spinner = match self.caps.mode {
Mode::Live => {
self.emit(&self.dim(g.bar));
self.spinner(&self.white(&format!("{running}{}", g.ellipsis)))
}
Mode::Append => {
self.emit(g.bar);
self.out
.line(&format!("{} {running}{}", g.active, g.ellipsis));
None
}
Mode::Plain => None,
};
self.stage = Some(Stage {
index,
name: name.to_string(),
running: running.to_string(),
shown: running.to_string(),
done: done.to_string(),
held: Vec::new(),
announced: false,
meter: None,
milestone: 0,
spinner,
});
}
fn spinner(&self, message: &str) -> Option<indicatif::ProgressBar> {
let Out::Term(term) = &self.out else {
return None;
};
let g = self.caps.glyphs();
let template = if self.caps.color {
"{spinner:.cyan} {msg}"
} else {
"{spinner} {msg}"
};
let style = indicatif::ProgressStyle::with_template(template)
.unwrap_or_else(|_| indicatif::ProgressStyle::default_spinner())
.tick_strings(&[g.spinner, &[g.done]].concat());
let pb = indicatif::ProgressBar::with_draw_target(
None,
indicatif::ProgressDrawTarget::term(term.clone(), 15),
);
pb.set_style(style);
pb.set_message(message.to_string());
pb.enable_steady_tick(Duration::from_millis(120));
Some(pb)
}
pub fn progress(&mut self, text: &str) {
if let Some(stage) = self.stage.as_mut() {
stage.shown = text.to_string();
}
self.refresh_block();
}
pub fn meter(&mut self, frac: Option<f64>, tail: &str) {
let frac = frac.filter(|f| f.is_finite()).map(|f| f.clamp(0.0, 1.0));
let Some(stage) = self.stage.as_mut() else {
return;
};
stage.meter = Some((frac, tail.to_string()));
let Some(frac) = frac else {
self.refresh_block();
return;
};
match self.caps.mode {
Mode::Live => self.refresh_block(),
Mode::Append => {
let pct = (frac * 100.0).floor() as u8;
let last = stage.milestone;
let marks: Vec<u8> = [25, 50, 75, 100]
.into_iter()
.filter(|m| *m > last && *m <= pct)
.collect();
let Some(&top) = marks.last() else {
return;
};
stage.milestone = top;
for mark in marks {
let text = if mark == top {
format!("{mark:>3}% {tail}")
} else {
format!("{mark:>3}%")
};
let line = format!("{}{}", self.gutter(), self.dim(text.trim_end()));
self.emit(&line);
}
}
Mode::Plain => self.announce(),
}
}
pub fn note(&mut self, text: &str) {
self.record("note", text);
if self.holds() {
if let Some(stage) = self.stage.as_mut() {
stage.held.push(Held::Note(text.to_string()));
}
self.refresh_block();
return;
}
self.announce();
for line in self.under(text, false) {
self.emit(&line);
}
}
pub fn hint(&mut self, text: &str) {
self.transient(Held::Hint(text.to_string()), text);
}
pub fn link(&mut self, lead: &str, url: &str) {
let held = Held::Link(lead.to_string(), url.to_string());
self.transient(held, &format!("{lead}{url}"));
}
fn transient(&mut self, held: Held, text: &str) {
self.record("note", text);
if self.holds() {
if let Some(stage) = self.stage.as_mut() {
stage.held.push(held);
}
self.refresh_block();
return;
}
self.announce();
for line in self.held_lines(&held) {
self.emit(&line);
}
}
fn held_lines(&self, held: &Held) -> Vec<String> {
match held {
Held::Note(t) => self.under(t, false),
Held::Hint(t) | Held::Detail(t) => self.under(t, true),
Held::Link(lead, url) => vec![format!(
"{}{}{}",
self.gutter(),
self.dim(lead),
paint::link(url, self.caps.color)
)],
}
}
pub fn relabel(&mut self, done: &str) {
if let Some(stage) = self.stage.as_mut() {
stage.done = done.to_string();
}
}
pub fn announce(&mut self) {
if self.caps.mode != Mode::Plain {
return;
}
let (total, ellipsis) = (self.total, self.caps.glyphs().ellipsis);
let heading = match self.stage.as_mut() {
Some(stage) if !stage.announced => {
stage.announced = true;
format!("[{}/{total}] {}{ellipsis}", stage.index, stage.running)
}
_ => return,
};
self.emit(&heading);
}
pub fn detail(&mut self, text: &str) {
self.record("detail", text);
if !self.caps.verbose {
return;
}
if self.holds() {
if let Some(stage) = self.stage.as_mut() {
stage.held.push(Held::Detail(text.to_string()));
}
return;
}
self.announce();
for line in self.under(text, true) {
self.emit(&line);
}
}
fn holds(&self) -> bool {
self.caps.mode == Mode::Live && self.stage.is_some()
}
fn refresh_block(&self) {
let Some(stage) = &self.stage else { return };
let Some(pb) = &stage.spinner else { return };
pb.set_message(self.block_message(stage));
}
fn block_message(&self, stage: &Stage) -> String {
let mut message = match &stage.meter {
Some((frac, tail)) => self.meter_line(&stage.shown, *frac, tail),
None => self.white(&format!("{}{}", stage.shown, self.caps.glyphs().ellipsis)),
};
for held in stage.held.iter().filter(|h| !matches!(h, Held::Detail(_))) {
for line in self.held_lines(held) {
message.push('\n');
message.push_str(&line);
}
}
message
}
fn meter_line(&self, shown: &str, frac: Option<f64>, tail: &str) -> String {
let (g, on) = (self.caps.glyphs(), self.caps.color);
let fit = |t: &str| super::fit_glyphs(t, &self.caps);
let width = |t: &str| console::measure_text_width(&fit(t));
let room = self.caps.width.saturating_sub(3);
let parts: Vec<&str> = tail.split(" · ").filter(|p| !p.is_empty()).collect();
let tails = || (0..=parts.len()).rev().map(|k| parts[..k].join(" · "));
if let Some(frac) = frac {
let pct = format!(" {:>3}%", (frac * 100.0).round() as u8);
let tries = [
(METER, true),
(METER_SHORT, true),
(METER, false),
(METER_SHORT, false),
];
for (bar, with_tail) in tries {
let fixed = width(shown) + 2 + bar + pct.len();
let Some(tail) = tails().find(|t| {
t.is_empty() != with_tail
&& fixed + if t.is_empty() { 0 } else { 2 + width(t) } <= room
}) else {
continue;
};
let full = (bar as f64 * frac).round() as usize;
let tail = if tail.is_empty() {
String::new()
} else {
self.dim(&fit(&format!(" {tail}")))
};
return format!(
"{}{}{}{}{tail}",
self.white(&format!("{shown} ")),
paint::accent(&fit(&"━".repeat(full)), on),
self.dim(&fit(&"─".repeat(bar - full))),
self.white(&pct),
);
}
}
let label = format!("{shown}{}", g.ellipsis);
let tail = tails()
.find(|t| t.is_empty() || width(&label) + 3 + width(t) <= room)
.unwrap_or_default();
if tail.is_empty() {
return self.white(&label);
}
format!(
"{}{}",
self.white(&label),
self.dim(&fit(&format!(" {} {tail}", g.dot)))
)
}
pub fn done(&mut self, result: &str) {
let (glyph, on) = (self.caps.glyphs().done, self.caps.color);
self.close(&paint::green(glyph, on), None, result, None, "done");
}
pub fn done_toned(&mut self, result: &str, tone: paint::Tone) {
let (glyph, on) = (self.caps.glyphs().done, self.caps.color);
self.close(&paint::green(glyph, on), None, result, Some(tone), "done");
}
pub fn attention(&mut self, result: &str) {
let (glyph, on) = (self.caps.glyphs().warn, self.caps.color);
self.close(&paint::amber(glyph, on), None, result, None, "attention");
}
pub fn halt(&mut self, result: &str) {
let (glyph, on) = (self.caps.glyphs().warn, self.caps.color);
let glyph = paint::bold(&paint::amber(glyph, on), on);
self.close(&glyph, Some(paint::amber), result, None, "halt");
}
pub fn failed(&mut self, result: &str) {
if let Some(stage) = self.stage.as_mut() {
stage.done = format!("{} failed", stage.name);
}
let (glyph, on) = (self.caps.glyphs().fail, self.caps.color);
let glyph = paint::bold(&paint::red(glyph, on), on);
self.close(&glyph, Some(paint::red), result, None, "failed");
}
fn close(
&mut self,
glyph: &str,
tone: Option<fn(&str, bool) -> String>,
result: &str,
result_tone: Option<paint::Tone>,
kind: &str,
) {
let Some(stage) = self.stage.take() else {
return;
};
if let Some(pb) = &stage.spinner {
pb.finish_and_clear();
}
let text = if result.is_empty() {
stage.done.clone()
} else {
format!("{} {} {result}", stage.done, self.caps.glyphs().dot)
};
self.record(kind, &format!("{}/{} {text}", stage.index, self.total));
let line = match self.caps.mode {
Mode::Plain => format!("[{}/{}] {text}", stage.index, self.total),
_ => {
let dot = self.caps.glyphs().dot;
let tail = match result_tone {
_ if result.is_empty() => String::new(),
Some(t) => format!(
"{}{}",
self.dim(&format!(" {dot} ")),
t.paint(result, self.caps.color)
),
None => self.dim(&format!(" {dot} {result}")),
};
let label = match tone {
Some(paint) => paint(&stage.done, self.caps.color),
None => self.white(&stage.done),
};
format!("{glyph} {label}{tail}")
}
};
self.emit(&line);
self.flush_held(stage.held);
}
fn flush_held(&mut self, held: Vec<Held>) {
for item in held
.iter()
.filter(|h| matches!(h, Held::Note(_) | Held::Detail(_)))
{
for line in self.held_lines(item) {
self.emit(&line);
}
}
}
pub fn fail(&mut self, error: &OlError) {
let (g, on) = (self.caps.glyphs(), self.caps.color);
let headline = format!("{} ({})", error.message, error.code);
self.record("error", &headline);
if let Some(s) = &error.suggestion {
self.record("error", s);
}
if let Some(d) = &error.docs_url {
self.record("error", &format!("Docs: {d}"));
}
let mut block = Vec::new();
match self.stage.take() {
Some(stage) => {
if let Some(pb) = &stage.spinner {
pb.finish_and_clear();
}
let line = match self.caps.mode {
Mode::Plain => {
format!(
"[{}/{}] {} {} failed",
stage.index, self.total, stage.running, g.dot
)
}
_ => format!(
"{} {}{}",
paint::bold(&paint::red(g.fail, on), on),
paint::red(&format!("{} failed", stage.name), on),
self.dim(&format!(" {} {}", g.dot, reason(&error.message)))
),
};
self.emit(&line);
self.flush_held(stage.held);
if self.caps.mode != Mode::Plain {
return;
}
block.extend(self.under(&headline, false));
}
None => {
let lead = match self.caps.mode {
Mode::Plain => format!("{} ", g.fail),
_ => format!("{} ", paint::bold(&paint::red(g.fail, on), on)),
};
block.push(format!("{lead}{headline}"));
}
}
if let Some(s) = &error.suggestion {
block.extend(self.under(s, false));
}
if let Some(d) = &error.docs_url {
block.extend(self.under(&format!("Docs: {d}"), true));
}
for line in block {
self.emit(&line);
}
}
pub fn outro(&mut self, text: &str) {
if self.stage.is_some() {
self.done("");
}
self.record("outro", text);
let g = self.caps.glyphs();
match self.caps.mode {
Mode::Plain => self.emit(text),
_ => {
self.emit(&self.dim(g.bar));
self.emit(&format!("{} {}", self.dim(g.end), self.white(text)));
}
}
}
pub fn card(&mut self, card: &Card) {
self.card_with_warnings(card, &[]);
}
pub fn card_with_warnings(&mut self, card: &Card, warnings: &[String]) {
let lines = card::render_with_warnings(card, warnings, &self.caps);
for line in &lines {
self.record("card", line);
}
self.emit("");
for line in lines {
self.emit(&line);
}
}
pub fn result_line(&mut self, verdict: Verdict, exit_code: i32, fields: &[(&str, &str)]) {
self.write_result(card::result_line(verdict, exit_code, fields));
}
pub fn result_line_token(&mut self, token: &str, exit_code: i32, fields: &[(&str, &str)]) {
self.write_result(card::result_line_token(token, exit_code, fields));
}
fn write_result(&mut self, line: String) {
self.record("result", &line);
if self.caps.mode == Mode::Plain {
self.emit(&line);
}
}
pub fn suspend<R>(&self, f: impl FnOnce() -> R) -> R {
match self.stage.as_ref().and_then(|s| s.spinner.as_ref()) {
Some(pb) => pb.suspend(f),
None => f(),
}
}
pub fn select(&mut self, q: &Select<'_>) -> SelectOutcome {
use std::io::IsTerminal;
self.record("ask", q.question);
if let Some(pb) = self.stage.as_mut().and_then(|s| s.spinner.take()) {
pb.finish_and_clear();
}
select::discard_typeahead();
let keys = self.caps.mode == Mode::Live
&& matches!(self.out, Out::Term(_))
&& std::io::stdin().is_terminal();
let outcome = if keys {
self.select_keys(q)
} else {
self.select_line(q, select::read_stdin_line)
};
self.record("answer", &format!("{outcome:?}"));
outcome
}
fn select_head(&self, q: &Select<'_>) -> Vec<String> {
let (g, on) = (self.caps.glyphs(), self.caps.color);
let gutter = self.active_gutter();
let mut lines = Vec::new();
if self.caps.mode != Mode::Plain {
lines.push(format!(
"{} {}",
paint::accent(g.active, on),
self.white(q.title)
));
}
lines.extend(self.under_gutter(&gutter, q.question, |part| {
paint::bold(&self.white(part), on)
}));
for l in q.lines {
lines.extend(self.under_gutter(&gutter, l, |part| self.dim(&style(part, on))));
}
lines
}
fn options_line(&self, q: &Select<'_>, state: KeyState) -> String {
let (g, on) = (self.caps.glyphs(), self.caps.color);
let pick = |label: &str, selected: bool| {
if selected {
paint::bold(&paint::highlight(&format!("{} {label}", g.on), on), on)
} else {
self.dim(&format!("{} {label}", g.off))
}
};
let lead = if state.yes {
format!("{} ", paint::accent(g.arrow, on))
} else {
" ".repeat(console::measure_text_width(g.arrow) + 1)
};
let keys = if self.caps.unicode {
"← → then Enter"
} else {
"<- -> then Enter"
};
format!(
"{}{lead}{} {} {}",
self.active_gutter(),
pick(q.yes, state.yes),
pick(q.no, !state.yes),
self.dim(keys)
)
}
fn select_keys(&mut self, q: &Select<'_>) -> SelectOutcome {
let Out::Term(term) = &self.out else {
unreachable!("key select runs on a terminal only")
};
let term = term.clone();
let head = self.select_head(q);
for line in &head {
self.emit(line);
}
let first = KeyState { yes: q.default_yes };
self.emit(&self.options_line(q, first));
let _ = term.hide_cursor();
let outcome = select::ask_keys(
q.default_yes,
|| term.read_key(),
|state| {
self.out.clear(1);
self.emit(&self.options_line(q, state));
},
);
let _ = term.show_cursor();
self.out.clear(head.len() + 1);
outcome
}
pub(crate) fn select_line(
&mut self,
q: &Select<'_>,
read: impl FnMut(Option<Duration>) -> LineRead,
) -> SelectOutcome {
for line in self.select_head(q) {
self.emit(&line);
}
let hint = if q.default_yes { "[Y/n]" } else { "[y/N]" };
let arrow = self.caps.glyphs().arrow;
let prompt = format!(
"{}{} / {} {hint} {arrow} ",
self.active_gutter(),
q.yes,
q.no
);
let prompt = super::fit_glyphs(&prompt, &self.caps);
self.out.prompt(&prompt);
let out = &self.out;
let outcome = select::ask_line(q.default_yes, q.timeout, read, || out.prompt(&prompt));
if !matches!(outcome, SelectOutcome::Answered(_)) {
self.emit("");
}
outcome
}
}
fn reason(message: &str) -> String {
let message = message.trim().trim_end_matches('.');
let mut chars = message.chars();
match (chars.next(), chars.next()) {
(Some(first), Some(second)) if first.is_uppercase() && second.is_lowercase() => first
.to_lowercase()
.chain(message.chars().skip(1))
.collect(),
_ => message.to_string(),
}
}
impl Drop for Rail {
fn drop(&mut self) {
if let Some(pb) = self.stage.as_ref().and_then(|s| s.spinner.as_ref()) {
pb.finish_and_clear();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::ui::card::{Row, Section};
fn rail(mode: Mode, verbose: bool) -> (Rail, Arc<Mutex<Vec<String>>>) {
Rail::buffer(Caps::new(mode, false, verbose, 80), 6)
}
fn screen(s: &Arc<Mutex<Vec<String>>>) -> Vec<String> {
lock(s).clone()
}
#[test]
fn test_live_rail_lines() {
let (mut r, s) = rail(Mode::Live, false);
r.intro("Setting up this machine");
r.stage("Checking this machine", "Checked this machine");
r.detail("Reclaimed daemon (PID 42)");
r.done("found Cline");
r.stage("Waiting for you to sign in", "Signed in");
r.note("Open https://app.openlatch.ai/device to sign in");
r.done("SuperAdmin");
r.outro("Done.");
assert_eq!(
screen(&s),
vec![
"┌ Setting up this machine",
"│",
"◇ Checked this machine · found Cline",
"│",
"◇ Signed in · SuperAdmin",
"│ Open https://app.openlatch.ai/device to sign in",
"│",
"└ Done.",
]
);
}
#[test]
fn test_verbose_shows_detail_dim_under_the_stage() {
let (mut r, s) = rail(Mode::Live, true);
r.stage("Checking this machine", "Checked this machine");
r.detail("Reclaimed daemon (PID 42)");
r.done("");
assert_eq!(
screen(&s),
vec![
"│",
"◇ Checked this machine",
"│ Reclaimed daemon (PID 42)"
]
);
}
#[test]
fn test_ascii_append_mode() {
let (mut r, s) = rail(Mode::Append, false);
r.intro("Setting up this machine");
r.stage("Checking this machine", "Checked this machine");
r.note("a note");
r.attention("no agent yet");
r.outro("Paused.");
let lines = screen(&s);
assert_eq!(
lines,
vec![
"+ Setting up this machine",
"|",
"> Checking this machine...",
"| a note",
"! Checked this machine - no agent yet",
"|",
"+ Paused.",
]
);
assert!(lines.iter().all(|l| l.is_ascii()));
}
#[test]
fn test_plain_mode_numbers_stages_and_hides_detail() {
let (mut r, s) = rail(Mode::Plain, false);
r.intro("Setting up this machine");
r.stage("Checking this machine", "Checked this machine");
r.detail("hidden without --verbose");
r.done("found Cline");
r.stage("Waiting for you to sign in", "Signed in");
r.note("Open the URL");
r.done("SuperAdmin");
r.result_line(Verdict::Live, 0, &[("org", "SuperAdmin")]);
assert_eq!(
screen(&s),
vec![
"Setting up this machine",
"[1/6] Checked this machine - found Cline",
"[2/6] Waiting for you to sign in...",
" Open the URL",
"[2/6] Signed in - SuperAdmin",
"result=live org=SuperAdmin exit=0",
]
);
}
#[test]
fn test_result_line_is_plain_mode_only() {
let (mut r, s) = rail(Mode::Live, false);
r.result_line(Verdict::Live, 0, &[]);
assert!(screen(&s).is_empty());
}
fn service_error() -> OlError {
OlError::new("OL-1502", "The service stopped right after it started.")
.with_suggestion("Run `openlatch init` again.")
.with_docs("https://docs.openlatch.ai/errors/OL-1502")
}
#[test]
fn test_fail_closes_the_stage_as_one_line() {
let (mut r, s) = rail(Mode::Live, false);
r.stage_named("Start OpenLatch", "Starting OpenLatch", "Started OpenLatch");
r.note("kept: a note is not the active block");
r.hint("dropped: a hint is");
r.fail(&service_error());
assert_eq!(
screen(&s),
vec![
"│",
"✗ Start OpenLatch failed · the service stopped right after it started",
"│ kept: a note is not the active block",
]
);
}
#[test]
fn test_fail_in_plain_mode_writes_the_error_block() {
let (mut r, s) = rail(Mode::Plain, false);
r.stage("Starting OpenLatch", "Started OpenLatch");
r.fail(&service_error());
assert_eq!(
screen(&s),
vec![
"[1/6] Starting OpenLatch - failed",
" The service stopped right after it started. (OL-1502)",
" Run openlatch init again.",
" Docs: https://docs.openlatch.ai/errors/OL-1502",
]
);
}
#[test]
fn test_live_rail_spans_match_the_playground() {
let (mut r, s) = Rail::buffer(Caps::new(Mode::Live, true, false, 80), 6);
r.intro("Setting up this machine");
r.stage("Checking this machine", "Checked this machine");
r.done("found Cline");
r.stage_named(
"Check this machine",
"Checking this machine",
"Checked this machine",
);
r.halt("no AI agent found");
r.stage("Running final checks", "Final checks");
r.attention("one thing left to do");
r.stage_named("Start OpenLatch", "Starting OpenLatch", "Started OpenLatch");
r.fail(&service_error());
r.outro("Done.");
let dim_bar = "\x1b[90m│\x1b[0m";
assert_eq!(
screen(&s),
vec![
"\x1b[90m┌\x1b[0m \x1b[97mSetting up this machine\x1b[0m".to_string(),
dim_bar.to_string(),
"\x1b[92m◇\x1b[0m \x1b[97mChecked this machine\x1b[0m\x1b[90m · found Cline\x1b[0m".to_string(),
dim_bar.to_string(),
"\x1b[1m\x1b[33m!\x1b[0m\x1b[22m \x1b[33mChecked this machine\x1b[0m\x1b[90m · no AI agent found\x1b[0m".to_string(),
dim_bar.to_string(),
"\x1b[33m!\x1b[0m \x1b[97mFinal checks\x1b[0m\x1b[90m · one thing left to do\x1b[0m".to_string(),
dim_bar.to_string(),
"\x1b[1m\x1b[91m✗\x1b[0m\x1b[22m \x1b[91mStart OpenLatch failed\x1b[0m\x1b[90m · the service stopped right after it started\x1b[0m".to_string(),
dim_bar.to_string(),
"\x1b[90m└\x1b[0m \x1b[97mDone.\x1b[0m".to_string(),
]
);
}
#[test]
fn test_hints_and_links_leave_with_their_stage() {
let url = format!("https://app.openlatch.ai/cli-auth?{}", "x".repeat(120));
let (mut r, s) = rail(Mode::Live, false);
r.stage("Waiting for you to sign in", "Signed in");
r.hint("A browser window opened. Sign in there to continue.");
r.link("Didn't open? ", &url);
r.done("Acme Corp");
assert_eq!(screen(&s), vec!["│", "◇ Signed in · Acme Corp"]);
let (mut r, s) = rail(Mode::Plain, false);
r.stage("Waiting for you to sign in", "Signed in");
r.hint("A browser window opened. Sign in there to continue.");
r.link("Didn't open? ", &url);
r.done("Acme Corp");
assert_eq!(
screen(&s),
vec![
"[1/6] Waiting for you to sign in...".to_string(),
" A browser window opened. Sign in there to continue.".to_string(),
format!(" Didn't open? {url}"),
"[1/6] Signed in - Acme Corp".to_string(),
]
);
}
#[test]
fn test_done_toned_paints_only_the_result() {
let (mut r, s) = Rail::buffer(Caps::new(Mode::Live, true, false, 80), 6);
r.stage("Checking for updates", "Checked for updates");
r.done_toned("0.5.7 → 0.5.8 · security fix", paint::Tone::Fail);
assert_eq!(
screen(&s)[1],
"\x1b[92m◇\x1b[0m \x1b[97mChecked for updates\x1b[0m\x1b[90m · \x1b[0m\x1b[91m0.5.7 → 0.5.8 · security fix\x1b[0m"
);
let (mut r, s) = rail(Mode::Append, false);
r.stage("Checking for updates", "Checked for updates");
r.done_toned("0.5.7 → 0.5.8", paint::Tone::Warn);
assert_eq!(screen(&s)[2], "+ Checked for updates - 0.5.7 -> 0.5.8");
let (mut r, s) = rail(Mode::Plain, false);
r.stage("Checking for updates", "Checked for updates");
r.done_toned("0.5.7 → 0.5.8", paint::Tone::Success);
assert_eq!(
screen(&s),
vec!["[1/6] Checked for updates - 0.5.7 -> 0.5.8"]
);
}
#[test]
fn test_live_meter_redraws_the_active_block() {
let (mut r, s) = Rail::buffer(Caps::new(Mode::Live, true, false, 80), 6);
r.stage("Downloading 0.5.8", "Downloaded");
r.meter(Some(0.5), "4.2 / 8.4 MB · 2.8 MB/s · 1s left");
let stage = r.stage.as_ref().expect("running");
assert_eq!(
r.block_message(stage),
format!(
"\x1b[97mDownloading 0.5.8 \x1b[0m\x1b[36m{}\x1b[0m\x1b[90m{}\x1b[0m\
\x1b[97m 50%\x1b[0m\x1b[90m 4.2 / 8.4 MB · 2.8 MB/s\x1b[0m",
"━".repeat(12),
"─".repeat(12)
)
);
r.meter(Some(7.0), "done");
let stage = r.stage.as_ref().expect("running");
assert!(
r.block_message(stage).contains(&"━".repeat(METER)),
"clamped"
);
assert!(r.block_message(stage).contains("100%"));
r.done("8.4 MB in 2.9s");
assert_eq!(
screen(&s),
vec![
"\x1b[90m│\x1b[0m",
"\x1b[92m◇\x1b[0m \x1b[97mDownloaded\x1b[0m\x1b[90m · 8.4 MB in 2.9s\x1b[0m"
]
);
}
#[test]
fn test_live_meter_fits_the_width() {
let tail = "4.2 / 8.4 MB · 2.8 MB/s · 1s left";
let line = |width: usize| {
let caps = Caps {
width,
..Caps::new(Mode::Live, false, false, 80)
};
let (mut r, _) = Rail::buffer(caps, 6);
r.stage("Downloading 0.5.8", "Downloaded");
r.meter(Some(0.5), tail);
let stage = r.stage.as_ref().expect("running");
r.block_message(stage)
};
let bar = |n: usize| format!("{}{}", "━".repeat(n / 2), "─".repeat(n / 2));
assert_eq!(
line(80),
format!(
"Downloading 0.5.8 {} 50% 4.2 / 8.4 MB · 2.8 MB/s",
bar(24)
)
);
assert_eq!(
line(60),
format!("Downloading 0.5.8 {} 50% 4.2 / 8.4 MB", bar(10))
);
assert_eq!(line(40), format!("Downloading 0.5.8 {} 50%", bar(10)));
assert_eq!(line(24), "Downloading 0.5.8…");
for width in [24, 40, 60, 80, 100] {
let first = line(width);
assert!(
3 + console::measure_text_width(&first) <= width,
"{width}: {first}"
);
}
let (mut r, _) = Rail::buffer(Caps::new(Mode::Live, false, false, 30), 6);
r.stage("Downloading 0.5.8", "Downloaded");
r.meter(Some(0.5), tail);
let stage = r.stage.as_ref().expect("running");
assert_eq!(r.block_message(stage), line(60));
}
#[test]
fn test_live_meter_without_a_fraction() {
let (mut r, _) = rail(Mode::Live, false);
r.stage("Restarting the background service", "Restarted");
r.meter(None, "draining in-flight events · 3s");
let stage = r.stage.as_ref().expect("running");
assert_eq!(
r.block_message(stage),
"Restarting the background service… · draining in-flight events · 3s"
);
r.meter(Some(f64::NAN), "");
let stage = r.stage.as_ref().expect("running");
assert_eq!(r.block_message(stage), "Restarting the background service…");
}
#[test]
fn test_live_meter_ascii() {
let caps = Caps {
unicode: false,
..Caps::new(Mode::Live, false, false, 80)
};
let (mut r, _) = Rail::buffer(caps, 6);
r.stage("Downloading", "Downloaded");
r.meter(Some(0.25), "2.1 / 8.4 MB · 2.8 MB/s");
let stage = r.stage.as_ref().expect("running");
assert_eq!(
r.block_message(stage),
format!(
"Downloading {}{} 25% 2.1 / 8.4 MB - 2.8 MB/s",
"#".repeat(6),
"-".repeat(18)
)
);
}
#[test]
fn test_append_meter_writes_each_milestone_once() {
let (mut r, s) = rail(Mode::Append, false);
r.stage("Downloading 0.5.8", "Downloaded");
for (frac, tail) in [
(Some(0.1), "0.8 / 8.4 MB"),
(Some(0.3), "2.5 / 8.4 MB · 2.8 MB/s"),
(Some(0.4), "3.4 / 8.4 MB"),
(None, "unknown"),
(Some(0.9), "7.6 / 8.4 MB"),
(Some(0.8), "6.7 / 8.4 MB"),
(Some(1.0), "8.4 / 8.4 MB"),
(Some(1.0), "8.4 / 8.4 MB"),
] {
r.meter(frac, tail);
}
r.done("8.4 MB in 2.9s");
assert_eq!(
screen(&s),
vec![
"|",
"> Downloading 0.5.8...",
"| 25% 2.5 / 8.4 MB - 2.8 MB/s",
"| 50%",
"| 75% 7.6 / 8.4 MB",
"| 100% 8.4 / 8.4 MB",
"+ Downloaded - 8.4 MB in 2.9s",
]
);
}
#[test]
fn test_plain_meter_writes_one_heading() {
let (mut r, s) = rail(Mode::Plain, false);
r.stage("Downloading 8.4 MB", "Downloaded");
r.meter(None, "starting");
assert!(screen(&s).is_empty());
for frac in [0.1, 0.5, 1.0] {
r.meter(Some(frac), "x / 8.4 MB");
}
r.done("8.4 MB in 2.9s");
r.stage(
"Restarting the background service",
"Restarted the background service",
);
r.announce();
r.announce();
r.done("back in 5.4s");
assert_eq!(
screen(&s),
vec![
"[1/6] Downloading 8.4 MB...",
"[1/6] Downloaded - 8.4 MB in 2.9s",
"[2/6] Restarting the background service...",
"[2/6] Restarted the background service - back in 5.4s",
]
);
}
#[test]
fn test_append_meter_jump_writes_every_mark() {
let (mut r, s) = rail(Mode::Append, false);
r.stage("Downloading 0.5.8", "Downloaded");
r.meter(Some(1.0), "8.4 / 8.4 MB");
assert_eq!(
screen(&s)[2..],
["| 25%", "| 50%", "| 75%", "| 100% 8.4 / 8.4 MB"]
);
}
#[test]
fn test_meter_without_a_stage_is_a_no_op() {
for mode in [Mode::Live, Mode::Append, Mode::Plain] {
let (mut r, s) = rail(mode, false);
r.meter(Some(0.5), "x");
r.announce();
assert!(screen(&s).is_empty());
}
}
#[test]
fn test_result_line_token_is_plain_mode_only() {
let (mut r, s) = rail(Mode::Plain, false);
r.result_line_token("up_to_date", 0, &[("version", "0.5.8")]);
assert_eq!(screen(&s), vec!["result=up_to_date version=0.5.8 exit=0"]);
let (mut r, s) = rail(Mode::Live, false);
r.result_line_token("up_to_date", 0, &[]);
assert!(screen(&s).is_empty());
}
#[test]
fn test_reason_lowers_a_sentence_and_keeps_a_name() {
assert_eq!(reason("The service stopped."), "the service stopped");
assert_eq!(
reason("OPENLATCH_PORT=7443 is already in use"),
"OPENLATCH_PORT=7443 is already in use"
);
assert_eq!(reason("cannot reach http://x"), "cannot reach http://x");
}
#[test]
fn test_fail_without_a_stage_stands_alone() {
let (mut r, s) = rail(Mode::Plain, false);
r.fail(&OlError::new("OL-1400", "No supported agent"));
assert_eq!(screen(&s), vec!["x No supported agent (OL-1400)"]);
}
#[test]
fn test_notes_wrap_to_the_rail_width() {
let (mut r, s) = rail(Mode::Live, false);
r.note(&"word ".repeat(40));
let lines = screen(&s);
assert!(lines.len() > 1);
assert!(lines.iter().all(|l| console::measure_text_width(l) <= 80));
assert!(lines.iter().all(|l| l.starts_with("│ ")));
}
#[test]
fn test_card_after_rail_fits_80_columns() {
let (mut r, s) = rail(Mode::Live, false);
r.outro("Done.");
r.card(&Card {
verdict: Verdict::NoAgent,
title: "No AI agent to connect on this machine yet".into(),
rows: vec![Row::new("Looked for", "Claude Code · Codex CLI · Cline")],
sections: vec![Section::new(
"To finish",
vec!["Install one of them, then run `openlatch init` again.".into()],
)],
footer: vec![],
});
let lines = screen(&s);
assert_eq!(lines[2], "");
assert!(lines.iter().all(|l| console::measure_text_width(l) <= 80));
assert!(lines.iter().any(|l| l.contains("! No AI agent")));
}
fn q(timeout: Option<Duration>) -> Select<'static> {
Select {
title: "Usage data",
question: "Share anonymous usage data to help improve OpenLatch?",
lines: &["Never prompts, code, or secrets."],
yes: "Yes, share",
no: "No thanks",
default_yes: true,
timeout,
}
}
#[test]
fn test_select_line_path() {
let (mut r, s) = rail(Mode::Append, false);
let outcome = r.select_line(&q(None), |_| LineRead::Line("n\n".into()));
assert_eq!(outcome, SelectOutcome::Answered(false));
assert_eq!(
screen(&s),
vec![
"> Usage data",
"| Share anonymous usage data to help improve OpenLatch?",
"| Never prompts, code, or secrets.",
"| Yes, share / No thanks [Y/n] > ",
]
);
}
#[test]
fn test_select_line_reports_eof_and_ends_the_prompt_line() {
let (mut r, s) = rail(Mode::Plain, false);
let outcome = r.select_line(&q(None), |_| LineRead::Eof);
assert_eq!(outcome, SelectOutcome::Eof);
assert_eq!(screen(&s).last().map(String::as_str), Some(""));
}
#[test]
fn test_options_line_without_colour() {
let (r, _) = rail(Mode::Live, false);
assert_eq!(
r.options_line(&q(None), KeyState { yes: true }),
"│ › ● Yes, share ○ No thanks ← → then Enter"
);
assert_eq!(
r.options_line(&q(None), KeyState { yes: false }),
"│ ○ Yes, share ● No thanks ← → then Enter"
);
}
#[test]
fn test_options_line_ascii() {
let caps = Caps {
unicode: false,
..Caps::new(Mode::Live, false, false, 80)
};
let (r, _) = Rail::buffer(caps, 6);
assert_eq!(
r.options_line(&q(None), KeyState { yes: true }),
"| > (*) Yes, share ( ) No thanks <- -> then Enter"
);
assert_eq!(
r.options_line(&q(None), KeyState { yes: false }),
"| ( ) Yes, share (*) No thanks <- -> then Enter"
);
}
#[test]
fn test_select_block_spans_match_the_playground() {
let (r, _) = Rail::buffer(Caps::new(Mode::Live, true, false, 80), 6);
let bar = "\x1b[36m│\x1b[0m ";
assert_eq!(
r.select_head(&q(None)),
vec![
"\x1b[36m◆\x1b[0m \x1b[97mUsage data\x1b[0m".to_string(),
format!("{bar}\x1b[1m\x1b[97mShare anonymous usage data to help improve OpenLatch?\x1b[0m\x1b[22m"),
format!("{bar}\x1b[90mNever prompts, code, or secrets.\x1b[0m"),
]
);
assert_eq!(
r.options_line(&q(None), KeyState { yes: true }),
format!(
"{bar}\x1b[36m›\x1b[0m \x1b[1m\x1b[96m● Yes, share\x1b[0m\x1b[22m \
\x1b[90m○ No thanks\x1b[0m \x1b[90m← → then Enter\x1b[0m"
)
);
assert_eq!(
r.options_line(&q(None), KeyState { yes: false }),
format!(
"{bar} \x1b[90m○ Yes, share\x1b[0m \x1b[1m\x1b[96m● No thanks\x1b[0m\x1b[22m \
\x1b[90m← → then Enter\x1b[0m"
)
);
}
}