Skip to main content

ito_core/
backend_sync.rs

1//! Artifact synchronization service for backend mode.
2//!
3//! Orchestrates pull (backend → local) and push (local → backend) flows for
4//! change artifacts, including revision metadata tracking and timestamped
5//! local backup snapshots.
6
7use 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
14/// Metadata written alongside pulled artifacts to track the backend revision.
15const REVISION_FILE: &str = ".backend-revision";
16
17/// Directory under a change for spec delta files.
18const SPECS_DIR: &str = "specs";
19
20/// Validate that a string is safe to use as a path component.
21///
22/// Rejects strings containing path traversal sequences (`..`), path
23/// separators (`/`, `\`), or null bytes. This prevents untrusted values
24/// from the backend from escaping the intended directory.
25fn 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
37// ── Pull ────────────────────────────────────────────────────────────
38
39/// Pull artifacts from the backend for a change and write them locally.
40///
41/// Creates a timestamped backup snapshot under `backup_dir` before writing.
42/// Returns the pulled artifact bundle.
43pub 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 before writing
56    create_backup_snapshot(ito_path, change_id, backup_dir, "pull")?;
57
58    // Write artifacts to the local change directory
59    write_bundle_to_local(ito_path, change_id, &bundle)?;
60
61    Ok(bundle)
62}
63
64/// Push local artifacts to the backend with revision conflict detection.
65///
66/// Creates a timestamped backup snapshot before attempting the push.
67/// Returns the push result on success or a conflict error.
68pub 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 before push
77    create_backup_snapshot(ito_path, change_id, backup_dir, "push")?;
78
79    // Read local artifacts into a bundle
80    let bundle = read_local_bundle(ito_path, change_id)?;
81
82    // Push to backend
83    let result = sync_client
84        .push(change_id, &bundle)
85        .map_err(|e| backend_error_to_core(e, "push"))?;
86
87    // Update local revision metadata
88    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
94// ── Local I/O helpers ───────────────────────────────────────────────
95
96/// Write a pulled artifact bundle to the local change directory.
97pub(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    // Write spec delta files.
136    //
137    // Note: if the backend omits specs that exist locally, we remove those stale
138    // capability subdirectories to keep local state consistent with the bundle.
139    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    // Store revision metadata
174    write_revision_file(&change_dir, &bundle.revision)?;
175
176    Ok(())
177}
178
179/// Read local change artifacts into an artifact bundle for pushing.
180pub(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
185/// Read change artifacts from an explicit directory into an artifact bundle.
186pub(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
233/// Read a file if it exists, returning `None` if absent.
234fn 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
243/// Write the backend revision to a metadata file in the change directory.
244pub(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
249/// Read the backend revision from a metadata file in the change directory.
250pub(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
260// ── Backup ──────────────────────────────────────────────────────────
261
262/// Create a timestamped backup snapshot of local change artifacts.
263fn 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(()); // Nothing to back up
277    }
278
279    // Copy key artifact files
280    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    // Copy spec files
289    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
297/// Recursively copy a directory.
298fn 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
316// ── Error mapping ───────────────────────────────────────────────────
317
318/// Convert a backend-specific error into a `CoreError`.
319fn 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
346/// Convert a `BackendError` to a `CoreError` (public API for CLI use).
347pub 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    /// Fake sync client that returns preconfigured results.
358    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        // Verify files were written
436        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        // Verify revision content
444        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        // Create existing local artifacts to back up
455        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        // Verify backup was created
465        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        // Create local artifacts
477        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        // Verify revision was updated locally
490        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        // Create minimal local artifacts
501        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        // Create specs in reverse order
536        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}