use std::collections::BTreeMap;
use std::io::{self, Write};
use comfy_table::{Cell, Color, Table, presets::UTF8_FULL};
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use is_terminal::IsTerminal;
use rayon::prelude::*;
use repograph_core::{
Check, Context, DoctorReport, Repo, RepoContext, RepoStatus, RepographError, Scope, Severity,
Workspace,
};
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputMode {
Tty,
Json,
}
impl OutputMode {
pub fn detect(force_json: bool) -> Self {
if force_json || !io::stdout().is_terminal() {
Self::Json
} else {
Self::Tty
}
}
}
#[derive(Serialize)]
struct ListEntry<'a> {
name: &'a str,
path: &'a std::path::Path,
description: Option<&'a str>,
stack: &'a [String],
}
#[derive(Serialize)]
struct ListEnvelope<'a> {
repos: &'a [ListEntry<'a>],
}
pub fn render_repos(
mode: OutputMode,
repos: &BTreeMap<String, Repo>,
) -> Result<(), RepographError> {
let entries: Vec<ListEntry> = repos
.iter()
.map(|(name, r)| ListEntry {
name,
path: &r.path,
description: r.description.as_deref(),
stack: &r.stack,
})
.collect();
render_repo_entries(mode, &entries)
}
pub fn render_repo_slice(
mode: OutputMode,
repos: &[(&String, &Repo)],
) -> Result<(), RepographError> {
let entries: Vec<ListEntry> = repos
.iter()
.map(|(name, r)| ListEntry {
name: name.as_str(),
path: &r.path,
description: r.description.as_deref(),
stack: &r.stack,
})
.collect();
render_repo_entries(mode, &entries)
}
fn render_repo_entries(mode: OutputMode, entries: &[ListEntry<'_>]) -> Result<(), RepographError> {
match mode {
OutputMode::Json => write_repo_json(entries),
OutputMode::Tty => write_repo_table(entries),
}
}
fn write_repo_json(entries: &[ListEntry<'_>]) -> Result<(), RepographError> {
let envelope = ListEnvelope { repos: entries };
let mut stdout = io::stdout().lock();
serde_json::to_writer(&mut stdout, &envelope).map_err(serde_json_to_repograph)?;
stdout.write_all(b"\n")?;
Ok(())
}
fn write_repo_table(entries: &[ListEntry<'_>]) -> Result<(), RepographError> {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec!["Name", "Path", "Description", "Stack"]);
for entry in entries {
table.add_row(vec![
Cell::new(entry.name),
Cell::new(entry.path.display()),
Cell::new(entry.description.unwrap_or("-")),
Cell::new(if entry.stack.is_empty() {
String::from("-")
} else {
entry.stack.join(", ")
}),
]);
}
let mut stdout = io::stdout().lock();
writeln!(stdout, "{table}")?;
Ok(())
}
#[derive(Serialize)]
struct WorkspaceListEntry<'a> {
name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<&'a str>,
members: &'a [String],
}
#[derive(Serialize)]
struct WorkspaceListEnvelope<'a> {
workspaces: Vec<WorkspaceListEntry<'a>>,
}
#[derive(Serialize)]
struct WorkspaceShowEnvelope<'a> {
name: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<&'a str>,
members: Vec<ListEntry<'a>>,
dangling: Vec<&'a str>,
}
pub fn render_workspaces(
mode: OutputMode,
workspaces: &BTreeMap<String, Workspace>,
) -> Result<(), RepographError> {
match mode {
OutputMode::Json => write_workspace_list_json(workspaces),
OutputMode::Tty => write_workspace_list_table(workspaces),
}
}
fn write_workspace_list_json(
workspaces: &BTreeMap<String, Workspace>,
) -> Result<(), RepographError> {
let entries: Vec<WorkspaceListEntry> = workspaces
.iter()
.map(|(name, ws)| WorkspaceListEntry {
name,
description: ws.description.as_deref(),
members: &ws.members,
})
.collect();
let envelope = WorkspaceListEnvelope {
workspaces: entries,
};
let mut stdout = io::stdout().lock();
serde_json::to_writer(&mut stdout, &envelope).map_err(serde_json_to_repograph)?;
stdout.write_all(b"\n")?;
Ok(())
}
fn write_workspace_list_table(
workspaces: &BTreeMap<String, Workspace>,
) -> Result<(), RepographError> {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec!["Name", "Description", "Members"]);
for (name, ws) in workspaces {
table.add_row(vec![
Cell::new(name),
Cell::new(ws.description.as_deref().unwrap_or("-")),
Cell::new(ws.members.len()),
]);
}
let mut stdout = io::stdout().lock();
writeln!(stdout, "{table}")?;
Ok(())
}
pub fn render_workspace_show(
mode: OutputMode,
name: &str,
description: Option<&str>,
live: &[(&String, &Repo)],
dangling: &[&String],
) -> Result<(), RepographError> {
let live_entries: Vec<ListEntry> = live
.iter()
.map(|(member_name, r)| ListEntry {
name: member_name.as_str(),
path: &r.path,
description: r.description.as_deref(),
stack: &r.stack,
})
.collect();
let dangling_refs: Vec<&str> = dangling.iter().map(|s| s.as_str()).collect();
match mode {
OutputMode::Json => {
let envelope = WorkspaceShowEnvelope {
name,
description,
members: live_entries,
dangling: dangling_refs,
};
let mut stdout = io::stdout().lock();
serde_json::to_writer(&mut stdout, &envelope).map_err(serde_json_to_repograph)?;
stdout.write_all(b"\n")?;
Ok(())
}
OutputMode::Tty => {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec!["Name", "Path", "Description", "Stack"]);
for entry in &live_entries {
table.add_row(vec![
Cell::new(entry.name),
Cell::new(entry.path.display()),
Cell::new(entry.description.unwrap_or("-")),
Cell::new(if entry.stack.is_empty() {
String::from("-")
} else {
entry.stack.join(", ")
}),
]);
}
let mut stdout = io::stdout().lock();
writeln!(stdout, "{table}")?;
Ok(())
}
}
}
#[derive(Serialize)]
struct StatusEnvelope<'a> {
repos: &'a [RepoStatus],
}
pub fn render_statuses(mode: OutputMode, statuses: &[RepoStatus]) -> Result<(), RepographError> {
match mode {
OutputMode::Json => write_status_json(statuses),
OutputMode::Tty => write_status_table(statuses),
}
}
fn write_status_json(statuses: &[RepoStatus]) -> Result<(), RepographError> {
let envelope = StatusEnvelope { repos: statuses };
let mut stdout = io::stdout().lock();
serde_json::to_writer(&mut stdout, &envelope).map_err(serde_json_to_repograph)?;
stdout.write_all(b"\n")?;
Ok(())
}
fn write_status_table(statuses: &[RepoStatus]) -> Result<(), RepographError> {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec![
"Name", "Branch", "Upstream", "Ahead", "Behind", "Dirty", "State",
]);
for s in statuses {
table.add_row(vec![
Cell::new(&s.name),
Cell::new(s.branch.as_deref().unwrap_or("-")),
Cell::new(s.upstream.as_deref().unwrap_or("-")),
Cell::new(s.ahead),
Cell::new(s.behind),
Cell::new(if s.dirty { "yes" } else { "no" }),
Cell::new(state_label(s.state)),
]);
}
let mut stdout = io::stdout().lock();
writeln!(stdout, "{table}")?;
Ok(())
}
const fn state_label(state: repograph_core::RepoState) -> &'static str {
use repograph_core::RepoState;
match state {
RepoState::Clean => "clean",
RepoState::Dirty => "dirty",
RepoState::Detached => "detached",
RepoState::Unborn => "unborn",
RepoState::Bare => "bare",
RepoState::Missing => "missing",
}
}
pub fn render_context(mode: OutputMode, context: &Context) -> Result<(), RepographError> {
match mode {
OutputMode::Json => render_context_json(context, &mut io::stdout().lock()),
OutputMode::Tty => render_context_markdown(context, &mut io::stdout().lock()),
}
}
fn render_context_json<W: Write>(context: &Context, writer: &mut W) -> Result<(), RepographError> {
serde_json::to_writer(&mut *writer, context).map_err(serde_json_to_repograph)?;
Ok(())
}
fn render_context_markdown<W: Write>(
context: &Context,
writer: &mut W,
) -> Result<(), RepographError> {
let scope_phrase = scope_phrase(&context.scope);
writeln!(
writer,
"# repograph context — {scope_phrase} ({} repo{}, {} agent{})",
context.repos.len(),
if context.repos.len() == 1 { "" } else { "s" },
context.agents.len(),
if context.agents.len() == 1 { "" } else { "s" },
)?;
writeln!(writer)?;
for warning in &context.warnings {
writeln!(writer, "> **warning:** {warning}")?;
writeln!(writer)?;
}
for repo in &context.repos {
render_repo_markdown(writer, repo)?;
}
Ok(())
}
fn scope_phrase(scope: &Scope) -> String {
match scope {
Scope::All => "all registered repos".to_string(),
Scope::Workspace { name } => format!("workspace `{name}`"),
Scope::Repos { repos } => {
let mut s = String::from("repos ");
for (i, name) in repos.iter().enumerate() {
if i > 0 {
s.push_str(", ");
}
s.push('`');
s.push_str(name);
s.push('`');
}
s
}
}
}
fn render_repo_markdown<W: Write>(
writer: &mut W,
repo: &RepoContext,
) -> Result<(), RepographError> {
let branch_label = repo.branch.as_deref().unwrap_or("none");
writeln!(writer, "## {} (branch: {branch_label})", repo.name)?;
writeln!(writer)?;
writeln!(writer, "`{}`", repo.path.display())?;
writeln!(writer)?;
for warning in &repo.warnings {
writeln!(writer, "> **warning:** {warning}")?;
writeln!(writer)?;
}
for doc in &repo.agent_docs {
if doc.files.is_empty() {
continue;
}
writeln!(writer, "### {}", doc.agent.as_str())?;
writeln!(writer)?;
for file in &doc.files {
let path_str = file
.path
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/");
writeln!(writer, "#### {path_str} ({})", human_size(file.bytes))?;
writeln!(writer)?;
let fence = pick_fence(&file.content);
writeln!(writer, "{fence}")?;
writer.write_all(file.content.as_bytes())?;
if !file.content.ends_with('\n') {
writeln!(writer)?;
}
writeln!(writer, "{fence}")?;
writeln!(writer)?;
}
}
Ok(())
}
fn pick_fence(content: &str) -> &'static str {
if content
.lines()
.any(|line| line.trim_start().starts_with("```"))
{
"~~~"
} else {
"```"
}
}
#[allow(clippy::cast_precision_loss)]
fn human_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * 1024;
const GB: u64 = 1024 * 1024 * 1024;
if bytes < KB {
format!("{bytes} B")
} else if bytes < MB {
format!("{:.1} KB", bytes as f64 / KB as f64)
} else if bytes < GB {
format!("{:.1} MB", bytes as f64 / MB as f64)
} else {
format!("{:.1} GB", bytes as f64 / GB as f64)
}
}
pub fn with_progress<T, R, F, B>(mode: OutputMode, items: &[T], label: F, body: B) -> Vec<R>
where
T: Sync,
R: Send,
F: Fn(&T) -> String + Sync,
B: Fn(&T) -> R + Sync + Send,
{
match mode {
OutputMode::Json => items.par_iter().map(body).collect(),
OutputMode::Tty => {
let progress = MultiProgress::new();
let style = ProgressStyle::with_template("{spinner} {msg}")
.unwrap_or_else(|_| ProgressStyle::default_spinner());
let bars: Vec<ProgressBar> = items
.iter()
.map(|item| {
let pb = progress.add(ProgressBar::new_spinner());
pb.set_style(style.clone());
pb.set_message(label(item));
pb.enable_steady_tick(std::time::Duration::from_millis(80));
pb
})
.collect();
let results: Vec<R> = items
.par_iter()
.zip(bars.par_iter())
.map(|(item, bar)| {
let r = body(item);
bar.finish_and_clear();
r
})
.collect();
drop(progress);
results
}
}
}
pub fn render_doctor(mode: OutputMode, report: &DoctorReport) -> Result<(), RepographError> {
match mode {
OutputMode::Json => render_doctor_json(report, &mut io::stdout().lock()),
OutputMode::Tty => render_doctor_table(report, &mut io::stdout().lock()),
}
}
fn render_doctor_json<W: Write>(
report: &DoctorReport,
writer: &mut W,
) -> Result<(), RepographError> {
serde_json::to_writer(&mut *writer, report).map_err(serde_json_to_repograph)?;
Ok(())
}
fn render_doctor_table<W: Write>(
report: &DoctorReport,
writer: &mut W,
) -> Result<(), RepographError> {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec!["Severity", "Check", "Target", "Message"]);
for f in &report.checks {
table.add_row(vec![
Cell::new(severity_label(f.severity)).fg(severity_colour(f.severity)),
Cell::new(check_label(f.check)),
Cell::new(&f.target),
Cell::new(&f.message),
]);
}
writeln!(writer, "{table}")?;
writeln!(
writer,
"{ok} ok · {warn} warn · {error} error",
ok = report.summary.ok,
warn = report.summary.warn,
error = report.summary.error,
)?;
Ok(())
}
const fn severity_label(s: Severity) -> &'static str {
match s {
Severity::Ok => "ok",
Severity::Warn => "warn",
Severity::Error => "error",
}
}
const fn severity_colour(s: Severity) -> Color {
match s {
Severity::Ok => Color::Green,
Severity::Warn => Color::Yellow,
Severity::Error => Color::Red,
}
}
const fn check_label(c: Check) -> &'static str {
match c {
Check::ConfigPresent => "ConfigPresent",
Check::ConfigParse => "ConfigParse",
Check::AgentsConfigured => "AgentsConfigured",
Check::ProjectsRootExists => "ProjectsRootExists",
Check::RepoPathExists => "RepoPathExists",
Check::RepoIsGitRepo => "RepoIsGitRepo",
Check::WorkspaceMembersResolve => "WorkspaceMembersResolve",
Check::AgentDocPresent => "AgentDocPresent",
}
}
fn serde_json_to_repograph(e: serde_json::Error) -> RepographError {
if e.is_io() {
RepographError::Io(e.into())
} else {
RepographError::Io(io::Error::other(e.to_string()))
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use std::path::PathBuf;
fn fixture() -> BTreeMap<String, Repo> {
let mut map = BTreeMap::new();
map.insert(
"alpha".to_string(),
Repo {
path: PathBuf::from("/tmp/alpha"),
description: Some("first".into()),
stack: vec!["rust".into(), "cli".into()],
},
);
map.insert(
"beta".to_string(),
Repo {
path: PathBuf::from("/tmp/beta"),
description: None,
stack: vec![],
},
);
map
}
#[test]
fn tty_rendering_includes_headers_and_rows() {
let repos = fixture();
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec!["Name", "Path", "Description", "Stack"]);
for (name, repo) in &repos {
table.add_row(vec![
Cell::new(name),
Cell::new(repo.path.display()),
Cell::new(repo.description.as_deref().unwrap_or("-")),
Cell::new(if repo.stack.is_empty() {
String::from("-")
} else {
repo.stack.join(", ")
}),
]);
}
let rendered = table.to_string();
assert!(rendered.contains("Name"), "header rendered");
assert!(rendered.contains("Path"));
assert!(rendered.contains("Description"));
assert!(rendered.contains("Stack"));
assert!(rendered.contains("alpha"));
assert!(rendered.contains("beta"));
assert!(rendered.contains("/tmp/alpha"));
assert!(rendered.contains("rust, cli"));
assert!(rendered.contains("first"));
assert!(rendered.contains('-'), "empty fields render as dash");
}
#[test]
fn json_envelope_shape() {
let repos = fixture();
let entries: Vec<ListEntry> = repos
.iter()
.map(|(name, r)| ListEntry {
name,
path: &r.path,
description: r.description.as_deref(),
stack: &r.stack,
})
.collect();
let envelope = ListEnvelope { repos: &entries };
let body = serde_json::to_string(&envelope).unwrap();
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert!(v["repos"].is_array());
assert_eq!(v["repos"].as_array().unwrap().len(), 2);
assert_eq!(v["repos"][0]["name"], "alpha");
assert_eq!(v["repos"][0]["stack"][0], "rust");
assert_eq!(v["repos"][1]["name"], "beta");
assert!(v["repos"][1]["description"].is_null());
}
#[test]
fn empty_registry_json_is_empty_array() {
let repos: BTreeMap<String, Repo> = BTreeMap::new();
let entries: Vec<ListEntry> = repos
.iter()
.map(|(name, r)| ListEntry {
name,
path: &r.path,
description: r.description.as_deref(),
stack: &r.stack,
})
.collect();
let body = serde_json::to_string(&ListEnvelope { repos: &entries }).unwrap();
assert_eq!(body, "{\"repos\":[]}");
}
fn workspace_fixture() -> BTreeMap<String, Workspace> {
let mut map = BTreeMap::new();
map.insert(
"acme".into(),
Workspace {
description: Some("Acme rebuild".into()),
members: vec!["api".into(), "ui".into()],
},
);
map.insert(
"billing".into(),
Workspace {
description: None,
members: vec![],
},
);
map
}
#[test]
fn workspace_list_json_envelope_shape() {
let workspaces = workspace_fixture();
let entries: Vec<WorkspaceListEntry> = workspaces
.iter()
.map(|(name, ws)| WorkspaceListEntry {
name,
description: ws.description.as_deref(),
members: &ws.members,
})
.collect();
let body = serde_json::to_string(&WorkspaceListEnvelope {
workspaces: entries,
})
.unwrap();
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
let arr = v["workspaces"].as_array().expect("workspaces array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["name"], "acme");
assert_eq!(arr[0]["description"], "Acme rebuild");
assert_eq!(arr[0]["members"][0], "api");
assert_eq!(arr[1]["name"], "billing");
assert!(arr[1].get("description").is_none());
assert_eq!(arr[1]["members"].as_array().unwrap().len(), 0);
}
#[test]
fn empty_workspaces_json_is_empty_array() {
let workspaces: BTreeMap<String, Workspace> = BTreeMap::new();
let entries: Vec<WorkspaceListEntry> = workspaces
.iter()
.map(|(name, ws)| WorkspaceListEntry {
name,
description: ws.description.as_deref(),
members: &ws.members,
})
.collect();
let body = serde_json::to_string(&WorkspaceListEnvelope {
workspaces: entries,
})
.unwrap();
assert_eq!(body, "{\"workspaces\":[]}");
}
#[test]
fn workspace_show_envelope_always_has_dangling_field() {
use std::path::PathBuf;
let api_name = String::from("api");
let api = Repo {
path: PathBuf::from("/tmp/api"),
description: None,
stack: vec![],
};
let ghost_name = String::from("ghost");
let live: Vec<(&String, &Repo)> = vec![(&api_name, &api)];
let dangling: Vec<&String> = vec![&ghost_name];
let live_entries: Vec<ListEntry> = live
.iter()
.map(|(name, r)| ListEntry {
name: name.as_str(),
path: &r.path,
description: r.description.as_deref(),
stack: &r.stack,
})
.collect();
let dangling_refs: Vec<&str> = dangling.iter().map(|s| s.as_str()).collect();
let envelope = WorkspaceShowEnvelope {
name: "acme",
description: Some("rebuild"),
members: live_entries,
dangling: dangling_refs,
};
let body = serde_json::to_string(&envelope).unwrap();
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["name"], "acme");
assert_eq!(v["description"], "rebuild");
assert_eq!(v["members"].as_array().unwrap().len(), 1);
assert_eq!(v["members"][0]["name"], "api");
let d = v["dangling"].as_array().expect("dangling is an array");
assert_eq!(d.len(), 1);
assert_eq!(d[0], "ghost");
}
fn synth_context(scope: Scope, content: &str) -> Context {
use repograph_core::{AgentDoc, AgentId, MatchedFile, RepoContext, SCHEMA_VERSION};
Context {
schema_version: SCHEMA_VERSION,
generated_at: "2026-05-24T00:00:00Z".into(),
agents: vec![AgentId::ClaudeCode],
scope,
repos: vec![RepoContext {
name: "r".into(),
path: PathBuf::from("/tmp/r"),
branch: Some("main".into()),
agent_docs: vec![AgentDoc {
agent: AgentId::ClaudeCode,
files: vec![MatchedFile {
path: PathBuf::from("CLAUDE.md"),
bytes: content.len() as u64,
content: content.to_string(),
}],
}],
warnings: vec![],
}],
warnings: vec![],
}
}
#[test]
fn context_json_renderer_writes_single_object_no_trailing_newline() {
let ctx = synth_context(Scope::All, "body\n");
let mut buf: Vec<u8> = Vec::new();
render_context_json(&ctx, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(!s.ends_with('\n'), "JSON path emits no trailing newline");
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["schema_version"], 1);
}
#[test]
fn context_markdown_renderer_emits_headers_and_fenced_code() {
let ctx = synth_context(Scope::All, "hello\n");
let mut buf: Vec<u8> = Vec::new();
render_context_markdown(&ctx, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(
s.contains("# repograph context"),
"top-level header present: {s}"
);
assert!(
s.contains("## r (branch: main)"),
"repo header present: {s}"
);
assert!(s.contains("`/tmp/r`"), "path rendered as inline code: {s}");
assert!(s.contains("### claude-code"), "agent header present: {s}");
assert!(
s.contains("#### CLAUDE.md ("),
"file header with size present: {s}"
);
assert!(s.contains("```"), "fenced code block present: {s}");
assert!(s.contains("hello"), "file content inlined: {s}");
}
#[test]
fn context_markdown_renderer_uses_tilde_fence_when_content_contains_backtick_fence() {
let ctx = synth_context(Scope::All, "intro\n```bash\nls\n```\nend\n");
let mut buf: Vec<u8> = Vec::new();
render_context_markdown(&ctx, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("~~~"), "tilde fence used: {s}");
assert!(s.contains("```bash"), "embedded backticks preserved: {s}");
}
#[test]
fn context_markdown_renderer_handles_workspace_scope() {
let ctx = synth_context(
Scope::Workspace {
name: "team".into(),
},
"x",
);
let mut buf: Vec<u8> = Vec::new();
render_context_markdown(&ctx, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(s.contains("workspace `team`"), "scope phrase: {s}");
}
#[test]
fn context_markdown_renderer_renders_warnings_as_blockquote() {
use repograph_core::{RepoContext, SCHEMA_VERSION};
let ctx = Context {
schema_version: SCHEMA_VERSION,
generated_at: "2026-05-24T00:00:00Z".into(),
agents: vec![],
scope: Scope::All,
repos: vec![RepoContext {
name: "ghost".into(),
path: PathBuf::from("/tmp/ghost"),
branch: None,
agent_docs: vec![],
warnings: vec!["path no longer accessible".into()],
}],
warnings: vec![],
};
let mut buf: Vec<u8> = Vec::new();
render_context_markdown(&ctx, &mut buf).unwrap();
let s = String::from_utf8(buf).unwrap();
assert!(
s.contains("> **warning:** path no longer accessible"),
"blockquote warning rendered: {s}"
);
assert!(
!s.contains("###"),
"missing repo section has no agent subheadings: {s}"
);
}
#[test]
fn human_size_handles_each_unit_band() {
assert_eq!(human_size(0), "0 B");
assert_eq!(human_size(999), "999 B");
assert_eq!(human_size(1024), "1.0 KB");
assert_eq!(human_size(1024 * 1024), "1.0 MB");
assert_eq!(human_size(1024 * 1024 * 1024), "1.0 GB");
}
#[test]
fn pick_fence_falls_back_when_content_has_backtick_fence() {
assert_eq!(pick_fence("plain text\n"), "```");
assert_eq!(pick_fence("```rust\nfoo\n```"), "~~~");
assert_eq!(pick_fence(" ```python\n"), "~~~");
}
#[test]
fn workspace_show_envelope_empty_dangling_is_array_not_null() {
let live: Vec<(&String, &Repo)> = vec![];
let dangling: Vec<&String> = vec![];
let live_entries: Vec<ListEntry> = live
.iter()
.map(|(name, r)| ListEntry {
name: name.as_str(),
path: &r.path,
description: r.description.as_deref(),
stack: &r.stack,
})
.collect();
let dangling_refs: Vec<&str> = dangling.iter().map(|s| s.as_str()).collect();
let envelope = WorkspaceShowEnvelope {
name: "empty",
description: None,
members: live_entries,
dangling: dangling_refs,
};
let body = serde_json::to_string(&envelope).unwrap();
assert!(body.contains("\"dangling\":[]"), "got body: {body}");
assert!(
!body.contains("description"),
"description omitted; body: {body}"
);
}
}