backhopper_core/store/
fs.rs1use std::fs;
8use std::io::{BufRead, BufReader, BufWriter, Write};
9use std::marker::PhantomData;
10use std::path::{Component, Path, PathBuf};
11
12use crate::errors::StoreError;
13use crate::model::names::{ProjectName, TagName};
14use crate::model::snapshot::{Snapshot, SnapshotHeader, state};
15use crate::snapshot::{format, parser};
16
17mod private {
18 pub trait Sealed {}
19}
20
21pub trait StoreMode: private::Sealed {}
24
25#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
26pub struct ReadOnly;
27
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub struct Mutable;
30
31impl private::Sealed for ReadOnly {}
32impl private::Sealed for Mutable {}
33impl StoreMode for ReadOnly {}
34impl StoreMode for Mutable {}
35
36#[derive(Debug)]
37pub struct SnapshotStore<M: StoreMode> {
38 root: PathBuf,
39 _mode: PhantomData<M>,
40}
41
42const SNAPSHOT_SUFFIX: &str = ".api.txt";
43
44impl SnapshotStore<ReadOnly> {
45 pub fn open(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
46 let root = root.into();
47 if !root.exists() {
48 return Err(StoreError::RootMissing(root));
49 }
50 Ok(Self {
51 root,
52 _mode: PhantomData,
53 })
54 }
55}
56
57impl SnapshotStore<Mutable> {
58 pub fn open_mut(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
59 let root = root.into();
60 fs::create_dir_all(&root)?;
61 Ok(Self {
62 root,
63 _mode: PhantomData,
64 })
65 }
66}
67
68impl<M: StoreMode> SnapshotStore<M> {
69 pub fn root(&self) -> &Path {
70 &self.root
71 }
72
73 pub fn project_dir(&self, project: &ProjectName) -> PathBuf {
74 self.root.join(project.as_str())
75 }
76
77 pub fn snapshot_path(
78 &self,
79 project: &ProjectName,
80 tag: &TagName,
81 ) -> Result<PathBuf, StoreError> {
82 let mut path = self.project_dir(project);
83 let filename = format!("{}{}", tag.as_str(), SNAPSHOT_SUFFIX);
84 path.push(&filename);
85 ensure_no_parent_escape(&path)?;
86 Ok(path)
87 }
88
89 fn existing_snapshot_path(
90 &self,
91 project: &ProjectName,
92 tag: &TagName,
93 ) -> Result<PathBuf, StoreError> {
94 let path = self.snapshot_path(project, tag)?;
95 if path.exists() {
96 Ok(path)
97 } else {
98 Err(StoreError::SnapshotNotFound {
99 project: project.to_string(),
100 tag: tag.to_string(),
101 })
102 }
103 }
104
105 pub fn list_tags(&self, project: &ProjectName) -> Result<Vec<TagName>, StoreError> {
106 let dir = self.project_dir(project);
107 if !dir.exists() {
108 return Ok(Vec::new());
109 }
110 let mut tags = Vec::new();
111 for entry in fs::read_dir(&dir)? {
112 let entry = entry?;
113 let path = entry.path();
114 if !path.is_file() {
115 continue;
116 }
117 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
118 continue;
119 };
120 let Some(stripped) = name.strip_suffix(SNAPSHOT_SUFFIX) else {
121 continue;
122 };
123 if let Ok(tag) = stripped.parse::<TagName>() {
124 tags.push(tag);
125 }
126 }
127 tags.sort();
128 Ok(tags)
129 }
130
131 pub fn read(
132 &self,
133 project: &ProjectName,
134 tag: &TagName,
135 ) -> Result<Snapshot<state::Canonical>, StoreError> {
136 let text = self.read_text(project, tag)?;
137 let snapshot = parser::parse(&text).map_err(StoreError::Snapshot)?;
138 Ok(snapshot)
139 }
140
141 pub fn read_text(&self, project: &ProjectName, tag: &TagName) -> Result<String, StoreError> {
144 let path = self.existing_snapshot_path(project, tag)?;
145 Ok(fs::read_to_string(&path)?)
146 }
147
148 pub fn read_header(
150 &self,
151 project: &ProjectName,
152 tag: &TagName,
153 ) -> Result<SnapshotHeader, StoreError> {
154 let path = self.existing_snapshot_path(project, tag)?;
155 let mut header_text = String::new();
156 for line in BufReader::new(fs::File::open(&path)?).lines() {
157 let line = line?;
158 if !line.starts_with("# ") {
159 break;
160 }
161 header_text.push_str(&line);
162 header_text.push('\n');
163 }
164 parser::parse_header(&header_text).map_err(StoreError::Snapshot)
165 }
166
167 pub fn has(&self, project: &ProjectName, tag: &TagName) -> bool {
168 self.snapshot_path(project, tag)
169 .map(|p| p.exists())
170 .unwrap_or(false)
171 }
172
173 pub fn list_projects(&self) -> Result<Vec<ProjectName>, StoreError> {
174 let mut out = Vec::new();
175 if !self.root.exists() {
176 return Ok(out);
177 }
178 for entry in fs::read_dir(&self.root)? {
179 let entry = entry?;
180 let path = entry.path();
181 if !path.is_dir() {
182 continue;
183 }
184 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
185 continue;
186 };
187 if let Ok(p) = name.parse::<ProjectName>() {
188 out.push(p);
189 }
190 }
191 out.sort();
192 Ok(out)
193 }
194}
195
196impl SnapshotStore<Mutable> {
197 pub fn write(&self, snapshot: &Snapshot<state::Canonical>) -> Result<PathBuf, StoreError> {
198 let path = self.snapshot_path(&snapshot.header().project, &snapshot.header().tag)?;
199 if let Some(parent) = path.parent() {
200 fs::create_dir_all(parent)?;
201 }
202 let temp = temp_sibling(&path);
203 let mut file = BufWriter::new(fs::File::create(&temp)?);
204 format::write(snapshot, &mut file).map_err(StoreError::Snapshot)?;
205 file.flush()?;
206 drop(file);
207 fs::rename(&temp, &path)?;
208 Ok(path)
209 }
210
211 pub fn delete(&self, project: &ProjectName, tag: &TagName) -> Result<(), StoreError> {
212 let path = self.snapshot_path(project, tag)?;
213 if path.exists() {
214 fs::remove_file(&path)?;
215 }
216 Ok(())
217 }
218}
219
220fn ensure_no_parent_escape(candidate: &Path) -> Result<(), StoreError> {
221 for c in candidate.components() {
222 if matches!(c, Component::ParentDir) {
223 return Err(StoreError::PathEscape(candidate.to_path_buf()));
224 }
225 }
226 Ok(())
227}
228
229fn temp_sibling(path: &Path) -> PathBuf {
230 let mut s = path.as_os_str().to_os_string();
231 s.push(".tmp");
232 PathBuf::from(s)
233}