diff --git a/crates/idx-cli/src/cli/cache.rs b/crates/idx-cli/src/cli/cache.rs index f542d10..6e13895 100644 --- a/crates/idx-cli/src/cli/cache.rs +++ b/crates/idx-cli/src/cli/cache.rs @@ -34,7 +34,10 @@ pub fn handle(cmd: &CacheCmd, format: &OutputFormat) -> Result<(), IdxError> { "newest": info.newest, })); } - println!("path: {}", info.path.display()); + println!( + "path: {}", + crate::output::text::terminal_safe(&info.path.display().to_string()) + ); println!("files: {}", info.files); println!("size_bytes: {}", info.total_size); println!( diff --git a/crates/idx-cli/src/cli/config.rs b/crates/idx-cli/src/cli/config.rs index e4d7dac..f6a3696 100644 --- a/crates/idx-cli/src/cli/config.rs +++ b/crates/idx-cli/src/cli/config.rs @@ -29,7 +29,7 @@ pub fn handle(cmd: &ConfigCmd, format: &OutputFormat) -> Result<(), IdxError> { if format.is_json() { json::print_json(&document) } else { - println!("{text}"); + println!("{}", crate::output::text::terminal_safe(&text)); Ok(()) } }; diff --git a/crates/idx-cli/src/cli/ownership.rs b/crates/idx-cli/src/cli/ownership.rs index dca938f..9ef70cd 100644 --- a/crates/idx-cli/src/cli/ownership.rs +++ b/crates/idx-cli/src/cli/ownership.rs @@ -228,7 +228,7 @@ fn handle_sync(investor: &Investor, args: &SyncArgs, config: &IdxConfig) -> Resu result.latest_as_of_date.format("%Y-%m-%d"), result.release_count, result.ticker_count, - result.db_path + terminal_safe(&result.db_path) ); } snapshot::OwnershipSyncAction::Updated => { @@ -237,14 +237,14 @@ fn handle_sync(investor: &Investor, args: &SyncArgs, config: &IdxConfig) -> Resu result.latest_as_of_date.format("%Y-%m-%d"), result.release_count, result.ticker_count, - result.db_path + terminal_safe(&result.db_path) ); } snapshot::OwnershipSyncAction::Refreshed => { println!( "Refreshed ownership snapshot {} in {}.", result.latest_as_of_date.format("%Y-%m-%d"), - result.db_path + terminal_safe(&result.db_path) ); } snapshot::OwnershipSyncAction::NoChange @@ -318,11 +318,11 @@ fn handle_ticker( println!( "{} {} KSEI={} Bing={}", "Ownership:".bold(), - data.ticker.code.bold(), + terminal_safe(&data.ticker.code).bold(), data.ksei_as_of .map(|d| d.format("%Y-%m-%d").to_string()) .unwrap_or_else(|| "-".to_string()), - data.bing_as_of.unwrap_or_else(|| "-".to_string()) + terminal_safe(&data.bing_as_of.unwrap_or_else(|| "-".to_string())) ); let mut table = Table::new(); diff --git a/crates/idx-cli/src/output/mod.rs b/crates/idx-cli/src/output/mod.rs index 055d08f..f9057b2 100644 --- a/crates/idx-cli/src/output/mod.rs +++ b/crates/idx-cli/src/output/mod.rs @@ -211,7 +211,8 @@ pub fn render_screener( pub fn emit_error(err: &IdxError, format: &OutputFormat) { if !format.is_json() { - eprintln!("Error: {err}"); + // Errors can carry provider, KSEI, or manifest text. + eprintln!("Error: {}", text::terminal_safe(&err.to_string())); return; } let payload = serde_json::json!({ diff --git a/crates/idx-cli/src/output/table.rs b/crates/idx-cli/src/output/table.rs index 21264c7..05f0907 100644 --- a/crates/idx-cli/src/output/table.rs +++ b/crates/idx-cli/src/output/table.rs @@ -135,7 +135,8 @@ pub fn print_technical(report: &TechnicalReport, no_color: bool) -> Result<(), I "{}", format!( "Technical Analysis for {} ({})", - report.symbol, report.as_of + terminal_safe(&report.symbol), + report.as_of ) .bold() ); @@ -363,7 +364,7 @@ pub fn print_compare(reports: &[FundamentalReport], no_color: bool) -> Result<() "Symbol", reports .iter() - .map(|report| report.symbol.clone()) + .map(|report| terminal_safe(&report.symbol)) .collect::>(), ); add_compare_row( diff --git a/crates/idx-cli/src/output/text.rs b/crates/idx-cli/src/output/text.rs index d9c64cc..91ffad4 100644 --- a/crates/idx-cli/src/output/text.rs +++ b/crates/idx-cli/src/output/text.rs @@ -1,24 +1,8 @@ -//! Text from providers and KSEI files is untrusted: before it reaches a -//! terminal, control characters (which can start escape sequences that clear -//! the screen, rewrite output, or set the window title) and bidirectional -//! overrides (which can reorder what is displayed) are replaced. +//! Terminal-safe table cells; see [`idx_core::text`]. use comfy_table::Cell; -/// `text` with control characters other than newline, and bidirectional -/// override characters, replaced by U+FFFD. -pub fn terminal_safe(text: &str) -> String { - text.chars() - .map(|ch| { - let bidi = matches!(ch, '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'); - if (ch.is_control() && ch != '\n') || bidi { - '\u{FFFD}' - } else { - ch - } - }) - .collect() -} +pub use idx_core::text::terminal_safe; /// A table cell whose content is made [`terminal_safe`]. pub fn cell(value: impl ToString) -> Cell { @@ -42,26 +26,7 @@ pub fn truncate_chars(text: &str, max: usize) -> String { #[cfg(test)] mod tests { - use super::{painted, terminal_safe, truncate_chars}; - - #[test] - fn escape_sequences_and_bidi_overrides_are_neutralized() { - assert_eq!( - terminal_safe("\u{1b}[2J\u{1b}[Hfake"), - "\u{FFFD}[2J\u{FFFD}[Hfake" - ); - assert_eq!( - terminal_safe("\u{1b}]0;title\u{7}x"), - "\u{FFFD}]0;title\u{FFFD}x" - ); - assert_eq!(terminal_safe("a\u{9b}31mb"), "a\u{FFFD}31mb"); - assert_eq!(terminal_safe("ab\u{202E}cd"), "ab\u{FFFD}cd"); - assert_eq!(terminal_safe("line one\nline two"), "line one\nline two"); - assert_eq!( - terminal_safe("Saham Grup Djarum’s 14,81%"), - "Saham Grup Djarum’s 14,81%" - ); - } + use super::{painted, truncate_chars}; #[test] fn painted_cells_keep_idx_color_codes() { diff --git a/crates/idx-core/src/lib.rs b/crates/idx-core/src/lib.rs index a89bc2f..0e67aca 100644 --- a/crates/idx-core/src/lib.rs +++ b/crates/idx-core/src/lib.rs @@ -15,6 +15,7 @@ pub mod investor; pub mod market; #[cfg(feature = "ownership")] pub mod ownership; +pub mod text; pub use client::{CachePolicy, Fetched, Idx}; @@ -47,7 +48,10 @@ pub mod runtime { let line = serde_json::json!({ "level": level, "message": message.to_string() }); eprintln!("{line}"); } else { - eprintln!("{level}: {message}"); + eprintln!( + "{level}: {}", + crate::text::terminal_safe(&message.to_string()) + ); } } diff --git a/crates/idx-core/src/text.rs b/crates/idx-core/src/text.rs new file mode 100644 index 0000000..4421815 --- /dev/null +++ b/crates/idx-core/src/text.rs @@ -0,0 +1,44 @@ +//! Text from providers, KSEI files, and snapshot manifests is untrusted. +//! Before it reaches a terminal, control characters (which can start escape +//! sequences that clear the screen, rewrite output, or set the window title) +//! and bidirectional overrides (which can reorder what is displayed) are +//! replaced. + +/// `text` with control characters other than newline, and bidirectional +/// override characters, replaced by U+FFFD. +pub fn terminal_safe(text: &str) -> String { + text.chars() + .map(|ch| { + let bidi = matches!(ch, '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'); + if (ch.is_control() && ch != '\n') || bidi { + '\u{FFFD}' + } else { + ch + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::terminal_safe; + + #[test] + fn escape_sequences_and_bidi_overrides_are_neutralized() { + assert_eq!( + terminal_safe("\u{1b}[2J\u{1b}[Hfake"), + "\u{FFFD}[2J\u{FFFD}[Hfake" + ); + assert_eq!( + terminal_safe("\u{1b}]0;title\u{7}x"), + "\u{FFFD}]0;title\u{FFFD}x" + ); + assert_eq!(terminal_safe("a\u{9b}31mb"), "a\u{FFFD}31mb"); + assert_eq!(terminal_safe("ab\u{202E}cd"), "ab\u{FFFD}cd"); + assert_eq!(terminal_safe("line one\nline two"), "line one\nline two"); + assert_eq!( + terminal_safe("Saham Grup Djarum’s 14,81%"), + "Saham Grup Djarum’s 14,81%" + ); + } +}