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