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.
97fn 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.
180fn 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
225/// Read a file if it exists, returning `None` if absent.
226fn 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
235/// Write the backend revision to a metadata file in the change directory.
236fn 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
241/// Read the backend revision from a metadata file in the change directory.
242fn 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
252// ── Backup ──────────────────────────────────────────────────────────
253
254/// Create a timestamped backup snapshot of local change artifacts.
255fn 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(()); // Nothing to back up
269    }
270
271    // Copy key artifact files
272    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    // Copy spec files
281    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
289/// Recursively copy a directory.
290fn 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
308// ── Error mapping ───────────────────────────────────────────────────
309
310/// Convert a backend-specific error into a `CoreError`.
311fn 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
338/// Convert a `BackendError` to a `CoreError` (public API for CLI use).
339pub 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    /// Fake sync client that returns preconfigured results.
350    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        // Verify files were written
428        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        // Verify revision content
436        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        // Create existing local artifacts to back up
447        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        // Verify backup was created
457        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        // Create local artifacts
469        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        // Verify revision was updated locally
482        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        // Create minimal local artifacts
493        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        // Create specs in reverse order
528        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}