ito_core/
fs_project_store.rs1use 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#[derive(Debug, Clone)]
27pub struct FsBackendProjectStore {
28 data_dir: PathBuf,
29}
30
31fn 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 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
45 Self {
46 data_dir: data_dir.into(),
47 }
48 }
49
50 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
113struct 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
177struct 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
206struct 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 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 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}