Skip to main content

coding_agent_search/html_export/
filename.rs

1//! Smart filename generation for HTML exports.
2//!
3//! Generates cross-platform safe filenames from session metadata,
4//! ensuring compatibility with Windows, macOS, and Linux filesystems.
5//!
6//! # Features
7//!
8//! - **Cross-platform safety**: Handles Windows reserved names, invalid characters
9//! - **Smart downloads detection**: Finds platform-specific downloads folder
10//! - **Collision handling**: Automatic numeric suffixes for duplicate names
11//! - **Agent normalization**: Canonical slugs for all supported agents
12//! - **Topic support**: Robot mode can specify intelligent topic names
13
14use std::path::{Path, PathBuf};
15
16use tracing::{debug, trace};
17
18/// Options for filename generation.
19#[derive(Debug, Clone, Default)]
20pub struct FilenameOptions {
21    /// Include date in filename
22    pub include_date: bool,
23
24    /// Include agent name in filename
25    pub include_agent: bool,
26
27    /// Include project name in filename
28    pub include_project: bool,
29
30    /// Include topic in filename (if provided)
31    pub include_topic: bool,
32
33    /// Maximum filename length (excluding extension)
34    pub max_length: Option<usize>,
35
36    /// Custom prefix
37    pub prefix: Option<String>,
38
39    /// Custom suffix (before extension)
40    pub suffix: Option<String>,
41}
42
43/// Metadata for filename generation.
44#[derive(Debug, Clone, Default)]
45pub struct FilenameMetadata {
46    /// Session title or ID
47    pub title: Option<String>,
48
49    /// ISO date (YYYY-MM-DD)
50    pub date: Option<String>,
51
52    /// Agent name (claude, codex, etc.)
53    pub agent: Option<String>,
54
55    /// Project name
56    pub project: Option<String>,
57
58    /// Topic provided by calling agent (robot mode).
59    /// Will be normalized to lowercase with underscores.
60    pub topic: Option<String>,
61}
62
63/// Normalize a topic string to lowercase with underscores.
64///
65/// This is the canonical way to convert a user-provided topic
66/// into the format expected by CASS filenames:
67/// - Converts to lowercase
68/// - Replaces spaces with underscores
69/// - Removes invalid characters
70/// - Collapses multiple underscores
71///
72/// # Examples
73/// ```
74/// use coding_agent_search::html_export::normalize_topic;
75/// assert_eq!(normalize_topic("My Cool Topic"), "my_cool_topic");
76/// assert_eq!(normalize_topic("HTML Export Feature"), "html_export_feature");
77/// ```
78pub fn normalize_topic(topic: &str) -> String {
79    sanitize(topic)
80}
81
82/// Generate a safe, descriptive filename.
83///
84/// Returns a filename without extension (add .html manually).
85pub fn generate_filename(metadata: &FilenameMetadata, options: &FilenameOptions) -> String {
86    let mut parts = Vec::new();
87
88    // Add prefix
89    if let Some(prefix) = &options.prefix {
90        push_part(&mut parts, prefix);
91    }
92
93    // Add date
94    if options.include_date
95        && let Some(date) = &metadata.date
96    {
97        push_part(&mut parts, date);
98    }
99
100    // Add agent
101    if options.include_agent
102        && let Some(agent) = &metadata.agent
103    {
104        push_part(&mut parts, agent);
105    }
106
107    // Add project
108    if options.include_project
109        && let Some(project) = &metadata.project
110    {
111        push_part(&mut parts, project);
112    }
113
114    // Add topic (robot mode can supply this for intelligent naming)
115    if options.include_topic
116        && let Some(topic) = &metadata.topic
117    {
118        let normalized = normalize_topic(topic);
119        if !normalized.is_empty() {
120            parts.push(normalized);
121        }
122    }
123
124    // Add title (always included if present)
125    if let Some(title) = &metadata.title {
126        push_part(&mut parts, title);
127    }
128
129    // Add suffix
130    if let Some(suffix) = &options.suffix {
131        push_part(&mut parts, suffix);
132    }
133
134    // Combine parts
135    let filename = if parts.is_empty() {
136        "session".to_string()
137    } else {
138        parts.join("_")
139    };
140
141    let final_name = finalize_filename(filename, options.max_length);
142    debug!(
143        component = "file",
144        operation = "generate_filename",
145        parts = parts.len(),
146        max_length = options.max_length.unwrap_or(0),
147        result_len = final_name.len(),
148        "Generated filename"
149    );
150    final_name
151}
152
153/// Generate a filename with path.
154pub fn generate_filepath(
155    base_dir: &std::path::Path,
156    metadata: &FilenameMetadata,
157    options: &FilenameOptions,
158) -> PathBuf {
159    let ext = ".html";
160    let base_max = MAX_FILENAME_LEN.saturating_sub(ext.len());
161    let mut adjusted = options.clone();
162    adjusted.max_length = Some(match options.max_length {
163        Some(user_max) => user_max.min(base_max).max(1),
164        None => base_max,
165    });
166    let filename = generate_filename(metadata, &adjusted);
167    let path = base_dir.join(format!("{filename}{ext}"));
168    debug!(
169        component = "file",
170        operation = "generate_filepath",
171        path = %path.display(),
172        "Generated filepath"
173    );
174    path
175}
176
177/// Sanitize a string for use in filenames.
178///
179/// - Replaces invalid characters with underscores
180/// - Removes leading/trailing whitespace
181/// - Collapses multiple underscores
182/// - Limits to ASCII alphanumeric plus safe punctuation
183fn sanitize(s: &str) -> String {
184    let mut result = String::new();
185    let mut last_was_underscore = false;
186
187    for c in s.chars() {
188        if c.is_ascii_alphanumeric() || c == '-' {
189            result.push(c.to_ascii_lowercase());
190            last_was_underscore = false;
191        } else if c == ' ' || c == '_' || c == '.' || c == '/' || c == '\\' {
192            // Replace separators with underscore, avoiding duplicates
193            if !last_was_underscore && !result.is_empty() {
194                result.push('_');
195                last_was_underscore = true;
196            }
197        }
198        // Skip other characters
199    }
200
201    // Trim leading/trailing underscores
202    result.trim_matches('_').to_string()
203}
204
205/// Push a sanitized part if it is non-empty.
206fn push_part(parts: &mut Vec<String>, raw: &str) {
207    let sanitized = sanitize(raw);
208    if !sanitized.is_empty() {
209        parts.push(sanitized);
210    }
211}
212
213const MAX_FILENAME_LEN: usize = 255;
214
215/// Finalize a filename by enforcing length limits and avoiding reserved names.
216fn finalize_filename(mut name: String, max_len: Option<usize>) -> String {
217    if name.is_empty() {
218        name = "session".to_string();
219    }
220
221    name = trim_separators(&name);
222    if name.is_empty() {
223        name = "session".to_string();
224    }
225
226    name = enforce_max_len(name, max_len);
227    name = avoid_reserved_name(name);
228    name = enforce_max_len(name, max_len);
229
230    name = trim_separators(&name);
231    if name.is_empty() {
232        "session".to_string()
233    } else {
234        name
235    }
236}
237
238fn enforce_max_len(mut name: String, max_len: Option<usize>) -> String {
239    let limit = max_len
240        .unwrap_or(MAX_FILENAME_LEN)
241        .clamp(1, MAX_FILENAME_LEN);
242    if name.len() > limit {
243        // Safe truncation at char boundary to avoid panic on multi-byte UTF-8
244        let safe_limit = truncate_to_char_boundary(&name, limit);
245        name.truncate(safe_limit);
246        name = trim_separators(&name);
247    }
248    name
249}
250
251/// Find the largest byte index <= `max_bytes` that is on a UTF-8 char boundary.
252fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> usize {
253    if max_bytes >= s.len() {
254        return s.len();
255    }
256    // Walk backwards from max_bytes to find a char boundary
257    let mut end = max_bytes;
258    while end > 0 && !s.is_char_boundary(end) {
259        end -= 1;
260    }
261    end
262}
263
264fn trim_separators(name: &str) -> String {
265    name.trim_matches(|c| c == '_' || c == '-').to_string()
266}
267
268fn avoid_reserved_name(name: String) -> String {
269    if is_reserved_basename(&name) {
270        format!("session_{}", name)
271    } else {
272        name
273    }
274}
275
276fn is_reserved_basename(name: &str) -> bool {
277    let upper = name.to_ascii_uppercase();
278    let base_name = upper.split('.').next().unwrap_or(&upper);
279    RESERVED_NAMES.contains(&base_name)
280}
281
282/// Characters that are invalid in filenames across platforms.
283const INVALID_CHARS: &[char] = &[
284    '<', '>', ':', '"', '/', '\\', '|', '?', '*', '\0', '\n', '\r', '\t',
285];
286
287/// Reserved filenames on Windows.
288const RESERVED_NAMES: &[&str] = &[
289    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
290    "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
291];
292
293/// Check if a filename is valid across platforms.
294pub fn is_valid_filename(name: &str) -> bool {
295    if name.is_empty() {
296        return false;
297    }
298
299    // Check for invalid characters
300    if name.chars().any(|c| INVALID_CHARS.contains(&c)) {
301        return false;
302    }
303
304    // Check for reserved names (Windows)
305    let upper = name.to_ascii_uppercase();
306    let base_name = upper.split('.').next().unwrap_or(&upper);
307    if RESERVED_NAMES.contains(&base_name) {
308        return false;
309    }
310
311    // Check for leading/trailing spaces or dots
312    if name.starts_with(' ') || name.starts_with('.') || name.ends_with(' ') || name.ends_with('.')
313    {
314        return false;
315    }
316
317    // Check length (Windows MAX_PATH is 260, but NTFS supports 255 per component)
318    if name.len() > 255 {
319        return false;
320    }
321
322    true
323}
324
325// ============================================================================
326// Platform-specific downloads folder detection
327// ============================================================================
328
329/// Get the default export directory.
330///
331/// Returns the current working directory as the default.
332/// This is more intuitive for CLI usage where exports should go
333/// where the user is working.
334pub fn get_downloads_dir() -> PathBuf {
335    // Primary: Current working directory (most intuitive for CLI usage)
336    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
337}
338
339/// Generate a unique filename that doesn't collide with existing files.
340///
341/// If the base filename exists, appends numeric suffixes: `file_1.html`, `file_2.html`, etc.
342/// As an ultimate fallback, appends a timestamp.
343pub fn unique_filename(dir: &Path, base_filename: &str) -> PathBuf {
344    let base_filename = safe_unique_base_filename(base_filename);
345    let path = dir.join(&base_filename);
346    if !filename_path_is_occupied(&path) {
347        return path;
348    }
349
350    // Extract stem and extension
351    let (stem, ext) = if let Some(dot_pos) = base_filename.rfind('.') {
352        (&base_filename[..dot_pos], &base_filename[dot_pos..])
353    } else {
354        (base_filename.as_str(), "")
355    };
356
357    // Try numeric suffixes
358    for i in 1..1000 {
359        let suffix = format!("_{i}");
360        let new_name = unique_candidate_filename(stem, ext, &suffix);
361        let new_path = dir.join(&new_name);
362        if !filename_path_is_occupied(&new_path) {
363            trace!(
364                component = "file",
365                operation = "collision_check",
366                attempts = i,
367                path = %new_path.display(),
368                "Resolved filename collision"
369            );
370            return new_path;
371        }
372    }
373
374    // Ultimate fallback: high-resolution timestamp with bounded collision probes.
375    let ts = std::time::SystemTime::now()
376        .duration_since(std::time::UNIX_EPOCH)
377        .map(|d| d.as_nanos())
378        .unwrap_or(0);
379    unique_timestamp_fallback_filename(dir, stem, ext, ts)
380}
381
382fn unique_timestamp_fallback_filename(dir: &Path, stem: &str, ext: &str, ts: u128) -> PathBuf {
383    for attempt in 0..1000 {
384        let suffix = if attempt == 0 {
385            format!("_{ts}")
386        } else {
387            format!("_{ts}_{attempt}")
388        };
389        let fallback = dir.join(unique_candidate_filename(stem, ext, &suffix));
390        if !filename_path_is_occupied(&fallback) {
391            trace!(
392                component = "file",
393                operation = "collision_fallback",
394                attempts = attempt,
395                path = %fallback.display(),
396                "Resolved filename via timestamp"
397            );
398            return fallback;
399        }
400    }
401
402    let suffix = format!("_{}_{}", ts, std::process::id());
403    dir.join(unique_candidate_filename(stem, ext, &suffix))
404}
405
406fn filename_path_is_occupied(path: &Path) -> bool {
407    match std::fs::symlink_metadata(path) {
408        Ok(_) => true,
409        Err(err) if err.kind() == std::io::ErrorKind::NotFound => false,
410        Err(_) => true,
411    }
412}
413
414fn unique_candidate_filename(stem: &str, ext: &str, suffix: &str) -> String {
415    let ext = bounded_extension_for_collision_candidate(ext, suffix.len());
416    let reserved_len = suffix.len().saturating_add(ext.len());
417    let max_stem_len = MAX_FILENAME_LEN.saturating_sub(reserved_len).max(1);
418    let mut candidate_stem = if stem.len() > max_stem_len {
419        let safe_end = truncate_to_char_boundary(stem, max_stem_len);
420        trim_separators(&stem[..safe_end])
421    } else {
422        trim_separators(stem)
423    };
424    if candidate_stem.is_empty() {
425        let safe_end = truncate_to_char_boundary("session", max_stem_len);
426        candidate_stem = "session"[..safe_end].to_string();
427    }
428    format!("{candidate_stem}{suffix}{ext}")
429}
430
431fn bounded_extension_for_collision_candidate(ext: &str, suffix_len: usize) -> String {
432    if ext.is_empty() {
433        return String::new();
434    }
435
436    // Keep at least one byte for the stem. If the extension alone would crowd
437    // out the collision suffix, truncate it rather than returning a filename
438    // component longer than platform limits.
439    let max_ext_len = MAX_FILENAME_LEN
440        .saturating_sub(suffix_len)
441        .saturating_sub(1);
442    if max_ext_len < 2 {
443        return String::new();
444    }
445    if ext.len() <= max_ext_len {
446        return ext.to_string();
447    }
448
449    let safe_end = truncate_to_char_boundary(ext, max_ext_len);
450    let truncated = &ext[..safe_end];
451    if truncated.len() < 2 {
452        String::new()
453    } else {
454        truncated.trim_end_matches('.').to_string()
455    }
456}
457
458fn safe_unique_base_filename(base_filename: &str) -> String {
459    let raw = Path::new(base_filename)
460        .file_name()
461        .and_then(|name| name.to_str())
462        .map(str::trim)
463        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
464        .unwrap_or("session.html");
465
466    if is_valid_filename(raw) {
467        return raw.to_string();
468    }
469
470    let (stem_raw, ext) = split_safe_extension(raw);
471    let stem = sanitize(stem_raw);
472    let stem = if stem.is_empty() {
473        "session".to_string()
474    } else {
475        let max_stem_len = MAX_FILENAME_LEN.saturating_sub(ext.len()).max(1);
476        finalize_filename(stem, Some(max_stem_len))
477    };
478    let candidate = format!("{stem}{ext}");
479
480    if is_valid_filename(&candidate) {
481        candidate
482    } else if ext.is_empty() {
483        "session".to_string()
484    } else {
485        format!("session{ext}")
486    }
487}
488
489fn split_safe_extension(filename: &str) -> (&str, String) {
490    let Some(dot_pos) = filename.rfind('.') else {
491        return (filename, String::new());
492    };
493    if dot_pos == 0 {
494        return ("", sanitize_extension(&filename[1..]));
495    }
496
497    let extension = sanitize_extension(&filename[dot_pos + 1..]);
498    (&filename[..dot_pos], extension)
499}
500
501fn sanitize_extension(extension: &str) -> String {
502    let ext: String = extension
503        .chars()
504        .filter(|c| c.is_ascii_alphanumeric())
505        .take(16)
506        .map(|c| c.to_ascii_lowercase())
507        .collect();
508    if ext.is_empty() {
509        String::new()
510    } else {
511        format!(".{ext}")
512    }
513}
514
515// ============================================================================
516// Agent slug normalization
517// ============================================================================
518
519/// Normalize agent name to canonical slug.
520///
521/// Maps various agent name formats to a consistent short form.
522pub fn agent_slug(agent: &str) -> String {
523    match agent.to_lowercase().replace(['-', '_'], "").as_str() {
524        "claudecode" | "claude" => "claude".to_string(),
525        "cursor" | "cursorai" => "cursor".to_string(),
526        "chatgpt" | "gpt" | "openai" => "chatgpt".to_string(),
527        "gemini" | "geminicli" | "google" => "gemini".to_string(),
528        "codex" | "codexcli" => "codex".to_string(),
529        "aider" => "aider".to_string(),
530        "piagent" | "pi" => "piagent".to_string(),
531        "factory" | "droid" => "factory".to_string(),
532        "opencode" => "opencode".to_string(),
533        "cline" => "cline".to_string(),
534        "amp" => "amp".to_string(),
535        "copilot" | "githubcopilot" => "copilot".to_string(),
536        "cody" | "sourcegraph" => "cody".to_string(),
537        "windsurf" => "windsurf".to_string(),
538        "grok" => "grok".to_string(),
539        other => {
540            // Slugify unknown agents
541            let slug = sanitize(other);
542            if slug.len() > 15 {
543                // Safe truncation at char boundary to avoid panic
544                let safe_end = truncate_to_char_boundary(&slug, 15);
545                slug[..safe_end].trim_end_matches('_').to_string()
546            } else {
547                slug
548            }
549        }
550    }
551}
552
553/// Extract workspace/project name from a path.
554///
555/// Returns the last path component as a slug, or "standalone" if no workspace.
556pub fn workspace_slug(workspace: Option<&Path>) -> String {
557    match workspace {
558        Some(path) => {
559            // Get last component (project name)
560            let name = path
561                .file_name()
562                .and_then(|n| n.to_str())
563                .unwrap_or("unknown");
564            let slug = sanitize(name);
565            if slug.len() > 20 {
566                // Safe truncation at char boundary to avoid panic
567                let safe_end = truncate_to_char_boundary(&slug, 20);
568                slug[..safe_end].trim_end_matches('_').to_string()
569            } else if slug.is_empty() {
570                "project".to_string()
571            } else {
572                slug
573            }
574        }
575        None => "standalone".to_string(),
576    }
577}
578
579/// Format a Unix timestamp as a filename-safe datetime string.
580///
581/// Output format: `YYYY_MM_DD_HHMM` (e.g., `2026_01_25_1430`)
582pub fn datetime_slug(timestamp_ms: Option<i64>) -> String {
583    use chrono::{TimeZone, Utc};
584
585    let dt = timestamp_ms
586        .and_then(|ts| Utc.timestamp_millis_opt(ts).single())
587        .unwrap_or_else(Utc::now);
588
589    dt.format("%Y_%m_%d_%H%M").to_string()
590}
591
592/// Extract a topic from conversation content.
593///
594/// Priority order:
595/// 1. Explicit title (if provided)
596/// 2. First user message (truncated, cleaned)
597/// 3. Fallback to "session"
598pub fn extract_topic(title: Option<&str>, first_user_message: Option<&str>) -> String {
599    // Priority 1: Explicit title
600    if let Some(t) = title {
601        let topic = sanitize(t);
602        if !topic.is_empty() {
603            return truncate_topic(&topic, 30);
604        }
605    }
606
607    // Priority 2: First user message
608    if let Some(msg) = first_user_message {
609        // Extract meaningful words, skip code/urls
610        let words: Vec<&str> = msg
611            .split_whitespace()
612            .filter(|w| !w.starts_with("http"))
613            .filter(|w| !w.contains('/'))
614            .filter(|w| !w.starts_with('`'))
615            .filter(|w| w.len() < 20)
616            .take(5)
617            .collect();
618
619        if !words.is_empty() {
620            let topic = sanitize(&words.join(" "));
621            if !topic.is_empty() {
622                return truncate_topic(&topic, 30);
623            }
624        }
625    }
626
627    // Fallback
628    "session".to_string()
629}
630
631/// Truncate a topic to max length at word boundaries.
632fn truncate_topic(topic: &str, max_len: usize) -> String {
633    if topic.len() <= max_len {
634        return topic.to_string();
635    }
636
637    // Safe truncation at char boundary to avoid panic on multi-byte UTF-8
638    let safe_end = truncate_to_char_boundary(topic, max_len);
639    let truncated = &topic[..safe_end];
640
641    // Try to truncate at underscore boundary for cleaner result
642    if let Some(last_underscore) = truncated.rfind('_')
643        && last_underscore > safe_end / 2
644    {
645        return truncated[..last_underscore].to_string();
646    }
647
648    truncated.trim_end_matches('_').to_string()
649}
650
651/// Generate a complete filename with all components.
652///
653/// Format: `{agent}_{workspace}_{datetime}_{topic}.html`
654pub fn generate_full_filename(
655    agent: &str,
656    workspace: Option<&Path>,
657    timestamp_ms: Option<i64>,
658    title: Option<&str>,
659    first_user_message: Option<&str>,
660) -> String {
661    let agent_part = agent_slug(agent);
662    let workspace_part = workspace_slug(workspace);
663    let datetime_part = datetime_slug(timestamp_ms);
664    let topic_part = extract_topic(title, first_user_message);
665
666    let ext = ".html";
667    let base_max = MAX_FILENAME_LEN.saturating_sub(ext.len());
668    let base = format!(
669        "{}_{}_{}_{}",
670        agent_part, workspace_part, datetime_part, topic_part
671    );
672    let base = finalize_filename(base, Some(base_max));
673    let filename = format!("{base}{ext}");
674    debug!(
675        component = "file",
676        operation = "generate_full_filename",
677        agent = agent,
678        result_len = filename.len(),
679        "Generated full filename"
680    );
681    filename
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    #[test]
689    fn test_sanitize_basic() {
690        assert_eq!(sanitize("Hello World"), "hello_world");
691        assert_eq!(sanitize("test.file"), "test_file");
692        assert_eq!(sanitize("path/to/file"), "path_to_file");
693    }
694
695    #[test]
696    fn test_sanitize_special_chars() {
697        assert_eq!(sanitize("file<>:name"), "filename");
698        assert_eq!(sanitize("test?*file"), "testfile");
699    }
700
701    #[test]
702    fn test_sanitize_multiple_separators() {
703        assert_eq!(sanitize("hello   world"), "hello_world");
704        assert_eq!(sanitize("test___file"), "test_file");
705    }
706
707    #[test]
708    fn test_generate_filename_basic() {
709        let meta = FilenameMetadata {
710            title: Some("My Session".to_string()),
711            ..Default::default()
712        };
713        let opts = FilenameOptions::default();
714
715        assert_eq!(generate_filename(&meta, &opts), "my_session");
716    }
717
718    #[test]
719    fn test_generate_filename_with_date() {
720        let meta = FilenameMetadata {
721            title: Some("Session".to_string()),
722            date: Some("2026-01-25".to_string()),
723            ..Default::default()
724        };
725        let opts = FilenameOptions {
726            include_date: true,
727            ..Default::default()
728        };
729
730        let result = generate_filename(&meta, &opts);
731        assert!(result.starts_with("2026-01-25"));
732        assert!(result.contains("session"));
733    }
734
735    #[test]
736    fn test_generate_filename_max_length() {
737        let meta = FilenameMetadata {
738            title: Some("A very long session title that exceeds limits".to_string()),
739            ..Default::default()
740        };
741        let opts = FilenameOptions {
742            max_length: Some(20),
743            ..Default::default()
744        };
745
746        let result = generate_filename(&meta, &opts);
747        assert!(result.len() <= 20);
748    }
749
750    #[test]
751    fn test_generate_filename_zero_max_length() {
752        let meta = FilenameMetadata {
753            title: Some("Any Title".to_string()),
754            ..Default::default()
755        };
756        let opts = FilenameOptions {
757            max_length: Some(0),
758            ..Default::default()
759        };
760
761        let result = generate_filename(&meta, &opts);
762        assert!(!result.is_empty());
763        assert!(result.len() <= 1);
764    }
765
766    #[test]
767    fn test_generate_filename_caps_at_platform_limit() {
768        let meta = FilenameMetadata {
769            title: Some("a".repeat(400)),
770            ..Default::default()
771        };
772        let opts = FilenameOptions {
773            max_length: Some(400),
774            ..Default::default()
775        };
776
777        let result = generate_filename(&meta, &opts);
778        assert!(result.len() <= MAX_FILENAME_LEN);
779    }
780
781    #[test]
782    fn test_generate_filename_empty() {
783        let meta = FilenameMetadata::default();
784        let opts = FilenameOptions::default();
785
786        assert_eq!(generate_filename(&meta, &opts), "session");
787    }
788
789    #[test]
790    fn test_generate_filename_skips_empty_parts() {
791        let meta = FilenameMetadata {
792            title: Some("Valid Session".to_string()),
793            ..Default::default()
794        };
795        let opts = FilenameOptions {
796            prefix: Some("###".to_string()),
797            ..Default::default()
798        };
799
800        assert_eq!(generate_filename(&meta, &opts), "valid_session");
801    }
802
803    #[test]
804    fn test_generate_filename_all_invalid() {
805        let meta = FilenameMetadata {
806            title: Some("###".to_string()),
807            ..Default::default()
808        };
809        let opts = FilenameOptions::default();
810
811        assert_eq!(generate_filename(&meta, &opts), "session");
812    }
813
814    #[test]
815    fn test_generate_filename_reserved_name() {
816        let meta = FilenameMetadata {
817            title: Some("CON".to_string()),
818            ..Default::default()
819        };
820        let opts = FilenameOptions::default();
821
822        assert_eq!(generate_filename(&meta, &opts), "session_con");
823    }
824
825    #[test]
826    fn test_is_valid_filename() {
827        assert!(is_valid_filename("valid_file.txt"));
828        assert!(is_valid_filename("test-123"));
829
830        assert!(!is_valid_filename(""));
831        assert!(!is_valid_filename("file<name"));
832        assert!(!is_valid_filename("CON")); // Reserved on Windows
833        assert!(!is_valid_filename(".hidden")); // Leading dot
834    }
835
836    #[test]
837    fn test_generate_filepath() {
838        let meta = FilenameMetadata {
839            title: Some("test".to_string()),
840            ..Default::default()
841        };
842        let opts = FilenameOptions::default();
843        let path = generate_filepath(std::path::Path::new("/tmp"), &meta, &opts);
844
845        assert_eq!(path, PathBuf::from("/tmp/test.html"));
846    }
847
848    #[test]
849    fn test_generate_filepath_respects_extension_limit() {
850        let meta = FilenameMetadata {
851            title: Some("a".repeat(300)),
852            ..Default::default()
853        };
854        let opts = FilenameOptions::default();
855        let path = generate_filepath(std::path::Path::new("/tmp"), &meta, &opts);
856        let filename = path.file_name().unwrap().to_string_lossy();
857        assert!(filename.len() <= MAX_FILENAME_LEN);
858        assert!(filename.ends_with(".html"));
859    }
860
861    #[test]
862    fn test_normalize_topic_basic() {
863        assert_eq!(normalize_topic("My Cool Topic"), "my_cool_topic");
864        assert_eq!(
865            normalize_topic("HTML Export Feature"),
866            "html_export_feature"
867        );
868        assert_eq!(
869            normalize_topic("debugging auth flow"),
870            "debugging_auth_flow"
871        );
872    }
873
874    #[test]
875    fn test_normalize_topic_special_chars() {
876        // Special characters should be removed
877        assert_eq!(normalize_topic("API Design (v2)"), "api_design_v2");
878        assert_eq!(normalize_topic("fix: login bug"), "fix_login_bug");
879        assert_eq!(normalize_topic("add feature #123"), "add_feature_123");
880    }
881
882    #[test]
883    fn test_normalize_topic_already_normalized() {
884        // Already normalized topics should pass through
885        assert_eq!(normalize_topic("already_normalized"), "already_normalized");
886        assert_eq!(normalize_topic("lowercase_topic"), "lowercase_topic");
887    }
888
889    #[test]
890    fn test_normalize_topic_multiple_spaces() {
891        // Multiple spaces should collapse to single underscore
892        assert_eq!(normalize_topic("too   many    spaces"), "too_many_spaces");
893    }
894
895    #[test]
896    fn test_generate_filename_with_topic() {
897        let meta = FilenameMetadata {
898            date: Some("2026-01-25".to_string()),
899            agent: Some("claude".to_string()),
900            topic: Some("Debugging Auth Flow".to_string()),
901            ..Default::default()
902        };
903        let opts = FilenameOptions {
904            include_date: true,
905            include_agent: true,
906            include_topic: true,
907            ..Default::default()
908        };
909
910        let result = generate_filename(&meta, &opts);
911        assert!(result.contains("2026-01-25"));
912        assert!(result.contains("claude"));
913        assert!(result.contains("debugging_auth_flow"));
914    }
915
916    #[test]
917    fn test_generate_filename_topic_without_flag() {
918        // Topic should not appear if include_topic is false
919        let meta = FilenameMetadata {
920            topic: Some("My Topic".to_string()),
921            title: Some("Session".to_string()),
922            ..Default::default()
923        };
924        let opts = FilenameOptions {
925            include_topic: false,
926            ..Default::default()
927        };
928
929        let result = generate_filename(&meta, &opts);
930        assert!(!result.contains("my_topic"));
931        assert_eq!(result, "session");
932    }
933
934    #[test]
935    fn test_generate_filename_full_robot_mode() {
936        // Typical robot mode export with all metadata
937        let meta = FilenameMetadata {
938            date: Some("2026-01-25".to_string()),
939            agent: Some("claude_code".to_string()),
940            project: Some("my-project".to_string()),
941            topic: Some("Fix Authentication Bug".to_string()),
942            title: None, // Robot mode might not use title
943        };
944        let opts = FilenameOptions {
945            include_date: true,
946            include_agent: true,
947            include_project: true,
948            include_topic: true,
949            ..Default::default()
950        };
951
952        let result = generate_filename(&meta, &opts);
953        // Should produce something like: 2026-01-25_claude_code_my-project_fix_authentication_bug
954        assert!(result.starts_with("2026-01-25"));
955        assert!(result.contains("claude_code"));
956        assert!(result.contains("my-project"));
957        assert!(result.contains("fix_authentication_bug"));
958    }
959
960    // ========================================================================
961    // Smart filename generation tests
962    // ========================================================================
963
964    #[test]
965    fn test_agent_slug_canonical() {
966        assert_eq!(agent_slug("claude_code"), "claude");
967        assert_eq!(agent_slug("Claude-Code"), "claude");
968        assert_eq!(agent_slug("cursor"), "cursor");
969        assert_eq!(agent_slug("ChatGPT"), "chatgpt");
970        assert_eq!(agent_slug("gemini-cli"), "gemini");
971        assert_eq!(agent_slug("github_copilot"), "copilot");
972    }
973
974    #[test]
975    fn test_agent_slug_unknown() {
976        // Unknown agents get slugified
977        assert_eq!(agent_slug("MyCustomAgent"), "mycustomagent");
978        // Long names get truncated
979        let long = agent_slug("VeryLongAgentNameThatExceedsLimit");
980        assert!(long.len() <= 15);
981    }
982
983    #[test]
984    fn test_workspace_slug_with_path() {
985        let path = PathBuf::from("/home/user/projects/my-awesome-project");
986        assert_eq!(workspace_slug(Some(&path)), "my-awesome-project");
987    }
988
989    #[test]
990    fn test_workspace_slug_without_path() {
991        assert_eq!(workspace_slug(None), "standalone");
992    }
993
994    #[test]
995    fn test_workspace_slug_long_name() {
996        let path = PathBuf::from("/path/to/very-long-project-name-that-exceeds-limit");
997        let slug = workspace_slug(Some(&path));
998        assert!(slug.len() <= 20);
999    }
1000
1001    #[test]
1002    fn test_datetime_slug_format() {
1003        // Test with a known timestamp (2026-01-25 14:30:00 UTC in milliseconds)
1004        let ts = 1769436600000i64;
1005        let slug = datetime_slug(Some(ts));
1006        // Should produce format like YYYY_MM_DD_HHMM
1007        assert!(slug.contains('_'));
1008        assert_eq!(slug.len(), 15); // YYYY_MM_DD_HHMM
1009    }
1010
1011    #[test]
1012    fn test_datetime_slug_none() {
1013        // Should use current time when None
1014        let slug = datetime_slug(None);
1015        assert!(slug.starts_with("202")); // Reasonable year check
1016        assert_eq!(slug.len(), 15);
1017    }
1018
1019    #[test]
1020    fn test_extract_topic_from_title() {
1021        let topic = extract_topic(Some("Fix Auth Bug"), None);
1022        assert_eq!(topic, "fix_auth_bug");
1023    }
1024
1025    #[test]
1026    fn test_extract_topic_from_message() {
1027        let topic = extract_topic(None, Some("Help me debug this authentication issue"));
1028        // Topic gets truncated to 30 chars at word boundary
1029        assert_eq!(topic, "help_me_debug_this");
1030    }
1031
1032    #[test]
1033    fn test_extract_topic_skips_urls() {
1034        let topic = extract_topic(None, Some("Check https://example.com for the issue"));
1035        assert!(!topic.contains("http"));
1036        assert!(topic.contains("check"));
1037    }
1038
1039    #[test]
1040    fn test_extract_topic_fallback() {
1041        let topic = extract_topic(None, None);
1042        assert_eq!(topic, "session");
1043    }
1044
1045    #[test]
1046    fn test_generate_full_filename() {
1047        let filename = generate_full_filename(
1048            "claude_code",
1049            Some(Path::new("/projects/myapp")),
1050            Some(1769436600000),
1051            Some("Fix Auth"),
1052            None,
1053        );
1054
1055        assert!(filename.starts_with("claude_"));
1056        assert!(filename.contains("myapp"));
1057        assert!(filename.ends_with(".html"));
1058    }
1059
1060    #[test]
1061    fn test_get_downloads_dir_returns_path() {
1062        let downloads = get_downloads_dir();
1063        // Should return some valid path
1064        assert!(!downloads.as_os_str().is_empty());
1065    }
1066
1067    #[test]
1068    fn test_unique_filename_no_collision() {
1069        let dir = std::env::temp_dir();
1070        let unique_base = format!("test_unique_{}.html", std::process::id());
1071        let path = unique_filename(&dir, &unique_base);
1072        // Should return the original name if no collision
1073        assert!(
1074            path.to_string_lossy()
1075                .contains(&unique_base.replace(".html", ""))
1076        );
1077    }
1078
1079    #[test]
1080    fn test_unique_filename_confines_path_components_to_dir() {
1081        let dir = Path::new("/exports");
1082
1083        assert_eq!(
1084            unique_filename(dir, "../escape.html"),
1085            PathBuf::from("/exports/escape.html")
1086        );
1087        assert_eq!(
1088            unique_filename(dir, "/tmp/escape.html"),
1089            PathBuf::from("/exports/escape.html")
1090        );
1091    }
1092
1093    #[test]
1094    fn test_unique_filename_sanitizes_invalid_basename_preserving_extension() {
1095        let dir = Path::new("/exports");
1096
1097        assert_eq!(
1098            unique_filename(dir, "CON.html"),
1099            PathBuf::from("/exports/session_con.html")
1100        );
1101        assert_eq!(
1102            unique_filename(dir, "bad<name>.HTML"),
1103            PathBuf::from("/exports/badname.html")
1104        );
1105        assert_eq!(
1106            unique_filename(dir, "../../"),
1107            PathBuf::from("/exports/session.html")
1108        );
1109    }
1110
1111    #[test]
1112    fn test_unique_filename_collision_keeps_platform_length_limit() {
1113        let temp = tempfile::tempdir().expect("tempdir");
1114        let base_filename = format!("{}.html", "a".repeat(MAX_FILENAME_LEN - ".html".len()));
1115        std::fs::write(temp.path().join(&base_filename), b"existing").expect("write existing");
1116
1117        let path = unique_filename(temp.path(), &base_filename);
1118        let filename = path.file_name().unwrap().to_string_lossy();
1119
1120        assert_ne!(filename.as_ref(), base_filename);
1121        assert!(filename.ends_with("_1.html"), "{filename}");
1122        assert!(filename.len() <= MAX_FILENAME_LEN, "{filename}");
1123    }
1124
1125    #[test]
1126    fn test_unique_filename_collision_with_long_extension_keeps_platform_length_limit() {
1127        let temp = tempfile::tempdir().expect("tempdir");
1128        let base_filename = format!("a.{}", "b".repeat(MAX_FILENAME_LEN - "a.".len()));
1129        assert_eq!(base_filename.len(), MAX_FILENAME_LEN);
1130        assert!(is_valid_filename(&base_filename));
1131        std::fs::write(temp.path().join(&base_filename), b"existing").expect("write existing");
1132
1133        let path = unique_filename(temp.path(), &base_filename);
1134        let filename = path.file_name().unwrap().to_string_lossy();
1135
1136        assert_ne!(filename.as_ref(), base_filename);
1137        assert!(filename.starts_with("a_1."), "{filename}");
1138        assert!(filename.len() <= MAX_FILENAME_LEN, "{filename}");
1139        assert!(
1140            is_valid_filename(&filename),
1141            "collision candidate should remain platform-safe: {filename}"
1142        );
1143    }
1144
1145    #[cfg(unix)]
1146    #[test]
1147    fn test_unique_filename_treats_dangling_symlink_as_collision() {
1148        let temp = tempfile::tempdir().expect("tempdir");
1149        let occupied = temp.path().join("session.html");
1150        std::os::unix::fs::symlink(temp.path().join("missing-target.html"), &occupied)
1151            .expect("create dangling symlink");
1152
1153        let path = unique_filename(temp.path(), "session.html");
1154
1155        assert_eq!(
1156            path.file_name().and_then(|name| name.to_str()),
1157            Some("session_1.html")
1158        );
1159        assert!(
1160            std::fs::symlink_metadata(&occupied)
1161                .expect("dangling symlink metadata")
1162                .file_type()
1163                .is_symlink(),
1164            "unique_filename must not replace a dangling symlink placeholder"
1165        );
1166    }
1167
1168    #[test]
1169    fn test_unique_timestamp_fallback_checks_occupied_candidate() {
1170        let temp = tempfile::tempdir().expect("tempdir");
1171        std::fs::write(temp.path().join("session_123.html"), b"existing")
1172            .expect("write occupied timestamp fallback");
1173
1174        let path = unique_timestamp_fallback_filename(temp.path(), "session", ".html", 123);
1175
1176        assert_eq!(
1177            path.file_name().and_then(|name| name.to_str()),
1178            Some("session_123_1.html")
1179        );
1180        assert!(
1181            !filename_path_is_occupied(&path),
1182            "fallback helper should return an unoccupied path"
1183        );
1184    }
1185
1186    #[test]
1187    fn test_truncate_topic() {
1188        // Short topics unchanged
1189        assert_eq!(truncate_topic("short", 30), "short");
1190
1191        // Long topics truncated at word boundary
1192        let long = "this_is_a_very_long_topic_name_that_needs_truncation";
1193        let truncated = truncate_topic(long, 30);
1194        assert!(truncated.len() <= 30);
1195        assert!(!truncated.ends_with('_'));
1196    }
1197
1198    // ========================================================================
1199    // UTF-8 boundary safety tests
1200    // ========================================================================
1201
1202    #[test]
1203    fn test_truncate_to_char_boundary() {
1204        // ASCII string
1205        assert_eq!(truncate_to_char_boundary("hello", 3), 3);
1206        assert_eq!(truncate_to_char_boundary("hello", 10), 5);
1207
1208        // UTF-8 multi-byte characters
1209        // "日本語" = 3 chars, 9 bytes (each char is 3 bytes)
1210        let japanese = "日本語";
1211        assert_eq!(japanese.len(), 9);
1212        // Truncating at byte 4 should back up to byte 3 (end of first char)
1213        assert_eq!(truncate_to_char_boundary(japanese, 4), 3);
1214        // Truncating at byte 6 should stay at 6 (end of second char)
1215        assert_eq!(truncate_to_char_boundary(japanese, 6), 6);
1216
1217        // "café" = 4 chars, 5 bytes (é is 2 bytes)
1218        let cafe = "café";
1219        assert_eq!(cafe.len(), 5);
1220        // Truncating at byte 4 should back up to byte 3 (before the é)
1221        assert_eq!(truncate_to_char_boundary(cafe, 4), 3);
1222    }
1223
1224    #[test]
1225    fn test_enforce_max_len_utf8_safe() {
1226        // This test would panic before the fix if max_len cuts into a multi-byte char
1227        let long_with_emoji = "this_is_a_test_with_emoji_🎉_at_end";
1228        let result = enforce_max_len(long_with_emoji.to_string(), Some(30));
1229        // Should not panic, and result should be valid UTF-8
1230        assert!(result.len() <= 30);
1231        // The result should be valid UTF-8 (this wouldn't compile if not)
1232        let _ = result.chars().count();
1233    }
1234
1235    #[test]
1236    fn test_agent_slug_utf8_safe() {
1237        // Long agent name with non-ASCII should not panic
1238        let result = agent_slug("müllerâgentnamëthätexceedslimit");
1239        // Should not panic, and result should be valid UTF-8
1240        assert!(result.len() <= 15);
1241        let _ = result.chars().count();
1242    }
1243
1244    #[test]
1245    fn test_workspace_slug_utf8_safe() {
1246        // Project path with non-ASCII chars
1247        let path = PathBuf::from("/home/user/projéctswithöddnämesthätexceedlimits");
1248        let result = workspace_slug(Some(&path));
1249        // Should not panic, and result should be valid UTF-8
1250        assert!(result.len() <= 20);
1251        let _ = result.chars().count();
1252    }
1253
1254    #[test]
1255    fn test_truncate_topic_utf8_safe() {
1256        // Topic with multi-byte characters that would panic if sliced incorrectly
1257        let topic = "日本語_programming_topic_that_is_very_long";
1258        let result = truncate_topic(topic, 20);
1259        // Should not panic, and result should be valid UTF-8
1260        assert!(result.len() <= 20);
1261        let _ = result.chars().count();
1262    }
1263}