okane_core/
load.rs

1//! Contains the functions to load Ledger file,
2//! with recursively resolving the `include` directives.
3
4use std::{
5    borrow::Cow,
6    collections::HashMap,
7    path::{self, Path, PathBuf},
8};
9
10use crate::{parse, syntax};
11
12/// Error caused by [Loader::load].
13#[derive(thiserror::Error, Debug)]
14pub enum LoadError {
15    #[error("failed to perform IO")]
16    IO(#[from] std::io::Error),
17    #[error("failed to parse file {1}")]
18    Parse(#[source] Box<parse::ParseError>, PathBuf),
19    #[error("unexpected include path {0}, maybe filesystem root is passed")]
20    IncludePath(PathBuf),
21}
22
23/// Loader is an object to keep loading a given file and may recusrively load them as `repr::LedgerEntry`,
24/// with the metadata about filename or line/column to point the error in a user friendly manner.
25pub struct Loader<F: FileSystem> {
26    source: PathBuf,
27    error_style: annotate_snippets::Renderer,
28    filesystem: F,
29}
30
31/// Creates a new [`Loader`] instance with [`ProdFileSystem`].
32pub fn new_loader(source: PathBuf) -> Loader<ProdFileSystem> {
33    Loader::new(source, ProdFileSystem)
34}
35
36impl<F: FileSystem> Loader<F> {
37    /// Create a new instance of `Loader` to load the given path.
38    ///
39    /// It might look weird to have the source path as a `Loader` member,
40    /// but that would give future flexibility to support loading from stdio/network without include,
41    /// or completely static one.
42    pub fn new(source: PathBuf, filesystem: F) -> Self {
43        Self {
44            source,
45            // TODO: use plain by default.
46            error_style: annotate_snippets::Renderer::styled(),
47            filesystem,
48        }
49    }
50
51    /// Returns a [`annotate_snippets::Renderer`] instance.
52    pub(crate) fn error_style(&self) -> &annotate_snippets::Renderer {
53        &self.error_style
54    }
55
56    /// Loads [syntax::LedgerEntry] and invoke callback on every entry,
57    /// recursively resolving `include` directives.
58    pub fn load<T, E, Deco>(&self, mut callback: T) -> Result<(), E>
59    where
60        T: FnMut(&Path, &parse::ParsedContext<'_>, &syntax::LedgerEntry<'_, Deco>) -> Result<(), E>,
61        E: std::error::Error + From<LoadError>,
62        Deco: syntax::decoration::Decoration,
63    {
64        let popts = parse::ParseOptions::default().with_error_style(self.error_style.clone());
65        self.load_impl(&popts, &self.source, &mut callback)
66    }
67
68    fn load_impl<T, E, Deco>(
69        &self,
70        parse_options: &parse::ParseOptions,
71        path: &Path,
72        callback: &mut T,
73    ) -> Result<(), E>
74    where
75        T: FnMut(&Path, &parse::ParsedContext<'_>, &syntax::LedgerEntry<'_, Deco>) -> Result<(), E>,
76        E: std::error::Error + From<LoadError>,
77        Deco: syntax::decoration::Decoration,
78    {
79        let path: Cow<'_, Path> = self.filesystem.canonicalize_path(path);
80        let content = self
81            .filesystem
82            .file_content_utf8(&path)
83            .map_err(LoadError::IO)?;
84        for parsed in parse_options.parse_ledger(&content) {
85            let (ctx, entry) =
86                parsed.map_err(|e| LoadError::Parse(Box::new(e), path.clone().into_owned()))?;
87            match entry {
88                syntax::LedgerEntry::Include(p) => {
89                    let include_path: PathBuf = p.0.as_ref().into();
90                    let target = path
91                        .as_ref()
92                        .parent()
93                        .ok_or_else(|| LoadError::IncludePath(path.as_ref().to_owned()))?
94                        .join(include_path);
95                    self.load_impl(parse_options, &target, callback)
96                }
97                _ => callback(&path, &ctx, &entry),
98            }?;
99        }
100        Ok(())
101    }
102}
103
104/// Interface to abstract file system.
105/// Normally you want to use [ProdFileSystem].
106pub trait FileSystem {
107    /// canonicalize the given path.
108    fn canonicalize_path<'a>(&self, path: &'a Path) -> Cow<'a, Path>;
109
110    /// Load the given path and returns it as UTF-8 String.
111    fn file_content_utf8<P: AsRef<Path>>(&self, path: P) -> Result<String, std::io::Error>;
112}
113
114/// [FileSystem] to regularly reads the files recursively in the local files.
115pub struct ProdFileSystem;
116
117impl FileSystem for ProdFileSystem {
118    fn canonicalize_path<'a>(&self, path: &'a Path) -> Cow<'a, Path> {
119        std::fs::canonicalize(path)
120            .map(|x| {
121                if x == path {
122                    Cow::Borrowed(path)
123                } else {
124                    Cow::Owned(x)
125                }
126            })
127            .unwrap_or_else(|x| {
128                log::warn!(
129                    "failed to canonicalize path {}, likeky to fail to load: {}",
130                    path.display(),
131                    x
132                );
133                path.into()
134            })
135    }
136
137    fn file_content_utf8<P: AsRef<Path>>(&self, path: P) -> Result<String, std::io::Error> {
138        std::fs::read_to_string(path)
139    }
140}
141
142/// [FileSystem] with given set of filename and content mapping.
143/// It won't cause any actual file read.
144pub struct FakeFileSystem(HashMap<PathBuf, Vec<u8>>);
145
146impl From<HashMap<PathBuf, Vec<u8>>> for FakeFileSystem {
147    fn from(value: HashMap<PathBuf, Vec<u8>>) -> Self {
148        Self(value)
149    }
150}
151
152impl FileSystem for FakeFileSystem {
153    fn canonicalize_path<'a>(&self, path: &'a Path) -> Cow<'a, Path> {
154        let mut ret = PathBuf::new();
155        for pc in path.components() {
156            match pc {
157                path::Component::CurDir => (),
158                path::Component::ParentDir => {
159                    if !ret.pop() {
160                        log::warn!(
161                            "failed to pop parent, maybe wrong path given: {}",
162                            path.display()
163                        );
164                    }
165                }
166                _ => ret.push(pc),
167            }
168        }
169        Cow::Owned(ret)
170    }
171
172    fn file_content_utf8<P: AsRef<Path>>(&self, path: P) -> Result<String, std::io::Error> {
173        let path = path.as_ref();
174        self.0
175            .get(path)
176            .ok_or(std::io::Error::new(
177                std::io::ErrorKind::NotFound,
178                format!("fake file {} not found", path.display()),
179            ))
180            .and_then(|x| {
181                String::from_utf8(x.clone())
182                    .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
183            })
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use std::{borrow::Borrow, path::Path, vec::Vec};
190
191    use indoc::indoc;
192    use maplit::hashmap;
193    use pretty_assertions::assert_eq;
194
195    use crate::parse::{self, ParseOptions};
196
197    use super::*;
198
199    fn parse_static_ledger_entry<'a>(
200        input: &[(&Path, &'static str)],
201    ) -> Result<Vec<(PathBuf, syntax::plain::LedgerEntry<'static>)>, parse::ParseError> {
202        let opts = ParseOptions::default();
203        input
204            .iter()
205            .flat_map(|(p, content)| {
206                opts.parse_ledger(content)
207                    .map(|elem| elem.map(|(_ctx, entry)| (p.to_path_buf(), entry)))
208            })
209            .collect()
210    }
211
212    fn parse_into_vec<L, F>(
213        loader: L,
214    ) -> Result<Vec<(PathBuf, syntax::plain::LedgerEntry<'static>)>, LoadError>
215    where
216        L: Borrow<Loader<F>>,
217        F: FileSystem,
218    {
219        let mut ret: Vec<(PathBuf, syntax::plain::LedgerEntry<'static>)> = Vec::new();
220        loader.borrow().load(|path, _ctx, entry| {
221            ret.push((path.to_owned(), entry.to_static()));
222            Ok::<(), LoadError>(())
223        })?;
224        Ok(ret)
225    }
226
227    #[test]
228    fn load_valid_input_real_file() {
229        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
230            .join("testdata/root.ledger")
231            .canonicalize()
232            .unwrap();
233        let child1 = Path::new(env!("CARGO_MANIFEST_DIR"))
234            .join("testdata/child1.ledger")
235            .canonicalize()
236            .unwrap();
237        let child2 = Path::new(env!("CARGO_MANIFEST_DIR"))
238            .join("testdata/sub/child2.ledger")
239            .canonicalize()
240            .unwrap();
241        let child3 = Path::new(env!("CARGO_MANIFEST_DIR"))
242            .join("testdata/child3.ledger")
243            .canonicalize()
244            .unwrap();
245        let want = parse_static_ledger_entry(&[
246            (
247                &root,
248                indoc! {"
249            account Expenses:Grocery
250                note スーパーマーケットで買ったやつ全部
251                ; comment
252                alias Expenses:CVS
253
254            2024/01/01 Initial Balance
255                Equity:Opening Balance                  -1000.00 CHF
256                Assets:Bank:ZKB                          1000.00 CHF
257            "},
258            ),
259            (
260                &child2,
261                indoc! {"
262            2024/01/01 * Complicated salary
263                Income:Salary                          -3,000.00 CHF
264                Assets:Bank:ZKB                         2,500.00 CHF
265                Expenses:Income Tax                       312.34 CHF
266                Expenses:Social Tax                        37.66 CHF
267                Assets:Fixed:年金                         150.00 CHF
268            "},
269            ),
270            (
271                &child3,
272                indoc! {"
273            2024/03/01 * SBB CFF FFS
274                Assets:Bank:ZKB                            -5.60 CHF
275                Expenses:Travel:Train                       5.60 CHF
276            "},
277            ),
278            (
279                &child2,
280                indoc! {"
281            2024/01/25 ! RSU
282                ; TODO: FMV not determined
283                Income:RSU                    (-50.0000 * 100.23 USD)
284                Expenses:Income Tax
285                Assets:Broker                            40.0000 OKANE @ 100.23 USD
286            "},
287            ),
288            (
289                &child1,
290                indoc! {"
291            2024/05/01 * Migros
292                Expenses:Grocery                          -10.00 CHF
293                Assets:Bank:ZKB                            10.00 CHF
294            "},
295            ),
296        ])
297        .expect("test input parse must not fail");
298        let got = parse_into_vec(new_loader(root.clone())).expect("failed to parse the test data");
299        assert_eq!(want, got);
300    }
301
302    #[test]
303    fn load_valid_fake() {
304        let fake = hashmap! {
305            PathBuf::from("/path/to/root.ledger") => indoc! {"
306                include child1.ledger
307            "}.as_bytes().to_vec(),
308            PathBuf::from("/path/to/child1.ledger") => indoc! {"
309                include sub/child2.ledger
310            "}.as_bytes().to_vec(),
311            PathBuf::from("/path/to/sub/child2.ledger") => indoc! {"
312                include child3.ledger
313            "}.as_bytes().to_vec(),
314            PathBuf::from("/path/to/sub/child3.ledger") => indoc! {"
315                ; comment here
316            "}.as_bytes().to_vec(),
317        };
318        let want = parse_static_ledger_entry(&[(
319            Path::new("/path/to/sub/child3.ledger"),
320            indoc! {"
321            ; comment here
322            "},
323        )])
324        .expect("test input parse must not fail");
325        let got = parse_into_vec(Loader::new(
326            PathBuf::from("/path/to/root.ledger"),
327            FakeFileSystem::from(fake),
328        ))
329        .expect("parse failed");
330        assert_eq!(want, got);
331    }
332}