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
94fn 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
179fn read_local_bundle(ito_path: &Path, change_id: &str) -> CoreResult<ArtifactBundle> {
181 let change_dir = paths::changes_dir(ito_path).join(change_id);
182 if !change_dir.is_dir() {
183 return Err(CoreError::not_found(format!(
184 "Change directory not found: {change_id}"
185 )));
186 }
187
188 let proposal = read_optional_file(&change_dir.join("proposal.md"))?;
189 let design = read_optional_file(&change_dir.join("design.md"))?;
190 let tasks = read_optional_file(&change_dir.join("tasks.md"))?;
191
192 let mut specs = Vec::new();
193 let specs_dir = change_dir.join(SPECS_DIR);
194 if specs_dir.is_dir() {
195 let entries =
196 std::fs::read_dir(&specs_dir).map_err(|e| CoreError::io("reading specs dir", e))?;
197 for entry in entries {
198 let entry = entry.map_err(|e| CoreError::io("reading spec entry", e))?;
199 let cap_dir = entry.path();
200 if cap_dir.is_dir() {
201 let spec_file = cap_dir.join("spec.md");
202 if spec_file.is_file() {
203 let content = std::fs::read_to_string(&spec_file)
204 .map_err(|e| CoreError::io("reading spec file", e))?;
205 let cap_name = entry.file_name().to_string_lossy().to_string();
206 specs.push((cap_name, content));
207 }
208 }
209 }
210 }
211 specs.sort_by(|a, b| a.0.cmp(&b.0));
212
213 let revision = read_revision_file(&change_dir)?.unwrap_or_default();
214
215 Ok(ArtifactBundle {
216 change_id: change_id.to_string(),
217 proposal,
218 design,
219 tasks,
220 specs,
221 revision,
222 })
223}
224
225fn read_optional_file(path: &Path) -> CoreResult<Option<String>> {
227 if !path.is_file() {
228 return Ok(None);
229 }
230 let content =
231 std::fs::read_to_string(path).map_err(|e| CoreError::io("reading artifact file", e))?;
232 Ok(Some(content))
233}
234
235fn write_revision_file(change_dir: &Path, revision: &str) -> CoreResult<()> {
237 let path = change_dir.join(REVISION_FILE);
238 std::fs::write(&path, revision).map_err(|e| CoreError::io("writing revision file", e))
239}
240
241fn read_revision_file(change_dir: &Path) -> CoreResult<Option<String>> {
243 let path = change_dir.join(REVISION_FILE);
244 if !path.is_file() {
245 return Ok(None);
246 }
247 let content =
248 std::fs::read_to_string(&path).map_err(|e| CoreError::io("reading revision file", e))?;
249 Ok(Some(content.trim().to_string()))
250}
251
252fn create_backup_snapshot(
256 ito_path: &Path,
257 change_id: &str,
258 backup_dir: &Path,
259 operation: &str,
260) -> CoreResult<()> {
261 let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ");
262 let snapshot_dir = backup_dir.join(format!("{change_id}_{operation}_{timestamp}"));
263 std::fs::create_dir_all(&snapshot_dir)
264 .map_err(|e| CoreError::io("creating backup directory", e))?;
265
266 let change_dir = paths::changes_dir(ito_path).join(change_id);
267 if !change_dir.is_dir() {
268 return Ok(()); }
270
271 for name in ["proposal.md", "design.md", "tasks.md"] {
273 let src = change_dir.join(name);
274 if src.is_file() {
275 let dst = snapshot_dir.join(name);
276 std::fs::copy(&src, &dst).map_err(|e| CoreError::io("backing up artifact", e))?;
277 }
278 }
279
280 let specs_src = change_dir.join(SPECS_DIR);
282 if specs_src.is_dir() {
283 copy_dir_recursive(&specs_src, &snapshot_dir.join(SPECS_DIR))?;
284 }
285
286 Ok(())
287}
288
289fn copy_dir_recursive(src: &Path, dst: &Path) -> CoreResult<()> {
291 std::fs::create_dir_all(dst).map_err(|e| CoreError::io("creating backup subdir", e))?;
292 let entries =
293 std::fs::read_dir(src).map_err(|e| CoreError::io("reading backup source dir", e))?;
294 for entry in entries {
295 let entry = entry.map_err(|e| CoreError::io("reading dir entry", e))?;
296 let src_path = entry.path();
297 let dst_path = dst.join(entry.file_name());
298 if src_path.is_dir() {
299 copy_dir_recursive(&src_path, &dst_path)?;
300 } else {
301 std::fs::copy(&src_path, &dst_path)
302 .map_err(|e| CoreError::io("copying backup file", e))?;
303 }
304 }
305 Ok(())
306}
307
308fn backend_error_to_core(err: BackendError, operation: &str) -> CoreError {
312 match err {
313 BackendError::LeaseConflict(c) => CoreError::validation(format!(
314 "Lease conflict during {operation}: change '{}' is claimed by '{}'",
315 c.change_id, c.holder
316 )),
317 BackendError::RevisionConflict(c) => CoreError::validation(format!(
318 "Revision conflict during {operation} for '{}': \
319 local revision '{}' is stale (server has '{}'). \
320 Run 'ito tasks sync pull {}' first, then retry.",
321 c.change_id, c.local_revision, c.server_revision, c.change_id
322 )),
323 BackendError::Unavailable(msg) => {
324 CoreError::process(format!("Backend unavailable during {operation}: {msg}"))
325 }
326 BackendError::Unauthorized(msg) => {
327 CoreError::validation(format!("Backend auth failed during {operation}: {msg}"))
328 }
329 BackendError::NotFound(msg) => CoreError::not_found(format!(
330 "Backend resource not found during {operation}: {msg}"
331 )),
332 BackendError::Other(msg) => {
333 CoreError::process(format!("Backend error during {operation}: {msg}"))
334 }
335 }
336}
337
338pub fn map_backend_error(err: BackendError, operation: &str) -> CoreError {
340 backend_error_to_core(err, operation)
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use ito_domain::backend::{BackendError, RevisionConflict};
347 use tempfile::TempDir;
348
349 struct FakeSyncClient {
351 pull_result: Result<ArtifactBundle, BackendError>,
352 push_result: Result<PushResult, BackendError>,
353 }
354
355 impl FakeSyncClient {
356 fn success_pull(bundle: ArtifactBundle) -> Self {
357 Self {
358 pull_result: Ok(bundle),
359 push_result: Ok(PushResult {
360 change_id: String::new(),
361 new_revision: String::new(),
362 }),
363 }
364 }
365
366 fn success_push(new_revision: &str) -> Self {
367 Self {
368 pull_result: Err(BackendError::Other("not configured".to_string())),
369 push_result: Ok(PushResult {
370 change_id: String::new(),
371 new_revision: new_revision.to_string(),
372 }),
373 }
374 }
375
376 fn conflict_push(local: &str, server: &str) -> Self {
377 Self {
378 pull_result: Err(BackendError::Other("not configured".to_string())),
379 push_result: Err(BackendError::RevisionConflict(RevisionConflict {
380 change_id: "test".to_string(),
381 local_revision: local.to_string(),
382 server_revision: server.to_string(),
383 })),
384 }
385 }
386 }
387
388 impl BackendSyncClient for FakeSyncClient {
389 fn pull(&self, _change_id: &str) -> Result<ArtifactBundle, BackendError> {
390 self.pull_result.clone()
391 }
392
393 fn push(
394 &self,
395 _change_id: &str,
396 _bundle: &ArtifactBundle,
397 ) -> Result<PushResult, BackendError> {
398 self.push_result.clone()
399 }
400 }
401
402 fn test_bundle(change_id: &str) -> ArtifactBundle {
403 ArtifactBundle {
404 change_id: change_id.to_string(),
405 proposal: Some("# Proposal\nTest".to_string()),
406 design: None,
407 tasks: Some("- [ ] Task 1\n".to_string()),
408 specs: vec![("auth".to_string(), "## ADDED Requirements\n".to_string())],
409 revision: "rev-1".to_string(),
410 }
411 }
412
413 #[test]
414 fn pull_writes_artifacts_locally() {
415 let tmp = TempDir::new().unwrap();
416 let ito_path = tmp.path().join(".ito");
417 let backup_dir = tmp.path().join("backups");
418 std::fs::create_dir_all(&ito_path).unwrap();
419
420 let bundle = test_bundle("test-change");
421 let client = FakeSyncClient::success_pull(bundle);
422
423 let result = pull_artifacts(&client, &ito_path, "test-change", &backup_dir).unwrap();
424 assert_eq!(result.change_id, "test-change");
425 assert_eq!(result.revision, "rev-1");
426
427 let change_dir = ito_path.join("changes").join("test-change");
429 assert!(change_dir.join("proposal.md").is_file());
430 assert!(change_dir.join("tasks.md").is_file());
431 assert!(!change_dir.join("design.md").exists());
432 assert!(change_dir.join("specs/auth/spec.md").is_file());
433 assert!(change_dir.join(REVISION_FILE).is_file());
434
435 let rev = std::fs::read_to_string(change_dir.join(REVISION_FILE)).unwrap();
437 assert_eq!(rev, "rev-1");
438 }
439
440 #[test]
441 fn pull_creates_backup() {
442 let tmp = TempDir::new().unwrap();
443 let ito_path = tmp.path().join(".ito");
444 let backup_dir = tmp.path().join("backups");
445
446 let change_dir = ito_path.join("changes").join("test-change");
448 std::fs::create_dir_all(&change_dir).unwrap();
449 std::fs::write(change_dir.join("proposal.md"), "old proposal").unwrap();
450
451 let bundle = test_bundle("test-change");
452 let client = FakeSyncClient::success_pull(bundle);
453
454 pull_artifacts(&client, &ito_path, "test-change", &backup_dir).unwrap();
455
456 assert!(backup_dir.is_dir());
458 let entries: Vec<_> = std::fs::read_dir(&backup_dir).unwrap().collect();
459 assert_eq!(entries.len(), 1);
460 }
461
462 #[test]
463 fn push_sends_local_bundle() {
464 let tmp = TempDir::new().unwrap();
465 let ito_path = tmp.path().join(".ito");
466 let backup_dir = tmp.path().join("backups");
467
468 let change_dir = ito_path.join("changes").join("test-change");
470 std::fs::create_dir_all(change_dir.join("specs/auth")).unwrap();
471 std::fs::write(change_dir.join("proposal.md"), "# Test Proposal").unwrap();
472 std::fs::write(change_dir.join("tasks.md"), "- [ ] Task").unwrap();
473 std::fs::write(change_dir.join("specs/auth/spec.md"), "## ADDED").unwrap();
474 std::fs::write(change_dir.join(REVISION_FILE), "rev-1").unwrap();
475
476 let client = FakeSyncClient::success_push("rev-2");
477
478 let result = push_artifacts(&client, &ito_path, "test-change", &backup_dir).unwrap();
479 assert_eq!(result.new_revision, "rev-2");
480
481 let rev = std::fs::read_to_string(change_dir.join(REVISION_FILE)).unwrap();
483 assert_eq!(rev, "rev-2");
484 }
485
486 #[test]
487 fn push_conflict_returns_actionable_error() {
488 let tmp = TempDir::new().unwrap();
489 let ito_path = tmp.path().join(".ito");
490 let backup_dir = tmp.path().join("backups");
491
492 let change_dir = ito_path.join("changes").join("test-change");
494 std::fs::create_dir_all(&change_dir).unwrap();
495 std::fs::write(change_dir.join("proposal.md"), "# Test").unwrap();
496
497 let client = FakeSyncClient::conflict_push("rev-1", "rev-3");
498
499 let err = push_artifacts(&client, &ito_path, "test-change", &backup_dir).unwrap_err();
500 let msg = err.to_string();
501 assert!(msg.contains("Revision conflict"), "msg: {msg}");
502 assert!(msg.contains("rev-1"), "msg: {msg}");
503 assert!(msg.contains("rev-3"), "msg: {msg}");
504 assert!(msg.contains("ito tasks sync pull"), "msg: {msg}");
505 }
506
507 #[test]
508 fn push_missing_change_dir_fails() {
509 let tmp = TempDir::new().unwrap();
510 let ito_path = tmp.path().join(".ito");
511 let backup_dir = tmp.path().join("backups");
512 std::fs::create_dir_all(&ito_path).unwrap();
513
514 let client = FakeSyncClient::success_push("rev-2");
515
516 let err = push_artifacts(&client, &ito_path, "nonexistent", &backup_dir).unwrap_err();
517 let msg = err.to_string();
518 assert!(msg.contains("not found"), "msg: {msg}");
519 }
520
521 #[test]
522 fn read_local_bundle_sorts_specs() {
523 let tmp = TempDir::new().unwrap();
524 let ito_path = tmp.path().join(".ito");
525 let change_dir = ito_path.join("changes").join("test-change");
526
527 std::fs::create_dir_all(change_dir.join("specs/z-spec")).unwrap();
529 std::fs::create_dir_all(change_dir.join("specs/a-spec")).unwrap();
530 std::fs::write(change_dir.join("proposal.md"), "# Proposal").unwrap();
531 std::fs::write(change_dir.join("specs/z-spec/spec.md"), "z content").unwrap();
532 std::fs::write(change_dir.join("specs/a-spec/spec.md"), "a content").unwrap();
533
534 let bundle = read_local_bundle(&ito_path, "test-change").unwrap();
535 assert_eq!(bundle.specs.len(), 2);
536 assert_eq!(bundle.specs[0].0, "a-spec");
537 assert_eq!(bundle.specs[1].0, "z-spec");
538 }
539
540 #[test]
541 fn path_traversal_in_change_id_rejected() {
542 let tmp = TempDir::new().unwrap();
543 let ito_path = tmp.path().join(".ito");
544 let backup_dir = tmp.path().join("backups");
545 std::fs::create_dir_all(&ito_path).unwrap();
546
547 let client = FakeSyncClient::success_push("rev-1");
548
549 let err = push_artifacts(&client, &ito_path, "../escape", &backup_dir).unwrap_err();
550 assert!(matches!(err, CoreError::Validation(_)));
551
552 let err = push_artifacts(&client, &ito_path, "foo/bar", &backup_dir).unwrap_err();
553 assert!(matches!(err, CoreError::Validation(_)));
554
555 let err = push_artifacts(&client, &ito_path, "", &backup_dir).unwrap_err();
556 assert!(matches!(err, CoreError::Validation(_)));
557 }
558
559 #[test]
560 fn path_traversal_in_capability_rejected() {
561 let tmp = TempDir::new().unwrap();
562 let ito_path = tmp.path().join(".ito");
563 let backup_dir = tmp.path().join("backups");
564 std::fs::create_dir_all(&ito_path).unwrap();
565
566 let bundle = ArtifactBundle {
567 change_id: "safe-change".to_string(),
568 proposal: None,
569 design: None,
570 tasks: None,
571 specs: vec![("../escape".to_string(), "content".to_string())],
572 revision: "rev-1".to_string(),
573 };
574 let client = FakeSyncClient::success_pull(bundle);
575
576 let err = pull_artifacts(&client, &ito_path, "safe-change", &backup_dir).unwrap_err();
577 assert!(matches!(err, CoreError::Validation(_)));
578 }
579
580 #[test]
581 fn backend_error_mapping_produces_correct_error_types() {
582 let unavailable =
583 backend_error_to_core(BackendError::Unavailable("timeout".to_string()), "pull");
584 assert!(matches!(unavailable, CoreError::Process(_)));
585
586 let auth = backend_error_to_core(
587 BackendError::Unauthorized("invalid token".to_string()),
588 "push",
589 );
590 assert!(matches!(auth, CoreError::Validation(_)));
591
592 let not_found =
593 backend_error_to_core(BackendError::NotFound("change xyz".to_string()), "pull");
594 assert!(matches!(not_found, CoreError::NotFound(_)));
595 }
596}