1use 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#[derive(Debug, Clone)]
36pub struct Paths {
37 pub vault: PathBuf,
38 pub index_dir: PathBuf,
39}
40
41impl Paths {
42 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 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 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 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
128fn 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
147pub 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
156fn 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}