use std::path::{Path, PathBuf};
use tracing::{debug, trace};
#[derive(Debug, Clone, Default)]
pub struct FilenameOptions {
pub include_date: bool,
pub include_agent: bool,
pub include_project: bool,
pub include_topic: bool,
pub max_length: Option<usize>,
pub prefix: Option<String>,
pub suffix: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct FilenameMetadata {
pub title: Option<String>,
pub date: Option<String>,
pub agent: Option<String>,
pub project: Option<String>,
pub topic: Option<String>,
}
pub fn normalize_topic(topic: &str) -> String {
sanitize(topic)
}
pub fn generate_filename(metadata: &FilenameMetadata, options: &FilenameOptions) -> String {
let mut parts = Vec::new();
if let Some(prefix) = &options.prefix {
push_part(&mut parts, prefix);
}
if options.include_date
&& let Some(date) = &metadata.date
{
push_part(&mut parts, date);
}
if options.include_agent
&& let Some(agent) = &metadata.agent
{
push_part(&mut parts, agent);
}
if options.include_project
&& let Some(project) = &metadata.project
{
push_part(&mut parts, project);
}
if options.include_topic
&& let Some(topic) = &metadata.topic
{
let normalized = normalize_topic(topic);
if !normalized.is_empty() {
parts.push(normalized);
}
}
if let Some(title) = &metadata.title {
push_part(&mut parts, title);
}
if let Some(suffix) = &options.suffix {
push_part(&mut parts, suffix);
}
let filename = if parts.is_empty() {
"session".to_string()
} else {
parts.join("_")
};
let final_name = finalize_filename(filename, options.max_length);
debug!(
component = "file",
operation = "generate_filename",
parts = parts.len(),
max_length = options.max_length.unwrap_or(0),
result_len = final_name.len(),
"Generated filename"
);
final_name
}
pub fn generate_filepath(
base_dir: &std::path::Path,
metadata: &FilenameMetadata,
options: &FilenameOptions,
) -> PathBuf {
let ext = ".html";
let base_max = MAX_FILENAME_LEN.saturating_sub(ext.len());
let mut adjusted = options.clone();
adjusted.max_length = Some(match options.max_length {
Some(user_max) => user_max.min(base_max).max(1),
None => base_max,
});
let filename = generate_filename(metadata, &adjusted);
let path = base_dir.join(format!("{filename}{ext}"));
debug!(
component = "file",
operation = "generate_filepath",
path = %path.display(),
"Generated filepath"
);
path
}
fn sanitize(s: &str) -> String {
let mut result = String::new();
let mut last_was_underscore = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() || c == '-' {
result.push(c.to_ascii_lowercase());
last_was_underscore = false;
} else if c == ' ' || c == '_' || c == '.' || c == '/' || c == '\\' {
if !last_was_underscore && !result.is_empty() {
result.push('_');
last_was_underscore = true;
}
}
}
result.trim_matches('_').to_string()
}
fn push_part(parts: &mut Vec<String>, raw: &str) {
let sanitized = sanitize(raw);
if !sanitized.is_empty() {
parts.push(sanitized);
}
}
const MAX_FILENAME_LEN: usize = 255;
fn finalize_filename(mut name: String, max_len: Option<usize>) -> String {
if name.is_empty() {
name = "session".to_string();
}
name = trim_separators(&name);
if name.is_empty() {
name = "session".to_string();
}
name = enforce_max_len(name, max_len);
name = avoid_reserved_name(name);
name = enforce_max_len(name, max_len);
name = trim_separators(&name);
if name.is_empty() {
"session".to_string()
} else {
name
}
}
fn enforce_max_len(mut name: String, max_len: Option<usize>) -> String {
let limit = max_len
.unwrap_or(MAX_FILENAME_LEN)
.clamp(1, MAX_FILENAME_LEN);
if name.len() > limit {
let safe_limit = truncate_to_char_boundary(&name, limit);
name.truncate(safe_limit);
name = trim_separators(&name);
}
name
}
fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> usize {
if max_bytes >= s.len() {
return s.len();
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
end
}
fn trim_separators(name: &str) -> String {
name.trim_matches(|c| c == '_' || c == '-').to_string()
}
fn avoid_reserved_name(name: String) -> String {
if is_reserved_basename(&name) {
format!("session_{}", name)
} else {
name
}
}
fn is_reserved_basename(name: &str) -> bool {
let upper = name.to_ascii_uppercase();
let base_name = upper.split('.').next().unwrap_or(&upper);
RESERVED_NAMES.contains(&base_name)
}
const INVALID_CHARS: &[char] = &[
'<', '>', ':', '"', '/', '\\', '|', '?', '*', '\0', '\n', '\r', '\t',
];
const RESERVED_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
pub fn is_valid_filename(name: &str) -> bool {
if name.is_empty() {
return false;
}
if name.chars().any(|c| INVALID_CHARS.contains(&c)) {
return false;
}
let upper = name.to_ascii_uppercase();
let base_name = upper.split('.').next().unwrap_or(&upper);
if RESERVED_NAMES.contains(&base_name) {
return false;
}
if name.starts_with(' ') || name.starts_with('.') || name.ends_with(' ') || name.ends_with('.')
{
return false;
}
if name.len() > 255 {
return false;
}
true
}
pub fn get_downloads_dir() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
pub fn unique_filename(dir: &Path, base_filename: &str) -> PathBuf {
let base_filename = safe_unique_base_filename(base_filename);
let path = dir.join(&base_filename);
if !filename_path_is_occupied(&path) {
return path;
}
let (stem, ext) = if let Some(dot_pos) = base_filename.rfind('.') {
(&base_filename[..dot_pos], &base_filename[dot_pos..])
} else {
(base_filename.as_str(), "")
};
for i in 1..1000 {
let suffix = format!("_{i}");
let new_name = unique_candidate_filename(stem, ext, &suffix);
let new_path = dir.join(&new_name);
if !filename_path_is_occupied(&new_path) {
trace!(
component = "file",
operation = "collision_check",
attempts = i,
path = %new_path.display(),
"Resolved filename collision"
);
return new_path;
}
}
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
unique_timestamp_fallback_filename(dir, stem, ext, ts)
}
fn unique_timestamp_fallback_filename(dir: &Path, stem: &str, ext: &str, ts: u128) -> PathBuf {
for attempt in 0..1000 {
let suffix = if attempt == 0 {
format!("_{ts}")
} else {
format!("_{ts}_{attempt}")
};
let fallback = dir.join(unique_candidate_filename(stem, ext, &suffix));
if !filename_path_is_occupied(&fallback) {
trace!(
component = "file",
operation = "collision_fallback",
attempts = attempt,
path = %fallback.display(),
"Resolved filename via timestamp"
);
return fallback;
}
}
let suffix = format!("_{}_{}", ts, std::process::id());
dir.join(unique_candidate_filename(stem, ext, &suffix))
}
fn filename_path_is_occupied(path: &Path) -> bool {
match std::fs::symlink_metadata(path) {
Ok(_) => true,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => false,
Err(_) => true,
}
}
fn unique_candidate_filename(stem: &str, ext: &str, suffix: &str) -> String {
let ext = bounded_extension_for_collision_candidate(ext, suffix.len());
let reserved_len = suffix.len().saturating_add(ext.len());
let max_stem_len = MAX_FILENAME_LEN.saturating_sub(reserved_len).max(1);
let mut candidate_stem = if stem.len() > max_stem_len {
let safe_end = truncate_to_char_boundary(stem, max_stem_len);
trim_separators(&stem[..safe_end])
} else {
trim_separators(stem)
};
if candidate_stem.is_empty() {
let safe_end = truncate_to_char_boundary("session", max_stem_len);
candidate_stem = "session"[..safe_end].to_string();
}
format!("{candidate_stem}{suffix}{ext}")
}
fn bounded_extension_for_collision_candidate(ext: &str, suffix_len: usize) -> String {
if ext.is_empty() {
return String::new();
}
let max_ext_len = MAX_FILENAME_LEN
.saturating_sub(suffix_len)
.saturating_sub(1);
if max_ext_len < 2 {
return String::new();
}
if ext.len() <= max_ext_len {
return ext.to_string();
}
let safe_end = truncate_to_char_boundary(ext, max_ext_len);
let truncated = &ext[..safe_end];
if truncated.len() < 2 {
String::new()
} else {
truncated.trim_end_matches('.').to_string()
}
}
fn safe_unique_base_filename(base_filename: &str) -> String {
let raw = Path::new(base_filename)
.file_name()
.and_then(|name| name.to_str())
.map(str::trim)
.filter(|name| !name.is_empty() && *name != "." && *name != "..")
.unwrap_or("session.html");
if is_valid_filename(raw) {
return raw.to_string();
}
let (stem_raw, ext) = split_safe_extension(raw);
let stem = sanitize(stem_raw);
let stem = if stem.is_empty() {
"session".to_string()
} else {
let max_stem_len = MAX_FILENAME_LEN.saturating_sub(ext.len()).max(1);
finalize_filename(stem, Some(max_stem_len))
};
let candidate = format!("{stem}{ext}");
if is_valid_filename(&candidate) {
candidate
} else if ext.is_empty() {
"session".to_string()
} else {
format!("session{ext}")
}
}
fn split_safe_extension(filename: &str) -> (&str, String) {
let Some(dot_pos) = filename.rfind('.') else {
return (filename, String::new());
};
if dot_pos == 0 {
return ("", sanitize_extension(&filename[1..]));
}
let extension = sanitize_extension(&filename[dot_pos + 1..]);
(&filename[..dot_pos], extension)
}
fn sanitize_extension(extension: &str) -> String {
let ext: String = extension
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(16)
.map(|c| c.to_ascii_lowercase())
.collect();
if ext.is_empty() {
String::new()
} else {
format!(".{ext}")
}
}
pub fn agent_slug(agent: &str) -> String {
match agent.to_lowercase().replace(['-', '_'], "").as_str() {
"claudecode" | "claude" => "claude".to_string(),
"cursor" | "cursorai" => "cursor".to_string(),
"chatgpt" | "gpt" | "openai" => "chatgpt".to_string(),
"gemini" | "geminicli" | "google" => "gemini".to_string(),
"codex" | "codexcli" => "codex".to_string(),
"aider" => "aider".to_string(),
"piagent" | "pi" => "piagent".to_string(),
"factory" | "droid" => "factory".to_string(),
"opencode" => "opencode".to_string(),
"cline" => "cline".to_string(),
"amp" => "amp".to_string(),
"copilot" | "githubcopilot" => "copilot".to_string(),
"cody" | "sourcegraph" => "cody".to_string(),
"windsurf" => "windsurf".to_string(),
"grok" => "grok".to_string(),
other => {
let slug = sanitize(other);
if slug.len() > 15 {
let safe_end = truncate_to_char_boundary(&slug, 15);
slug[..safe_end].trim_end_matches('_').to_string()
} else {
slug
}
}
}
}
pub fn workspace_slug(workspace: Option<&Path>) -> String {
match workspace {
Some(path) => {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown");
let slug = sanitize(name);
if slug.len() > 20 {
let safe_end = truncate_to_char_boundary(&slug, 20);
slug[..safe_end].trim_end_matches('_').to_string()
} else if slug.is_empty() {
"project".to_string()
} else {
slug
}
}
None => "standalone".to_string(),
}
}
pub fn datetime_slug(timestamp_ms: Option<i64>) -> String {
use chrono::{TimeZone, Utc};
let dt = timestamp_ms
.and_then(|ts| Utc.timestamp_millis_opt(ts).single())
.unwrap_or_else(Utc::now);
dt.format("%Y_%m_%d_%H%M").to_string()
}
pub fn extract_topic(title: Option<&str>, first_user_message: Option<&str>) -> String {
if let Some(t) = title {
let topic = sanitize(t);
if !topic.is_empty() {
return truncate_topic(&topic, 30);
}
}
if let Some(msg) = first_user_message {
let words: Vec<&str> = msg
.split_whitespace()
.filter(|w| !w.starts_with("http"))
.filter(|w| !w.contains('/'))
.filter(|w| !w.starts_with('`'))
.filter(|w| w.len() < 20)
.take(5)
.collect();
if !words.is_empty() {
let topic = sanitize(&words.join(" "));
if !topic.is_empty() {
return truncate_topic(&topic, 30);
}
}
}
"session".to_string()
}
fn truncate_topic(topic: &str, max_len: usize) -> String {
if topic.len() <= max_len {
return topic.to_string();
}
let safe_end = truncate_to_char_boundary(topic, max_len);
let truncated = &topic[..safe_end];
if let Some(last_underscore) = truncated.rfind('_')
&& last_underscore > safe_end / 2
{
return truncated[..last_underscore].to_string();
}
truncated.trim_end_matches('_').to_string()
}
pub fn generate_full_filename(
agent: &str,
workspace: Option<&Path>,
timestamp_ms: Option<i64>,
title: Option<&str>,
first_user_message: Option<&str>,
) -> String {
let agent_part = agent_slug(agent);
let workspace_part = workspace_slug(workspace);
let datetime_part = datetime_slug(timestamp_ms);
let topic_part = extract_topic(title, first_user_message);
let ext = ".html";
let base_max = MAX_FILENAME_LEN.saturating_sub(ext.len());
let base = format!(
"{}_{}_{}_{}",
agent_part, workspace_part, datetime_part, topic_part
);
let base = finalize_filename(base, Some(base_max));
let filename = format!("{base}{ext}");
debug!(
component = "file",
operation = "generate_full_filename",
agent = agent,
result_len = filename.len(),
"Generated full filename"
);
filename
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sanitize_basic() {
assert_eq!(sanitize("Hello World"), "hello_world");
assert_eq!(sanitize("test.file"), "test_file");
assert_eq!(sanitize("path/to/file"), "path_to_file");
}
#[test]
fn test_sanitize_special_chars() {
assert_eq!(sanitize("file<>:name"), "filename");
assert_eq!(sanitize("test?*file"), "testfile");
}
#[test]
fn test_sanitize_multiple_separators() {
assert_eq!(sanitize("hello world"), "hello_world");
assert_eq!(sanitize("test___file"), "test_file");
}
#[test]
fn test_generate_filename_basic() {
let meta = FilenameMetadata {
title: Some("My Session".to_string()),
..Default::default()
};
let opts = FilenameOptions::default();
assert_eq!(generate_filename(&meta, &opts), "my_session");
}
#[test]
fn test_generate_filename_with_date() {
let meta = FilenameMetadata {
title: Some("Session".to_string()),
date: Some("2026-01-25".to_string()),
..Default::default()
};
let opts = FilenameOptions {
include_date: true,
..Default::default()
};
let result = generate_filename(&meta, &opts);
assert!(result.starts_with("2026-01-25"));
assert!(result.contains("session"));
}
#[test]
fn test_generate_filename_max_length() {
let meta = FilenameMetadata {
title: Some("A very long session title that exceeds limits".to_string()),
..Default::default()
};
let opts = FilenameOptions {
max_length: Some(20),
..Default::default()
};
let result = generate_filename(&meta, &opts);
assert!(result.len() <= 20);
}
#[test]
fn test_generate_filename_zero_max_length() {
let meta = FilenameMetadata {
title: Some("Any Title".to_string()),
..Default::default()
};
let opts = FilenameOptions {
max_length: Some(0),
..Default::default()
};
let result = generate_filename(&meta, &opts);
assert!(!result.is_empty());
assert!(result.len() <= 1);
}
#[test]
fn test_generate_filename_caps_at_platform_limit() {
let meta = FilenameMetadata {
title: Some("a".repeat(400)),
..Default::default()
};
let opts = FilenameOptions {
max_length: Some(400),
..Default::default()
};
let result = generate_filename(&meta, &opts);
assert!(result.len() <= MAX_FILENAME_LEN);
}
#[test]
fn test_generate_filename_empty() {
let meta = FilenameMetadata::default();
let opts = FilenameOptions::default();
assert_eq!(generate_filename(&meta, &opts), "session");
}
#[test]
fn test_generate_filename_skips_empty_parts() {
let meta = FilenameMetadata {
title: Some("Valid Session".to_string()),
..Default::default()
};
let opts = FilenameOptions {
prefix: Some("###".to_string()),
..Default::default()
};
assert_eq!(generate_filename(&meta, &opts), "valid_session");
}
#[test]
fn test_generate_filename_all_invalid() {
let meta = FilenameMetadata {
title: Some("###".to_string()),
..Default::default()
};
let opts = FilenameOptions::default();
assert_eq!(generate_filename(&meta, &opts), "session");
}
#[test]
fn test_generate_filename_reserved_name() {
let meta = FilenameMetadata {
title: Some("CON".to_string()),
..Default::default()
};
let opts = FilenameOptions::default();
assert_eq!(generate_filename(&meta, &opts), "session_con");
}
#[test]
fn test_is_valid_filename() {
assert!(is_valid_filename("valid_file.txt"));
assert!(is_valid_filename("test-123"));
assert!(!is_valid_filename(""));
assert!(!is_valid_filename("file<name"));
assert!(!is_valid_filename("CON")); assert!(!is_valid_filename(".hidden")); }
#[test]
fn test_generate_filepath() {
let meta = FilenameMetadata {
title: Some("test".to_string()),
..Default::default()
};
let opts = FilenameOptions::default();
let path = generate_filepath(std::path::Path::new("/tmp"), &meta, &opts);
assert_eq!(path, PathBuf::from("/tmp/test.html"));
}
#[test]
fn test_generate_filepath_respects_extension_limit() {
let meta = FilenameMetadata {
title: Some("a".repeat(300)),
..Default::default()
};
let opts = FilenameOptions::default();
let path = generate_filepath(std::path::Path::new("/tmp"), &meta, &opts);
let filename = path.file_name().unwrap().to_string_lossy();
assert!(filename.len() <= MAX_FILENAME_LEN);
assert!(filename.ends_with(".html"));
}
#[test]
fn test_normalize_topic_basic() {
assert_eq!(normalize_topic("My Cool Topic"), "my_cool_topic");
assert_eq!(
normalize_topic("HTML Export Feature"),
"html_export_feature"
);
assert_eq!(
normalize_topic("debugging auth flow"),
"debugging_auth_flow"
);
}
#[test]
fn test_normalize_topic_special_chars() {
assert_eq!(normalize_topic("API Design (v2)"), "api_design_v2");
assert_eq!(normalize_topic("fix: login bug"), "fix_login_bug");
assert_eq!(normalize_topic("add feature #123"), "add_feature_123");
}
#[test]
fn test_normalize_topic_already_normalized() {
assert_eq!(normalize_topic("already_normalized"), "already_normalized");
assert_eq!(normalize_topic("lowercase_topic"), "lowercase_topic");
}
#[test]
fn test_normalize_topic_multiple_spaces() {
assert_eq!(normalize_topic("too many spaces"), "too_many_spaces");
}
#[test]
fn test_generate_filename_with_topic() {
let meta = FilenameMetadata {
date: Some("2026-01-25".to_string()),
agent: Some("claude".to_string()),
topic: Some("Debugging Auth Flow".to_string()),
..Default::default()
};
let opts = FilenameOptions {
include_date: true,
include_agent: true,
include_topic: true,
..Default::default()
};
let result = generate_filename(&meta, &opts);
assert!(result.contains("2026-01-25"));
assert!(result.contains("claude"));
assert!(result.contains("debugging_auth_flow"));
}
#[test]
fn test_generate_filename_topic_without_flag() {
let meta = FilenameMetadata {
topic: Some("My Topic".to_string()),
title: Some("Session".to_string()),
..Default::default()
};
let opts = FilenameOptions {
include_topic: false,
..Default::default()
};
let result = generate_filename(&meta, &opts);
assert!(!result.contains("my_topic"));
assert_eq!(result, "session");
}
#[test]
fn test_generate_filename_full_robot_mode() {
let meta = FilenameMetadata {
date: Some("2026-01-25".to_string()),
agent: Some("claude_code".to_string()),
project: Some("my-project".to_string()),
topic: Some("Fix Authentication Bug".to_string()),
title: None, };
let opts = FilenameOptions {
include_date: true,
include_agent: true,
include_project: true,
include_topic: true,
..Default::default()
};
let result = generate_filename(&meta, &opts);
assert!(result.starts_with("2026-01-25"));
assert!(result.contains("claude_code"));
assert!(result.contains("my-project"));
assert!(result.contains("fix_authentication_bug"));
}
#[test]
fn test_agent_slug_canonical() {
assert_eq!(agent_slug("claude_code"), "claude");
assert_eq!(agent_slug("Claude-Code"), "claude");
assert_eq!(agent_slug("cursor"), "cursor");
assert_eq!(agent_slug("ChatGPT"), "chatgpt");
assert_eq!(agent_slug("gemini-cli"), "gemini");
assert_eq!(agent_slug("github_copilot"), "copilot");
}
#[test]
fn test_agent_slug_unknown() {
assert_eq!(agent_slug("MyCustomAgent"), "mycustomagent");
let long = agent_slug("VeryLongAgentNameThatExceedsLimit");
assert!(long.len() <= 15);
}
#[test]
fn test_workspace_slug_with_path() {
let path = PathBuf::from("/home/user/projects/my-awesome-project");
assert_eq!(workspace_slug(Some(&path)), "my-awesome-project");
}
#[test]
fn test_workspace_slug_without_path() {
assert_eq!(workspace_slug(None), "standalone");
}
#[test]
fn test_workspace_slug_long_name() {
let path = PathBuf::from("/path/to/very-long-project-name-that-exceeds-limit");
let slug = workspace_slug(Some(&path));
assert!(slug.len() <= 20);
}
#[test]
fn test_datetime_slug_format() {
let ts = 1769436600000i64;
let slug = datetime_slug(Some(ts));
assert!(slug.contains('_'));
assert_eq!(slug.len(), 15); }
#[test]
fn test_datetime_slug_none() {
let slug = datetime_slug(None);
assert!(slug.starts_with("202")); assert_eq!(slug.len(), 15);
}
#[test]
fn test_extract_topic_from_title() {
let topic = extract_topic(Some("Fix Auth Bug"), None);
assert_eq!(topic, "fix_auth_bug");
}
#[test]
fn test_extract_topic_from_message() {
let topic = extract_topic(None, Some("Help me debug this authentication issue"));
assert_eq!(topic, "help_me_debug_this");
}
#[test]
fn test_extract_topic_skips_urls() {
let topic = extract_topic(None, Some("Check https://example.com for the issue"));
assert!(!topic.contains("http"));
assert!(topic.contains("check"));
}
#[test]
fn test_extract_topic_fallback() {
let topic = extract_topic(None, None);
assert_eq!(topic, "session");
}
#[test]
fn test_generate_full_filename() {
let filename = generate_full_filename(
"claude_code",
Some(Path::new("/projects/myapp")),
Some(1769436600000),
Some("Fix Auth"),
None,
);
assert!(filename.starts_with("claude_"));
assert!(filename.contains("myapp"));
assert!(filename.ends_with(".html"));
}
#[test]
fn test_get_downloads_dir_returns_path() {
let downloads = get_downloads_dir();
assert!(!downloads.as_os_str().is_empty());
}
#[test]
fn test_unique_filename_no_collision() {
let dir = std::env::temp_dir();
let unique_base = format!("test_unique_{}.html", std::process::id());
let path = unique_filename(&dir, &unique_base);
assert!(
path.to_string_lossy()
.contains(&unique_base.replace(".html", ""))
);
}
#[test]
fn test_unique_filename_confines_path_components_to_dir() {
let dir = Path::new("/exports");
assert_eq!(
unique_filename(dir, "../escape.html"),
PathBuf::from("/exports/escape.html")
);
assert_eq!(
unique_filename(dir, "/tmp/escape.html"),
PathBuf::from("/exports/escape.html")
);
}
#[test]
fn test_unique_filename_sanitizes_invalid_basename_preserving_extension() {
let dir = Path::new("/exports");
assert_eq!(
unique_filename(dir, "CON.html"),
PathBuf::from("/exports/session_con.html")
);
assert_eq!(
unique_filename(dir, "bad<name>.HTML"),
PathBuf::from("/exports/badname.html")
);
assert_eq!(
unique_filename(dir, "../../"),
PathBuf::from("/exports/session.html")
);
}
#[test]
fn test_unique_filename_collision_keeps_platform_length_limit() {
let temp = tempfile::tempdir().expect("tempdir");
let base_filename = format!("{}.html", "a".repeat(MAX_FILENAME_LEN - ".html".len()));
std::fs::write(temp.path().join(&base_filename), b"existing").expect("write existing");
let path = unique_filename(temp.path(), &base_filename);
let filename = path.file_name().unwrap().to_string_lossy();
assert_ne!(filename.as_ref(), base_filename);
assert!(filename.ends_with("_1.html"), "{filename}");
assert!(filename.len() <= MAX_FILENAME_LEN, "{filename}");
}
#[test]
fn test_unique_filename_collision_with_long_extension_keeps_platform_length_limit() {
let temp = tempfile::tempdir().expect("tempdir");
let base_filename = format!("a.{}", "b".repeat(MAX_FILENAME_LEN - "a.".len()));
assert_eq!(base_filename.len(), MAX_FILENAME_LEN);
assert!(is_valid_filename(&base_filename));
std::fs::write(temp.path().join(&base_filename), b"existing").expect("write existing");
let path = unique_filename(temp.path(), &base_filename);
let filename = path.file_name().unwrap().to_string_lossy();
assert_ne!(filename.as_ref(), base_filename);
assert!(filename.starts_with("a_1."), "{filename}");
assert!(filename.len() <= MAX_FILENAME_LEN, "{filename}");
assert!(
is_valid_filename(&filename),
"collision candidate should remain platform-safe: {filename}"
);
}
#[cfg(unix)]
#[test]
fn test_unique_filename_treats_dangling_symlink_as_collision() {
let temp = tempfile::tempdir().expect("tempdir");
let occupied = temp.path().join("session.html");
std::os::unix::fs::symlink(temp.path().join("missing-target.html"), &occupied)
.expect("create dangling symlink");
let path = unique_filename(temp.path(), "session.html");
assert_eq!(
path.file_name().and_then(|name| name.to_str()),
Some("session_1.html")
);
assert!(
std::fs::symlink_metadata(&occupied)
.expect("dangling symlink metadata")
.file_type()
.is_symlink(),
"unique_filename must not replace a dangling symlink placeholder"
);
}
#[test]
fn test_unique_timestamp_fallback_checks_occupied_candidate() {
let temp = tempfile::tempdir().expect("tempdir");
std::fs::write(temp.path().join("session_123.html"), b"existing")
.expect("write occupied timestamp fallback");
let path = unique_timestamp_fallback_filename(temp.path(), "session", ".html", 123);
assert_eq!(
path.file_name().and_then(|name| name.to_str()),
Some("session_123_1.html")
);
assert!(
!filename_path_is_occupied(&path),
"fallback helper should return an unoccupied path"
);
}
#[test]
fn test_truncate_topic() {
assert_eq!(truncate_topic("short", 30), "short");
let long = "this_is_a_very_long_topic_name_that_needs_truncation";
let truncated = truncate_topic(long, 30);
assert!(truncated.len() <= 30);
assert!(!truncated.ends_with('_'));
}
#[test]
fn test_truncate_to_char_boundary() {
assert_eq!(truncate_to_char_boundary("hello", 3), 3);
assert_eq!(truncate_to_char_boundary("hello", 10), 5);
let japanese = "日本語";
assert_eq!(japanese.len(), 9);
assert_eq!(truncate_to_char_boundary(japanese, 4), 3);
assert_eq!(truncate_to_char_boundary(japanese, 6), 6);
let cafe = "café";
assert_eq!(cafe.len(), 5);
assert_eq!(truncate_to_char_boundary(cafe, 4), 3);
}
#[test]
fn test_enforce_max_len_utf8_safe() {
let long_with_emoji = "this_is_a_test_with_emoji_🎉_at_end";
let result = enforce_max_len(long_with_emoji.to_string(), Some(30));
assert!(result.len() <= 30);
let _ = result.chars().count();
}
#[test]
fn test_agent_slug_utf8_safe() {
let result = agent_slug("müllerâgentnamëthätexceedslimit");
assert!(result.len() <= 15);
let _ = result.chars().count();
}
#[test]
fn test_workspace_slug_utf8_safe() {
let path = PathBuf::from("/home/user/projéctswithöddnämesthätexceedlimits");
let result = workspace_slug(Some(&path));
assert!(result.len() <= 20);
let _ = result.chars().count();
}
#[test]
fn test_truncate_topic_utf8_safe() {
let topic = "日本語_programming_topic_that_is_very_long";
let result = truncate_topic(topic, 20);
assert!(result.len() <= 20);
let _ = result.chars().count();
}
}