Skip to main content

ito_core/
fs_project_store.rs

1//! Filesystem-backed [`BackendProjectStore`] implementation.
2//!
3//! Resolves `{org}/{repo}` to a project `.ito/` path under a configurable
4//! data directory and constructs domain repositories from that path.
5//!
6//! Directory layout: `<data_dir>/projects/{org}/{repo}/.ito/`
7
8use std::path::PathBuf;
9
10use ito_domain::backend::BackendProjectStore;
11use ito_domain::changes::{
12    Change, ChangeRepository, ChangeSummary, ChangeTargetResolution, ResolveTargetOptions,
13};
14use ito_domain::errors::{DomainError, DomainResult};
15use ito_domain::modules::{Module, ModuleRepository, ModuleSummary};
16use ito_domain::tasks::{TaskRepository, TasksParseResult};
17
18use crate::change_repository::FsChangeRepository;
19use crate::module_repository::FsModuleRepository;
20use crate::task_repository::FsTaskRepository;
21
22/// Filesystem-backed project store rooted at a configurable data directory.
23///
24/// Projects are stored under `<data_dir>/projects/{org}/{repo}/.ito/`.
25/// Repositories are constructed per-request using the resolved `.ito/` path.
26#[derive(Debug, Clone)]
27pub struct FsBackendProjectStore {
28    data_dir: PathBuf,
29}
30
31/// Check that a path segment is safe for use in filesystem paths.
32///
33/// Rejects empty strings, `.`, `..`, and values containing `/` or `\`.
34fn is_safe_path_segment(value: &str) -> bool {
35    !value.is_empty()
36        && value != "."
37        && value != ".."
38        && !value.contains('/')
39        && !value.contains('\\')
40}
41
42impl FsBackendProjectStore {
43    /// Create a new filesystem project store rooted at the given data directory.
44    pub fn new(data_dir: impl Into<PathBuf>) -> Self {
45        Self {
46            data_dir: data_dir.into(),
47        }
48    }
49
50    /// Compute the `.ito/` path for a project.
51    ///
52    /// Returns an error if `org` or `repo` contain path traversal characters.
53    pub fn ito_path_for(&self, org: &str, repo: &str) -> DomainResult<PathBuf> {
54        if !is_safe_path_segment(org) || !is_safe_path_segment(repo) {
55            return Err(DomainError::io(
56                "invalid path segment in org/repo",
57                std::io::Error::new(
58                    std::io::ErrorKind::InvalidInput,
59                    format!("invalid path segment: org={org:?}, repo={repo:?}"),
60                ),
61            ));
62        }
63        Ok(self
64            .data_dir
65            .join("projects")
66            .join(org)
67            .join(repo)
68            .join(".ito"))
69    }
70}
71
72impl BackendProjectStore for FsBackendProjectStore {
73    fn change_repository(
74        &self,
75        org: &str,
76        repo: &str,
77    ) -> DomainResult<Box<dyn ChangeRepository + Send>> {
78        let ito_path = self.ito_path_for(org, repo)?;
79        Ok(Box::new(OwnedFsChangeRepository::new(ito_path)))
80    }
81
82    fn module_repository(
83        &self,
84        org: &str,
85        repo: &str,
86    ) -> DomainResult<Box<dyn ModuleRepository + Send>> {
87        let ito_path = self.ito_path_for(org, repo)?;
88        Ok(Box::new(OwnedFsModuleRepository::new(ito_path)))
89    }
90
91    fn task_repository(
92        &self,
93        org: &str,
94        repo: &str,
95    ) -> DomainResult<Box<dyn TaskRepository + Send>> {
96        let ito_path = self.ito_path_for(org, repo)?;
97        Ok(Box::new(OwnedFsTaskRepository::new(ito_path)))
98    }
99
100    fn ensure_project(&self, org: &str, repo: &str) -> DomainResult<()> {
101        let ito_path = self.ito_path_for(org, repo)?;
102        std::fs::create_dir_all(&ito_path)
103            .map_err(|e| DomainError::io("creating project directory", e))
104    }
105
106    fn project_exists(&self, org: &str, repo: &str) -> bool {
107        self.ito_path_for(org, repo)
108            .map(|p| p.is_dir())
109            .unwrap_or(false)
110    }
111}
112
113// ── Owned-path repository wrappers ─────────────────────────────────
114//
115// The standard `Fs*Repository` types borrow their path (`&'a Path`).
116// The `BackendProjectStore` trait returns `Box<dyn ... + Send>` which
117// requires `'static`. These wrappers own the `PathBuf` and delegate
118// to the borrowed-path implementations by creating them on the fly.
119
120/// Change repository that owns its `.ito/` path.
121struct OwnedFsChangeRepository {
122    ito_path: PathBuf,
123}
124
125impl OwnedFsChangeRepository {
126    fn new(ito_path: PathBuf) -> Self {
127        Self { ito_path }
128    }
129
130    fn inner(&self) -> FsChangeRepository<'_> {
131        FsChangeRepository::new(&self.ito_path)
132    }
133}
134
135impl ChangeRepository for OwnedFsChangeRepository {
136    fn resolve_target_with_options(
137        &self,
138        input: &str,
139        options: ResolveTargetOptions,
140    ) -> ChangeTargetResolution {
141        self.inner().resolve_target_with_options(input, options)
142    }
143
144    fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
145        self.inner().suggest_targets(input, max)
146    }
147
148    fn exists(&self, id: &str) -> bool {
149        self.inner().exists(id)
150    }
151
152    fn get(&self, id: &str) -> DomainResult<Change> {
153        self.inner().get(id)
154    }
155
156    fn list(&self) -> DomainResult<Vec<ChangeSummary>> {
157        self.inner().list()
158    }
159
160    fn list_by_module(&self, module_id: &str) -> DomainResult<Vec<ChangeSummary>> {
161        self.inner().list_by_module(module_id)
162    }
163
164    fn list_incomplete(&self) -> DomainResult<Vec<ChangeSummary>> {
165        self.inner().list_incomplete()
166    }
167
168    fn list_complete(&self) -> DomainResult<Vec<ChangeSummary>> {
169        self.inner().list_complete()
170    }
171
172    fn get_summary(&self, id: &str) -> DomainResult<ChangeSummary> {
173        self.inner().get_summary(id)
174    }
175}
176
177/// Module repository that owns its `.ito/` path.
178struct OwnedFsModuleRepository {
179    ito_path: PathBuf,
180}
181
182impl OwnedFsModuleRepository {
183    fn new(ito_path: PathBuf) -> Self {
184        Self { ito_path }
185    }
186
187    fn inner(&self) -> FsModuleRepository<'_> {
188        FsModuleRepository::new(&self.ito_path)
189    }
190}
191
192impl ModuleRepository for OwnedFsModuleRepository {
193    fn exists(&self, id: &str) -> bool {
194        self.inner().exists(id)
195    }
196
197    fn get(&self, id_or_name: &str) -> DomainResult<Module> {
198        self.inner().get(id_or_name)
199    }
200
201    fn list(&self) -> DomainResult<Vec<ModuleSummary>> {
202        self.inner().list()
203    }
204}
205
206/// Task repository that owns its `.ito/` path.
207struct OwnedFsTaskRepository {
208    ito_path: PathBuf,
209}
210
211impl OwnedFsTaskRepository {
212    fn new(ito_path: PathBuf) -> Self {
213        Self { ito_path }
214    }
215
216    fn inner(&self) -> FsTaskRepository<'_> {
217        FsTaskRepository::new(&self.ito_path)
218    }
219}
220
221impl TaskRepository for OwnedFsTaskRepository {
222    fn load_tasks(&self, change_id: &str) -> DomainResult<TasksParseResult> {
223        self.inner().load_tasks(change_id)
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn ito_path_resolves_correctly() {
233        let store = FsBackendProjectStore::new("/data");
234        let path = store.ito_path_for("withakay", "ito").unwrap();
235        assert_eq!(path, PathBuf::from("/data/projects/withakay/ito/.ito"));
236    }
237
238    #[test]
239    fn ito_path_rejects_path_traversal() {
240        let store = FsBackendProjectStore::new("/data");
241        assert!(store.ito_path_for("..", "ito").is_err());
242        assert!(store.ito_path_for("org", "..").is_err());
243        assert!(store.ito_path_for(".", "repo").is_err());
244        assert!(store.ito_path_for("org/evil", "repo").is_err());
245        assert!(store.ito_path_for("org", "repo\\evil").is_err());
246        assert!(store.ito_path_for("", "repo").is_err());
247    }
248
249    #[test]
250    fn project_exists_returns_false_for_missing() {
251        let tmp = tempfile::tempdir().unwrap();
252        let store = FsBackendProjectStore::new(tmp.path());
253        assert!(!store.project_exists("noorg", "norepo"));
254    }
255
256    #[test]
257    fn ensure_project_creates_directory() {
258        let tmp = tempfile::tempdir().unwrap();
259        let store = FsBackendProjectStore::new(tmp.path());
260        store.ensure_project("acme", "widgets").unwrap();
261        assert!(store.project_exists("acme", "widgets"));
262        assert!(store.ito_path_for("acme", "widgets").unwrap().is_dir());
263    }
264
265    #[test]
266    fn change_repository_returns_box_trait() {
267        let tmp = tempfile::tempdir().unwrap();
268        let store = FsBackendProjectStore::new(tmp.path());
269        store.ensure_project("org", "repo").unwrap();
270        let repo = store.change_repository("org", "repo").unwrap();
271        // Should return an empty list for a fresh project
272        let changes = repo.list().unwrap();
273        assert!(changes.is_empty());
274    }
275
276    #[test]
277    fn module_repository_returns_box_trait() {
278        let tmp = tempfile::tempdir().unwrap();
279        let store = FsBackendProjectStore::new(tmp.path());
280        store.ensure_project("org", "repo").unwrap();
281        let repo = store.module_repository("org", "repo").unwrap();
282        let modules = repo.list().unwrap();
283        assert!(modules.is_empty());
284    }
285
286    #[test]
287    fn task_repository_returns_box_trait() {
288        let tmp = tempfile::tempdir().unwrap();
289        let store = FsBackendProjectStore::new(tmp.path());
290        store.ensure_project("org", "repo").unwrap();
291        let repo = store.task_repository("org", "repo").unwrap();
292        // Loading tasks for a non-existent change should return empty
293        let result = repo.load_tasks("nonexistent-change").unwrap();
294        assert_eq!(result.progress.total, 0);
295    }
296
297    #[test]
298    fn store_is_send_sync() {
299        fn assert_send_sync<T: Send + Sync>() {}
300        assert_send_sync::<FsBackendProjectStore>();
301    }
302}