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 process_id = std::process::id();
403    for attempt in 0..1000 {
404        let suffix = if attempt == 0 {
405            format!("_{ts}_{process_id}")
406        } else {
407            format!("_{ts}_{process_id}_{attempt}")
408        };
409        let fallback = dir.join(unique_candidate_filename(stem, ext, &suffix));
410        if !filename_path_is_occupied(&fallback) {
411            return fallback;
412        }
413    }
414
415    let suffix = format!("_{ts}_{process_id}_overflow");
416    dir.join(unique_candidate_filename(stem, ext, &suffix))
417}
418
419fn filename_path_is_occupied(path: &Path) -> bool {
420    match std::fs::symlink_metadata(path) {
421        Ok(_) => true,
422        Err(err) if err.kind() == std::io::ErrorKind::NotFound => false,
423        Err(_) => true,
424    }
425}
426
427fn unique_candidate_filename(stem: &str, ext: &str, suffix: &str) -> String {
428    let ext = bounded_extension_for_collision_candidate(ext, suffix.len());
429    let reserved_len = suffix.len().saturating_add(ext.len());
430    let max_stem_len = MAX_FILENAME_LEN.saturating_sub(reserved_len).max(1);
431    let mut candidate_stem = if stem.len() > max_stem_len {
432        let safe_end = truncate_to_char_boundary(stem, max_stem_len);
433        trim_separators(&stem[..safe_end])
434    } else {
435        trim_separators(stem)
436    };
437    if candidate_stem.is_empty() {
438        let safe_end = truncate_to_char_boundary("session", max_stem_len);
439        candidate_stem = "session"[..safe_end].to_string();
440    }
441    format!("{candidate_stem}{suffix}{ext}")
442}
443
444fn bounded_extension_for_collision_candidate(ext: &str, suffix_len: usize) -> String {
445    if ext.is_empty() {
446        return String::new();
447    }
448
449    // Keep at least one byte for the stem. If the extension alone would crowd
450    // out the collision suffix, truncate it rather than returning a filename
451    // component longer than platform limits.
452    let max_ext_len = MAX_FILENAME_LEN
453        .saturating_sub(suffix_len)
454        .saturating_sub(1);
455    if max_ext_len < 2 {
456        return String::new();
457    }
458    if ext.len() <= max_ext_len {
459        return ext.to_string();
460    }
461
462    let safe_end = truncate_to_char_boundary(ext, max_ext_len);
463    let truncated = &ext[..safe_end];
464    if truncated.len() < 2 {
465        String::new()
466    } else {
467        truncated.trim_end_matches('.').to_string()
468    }
469}
470
471fn safe_unique_base_filename(base_filename: &str) -> String {
472    let raw = Path::new(base_filename)
473        .file_name()
474        .and_then(|name| name.to_str())
475        .map(str::trim)
476        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
477        .unwrap_or("session.html");
478
479    if is_valid_filename(raw) {
480        return raw.to_string();
481    }
482
483    let (stem_raw, ext) = split_safe_extension(raw);
484    let stem = sanitize(stem_raw);
485    let stem = if stem.is_empty() {
486        "session".to_string()
487    } else {
488        let max_stem_len = MAX_FILENAME_LEN.saturating_sub(ext.len()).max(1);
489        finalize_filename(stem, Some(max_stem_len))
490    };
491    let candidate = format!("{stem}{ext}");
492
493    if is_valid_filename(&candidate) {
494        candidate
495    } else if ext.is_empty() {
496        "session".to_string()
497    } else {
498        format!("session{ext}")
499    }
500}
501
502fn split_safe_extension(filename: &str) -> (&str, String) {
503    let Some(dot_pos) = filename.rfind('.') else {
504        return (filename, String::new());
505    };
506    if dot_pos == 0 {
507        return ("", sanitize_extension(&filename[1..]));
508    }
509
510    let extension = sanitize_extension(&filename[dot_pos + 1..]);
511    (&filename[..dot_pos], extension)
512}
513
514fn sanitize_extension(extension: &str) -> String {
515    let ext: String = extension
516        .chars()
517        .filter(|c| c.is_ascii_alphanumeric())
518        .take(16)
519        .map(|c| c.to_ascii_lowercase())
520        .collect();
521    if ext.is_empty() {
522        String::new()
523    } else {
524        format!(".{ext}")
525    }
526}
527
528// ============================================================================
529// Agent slug normalization
530// ============================================================================
531
532/// Normalize agent name to canonical slug.
533///
534/// Maps various agent name formats to a consistent short form.
535pub fn agent_slug(agent: &str) -> String {
536    match agent.to_lowercase().replace(['-', '_'], "").as_str() {
537        "claudecode" | "claude" => "claude".to_string(),
538        "cursor" | "cursorai" => "cursor".to_string(),
539        "chatgpt" | "gpt" | "openai" => "chatgpt".to_string(),
540        "gemini" | "geminicli" | "google" => "gemini".to_string(),
541        "codex" | "codexcli" => "codex".to_string(),
542        "aider" => "aider".to_string(),
543        "piagent" | "pi" => "piagent".to_string(),
544        "factory" | "droid" => "factory".to_string(),
545        "opencode" => "opencode".to_string(),
546        "cline" => "cline".to_string(),
547        "amp" => "amp".to_string(),
548        "copilot" | "githubcopilot" => "copilot".to_string(),
549        "cody" | "sourcegraph" => "cody".to_string(),
550        "windsurf" => "windsurf".to_string(),
551        "grok" => "grok".to_string(),
552        other => {
553            // Slugify unknown agents
554            let slug = sanitize(other);
555            if slug.len() > 15 {
556                // Safe truncation at char boundary to avoid panic
557                let safe_end = truncate_to_char_boundary(&slug, 15);
558                slug[..safe_end].trim_end_matches('_').to_string()
559            } else {
560                slug
561            }
562        }
563    }
564}
565
566/// Extract workspace/project name from a path.
567///
568/// Returns the last path component as a slug, or "standalone" if no workspace.
569pub fn workspace_slug(workspace: Option<&Path>) -> String {
570    match workspace {
571        Some(path) => {
572            // Get last component (project name)
573            let name = path
574                .file_name()
575                .and_then(|n| n.to_str())
576                .unwrap_or("unknown");
577            let slug = sanitize(name);
578            if slug.len() > 20 {
579                // Safe truncation at char boundary to avoid panic
580                let safe_end = truncate_to_char_boundary(&slug, 20);
581                slug[..safe_end].trim_end_matches('_').to_string()
582            } else if slug.is_empty() {
583                "project".to_string()
584            } else {
585                slug
586            }
587        }
588        None => "standalone".to_string(),
589    }
590}
591
592/// Format a Unix timestamp as a filename-safe datetime string.
593///
594/// Output format: `YYYY_MM_DD_HHMM` (e.g., `2026_01_25_1430`)
595pub fn datetime_slug(timestamp_ms: Option<i64>) -> String {
596    use chrono::{TimeZone, Utc};
597
598    let dt = timestamp_ms
599        .and_then(|ts| Utc.timestamp_millis_opt(ts).single())
600        .unwrap_or_else(Utc::now);
601
602    dt.format("%Y_%m_%d_%H%M").to_string()
603}
604
605/// Extract a topic from conversation content.
606///
607/// Priority order:
608/// 1. Explicit title (if provided)
609/// 2. First user message (truncated, cleaned)
610/// 3. Fallback to "session"
611pub fn extract_topic(title: Option<&str>, first_user_message: Option<&str>) -> String {
612    // Priority 1: Explicit title
613    if let Some(t) = title {
614        let topic = sanitize(t);
615        if !topic.is_empty() {
616            return truncate_topic(&topic, 30);
617        }
618    }
619
620    // Priority 2: First user message
621    if let Some(msg) = first_user_message {
622        // Extract meaningful words, skip code/urls
623        let words: Vec<&str> = msg
624            .split_whitespace()
625            .filter(|w| !w.starts_with("http"))
626            .filter(|w| !w.contains('/'))
627            .filter(|w| !w.starts_with('`'))
628            .filter(|w| w.len() < 20)
629            .take(5)
630            .collect();
631
632        if !words.is_empty() {
633            let topic = sanitize(&words.join(" "));
634            if !topic.is_empty() {
635                return truncate_topic(&topic, 30);
636            }
637        }
638    }
639
640    // Fallback
641    "session".to_string()
642}
643
644/// Truncate a topic to max length at word boundaries.
645fn truncate_topic(topic: &str, max_len: usize) -> String {
646    if topic.len() <= max_len {
647        return topic.to_string();
648    }
649
650    // Safe truncation at char boundary to avoid panic on multi-byte UTF-8
651    let safe_end = truncate_to_char_boundary(topic, max_len);
652    let truncated = &topic[..safe_end];
653
654    // Try to truncate at underscore boundary for cleaner result
655    if let Some(last_underscore) = truncated.rfind('_')
656        && last_underscore > safe_end / 2
657    {
658        return truncated[..last_underscore].to_string();
659    }
660
661    truncated.trim_end_matches('_').to_string()
662}
663
664/// Generate a complete filename with all components.
665///
666/// Format: `{agent}_{workspace}_{datetime}_{topic}.html`
667pub fn generate_full_filename(
668    agent: &str,
669    workspace: Option<&Path>,
670    timestamp_ms: Option<i64>,
671    title: Option<&str>,
672    first_user_message: Option<&str>,
673) -> String {
674    let agent_part = agent_slug(agent);
675    let workspace_part = workspace_slug(workspace);
676    let datetime_part = datetime_slug(timestamp_ms);
677    let topic_part = extract_topic(title, first_user_message);
678
679    let ext = ".html";
680    let base_max = MAX_FILENAME_LEN.saturating_sub(ext.len());
681    let base = format!(
682        "{}_{}_{}_{}",
683        agent_part, workspace_part, datetime_part, topic_part
684    );
685    let base = finalize_filename(base, Some(base_max));
686    let filename = format!("{base}{ext}");
687    debug!(
688        component = "file",
689        operation = "generate_full_filename",
690        agent = agent,
691        result_len = filename.len(),
692        "Generated full filename"
693    );
694    filename
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    #[test]
702    fn test_sanitize_basic() {
703        assert_eq!(sanitize("Hello World"), "hello_world");
704        assert_eq!(sanitize("test.file"), "test_file");
705        assert_eq!(sanitize("path/to/file"), "path_to_file");
706    }
707
708    #[test]
709    fn test_sanitize_special_chars() {
710        assert_eq!(sanitize("file<>:name"), "filename");
711        assert_eq!(sanitize("test?*file"), "testfile");
712    }
713
714    #[test]
715    fn test_sanitize_multiple_separators() {
716        assert_eq!(sanitize("hello   world"), "hello_world");
717        assert_eq!(sanitize("test___file"), "test_file");
718    }
719
720    #[test]
721    fn test_generate_filename_basic() {
722        let meta = FilenameMetadata {
723            title: Some("My Session".to_string()),
724            ..Default::default()
725        };
726        let opts = FilenameOptions::default();
727
728        assert_eq!(generate_filename(&meta, &opts), "my_session");
729    }
730
731    #[test]
732    fn test_generate_filename_with_date() {
733        let meta = FilenameMetadata {
734            title: Some("Session".to_string()),
735            date: Some("2026-01-25".to_string()),
736            ..Default::default()
737        };
738        let opts = FilenameOptions {
739            include_date: true,
740            ..Default::default()
741        };
742
743        let result = generate_filename(&meta, &opts);
744        assert!(result.starts_with("2026-01-25"));
745        assert!(result.contains("session"));
746    }
747
748    #[test]
749    fn test_generate_filename_max_length() {
750        let meta = FilenameMetadata {
751            title: Some("A very long session title that exceeds limits".to_string()),
752            ..Default::default()
753        };
754        let opts = FilenameOptions {
755            max_length: Some(20),
756            ..Default::default()
757        };
758
759        let result = generate_filename(&meta, &opts);
760        assert!(result.len() <= 20);
761    }
762
763    #[test]
764    fn test_generate_filename_zero_max_length() {
765        let meta = FilenameMetadata {
766            title: Some("Any Title".to_string()),
767            ..Default::default()
768        };
769        let opts = FilenameOptions {
770            max_length: Some(0),
771            ..Default::default()
772        };
773
774        let result = generate_filename(&meta, &opts);
775        assert!(!result.is_empty());
776        assert!(result.len() <= 1);
777    }
778
779    #[test]
780    fn test_generate_filename_caps_at_platform_limit() {
781        let meta = FilenameMetadata {
782            title: Some("a".repeat(400)),
783            ..Default::default()
784        };
785        let opts = FilenameOptions {
786            max_length: Some(400),
787            ..Default::default()
788        };
789
790        let result = generate_filename(&meta, &opts);
791        assert!(result.len() <= MAX_FILENAME_LEN);
792    }
793
794    #[test]
795    fn test_generate_filename_empty() {
796        let meta = FilenameMetadata::default();
797        let opts = FilenameOptions::default();
798
799        assert_eq!(generate_filename(&meta, &opts), "session");
800    }
801
802    #[test]
803    fn test_generate_filename_skips_empty_parts() {
804        let meta = FilenameMetadata {
805            title: Some("Valid Session".to_string()),
806            ..Default::default()
807        };
808        let opts = FilenameOptions {
809            prefix: Some("###".to_string()),
810            ..Default::default()
811        };
812
813        assert_eq!(generate_filename(&meta, &opts), "valid_session");
814    }
815
816    #[test]
817    fn test_generate_filename_all_invalid() {
818        let meta = FilenameMetadata {
819            title: Some("###".to_string()),
820            ..Default::default()
821        };
822        let opts = FilenameOptions::default();
823
824        assert_eq!(generate_filename(&meta, &opts), "session");
825    }
826
827    #[test]
828    fn test_generate_filename_reserved_name() {
829        let meta = FilenameMetadata {
830            title: Some("CON".to_string()),
831            ..Default::default()
832        };
833        let opts = FilenameOptions::default();
834
835        assert_eq!(generate_filename(&meta, &opts), "session_con");
836    }
837
838    #[test]
839    fn test_is_valid_filename() {
840        assert!(is_valid_filename("valid_file.txt"));
841        assert!(is_valid_filename("test-123"));
842
843        assert!(!is_valid_filename(""));
844        assert!(!is_valid_filename("file<name"));
845        assert!(!is_valid_filename("CON")); // Reserved on Windows
846        assert!(!is_valid_filename(".hidden")); // Leading dot
847    }
848
849    #[test]
850    fn test_generate_filepath() {
851        let meta = FilenameMetadata {
852            title: Some("test".to_string()),
853            ..Default::default()
854        };
855        let opts = FilenameOptions::default();
856        let path = generate_filepath(std::path::Path::new("/tmp"), &meta, &opts);
857
858        assert_eq!(path, PathBuf::from("/tmp/test.html"));
859    }
860
861    #[test]
862    fn test_generate_filepath_respects_extension_limit() {
863        let meta = FilenameMetadata {
864            title: Some("a".repeat(300)),
865            ..Default::default()
866        };
867        let opts = FilenameOptions::default();
868        let path = generate_filepath(std::path::Path::new("/tmp"), &meta, &opts);
869        let filename = path.file_name().unwrap().to_string_lossy();
870        assert!(filename.len() <= MAX_FILENAME_LEN);
871        assert!(filename.ends_with(".html"));
872    }
873
874    #[test]
875    fn test_normalize_topic_basic() {
876        assert_eq!(normalize_topic("My Cool Topic"), "my_cool_topic");
877        assert_eq!(
878            normalize_topic("HTML Export Feature"),
879            "html_export_feature"
880        );
881        assert_eq!(
882            normalize_topic("debugging auth flow"),
883            "debugging_auth_flow"
884        );
885    }
886
887    #[test]
888    fn test_normalize_topic_special_chars() {
889        // Special characters should be removed
890        assert_eq!(normalize_topic("API Design (v2)"), "api_design_v2");
891        assert_eq!(normalize_topic("fix: login bug"), "fix_login_bug");
892        assert_eq!(normalize_topic("add feature #123"), "add_feature_123");
893    }
894
895    #[test]
896    fn test_normalize_topic_already_normalized() {
897        // Already normalized topics should pass through
898        assert_eq!(normalize_topic("already_normalized"), "already_normalized");
899        assert_eq!(normalize_topic("lowercase_topic"), "lowercase_topic");
900    }
901
902    #[test]
903    fn test_normalize_topic_multiple_spaces() {
904        // Multiple spaces should collapse to single underscore
905        assert_eq!(normalize_topic("too   many    spaces"), "too_many_spaces");
906    }
907
908    #[test]
909    fn test_generate_filename_with_topic() {
910        let meta = FilenameMetadata {
911            date: Some("2026-01-25".to_string()),
912            agent: Some("claude".to_string()),
913            topic: Some("Debugging Auth Flow".to_string()),
914            ..Default::default()
915        };
916        let opts = FilenameOptions {
917            include_date: true,
918            include_agent: true,
919            include_topic: true,
920            ..Default::default()
921        };
922
923        let result = generate_filename(&meta, &opts);
924        assert!(result.contains("2026-01-25"));
925        assert!(result.contains("claude"));
926        assert!(result.contains("debugging_auth_flow"));
927    }
928
929    #[test]
930    fn test_generate_filename_topic_without_flag() {
931        // Topic should not appear if include_topic is false
932        let meta = FilenameMetadata {
933            topic: Some("My Topic".to_string()),
934            title: Some("Session".to_string()),
935            ..Default::default()
936        };
937        let opts = FilenameOptions {
938            include_topic: false,
939            ..Default::default()
940        };
941
942        let result = generate_filename(&meta, &opts);
943        assert!(!result.contains("my_topic"));
944        assert_eq!(result, "session");
945    }
946
947    #[test]
948    fn test_generate_filename_full_robot_mode() {
949        // Typical robot mode export with all metadata
950        let meta = FilenameMetadata {
951            date: Some("2026-01-25".to_string()),
952            agent: Some("claude_code".to_string()),
953            project: Some("my-project".to_string()),
954            topic: Some("Fix Authentication Bug".to_string()),
955            title: None, // Robot mode might not use title
956        };
957        let opts = FilenameOptions {
958            include_date: true,
959            include_agent: true,
960            include_project: true,
961            include_topic: true,
962            ..Default::default()
963        };
964
965        let result = generate_filename(&meta, &opts);
966        // Should produce something like: 2026-01-25_claude_code_my-project_fix_authentication_bug
967        assert!(result.starts_with("2026-01-25"));
968        assert!(result.contains("claude_code"));
969        assert!(result.contains("my-project"));
970        assert!(result.contains("fix_authentication_bug"));
971    }
972
973    // ========================================================================
974    // Smart filename generation tests
975    // ========================================================================
976
977    #[test]
978    fn test_agent_slug_canonical() {
979        assert_eq!(agent_slug("claude_code"), "claude");
980        assert_eq!(agent_slug("Claude-Code"), "claude");
981        assert_eq!(agent_slug("cursor"), "cursor");
982        assert_eq!(agent_slug("ChatGPT"), "chatgpt");
983        assert_eq!(agent_slug("gemini-cli"), "gemini");
984        assert_eq!(agent_slug("github_copilot"), "copilot");
985    }
986
987    #[test]
988    fn test_agent_slug_unknown() {
989        // Unknown agents get slugified
990        assert_eq!(agent_slug("MyCustomAgent"), "mycustomagent");
991        // Long names get truncated
992        let long = agent_slug("VeryLongAgentNameThatExceedsLimit");
993        assert!(long.len() <= 15);
994    }
995
996    #[test]
997    fn test_workspace_slug_with_path() {
998        let path = PathBuf::from("/home/user/projects/my-awesome-project");
999        assert_eq!(workspace_slug(Some(&path)), "my-awesome-project");
1000    }
1001
1002    #[test]
1003    fn test_workspace_slug_without_path() {
1004        assert_eq!(workspace_slug(None), "standalone");
1005    }
1006
1007    #[test]
1008    fn test_workspace_slug_long_name() {
1009        let path = PathBuf::from("/path/to/very-long-project-name-that-exceeds-limit");
1010        let slug = workspace_slug(Some(&path));
1011        assert!(slug.len() <= 20);
1012    }
1013
1014    #[test]
1015    fn test_datetime_slug_format() {
1016        // Test with a known timestamp (2026-01-25 14:30:00 UTC in milliseconds)
1017        let ts = 1769436600000i64;
1018        let slug = datetime_slug(Some(ts));
1019        // Should produce format like YYYY_MM_DD_HHMM
1020        assert!(slug.contains('_'));
1021        assert_eq!(slug.len(), 15); // YYYY_MM_DD_HHMM
1022    }
1023
1024    #[test]
1025    fn test_datetime_slug_none() {
1026        // Should use current time when None
1027        let slug = datetime_slug(None);
1028        assert!(slug.starts_with("202")); // Reasonable year check
1029        assert_eq!(slug.len(), 15);
1030    }
1031
1032    #[test]
1033    fn test_extract_topic_from_title() {
1034        let topic = extract_topic(Some("Fix Auth Bug"), None);
1035        assert_eq!(topic, "fix_auth_bug");
1036    }
1037
1038    #[test]
1039    fn test_extract_topic_from_message() {
1040        let topic = extract_topic(None, Some("Help me debug this authentication issue"));
1041        // Topic gets truncated to 30 chars at word boundary
1042        assert_eq!(topic, "help_me_debug_this");
1043    }
1044
1045    #[test]
1046    fn test_extract_topic_skips_urls() {
1047        let topic = extract_topic(None, Some("Check https://example.com for the issue"));
1048        assert!(!topic.contains("http"));
1049        assert!(topic.contains("check"));
1050    }
1051
1052    #[test]
1053    fn test_extract_topic_fallback() {
1054        let topic = extract_topic(None, None);
1055        assert_eq!(topic, "session");
1056    }
1057
1058    #[test]
1059    fn test_generate_full_filename() {
1060        let filename = generate_full_filename(
1061            "claude_code",
1062            Some(Path::new("/projects/myapp")),
1063            Some(1769436600000),
1064            Some("Fix Auth"),
1065            None,
1066        );
1067
1068        assert!(filename.starts_with("claude_"));
1069        assert!(filename.contains("myapp"));
1070        assert!(filename.ends_with(".html"));
1071    }
1072
1073    #[test]
1074    fn test_get_downloads_dir_returns_path() {
1075        let downloads = get_downloads_dir();
1076        // Should return some valid path
1077        assert!(!downloads.as_os_str().is_empty());
1078    }
1079
1080    #[test]
1081    fn test_unique_filename_no_collision() {
1082        let dir = std::env::temp_dir();
1083        let unique_base = format!("test_unique_{}.html", std::process::id());
1084        let path = unique_filename(&dir, &unique_base);
1085        // Should return the original name if no collision
1086        assert!(
1087            path.to_string_lossy()
1088                .contains(&unique_base.replace(".html", ""))
1089        );
1090    }
1091
1092    #[test]
1093    fn test_unique_filename_confines_path_components_to_dir() {
1094        let dir = Path::new("/exports");
1095
1096        assert_eq!(
1097            unique_filename(dir, "../escape.html"),
1098            PathBuf::from("/exports/escape.html")
1099        );
1100        assert_eq!(
1101            unique_filename(dir, "/tmp/escape.html"),
1102            PathBuf::from("/exports/escape.html")
1103        );
1104    }
1105
1106    #[test]
1107    fn test_unique_filename_sanitizes_invalid_basename_preserving_extension() {
1108        let dir = Path::new("/exports");
1109
1110        assert_eq!(
1111            unique_filename(dir, "CON.html"),
1112            PathBuf::from("/exports/session_con.html")
1113        );
1114        assert_eq!(
1115            unique_filename(dir, "bad<name>.HTML"),
1116            PathBuf::from("/exports/badname.html")
1117        );
1118        assert_eq!(
1119            unique_filename(dir, "../../"),
1120            PathBuf::from("/exports/session.html")
1121        );
1122    }
1123
1124    #[test]
1125    fn test_unique_filename_collision_keeps_platform_length_limit() {
1126        let temp = tempfile::tempdir().expect("tempdir");
1127        let base_filename = format!("{}.html", "a".repeat(MAX_FILENAME_LEN - ".html".len()));
1128        std::fs::write(temp.path().join(&base_filename), b"existing").expect("write existing");
1129
1130        let path = unique_filename(temp.path(), &base_filename);
1131        let filename = path.file_name().unwrap().to_string_lossy();
1132
1133        assert_ne!(filename.as_ref(), base_filename);
1134        assert!(filename.ends_with("_1.html"), "{filename}");
1135        assert!(filename.len() <= MAX_FILENAME_LEN, "{filename}");
1136    }
1137
1138    #[test]
1139    fn test_unique_filename_collision_with_long_extension_keeps_platform_length_limit() {
1140        let temp = tempfile::tempdir().expect("tempdir");
1141        let base_filename = format!("a.{}", "b".repeat(MAX_FILENAME_LEN - "a.".len()));
1142        assert_eq!(base_filename.len(), MAX_FILENAME_LEN);
1143        assert!(is_valid_filename(&base_filename));
1144        std::fs::write(temp.path().join(&base_filename), b"existing").expect("write existing");
1145
1146        let path = unique_filename(temp.path(), &base_filename);
1147        let filename = path.file_name().unwrap().to_string_lossy();
1148
1149        assert_ne!(filename.as_ref(), base_filename);
1150        assert!(filename.starts_with("a_1."), "{filename}");
1151        assert!(filename.len() <= MAX_FILENAME_LEN, "{filename}");
1152        assert!(
1153            is_valid_filename(&filename),
1154            "collision candidate should remain platform-safe: {filename}"
1155        );
1156    }
1157
1158    #[cfg(unix)]
1159    #[test]
1160    fn test_unique_filename_treats_dangling_symlink_as_collision() {
1161        let temp = tempfile::tempdir().expect("tempdir");
1162        let occupied = temp.path().join("session.html");
1163        std::os::unix::fs::symlink(temp.path().join("missing-target.html"), &occupied)
1164            .expect("create dangling symlink");
1165
1166        let path = unique_filename(temp.path(), "session.html");
1167
1168        assert_eq!(
1169            path.file_name().and_then(|name| name.to_str()),
1170            Some("session_1.html")
1171        );
1172        assert!(
1173            std::fs::symlink_metadata(&occupied)
1174                .expect("dangling symlink metadata")
1175                .file_type()
1176                .is_symlink(),
1177            "unique_filename must not replace a dangling symlink placeholder"
1178        );
1179    }
1180
1181    #[test]
1182    fn test_unique_timestamp_fallback_checks_occupied_candidate() {
1183        let temp = tempfile::tempdir().expect("tempdir");
1184        std::fs::write(temp.path().join("session_123.html"), b"existing")
1185            .expect("write occupied timestamp fallback");
1186
1187        let path = unique_timestamp_fallback_filename(temp.path(), "session", ".html", 123);
1188
1189        assert_eq!(
1190            path.file_name().and_then(|name| name.to_str()),
1191            Some("session_123_1.html")
1192        );
1193        assert!(
1194            !filename_path_is_occupied(&path),
1195            "fallback helper should return an unoccupied path"
1196        );
1197    }
1198
1199    #[test]
1200    fn test_unique_timestamp_fallback_checks_pid_candidate() {
1201        let temp = tempfile::tempdir().expect("tempdir");
1202        let ts = 9_876_543_210u128;
1203        for attempt in 0..1000 {
1204            let suffix = if attempt == 0 {
1205                format!("_{ts}")
1206            } else {
1207                format!("_{ts}_{attempt}")
1208            };
1209            let filename = unique_candidate_filename("session", ".html", &suffix);
1210            std::fs::write(temp.path().join(filename), b"existing")
1211                .expect("write occupied timestamp fallback");
1212        }
1213        let process_id = std::process::id();
1214        let occupied_pid = format!("session_{ts}_{process_id}.html");
1215        std::fs::write(temp.path().join(occupied_pid), b"existing")
1216            .expect("write occupied pid fallback");
1217
1218        let path = unique_timestamp_fallback_filename(temp.path(), "session", ".html", ts);
1219        let expected = format!("session_{ts}_{process_id}_1.html");
1220
1221        assert_eq!(
1222            path.file_name().and_then(|name| name.to_str()),
1223            Some(expected.as_str())
1224        );
1225        assert!(
1226            !filename_path_is_occupied(&path),
1227            "pid fallback helper should return an unoccupied path"
1228        );
1229    }
1230
1231    #[test]
1232    fn test_truncate_topic() {
1233        // Short topics unchanged
1234        assert_eq!(truncate_topic("short", 30), "short");
1235
1236        // Long topics truncated at word boundary
1237        let long = "this_is_a_very_long_topic_name_that_needs_truncation";
1238        let truncated = truncate_topic(long, 30);
1239        assert!(truncated.len() <= 30);
1240        assert!(!truncated.ends_with('_'));
1241    }
1242
1243    // ========================================================================
1244    // UTF-8 boundary safety tests
1245    // ========================================================================
1246
1247    #[test]
1248    fn test_truncate_to_char_boundary() {
1249        // ASCII string
1250        assert_eq!(truncate_to_char_boundary("hello", 3), 3);
1251        assert_eq!(truncate_to_char_boundary("hello", 10), 5);
1252
1253        // UTF-8 multi-byte characters
1254        // "日本語" = 3 chars, 9 bytes (each char is 3 bytes)
1255        let japanese = "日本語";
1256        assert_eq!(japanese.len(), 9);
1257        // Truncating at byte 4 should back up to byte 3 (end of first char)
1258        assert_eq!(truncate_to_char_boundary(japanese, 4), 3);
1259        // Truncating at byte 6 should stay at 6 (end of second char)
1260        assert_eq!(truncate_to_char_boundary(japanese, 6), 6);
1261
1262        // "café" = 4 chars, 5 bytes (é is 2 bytes)
1263        let cafe = "café";
1264        assert_eq!(cafe.len(), 5);
1265        // Truncating at byte 4 should back up to byte 3 (before the é)
1266        assert_eq!(truncate_to_char_boundary(cafe, 4), 3);
1267    }
1268
1269    #[test]
1270    fn test_enforce_max_len_utf8_safe() {
1271        // This test would panic before the fix if max_len cuts into a multi-byte char
1272        let long_with_emoji = "this_is_a_test_with_emoji_🎉_at_end";
1273        let result = enforce_max_len(long_with_emoji.to_string(), Some(30));
1274        // Should not panic, and result should be valid UTF-8
1275        assert!(result.len() <= 30);
1276        // The result should be valid UTF-8 (this wouldn't compile if not)
1277        let _ = result.chars().count();
1278    }
1279
1280    #[test]
1281    fn test_agent_slug_utf8_safe() {
1282        // Long agent name with non-ASCII should not panic
1283        let result = agent_slug("müllerâgentnamëthätexceedslimit");
1284        // Should not panic, and result should be valid UTF-8
1285        assert!(result.len() <= 15);
1286        let _ = result.chars().count();
1287    }
1288
1289    #[test]
1290    fn test_workspace_slug_utf8_safe() {
1291        // Project path with non-ASCII chars
1292        let path = PathBuf::from("/home/user/projéctswithöddnämesthätexceedlimits");
1293        let result = workspace_slug(Some(&path));
1294        // Should not panic, and result should be valid UTF-8
1295        assert!(result.len() <= 20);
1296        let _ = result.chars().count();
1297    }
1298
1299    #[test]
1300    fn test_truncate_topic_utf8_safe() {
1301        // Topic with multi-byte characters that would panic if sliced incorrectly
1302        let topic = "日本語_programming_topic_that_is_very_long";
1303        let result = truncate_topic(topic, 20);
1304        // Should not panic, and result should be valid UTF-8
1305        assert!(result.len() <= 20);
1306        let _ = result.chars().count();
1307    }
1308}