pub mod card;
pub mod log;
pub mod paint;
pub mod rail;
pub mod select;
pub mod term;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
pub use card::{result_line, result_line_token, Card, Row, Section, Update, Verdict};
pub use paint::Tone;
pub use rail::{Out, Rail};
pub use select::{Select, SelectOutcome};
pub use term::{Caps, Mode};
use crate::cli::output::OutputConfig;
use crate::error::OlError;
static RAIL: Mutex<Option<Rail>> = Mutex::new(None);
static ACTIVE: AtomicBool = AtomicBool::new(false);
fn slot() -> std::sync::MutexGuard<'static, Option<Rail>> {
RAIL.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[must_use = "the rail is uninstalled as soon as the guard is dropped"]
pub struct RailGuard {
_private: (),
}
impl Drop for RailGuard {
fn drop(&mut self) {
let rail = {
let mut slot = slot();
ACTIVE.store(false, Ordering::SeqCst);
slot.take()
};
drop(rail);
}
}
pub fn install(output: &OutputConfig, command: &str, total: usize) -> Option<RailGuard> {
let caps = Caps::detect(output)?;
let log = log::Transcript::open(command);
install_rail(Rail::new(
caps,
Out::Term(console::Term::stderr()),
log,
total,
))
}
pub fn install_rail(rail: Rail) -> Option<RailGuard> {
let mut slot = slot();
if slot.is_some() {
return None;
}
*slot = Some(rail);
ACTIVE.store(true, Ordering::SeqCst);
Some(RailGuard { _private: () })
}
pub fn is_active() -> bool {
ACTIVE.load(Ordering::SeqCst)
}
fn with<R>(f: impl FnOnce(&mut Rail) -> R) -> Option<R> {
if !is_active() {
return None;
}
slot().as_mut().map(f)
}
pub fn intro(title: &str) {
with(|r| r.intro(title));
}
pub fn stage(running: &str, done: &str) {
with(|r| r.stage(running, done));
}
pub fn progress(text: &str) {
with(|r| r.progress(text));
}
pub fn done(result: &str) {
with(|r| r.done(result));
}
pub fn done_toned(result: &str, tone: Tone) {
with(|r| r.done_toned(result, tone));
}
pub fn meter(frac: Option<f64>, tail: &str) {
with(|r| r.meter(frac, tail));
}
pub fn announce() {
with(|r| r.announce());
}
pub fn attention(result: &str) {
with(|r| r.attention(result));
}
pub fn note(text: &str) {
with(|r| r.note(text));
}
pub fn stage_named(name: &str, running: &str, done: &str) {
with(|r| r.stage_named(name, running, done));
}
pub fn hint(text: &str) {
with(|r| r.hint(text));
}
pub fn link(lead: &str, url: &str) {
with(|r| r.link(lead, url));
}
pub fn halt(result: &str) {
with(|r| r.halt(result));
}
pub fn failed(result: &str) {
with(|r| r.failed(result));
}
pub fn relabel(done: &str) {
with(|r| r.relabel(done));
}
pub fn gutter() -> Option<String> {
with(|r| r.gutter())
}
pub fn detail(text: &str) {
with(|r| r.detail(text));
}
pub fn fail(error: &OlError) -> bool {
let shown = with(|r| r.fail(error)).is_some();
if shown {
mark_error_reported(error);
}
shown
}
pub fn outro(text: &str) {
with(|r| r.outro(text));
}
pub fn card(card: &Card) {
with(|r| r.card(card));
}
pub fn card_with_warnings(card: &Card, warnings: &[String]) {
with(|r| r.card_with_warnings(card, warnings));
}
pub fn result(verdict: Verdict, exit_code: i32, fields: &[(&str, &str)]) {
with(|r| r.result_line(verdict, exit_code, fields));
}
pub fn result_token(token: &str, exit_code: i32, fields: &[(&str, &str)]) {
with(|r| r.result_line_token(token, exit_code, fields));
}
pub fn select(question: &Select<'_>) -> Option<SelectOutcome> {
with(|r| r.select(question))
}
pub fn suspend<R>(f: impl FnOnce() -> R) -> R {
if !is_active() {
return f();
}
let slot = slot();
match slot.as_ref() {
Some(rail) => rail.suspend(f),
None => f(),
}
}
pub fn caps() -> Option<Caps> {
with(|r| *r.caps())
}
pub fn log_path() -> Option<std::path::PathBuf> {
with(|r| r.log_path().map(std::path::Path::to_path_buf)).flatten()
}
pub fn take_back_log() {
with(|r| r.take_back_log(&std::env::temp_dir()));
}
#[cfg(test)]
pub(crate) static INSTALL_LOCK: Mutex<()> = Mutex::new(());
static REPORTED: Mutex<Option<(&'static str, String)>> = Mutex::new(None);
pub fn mark_error_reported(error: &OlError) {
*REPORTED
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) =
Some((error.code, error.message.clone()));
}
pub fn error_reported(error: &OlError) -> bool {
REPORTED
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.is_some_and(|(code, message)| *code == error.code && *message == error.message)
}
pub fn style(text: &str, color_on: bool) -> String {
style_over(text, color_on, |t, _| t.to_string())
}
pub fn style_over(text: &str, color_on: bool, base: fn(&str, bool) -> String) -> String {
let mut out = String::with_capacity(text.len());
for (i, part) in text.split('`').enumerate() {
if i % 2 == 1 && !part.is_empty() {
if part.starts_with("http://") || part.starts_with("https://") {
out.push_str(&paint::link(part, color_on));
} else {
out.push_str(&paint::accent(part, color_on));
}
} else {
out.push_str(&base(part, color_on));
}
}
out
}
pub fn fit_glyphs(text: &str, caps: &Caps) -> String {
if caps.unicode {
return text.to_string();
}
text.replace('·', "-")
.replace('…', "...")
.replace('—', "-")
.replace('›', ">")
.replace('→', "->")
.replace('━', "#")
.replace('─', "-")
}
fn visible_width(text: &str) -> usize {
console::measure_text_width(&text.replace('`', ""))
}
pub fn wrap(text: &str, width: usize) -> Vec<String> {
let width = width.max(1);
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
for piece in split_long(word, width) {
let need = if line.is_empty() {
visible_width(&piece)
} else {
visible_width(&line) + 1 + visible_width(&piece)
};
if need > width && !line.is_empty() {
lines.push(std::mem::take(&mut line));
}
if !line.is_empty() {
line.push(' ');
}
line.push_str(&piece);
}
}
if !line.is_empty() || lines.is_empty() {
lines.push(line);
}
let mut open = false;
for line in &mut lines {
if open {
line.insert(0, '`');
}
if line.matches('`').count() % 2 == 1 {
line.push('`');
open = true;
} else {
open = false;
}
}
lines
}
fn split_long(word: &str, width: usize) -> Vec<String> {
if visible_width(word) <= width {
return vec![word.to_string()];
}
let mut out = Vec::new();
let mut chunk = String::new();
for ch in word.chars() {
if ch != '`' && visible_width(&chunk) >= width {
out.push(std::mem::take(&mut chunk));
}
chunk.push(ch);
}
if !chunk.is_empty() {
out.push(chunk);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_style_marks_commands() {
assert_eq!(
style("run `openlatch init` now", false),
"run openlatch init now"
);
assert_eq!(
style("run `openlatch init`", true),
"run \x1b[36mopenlatch init\x1b[0m"
);
assert_eq!(
style("see `https://app.openlatch.ai`", true),
"see \x1b[4;36mhttps://app.openlatch.ai\x1b[0m"
);
assert_eq!(
style_over("Check `openlatch status`", true, paint::dim),
"\x1b[90mCheck \x1b[0m\x1b[36mopenlatch status\x1b[0m"
);
assert_eq!(style("no marks", true), "no marks");
}
#[test]
fn test_fit_glyphs() {
let ascii = Caps::new(Mode::Append, false, false, 80);
assert_eq!(
fit_glyphs("━━━─── 50% · 0.5.7 → 0.5.8…", &ascii),
"###--- 50% - 0.5.7 -> 0.5.8..."
);
let unicode = Caps::new(Mode::Live, false, false, 80);
assert_eq!(fit_glyphs("━━━───", &unicode), "━━━───");
}
#[test]
fn test_wrap() {
assert_eq!(wrap("", 10), vec![""]);
assert_eq!(wrap("one two three", 7), vec!["one two", "three"]);
assert_eq!(wrap("abcdefghij", 4), vec!["abcd", "efgh", "ij"]);
assert_eq!(
wrap("run `openlatch system telemetry enable` later", 20),
vec!["run `openlatch system`", "`telemetry enable`", "later"]
);
for line in wrap(&"lorem ipsum ".repeat(30), 50) {
assert!(visible_width(&line) <= 50, "{line}");
}
}
#[test]
fn test_error_fingerprint() {
let e = OlError::new("OL-9901", "fingerprint test");
assert!(!error_reported(&e));
mark_error_reported(&e);
assert!(error_reported(&e));
assert!(!error_reported(&OlError::new("OL-9901", "another message")));
assert!(!error_reported(&OlError::new(
"OL-9902",
"fingerprint test"
)));
}
#[test]
fn test_guard_installs_and_uninstalls() {
let _l = INSTALL_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (rail, screen) = Rail::buffer(Caps::new(Mode::Plain, false, false, 80), 2);
{
let _guard = install_rail(rail).expect("installs");
assert!(is_active());
let (second, _) = Rail::buffer(Caps::new(Mode::Plain, false, false, 80), 1);
assert!(install_rail(second).is_none(), "one rail at a time");
intro("Setting up");
stage("Checking", "Checked");
done("ok");
assert!(fail(&OlError::new("OL-9903", "boom")));
assert!(error_reported(&OlError::new("OL-9903", "boom")));
}
assert!(!is_active());
assert!(caps().is_none());
note("not drawn");
assert!(!fail(&OlError::new("OL-9904", "not drawn")));
let lines = screen.lock().unwrap().clone();
assert_eq!(
lines,
vec!["Setting up", "[1/2] Checked - ok", "x boom (OL-9903)"]
);
}
}