Skip to main content

pinto/storage/file_repository/
sprints.rs

1//! Sprint persistence for [`FileRepository`]: the [`SprintRepository`]
2//! implementation and the sprint record reading/validation helpers it relies on.
3
4use super::{FileRepository, SprintRecord};
5use crate::error::Error;
6use crate::error::Result;
7use crate::sprint::{Sprint, SprintId};
8use crate::storage::atomic_write;
9use crate::storage::markdown::{sprint_from_markdown, sprint_to_markdown};
10use crate::storage::repository::SprintRepository;
11use std::collections::HashMap;
12use std::io;
13use std::path::Path;
14use tokio::fs;
15use tokio::task::JoinSet;
16
17impl SprintRepository for FileRepository {
18    async fn save(&self, sprint: &Sprint) -> Result<()> {
19        self.read_sprint_records().await?;
20        let dir = self.sprints_dir();
21        fs::create_dir_all(&dir)
22            .await
23            .map_err(|e| Error::io(&dir, &e))?;
24        let path = self.sprint_path_for(&sprint.id);
25        let text = sprint_to_markdown(sprint)?;
26        atomic_write(&path, &text).await
27    }
28
29    async fn load(&self, id: &SprintId) -> Result<Sprint> {
30        self.read_sprint_records()
31            .await?
32            .into_iter()
33            .find_map(|(_, sprint)| (sprint.id == *id).then_some(sprint))
34            .ok_or_else(|| Error::SprintNotFound(id.clone()))
35    }
36
37    async fn list(&self) -> Result<Vec<Sprint>> {
38        let mut sprints = self
39            .read_sprint_records()
40            .await?
41            .into_iter()
42            .map(|(_, sprint)| sprint)
43            .collect::<Vec<_>>();
44
45        // Sort by creation time, using the ID as a deterministic tie-breaker.
46        sprints.sort_by(|a, b| {
47            a.created
48                .cmp(&b.created)
49                .then_with(|| a.id.as_str().cmp(b.id.as_str()))
50        });
51        Ok(sprints)
52    }
53
54    async fn delete(&self, id: &SprintId) -> Result<()> {
55        self.read_sprint_records().await?;
56        let path = self.sprint_path_for(id);
57        match fs::remove_file(&path).await {
58            Ok(()) => Ok(()),
59            Err(e) if e.kind() == io::ErrorKind::NotFound => Err(Error::SprintNotFound(id.clone())),
60            Err(e) => Err(Error::io(&path, &e)),
61        }
62    }
63}
64
65impl FileRepository {
66    /// Read and validate every sprint file, retaining paths for collision diagnostics.
67    async fn read_sprint_records(&self) -> Result<Vec<SprintRecord>> {
68        let dir = self.sprints_dir();
69        let Some(paths) = self.markdown_paths(&dir).await? else {
70            return Ok(Vec::new());
71        };
72
73        let mut reads = JoinSet::new();
74        for path in paths {
75            reads.spawn(async move {
76                fs::read_to_string(&path)
77                    .await
78                    .map_err(|e| Error::io(&path, &e))
79                    .map(|text| (path, text))
80            });
81        }
82        let mut contents = Vec::new();
83        while let Some(joined) = reads.join_next().await {
84            contents.push(joined.map_err(Error::task)??);
85        }
86
87        let records = contents
88            .into_iter()
89            .map(|(path, text)| sprint_from_markdown(&text, &path).map(|sprint| (path, sprint)))
90            .collect::<Result<Vec<_>>>()?;
91        Self::ensure_unique_sprint_ids(&records)?;
92        for (path, sprint) in &records {
93            Self::validate_sprint_filename(path, sprint)?;
94        }
95        Ok(records)
96    }
97
98    /// Reject two sprint files that resolve to the same logical sprint ID.
99    fn ensure_unique_sprint_ids(records: &[SprintRecord]) -> Result<()> {
100        let mut seen = HashMap::new();
101        for (path, sprint) in records {
102            if let Some(previous) = seen.insert(sprint.id.clone(), path.clone()) {
103                return Err(Error::parse(
104                    path,
105                    format!(
106                        "duplicate sprint ID `{}` in {} and {}; fix one frontmatter ID or rename one file",
107                        sprint.id,
108                        previous.display(),
109                        path.display()
110                    ),
111                ));
112            }
113        }
114        Ok(())
115    }
116
117    /// Ensure a sprint filename stem and frontmatter ID describe the same record.
118    fn validate_sprint_filename(path: &Path, sprint: &Sprint) -> Result<()> {
119        let stem = path
120            .file_stem()
121            .and_then(|value| value.to_str())
122            .ok_or_else(|| {
123                Error::parse(
124                    path,
125                    "sprint filename must be a UTF-8 `<ID>.md`; rename the file",
126                )
127            })?;
128        let filename_id = stem.parse::<SprintId>().map_err(|error| {
129            Error::parse(
130                path,
131                format!(
132                    "invalid sprint filename `{stem}.md`: {error}; rename the file to `<ID>.md`"
133                ),
134            )
135        })?;
136        if filename_id != sprint.id {
137            return Err(Error::parse(
138                path,
139                format!(
140                    "filename ID `{filename_id}` does not match frontmatter ID `{}`; rename the file or fix its frontmatter",
141                    sprint.id
142                ),
143            ));
144        }
145        Ok(())
146    }
147}