1use std::path::{Path, PathBuf};
15
16use tracing::{debug, trace};
17
18#[derive(Debug, Clone, Default)]
20pub struct FilenameOptions {
21 pub include_date: bool,
23
24 pub include_agent: bool,
26
27 pub include_project: bool,
29
30 pub include_topic: bool,
32
33 pub max_length: Option<usize>,
35
36 pub prefix: Option<String>,
38
39 pub suffix: Option<String>,
41}
42
43#[derive(Debug, Clone, Default)]
45pub struct FilenameMetadata {
46 pub title: Option<String>,
48
49 pub date: Option<String>,
51
52 pub agent: Option<String>,
54
55 pub project: Option<String>,
57
58 pub topic: Option<String>,
61}
62
63pub fn normalize_topic(topic: &str) -> String {
79 sanitize(topic)
80}
81
82pub fn generate_filename(metadata: &FilenameMetadata, options: &FilenameOptions) -> String {
86 let mut parts = Vec::new();
87
88 if let Some(prefix) = &options.prefix {
90 push_part(&mut parts, prefix);
91 }
92
93 if options.include_date
95 && let Some(date) = &metadata.date
96 {
97 push_part(&mut parts, date);
98 }
99
100 if options.include_agent
102 && let Some(agent) = &metadata.agent
103 {
104 push_part(&mut parts, agent);
105 }
106
107 if options.include_project
109 && let Some(project) = &metadata.project
110 {
111 push_part(&mut parts, project);
112 }
113
114 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 if let Some(title) = &metadata.title {
126 push_part(&mut parts, title);
127 }
128
129 if let Some(suffix) = &options.suffix {
131 push_part(&mut parts, suffix);
132 }
133
134 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
153pub 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
177fn 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 if !last_was_underscore && !result.is_empty() {
194 result.push('_');
195 last_was_underscore = true;
196 }
197 }
198 }
200
201 result.trim_matches('_').to_string()
203}
204
205fn 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
215fn 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 let safe_limit = truncate_to_char_boundary(&name, limit);
245 name.truncate(safe_limit);
246 name = trim_separators(&name);
247 }
248 name
249}
250
251fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> usize {
253 if max_bytes >= s.len() {
254 return s.len();
255 }
256 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
282const INVALID_CHARS: &[char] = &[
284 '<', '>', ':', '"', '/', '\\', '|', '?', '*', '\0', '\n', '\r', '\t',
285];
286
287const 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
293pub fn is_valid_filename(name: &str) -> bool {
295 if name.is_empty() {
296 return false;
297 }
298
299 if name.chars().any(|c| INVALID_CHARS.contains(&c)) {
301 return false;
302 }
303
304 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 if name.starts_with(' ') || name.starts_with('.') || name.ends_with(' ') || name.ends_with('.')
313 {
314 return false;
315 }
316
317 if name.len() > 255 {
319 return false;
320 }
321
322 true
323}
324
325pub fn get_downloads_dir() -> PathBuf {
335 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
337}
338
339pub 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 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 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 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 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
515pub 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 let slug = sanitize(other);
542 if slug.len() > 15 {
543 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
553pub fn workspace_slug(workspace: Option<&Path>) -> String {
557 match workspace {
558 Some(path) => {
559 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 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
579pub 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
592pub fn extract_topic(title: Option<&str>, first_user_message: Option<&str>) -> String {
599 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 if let Some(msg) = first_user_message {
609 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 "session".to_string()
629}
630
631fn truncate_topic(topic: &str, max_len: usize) -> String {
633 if topic.len() <= max_len {
634 return topic.to_string();
635 }
636
637 let safe_end = truncate_to_char_boundary(topic, max_len);
639 let truncated = &topic[..safe_end];
640
641 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
651pub 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")); assert!(!is_valid_filename(".hidden")); }
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 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 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 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 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 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, };
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 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 #[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 assert_eq!(agent_slug("MyCustomAgent"), "mycustomagent");
978 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 let ts = 1769436600000i64;
1005 let slug = datetime_slug(Some(ts));
1006 assert!(slug.contains('_'));
1008 assert_eq!(slug.len(), 15); }
1010
1011 #[test]
1012 fn test_datetime_slug_none() {
1013 let slug = datetime_slug(None);
1015 assert!(slug.starts_with("202")); 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 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 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 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 assert_eq!(truncate_topic("short", 30), "short");
1190
1191 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 #[test]
1203 fn test_truncate_to_char_boundary() {
1204 assert_eq!(truncate_to_char_boundary("hello", 3), 3);
1206 assert_eq!(truncate_to_char_boundary("hello", 10), 5);
1207
1208 let japanese = "日本語";
1211 assert_eq!(japanese.len(), 9);
1212 assert_eq!(truncate_to_char_boundary(japanese, 4), 3);
1214 assert_eq!(truncate_to_char_boundary(japanese, 6), 6);
1216
1217 let cafe = "café";
1219 assert_eq!(cafe.len(), 5);
1220 assert_eq!(truncate_to_char_boundary(cafe, 4), 3);
1222 }
1223
1224 #[test]
1225 fn test_enforce_max_len_utf8_safe() {
1226 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 assert!(result.len() <= 30);
1231 let _ = result.chars().count();
1233 }
1234
1235 #[test]
1236 fn test_agent_slug_utf8_safe() {
1237 let result = agent_slug("müllerâgentnamëthätexceedslimit");
1239 assert!(result.len() <= 15);
1241 let _ = result.chars().count();
1242 }
1243
1244 #[test]
1245 fn test_workspace_slug_utf8_safe() {
1246 let path = PathBuf::from("/home/user/projéctswithöddnämesthätexceedlimits");
1248 let result = workspace_slug(Some(&path));
1249 assert!(result.len() <= 20);
1251 let _ = result.chars().count();
1252 }
1253
1254 #[test]
1255 fn test_truncate_topic_utf8_safe() {
1256 let topic = "日本語_programming_topic_that_is_very_long";
1258 let result = truncate_topic(topic, 20);
1259 assert!(result.len() <= 20);
1261 let _ = result.chars().count();
1262 }
1263}