use std::path::Path;
use crate::errors::{CoreError, CoreResult};
use chrono::Utc;
use ito_common::paths;
use ito_domain::backend::{ArtifactBundle, BackendError, BackendSyncClient, PushResult};
const REVISION_FILE: &str = ".backend-revision";
const SPECS_DIR: &str = "specs";
fn validate_path_component(name: &str, label: &str) -> CoreResult<()> {
if name.is_empty() {
return Err(CoreError::Validation(format!("{label} must not be empty")));
}
if name.contains("..") || name.contains('/') || name.contains('\\') || name.contains('\0') {
return Err(CoreError::Validation(format!(
"{label} contains unsafe path characters: {name:?}"
)));
}
Ok(())
}
pub fn pull_artifacts<S: BackendSyncClient + ?Sized>(
sync_client: &S,
ito_path: &Path,
change_id: &str,
backup_dir: &Path,
) -> CoreResult<ArtifactBundle> {
validate_path_component(change_id, "change_id")?;
let bundle = sync_client
.pull(change_id)
.map_err(|e| backend_error_to_core(e, "pull"))?;
create_backup_snapshot(ito_path, change_id, backup_dir, "pull")?;
write_bundle_to_local(ito_path, change_id, &bundle)?;
Ok(bundle)
}
pub fn push_artifacts<S: BackendSyncClient + ?Sized>(
sync_client: &S,
ito_path: &Path,
change_id: &str,
backup_dir: &Path,
) -> CoreResult<PushResult> {
validate_path_component(change_id, "change_id")?;
create_backup_snapshot(ito_path, change_id, backup_dir, "push")?;
let bundle = read_local_bundle(ito_path, change_id)?;
let result = sync_client
.push(change_id, &bundle)
.map_err(|e| backend_error_to_core(e, "push"))?;
let change_dir = paths::changes_dir(ito_path).join(change_id);
write_revision_file(&change_dir, &result.new_revision)?;
Ok(result)
}
pub(crate) fn write_bundle_to_local(
ito_path: &Path,
change_id: &str,
bundle: &ArtifactBundle,
) -> CoreResult<()> {
let change_dir = paths::changes_dir(ito_path).join(change_id);
std::fs::create_dir_all(&change_dir)
.map_err(|e| CoreError::io("creating change directory", e))?;
fn remove_file_if_exists(path: &Path, label: &'static str) -> CoreResult<()> {
if path.is_file() {
std::fs::remove_file(path).map_err(|e| CoreError::io(label, e))?;
}
Ok(())
}
let proposal_path = change_dir.join("proposal.md");
if let Some(proposal) = &bundle.proposal {
std::fs::write(&proposal_path, proposal)
.map_err(|e| CoreError::io("writing proposal.md", e))?;
} else {
remove_file_if_exists(&proposal_path, "removing proposal.md")?;
}
let design_path = change_dir.join("design.md");
if let Some(design) = &bundle.design {
std::fs::write(&design_path, design).map_err(|e| CoreError::io("writing design.md", e))?;
} else {
remove_file_if_exists(&design_path, "removing design.md")?;
}
let tasks_path = change_dir.join("tasks.md");
if let Some(tasks) = &bundle.tasks {
std::fs::write(&tasks_path, tasks).map_err(|e| CoreError::io("writing tasks.md", e))?;
} else {
remove_file_if_exists(&tasks_path, "removing tasks.md")?;
}
let specs_dir = change_dir.join(SPECS_DIR);
let mut expected_caps: std::collections::HashSet<String> = std::collections::HashSet::new();
for (capability, content) in &bundle.specs {
validate_path_component(capability, "capability")?;
expected_caps.insert(capability.to_string());
let cap_dir = specs_dir.join(capability);
std::fs::create_dir_all(&cap_dir)
.map_err(|e| CoreError::io("creating spec directory", e))?;
std::fs::write(cap_dir.join("spec.md"), content)
.map_err(|e| CoreError::io("writing spec delta", e))?;
}
if specs_dir.is_dir() {
let entries =
std::fs::read_dir(&specs_dir).map_err(|e| CoreError::io("reading specs dir", e))?;
for entry in entries {
let entry = entry.map_err(|e| CoreError::io("reading spec entry", e))?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let cap_name = entry.file_name().to_string_lossy().to_string();
validate_path_component(&cap_name, "capability")?;
if !expected_caps.contains(&cap_name) {
std::fs::remove_dir_all(&path)
.map_err(|e| CoreError::io("removing stale spec directory", e))?;
}
}
}
write_revision_file(&change_dir, &bundle.revision)?;
Ok(())
}
pub(crate) fn read_local_bundle(ito_path: &Path, change_id: &str) -> CoreResult<ArtifactBundle> {
let change_dir = paths::changes_dir(ito_path).join(change_id);
read_bundle_from_change_dir(&change_dir, change_id)
}
pub(crate) fn read_bundle_from_change_dir(
change_dir: &Path,
change_id: &str,
) -> CoreResult<ArtifactBundle> {
if !change_dir.is_dir() {
return Err(CoreError::not_found(format!(
"Change directory not found: {change_id}"
)));
}
let proposal = read_optional_file(&change_dir.join("proposal.md"))?;
let design = read_optional_file(&change_dir.join("design.md"))?;
let tasks = read_optional_file(&change_dir.join("tasks.md"))?;
let mut specs = Vec::new();
let specs_dir = change_dir.join(SPECS_DIR);
if specs_dir.is_dir() {
let entries =
std::fs::read_dir(&specs_dir).map_err(|e| CoreError::io("reading specs dir", e))?;
for entry in entries {
let entry = entry.map_err(|e| CoreError::io("reading spec entry", e))?;
let cap_dir = entry.path();
if cap_dir.is_dir() {
let spec_file = cap_dir.join("spec.md");
if spec_file.is_file() {
let content = std::fs::read_to_string(&spec_file)
.map_err(|e| CoreError::io("reading spec file", e))?;
let cap_name = entry.file_name().to_string_lossy().to_string();
specs.push((cap_name, content));
}
}
}
}
specs.sort_by(|a, b| a.0.cmp(&b.0));
let revision = read_revision_file(change_dir)?.unwrap_or_default();
Ok(ArtifactBundle {
change_id: change_id.to_string(),
proposal,
design,
tasks,
specs,
revision,
})
}
fn read_optional_file(path: &Path) -> CoreResult<Option<String>> {
if !path.is_file() {
return Ok(None);
}
let content =
std::fs::read_to_string(path).map_err(|e| CoreError::io("reading artifact file", e))?;
Ok(Some(content))
}
pub(crate) fn write_revision_file(change_dir: &Path, revision: &str) -> CoreResult<()> {
let path = change_dir.join(REVISION_FILE);
std::fs::write(&path, revision).map_err(|e| CoreError::io("writing revision file", e))
}
pub(crate) fn read_revision_file(change_dir: &Path) -> CoreResult<Option<String>> {
let path = change_dir.join(REVISION_FILE);
if !path.is_file() {
return Ok(None);
}
let content =
std::fs::read_to_string(&path).map_err(|e| CoreError::io("reading revision file", e))?;
Ok(Some(content.trim().to_string()))
}
fn create_backup_snapshot(
ito_path: &Path,
change_id: &str,
backup_dir: &Path,
operation: &str,
) -> CoreResult<()> {
let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ");
let snapshot_dir = backup_dir.join(format!("{change_id}_{operation}_{timestamp}"));
std::fs::create_dir_all(&snapshot_dir)
.map_err(|e| CoreError::io("creating backup directory", e))?;
let change_dir = paths::changes_dir(ito_path).join(change_id);
if !change_dir.is_dir() {
return Ok(()); }
for name in ["proposal.md", "design.md", "tasks.md"] {
let src = change_dir.join(name);
if src.is_file() {
let dst = snapshot_dir.join(name);
std::fs::copy(&src, &dst).map_err(|e| CoreError::io("backing up artifact", e))?;
}
}
let specs_src = change_dir.join(SPECS_DIR);
if specs_src.is_dir() {
copy_dir_recursive(&specs_src, &snapshot_dir.join(SPECS_DIR))?;
}
Ok(())
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> CoreResult<()> {
std::fs::create_dir_all(dst).map_err(|e| CoreError::io("creating backup subdir", e))?;
let entries =
std::fs::read_dir(src).map_err(|e| CoreError::io("reading backup source dir", e))?;
for entry in entries {
let entry = entry.map_err(|e| CoreError::io("reading dir entry", e))?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if src_path.is_dir() {
copy_dir_recursive(&src_path, &dst_path)?;
} else {
std::fs::copy(&src_path, &dst_path)
.map_err(|e| CoreError::io("copying backup file", e))?;
}
}
Ok(())
}
fn backend_error_to_core(err: BackendError, operation: &str) -> CoreError {
match err {
BackendError::LeaseConflict(c) => CoreError::validation(format!(
"Lease conflict during {operation}: change '{}' is claimed by '{}'",
c.change_id, c.holder
)),
BackendError::RevisionConflict(c) => CoreError::validation(format!(
"Revision conflict during {operation} for '{}': \
local revision '{}' is stale (server has '{}'). \
Run 'ito tasks sync pull {}' first, then retry.",
c.change_id, c.local_revision, c.server_revision, c.change_id
)),
BackendError::Unavailable(msg) => {
CoreError::process(format!("Backend unavailable during {operation}: {msg}"))
}
BackendError::Unauthorized(msg) => {
CoreError::validation(format!("Backend auth failed during {operation}: {msg}"))
}
BackendError::NotFound(msg) => CoreError::not_found(format!(
"Backend resource not found during {operation}: {msg}"
)),
BackendError::Other(msg) => {
CoreError::process(format!("Backend error during {operation}: {msg}"))
}
}
}
pub fn map_backend_error(err: BackendError, operation: &str) -> CoreError {
backend_error_to_core(err, operation)
}
#[cfg(test)]
#[path = "backend_sync_tests.rs"]
mod backend_sync_tests;