use crate::out::{self, Cell, Report, Table};
use serde_json::json;
#[derive(Debug, Clone)]
pub struct Action {
pub kind: &'static str,
pub summary: String,
pub next: Vec<String>,
pub notes: Vec<String>,
pub data: serde_json::Value,
}
impl Action {
pub fn new(kind: &'static str, summary: impl Into<String>) -> Self {
Self {
kind,
summary: summary.into(),
next: Vec::new(),
notes: Vec::new(),
data: json!({}),
}
}
pub fn next(mut self, command: impl Into<String>) -> Self {
self.next.push(command.into());
self
}
pub fn note(mut self, consequence: impl Into<String>) -> Self {
self.notes.push(consequence.into());
self
}
pub fn data(mut self, data: serde_json::Value) -> Self {
self.data = data;
self
}
}
impl Report for Action {
fn human(&self, _p: &out::Palette) -> String {
let mut s = format!("{}\n", self.summary);
for note in &self.notes {
s.push_str(&format!(" {note}\n"));
}
s
}
fn asides(&self) -> out::Asides {
out::Asides {
warnings: Vec::new(),
hints: self.next.iter().map(|c| format!(" {c}")).collect(),
}
}
fn json(&self) -> serde_json::Value {
let mut o = json!({
"action": self.kind,
"message": self.summary,
});
if !self.next.is_empty() {
o["next"] = json!(self.next);
}
if !self.notes.is_empty() {
o["notes"] = json!(self.notes);
}
if let (Some(o), Some(extra)) = (o.as_object_mut(), self.data.as_object()) {
for (k, v) in extra {
o.insert(k.clone(), v.clone());
}
}
o
}
}
#[derive(Debug, Clone)]
pub struct Synced {
pub id: String,
pub base: String,
pub onto: String,
pub moved: usize,
pub conflicted: Vec<String>,
pub checkpoint: bool,
pub note: Option<String>,
}
impl Report for Synced {
fn human(&self, p: &out::Palette) -> String {
let mut s = out::heading(
p,
&format!(
"{} · {} commit{} from {}",
self.id,
self.moved,
if self.moved == 1 { "" } else { "s" },
self.base
),
);
s.push('\n');
if self.conflicted.is_empty() {
s.push_str(&out::nothing(p, "merged cleanly — nothing needs deciding"));
return s;
}
s.push_str(&format!(
" {}\n",
p.paint(
out::WARN,
&format!(
"{} file{} resolving:",
self.conflicted.len(),
if self.conflicted.len() == 1 {
" needs"
} else {
"s need"
}
)
)
));
for path in &self.conflicted {
s.push_str(&format!(" {}\n", out::untrusted(path)));
}
s
}
fn json(&self) -> serde_json::Value {
json!({
"session": self.id,
"base": self.base,
"onto": self.onto,
"moved": self.moved,
"conflicted": self.conflicted,
"checkpointed": self.checkpoint,
"noted": self.note.is_none(),
})
}
fn asides(&self) -> out::Asides {
let mut asides = out::Asides::default();
if let Some(why) = &self.note {
asides = asides.warn(format!(
"the sandbox will not be told this happened — omh could not leave the note \
({}). It will find `base moved to {}` in its own log: {why}",
self.id, self.onto
));
}
if self.checkpoint {
asides = asides.hint(format!(
" omh {} log the checkpoint this can be undone from",
self.id
));
}
if !self.conflicted.is_empty() {
asides = asides.hint(format!(
" omh {} claude the markers are in the sandbox, where fixing \
them cannot hurt you",
self.id
));
}
asides
}
}
#[derive(Debug)]
pub struct Log {
pub id: String,
pub read: crate::shadow::Checkpoints,
pub behind: Option<usize>,
pub base: String,
pub turns: Option<Vec<crate::shadow::Turn>>,
}
impl Log {
fn pending(&self) -> usize {
self.read.commits.iter().filter(|c| !c.landed).count()
}
fn cleanly_split(&self) -> bool {
let pending = self.pending();
self.read.commits[..self.read.commits.len() - pending]
.iter()
.all(|c| c.landed)
}
fn incomplete(&self) -> bool {
self.read.unreachable > 0 || self.read.replay_point_lost
}
}
fn ago(seconds: u64) -> String {
match seconds {
s if s < 60 => format!("{s}s"),
s if s < 60 * 60 => format!("{}m", s / 60),
s if s < 48 * 60 * 60 => format!("{}h", s / (60 * 60)),
s => format!("{}d", s / (24 * 60 * 60)),
}
}
impl Log {
fn turns_human(&self, p: &out::Palette, turns: &[crate::shadow::Turn]) -> String {
let mut s = out::heading(
p,
&format!(
"{} · {} turn{}",
self.id,
turns.len(),
if turns.len() == 1 { "" } else { "s" }
),
);
s.push('\n');
if turns.is_empty() {
s.push_str(&out::nothing(
p,
"no turns recorded — nothing has been photographed in this sandbox yet",
));
return s;
}
let mut table = Table::new();
for t in turns {
let (files, churn) = match &t.touched {
None => ("merge".to_string(), String::new()),
Some(c) => (
format!("{} file{}", c.files, if c.files == 1 { "" } else { "s" }),
churn(c),
),
};
table = table.row(vec![
Cell::styled(format!("~{}", t.back), out::NAME),
Cell::styled(t.age.map_or("?".into(), ago), out::DIM),
Cell::plain(out::untrusted(&t.subject)),
Cell::styled(files, out::DIM),
Cell::styled(churn, out::DIM),
]);
}
s.push_str(&table.render(p));
s
}
}
impl Report for Log {
fn human(&self, p: &out::Palette) -> String {
if let Some(turns) = &self.turns {
return self.turns_human(p, turns);
}
let total = self.read.commits.len();
let pending = self.pending();
let mut head = format!(
"{} · {total} checkpoint{}",
self.id,
if total == 1 { "" } else { "s" }
);
if pending > 0 {
head.push_str(&format!(", {pending} not yours yet"));
}
match self.behind {
Some(0) => {}
Some(behind) => head.push_str(&format!(" · {behind} behind {}", self.base)),
None => head.push_str(&format!(" · how far behind {} is unknown", self.base)),
}
let mut s = out::heading(p, &head);
s.push('\n');
if self.read.commits.is_empty() {
s.push_str(&out::nothing(
p,
"no checkpoints — the agent has not committed anything in this session",
));
} else {
let width = total.to_string().len();
let mut table = Table::new();
for c in self.read.commits.iter().rev() {
let (files, churn) = match &c.touched {
None => ("merge".to_string(), String::new()),
Some(t) => (
format!("{} file{}", t.files, if t.files == 1 { "" } else { "s" }),
churn(t),
),
};
table = table.row(vec![
Cell::styled(format!("{:>width$}", c.number), out::NAME),
Cell::styled(c.age.map_or("?".into(), ago), out::DIM),
Cell::plain(out::untrusted(&c.subject)),
Cell::styled(files, out::DIM),
Cell::styled(churn, out::DIM),
]);
}
let rendered = table.render(p);
let mut lines: Vec<String> = rendered.lines().map(str::to_string).collect();
if pending > 0 && pending < total && self.cleanly_split() {
let widest = lines
.iter()
.map(|l| out::display_width(l))
.max()
.unwrap_or(0);
let label = " yours from here ";
let dashes = widest.saturating_sub(label.chars().count() + 2).max(4);
let left = "─".repeat(dashes / 2);
let right = "─".repeat(dashes - dashes / 2);
lines.insert(
pending,
p.paint(out::DIM, &format!(" {left}{label}{right}")),
);
}
s.push_str(&lines.join("\n"));
s.push('\n');
}
s.push('\n');
s.push_str(&format!(
" {}\n",
p.paint(
out::DIM,
&format!(
"uncommitted in the sandbox: {} file{}",
self.read.uncommitted,
if self.read.uncommitted == 1 { "" } else { "s" }
)
)
));
s
}
fn json(&self) -> serde_json::Value {
json!({
"session": self.id,
"base": self.base,
"turns": self.turns.as_ref().map(|turns| {
turns
.iter()
.map(|t| {
json!({
"back": t.back,
"ref": format!("{}~{}", crate::shadow::TURN_REF, t.back),
"subject": t.subject,
"age_seconds": t.age,
"files": t.touched.as_ref().map(|c| c.files),
"added": t.touched.as_ref().map(|c| c.added),
"removed": t.touched.as_ref().map(|c| c.removed),
})
})
.collect::<Vec<_>>()
}),
"behind": self.behind,
"uncommitted": self.read.uncommitted,
"unreachable": self.read.unreachable,
"replay_point_lost": self.read.replay_point_lost,
"pending": self.pending(),
"checkpoints": self.read.commits.iter().rev().map(|c| json!({
"number": c.number,
"id": c.id,
"subject": c.subject,
"age_seconds": c.age,
"merge": c.touched.is_none(),
"files": c.touched.as_ref().map(|t| t.files),
"added": c.touched.as_ref().map(|t| t.added),
"removed": c.touched.as_ref().map(|t| t.removed),
"uncounted": c.touched.as_ref().map(|t| t.uncounted),
"landed": c.landed,
})).collect::<Vec<_>>(),
})
}
fn asides(&self) -> out::Asides {
let hints_are_meaningless_here = self.turns.is_some();
let mut asides = out::Asides::default();
if self.read.unreachable > 0 {
asides = asides.warn(format!(
"{} commit{} in this sandbox are on no branch it can reach, and are not \
listed above. `omh {} commit --keep` refuses until they are:\n \
git --git-dir=<the sandbox repo> log --all --not HEAD",
self.read.unreachable,
if self.read.unreachable == 1 {
" "
} else {
"s "
},
self.id
));
}
if self.read.replay_point_lost {
asides = asides.warn(format!(
"the last handover is no longer in this history — something rewound below \
it — so omh cannot tell which of these the branch already has. `omh {} \
commit --keep` refuses until that is resolved",
self.id
));
}
if !self.cleanly_split() {
let landed: Vec<String> = self
.read
.commits
.iter()
.filter(|c| c.landed)
.map(|c| c.number.to_string())
.collect();
asides = asides.warn(format!(
"no single line divides this list: {} already on the branch. Everything \
else is new",
landed.join(", ")
));
}
let mut offered: Vec<(String, String)> = Vec::new();
if hints_are_meaningless_here {
return asides;
}
if let Some(newest) = self.read.commits.last() {
offered.push((
format!("omh {} diff {}", self.id, newest.number),
"read that one".into(),
));
}
if self.pending() > 0 && !self.incomplete() {
offered.push((
format!("omh {} commit --keep", self.id),
format!(
"bring the {} new one{} onto the branch",
self.pending(),
if self.pending() == 1 { "" } else { "s" }
),
));
}
let widest = offered
.iter()
.map(|(cmd, _)| out::display_width(cmd))
.max()
.unwrap_or(0);
offered.into_iter().fold(asides, |asides, (cmd, what)| {
let pad = " ".repeat(widest - out::display_width(&cmd) + 4);
asides.hint(format!(" {cmd}{pad}{what}"))
})
}
}
fn churn(t: &crate::shadow::Touched) -> String {
let counted = match (t.added, t.removed) {
(0, 0) => String::new(),
(a, 0) => format!("+{a}"),
(0, r) => format!("−{r}"),
(a, r) => format!("+{a} −{r}"),
};
match (counted.is_empty(), t.uncounted) {
(_, 0) => counted,
(true, n) => format!("·{n}"),
(false, n) => format!("{counted} ·{n}"),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Stopped {
Yes,
WasNotRunning,
CouldNotTell(String),
}
#[derive(Debug, Clone)]
pub struct Down {
pub sessions: Vec<(String, Stopped)>,
}
impl Report for Down {
fn human(&self, p: &out::Palette) -> String {
if self.sessions.is_empty() {
return format!("{}\n", p.paint(out::DIM, "no sessions"));
}
let mut t = Table::new();
for (id, stopped) in &self.sessions {
t = t.row(vec![
Cell::styled(id, out::NAME),
match stopped {
Stopped::Yes => Cell::plain("stopped; worktree and branch survive"),
Stopped::WasNotRunning => Cell::styled("was not running", out::DIM),
Stopped::CouldNotTell(_) => {
Cell::styled("omh could not tell — left alone", out::WARN)
}
},
]);
}
t.render(p)
}
fn json(&self) -> serde_json::Value {
json!({
"action": "sessions-down",
"sessions": self.sessions.iter().map(|(id, stopped)| json!({
"session": id,
"stopped": match stopped {
Stopped::Yes => serde_json::Value::Bool(true),
Stopped::WasNotRunning => serde_json::Value::Bool(false),
Stopped::CouldNotTell(_) => serde_json::Value::Null,
},
"why": match stopped {
Stopped::CouldNotTell(why) => serde_json::Value::String(why.clone()),
_ => serde_json::Value::Null,
},
})).collect::<Vec<_>>(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Work {
Unknown,
Uncommitted(usize),
ToPush(usize),
Published(String),
Clean,
}
impl Work {
pub fn human(&self) -> String {
match self {
Self::Unknown => "?".into(),
Self::Uncommitted(n) => format!("{n} uncommitted"),
Self::ToPush(n) => format!("{n} to push"),
Self::Published(target) => format!("→ {target}"),
Self::Clean => String::new(),
}
}
fn style(&self) -> anstyle::Style {
match self {
Self::Unknown => out::WARN,
Self::Uncommitted(_) | Self::ToPush(_) => out::WARN,
Self::Published(_) => out::OK,
Self::Clean => out::DIM,
}
}
fn json(&self) -> serde_json::Value {
match self {
Self::Unknown => json!({ "state": "unknown" }),
Self::Uncommitted(n) => json!({ "state": "uncommitted", "count": n }),
Self::ToPush(n) => json!({ "state": "unpushed", "count": n }),
Self::Published(target) => json!({ "state": "published", "branch": target }),
Self::Clean => json!({ "state": "clean" }),
}
}
}
#[derive(Debug, Clone)]
pub struct Session {
pub id: String,
pub label: String,
pub running: Option<crate::image::Running>,
pub work: Option<Work>,
pub behind: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Overlap {
pub sessions: Vec<String>,
pub paths: Vec<String>,
}
fn spoken(names: &[String]) -> String {
match names.split_last() {
None => String::new(),
Some((last, [])) => last.clone(),
Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
}
}
pub fn overlaps(changed: &[(String, Vec<String>)]) -> Vec<Overlap> {
let mut who: std::collections::BTreeMap<&str, Vec<&str>> = Default::default();
for (id, paths) in changed {
for path in paths {
let sessions = who.entry(path.as_str()).or_default();
if !sessions.contains(&id.as_str()) {
sessions.push(id);
}
}
}
let mut grouped: std::collections::BTreeMap<Vec<&str>, Vec<&str>> = Default::default();
for (path, sessions) in who {
if sessions.len() > 1 {
grouped.entry(sessions).or_default().push(path);
}
}
grouped
.into_iter()
.map(|(sessions, paths)| Overlap {
sessions: sessions.into_iter().map(str::to_string).collect(),
paths: paths.into_iter().map(str::to_string).collect(),
})
.collect()
}
fn running_cell(running: &Option<crate::image::Running>) -> Cell {
use crate::image::Running;
match running {
Some(Running::Yes) => Cell::styled("up", out::OK),
Some(Running::No) => Cell::styled("stopped", out::DIM),
Some(Running::Unknown(_)) => Cell::styled("up?", out::WARN),
None => Cell::plain(""),
}
}
fn behind_cell(behind: Option<usize>, base: &str) -> Cell {
match behind {
Some(0) => Cell::plain(""),
Some(n) => Cell::styled(format!("({n} behind {base})"), out::DIM),
None => Cell::styled(format!("(how far behind {base}?)"), out::WARN),
}
}
#[derive(Debug, Clone)]
pub struct Sessions {
pub sessions: Vec<Session>,
pub base: String,
pub leftovers: Vec<String>,
pub overlaps: Vec<Overlap>,
pub unreadable: Vec<String>,
}
impl Report for Sessions {
fn human(&self, p: &out::Palette) -> String {
if self.sessions.is_empty() {
return format!("{}\n", p.paint(out::DIM, "no sessions"));
}
let mut table = Table::new();
for s in &self.sessions {
table = table.row(vec![
Cell::styled(&s.id, out::NAME),
Cell::plain(&s.label),
running_cell(&s.running),
match &s.work {
Some(work) => Cell::styled(work.human(), work.style()),
None => Cell::plain(""),
},
behind_cell(s.behind, &self.base),
]);
}
let mut out = table.render(p);
for overlap in &self.overlaps {
out.push_str(&format!(
"\n {}\n",
p.paint(
out::HEAD,
&format!(
"{} both change {}",
spoken(&overlap.sessions),
overlap.paths.join(", ")
)
)
));
}
if !self.unreadable.is_empty() {
out.push_str(&format!(
"\n {}\n",
p.paint(
out::WARN,
&format!(
"omh could not read what {} {} changing, so anything above may be \
incomplete",
spoken(&self.unreadable),
match self.unreadable.len() {
1 => "is",
_ => "are",
}
)
)
));
}
out
}
fn asides(&self) -> out::Asides {
let mut asides = out::Asides::default();
let stale: Vec<&Session> = self
.sessions
.iter()
.filter(|s| s.behind.is_some_and(|n| n > 0))
.filter(|s| !matches!(s.running, Some(crate::image::Running::Unknown(_))))
.collect();
let offered: Vec<(String, &Session)> = stale
.iter()
.map(|s| {
let cmd = match s.running {
Some(crate::image::Running::Yes) => {
format!("omh {} sync --down", out::untrusted(&s.id))
}
_ => format!("omh {} sync", out::untrusted(&s.id)),
};
(cmd, *s)
})
.collect();
let widest = offered
.iter()
.map(|(cmd, _)| out::display_width(cmd))
.max()
.unwrap_or(0);
for (cmd, s) in &offered {
let pad = " ".repeat(widest - out::display_width(cmd) + 2);
asides = asides.hint(format!(
" {cmd}{pad}bring {} in{}",
self.base,
match s.running {
Some(crate::image::Running::Yes) => ", stopping the sandbox first",
_ => ", merged on the host",
}
));
}
let unmeasured: Vec<&str> = self
.sessions
.iter()
.filter(|s| s.behind.is_none())
.map(|s| s.id.as_str())
.collect();
if let Some(first) = unmeasured.first() {
asides = asides
.warn(format!(
"omh could not measure {} against {} — {} may be working against code that \
moved, and `sync` is not offered over a count that failed",
spoken(&unmeasured.iter().map(|s| s.to_string()).collect::<Vec<_>>()),
self.base,
if unmeasured.len() == 1 { "it" } else { "they" }
))
.hint(format!(
" omh {} log says why the count could not be taken",
out::untrusted(first)
));
}
if self.leftovers.is_empty() {
return asides;
}
asides
.warn(format!(
"{} removed but left something behind: {}",
if self.leftovers.len() == 1 {
"1 session was"
} else {
"sessions were"
},
self.leftovers.join(", ")
))
.hint(" clear each with omh <id> rm")
}
fn json(&self) -> serde_json::Value {
json!({
"base": self.base,
"sessions": self.sessions.iter().map(|s| json!({
"id": s.id,
"label": s.label,
"running": match &s.running {
Some(crate::image::Running::Yes) => serde_json::Value::Bool(true),
Some(crate::image::Running::No) => serde_json::Value::Bool(false),
Some(crate::image::Running::Unknown(_)) | None => serde_json::Value::Null,
},
"running_unknown": match &s.running {
Some(crate::image::Running::Unknown(why)) => {
serde_json::Value::String(why.clone())
}
_ => serde_json::Value::Null,
},
"work": s.work.as_ref().map(Work::json),
"behind": s.behind,
})).collect::<Vec<_>>(),
"leftovers": self.leftovers,
"overlaps": self.overlaps.iter().map(|o| json!({
"sessions": o.sessions,
"paths": o.paths,
})).collect::<Vec<_>>(),
"unreadable": self.unreadable,
})
}
}
#[derive(Debug, Clone)]
pub struct Harness {
pub name: String,
pub accounts: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Editor {
pub name: String,
pub installed: bool,
}
#[derive(Debug, Clone)]
pub struct Inventory {
pub harnesses: Vec<Harness>,
pub adapters_dir: String,
pub editors: Vec<Editor>,
pub sessions: Vec<Session>,
pub base: String,
}
impl Report for Inventory {
fn human(&self, p: &out::Palette) -> String {
let mut s = out::heading(p, "harnesses:");
if self.harnesses.is_empty() {
s.push_str(&out::nothing(
p,
&format!("none — add {}/<name>.toml", self.adapters_dir),
));
} else {
let mut t = Table::new();
for h in &self.harnesses {
t = t.row(vec![
Cell::styled(&h.name, out::NAME),
if h.accounts.is_empty() {
Cell::styled("not authed", out::DIM)
} else {
Cell::styled(h.accounts.join(", "), out::OK)
},
]);
}
s.push_str(&t.render(p));
}
if !self.editors.is_empty() {
s.push('\n');
s.push_str(&out::heading(p, "editors:"));
let mut t = Table::new();
for e in &self.editors {
t = t.row(vec![
Cell::styled(&e.name, out::NAME),
if e.installed {
Cell::styled("installed", out::OK)
} else {
Cell::styled("not installed", out::DIM)
},
]);
}
s.push_str(&t.render(p));
}
s.push('\n');
s.push_str(&out::heading(p, "sessions:"));
if self.sessions.is_empty() {
s.push_str(&out::nothing(p, "none"));
} else {
let mut t = Table::new();
for sess in &self.sessions {
t = t.row(vec![
Cell::styled(&sess.id, out::NAME),
Cell::plain(&sess.label),
behind_cell(sess.behind, &self.base),
]);
}
s.push_str(&t.render(p));
}
s
}
fn json(&self) -> serde_json::Value {
json!({
"adapters_dir": self.adapters_dir,
"harnesses": self.harnesses.iter().map(|h| json!({
"name": h.name,
"accounts": h.accounts,
"authed": !h.accounts.is_empty(),
})).collect::<Vec<_>>(),
"editors": self.editors.iter().map(|e| json!({
"name": e.name,
"installed": e.installed,
})).collect::<Vec<_>>(),
"sessions": self.sessions.iter().map(|s| json!({
"id": s.id,
"label": s.label,
"behind": s.behind,
})).collect::<Vec<_>>(),
"base": self.base,
})
}
}
#[derive(Debug, Clone)]
pub struct Doctor {
pub harness: String,
pub tag: String,
pub account: Option<String>,
pub outcomes: Vec<crate::doctor::Outcome>,
}
impl Doctor {
pub fn failed(&self) -> usize {
self.outcomes.iter().filter(|o| !o.ok).count()
}
pub fn passed(&self) -> bool {
crate::doctor::passed(&self.outcomes)
}
}
impl Report for Doctor {
fn human(&self, p: &out::Palette) -> String {
let mut t = Table::new();
for o in &self.outcomes {
t = t.row(vec![
if o.ok {
Cell::styled("✓", out::OK)
} else {
Cell::styled("✗", out::BAD)
},
Cell::plain(&o.name),
Cell::plain(&o.detail),
]);
}
let mut s = t.render(p);
if self.passed() {
s.push('\n');
s.push_str(&format!(
" {}\n",
p.paint(
out::OK,
&format!(
"all {} checks passed — {}'s adapter paths are verified",
self.outcomes.len(),
self.harness
)
)
));
}
s
}
fn json(&self) -> serde_json::Value {
json!({
"harness": self.harness,
"image": self.tag,
"account": self.account,
"ok": self.passed(),
"passed_count": self.outcomes.len() - self.failed(),
"failed_count": self.failed(),
"checks": self.outcomes.iter().map(|o| json!({
"name": o.name,
"ok": o.ok,
"detail": o.detail,
})).collect::<Vec<_>>(),
})
}
}
#[derive(Debug, Clone)]
pub struct Setting {
pub key: String,
pub value: String,
pub whose: Option<String>,
}
#[derive(Debug, Clone)]
pub struct Catalogue {
pub capability: String,
pub entries: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Config {
pub defaults_file: String,
pub settings: Vec<Setting>,
pub catalogue_dir: String,
pub catalogue: Vec<Catalogue>,
}
impl Report for Config {
fn human(&self, p: &out::Palette) -> String {
let mut s = format!(
"{} {}\n",
p.paint(out::HEAD, "your defaults"),
p.paint(out::DIM, &self.defaults_file)
);
if self.settings.is_empty() {
s.push_str(&out::nothing(p, "nothing set"));
} else {
let mut t = Table::new();
for setting in &self.settings {
t = t.row(vec![
Cell::styled(&setting.key, out::NAME),
Cell::plain(&setting.value),
]);
}
s.push_str(&t.render(p));
}
s.push('\n');
s.push_str(&format!(
"{} {}\n",
p.paint(out::HEAD, "your catalogue"),
p.paint(out::DIM, &self.catalogue_dir)
));
let mut t = Table::new();
for c in &self.catalogue {
t = t.row(vec![
Cell::plain(&c.capability),
Cell::styled(c.entries.len().to_string(), out::DIM),
Cell::plain(c.entries.join(", ")),
]);
}
s.push_str(&t.render(p));
s
}
fn json(&self) -> serde_json::Value {
json!({
"defaults_file": self.defaults_file,
"settings": self.settings.iter().map(|s| json!({
"key": s.key,
"value": s.value,
"whose": s.whose,
})).collect::<Vec<_>>(),
"catalogue_dir": self.catalogue_dir,
"catalogue": self.catalogue.iter().map(|c| json!({
"capability": c.capability,
"count": c.entries.len(),
"entries": c.entries,
})).collect::<Vec<_>>(),
})
}
}
#[derive(Debug, Clone)]
pub struct Servers {
pub servers: Vec<Setting>,
}
impl Report for Servers {
fn human(&self, p: &out::Palette) -> String {
let mut s = out::heading(p, "mcp:");
if self.servers.is_empty() {
s.push_str(&out::nothing(p, "nothing set"));
return s;
}
let mut t = Table::new();
for server in &self.servers {
t = t.row(vec![
Cell::styled(&server.key, out::NAME),
Cell::plain(&server.value),
Cell::styled(
format!("← {}", server.whose.as_deref().unwrap_or("?")),
out::DIM,
),
]);
}
s.push_str(&t.render(p));
s
}
fn json(&self) -> serde_json::Value {
json!({
"servers": self.servers.iter().map(|s| json!({
"name": s.key,
"command": s.value,
"whose": s.whose,
})).collect::<Vec<_>>(),
})
}
}
#[derive(Debug, Clone)]
pub struct Effective {
pub key: String,
pub value: String,
pub layer: String,
pub shadows: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Feature {
pub name: String,
pub on: bool,
}
#[derive(Debug, Clone)]
pub struct Using {
pub capability: String,
pub selected: Option<Vec<String>>,
pub unselected: Vec<String>,
}
impl Using {
fn summary(&self) -> String {
match &self.selected {
None => "everything".into(),
Some(taken) if taken.is_empty() => "nothing".into(),
Some(taken) => taken.join(", "),
}
}
}
#[derive(Debug, Clone)]
pub struct Repo {
pub dir: String,
pub settings: Vec<Effective>,
pub features: Vec<Feature>,
pub using: Vec<Using>,
pub notices: Vec<String>,
}
impl Report for Repo {
fn human(&self, p: &out::Palette) -> String {
let mut s = format!(
"{} {}\n",
p.paint(out::HEAD, "this repo"),
p.paint(out::DIM, &self.dir)
);
s.push('\n');
s.push_str(&out::heading(p, "settings"));
if self.settings.is_empty() {
s.push_str(&out::nothing(p, "nothing set"));
} else {
let mut t = Table::new();
for e in &self.settings {
t = t.row(vec![
Cell::styled(&e.key, out::NAME),
Cell::plain(&e.value),
Cell::styled(
if e.shadows.is_empty() {
format!("← {}", e.layer)
} else {
format!("← {} (overrides {})", e.layer, e.shadows.join(", "))
},
out::DIM,
),
]);
}
s.push_str(&t.render(p));
}
s.push('\n');
s.push_str(&out::heading(p, "omh's features"));
let mut t = Table::new();
for f in &self.features {
t = t.row(vec![
Cell::plain(&f.name),
if f.on {
Cell::styled("on", out::OK)
} else {
Cell::styled("off here", out::WARN)
},
]);
}
s.push_str(&t.render(p));
s.push('\n');
s.push_str(&out::heading(p, "using"));
let mut t = Table::new();
for u in &self.using {
t = t.row(vec![
Cell::plain(&u.capability),
Cell::plain(u.summary()),
if u.unselected.is_empty() {
Cell::plain("")
} else {
Cell::styled(
format!(
"({} not selected: {})",
u.unselected.len(),
u.unselected.join(", ")
),
out::DIM,
)
},
]);
}
s.push_str(&t.render(p));
for line in &self.notices {
s.push('\n');
s.push_str(&format!("{line}\n"));
}
s
}
fn json(&self) -> serde_json::Value {
json!({
"dir": self.dir,
"settings": self.settings.iter().map(|e| json!({
"key": e.key,
"value": e.value,
"layer": e.layer,
"overrides": e.shadows,
})).collect::<Vec<_>>(),
"features": self.features.iter().map(|f| json!({
"name": f.name,
"on": f.on,
})).collect::<Vec<_>>(),
"using": self.using.iter().map(|u| json!({
"capability": u.capability,
"selected": u.selected,
"unselected": u.unselected,
})).collect::<Vec<_>>(),
"notices": self.notices,
})
}
}
#[derive(Debug, Clone)]
pub struct Resynced {
pub wrote: Vec<String>,
pub counts: Vec<(String, usize)>,
}
impl Report for Resynced {
fn human(&self, p: &out::Palette) -> String {
let mut s = String::new();
for path in &self.wrote {
s.push_str(&format!("resynced to your catalogue — wrote → {path}\n"));
}
let mut t = Table::new();
for (capability, count) in &self.counts {
t = t.row(vec![
Cell::plain(capability),
Cell::styled(count.to_string(), out::DIM),
]);
}
s.push_str(&t.render(p));
s
}
fn json(&self) -> serde_json::Value {
json!({
"action": "catalogue-resynced",
"wrote": self.wrote,
"capabilities": self.counts.iter().map(|(capability, count)| json!({
"capability": capability,
"count": count,
})).collect::<Vec<_>>(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
Took,
Kept,
Conflict,
Skipped,
Left,
}
impl Verdict {
fn mark(&self) -> &'static str {
match self {
Self::Took => "imported",
Self::Kept => "kept",
Self::Conflict => "conflict",
Self::Skipped => "skipped",
Self::Left => "left",
}
}
fn style(&self) -> anstyle::Style {
match self {
Self::Took => out::OK,
Self::Kept => out::DIM,
Self::Conflict | Self::Skipped => out::WARN,
Self::Left => out::DIM,
}
}
fn key(&self) -> &'static str {
match self {
Self::Took => "took",
Self::Kept => "kept",
Self::Conflict => "conflict",
Self::Skipped => "skipped",
Self::Left => "left",
}
}
}
#[derive(Debug, Clone)]
pub struct Considered {
pub name: String,
pub verdict: Verdict,
pub detail: String,
}
#[derive(Debug, Clone, Default)]
pub struct Imported {
pub what: String,
pub source: String,
pub considered: Vec<Considered>,
pub noun: String,
pub dry_run: bool,
pub wrote: Option<String>,
pub selected_in: Vec<String>,
}
impl Imported {
pub fn count(&self, verdict: Verdict) -> usize {
self.considered
.iter()
.filter(|c| c.verdict == verdict)
.count()
}
}
impl Report for Imported {
fn human(&self, p: &out::Palette) -> String {
let mut s = format!(
"import from {} {}\n",
p.paint(out::HEAD, &self.what),
p.paint(out::DIM, &format!("({})", self.source))
);
if self.considered.is_empty() {
s.push_str(&out::nothing(p, &format!("no {} found", self.noun)));
return s;
}
let mut t = Table::new();
for c in &self.considered {
t = t.row(vec![
Cell::styled(c.verdict.mark(), c.verdict.style()),
Cell::plain(&c.name),
Cell::styled(&c.detail, out::DIM),
]);
}
s.push_str(&t.render(p));
for path in &self.selected_in {
s.push_str(&format!(" selected in {path}\n"));
}
if self.dry_run {
s.push_str(&format!(
"\n{}\n",
p.paint(out::DIM, "--dry-run: nothing written")
));
} else if let Some(path) = &self.wrote {
s.push_str(&format!("\nwrote → {path}\n"));
}
s
}
fn json(&self) -> serde_json::Value {
json!({
"what": self.what,
"source": self.source,
"considered": self.considered.iter().map(|c| json!({
"name": c.name,
"verdict": c.verdict.key(),
"detail": c.detail,
})).collect::<Vec<_>>(),
"took": self.count(Verdict::Took),
"kept": self.count(Verdict::Kept),
"conflicts": self.count(Verdict::Conflict),
"skipped": self.count(Verdict::Skipped),
"left": self.count(Verdict::Left),
"dry_run": self.dry_run,
"wrote": self.wrote,
"selected_in": self.selected_in,
})
}
}
const NOT_YET_DONE: &str = "not yet done: cost accounting.";
#[derive(Debug, Clone, Default)]
pub struct Init {
pub asked: usize,
pub adapters: Vec<String>,
pub editors: Vec<String>,
pub harness: Option<String>,
pub harness_on_host: bool,
pub image: Option<String>,
pub stack_image: Option<String>,
pub stacks: Vec<(String, String)>,
pub provisioned: Vec<String>,
pub provision_problems: Vec<String>,
pub held_back: Vec<(String, String)>,
pub importable: Vec<String>,
pub memory: String,
pub catalogue_dir: String,
pub repo_dir: String,
pub graph: Option<String>,
pub base_set: String,
pub rationale: Vec<(String, String)>,
pub next_command: String,
}
impl Report for Init {
fn human(&self, p: &out::Palette) -> String {
let mut s = format!(
"{}\n\n",
p.paint(
out::HEAD,
&match self.asked {
0 => "omh init — decided, asked nothing".to_string(),
1 => "omh init — decided all but one question".to_string(),
n => format!("omh init — decided the rest; asked {n} questions"),
}
)
);
let mut t = Table::new();
t = t.row(vec![
Cell::plain("harnesses"),
Cell::plain(format!(
"{} ({})",
self.adapters.len(),
self.adapters.join(", ")
)),
]);
t = t.row(vec![
Cell::plain("editors"),
Cell::plain(format!(
"{} ({})",
self.editors.len(),
self.editors.join(", ")
)),
]);
t = t.row(vec![
Cell::plain("harness"),
match &self.harness {
Some(h) if self.harness_on_host => {
Cell::plain(format!("{h} (found on your host)"))
}
Some(h) => Cell::plain(format!("{h} (default; nothing detected on host)")),
None => Cell::styled("none — no adapters available", out::WARN),
},
]);
if let Some(image) = &self.image {
t = t.row(vec![Cell::plain("image"), Cell::plain(image)]);
}
if let Some(image) = &self.stack_image {
t = t.row(vec![
Cell::plain("image"),
Cell::plain(format!("{image} (this repo's toolchain)")),
]);
}
if self.stacks.is_empty() {
t = t.row(vec![
Cell::plain("stack"),
Cell::styled(
"none detected — write your test and format hooks into .omh/hooks/",
out::DIM,
),
]);
}
for (name, marker) in &self.stacks {
t = t.row(vec![
Cell::plain("stack"),
Cell::plain(format!("{name} (from {marker})")),
]);
}
for key in &self.provisioned {
t = t.row(vec![Cell::plain("provision"), Cell::plain(key)]);
}
for problem in &self.provision_problems {
t = t.row(vec![
Cell::plain("provision"),
Cell::styled(problem, out::WARN),
]);
}
for (name, wanted) in &self.held_back {
t = t.row(vec![
Cell::styled("held back", out::WARN),
Cell::plain(format!("`{name}` needs {wanted}")),
]);
}
for line in &self.importable {
t = t.row(vec![Cell::plain(""), Cell::plain(line)]);
}
t = t.row(vec![Cell::plain("memory"), Cell::plain(&self.memory)]);
if let Some(graph) = &self.graph {
t = t.row(vec![Cell::plain("graph"), Cell::plain(graph)]);
}
s.push_str(&t.render(p));
if self.stacks.len() > 1 {
s.push_str(&format!(
"\n {} {} stacks detected; hooks were written for every command \
the sandbox can run.\n drop the ones you do not want: .omh/hooks/\n",
p.paint(out::WARN, "!"),
self.stacks.len()
));
}
s.push('\n');
let mut where_ = Table::new();
where_ = where_.row(vec![
Cell::plain("catalogue"),
Cell::plain(&self.catalogue_dir),
]);
where_ = where_.row(vec![
Cell::plain("this repo"),
Cell::plain(format!("{} (committed)", self.repo_dir)),
]);
s.push_str(&where_.render(p));
s.push_str(&format!(
"\n {} ({})\n",
p.paint(out::HEAD, "base set"),
self.base_set
));
let mut why = Table::new().indent(4);
for (name, reason) in &self.rationale {
why = why.row(vec![Cell::styled(name, out::NAME), Cell::plain(reason)]);
}
s.push_str(&why.render(p));
s.push_str(&format!(
"\n {}\n",
p.paint(
out::DIM,
"omh why <name> what it costs, what was considered instead, how to remove it"
)
));
s.push_str(&format!("\n{}\n", p.paint(out::DIM, NOT_YET_DONE)));
s.push_str(&format!("next: omh {}\n", self.next_command));
s
}
fn json(&self) -> serde_json::Value {
json!({
"asked": self.asked,
"adapters": self.adapters,
"editors": self.editors,
"harness": self.harness,
"harness_on_host": self.harness_on_host,
"image": self.image,
"stack_image": self.stack_image,
"stacks": self.stacks.iter().map(|(name, marker)| json!({
"name": name,
"marker": marker,
})).collect::<Vec<_>>(),
"provisioned": self.provisioned,
"provision_problems": self.provision_problems,
"held_back": self.held_back.iter().map(|(name, wanted)| json!({
"hook": name,
"needs": wanted,
})).collect::<Vec<_>>(),
"importable": self.importable,
"memory": self.memory,
"catalogue_dir": self.catalogue_dir,
"repo_dir": self.repo_dir,
"graph": self.graph,
"base_set": self.base_set,
"rationale": self.rationale.iter().map(|(name, why)| json!({
"name": name,
"why": why,
})).collect::<Vec<_>>(),
"next": self.next_command,
})
}
}
#[derive(Debug, Clone)]
pub struct Judged {
pub key: String,
pub layer: String,
pub recorded: String,
pub age: Age,
pub because: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Age {
Stale,
Unknown,
NoTrigger,
Fresh,
}
impl Age {
fn heading(&self) -> Option<&'static str> {
match self {
Self::Stale => Some("stale"),
Self::Unknown => Some("omh cannot tell"),
Self::NoTrigger => Some("no expiry — carries only its date"),
Self::Fresh => None,
}
}
fn key(&self) -> &'static str {
match self {
Self::Stale => "stale",
Self::Unknown => "unknown",
Self::NoTrigger => "no-trigger",
Self::Fresh => "fresh",
}
}
}
#[derive(Debug, Clone)]
pub struct DryRun {
pub status: String,
pub worktree: String,
pub argv: Vec<String>,
}
impl Report for DryRun {
fn human(&self, p: &out::Palette) -> String {
let mut s = format!("{}\n", p.paint(out::HEAD, &self.status));
s.push_str(&format!(
"worktree {}\n\n",
p.paint(out::DIM, &self.worktree)
));
s.push_str(&self.argv.join(" \\\n "));
s.push('\n');
s
}
fn json(&self) -> serde_json::Value {
json!({
"status": self.status,
"worktree": self.worktree,
"argv": self.argv,
})
}
}
#[derive(Debug, Clone)]
pub struct Probe {
pub script: String,
pub checks: Vec<String>,
}
impl Report for Probe {
fn human(&self, _p: &out::Palette) -> String {
format!("{}\n", self.script)
}
fn json(&self) -> serde_json::Value {
json!({
"script": self.script,
"checks": self.checks,
})
}
}
#[derive(Debug, Clone)]
pub struct Why {
pub thing: String,
pub text: String,
}
impl Report for Why {
fn human(&self, _p: &out::Palette) -> String {
self.text.clone()
}
fn json(&self) -> serde_json::Value {
json!({
"thing": self.thing,
"explanation": self.text,
})
}
}
#[derive(Debug, Clone)]
pub struct Diff {
pub label: String,
pub session: String,
pub checkpoint: Option<usize>,
pub base: String,
pub what: crate::session::What,
pub body: String,
}
impl Diff {
pub fn changed(&self) -> bool {
!self.body.trim().is_empty()
}
}
impl Report for Diff {
fn human(&self, p: &out::Palette) -> String {
if !self.changed() {
return format!(
"{}\n",
p.paint(
out::DIM,
&format!("no changes on {} (against {})", self.label, self.base)
)
);
}
out::untrusted(&self.body)
}
fn json(&self) -> serde_json::Value {
let mut v = json!({
"session": self.session,
"checkpoint": self.checkpoint,
"base": self.base,
"changed": self.changed(),
});
let key = match self.what {
crate::session::What::Summary => "summary",
crate::session::What::Patch => "patch",
};
v[key] = json!(self.body);
v
}
}
#[derive(Debug, Clone)]
pub struct Promoted {
pub text: String,
pub keys: Vec<String>,
}
impl Report for Promoted {
fn human(&self, _p: &out::Palette) -> String {
self.text.clone()
}
fn json(&self) -> serde_json::Value {
json!({
"action": "notes-promoted",
"keys": self.keys,
"message": self.text.trim_end(),
})
}
}
#[derive(Debug, Clone)]
pub struct Stale {
pub judged: Vec<Judged>,
}
impl Stale {
const GROUPS: [Age; 3] = [Age::Stale, Age::Unknown, Age::NoTrigger];
pub fn count(&self, age: Age) -> usize {
self.judged.iter().filter(|j| j.age == age).count()
}
fn fresh(&self) -> usize {
self.count(Age::Fresh)
}
}
impl Report for Stale {
fn human(&self, p: &out::Palette) -> String {
let mut s = String::new();
let mut printed = false;
for age in Self::GROUPS {
let members: Vec<&Judged> = self.judged.iter().filter(|j| j.age == age).collect();
if members.is_empty() {
continue;
}
if printed {
s.push('\n');
}
printed = true;
s.push_str(&out::heading(
p,
&format!("{}:", age.heading().expect("GROUPS excludes Fresh")),
));
let mut t = Table::new();
for j in members {
t = t.row(vec![
Cell::styled(&j.key, out::NAME),
Cell::plain(&j.layer),
Cell::plain(&j.recorded),
match &j.because {
Some(because) => Cell::styled(format!("— {because}"), out::DIM),
None => Cell::plain(""),
},
]);
}
s.push_str(&t.render(p));
}
let fresh = self.fresh();
if !printed && fresh == 0 {
s.push_str(&format!("{}\n", p.paint(out::DIM, "no notes yet")));
} else if fresh > 0 {
s.push_str(&format!(
"\n{}\n",
p.paint(out::OK, &format!("{fresh} still current"))
));
}
s
}
fn json(&self) -> serde_json::Value {
json!({
"notes": self.judged.iter().map(|j| json!({
"key": j.key,
"layer": j.layer,
"recorded": j.recorded,
"verdict": j.age.key(),
"because": j.because,
})).collect::<Vec<_>>(),
"fresh": self.fresh(),
"stale": self.count(Age::Stale),
"unknown": self.count(Age::Unknown),
})
}
}
#[derive(Debug, Clone)]
pub struct Attached {
pub session: String,
pub url: String,
pub alias: String,
pub opened_in: Option<String>,
pub editors: Vec<(String, String)>,
}
impl Report for Attached {
fn human(&self, p: &out::Palette) -> String {
let mut s = match &self.opened_in {
Some(name) => format!(
"opening {} in {}\n",
p.paint(out::NAME, &self.url),
p.paint(out::HEAD, name)
),
None => format!("session {} is up\n", p.paint(out::NAME, &self.session)),
};
s.push('\n');
s.push_str(&format!(" {}\n", self.url));
s.push_str(&format!(" ssh {}\n", self.alias));
if self.opened_in.is_none() && !self.editors.is_empty() {
s.push('\n');
let mut t = Table::new();
for (name, command) in &self.editors {
t = t.row(vec![
Cell::styled(name, out::NAME),
Cell::styled(command, out::DIM),
]);
}
s.push_str(&t.render(p));
}
s
}
fn json(&self) -> serde_json::Value {
json!({
"session": self.session,
"url": self.url,
"ssh_alias": self.alias,
"opened_in": self.opened_in,
"editors": self.editors.iter().map(|(name, command)| json!({
"name": name,
"command": command,
})).collect::<Vec<_>>(),
})
}
}
#[derive(Debug, Clone)]
pub struct Notes {
pub notes: Vec<crate::memory::Note>,
}
impl Report for Notes {
fn human(&self, p: &out::Palette) -> String {
if self.notes.is_empty() {
return format!(
"{}\n",
p.paint(
out::DIM,
"no notes yet — the store fills as work surprises the agent"
)
);
}
crate::memory::render_list(&self.notes)
}
fn json(&self) -> serde_json::Value {
json!({
"notes": self.notes.iter().map(|n| json!({
"key": n.key,
"kind": n.kind.to_string(),
"layer": n.layer.to_string(),
"source": n.source,
"recorded": n.recorded,
"invalidated_by": n.invalidated_by,
"path": n.path.display().to_string(),
})).collect::<Vec<_>>(),
})
}
}
#[derive(Debug, Clone)]
pub struct Lint {
pub violations: Vec<crate::memory::Violation>,
pub tally: std::collections::BTreeMap<crate::memory::Rule, usize>,
}
impl Lint {
pub fn refused(&self) -> usize {
crate::memory::refused(&self.violations)
}
}
impl Report for Lint {
fn human(&self, p: &out::Palette) -> String {
if self.violations.is_empty() {
return format!("{}\n", p.paint(out::OK, "no violations"));
}
let mut t = Table::new().indent(0);
for v in &self.violations {
let refused = matches!(v.rule.severity(), crate::memory::Severity::Refused);
t = t.row(vec![
if refused {
Cell::styled("refused", out::BAD)
} else {
Cell::styled("warning", out::WARN)
},
Cell::plain(v.layer.to_string()),
Cell::plain(&v.detail),
]);
}
let mut s = t.render(p);
s.push('\n');
let mut counts = Table::new();
for (rule, count) in &self.tally {
counts = counts.row(vec![
Cell::styled(count.to_string(), out::HEAD),
Cell::plain(format!("{rule:?}")),
]);
}
s.push_str(&counts.render(p));
s
}
fn json(&self) -> serde_json::Value {
json!({
"violations": self.violations.iter().map(|v| json!({
"key": v.key,
"layer": v.layer.to_string(),
"rule": format!("{:?}", v.rule),
"severity": match v.rule.severity() {
crate::memory::Severity::Refused => "refused",
crate::memory::Severity::Warning => "warning",
},
"detail": v.detail,
})).collect::<Vec<_>>(),
"tally": self.tally.iter().map(|(rule, count)| json!({
"rule": format!("{rule:?}"),
"count": count,
})).collect::<Vec<_>>(),
"refused": self.refused(),
"warnings": self.violations.len() - self.refused(),
})
}
}
#[cfg(test)]
mod tests {
#[test]
fn init_does_not_call_unfinished_a_capability_that_ships() {
for tool in ["recall", "remember"] {
assert!(
!super::NOT_YET_DONE.contains(tool),
"`{}` ships — the memory server answers it and `omh doctor` \
checks that it does — but init still reports it as undone: {}",
tool,
super::NOT_YET_DONE
);
}
}
#[test]
fn a_sync_names_what_needs_deciding_and_says_so_in_english() {
let synced = |conflicted: Vec<String>, moved: usize| super::Synced {
id: "s01".into(),
base: "main".into(),
onto: "abc1234".into(),
moved,
conflicted,
checkpoint: true,
note: None,
};
let p = crate::out::Palette::plain();
let clean = synced(vec![], 3).human(&p);
assert!(
clean.contains("3 commits from main"),
"what arrived, and from where: {clean}"
);
assert!(
clean.contains("nothing needs deciding"),
"and that there is nothing to do: {clean}"
);
let one = synced(vec!["src/tap.rs".into()], 1).human(&p);
assert!(
one.contains("1 commit from main"),
"one commit, not `1 commits`: {one}"
);
assert!(
one.contains("1 file needs resolving"),
"and one file needs it, rather than need it: {one}"
);
assert!(one.contains("src/tap.rs"), "named: {one}");
let two = synced(vec!["a.rs".into(), "b.rs".into()], 2).human(&p);
assert!(
two.contains("2 files need resolving"),
"and two of them need it: {two}"
);
assert!(
two.contains("a.rs") && two.contains("b.rs"),
"every one named — a count is not something you can act on: {two}"
);
let quiet = super::Synced {
note: Some("Permission denied (os error 13)".into()),
..synced(vec![], 2)
};
assert!(
quiet.human(&p).contains("2 commits from main"),
"the sync is reported as the success it was: {}",
quiet.human(&p)
);
let said = quiet.asides().warnings.join(" ");
assert!(
said.contains("Permission denied"),
"with the reason, not just the fact: {said}"
);
assert!(
said.contains("base moved to"),
"and what the agent will find instead: {said}"
);
assert_eq!(quiet.json()["noted"], serde_json::json!(false));
assert_eq!(synced(vec![], 2).json()["noted"], serde_json::json!(true));
}
#[test]
fn the_turn_view_never_borrows_the_numbers_that_land_work() {
let snapshot = |back: usize| crate::shadow::Turn {
back,
subject: "turn end".into(),
age: Some(60),
touched: Some(crate::shadow::Touched {
files: 2,
added: 8,
removed: 1,
uncounted: 0,
}),
};
let mut log = a_log();
let plain = out::Palette::plain();
let commits = log.read.commits.clone();
log.turns = Some(vec![snapshot(0), snapshot(1)]);
let printed = log.human(&plain);
assert!(printed.contains("2 turns"), "the turn count: {printed}");
assert!(
printed.contains("~0") && printed.contains("~1"),
"each row is the spelling that gets that tree back: {printed}"
);
let rows: Vec<&str> = printed.lines().filter(|l| l.contains('~')).collect();
assert!(
rows.first().is_some_and(|r| r.contains("~0")),
"newest first: {rows:?}"
);
for c in &commits {
assert!(
!printed.contains(&c.subject),
"and not one of the agent's own subjects: {printed}"
);
}
assert!(
!printed.contains("yours from here"),
"no divider, because nothing here is going anywhere: {printed}"
);
assert!(
!printed.contains("not yours yet"),
"and no pending count, which counts a different list: {printed}"
);
assert!(
log.asides().hints.is_empty(),
"nothing to offer: there are no numbers here a command takes: {:?}",
log.asides()
);
let mut lost = a_log();
lost.read.replay_point_lost = true;
lost.turns = Some(vec![snapshot(0)]);
assert!(
lost.asides()
.warnings
.iter()
.any(|w| w.contains("the last handover is no longer")),
"a session-level warning still reaches the turn view: {:?}",
lost.asides()
);
let doc = log.json();
assert_eq!(doc["turns"].as_array().map(Vec::len), Some(2));
assert!(
doc["turns"][0]["number"].is_null(),
"a turn carries no number: {doc}"
);
assert_eq!(
doc["turns"][0]["ref"],
serde_json::json!("refs/omh/turn~0"),
"it carries the spelling that works instead: {doc}"
);
assert_eq!(
doc["checkpoints"].as_array().map(Vec::len),
Some(commits.len()),
"and the agent's own list is untouched: {doc}"
);
log.turns = None;
assert_eq!(log.json()["turns"], serde_json::Value::Null);
assert!(log.human(&plain).contains("not yours yet"));
}
#[test]
fn three_sessions_and_two_files_read_as_one_sentence() {
let mut listing = sessions(vec![session("s01", Work::Uncommitted(1))]);
listing.overlaps = vec![Overlap {
sessions: vec!["s01".into(), "s02".into(), "s03".into()],
paths: vec!["src/base.rs".into(), "src/render.rs".into()],
}];
let said = listing.human(&out::Palette::plain());
assert!(
said.contains("s01, s02 and s03 both change src/base.rs, src/render.rs"),
"a list as a person reads one: {said}"
);
}
#[test]
fn a_session_omh_could_not_read_is_not_a_session_that_collides_with_nobody() {
let mut listing = sessions(vec![session("s01", Work::Unknown)]);
listing.unreadable = vec!["s02".into()];
let said = listing.human(&out::Palette::plain());
assert!(
said.contains("could not read what s02 is changing"),
"named, and in the singular: {said}"
);
assert!(
said.contains("incomplete"),
"and what that means for the lines above it: {said}"
);
assert_eq!(
listing.json()["unreadable"],
json!(["s02"]),
"a script reading `overlaps: []` has to be able to tell a partial \
answer from a clean one"
);
let quiet = sessions(vec![session("s01", Work::Uncommitted(1))]);
assert!(!quiet
.human(&out::Palette::plain())
.contains("could not read"));
}
#[test]
fn a_file_two_sessions_are_both_changing_is_named_with_both() {
let changed = |pairs: &[(&str, &[&str])]| -> Vec<(String, Vec<String>)> {
pairs
.iter()
.map(|(id, paths)| {
(
id.to_string(),
paths.iter().map(|p| p.to_string()).collect(),
)
})
.collect()
};
assert_eq!(
overlaps(&changed(&[
("s01", &["src/render.rs", "src/base.rs", "only-mine.rs"]),
("s02", &["elsewhere.rs"]),
("s03", &["src/render.rs", "src/base.rs"]),
])),
vec![Overlap {
sessions: vec!["s01".into(), "s03".into()],
paths: vec!["src/base.rs".into(), "src/render.rs".into()],
}],
"one line for the pair, not one per file — and nothing about the files \
only one session has"
);
assert!(
overlaps(&changed(&[("s01", &["a.rs"]), ("s02", &["b.rs"])])).is_empty(),
"sessions working on different things collide with nobody"
);
assert!(
overlaps(&changed(&[("s01", &["a.rs", "a.rs"])])).is_empty(),
"and a session is never in collision with itself"
);
let three = overlaps(&changed(&[
("s01", &["shared.rs", "pair.rs"]),
("s02", &["shared.rs"]),
("s03", &["shared.rs", "pair.rs"]),
]));
assert_eq!(three.len(), 2, "grouped by who, not by file: {three:?}");
assert!(three
.iter()
.any(|o| o.sessions.len() == 3 && o.paths == ["shared.rs"]));
assert!(three
.iter()
.any(|o| o.sessions == ["s01", "s03"] && o.paths == ["pair.rs"]));
let reversed = overlaps(&changed(&[
("s03", &["x.rs", "y.rs"]),
("s01", &["y.rs", "x.rs"]),
]));
assert_eq!(reversed.len(), 1, "one pair, one line: {reversed:?}");
assert_eq!(reversed[0].sessions, ["s03", "s01"], "in listing order");
}
fn checkpoint(number: usize, subject: &str, landed: bool) -> crate::shadow::Checkpoint {
crate::shadow::Checkpoint {
number,
id: format!("{number:0>7}c"),
subject: subject.to_string(),
age: Some(number as u64 * 600),
touched: Some(crate::shadow::Touched {
files: number,
added: number * 10,
removed: number,
uncounted: 0,
}),
landed,
}
}
fn a_log() -> Log {
Log {
turns: None,
id: "s01".into(),
read: crate::shadow::Checkpoints {
commits: vec![
checkpoint(1, "Rename shadow to sandbox repo", true),
checkpoint(2, "Fix typo", true),
checkpoint(3, "Add the failing test first", false),
checkpoint(4, "Extract the tap guard", false),
],
uncommitted: 2,
..Default::default()
},
behind: Some(2),
base: "main".into(),
}
}
#[test]
fn the_log_draws_the_line_where_the_next_harvest_starts() {
let printed = a_log().human(&out::Palette::plain());
let lines: Vec<&str> = printed.lines().collect();
let at = |needle: &str| {
lines
.iter()
.position(|l| l.contains(needle))
.unwrap_or_else(|| panic!("no line for {needle}: {printed}"))
};
assert!(
at("Extract the tap guard") < at("Add the failing test first"),
"newest first: {printed}"
);
assert!(
at("Add the failing test first") < at("yours from here"),
"unharvested work is above the line: {printed}"
);
assert!(
at("yours from here") < at("Fix typo"),
"and what the branch already has is below it: {printed}"
);
}
#[test]
fn the_count_is_exact_even_where_one_line_cannot_say_it() {
let mut log = a_log();
log.read.commits[1].landed = false;
log.read.commits[2].landed = true;
let printed = log.human(&out::Palette::plain());
assert!(
printed.contains("2 not yours yet"),
"two are not the branch's, wherever the line falls: {printed}"
);
assert_eq!(log.json()["pending"], json!(2));
}
#[test]
fn a_log_with_nothing_handed_over_yet_has_no_line_to_draw() {
let mut log = a_log();
log.read.commits.iter_mut().for_each(|c| c.landed = false);
let printed = log.human(&out::Palette::plain());
assert!(
!printed.contains("yours from here"),
"nothing is the branch's yet, so there is no line: {printed}"
);
assert!(
printed.contains("Fix typo"),
"every checkpoint is still listed: {printed}"
);
}
#[test]
fn a_subject_the_agent_wrote_cannot_repaint_the_log() {
let mut log = a_log();
log.read.commits[3].subject = "Fix \u{1b}[2K\rand \u{8}nothing at all".into();
let printed = log.human(&out::Palette::plain());
assert!(
!printed.chars().any(|c| c.is_control() && c != '\n'),
"no control character survives into omh's own output: {printed:?}"
);
assert!(
printed.contains("nothing at all"),
"the words still arrive: {printed}"
);
}
#[test]
fn a_sandbox_that_has_committed_nothing_says_so() {
let mut log = a_log();
log.read.commits.clear();
log.read.uncommitted = 3;
let printed = log.human(&out::Palette::plain());
assert!(printed.contains("no checkpoints"), "it says so: {printed}");
assert!(
printed.contains('3'),
"and still reports the work that is there: {printed}"
);
}
#[test]
fn what_to_type_next_is_an_aside_and_not_the_log() {
let log = a_log();
let printed = log.human(&out::Palette::plain());
let hints = log.asides().hints.join("\n");
assert!(
hints.contains("omh s01 commit --keep"),
"the harvest is offered: {hints}"
);
assert!(
hints.contains("omh s01 diff 4"),
"the newest checkpoint is offered by number: {hints}"
);
assert!(
!printed.contains("--keep"),
"but not in the answer: {printed}"
);
}
#[test]
fn a_program_reading_the_log_gets_the_numbers_not_the_english() {
let v = a_log().json();
let checkpoints = v["checkpoints"].as_array().expect("a list");
assert_eq!(checkpoints.len(), 4);
assert_eq!(
checkpoints[0]["number"],
json!(4),
"newest first, as printed"
);
assert_eq!(checkpoints[0]["landed"], json!(false));
assert_eq!(checkpoints[3]["number"], json!(1));
assert_eq!(checkpoints[3]["landed"], json!(true));
assert_eq!(v["pending"], json!(2), "what --keep would take");
assert_eq!(v["uncommitted"], json!(2));
assert_eq!(v["behind"], json!(2));
}
#[test]
fn a_count_omh_could_not_take_does_not_print_as_zero() {
let render = |behind| {
let mut log = a_log();
log.behind = behind;
log.human(&out::Palette::plain())
};
assert!(
render(Some(2)).contains("2 behind main"),
"a count omh could take is reported"
);
assert_ne!(
render(None),
render(Some(0)),
"an unanswered question and a zero are the two answers it is most \
dangerous to confuse"
);
assert!(
!render(Some(0)).contains("behind"),
"nothing to say when the session is level with its base"
);
assert_eq!(a_log().json()["behind"], json!(2));
let mut unknown = a_log();
unknown.behind = None;
assert_eq!(unknown.json()["behind"], json!(null));
}
#[test]
fn a_session_with_nothing_left_to_hand_over_offers_nothing() {
let mut log = a_log();
log.read.commits.iter_mut().for_each(|c| c.landed = true);
let printed = log.human(&out::Palette::plain());
assert!(
!printed.contains("not yours yet"),
"there is no work the branch has not seen: {printed}"
);
assert!(
!printed.contains("yours from here"),
"and no line to draw, since everything is below it: {printed}"
);
assert!(
!log.asides().hints.join("\n").contains("--keep"),
"nothing left to bring onto the branch: {:?}",
log.asides().hints
);
assert_eq!(log.json()["pending"], json!(0));
}
#[test]
fn a_history_one_line_cannot_divide_gets_no_line() {
let mut log = a_log();
log.read.commits[1].landed = false;
log.read.commits[2].landed = true;
let printed = log.human(&out::Palette::plain());
let warnings = log.asides().warnings.join("\n");
assert!(
!printed.contains("yours from here"),
"no line can say this: {printed}"
);
assert!(
warnings.contains('1') && warnings.contains('3'),
"so the numbers already on the branch are named: {warnings}"
);
assert!(
printed.contains("2 not yours yet"),
"and the count stays exact: {printed}"
);
}
#[test]
fn what_omh_did_not_measure_never_renders_as_nothing() {
let mut log = a_log();
log.read.commits[3].touched = None;
log.read.commits[2].touched = Some(crate::shadow::Touched {
files: 2,
added: 0,
removed: 0,
uncounted: 2,
});
let printed = log.human(&out::Palette::plain());
let line = |needle: &str| {
printed
.lines()
.find(|l| l.contains(needle))
.unwrap_or_else(|| panic!("no row for {needle}: {printed}"))
.to_string()
};
assert!(
line("Extract the tap guard").contains("merge"),
"a merge says so rather than reporting 0 files: {}",
line("Extract the tap guard")
);
assert!(
!line("Extract the tap guard").contains("0 file"),
"and never claims a measurement it did not take"
);
assert!(
line("Add the failing test first").contains('·'),
"two files git would not count are marked, not blank: {}",
line("Add the failing test first")
);
assert_eq!(log.json()["checkpoints"][0]["merge"], json!(true));
assert_eq!(log.json()["checkpoints"][0]["files"], json!(null));
assert_eq!(log.json()["checkpoints"][1]["uncounted"], json!(2));
}
#[test]
fn a_checkpoint_omh_could_not_date_does_not_read_as_just_committed() {
let mut log = a_log();
log.read.commits[3].age = None;
let printed = log.human(&out::Palette::plain());
let row = printed
.lines()
.find(|l| l.contains("Extract the tap guard"))
.unwrap();
assert!(row.contains('?'), "the age is unknown and says so: {row}");
assert!(
!row.contains("0s"),
"not the strongest possible claim as a fallback for having none: {row}"
);
assert_eq!(log.json()["checkpoints"][0]["age_seconds"], json!(null));
}
#[test]
fn work_the_log_cannot_show_is_said_and_the_harvest_is_not_offered() {
for (label, wreck) in [
(
"commits on a branch it wandered off",
(|log: &mut Log| log.read.unreachable = 3) as fn(&mut Log),
),
("a lost replay point", |log: &mut Log| {
log.read.replay_point_lost = true
}),
] {
let mut log = a_log();
wreck(&mut log);
let warnings = log.asides().warnings.join("\n");
assert!(
!warnings.is_empty(),
"{label} has to reach the reader: {warnings}"
);
assert!(
!log.asides().hints.join("\n").contains("--keep"),
"--keep is not offered when omh knows it would be refused ({label}): {:?}",
log.asides().hints
);
}
assert_eq!(a_log().json()["unreachable"], json!(0));
assert_eq!(a_log().json()["replay_point_lost"], json!(false));
}
#[test]
fn the_json_carries_every_field_a_script_reads() {
let mut log = a_log();
log.read.commits[3].subject = "Fix \u{1b}[31m things".into();
let v = log.json();
let newest = &v["checkpoints"][0];
assert_eq!(v["session"], json!("s01"));
assert_eq!(v["base"], json!("main"));
assert_eq!(v["uncommitted"], json!(2));
assert_eq!(newest["number"], json!(4));
assert_eq!(newest["files"], json!(4));
assert_eq!(newest["added"], json!(40), "added is not removed");
assert_eq!(newest["removed"], json!(4), "and removed is not added");
assert_eq!(newest["age_seconds"], json!(2400));
assert!(newest["id"].as_str().is_some_and(|id| !id.is_empty()));
assert!(
newest["subject"].as_str().unwrap().contains('\u{1b}'),
"the escape survives into JSON: {newest}"
);
}
#[test]
fn churn_drops_the_half_that_is_zero_and_never_blanks_the_uncounted() {
let t = |added, removed, uncounted| {
churn(&crate::shadow::Touched {
files: 1,
added,
removed,
uncounted,
})
};
assert_eq!(t(48, 12, 0), "+48 −12");
assert_eq!(t(48, 0, 0), "+48", "no +48 −0 in a column scanned for size");
assert_eq!(t(0, 12, 0), "−12");
assert_eq!(t(0, 0, 0), "", "git measured, and nothing changed");
assert_eq!(
t(0, 0, 2),
"·2",
"git would not measure — not the same, not blank"
);
assert_eq!(t(48, 12, 1), "+48 −12 ·1");
}
#[test]
fn an_age_reads_as_one_unit() {
assert_eq!(ago(0), "0s");
assert_eq!(ago(59), "59s");
assert_eq!(ago(60), "1m");
assert_eq!(ago(60 * 60 - 1), "59m");
assert_eq!(ago(60 * 60), "1h");
assert_eq!(ago(36 * 60 * 60), "36h");
assert_eq!(ago(48 * 60 * 60), "2d");
assert_eq!(ago(9 * 24 * 60 * 60), "9d");
}
use super::*;
use crate::out::{emit, Format, Palette};
fn session(id: &str, work: Work) -> Session {
Session {
id: id.into(),
label: "claude".into(),
running: Some(crate::image::Running::No),
work: Some(work),
behind: Some(0),
}
}
fn sessions(rows: Vec<Session>) -> Sessions {
Sessions {
sessions: rows,
base: "main".into(),
leftovers: vec![],
overlaps: vec![],
unreadable: vec![],
}
}
#[test]
fn a_session_behind_its_base_is_told_what_to_do_about_it() {
let behind = |id: &str, n: Option<usize>| {
let mut row = session(id, Work::Clean);
row.behind = n;
row
};
let current = sessions(vec![behind("s01", Some(0))]);
assert!(
!current.asides().hints.join(" ").contains("sync"),
"nothing to say when nothing is behind: {:?}",
current.asides()
);
let stale = sessions(vec![
behind("s01", Some(0)),
behind("s02", Some(12)),
behind("s03", Some(3)),
]);
let said = stale.asides().hints.join("\n");
assert!(
said.contains("omh s02 sync") && said.contains("omh s03 sync"),
"each one that is behind, by name: {said}"
);
assert!(
!said.contains("omh s01 sync"),
"and not the one that is current: {said}"
);
let unknown = sessions(vec![behind("s01", None)]);
assert!(
!unknown.asides().hints.join(" ").contains("sync"),
"an unanswered count is not a reason to act: {:?}",
unknown.asides()
);
let said = format!("{:?}", unknown.asides());
assert!(
said.contains("could not measure") && said.contains("s01 log"),
"the row omh could not measure still gets a route: {said}"
);
for line in &unknown.asides().warnings {
assert!(!line.contains(" "), "a fold's indentation shipped: {line}");
}
}
#[test]
fn a_runtime_that_would_not_answer_is_not_rendered_as_a_stopped_sandbox() {
use crate::image::Running;
let render = |running| {
let mut row = session("s01", Work::Clean);
row.running = running;
sessions(vec![row]).human(&out::Palette::plain())
};
assert!(render(Some(Running::Yes)).contains("up"));
assert!(render(Some(Running::No)).contains("stopped"));
for (a, b, what) in [
(
Some(Running::Unknown("daemon down".into())),
Some(Running::No),
"a runtime that would not answer is not a stopped sandbox",
),
(
Some(Running::Yes),
Some(Running::Unknown("daemon down".into())),
"and it is not a sandbox omh confirmed was up either — `up?` \
contains `up`, so asserting on that substring cannot tell them apart",
),
(
None,
Some(Running::Unknown("daemon down".into())),
"a question nobody asked is not a question that went unanswered",
),
] {
assert_ne!(render(a), render(b), "{what}");
}
}
#[test]
fn a_sandbox_omh_could_not_ask_about_is_null_and_not_false() {
use crate::image::Running;
let field = |running| {
let mut row = session("s01", Work::Clean);
row.running = running;
sessions(vec![row]).json()["sessions"][0]["running"].clone()
};
assert_eq!(field(Some(Running::Yes)), json!(true));
assert_eq!(field(Some(Running::No)), json!(false));
assert_eq!(
field(Some(Running::Unknown("daemon down".into()))),
serde_json::Value::Null,
"a question omh could not answer is not a `false`"
);
assert_eq!(field(None), serde_json::Value::Null);
let why = |running| {
let mut row = session("s01", Work::Clean);
row.running = running;
sessions(vec![row]).json()["sessions"][0]["running_unknown"].clone()
};
assert_eq!(
why(Some(Running::Unknown("daemon down".into()))),
json!("daemon down"),
"the runtime's reason reaches a script"
);
assert_eq!(
why(None),
serde_json::Value::Null,
"and nobody-asked carries no reason, because there is none"
);
}
#[test]
fn a_running_session_is_told_the_form_of_sync_that_works_on_it() {
let running = |up: bool| {
let mut row = session("s01", Work::Clean);
row.behind = Some(4);
row.running = Some(match up {
true => crate::image::Running::Yes,
false => crate::image::Running::No,
});
sessions(vec![row]).asides().hints.join("\n")
};
assert!(
running(true).contains("omh s01 sync --down"),
"a running session is told to stop it first: {}",
running(true)
);
assert!(
!running(false).contains("--down"),
"and a stopped one is not told to stop something: {}",
running(false)
);
}
#[test]
fn the_suggested_commands_line_up_for_ids_of_any_width() {
let row = |id: &str| {
let mut s = session(id, Work::Clean);
s.behind = Some(2);
s
};
let hints = sessions(vec![row("s01"), row("café"), row("a-long-one")])
.asides()
.hints;
let columns: Vec<usize> = hints
.iter()
.map(|h| out::display_width(h.split("bring").next().unwrap()))
.collect();
assert!(
columns.windows(2).all(|w| w[0] == w[1]),
"the description starts at one column: {hints:#?}"
);
}
#[test]
fn the_dashboard_does_not_render_an_unanswered_count_as_up_to_date() {
let render = |behind| {
let mut row = session("s01", Work::Clean);
row.behind = behind;
sessions(vec![row]).human(&out::Palette::plain())
};
assert!(
render(Some(12)).contains("12 behind main"),
"a count omh could take is reported: {}",
render(Some(12))
);
assert_ne!(
render(None),
render(Some(0)),
"an unanswered question and a zero are the two answers it is most \
dangerous to confuse — the dashboard is where that decision is made"
);
let unknown = behind_cell(None, "main");
let cell = unknown.text();
assert!(
!cell.split_whitespace().any(|word| word
.trim_matches(|c| c == '(' || c == ')')
.parse::<usize>()
.is_ok()),
"*could not tell* is not dressed up as a count: {cell}"
);
assert!(
cell.contains("how far behind main"),
"and it does ask the question rather than going quiet: {cell}"
);
assert!(
behind_cell(Some(4), "develop").text().contains("develop")
&& behind_cell(None, "develop").text().contains("develop"),
"the base is read, not assumed"
);
}
#[test]
fn the_wide_listing_answers_the_same_question_the_same_way() {
let render = |behind| {
let mut row = session("s01", Work::Clean);
row.behind = behind;
Inventory {
harnesses: vec![],
adapters_dir: "/adapters".into(),
editors: vec![],
sessions: vec![row],
base: "main".into(),
}
.human(&out::Palette::plain())
};
assert_ne!(
render(None),
render(Some(0)),
"the wide listing keeps the two apart too"
);
assert!(
render(Some(7)).contains("7 behind main"),
"and still reports a count it could take: {}",
render(Some(7))
);
}
#[test]
fn a_count_omh_could_not_take_is_null_and_not_zero_in_both_listings() {
let row = |behind| {
let mut s = session("s01", Work::Clean);
s.behind = behind;
s
};
let wide = |behind| {
Inventory {
harnesses: vec![],
adapters_dir: "/adapters".into(),
editors: vec![],
sessions: vec![row(behind)],
base: "main".into(),
}
.json()["sessions"][0]["behind"]
.clone()
};
for (dashboard, listing, expected, what) in [
(
sessions(vec![row(None)]).json()["sessions"][0]["behind"].clone(),
wide(None),
serde_json::Value::Null,
"a count omh could not take is null",
),
(
sessions(vec![row(Some(0))]).json()["sessions"][0]["behind"].clone(),
wide(Some(0)),
json!(0),
"and a zero is a zero",
),
] {
assert_eq!(dashboard, expected, "{what}, on the dashboard");
assert_eq!(listing, expected, "{what}, in the wide listing");
}
}
#[test]
fn a_session_omh_cannot_read_is_clean_in_neither_format() {
let report = sessions(vec![session("s01", Work::Unknown)]);
let human = emit(&report, Format::Human, &Palette::plain());
assert!(
human.contains('?'),
"omh cannot tell, and must say so rather than imply clean — got {human:?}"
);
let machine = report.json();
let state = &machine["sessions"][0]["work"]["state"];
assert_eq!(
state, "unknown",
"and a script must be able to tell not-known from nothing-to-do"
);
assert_ne!(state, "clean");
assert!(
machine["sessions"][0]["work"].get("count").is_none(),
"an unknown state carries no count, because inventing 0 is the bug"
);
}
#[test]
fn a_person_and_a_script_are_told_about_the_same_sessions() {
let report = sessions(vec![
session("s01", Work::Uncommitted(3)),
session("s02", Work::Published("feat/a".into())),
]);
let human = emit(&report, Format::Human, &Palette::plain());
let machine = report.json();
assert_eq!(machine["sessions"].as_array().unwrap().len(), 2);
for id in ["s01", "s02"] {
assert!(human.contains(id), "{id} is missing from {human:?}");
}
assert_eq!(
human.lines().filter(|l| !l.trim().is_empty()).count(),
2,
"one line per session, and no more — got {human:?}"
);
}
#[test]
fn the_column_says_what_it_has_always_said() {
assert_eq!(Work::Uncommitted(1).human(), "1 uncommitted");
assert_eq!(Work::ToPush(1).human(), "1 to push");
assert_eq!(Work::Published("feat/a".into()).human(), "→ feat/a");
assert_eq!(Work::Unknown.human(), "?");
assert_eq!(Work::Clean.human(), "", "clean is the quiet one");
}
fn inventory(harnesses: Vec<Harness>) -> Inventory {
Inventory {
harnesses,
adapters_dir: "/home/u/.omh/adapters".into(),
editors: vec![],
sessions: vec![],
base: "main".into(),
}
}
#[test]
fn a_harness_with_no_account_is_the_row_you_came_to_read() {
let report = inventory(vec![
Harness {
name: "claude".into(),
accounts: vec![],
},
Harness {
name: "opencode".into(),
accounts: vec!["work".into()],
},
]);
let human = emit(&report, Format::Human, &Palette::plain());
assert!(
human.contains("claude") && human.contains("not authed"),
"the un-authed harness is named and its state given — got {human:?}"
);
let machine = report.json();
assert_eq!(
machine["harnesses"].as_array().unwrap().len(),
2,
"both harnesses reach a script, authed or not"
);
assert_eq!(machine["harnesses"][0]["authed"], false);
assert_eq!(machine["harnesses"][1]["authed"], true);
}
#[test]
fn no_section_reaches_a_person_without_also_reaching_a_script() {
let report = Inventory {
harnesses: vec![Harness {
name: "claude".into(),
accounts: vec![],
}],
editors: vec![Editor {
name: "vscode".into(),
installed: true,
}],
sessions: vec![session("s01", Work::Clean)],
..inventory(vec![])
};
let human = emit(&report, Format::Human, &Palette::plain());
let machine = report.json();
for section in ["harnesses", "editors", "sessions"] {
assert!(
human.contains(&format!("{section}:")),
"{section} is missing from the human report — got {human:?}"
);
assert!(
machine[section].as_array().is_some_and(|a| !a.is_empty()),
"{section} is missing from the machine report — got {machine}"
);
}
}
fn check(name: &str, ok: bool) -> crate::doctor::Outcome {
crate::doctor::Outcome {
name: name.into(),
ok,
detail: if ok { "resolves" } else { "missing" }.into(),
}
}
#[test]
fn a_failed_check_is_legible_with_no_colour_at_all() {
let report = Doctor {
harness: "claude".into(),
tag: "omh/claude:abc".into(),
account: None,
outcomes: vec![check("rules", true), check("mcp", false)],
};
let human = emit(&report, Format::Human, &Palette::plain());
assert!(
!human.contains('\x1b'),
"the premise: this reader has no colour at all"
);
let mcp = human
.lines()
.find(|l| l.contains("mcp"))
.expect("the failing check is listed");
let rules = human
.lines()
.find(|l| l.contains("rules"))
.expect("the passing check is listed");
assert_ne!(
mcp.chars().find(|c| !c.is_whitespace()),
rules.chars().find(|c| !c.is_whitespace()),
"pass and fail must differ by more than colour — got {human:?}"
);
assert!(
!human.contains("checks passed"),
"and a run with a failure in it never claims success — got {human:?}"
);
}
#[test]
fn the_tally_is_the_list_counted_and_the_verdict_is_not_a_tally() {
let report = Doctor {
harness: "claude".into(),
tag: "t".into(),
account: Some("work".into()),
outcomes: vec![check("a", true), check("b", false), check("c", false)],
};
let machine = report.json();
assert_eq!(machine["failed_count"], 2);
assert_eq!(machine["passed_count"], 1);
assert_eq!(
machine["passed_count"].as_u64().unwrap() + machine["failed_count"].as_u64().unwrap(),
machine["checks"].as_array().unwrap().len() as u64
);
assert_eq!(
machine["ok"],
json!(false),
"the verdict is a bool, and a run with failures in it is false"
);
assert!(
machine["ok"].is_boolean(),
"never a count — a truthy number here says `passed` about a failed run"
);
assert_eq!(
machine["account"], "work",
"and whose credentials were checked is on the record, not in a header"
);
}
#[test]
fn a_probe_that_produced_nothing_is_not_reported_as_a_pass() {
let empty = Doctor {
harness: "claude".into(),
tag: "t".into(),
account: None,
outcomes: vec![],
};
assert_eq!(empty.failed(), 0, "nothing failed, because nothing ran");
assert_eq!(
empty.json()["ok"],
json!(false),
"and that is still not a pass"
);
}
#[test]
fn following_the_catalogue_is_not_a_list_that_happens_to_be_complete() {
let report = Repo {
dir: "/r/.omh".into(),
settings: vec![],
features: vec![],
using: vec![
Using {
capability: "rules".into(),
selected: None,
unselected: vec![],
},
Using {
capability: "skills".into(),
selected: Some(vec!["a".into(), "b".into()]),
unselected: vec![],
},
],
notices: vec![],
};
let human = emit(&report, Format::Human, &Palette::plain());
assert!(
human.contains("everything"),
"an unpinned capability says so in words — got {human:?}"
);
let machine = report.json();
assert!(
machine["using"][0]["selected"].is_null(),
"and as null to a script, not as an array of today's names"
);
assert_eq!(
machine["using"][1]["selected"],
json!(["a", "b"]),
"while a real selection is the list it is"
);
}
#[test]
fn an_overridden_setting_names_what_it_overrode() {
let report = Repo {
dir: "/r/.omh".into(),
settings: vec![Effective {
key: "account".into(),
value: "work".into(),
layer: "local".into(),
shadows: vec!["shared".into(), "personal".into()],
}],
features: vec![],
using: vec![],
notices: vec![],
};
let human = emit(&report, Format::Human, &Palette::plain());
for part in ["account", "work", "local", "shared", "personal"] {
assert!(
human.contains(part),
"{part} is missing from the provenance — got {human:?}"
);
}
assert_eq!(
report.json()["settings"][0]["overrides"],
json!(["shared", "personal"])
);
}
#[test]
fn an_action_gives_a_script_fields_and_not_a_sentence_to_parse() {
let action = Action::new(
"session-removed",
"removed session s01; branch omh/s01 kept",
)
.next("git log main..omh/s01")
.data(json!({ "session": "s01", "branch_kept": true, "commits": 3 }));
let machine = action.json();
assert_eq!(machine["action"], "session-removed");
assert_eq!(
machine["session"], "s01",
"the id is a field, not something to regex out of the message"
);
assert_eq!(machine["branch_kept"], true);
assert_eq!(machine["commits"], 3);
let human = emit(&action, Format::Human, &Palette::plain());
assert!(human.starts_with("removed session s01"));
assert!(
!human.contains("git log main..omh/s01"),
"the next step is not part of the answer — it would land in a \
redirected stdout — got {human:?}"
);
let hints = action.asides().hints;
assert_eq!(
hints.iter().map(|h| h.trim()).collect::<Vec<_>>(),
vec!["git log main..omh/s01"],
"it is still offered to the person, on stderr"
);
}
#[test]
fn a_suggested_command_survives_being_pasted() {
let action = Action::new("x", "done")
.next("omh s01 rm")
.note("teammates keep it until you commit the deletion");
let hints = action.asides().hints;
assert!(
hints.iter().any(|l| l.trim() == "omh s01 rm"),
"the command is handed over verbatim — got {hints:?}"
);
let human = emit(&action, Format::Human, &Palette::plain());
assert!(
human.contains("teammates keep it"),
"the consequence is the answer and stays on stdout — got {human:?}"
);
let machine = action.json();
assert_eq!(
machine["next"],
json!(["omh s01 rm"]),
"`next` is runnable commands and nothing else"
);
assert_eq!(
machine["notes"],
json!(["teammates keep it until you commit the deletion"]),
"and prose has its own key, so a script can run one and show the other"
);
}
#[test]
fn what_omh_would_not_take_is_named_and_not_merely_absent() {
let report = Imported {
what: "claude hooks".into(),
source: "/h/settings.json".into(),
considered: vec![
Considered {
name: "fmt".into(),
verdict: Verdict::Took,
detail: "runs on save".into(),
},
Considered {
name: "sneaky".into(),
verdict: Verdict::Skipped,
detail: "is a symlink".into(),
},
Considered {
name: "PreToolUse[0]".into(),
verdict: Verdict::Left,
detail: "a handler with `if`, which omh cannot express".into(),
},
],
noun: "hooks".into(),
..Default::default()
};
let human = emit(&report, Format::Human, &Palette::plain());
for (word, why) in [
("skipped", "is a symlink"),
("left", "which omh cannot express"),
] {
assert!(
human.contains(word) && human.contains(why),
"{word} and its reason must both survive — got {human:?}"
);
}
let machine = report.json();
assert_eq!(machine["took"], 1);
assert_eq!(machine["skipped"], 1);
assert_eq!(machine["left"], 1);
assert_eq!(
machine["considered"].as_array().unwrap().len(),
3,
"and every name is in the list, whatever became of it"
);
}
#[test]
fn nothing_to_report_is_still_something_to_say() {
let human = emit(&sessions(vec![]), Format::Human, &Palette::plain());
assert_eq!(human.trim(), "no sessions");
let machine = sessions(vec![]).json();
assert_eq!(
machine["sessions"].as_array().unwrap().len(),
0,
"and the machine format is an empty list, not a missing key"
);
}
}