use std::path::Path;
use ito_domain::backend::{
AllocateResult, ArchiveResult, ArtifactBundle, BackendArchiveClient, BackendLeaseClient,
BackendSyncClient, ClaimResult, PushResult, ReleaseResult,
};
use crate::backend_sync::map_backend_error;
use crate::errors::{CoreError, CoreResult};
pub fn claim_change(
lease_client: &dyn BackendLeaseClient,
change_id: &str,
) -> CoreResult<ClaimResult> {
lease_client
.claim(change_id)
.map_err(|e| map_backend_error(e, "claim"))
}
pub fn release_change(
lease_client: &dyn BackendLeaseClient,
change_id: &str,
) -> CoreResult<ReleaseResult> {
lease_client
.release(change_id)
.map_err(|e| map_backend_error(e, "release"))
}
pub fn allocate_change(lease_client: &dyn BackendLeaseClient) -> CoreResult<AllocateResult> {
lease_client
.allocate()
.map_err(|e| map_backend_error(e, "allocate"))
}
pub fn sync_pull(
sync_client: &dyn BackendSyncClient,
ito_path: &std::path::Path,
change_id: &str,
backup_dir: &std::path::Path,
) -> CoreResult<ArtifactBundle> {
crate::backend_sync::pull_artifacts(sync_client, ito_path, change_id, backup_dir)
}
pub fn sync_push(
sync_client: &dyn BackendSyncClient,
ito_path: &std::path::Path,
change_id: &str,
backup_dir: &std::path::Path,
) -> CoreResult<PushResult> {
crate::backend_sync::push_artifacts(sync_client, ito_path, change_id, backup_dir)
}
#[derive(Debug)]
pub struct BackendArchiveOutcome {
pub specs_updated: Vec<String>,
pub archive_name: String,
pub backend_result: ArchiveResult,
}
pub fn archive_with_backend(
sync_client: &dyn BackendSyncClient,
archive_client: &dyn BackendArchiveClient,
ito_path: &Path,
change_id: &str,
backup_dir: &Path,
skip_specs: bool,
) -> CoreResult<BackendArchiveOutcome> {
crate::backend_sync::pull_artifacts(sync_client, ito_path, change_id, backup_dir)?;
let specs_updated = if skip_specs {
Vec::new()
} else {
let spec_names = crate::archive::discover_change_specs(ito_path, change_id)?;
crate::archive::copy_specs_to_main(ito_path, change_id, &spec_names)?
};
let archive_name = crate::archive::generate_archive_name(change_id);
crate::archive::move_to_archive(ito_path, change_id, &archive_name)?;
let backend_result = archive_client
.mark_archived(change_id)
.map_err(|e| map_backend_error(e, "archive"))?;
Ok(BackendArchiveOutcome {
specs_updated,
archive_name,
backend_result,
})
}
pub fn is_backend_unavailable(err: &CoreError) -> bool {
match err {
CoreError::Process(msg) => msg.contains("Backend unavailable"),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use ito_domain::backend::{
ArchiveResult, BackendError, ClaimResult, LeaseConflict, ReleaseResult,
};
struct FakeLeaseClient {
claim_result: Result<ClaimResult, BackendError>,
release_result: Result<ReleaseResult, BackendError>,
allocate_result: Result<AllocateResult, BackendError>,
}
impl FakeLeaseClient {
fn success() -> Self {
Self {
claim_result: Ok(ClaimResult {
change_id: "test".to_string(),
holder: "me".to_string(),
expires_at: None,
}),
release_result: Ok(ReleaseResult {
change_id: "test".to_string(),
}),
allocate_result: Ok(AllocateResult {
claim: Some(ClaimResult {
change_id: "test".to_string(),
holder: "me".to_string(),
expires_at: None,
}),
}),
}
}
fn conflict() -> Self {
Self {
claim_result: Err(BackendError::LeaseConflict(LeaseConflict {
change_id: "test".to_string(),
holder: "other".to_string(),
expires_at: None,
})),
release_result: Ok(ReleaseResult {
change_id: "test".to_string(),
}),
allocate_result: Ok(AllocateResult { claim: None }),
}
}
}
impl BackendLeaseClient for FakeLeaseClient {
fn claim(&self, _change_id: &str) -> Result<ClaimResult, BackendError> {
self.claim_result.clone()
}
fn release(&self, _change_id: &str) -> Result<ReleaseResult, BackendError> {
self.release_result.clone()
}
fn allocate(&self) -> Result<AllocateResult, BackendError> {
self.allocate_result.clone()
}
}
#[test]
fn claim_success() {
let client = FakeLeaseClient::success();
let result = claim_change(&client, "test").unwrap();
assert_eq!(result.change_id, "test");
assert_eq!(result.holder, "me");
}
#[test]
fn claim_conflict() {
let client = FakeLeaseClient::conflict();
let err = claim_change(&client, "test").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Lease conflict"), "msg: {msg}");
assert!(msg.contains("other"), "msg: {msg}");
}
#[test]
fn release_success() {
let client = FakeLeaseClient::success();
let result = release_change(&client, "test").unwrap();
assert_eq!(result.change_id, "test");
}
#[test]
fn allocate_with_work() {
let client = FakeLeaseClient::success();
let result = allocate_change(&client).unwrap();
assert!(result.claim.is_some());
assert_eq!(result.claim.unwrap().change_id, "test");
}
#[test]
fn allocate_no_work() {
let client = FakeLeaseClient::conflict();
let result = allocate_change(&client).unwrap();
assert!(result.claim.is_none());
}
#[test]
fn is_backend_unavailable_detects_process_error() {
let err = CoreError::process("Backend unavailable during pull: timeout");
assert!(is_backend_unavailable(&err));
let err = CoreError::validation("some other error");
assert!(!is_backend_unavailable(&err));
}
use ito_domain::backend::BackendSyncClient;
use std::cell::Cell;
struct FakeSyncClient {
bundle: ArtifactBundle,
}
impl FakeSyncClient {
fn new(change_id: &str) -> Self {
Self {
bundle: ArtifactBundle {
change_id: change_id.to_string(),
proposal: Some("# Proposal\nTest content".to_string()),
design: None,
tasks: Some("- [x] Task 1\n".to_string()),
specs: vec![(
"test-cap".to_string(),
"## ADDED Requirements\n".to_string(),
)],
revision: "rev-final".to_string(),
},
}
}
}
impl BackendSyncClient for FakeSyncClient {
fn pull(&self, _change_id: &str) -> Result<ArtifactBundle, BackendError> {
Ok(self.bundle.clone())
}
fn push(
&self,
_change_id: &str,
_bundle: &ArtifactBundle,
) -> Result<PushResult, BackendError> {
Ok(PushResult {
change_id: self.bundle.change_id.clone(),
new_revision: "rev-new".to_string(),
})
}
}
struct FakeArchiveClient {
should_fail: bool,
call_count: Cell<usize>,
}
impl FakeArchiveClient {
fn success() -> Self {
Self {
should_fail: false,
call_count: Cell::new(0),
}
}
fn failing() -> Self {
Self {
should_fail: true,
call_count: Cell::new(0),
}
}
fn calls(&self) -> usize {
self.call_count.get()
}
}
impl BackendArchiveClient for FakeArchiveClient {
fn mark_archived(&self, change_id: &str) -> Result<ArchiveResult, BackendError> {
self.call_count.set(self.call_count.get() + 1);
if self.should_fail {
return Err(BackendError::Unavailable(
"backend offline during archive".to_string(),
));
}
Ok(ArchiveResult {
change_id: change_id.to_string(),
archived_at: "2026-02-28T12:00:00Z".to_string(),
})
}
}
fn setup_change_on_disk(ito_path: &std::path::Path, change_id: &str) {
let change_dir = ito_path.join("changes").join(change_id);
std::fs::create_dir_all(change_dir.join("specs/test-cap")).unwrap();
std::fs::write(change_dir.join("proposal.md"), "# Proposal").unwrap();
std::fs::write(change_dir.join("tasks.md"), "- [x] Done").unwrap();
std::fs::write(
change_dir.join("specs/test-cap/spec.md"),
"## ADDED Requirements\n",
)
.unwrap();
}
#[test]
fn archive_with_backend_happy_path() {
let tmp = tempfile::TempDir::new().unwrap();
let ito_path = tmp.path().join(".ito");
let backup_dir = tmp.path().join("backups");
std::fs::create_dir_all(&ito_path).unwrap();
let change_id = "test-change";
setup_change_on_disk(&ito_path, change_id);
let sync_client = FakeSyncClient::new(change_id);
let archive_client = FakeArchiveClient::success();
let outcome = archive_with_backend(
&sync_client,
&archive_client,
&ito_path,
change_id,
&backup_dir,
false,
)
.unwrap();
assert_eq!(outcome.specs_updated, vec!["test-cap"]);
assert!(outcome.archive_name.contains(change_id));
assert_eq!(archive_client.calls(), 1);
assert_eq!(outcome.backend_result.change_id, change_id);
let archive_dir = ito_path
.join("changes")
.join("archive")
.join(&outcome.archive_name);
assert!(archive_dir.exists(), "archive directory should exist");
let original_dir = ito_path.join("changes").join(change_id);
assert!(!original_dir.exists(), "original change dir should be gone");
let main_spec = ito_path.join("specs").join("test-cap").join("spec.md");
assert!(main_spec.exists(), "main spec should exist");
}
#[test]
fn archive_with_backend_skip_specs() {
let tmp = tempfile::TempDir::new().unwrap();
let ito_path = tmp.path().join(".ito");
let backup_dir = tmp.path().join("backups");
std::fs::create_dir_all(&ito_path).unwrap();
let change_id = "test-change";
setup_change_on_disk(&ito_path, change_id);
let sync_client = FakeSyncClient::new(change_id);
let archive_client = FakeArchiveClient::success();
let outcome = archive_with_backend(
&sync_client,
&archive_client,
&ito_path,
change_id,
&backup_dir,
true, )
.unwrap();
assert!(outcome.specs_updated.is_empty());
let archive_dir = ito_path
.join("changes")
.join("archive")
.join(&outcome.archive_name);
assert!(archive_dir.exists());
let main_spec = ito_path.join("specs").join("test-cap").join("spec.md");
assert!(
!main_spec.exists(),
"main spec should not be created when skip_specs is true"
);
}
#[test]
fn archive_with_backend_backend_unavailable() {
let tmp = tempfile::TempDir::new().unwrap();
let ito_path = tmp.path().join(".ito");
let backup_dir = tmp.path().join("backups");
std::fs::create_dir_all(&ito_path).unwrap();
let change_id = "test-change";
setup_change_on_disk(&ito_path, change_id);
let sync_client = FakeSyncClient::new(change_id);
let archive_client = FakeArchiveClient::failing();
let err = archive_with_backend(
&sync_client,
&archive_client,
&ito_path,
change_id,
&backup_dir,
false,
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Backend unavailable"),
"should report backend unavailability: {msg}"
);
}
}