pinto/storage/file_repository/
sprints.rs1use 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 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 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 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 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}