Skip to main content

murmur_core/audio/
recordings.rs

1use std::path::PathBuf;
2
3use std::sync::atomic::{AtomicU64, Ordering};
4
5use crate::config::Config;
6
7const FILE_PREFIX: &str = "recording-";
8const FILE_EXTENSION: &str = "wav";
9
10pub struct RecordingStore;
11
12impl RecordingStore {
13    pub fn recordings_dir() -> PathBuf {
14        Config::dir().join("recordings")
15    }
16
17    pub fn ensure_dir_at(dir: &std::path::Path) {
18        if let Err(e) = std::fs::create_dir_all(dir) {
19            eprintln!("Warning: could not create recordings directory: {e}");
20        }
21    }
22
23    pub fn temp_recording_path() -> PathBuf {
24        let unique = uuid_short();
25        std::env::temp_dir().join(format!("murmur-{unique}.wav"))
26    }
27
28    pub fn new_recording_path() -> PathBuf {
29        Self::new_recording_path_in(&Self::recordings_dir())
30    }
31
32    pub fn new_recording_path_in(dir: &std::path::Path) -> PathBuf {
33        Self::ensure_dir_at(dir);
34        let now = chrono_timestamp();
35        let unique = &uuid_short();
36        let filename = format!("{FILE_PREFIX}{now}-{unique}.{FILE_EXTENSION}");
37        dir.join(filename)
38    }
39
40    pub fn list_recordings_in(dir: &std::path::Path) -> Vec<(PathBuf, String)> {
41        Self::ensure_dir_at(dir);
42        let Ok(entries) = std::fs::read_dir(dir) else {
43            return vec![];
44        };
45
46        let mut recordings: Vec<(PathBuf, String)> = entries
47            .filter_map(|e| e.ok())
48            .filter(|e| {
49                let name = e.file_name().to_string_lossy().to_string();
50                name.starts_with(FILE_PREFIX) && name.ends_with(&format!(".{FILE_EXTENSION}"))
51            })
52            .map(|e| {
53                let name = e.file_name().to_string_lossy().to_string();
54                (e.path(), name)
55            })
56            .collect();
57
58        // Sort by name descending (newest first, since names contain timestamps)
59        recordings.sort_by(|a, b| b.1.cmp(&a.1));
60        recordings
61    }
62
63    pub fn prune(max_count: u32) {
64        Self::prune_in(&Self::recordings_dir(), max_count);
65    }
66
67    pub fn prune_in(dir: &std::path::Path, max_count: u32) {
68        let recordings = Self::list_recordings_in(dir);
69        if recordings.len() <= max_count as usize {
70            return;
71        }
72
73        for (path, _) in recordings.into_iter().skip(max_count as usize) {
74            if let Err(e) = std::fs::remove_file(&path) {
75                eprintln!(
76                    "Warning: could not remove old recording {}: {e}",
77                    path.display()
78                );
79            }
80        }
81    }
82}
83
84fn chrono_timestamp() -> String {
85    // Simple timestamp without pulling in the chrono crate
86    use std::time::{SystemTime, UNIX_EPOCH};
87    let duration = SystemTime::now()
88        .duration_since(UNIX_EPOCH)
89        .unwrap_or_default();
90    format!("{}", duration.as_secs())
91}
92
93fn uuid_short() -> String {
94    static COUNTER: AtomicU64 = AtomicU64::new(0);
95    let count = COUNTER.fetch_add(1, Ordering::Relaxed);
96    let ts = std::time::SystemTime::now()
97        .duration_since(std::time::UNIX_EPOCH)
98        .unwrap_or_default()
99        .as_nanos();
100    format!("{:016x}", (ts as u64).wrapping_add(count))
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[test]
108    fn test_temp_recording_path() {
109        let path = RecordingStore::temp_recording_path();
110        assert!(path.to_string_lossy().contains("murmur-"));
111    }
112
113    #[test]
114    fn test_chrono_timestamp() {
115        let ts = chrono_timestamp();
116        assert!(!ts.is_empty());
117        assert!(ts.parse::<u64>().is_ok());
118    }
119
120    #[test]
121    fn test_uuid_short() {
122        let id = uuid_short();
123        assert_eq!(id.len(), 16);
124    }
125
126    #[test]
127    fn test_ensure_dir_creates_directory() {
128        let tmp = tempfile::TempDir::new().unwrap();
129        let dir = tmp.path().join("test_recordings");
130        assert!(!dir.exists());
131        RecordingStore::ensure_dir_at(&dir);
132        assert!(dir.exists());
133    }
134
135    #[test]
136    fn test_new_recording_path_format() {
137        let tmp = tempfile::TempDir::new().unwrap();
138        let path = RecordingStore::new_recording_path_in(tmp.path());
139        let name = path.file_name().unwrap().to_string_lossy();
140        assert!(name.starts_with("recording-"));
141        assert!(name.ends_with(".wav"));
142    }
143
144    #[test]
145    fn test_list_recordings_empty() {
146        let tmp = tempfile::TempDir::new().unwrap();
147        let recordings = RecordingStore::list_recordings_in(tmp.path());
148        assert!(recordings.is_empty());
149    }
150
151    #[test]
152    fn test_list_recordings_filters_non_recording_files() {
153        let tmp = tempfile::TempDir::new().unwrap();
154        // Create a non-recording file
155        std::fs::write(tmp.path().join("other.txt"), "not a recording").unwrap();
156        // Create a recording file
157        std::fs::write(tmp.path().join("recording-123-abc.wav"), "").unwrap();
158        let recordings = RecordingStore::list_recordings_in(tmp.path());
159        assert_eq!(recordings.len(), 1);
160        assert!(recordings[0].1.starts_with("recording-"));
161    }
162
163    #[test]
164    fn test_list_recordings_sorted_descending() {
165        let tmp = tempfile::TempDir::new().unwrap();
166        std::fs::write(tmp.path().join("recording-001-aaa.wav"), "").unwrap();
167        std::fs::write(tmp.path().join("recording-003-ccc.wav"), "").unwrap();
168        std::fs::write(tmp.path().join("recording-002-bbb.wav"), "").unwrap();
169        let recordings = RecordingStore::list_recordings_in(tmp.path());
170        assert_eq!(recordings.len(), 3);
171        assert!(recordings[0].1 > recordings[1].1);
172        assert!(recordings[1].1 > recordings[2].1);
173    }
174
175    #[test]
176    fn test_prune_removes_oldest() {
177        let tmp = tempfile::TempDir::new().unwrap();
178        std::fs::write(tmp.path().join("recording-001-aaa.wav"), "").unwrap();
179        std::fs::write(tmp.path().join("recording-002-bbb.wav"), "").unwrap();
180        std::fs::write(tmp.path().join("recording-003-ccc.wav"), "").unwrap();
181
182        RecordingStore::prune_in(tmp.path(), 2);
183
184        let remaining = RecordingStore::list_recordings_in(tmp.path());
185        assert_eq!(remaining.len(), 2);
186        // Newest should remain
187        assert!(remaining.iter().any(|(_, n)| n.contains("003")));
188        assert!(remaining.iter().any(|(_, n)| n.contains("002")));
189    }
190
191    #[test]
192    fn test_prune_noop_when_under_limit() {
193        let tmp = tempfile::TempDir::new().unwrap();
194        std::fs::write(tmp.path().join("recording-001-aaa.wav"), "").unwrap();
195        RecordingStore::prune_in(tmp.path(), 5);
196        let remaining = RecordingStore::list_recordings_in(tmp.path());
197        assert_eq!(remaining.len(), 1);
198    }
199
200    #[test]
201    fn test_delete_all() {
202        let tmp = tempfile::TempDir::new().unwrap();
203        std::fs::write(tmp.path().join("recording-001-aaa.wav"), "").unwrap();
204        std::fs::write(tmp.path().join("recording-002-bbb.wav"), "").unwrap();
205        // Inline delete-all logic (the wrapper was removed as dead code)
206        for (path, _) in RecordingStore::list_recordings_in(tmp.path()) {
207            let _ = std::fs::remove_file(&path);
208        }
209        let remaining = RecordingStore::list_recordings_in(tmp.path());
210        assert!(remaining.is_empty());
211    }
212
213    #[test]
214    fn test_recordings_dir_path() {
215        let dir = RecordingStore::recordings_dir();
216        assert!(dir.to_string_lossy().contains("recordings"));
217    }
218
219    #[test]
220    fn test_new_recording_paths_are_unique() {
221        let tmp = tempfile::TempDir::new().unwrap();
222        let p1 = RecordingStore::new_recording_path_in(tmp.path());
223        std::thread::sleep(std::time::Duration::from_millis(10));
224        let p2 = RecordingStore::new_recording_path_in(tmp.path());
225        assert_ne!(p1, p2);
226    }
227
228    #[test]
229    fn test_prune_wrapper() {
230        // Calls the real dir but safe with a large limit
231        RecordingStore::prune(1000);
232    }
233
234    #[test]
235    fn test_new_recording_path_wrapper() {
236        let path = RecordingStore::new_recording_path();
237        let name = path.file_name().unwrap().to_string_lossy();
238        assert!(name.starts_with("recording-"));
239        assert!(name.ends_with(".wav"));
240    }
241}