Skip to main content

archelon_core/
journal.rs

1use std::path::{Path, PathBuf};
2
3use caretta_id::CarettaId;
4use chrono::Datelike as _;
5use indexmap::IndexMap;
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::error::{Error, Result};
10
11const ARCHELON_DIR: &str = ".archelon";
12
13/// A located journal — a directory tree that contains a `.archelon` directory.
14#[derive(Debug, Clone)]
15pub struct Journal {
16    /// The directory that directly contains `.archelon/`.
17    pub root: PathBuf,
18}
19
20impl Journal {
21    /// Create a `Journal` from an explicit root path.
22    ///
23    /// Returns `Err(Error::JournalNotFound)` if `root` does not contain a `.archelon` directory.
24    pub fn from_root(root: PathBuf) -> Result<Self> {
25        if root.join(ARCHELON_DIR).is_dir() {
26            Ok(Journal { root })
27        } else {
28            Err(Error::JournalNotFound)
29        }
30    }
31
32    /// Walk up from `start` until a directory containing `.archelon/` is found.
33    ///
34    /// Returns `Err(Error::JournalNotFound)` if no such directory exists.
35    pub fn find_from(start: &Path) -> Result<Self> {
36        let mut current = start.to_path_buf();
37        loop {
38            if current.join(ARCHELON_DIR).is_dir() {
39                return Ok(Journal { root: current });
40            }
41            if !current.pop() {
42                return Err(Error::JournalNotFound);
43            }
44        }
45    }
46
47    /// Walk up from the current working directory.
48    pub fn find() -> Result<Self> {
49        let cwd = std::env::current_dir()?;
50        Self::find_from(&cwd)
51    }
52
53    /// Path to the `.archelon` directory itself.
54    pub fn archelon_dir(&self) -> PathBuf {
55        self.root.join(ARCHELON_DIR)
56    }
57
58    /// Path to the directory that directly contains year subdirectories.
59    ///
60    /// Returns `root.join(entries_dir)` when `entries_dir` is configured in
61    /// `.archelon/config.toml`, otherwise returns `root` itself.
62    pub fn entries_root(&self) -> Result<PathBuf> {
63        let config = self.config()?;
64        Ok(match config.journal.entries_dir {
65            Some(ref dir) if !dir.is_empty() => self.root.join(dir),
66            _ => self.root.clone(),
67        })
68    }
69
70    /// Read the journal config from `.archelon/config.toml`.
71    /// Returns the default config if the file does not exist.
72    pub fn config(&self) -> Result<JournalConfig> {
73        let path = self.archelon_dir().join("config.toml");
74        if !path.exists() {
75            return Ok(JournalConfig::default());
76        }
77        let contents = std::fs::read_to_string(&path)?;
78        toml::from_str(&contents).map_err(|e| Error::InvalidConfig(e.to_string()))
79    }
80
81    /// Find a single `.md` entry file whose stem starts with `id_prefix`.
82    ///
83    /// Scans `self.root` and all direct year subdirectories.
84    /// Returns `Err(EntryNotFound)` if nothing matches, or `Err(AmbiguousId)`
85    /// if more than one file matches.
86    pub fn find_entry_by_id(&self, id_prefix: &str) -> Result<PathBuf> {
87        let mut matches = Vec::new();
88        for dir in std::iter::once(self.entries_root()?).chain(self.year_subdirs()?) {
89            let Ok(rd) = std::fs::read_dir(&dir) else { continue };
90            for entry in rd.filter_map(|e| e.ok()) {
91                let p = entry.path();
92                if p.extension().and_then(|s| s.to_str()) == Some("md")
93                    && p.file_stem()
94                        .and_then(|s| s.to_str())
95                        .is_some_and(|stem| stem.starts_with(id_prefix))
96                {
97                    matches.push(p);
98                }
99            }
100        }
101
102        match matches.len() {
103            0 => Err(Error::EntryNotFound(id_prefix.to_owned())),
104            1 => Ok(matches.remove(0)),
105            n => Err(Error::AmbiguousId(id_prefix.to_owned(), n)),
106        }
107    }
108
109    /// Collect all `.md` entry files in the journal: root + year subdirectories.
110    pub fn collect_entries(&self) -> Result<Vec<PathBuf>> {
111        let entries_root = self.entries_root()?;
112        let mut paths = Vec::new();
113        collect_md_in(&entries_root, &mut paths)?;
114        for subdir in self.year_subdirs()? {
115            collect_md_in(&subdir, &mut paths)?;
116        }
117        paths.sort();
118        Ok(paths)
119    }
120
121    fn year_subdirs(&self) -> Result<Vec<PathBuf>> {
122        let root = self.entries_root()?;
123        let mut dirs = Vec::new();
124        for entry in std::fs::read_dir(&root)?.filter_map(|e| e.ok()) {
125            let p = entry.path();
126            if p.is_dir() {
127                if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
128                    if !name.starts_with('.') && name.chars().all(|c| c.is_ascii_digit()) {
129                        dirs.push(p);
130                    }
131                }
132            }
133        }
134        Ok(dirs)
135    }
136
137    /// Return the stable journal ID, generating and persisting it if not yet set.
138    pub fn journal_id(&self) -> Result<Uuid> {
139        let config = self.config()?;
140        if let Some(id) = config.journal.id {
141            return Ok(id);
142        }
143        let id = Uuid::new_v4();
144        self.save_journal_id(id)?;
145        Ok(id)
146    }
147
148    fn save_journal_id(&self, id: Uuid) -> Result<()> {
149        let mut config = self.config()?;
150        config.journal.id = Some(id);
151        let path = self.archelon_dir().join("config.toml");
152        let content = toml::to_string_pretty(&config)
153            .map_err(|e| Error::InvalidConfig(e.to_string()))?;
154        std::fs::write(&path, content)?;
155        Ok(())
156    }
157
158    /// Machine-local cache directory for this journal.
159    ///
160    /// Resolves to `$XDG_CACHE_HOME/archelon/{journal_id}/`
161    /// (or `~/.cache/archelon/...` when `XDG_CACHE_HOME` is not set).
162    /// This directory is intentionally outside the journal directory so it is
163    /// never synced by git, Syncthing, or Nextcloud.
164    ///
165    /// Individual cache files within this directory are named with their schema
166    /// version (e.g. `cache_v2.db`) so that old data survives schema upgrades
167    /// until explicitly removed with `archelon cache clean`.
168    pub fn cache_dir(&self) -> Result<PathBuf> {
169        let id = self.journal_id()?;
170        Ok(xdg_cache_home().join("archelon").join(id.to_string()))
171    }
172
173}
174
175fn xdg_cache_home() -> PathBuf {
176    if let Ok(dir) = std::env::var("XDG_CACHE_HOME") {
177        if !dir.is_empty() {
178            return PathBuf::from(dir);
179        }
180    }
181    if let Ok(home) = std::env::var("HOME") {
182        return PathBuf::from(home).join(".cache");
183    }
184    std::env::temp_dir()
185}
186
187fn collect_md_in(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
188    let Ok(rd) = std::fs::read_dir(dir) else { return Ok(()) };
189    for entry in rd.filter_map(|e| e.ok()) {
190        let p = entry.path();
191        if p.extension().and_then(|s| s.to_str()) == Some("md") {
192            out.push(p);
193        }
194    }
195    Ok(())
196}
197
198// ── config ────────────────────────────────────────────────────────────────────
199
200/// Contents of `.archelon/config.toml`.
201#[derive(Debug, Clone, Serialize, Deserialize, Default)]
202pub struct JournalConfig {
203    #[serde(default)]
204    pub journal: JournalSection,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct JournalSection {
209    /// First day of the week, used by `--this-week`. Defaults to `monday`.
210    #[serde(default)]
211    pub week_start: WeekStart,
212
213    /// Stable identifier for this journal, used to locate the machine-local
214    /// SQLite cache at `$XDG_CACHE_HOME/archelon/{id}/cache.db`.
215    /// Generated on first cache access and stored here so the cache survives
216    /// directory moves and is never synced by git/Syncthing/Nextcloud.
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub id: Option<Uuid>,
219
220    /// What to do when two entries share the same title during cache sync.
221    ///
222    /// - `allow` (default): duplicates are silently permitted.
223    /// - `warn`: a warning is printed to stderr but sync succeeds.
224    /// - `error`: sync fails immediately with [`Error::DuplicateTitle`].
225    #[serde(default)]
226    pub duplicate_title: DuplicateTitlePolicy,
227
228    /// Sub-directory (relative to the journal root) where year directories are
229    /// created.  When unset, year directories are placed directly under the
230    /// journal root.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub entries_dir: Option<String>,
233
234    /// Unknown fields preserved for round-trip compatibility.
235    #[serde(flatten)]
236    pub extra: IndexMap<String, toml::Value>,
237}
238
239impl Default for JournalSection {
240    fn default() -> Self {
241        JournalSection {
242            week_start: WeekStart::Monday,
243            id: None,
244            duplicate_title: DuplicateTitlePolicy::default(),
245            entries_dir: None,
246            extra: IndexMap::new(),
247        }
248    }
249}
250
251/// Controls how duplicate entry titles are treated during cache sync.
252#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
253#[serde(rename_all = "snake_case")]
254pub enum DuplicateTitlePolicy {
255    /// Duplicates are silently allowed.
256    Allow,
257    /// Print a warning to stderr for each duplicate title, but continue.
258    #[default]
259    Warn,
260    /// Abort sync with [`Error::DuplicateTitle`] on the first duplicate found.
261    Error,
262}
263
264/// First day of the week for `--this-week` calculations.
265#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
266#[serde(rename_all = "lowercase")]
267pub enum WeekStart {
268    #[default]
269    Monday,
270    Sunday,
271}
272
273// ── filename helpers ──────────────────────────────────────────────────────────
274
275/// Convert a title to a filename-safe slug.
276///
277/// Lowercases the string, replaces whitespace with `_`, and strips any
278/// character that is not ASCII alphanumeric or `_`.
279///
280/// ```
281/// # use archelon_core::journal::slugify;
282/// assert_eq!(slugify("My Example Entry!"), "my_example_entry");
283/// ```
284pub fn slugify(title: &str) -> String {
285    title
286        .chars()
287        .map(|c| match c {
288            c if c.is_whitespace() => '_',
289            c if c.is_ascii_uppercase() => c.to_ascii_lowercase(),
290            c if c.is_ascii_alphanumeric() => c,
291            '-' | '_' | '.' => c,
292            c if !c.is_ascii() => c,
293            _ => '_',
294        })
295        .collect::<String>()
296        .split('_')
297        .filter(|s| !s.is_empty())
298        .collect::<Vec<_>>()
299        .join("_")
300}
301
302/// Build the canonical entry filename: `{id}_{slug}.md`.
303///
304/// If the slug is empty the filename is just `{id}.md`.
305pub fn entry_filename(id: CarettaId, title: &str) -> String {
306    let slug = slugify(title);
307    if slug.is_empty() {
308        format!("{id}.md")
309    } else {
310        format!("{id}_{slug}.md")
311    }
312}
313
314/// Generate a relative path for a new entry: `{year}/{id}_{slug}.md`.
315///
316/// The ID is based on the current Unix time (`CarettaId::now_unix()`), so
317/// filenames sort chronologically within a year directory.
318///
319/// Returns `(relative_path, id)` so the caller can embed the ID in frontmatter.
320pub fn new_entry_path(title: &str) -> (PathBuf, CarettaId) {
321    let id = CarettaId::now_unix();
322    let year = chrono::Local::now().year();
323    let path = PathBuf::from(year.to_string()).join(entry_filename(id, title));
324    (path, id)
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn slugify_basic() {
333        assert_eq!(slugify("My Example Entry"), "my_example_entry");
334    }
335
336    #[test]
337    fn slugify_preserves_hyphens() {
338        assert_eq!(slugify("2026-03-17"), "2026-03-17");
339    }
340
341    #[test]
342    fn slugify_preserves_japanese() {
343        assert_eq!(slugify("日本語タイトル"), "日本語タイトル");
344    }
345
346    #[test]
347    fn slugify_japanese_with_spaces() {
348        assert_eq!(slugify("今日の メモ"), "今日の_メモ");
349    }
350
351    #[test]
352    fn slugify_replaces_special_chars() {
353        assert_eq!(slugify("Hello, World! (2026)"), "hello_world_2026");
354        assert_eq!(slugify("a/b:c*d"), "a_b_c_d");
355    }
356
357    #[test]
358    fn slugify_trims_underscores() {
359        assert_eq!(slugify("  leading"), "leading");
360    }
361}