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)]
352mod tests {
353 use super::*;
354 use ito_domain::backend::{BackendError, RevisionConflict};
355 use tempfile::TempDir;
356
357 struct FakeSyncClient {
359 pull_result: Result<ArtifactBundle, BackendError>,
360 push_result: Result<PushResult, BackendError>,
361 }
362
363 impl FakeSyncClient {
364 fn success_pull(bundle: ArtifactBundle) -> Self {
365 Self {
366 pull_result: Ok(bundle),
367 push_result: Ok(PushResult {
368 change_id: String::new(),
369 new_revision: String::new(),
370 }),
371 }
372 }
373
374 fn success_push(new_revision: &str) -> Self {
375 Self {
376 pull_result: Err(BackendError::Other("not configured".to_string())),
377 push_result: Ok(PushResult {
378 change_id: String::new(),
379 new_revision: new_revision.to_string(),
380 }),
381 }
382 }
383
384 fn conflict_push(local: &str, server: &str) -> Self {
385 Self {
386 pull_result: Err(BackendError::Other("not configured".to_string())),
387 push_result: Err(BackendError::RevisionConflict(RevisionConflict {
388 change_id: "test".to_string(),
389 local_revision: local.to_string(),
390 server_revision: server.to_string(),
391 })),
392 }
393 }
394 }
395
396 impl BackendSyncClient for FakeSyncClient {
397 fn pull(&self, _change_id: &str) -> Result<ArtifactBundle, BackendError> {
398 self.pull_result.clone()
399 }
400
401 fn push(
402 &self,
403 _change_id: &str,
404 _bundle: &ArtifactBundle,
405 ) -> Result<PushResult, BackendError> {
406 self.push_result.clone()
407 }
408 }
409
410 fn test_bundle(change_id: &str) -> ArtifactBundle {
411 ArtifactBundle {
412 change_id: change_id.to_string(),
413 proposal: Some("# Proposal\nTest".to_string()),
414 design: None,
415 tasks: Some("- [ ] Task 1\n".to_string()),
416 specs: vec![("auth".to_string(), "## ADDED Requirements\n".to_string())],
417 revision: "rev-1".to_string(),
418 }
419 }
420
421 #[test]
422 fn pull_writes_artifacts_locally() {
423 let tmp = TempDir::new().unwrap();
424 let ito_path = tmp.path().join(".ito");
425 let backup_dir = tmp.path().join("backups");
426 std::fs::create_dir_all(&ito_path).unwrap();
427
428 let bundle = test_bundle("test-change");
429 let client = FakeSyncClient::success_pull(bundle);
430
431 let result = pull_artifacts(&client, &ito_path, "test-change", &backup_dir).unwrap();
432 assert_eq!(result.change_id, "test-change");
433 assert_eq!(result.revision, "rev-1");
434
435 let change_dir = ito_path.join("changes").join("test-change");
437 assert!(change_dir.join("proposal.md").is_file());
438 assert!(change_dir.join("tasks.md").is_file());
439 assert!(!change_dir.join("design.md").exists());
440 assert!(change_dir.join("specs/auth/spec.md").is_file());
441 assert!(change_dir.join(REVISION_FILE).is_file());
442
443 let rev = std::fs::read_to_string(change_dir.join(REVISION_FILE)).unwrap();
445 assert_eq!(rev, "rev-1");
446 }
447
448 #[test]
449 fn pull_creates_backup() {
450 let tmp = TempDir::new().unwrap();
451 let ito_path = tmp.path().join(".ito");
452 let backup_dir = tmp.path().join("backups");
453
454 let change_dir = ito_path.join("changes").join("test-change");
456 std::fs::create_dir_all(&change_dir).unwrap();
457 std::fs::write(change_dir.join("proposal.md"), "old proposal").unwrap();
458
459 let bundle = test_bundle("test-change");
460 let client = FakeSyncClient::success_pull(bundle);
461
462 pull_artifacts(&client, &ito_path, "test-change", &backup_dir).unwrap();
463
464 assert!(backup_dir.is_dir());
466 let entries: Vec<_> = std::fs::read_dir(&backup_dir).unwrap().collect();
467 assert_eq!(entries.len(), 1);
468 }
469
470 #[test]
471 fn push_sends_local_bundle() {
472 let tmp = TempDir::new().unwrap();
473 let ito_path = tmp.path().join(".ito");
474 let backup_dir = tmp.path().join("backups");
475
476 let change_dir = ito_path.join("changes").join("test-change");
478 std::fs::create_dir_all(change_dir.join("specs/auth")).unwrap();
479 std::fs::write(change_dir.join("proposal.md"), "# Test Proposal").unwrap();
480 std::fs::write(change_dir.join("tasks.md"), "- [ ] Task").unwrap();
481 std::fs::write(change_dir.join("specs/auth/spec.md"), "## ADDED").unwrap();
482 std::fs::write(change_dir.join(REVISION_FILE), "rev-1").unwrap();
483
484 let client = FakeSyncClient::success_push("rev-2");
485
486 let result = push_artifacts(&client, &ito_path, "test-change", &backup_dir).unwrap();
487 assert_eq!(result.new_revision, "rev-2");
488
489 let rev = std::fs::read_to_string(change_dir.join(REVISION_FILE)).unwrap();
491 assert_eq!(rev, "rev-2");
492 }
493
494 #[test]
495 fn push_conflict_returns_actionable_error() {
496 let tmp = TempDir::new().unwrap();
497 let ito_path = tmp.path().join(".ito");
498 let backup_dir = tmp.path().join("backups");
499
500 let change_dir = ito_path.join("changes").join("test-change");
502 std::fs::create_dir_all(&change_dir).unwrap();
503 std::fs::write(change_dir.join("proposal.md"), "# Test").unwrap();
504
505 let client = FakeSyncClient::conflict_push("rev-1", "rev-3");
506
507 let err = push_artifacts(&client, &ito_path, "test-change", &backup_dir).unwrap_err();
508 let msg = err.to_string();
509 assert!(msg.contains("Revision conflict"), "msg: {msg}");
510 assert!(msg.contains("rev-1"), "msg: {msg}");
511 assert!(msg.contains("rev-3"), "msg: {msg}");
512 assert!(msg.contains("ito tasks sync pull"), "msg: {msg}");
513 }
514
515 #[test]
516 fn push_missing_change_dir_fails() {
517 let tmp = TempDir::new().unwrap();
518 let ito_path = tmp.path().join(".ito");
519 let backup_dir = tmp.path().join("backups");
520 std::fs::create_dir_all(&ito_path).unwrap();
521
522 let client = FakeSyncClient::success_push("rev-2");
523
524 let err = push_artifacts(&client, &ito_path, "nonexistent", &backup_dir).unwrap_err();
525 let msg = err.to_string();
526 assert!(msg.contains("not found"), "msg: {msg}");
527 }
528
529 #[test]
530 fn read_local_bundle_sorts_specs() {
531 let tmp = TempDir::new().unwrap();
532 let ito_path = tmp.path().join(".ito");
533 let change_dir = ito_path.join("changes").join("test-change");
534
535 std::fs::create_dir_all(change_dir.join("specs/z-spec")).unwrap();
537 std::fs::create_dir_all(change_dir.join("specs/a-spec")).unwrap();
538 std::fs::write(change_dir.join("proposal.md"), "# Proposal").unwrap();
539 std::fs::write(change_dir.join("specs/z-spec/spec.md"), "z content").unwrap();
540 std::fs::write(change_dir.join("specs/a-spec/spec.md"), "a content").unwrap();
541
542 let bundle = read_local_bundle(&ito_path, "test-change").unwrap();
543 assert_eq!(bundle.specs.len(), 2);
544 assert_eq!(bundle.specs[0].0, "a-spec");
545 assert_eq!(bundle.specs[1].0, "z-spec");
546 }
547
548 #[test]
549 fn path_traversal_in_change_id_rejected() {
550 let tmp = TempDir::new().unwrap();
551 let ito_path = tmp.path().join(".ito");
552 let backup_dir = tmp.path().join("backups");
553 std::fs::create_dir_all(&ito_path).unwrap();
554
555 let client = FakeSyncClient::success_push("rev-1");
556
557 let err = push_artifacts(&client, &ito_path, "../escape", &backup_dir).unwrap_err();
558 assert!(matches!(err, CoreError::Validation(_)));
559
560 let err = push_artifacts(&client, &ito_path, "foo/bar", &backup_dir).unwrap_err();
561 assert!(matches!(err, CoreError::Validation(_)));
562
563 let err = push_artifacts(&client, &ito_path, "", &backup_dir).unwrap_err();
564 assert!(matches!(err, CoreError::Validation(_)));
565 }
566
567 #[test]
568 fn path_traversal_in_capability_rejected() {
569 let tmp = TempDir::new().unwrap();
570 let ito_path = tmp.path().join(".ito");
571 let backup_dir = tmp.path().join("backups");
572 std::fs::create_dir_all(&ito_path).unwrap();
573
574 let bundle = ArtifactBundle {
575 change_id: "safe-change".to_string(),
576 proposal: None,
577 design: None,
578 tasks: None,
579 specs: vec![("../escape".to_string(), "content".to_string())],
580 revision: "rev-1".to_string(),
581 };
582 let client = FakeSyncClient::success_pull(bundle);
583
584 let err = pull_artifacts(&client, &ito_path, "safe-change", &backup_dir).unwrap_err();
585 assert!(matches!(err, CoreError::Validation(_)));
586 }
587
588 #[test]
589 fn backend_error_mapping_produces_correct_error_types() {
590 let unavailable =
591 backend_error_to_core(BackendError::Unavailable("timeout".to_string()), "pull");
592 assert!(matches!(unavailable, CoreError::Process(_)));
593
594 let auth = backend_error_to_core(
595 BackendError::Unauthorized("invalid token".to_string()),
596 "push",
597 );
598 assert!(matches!(auth, CoreError::Validation(_)));
599
600 let not_found =
601 backend_error_to_core(BackendError::NotFound("change xyz".to_string()), "pull");
602 assert!(matches!(not_found, CoreError::NotFound(_)));
603 }
604}