use super::term::{Caps, Mode};
use super::{fit_glyphs, paint, style, style_over, wrap};
const INNER: usize = 70;
const INDENT: usize = 4;
const LABEL: usize = 15;
const VALUE: usize = INNER - INDENT - LABEL - 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Live,
NeedsAttention,
NoAgent,
NotLive,
}
impl Verdict {
pub fn token(self) -> &'static str {
match self {
Self::Live => "live",
Self::NeedsAttention | Self::NoAgent => "attention",
Self::NotLive => "not_live",
}
}
fn glyph(self, caps: &Caps) -> &'static str {
let g = caps.glyphs();
match self {
Self::Live => g.ok,
Self::NeedsAttention | Self::NoAgent => g.warn,
Self::NotLive => g.fail,
}
}
fn paint(self, text: &str, on: bool) -> String {
match self {
Self::Live => paint::green(text, on),
Self::NeedsAttention | Self::NoAgent => paint::amber(text, on),
Self::NotLive => paint::red(text, on),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Update {
Updated,
UpToDate,
Available,
Declined,
CheckFailed,
Failed,
RolledBack,
}
impl Update {
pub fn token(self) -> &'static str {
match self {
Self::Updated => "updated",
Self::UpToDate => "up_to_date",
Self::Available => "available",
Self::Declined => "declined",
Self::CheckFailed => "check_failed",
Self::Failed => "failed",
Self::RolledBack => "rolled_back",
}
}
pub fn verdict(self) -> Verdict {
match self {
Self::Updated | Self::UpToDate => Verdict::Live,
Self::Available | Self::Declined | Self::CheckFailed => Verdict::NeedsAttention,
Self::Failed | Self::RolledBack => Verdict::NotLive,
}
}
pub fn exit_code(self) -> i32 {
match self.verdict() {
Verdict::Live => 0,
Verdict::NeedsAttention | Verdict::NoAgent => crate::cli::report::EXIT_DEGRADED,
Verdict::NotLive => 1,
}
}
pub fn title(self, from: &str, to: &str) -> String {
match self {
Self::Updated => format!("OpenLatch {to} is running"),
Self::UpToDate => format!("OpenLatch {to} is up to date"),
Self::Available => format!("{to} is available"),
Self::Declined => format!("Not updated · still on {from}"),
Self::CheckFailed => "Couldn't check for updates".to_string(),
Self::Failed => "Update stopped".to_string(),
Self::RolledBack => format!("{to} didn't start, so {from} is back"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
pub label: String,
pub value: String,
pub dim: bool,
pub tail: String,
}
impl Row {
pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
Self {
label: label.into(),
value: value.into(),
dim: false,
tail: String::new(),
}
}
pub fn more(value: impl Into<String>) -> Self {
Self {
label: String::new(),
value: value.into(),
dim: true,
tail: String::new(),
}
}
pub fn with_tail(mut self, tail: impl Into<String>) -> Self {
self.tail = tail.into();
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Section {
pub label: String,
pub lines: Vec<String>,
}
impl Section {
pub fn new(label: impl Into<String>, lines: Vec<String>) -> Self {
Self {
label: label.into(),
lines,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Card {
pub verdict: Verdict,
pub title: String,
pub rows: Vec<Row>,
pub sections: Vec<Section>,
pub footer: Vec<String>,
}
#[derive(Clone, Copy)]
enum Ink {
Fact,
Dim,
Plain,
}
enum Line {
Blank,
Title,
Field(String, String, Ink, bool, String),
}
pub fn render(card: &Card, caps: &Caps) -> Vec<String> {
render_with_warnings(card, &[], caps)
}
pub fn render_with_warnings(card: &Card, warnings: &[String], caps: &Caps) -> Vec<String> {
let on = caps.color;
let mut body = vec![Line::Blank, Line::Title, Line::Blank];
let fit = |text: &str| fit_glyphs(text, caps);
for row in &card.rows {
let ink = if row.dim { Ink::Dim } else { Ink::Fact };
push_field(
&mut body,
&row.label,
&fit(&row.value),
ink,
false,
&fit(&row.tail),
);
}
for section in &card.sections {
body.push(Line::Blank);
for (i, line) in section.lines.iter().enumerate() {
let label = if i == 0 { section.label.as_str() } else { "" };
push_field(&mut body, label, &fit(line), Ink::Plain, true, "");
}
}
body.push(Line::Blank);
let draw = |line: &Line| -> String {
match line {
Line::Blank => String::new(),
Line::Title => format!(
" {} {}",
paint::bold(&card.verdict.paint(card.verdict.glyph(caps), on), on),
paint::bold(&paint::white(&card.title, on), on)
),
Line::Field(label, value, ink, strong, tail) => {
let label = format!("{label:<LABEL$}");
let label = if *strong {
paint::bold(&paint::white(&label, on), on)
} else {
paint::dim(&label, on)
};
let value = fit_glyphs(value, caps);
let value = match ink {
Ink::Fact => style_over(&value, on, paint::white),
Ink::Dim => style_over(&value, on, paint::dim),
Ink::Plain => style(&value, on),
};
let tail = if tail.is_empty() {
String::new()
} else {
paint::dim(&format!(" {}", fit_glyphs(tail, caps)), on)
};
format!("{}{label}{value}{tail}", " ".repeat(INDENT))
}
}
};
let mut out = Vec::new();
if caps.mode == Mode::Plain {
let inner = &body[1..body.len() - 1];
out.extend(
inner
.iter()
.map(|l| format!(" {}", draw(l)).trim_end().to_string()),
);
} else {
let g = caps.glyphs();
let edge = |l: &str, r: &str| {
let rule = format!("{l}{}{r}", g.horizontal.repeat(INNER));
format!(" {}", card.verdict.paint(&rule, on))
};
let side = card.verdict.paint(g.vertical, on);
out.push(edge(g.top_left, g.top_right));
for line in &body {
let text = draw(line);
let pad = INNER.saturating_sub(console::measure_text_width(&text));
out.push(format!(" {side}{text}{}{side}", " ".repeat(pad)));
}
out.push(edge(g.bottom_left, g.bottom_right));
}
if !card.footer.is_empty() || !warnings.is_empty() {
out.push(String::new());
}
out.extend(warnings.iter().map(|w| warn_line(w, caps)));
out.extend(card.footer.iter().map(|f| {
format!(" {}", style_over(&fit_glyphs(f, caps), on, paint::dim))
.trim_end()
.to_string()
}));
out
}
fn warn_line(text: &str, caps: &Caps) -> String {
let on = caps.color;
let (head, rest) = match text.split_once(" · ") {
Some((head, rest)) => (head, format!(" · {rest}")),
None => (text, String::new()),
};
format!(
" {} {}{}",
paint::bold(&paint::amber(caps.glyphs().warn, on), on),
style_over(&fit_glyphs(head, caps), on, paint::amber),
style_over(&fit_glyphs(&rest, caps), on, paint::dim)
)
.trim_end()
.to_string()
}
fn push_field(body: &mut Vec<Line>, label: &str, value: &str, ink: Ink, strong: bool, tail: &str) {
let parts = wrap(value, VALUE);
let last = parts.len() - 1;
let fits = tail.is_empty()
|| console::measure_text_width(&parts[last].replace('`', "")) + 1 + tail.chars().count()
<= VALUE;
for (i, part) in parts.into_iter().enumerate() {
let label = if i == 0 { label } else { "" };
let with_tail = if i == last && fits { tail } else { "" };
body.push(Line::Field(
label.to_string(),
part,
ink,
strong,
with_tail.to_string(),
));
}
if !fits {
body.push(Line::Field(
String::new(),
String::new(),
Ink::Dim,
false,
tail.to_string(),
));
}
}
pub fn result_line(verdict: Verdict, exit_code: i32, fields: &[(&str, &str)]) -> String {
let mut all = Vec::with_capacity(fields.len() + 1);
if verdict == Verdict::NoAgent {
all.push(("reason", "no-agent"));
}
all.extend_from_slice(fields);
result_line_token(verdict.token(), exit_code, &all)
}
pub fn result_line_token(token: &str, exit_code: i32, fields: &[(&str, &str)]) -> String {
let mut line = format!("result={token}");
for (key, value) in fields {
line.push(' ');
line.push_str(key);
line.push('=');
if value.is_empty() || value.contains(|c: char| c.is_whitespace() || c == '"') {
line.push('"');
line.push_str(&value.replace('\\', "\\\\").replace('"', "\\\""));
line.push('"');
} else {
line.push_str(value);
}
}
line.push_str(&format!(" exit={exit_code}"));
line
}
#[cfg(test)]
mod tests {
use super::*;
fn live_card() -> Card {
Card {
verdict: Verdict::Live,
title: "OpenLatch is live on this machine".into(),
rows: vec![
Row::new("Organization", "SuperAdmin"),
Row::new("Agent", "Cline"),
Row::more("actions controlled · model calls not observed"),
Row::new("Policies", "in sync"),
],
sections: vec![Section::new(
"Next",
vec![
"Keep using Cline as usual.".into(),
"See what they do at `https://app.openlatch.ai`".into(),
],
)],
footer: vec![
"Check anytime `openlatch status` · Full diagnostics `openlatch doctor`".into(),
],
}
}
fn caps(mode: Mode, color: bool) -> Caps {
Caps::new(mode, color, false, 80)
}
#[test]
fn test_boxed_card_fits_80_columns_and_is_square() {
for color in [false, true] {
let lines = render(&live_card(), &caps(Mode::Live, color));
let widths: Vec<usize> = lines
.iter()
.map(|l| console::measure_text_width(l))
.collect();
assert!(widths.iter().all(|w| *w <= 80), "{lines:#?}");
let boxed = &widths[..widths.len() - 2];
assert!(boxed.iter().all(|w| *w == boxed[0]), "{widths:?}");
}
}
#[test]
fn test_boxed_card_content() {
let lines = render(&live_card(), &caps(Mode::Live, false));
assert!(lines[0].starts_with(" ╭─"));
assert!(lines[2].starts_with(" │ ✓ OpenLatch is live on this machine"));
assert!(lines
.iter()
.any(|l| l.contains(" Organization SuperAdmin")));
assert!(lines
.iter()
.any(|l| l.contains(" actions controlled")));
assert!(lines
.iter()
.any(|l| l.contains("Next Keep using")));
assert!(
lines.iter().all(|l| !l.contains('`')),
"backticks are markup, never printed"
);
assert!(lines.last().unwrap().contains("openlatch doctor"));
}
#[test]
fn test_colour_marks_commands_cyan_and_border_in_verdict_colour() {
let lines = render(&live_card(), &caps(Mode::Live, true));
assert!(lines[0].contains("\x1b[92m"), "green border for Live");
assert!(lines
.iter()
.any(|l| l.contains("\x1b[4;36mhttps://app.openlatch.ai")));
let red = Card {
verdict: Verdict::NotLive,
..live_card()
};
let lines = render(&red, &caps(Mode::Live, true));
assert!(lines[0].contains("\x1b[91m"));
assert!(lines[2].contains('✗'));
}
#[test]
fn test_card_spans_match_the_playground() {
let mut card = live_card();
card.rows[3] =
Row::new("Policies", "first sync in progress").with_tail("· applies on arrival");
let lines = render(&card, &caps(Mode::Live, true));
let g = "\x1b[92m";
let side = format!("{g}│\x1b[0m");
let find = |needle: &str| {
lines
.iter()
.find(|l| l.contains(needle))
.unwrap_or_else(|| panic!("{needle:?} in {lines:#?}"))
.clone()
};
assert!(find("OpenLatch is live").starts_with(&format!(
" {side} \x1b[1m{g}✓\x1b[0m\x1b[22m \x1b[1m\x1b[97mOpenLatch is live on this machine\x1b[0m\x1b[22m"
)));
assert!(find("SuperAdmin").starts_with(&format!(
" {side} \x1b[90mOrganization \x1b[0m\x1b[97mSuperAdmin\x1b[0m"
)));
assert!(find("actions controlled")
.contains("\x1b[90mactions controlled · model calls not observed\x1b[0m"));
assert!(find("first sync")
.contains("\x1b[97mfirst sync in progress\x1b[0m\x1b[90m · applies on arrival\x1b[0m"));
assert!(find("Keep using")
.contains("\x1b[1m\x1b[97mNext \x1b[0m\x1b[22mKeep using Cline as usual."));
assert!(find("See what")
.contains("See what they do at \x1b[4;36mhttps://app.openlatch.ai\x1b[0m"));
let n = lines.len();
assert_eq!(
lines[n - 2],
"",
"a blank line between the card and its footer"
);
assert_eq!(
lines[n - 1],
" \x1b[90mCheck anytime \x1b[0m\x1b[36mopenlatch status\x1b[0m\x1b[90m · Full diagnostics \x1b[0m\x1b[36mopenlatch doctor\x1b[0m"
);
}
#[test]
fn test_ascii_box_on_append_mode() {
let lines = render(&live_card(), &caps(Mode::Append, true));
assert!(lines.iter().all(|l| l.is_ascii()), "{lines:#?}");
assert!(lines.iter().all(|l| !l.contains('\x1b')));
assert!(lines[0].starts_with(" +---"));
assert!(lines[2].starts_with(" | + OpenLatch is live"));
}
#[test]
fn test_plain_card_has_no_border_and_no_trailing_space() {
let lines = render(&live_card(), &caps(Mode::Plain, true));
assert_eq!(lines[0], " + OpenLatch is live on this machine");
assert!(lines.iter().all(|l| l.is_ascii()), "{lines:#?}");
assert!(lines.iter().all(|l| !l.ends_with(' ')));
assert!(lines.iter().all(|l| !l.contains('|')));
assert!(lines.iter().any(|l| l == " Organization SuperAdmin"));
}
#[test]
fn test_ascii_glyphs_widen_before_wrapping() {
let mut card = live_card();
let value = format!("{} →→→…", "x".repeat(VALUE - 5));
card.rows = vec![Row::new("Updated", value)];
let lines = render(&card, &caps(Mode::Append, false));
let widths: Vec<usize> = lines
.iter()
.map(|l| console::measure_text_width(l))
.collect();
let boxed = &widths[..widths.len() - 2];
assert!(boxed.iter().all(|w| *w == boxed[0]), "{lines:#?}");
assert!(lines.iter().any(|l| l.contains("->->->...")), "{lines:#?}");
assert!(lines.iter().all(|l| l.is_ascii()));
}
#[test]
fn test_long_values_wrap_inside_the_card() {
let mut card = live_card();
card.sections = vec![Section::new(
"Try this",
vec!["Run `openlatch init` again. If it fails twice, check that antivirus allows openlatch.exe to start and listen on loopback.".into()],
)];
let lines = render(&card, &caps(Mode::Live, false));
assert!(lines.iter().all(|l| console::measure_text_width(l) <= 80));
let body: Vec<_> = lines.iter().filter(|l| l.contains("antivirus")).collect();
assert_eq!(body.len(), 1, "{lines:#?}");
assert!(lines
.iter()
.any(|l| l.contains("Try this Run openlatch init again.")));
}
#[test]
fn test_update_tokens() {
let cases = [
(
Update::Updated,
"updated",
Verdict::Live,
0,
"OpenLatch 0.5.8 is running",
"✓",
),
(
Update::UpToDate,
"up_to_date",
Verdict::Live,
0,
"OpenLatch 0.5.8 is up to date",
"✓",
),
(
Update::Available,
"available",
Verdict::NeedsAttention,
7,
"0.5.8 is available",
"!",
),
(
Update::Declined,
"declined",
Verdict::NeedsAttention,
7,
"Not updated · still on 0.5.7",
"!",
),
(
Update::CheckFailed,
"check_failed",
Verdict::NeedsAttention,
7,
"Couldn't check for updates",
"!",
),
(
Update::Failed,
"failed",
Verdict::NotLive,
1,
"Update stopped",
"✗",
),
(
Update::RolledBack,
"rolled_back",
Verdict::NotLive,
1,
"0.5.8 didn't start, so 0.5.7 is back",
"✗",
),
];
for (update, token, verdict, exit, title, glyph) in cases {
assert_eq!(update.token(), token);
assert_eq!(update.verdict(), verdict);
assert_eq!(update.exit_code(), exit);
assert_eq!(update.title("0.5.7", "0.5.8"), title);
let card = Card {
verdict: update.verdict(),
title: update.title("0.5.7", "0.5.8"),
rows: vec![],
sections: vec![],
footer: vec![],
};
let lines = render(&card, &caps(Mode::Live, false));
assert!(
lines[2].starts_with(&format!(" │ {glyph} {title}")),
"{lines:#?}"
);
assert_eq!(
result_line_token(update.token(), update.exit_code(), &[("to", "0.5.8")]),
format!("result={token} to=0.5.8 exit={exit}")
);
}
}
#[test]
fn test_warning_footer_spans() {
let warnings = vec![
"Automatic updates: off · turn on: `openlatch config set update.auto_update true`"
.to_string(),
];
let lines = render_with_warnings(&live_card(), &warnings, &caps(Mode::Live, true));
let n = lines.len();
assert_eq!(lines[n - 3], "");
assert_eq!(
lines[n - 2],
" \x1b[1m\x1b[33m!\x1b[0m\x1b[22m \x1b[33mAutomatic updates: off\x1b[0m\
\x1b[90m · turn on: \x1b[0m\x1b[36mopenlatch config set update.auto_update true\x1b[0m"
);
assert!(
lines[n - 1].contains("openlatch doctor"),
"the dim footer follows"
);
let hint =
vec!["The background service isn't running · start it: `openlatch start`".to_string()];
let card = Card {
footer: vec![],
..live_card()
};
let lines = render_with_warnings(&card, &hint, &caps(Mode::Plain, false));
let n = lines.len();
assert_eq!(lines[n - 2], "");
assert_eq!(
lines[n - 1],
" ! The background service isn't running - start it: openlatch start"
);
let live = caps(Mode::Live, false);
assert_eq!(
render_with_warnings(&card, &[], &live),
render(&card, &live)
);
}
#[test]
fn test_result_line() {
assert_eq!(
result_line(
Verdict::Live,
0,
&[("org", "Acme Corp"), ("agents", "cline")]
),
r#"result=live org="Acme Corp" agents=cline exit=0"#
);
assert_eq!(
result_line(Verdict::NoAgent, 3, &[]),
"result=attention reason=no-agent exit=3"
);
assert_eq!(
result_line(
Verdict::NotLive,
1,
&[("code", "OL-1502"), ("log", r#"C:\a "b""#)]
),
r#"result=not_live code=OL-1502 log="C:\\a \"b\"" exit=1"#
);
assert_eq!(
result_line(Verdict::NeedsAttention, 7, &[("org", "")]),
r#"result=attention org="" exit=7"#
);
}
}