Skip to main content

rustledger_loader/
discover.rs

1//! Finding a ledger's root journal without being told where it is.
2//!
3//! Both the language server and `rledger format` need this: neither is
4//! handed a root, but both must resolve options (`render_commas`,
5//! per-commodity declarations) that only exist there. The list of names
6//! lives here so the two cannot disagree about what a root looks like —
7//! a file the LSP formats one way and the CLI another is precisely the
8//! failure this is meant to prevent.
9
10use std::path::{Path, PathBuf};
11
12/// Common root journal filenames, in priority order.
13pub const COMMON_ROOT_NAMES: &[&str] = &[
14    "main.bean",
15    "main.beancount",
16    "ledger.bean",
17    "ledger.beancount",
18    "journal.bean",
19    "journal.beancount",
20    "index.bean",
21    "index.beancount",
22];
23
24/// The root journal directly inside `dir`, if one is there.
25///
26/// Checks [`COMMON_ROOT_NAMES`] in order and returns the first that exists
27/// as a file. Does not recurse and does not walk upward — see
28/// [`discover_journal_upward`] for that.
29#[must_use]
30pub fn discover_journal_file(dir: &Path) -> Option<PathBuf> {
31    for name in COMMON_ROOT_NAMES {
32        let candidate = dir.join(name);
33        if candidate.is_file() {
34            return Some(candidate);
35        }
36    }
37    None
38}
39
40/// The nearest root journal at or above `start`.
41///
42/// Walks toward the filesystem root and returns the first directory that
43/// holds one. Nearest wins, so a nested sub-ledger with its own root beats
44/// an outer one — the same "most specific enclosing scope" rule an editor
45/// or a version-control tool uses.
46///
47/// `start` is a DIRECTORY. Callers with a file path should pass its parent.
48#[must_use]
49pub fn discover_journal_upward(start: &Path) -> Option<PathBuf> {
50    let mut dir = Some(start);
51    while let Some(d) = dir {
52        if let Some(found) = discover_journal_file(d) {
53            return Some(found);
54        }
55        dir = d.parent();
56    }
57    None
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn finds_a_root_beside_the_file_and_prefers_the_nearest() {
66        let dir = tempfile::tempdir().expect("tempdir");
67        let outer = dir.path();
68        let inner = outer.join("sub/deeper");
69        std::fs::create_dir_all(&inner).expect("mkdir");
70
71        std::fs::write(outer.join("main.beancount"), "").expect("write outer");
72        assert_eq!(
73            discover_journal_upward(&inner),
74            Some(outer.join("main.beancount")),
75            "walks up when nothing is nearer"
76        );
77
78        let nested = outer.join("sub").join("ledger.beancount");
79        std::fs::write(&nested, "").expect("write inner");
80        assert_eq!(
81            discover_journal_upward(&inner),
82            Some(nested),
83            "a nearer root wins over an outer one"
84        );
85    }
86
87    #[test]
88    fn name_priority_is_stable_and_directories_do_not_count() {
89        let dir = tempfile::tempdir().expect("tempdir");
90        // A DIRECTORY named like a root must not be mistaken for one.
91        std::fs::create_dir(dir.path().join("main.bean")).expect("mkdir");
92        assert_eq!(discover_journal_file(dir.path()), None);
93
94        std::fs::write(dir.path().join("journal.beancount"), "").expect("write");
95        std::fs::write(dir.path().join("main.beancount"), "").expect("write");
96        assert_eq!(
97            discover_journal_file(dir.path()),
98            Some(dir.path().join("main.beancount")),
99            "`main.beancount` outranks `journal.beancount`"
100        );
101    }
102
103    #[test]
104    fn returns_none_when_there_is_no_ledger_anywhere_above() {
105        let dir = tempfile::tempdir().expect("tempdir");
106        let deep = dir.path().join("a/b/c");
107        std::fs::create_dir_all(&deep).expect("mkdir");
108        // The walk reaches the filesystem root and stops; tempdirs live
109        // under /tmp, which has no journal.
110        assert_eq!(discover_journal_upward(&deep), None);
111    }
112}