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 Down {
pub sessions: Vec<(String, bool)>,
}
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),
if *stopped {
Cell::plain("stopped; worktree and branch survive")
} else {
Cell::styled("was not running", out::DIM)
},
]);
}
t.render(p)
}
fn json(&self) -> serde_json::Value {
json!({
"action": "sessions-down",
"sessions": self.sessions.iter().map(|(id, stopped)| json!({
"session": id,
"stopped": stopped,
})).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: bool,
pub work: Option<Work>,
pub behind: usize,
}
#[derive(Debug, Clone)]
pub struct Sessions {
pub sessions: Vec<Session>,
pub base: String,
pub leftovers: 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),
if s.running {
Cell::styled("up", out::OK)
} else {
Cell::styled("stopped", out::DIM)
},
match &s.work {
Some(work) => Cell::styled(work.human(), work.style()),
None => Cell::plain(""),
},
match s.behind {
0 => Cell::plain(""),
n => Cell::styled(format!("({n} behind {})", self.base), out::DIM),
},
]);
}
table.render(p)
}
fn asides(&self) -> out::Asides {
if self.leftovers.is_empty() {
return out::Asides::default();
}
out::Asides::default()
.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 s rm <id>")
}
fn json(&self) -> serde_json::Value {
json!({
"base": self.base,
"sessions": self.sessions.iter().map(|s| json!({
"id": s.id,
"label": s.label,
"running": s.running,
"work": s.work.as_ref().map(Work::json),
"behind": s.behind,
})).collect::<Vec<_>>(),
"leftovers": self.leftovers,
})
}
}
#[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),
match sess.behind {
0 => Cell::plain(""),
n => Cell::styled(format!("({n} behind {})", self.base), out::DIM),
},
]);
}
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,
})
}
}
#[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: recall, cost accounting.")
));
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 base: String,
pub summary: String,
}
impl Report for Diff {
fn human(&self, p: &out::Palette) -> String {
if self.summary.trim().is_empty() {
return format!(
"{}\n",
p.paint(
out::DIM,
&format!("no changes on {} (against {})", self.label, self.base)
)
);
}
self.summary.clone()
}
fn json(&self) -> serde_json::Value {
json!({
"session": self.label,
"base": self.base,
"changed": !self.summary.trim().is_empty(),
"summary": self.summary,
})
}
}
#[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 {
use super::*;
use crate::out::{emit, Format, Palette};
fn session(id: &str, work: Work) -> Session {
Session {
id: id.into(),
label: "claude".into(),
running: false,
work: Some(work),
behind: 0,
}
}
fn sessions(rows: Vec<Session>) -> Sessions {
Sessions {
sessions: rows,
base: "main".into(),
leftovers: vec![],
}
}
#[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 s rm s01")
.note("teammates keep it until you commit the deletion");
let hints = action.asides().hints;
assert!(
hints.iter().any(|l| l.trim() == "omh s rm s01"),
"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 s rm s01"]),
"`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"
);
}
}