Skip to content

Commit 395c500

Browse files
authored
fix: prevent underflow crash in truncate_str (#125)
1 parent a413c58 commit 395c500

File tree

10 files changed

+133
-39
lines changed

10 files changed

+133
-39
lines changed

cli/src/commands/history.rs

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use color_eyre::eyre::Result;
44

55
use crate::cli::HistoryCommands;
66
use crate::data::{self, HistoryStore};
7+
use crate::ui::utils::truncate_str;
78

89
pub fn run(command: Option<HistoryCommands>) -> Result<()> {
910
let cmd = command.unwrap_or(HistoryCommands::Summary {
@@ -218,14 +219,6 @@ pub fn get_date_range(period: &str) -> (String, String) {
218219
}
219220
}
220221

221-
pub fn truncate_str(s: &str, max_len: usize) -> String {
222-
if s.len() <= max_len {
223-
s.to_string()
224-
} else {
225-
format!("{}...", &s[..max_len - 3])
226-
}
227-
}
228-
229222
fn export_to_json(
230223
from: &str,
231224
to: &str,

cli/src/main.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ mod tests {
6868
use clap::CommandFactory;
6969

7070
use crate::cli::{DaemonCommands, HistoryCommands, ThemeCommands};
71-
use crate::commands::history::{escape_csv, get_date_range, truncate_str};
71+
use crate::commands::history::{escape_csv, get_date_range};
72+
use crate::ui::utils::truncate_str;
7273

7374
#[test]
7475
fn cli_configuration_is_valid() {

cli/src/ui/history.rs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use crate::input::keys;
1515
use crate::theme::ThemeColors;
1616

1717
use super::utils::{
18-
centered_rect_percent, energy_unit_label, format_energy, format_energy_compact,
18+
centered_rect_percent, energy_unit_label, format_energy, format_energy_compact, truncate_str,
1919
};
2020

2121
pub fn render(frame: &mut Frame, app: &App, theme: &ThemeColors) {
@@ -453,7 +453,7 @@ fn render_top_processes(frame: &mut Frame, area: Rect, app: &App, theme: &ThemeC
453453
let power_color = power_level_color(p.avg_power, max_power, theme);
454454

455455
Row::new(vec![
456-
truncate_name(&p.process_name, name_width),
456+
truncate_str(&p.process_name, name_width),
457457
format!("{:.1}", p.avg_power),
458458
format_energy_compact(p.total_energy_wh, energy_unit),
459459
format!("{:.0}", p.avg_cpu),
@@ -509,11 +509,3 @@ fn render_footer(frame: &mut Frame, area: Rect, theme: &ThemeColors) {
509509
.centered();
510510
frame.render_widget(footer, area);
511511
}
512-
513-
fn truncate_name(name: &str, max_len: usize) -> String {
514-
if name.len() <= max_len {
515-
name.to_string()
516-
} else {
517-
format!("{}...", &name[..max_len - 3])
518-
}
519-
}

cli/src/ui/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ mod status_bar;
1111
mod system_stats;
1212
mod theme_importer;
1313
mod theme_picker;
14-
mod utils;
14+
pub mod utils;
1515

1616
use ratatui::{
1717
layout::{Constraint, Direction, Layout},

cli/src/ui/processes.rs

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use crate::app::{App, SortColumn};
1010
use crate::data::ProcessState;
1111
use crate::theme::ThemeColors;
1212

13-
use super::utils::format_duration;
13+
use super::utils::{format_duration, truncate_str};
1414

1515
const COL_EXPAND: u16 = 6;
1616
const COL_PID: u16 = 7;
@@ -235,8 +235,8 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App, theme: &ThemeColors)
235235
Span::styled(process.pid.to_string(), style),
236236
Span::styled(status_char, status_style),
237237
Span::styled(format!("{:.1}", process.energy_impact), style),
238-
Span::styled(truncate_name(display_name, name_width), style),
239-
Span::styled(truncate_name(&process.command_args, command_width), style),
238+
Span::styled(truncate_str(display_name, name_width), style),
239+
Span::styled(truncate_str(&process.command_args, command_width), style),
240240
Span::styled(format!("{:.1}", process.cpu_usage), style),
241241
Span::styled(format_memory(process.memory_mb), style),
242242
Span::styled(disk_io, style),
@@ -269,14 +269,6 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App, theme: &ThemeColors)
269269
frame.render_widget(table, inner);
270270
}
271271

272-
fn truncate_name(name: &str, max_len: usize) -> String {
273-
if name.len() <= max_len {
274-
name.to_string()
275-
} else {
276-
format!("{}...", &name[..max_len - 3])
277-
}
278-
}
279-
280272
fn format_header(name: &str, col: SortColumn, current: SortColumn, indicator: &str) -> String {
281273
if col == current {
282274
format!("{} {}", name, indicator)

cli/src/ui/status_bar.rs

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ use crate::data::SystemInfo;
1111
use crate::input::keys;
1212
use crate::theme::ThemeColors;
1313

14+
use super::utils::truncate_str;
15+
1416
pub fn render_title_bar(
1517
frame: &mut Frame,
1618
area: Rect,
@@ -45,11 +47,7 @@ pub fn render_title_bar(
4547

4648
pub fn render_status_bar(frame: &mut Frame, area: Rect, app: &App, theme: &ThemeColors) {
4749
let theme_name = app.config.theme_name();
48-
let theme_display = if theme_name.len() > 12 {
49-
format!("{}...", &theme_name[..9])
50-
} else {
51-
theme_name.to_string()
52-
};
50+
let theme_display = truncate_str(theme_name, 12);
5351

5452
let appearance = app.config.appearance_label().to_lowercase();
5553

cli/src/ui/utils.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,19 @@ pub fn convert_temperature(celsius: f32, unit: TemperatureUnit) -> f32 {
125125
}
126126
}
127127

128+
pub fn truncate_str(s: &str, max_len: usize) -> String {
129+
let char_count = s.chars().count();
130+
if char_count <= max_len {
131+
s.to_string()
132+
} else if max_len <= 3 {
133+
s.chars().take(max_len).collect()
134+
} else {
135+
let visible_len = max_len - 3;
136+
let result: String = s.chars().take(visible_len).collect();
137+
result + "..."
138+
}
139+
}
140+
128141
#[cfg(test)]
129142
mod tests {
130143
use super::*;
@@ -241,4 +254,61 @@ mod tests {
241254
"1.0 GiB"
242255
);
243256
}
257+
258+
#[test]
259+
fn test_truncate_str_no_underflow_when_max_len_small() {
260+
assert_eq!(truncate_str("Terminal", 0), "");
261+
assert_eq!(truncate_str("Terminal", 1), "T");
262+
assert_eq!(truncate_str("Terminal", 2), "Te");
263+
assert_eq!(truncate_str("Terminal", 3), "Ter");
264+
}
265+
266+
#[test]
267+
fn test_truncate_str_adds_ellipsis() {
268+
assert_eq!(truncate_str("Terminal", 7), "Term...");
269+
assert_eq!(truncate_str("Terminal", 4), "T...");
270+
}
271+
272+
#[test]
273+
fn test_truncate_str_unchanged_when_fits() {
274+
assert_eq!(truncate_str("Terminal", 8), "Terminal");
275+
assert_eq!(truncate_str("Terminal", 10), "Terminal");
276+
}
277+
278+
#[test]
279+
fn test_truncate_str_utf8_multibyte_chars() {
280+
// Emoji (4 bytes each in UTF-8)
281+
assert_eq!(truncate_str("🚀🔥💻", 3), "🚀🔥💻"); // Fits exactly
282+
assert_eq!(truncate_str("🚀🔥💻", 2), "🚀🔥"); // max_len <= 3, no ellipsis
283+
assert_eq!(truncate_str("🚀🔥💻🎉", 4), "🚀🔥💻🎉"); // Fits exactly (4 chars)
284+
assert_eq!(truncate_str("🚀🔥💻🎉🌟", 4), "🚀..."); // Truncates with ellipsis
285+
286+
// Mixed ASCII and emoji
287+
assert_eq!(truncate_str("Terminal🚀", 9), "Terminal🚀"); // Fits exactly
288+
assert_eq!(truncate_str("Terminal🚀", 8), "Termi..."); // Truncates
289+
290+
// CJK characters (3 bytes each in UTF-8)
291+
assert_eq!(truncate_str("文字列", 3), "文字列"); // Fits exactly
292+
assert_eq!(truncate_str("文字列テスト", 5), "文字..."); // Truncates with ellipsis
293+
294+
// Accented characters
295+
assert_eq!(truncate_str("café", 4), "café"); // Fits exactly
296+
assert_eq!(truncate_str("naïve", 4), "n..."); // Truncates with ellipsis
297+
}
298+
299+
#[test]
300+
fn test_truncate_str_utf8_edge_cases() {
301+
// Empty string
302+
assert_eq!(truncate_str("", 0), "");
303+
assert_eq!(truncate_str("", 5), "");
304+
305+
// Single multi-byte char with small max_len
306+
assert_eq!(truncate_str("🚀", 1), "🚀"); // Fits (1 char)
307+
assert_eq!(truncate_str("🚀", 0), ""); // Zero length
308+
309+
// Ensure no panic on boundary conditions
310+
assert_eq!(truncate_str("🚀hello", 1), "🚀");
311+
assert_eq!(truncate_str("🚀hello", 4), "🚀...");
312+
assert_eq!(truncate_str("🚀hello", 6), "🚀hello");
313+
}
244314
}

fixtures/responses/current_data.json

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"pid": 1234,
3535
"name": "Safari",
3636
"command": "/Applications/Safari.app/Contents/MacOS/Safari",
37+
"command_args": "/Applications/Safari.app/Contents/MacOS/Safari -NSDocumentRevisionsDebugMode YES",
3738
"cpu_usage": 15.5,
3839
"memory_mb": 256.0,
3940
"energy_impact": 25.0,
@@ -43,6 +44,7 @@
4344
"pid": 1235,
4445
"name": "Safari Web Content",
4546
"command": "Safari Web Content",
47+
"command_args": "Safari Web Content",
4648
"cpu_usage": 5.0,
4749
"memory_mb": 128.0,
4850
"energy_impact": 10.0,
@@ -63,6 +65,28 @@
6365
"run_time_secs": 7200,
6466
"total_cpu_time_secs": 600
6567
}
66-
]
68+
],
69+
"system": {
70+
"chip": "Apple M1 Pro",
71+
"os_version": "14.2.1",
72+
"p_cores": 8,
73+
"e_cores": 2
74+
},
75+
"system_stats": {
76+
"cpu_usage_percent": 25.5,
77+
"load_one": 2.5,
78+
"load_five": 2.0,
79+
"load_fifteen": 1.5,
80+
"memory_used_bytes": 8589934592,
81+
"memory_total_bytes": 17179869184,
82+
"uptime_secs": 86400,
83+
"is_warmed_up": true
84+
},
85+
"forecast": {
86+
"duration_secs": 18000,
87+
"avg_power_watts": 12.5,
88+
"sample_count": 30,
89+
"source": "daemon"
90+
}
6791
}
6892
}

fixtures/responses/data_update.json

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"pid": 1234,
3535
"name": "Safari",
3636
"command": "/Applications/Safari.app/Contents/MacOS/Safari",
37+
"command_args": "/Applications/Safari.app/Contents/MacOS/Safari -NSDocumentRevisionsDebugMode YES",
3738
"cpu_usage": 15.5,
3839
"memory_mb": 256.0,
3940
"energy_impact": 25.0,
@@ -43,6 +44,7 @@
4344
"pid": 1235,
4445
"name": "Safari Web Content",
4546
"command": "Safari Web Content",
47+
"command_args": "Safari Web Content",
4648
"cpu_usage": 5.0,
4749
"memory_mb": 128.0,
4850
"energy_impact": 10.0,
@@ -63,6 +65,28 @@
6365
"run_time_secs": 7200,
6466
"total_cpu_time_secs": 600
6567
}
66-
]
68+
],
69+
"system": {
70+
"chip": "Apple M1 Pro",
71+
"os_version": "14.2.1",
72+
"p_cores": 8,
73+
"e_cores": 2
74+
},
75+
"system_stats": {
76+
"cpu_usage_percent": 25.5,
77+
"load_one": 2.5,
78+
"load_five": 2.0,
79+
"load_fifteen": 1.5,
80+
"memory_used_bytes": 8589934592,
81+
"memory_total_bytes": 17179869184,
82+
"uptime_secs": 86400,
83+
"is_warmed_up": true
84+
},
85+
"forecast": {
86+
"duration_secs": 18000,
87+
"avg_power_watts": 12.5,
88+
"sample_count": 30,
89+
"source": "daemon"
90+
}
6791
}
6892
}

fixtures/responses/status.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"version": "0.1.0",
99
"subscriber_count": 2,
1010
"history_enabled": true,
11-
"protocol_version": 1,
11+
"protocol_version": 2,
1212
"min_supported_version": 1
1313
}
1414
}

0 commit comments

Comments
 (0)