voice_bird_cli/session/
layout.rs1use std::path::{Path, PathBuf};
2
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub enum SessionSource {
5 Microphone,
6 System,
7 App {
8 id: String,
9 name: String,
10 device_name: String,
11 },
12}
13
14pub fn session_slug(ts: chrono::DateTime<chrono::Utc>, source: &SessionSource) -> String {
15 let ts = ts.format("%Y-%m-%d_%H-%M-%S");
16 let src = match source {
17 SessionSource::Microphone => "mic".to_string(),
18 SessionSource::System => "system".to_string(),
19 SessionSource::App {
20 name, device_name, ..
21 } => {
22 let app = normalize_slug(name);
23 let dev = normalize_slug(device_name);
24 if dev.is_empty() {
25 app
26 } else {
27 format!("{app}-on-{dev}")
28 }
29 }
30 };
31 format!("{}-{}", ts, src)
32}
33
34pub fn session_dir(
35 base: &Path,
36 ts: chrono::DateTime<chrono::Utc>,
37 source: &SessionSource,
38) -> PathBuf {
39 base.join(session_slug(ts, source))
40}
41
42fn normalize_slug(name: &str) -> String {
43 name.chars()
44 .map(|c| {
45 if c.is_ascii_alphanumeric() {
46 c.to_ascii_lowercase()
47 } else {
48 '-'
49 }
50 })
51 .collect::<String>()
52 .split('-')
53 .filter(|s| !s.is_empty())
54 .collect::<Vec<_>>()
55 .join("-")
56}
57
58#[cfg(test)]
59mod tests {
60 use super::*;
61 use chrono::TimeZone;
62
63 fn app_zoom_on(device: &str) -> SessionSource {
64 SessionSource::App {
65 id: "us.zoom.xos".into(),
66 name: "Zoom".into(),
67 device_name: device.into(),
68 }
69 }
70
71 #[test]
72 fn slug_uses_timestamp_and_source() {
73 let ts = chrono::Utc
74 .with_ymd_and_hms(2026, 4, 16, 14, 32, 7)
75 .unwrap();
76 let s = session_slug(ts, &app_zoom_on("MacBook Pro Speakers"));
77 assert_eq!(s, "2026-04-16_14-32-07-zoom-on-macbook-pro-speakers");
78 }
79
80 #[test]
81 fn slug_normalizes_app_and_device_name() {
82 let ts = chrono::Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap();
83 let s = session_slug(
84 ts,
85 &SessionSource::App {
86 id: "com.google.Chrome.helper".into(),
87 name: "Google Chrome Helper".into(),
88 device_name: "AirPods Pro (3rd gen)".into(),
89 },
90 );
91 assert_eq!(
92 s,
93 "2026-01-02_03-04-05-google-chrome-helper-on-airpods-pro-3rd-gen"
94 );
95 }
96
97 #[test]
98 fn slug_for_app_without_device_name() {
99 let ts = chrono::Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap();
100 let s = session_slug(
101 ts,
102 &SessionSource::App {
103 id: "us.zoom.xos".into(),
104 name: "Zoom".into(),
105 device_name: String::new(),
106 },
107 );
108 assert_eq!(s, "2026-01-02_03-04-05-zoom");
109 }
110
111 #[test]
112 fn slug_for_mic_and_system() {
113 let ts = chrono::Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap();
114 assert_eq!(
115 session_slug(ts, &SessionSource::Microphone),
116 "2026-01-02_03-04-05-mic"
117 );
118 assert_eq!(
119 session_slug(ts, &SessionSource::System),
120 "2026-01-02_03-04-05-system"
121 );
122 }
123
124 #[test]
125 fn session_dir_joins_base_and_slug() {
126 let ts = chrono::Utc.with_ymd_and_hms(2026, 1, 2, 3, 4, 5).unwrap();
127 let root = std::path::PathBuf::from("/tmp/voice-bird/sessions");
128 let dir = session_dir(&root, ts, &SessionSource::Microphone);
129 assert_eq!(
130 dir,
131 std::path::PathBuf::from("/tmp/voice-bird/sessions/2026-01-02_03-04-05-mic")
132 );
133 }
134}