use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SoftTrimConfig {
pub max_chars: usize,
pub head_percent: usize,
pub tail_percent: usize,
pub ellipsis: String,
pub preserve_words: bool,
}
impl Default for SoftTrimConfig {
fn default() -> Self {
Self {
max_chars: 500,
head_percent: 60,
tail_percent: 30,
ellipsis: "\n...\n".to_string(),
preserve_words: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SoftTrimResult {
pub content: String,
pub was_trimmed: bool,
pub original_chars: usize,
pub trimmed_chars: usize,
pub chars_removed: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompactMemory {
pub id: i64,
pub preview: String,
pub memory_type: String,
pub tags: Vec<String>,
pub importance: Option<f32>,
pub created_at: String,
pub updated_at: String,
pub workspace: String,
pub tier: String,
pub content_length: usize,
pub is_truncated: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentStats {
pub chars: usize,
pub words: usize,
pub lines: usize,
pub sentences: usize,
pub paragraphs: usize,
}
pub fn soft_trim(content: &str, config: &SoftTrimConfig) -> SoftTrimResult {
let original_chars = content.chars().count();
if original_chars <= config.max_chars {
return SoftTrimResult {
content: content.to_string(),
was_trimmed: false,
original_chars,
trimmed_chars: original_chars,
chars_removed: 0,
};
}
let ellipsis_char_len = config.ellipsis.chars().count();
let available = config.max_chars.saturating_sub(ellipsis_char_len);
let head_char_count = (available * config.head_percent) / 100;
let tail_char_count = (available * config.tail_percent) / 100;
let head_byte_end: usize = content
.char_indices()
.take(head_char_count)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
let mut head_end = head_byte_end;
if config.preserve_words && head_end < content.len() {
if let Some(last_space) = content[..head_end].rfind(|c: char| c.is_whitespace()) {
if last_space > head_end / 2 {
head_end = last_space;
}
}
}
let total_chars = original_chars;
let tail_start_char = total_chars.saturating_sub(tail_char_count);
let tail_byte_start: usize = content
.char_indices()
.nth(tail_start_char)
.map(|(i, _)| i)
.unwrap_or(content.len());
let mut tail_start = tail_byte_start;
if config.preserve_words && tail_start > 0 && tail_start < content.len() {
if let Some(first_space) = content[tail_start..].find(|c: char| c.is_whitespace()) {
let new_start = tail_start + first_space + 1;
if new_start < content.len() {
tail_start = new_start;
}
}
}
if head_end >= tail_start {
let truncate_byte_end: usize = content
.char_indices()
.take(config.max_chars)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(content.len());
let truncated = &content[..truncate_byte_end.min(content.len())];
let trimmed_chars = truncated.chars().count() + ellipsis_char_len;
return SoftTrimResult {
content: format!("{}{}", truncated.trim_end(), config.ellipsis.trim()),
was_trimmed: true,
original_chars,
trimmed_chars,
chars_removed: original_chars - truncated.chars().count(),
};
}
let head = content[..head_end].trim_end();
let tail = content[tail_start..].trim_start();
let trimmed = format!("{}{}{}", head, config.ellipsis, tail);
SoftTrimResult {
content: trimmed.clone(),
was_trimmed: true,
original_chars,
trimmed_chars: trimmed.chars().count(),
chars_removed: original_chars - head.chars().count() - tail.chars().count(),
}
}
pub fn compact_preview(content: &str, max_chars: usize) -> (String, bool) {
let content = content.trim();
if content.is_empty() {
return (String::new(), false);
}
let first_line = content.lines().next().unwrap_or(content);
let char_count = first_line.chars().count();
if char_count <= max_chars {
let is_truncated = content.len() > first_line.len();
return (first_line.to_string(), is_truncated);
}
let mut byte_end = first_line
.char_indices()
.nth(max_chars.min(char_count))
.map(|(pos, _)| pos)
.unwrap_or(first_line.len());
let slice_to_check = &first_line[..byte_end];
if let Some(last_space) = slice_to_check.rfind(' ') {
if last_space > byte_end / 2 {
byte_end = last_space;
}
}
let preview = format!("{}...", first_line[..byte_end].trim_end());
(preview, true)
}
pub fn content_stats(content: &str) -> ContentStats {
let chars = content.chars().count(); let words = content.split_whitespace().count();
let lines = content.lines().count().max(1);
let sentences = content
.chars()
.filter(|c| *c == '.' || *c == '!' || *c == '?')
.count()
.max(1);
let paragraphs = content
.split("\n\n")
.filter(|p| !p.trim().is_empty())
.count()
.max(1);
ContentStats {
chars,
words,
lines,
sentences,
paragraphs,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_soft_trim_short_content() {
let content = "Short content";
let result = soft_trim(content, &SoftTrimConfig::default());
assert!(!result.was_trimmed);
assert_eq!(result.content, content);
assert_eq!(result.chars_removed, 0);
}
#[test]
fn test_soft_trim_long_content() {
let content = "A".repeat(1000);
let config = SoftTrimConfig {
max_chars: 100,
..Default::default()
};
let result = soft_trim(&content, &config);
assert!(result.was_trimmed);
assert!(result.content.len() <= 100);
assert!(result.content.contains("..."));
assert!(result.chars_removed > 0);
}
#[test]
fn test_soft_trim_preserves_head_and_tail() {
let content = format!(
"HEADER: Important beginning content. {} FOOTER: Critical ending info.",
"Middle content that can be removed. ".repeat(50)
);
let config = SoftTrimConfig {
max_chars: 200,
..Default::default()
};
let result = soft_trim(&content, &config);
assert!(result.was_trimmed);
assert!(result.content.starts_with("HEADER"));
assert!(result.content.ends_with("info."));
}
#[test]
fn test_soft_trim_word_boundaries() {
let content = "The quick brown fox jumps over the lazy dog. ".repeat(20);
let config = SoftTrimConfig {
max_chars: 100,
preserve_words: true,
..Default::default()
};
let result = soft_trim(&content, &config);
assert!(!result.content.ends_with("Th"));
assert!(!result.content.ends_with("fo"));
}
#[test]
fn test_compact_preview_short() {
let content = "Short content";
let (preview, truncated) = compact_preview(content, 100);
assert_eq!(preview, "Short content");
assert!(!truncated);
}
#[test]
fn test_compact_preview_long() {
let content = "This is a very long first line that exceeds the maximum character limit for preview display";
let (preview, truncated) = compact_preview(content, 30);
assert!(preview.len() <= 33); assert!(preview.ends_with("..."));
assert!(truncated);
}
#[test]
fn test_compact_preview_multiline() {
let content = "First line only\nSecond line ignored\nThird line also";
let (preview, truncated) = compact_preview(content, 100);
assert_eq!(preview, "First line only");
assert!(truncated); }
#[test]
fn test_content_stats() {
let content = "Hello world. This is a test! How are you?\n\nSecond paragraph here.";
let stats = content_stats(content);
assert_eq!(stats.words, 12);
assert_eq!(stats.lines, 3);
assert_eq!(stats.sentences, 4);
assert_eq!(stats.paragraphs, 2);
}
#[test]
fn test_content_stats_empty() {
let stats = content_stats("");
assert_eq!(stats.chars, 0);
assert_eq!(stats.words, 0);
assert_eq!(stats.lines, 1); assert_eq!(stats.sentences, 1); assert_eq!(stats.paragraphs, 1); }
#[test]
fn test_soft_trim_unicode() {
let content = "你好世界!这是一个很长的中文字符串。".repeat(50);
let config = SoftTrimConfig {
max_chars: 100,
..Default::default()
};
let result = soft_trim(&content, &config);
assert!(result.was_trimmed);
assert!(result.content.is_ascii() || !result.content.is_empty());
}
#[test]
fn test_compact_preview_empty() {
let (preview, truncated) = compact_preview("", 100);
assert!(preview.is_empty());
assert!(!truncated);
}
#[test]
fn test_compact_preview_whitespace_only() {
let (preview, truncated) = compact_preview(" \n \n ", 100);
assert!(preview.is_empty());
assert!(!truncated);
}
}