1#![allow(
10 clippy::cast_possible_wrap,
11 clippy::cast_possible_truncation,
12 clippy::cast_sign_loss,
13 reason = "Howard Hinnant civil-from-days algorithm operates in i64 intermediate values + casts back to u32 / u64 at known-safe range boundaries. The algorithm is exact for any positive Unix timestamp through year 9999 (way past any realistic recorder use)."
14)]
15
16use std::path::{Path, PathBuf};
17
18use media::encode::OutputFormat;
19
20#[must_use]
25pub fn default_output_dir() -> PathBuf {
26 if let Some(override_dir) = std::env::var_os("SCREEN_RECORDER_OUTPUT_DIR") {
27 return PathBuf::from(override_dir);
28 }
29 let home = std::env::var_os("HOME")
30 .or_else(|| std::env::var_os("USERPROFILE"))
31 .map_or_else(|| PathBuf::from("."), PathBuf::from);
32 if cfg!(target_os = "macos") {
33 home.join("Movies").join("Screen")
34 } else {
35 home.join("Videos").join("Screen")
36 }
37}
38
39#[must_use]
45pub fn default_basename(started_at_unix_secs: u64) -> String {
46 let (year, month, day, hour, minute, second) = unix_to_ymdhms(started_at_unix_secs);
47 format!("Screen-{year:04}-{month:02}-{day:02}-{hour:02}{minute:02}{second:02}")
48}
49
50#[must_use]
54pub fn default_filename(started_at_unix_secs: u64, format: OutputFormat) -> String {
55 format!(
56 "{}.{ext}",
57 default_basename(started_at_unix_secs),
58 ext = format.extension(),
59 )
60}
61
62#[must_use]
65pub fn default_output_path(started_at_unix_secs: u64, format: OutputFormat) -> PathBuf {
66 default_output_dir().join(default_filename(started_at_unix_secs, format))
67}
68
69pub fn ensure_parent_dir(path: &Path) -> std::io::Result<()> {
78 if let Some(parent) = path.parent() {
79 std::fs::create_dir_all(parent)?;
80 }
81 Ok(())
82}
83
84pub fn move_file(src: &Path, dst: &Path) -> std::io::Result<()> {
101 ensure_parent_dir(dst)?;
102 if std::fs::rename(src, dst).is_ok() {
103 return Ok(());
104 }
105 std::fs::copy(src, dst)?;
108 std::fs::remove_file(src)
109}
110
111pub fn reveal_in_file_manager(path: &Path) -> Result<(), String> {
123 let cmd_result = if cfg!(target_os = "macos") {
124 std::process::Command::new("open")
125 .arg("-R")
126 .arg(path)
127 .spawn()
128 } else if cfg!(target_os = "windows") {
129 std::process::Command::new("explorer")
130 .arg(format!("/select,{}", path.display()))
131 .spawn()
132 } else {
133 let dir = path.parent().unwrap_or(Path::new("."));
134 std::process::Command::new("xdg-open").arg(dir).spawn()
135 };
136 cmd_result
137 .map(|_child| ())
138 .map_err(|err| format!("failed to spawn file-manager command: {err}"))
139}
140
141fn unix_to_ymdhms(unix_secs: u64) -> (u32, u32, u32, u32, u32, u32) {
149 let second = (unix_secs % 60) as u32;
150 let minutes_total = unix_secs / 60;
151 let minute = (minutes_total % 60) as u32;
152 let hours_total = minutes_total / 60;
153 let hour = (hours_total % 24) as u32;
154 let days_since_epoch = hours_total / 24;
155
156 let z = days_since_epoch as i64 + 719_468;
159 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
160 let doe = (z - era * 146_097) as u64;
161 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
162 let y = yoe as i64 + era * 400;
163 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
164 let mp = (5 * doy + 2) / 153;
165 let d = doy - (153 * mp + 2) / 5 + 1;
166 let m = if mp < 10 { mp + 3 } else { mp - 9 };
167 let year = if m <= 2 { y + 1 } else { y } as u32;
168
169 (year, m as u32, d as u32, hour, minute, second)
170}
171
172#[cfg(test)]
173#[allow(
174 unsafe_code,
175 reason = "Tests need to set/unset SCREEN_RECORDER_OUTPUT_DIR to exercise both env-var-honored and HOME-default branches. The wider `unsafe_code = warn` lint is workspace-wide; tests scope the safety justification."
176)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn default_output_dir_uses_home() {
182 let saved_override = std::env::var_os("SCREEN_RECORDER_OUTPUT_DIR");
184 unsafe {
187 std::env::remove_var("SCREEN_RECORDER_OUTPUT_DIR");
188 }
189 let dir = default_output_dir();
190 let s = dir.to_string_lossy();
192 assert!(
193 s.contains("Movies/Screen")
194 || s.contains("Videos/Screen")
195 || s.contains("Videos\\Screen"),
196 "unexpected default dir: {s}"
197 );
198 if let Some(val) = saved_override {
200 unsafe {
202 std::env::set_var("SCREEN_RECORDER_OUTPUT_DIR", val);
203 }
204 }
205 }
206
207 #[test]
208 fn default_output_dir_honors_override_env_var() {
209 unsafe {
211 std::env::set_var("SCREEN_RECORDER_OUTPUT_DIR", "/tmp/m-export-override");
212 }
213 assert_eq!(
214 default_output_dir(),
215 PathBuf::from("/tmp/m-export-override")
216 );
217 unsafe {
219 std::env::remove_var("SCREEN_RECORDER_OUTPUT_DIR");
220 }
221 }
222
223 #[test]
224 fn default_filename_uses_mp4_extension_for_h264() {
225 let name = default_filename(1_763_402_400, OutputFormat::Mp4H264Aac);
227 assert_eq!(name, "Screen-2025-11-17-180000.mp4");
228 }
232
233 #[test]
234 fn default_filename_uses_webm_extension_for_vp9() {
235 let name = default_filename(1_763_402_400, OutputFormat::WebmVp9Opus);
236 assert!(
237 std::path::Path::new(&name)
238 .extension()
239 .is_some_and(|ext| ext.eq_ignore_ascii_case("webm"))
240 );
241 }
242
243 #[test]
244 fn default_filename_pads_single_digits() {
245 let name = default_filename(62, OutputFormat::Mp4H264Aac);
247 assert_eq!(name, "Screen-1970-01-01-000102.mp4");
248 }
249
250 #[test]
251 fn default_filename_handles_unix_epoch() {
252 let name = default_filename(0, OutputFormat::Mp4H264Aac);
253 assert_eq!(name, "Screen-1970-01-01-000000.mp4");
254 }
255
256 #[test]
257 fn default_output_path_joins_dir_and_filename() {
258 unsafe {
260 std::env::set_var("SCREEN_RECORDER_OUTPUT_DIR", "/tmp/screen-test");
261 }
262 let p = default_output_path(0, OutputFormat::Mp4H264Aac);
263 assert_eq!(
264 p,
265 PathBuf::from("/tmp/screen-test/Screen-1970-01-01-000000.mp4")
266 );
267 unsafe {
269 std::env::remove_var("SCREEN_RECORDER_OUTPUT_DIR");
270 }
271 }
272
273 #[test]
274 fn ensure_parent_dir_creates_nested_path() {
275 let test_dir = std::env::temp_dir().join("m-export-ensure-parent-test");
276 let _ = std::fs::remove_dir_all(&test_dir);
277 let nested = test_dir.join("sub").join("deeper").join("file.mp4");
278 ensure_parent_dir(&nested).expect("create_dir_all");
279 assert!(nested.parent().unwrap().exists());
280 let _ = std::fs::remove_dir_all(&test_dir);
281 }
282
283 #[test]
284 fn ensure_parent_dir_is_idempotent() {
285 let test_dir = std::env::temp_dir().join("m-export-ensure-parent-idempotent");
286 let _ = std::fs::remove_dir_all(&test_dir);
287 let target = test_dir.join("file.mp4");
288 ensure_parent_dir(&target).expect("first call");
289 ensure_parent_dir(&target).expect("second call");
290 let _ = std::fs::remove_dir_all(&test_dir);
291 }
292
293 #[test]
294 fn default_basename_has_no_extension() {
295 let base = default_basename(1_763_402_400);
296 assert_eq!(base, "Screen-2025-11-17-180000");
297 assert!(std::path::Path::new(&base).extension().is_none());
298 assert_eq!(
300 default_filename(1_763_402_400, OutputFormat::Mp4H264Aac),
301 format!("{base}.mp4")
302 );
303 }
304
305 #[test]
306 fn move_file_same_dir_moves_contents_and_removes_source() {
307 let dir = std::env::temp_dir().join("m-save-move-same-dir");
308 let _ = std::fs::remove_dir_all(&dir);
309 std::fs::create_dir_all(&dir).expect("mkdir");
310 let src = dir.join("scratch.mp4");
311 let dst = dir.join("Screen-final.mp4");
312 std::fs::write(&src, b"fake-mp4-bytes").expect("write src");
313 move_file(&src, &dst).expect("move");
314 assert!(!src.exists(), "source should be gone after move");
315 assert_eq!(std::fs::read(&dst).expect("read dst"), b"fake-mp4-bytes");
316 let _ = std::fs::remove_dir_all(&dir);
317 }
318
319 #[test]
320 fn move_file_creates_destination_parent_dir() {
321 let dir = std::env::temp_dir().join("m-save-move-mkparent");
322 let _ = std::fs::remove_dir_all(&dir);
323 std::fs::create_dir_all(&dir).expect("mkdir");
324 let src = dir.join("scratch.mp4");
325 let dst = dir.join("nested").join("deeper").join("out.mp4");
326 std::fs::write(&src, b"x").expect("write src");
327 move_file(&src, &dst).expect("move into nonexistent parent");
328 assert!(dst.exists());
329 let _ = std::fs::remove_dir_all(&dir);
330 }
331
332 #[test]
333 fn unix_to_ymdhms_reference_points() {
334 assert_eq!(unix_to_ymdhms(0), (1970, 1, 1, 0, 0, 0));
336 assert_eq!(unix_to_ymdhms(86_400), (1970, 1, 2, 0, 0, 0));
338 assert_eq!(unix_to_ymdhms(1_700_000_000), (2023, 11, 14, 22, 13, 20));
341 }
342
343 #[test]
344 fn unix_to_ymdhms_handles_leap_year_feb_29() {
345 let (y, m, d, h, _, _) = unix_to_ymdhms(1_709_164_800);
347 assert_eq!((y, m, d, h), (2024, 2, 29, 0));
348 }
349}