Skip to main content

backhopper_core/store/
fs.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! Filesystem-backed snapshot store with `ReadOnly`/`Mutable` type-state.
6
7use 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
21/// Sealed so a foreign marker cannot stand in for `ReadOnly` or `Mutable`
22/// and give itself a `write`.
23pub 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    /// The snapshot file as stored, so a caller can parse its header and
142    /// its body from one read.
143    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    /// Reads and parses only the header block of a snapshot file.
149    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}