Skip to main content

voice_bird_cli/session/
layout.rs

1use 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}