backhopper_core/store/
fs.rs1use std::fs;
8use std::io::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, state};
15use crate::snapshot::{format, parser};
16
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
18pub struct ReadOnly;
19
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21pub struct Mutable;
22
23#[derive(Debug)]
24pub struct SnapshotStore<M> {
25 root: PathBuf,
26 _mode: PhantomData<M>,
27}
28
29const SNAPSHOT_SUFFIX: &str = ".api.txt";
30
31impl SnapshotStore<ReadOnly> {
32 pub fn open(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
33 let root = root.into();
34 if !root.exists() {
35 return Err(StoreError::RootMissing(root));
36 }
37 Ok(Self {
38 root,
39 _mode: PhantomData,
40 })
41 }
42}
43
44impl SnapshotStore<Mutable> {
45 pub fn open_mut(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
46 let root = root.into();
47 fs::create_dir_all(&root)?;
48 Ok(Self {
49 root,
50 _mode: PhantomData,
51 })
52 }
53}
54
55impl<M> SnapshotStore<M> {
56 pub fn root(&self) -> &Path {
57 &self.root
58 }
59
60 pub fn project_dir(&self, project: &ProjectName) -> PathBuf {
61 self.root.join(project.as_str())
62 }
63
64 pub fn snapshot_path(
65 &self,
66 project: &ProjectName,
67 tag: &TagName,
68 ) -> Result<PathBuf, StoreError> {
69 let mut path = self.project_dir(project);
70 let filename = format!("{}{}", tag.as_str(), SNAPSHOT_SUFFIX);
71 path.push(&filename);
72 ensure_no_parent_escape(&path)?;
73 Ok(path)
74 }
75
76 pub fn list_tags(&self, project: &ProjectName) -> Result<Vec<TagName>, StoreError> {
77 let dir = self.project_dir(project);
78 if !dir.exists() {
79 return Ok(Vec::new());
80 }
81 let mut tags = Vec::new();
82 for entry in fs::read_dir(&dir)? {
83 let entry = entry?;
84 let path = entry.path();
85 if !path.is_file() {
86 continue;
87 }
88 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
89 continue;
90 };
91 let Some(stripped) = name.strip_suffix(SNAPSHOT_SUFFIX) else {
92 continue;
93 };
94 if let Ok(tag) = stripped.parse::<TagName>() {
95 tags.push(tag);
96 }
97 }
98 tags.sort();
99 Ok(tags)
100 }
101
102 pub fn read(
103 &self,
104 project: &ProjectName,
105 tag: &TagName,
106 ) -> Result<Snapshot<state::Canonical>, StoreError> {
107 let path = self.snapshot_path(project, tag)?;
108 if !path.exists() {
109 return Err(StoreError::SnapshotNotFound {
110 project: project.to_string(),
111 tag: tag.to_string(),
112 });
113 }
114 let text = fs::read_to_string(&path)?;
115 let snapshot = parser::parse(&text).map_err(StoreError::Snapshot)?;
116 Ok(snapshot)
117 }
118
119 pub fn has(&self, project: &ProjectName, tag: &TagName) -> bool {
120 self.snapshot_path(project, tag)
121 .map(|p| p.exists())
122 .unwrap_or(false)
123 }
124
125 pub fn list_projects(&self) -> Result<Vec<ProjectName>, StoreError> {
126 let mut out = Vec::new();
127 if !self.root.exists() {
128 return Ok(out);
129 }
130 for entry in fs::read_dir(&self.root)? {
131 let entry = entry?;
132 let path = entry.path();
133 if !path.is_dir() {
134 continue;
135 }
136 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
137 continue;
138 };
139 if let Ok(p) = name.parse::<ProjectName>() {
140 out.push(p);
141 }
142 }
143 out.sort();
144 Ok(out)
145 }
146}
147
148impl SnapshotStore<Mutable> {
149 pub fn write(&self, snapshot: &Snapshot<state::Canonical>) -> Result<PathBuf, StoreError> {
150 let path = self.snapshot_path(&snapshot.header.project, &snapshot.header.tag)?;
151 if let Some(parent) = path.parent() {
152 fs::create_dir_all(parent)?;
153 }
154 let temp = temp_sibling(&path);
155 let mut file = fs::File::create(&temp)?;
156 format::write(snapshot, &mut file).map_err(StoreError::Snapshot)?;
157 file.flush()?;
158 drop(file);
159 fs::rename(&temp, &path)?;
160 Ok(path)
161 }
162
163 pub fn delete(&self, project: &ProjectName, tag: &TagName) -> Result<(), StoreError> {
164 let path = self.snapshot_path(project, tag)?;
165 if path.exists() {
166 fs::remove_file(&path)?;
167 }
168 Ok(())
169 }
170}
171
172fn ensure_no_parent_escape(candidate: &Path) -> Result<(), StoreError> {
173 for c in candidate.components() {
174 if matches!(c, Component::ParentDir) {
175 return Err(StoreError::PathEscape(candidate.to_path_buf()));
176 }
177 }
178 Ok(())
179}
180
181fn temp_sibling(path: &Path) -> PathBuf {
182 let mut s = path.as_os_str().to_os_string();
183 s.push(".tmp");
184 PathBuf::from(s)
185}