Skip to content

Commit f34d36f

Browse files
authored
fix: UTF-8 char boundary panic in extract_title (#12)
Co-authored-by: shayne-snap <shayne-snap@users.noreply.github.com>
1 parent 9c674bb commit f34d36f

3 files changed

Lines changed: 119 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "waylog"
3-
version = "0.2.0"
3+
version = "0.2.1"
44
edition = "2021"
55
authors = ["WayLog Contributors"]
66
description = "Automatically save chats from Claude, Codex, and Gemini CLI to local Markdown files."

src/exporter/markdown/formatter.rs

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,12 @@ pub(crate) fn extract_title(messages: &[ChatMessage]) -> String {
5555
.iter()
5656
.find(|m| matches!(m.role, MessageRole::User))
5757
.map(|m| {
58-
// Take first line or first 60 characters
58+
// Take first line or first 60 characters (char-boundary safe)
5959
let first_line = m.content.lines().next().unwrap_or("Untitled Session");
60-
if first_line.len() > 60 {
61-
format!("{}...", &first_line[..60])
60+
let char_count = first_line.chars().count();
61+
if char_count > 60 {
62+
let truncated: String = first_line.chars().take(60).collect();
63+
format!("{}...", truncated)
6264
} else {
6365
first_line.to_string()
6466
}
@@ -70,3 +72,115 @@ pub(crate) fn extract_title(messages: &[ChatMessage]) -> String {
7072
pub(crate) fn format_datetime(dt: &DateTime<Utc>) -> String {
7173
dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()
7274
}
75+
76+
#[cfg(test)]
77+
mod tests {
78+
use super::*;
79+
use crate::providers::base::MessageMetadata;
80+
81+
fn create_test_message(content: &str, role: MessageRole) -> ChatMessage {
82+
ChatMessage {
83+
id: "test-id".to_string(),
84+
role,
85+
content: content.to_string(),
86+
timestamp: Utc::now(),
87+
metadata: MessageMetadata::default(),
88+
}
89+
}
90+
91+
#[test]
92+
fn test_extract_title_short_english() {
93+
let messages = vec![create_test_message("Hello world", MessageRole::User)];
94+
let title = extract_title(&messages);
95+
assert_eq!(title, "Hello world");
96+
}
97+
98+
#[test]
99+
fn test_extract_title_long_english() {
100+
let long_text =
101+
"This is a very long message that exceeds sixty characters and should be truncated";
102+
let messages = vec![create_test_message(long_text, MessageRole::User)];
103+
let title = extract_title(&messages);
104+
assert!(title.ends_with("..."));
105+
assert!(title.len() <= 63); // 60 chars + "..."
106+
}
107+
108+
#[test]
109+
fn test_extract_title_short_chinese() {
110+
let messages = vec![create_test_message("你好世界", MessageRole::User)];
111+
let title = extract_title(&messages);
112+
assert_eq!(title, "你好世界");
113+
}
114+
115+
#[test]
116+
fn test_extract_title_long_chinese() {
117+
let long_chinese =
118+
"把 pg_stateful.yaml 改写为 docker compose 可以运行的yaml,输出到 docker-compose.yaml";
119+
let messages = vec![create_test_message(long_chinese, MessageRole::User)];
120+
// This should not panic
121+
let title = extract_title(&messages);
122+
assert!(title.ends_with("..."));
123+
}
124+
125+
#[test]
126+
fn test_extract_title_mixed_long() {
127+
let mixed = "这是一个包含English和中文的very long message that should be truncated properly without panic";
128+
let messages = vec![create_test_message(mixed, MessageRole::User)];
129+
let title = extract_title(&messages);
130+
assert!(title.ends_with("..."));
131+
}
132+
133+
#[test]
134+
fn test_extract_title_multiline() {
135+
let multiline = "First line\nSecond line\nThird line";
136+
let messages = vec![create_test_message(multiline, MessageRole::User)];
137+
let title = extract_title(&messages);
138+
assert_eq!(title, "First line");
139+
}
140+
141+
#[test]
142+
fn test_extract_title_empty_messages() {
143+
let messages: Vec<ChatMessage> = vec![];
144+
let title = extract_title(&messages);
145+
assert_eq!(title, "Untitled Session");
146+
}
147+
148+
#[test]
149+
fn test_extract_title_no_user_messages() {
150+
let messages = vec![
151+
create_test_message("Assistant response", MessageRole::Assistant),
152+
create_test_message("System message", MessageRole::System),
153+
];
154+
let title = extract_title(&messages);
155+
assert_eq!(title, "Untitled Session");
156+
}
157+
158+
#[test]
159+
fn test_extract_title_exactly_60_chars() {
160+
let exactly_60 = "a".repeat(60);
161+
let messages = vec![create_test_message(&exactly_60, MessageRole::User)];
162+
let title = extract_title(&messages);
163+
assert_eq!(title, exactly_60);
164+
assert!(!title.ends_with("..."));
165+
}
166+
167+
#[test]
168+
fn test_extract_title_with_emoji() {
169+
let with_emoji = "Hello 👋 this is a message with emoji 🎉 that might be long enough to truncate properly";
170+
let messages = vec![create_test_message(with_emoji, MessageRole::User)];
171+
let title = extract_title(&messages);
172+
// Should not panic on emoji boundaries
173+
assert!(title.len() > 0);
174+
}
175+
176+
#[test]
177+
fn test_extract_title_finds_first_user_message() {
178+
let messages = vec![
179+
create_test_message("System init", MessageRole::System),
180+
create_test_message("First user message", MessageRole::User),
181+
create_test_message("Second user message", MessageRole::User),
182+
];
183+
let title = extract_title(&messages);
184+
assert_eq!(title, "First user message");
185+
}
186+
}

0 commit comments

Comments
 (0)