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 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 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
528pub 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 let slug = sanitize(other);
555 if slug.len() > 15 {
556 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
566pub fn workspace_slug(workspace: Option<&Path>) -> String {
570 match workspace {
571 Some(path) => {
572 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 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
592pub 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
605pub fn extract_topic(title: Option<&str>, first_user_message: Option<&str>) -> String {
612 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 if let Some(msg) = first_user_message {
622 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 "session".to_string()
642}
643
644fn truncate_topic(topic: &str, max_len: usize) -> String {
646 if topic.len() <= max_len {
647 return topic.to_string();
648 }
649
650 let safe_end = truncate_to_char_boundary(topic, max_len);
652 let truncated = &topic[..safe_end];
653
654 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
664pub 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")); assert!(!is_valid_filename(".hidden")); }
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 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 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 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 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 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, };
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 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 #[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 assert_eq!(agent_slug("MyCustomAgent"), "mycustomagent");
991 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 let ts = 1769436600000i64;
1018 let slug = datetime_slug(Some(ts));
1019 assert!(slug.contains('_'));
1021 assert_eq!(slug.len(), 15); }
1023
1024 #[test]
1025 fn test_datetime_slug_none() {
1026 let slug = datetime_slug(None);
1028 assert!(slug.starts_with("202")); 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 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 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 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 assert_eq!(truncate_topic("short", 30), "short");
1235
1236 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 #[test]
1248 fn test_truncate_to_char_boundary() {
1249 assert_eq!(truncate_to_char_boundary("hello", 3), 3);
1251 assert_eq!(truncate_to_char_boundary("hello", 10), 5);
1252
1253 let japanese = "日本語";
1256 assert_eq!(japanese.len(), 9);
1257 assert_eq!(truncate_to_char_boundary(japanese, 4), 3);
1259 assert_eq!(truncate_to_char_boundary(japanese, 6), 6);
1261
1262 let cafe = "café";
1264 assert_eq!(cafe.len(), 5);
1265 assert_eq!(truncate_to_char_boundary(cafe, 4), 3);
1267 }
1268
1269 #[test]
1270 fn test_enforce_max_len_utf8_safe() {
1271 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 assert!(result.len() <= 30);
1276 let _ = result.chars().count();
1278 }
1279
1280 #[test]
1281 fn test_agent_slug_utf8_safe() {
1282 let result = agent_slug("müllerâgentnamëthätexceedslimit");
1284 assert!(result.len() <= 15);
1286 let _ = result.chars().count();
1287 }
1288
1289 #[test]
1290 fn test_workspace_slug_utf8_safe() {
1291 let path = PathBuf::from("/home/user/projéctswithöddnämesthätexceedlimits");
1293 let result = workspace_slug(Some(&path));
1294 assert!(result.len() <= 20);
1296 let _ = result.chars().count();
1297 }
1298
1299 #[test]
1300 fn test_truncate_topic_utf8_safe() {
1301 let topic = "日本語_programming_topic_that_is_very_long";
1303 let result = truncate_topic(topic, 20);
1304 assert!(result.len() <= 20);
1306 let _ = result.chars().count();
1307 }
1308}