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}