use crate::error::CliError;
use crate::pipeline::PipelineResult;
use crate::types::{MultiSearchOutput, OutputFormat, SearchOutput, SearchResult};
use std::fmt::Write as FmtWrite;
use std::fs;
use std::io::{self, Write};
use std::path::Path;
pub fn emit_result(
result: &PipelineResult,
format: OutputFormat,
output_path: Option<&Path>,
) -> Result<(), CliError> {
if matches!(result, PipelineResult::Stream(_)) {
tracing::debug!("PipelineResult::Stream — output already emitted via streaming");
return Ok(());
}
let resolved_format = resolve_auto_format(format, output_path);
let text = match result {
PipelineResult::Single(output) => format_single(output.as_ref(), resolved_format)?,
PipelineResult::Multi(output) => format_multi(output.as_ref(), resolved_format)?,
PipelineResult::Stream(_) => unreachable!("Stream tratado acima"),
};
match output_path {
Some(path) => write_to_file(path, &text),
None => write_to_stdout(&text),
}
}
pub fn emit(output: &SearchOutput, format: OutputFormat) -> Result<(), CliError> {
let resolved_format = resolve_auto_format(format, None);
let text = format_single(output, resolved_format)?;
write_to_stdout(&text)
}
pub fn emit_multi(output: &MultiSearchOutput, format: OutputFormat) -> Result<(), CliError> {
let resolved_format = resolve_auto_format(format, None);
let text = format_multi(output, resolved_format)?;
write_to_stdout(&text)
}
fn resolve_auto_format(format: OutputFormat, output_path: Option<&Path>) -> OutputFormat {
match format {
OutputFormat::Auto => {
if output_path.is_some() {
OutputFormat::Json
} else if crate::platform::stdout_is_tty() {
OutputFormat::Text
} else {
OutputFormat::Json
}
}
other => other,
}
}
fn format_single(output: &SearchOutput, format: OutputFormat) -> Result<String, CliError> {
match format {
OutputFormat::Json | OutputFormat::Auto => {
serde_json::to_string_pretty(output).map_err(|e| CliError::InvalidConfig {
message: format!("failed to serialize search output as JSON: {e}"),
})
}
OutputFormat::Text => Ok(format_single_text(output)),
OutputFormat::Markdown => Ok(format_single_markdown(output)),
}
}
fn format_multi(output: &MultiSearchOutput, format: OutputFormat) -> Result<String, CliError> {
match format {
OutputFormat::Json | OutputFormat::Auto => {
serde_json::to_string_pretty(output).map_err(|e| CliError::InvalidConfig {
message: format!("failed to serialize multi-search output as JSON: {e}"),
})
}
OutputFormat::Text => Ok(format_multi_text(output)),
OutputFormat::Markdown => Ok(format_multi_markdown(output)),
}
}
fn format_single_text(output: &SearchOutput) -> String {
let mut buffer = String::with_capacity(100 + output.results.len() * 200);
buffer.push_str(&format_header_text(output));
if output.results.is_empty() {
buffer.push_str("\n(sem resultados)\n");
return buffer;
}
for result_item in &output.results {
buffer.push('\n');
buffer.push_str(&format_result_text(result_item));
}
buffer
}
fn format_multi_text(output: &MultiSearchOutput) -> String {
let mut buffer = String::with_capacity(100 + output.searches.len() * 800);
let _ = writeln!(
buffer,
"Queries: {} | Parallel: {} | Timestamp: {}",
output.query_count, output.parallelism, output.timestamp
);
for (i, search) in output.searches.iter().enumerate() {
let _ = write!(buffer, "\n========== Query #{} ==========\n", i + 1);
buffer.push_str(&format_single_text(search));
}
buffer
}
fn format_header_text(output: &SearchOutput) -> String {
format!(
"Query: {} | Engine: {} | Endpoint: {} | Results: {}\n",
output.query, output.engine, output.endpoint, output.result_count
)
}
fn format_result_text(r: &SearchResult) -> String {
let mut block = String::with_capacity(300);
let _ = writeln!(block, "[{}] {}", r.position, r.title);
if let Some(original) = &r.original_title {
if !original.is_empty() {
let _ = writeln!(block, " (original: {})", original);
}
}
let _ = writeln!(block, " {}", r.url);
if let Some(snippet) = &r.snippet {
if !snippet.is_empty() {
let _ = writeln!(block, " {}", snippet);
}
}
block
}
fn format_single_markdown(output: &SearchOutput) -> String {
let mut buffer = String::with_capacity(200 + output.results.len() * 300);
let _ = write!(buffer, "# Resultados: {}\n\n", output.query);
let _ = write!(
buffer,
"**Motor:** {} | **Endpoint:** {} | **Total:** {}\n\n",
output.engine, output.endpoint, output.result_count
);
if output.results.is_empty() {
buffer.push_str("_Nenhum resultado encontrado._\n");
return buffer;
}
for (i, r) in output.results.iter().enumerate() {
if i > 0 {
buffer.push_str("---\n\n");
}
let _ = write!(
buffer,
"## {}. [{}]({})\n\n",
r.position,
escapar_markdown(&r.title),
r.url
);
if let Some(original) = &r.original_title {
if !original.is_empty() {
let _ = write!(
buffer,
"_Original title: {}_\n\n",
escapar_markdown(original)
);
}
}
if let Some(snippet) = &r.snippet {
if !snippet.is_empty() {
let _ = write!(buffer, "{}\n\n", escapar_markdown(snippet));
}
}
if let Some(url_exibicao) = &r.display_url {
if !url_exibicao.is_empty() {
let _ = write!(buffer, "`{}`\n\n", url_exibicao);
}
}
}
buffer
}
fn format_multi_markdown(output: &MultiSearchOutput) -> String {
let mut buffer = String::with_capacity(200 + output.searches.len() * 1200);
let _ = write!(
buffer,
"# Multiple Searches ({} queries)\n\n",
output.query_count
);
let _ = write!(
buffer,
"**Paralelismo:** {} | **Timestamp:** {}\n\n",
output.parallelism, output.timestamp
);
for (i, search) in output.searches.iter().enumerate() {
if i > 0 {
buffer.push_str("\n---\n\n");
}
buffer.push_str(&format_single_markdown(search));
}
buffer
}
fn escapar_markdown(text: &str) -> String {
let mut out = String::with_capacity(text.len() + text.len() / 8);
for ch in text.chars() {
match ch {
'\\' | '*' | '[' | ']' | '`' => {
out.push('\\');
out.push(ch);
}
_ => out.push(ch),
}
}
out
}
pub fn emit_stderr(msg: &str) {
let _ = writeln!(std::io::stderr(), "{msg}");
}
fn write_to_stdout(content: &str) -> Result<(), CliError> {
let stdout = io::stdout();
let lock = stdout.lock();
let mut writer = io::BufWriter::new(lock);
writeln!(writer, "{content}").map_err(|e| map_io(e, "failed to write to stdout"))?;
writer
.flush()
.map_err(|e| map_io(e, "failed to flush stdout"))?;
Ok(())
}
#[cold]
fn map_io(e: io::Error, ctx: &str) -> CliError {
if e.kind() == io::ErrorKind::BrokenPipe {
CliError::BrokenPipe
} else {
CliError::PathError {
message: format!("{ctx}: {e}"),
}
}
}
#[inline]
pub(crate) fn is_broken_pipe(error: &CliError) -> bool {
matches!(error, CliError::BrokenPipe)
}
pub fn print_line_stdout(content: &str) -> Result<(), CliError> {
write_to_stdout(content)
}
pub fn emit_ndjson(
output: &crate::types::SearchOutput,
output_file: Option<&Path>,
) -> Result<(), CliError> {
match output_file {
Some(path) => {
let line = serde_json::to_string(output).map_err(|e| CliError::InvalidConfig {
message: format!("failed to serialize search output as NDJSON: {e}"),
})?;
append_line_to_file(path, &line)
}
None => {
let stdout = io::stdout();
let lock = stdout.lock();
let mut writer = io::BufWriter::new(lock);
serde_json::to_writer(&mut writer, output).map_err(|e| CliError::InvalidConfig {
message: format!("failed to serialize NDJSON: {e}"),
})?;
writeln!(writer).map_err(|e| map_io(e, "failed to write NDJSON newline"))?;
writer
.flush()
.map_err(|e| map_io(e, "failed to flush stdout"))?;
Ok(())
}
}
}
pub fn emit_stream_text(
index: usize,
output: &crate::types::SearchOutput,
output_file: Option<&Path>,
) -> Result<(), CliError> {
let mut block = String::with_capacity(900);
let _ = writeln!(block, "========== Query #{} ==========", index + 1);
block.push_str(&format_single_text(output));
emit_block_stream(&block, output_file)
}
pub fn emit_stream_markdown(
index: usize,
output: &crate::types::SearchOutput,
output_file: Option<&Path>,
) -> Result<(), CliError> {
let mut block = String::with_capacity(1200);
if index > 0 {
block.push_str("\n---\n\n");
}
block.push_str(&format_single_markdown(output));
emit_block_stream(&block, output_file)
}
fn emit_block_stream(block: &str, output_file: Option<&Path>) -> Result<(), CliError> {
match output_file {
Some(path) => append_line_to_file(path, block),
None => {
let stdout = io::stdout();
let lock = stdout.lock();
let mut writer = io::BufWriter::new(lock);
write!(writer, "{block}")
.map_err(|e| map_io(e, "failed to write streaming block to stdout"))?;
writer
.flush()
.map_err(|e| map_io(e, "failed to flush stdout"))?;
Ok(())
}
}
}
fn append_line_to_file(path: &Path, line: &str) -> Result<(), CliError> {
use std::fs::OpenOptions;
crate::paths::validate_output_path(path)?;
crate::paths::create_parent_dirs(path)?;
let needed_create = !path.exists();
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|e| CliError::PathError {
message: format!("failed to open (append) {}: {e}", path.display()),
})?;
writeln!(file, "{line}").map_err(|e| CliError::PathError {
message: format!("failed to write to {}: {e}", path.display()),
})?;
file.flush().map_err(|e| CliError::PathError {
message: format!("failed to flush {}: {e}", path.display()),
})?;
drop(file);
#[cfg(unix)]
if needed_create {
crate::paths::apply_permissions_644(path)?;
}
#[cfg(not(unix))]
let _ = needed_create;
Ok(())
}
fn write_to_file(path: &Path, content: &str) -> Result<(), CliError> {
crate::paths::validate_output_path(path)?;
crate::paths::create_parent_dirs(path)?;
fs::write(path, content).map_err(|e| CliError::PathError {
message: format!("failed to write file {}: {e}", path.display()),
})?;
crate::paths::apply_permissions_644(path)?;
tracing::info!(path = %path.display(), bytes = content.len(), "output written to file");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{SearchMetadata, SearchResult};
fn test_output() -> SearchOutput {
SearchOutput {
query: "teste".to_string(),
engine: "duckduckgo".to_string(),
endpoint: "html".to_string(),
timestamp: "2026-04-14T00:00:00+00:00".to_string(),
region: "br-pt".to_string(),
result_count: 1,
results: vec![SearchResult {
position: 1,
title: "Título com [colchetes]".to_string(),
url: "https://exemplo.com".to_string(),
display_url: Some("exemplo.com".to_string()),
snippet: Some("Descrição com *asteriscos* e `backticks`".to_string()),
original_title: None,
content: None,
content_size: None,
content_extraction_method: None,
}],
pages_fetched: 1,
error: None,
message: None,
metadata: SearchMetadata {
execution_time_ms: 100,
selectors_hash: "abc1234567890def".to_string(),
retries: 0,
used_fallback_endpoint: false,
concurrent_fetches: 0,
fetch_successes: 0,
fetch_failures: 0,
used_chrome: false,
user_agent: "Mozilla/5.0".to_string(),
used_proxy: false,
identity_used: None,
cascade_level: None,
},
}
}
#[test]
fn resolve_auto_format_for_file_always_json() {
let path = Path::new("/tmp/teste.json");
assert_eq!(
resolve_auto_format(OutputFormat::Auto, Some(path)),
OutputFormat::Json
);
}
#[test]
fn resolver_formato_auto_preserva_formatos_concretos() {
assert_eq!(
resolve_auto_format(OutputFormat::Json, None),
OutputFormat::Json
);
assert_eq!(
resolve_auto_format(OutputFormat::Text, None),
OutputFormat::Text
);
assert_eq!(
resolve_auto_format(OutputFormat::Markdown, None),
OutputFormat::Markdown
);
}
#[test]
fn format_single_text_includes_query_and_results() {
let output = test_output();
let text = format_single_text(&output);
assert!(text.contains("Query: teste"));
assert!(text.contains("Engine: duckduckgo"));
assert!(text.contains("Endpoint: html"));
assert!(text.contains("Results: 1"));
assert!(text.contains("[1] Título com [colchetes]"));
assert!(text.contains("https://exemplo.com"));
assert!(text.contains("Descrição com *asteriscos*"));
}
#[test]
fn format_single_text_handles_zero_results() {
let mut output = test_output();
output.result_count = 0;
output.results = vec![];
let text = format_single_text(&output);
assert!(text.contains("Results: 0"));
assert!(text.contains("(sem resultados)"));
}
#[test]
fn format_single_markdown_includes_h1_and_links() {
let output = test_output();
let md = format_single_markdown(&output);
assert!(md.starts_with("# Resultados: teste\n\n"));
assert!(md.contains("**Motor:** duckduckgo"));
assert!(md.contains("**Total:** 1"));
assert!(md.contains("[Título com \\[colchetes\\]](https://exemplo.com)"));
assert!(md.contains("Descrição com \\*asteriscos\\* e \\`backticks\\`"));
assert!(md.contains("`exemplo.com`"));
}
#[test]
fn format_single_markdown_no_results_emits_warning() {
let mut output = test_output();
output.result_count = 0;
output.results = vec![];
let md = format_single_markdown(&output);
assert!(md.contains("# Resultados: teste"));
assert!(md.contains("_Nenhum resultado encontrado._"));
}
#[test]
fn format_result_with_original_title_shows_annotation_text() {
let mut output = test_output();
output.results = vec![SearchResult {
position: 1,
title: "saofidelis.rj.gov.br".to_string(),
url: "https://saofidelis.rj.gov.br".to_string(),
display_url: Some("saofidelis.rj.gov.br".to_string()),
snippet: Some("Prefeitura de São Fidélis".to_string()),
original_title: Some("Official site".to_string()),
content: None,
content_size: None,
content_extraction_method: None,
}];
let text = format_single_text(&output);
assert!(text.contains("[1] saofidelis.rj.gov.br"));
assert!(
text.contains("(original: Official site)"),
"text deve exibir titulo_original quando presente"
);
}
#[test]
fn format_result_with_original_title_shows_annotation_markdown() {
let mut output = test_output();
output.results = vec![SearchResult {
position: 1,
title: "saofidelis.rj.gov.br".to_string(),
url: "https://saofidelis.rj.gov.br".to_string(),
display_url: Some("saofidelis.rj.gov.br".to_string()),
snippet: Some("Prefeitura".to_string()),
original_title: Some("Official site".to_string()),
content: None,
content_size: None,
content_extraction_method: None,
}];
let md = format_single_markdown(&output);
assert!(md.contains("[saofidelis.rj.gov.br](https://saofidelis.rj.gov.br)"));
assert!(
md.contains("_Original title: Official site_"),
"markdown deve exibir titulo_original em itálico quando presente"
);
}
#[test]
fn format_result_without_original_title_no_annotation() {
let output = test_output();
let text = format_single_text(&output);
let md = format_single_markdown(&output);
assert!(!text.contains("(original:"));
assert!(!md.contains("_Original title:"));
}
#[test]
fn json_omits_original_title_when_absent() {
let output = test_output();
let json = serde_json::to_string(&output).expect("serialize");
assert!(
!json.contains("titulo_original"),
"JSON não deve expor titulo_original quando é None"
);
}
#[test]
fn json_includes_original_title_when_present() {
let mut output = test_output();
output.results[0].original_title = Some("Official site".to_string());
let json = serde_json::to_string(&output).expect("serialize");
assert!(json.contains("\"titulo_original\":\"Official site\""));
}
#[test]
fn json_no_longer_contains_related_searches_field() {
let output = test_output();
let json = serde_json::to_string(&output).expect("serialize");
assert!(
!json.contains("buscas_relacionadas"),
"v0.3.0 removeu buscas_relacionadas do schema JSON"
);
}
#[test]
fn format_multi_text_includes_separators_per_query() {
let output = MultiSearchOutput {
query_count: 2,
timestamp: "2026-04-14T00:00:00+00:00".to_string(),
parallelism: 3,
searches: vec![test_output(), test_output()],
};
let text = format_multi_text(&output);
assert!(text.contains("Queries: 2"));
assert!(text.contains("Parallel: 3"));
assert!(text.contains("========== Query #1 =========="));
assert!(text.contains("========== Query #2 =========="));
}
#[test]
fn format_multi_markdown_includes_overall_h1() {
let output = MultiSearchOutput {
query_count: 2,
timestamp: "2026-04-14T00:00:00+00:00".to_string(),
parallelism: 3,
searches: vec![test_output(), test_output()],
};
let md = format_multi_markdown(&output);
assert!(md.starts_with("# Multiple Searches (2 queries)"));
assert!(md.contains("**Paralelismo:** 3"));
assert_eq!(md.matches("# Resultados: teste").count(), 2);
}
#[test]
fn escapar_markdown_protege_caracteres_problematicos() {
assert_eq!(escapar_markdown("a*b"), "a\\*b");
assert_eq!(escapar_markdown("a[b]"), "a\\[b\\]");
assert_eq!(escapar_markdown("a`b"), "a\\`b");
assert_eq!(escapar_markdown("texto normal"), "texto normal");
}
#[test]
fn write_to_file_creates_parent_dirs() {
let temp = std::env::temp_dir().join(format!("ddgcli-output-test-{}", std::process::id()));
let _ = fs::remove_dir_all(&temp);
let file = temp.join("sub").join("nested").join("saida.txt");
write_to_file(&file, "conteudo de teste\nlinha 2\n")
.expect("should write file with parent directories");
let lido = fs::read_to_string(&file).expect("file should exist");
assert_eq!(lido, "conteudo de teste\nlinha 2\n");
fs::remove_dir_all(&temp).ok();
}
#[cfg(unix)]
#[test]
fn write_to_file_applies_644_permissions_on_unix() {
use std::os::unix::fs::PermissionsExt;
let file =
std::env::temp_dir().join(format!("ddgcli-perms-test-{}.txt", std::process::id()));
let _ = fs::remove_file(&file);
write_to_file(&file, "x").expect("should write");
let metadata = fs::metadata(&file).expect("should get metadata");
let modo = metadata.permissions().mode() & 0o777;
assert_eq!(modo, 0o644, "permissões devem ser 0o644 (foi {modo:o})");
fs::remove_file(&file).ok();
}
#[test]
fn emitir_json_single_via_serde_continua_estavel() {
let output = test_output();
let json = serde_json::to_string_pretty(&output).expect("serialization should work");
assert!(json.contains("\"query\": \"teste\""));
assert!(json.contains("\"quantidade_resultados\": 1"));
assert!(json.contains("\"motor\": \"duckduckgo\""));
}
#[test]
fn emit_ndjson_to_file_writes_single_parseable_line() {
let dir = tempfile::tempdir().expect("create tempdir");
let file = dir.path().join("ndjson.log");
let output = test_output();
emit_ndjson(&output, Some(&file)).expect("ndjson should write");
let content = fs::read_to_string(&file).expect("read file");
let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 1, "NDJSON = 1 linha por chamada");
let _: serde_json::Value =
serde_json::from_str(lines[0]).expect("NDJSON line should be valid JSON");
}
#[test]
fn emit_ndjson_two_calls_append_without_truncating() {
let dir = tempfile::tempdir().expect("create tempdir");
let file = dir.path().join("ndjson.log");
let output = test_output();
emit_ndjson(&output, Some(&file)).expect("1st write");
emit_ndjson(&output, Some(&file)).expect("2nd write (append)");
let content = fs::read_to_string(&file).expect("read");
let lines: Vec<&str> = content.lines().filter(|l| !l.trim().is_empty()).collect();
assert_eq!(lines.len(), 2, "modo append: 2 chamadas = 2 linhas");
}
#[test]
fn emit_ndjson_creates_parent_dirs_when_missing() {
let dir = tempfile::tempdir().expect("create tempdir");
let file = dir.path().join("sub/outro/out.ndjson");
assert!(!file.parent().unwrap().exists());
emit_ndjson(&test_output(), Some(&file)).expect("should create parents");
assert!(file.exists(), "arquivo criado");
assert!(file.parent().unwrap().exists(), "diretório pai criado");
}
#[test]
fn emit_stream_text_to_file_includes_query_header() {
let dir = tempfile::tempdir().expect("create tempdir");
let file = dir.path().join("stream.txt");
emit_stream_text(0, &test_output(), Some(&file)).expect("stream text");
emit_stream_text(1, &test_output(), Some(&file)).expect("stream text 2");
let content = fs::read_to_string(&file).expect("read");
assert!(content.contains("========== Query #1 =========="));
assert!(content.contains("========== Query #2 =========="));
assert!(content.contains("Query: teste"));
}
#[test]
fn emit_stream_markdown_separates_queries_with_divider_from_second() {
let dir = tempfile::tempdir().expect("create tempdir");
let file = dir.path().join("stream.md");
emit_stream_markdown(0, &test_output(), Some(&file)).expect("1st");
emit_stream_markdown(1, &test_output(), Some(&file)).expect("2nd");
let content = fs::read_to_string(&file).expect("read");
let ocorrencias = content.matches("\n---\n").count();
assert_eq!(
ocorrencias, 1,
"divisor apenas entre queries (1 para 2 blocos)"
);
assert!(content.contains("# Resultados: teste"));
}
#[test]
fn emit_result_stream_is_noop_and_does_not_create_file() {
use crate::parallel::StreamStats;
use crate::pipeline::PipelineResult;
let dir = tempfile::tempdir().expect("create tempdir");
let file = dir.path().join("nao-cria.json");
let stream_stats = StreamStats {
total: 3,
successes: 3,
errors: 0,
start_timestamp: "2026-04-14T00:00:00Z".to_string(),
parallelism: 2,
};
let res = PipelineResult::Stream(stream_stats);
emit_result(&res, OutputFormat::Json, Some(&file)).expect("no-op OK");
assert!(
!file.exists(),
"Stream não deve escrever nada em emit_result"
);
}
#[test]
fn emit_result_single_to_file_writes_formatted_json() {
use crate::pipeline::PipelineResult;
let dir = tempfile::tempdir().expect("create tempdir");
let file = dir.path().join("saida.json");
let res = PipelineResult::Single(Box::new(test_output()));
emit_result(&res, OutputFormat::Json, Some(&file)).expect("emit");
let content = fs::read_to_string(&file).expect("read");
let _: serde_json::Value =
serde_json::from_str(&content).expect("content should be valid JSON");
assert!(content.contains("\"query\": \"teste\""));
}
#[test]
fn emit_result_multi_text_to_file_contains_both_queries() {
use crate::pipeline::PipelineResult;
use crate::types::MultiSearchOutput;
let dir = tempfile::tempdir().expect("create tempdir");
let file = dir.path().join("multi.txt");
let mut output1 = test_output();
output1.query = "alpha".into();
let mut output2 = test_output();
output2.query = "beta".into();
let multi = MultiSearchOutput {
query_count: 2,
timestamp: "2026-04-14T00:00:00Z".into(),
parallelism: 2,
searches: vec![output1, output2],
};
let res = PipelineResult::Multi(Box::new(multi));
emit_result(&res, OutputFormat::Text, Some(&file)).expect("emit");
let content = fs::read_to_string(&file).expect("read");
assert!(content.contains("Query: alpha"));
assert!(content.contains("Query: beta"));
}
#[test]
fn emit_result_auto_to_file_writes_json() {
use crate::pipeline::PipelineResult;
let dir = tempfile::tempdir().expect("create tempdir");
let file = dir.path().join("auto.out");
let res = PipelineResult::Single(Box::new(test_output()));
emit_result(&res, OutputFormat::Auto, Some(&file)).expect("emit");
let content = fs::read_to_string(&file).expect("read");
assert!(content.trim_start().starts_with('{'));
assert!(content.contains("\"query\""));
}
#[test]
fn is_broken_pipe_detects_broken_pipe() {
assert!(is_broken_pipe(&CliError::BrokenPipe));
}
#[test]
fn is_broken_pipe_rejects_other_errors() {
let err = CliError::PathError {
message: "not found".into(),
};
assert!(!is_broken_pipe(&err));
}
#[test]
fn is_broken_pipe_rejects_network_error() {
let err = CliError::NetworkError {
message: "timeout".into(),
};
assert!(!is_broken_pipe(&err));
}
}