ito-core 0.1.33

Core functionality and business logic for Ito
Documentation
use super::*;
#[cfg(feature = "backend")]
use ito_domain::backend::PushResult;
use ito_domain::backend::{LeaseConflict, RevisionConflict};
use std::sync::{Arc, Mutex};
use tempfile::tempdir;

#[test]
fn fs_service_writes_and_patches_proposal() {
    let tmp = tempdir().expect("tempdir");
    let ito_path = tmp.path().join(".ito");
    std::fs::create_dir_all(ito_path.join("changes").join("025-11_demo")).expect("change dir");

    let service = FsChangeArtifactMutationService::new(&ito_path);
    let target = ChangeArtifactRef {
        change_id: "025-11_demo".to_string(),
        artifact: ChangeArtifactKind::Proposal,
    };

    let result = service
        .write_artifact(&target, "# Proposal\n")
        .expect("write proposal");
    assert!(!result.existed);
    assert_eq!(
        std::fs::read_to_string(
            ito_path
                .join("changes")
                .join("025-11_demo")
                .join("proposal.md")
        )
        .expect("read proposal"),
        "# Proposal\n"
    );

    let patch = "--- proposal\n+++ proposal\n@@ -1 +1 @@\n-# Proposal\n+# Updated Proposal\n";
    let result = service
        .patch_artifact(&target, patch)
        .expect("patch proposal");
    assert!(result.existed);
    assert_eq!(
        std::fs::read_to_string(
            ito_path
                .join("changes")
                .join("025-11_demo")
                .join("proposal.md")
        )
        .expect("read patched proposal"),
        "# Updated Proposal\n"
    );
}

#[test]
fn fs_service_creates_spec_delta_directory_on_write() {
    let tmp = tempdir().expect("tempdir");
    let ito_path = tmp.path().join(".ito");
    std::fs::create_dir_all(ito_path.join("changes").join("025-11_demo")).expect("change dir");

    let service = FsChangeArtifactMutationService::new(&ito_path);
    let target = ChangeArtifactRef {
        change_id: "025-11_demo".to_string(),
        artifact: ChangeArtifactKind::SpecDelta {
            capability: "backend-agent-instructions".to_string(),
        },
    };

    service
        .write_artifact(&target, "## ADDED Requirements\n")
        .expect("write spec delta");

    let spec_path = ito_path
        .join("changes")
        .join("025-11_demo")
        .join("specs")
        .join("backend-agent-instructions")
        .join("spec.md");
    assert_eq!(
        std::fs::read_to_string(spec_path).expect("read spec delta"),
        "## ADDED Requirements\n"
    );
}

#[test]
fn fs_service_loads_missing_and_rejects_invalid_or_unknown_targets() {
    let tmp = tempdir().expect("tempdir");
    let ito_path = tmp.path().join(".ito");
    std::fs::create_dir_all(ito_path.join("changes").join("025-11_demo")).expect("change dir");

    let service = FsChangeArtifactMutationService::new(&ito_path);
    let missing = ChangeArtifactRef {
        change_id: "025-11_demo".to_string(),
        artifact: ChangeArtifactKind::Design,
    };
    assert_eq!(service.load_artifact(&missing).expect("load missing"), None);

    let invalid = ChangeArtifactRef {
        change_id: "bad".to_string(),
        artifact: ChangeArtifactKind::Proposal,
    };
    let err = service
        .load_artifact(&invalid)
        .expect_err("invalid change id should fail");
    assert!(err.to_string().contains("Invalid change id 'bad'"));

    let unknown = ChangeArtifactRef {
        change_id: "025-12_missing".to_string(),
        artifact: ChangeArtifactKind::Proposal,
    };
    let err = service
        .write_artifact(&unknown, "# Missing\n")
        .expect_err("unknown change should fail");
    assert!(
        err.to_string()
            .contains("Change '025-12_missing' not found")
    );
}

#[test]
fn fs_service_rejects_unsafe_spec_capability_and_patch_errors() {
    let tmp = tempdir().expect("tempdir");
    let ito_path = tmp.path().join(".ito");
    std::fs::create_dir_all(ito_path.join("changes").join("025-11_demo")).expect("change dir");

    let service = FsChangeArtifactMutationService::new(&ito_path);
    let unsafe_spec = ChangeArtifactRef {
        change_id: "025-11_demo".to_string(),
        artifact: ChangeArtifactKind::SpecDelta {
            capability: "../escape".to_string(),
        },
    };
    let err = service
        .write_artifact(&unsafe_spec, "bad")
        .expect_err("unsafe capability should fail");
    assert!(
        err.to_string()
            .contains("capability contains unsafe path characters")
    );

    let proposal = ChangeArtifactRef {
        change_id: "025-11_demo".to_string(),
        artifact: ChangeArtifactKind::Proposal,
    };
    service
        .write_artifact(&proposal, "# Proposal\n")
        .expect("write proposal");
    let invalid = service
        .patch_artifact(&proposal, "--- proposal\n+++ proposal\n@@ invalid\n")
        .expect_err("invalid patch should fail");
    assert!(invalid.to_string().contains("Invalid patch"));

    let clean_miss = service
        .patch_artifact(
            &proposal,
            "--- proposal\n+++ proposal\n@@ -1 +1 @@\n-# Other\n+# Updated\n",
        )
        .expect_err("non-matching patch should fail");
    assert!(
        clean_miss
            .to_string()
            .contains("Patch did not apply cleanly")
    );
}

#[derive(Debug, Clone)]
struct FakeBundleClient {
    bundle: Arc<Mutex<ArtifactBundle>>,
    revision: Arc<Mutex<u32>>,
}

impl ChangeArtifactBundleClient for FakeBundleClient {
    fn pull_bundle(&self, _change_id: &str) -> ChangeArtifactMutationServiceResult<ArtifactBundle> {
        Ok(self.bundle.lock().expect("bundle lock").clone())
    }

    fn push_bundle(
        &self,
        _change_id: &str,
        bundle: &ArtifactBundle,
    ) -> ChangeArtifactMutationServiceResult<String> {
        *self.bundle.lock().expect("bundle lock") = bundle.clone();
        let mut revision = self.revision.lock().expect("revision lock");
        *revision += 1;
        Ok(format!("rev-{}", *revision))
    }
}

#[test]
fn bundle_service_patches_design_and_returns_revision() {
    let client = FakeBundleClient {
        bundle: Arc::new(Mutex::new(ArtifactBundle {
            change_id: "025-11_demo".to_string(),
            proposal: Some("# Proposal\n".to_string()),
            design: Some("# Design\n".to_string()),
            tasks: None,
            specs: vec![],
            revision: "rev-1".to_string(),
        })),
        revision: Arc::new(Mutex::new(1)),
    };

    let service = BundleBackedChangeArtifactMutationService::new(client.clone());
    let target = ChangeArtifactRef {
        change_id: "025-11_demo".to_string(),
        artifact: ChangeArtifactKind::Design,
    };
    let patch = "--- design\n+++ design\n@@ -1 +1 @@\n-# Design\n+# Updated Design\n";

    let result = service
        .patch_artifact(&target, patch)
        .expect("patch design");
    assert_eq!(result.revision.as_deref(), Some("rev-2"));
    assert_eq!(
        client.bundle.lock().expect("bundle lock").design.as_deref(),
        Some("# Updated Design\n")
    );
}

#[test]
fn bundle_service_loads_writes_and_sorts_spec_deltas() {
    let client = FakeBundleClient {
        bundle: Arc::new(Mutex::new(ArtifactBundle {
            change_id: "025-11_demo".to_string(),
            proposal: Some("# Proposal\n".to_string()),
            design: None,
            tasks: Some("- [ ] task\n".to_string()),
            specs: vec![("zeta".to_string(), "old".to_string())],
            revision: "rev-1".to_string(),
        })),
        revision: Arc::new(Mutex::new(1)),
    };
    let service = BundleBackedChangeArtifactMutationService::new(client.clone());

    let tasks = ChangeArtifactRef {
        change_id: "025-11_demo".to_string(),
        artifact: ChangeArtifactKind::Tasks,
    };
    assert_eq!(
        service
            .load_artifact(&tasks)
            .expect("load tasks")
            .as_deref(),
        Some("- [ ] task\n")
    );

    let alpha = ChangeArtifactRef {
        change_id: "025-11_demo".to_string(),
        artifact: ChangeArtifactKind::SpecDelta {
            capability: "alpha".to_string(),
        },
    };
    let result = service
        .write_artifact(&alpha, "alpha spec")
        .expect("write new spec");
    assert!(!result.existed);
    assert_eq!(result.revision.as_deref(), Some("rev-2"));

    let zeta = ChangeArtifactRef {
        change_id: "025-11_demo".to_string(),
        artifact: ChangeArtifactKind::SpecDelta {
            capability: "zeta".to_string(),
        },
    };
    let result = service
        .write_artifact(&zeta, "new zeta")
        .expect("replace existing spec");
    assert!(result.existed);

    let bundle = client.bundle.lock().expect("bundle lock");
    assert_eq!(
        bundle.specs,
        vec![
            ("alpha".to_string(), "alpha spec".to_string()),
            ("zeta".to_string(), "new zeta".to_string()),
        ]
    );
}

#[test]
fn backend_errors_map_to_actionable_mutation_errors() {
    let lease = change_artifact_error_from_backend(BackendError::LeaseConflict(LeaseConflict {
        change_id: "025-11_demo".to_string(),
        holder: "worker-a".to_string(),
        expires_at: None,
    }));
    assert!(lease.to_string().contains("claimed by 'worker-a'"));

    let revision =
        change_artifact_error_from_backend(BackendError::RevisionConflict(RevisionConflict {
            change_id: "025-11_demo".to_string(),
            local_revision: "old".to_string(),
            server_revision: "new".to_string(),
        }));
    assert!(revision.to_string().contains("Retry after re-reading"));

    let unavailable =
        change_artifact_error_from_backend(BackendError::Unavailable("down".to_string()));
    assert_eq!(unavailable.to_string(), "backend unavailable: down");

    let unauthorized =
        change_artifact_error_from_backend(BackendError::Unauthorized("bad token".to_string()));
    assert_eq!(unauthorized.to_string(), "backend auth failed: bad token");

    let not_found = change_artifact_error_from_backend(BackendError::NotFound("gone".to_string()));
    assert_eq!(not_found.to_string(), "backend resource not found: gone");

    let other = change_artifact_error_from_backend(BackendError::Other("boom".to_string()));
    assert_eq!(other.to_string(), "boom");
}

#[cfg(feature = "backend")]
#[derive(Debug, Clone)]
struct FakeSyncClient {
    bundle: ArtifactBundle,
}

#[cfg(feature = "backend")]
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: change_id.to_string(),
            new_revision: "remote-rev".to_string(),
        })
    }
}

#[test]
#[cfg(feature = "backend")]
fn remote_bundle_client_delegates_pull_and_push() {
    let bundle = ArtifactBundle {
        change_id: "025-11_demo".to_string(),
        proposal: Some("# Proposal\n".to_string()),
        design: None,
        tasks: None,
        specs: vec![],
        revision: "rev-1".to_string(),
    };
    let client = RemoteChangeArtifactBundleClient::new(FakeSyncClient {
        bundle: bundle.clone(),
    });

    assert_eq!(
        client
            .pull_bundle("025-11_demo")
            .expect("pull bundle")
            .proposal,
        bundle.proposal
    );
    assert_eq!(
        client
            .push_bundle("025-11_demo", &bundle)
            .expect("push bundle"),
        "remote-rev"
    );
}