1use 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#[derive(Debug, Clone)]
50pub struct FsBackendProjectStore {
51 data_dir: PathBuf,
52}
53
54fn 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 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
68 Self {
69 data_dir: data_dir.into(),
70 }
71 }
72
73 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 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 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}