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