use std::path::Path;
use std::sync::Arc;
use chrono::Utc;
use serde_json::json;
use tempfile::TempDir;
use ito_config::ConfigContext;
use ito_core::BackendProjectStore;
use ito_core::backend_client::BackendRuntime;
use ito_core::errors::CoreResult;
use ito_core::list::{
ChangeProgressFilter, ChangeSortOrder, ListChangesInput, list_changes, list_modules,
};
use ito_core::repository_runtime::{
PersistenceMode, RemoteRepositoryFactory, RepositoryRuntimeBuilder, RepositorySet,
SqliteRuntime, resolve_repository_runtime,
};
use ito_core::sqlite_project_store::{SqliteBackendProjectStore, UpsertChangeParams};
use ito_domain::changes::{
Change, ChangeRepository, ChangeSummary, ChangeTargetResolution, ResolveTargetOptions, Spec,
};
use ito_domain::errors::{DomainError, DomainResult};
use ito_domain::modules::{Module, ModuleRepository, ModuleSummary};
use ito_domain::specs::{SpecDocument, SpecRepository, SpecSummary};
use ito_domain::tasks::{
TaskInitResult, TaskMutationResult, TaskMutationService, TaskMutationServiceResult,
TaskRepository, TasksParseResult,
};
fn write(path: impl AsRef<Path>, contents: &str) {
let path = path.as_ref();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("parent dirs should exist");
}
std::fs::write(path, contents).expect("test fixture should write");
}
fn make_change(root: &Path, id: &str) {
write(
root.join(".ito/changes").join(id).join("proposal.md"),
"## Why\nfixture\n\n## What Changes\n- fixture\n\n## Impact\n- fixture\n",
);
write(
root.join(".ito/changes").join(id).join("tasks.md"),
"## 1. Implementation\n- [ ] 1.1 todo\n",
);
write(
root.join(".ito/changes")
.join(id)
.join("specs/alpha/spec.md"),
"## ADDED Requirements\n\n### Requirement: Fixture\nFixture requirement.\n\n#### Scenario: Works\n- **WHEN** fixture runs\n- **THEN** it is ready\n",
);
}
fn make_module(root: &Path, id: &str, name: &str) {
let module_dir = root.join(".ito/modules").join(format!("{id}_{name}"));
std::fs::create_dir_all(&module_dir).expect("module dir");
}
#[test]
fn filesystem_runtime_builds_repository_set() {
let repo = TempDir::new().expect("temp repo");
let ito_path = repo.path().join(".ito");
make_change(repo.path(), "000-01_alpha");
make_module(repo.path(), "001", "demo");
let runtime = RepositoryRuntimeBuilder::new(&ito_path)
.build()
.expect("filesystem runtime");
assert_eq!(runtime.mode(), PersistenceMode::Filesystem);
let repos = runtime.repositories();
let changes = list_changes(
repos.changes.as_ref(),
ListChangesInput {
progress_filter: ChangeProgressFilter::All,
sort: ChangeSortOrder::Name,
},
)
.expect("list changes");
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].name, "000-01_alpha");
let modules = list_modules(repos.modules.as_ref()).expect("list modules");
assert_eq!(modules.len(), 1);
assert_eq!(modules[0].id, "001");
}
#[test]
fn sqlite_runtime_builds_repository_set() {
let repo = TempDir::new().expect("temp repo");
let ito_path = repo.path().join(".ito");
let db_path = repo.path().join("ito.db");
let repo_name = repo
.path()
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("project");
let store = SqliteBackendProjectStore::open(&db_path).expect("sqlite store");
store
.ensure_project("local", repo_name)
.expect("ensure project");
store
.upsert_change(&UpsertChangeParams {
org: "local",
repo: repo_name,
change_id: "025-07_demo",
module_id: Some("025"),
sub_module_id: None,
proposal: Some("# Proposal"),
design: None,
tasks_md: Some("## 1. Implementation\n- [ ] 1.1 Todo"),
specs: &[("repository-runtime-selection", "## ADDED")],
})
.expect("upsert change");
store
.upsert_module(
"local",
repo_name,
"025",
"repository-backends",
Some("demo"),
)
.expect("upsert module");
let config = json!({
"repository": {
"mode": "sqlite",
"sqlite": { "dbPath": db_path.to_string_lossy() }
}
});
write(ito_path.join("config.json"), &config.to_string());
let runtime =
resolve_repository_runtime(&ito_path, &ConfigContext::default()).expect("sqlite runtime");
assert_eq!(runtime.mode(), PersistenceMode::Sqlite);
let repos = runtime.repositories();
let changes = list_changes(
repos.changes.as_ref(),
ListChangesInput {
progress_filter: ChangeProgressFilter::All,
sort: ChangeSortOrder::Name,
},
)
.expect("list changes");
assert_eq!(changes.len(), 1);
assert_eq!(changes[0].name, "025-07_demo");
let modules = list_modules(repos.modules.as_ref()).expect("list modules");
assert_eq!(modules.len(), 1);
assert_eq!(modules[0].id, "025");
}
#[test]
fn sqlite_mode_requires_db_path() {
let repo = TempDir::new().expect("temp repo");
let ito_path = repo.path().join(".ito");
let config = json!({ "repository": { "mode": "sqlite" } });
write(ito_path.join("config.json"), &config.to_string());
let err = resolve_repository_runtime(&ito_path, &ConfigContext::default())
.err()
.expect("expected sqlite config error");
let msg = err.to_string();
assert!(msg.contains("repository.sqlite.dbPath"));
}
struct FakeChangeRepo {
summaries: Vec<ChangeSummary>,
full: Vec<Change>,
}
impl FakeChangeRepo {
fn new(summary: ChangeSummary, change: Change) -> Self {
Self {
summaries: vec![summary],
full: vec![change],
}
}
}
impl ChangeRepository for FakeChangeRepo {
fn resolve_target_with_options(
&self,
input: &str,
_options: ResolveTargetOptions,
) -> ChangeTargetResolution {
if self.summaries.iter().any(|s| s.id == input) {
return ChangeTargetResolution::Unique(input.to_string());
}
ChangeTargetResolution::NotFound
}
fn suggest_targets(&self, _input: &str, _max: usize) -> Vec<String> {
Vec::new()
}
fn exists(&self, id: &str) -> bool {
self.summaries.iter().any(|s| s.id == id)
}
fn exists_with_filter(
&self,
id: &str,
filter: ito_domain::changes::ChangeLifecycleFilter,
) -> bool {
if !filter.includes_active() {
return false;
}
self.summaries.iter().any(|s| s.id == id)
}
fn get_with_filter(
&self,
id: &str,
filter: ito_domain::changes::ChangeLifecycleFilter,
) -> DomainResult<Change> {
if !filter.includes_active() {
return Err(DomainError::not_found("change", id));
}
self.full
.iter()
.find(|c| c.id == id)
.cloned()
.ok_or_else(|| DomainError::not_found("change", id))
}
fn list_with_filter(
&self,
filter: ito_domain::changes::ChangeLifecycleFilter,
) -> DomainResult<Vec<ChangeSummary>> {
if !filter.includes_active() {
return Ok(Vec::new());
}
Ok(self.summaries.clone())
}
fn list_by_module_with_filter(
&self,
module_id: &str,
filter: ito_domain::changes::ChangeLifecycleFilter,
) -> DomainResult<Vec<ChangeSummary>> {
if !filter.includes_active() {
return Ok(Vec::new());
}
Ok(self
.summaries
.iter()
.filter(|s| s.module_id.as_deref() == Some(module_id))
.cloned()
.collect())
}
fn list_incomplete_with_filter(
&self,
filter: ito_domain::changes::ChangeLifecycleFilter,
) -> DomainResult<Vec<ChangeSummary>> {
if !filter.includes_active() {
return Ok(Vec::new());
}
Ok(self
.summaries
.iter()
.filter(|s| s.total_tasks == 0 || s.completed_tasks < s.total_tasks)
.cloned()
.collect())
}
fn list_complete_with_filter(
&self,
filter: ito_domain::changes::ChangeLifecycleFilter,
) -> DomainResult<Vec<ChangeSummary>> {
if !filter.includes_active() {
return Ok(Vec::new());
}
Ok(self
.summaries
.iter()
.filter(|s| s.total_tasks > 0 && s.completed_tasks == s.total_tasks)
.cloned()
.collect())
}
fn get_summary_with_filter(
&self,
id: &str,
filter: ito_domain::changes::ChangeLifecycleFilter,
) -> DomainResult<ChangeSummary> {
if !filter.includes_active() {
return Err(DomainError::not_found("change", id));
}
self.summaries
.iter()
.find(|s| s.id == id)
.cloned()
.ok_or_else(|| DomainError::not_found("change", id))
}
}
struct FakeModuleRepo {
modules: Vec<ModuleSummary>,
full: Vec<Module>,
}
impl FakeModuleRepo {
fn new(summary: ModuleSummary, module: Module) -> Self {
Self {
modules: vec![summary],
full: vec![module],
}
}
}
impl ModuleRepository for FakeModuleRepo {
fn exists(&self, id: &str) -> bool {
self.modules.iter().any(|m| m.id == id)
}
fn get(&self, id_or_name: &str) -> DomainResult<Module> {
self.full
.iter()
.find(|m| m.id == id_or_name || m.name == id_or_name)
.cloned()
.ok_or_else(|| DomainError::not_found("module", id_or_name))
}
fn list(&self) -> DomainResult<Vec<ModuleSummary>> {
Ok(self.modules.clone())
}
}
struct FakeTaskRepo;
impl TaskRepository for FakeTaskRepo {
fn load_tasks(&self, _change_id: &str) -> DomainResult<TasksParseResult> {
Ok(TasksParseResult::empty())
}
}
struct FakeTaskMutations;
impl FakeTaskMutations {
fn unsupported<T>() -> TaskMutationServiceResult<T> {
Err(ito_domain::tasks::TaskMutationError::other(
"task mutations not configured",
))
}
}
impl TaskMutationService for FakeTaskMutations {
fn load_tasks_markdown(&self, _change_id: &str) -> TaskMutationServiceResult<Option<String>> {
Ok(None)
}
fn init_tasks(&self, _change_id: &str) -> TaskMutationServiceResult<TaskInitResult> {
Self::unsupported()
}
fn start_task(
&self,
_change_id: &str,
_task_id: &str,
) -> TaskMutationServiceResult<TaskMutationResult> {
Self::unsupported()
}
fn complete_task(
&self,
_change_id: &str,
_task_id: &str,
_note: Option<String>,
) -> TaskMutationServiceResult<TaskMutationResult> {
Self::unsupported()
}
fn shelve_task(
&self,
_change_id: &str,
_task_id: &str,
_reason: Option<String>,
) -> TaskMutationServiceResult<TaskMutationResult> {
Self::unsupported()
}
fn unshelve_task(
&self,
_change_id: &str,
_task_id: &str,
) -> TaskMutationServiceResult<TaskMutationResult> {
Self::unsupported()
}
fn add_task(
&self,
_change_id: &str,
_title: &str,
_wave: Option<u32>,
) -> TaskMutationServiceResult<TaskMutationResult> {
Self::unsupported()
}
}
struct FakeSpecRepo;
impl SpecRepository for FakeSpecRepo {
fn list(&self) -> DomainResult<Vec<SpecSummary>> {
Ok(vec![SpecSummary {
id: "repository-runtime-selection".to_string(),
path: std::path::PathBuf::from(".ito/specs/repository-runtime-selection/spec.md"),
last_modified: Utc::now(),
}])
}
fn get(&self, id: &str) -> DomainResult<SpecDocument> {
Ok(SpecDocument {
id: id.to_string(),
path: std::path::PathBuf::from(format!(".ito/specs/{id}/spec.md")),
markdown: "# Spec\n".to_string(),
last_modified: Utc::now(),
})
}
}
struct FakeRemoteFactory {
repos: RepositorySet,
}
impl RemoteRepositoryFactory for FakeRemoteFactory {
fn build(&self, _runtime: &BackendRuntime) -> CoreResult<RepositorySet> {
Ok(self.repos.clone())
}
}
#[test]
fn remote_runtime_uses_remote_factory() {
let repo = TempDir::new().expect("temp repo");
let ito_path = repo.path().join(".ito");
let summary = ChangeSummary {
id: "025-04_demo".to_string(),
module_id: Some("025".to_string()),
sub_module_id: None,
completed_tasks: 1,
shelved_tasks: 0,
in_progress_tasks: 0,
pending_tasks: 0,
total_tasks: 1,
last_modified: Utc::now(),
has_proposal: true,
has_design: false,
has_specs: true,
has_tasks: true,
orchestrate: ito_domain::changes::ChangeOrchestrateMetadata::default(),
};
let change = Change {
id: "025-04_demo".to_string(),
module_id: Some("025".to_string()),
sub_module_id: None,
path: std::path::PathBuf::new(),
proposal: Some("# Proposal".to_string()),
design: None,
specs: vec![Spec {
name: "repository-runtime-selection".to_string(),
content: "## ADDED".to_string(),
}],
tasks: TasksParseResult::empty(),
orchestrate: ito_domain::changes::ChangeOrchestrateMetadata::default(),
last_modified: Utc::now(),
};
let module_summary = ModuleSummary {
id: "025".to_string(),
name: "repository-backends".to_string(),
change_count: 1,
sub_modules: Vec::new(),
};
let module = Module {
id: "025".to_string(),
name: "repository-backends".to_string(),
description: Some("Demo".to_string()),
path: std::path::PathBuf::new(),
sub_modules: Vec::new(),
};
let repos = RepositorySet {
changes: Arc::new(FakeChangeRepo::new(summary, change)),
modules: Arc::new(FakeModuleRepo::new(module_summary, module)),
tasks: Arc::new(FakeTaskRepo),
task_mutations: Arc::new(FakeTaskMutations),
specs: Arc::new(FakeSpecRepo),
};
let backend_runtime = BackendRuntime {
base_url: "http://127.0.0.1:9010".to_string(),
token: "test".to_string(),
timeout: std::time::Duration::from_secs(1),
max_retries: 1,
backup_dir: repo.path().join("backups"),
org: "acme".to_string(),
repo: "widgets".to_string(),
};
let factory = FakeRemoteFactory { repos };
let runtime = RepositoryRuntimeBuilder::new(&ito_path)
.mode(PersistenceMode::Remote)
.backend_runtime(backend_runtime)
.remote_factory(Arc::new(factory))
.build()
.expect("remote runtime");
assert_eq!(runtime.mode(), PersistenceMode::Remote);
let repos = runtime.repositories();
let summaries = list_changes(
repos.changes.as_ref(),
ListChangesInput {
progress_filter: ChangeProgressFilter::All,
sort: ChangeSortOrder::Name,
},
)
.expect("list remote changes");
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].name, "025-04_demo");
let modules = list_modules(repos.modules.as_ref()).expect("list remote modules");
assert_eq!(modules.len(), 1);
assert_eq!(modules[0].id, "025");
}
#[test]
fn repository_modes_return_consistent_change_names() {
let change_id = "025-07_parity";
let module_id = "025";
let fs_repo = TempDir::new().expect("temp repo");
let fs_ito_path = fs_repo.path().join(".ito");
make_change(fs_repo.path(), change_id);
make_module(fs_repo.path(), module_id, "repository-backends");
let fs_runtime = RepositoryRuntimeBuilder::new(&fs_ito_path)
.build()
.expect("filesystem runtime");
let sqlite_repo = TempDir::new().expect("temp repo");
let db_path = sqlite_repo.path().join("ito.db");
let store = SqliteBackendProjectStore::open(&db_path).expect("sqlite store");
store
.ensure_project("local", "demo")
.expect("ensure sqlite project");
store
.upsert_change(&UpsertChangeParams {
org: "local",
repo: "demo",
change_id,
module_id: Some(module_id),
sub_module_id: None,
proposal: Some("# Proposal"),
design: None,
tasks_md: Some("## 1. Implementation\n- [ ] 1.1 Todo"),
specs: &[("repository-runtime-selection", "## ADDED")],
})
.expect("upsert sqlite change");
store
.upsert_module(
"local",
"demo",
module_id,
"repository-backends",
Some("demo"),
)
.expect("upsert sqlite module");
let sqlite_runtime = RepositoryRuntimeBuilder::new(sqlite_repo.path().join(".ito"))
.mode(PersistenceMode::Sqlite)
.sqlite_runtime(SqliteRuntime {
db_path,
org: "local".to_string(),
repo: "demo".to_string(),
})
.build()
.expect("sqlite runtime");
let summary = ChangeSummary {
id: change_id.to_string(),
module_id: Some(module_id.to_string()),
sub_module_id: None,
completed_tasks: 0,
shelved_tasks: 0,
in_progress_tasks: 0,
pending_tasks: 1,
total_tasks: 1,
last_modified: Utc::now(),
has_proposal: true,
has_design: false,
has_specs: true,
has_tasks: true,
orchestrate: ito_domain::changes::ChangeOrchestrateMetadata::default(),
};
let change = Change {
id: change_id.to_string(),
module_id: Some(module_id.to_string()),
sub_module_id: None,
path: std::path::PathBuf::new(),
proposal: Some("# Proposal".to_string()),
design: None,
specs: vec![Spec {
name: "repository-runtime-selection".to_string(),
content: "## ADDED".to_string(),
}],
tasks: TasksParseResult::empty(),
orchestrate: ito_domain::changes::ChangeOrchestrateMetadata::default(),
last_modified: Utc::now(),
};
let module_summary = ModuleSummary {
id: module_id.to_string(),
name: "repository-backends".to_string(),
change_count: 1,
sub_modules: Vec::new(),
};
let module = Module {
id: module_id.to_string(),
name: "repository-backends".to_string(),
description: Some("demo".to_string()),
path: std::path::PathBuf::new(),
sub_modules: Vec::new(),
};
let remote_repos = RepositorySet {
changes: Arc::new(FakeChangeRepo::new(summary, change)),
modules: Arc::new(FakeModuleRepo::new(module_summary, module)),
tasks: Arc::new(FakeTaskRepo),
task_mutations: Arc::new(FakeTaskMutations),
specs: Arc::new(FakeSpecRepo),
};
let backend_runtime = BackendRuntime {
base_url: "http://127.0.0.1:9010".to_string(),
token: "test".to_string(),
timeout: std::time::Duration::from_secs(1),
max_retries: 1,
backup_dir: sqlite_repo.path().join("backups"),
org: "acme".to_string(),
repo: "widgets".to_string(),
};
let remote_runtime = RepositoryRuntimeBuilder::new(&fs_ito_path)
.mode(PersistenceMode::Remote)
.backend_runtime(backend_runtime)
.remote_factory(Arc::new(FakeRemoteFactory {
repos: remote_repos,
}))
.build()
.expect("remote runtime");
let fs_changes = list_changes(
fs_runtime.repositories().changes.as_ref(),
ListChangesInput {
progress_filter: ChangeProgressFilter::All,
sort: ChangeSortOrder::Name,
},
)
.expect("list fs changes");
let sqlite_changes = list_changes(
sqlite_runtime.repositories().changes.as_ref(),
ListChangesInput {
progress_filter: ChangeProgressFilter::All,
sort: ChangeSortOrder::Name,
},
)
.expect("list sqlite changes");
let remote_changes = list_changes(
remote_runtime.repositories().changes.as_ref(),
ListChangesInput {
progress_filter: ChangeProgressFilter::All,
sort: ChangeSortOrder::Name,
},
)
.expect("list remote changes");
assert_eq!(fs_changes[0].name, sqlite_changes[0].name);
assert_eq!(fs_changes[0].name, remote_changes[0].name);
let fs_modules = list_modules(fs_runtime.repositories().modules.as_ref()).expect("list fs");
let sqlite_modules =
list_modules(sqlite_runtime.repositories().modules.as_ref()).expect("list sqlite");
let remote_modules =
list_modules(remote_runtime.repositories().modules.as_ref()).expect("list remote");
assert_eq!(fs_modules[0].id, sqlite_modules[0].id);
assert_eq!(fs_modules[0].id, remote_modules[0].id);
}
#[test]
fn resolve_target_parity_between_filesystem_and_sqlite() {
let fs_repo = TempDir::new().expect("temp repo");
let fs_ito_path = fs_repo.path().join(".ito");
make_change(fs_repo.path(), "025-07_feature-one");
make_change(fs_repo.path(), "025-08_feature-two");
let fs_runtime = RepositoryRuntimeBuilder::new(&fs_ito_path)
.build()
.expect("filesystem runtime");
let sqlite_repo = TempDir::new().expect("temp repo");
let db_path = sqlite_repo.path().join("ito.db");
let store = SqliteBackendProjectStore::open(&db_path).expect("sqlite store");
store
.ensure_project("local", "demo")
.expect("ensure sqlite project");
for change_id in ["025-07_feature-one", "025-08_feature-two"] {
store
.upsert_change(&UpsertChangeParams {
org: "local",
repo: "demo",
change_id,
module_id: Some("025"),
sub_module_id: None,
proposal: Some("# Proposal"),
design: None,
tasks_md: Some("## 1. Implementation\n- [ ] 1.1 Todo"),
specs: &[],
})
.expect("upsert sqlite change");
}
let sqlite_runtime = RepositoryRuntimeBuilder::new(sqlite_repo.path().join(".ito"))
.mode(PersistenceMode::Sqlite)
.sqlite_runtime(SqliteRuntime {
db_path,
org: "local".to_string(),
repo: "demo".to_string(),
})
.build()
.expect("sqlite runtime");
let fs_repo = fs_runtime.repositories().changes.as_ref();
let sqlite_repo = sqlite_runtime.repositories().changes.as_ref();
let options = ResolveTargetOptions::default();
let exact_fs = fs_repo.resolve_target_with_options("025-07_feature-one", options);
let exact_sqlite = sqlite_repo.resolve_target_with_options("025-07_feature-one", options);
assert_eq!(exact_fs, exact_sqlite);
let numeric_fs = fs_repo.resolve_target_with_options("025-07", options);
let numeric_sqlite = sqlite_repo.resolve_target_with_options("025-07", options);
assert_eq!(numeric_fs, numeric_sqlite);
let slug_fs = fs_repo.resolve_target_with_options("feature", options);
let slug_sqlite = sqlite_repo.resolve_target_with_options("feature", options);
assert_eq!(slug_fs, slug_sqlite);
}