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 chrono::{DateTime, Utc};
11use ito_domain::backend::{
12    ArchiveResult, ArtifactBundle, BackendError, BackendProjectStore, PushResult,
13};
14use ito_domain::changes::ChangeRepository;
15use ito_domain::errors::{DomainError, DomainResult};
16use ito_domain::modules::ModuleRepository;
17use ito_domain::tasks::TaskRepository;
18
19use crate::repository_runtime::{
20    boxed_fs_change_repository, boxed_fs_module_repository, boxed_fs_spec_repository,
21    boxed_fs_task_mutation_port, boxed_fs_task_repository,
22};
23
24fn filesystem_revision(ito_path: &std::path::Path, change_id: &str) -> String {
25    let change_dir = ito_common::paths::changes_dir(ito_path).join(change_id);
26    if let Ok(Some(revision)) = crate::backend_sync::read_revision_file(&change_dir)
27        && !revision.trim().is_empty()
28    {
29        return revision;
30    }
31
32    let mut latest: Option<DateTime<Utc>> = None;
33    for relative in ["proposal.md", "design.md", "tasks.md"] {
34        let path = change_dir.join(relative);
35        if let Ok(metadata) = std::fs::metadata(&path)
36            && let Ok(modified) = metadata.modified()
37        {
38            let timestamp = DateTime::<Utc>::from(modified);
39            latest = Some(latest.map_or(timestamp, |current| current.max(timestamp)));
40        }
41    }
42    latest.unwrap_or_else(Utc::now).to_rfc3339()
43}
44
45/// Filesystem-backed project store rooted at a configurable data directory.
46///
47/// Projects are stored under `<data_dir>/projects/{org}/{repo}/.ito/`.
48/// Repositories are constructed per-request using the resolved `.ito/` path.
49#[derive(Debug, Clone)]
50pub struct FsBackendProjectStore {
51    data_dir: PathBuf,
52}
53
54/// Check that a path segment is safe for use in filesystem paths.
55///
56/// Rejects empty strings, `.`, `..`, and values containing `/` or `\`.
57fn is_safe_path_segment(value: &str) -> bool {
58    !value.is_empty()
59        && value != "."
60        && value != ".."
61        && !value.contains('/')
62        && !value.contains('\\')
63}
64
65impl FsBackendProjectStore {
66    /// Create a new filesystem project store rooted at the given data directory.
67    pub fn new(data_dir: impl Into<PathBuf>) -> Self {
68        Self {
69            data_dir: data_dir.into(),
70        }
71    }
72
73    /// Compute the `.ito/` path for a project.
74    ///
75    /// Returns an error if `org` or `repo` contain path traversal characters.
76    pub fn ito_path_for(&self, org: &str, repo: &str) -> DomainResult<PathBuf> {
77        if !is_safe_path_segment(org) || !is_safe_path_segment(repo) {
78            return Err(DomainError::io(
79                "invalid path segment in org/repo",
80                std::io::Error::new(
81                    std::io::ErrorKind::InvalidInput,
82                    format!("invalid path segment: org={org:?}, repo={repo:?}"),
83                ),
84            ));
85        }
86        Ok(self
87            .data_dir
88            .join("projects")
89            .join(org)
90            .join(repo)
91            .join(".ito"))
92    }
93}
94
95impl BackendProjectStore for FsBackendProjectStore {
96    fn change_repository(
97        &self,
98        org: &str,
99        repo: &str,
100    ) -> DomainResult<Box<dyn ChangeRepository + Send>> {
101        let ito_path = self.ito_path_for(org, repo)?;
102        Ok(boxed_fs_change_repository(ito_path))
103    }
104
105    fn module_repository(
106        &self,
107        org: &str,
108        repo: &str,
109    ) -> DomainResult<Box<dyn ModuleRepository + Send>> {
110        let ito_path = self.ito_path_for(org, repo)?;
111        Ok(boxed_fs_module_repository(ito_path))
112    }
113
114    fn task_repository(
115        &self,
116        org: &str,
117        repo: &str,
118    ) -> DomainResult<Box<dyn TaskRepository + Send>> {
119        let ito_path = self.ito_path_for(org, repo)?;
120        Ok(boxed_fs_task_repository(ito_path))
121    }
122
123    fn task_mutation_service(
124        &self,
125        org: &str,
126        repo: &str,
127    ) -> DomainResult<Box<dyn ito_domain::tasks::TaskMutationService + Send>> {
128        let ito_path = self.ito_path_for(org, repo)?;
129        Ok(boxed_fs_task_mutation_port(ito_path))
130    }
131
132    fn spec_repository(
133        &self,
134        org: &str,
135        repo: &str,
136    ) -> DomainResult<Box<dyn ito_domain::specs::SpecRepository + Send>> {
137        let ito_path = self.ito_path_for(org, repo)?;
138        Ok(boxed_fs_spec_repository(ito_path))
139    }
140
141    fn pull_artifact_bundle(
142        &self,
143        org: &str,
144        repo: &str,
145        change_id: &str,
146    ) -> Result<ArtifactBundle, BackendError> {
147        let ito_path = self
148            .ito_path_for(org, repo)
149            .map_err(|err| BackendError::Other(err.to_string()))?;
150        let mut bundle = crate::backend_sync::read_local_bundle(&ito_path, change_id)
151            .map_err(|err| BackendError::NotFound(err.to_string()))?;
152        bundle.revision = filesystem_revision(&ito_path, change_id);
153        Ok(bundle)
154    }
155
156    fn push_artifact_bundle(
157        &self,
158        org: &str,
159        repo: &str,
160        change_id: &str,
161        bundle: &ArtifactBundle,
162    ) -> Result<PushResult, BackendError> {
163        let ito_path = self
164            .ito_path_for(org, repo)
165            .map_err(|err| BackendError::Other(err.to_string()))?;
166        let current_revision = filesystem_revision(&ito_path, change_id);
167        if !bundle.revision.trim().is_empty() && bundle.revision != current_revision {
168            return Err(BackendError::RevisionConflict(
169                ito_domain::backend::RevisionConflict {
170                    change_id: change_id.to_string(),
171                    local_revision: bundle.revision.clone(),
172                    server_revision: current_revision,
173                },
174            ));
175        }
176
177        let new_revision = Utc::now().to_rfc3339();
178        let mut next = bundle.clone();
179        next.change_id = change_id.to_string();
180        next.revision = new_revision.clone();
181        crate::backend_sync::write_bundle_to_local(&ito_path, change_id, &next)
182            .map_err(|err| BackendError::Other(err.to_string()))?;
183
184        Ok(PushResult {
185            change_id: change_id.to_string(),
186            new_revision,
187        })
188    }
189
190    fn archive_change(
191        &self,
192        org: &str,
193        repo: &str,
194        change_id: &str,
195    ) -> Result<ArchiveResult, BackendError> {
196        let ito_path = self
197            .ito_path_for(org, repo)
198            .map_err(|err| BackendError::Other(err.to_string()))?;
199        let spec_names = crate::archive::discover_change_specs(&ito_path, change_id)
200            .map_err(|err| BackendError::Other(err.to_string()))?;
201        crate::archive::copy_specs_to_main(&ito_path, change_id, &spec_names)
202            .map_err(|err| BackendError::Other(err.to_string()))?;
203        crate::archive::mark_change_complete_in_module_markdown(&ito_path, change_id)
204            .map_err(|err| BackendError::Other(err.to_string()))?;
205        let archive_name = crate::archive::generate_archive_name(change_id);
206        crate::archive::move_to_archive(&ito_path, change_id, &archive_name)
207            .map_err(|err| BackendError::Other(err.to_string()))?;
208
209        Ok(ArchiveResult {
210            change_id: change_id.to_string(),
211            archived_at: Utc::now().to_rfc3339(),
212        })
213    }
214
215    fn ensure_project(&self, org: &str, repo: &str) -> DomainResult<()> {
216        let ito_path = self.ito_path_for(org, repo)?;
217        std::fs::create_dir_all(&ito_path)
218            .map_err(|e| DomainError::io("creating project directory", e))
219    }
220
221    fn project_exists(&self, org: &str, repo: &str) -> bool {
222        self.ito_path_for(org, repo)
223            .map(|p| p.is_dir())
224            .unwrap_or(false)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn ito_path_resolves_correctly() {
234        let store = FsBackendProjectStore::new("/data");
235        let path = store.ito_path_for("withakay", "ito").unwrap();
236        assert_eq!(path, PathBuf::from("/data/projects/withakay/ito/.ito"));
237    }
238
239    #[test]
240    fn ito_path_rejects_path_traversal() {
241        let store = FsBackendProjectStore::new("/data");
242        assert!(store.ito_path_for("..", "ito").is_err());
243        assert!(store.ito_path_for("org", "..").is_err());
244        assert!(store.ito_path_for(".", "repo").is_err());
245        assert!(store.ito_path_for("org/evil", "repo").is_err());
246        assert!(store.ito_path_for("org", "repo\\evil").is_err());
247        assert!(store.ito_path_for("", "repo").is_err());
248    }
249
250    #[test]
251    fn project_exists_returns_false_for_missing() {
252        let tmp = tempfile::tempdir().unwrap();
253        let store = FsBackendProjectStore::new(tmp.path());
254        assert!(!store.project_exists("noorg", "norepo"));
255    }
256
257    #[test]
258    fn ensure_project_creates_directory() {
259        let tmp = tempfile::tempdir().unwrap();
260        let store = FsBackendProjectStore::new(tmp.path());
261        store.ensure_project("acme", "widgets").unwrap();
262        assert!(store.project_exists("acme", "widgets"));
263        assert!(store.ito_path_for("acme", "widgets").unwrap().is_dir());
264    }
265
266    #[test]
267    fn change_repository_returns_box_trait() {
268        let tmp = tempfile::tempdir().unwrap();
269        let store = FsBackendProjectStore::new(tmp.path());
270        store.ensure_project("org", "repo").unwrap();
271        let repo = store.change_repository("org", "repo").unwrap();
272        // Should return an empty list for a fresh project
273        let changes = repo.list().unwrap();
274        assert!(changes.is_empty());
275    }
276
277    #[test]
278    fn module_repository_returns_box_trait() {
279        let tmp = tempfile::tempdir().unwrap();
280        let store = FsBackendProjectStore::new(tmp.path());
281        store.ensure_project("org", "repo").unwrap();
282        let repo = store.module_repository("org", "repo").unwrap();
283        let modules = repo.list().unwrap();
284        assert!(modules.is_empty());
285    }
286
287    #[test]
288    fn task_repository_returns_box_trait() {
289        let tmp = tempfile::tempdir().unwrap();
290        let store = FsBackendProjectStore::new(tmp.path());
291        store.ensure_project("org", "repo").unwrap();
292        let repo = store.task_repository("org", "repo").unwrap();
293        // Loading tasks for a non-existent change should return empty
294        let result = repo.load_tasks("nonexistent-change").unwrap();
295        assert_eq!(result.progress.total, 0);
296    }
297
298    #[test]
299    fn store_is_send_sync() {
300        fn assert_send_sync<T: Send + Sync>() {}
301        assert_send_sync::<FsBackendProjectStore>();
302    }
303}