suno_core/fs.rs
1//! The filesystem port: the executor's only window to disk.
2//!
3//! The download executor never touches the disk directly. It writes, renames,
4//! removes, reads, and probes files through this trait, which a CLI adapter
5//! implements with `std::fs` (an atomic temp-and-rename write, a cross-platform
6//! replace, and parent-directory creation). Tests use an in-memory double so
7//! the executor's logic is exercised without real IO.
8//!
9//! Paths are relative to an account root the adapter owns; the executor only
10//! ever passes the relative path a [`crate::Plan`] carries.
11
12/// On-disk facts about one path, as probed by [`Filesystem::metadata`].
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub struct FileStat {
15 /// Whether the file exists.
16 pub exists: bool,
17 /// Size of the file in bytes (zero when absent).
18 pub size: u64,
19}
20
21/// Why a filesystem write failed, so the executor can treat a full disk as a
22/// systemic abort rather than one more skippable per-clip fault.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum FsErrorKind {
25 /// The device or quota ran out of space.
26 OutOfSpace,
27 /// Any other failure (permission, missing parent, corruption).
28 Other,
29}
30
31/// A filesystem failure, carrying a kind and a human-readable, secret-free
32/// reason.
33#[derive(Debug, thiserror::Error)]
34#[error("{reason}")]
35pub struct FsError {
36 kind: FsErrorKind,
37 reason: String,
38}
39
40impl FsError {
41 /// Build an [`FsError`] of kind [`FsErrorKind::Other`] from any displayable
42 /// cause.
43 pub fn new(reason: impl Into<String>) -> Self {
44 Self {
45 kind: FsErrorKind::Other,
46 reason: reason.into(),
47 }
48 }
49
50 /// Build an out-of-space [`FsError`] (kind [`FsErrorKind::OutOfSpace`]).
51 pub fn out_of_space(reason: impl Into<String>) -> Self {
52 Self {
53 kind: FsErrorKind::OutOfSpace,
54 reason: reason.into(),
55 }
56 }
57
58 /// The failure kind.
59 pub fn kind(&self) -> FsErrorKind {
60 self.kind
61 }
62
63 /// Whether this failure was a full disk or exhausted quota.
64 pub fn is_out_of_space(&self) -> bool {
65 self.kind == FsErrorKind::OutOfSpace
66 }
67}
68
69/// The disk port the executor writes the plan through.
70///
71/// Methods are synchronous: disk IO is fast and the adapter can offload it if
72/// it must. Every method returns a [`Result`] so the engine never panics on an
73/// IO fault; a write failure must leave any prior file intact (atomic write).
74pub trait Filesystem {
75 /// Write `bytes` to `path` atomically, replacing any existing file.
76 ///
77 /// On failure the prior file at `path` is left untouched: the adapter
78 /// stages a temporary sibling and renames it into place only once the full
79 /// contents are written, so a partial write can never be observed.
80 fn write_atomic(&self, path: &str, bytes: &[u8]) -> Result<(), FsError>;
81
82 /// Move `from` onto `to`, replacing any existing destination.
83 fn rename(&self, from: &str, to: &str) -> Result<(), FsError>;
84
85 /// Remove `path`. Succeeds when the file is already absent (idempotent).
86 fn remove(&self, path: &str) -> Result<(), FsError>;
87
88 /// Remove empty directories under `root`, bottom-up.
89 ///
90 /// After a rename/move or a delete empties an album directory, that now-dead
91 /// directory is a ghost. This prunes it. The contract is strictly additive
92 /// and safe:
93 ///
94 /// - it removes only directories that are genuinely empty, walking
95 /// depth-first so an emptied parent is pruned once its last child is;
96 /// - it NEVER removes a directory holding any entry, including a hidden file
97 /// (a `.suno-manifest.json`, `.suno-lineage.json`, or `.m3u8`); and
98 /// - it NEVER removes `root` itself, only directories strictly beneath it.
99 ///
100 /// `root` is a library-relative directory, with the empty string (or `"."`)
101 /// meaning the account root. A prune failure is never fatal: the tool
102 /// re-plans and retries on the next run, so this only ever tidies.
103 fn prune_empty_dirs(&self, root: &str) -> Result<(), FsError>;
104
105 /// Read the whole file at `path`.
106 fn read(&self, path: &str) -> Result<Vec<u8>, FsError>;
107
108 /// Probe `path`, returning its [`FileStat`] or `None` when it is absent.
109 fn metadata(&self, path: &str) -> Option<FileStat>;
110}