1use std::path::Path;
8
9use crate::errors::{CoreError, CoreResult};
10use chrono::Utc;
11use ito_common::paths;
12use ito_domain::backend::{ArtifactBundle, BackendError, BackendSyncClient, PushResult};
13
14const REVISION_FILE: &str = ".backend-revision";
16
17const SPECS_DIR: &str = "specs";
19
20fn validate_path_component(name: &str, label: &str) -> CoreResult<()> {
26 if name.is_empty() {
27 return Err(CoreError::Validation(format!("{label} must not be empty")));
28 }
29 if name.contains("..") || name.contains('/') || name.contains('\\') || name.contains('\0') {
30 return Err(CoreError::Validation(format!(
31 "{label} contains unsafe path characters: {name:?}"
32 )));
33 }
34 Ok(())
35}
36
37pub fn pull_artifacts<S: BackendSyncClient + ?Sized>(
44 sync_client: &S,
45 ito_path: &Path,
46 change_id: &str,
47 backup_dir: &Path,
48) -> CoreResult<ArtifactBundle> {
49 validate_path_component(change_id, "change_id")?;
50
51 let bundle = sync_client
52 .pull(change_id)
53 .map_err(|e| backend_error_to_core(e, "pull"))?;
54
55 create_backup_snapshot(ito_path, change_id, backup_dir, "pull")?;
57
58 write_bundle_to_local(ito_path, change_id, &bundle)?;
60
61 Ok(bundle)
62}
63
64pub fn push_artifacts<S: BackendSyncClient + ?Sized>(
69 sync_client: &S,
70 ito_path: &Path,
71 change_id: &str,
72 backup_dir: &Path,
73) -> CoreResult<PushResult> {
74 validate_path_component(change_id, "change_id")?;
75
76 create_backup_snapshot(ito_path, change_id, backup_dir, "push")?;
78
79 let bundle = read_local_bundle(ito_path, change_id)?;
81
82 let result = sync_client
84 .push(change_id, &bundle)
85 .map_err(|e| backend_error_to_core(e, "push"))?;
86
87 let change_dir = paths::changes_dir(ito_path).join(change_id);
89 write_revision_file(&change_dir, &result.new_revision)?;
90
91 Ok(result)
92}
93
94pub(crate) fn write_bundle_to_local(
98 ito_path: &Path,
99 change_id: &str,
100 bundle: &ArtifactBundle,
101) -> CoreResult<()> {
102 let change_dir = paths::changes_dir(ito_path).join(change_id);
103 std::fs::create_dir_all(&change_dir)
104 .map_err(|e| CoreError::io("creating change directory", e))?;
105
106 fn remove_file_if_exists(path: &Path, label: &'static str) -> CoreResult<()> {
107 if path.is_file() {
108 std::fs::remove_file(path).map_err(|e| CoreError::io(label, e))?;
109 }
110 Ok(())
111 }
112
113 let proposal_path = change_dir.join("proposal.md");
114 if let Some(proposal) = &bundle.proposal {
115 std::fs::write(&proposal_path, proposal)
116 .map_err(|e| CoreError::io("writing proposal.md", e))?;
117 } else {
118 remove_file_if_exists(&proposal_path, "removing proposal.md")?;
119 }
120
121 let design_path = change_dir.join("design.md");
122 if let Some(design) = &bundle.design {
123 std::fs::write(&design_path, design).map_err(|e| CoreError::io("writing design.md", e))?;
124 } else {
125 remove_file_if_exists(&design_path, "removing design.md")?;
126 }
127
128 let tasks_path = change_dir.join("tasks.md");
129 if let Some(tasks) = &bundle.tasks {
130 std::fs::write(&tasks_path, tasks).map_err(|e| CoreError::io("writing tasks.md", e))?;
131 } else {
132 remove_file_if_exists(&tasks_path, "removing tasks.md")?;
133 }
134
135 let specs_dir = change_dir.join(SPECS_DIR);
140 let mut expected_caps: std::collections::HashSet<String> = std::collections::HashSet::new();
141
142 for (capability, content) in &bundle.specs {
143 validate_path_component(capability, "capability")?;
144 expected_caps.insert(capability.to_string());
145
146 let cap_dir = specs_dir.join(capability);
147 std::fs::create_dir_all(&cap_dir)
148 .map_err(|e| CoreError::io("creating spec directory", e))?;
149 std::fs::write(cap_dir.join("spec.md"), content)
150 .map_err(|e| CoreError::io("writing spec delta", e))?;
151 }
152
153 if specs_dir.is_dir() {
154 let entries =
155 std::fs::read_dir(&specs_dir).map_err(|e| CoreError::io("reading specs dir", e))?;
156 for entry in entries {
157 let entry = entry.map_err(|e| CoreError::io("reading spec entry", e))?;
158 let path = entry.path();
159 if !path.is_dir() {
160 continue;
161 }
162
163 let cap_name = entry.file_name().to_string_lossy().to_string();
164 validate_path_component(&cap_name, "capability")?;
165
166 if !expected_caps.contains(&cap_name) {
167 std::fs::remove_dir_all(&path)
168 .map_err(|e| CoreError::io("removing stale spec directory", e))?;
169 }
170 }
171 }
172
173 write_revision_file(&change_dir, &bundle.revision)?;
175
176 Ok(())
177}
178
179pub(crate) fn read_local_bundle(ito_path: &Path, change_id: &str) -> CoreResult<ArtifactBundle> {
181 let change_dir = paths::changes_dir(ito_path).join(change_id);
182 read_bundle_from_change_dir(&change_dir, change_id)
183}
184
185pub(crate) fn read_bundle_from_change_dir(
187 change_dir: &Path,
188 change_id: &str,
189) -> CoreResult<ArtifactBundle> {
190 if !change_dir.is_dir() {
191 return Err(CoreError::not_found(format!(
192 "Change directory not found: {change_id}"
193 )));
194 }
195
196 let proposal = read_optional_file(&change_dir.join("proposal.md"))?;
197 let design = read_optional_file(&change_dir.join("design.md"))?;
198 let tasks = read_optional_file(&change_dir.join("tasks.md"))?;
199
200 let mut specs = Vec::new();
201 let specs_dir = change_dir.join(SPECS_DIR);
202 if specs_dir.is_dir() {
203 let entries =
204 std::fs::read_dir(&specs_dir).map_err(|e| CoreError::io("reading specs dir", e))?;
205 for entry in entries {
206 let entry = entry.map_err(|e| CoreError::io("reading spec entry", e))?;
207 let cap_dir = entry.path();
208 if cap_dir.is_dir() {
209 let spec_file = cap_dir.join("spec.md");
210 if spec_file.is_file() {
211 let content = std::fs::read_to_string(&spec_file)
212 .map_err(|e| CoreError::io("reading spec file", e))?;
213 let cap_name = entry.file_name().to_string_lossy().to_string();
214 specs.push((cap_name, content));
215 }
216 }
217 }
218 }
219 specs.sort_by(|a, b| a.0.cmp(&b.0));
220
221 let revision = read_revision_file(change_dir)?.unwrap_or_default();
222
223 Ok(ArtifactBundle {
224 change_id: change_id.to_string(),
225 proposal,
226 design,
227 tasks,
228 specs,
229 revision,
230 })
231}
232
233fn read_optional_file(path: &Path) -> CoreResult<Option<String>> {
235 if !path.is_file() {
236 return Ok(None);
237 }
238 let content =
239 std::fs::read_to_string(path).map_err(|e| CoreError::io("reading artifact file", e))?;
240 Ok(Some(content))
241}
242
243pub(crate) fn write_revision_file(change_dir: &Path, revision: &str) -> CoreResult<()> {
245 let path = change_dir.join(REVISION_FILE);
246 std::fs::write(&path, revision).map_err(|e| CoreError::io("writing revision file", e))
247}
248
249pub(crate) fn read_revision_file(change_dir: &Path) -> CoreResult<Option<String>> {
251 let path = change_dir.join(REVISION_FILE);
252 if !path.is_file() {
253 return Ok(None);
254 }
255 let content =
256 std::fs::read_to_string(&path).map_err(|e| CoreError::io("reading revision file", e))?;
257 Ok(Some(content.trim().to_string()))
258}
259
260fn create_backup_snapshot(
264 ito_path: &Path,
265 change_id: &str,
266 backup_dir: &Path,
267 operation: &str,
268) -> CoreResult<()> {
269 let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ");
270 let snapshot_dir = backup_dir.join(format!("{change_id}_{operation}_{timestamp}"));
271 std::fs::create_dir_all(&snapshot_dir)
272 .map_err(|e| CoreError::io("creating backup directory", e))?;
273
274 let change_dir = paths::changes_dir(ito_path).join(change_id);
275 if !change_dir.is_dir() {
276 return Ok(()); }
278
279 for name in ["proposal.md", "design.md", "tasks.md"] {
281 let src = change_dir.join(name);
282 if src.is_file() {
283 let dst = snapshot_dir.join(name);
284 std::fs::copy(&src, &dst).map_err(|e| CoreError::io("backing up artifact", e))?;
285 }
286 }
287
288 let specs_src = change_dir.join(SPECS_DIR);
290 if specs_src.is_dir() {
291 copy_dir_recursive(&specs_src, &snapshot_dir.join(SPECS_DIR))?;
292 }
293
294 Ok(())
295}
296
297fn copy_dir_recursive(src: &Path, dst: &Path) -> CoreResult<()> {
299 std::fs::create_dir_all(dst).map_err(|e| CoreError::io("creating backup subdir", e))?;
300 let entries =
301 std::fs::read_dir(src).map_err(|e| CoreError::io("reading backup source dir", e))?;
302 for entry in entries {
303 let entry = entry.map_err(|e| CoreError::io("reading dir entry", e))?;
304 let src_path = entry.path();
305 let dst_path = dst.join(entry.file_name());
306 if src_path.is_dir() {
307 copy_dir_recursive(&src_path, &dst_path)?;
308 } else {
309 std::fs::copy(&src_path, &dst_path)
310 .map_err(|e| CoreError::io("copying backup file", e))?;
311 }
312 }
313 Ok(())
314}
315
316fn backend_error_to_core(err: BackendError, operation: &str) -> CoreError {
320 match err {
321 BackendError::LeaseConflict(c) => CoreError::validation(format!(
322 "Lease conflict during {operation}: change '{}' is claimed by '{}'",
323 c.change_id, c.holder
324 )),
325 BackendError::RevisionConflict(c) => CoreError::validation(format!(
326 "Revision conflict during {operation} for '{}': \
327 local revision '{}' is stale (server has '{}'). \
328 Run 'ito tasks sync pull {}' first, then retry.",
329 c.change_id, c.local_revision, c.server_revision, c.change_id
330 )),
331 BackendError::Unavailable(msg) => {
332 CoreError::process(format!("Backend unavailable during {operation}: {msg}"))
333 }
334 BackendError::Unauthorized(msg) => {
335 CoreError::validation(format!("Backend auth failed during {operation}: {msg}"))
336 }
337 BackendError::NotFound(msg) => CoreError::not_found(format!(
338 "Backend resource not found during {operation}: {msg}"
339 )),
340 BackendError::Other(msg) => {
341 CoreError::process(format!("Backend error during {operation}: {msg}"))
342 }
343 }
344}
345
346pub fn map_backend_error(err: BackendError, operation: &str) -> CoreError {
348 backend_error_to_core(err, operation)
349}
350
351#[cfg(test)]
352#[path = "backend_sync_tests.rs"]
353mod backend_sync_tests;