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::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}