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}