Skip to main content

oximemo_core/
paths.rs

1//! Filesystem layout for a vault and its derived index (§5.1, §5.2).
2//!
3//! Layout:
4//! ```text
5//! <vault>/
6//! ├── memos/<YYYY>/<MM>/<id>.md
7//! ├── assets/<blake3hex>.<ext>   # images referenced as oximg://<name>
8//! ├── .trash/<id>.md
9//! └── config.toml
10//! <app_support>/index/
11//!     ├── meta.redb
12//!     ├── meta.redb.lock
13//!     ├── search/
14//!     └── by-vault/<hash>/   # only for custom `--vault` paths
15//! ```
16
17use std::path::{Path, PathBuf};
18use time::{Month, OffsetDateTime};
19
20use crate::memo::MemoId;
21
22pub const APP_SUPPORT_SUBDIR: &str = "com.oximemo.app";
23pub const VAULT_DEFAULT_SUBDIR: &str = "vault";
24pub const INDEX_SUBDIR: &str = "index";
25pub const META_DB_NAME: &str = "meta.redb";
26pub const META_LOCK_NAME: &str = "meta.redb.lock";
27pub const SEARCH_SUBDIR: &str = "search";
28pub const MEMOS_DIR: &str = "memos";
29pub const TRASH_DIR: &str = ".trash";
30pub const ASSETS_DIR: &str = "assets";
31pub const BY_VAULT_SUBDIR: &str = "by-vault";
32pub const CONFIG_NAME: &str = "config.toml";
33
34/// Resolved filesystem locations for one vault.
35#[derive(Debug, Clone)]
36pub struct Paths {
37    pub vault: PathBuf,
38    pub index_dir: PathBuf,
39}
40
41impl Paths {
42    /// Resolve paths for a vault root.
43    ///
44    /// The derived index **always** lives under application support, never
45    /// inside the vault. This honors §15's hard rule: a vault placed inside an
46    /// iCloud Drive folder would otherwise sync the binary index files and
47    /// corrupt them across devices. For the default vault the index sits at the
48    /// documented `…/index/` location (§5.1); a custom (`--vault`) vault is
49    /// namespaced under `…/index/by-vault/<hash>/` so distinct vaults never
50    /// share an index.
51    pub fn resolve(vault: Option<&Path>) -> Self {
52        let support = app_support_dir();
53        match vault {
54            None => {
55                let vault = support.join(VAULT_DEFAULT_SUBDIR);
56                let index_dir = support.join(INDEX_SUBDIR);
57                Self { vault, index_dir }
58            }
59            Some(v) => {
60                let index_dir = support
61                    .join(INDEX_SUBDIR)
62                    .join(BY_VAULT_SUBDIR)
63                    .join(vault_namespace(v));
64                Self {
65                    vault: v.to_path_buf(),
66                    index_dir,
67                }
68            }
69        }
70    }
71
72    pub fn memos_root(&self) -> PathBuf {
73        self.vault.join(MEMOS_DIR)
74    }
75
76    pub fn trash_root(&self) -> PathBuf {
77        self.vault.join(TRASH_DIR)
78    }
79
80    pub fn assets_root(&self) -> PathBuf {
81        self.vault.join(ASSETS_DIR)
82    }
83
84    /// Path of a single asset by its `<hash>.<ext>` name. Caller is
85    /// responsible for validating `name` (see `assets::valid_name`); this
86    /// only joins it.
87    pub fn asset_path(&self, name: &str) -> PathBuf {
88        self.assets_root().join(name)
89    }
90
91    pub fn config_path(&self) -> PathBuf {
92        self.vault.join(CONFIG_NAME)
93    }
94
95    pub fn meta_db_path(&self) -> PathBuf {
96        self.index_dir.join(META_DB_NAME)
97    }
98
99    pub fn meta_lock_path(&self) -> PathBuf {
100        self.index_dir.join(META_LOCK_NAME)
101    }
102
103    /// Marker file recording the indexed preview format version. Its absence
104    /// (or a stale version) triggers a one-time reindex on startup so cached
105    /// previews are regenerated after `make_preview` changes.
106    pub fn index_fmt_marker_path(&self) -> PathBuf {
107        self.index_dir.join("index-fmt")
108    }
109
110    pub fn search_dir(&self) -> PathBuf {
111        self.index_dir.join(SEARCH_SUBDIR)
112    }
113
114    /// Where a live note's file lives, sharded by creation year/month.
115    pub fn memo_path(&self, id: MemoId, created_at: OffsetDateTime) -> PathBuf {
116        let (year, month) = shard(created_at);
117        self.memos_root()
118            .join(year.to_string())
119            .join(month)
120            .join(format!("{}.md", id))
121    }
122
123    pub fn trash_path(&self, id: MemoId) -> PathBuf {
124        self.trash_root().join(format!("{}.md", id))
125    }
126}
127
128/// Year + zero-padded 2-digit month for directory sharding.
129fn shard(t: OffsetDateTime) -> (i32, String) {
130    let month = match t.month() {
131        Month::January => "01",
132        Month::February => "02",
133        Month::March => "03",
134        Month::April => "04",
135        Month::May => "05",
136        Month::June => "06",
137        Month::July => "07",
138        Month::August => "08",
139        Month::September => "09",
140        Month::October => "10",
141        Month::November => "11",
142        Month::December => "12",
143    };
144    (t.year(), month.to_string())
145}
146
147/// `~/Library/Application Support/com.oximemo.app` (macOS default).
148pub fn app_support_dir() -> PathBuf {
149    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
150    PathBuf::from(home)
151        .join("Library")
152        .join("Application Support")
153        .join(APP_SUPPORT_SUBDIR)
154}
155
156/// Stable, collision-resistant namespace for a custom vault's index dir.
157///
158/// Derived from the absolute path so the same vault always maps to the same
159/// index even if the (possibly non-existent) dir is referenced by a relative
160/// path. Truncated to 16 hex chars: enough entropy, human-scannable.
161fn vault_namespace(vault: &Path) -> String {
162    let abs = if vault.is_absolute() {
163        vault.to_path_buf()
164    } else {
165        std::env::current_dir().unwrap_or_default().join(vault)
166    };
167    let mut hasher = blake3::Hasher::new();
168    hasher.update(abs.to_string_lossy().as_bytes());
169    let hex = hasher.finalize().to_hex();
170    hex.as_str()[..16].to_string()
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn custom_vault_index_lives_outside_vault() {
179        let p = Paths::resolve(Some(Path::new("/tmp/some-vault")));
180        assert!(
181            !p.index_dir.starts_with(&p.vault),
182            "index must not be inside the vault"
183        );
184        assert!(p.index_dir.starts_with(app_support_dir()));
185    }
186
187    #[test]
188    fn default_vault_uses_documented_index_layout() {
189        let p = Paths::resolve(None);
190        assert_eq!(p.index_dir, app_support_dir().join(INDEX_SUBDIR));
191    }
192
193    #[test]
194    fn distinct_custom_vaults_get_distinct_indexes() {
195        let a = Paths::resolve(Some(Path::new("/tmp/vault-a")));
196        let b = Paths::resolve(Some(Path::new("/tmp/vault-b")));
197        assert_ne!(a.index_dir, b.index_dir);
198    }
199}