Skip to main content

ito_core/
repository_runtime.rs

1//! Repository runtime selection and composition.
2//!
3//! Centralizes selection of repository implementations for the active
4//! persistence mode, so adapters do not instantiate concrete repositories
5//! directly.
6
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9
10use ito_config::ito_dir::{absolutize_and_normalize, lexical_normalize};
11use ito_config::types::{ItoConfig, RepositoryPersistenceMode};
12use ito_config::{ConfigContext, load_cascading_project_config};
13
14use crate::artifact_mutations::{
15    BundleBackedChangeArtifactMutationService, FsChangeArtifactMutationService,
16    RemoteChangeArtifactBundleClient, SqliteChangeArtifactBundleClient,
17};
18use crate::backend_change_repository::BackendChangeRepository;
19use crate::backend_client::{BackendRuntime, resolve_backend_runtime};
20use crate::backend_http::BackendHttpClient;
21use crate::backend_module_repository::BackendModuleRepository;
22use crate::backend_spec_repository::BackendSpecRepository;
23use crate::change_repository::FsChangeRepository;
24use crate::errors::{CoreError, CoreResult};
25use crate::module_repository::FsModuleRepository;
26use crate::remote_task_repository::RemoteTaskRepository;
27use crate::spec_repository::FsSpecRepository;
28use crate::sqlite_project_store::SqliteBackendProjectStore;
29use crate::task_mutations::{FsTaskMutationService, boxed_fs_task_mutation_service};
30use crate::task_repository::FsTaskRepository;
31use ito_domain::changes::ChangeArtifactMutationService;
32use ito_domain::changes::ChangeRepository;
33use ito_domain::modules::ModuleRepository;
34use ito_domain::specs::SpecRepository;
35use ito_domain::tasks::{TaskMutationService, TaskRepository};
36
37/// Client persistence mode used for repository selection.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum PersistenceMode {
40    /// Local filesystem-backed repositories.
41    Filesystem,
42    /// Local SQLite-backed repositories.
43    Sqlite,
44    /// Remote repositories backed by the backend API.
45    Remote,
46}
47
48/// Bundle of domain repositories selected for the active persistence mode.
49#[derive(Clone)]
50pub struct RepositorySet {
51    /// Change repository implementation.
52    pub changes: Arc<dyn ChangeRepository + Send + Sync>,
53    /// Module repository implementation.
54    pub modules: Arc<dyn ModuleRepository + Send + Sync>,
55    /// Task repository implementation.
56    pub tasks: Arc<dyn TaskRepository + Send + Sync>,
57    /// Task mutation service implementation.
58    pub task_mutations: Arc<dyn TaskMutationService + Send + Sync>,
59    /// Promoted spec repository implementation.
60    pub specs: Arc<dyn SpecRepository + Send + Sync>,
61}
62
63/// Resolved SQLite runtime settings for local persistence.
64#[derive(Debug, Clone)]
65pub struct SqliteRuntime {
66    /// Path to the SQLite database file.
67    pub db_path: PathBuf,
68    /// Organization namespace for the local project (currently derived as `local`).
69    pub org: String,
70    /// Repository namespace for the local project (derived from the project root directory name).
71    pub repo: String,
72}
73
74/// Resolved repository runtime for the current configuration.
75pub struct RepositoryRuntime {
76    mode: PersistenceMode,
77    ito_path: PathBuf,
78    backend_runtime: Option<BackendRuntime>,
79    sqlite_runtime: Option<SqliteRuntime>,
80    repositories: RepositorySet,
81    change_artifact_mutations: Arc<dyn ChangeArtifactMutationService + Send + Sync>,
82}
83
84impl RepositoryRuntime {
85    /// Active persistence mode.
86    pub fn mode(&self) -> PersistenceMode {
87        self.mode
88    }
89
90    /// Root `.ito/` path for filesystem-backed helpers.
91    pub fn ito_path(&self) -> &Path {
92        self.ito_path.as_path()
93    }
94
95    /// Resolved backend runtime, if remote mode is active.
96    pub fn backend_runtime(&self) -> Option<&BackendRuntime> {
97        self.backend_runtime.as_ref()
98    }
99
100    /// Resolved SQLite runtime, if SQLite mode is active.
101    pub fn sqlite_runtime(&self) -> Option<&SqliteRuntime> {
102        self.sqlite_runtime.as_ref()
103    }
104
105    /// Selected repository bundle.
106    pub fn repositories(&self) -> &RepositorySet {
107        &self.repositories
108    }
109
110    /// Selected active-change artifact mutation service.
111    pub fn change_artifact_mutations(&self) -> &(dyn ChangeArtifactMutationService + Send + Sync) {
112        self.change_artifact_mutations.as_ref()
113    }
114}
115
116/// Factory interface for building remote repository bundles.
117pub trait RemoteRepositoryFactory: Send + Sync {
118    /// Build a repository bundle using the provided backend runtime.
119    fn build(&self, runtime: &BackendRuntime) -> CoreResult<RepositorySet>;
120}
121
122/// Remote factory that uses HTTP-backed repositories.
123pub struct HttpRemoteRepositoryFactory;
124
125impl RemoteRepositoryFactory for HttpRemoteRepositoryFactory {
126    fn build(&self, runtime: &BackendRuntime) -> CoreResult<RepositorySet> {
127        let client = BackendHttpClient::new(runtime.clone());
128        Ok(RepositorySet {
129            changes: Arc::new(BackendChangeRepository::new(client.clone())),
130            modules: Arc::new(BackendModuleRepository::new(client.clone())),
131            tasks: Arc::new(RemoteTaskRepository::new(client.clone())),
132            task_mutations: Arc::new(client.clone()),
133            specs: Arc::new(BackendSpecRepository::new(client.clone())),
134        })
135    }
136}
137
138/// Builder for repository runtime selection.
139pub struct RepositoryRuntimeBuilder {
140    ito_path: PathBuf,
141    mode: PersistenceMode,
142    backend_runtime: Option<BackendRuntime>,
143    sqlite_runtime: Option<SqliteRuntime>,
144    remote_factory: Arc<dyn RemoteRepositoryFactory>,
145}
146
147impl RepositoryRuntimeBuilder {
148    /// Create a builder targeting the provided `.ito/` path.
149    pub fn new(ito_path: impl Into<PathBuf>) -> Self {
150        Self {
151            ito_path: ito_path.into(),
152            mode: PersistenceMode::Filesystem,
153            backend_runtime: None,
154            sqlite_runtime: None,
155            remote_factory: Arc::new(HttpRemoteRepositoryFactory),
156        }
157    }
158
159    /// Set the persistence mode.
160    pub fn mode(mut self, mode: PersistenceMode) -> Self {
161        self.mode = mode;
162        self
163    }
164
165    /// Set the backend runtime for remote mode.
166    pub fn backend_runtime(mut self, runtime: BackendRuntime) -> Self {
167        self.backend_runtime = Some(runtime);
168        self
169    }
170
171    /// Set the SQLite runtime for SQLite mode.
172    pub fn sqlite_runtime(mut self, runtime: SqliteRuntime) -> Self {
173        self.sqlite_runtime = Some(runtime);
174        self
175    }
176
177    /// Override the remote repository factory.
178    pub fn remote_factory(mut self, factory: Arc<dyn RemoteRepositoryFactory>) -> Self {
179        self.remote_factory = factory;
180        self
181    }
182
183    /// Build the repository runtime.
184    pub fn build(self) -> CoreResult<RepositoryRuntime> {
185        match self.mode {
186            PersistenceMode::Filesystem => {
187                let ito_path = self.ito_path;
188                let repositories = filesystem_repository_set(&ito_path);
189                Ok(RepositoryRuntime {
190                    mode: PersistenceMode::Filesystem,
191                    ito_path: ito_path.clone(),
192                    backend_runtime: None,
193                    sqlite_runtime: None,
194                    repositories,
195                    change_artifact_mutations: Arc::new(FsChangeArtifactMutationService::new(
196                        ito_path,
197                    )),
198                })
199            }
200            PersistenceMode::Sqlite => {
201                let ito_path = self.ito_path;
202                let runtime = self.sqlite_runtime.ok_or_else(|| {
203                    CoreError::validation("sqlite mode requires sqlite runtime".to_string())
204                })?;
205                let repositories = sqlite_repository_set(&runtime)?;
206                let artifact_mutations = Arc::new(BundleBackedChangeArtifactMutationService::new(
207                    SqliteChangeArtifactBundleClient::new(&runtime),
208                ));
209                Ok(RepositoryRuntime {
210                    mode: PersistenceMode::Sqlite,
211                    ito_path,
212                    backend_runtime: None,
213                    sqlite_runtime: Some(runtime),
214                    repositories,
215                    change_artifact_mutations: artifact_mutations,
216                })
217            }
218            PersistenceMode::Remote => {
219                let ito_path = self.ito_path;
220                let runtime = self.backend_runtime.ok_or_else(|| {
221                    CoreError::validation("remote mode requires backend runtime".to_string())
222                })?;
223                let repositories = self.remote_factory.build(&runtime)?;
224                let artifact_mutations = Arc::new(BundleBackedChangeArtifactMutationService::new(
225                    RemoteChangeArtifactBundleClient::new(BackendHttpClient::new(runtime.clone())),
226                ));
227                Ok(RepositoryRuntime {
228                    mode: PersistenceMode::Remote,
229                    ito_path,
230                    backend_runtime: Some(runtime),
231                    sqlite_runtime: None,
232                    repositories,
233                    change_artifact_mutations: artifact_mutations,
234                })
235            }
236        }
237    }
238}
239
240/// Resolve repository runtime for the current configuration.
241pub fn resolve_repository_runtime(
242    ito_path: &Path,
243    ctx: &ConfigContext,
244) -> CoreResult<RepositoryRuntime> {
245    let project_root = ctx
246        .project_dir
247        .as_deref()
248        .unwrap_or_else(|| ito_path.parent().unwrap_or(ito_path));
249    let merged = load_cascading_project_config(project_root, ito_path, ctx).merged;
250    let backend_enabled = merged
251        .pointer("/backend/enabled")
252        .and_then(|v| v.as_bool())
253        .unwrap_or(false);
254    let raw_mode = merged
255        .pointer("/repository/mode")
256        .and_then(|v| v.as_str())
257        .unwrap_or("filesystem");
258
259    // Fail fast on unrecognized repository.mode values before attempting full
260    // config deserialization. This prevents silent fallback to filesystem mode
261    // when the user has set an invalid mode string.
262    if RepositoryPersistenceMode::parse_value(raw_mode).is_none() {
263        let valid = RepositoryPersistenceMode::ALL.join(", ");
264        return Err(CoreError::validation(format!(
265            "Invalid repository.mode '{raw_mode}': must be one of {valid}"
266        )));
267    }
268
269    let sqlite_enabled = raw_mode == "sqlite";
270
271    let config = match serde_json::from_value::<ItoConfig>(merged) {
272        Ok(config) => config,
273        Err(err) => {
274            if backend_enabled || sqlite_enabled {
275                let mode = if backend_enabled {
276                    "backend mode is enabled"
277                } else {
278                    "sqlite persistence mode is enabled"
279                };
280                return Err(CoreError::validation(format!(
281                    "Failed to parse Ito config while {mode}: {err}"
282                )));
283            }
284            return RepositoryRuntimeBuilder::new(ito_path).build();
285        }
286    };
287
288    if !config.backend.enabled {
289        return match config.repository.mode {
290            RepositoryPersistenceMode::Filesystem => {
291                RepositoryRuntimeBuilder::new(ito_path).build()
292            }
293            RepositoryPersistenceMode::Sqlite => {
294                let runtime = resolve_sqlite_runtime(&config, project_root)?;
295                RepositoryRuntimeBuilder::new(ito_path)
296                    .mode(PersistenceMode::Sqlite)
297                    .sqlite_runtime(runtime)
298                    .build()
299            }
300        };
301    }
302
303    let runtime = resolve_backend_runtime(&config.backend)?.ok_or_else(|| {
304        CoreError::validation("Backend mode is enabled but runtime was not resolved".to_string())
305    })?;
306
307    RepositoryRuntimeBuilder::new(ito_path)
308        .mode(PersistenceMode::Remote)
309        .backend_runtime(runtime)
310        .build()
311}
312
313fn resolve_sqlite_runtime(config: &ItoConfig, project_root: &Path) -> CoreResult<SqliteRuntime> {
314    let Some(db_path) = config.repository.sqlite.db_path.as_deref() else {
315        return Err(CoreError::validation(
316            "SQLite persistence mode requires 'repository.sqlite.dbPath' to be set",
317        ));
318    };
319    let db_path = db_path.trim();
320    if db_path.is_empty() {
321        return Err(CoreError::validation(
322            "SQLite persistence mode requires 'repository.sqlite.dbPath' to be set",
323        ));
324    }
325
326    let db_path = PathBuf::from(db_path);
327    let db_path = if db_path.is_absolute() {
328        db_path
329    } else {
330        project_root.join(db_path)
331    };
332    let db_path =
333        absolutize_and_normalize(&db_path).unwrap_or_else(|_| lexical_normalize(&db_path));
334
335    let repo = match project_root.file_name().and_then(|s| s.to_str()) {
336        Some(name) if !name.trim().is_empty() => name.to_string(),
337        _ => "project".to_string(),
338    };
339
340    Ok(SqliteRuntime {
341        db_path,
342        org: "local".to_string(),
343        repo,
344    })
345}
346
347fn filesystem_repository_set(ito_path: &Path) -> RepositorySet {
348    let ito_path = ito_path.to_path_buf();
349    RepositorySet {
350        changes: Arc::new(OwnedFsChangeRepository::new(ito_path.clone())),
351        modules: Arc::new(OwnedFsModuleRepository::new(ito_path.clone())),
352        tasks: Arc::new(OwnedFsTaskRepository::new(ito_path.clone())),
353        task_mutations: Arc::new(FsTaskMutationService::new(ito_path.clone())),
354        specs: Arc::new(OwnedFsSpecRepository::new(ito_path)),
355    }
356}
357
358fn sqlite_repository_set(runtime: &SqliteRuntime) -> CoreResult<RepositorySet> {
359    let store = SqliteBackendProjectStore::open(&runtime.db_path)?;
360    store.repository_set(&runtime.org, &runtime.repo)
361}
362
363pub(crate) fn boxed_fs_change_repository(ito_path: PathBuf) -> Box<dyn ChangeRepository + Send> {
364    Box::new(OwnedFsChangeRepository::new(ito_path))
365}
366
367pub(crate) fn boxed_fs_module_repository(ito_path: PathBuf) -> Box<dyn ModuleRepository + Send> {
368    Box::new(OwnedFsModuleRepository::new(ito_path))
369}
370
371pub(crate) fn boxed_fs_task_repository(ito_path: PathBuf) -> Box<dyn TaskRepository + Send> {
372    Box::new(OwnedFsTaskRepository::new(ito_path))
373}
374
375pub(crate) fn boxed_fs_task_mutation_port(
376    ito_path: PathBuf,
377) -> Box<dyn TaskMutationService + Send> {
378    boxed_fs_task_mutation_service(ito_path)
379}
380
381pub(crate) fn boxed_fs_spec_repository(ito_path: PathBuf) -> Box<dyn SpecRepository + Send> {
382    Box::new(OwnedFsSpecRepository::new(ito_path))
383}
384
385// ── Owned-path filesystem wrappers ─────────────────────────────────
386
387#[derive(Debug, Clone)]
388struct OwnedFsChangeRepository {
389    ito_path: PathBuf,
390}
391
392impl OwnedFsChangeRepository {
393    fn new(ito_path: PathBuf) -> Self {
394        Self { ito_path }
395    }
396
397    fn inner(&self) -> FsChangeRepository<'_> {
398        FsChangeRepository::new(&self.ito_path)
399    }
400}
401
402impl ChangeRepository for OwnedFsChangeRepository {
403    fn resolve_target_with_options(
404        &self,
405        input: &str,
406        options: ito_domain::changes::ResolveTargetOptions,
407    ) -> ito_domain::changes::ChangeTargetResolution {
408        self.inner().resolve_target_with_options(input, options)
409    }
410
411    fn suggest_targets(&self, input: &str, max: usize) -> Vec<String> {
412        self.inner().suggest_targets(input, max)
413    }
414
415    fn exists(&self, id: &str) -> bool {
416        self.inner().exists(id)
417    }
418
419    fn exists_with_filter(
420        &self,
421        id: &str,
422        filter: ito_domain::changes::ChangeLifecycleFilter,
423    ) -> bool {
424        self.inner().exists_with_filter(id, filter)
425    }
426
427    fn get_with_filter(
428        &self,
429        id: &str,
430        filter: ito_domain::changes::ChangeLifecycleFilter,
431    ) -> ito_domain::errors::DomainResult<ito_domain::changes::Change> {
432        self.inner().get_with_filter(id, filter)
433    }
434
435    fn list_with_filter(
436        &self,
437        filter: ito_domain::changes::ChangeLifecycleFilter,
438    ) -> ito_domain::errors::DomainResult<Vec<ito_domain::changes::ChangeSummary>> {
439        self.inner().list_with_filter(filter)
440    }
441
442    fn list_by_module_with_filter(
443        &self,
444        module_id: &str,
445        filter: ito_domain::changes::ChangeLifecycleFilter,
446    ) -> ito_domain::errors::DomainResult<Vec<ito_domain::changes::ChangeSummary>> {
447        self.inner().list_by_module_with_filter(module_id, filter)
448    }
449
450    fn list_incomplete_with_filter(
451        &self,
452        filter: ito_domain::changes::ChangeLifecycleFilter,
453    ) -> ito_domain::errors::DomainResult<Vec<ito_domain::changes::ChangeSummary>> {
454        self.inner().list_incomplete_with_filter(filter)
455    }
456
457    fn list_complete_with_filter(
458        &self,
459        filter: ito_domain::changes::ChangeLifecycleFilter,
460    ) -> ito_domain::errors::DomainResult<Vec<ito_domain::changes::ChangeSummary>> {
461        self.inner().list_complete_with_filter(filter)
462    }
463
464    fn get_summary_with_filter(
465        &self,
466        id: &str,
467        filter: ito_domain::changes::ChangeLifecycleFilter,
468    ) -> ito_domain::errors::DomainResult<ito_domain::changes::ChangeSummary> {
469        self.inner().get_summary_with_filter(id, filter)
470    }
471}
472
473#[derive(Debug, Clone)]
474struct OwnedFsModuleRepository {
475    ito_path: PathBuf,
476}
477
478impl OwnedFsModuleRepository {
479    fn new(ito_path: PathBuf) -> Self {
480        Self { ito_path }
481    }
482
483    fn inner(&self) -> FsModuleRepository<'_> {
484        FsModuleRepository::new(&self.ito_path)
485    }
486}
487
488impl ModuleRepository for OwnedFsModuleRepository {
489    fn exists(&self, id: &str) -> bool {
490        self.inner().exists(id)
491    }
492
493    fn get(
494        &self,
495        id_or_name: &str,
496    ) -> ito_domain::errors::DomainResult<ito_domain::modules::Module> {
497        self.inner().get(id_or_name)
498    }
499
500    fn list(&self) -> ito_domain::errors::DomainResult<Vec<ito_domain::modules::ModuleSummary>> {
501        self.inner().list()
502    }
503
504    fn list_sub_modules(
505        &self,
506        parent_id: &str,
507    ) -> ito_domain::errors::DomainResult<Vec<ito_domain::modules::SubModuleSummary>> {
508        self.inner().list_sub_modules(parent_id)
509    }
510
511    fn get_sub_module(
512        &self,
513        composite_id: &str,
514    ) -> ito_domain::errors::DomainResult<ito_domain::modules::SubModule> {
515        self.inner().get_sub_module(composite_id)
516    }
517}
518
519#[derive(Debug, Clone)]
520struct OwnedFsTaskRepository {
521    ito_path: PathBuf,
522}
523
524impl OwnedFsTaskRepository {
525    fn new(ito_path: PathBuf) -> Self {
526        Self { ito_path }
527    }
528
529    fn inner(&self) -> FsTaskRepository<'_> {
530        FsTaskRepository::new(&self.ito_path)
531    }
532}
533
534impl TaskRepository for OwnedFsTaskRepository {
535    fn load_tasks(
536        &self,
537        change_id: &str,
538    ) -> ito_domain::errors::DomainResult<ito_domain::tasks::TasksParseResult> {
539        self.inner().load_tasks(change_id)
540    }
541}
542
543#[derive(Debug, Clone)]
544struct OwnedFsSpecRepository {
545    ito_path: PathBuf,
546}
547
548impl OwnedFsSpecRepository {
549    fn new(ito_path: PathBuf) -> Self {
550        Self { ito_path }
551    }
552
553    fn inner(&self) -> FsSpecRepository<'_> {
554        FsSpecRepository::new(&self.ito_path)
555    }
556}
557
558impl SpecRepository for OwnedFsSpecRepository {
559    fn list(&self) -> ito_domain::errors::DomainResult<Vec<ito_domain::specs::SpecSummary>> {
560        self.inner().list()
561    }
562
563    fn get(&self, id: &str) -> ito_domain::errors::DomainResult<ito_domain::specs::SpecDocument> {
564        self.inner().get(id)
565    }
566}