Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions crates/goose-cli/src/commands/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use cliclack::{self, intro, outro};
use std::path::Path;

use crate::project_tracker::ProjectTracker;
use crate::utils::safe_truncate;

/// Format a DateTime for display
fn format_date(date: DateTime<chrono::Utc>) -> String {
Expand Down Expand Up @@ -199,11 +200,7 @@ pub fn handle_projects_interactive() -> Result<()> {
.last_instruction
.as_ref()
.map_or(String::new(), |instr| {
let truncated = if instr.len() > 40 {
format!("{}...", &instr[0..37])
} else {
instr.clone()
};
let truncated = safe_truncate(instr, 40);
format!(" [{}]", truncated)
});

Expand Down
13 changes: 3 additions & 10 deletions crates/goose-cli/src/commands/session.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::session::message_to_markdown;
use crate::utils::safe_truncate;
use anyhow::{Context, Result};
use cliclack::{confirm, multiselect, select};
use goose::session::info::{get_valid_sorted_sessions, SessionInfo, SortOrder};
Expand Down Expand Up @@ -50,11 +51,7 @@ fn prompt_interactive_session_removal(sessions: &[SessionInfo]) -> Result<Vec<Se
} else {
&s.metadata.description
};
let truncated_desc = if desc.len() > TRUNCATED_DESC_LENGTH {
format!("{}...", &desc[..TRUNCATED_DESC_LENGTH - 3])
} else {
desc.to_string()
};
let truncated_desc = safe_truncate(desc, TRUNCATED_DESC_LENGTH);
let display_text = format!("{} - {} ({})", s.modified, truncated_desc, s.id);
(display_text, s.clone())
})
Expand Down Expand Up @@ -320,11 +317,7 @@ pub fn prompt_interactive_session_selection() -> Result<session::Identifier> {
};

// Truncate description if too long
let truncated_desc = if desc.len() > 40 {
format!("{}...", &desc[..37])
} else {
desc.to_string()
};
let truncated_desc = safe_truncate(desc, 40);

let display_text = format!("{} - {} ({})", s.modified, truncated_desc, s.id);
(display_text, s.clone())
Expand Down
1 change: 1 addition & 0 deletions crates/goose-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod project_tracker;
pub mod recipes;
pub mod session;
pub mod signal;
pub mod utils;
// Re-export commonly used types
pub use session::Session;

Expand Down
50 changes: 50 additions & 0 deletions crates/goose-cli/src/utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/// Utility functions for safe string handling and other common operations
/// Safely truncate a string at character boundaries, not byte boundaries
///
/// This function ensures that multi-byte UTF-8 characters (like Japanese, emoji, etc.)
/// are not split in the middle, which would cause a panic.
///
/// # Arguments
/// * `s` - The string to truncate
/// * `max_chars` - Maximum number of characters to keep
///
/// # Returns
/// A truncated string with "..." appended if truncation occurred
pub fn safe_truncate(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
s.to_string()
} else {
let truncated: String = s.chars().take(max_chars.saturating_sub(3)).collect();
format!("{}...", truncated)
}
Comment on lines +14 to +19
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally fine as is, just a note this walks the string twice. Hopefully we aren't passing large file data here anyways.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also came across https://doc.rust-lang.org/std/primitive.str.html#method.floor_char_boundary which is exactly what we want here, but yeah still experimental.

}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_safe_truncate_ascii() {
assert_eq!(safe_truncate("hello world", 20), "hello world");
assert_eq!(safe_truncate("hello world", 8), "hello...");
assert_eq!(safe_truncate("hello", 5), "hello");
assert_eq!(safe_truncate("hello", 3), "...");
}

#[test]
fn test_safe_truncate_japanese() {
// Japanese characters: "こんにちは世界" (Hello World)
let japanese = "こんにちは世界";
assert_eq!(safe_truncate(japanese, 10), japanese);
assert_eq!(safe_truncate(japanese, 5), "こん...");
assert_eq!(safe_truncate(japanese, 7), japanese);
}

#[test]
fn test_safe_truncate_mixed() {
// Mixed ASCII and Japanese
let mixed = "Hello こんにちは";
assert_eq!(safe_truncate(mixed, 20), mixed);
assert_eq!(safe_truncate(mixed, 8), "Hello...");
}
}