Skip to main content

pinto/storage/
file_repository.rs

1//! [`FileRepository`]: the `.pinto/` plain-text backend, split by concern into
2//! item persistence (`items`) and sprint persistence (`sprints`).
3
4use crate::backlog::{BacklogItem, ItemId};
5use crate::error::Error;
6use crate::error::Result;
7use crate::sprint::{Sprint, SprintId};
8use std::io;
9use std::path::{Path, PathBuf};
10use tokio::fs;
11
12mod items;
13mod sprints;
14
15/// [`BacklogItemRepository`] and [`SprintRepository`] implementation backed by the `.pinto/` directory.
16///
17/// [`BacklogItemRepository`]: crate::storage::repository::BacklogItemRepository
18/// [`SprintRepository`]: crate::storage::repository::SprintRepository
19#[derive(Debug, Clone)]
20pub struct FileRepository {
21    root: PathBuf,
22}
23
24type ItemRecord = (PathBuf, BacklogItem);
25type SprintRecord = (PathBuf, Sprint);
26
27impl FileRepository {
28    /// Build by specifying the board root (`.pinto/`). No file I/O is performed.
29    pub fn new(root: impl Into<PathBuf>) -> Self {
30        Self { root: root.into() }
31    }
32
33    /// Directory to place task files (`<root>/tasks`).
34    #[must_use]
35    pub fn tasks_dir(&self) -> PathBuf {
36        self.root.join("tasks")
37    }
38
39    /// Directory to put sprint files (`<root>/sprints`).
40    #[must_use]
41    pub fn sprints_dir(&self) -> PathBuf {
42        self.root.join("sprints")
43    }
44
45    /// The sprint file path for the specified ID (`<root>/sprints/<id>.md`).
46    pub(crate) fn sprint_path_for(&self, id: &SprintId) -> PathBuf {
47        self.sprints_dir().join(format!("{id}.md"))
48    }
49
50    /// Task file path for the specified ID (`<root>/tasks/<id>.md`).
51    pub(crate) fn path_for(&self, id: &ItemId) -> Result<PathBuf> {
52        self.safe_item_path(&self.tasks_dir(), id)
53    }
54
55    /// Destination directory (`<root>/archive`).
56    fn archive_dir(&self) -> PathBuf {
57        self.root.join("archive")
58    }
59
60    /// Archive file path for the specified ID (`<root>/archive/<id>.md`).
61    fn archive_path_for(&self, id: &ItemId) -> Result<PathBuf> {
62        self.safe_item_path(&self.archive_dir(), id)
63    }
64
65    /// Build one item path while checking that the ID contributes exactly one filename component.
66    fn safe_item_path(&self, directory: &Path, id: &ItemId) -> Result<PathBuf> {
67        let path = directory.join(format!("{id}.md"));
68        if path.parent() != Some(directory) || !path.starts_with(directory) {
69            return Err(Error::InvalidItemId(id.to_string()));
70        }
71        Ok(path)
72    }
73
74    /// Collect the `.md` file paths directly under `tasks/` (directory scanning is asynchronous).
75    ///
76    /// If the directory does not exist, return `None` to indicate that it has no items yet.
77    async fn markdown_paths(&self, dir: &Path) -> Result<Option<Vec<PathBuf>>> {
78        let mut read_dir = match fs::read_dir(dir).await {
79            Ok(rd) => rd,
80            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
81            Err(e) => return Err(Error::io(dir, &e)),
82        };
83
84        let mut paths = Vec::new();
85        while let Some(entry) = read_dir
86            .next_entry()
87            .await
88            .map_err(|e| Error::io(dir, &e))?
89        {
90            let path = entry.path();
91            if Self::is_markdown(&path) {
92                paths.push(path);
93            }
94        }
95        Ok(Some(paths))
96    }
97
98    /// Return whether `path` has the `.md` extension.
99    fn is_markdown(path: &Path) -> bool {
100        path.extension().and_then(|s| s.to_str()) == Some("md")
101    }
102}
103
104#[cfg(test)]
105mod tests;