use std::env;
use std::io::{self, IsTerminal, Write};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Mutex, MutexGuard};
use crossterm::terminal;
use semver::Version;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::model::{InstallPreview, SkillStatus, ToolStatus};
pub(crate) mod input;
pub(crate) mod progress;
mod theme;
pub(crate) use crate::ui::theme::RenderOptions;
static OUTPUT_SILENCE_DEPTH: AtomicUsize = AtomicUsize::new(0);
static STDERR_COORDINATOR: Mutex<()> = Mutex::new(());
const FIELD_WIDTH: usize = 10;
const ITEM_WIDTH: usize = 24;
const CHOICE_LABEL_WIDTH: usize = 20;
const CHOICE_DESCRIPTION_INDENT: usize = CHOICE_LABEL_WIDTH + 5;
const CHOICE_STATE_WIDTH: usize = 48;
const INSTALL_CHECK_STATE_WIDTH: usize = 32;
pub(crate) fn stdout_line(text: &str) {
if OUTPUT_SILENCE_DEPTH.load(Ordering::SeqCst) > 0 {
return;
}
let _progress = progress::pause();
let _ = writeln!(io::stdout().lock(), "{text}");
}
pub(crate) struct OutputSilencer {
active: bool,
}
pub(crate) struct RawModeGuard {
active: bool,
}
impl RawModeGuard {
pub(crate) fn acquire() -> io::Result<Self> {
terminal::enable_raw_mode()?;
Ok(Self { active: true })
}
pub(crate) fn restore(&mut self) -> io::Result<()> {
if self.active {
terminal::disable_raw_mode()?;
self.active = false;
}
Ok(())
}
}
pub(crate) fn install_terminal_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = crossterm::execute!(
io::stderr(),
crossterm::cursor::Show,
terminal::LeaveAlternateScreen
);
let _ = terminal::disable_raw_mode();
previous(info);
}));
}
impl Drop for RawModeGuard {
fn drop(&mut self) {
let _ = self.restore();
}
}
impl OutputSilencer {
pub(crate) fn stdout() -> io::Result<Self> {
OUTPUT_SILENCE_DEPTH.fetch_add(1, Ordering::SeqCst);
Ok(Self { active: true })
}
}
impl Drop for OutputSilencer {
fn drop(&mut self) {
if self.active {
OUTPUT_SILENCE_DEPTH.fetch_sub(1, Ordering::SeqCst);
self.active = false;
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct Document {
pub(crate) title: String,
pub(crate) subtitle: String,
pub(crate) blocks: Vec<DocumentBlock>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum DocumentBlock {
Section(String),
Field {
label: String,
value: String,
emphasis: EmphasisKind,
},
Item {
name: String,
description: String,
emphasis: EmphasisKind,
},
Path {
label: String,
value: String,
},
Code(String),
Hint(String),
Status {
kind: StatusKind,
message: String,
},
Blank,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum StatusKind {
Info,
Success,
Warning,
Error,
Hint,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum EmphasisKind {
Default,
Help,
Success,
Warning,
Error,
Info,
}
impl Document {
pub(crate) fn with_subtitle(title: impl Into<String>, subtitle: impl Into<String>) -> Self {
Self {
title: sanitize_human_text(&title.into()),
subtitle: sanitize_human_text(&subtitle.into()),
blocks: Vec::new(),
}
}
pub(crate) fn section(mut self, title: impl Into<String>) -> Self {
self.blocks
.push(DocumentBlock::Section(sanitize_human_text(&title.into())));
self
}
pub(crate) fn field(mut self, label: impl Into<String>, value: impl Into<String>) -> Self {
self.blocks.push(DocumentBlock::Field {
label: sanitize_human_text(&label.into()),
value: sanitize_human_text(&value.into()),
emphasis: EmphasisKind::Default,
});
self
}
pub(crate) fn item(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
self.blocks.push(DocumentBlock::Item {
name: sanitize_human_text(&name.into()),
description: sanitize_human_text(&description.into()),
emphasis: EmphasisKind::Default,
});
self
}
pub(crate) fn status_item(
mut self,
name: impl Into<String>,
description: impl Into<String>,
kind: StatusKind,
) -> Self {
self.blocks.push(DocumentBlock::Item {
name: sanitize_human_text(&name.into()),
description: sanitize_human_text(&description.into()),
emphasis: emphasis_for_status(kind),
});
self
}
pub(crate) fn labeled_path(
mut self,
label: impl Into<String>,
value: impl Into<String>,
) -> Self {
self.blocks.push(DocumentBlock::Path {
label: sanitize_human_text(&label.into()),
value: sanitize_human_text(&value.into()),
});
self
}
pub(crate) fn code(mut self, value: impl Into<String>) -> Self {
self.blocks
.push(DocumentBlock::Code(sanitize_human_text(&value.into())));
self
}
pub(crate) fn hint(mut self, message: impl Into<String>) -> Self {
self.blocks
.push(DocumentBlock::Hint(sanitize_human_text(&message.into())));
self
}
pub(crate) fn status(mut self, kind: StatusKind, message: impl Into<String>) -> Self {
self.blocks.push(DocumentBlock::Status {
kind,
message: sanitize_human_text(&message.into()),
});
self
}
pub(crate) fn blank(mut self) -> Self {
self.blocks.push(DocumentBlock::Blank);
self
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RawKind {
Json,
JsonLines,
Toml,
Schema,
Completion,
ManPage,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum CliOutput {
Human(Document),
HumanHelp(String),
Raw { kind: RawKind, text: String },
}
pub(crate) fn render(output: &CliOutput, options: RenderOptions) -> String {
match output {
CliOutput::Human(document) => render_document(document, options),
CliOutput::HumanHelp(text) => render_usage(&sanitize_human_text(text), options),
CliOutput::Raw { text, .. } => text.clone(),
}
}
pub(crate) fn write_output_to(
output: &CliOutput,
writer: impl Write,
options: RenderOptions,
) -> io::Result<()> {
write_allow_broken_pipe(writer, &render(output, options))
}
pub(crate) fn try_print(output: &CliOutput) -> io::Result<()> {
let _progress = progress::pause();
write_output_to(output, io::stdout().lock(), RenderOptions::default())
}
pub(crate) fn try_print_error(document: &Document) -> io::Result<()> {
let _progress = progress::pause();
let _coordinator = stderr_coordinator();
let options = RenderOptions::stderr();
write_allow_broken_pipe(
io::stderr().lock(),
&render(&CliOutput::Human(document.clone()), options),
)
}
fn write_progress_event(event: serde_json::Value) {
if !io::stderr().is_terminal() {
let _ = writeln!(io::stderr().lock(), "{event}");
}
}
pub(crate) fn progress_started(step: usize, total: usize, component: &str) {
write_progress_event(serde_json::json!({
"event": "started",
"step": step,
"total": total,
"component": component,
}));
}
pub(crate) fn progress_update(
step: usize,
total: usize,
component: &str,
elapsed_secs: u64,
recent: &str,
) {
write_progress_event(serde_json::json!({
"event": "progress",
"step": step,
"total": total,
"component": component,
"elapsed_secs": elapsed_secs,
"recent": recent,
}));
}
pub(crate) fn progress_completed(step: usize, total: usize, component: &str, elapsed_secs: u64) {
write_progress_event(serde_json::json!({
"event": "completed",
"step": step,
"total": total,
"component": component,
"elapsed_secs": elapsed_secs,
}));
}
pub(crate) fn print_install_preview(preview: &InstallPreview) {
let options = RenderOptions::stdout();
stdout_line(§ion("Install checks", &options));
let mut tools = preview.tools.iter().collect::<Vec<_>>();
tools.sort_by_key(|status| {
let presentation = tool_presentation(&status.name);
(tool_category_order(presentation.1), status.name.as_str())
});
let mut category = None;
for status in tools {
let presentation = tool_presentation(&status.name);
if category != Some(presentation.1) {
if category.is_some() {
stdout_line("");
}
category = Some(presentation.1);
stdout_line(&category_heading(presentation.1, &options));
}
if status.installed {
let state = tool_selection_description(status);
let state = if status.outdated {
warning_text(&state, &options)
} else {
state
};
stdout_line(&status_line(
if status.outdated {
StatusKind::Warning
} else {
StatusKind::Success
},
&install_check_columns(&tool_status_name(status), &state, presentation.2),
&options,
));
for detail in status
.version
.as_deref()
.map(composite_detection_details)
.unwrap_or_default()
{
stdout_line(&install_check_detail_line(&detail, &options));
}
} else {
stdout_line(&status_line(
if status.installable {
StatusKind::Warning
} else {
StatusKind::Error
},
&install_check_columns(
&tool_status_name(status),
if status.installable {
"not installed"
} else {
"unsupported"
},
presentation.2,
),
&options,
));
}
}
for status in &preview.skills {
stdout_line(&status_line(
if status.installed {
StatusKind::Success
} else if status.installable {
StatusKind::Warning
} else {
StatusKind::Error
},
&format!(
"skill {} -> {} · {}",
skill_status_name(status),
status.agent.as_str(),
if status.installed {
"installed"
} else if status.installable {
"not installed"
} else {
"unsupported"
}
),
&options,
));
}
}
pub(crate) fn tool_presentation(name: &str) -> (u8, &'static str, &'static str) {
match name {
"rust-build-base" => (0, "Core toolchain", "Native build prerequisites"),
"rust-toolchain" => (1, "Core toolchain", "Rust, Cargo, rustfmt, and Clippy"),
"rust-bot" => (2, "Core toolchain", "RustBot skills management"),
"git" => (10, "Daily development", "Version control"),
"cmake" => (11, "Daily development", "Build system generator"),
"ninja" => (12, "Daily development", "Fast build runner"),
"python" => (13, "Daily development", "Python scripting runtime"),
"llvm-toolchain" => (14, "Daily development", "LLVM and Clang toolchain"),
"llvm-tools-preview" => (15, "Daily development", "LLVM coverage tooling"),
"bindgen-cli" => (16, "Daily development", "C bindings generator"),
"rust-analyzer" => (17, "Daily development", "Rust language server"),
"cargo-expand" => (20, "Quality checks", "Rust macro expansion"),
"cargo-nextest" => (21, "Quality checks", "Fast Rust test runner"),
"cargo-audit" => (22, "Quality checks", "Rust dependency auditing"),
"cargo-deny" => (23, "Quality checks", "Dependency policy enforcement"),
"cargo-geiger" => (24, "Quality checks", "Unsafe-code analysis"),
"cargo-llvm-cov" => (26, "Quality checks", "Rust code coverage"),
"bot-gate" => (27, "Quality checks", "Quality gate enforcement"),
"bot-metric" => (28, "Quality checks", "Code quality metrics"),
"bot-compass" => (29, "Quality checks", "Project diagnostics"),
"rust-src" => (30, "Advanced analysis", "Rust source component"),
"miri" => (31, "Advanced analysis", "Undefined behavior detection"),
"cargo-fuzz" => (32, "Advanced analysis", "Rust fuzz testing"),
"valgrind" => (33, "Advanced analysis", "Memory error detection"),
"cargo-valgrind" => (34, "Advanced analysis", "Valgrind test runner"),
"nodejs" => (40, "Developer automation", "JavaScript runtime"),
"gitnexus" => (41, "Developer automation", "Repository graph analysis"),
"tsx" => (42, "Developer automation", "TypeScript execution runtime"),
"openspec" => (
43,
"Developer automation",
"Specification-driven development",
),
"uv" => (44, "Developer automation", "Python tool management"),
"project-brain" => (45, "Developer automation", "Project knowledge management"),
_ => (50, "Other tools", "Development tool"),
}
}
pub(crate) fn tool_category_order(category: &str) -> u8 {
match category {
"Core toolchain" => 0,
"Daily development" => 1,
"Quality checks" => 2,
"Advanced analysis" => 3,
"Developer automation" => 4,
_ => 5,
}
}
pub(crate) fn tool_selection_description(status: &ToolStatus) -> String {
if status.outdated {
let installed_version = status
.version
.as_deref()
.map(|version| display_tool_version(&status.name, version));
let required_version = status
.required_version
.as_deref()
.map(|version| display_tool_version(&status.name, version));
return format!(
"outdated ({} -> {})",
installed_version.as_deref().unwrap_or("unknown"),
required_version.as_deref().unwrap_or("configured")
);
}
if status.installed {
if matches!(status.name.as_str(), "rust-src" | "llvm-tools-preview")
|| status
.version
.as_deref()
.is_some_and(|version| !composite_detection_details(version).is_empty())
{
return "installed".to_string();
}
return format!(
"installed{}",
status
.version
.as_deref()
.map(|version| format!(" ({})", display_tool_version(&status.name, version)))
.unwrap_or_default()
);
}
if !status.installable {
"unsupported".to_string()
} else if status.optional {
"tool, optional".to_string()
} else {
"tool".to_string()
}
}
pub(crate) fn display_tool_version(name: &str, version: &str) -> String {
let version = version.trim();
let prefix = format!("{name} ");
let version = version
.strip_prefix(&prefix)
.filter(|value| !value.trim().is_empty())
.unwrap_or(version)
.trim();
let version = version
.strip_prefix("version ")
.or_else(|| version.strip_prefix("Version "))
.unwrap_or(version)
.trim();
version
.split(|character: char| {
!(character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '+'))
})
.filter_map(|token| {
let normalized = token.trim_start_matches(['v', 'V', '=']);
Version::parse(normalized)
.ok()
.map(|_| normalized.to_string())
})
.next()
.unwrap_or_else(|| version.to_string())
}
pub(crate) fn composite_detection_details(version: &str) -> Vec<String> {
let parts = version
.split(';')
.map(str::trim)
.filter(|part| !part.is_empty())
.map(|part| part.split_once(" (").map_or(part, |(summary, _)| summary))
.collect::<Vec<_>>();
if parts.len() <= 1 {
return Vec::new();
}
parts.chunks(2).map(|chunk| chunk.join(" · ")).collect()
}
pub(crate) fn tool_status_name(status: &ToolStatus) -> String {
status.display_name.as_deref().map_or_else(
|| status.name.clone(),
|display_name| format!("{display_name} ({})", status.name),
)
}
pub(crate) fn skill_status_name(status: &SkillStatus) -> String {
status.display_name.as_deref().map_or_else(
|| status.name.clone(),
|display_name| format!("{display_name} ({})", status.name),
)
}
pub(crate) fn render_document(document: &Document, options: RenderOptions) -> String {
let mut output = String::new();
let widths = LayoutWidths::from_blocks(&document.blocks, options.width);
output.push_str(&page_title(&document.title, &document.subtitle, &options));
output.push('\n');
for block in &document.blocks {
match block {
DocumentBlock::Section(title) => {
output.push_str(&format!("{}\n", section(title, &options)))
}
DocumentBlock::Field {
label,
value,
emphasis,
} => {
output.push_str(&format!(
"{}\n",
field(label, value, *emphasis, &options, widths.field)
));
}
DocumentBlock::Item {
name,
description,
emphasis,
} => {
output.push_str(&format!(
"{}\n",
row_line(name, description, *emphasis, &options, widths.item)
));
}
DocumentBlock::Path { label, value } => {
output.push_str(&format!(
"{}\n",
field(label, value, EmphasisKind::Default, &options, widths.field)
));
}
DocumentBlock::Code(value) => {
output.push_str(&hanging_value(" ", value, EmphasisKind::Info, &options));
output.push('\n');
}
DocumentBlock::Hint(message) => {
output.push_str(&format!(
"{}\n",
status_line(StatusKind::Hint, message, &options)
));
}
DocumentBlock::Status { kind, message } => {
output.push_str(&format!("{}\n", status_line(*kind, message, &options)));
}
DocumentBlock::Blank => output.push('\n'),
}
}
output
}
fn write_allow_broken_pipe(mut writer: impl Write, text: &str) -> io::Result<()> {
match writer.write_all(text.as_bytes()) {
Ok(()) => writer.flush(),
Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Ok(()),
Err(error) => Err(error),
}
}
pub(crate) fn display_width(text: &str) -> usize {
let mut width = 0;
for segment in ansi_segments(text) {
if let AnsiSegment::Text(value) = segment {
width += UnicodeWidthStr::width(value);
}
}
width
}
pub(crate) fn fit_display_width(text: &str, max_width: usize) -> String {
if max_width == 0 {
return String::new();
}
if display_width(text) <= max_width {
return text.to_string();
}
let suffix = if max_width > 3 { "..." } else { "" };
let content_width = max_width.saturating_sub(suffix.len());
let mut output = String::new();
let mut width = 0;
let mut styled = false;
'segments: for segment in ansi_segments(text) {
match segment {
AnsiSegment::Escape(value) => {
styled = true;
output.push_str(value);
}
AnsiSegment::Text(value) => {
for character in value.chars() {
let character_width = UnicodeWidthChar::width(character).unwrap_or(0);
if width + character_width > content_width {
break 'segments;
}
width += character_width;
output.push(character);
}
}
}
}
output.push_str(suffix);
if styled {
output.push_str(theme::RESET);
}
output
}
#[derive(Clone, Copy)]
enum AnsiSegment<'a> {
Text(&'a str),
Escape(&'a str),
}
fn ansi_segments(text: &str) -> Vec<AnsiSegment<'_>> {
let bytes = text.as_bytes();
let mut segments = Vec::new();
let mut start = 0;
let mut index = 0;
while index < bytes.len() {
if bytes[index] != 0x1b {
index += 1;
continue;
}
if start < index {
segments.push(AnsiSegment::Text(&text[start..index]));
}
let escape_start = index;
index += 1;
if index < bytes.len() && bytes[index] == b'[' {
index += 1;
while index < bytes.len() {
let byte = bytes[index];
index += 1;
if (b'@'..=b'~').contains(&byte) {
break;
}
}
} else if index < bytes.len() && bytes[index] == b']' {
index += 1;
while index < bytes.len() {
if bytes[index] == 0x07 {
index += 1;
break;
}
if bytes[index] == 0x1b && bytes.get(index + 1).is_some_and(|byte| *byte == b'\\') {
index += 2;
break;
}
index += 1;
}
} else {
index = index.min(bytes.len());
}
segments.push(AnsiSegment::Escape(&text[escape_start..index]));
start = index;
}
if start < text.len() {
segments.push(AnsiSegment::Text(&text[start..]));
}
segments
}
fn stderr_coordinator() -> MutexGuard<'static, ()> {
STDERR_COORDINATOR
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub(crate) fn terminal_supports_cursor() -> bool {
env::var("TERM").map_or(true, |term| term != "dumb")
}
pub(crate) fn page_title(title: &str, subtitle: &str, options: &RenderOptions) -> String {
if subtitle.is_empty() {
return if options.color {
format!(
"{}{title}{}",
options.theme.accent_bold, options.theme.reset
)
} else {
title.to_string()
};
}
if options.color {
format!(
"{}{title}{} {}{subtitle}{}",
options.theme.accent_bold,
options.theme.reset,
options.theme.muted,
options.theme.reset
)
} else {
format!("{title} {subtitle}")
}
}
fn render_usage(usage: &str, options: RenderOptions) -> String {
let item_width = usage
.lines()
.filter(|line| line.starts_with(" "))
.map(str::trim)
.filter_map(|line| {
let (name, description) = split_usage_row(line);
(!name.is_empty() && !description.is_empty()).then(|| display_width(name) + 2)
})
.max()
.map_or(ITEM_WIDTH, |width| width.max(ITEM_WIDTH));
let mut rendered = usage
.lines()
.map(|line| usage_line(line, &options, item_width))
.collect::<Vec<_>>()
.join("\n");
if usage.ends_with('\n') {
rendered.push('\n');
}
rendered
}
fn usage_line(line: &str, options: &RenderOptions, item_width: usize) -> String {
let trimmed = line.trim();
if trimmed.is_empty() {
return String::new();
}
if let Some(rest) = trimmed.strip_prefix("bot-forge ") {
return page_title("bot-forge", rest, options);
}
if let Some(rest) = trimmed.strip_prefix("Usage:") {
return if rest.trim().is_empty() {
section(trimmed.trim_end_matches([':', ':']), options)
} else {
field(
"Usage",
rest.trim(),
EmphasisKind::Help,
options,
FIELD_WIDTH,
)
};
}
if trimmed.ends_with(':') || trimmed.ends_with(':') {
return section(trimmed.trim_end_matches([':', ':']), options);
}
if line.starts_with(" ") {
let (name, description) = split_usage_row(trimmed);
if !name.is_empty() && !description.is_empty() {
return help_row_line(name, description, options, item_width);
}
return if options.color {
format!(" {}{}", options.theme.muted, trimmed) + options.theme.reset
} else {
line.to_string()
};
}
if options.color {
format!("{}{trimmed}{}", options.theme.muted, options.theme.reset)
} else {
trimmed.to_string()
}
}
fn split_usage_row(line: &str) -> (&str, &str) {
if let Some((index, _)) = line.match_indices(" ").next() {
let (name, description) = line.split_at(index);
(name.trim_end(), description.trim_start())
} else {
(line, "")
}
}
pub(crate) fn section(title: &str, options: &RenderOptions) -> String {
if options.color {
format!("{}{title}{}", options.theme.title, options.theme.reset)
} else {
title.to_string()
}
}
pub(crate) fn choice_line(
active: bool,
selected: bool,
locked: bool,
label: &str,
description: &str,
options: &RenderOptions,
) -> String {
choice_line_with_recommendation(active, selected, locked, label, description, false, options)
}
pub(crate) fn choice_line_with_recommendation(
active: bool,
selected: bool,
locked: bool,
label: &str,
description: &str,
recommended: bool,
options: &RenderOptions,
) -> String {
let marker = if active && !locked { "›" } else { " " };
let checkbox = if recommended {
if selected { "★" } else { "☆" }
} else if locked && description.starts_with("installed") {
"✓"
} else if locked && description.starts_with("required") {
"◆"
} else if locked {
"-"
} else if selected {
"◉"
} else {
"○"
};
let label = pad_display(label, CHOICE_LABEL_WIDTH);
if options.color {
let outdated = locked && description.trim_start().starts_with("outdated");
let platform_unsupported =
locked && description.trim_start().starts_with("unsupported on ");
let required = locked && description.trim_start().starts_with("required");
let marker_style = if active {
options.theme.accent
} else {
options.theme.muted
};
let checkbox = if platform_unsupported {
"⊘"
} else if required {
"◆"
} else {
checkbox
};
let checkbox_style = if outdated {
options.theme.warning
} else if platform_unsupported {
options.theme.error
} else if required {
options.theme.accent_bold
} else if selected && !locked {
options.theme.accent
} else {
options.theme.muted
};
let label_style = if outdated {
options.theme.warning
} else if locked {
options.theme.muted
} else if active || selected {
options.theme.accent_bold
} else {
""
};
let badge = if recommended {
format!(
" {}★ Recommended{}",
options.theme.accent_bold, options.theme.reset
)
} else {
String::new()
};
let description_gap = " ";
let description_style = if outdated {
options.theme.warning
} else {
options.theme.muted
};
format!(
"{marker_style}{marker}{} {checkbox_style}{checkbox}{} {label_style}{label}{}{description_gap}{description_style}{description}{badge}{}",
options.theme.reset, options.theme.reset, options.theme.reset, options.theme.reset
)
} else {
format!(
"{marker} {checkbox} {label} {description}{}",
if recommended { " ★ Recommended" } else { "" }
)
}
}
pub(crate) fn choice_columns(state: &str, purpose: &str) -> String {
format!(
"{} {purpose}",
pad_display(
&fit_display_width(state, CHOICE_STATE_WIDTH),
CHOICE_STATE_WIDTH
)
)
}
fn install_check_columns(name: &str, state: &str, purpose: &str) -> String {
format!(
"{} {} {purpose}",
pad_display(name, CHOICE_LABEL_WIDTH),
pad_display(
&fit_display_width(state, INSTALL_CHECK_STATE_WIDTH),
INSTALL_CHECK_STATE_WIDTH
)
)
}
pub(crate) fn choice_detail_line(detail: &str, options: &RenderOptions) -> String {
muted_text(
&format!("{}{detail}", " ".repeat(CHOICE_DESCRIPTION_INDENT)),
options,
)
}
pub(crate) fn install_check_detail_line(detail: &str, options: &RenderOptions) -> String {
muted_text(
&format!("{}{}", " ".repeat(CHOICE_DESCRIPTION_INDENT + 3), detail),
options,
)
}
pub(crate) fn choice_key_help(options: &RenderOptions) -> String {
styled_key_help(
&[
("↑↓", " navigate • "),
("Space", " toggle • "),
("A", " select all • "),
("N", " clear • "),
("Enter", " install • "),
("B", " back • "),
("Q", " exit"),
],
options,
)
}
pub(crate) fn launcher_key_help(options: &RenderOptions) -> String {
styled_key_help(
&[
("↑↓", " navigate • "),
("Enter", " open • "),
("1-6", " shortcut • "),
("Q", " cancel"),
],
options,
)
}
fn styled_key_help(parts: &[(&str, &str)], options: &RenderOptions) -> String {
if !options.color {
return parts
.iter()
.map(|(key, text)| format!("{key}{text}"))
.collect();
}
parts
.iter()
.map(|(key, text)| {
format!(
"{}{key}{}{}{text}{}",
options.theme.accent, options.theme.reset, options.theme.muted, options.theme.reset
)
})
.collect()
}
fn field(
label: &str,
value: &str,
emphasis: EmphasisKind,
options: &RenderOptions,
width: usize,
) -> String {
let label = trim_trailing_colon(label);
let prefix = format!(" {}", padded_label(label, width));
if options.width.saturating_sub(display_width(&prefix)) < 24 {
return format!(
" {label}\n{}",
hanging_value(" ", value, emphasis, options)
);
}
hanging_value(&prefix, value, emphasis, options)
}
fn row_line(
name: &str,
description: &str,
emphasis: EmphasisKind,
options: &RenderOptions,
width: usize,
) -> String {
let name = trim_trailing_colon(name);
let prefix = format!(" {} ", pad_display(name, width));
if options.width.saturating_sub(display_width(&prefix)) < 8 {
return format!(
" {name}\n{}",
hanging_value(" ", description, emphasis, options)
);
}
hanging_value(&prefix, description, emphasis, options)
}
fn help_row_line(name: &str, description: &str, options: &RenderOptions, width: usize) -> String {
let prefix = format!(" {} ", pad_display(name, width));
let available = options.width.saturating_sub(display_width(&prefix)).max(1);
wrap_text(description, available)
.into_iter()
.enumerate()
.map(|(index, line)| {
if index == 0 {
if options.color {
format!(
" {}{}{}",
accent_text(&pad_display(name, width), options),
muted_text(" ", options),
muted_text(&line, options)
)
} else {
format!("{prefix}{line}")
}
} else if options.color {
format!(
"{}{}",
muted_text(&" ".repeat(display_width(&prefix)), options),
muted_text(&line, options)
)
} else {
format!("{}{}", " ".repeat(display_width(&prefix)), line)
}
})
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn status_line(kind: StatusKind, message: &str, options: &RenderOptions) -> String {
let (label, style) = match kind {
StatusKind::Info => ("INFO", options.theme.info),
StatusKind::Success => ("OK", options.theme.success),
StatusKind::Warning => ("WARN", options.theme.warning),
StatusKind::Error => ("ERROR", options.theme.error),
StatusKind::Hint => ("HINT", options.theme.info),
};
if options.color {
format!("{style}{label:>5}{} {message}", options.theme.reset)
} else {
format!("{label:>5} {message}")
}
}
pub(crate) fn muted_text(text: &str, options: &RenderOptions) -> String {
if options.color {
format!("{}{text}{}", options.theme.muted, options.theme.reset)
} else {
text.to_string()
}
}
pub(crate) fn warning_text(text: &str, options: &RenderOptions) -> String {
if options.color {
format!("{}{}{}", options.theme.warning, text, options.theme.reset)
} else {
text.to_string()
}
}
pub(crate) fn warning_plain_text(text: &str, options: &RenderOptions) -> String {
if options.color {
format!(
"{}{}{}",
options.theme.warning_plain, text, options.theme.reset
)
} else {
text.to_string()
}
}
fn accent_text(text: &str, options: &RenderOptions) -> String {
if options.color {
format!("{}{text}{}", options.theme.accent, options.theme.reset)
} else {
text.to_string()
}
}
pub(crate) fn category_heading(title: &str, options: &RenderOptions) -> String {
let text = format!(" ── {title}");
if options.color {
format!("{}{text}{}", options.theme.info, options.theme.reset)
} else {
text
}
}
pub(crate) fn launcher_group_label(title: &str, options: &RenderOptions) -> String {
let text = format!("{title}✦");
if options.color {
format!(
"{}{}{}{}{}{}",
options.theme.info,
title,
options.theme.reset,
options.theme.accent_bold,
"✦",
options.theme.reset
)
} else {
text
}
}
pub(crate) fn stdout_status(kind: StatusKind, message: &str) {
stdout_line(&status_line(kind, message, &RenderOptions::stdout()));
}
pub(crate) fn stderr_status(kind: StatusKind, message: &str) {
let _progress = progress::pause();
let _coordinator = stderr_coordinator();
let _ = writeln!(
io::stderr().lock(),
"{}",
status_line(kind, message, &RenderOptions::stderr())
);
}
pub(crate) fn replace_progress_stderr(lines: &[String], previous_lines: usize) -> usize {
let options = RenderOptions::stderr();
if !options.cursor {
return 0;
}
let _coordinator = stderr_coordinator();
let output = progress_replacement(lines, previous_lines, options.width);
let _ = write_allow_broken_pipe(io::stderr().lock(), &output);
lines.len()
}
fn progress_replacement(lines: &[String], previous_lines: usize, width: usize) -> String {
let mut output = String::new();
if previous_lines > 0 {
output.push_str("\r\x1b[2K");
for _ in 1..previous_lines {
output.push_str("\x1b[1A\r\x1b[2K");
}
}
let line_width = width.saturating_sub(1).max(1);
output.push_str(
&lines
.iter()
.map(|line| fit_display_width(line, line_width))
.collect::<Vec<_>>()
.join("\r\n"),
);
output
}
pub(crate) fn confirm_key_help(options: &RenderOptions) -> String {
styled_key_help(&[("Y", " confirm • "), ("N", " cancel (default)")], options)
}
pub(crate) fn overwrite_key_help(options: &RenderOptions) -> String {
styled_key_help(
&[
("Y", " overwrite all • "),
("N", " keep existing • "),
("C", " custom"),
],
options,
)
}
fn padded_label(label: &str, width: usize) -> String {
if display_width(label) >= width {
format!("{label} ")
} else {
pad_display(label, width)
}
}
fn pad_display(value: &str, width: usize) -> String {
format!(
"{value}{}",
" ".repeat(width.saturating_sub(display_width(value)))
)
}
fn hanging_value(
prefix: &str,
value: &str,
emphasis: EmphasisKind,
options: &RenderOptions,
) -> String {
let indent = display_width(prefix);
let available = options.width.saturating_sub(indent).max(1);
wrap_text(value, available)
.into_iter()
.enumerate()
.map(|(index, line)| {
let prefix = if index == 0 {
prefix.to_string()
} else {
" ".repeat(indent)
};
if options.color {
format!(
"{}{}{}{}{}",
options.theme.muted,
prefix,
options.theme.reset,
emphasis_style(emphasis, options),
line
) + options.theme.reset
} else {
format!("{prefix}{line}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn emphasis_style(emphasis: EmphasisKind, options: &RenderOptions) -> &'static str {
match emphasis {
EmphasisKind::Default => options.theme.accent_bold,
EmphasisKind::Help => options.theme.accent,
EmphasisKind::Success => options.theme.success,
EmphasisKind::Warning => options.theme.warning,
EmphasisKind::Error => options.theme.error,
EmphasisKind::Info => options.theme.info,
}
}
fn emphasis_for_status(kind: StatusKind) -> EmphasisKind {
match kind {
StatusKind::Success => EmphasisKind::Success,
StatusKind::Warning => EmphasisKind::Warning,
StatusKind::Error => EmphasisKind::Error,
StatusKind::Info | StatusKind::Hint => EmphasisKind::Info,
}
}
fn wrap_text(value: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
for source_line in value.lines() {
let mut line = String::new();
for word in source_line.split_whitespace() {
let separator = usize::from(!line.is_empty());
if display_width(&line) + separator + display_width(word) <= width {
if separator == 1 {
line.push(' ');
}
line.push_str(word);
continue;
}
if !line.is_empty() {
lines.push(std::mem::take(&mut line));
}
let mut chunk = String::new();
for character in word.chars() {
let character_width = UnicodeWidthChar::width(character).unwrap_or(0);
if !chunk.is_empty() && display_width(&chunk) + character_width > width {
lines.push(std::mem::take(&mut chunk));
}
chunk.push(character);
if matches!(character, '/' | '\\' | '=') && display_width(&chunk) >= width / 2 {
lines.push(std::mem::take(&mut chunk));
}
}
line = chunk;
}
lines.push(line);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
pub(crate) fn truncate_display(value: &str, width: usize) -> String {
if display_width(value) <= width {
return value.to_string();
}
let content_width = width.saturating_sub(1);
let mut output = String::new();
for character in value.chars() {
if display_width(&output) + UnicodeWidthChar::width(character).unwrap_or(0) > content_width
{
break;
}
output.push(character);
}
output.push('…');
output
}
pub(crate) fn command_progress_line(
frame: &str,
step: Option<(usize, usize)>,
running: &str,
options: &RenderOptions,
) -> String {
let progress = step.map(|(completed, total)| format!("{completed}/{total}"));
let plain_prefix = progress
.as_deref()
.map_or_else(|| format!("{frame} "), |value| format!("{frame} {value} "));
let running = truncate_display(
running,
options
.width
.saturating_sub(display_width(&plain_prefix))
.max(1),
);
if options.color {
let progress = progress.map_or_else(String::new, |value| {
format!(" {}{value}{}", options.theme.muted, options.theme.reset)
});
format!(
"{}{frame}{}{progress} {}{running}{}",
options.theme.accent_bold, options.theme.reset, options.theme.info, options.theme.reset
)
} else {
format!("{plain_prefix}{running}")
}
}
fn trim_trailing_colon(value: &str) -> &str {
value.trim_end_matches([':', ':'])
}
pub(crate) fn sanitize_human_text(text: &str) -> String {
let mut clean = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
match chars.next() {
Some('[') => {
for next in chars.by_ref() {
if ('@'..='~').contains(&next) {
break;
}
}
}
Some(']') => {
let mut escaped = false;
for next in chars.by_ref() {
if next == '\x07' || (escaped && next == '\\') {
break;
}
escaped = next == '\x1b';
}
}
Some(_) | None => {}
}
} else if ch == '\n' || ch == '\t' || !ch.is_control() {
clean.push(ch);
}
}
clean
}
#[derive(Clone, Copy, Debug)]
struct LayoutWidths {
field: usize,
item: usize,
}
impl LayoutWidths {
fn from_blocks(blocks: &[DocumentBlock], terminal_width: usize) -> Self {
let mut widths = Self {
field: FIELD_WIDTH,
item: ITEM_WIDTH,
};
for block in blocks {
match block {
DocumentBlock::Field { label, .. } | DocumentBlock::Path { label, .. } => {
widths.field = widths
.field
.max(display_width(trim_trailing_colon(label)) + 2);
}
DocumentBlock::Item { name, .. } => {
widths.item = widths.item.max(display_width(trim_trailing_colon(name)));
}
_ => {}
}
}
let limit = terminal_width.saturating_sub(12).max(8) / 2;
widths.field = widths.field.min(limit.max(8));
widths.item = widths.item.min(limit.max(8));
widths
}
}
#[cfg(test)]
mod tests {
use crate::events::{EventFactory, LifecycleEvent, write_jsonl_event};
use crate::model::ToolStatus;
use crate::ui::theme::Theme;
use crate::ui::{
CliOutput, Document, RawKind, RenderOptions, StatusKind, category_heading,
choice_detail_line, choice_key_help, choice_line, choice_line_with_recommendation,
command_progress_line, confirm_key_help, display_width, fit_display_width,
install_check_columns, install_check_detail_line, launcher_group_label, overwrite_key_help,
progress_replacement, render, sanitize_human_text, status_line, tool_selection_description,
warning_plain_text, warning_text, write_allow_broken_pipe,
};
use std::io::{self, Write};
#[test]
fn raw_output_is_not_decorated() {
let output = CliOutput::Raw {
kind: RawKind::Json,
text: "{\"ok\":true}\n".to_string(),
};
assert_eq!(render(&output, RenderOptions::default()), "{\"ok\":true}\n");
}
#[test]
fn unicode_width_handles_cjk() {
assert_eq!(display_width("bot 工具"), 8);
}
#[test]
fn tool_version_display_omits_a_repeated_tool_name() {
let status = ToolStatus {
name: "bot-compass".into(),
display_name: None,
optional: false,
version: Some("bot-compass 1.0.0".into()),
installed: true,
required_version: None,
outdated: false,
installable: true,
};
assert_eq!(tool_selection_description(&status), "installed (1.0.0)");
}
#[test]
fn tool_version_display_keeps_unrelated_and_composite_output() {
let unrelated = ToolStatus {
name: "bot-compass".into(),
display_name: None,
optional: false,
version: Some("project-bot 1.0.0".into()),
installed: true,
required_version: None,
outdated: false,
installable: true,
};
assert_eq!(tool_selection_description(&unrelated), "installed (1.0.0)");
let composite = ToolStatus {
name: "bot-compass".into(),
display_name: None,
optional: false,
version: Some("bot-compass 1.0.0; companion 2.0.0".into()),
installed: true,
required_version: None,
outdated: false,
installable: true,
};
assert_eq!(tool_selection_description(&composite), "installed");
}
#[test]
fn tool_version_display_normalizes_common_command_prefixes() {
for (name, version, expected) in [
("git", "version 2.55.0", "installed (2.55.0)"),
("nodejs", "v26.7.0", "installed (26.7.0)"),
(
"rust-analyzer",
"1.89.0 (f8297e35 2025-10-28)",
"installed (1.89.0)",
),
("llvm-toolchain", "llvm 23.1.0", "installed (23.1.0)"),
] {
let status = ToolStatus {
name: name.into(),
display_name: None,
optional: false,
version: Some(version.into()),
installed: true,
required_version: None,
outdated: false,
installable: true,
};
assert_eq!(tool_selection_description(&status), expected);
}
}
#[test]
fn ansi_aware_width_and_truncation_preserve_terminal_sequences() {
let styled = "\x1b[38;2;255;165;0m工具 tool with a long label\x1b[0m";
assert_eq!(
display_width(styled),
display_width("工具 tool with a long label")
);
let fitted = fit_display_width(styled, 12);
assert!(display_width(&fitted) <= 12);
assert!(fitted.ends_with("\x1b[0m"));
assert!(!sanitize_human_text(&fitted).contains('\x1b'));
assert!(sanitize_human_text(&fitted).ends_with("..."));
}
#[test]
fn renderer_is_deterministic_at_supported_widths() {
let document = Document::with_subtitle("bot-forge", "工具")
.field("状态", "安装完成")
.hint("中文宽字符保持稳定");
let expected = "bot-forge 工具\n 状态 安装完成\n HINT 中文宽字符保持稳定\n";
for width in [40, 80, 120] {
let rendered = render(
&CliOutput::Human(document.clone()),
RenderOptions {
color: false,
width,
cursor: false,
theme: Theme::default(),
},
);
assert_eq!(rendered, expected, "width={width}");
assert!(!rendered.contains('\x1b'));
}
}
#[test]
fn renderer_matches_the_suite_theme_and_status_language() {
let document = Document::with_subtitle("bot-forge", "doctor")
.section("检查")
.status(StatusKind::Info, "正在检查环境")
.status(StatusKind::Success, "环境正常")
.hint("使用 --format json 获取机器输出");
let rendered = render(
&CliOutput::Human(document),
RenderOptions {
color: true,
width: 80,
cursor: true,
theme: Theme::default(),
},
);
assert!(rendered.contains("\x1b[1;38;2;255;165;0mbot-forge\x1b[0m"));
assert!(rendered.contains(" INFO\x1b[0m 正在检查环境"));
assert!(rendered.contains(" OK\x1b[0m 环境正常"));
assert!(rendered.contains(" HINT\x1b[0m 使用 --format json 获取机器输出"));
}
#[test]
fn suite_key_help_and_status_helpers_have_stable_plain_output() {
let options = RenderOptions::no_color();
assert_eq!(
choice_key_help(&options),
"↑↓ navigate • Space toggle • A select all • N clear • Enter install • B back • Q exit"
);
assert_eq!(confirm_key_help(&options), "Y confirm • N cancel (default)");
assert_eq!(
overwrite_key_help(&options),
"Y overwrite all • N keep existing • C custom"
);
assert_eq!(
status_line(StatusKind::Warning, "not installed", &options),
" WARN not installed"
);
assert_eq!(
command_progress_line("⠋", None, "cargo-bloat · 11s · Compiling regex", &options),
"⠋ cargo-bloat · 11s · Compiling regex"
);
assert_eq!(
category_heading("Daily development", &options),
" ── Daily development"
);
}
#[test]
fn category_heading_uses_bold_blue_in_color_output() {
let options = RenderOptions {
color: true,
width: 80,
cursor: true,
theme: Theme::default(),
};
assert_eq!(
category_heading("Daily development", &options),
"\x1b[1;38;2;96;165;250m ── Daily development\x1b[0m"
);
}
#[test]
fn launcher_group_label_is_quiet_and_has_no_rule() {
assert_eq!(
launcher_group_label("Utilities", &RenderOptions::no_color()),
"Utilities✦"
);
let options = RenderOptions {
color: true,
width: 80,
cursor: true,
theme: Theme::default(),
};
assert_eq!(
launcher_group_label("Utilities", &options),
format!(
"{}Utilities{}{}✦{}",
options.theme.info,
options.theme.reset,
options.theme.accent_bold,
options.theme.reset
)
);
}
#[test]
fn overwrite_keys_use_the_orange_accent() {
let options = RenderOptions {
color: true,
width: 80,
cursor: true,
theme: Theme::default(),
};
let rendered = overwrite_key_help(&options);
for key in ["Y", "N", "C"] {
assert!(rendered.contains(&format!("\x1b[38;2;255;165;0m{key}\x1b[0m")));
}
}
#[test]
fn outdated_versions_use_warning_emphasis() {
let options = RenderOptions {
color: true,
width: 80,
cursor: true,
theme: Theme::default(),
};
assert_eq!(
warning_text("outdated (1.0.0 -> 2.0.0)", &options),
"\x1b[1;38;2;251;191;36moutdated (1.0.0 -> 2.0.0)\x1b[0m"
);
assert_eq!(
display_width(&warning_text("outdated (1.0.0 -> 2.0.0)", &options)),
display_width("outdated (1.0.0 -> 2.0.0)")
);
assert_eq!(
warning_plain_text("0.22.2", &options),
"\x1b[38;2;251;191;36m0.22.2\x1b[0m"
);
let line = choice_line(
false,
false,
true,
"cargo-expand",
"outdated (1.0.116 -> 1.0.126) Quality checks",
&options,
);
assert!(line.contains("\x1b[1;38;2;251;191;36m-\x1b[0m"));
assert!(line.contains("\x1b[1;38;2;251;191;36mcargo-expand"));
assert!(line.contains("\x1b[1;38;2;251;191;36moutdated (1.0.116 -> 1.0.126)"));
}
#[test]
fn platform_unsupported_choices_use_info_color() {
let options = RenderOptions {
color: true,
width: 80,
cursor: true,
theme: Theme::default(),
};
let line = choice_line(
false,
false,
true,
"linux-only",
"unsupported on this platform",
&options,
);
assert!(line.contains(options.theme.error));
assert!(!line.contains(options.theme.warning));
assert!(line.contains("⊘"));
}
#[test]
fn choice_details_align_with_the_description_column() {
let options = RenderOptions::no_color();
let choice = choice_line(false, false, true, "rust-toolchain", "installed", &options);
let detail = choice_detail_line("rustup 1.29.0", &options);
assert_eq!(
display_width(choice.split_once("installed").unwrap().0),
display_width(detail.split_once("rustup 1.29.0").unwrap().0)
);
assert_eq!(detail, " rustup 1.29.0");
}
#[test]
fn recommended_choices_keep_the_description_column_aligned() {
let options = RenderOptions::no_color();
let recommended = choice_line_with_recommendation(
true,
true,
false,
"Install standard",
"Rust development and quality toolchain",
true,
&options,
);
let regular = choice_line(
false,
false,
false,
"Install minimal",
"Minimal Rust development environment",
&options,
);
assert_eq!(
display_width(recommended.split_once("Rust development").unwrap().0),
display_width(regular.split_once("Minimal Rust").unwrap().0)
);
}
#[test]
fn install_check_details_align_under_the_status_column() {
let detail = install_check_detail_line("rustc 1.90.0", &RenderOptions::no_color());
assert_eq!(detail, " rustc 1.90.0");
}
#[test]
fn install_check_columns_keep_purpose_close_to_the_status() {
let line = install_check_columns("git", "installed (2.55.0)", "Version control");
assert_eq!(
line,
"git installed (2.55.0) Version control"
);
}
#[test]
fn command_progress_uses_the_suite_theme_and_width() {
let rendered = command_progress_line(
"⠋",
Some((2, 8)),
"cargo-bloat · 11s · Compiling regex-automata with a long target name",
&RenderOptions {
color: true,
width: 48,
cursor: true,
theme: Theme::default(),
},
);
assert!(rendered.starts_with("\x1b[1;38;2;255;165;0m⠋\x1b[0m"));
assert!(rendered.contains("\x1b[38;2;178;160;142m2/8\x1b[0m"));
assert!(rendered.contains("\x1b[1;38;2;96;165;250m"));
assert!(display_width(&rendered) <= 48);
}
#[test]
fn progress_replacement_uses_crlf_and_prevents_terminal_wrapping() {
let output = progress_replacement(
&["first progress line".to_string(), "second".to_string()],
3,
12,
);
assert!(output.starts_with("\r\x1b[2K\x1b[1A\r\x1b[2K\x1b[1A\r\x1b[2K"));
assert!(output.contains("...\r\nsecond"));
assert!(!output.contains("\nsecond") || output.contains("\r\nsecond"));
for line in output.rsplit("\x1b[2K").next().unwrap().split("\r\n") {
assert!(display_width(line) < 12);
}
}
#[test]
fn human_help_uses_the_suite_document_language() {
let help = "bot-forge 1.0.1\n\nUsage:\n bot-forge <COMMAND> [OPTIONS]\n\nCommands:\n doctor Run system diagnostics\n";
let rendered = render(
&CliOutput::HumanHelp(help.to_string()),
RenderOptions {
color: true,
width: 80,
cursor: true,
theme: Theme::default(),
},
);
assert!(rendered.starts_with("\x1b[1;38;2;255;165;0mbot-forge\x1b[0m"));
assert!(rendered.contains("\x1b[1;38;2;255;244;226mUsage\x1b[0m"));
assert!(rendered.contains("doctor"));
}
#[test]
fn human_help_uses_the_suite_item_width_as_its_minimum() {
let help = "BotForge CLI 1.0.1 | Configurable Rust tool installer\n\nUsage: bot-forge [OPTIONS] <COMMAND>\n\nCommands:\n install Execute an installation plan\n";
let rendered = render(
&CliOutput::HumanHelp(help.to_string()),
RenderOptions::no_color(),
);
assert!(rendered.contains(" install Execute an installation plan"));
}
#[test]
fn human_text_is_sanitized_but_raw_output_is_untouched() {
let unsafe_text = "\x1b[31mred\x1b[0m\n";
let human = render(
&CliOutput::HumanHelp(unsafe_text.to_string()),
RenderOptions::no_color(),
);
assert_eq!(human, "red\n");
let raw = render(
&CliOutput::Raw {
kind: RawKind::Json,
text: unsafe_text.to_string(),
},
RenderOptions::no_color(),
);
assert_eq!(raw, unsafe_text);
}
#[test]
fn broken_pipe_is_success() {
struct Broken;
impl Write for Broken {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed"))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
assert!(write_allow_broken_pipe(Broken, "text").is_ok());
}
#[test]
fn jsonl_events_are_single_line_and_have_no_ansi() {
let event = EventFactory::new("run").envelope(
Some("demo:build"),
Some("demo"),
Some("cargo"),
LifecycleEvent::Progress {
elapsed_ms: 42,
message: "building".into(),
},
);
let mut output = Vec::new();
write_jsonl_event(&mut output, &event).unwrap();
let text = String::from_utf8(output).unwrap();
assert_eq!(text.lines().count(), 1);
assert!(!text.contains('\x1b'));
let value: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
assert_eq!(value["sequence"], 1);
}
}