use std::io::Write;
use std::path::PathBuf;
use std::str::FromStr;
use anyhow::Result;
use clap::CommandFactory;
use clap::Subcommand;
use crate::commands::config::ConfigCommand;
use crate::commands::facet::{
EstimateClearArgs, EstimateSetArgs, RiskClearArgs, RiskSetArgs, ValueClearArgs, ValueSetArgs,
};
use crate::listing::Format;
use crate::search::SearchArgs;
#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum DirArg {
#[value(alias = "up")]
Inbound,
#[value(alias = "down")]
Outbound,
Both,
}
impl DirArg {
pub(crate) fn to_transitive(self) -> crate::relation_graph::TransitiveDir {
use crate::relation_graph::TransitiveDir;
match self {
Self::Inbound => TransitiveDir::Inbound,
Self::Outbound => TransitiveDir::Outbound,
Self::Both => TransitiveDir::Both,
}
}
}
#[derive(clap::Subcommand)]
pub(crate) enum EstimateAction {
Set(EstimateSetArgs),
Clear(EstimateClearArgs),
}
#[derive(clap::Subcommand)]
pub(crate) enum ValueAction {
Set(ValueSetArgs),
Clear(ValueClearArgs),
}
#[derive(clap::Subcommand)]
pub(crate) enum RiskAction {
Set(RiskSetArgs),
Clear(RiskClearArgs),
}
#[derive(Subcommand)]
pub(crate) enum Command {
Install {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
#[arg(short = 'a', long)]
agent: Vec<String>,
#[arg(short = 's', long)]
skill: Vec<String>,
#[arg(short = 'd', long)]
domain: Vec<String>,
#[arg(long, conflicts_with_all = ["skill", "domain"])]
only_memory: bool,
#[arg(short = 'g', long)]
global: bool,
#[arg(long)]
dry_run: bool,
#[arg(short = 'y', long)]
yes: bool,
},
Catalog {
#[command(subcommand)]
command: crate::catalog::CatalogCommand,
},
#[command(hide = true)]
Skills {
#[command(subcommand)]
command: crate::skills::SkillsCommand,
},
Map {
#[command(subcommand)]
command: crate::commands::map::MapCommand,
},
ConceptMap {
#[command(subcommand)]
command: crate::concept_map::ConceptMapCommand,
},
Slice {
#[command(subcommand)]
command: crate::slice::SliceCommand,
},
Memory {
#[command(subcommand)]
command: crate::memory::MemoryCommand,
},
Review {
#[command(subcommand)]
command: crate::review::ReviewCommand,
},
Rec {
#[command(subcommand)]
command: crate::rec::RecCommand,
},
Search(SearchArgs),
Revision {
#[command(subcommand)]
command: crate::revision::RevisionCommand,
},
Reconcile {
req: String,
#[arg(long)]
slice: String,
#[arg(long = "move", value_parser = crate::rec::RecMove::parse)]
r#move: crate::rec::RecMove,
#[arg(long, value_enum)]
to: Option<crate::requirement::ReqStatus>,
#[arg(long)]
note: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Coverage {
#[command(subcommand)]
command: crate::commands::coverage::CoverageCommand,
},
Check {
#[command(subcommand)]
command: crate::commands::check::CheckCommand,
},
Inspect {
id: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(long)]
transitive: bool,
#[arg(long, value_enum, default_value_t = DirArg::Both, requires = "transitive")]
direction: DirArg,
#[arg(
long = "labels",
alias = "label",
value_delimiter = ',',
requires = "transitive"
)]
labels: Vec<String>,
#[arg(long, requires = "transitive")]
max_depth: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Survey {
#[arg(long)]
all: bool,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Next {
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
#[arg(long, value_delimiter = ',')]
columns: Option<Vec<String>>,
#[arg(long, default_value_t = crate::priority::NEXT_LIMIT_DEFAULT)]
limit: usize,
#[arg(long, default_value_t = 0)]
offset: usize,
#[arg(long, conflicts_with = "offset")]
page: Option<usize>,
},
Blockers {
id: String,
#[arg(long)]
transitive: bool,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Explain {
id: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Adr {
#[command(subcommand)]
command: crate::adr::AdrCommand,
},
Policy {
#[command(subcommand)]
command: crate::policy::PolicyCommand,
},
Standard {
#[command(subcommand)]
command: crate::standard::StandardCommand,
},
Rfc {
#[command(subcommand)]
command: crate::rfc::RfcCommand,
},
Spec {
#[command(subcommand)]
command: crate::spec::SpecCommand,
},
Export {
#[command(subcommand)]
command: ExportCommand,
},
Backlog {
#[command(subcommand)]
command: crate::backlog::BacklogCommand,
},
Knowledge {
#[command(subcommand)]
command: crate::knowledge::KnowledgeCommand,
},
Tag {
#[command(subcommand)]
command: crate::commands::tag::TagCommand,
},
Reservation {
#[command(subcommand)]
command: crate::commands::reservation::ReservationCommand,
},
Serve {
#[command(flatten)]
args: crate::commands::serve::ServeArgs,
},
Boot {
#[command(subcommand)]
command: Option<crate::boot::BootCommand>,
#[arg(long, conflicts_with = "check")]
emit: bool,
#[arg(long)]
check: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Worktree {
#[command(subcommand)]
command: crate::worktree::WorktreeCommand,
},
Dispatch {
#[command(subcommand)]
command: crate::dispatch::DispatchCommand,
},
Validate {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Doctor {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
#[arg(long)]
json: bool,
},
Reseat {
reference: String,
#[arg(long)]
to: Option<u32>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Relation {
#[command(subcommand)]
command: crate::commands::relation::RelationCommand,
},
Link {
source: String,
label: String,
#[arg(long)]
role: Option<String>,
#[arg(long)]
degree: Option<String>,
target: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Config {
#[command(subcommand)]
command: ConfigCommand,
},
Unlink {
source: String,
label: String,
#[arg(long)]
role: Option<String>,
target: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Needs {
source: String,
target: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
After {
source: String,
#[arg(required_unless_present = "prune")]
target: Option<String>,
#[arg(long, default_value_t = 0)]
rank: i32,
#[arg(long, conflicts_with = "prune")]
remove: bool,
#[arg(long, conflicts_with = "remove")]
prune: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Supersede {
new: String,
old: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Estimate {
#[command(subcommand)]
action: EstimateAction,
},
Value {
#[command(subcommand)]
action: ValueAction,
},
Risk {
#[command(subcommand)]
action: RiskAction,
},
}
struct Family {
key: &'static str,
members: &'static [&'static str],
suppress_verbs: bool,
}
static FAMILIES: &[Family] = &[
Family {
key: "change",
suppress_verbs: false,
members: &[
"slice",
"revision",
"rfc",
"rec",
"review",
"reconcile",
"coverage",
],
},
Family {
key: "governance",
suppress_verbs: false,
members: &["adr", "policy", "standard", "spec"],
},
Family {
key: "knowledge",
suppress_verbs: false,
members: &["memory", "knowledge", "backlog"],
},
Family {
key: "relations",
suppress_verbs: false,
members: &["link", "unlink", "needs", "after", "supersede"],
},
Family {
key: "facets",
suppress_verbs: false,
members: &["estimate", "value", "risk", "tag"],
},
Family {
key: "reports",
suppress_verbs: false,
members: &["status", "next", "blockers", "survey", "explain"],
},
Family {
key: "explore",
suppress_verbs: false,
members: &["search", "inspect", "relation", "concept-map", "map"],
},
Family {
key: "infra",
suppress_verbs: true,
members: &[
"install",
"boot",
"serve",
"config",
"validate",
"doctor",
"check",
"reseat",
"export",
"reservation",
"worktree",
"dispatch",
"catalog",
],
},
];
const SPINE: &[&str] = &["new", "list", "show", "paths"];
struct HelpEntry {
name: String,
about: String,
}
pub(crate) fn render_top_level_help(color: bool, term_width: Option<u16>) -> String {
use crate::listing::{self, Column, ColumnPaint, RenderOpts};
let cmd = <crate::Cli as CommandFactory>::command();
let about_of = |name: &str| -> Option<String> {
cmd.get_subcommands()
.find(|sub| !sub.is_hide_set() && sub.get_name() == name)
.map(|sub| sub.get_about().map_or(String::new(), ToString::to_string))
};
let groups: Vec<(&str, Vec<HelpEntry>)> = FAMILIES
.iter()
.map(|fam| {
let entries: Vec<HelpEntry> = fam
.members
.iter()
.filter_map(|name| {
about_of(name).map(|about| HelpEntry {
name: (*name).to_string(),
about,
})
})
.collect();
(fam.key, entries)
})
.collect();
let cols: &[&Column<HelpEntry>] = &[
&Column {
name: "command",
header: "command",
cell: |e| e.name.clone(),
paint: ColumnPaint::None,
},
&Column {
name: "description",
header: "description",
cell: |e| e.about.clone(),
paint: ColumnPaint::Alternate([listing::TITLE_EVEN, listing::TITLE_ODD]),
},
];
let opts = RenderOpts { color, term_width };
listing::render_grouped(&groups, cols, opts)
}
pub(crate) fn render_boot_map() -> String {
use std::fmt::Write as _;
let cmd = <crate::Cli as CommandFactory>::command();
let distinctive = |name: &str| -> Vec<String> {
cmd.get_subcommands()
.find(|sub| !sub.is_hide_set() && sub.get_name() == name)
.map(|sub| {
sub.get_subcommands()
.filter(|g| !g.is_hide_set() && g.get_name() != "help")
.map(|g| g.get_name().to_string())
.filter(|verb| !SPINE.contains(&verb.as_str()))
.collect()
})
.unwrap_or_default()
};
let pad = FAMILIES.iter().map(|f| f.key.len()).max().unwrap_or(0) + 2;
let mut out = String::new();
out.push_str("SPINE: ");
out.push_str(&SPINE.join(" "));
out.push_str(" (+status where lifecycle) \u{2014} entity kinds\n\n");
for fam in FAMILIES {
_ = writeln!(out, "{:<pad$}{}", fam.key, fam.members.join(" "));
if fam.suppress_verbs {
continue;
}
for member in fam.members {
let verbs = distinctive(member);
if verbs.is_empty() {
continue;
}
_ = writeln!(out, " {:<pad$}{}", member, verbs.join(" "));
}
}
out
}
struct VerbEntry {
command: String,
verb: String,
description: String,
}
fn first_sentence(about: &str) -> String {
let mut pos = 0;
while let Some(candidate) = about[pos..].find(". ") {
let abs = pos + candidate;
if about[..abs].ends_with("e.g") || about[..abs].ends_with("i.e") {
pos = abs + 1; continue;
}
if let Some(next_char) = about[abs + 2..].chars().next()
&& (next_char.is_ascii_uppercase() || next_char == '`')
{
return about[..=abs].to_string();
}
pos = abs + 1; }
about.to_string()
}
pub(crate) fn render_commands_table(color: bool, term_width: Option<u16>) -> String {
use crate::listing::{self, Column, ColumnPaint, RenderOpts};
let cmd = <crate::Cli as CommandFactory>::command();
let mut entries: Vec<VerbEntry> = Vec::new();
for sub in cmd
.get_subcommands()
.filter(|s| !s.is_hide_set() && s.get_name() != "help")
{
let parent = sub.get_name().to_string();
let grandchildren: Vec<_> = sub
.get_subcommands()
.filter(|g| !g.is_hide_set() && g.get_name() != "help")
.collect();
if grandchildren.is_empty() {
let about = sub
.get_about()
.map_or(String::new(), |a| first_sentence(&a.to_string()));
entries.push(VerbEntry {
command: parent,
verb: "\u{2014}".to_string(),
description: about,
});
} else {
for (i, gc) in grandchildren.into_iter().enumerate() {
let verb = gc.get_name().to_string();
let desc = gc
.get_about()
.map_or(String::new(), |a| first_sentence(&a.to_string()));
entries.push(VerbEntry {
command: if i == 0 {
parent.clone()
} else {
String::new()
},
verb,
description: desc,
});
}
}
}
if entries.is_empty() {
return String::new();
}
let cols: &[&Column<VerbEntry>] = &[
&Column {
name: "command",
header: "command",
cell: |e| e.command.clone(),
paint: ColumnPaint::None,
},
&Column {
name: "verb",
header: "verb",
cell: |e| e.verb.clone(),
paint: ColumnPaint::None,
},
&Column {
name: "description",
header: "description",
cell: |e| e.description.clone(),
paint: ColumnPaint::Alternate([listing::TITLE_EVEN, listing::TITLE_ODD]),
},
];
let mut out = listing::render_columns(&entries, cols, RenderOpts { color, term_width });
out.push_str("\nFor arguments & options: doctrine <command> <verb> --help\n");
out
}
#[derive(Subcommand)]
pub(crate) enum ExportCommand {
Lazyspec {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
pub(crate) fn dispatch(cmd: Command, color: bool) -> Result<()> {
match cmd {
Command::Install {
path,
agent,
skill,
domain,
only_memory,
global,
dry_run,
yes,
} => crate::install::run(
path,
&crate::install::InstallArgs {
agents: &agent,
skills: &skill,
domains: &domain,
only_memory,
global,
dry_run,
yes,
},
),
Command::Skills { command } => crate::skills::dispatch(command, color),
Command::ConceptMap { command } => crate::concept_map::dispatch(command, color),
Command::Slice { command } => crate::slice::dispatch(command, color),
Command::Memory {
command:
crate::memory::MemoryCommand::Sync {
command,
dry_run: sync_dry_run,
yes: sync_yes,
path: sync_path,
},
} => match command {
None => crate::corpus::run_sync(sync_path, sync_dry_run, sync_yes),
Some(crate::memory::SyncCommand::Install { path, dry_run, yes }) => {
crate::corpus::run_sync_install(path, dry_run, yes)
}
},
Command::Memory { command } => crate::memory::dispatch(command, color),
Command::Review { command } => crate::review::dispatch(command, color),
Command::Rec { command } => crate::rec::dispatch(command, color),
Command::Search(args) => crate::search::run(
args,
crate::listing::RenderOpts {
color,
term_width: crate::tty::stdout_terminal_width(),
},
),
Command::Revision { command } => crate::revision::dispatch(command, color),
Command::Reconcile {
req,
slice,
r#move,
to,
note,
path,
} => crate::reconcile::run(
path,
&crate::reconcile::ReconcileArgs {
req,
slice,
r#move,
to,
note,
},
),
Command::Coverage { command } => crate::commands::coverage::dispatch(command, color),
Command::Check { command } => crate::commands::check::dispatch(command),
Command::Inspect {
id,
format,
json,
transitive,
direction,
labels,
max_depth,
path,
} => crate::commands::inspect::run_inspect(
path,
&crate::commands::inspect::InspectArgs {
id: &id,
format,
json,
transitive,
direction: direction.to_transitive(),
labels,
max_depth,
},
),
Command::Survey {
all,
format,
json,
path,
} => crate::priority::run_survey(
path,
all,
format,
json,
crate::listing::RenderOpts {
color,
term_width: crate::tty::stdout_terminal_width(),
},
),
Command::Next {
format,
json,
path,
columns,
limit,
offset,
page,
} => {
if page == Some(0) {
anyhow::bail!("--page must be >= 1");
}
if limit == 0 && page.is_some() {
anyhow::bail!("--page requires a positive --limit");
}
let resolved_offset = match page {
Some(p) => {
let page_size = limit;
(p - 1) * page_size
}
None => offset,
};
crate::priority::run_next(
path,
format,
json,
crate::listing::RenderOpts {
color,
term_width: crate::tty::stdout_terminal_width(),
},
columns.as_ref(),
limit,
resolved_offset,
)
}
Command::Blockers {
id,
transitive,
format,
json,
path,
} => crate::priority::run_blockers(
path,
&id,
transitive,
format,
json,
crate::listing::RenderOpts {
color,
term_width: crate::tty::stdout_terminal_width(),
},
),
Command::Explain {
id,
format,
json,
path,
} => crate::priority::run_explain(
path,
&id,
format,
json,
crate::listing::RenderOpts {
color,
term_width: crate::tty::stdout_terminal_width(),
},
),
Command::Adr { command } => crate::adr::dispatch(command, color),
Command::Policy { command } => crate::policy::dispatch(command, color),
Command::Standard { command } => crate::standard::dispatch(command, color),
Command::Rfc { command } => crate::rfc::dispatch(command, color),
Command::Spec { command } => crate::spec::dispatch(command, color),
Command::Export { command } => match command {
ExportCommand::Lazyspec { path } => {
let root = crate::root::find(path, &crate::root::default_markers())?;
let now = crate::clock::now_timestamp()?;
let version = env!("CARGO_PKG_VERSION");
let json = crate::lazyspec::run_export_lazyspec(&root, &now, version)?;
writeln!(std::io::stdout(), "{json}")?;
Ok(())
}
},
Command::Backlog { command } => crate::backlog::dispatch(command, color),
Command::Knowledge { command } => crate::knowledge::dispatch(command, color),
Command::Tag { command } => crate::commands::tag::dispatch(command),
Command::Reservation { command } => crate::commands::reservation::dispatch(command),
Command::Serve { args } => crate::commands::serve::run_serve(args),
Command::Boot {
command,
check,
emit,
path,
} => crate::boot::dispatch(command, check, emit, path, color, render_boot_map),
Command::Catalog { command } => crate::catalog::dispatch(command, color),
Command::Worktree { command } => crate::worktree::dispatch(command),
Command::Dispatch { command } => crate::dispatch::dispatch(command, color),
Command::Validate { path } => crate::commands::validate::run_validate(path),
Command::Doctor { path, json } => crate::commands::doctor::run_doctor(path, json),
Command::Reseat {
reference,
to,
path,
} => crate::integrity::run_reseat(path, &reference, to),
Command::Relation { command } => match command {
crate::commands::relation::RelationCommand::List {
include_memory,
label,
target,
source_kind,
unresolved,
format,
json,
columns,
path,
} => crate::commands::relation::run_relation_list(
path,
include_memory,
label,
target,
source_kind,
unresolved,
format,
json,
columns.as_deref(),
),
crate::commands::relation::RelationCommand::Census {
include_memory,
format,
json,
columns,
path,
} => crate::commands::relation::run_relation_census(
path,
include_memory,
format,
json,
columns.as_deref(),
),
},
Command::Link {
source,
label,
role,
degree,
target,
path,
} => crate::commands::relation::run_link(
path,
&source,
&label,
role.as_deref(),
degree.as_deref(),
&target,
),
Command::Config { command } => {
let root = crate::root::find(None, &crate::root::default_markers())?;
match command {
ConfigCommand::Show(ref args) => {
crate::commands::config::run_config_show(&root, args)
}
ConfigCommand::Set(ref args) => {
crate::commands::config::run_config_set(&root, args)
}
ConfigCommand::Get(ref args) => {
crate::commands::config::run_config_get(&root, args)
}
ConfigCommand::Unset(ref args) => {
crate::commands::config::run_config_unset(&root, args)
}
ConfigCommand::Validate => crate::commands::config::run_config_validate(&root),
}
}
Command::Unlink {
source,
label,
role,
target,
path,
} => crate::commands::relation::run_unlink(path, &source, &label, role.as_deref(), &target),
Command::Needs {
source,
target,
path,
} => crate::commands::dep_seq::run_needs_edge(path, &source, &target),
Command::After {
source,
target,
rank,
remove,
prune,
path,
} => {
if prune {
crate::commands::dep_seq::run_after_prune(path, &source)
} else if remove {
crate::commands::dep_seq::run_after_remove(
path,
&source,
target.as_deref().unwrap_or(""),
rank,
)
} else {
crate::commands::dep_seq::run_after_edge(
path,
&source,
target.as_deref().unwrap_or(""),
rank,
)
}
}
Command::Status { format, json, path } => crate::status::run(path, format, json),
Command::Estimate { action } => match action {
EstimateAction::Set(args) => crate::commands::facet::run_estimate_set(&args),
EstimateAction::Clear(args) => crate::commands::facet::run_estimate_clear(&args),
},
Command::Value { action } => match action {
ValueAction::Set(args) => crate::commands::facet::run_value_set(&args),
ValueAction::Clear(args) => crate::commands::facet::run_value_clear(&args),
},
Command::Risk { action } => match action {
RiskAction::Set(args) => crate::commands::facet::run_risk_set(&args),
RiskAction::Clear(args) => crate::commands::facet::run_risk_clear(&args),
},
Command::Supersede { new, old, path } => {
crate::commands::supersede::run_supersede(path, &new, &old)
}
Command::Map { command } => crate::commands::map::dispatch(command),
}
}
#[cfg(test)]
#[expect(
clippy::unwrap_used,
clippy::expect_used,
reason = "test code: fail-fast on internal invariant violations"
)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn visible_commands() -> Vec<String> {
let cmd = <crate::Cli as CommandFactory>::command();
cmd.get_subcommands()
.filter(|s| !s.is_hide_set() && s.get_name() != "help")
.map(|s| s.get_name().to_string())
.collect()
}
#[test]
fn families_partition_the_visible_command_tree() {
let visible: std::collections::BTreeSet<String> = visible_commands().into_iter().collect();
let mut owner: BTreeMap<&str, &str> = BTreeMap::new();
for fam in FAMILIES {
for &member in fam.members {
if let Some(prev) = owner.insert(member, fam.key) {
panic!(
"command `{member}` is in two families (`{prev}` and `{}`)",
fam.key
);
}
}
}
for (&member, &family) in &owner {
assert!(
visible.contains(member),
"FAMILIES member `{member}` (family `{family}`) is not a visible command"
);
}
for name in &visible {
assert!(
owner.contains_key(name.as_str()),
"visible command `{name}` is not classified into any family"
);
}
assert_eq!(visible.len(), 46, "expected 46 visible top-level commands");
}
#[test]
fn narrow_width_wrap_keeps_eight_bands_and_no_mid_row_heading() {
let out = render_top_level_help(false, Some(40));
let lines: Vec<&str> = out.lines().collect();
let keys: std::collections::BTreeSet<&str> = [
"change",
"governance",
"knowledge",
"relations",
"facets",
"reports",
"explore",
"infra",
]
.into_iter()
.collect();
let mut headings = 0;
for (i, line) in lines.iter().enumerate() {
if let Some(rest) = line.strip_prefix(" ")
&& keys.contains(rest)
{
headings += 1;
assert!(
i > 0 && lines[i - 1].is_empty(),
"wrapped output put family band `{rest}` mid-row (no blank above)"
);
}
}
assert_eq!(headings, 8, "all 8 family bands must survive wrapping");
assert!(
lines.iter().any(|l| {
l.starts_with(' ') && !l.trim_start().is_empty() && {
let head = l.split('\u{2502}').next().unwrap_or(l);
head.chars().all(char::is_whitespace)
}
}),
"the 40-col width must actually wrap at least one description"
);
}
#[test]
fn colour_on_help_paints_full_width_family_bands() {
let out = render_top_level_help(true, Some(80));
assert!(
out.contains('\u{1b}'),
"colour-on help must emit ANSI escapes"
);
assert!(
out.contains("change"),
"first family heading `change` must appear"
);
let widest = out
.lines()
.map(|l| crate::listing::strip_ansi(l).chars().count())
.max()
.unwrap_or(0);
assert!(
widest >= 80,
"a band line must pad to term_width (80); widest visible was {widest}"
);
}
}