stately_files/
path.rs

1//! The files in this module are to be used throughout `stately` configuration.
2//!
3//! TODO:
4//!   * Docs - talk about how they aren't entities, but rather entity properties.
5//!   * Note how this is the 'read' side of the equation. To get a file from configuration.
6use std::path::{Path, PathBuf};
7
8use crate::error::Result;
9use crate::settings::{Dirs, UPLOAD_DIR, VERSION_DIR};
10
11/// Newtype wrapper for versioned file paths.
12///
13/// Represents a logical file name that resolves to the latest UUID-versioned
14/// file in a directory (e.g., "config.json" → "uploads/config.json/{latest-uuid}").
15///
16/// The inner string is not directly accessible to prevent bypassing version resolution.
17#[derive(
18    Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
19)]
20#[serde(transparent)]
21pub struct VersionedPath(String);
22
23impl VersionedPath {
24    /// Creates a new versioned path.
25    pub fn new(name: impl Into<String>) -> Self { Self(name.into()) }
26
27    /// Returns the logical name of this versioned path.
28    ///
29    /// This is the directory name, not the UUID filename.
30    pub fn logical(&self) -> &str { &self.0 }
31
32    /// Resolves to the latest UUID-versioned file in the given base directory.
33    ///
34    /// # Errors
35    /// Returns an error if the directory doesn't exist or contains no files.
36    pub fn resolve(&self, base_dir: &Path) -> std::io::Result<PathBuf> {
37        Self::find_latest(base_dir.join(&self.0).join(VERSION_DIR))
38    }
39
40    /// Finds the latest UUID-named file in a directory.
41    ///
42    /// Files are sorted lexicographically (UUID v7 is time-sortable).
43    ///
44    /// # Errors
45    /// Returns an error if the directory cannot be read or contains no files.
46    pub fn find_latest<P: AsRef<Path>>(dir: P) -> std::io::Result<PathBuf> {
47        let dir_path = dir.as_ref();
48        let mut entries: Vec<_> = std::fs::read_dir(dir_path)?
49            .filter_map(std::io::Result::ok)
50            .filter(|e| e.path().is_file())
51            .collect();
52
53        // UUID v7 is time-sortable lexicographically
54        entries.sort_by_key(std::fs::DirEntry::file_name);
55
56        entries.last().map(std::fs::DirEntry::path).ok_or_else(|| {
57            std::io::Error::new(
58                std::io::ErrorKind::NotFound,
59                format!("No files found in directory: {}", dir_path.display()),
60            )
61        })
62    }
63}
64
65/// Path relative to an app directory (upload, data, config, or cache).
66///
67/// Use this type in configuration structs when you need paths relative to
68/// app directories with optional version resolution for uploaded files.
69///
70/// For paths that are just strings (e.g., user-provided absolute paths or
71/// URLs), use `String` or `PathBuf` directly instead.
72#[derive(
73    Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
74)]
75#[serde(tag = "dir", content = "path", rename_all = "lowercase")]
76pub enum RelativePath {
77    /// Path relative to the cache directory
78    Cache(String),
79    /// Path relative to the data directory (non-versioned files)
80    Data(String),
81    /// Path to uploaded file with version resolution support (in uploads/ directory)
82    Upload(VersionedPath),
83}
84
85impl RelativePath {
86    /// Resolves the relative path to a full absolute `PathBuf`.
87    ///
88    /// For `Upload` variant with `VersionedPath`, automatically resolves to the
89    /// latest UUID-versioned file in the uploads directory.
90    ///
91    /// # Errors
92    /// Returns an error if:
93    /// - The path cannot be resolved
94    /// - For versioned paths, if the directory doesn't exist or contains no files
95    /// - File system operations fail
96    pub fn get(&self, base: Option<&Dirs>) -> std::io::Result<PathBuf> {
97        let dirs = base.unwrap_or(Dirs::get());
98        match self {
99            RelativePath::Cache(path) => Ok(dirs.cache.join(path)),
100            RelativePath::Data(path) => Ok(dirs.data.join(path)),
101            RelativePath::Upload(versioned) => {
102                // Uploaded files are in data/{UPLOAD_DIR}/ with __versions__ subdirectories
103                versioned.resolve(&dirs.data.join(UPLOAD_DIR))
104            }
105        }
106    }
107}
108
109/// Path that can be either managed by the application or user-defined.
110///
111/// Use this type when a path could be either:
112/// - An uploaded file managed by the app (with version resolution)
113/// - A user-provided path on the filesystem
114///
115/// # Examples
116/// ```rust,ignore
117/// // Managed: uploads/config.json (resolved to latest UUID)
118/// UserDefinedPath::Managed(RelativePath::Data(VersionedPath::new("uploads/config.json")))
119///
120/// // External: /usr/local/bin/script.sh
121/// UserDefinedPath::External("/usr/local/bin/script.sh".to_string())
122/// ```
123#[derive(
124    Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema,
125)]
126#[serde(untagged)]
127pub enum UserDefinedPath {
128    /// Application-managed path with optional version resolution
129    Managed(RelativePath),
130    /// User-provided external path (filesystem path or URL)
131    External(String),
132}
133
134impl UserDefinedPath {
135    /// Resolves the path to a full absolute `PathBuf`.
136    ///
137    /// For `Managed` paths, uses `RelativePath::get()` with automatic version resolution.
138    /// For `External` paths, expands environment variables and tildes, then normalizes.
139    ///
140    /// # Errors
141    /// Returns an error if:
142    /// - Path resolution fails
143    /// - For managed paths with versioning, if the directory doesn't exist or contains no files
144    /// - Environment variable expansion fails
145    /// - File system operations fail
146    pub fn resolve(&self, base: Option<&Dirs>) -> Result<PathBuf> {
147        match self {
148            UserDefinedPath::Managed(rel_path) => Ok(rel_path.get(base)?),
149            UserDefinedPath::External(path) => {
150                let expanded = super::utils::try_env_expand(path)?;
151                super::utils::normalize_path_to_cur_dir(expanded)
152            }
153        }
154    }
155}