use std::collections::BTreeMap;
use std::io::{self, Write as _};
use std::path::PathBuf;
use serde::Serialize;
use crate::listing::Format;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct SliceCounts {
pub(crate) active: usize,
pub(crate) blocked: usize,
pub(crate) total: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct NextItem {
pub(crate) id: String,
pub(crate) status: String,
pub(crate) title: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct WorkSection {
pub(crate) slices: SliceCounts,
pub(crate) backlog: BTreeMap<String, usize>,
pub(crate) next_up: Vec<NextItem>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct BlockedItem {
pub(crate) id: String,
pub(crate) title: String,
pub(crate) blocked_by: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct BootSection {
pub(crate) staleness: String,
pub(crate) age_seconds: u64,
pub(crate) commit: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct CommitLine {
pub(crate) hash: String,
pub(crate) subject: String,
pub(crate) relative_time: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Status {
pub(crate) work: WorkSection,
pub(crate) blocked_slices: Vec<BlockedItem>,
pub(crate) blocked_backlog: Vec<BlockedItem>,
pub(crate) boot: BootSection,
pub(crate) recent_commits: Vec<CommitLine>,
}
pub(crate) fn assemble_status(
slice_counts: SliceCounts,
backlog_counts: BTreeMap<String, usize>,
next_up: Vec<NextItem>,
blocked_slices: Vec<BlockedItem>,
blocked_backlog: Vec<BlockedItem>,
boot: BootSection,
recent_commits: Vec<CommitLine>,
) -> Status {
Status {
work: WorkSection {
slices: slice_counts,
backlog: backlog_counts,
next_up,
},
blocked_slices,
blocked_backlog,
boot,
recent_commits,
}
}
fn is_empty(status: &Status) -> bool {
status.work.slices.active == 0 && status.work.backlog.values().sum::<usize>() == 0
}
pub(crate) fn render_human(status: &Status) -> String {
if is_empty(status) {
return "No active work.\n".to_string();
}
let mut parts: Vec<String> = Vec::new();
parts.push("Work\n".to_string());
let blocked_suffix = if status.work.slices.blocked > 0 {
format!(" ({} blocked)", status.work.slices.blocked)
} else {
String::new()
};
parts.push(format!(
" slices: {} active{blocked_suffix}, {} total\n",
status.work.slices.active, status.work.slices.total
));
if !status.work.backlog.is_empty() {
let kinds: Vec<String> = status
.work
.backlog
.iter()
.map(|(k, v)| format!("{v} {k}{}", if *v == 1 { "" } else { "s" }))
.collect();
parts.push(format!(" backlog: {}\n", kinds.join(", ")));
}
if !status.work.next_up.is_empty() {
let items: Vec<String> = status
.work
.next_up
.iter()
.map(|n| format!("{} ({})", n.id, n.status))
.collect();
parts.push(format!(" next up: {}\n", items.join(", ")));
}
if !status.blocked_slices.is_empty() {
parts.push("\nBlocked slices\n".to_string());
for item in &status.blocked_slices {
parts.push(format!(
" {} blocked by {} — {}\n",
item.id,
item.blocked_by.join(", "),
item.title
));
}
}
if !status.blocked_backlog.is_empty() {
parts.push("\nBlocked backlog\n".to_string());
for item in &status.blocked_backlog {
parts.push(format!(
" {} blocked by {} — {}\n",
item.id,
item.blocked_by.join(", "),
item.title
));
}
}
parts.push(format!("\nBoot\n boot.md {}", boot_line(&status.boot)));
if !status.recent_commits.is_empty() {
parts.push("\nRecent commits\n".to_string());
for c in &status.recent_commits {
parts.push(format!(
" {} {} — {}\n",
c.hash, c.subject, c.relative_time
));
}
}
parts.push("\n".to_string());
parts.concat()
}
fn boot_line(boot: &BootSection) -> String {
match boot.staleness.as_str() {
"fresh" => {
let mins = boot.age_seconds.div_ceil(60);
format!("fresh ({mins} min ago) from commit {}\n", boot.commit)
}
"stale" => format!("stale from commit {}\n", boot.commit),
_missing => "missing\n".to_string(),
}
}
#[derive(Serialize)]
struct StatusEnvelope<'a> {
kind: &'static str,
#[serde(flatten)]
status: &'a Status,
}
pub(crate) fn render_json(status: &Status) -> serde_json::Result<String> {
let envelope = StatusEnvelope {
kind: "status",
status,
};
serde_json::to_string_pretty(&envelope)
}
pub(crate) fn run(path: Option<PathBuf>, format: Format, json: bool) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let resolved_format = if json { Format::Json } else { format };
let slice_metas =
crate::meta::read_metas(&root.join(".doctrine/slice"), "slice").unwrap_or_default();
let slice_total = slice_metas.len();
let slice_active: Vec<&crate::meta::Meta> = slice_metas
.iter()
.filter(|m| !matches!(m.status.as_str(), "done" | "abandoned"))
.collect();
let slice_active_count = slice_active.len();
let backlog_items = crate::backlog::read_all(&root).unwrap_or_default();
let open_items: Vec<&crate::backlog::BacklogItem> = backlog_items
.iter()
.filter(|i| !i.status.is_terminal())
.collect();
let mut backlog_counts: BTreeMap<String, usize> = BTreeMap::new();
for item in &open_items {
*backlog_counts
.entry(item.kind.as_str().to_string())
.or_insert(0) += 1;
}
let next_rows = crate::priority::surface::next(&root).unwrap_or_default();
let next_up: Vec<NextItem> = next_rows
.iter()
.take(5)
.map(|r| NextItem {
id: r.id.clone(),
status: r.status.clone(),
title: r.title.clone(),
})
.collect();
let (blocked_slices, blocked_backlog, slice_blocked_count) =
if let Ok(graph) = crate::priority::graph::build(&root) {
let mut bs: Vec<BlockedItem> = Vec::new();
for m in &slice_active {
let key = crate::relation_graph::EntityKey {
prefix: "SL",
id: m.id,
};
if crate::priority::channels::blocked(&graph, key) {
let blockers = crate::priority::channels::blocked_by(&graph, key);
bs.push(BlockedItem {
id: key.canonical(),
title: m.title.clone(),
blocked_by: blockers.iter().map(|k| k.canonical()).collect(),
});
}
}
bs.sort_by(|a, b| a.id.cmp(&b.id));
let cb = bs.len();
bs.truncate(5);
let mut bb: Vec<BlockedItem> = Vec::new();
for item in &open_items {
let key = crate::relation_graph::EntityKey {
prefix: item.kind.prefix(),
id: item.id,
};
if crate::priority::channels::blocked(&graph, key) {
let blockers = crate::priority::channels::blocked_by(&graph, key);
bb.push(BlockedItem {
id: key.canonical(),
title: item.title.clone(),
blocked_by: blockers.iter().map(|k| k.canonical()).collect(),
});
}
}
bb.sort_by(|a, b| a.id.cmp(&b.id));
bb.truncate(5);
(bs, bb, cb)
} else {
(Vec::new(), Vec::new(), 0)
};
let slice_counts = SliceCounts {
active: slice_active_count,
blocked: slice_blocked_count,
total: slice_total,
};
let exec = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("doctrine"));
let report = crate::boot::boot_check(&root, &exec);
let boot_path = root.join(".doctrine/state/boot.md");
let (staleness, age_seconds, commit) = if boot_path.exists() {
let staleness_str = if report.stale { "stale" } else { "fresh" }.to_string();
let age = std::fs::metadata(&boot_path)
.ok()
.and_then(|md| md.modified().ok())
.and_then(|mtime| {
std::time::SystemTime::now()
.duration_since(mtime)
.ok()
.map(|d| d.as_secs())
})
.unwrap_or(0);
let commit_sha = crate::git::git_text(
&root,
&["log", "-1", "--format=%h", "--", ".doctrine/state/boot.md"],
)
.ok()
.filter(|s| !s.is_empty())
.or_else(|| crate::git::git_text(&root, &["log", "-1", "--format=%h"]).ok())
.unwrap_or_default();
(staleness_str, age, commit_sha)
} else {
("missing".to_string(), 0_u64, String::new())
};
let boot = BootSection {
staleness,
age_seconds,
commit,
};
let recent_commits = parse_git_log(&root);
let status = assemble_status(
slice_counts,
backlog_counts,
next_up,
blocked_slices,
blocked_backlog,
boot,
recent_commits,
);
let out = match resolved_format {
Format::Table => render_human(&status),
Format::Json => render_json(&status)
.map_err(|e| anyhow::anyhow!("failed to serialize status JSON: {e}"))?,
};
write!(io::stdout(), "{out}")?;
Ok(())
}
fn parse_git_log(root: &std::path::Path) -> Vec<CommitLine> {
let Ok(text) = crate::git::git_text(root, &["log", "-5", "--format=%h %s — %ar"]) else {
return Vec::new();
};
text.lines()
.filter_map(|line| {
let (hash, rest) = line.split_once(' ')?;
let (subject, relative_time) = rest.rsplit_once(" — ")?;
Some(CommitLine {
hash: hash.to_string(),
subject: subject.to_string(),
relative_time: relative_time.to_string(),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn counts(active: usize, blocked: usize, total: usize) -> SliceCounts {
SliceCounts {
active,
blocked,
total,
}
}
fn empty_counts() -> SliceCounts {
counts(0, 0, 0)
}
fn empty_backlog() -> BTreeMap<String, usize> {
BTreeMap::new()
}
fn empty_next() -> Vec<NextItem> {
Vec::new()
}
fn empty_blocked() -> Vec<BlockedItem> {
Vec::new()
}
fn fresh_boot() -> BootSection {
BootSection {
staleness: "fresh".to_string(),
age_seconds: 120,
commit: "a3f7b2c".to_string(),
}
}
fn no_commits() -> Vec<CommitLine> {
Vec::new()
}
#[test]
fn empty_corpus_shows_no_active_work() {
let status = assemble_status(
empty_counts(),
empty_backlog(),
empty_next(),
empty_blocked(),
empty_blocked(),
fresh_boot(),
no_commits(),
);
assert_eq!(render_human(&status), "No active work.\n");
}
#[test]
fn empty_corpus_json_has_expected_keys() {
let status = assemble_status(
empty_counts(),
empty_backlog(),
empty_next(),
empty_blocked(),
empty_blocked(),
fresh_boot(),
no_commits(),
);
let json = render_json(&status).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["kind"], "status");
assert_eq!(parsed["work"]["slices"]["active"], 0);
assert_eq!(parsed["work"]["slices"]["blocked"], 0);
assert_eq!(parsed["work"]["slices"]["total"], 0);
}
#[test]
fn non_empty_status_shows_all_sections() {
let mut backlog = BTreeMap::new();
backlog.insert("issue".to_string(), 3_usize);
backlog.insert("improvement".to_string(), 1_usize);
let next = vec![NextItem {
id: "SL-086".to_string(),
status: "design".to_string(),
title: "CLI UX".to_string(),
}];
let blocked_slices = vec![BlockedItem {
id: "SL-082".to_string(),
title: "reconcile engine".to_string(),
blocked_by: vec!["SL-047".to_string()],
}];
let commits = vec![CommitLine {
hash: "a3f7b2c".to_string(),
subject: "plan(SL-086): phase sheets".to_string(),
relative_time: "2 min ago".to_string(),
}];
let status = assemble_status(
counts(2, 1, 4),
backlog,
next,
blocked_slices,
empty_blocked(),
fresh_boot(),
commits,
);
let output = render_human(&status);
assert!(output.contains("Work\n"));
assert!(output.contains("slices: 2 active (1 blocked), 4 total\n"));
assert!(output.contains("backlog: 1 improvement, 3 issues\n"));
assert!(output.contains("next up: SL-086 (design)\n"));
assert!(output.contains("Blocked slices\n"));
assert!(output.contains("SL-082 blocked by SL-047 — reconcile engine\n"));
assert!(output.contains("Boot\n"));
assert!(output.contains("fresh (2 min ago) from commit a3f7b2c\n"));
assert!(output.contains("Recent commits\n"));
assert!(output.contains("a3f7b2c plan(SL-086): phase sheets — 2 min ago\n"));
}
#[test]
fn json_output_has_expected_shape() {
let status = assemble_status(
counts(2, 1, 4),
empty_backlog(),
empty_next(),
empty_blocked(),
empty_blocked(),
fresh_boot(),
no_commits(),
);
let json = render_json(&status).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["kind"], "status");
assert_eq!(v["work"]["slices"]["active"], 2);
assert_eq!(v["work"]["slices"]["blocked"], 1);
assert_eq!(v["work"]["slices"]["total"], 4);
assert_eq!(v["boot"]["staleness"], "fresh");
assert_eq!(v["boot"]["age_seconds"], 120);
assert_eq!(v["boot"]["commit"], "a3f7b2c");
}
#[test]
fn blocked_sections_suppressed_when_empty() {
let status = assemble_status(
counts(1, 0, 1),
empty_backlog(),
empty_next(),
empty_blocked(),
empty_blocked(),
fresh_boot(),
no_commits(),
);
let output = render_human(&status);
assert!(!output.contains("Blocked slices"));
assert!(!output.contains("Blocked backlog"));
}
#[test]
fn recent_commits_suppressed_when_empty() {
let status = assemble_status(
counts(1, 0, 1),
empty_backlog(),
empty_next(),
empty_blocked(),
empty_blocked(),
fresh_boot(),
no_commits(),
);
let output = render_human(&status);
assert!(!output.contains("Recent commits"));
}
#[test]
fn boot_stale_output() {
let boot = BootSection {
staleness: "stale".to_string(),
age_seconds: 3600,
commit: "deadbee".to_string(),
};
let status = assemble_status(
counts(1, 0, 1),
empty_backlog(),
empty_next(),
empty_blocked(),
empty_blocked(),
boot,
no_commits(),
);
let output = render_human(&status);
assert!(output.contains("boot.md stale from commit deadbee\n"));
}
#[test]
fn boot_missing_output() {
let boot = BootSection {
staleness: "missing".to_string(),
age_seconds: 0,
commit: String::new(),
};
let status = assemble_status(
counts(1, 0, 1),
empty_backlog(),
empty_next(),
empty_blocked(),
empty_blocked(),
boot,
no_commits(),
);
let output = render_human(&status);
assert!(output.contains("boot.md missing\n"));
}
#[test]
fn is_empty_true_when_no_active_slices_and_no_backlog() {
let status = assemble_status(
empty_counts(),
empty_backlog(),
empty_next(),
empty_blocked(),
empty_blocked(),
fresh_boot(),
no_commits(),
);
assert!(is_empty(&status));
}
#[test]
fn is_empty_false_when_has_active_slices() {
let status = assemble_status(
counts(1, 0, 1),
empty_backlog(),
empty_next(),
empty_blocked(),
empty_blocked(),
fresh_boot(),
no_commits(),
);
assert!(!is_empty(&status));
}
#[test]
fn is_empty_false_when_has_backlog_items() {
let mut backlog = BTreeMap::new();
backlog.insert("issue".to_string(), 1_usize);
let status = assemble_status(
empty_counts(),
backlog,
empty_next(),
empty_blocked(),
empty_blocked(),
fresh_boot(),
no_commits(),
);
assert!(!is_empty(&status));
}
#[test]
fn blocked_items_render_correctly() {
let blocked = vec![BlockedItem {
id: "SL-082".to_string(),
title: "reconcile engine".to_string(),
blocked_by: vec!["SL-047".to_string()],
}];
let status = assemble_status(
counts(1, 1, 2),
empty_backlog(),
empty_next(),
blocked,
empty_blocked(),
fresh_boot(),
no_commits(),
);
let output = render_human(&status);
assert!(output.contains("Blocked slices"));
assert!(output.contains("SL-082 blocked by SL-047"));
assert!(!output.contains("Blocked backlog"));
}
#[test]
fn next_up_shows_five_items() {
let mut backlog = BTreeMap::new();
backlog.insert("issue".to_string(), 1_usize);
let next: Vec<NextItem> = (1..=5)
.map(|i| NextItem {
id: format!("SL-{i:03}"),
status: "design".to_string(),
title: format!("slice {i}"),
})
.collect();
let status = assemble_status(
counts(5, 0, 5),
backlog,
next,
empty_blocked(),
empty_blocked(),
fresh_boot(),
no_commits(),
);
let output = render_human(&status);
assert!(output.contains("SL-001 (design)"));
assert!(output.contains("SL-005 (design)"));
}
#[test]
fn parse_git_log_parses_standard_format() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut child = std::process::Command::new("git")
.arg("-C")
.arg(root)
.arg("init")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
child.wait().unwrap();
for (k, v) in [("user.name", "test"), ("user.email", "test@test")] {
std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(["config", k, v])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.unwrap();
}
let f = root.join("test.txt");
fs::write(&f, "hello").unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(["add", "test.txt"])
.output()
.unwrap();
std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(["commit", "-m", "test commit"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.unwrap();
let commits = super::parse_git_log(root);
assert!(
!commits.is_empty(),
"git log should produce at least one commit"
);
assert!(!commits[0].hash.is_empty());
assert!(commits[0].subject.contains("test commit"));
}
use std::fs;
}