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}