murmur_core/audio/
recordings.rs1use 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 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 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 std::fs::write(tmp.path().join("other.txt"), "not a recording").unwrap();
156 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 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 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 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}