Skip to main content

screen_app/
recording_paths.rs

1//! Default output path resolution + "Reveal in Finder/Explorer/Files"
2//! plumbing for M-EXPORT.4 (M-RECORD-EXPORT).
3//!
4//! Pure-Rust path math + a `std::process::Command` wrapper for the
5//! per-OS file-manager reveal. The user-pickable output directory
6//! (M-SAVE.0) layers on top via
7//! [`recorder_settings`](crate::recorder_settings).
8
9#![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/// Default output directory for new recordings. macOS uses
21/// `~/Movies/Screen/`; Windows + Linux use `~/Videos/Screen/`.
22/// Honors the `SCREEN_RECORDER_OUTPUT_DIR` env var for tests and
23/// power users.
24#[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/// Compose the extension-less base filename for a session that
40/// started at `started_at` (Unix epoch seconds). Format
41/// `Screen-YYYY-MM-DD-HHMMSS` — sortable, unambiguous, file-system-
42/// safe on every OS. M-SAVE.1's Save panel appends the extension
43/// matching the chosen export format.
44#[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/// Compose a default output filename for a session that started at
51/// `started_at` (Unix epoch seconds). [`default_basename`] + the
52/// format's extension (e.g. `Screen-2026-05-25-123700.mp4`).
53#[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/// Compose the full default output path. The directory is created
63/// on first use by the encoder; this just returns the path.
64#[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
69/// Ensure the parent directory of `path` exists, creating it
70/// recursively if needed. Used by the encoder before opening the
71/// scratch files.
72///
73/// # Errors
74///
75/// Returns the underlying [`std::io::Error`] when directory creation
76/// fails (permission denied, read-only filesystem, etc.).
77pub 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
84/// Move `src` to `dst`, creating `dst`'s parent directory first.
85/// Used by M-SAVE.1's MP4 export path to promote the finalized
86/// scratch file into the user's chosen folder.
87///
88/// Tries an atomic [`std::fs::rename`] first — fast and the common
89/// case, since the scratch dir and the default output dir are
90/// normally on the same (home) volume. Rename fails across volumes
91/// (`EXDEV`) — e.g. exporting to an external drive — so on *any*
92/// rename error it falls back to copy-then-remove, which works
93/// across devices.
94///
95/// # Errors
96///
97/// Returns the underlying [`std::io::Error`] when the parent dir
98/// can't be created, or when the copy fallback itself fails (source
99/// missing, destination not writable, disk full).
100pub 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    // Cross-device (or other rename failure): copy then remove the
106    // source. The copy error, if any, is the meaningful one to surface.
107    std::fs::copy(src, dst)?;
108    std::fs::remove_file(src)
109}
110
111/// Spawn the per-OS file-manager "reveal this file" command.
112/// macOS uses `open -R <path>`, Windows uses
113/// `explorer /select,<path>`, Linux uses `xdg-open <parent-dir>`
114/// (most distros don't have a portable "select" verb).
115///
116/// # Errors
117///
118/// Returns an error string if the spawn itself failed (binary not on
119/// PATH). Non-zero exit from the spawned command is treated as
120/// success because the file manager may legitimately background the
121/// window and return immediately.
122pub 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
141/// Convert Unix epoch seconds to (year, month, day, hour, minute,
142/// second) via a small pure-Rust algorithm. Avoids pulling in
143/// `chrono` for this single-purpose use.
144///
145/// Accurate for any positive timestamp through year 9999. Returns
146/// `(1970, 1, 1, 0, 0, 0)` for negative inputs (impossible in
147/// practice since session timestamps are always now-ish).
148fn 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    // Civil-from-days algorithm (Howard Hinnant) — exact for the
157    // proleptic Gregorian calendar.
158    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        // Save + clear the override so we exercise the HOME path.
183        let saved_override = std::env::var_os("SCREEN_RECORDER_OUTPUT_DIR");
184        // SAFETY: unit tests are single-threaded per nextest's
185        // default; the env-var dance is fine here.
186        unsafe {
187            std::env::remove_var("SCREEN_RECORDER_OUTPUT_DIR");
188        }
189        let dir = default_output_dir();
190        // Either Movies (macOS) or Videos (Win/Linux) subfolder.
191        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        // Restore.
199        if let Some(val) = saved_override {
200            // SAFETY: same justification as the remove above.
201            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        // SAFETY: see above.
210        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        // SAFETY: see above.
218        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        // 2026-05-17 18:00:00 UTC = 1763402400
226        let name = default_filename(1_763_402_400, OutputFormat::Mp4H264Aac);
227        assert_eq!(name, "Screen-2025-11-17-180000.mp4");
228        // (Note: the "2025" is correct — 1763402400 is November 2025,
229        // not May 2026. The unix timestamp doesn't lie even if the
230        // user's wall clock does.)
231    }
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        // 1970-01-01 00:01:02 = 62 seconds.
246        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        // SAFETY: single-threaded test env.
259        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        // SAFETY: see above.
268        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        // default_filename is the basename + the format extension.
299        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        // 0 → Unix epoch.
335        assert_eq!(unix_to_ymdhms(0), (1970, 1, 1, 0, 0, 0));
336        // 86400 → next day.
337        assert_eq!(unix_to_ymdhms(86_400), (1970, 1, 2, 0, 0, 0));
338        // 1700000000 ≈ 2023-11-14 22:13:20 UTC (Howard Hinnant's
339        // formula is exact; spot-check against a known value).
340        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        // 2024-02-29 00:00:00 UTC = 1709164800
346        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}