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