ailloli_ui_app_storage/lib.rs
1//! App-level storage paths, diagnostics, and small persisted documents.
2//!
3//! The canonical storage layout follows XDG directories. A home-directory
4//! symlink view can be created as an optional user-facing convenience, but it
5//! is never the source of truth.
6//!
7//! # Examples
8//!
9//! ```
10//! use ailloli_ui_app_storage::AppStorage;
11//!
12//! let storage = AppStorage::for_app("example-app")
13//! .resolve_with_env(|key| (key == "HOME").then(|| "/var/empty/example-user".into()))?;
14//! assert_eq!(storage.config_dir().to_string_lossy(), "/var/empty/example-user/.config/example-app");
15//! # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
16//! ```
17
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use std::collections::BTreeMap;
21use std::fs::{self, File};
22use std::io::Write;
23use std::path::{Path, PathBuf};
24
25/// Current [`AppPreferencesDocument`] schema version written by this crate.
26///
27/// # Examples
28///
29/// ```
30/// use ailloli_ui_app_storage::{AppPreferencesDocument, APP_PREFERENCES_VERSION};
31/// assert_eq!(AppPreferencesDocument::new().version, APP_PREFERENCES_VERSION);
32/// ```
33pub const APP_PREFERENCES_VERSION: u32 = 1;
34
35/// Current [`WindowStateDocument`] schema version written by this crate.
36///
37/// # Examples
38///
39/// ```
40/// use ailloli_ui_app_storage::{WindowStateDocument, WINDOW_STATE_VERSION};
41/// assert_eq!(WindowStateDocument::empty().version, WINDOW_STATE_VERSION);
42/// ```
43pub const WINDOW_STATE_VERSION: u32 = 1;
44
45/// Result type returned by storage operations in this crate.
46///
47/// # Examples
48///
49/// ```
50/// use ailloli_ui_app_storage::{AppId, Result};
51/// let id: Result<AppId> = AppId::new("example-app");
52/// assert!(id.is_ok());
53/// ```
54pub type Result<T> = std::result::Result<T, AppStorageError>;
55
56/// Failure produced while resolving paths, validating documents, or touching storage.
57///
58/// # Examples
59///
60/// ```
61/// use ailloli_ui_app_storage::{AppId, AppStorageError};
62/// assert!(matches!(AppId::new("Invalid"), Err(AppStorageError::InvalidAppId(_))));
63/// ```
64#[derive(Debug, thiserror::Error)]
65pub enum AppStorageError {
66 /// The application identifier violates [`AppId::new`] rules.
67 #[error("invalid app id `{0}`")]
68 InvalidAppId(String),
69 /// A filesystem operation failed, optionally at the supplied path.
70 #[error("io error at {path:?}: {source}")]
71 Io {
72 /// Path associated with the operation, or `None` when unavailable.
73 path: Option<PathBuf>,
74 /// Operating-system I/O error returned by the filesystem.
75 #[source]
76 source: std::io::Error,
77 },
78 /// JSON serialization or deserialization failed.
79 #[error("json error: {0}")]
80 Json(#[from] serde_json::Error),
81 /// A persisted document or atomic-write destination is structurally unsupported.
82 #[error("invalid storage document: {0}")]
83 InvalidDocument(String),
84 /// A requested compatibility symlink would replace or disagree with an existing path.
85 #[error("home symlink view collision at {path:?}: {reason}")]
86 SymlinkCollision {
87 /// Existing link or root path that prevented creation.
88 path: PathBuf,
89 /// Human-readable collision detail.
90 reason: String,
91 },
92 /// Directory symlinks are unavailable on the current platform or provider.
93 #[error("home symlink view unsupported: {0}")]
94 SymlinkUnsupported(String),
95}
96
97impl AppStorageError {
98 /// Wraps one I/O failure while retaining its optional path context.
99 fn io(path: impl Into<Option<PathBuf>>, source: std::io::Error) -> Self {
100 Self::Io {
101 path: path.into(),
102 source,
103 }
104 }
105}
106
107/// Filename-safe application identifier used as the leaf of storage paths.
108///
109/// Valid constructor input is non-empty lowercase ASCII letters, digits, and
110/// single hyphens. It cannot begin or end with a hyphen and cannot contain
111/// consecutive hyphens. Derived deserialization does not call [`Self::new`],
112/// so consumers of untrusted serialized `AppId` values must validate them.
113///
114/// # Examples
115///
116/// ```
117/// use ailloli_ui_app_storage::AppId;
118/// assert_eq!(AppId::new("demo-2")?.as_str(), "demo-2");
119/// assert!(AppId::new("Demo_2").is_err());
120/// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
121/// ```
122#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
123pub struct AppId(String);
124
125impl AppId {
126 /// Validates and stores an application identifier.
127 ///
128 /// # Errors
129 ///
130 /// Returns [`AppStorageError::InvalidAppId`] for empty input, non-lowercase
131 /// ASCII letters, punctuation other than internal single hyphens, leading
132 /// or trailing hyphens, or consecutive hyphens.
133 ///
134 /// # Examples
135 ///
136 /// ```
137 /// use ailloli_ui_app_storage::AppId;
138 /// assert!(AppId::new("2d-editor").is_ok());
139 /// assert!(AppId::new("-editor").is_err());
140 /// ```
141 pub fn new(value: impl Into<String>) -> Result<Self> {
142 let value = value.into();
143 if !is_valid_app_id(&value) {
144 return Err(AppStorageError::InvalidAppId(value));
145 }
146 Ok(Self(value))
147 }
148
149 /// Borrows the stored identifier.
150 ///
151 /// # Examples
152 ///
153 /// ```
154 /// use ailloli_ui_app_storage::AppId;
155 /// assert_eq!(AppId::new("example")?.as_str(), "example");
156 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
157 /// ```
158 pub fn as_str(&self) -> &str {
159 &self.0
160 }
161
162 /// Returns the uppercase prefix used for app-specific environment variables.
163 ///
164 /// ASCII alphanumeric characters are uppercased and every other character,
165 /// including the normally valid hyphen, becomes `_`.
166 ///
167 /// # Examples
168 ///
169 /// ```
170 /// use ailloli_ui_app_storage::AppId;
171 /// assert_eq!(AppId::new("my-app")?.env_prefix(), "MY_APP");
172 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
173 /// ```
174 pub fn env_prefix(&self) -> String {
175 self.0
176 .chars()
177 .map(|ch| {
178 if ch.is_ascii_alphanumeric() {
179 ch.to_ascii_uppercase()
180 } else {
181 '_'
182 }
183 })
184 .collect()
185 }
186}
187
188/// Checks the complete portable application-identifier grammar.
189fn is_valid_app_id(value: &str) -> bool {
190 if value.is_empty() || value.starts_with('.') || value.starts_with('-') {
191 return false;
192 }
193 if value.ends_with('-') || value.contains("--") {
194 return false;
195 }
196 value
197 .bytes()
198 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
199}
200
201/// Strategy used to resolve the four canonical storage directories.
202///
203/// # Examples
204///
205/// ```
206/// use ailloli_ui_app_storage::AppStorageMode;
207/// assert_eq!(AppStorageMode::default(), AppStorageMode::Xdg);
208/// ```
209#[derive(Debug, Clone, Default, PartialEq, Eq)]
210pub enum AppStorageMode {
211 /// Follow app-specific overrides, XDG homes, conventional home paths, then temp fallbacks.
212 #[default]
213 Xdg,
214 /// Place `config`, `state`, `data`, and `cache` below one explicit root.
215 SingleDir {
216 /// Parent directory of the four category directories.
217 root: PathBuf,
218 },
219}
220
221/// Resolved canonical storage directories for one application.
222///
223/// No directory is created merely by constructing this value or resolving an
224/// [`AppStorage`].
225///
226/// # Examples
227///
228/// ```
229/// use ailloli_ui_app_storage::AppStorage;
230/// let storage = AppStorage::single_dir("example", "/tmp/example-store").resolve_with_env(|_| None)?;
231/// assert_eq!(storage.dirs().state_dir.to_string_lossy(), "/tmp/example-store/state");
232/// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
233/// ```
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct AppStorageDirs {
236 /// Directory containing user preferences and configuration.
237 pub config_dir: PathBuf,
238 /// Directory containing restorable application/window state.
239 pub state_dir: PathBuf,
240 /// Directory containing durable application data.
241 pub data_dir: PathBuf,
242 /// Directory containing disposable cached data.
243 pub cache_dir: PathBuf,
244}
245
246/// Optional human-facing directory whose entries link to canonical storage.
247///
248/// This view never becomes the source of truth and is disabled by default.
249///
250/// # Examples
251///
252/// ```
253/// use ailloli_ui_app_storage::HomeSymlinkView;
254/// let view = HomeSymlinkView { enabled: false, root: "/tmp/.example".into() };
255/// assert!(!view.enabled);
256/// ```
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct HomeSymlinkView {
259 /// Whether [`AppStorage::ensure_home_symlink_view`] may create the view.
260 pub enabled: bool,
261 /// Directory containing the `config`, `state`, `data`, and `cache` links.
262 pub root: PathBuf,
263}
264
265/// Filesystem inspection result for the optional symlink view.
266///
267/// # Examples
268///
269/// ```
270/// use ailloli_ui_app_storage::{HomeSymlinkViewState, HomeSymlinkViewStatus};
271/// let status = HomeSymlinkViewStatus { enabled: false, root: "/tmp/.example".into(), entries: vec![] };
272/// assert_eq!(status.state(), HomeSymlinkViewState::Disabled);
273/// ```
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct HomeSymlinkViewStatus {
276 /// Configured enablement; disabled status takes precedence over entry states.
277 pub enabled: bool,
278 /// Inspected compatibility-view root.
279 pub root: PathBuf,
280 /// Per-category results in `config`, `state`, `data`, `cache` order.
281 pub entries: Vec<HomeSymlinkEntryStatus>,
282}
283
284impl HomeSymlinkViewStatus {
285 /// Reduces per-entry results to one view state.
286 ///
287 /// Precedence is disabled, collision, unsupported, all-ready, then missing.
288 /// An enabled empty entry list is vacuously ready; statuses produced by
289 /// [`AppStorage`] contain four entries.
290 ///
291 /// # Examples
292 ///
293 /// ```
294 /// use ailloli_ui_app_storage::{HomeSymlinkViewState, HomeSymlinkViewStatus};
295 /// let status = HomeSymlinkViewStatus { enabled: true, root: "/tmp/.example".into(), entries: vec![] };
296 /// assert_eq!(status.state(), HomeSymlinkViewState::Ready);
297 /// ```
298 pub fn state(&self) -> HomeSymlinkViewState {
299 if !self.enabled {
300 return HomeSymlinkViewState::Disabled;
301 }
302 if self
303 .entries
304 .iter()
305 .any(|entry| matches!(entry.state, HomeSymlinkEntryState::Collision { .. }))
306 {
307 return HomeSymlinkViewState::Collision;
308 }
309 if self
310 .entries
311 .iter()
312 .any(|entry| matches!(entry.state, HomeSymlinkEntryState::Unsupported { .. }))
313 {
314 return HomeSymlinkViewState::Unsupported;
315 }
316 if self
317 .entries
318 .iter()
319 .all(|entry| matches!(entry.state, HomeSymlinkEntryState::Ready))
320 {
321 HomeSymlinkViewState::Ready
322 } else {
323 HomeSymlinkViewState::Missing
324 }
325 }
326}
327
328/// Aggregate readiness of the optional symlink view.
329///
330/// # Examples
331///
332/// ```
333/// use ailloli_ui_app_storage::HomeSymlinkViewState;
334/// assert_ne!(HomeSymlinkViewState::Missing, HomeSymlinkViewState::Ready);
335/// ```
336#[derive(Debug, Clone, PartialEq, Eq)]
337pub enum HomeSymlinkViewState {
338 /// Creation is disabled regardless of existing paths.
339 Disabled,
340 /// At least one expected link does not exist.
341 Missing,
342 /// Every expected link points to its canonical directory.
343 Ready,
344 /// An existing path would be overwritten or points elsewhere.
345 Collision,
346 /// A platform/provider cannot represent the requested directory links.
347 Unsupported,
348}
349
350/// Inspection result for one named compatibility link.
351///
352/// # Examples
353///
354/// ```
355/// use ailloli_ui_app_storage::{HomeSymlinkEntryState, HomeSymlinkEntryStatus};
356/// let entry = HomeSymlinkEntryStatus { name: "state".into(), link: "/tmp/view/state".into(), target: "/tmp/data/state".into(), state: HomeSymlinkEntryState::Missing };
357/// assert_eq!(entry.name, "state");
358/// ```
359#[derive(Debug, Clone, PartialEq, Eq)]
360pub struct HomeSymlinkEntryStatus {
361 /// Stable category name: `config`, `state`, `data`, or `cache`.
362 pub name: String,
363 /// Compatibility path inspected as a possible symlink.
364 pub link: PathBuf,
365 /// Canonical directory the link must reference exactly.
366 pub target: PathBuf,
367 /// Result of inspecting `link` against `target`.
368 pub state: HomeSymlinkEntryState,
369}
370
371/// State of one compatibility link.
372///
373/// # Examples
374///
375/// ```
376/// use ailloli_ui_app_storage::HomeSymlinkEntryState;
377/// assert!(matches!(HomeSymlinkEntryState::Missing, HomeSymlinkEntryState::Missing));
378/// ```
379#[derive(Debug, Clone, PartialEq, Eq)]
380pub enum HomeSymlinkEntryState {
381 /// No filesystem entry exists at the link path.
382 Missing,
383 /// The path is a symlink whose stored target exactly equals the canonical target.
384 Ready,
385 /// Creation succeeded; final statuses returned by the current implementation re-inspect as ready.
386 Created,
387 /// An existing path cannot be reused without destructive replacement.
388 Collision {
389 /// Human-readable mismatch or filesystem inspection failure.
390 reason: String,
391 },
392 /// The platform/provider cannot represent this entry.
393 Unsupported {
394 /// Human-readable capability limitation.
395 reason: String,
396 },
397}
398
399/// App-specific environment path that took precedence during resolution.
400///
401/// Generic `XDG_*_HOME` and `HOME` inputs are intentionally not recorded here.
402///
403/// # Examples
404///
405/// ```
406/// use ailloli_ui_app_storage::EnvOverride;
407/// let value = EnvOverride { key: "MY_APP_STATE_DIR".into(), path: "/state".into() };
408/// assert_eq!(value.key, "MY_APP_STATE_DIR");
409/// ```
410#[derive(Debug, Clone, PartialEq, Eq)]
411pub struct EnvOverride {
412 /// Environment variable name that supplied the path.
413 pub key: String,
414 /// Non-empty path value used verbatim.
415 pub path: PathBuf,
416}
417
418/// Snapshot of resolved paths, overrides, and compatibility-view readiness.
419///
420/// Calling [`AppStorage::diagnostics`] inspects symlink paths but creates or
421/// modifies nothing.
422///
423/// # Examples
424///
425/// ```
426/// use ailloli_ui_app_storage::AppStorage;
427/// let storage = AppStorage::single_dir("example", "/tmp/example").resolve_with_env(|_| None)?;
428/// assert_eq!(storage.diagnostics().app_id.as_str(), "example");
429/// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
430/// ```
431#[derive(Debug, Clone, PartialEq, Eq)]
432pub struct AppStorageDiagnostics {
433 /// Validated application identifier.
434 pub app_id: AppId,
435 /// Four canonical directories selected by resolution.
436 pub dirs: AppStorageDirs,
437 /// App-specific non-empty environment overrides that were consumed.
438 pub env_overrides: Vec<EnvOverride>,
439 /// Current read-only inspection of the optional compatibility view.
440 pub home_symlink_view: HomeSymlinkViewStatus,
441}
442
443/// Configures storage resolution without accessing the filesystem.
444///
445/// # Examples
446///
447/// ```
448/// use ailloli_ui_app_storage::AppStorage;
449/// let builder = AppStorage::for_app("example").home_symlink_view(false);
450/// let storage = builder.resolve_with_env(|_| None)?;
451/// assert_eq!(storage.app_id().as_str(), "example");
452/// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
453/// ```
454#[derive(Debug, Clone)]
455pub struct AppStorageBuilder {
456 /// Unvalidated application identifier supplied by the caller.
457 app_id: String,
458 /// XDG or explicit single-directory resolution policy.
459 mode: AppStorageMode,
460 /// Whether resolution should prepare the optional home symlink view.
461 home_symlink_view: bool,
462 /// Optional caller-selected root for that compatibility view.
463 home_symlink_root: Option<PathBuf>,
464}
465
466impl AppStorageBuilder {
467 /// Enables or disables creation of the optional compatibility symlink view.
468 ///
469 /// The default is `false`. This setting alone performs no filesystem work.
470 ///
471 /// # Examples
472 ///
473 /// ```
474 /// use ailloli_ui_app_storage::{AppStorage, HomeSymlinkViewState};
475 /// let storage = AppStorage::for_app("example").home_symlink_view(false).resolve_with_env(|_| None)?;
476 /// assert_eq!(storage.home_symlink_view_status().state(), HomeSymlinkViewState::Disabled);
477 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
478 /// ```
479 pub fn home_symlink_view(mut self, enabled: bool) -> Self {
480 self.home_symlink_view = enabled;
481 self
482 }
483
484 /// Overrides the compatibility-view root.
485 ///
486 /// Exact `~` and `~/...` prefixes expand from a non-empty `HOME`; without
487 /// one, the path remains textual. This does not enable or create the view.
488 ///
489 /// # Examples
490 ///
491 /// ```
492 /// use ailloli_ui_app_storage::AppStorage;
493 /// let storage = AppStorage::for_app("example").home_symlink_root("~/view").resolve_with_env(|key| (key == "HOME").then(|| "/var/empty/example-user".into()))?;
494 /// assert_eq!(storage.home_symlink_view_status().root.to_string_lossy(), "/var/empty/example-user/view");
495 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
496 /// ```
497 pub fn home_symlink_root(mut self, root: impl Into<PathBuf>) -> Self {
498 self.home_symlink_root = Some(root.into());
499 self
500 }
501
502 /// Switches to a single explicit root with four category subdirectories.
503 ///
504 /// Exact `~`/`~/...` expansion occurs during resolution. Repeated calls use
505 /// the last root.
506 ///
507 /// # Examples
508 ///
509 /// ```
510 /// use ailloli_ui_app_storage::AppStorage;
511 /// let storage = AppStorage::for_app("example").single_dir("/store").resolve_with_env(|_| None)?;
512 /// assert_eq!(storage.cache_dir().to_string_lossy(), "/store/cache");
513 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
514 /// ```
515 pub fn single_dir(mut self, root: impl Into<PathBuf>) -> Self {
516 self.mode = AppStorageMode::SingleDir { root: root.into() };
517 self
518 }
519
520 /// Resolves paths from the current process environment without creating them.
521 ///
522 /// Empty environment values are treated as absent.
523 ///
524 /// # Errors
525 ///
526 /// Returns [`AppStorageError::InvalidAppId`] if the builder's identifier is invalid.
527 ///
528 /// # Examples
529 ///
530 /// ```
531 /// use ailloli_ui_app_storage::AppStorage;
532 /// let storage = AppStorage::for_app("example").resolve()?;
533 /// assert_eq!(storage.app_id().as_str(), "example");
534 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
535 /// ```
536 pub fn resolve(self) -> Result<AppStorage> {
537 self.resolve_with_env(|key| std::env::var(key).ok())
538 }
539
540 /// Resolves paths using a caller-supplied environment lookup.
541 ///
542 /// In XDG mode each category uses, in order: `<APP>_<KIND>_DIR`, the
543 /// corresponding `XDG_*_HOME` plus the app id, the conventional path below
544 /// `HOME`, then a category-specific temporary-directory fallback. Only the
545 /// first app-specific tier is recorded in diagnostics. Empty strings are
546 /// absent. Resolution performs no filesystem writes.
547 ///
548 /// # Errors
549 ///
550 /// Returns [`AppStorageError::InvalidAppId`] if the builder's identifier is invalid.
551 ///
552 /// # Examples
553 ///
554 /// ```
555 /// use ailloli_ui_app_storage::AppStorage;
556 /// let storage = AppStorage::for_app("my-app").resolve_with_env(|key| match key {
557 /// "MY_APP_STATE_DIR" => Some("/override/state".into()),
558 /// "HOME" => Some("/var/empty/example-user".into()),
559 /// _ => None,
560 /// })?;
561 /// assert_eq!(storage.state_dir().to_string_lossy(), "/override/state");
562 /// assert_eq!(storage.diagnostics().env_overrides.len(), 1);
563 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
564 /// ```
565 pub fn resolve_with_env(
566 mut self,
567 get: impl FnMut(&str) -> Option<String>,
568 ) -> Result<AppStorage> {
569 let app_id = AppId::new(std::mem::take(&mut self.app_id))?;
570 let mut resolver = EnvResolver::new(get);
571 let home = resolver.get_path("HOME");
572 let dirs = match &self.mode {
573 AppStorageMode::Xdg => resolve_xdg_dirs(&app_id, &mut resolver, home.as_deref()),
574 AppStorageMode::SingleDir { root } => {
575 let root = expand_home(root.clone(), home.as_deref());
576 AppStorageDirs {
577 config_dir: root.join("config"),
578 state_dir: root.join("state"),
579 data_dir: root.join("data"),
580 cache_dir: root.join("cache"),
581 }
582 }
583 };
584 let home_symlink_root = self
585 .home_symlink_root
586 .map(|root| expand_home(root, home.as_deref()))
587 .or_else(|| {
588 home.as_ref()
589 .map(|home| home.join(format!(".{}", app_id.as_str())))
590 })
591 .unwrap_or_else(|| std::env::temp_dir().join(format!(".{}", app_id.as_str())));
592 let home_symlink_view = HomeSymlinkView {
593 enabled: self.home_symlink_view,
594 root: home_symlink_root,
595 };
596 let storage = AppStorage {
597 app_id: app_id.clone(),
598 mode: self.mode,
599 dirs: dirs.clone(),
600 home_symlink_view,
601 env_overrides: resolver.overrides,
602 };
603 Ok(storage)
604 }
605}
606
607/// Filters environment values and records app-specific paths that are consumed.
608struct EnvResolver<F> {
609 /// Environment lookup supplied by production code or a deterministic test.
610 get: F,
611 /// App-specific environment overrides consumed during resolution.
612 overrides: Vec<EnvOverride>,
613}
614
615impl<F> EnvResolver<F>
616where
617 F: FnMut(&str) -> Option<String>,
618{
619 /// Wraps a lookup closure with an empty override log.
620 fn new(get: F) -> Self {
621 Self {
622 get,
623 overrides: Vec::new(),
624 }
625 }
626
627 /// Returns one non-empty environment value.
628 fn get(&mut self, key: &str) -> Option<String> {
629 (self.get)(key).filter(|value| !value.is_empty())
630 }
631
632 /// Converts one non-empty environment value into a path verbatim.
633 fn get_path(&mut self, key: &str) -> Option<PathBuf> {
634 self.get(key).map(PathBuf::from)
635 }
636
637 /// Gets and records one app-specific path override.
638 fn get_override_path(&mut self, key: &str) -> Option<PathBuf> {
639 let path = self.get_path(key)?;
640 self.overrides.push(EnvOverride {
641 key: key.to_string(),
642 path: path.clone(),
643 });
644 Some(path)
645 }
646}
647
648/// Resolves all XDG category directories using the documented priority chain.
649fn resolve_xdg_dirs<F>(
650 app_id: &AppId,
651 resolver: &mut EnvResolver<F>,
652 home: Option<&Path>,
653) -> AppStorageDirs
654where
655 F: FnMut(&str) -> Option<String>,
656{
657 let prefix = app_id.env_prefix();
658 let app = app_id.as_str();
659 let config_dir = resolver
660 .get_override_path(&format!("{prefix}_CONFIG_DIR"))
661 .or_else(|| {
662 resolver
663 .get_path("XDG_CONFIG_HOME")
664 .map(|path| path.join(app))
665 })
666 .or_else(|| home.map(|home| home.join(".config").join(app)))
667 .unwrap_or_else(|| std::env::temp_dir().join(format!("{app}-config")));
668 let state_dir = resolver
669 .get_override_path(&format!("{prefix}_STATE_DIR"))
670 .or_else(|| {
671 resolver
672 .get_path("XDG_STATE_HOME")
673 .map(|path| path.join(app))
674 })
675 .or_else(|| home.map(|home| home.join(".local/state").join(app)))
676 .unwrap_or_else(|| std::env::temp_dir().join(format!("{app}-state")));
677 let data_dir = resolver
678 .get_override_path(&format!("{prefix}_DATA_DIR"))
679 .or_else(|| {
680 resolver
681 .get_path("XDG_DATA_HOME")
682 .map(|path| path.join(app))
683 })
684 .or_else(|| home.map(|home| home.join(".local/share").join(app)))
685 .unwrap_or_else(|| std::env::temp_dir().join(format!("{app}-data")));
686 let cache_dir = resolver
687 .get_override_path(&format!("{prefix}_CACHE_DIR"))
688 .or_else(|| {
689 resolver
690 .get_path("XDG_CACHE_HOME")
691 .map(|path| path.join(app))
692 })
693 .or_else(|| home.map(|home| home.join(".cache").join(app)))
694 .unwrap_or_else(|| std::env::temp_dir().join(format!("{app}-cache")));
695 AppStorageDirs {
696 config_dir,
697 state_dir,
698 data_dir,
699 cache_dir,
700 }
701}
702
703/// Expands only exact `~` and leading `~/` forms when a home path is available.
704fn expand_home(path: PathBuf, home: Option<&Path>) -> PathBuf {
705 let value = path.to_string_lossy();
706 if value == "~" {
707 return home.map(Path::to_path_buf).unwrap_or(path);
708 }
709 if let Some(rest) = value.strip_prefix("~/") {
710 return home
711 .map(|home| home.join(rest))
712 .unwrap_or_else(|| PathBuf::from(value.as_ref()));
713 }
714 path
715}
716
717/// Resolved application storage paths and optional compatibility-view settings.
718///
719/// Constructing this value does not create directories. Write methods and
720/// [`Self::ensure_home_symlink_view`] perform the filesystem mutations.
721///
722/// # Examples
723///
724/// ```
725/// use ailloli_ui_app_storage::{AppStorage, AppStorageMode};
726/// let storage = AppStorage::single_dir("example", "/tmp/store").resolve_with_env(|_| None)?;
727/// assert!(matches!(storage.mode(), AppStorageMode::SingleDir { .. }));
728/// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
729/// ```
730#[derive(Debug, Clone)]
731pub struct AppStorage {
732 /// Validated filename-safe application identifier.
733 app_id: AppId,
734 /// Resolution policy that produced [`Self::dirs`].
735 mode: AppStorageMode,
736 /// Fully resolved configuration, state, data, and cache directories.
737 dirs: AppStorageDirs,
738 /// Optional home-view configuration; no symlink is created by resolution.
739 home_symlink_view: HomeSymlinkView,
740 /// App-specific environment values consumed while resolving paths.
741 env_overrides: Vec<EnvOverride>,
742}
743
744impl AppStorage {
745 /// Starts an XDG-mode builder with the symlink view disabled.
746 ///
747 /// The identifier is validated only by [`AppStorageBuilder::resolve`] or
748 /// [`AppStorageBuilder::resolve_with_env`].
749 ///
750 /// # Examples
751 ///
752 /// ```
753 /// use ailloli_ui_app_storage::{AppStorage, AppStorageMode};
754 /// let storage = AppStorage::for_app("example").resolve_with_env(|_| None)?;
755 /// assert_eq!(storage.mode(), &AppStorageMode::Xdg);
756 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
757 /// ```
758 pub fn for_app(app_id: impl Into<String>) -> AppStorageBuilder {
759 AppStorageBuilder {
760 app_id: app_id.into(),
761 mode: AppStorageMode::Xdg,
762 home_symlink_view: false,
763 home_symlink_root: None,
764 }
765 }
766
767 /// Starts a builder that places all category directories below `root`.
768 ///
769 /// This is shorthand for [`Self::for_app`] followed by
770 /// [`AppStorageBuilder::single_dir`].
771 ///
772 /// # Examples
773 ///
774 /// ```
775 /// use ailloli_ui_app_storage::AppStorage;
776 /// let storage = AppStorage::single_dir("example", "/store").resolve_with_env(|_| None)?;
777 /// assert_eq!(storage.data_dir().to_string_lossy(), "/store/data");
778 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
779 /// ```
780 pub fn single_dir(app_id: impl Into<String>, root: impl Into<PathBuf>) -> AppStorageBuilder {
781 Self::for_app(app_id).single_dir(root)
782 }
783
784 /// Borrows the validated application identifier.
785 ///
786 /// # Examples
787 ///
788 /// ```
789 /// use ailloli_ui_app_storage::AppStorage;
790 /// let storage = AppStorage::single_dir("example", "/store").resolve_with_env(|_| None)?;
791 /// assert_eq!(storage.app_id().as_str(), "example");
792 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
793 /// ```
794 pub fn app_id(&self) -> &AppId {
795 &self.app_id
796 }
797
798 /// Borrows the selected resolution strategy and its root, if any.
799 ///
800 /// # Examples
801 ///
802 /// ```
803 /// use ailloli_ui_app_storage::{AppStorage, AppStorageMode};
804 /// let storage = AppStorage::single_dir("example", "/store").resolve_with_env(|_| None)?;
805 /// assert!(matches!(storage.mode(), AppStorageMode::SingleDir { .. }));
806 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
807 /// ```
808 pub fn mode(&self) -> &AppStorageMode {
809 &self.mode
810 }
811
812 /// Borrows all four resolved canonical directories.
813 ///
814 /// # Examples
815 ///
816 /// ```
817 /// use ailloli_ui_app_storage::AppStorage;
818 /// let storage = AppStorage::single_dir("example", "/store").resolve_with_env(|_| None)?;
819 /// assert_eq!(storage.dirs().config_dir.to_string_lossy(), "/store/config");
820 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
821 /// ```
822 pub fn dirs(&self) -> &AppStorageDirs {
823 &self.dirs
824 }
825
826 /// Returns the canonical configuration directory.
827 ///
828 /// # Examples
829 ///
830 /// ```
831 /// use ailloli_ui_app_storage::AppStorage;
832 /// let storage = AppStorage::single_dir("example", "/store").resolve_with_env(|_| None)?;
833 /// assert_eq!(storage.config_dir().to_string_lossy(), "/store/config");
834 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
835 /// ```
836 pub fn config_dir(&self) -> &Path {
837 &self.dirs.config_dir
838 }
839
840 /// Returns the canonical restorable-state directory.
841 ///
842 /// # Examples
843 ///
844 /// ```
845 /// use ailloli_ui_app_storage::AppStorage;
846 /// let storage = AppStorage::single_dir("example", "/store").resolve_with_env(|_| None)?;
847 /// assert_eq!(storage.state_dir().to_string_lossy(), "/store/state");
848 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
849 /// ```
850 pub fn state_dir(&self) -> &Path {
851 &self.dirs.state_dir
852 }
853
854 /// Returns the canonical durable-data directory.
855 ///
856 /// # Examples
857 ///
858 /// ```
859 /// use ailloli_ui_app_storage::AppStorage;
860 /// let storage = AppStorage::single_dir("example", "/store").resolve_with_env(|_| None)?;
861 /// assert_eq!(storage.data_dir().to_string_lossy(), "/store/data");
862 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
863 /// ```
864 pub fn data_dir(&self) -> &Path {
865 &self.dirs.data_dir
866 }
867
868 /// Returns the canonical disposable-cache directory.
869 ///
870 /// # Examples
871 ///
872 /// ```
873 /// use ailloli_ui_app_storage::AppStorage;
874 /// let storage = AppStorage::single_dir("example", "/store").resolve_with_env(|_| None)?;
875 /// assert_eq!(storage.cache_dir().to_string_lossy(), "/store/cache");
876 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
877 /// ```
878 pub fn cache_dir(&self) -> &Path {
879 &self.dirs.cache_dir
880 }
881
882 /// Returns `<config_dir>/preferences.json` without creating it.
883 ///
884 /// # Examples
885 ///
886 /// ```
887 /// use ailloli_ui_app_storage::AppStorage;
888 /// let storage = AppStorage::single_dir("example", "/store").resolve_with_env(|_| None)?;
889 /// assert_eq!(storage.preferences_file().to_string_lossy(), "/store/config/preferences.json");
890 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
891 /// ```
892 pub fn preferences_file(&self) -> PathBuf {
893 self.config_dir().join("preferences.json")
894 }
895
896 /// Returns `<state_dir>/windows.json` without creating it.
897 ///
898 /// # Examples
899 ///
900 /// ```
901 /// use ailloli_ui_app_storage::AppStorage;
902 /// let storage = AppStorage::single_dir("example", "/store").resolve_with_env(|_| None)?;
903 /// assert_eq!(storage.window_state_file().to_string_lossy(), "/store/state/windows.json");
904 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
905 /// ```
906 pub fn window_state_file(&self) -> PathBuf {
907 self.state_dir().join("windows.json")
908 }
909
910 /// Clones resolved metadata and inspects the current compatibility-view paths.
911 ///
912 /// This is read-only. The override list contains only app-specific
913 /// `<APP>_<KIND>_DIR` values that actually won resolution.
914 ///
915 /// # Examples
916 ///
917 /// ```
918 /// use ailloli_ui_app_storage::AppStorage;
919 /// let storage = AppStorage::single_dir("example", "/store").resolve_with_env(|_| None)?;
920 /// assert!(storage.diagnostics().env_overrides.is_empty());
921 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
922 /// ```
923 pub fn diagnostics(&self) -> AppStorageDiagnostics {
924 AppStorageDiagnostics {
925 app_id: self.app_id.clone(),
926 dirs: self.dirs.clone(),
927 env_overrides: self.env_overrides.clone(),
928 home_symlink_view: self.home_symlink_view_status(),
929 }
930 }
931
932 /// Inspects the four expected compatibility links without modifying them.
933 ///
934 /// Symlink targets are compared as stored paths; equivalent paths with
935 /// different textual forms are reported as collisions. Inspection errors
936 /// are represented as entry collisions rather than returned as `Err`.
937 ///
938 /// # Examples
939 ///
940 /// ```
941 /// use ailloli_ui_app_storage::{AppStorage, HomeSymlinkViewState};
942 /// let storage = AppStorage::single_dir("example", "/store").resolve_with_env(|_| None)?;
943 /// assert_eq!(storage.home_symlink_view_status().state(), HomeSymlinkViewState::Disabled);
944 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
945 /// ```
946 pub fn home_symlink_view_status(&self) -> HomeSymlinkViewStatus {
947 let entries = self
948 .symlink_entries()
949 .into_iter()
950 .map(|(name, link, target)| HomeSymlinkEntryStatus {
951 name,
952 state: inspect_symlink_entry(&link, &target),
953 link,
954 target,
955 })
956 .collect();
957 HomeSymlinkViewStatus {
958 enabled: self.home_symlink_view.enabled,
959 root: self.home_symlink_view.root.clone(),
960 entries,
961 }
962 }
963
964 /// Creates missing canonical directories and non-destructive compatibility links.
965 ///
966 /// Disabled views return their read-only status without writing. Enabled
967 /// creation is sequential and not transactional: an error can leave earlier
968 /// target directories or links in place. Existing correct links are reused;
969 /// no existing non-link or differently targeted link is replaced.
970 ///
971 /// # Errors
972 ///
973 /// Returns [`AppStorageError::SymlinkCollision`] for an incompatible root or
974 /// entry, [`AppStorageError::SymlinkUnsupported`] where directory links are
975 /// unavailable, or [`AppStorageError::Io`] for directory/link operations.
976 ///
977 /// # Platform behavior
978 ///
979 /// Uses Unix symlinks on Unix and directory symlinks on Windows. Windows may
980 /// require host policy or privileges for link creation. Other targets return
981 /// [`AppStorageError::SymlinkUnsupported`].
982 ///
983 /// # Examples
984 ///
985 /// ```
986 /// use ailloli_ui_app_storage::{AppStorage, HomeSymlinkViewState};
987 /// let storage = AppStorage::single_dir("example", "/store").resolve_with_env(|_| None)?;
988 /// assert_eq!(storage.ensure_home_symlink_view()?.state(), HomeSymlinkViewState::Disabled);
989 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
990 /// ```
991 pub fn ensure_home_symlink_view(&self) -> Result<HomeSymlinkViewStatus> {
992 if !self.home_symlink_view.enabled {
993 return Ok(self.home_symlink_view_status());
994 }
995 match fs::symlink_metadata(&self.home_symlink_view.root) {
996 Ok(metadata) if !metadata.is_dir() => {
997 return Err(AppStorageError::SymlinkCollision {
998 path: self.home_symlink_view.root.clone(),
999 reason: "root exists and is not a directory".into(),
1000 });
1001 }
1002 Ok(_) => {}
1003 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
1004 fs::create_dir_all(&self.home_symlink_view.root).map_err(|err| {
1005 AppStorageError::io(Some(self.home_symlink_view.root.clone()), err)
1006 })?;
1007 }
1008 Err(err) => {
1009 return Err(AppStorageError::io(
1010 Some(self.home_symlink_view.root.clone()),
1011 err,
1012 ));
1013 }
1014 }
1015 for (_, link, target) in self.symlink_entries() {
1016 fs::create_dir_all(&target)
1017 .map_err(|err| AppStorageError::io(Some(target.clone()), err))?;
1018 match inspect_symlink_entry(&link, &target) {
1019 HomeSymlinkEntryState::Ready => {}
1020 HomeSymlinkEntryState::Missing => create_dir_symlink(&target, &link)?,
1021 HomeSymlinkEntryState::Collision { reason } => {
1022 return Err(AppStorageError::SymlinkCollision { path: link, reason });
1023 }
1024 HomeSymlinkEntryState::Unsupported { reason } => {
1025 return Err(AppStorageError::SymlinkUnsupported(reason));
1026 }
1027 HomeSymlinkEntryState::Created => {}
1028 }
1029 }
1030 Ok(self.home_symlink_view_status())
1031 }
1032
1033 /// Reads and validates the application preferences document if present.
1034 ///
1035 /// A missing file returns `Ok(None)`. An existing document must carry
1036 /// [`APP_PREFERENCES_VERSION`]; arbitrary preference keys and JSON values
1037 /// are preserved.
1038 ///
1039 /// # Errors
1040 ///
1041 /// Returns [`AppStorageError::Io`] for read failures,
1042 /// [`AppStorageError::Json`] for invalid JSON, or
1043 /// [`AppStorageError::InvalidDocument`] for an unsupported version.
1044 ///
1045 /// # Examples
1046 ///
1047 /// ```
1048 /// use ailloli_ui_app_storage::AppStorage;
1049 /// let root = std::env::temp_dir().join(format!("ailloli-doc-missing-prefs-{}", std::process::id()));
1050 /// let storage = AppStorage::single_dir("example", root).resolve_with_env(|_| None)?;
1051 /// assert_eq!(storage.read_preferences()?, None);
1052 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
1053 /// ```
1054 pub fn read_preferences(&self) -> Result<Option<AppPreferencesDocument>> {
1055 read_optional_json(self.preferences_file()).and_then(|doc| match doc {
1056 Some(doc) => validate_preferences(doc).map(Some),
1057 None => Ok(None),
1058 })
1059 }
1060
1061 /// Pretty-prints preferences through a same-directory temp file and rename.
1062 ///
1063 /// This method does not validate [`AppPreferencesDocument::version`]; a
1064 /// mismatched version can be written but a later [`Self::read_preferences`]
1065 /// will reject it.
1066 ///
1067 /// # Errors
1068 ///
1069 /// Returns [`AppStorageError::Json`] for serialization failure,
1070 /// [`AppStorageError::InvalidDocument`] for an unusable destination name,
1071 /// or [`AppStorageError::Io`] for filesystem failures.
1072 ///
1073 /// # Examples
1074 ///
1075 /// ```
1076 /// use ailloli_ui_app_storage::{AppPreferencesDocument, AppStorage};
1077 /// let root = std::env::temp_dir().join(format!("ailloli-doc-write-prefs-{}", std::process::id()));
1078 /// let storage = AppStorage::single_dir("example", &root).resolve_with_env(|_| None)?;
1079 /// storage.write_preferences(&AppPreferencesDocument::new())?;
1080 /// assert!(storage.preferences_file().is_file());
1081 /// std::fs::remove_dir_all(root)?;
1082 /// # Ok::<(), Box<dyn std::error::Error>>(())
1083 /// ```
1084 pub fn write_preferences(&self, preferences: &AppPreferencesDocument) -> Result<()> {
1085 write_json_atomic(self.preferences_file(), preferences)
1086 }
1087
1088 /// Reads and validates the window-state document if present.
1089 ///
1090 /// A missing file returns `Ok(None)`. Validation currently checks only that
1091 /// [`WindowStateDocument::version`] equals [`WINDOW_STATE_VERSION`]; snapshot
1092 /// fields deserialized from JSON are otherwise preserved verbatim.
1093 ///
1094 /// # Errors
1095 ///
1096 /// Returns [`AppStorageError::Io`] for read failures,
1097 /// [`AppStorageError::Json`] for invalid JSON, or
1098 /// [`AppStorageError::InvalidDocument`] for an unsupported version.
1099 ///
1100 /// # Examples
1101 ///
1102 /// ```
1103 /// use ailloli_ui_app_storage::AppStorage;
1104 /// let root = std::env::temp_dir().join(format!("ailloli-doc-missing-windows-{}", std::process::id()));
1105 /// let storage = AppStorage::single_dir("example", root).resolve_with_env(|_| None)?;
1106 /// assert_eq!(storage.read_window_state()?, None);
1107 /// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
1108 /// ```
1109 pub fn read_window_state(&self) -> Result<Option<WindowStateDocument>> {
1110 read_optional_json(self.window_state_file()).and_then(|doc| match doc {
1111 Some(doc) => validate_window_state(doc).map(Some),
1112 None => Ok(None),
1113 })
1114 }
1115
1116 /// Pretty-prints window state through a same-directory temp file and rename.
1117 ///
1118 /// This method does not validate the document version or snapshot contents;
1119 /// version validation occurs when reading through [`Self::read_window_state`].
1120 ///
1121 /// # Errors
1122 ///
1123 /// Returns [`AppStorageError::Json`] for serialization failure,
1124 /// [`AppStorageError::InvalidDocument`] for an unusable destination name,
1125 /// or [`AppStorageError::Io`] for filesystem failures.
1126 ///
1127 /// # Examples
1128 ///
1129 /// ```
1130 /// use ailloli_ui_app_storage::{AppStorage, WindowStateDocument};
1131 /// let root = std::env::temp_dir().join(format!("ailloli-doc-write-windows-{}", std::process::id()));
1132 /// let storage = AppStorage::single_dir("example", &root).resolve_with_env(|_| None)?;
1133 /// storage.write_window_state(&WindowStateDocument::empty())?;
1134 /// assert!(storage.window_state_file().is_file());
1135 /// std::fs::remove_dir_all(root)?;
1136 /// # Ok::<(), Box<dyn std::error::Error>>(())
1137 /// ```
1138 pub fn write_window_state(&self, document: &WindowStateDocument) -> Result<()> {
1139 write_json_atomic(self.window_state_file(), document)
1140 }
1141
1142 /// Builds the stable category/link/target triples in diagnostic order.
1143 fn symlink_entries(&self) -> Vec<(String, PathBuf, PathBuf)> {
1144 vec![
1145 (
1146 "config".into(),
1147 self.home_symlink_view.root.join("config"),
1148 self.dirs.config_dir.clone(),
1149 ),
1150 (
1151 "state".into(),
1152 self.home_symlink_view.root.join("state"),
1153 self.dirs.state_dir.clone(),
1154 ),
1155 (
1156 "data".into(),
1157 self.home_symlink_view.root.join("data"),
1158 self.dirs.data_dir.clone(),
1159 ),
1160 (
1161 "cache".into(),
1162 self.home_symlink_view.root.join("cache"),
1163 self.dirs.cache_dir.clone(),
1164 ),
1165 ]
1166 }
1167}
1168
1169/// Inspects one path without following it and compares a symlink target verbatim.
1170fn inspect_symlink_entry(link: &Path, target: &Path) -> HomeSymlinkEntryState {
1171 match fs::symlink_metadata(link) {
1172 Ok(metadata) if metadata.file_type().is_symlink() => match fs::read_link(link) {
1173 Ok(existing) if existing == target => HomeSymlinkEntryState::Ready,
1174 Ok(existing) => HomeSymlinkEntryState::Collision {
1175 reason: format!(
1176 "points to `{}`, expected `{}`",
1177 existing.display(),
1178 target.display()
1179 ),
1180 },
1181 Err(err) => HomeSymlinkEntryState::Collision {
1182 reason: err.to_string(),
1183 },
1184 },
1185 Ok(_) => HomeSymlinkEntryState::Collision {
1186 reason: "path exists and is not a symlink".into(),
1187 },
1188 Err(err) if err.kind() == std::io::ErrorKind::NotFound => HomeSymlinkEntryState::Missing,
1189 Err(err) => HomeSymlinkEntryState::Collision {
1190 reason: err.to_string(),
1191 },
1192 }
1193}
1194
1195#[cfg(unix)]
1196/// Creates one Unix directory-target symlink without replacing an existing path.
1197///
1198/// # Errors
1199///
1200/// Returns [`AppStorageError::Io`] with `link` as path context when the native
1201/// symlink operation fails, including when the destination already exists.
1202fn create_dir_symlink(target: &Path, link: &Path) -> Result<()> {
1203 std::os::unix::fs::symlink(target, link)
1204 .map_err(|err| AppStorageError::io(Some(link.to_path_buf()), err))
1205}
1206
1207#[cfg(windows)]
1208/// Creates one Windows directory symlink without replacing an existing path.
1209///
1210/// # Errors
1211///
1212/// Returns [`AppStorageError::Io`] with `link` as path context when the native
1213/// directory-symlink operation fails, including permission and collision errors.
1214fn create_dir_symlink(target: &Path, link: &Path) -> Result<()> {
1215 std::os::windows::fs::symlink_dir(target, link)
1216 .map_err(|err| AppStorageError::io(Some(link.to_path_buf()), err))
1217}
1218
1219#[cfg(not(any(unix, windows)))]
1220/// Reports the lack of directory-symlink support on other targets.
1221///
1222/// # Errors
1223///
1224/// Always returns [`AppStorageError::SymlinkUnsupported`] because this target
1225/// has no implementation for directory symlinks.
1226fn create_dir_symlink(_target: &Path, _link: &Path) -> Result<()> {
1227 Err(AppStorageError::SymlinkUnsupported(
1228 "directory symlinks are not available on this platform".into(),
1229 ))
1230}
1231
1232/// Versioned, extensible application-preferences payload.
1233///
1234/// Values are ordered by key for deterministic in-memory traversal and JSON
1235/// serialization. Empty values are valid. Deserialization preserves any
1236/// version; [`AppStorage::read_preferences`] performs compatibility validation.
1237///
1238/// # Examples
1239///
1240/// ```
1241/// use ailloli_ui_app_storage::AppPreferencesDocument;
1242/// use serde_json::json;
1243/// let mut preferences = AppPreferencesDocument::new();
1244/// preferences.values.insert("theme".into(), json!("dark"));
1245/// assert_eq!(preferences.values["theme"], "dark");
1246/// ```
1247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1248pub struct AppPreferencesDocument {
1249 /// Persisted schema version; the current supported value is [`APP_PREFERENCES_VERSION`].
1250 pub version: u32,
1251 /// Application-defined JSON preferences; an empty map means no saved preferences.
1252 pub values: BTreeMap<String, Value>,
1253}
1254
1255impl AppPreferencesDocument {
1256 /// Creates a current-version document with no preference values.
1257 ///
1258 /// # Examples
1259 ///
1260 /// ```
1261 /// use ailloli_ui_app_storage::{AppPreferencesDocument, APP_PREFERENCES_VERSION};
1262 /// let document = AppPreferencesDocument::new();
1263 /// assert_eq!(document.version, APP_PREFERENCES_VERSION);
1264 /// assert!(document.values.is_empty());
1265 /// ```
1266 pub fn new() -> Self {
1267 Self {
1268 version: APP_PREFERENCES_VERSION,
1269 values: BTreeMap::new(),
1270 }
1271 }
1272}
1273
1274impl Default for AppPreferencesDocument {
1275 fn default() -> Self {
1276 Self::new()
1277 }
1278}
1279
1280/// Versioned set of persisted top-level window snapshots.
1281///
1282/// An empty vector is valid. Duplicate or empty window identifiers are stored;
1283/// [`Self::snapshot_for`] returns the first exact match. Deserialization
1284/// preserves any version until [`AppStorage::read_window_state`] validates it.
1285///
1286/// # Examples
1287///
1288/// ```
1289/// use ailloli_ui_app_storage::{WindowSnapshot, WindowStateDocument};
1290/// let document = WindowStateDocument::new(vec![WindowSnapshot::new("main")]);
1291/// assert!(document.snapshot_for("main").is_some());
1292/// ```
1293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1294pub struct WindowStateDocument {
1295 /// Persisted schema version; the current supported value is [`WINDOW_STATE_VERSION`].
1296 pub version: u32,
1297 /// Snapshots in caller-provided order; an empty list represents no saved windows.
1298 pub windows: Vec<WindowSnapshot>,
1299}
1300
1301impl WindowStateDocument {
1302 /// Creates a current-version document from snapshots stored verbatim.
1303 ///
1304 /// # Examples
1305 ///
1306 /// ```
1307 /// use ailloli_ui_app_storage::{WindowSnapshot, WindowStateDocument};
1308 /// let document = WindowStateDocument::new(vec![WindowSnapshot::new("main")]);
1309 /// assert_eq!(document.windows.len(), 1);
1310 /// ```
1311 pub fn new(windows: Vec<WindowSnapshot>) -> Self {
1312 Self {
1313 version: WINDOW_STATE_VERSION,
1314 windows,
1315 }
1316 }
1317
1318 /// Creates a current-version document with no snapshots.
1319 ///
1320 /// # Examples
1321 ///
1322 /// ```
1323 /// use ailloli_ui_app_storage::WindowStateDocument;
1324 /// assert!(WindowStateDocument::empty().windows.is_empty());
1325 /// ```
1326 pub fn empty() -> Self {
1327 Self::new(Vec::new())
1328 }
1329
1330 /// Returns the first snapshot whose identifier exactly equals `window_id`.
1331 ///
1332 /// Returns `None` for no match, including when the document is empty. The
1333 /// lookup is linear in the number of windows.
1334 ///
1335 /// # Examples
1336 ///
1337 /// ```
1338 /// use ailloli_ui_app_storage::{WindowSnapshot, WindowStateDocument};
1339 /// let document = WindowStateDocument::new(vec![WindowSnapshot::new("main")]);
1340 /// assert_eq!(document.snapshot_for("missing"), None);
1341 /// ```
1342 pub fn snapshot_for(&self, window_id: &str) -> Option<&WindowSnapshot> {
1343 self.windows
1344 .iter()
1345 .find(|snapshot| snapshot.window_id == window_id)
1346 }
1347}
1348
1349impl Default for WindowStateDocument {
1350 fn default() -> Self {
1351 Self::empty()
1352 }
1353}
1354
1355/// Restorable state for one logical top-level window.
1356///
1357/// The identifier may be empty and is not normalized. `None` size/position
1358/// means that geometry was not saved and the platform should choose it.
1359///
1360/// # Examples
1361///
1362/// ```
1363/// use ailloli_ui_app_storage::WindowSnapshot;
1364/// let snapshot = WindowSnapshot::new("main");
1365/// assert_eq!(snapshot.inner_size, None);
1366/// assert!(!snapshot.maximized && !snapshot.fullscreen);
1367/// ```
1368#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1369pub struct WindowSnapshot {
1370 /// Consumer-defined logical window identifier, matched exactly.
1371 pub window_id: String,
1372 /// Saved inner content size in logical pixels, or `None` to use platform defaults.
1373 pub inner_size: Option<LogicalWindowSize>,
1374 /// Whether the window should be restored maximized.
1375 pub maximized: bool,
1376 /// Whether the window should be restored fullscreen.
1377 pub fullscreen: bool,
1378 /// Saved logical desktop position, or `None` to let the platform place it.
1379 pub position: Option<LogicalWindowPosition>,
1380}
1381
1382impl WindowSnapshot {
1383 /// Creates a normally positioned snapshot with no saved geometry.
1384 ///
1385 /// `window_id` is stored verbatim, including an empty string.
1386 ///
1387 /// # Examples
1388 ///
1389 /// ```
1390 /// use ailloli_ui_app_storage::WindowSnapshot;
1391 /// let snapshot = WindowSnapshot::new("main");
1392 /// assert_eq!(snapshot.window_id, "main");
1393 /// assert_eq!(snapshot.position, None);
1394 /// ```
1395 pub fn new(window_id: impl Into<String>) -> Self {
1396 Self {
1397 window_id: window_id.into(),
1398 inner_size: None,
1399 maximized: false,
1400 fullscreen: false,
1401 position: None,
1402 }
1403 }
1404}
1405
1406/// Window inner size in logical pixels.
1407///
1408/// [`Self::new`] floors ordinary finite components, NaN, and negative infinity
1409/// at `1.0`; positive infinity remains infinite. Public fields and derived
1410/// deserialization can represent values that bypass that normalization.
1411///
1412/// # Examples
1413///
1414/// ```
1415/// use ailloli_ui_app_storage::LogicalWindowSize;
1416/// assert_eq!(LogicalWindowSize::new(0.0, 480.0), LogicalWindowSize { width: 1.0, height: 480.0 });
1417/// ```
1418#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1419pub struct LogicalWindowSize {
1420 /// Inner width in logical pixels.
1421 pub width: f64,
1422 /// Inner height in logical pixels.
1423 pub height: f64,
1424}
1425
1426impl LogicalWindowSize {
1427 /// Creates a size with each component floored at one logical pixel.
1428 ///
1429 /// Floating-point `max` maps NaN and negative infinity to `1.0`, while
1430 /// positive infinity remains infinite.
1431 ///
1432 /// # Examples
1433 ///
1434 /// ```
1435 /// use ailloli_ui_app_storage::LogicalWindowSize;
1436 /// assert_eq!(LogicalWindowSize::new(-10.0, f64::NAN), LogicalWindowSize::new(1.0, 1.0));
1437 /// ```
1438 pub fn new(width: f64, height: f64) -> Self {
1439 Self {
1440 width: width.max(1.0),
1441 height: height.max(1.0),
1442 }
1443 }
1444}
1445
1446/// Window position in logical desktop coordinates.
1447///
1448/// Negative, fractional, and non-finite coordinates are stored verbatim.
1449///
1450/// # Examples
1451///
1452/// ```
1453/// use ailloli_ui_app_storage::LogicalWindowPosition;
1454/// assert_eq!(LogicalWindowPosition::new(-20.5, 10.0).x, -20.5);
1455/// ```
1456#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1457pub struct LogicalWindowPosition {
1458 /// Horizontal logical desktop coordinate.
1459 pub x: f64,
1460 /// Vertical logical desktop coordinate.
1461 pub y: f64,
1462}
1463
1464impl LogicalWindowPosition {
1465 /// Stores a logical position without validation or normalization.
1466 ///
1467 /// # Examples
1468 ///
1469 /// ```
1470 /// use ailloli_ui_app_storage::LogicalWindowPosition;
1471 /// assert_eq!(LogicalWindowPosition::new(10.0, 20.0), LogicalWindowPosition { x: 10.0, y: 20.0 });
1472 /// ```
1473 pub fn new(x: f64, y: f64) -> Self {
1474 Self { x, y }
1475 }
1476}
1477
1478/// Accepts only the current preferences schema version, preserving all values.
1479///
1480/// # Errors
1481///
1482/// Returns [`AppStorageError::InvalidDocument`] when `document.version` differs
1483/// from [`APP_PREFERENCES_VERSION`].
1484fn validate_preferences(document: AppPreferencesDocument) -> Result<AppPreferencesDocument> {
1485 if document.version != APP_PREFERENCES_VERSION {
1486 return Err(AppStorageError::InvalidDocument(format!(
1487 "unsupported preferences version {}",
1488 document.version
1489 )));
1490 }
1491 Ok(document)
1492}
1493
1494/// Accepts only the current window-state version without validating snapshots.
1495///
1496/// # Errors
1497///
1498/// Returns [`AppStorageError::InvalidDocument`] when `document.version` differs
1499/// from [`WINDOW_STATE_VERSION`].
1500fn validate_window_state(document: WindowStateDocument) -> Result<WindowStateDocument> {
1501 if document.version != WINDOW_STATE_VERSION {
1502 return Err(AppStorageError::InvalidDocument(format!(
1503 "unsupported window state version {}",
1504 document.version
1505 )));
1506 }
1507 Ok(document)
1508}
1509
1510/// Deserializes an optional UTF-8 or binary JSON file into `T`.
1511///
1512/// A missing path returns `Ok(None)`; an existing JSON `null` is still
1513/// `Some(T)` when `T` accepts it. The whole file is read into memory.
1514///
1515/// # Errors
1516///
1517/// Returns [`AppStorageError::Io`] for filesystem failures other than not found,
1518/// or [`AppStorageError::Json`] when the bytes do not deserialize as `T`.
1519///
1520/// # Examples
1521///
1522/// ```
1523/// use ailloli_ui_app_storage::read_optional_json;
1524/// use serde_json::Value;
1525/// let path = std::env::temp_dir().join(format!("ailloli-doc-missing-json-{}", std::process::id()));
1526/// let value: Option<Value> = read_optional_json(path)?;
1527/// assert_eq!(value, None);
1528/// # Ok::<(), ailloli_ui_app_storage::AppStorageError>(())
1529/// ```
1530pub fn read_optional_json<T>(path: impl AsRef<Path>) -> Result<Option<T>>
1531where
1532 T: for<'de> Deserialize<'de>,
1533{
1534 let path = path.as_ref();
1535 let bytes = match fs::read(path) {
1536 Ok(bytes) => bytes,
1537 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1538 Err(err) => return Err(AppStorageError::io(Some(path.to_path_buf()), err)),
1539 };
1540 Ok(Some(serde_json::from_slice(&bytes)?))
1541}
1542
1543/// Pretty-serializes `value` and writes it through a same-directory temp file.
1544///
1545/// The complete JSON representation is buffered in memory before writing. See
1546/// [`atomic_write_bytes`] for rename, concurrency, and durability semantics.
1547///
1548/// # Errors
1549///
1550/// Returns [`AppStorageError::Json`] when serialization fails, or propagates
1551/// [`atomic_write_bytes`] destination and filesystem errors.
1552///
1553/// # Examples
1554///
1555/// ```
1556/// use ailloli_ui_app_storage::write_json_atomic;
1557/// use serde_json::json;
1558/// let path = std::env::temp_dir().join(format!("ailloli-doc-json-{}.json", std::process::id()));
1559/// write_json_atomic(&path, &json!({"ready": true}))?;
1560/// assert!(std::fs::read_to_string(&path)?.contains("ready"));
1561/// std::fs::remove_file(path)?;
1562/// # Ok::<(), Box<dyn std::error::Error>>(())
1563/// ```
1564pub fn write_json_atomic<T>(path: impl AsRef<Path>, value: &T) -> Result<()>
1565where
1566 T: Serialize,
1567{
1568 let bytes = serde_json::to_vec_pretty(value)?;
1569 atomic_write_bytes(path.as_ref(), &bytes)
1570}
1571
1572/// Writes bytes to `<file-name>.tmp`, syncs them, then renames over `path`.
1573///
1574/// Missing parent directories are created. The temp name is deterministic, so
1575/// concurrent writers to the same destination race with one another. A failed
1576/// operation can leave the temp file behind. File contents are synced before
1577/// rename; opening and syncing the parent directory is best effort and its
1578/// errors are ignored. Destination replacement and rename atomicity follow the
1579/// host filesystem and platform; permissions of an older destination are not
1580/// preserved on the newly created file.
1581///
1582/// # Errors
1583///
1584/// Returns [`AppStorageError::InvalidDocument`] if the destination has no
1585/// UTF-8 file name, or [`AppStorageError::Io`] if parent creation, temp-file
1586/// creation/write/sync, or rename fails.
1587///
1588/// # Examples
1589///
1590/// ```
1591/// use ailloli_ui_app_storage::atomic_write_bytes;
1592/// let path = std::env::temp_dir().join(format!("ailloli-doc-bytes-{}.bin", std::process::id()));
1593/// atomic_write_bytes(&path, b"saved")?;
1594/// assert_eq!(std::fs::read(&path)?, b"saved");
1595/// std::fs::remove_file(path)?;
1596/// # Ok::<(), Box<dyn std::error::Error>>(())
1597/// ```
1598pub fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
1599 if let Some(parent) = path.parent() {
1600 fs::create_dir_all(parent)
1601 .map_err(|err| AppStorageError::io(Some(parent.to_path_buf()), err))?;
1602 }
1603 let tmp_path = temp_path_for(path)?;
1604 {
1605 let mut file = File::create(&tmp_path)
1606 .map_err(|err| AppStorageError::io(Some(tmp_path.clone()), err))?;
1607 file.write_all(bytes)
1608 .map_err(|err| AppStorageError::io(Some(tmp_path.clone()), err))?;
1609 file.sync_all()
1610 .map_err(|err| AppStorageError::io(Some(tmp_path.clone()), err))?;
1611 }
1612 fs::rename(&tmp_path, path)
1613 .map_err(|err| AppStorageError::io(Some(path.to_path_buf()), err))?;
1614 if let Some(parent) = path.parent() {
1615 if let Ok(dir) = File::open(parent) {
1616 let _ = dir.sync_all();
1617 }
1618 }
1619 Ok(())
1620}
1621
1622/// Derives the deterministic sibling temp path and requires a UTF-8 file name.
1623///
1624/// # Errors
1625///
1626/// Returns [`AppStorageError::InvalidDocument`] when `path` has no final file
1627/// name or that name is not valid UTF-8.
1628fn temp_path_for(path: &Path) -> Result<PathBuf> {
1629 let file_name = path
1630 .file_name()
1631 .and_then(|value| value.to_str())
1632 .ok_or_else(|| AppStorageError::InvalidDocument(path.display().to_string()))?;
1633 Ok(path.with_file_name(format!("{file_name}.tmp")))
1634}
1635
1636#[cfg(test)]
1637mod tests {
1638 //! Covers identifier validation, path precedence, non-destructive symlinks,
1639 //! collision reporting, and persisted-document round trips.
1640
1641 use super::*;
1642 use std::time::{SystemTime, UNIX_EPOCH};
1643
1644 /// Creates a process/time-namespaced temporary directory for one test.
1645 fn temp_dir(name: &str) -> PathBuf {
1646 let nanos = SystemTime::now()
1647 .duration_since(UNIX_EPOCH)
1648 .expect("clock")
1649 .as_nanos();
1650 let dir = std::env::temp_dir().join(format!(
1651 "ailloli_ui_app_storage_{name}_{}_{}",
1652 std::process::id(),
1653 nanos
1654 ));
1655 fs::create_dir_all(&dir).expect("create temp dir");
1656 dir
1657 }
1658
1659 #[test]
1660 fn app_id_validation_is_filename_safe() {
1661 assert_eq!(AppId::new("my-app").unwrap().env_prefix(), "MY_APP");
1662 assert!(AppId::new(".my-app").is_err());
1663 assert!(AppId::new("MyApp").is_err());
1664 assert!(AppId::new("my/app").is_err());
1665 assert!(AppId::new("my--app").is_err());
1666 }
1667
1668 #[test]
1669 fn xdg_dirs_use_expected_fallbacks() {
1670 let home = temp_dir("home");
1671 let storage = AppStorage::for_app("my-app")
1672 .resolve_with_env(|key| {
1673 if key == "HOME" {
1674 Some(home.display().to_string())
1675 } else {
1676 None
1677 }
1678 })
1679 .unwrap();
1680 assert_eq!(storage.config_dir(), home.join(".config/my-app"));
1681 assert_eq!(storage.state_dir(), home.join(".local/state/my-app"));
1682 assert_eq!(storage.data_dir(), home.join(".local/share/my-app"));
1683 assert_eq!(storage.cache_dir(), home.join(".cache/my-app"));
1684 }
1685
1686 #[test]
1687 fn env_overrides_take_priority() {
1688 let storage = AppStorage::for_app("my-app")
1689 .resolve_with_env(|key| match key {
1690 "MY_APP_STATE_DIR" => Some("/override/state".into()),
1691 "XDG_STATE_HOME" => Some("/xdg/state".into()),
1692 "HOME" => Some("/tmp/ailloli_ui_home".into()),
1693 _ => None,
1694 })
1695 .unwrap();
1696 assert_eq!(storage.state_dir(), Path::new("/override/state"));
1697 assert_eq!(
1698 storage.diagnostics().env_overrides[0].key,
1699 "MY_APP_STATE_DIR"
1700 );
1701 }
1702
1703 #[test]
1704 fn single_dir_mode_uses_explicit_root() {
1705 let root = temp_dir("single");
1706 let storage = AppStorage::single_dir("my-app", root.join("store"))
1707 .resolve_with_env(|_| None)
1708 .unwrap();
1709 assert_eq!(storage.config_dir(), root.join("store/config"));
1710 assert_eq!(storage.state_dir(), root.join("store/state"));
1711 }
1712
1713 #[cfg(any(unix, windows))]
1714 #[test]
1715 fn symlink_view_creates_non_destructive_links() {
1716 let home = temp_dir("symlink_home");
1717 let storage = AppStorage::for_app("my-app")
1718 .home_symlink_view(true)
1719 .resolve_with_env(|key| {
1720 if key == "HOME" {
1721 Some(home.display().to_string())
1722 } else {
1723 None
1724 }
1725 })
1726 .unwrap();
1727 let status = storage.ensure_home_symlink_view().unwrap();
1728 assert_eq!(status.state(), HomeSymlinkViewState::Ready);
1729 assert_eq!(
1730 fs::read_link(home.join(".my-app/state")).unwrap(),
1731 home.join(".local/state/my-app")
1732 );
1733 }
1734
1735 #[test]
1736 fn symlink_view_reports_collisions() {
1737 let home = temp_dir("symlink_collision");
1738 fs::create_dir_all(home.join(".my-app")).unwrap();
1739 fs::write(home.join(".my-app/config"), "not a symlink").unwrap();
1740 let storage = AppStorage::for_app("my-app")
1741 .home_symlink_view(true)
1742 .resolve_with_env(|key| {
1743 if key == "HOME" {
1744 Some(home.display().to_string())
1745 } else {
1746 None
1747 }
1748 })
1749 .unwrap();
1750 assert!(matches!(
1751 storage.ensure_home_symlink_view(),
1752 Err(AppStorageError::SymlinkCollision { .. })
1753 ));
1754 }
1755
1756 #[test]
1757 fn preferences_and_window_state_roundtrip() {
1758 let home = temp_dir("docs");
1759 let storage = AppStorage::for_app("my-app")
1760 .resolve_with_env(|key| {
1761 if key == "HOME" {
1762 Some(home.display().to_string())
1763 } else {
1764 None
1765 }
1766 })
1767 .unwrap();
1768 let mut prefs = AppPreferencesDocument::new();
1769 prefs
1770 .values
1771 .insert("theme".into(), Value::String("dark".into()));
1772 storage.write_preferences(&prefs).unwrap();
1773 assert_eq!(storage.read_preferences().unwrap(), Some(prefs));
1774
1775 let mut snapshot = WindowSnapshot::new("main");
1776 snapshot.inner_size = Some(LogicalWindowSize::new(800.0, 600.0));
1777 snapshot.maximized = true;
1778 let windows = WindowStateDocument::new(vec![snapshot.clone()]);
1779 storage.write_window_state(&windows).unwrap();
1780 let read = storage.read_window_state().unwrap().unwrap();
1781 assert_eq!(read.snapshot_for("main"), Some(&snapshot));
1782 }
1783}