Skip to main content

origin_app/
builder.rs

1use crate::application::Application;
2use crate::module::{ApplicationModule, ModuleRegistry};
3use crate::platform::Platform;
4use origin_accounts::{AccountService, AccountStore};
5use origin_auth::TokenStore;
6use origin_connector::{Connector, ConnectorRegistry};
7use origin_domain::{AppError, Clock, SystemClock};
8use origin_events::EventBus;
9use origin_http::HttpClient;
10use origin_jobs::Jobs;
11use origin_platform::{
12    ConfirmationService, DenyingConfirmationService, GlobalShortcutService, MemoryProcessRunner,
13    MemoryWorkspaceFs, MemoryWorkspaceWatcher, NoopGlobalShortcutService, NoopNotificationService,
14    NotificationService, Opener, ProcessAllowlist, ProcessRunner, TrayService, WorkspaceFs,
15    WorkspaceWatcher,
16};
17use origin_secrets::{MemorySecretStore, SecretStore};
18use origin_settings::{Settings, StorageSettingsStore};
19use origin_storage::{Cache, MemoryStorage, Storage};
20use origin_sync::SyncEngine;
21use std::sync::Arc;
22
23#[derive(Debug, thiserror::Error)]
24pub enum BuildError {
25    /// A required component was never provided. There is no implicit fallback for
26    /// storage or credentials — silently defaulting those would ship an application
27    /// that loses data or keeps tokens in process memory.
28    #[error("no {component} configured — call `.{component}(...)` on the builder")]
29    MissingComponent { component: &'static str },
30
31    #[error("module `{module}` failed to register: {source}")]
32    ModuleRegistration {
33        module: &'static str,
34        #[source]
35        source: AppError,
36    },
37}
38
39/// Assembles an [`Application`] from ports and modules.
40///
41/// Clock and event bus have exactly one sensible default and are pre-filled. Storage,
42/// credentials and notifications must be chosen explicitly — or taken from
43/// [`ApplicationBuilder::in_memory`] for tests.
44#[derive(Debug)]
45pub struct ApplicationBuilder {
46    clock: Arc<dyn Clock>,
47    events: EventBus,
48    storage: Option<Arc<dyn Storage>>,
49    secrets: Option<Arc<dyn SecretStore>>,
50    notifications: Option<Arc<dyn NotificationService>>,
51    opener: Option<Arc<dyn Opener>>,
52    http: Option<Arc<dyn HttpClient>>,
53    /// Human confirmation for operations that need it. Defaults to deny-all
54    /// (fail-closed) so a product that never wires a real prompt is safe.
55    confirmation: Arc<dyn ConfirmationService>,
56    /// The system tray, present only when the product declared it.
57    tray: Option<Arc<dyn TrayService>>,
58    connectors: ConnectorRegistry,
59    modules: Vec<Box<dyn ApplicationModule>>,
60    workspace_fs: Option<Arc<dyn WorkspaceFs>>,
61    workspace_watcher: Option<Arc<dyn WorkspaceWatcher>>,
62    process_runner: Option<Arc<dyn ProcessRunner>>,
63    global_shortcuts: Option<Arc<dyn GlobalShortcutService>>,
64}
65
66impl Default for ApplicationBuilder {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72impl ApplicationBuilder {
73    pub fn new() -> Self {
74        Self {
75            clock: Arc::new(SystemClock),
76            events: EventBus::new(),
77            storage: None,
78            secrets: None,
79            notifications: None,
80            opener: None,
81            http: None,
82            confirmation: Arc::new(DenyingConfirmationService),
83            tray: None,
84            connectors: ConnectorRegistry::new(),
85            modules: Vec::new(),
86            workspace_fs: None,
87            workspace_watcher: None,
88            process_runner: None,
89            global_shortcuts: None,
90        }
91    }
92
93    /// A fully in-memory application: no files, no keychain, no notifications.
94    ///
95    /// This is the configuration from ADR-0002 — it makes the whole application
96    /// testable without starting a desktop session.
97    pub fn in_memory() -> Self {
98        Self::new()
99            .storage(Arc::new(MemoryStorage::new()))
100            .secret_store(Arc::new(MemorySecretStore::new()))
101            .notifications(Arc::new(NoopNotificationService))
102            .workspace_fs(Arc::new(MemoryWorkspaceFs::new()))
103            .workspace_watcher(Arc::new(MemoryWorkspaceWatcher::new()))
104            .process_runner(Arc::new(MemoryProcessRunner::success(
105                ProcessAllowlist::default(),
106            )))
107            .global_shortcuts(Arc::new(NoopGlobalShortcutService))
108    }
109
110    pub fn clock(mut self, clock: Arc<dyn Clock>) -> Self {
111        self.clock = clock;
112        self
113    }
114
115    /// Share an existing bus, e.g. one the host layer already subscribed to.
116    pub fn event_bus(mut self, events: EventBus) -> Self {
117        self.events = events;
118        self
119    }
120
121    pub fn storage(mut self, storage: Arc<dyn Storage>) -> Self {
122        self.storage = Some(storage);
123        self
124    }
125
126    pub fn secret_store(mut self, secrets: Arc<dyn SecretStore>) -> Self {
127        self.secrets = Some(secrets);
128        self
129    }
130
131    pub fn notifications(mut self, notifications: Arc<dyn NotificationService>) -> Self {
132        self.notifications = Some(notifications);
133        self
134    }
135
136    /// Grant the ability to open external URLs. Omitted means the product does not
137    /// have the capability at all, not that it is disabled at runtime.
138    pub fn opener(mut self, opener: Arc<dyn Opener>) -> Self {
139        self.opener = Some(opener);
140        self
141    }
142
143    /// Override the human confirmation service. The default denies everything, so a
144    /// product that grants MCP mutation *must* call this — the error message in the
145    /// MCP tool response tells the product author why.
146    pub fn confirmation(mut self, confirmation: Arc<dyn ConfirmationService>) -> Self {
147        self.confirmation = confirmation;
148        self
149    }
150
151    /// Give the application a system tray. Products without a tray never call this
152    /// and get `None` — modules that need one see it through the platform.
153    pub fn tray(mut self, tray: Arc<dyn TrayService>) -> Self {
154        self.tray = Some(tray);
155        self
156    }
157
158    /// Give the application an HTTP client.
159    ///
160    /// One client for the whole application: it owns the connection pool, and several
161    /// would defeat keep-alive (ADR-0014).
162    pub fn http_client(mut self, http: Arc<dyn HttpClient>) -> Self {
163        self.http = Some(http);
164        self
165    }
166
167    /// Register a connector.
168    ///
169    /// The set of external services a build can reach is fixed here, at compile time,
170    /// and is therefore auditable (ADR-0006).
171    pub fn connector(mut self, connector: impl Connector) -> Self {
172        self.connectors.insert(Arc::new(connector));
173        self
174    }
175
176    /// Give the application a workspace filesystem adapter (B2).
177    pub fn workspace_fs(mut self, workspace_fs: Arc<dyn WorkspaceFs>) -> Self {
178        self.workspace_fs = Some(workspace_fs);
179        self
180    }
181
182    /// Give the application a workspace watcher adapter (B3).
183    pub fn workspace_watcher(mut self, workspace_watcher: Arc<dyn WorkspaceWatcher>) -> Self {
184        self.workspace_watcher = Some(workspace_watcher);
185        self
186    }
187
188    /// Give the application a process runner adapter (B1).
189    pub fn process_runner(mut self, process_runner: Arc<dyn ProcessRunner>) -> Self {
190        self.process_runner = Some(process_runner);
191        self
192    }
193
194    /// Give the application a global shortcut service adapter (B5).
195    pub fn global_shortcuts(mut self, global_shortcuts: Arc<dyn GlobalShortcutService>) -> Self {
196        self.global_shortcuts = Some(global_shortcuts);
197        self
198    }
199
200    pub fn module(mut self, module: impl ApplicationModule) -> Self {
201        self.modules.push(Box::new(module));
202        self
203    }
204
205    pub fn build(self) -> Result<Application, BuildError> {
206        let storage = self.storage.ok_or(BuildError::MissingComponent {
207            component: "storage",
208        })?;
209        let secrets = self.secrets.ok_or(BuildError::MissingComponent {
210            component: "secret_store",
211        })?;
212        let notifications = self.notifications.ok_or(BuildError::MissingComponent {
213            component: "notifications",
214        })?;
215
216        let cache = Cache::new(storage.clone(), self.clock.clone());
217        let settings = Settings::new(Arc::new(StorageSettingsStore::new(
218            storage.clone(),
219            self.clock.clone(),
220        )));
221
222        let accounts = AccountService::new(
223            AccountStore::new(storage.clone(), self.clock.clone()),
224            TokenStore::new(secrets.clone()),
225            self.events.clone(),
226            storage.clone(),
227            self.clock.clone(),
228        );
229
230        let jobs = Jobs::new(self.events.clone(), self.clock.clone());
231        let sync = SyncEngine::new(storage.clone(), self.clock.clone(), self.events.clone());
232
233        let platform = Platform {
234            clock: self.clock,
235            events: self.events,
236            jobs,
237            sync,
238            storage,
239            cache,
240            secrets,
241            settings,
242            notifications,
243            confirmation: self.confirmation,
244            tray: self.tray,
245            accounts,
246            connectors: self.connectors,
247            opener: self.opener,
248            http: self.http,
249            workspace_fs: self.workspace_fs,
250            workspace_watcher: self.workspace_watcher,
251            process_runner: self.process_runner,
252            global_shortcuts: self.global_shortcuts,
253        };
254
255        let mut registry = ModuleRegistry::new(platform.clone());
256        for module in &self.modules {
257            let id = module.id();
258            tracing::debug!(module = id, "registering module");
259            module
260                .register(&mut registry)
261                .map_err(|source| BuildError::ModuleRegistration { module: id, source })?;
262            registry.record_module(id);
263        }
264
265        tracing::info!(modules = ?registry.module_ids(), "application built");
266        Ok(Application::new(platform, registry))
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::ModuleRegistry;
274    use origin_domain::Result;
275
276    #[derive(Debug)]
277    struct Counter(u32);
278
279    #[derive(Debug)]
280    struct CountingModule;
281
282    impl ApplicationModule for CountingModule {
283        fn id(&self) -> &'static str {
284            "counting"
285        }
286
287        fn register(&self, registry: &mut ModuleRegistry) -> Result<()> {
288            registry.provide(Arc::new(Counter(7)));
289            Ok(())
290        }
291    }
292
293    #[derive(Debug)]
294    struct FailingModule;
295
296    impl ApplicationModule for FailingModule {
297        fn id(&self) -> &'static str {
298            "failing"
299        }
300
301        fn register(&self, _registry: &mut ModuleRegistry) -> Result<()> {
302            Err(AppError::configuration("missing api key"))
303        }
304    }
305
306    #[test]
307    fn a_module_service_is_resolvable_by_type() {
308        let app = ApplicationBuilder::in_memory()
309            .module(CountingModule)
310            .build()
311            .unwrap();
312
313        assert_eq!(app.modules(), &["counting"]);
314        assert_eq!(app.require::<Counter>().unwrap().0, 7);
315    }
316
317    #[test]
318    fn resolving_an_unregistered_service_names_the_type() {
319        let app = ApplicationBuilder::in_memory().build().unwrap();
320
321        let error = app.require::<Counter>().unwrap_err();
322        assert!(error.to_string().contains("Counter"), "got: {error}");
323    }
324
325    #[test]
326    fn missing_storage_is_a_build_error_not_a_silent_default() {
327        let error = ApplicationBuilder::new().build().unwrap_err();
328        assert!(matches!(
329            error,
330            BuildError::MissingComponent {
331                component: "storage"
332            }
333        ));
334    }
335
336    #[test]
337    fn a_build_without_an_http_client_says_what_is_missing() {
338        let app = ApplicationBuilder::in_memory().build().unwrap();
339
340        let error = app.platform().http().unwrap_err();
341
342        assert_eq!(error.kind(), origin_domain::ErrorKind::Configuration);
343        assert!(error.to_string().contains("http_client"), "got: {error}");
344    }
345
346    #[test]
347    fn a_build_without_an_opener_reports_a_permission_error() {
348        let app = ApplicationBuilder::in_memory().build().unwrap();
349
350        // Capabilities are absent, not disabled: nothing can turn this on at runtime.
351        assert_eq!(
352            app.platform().opener().unwrap_err().kind(),
353            origin_domain::ErrorKind::Permission
354        );
355    }
356
357    #[test]
358    fn in_memory_wires_workspace_and_process_and_shortcuts() {
359        let app = ApplicationBuilder::in_memory().build().unwrap();
360
361        assert!(app.platform().workspace_fs().is_ok());
362        assert!(app.platform().workspace_watcher().is_ok());
363        assert!(app.platform().process_runner().is_ok());
364        assert!(app.platform().global_shortcuts().is_ok());
365    }
366
367    #[test]
368    fn clean_build_reports_missing_workspace_components() {
369        let app = ApplicationBuilder::new()
370            .storage(Arc::new(MemoryStorage::new()))
371            .secret_store(Arc::new(MemorySecretStore::new()))
372            .notifications(Arc::new(NoopNotificationService))
373            .build()
374            .unwrap();
375
376        assert!(app.platform().workspace_fs().is_err());
377        assert!(app.platform().workspace_watcher().is_err());
378        assert!(app.platform().process_runner().is_err());
379        assert!(app.platform().global_shortcuts().is_err());
380    }
381
382    #[test]
383    fn registered_connectors_are_resolvable_and_unknown_ones_are_not() {
384        use origin_connector::{AuthKind, Connector, ConnectorDescriptor};
385        use origin_domain::{AccountId, ConnectorId};
386
387        #[derive(Debug)]
388        struct TestConnector;
389
390        #[async_trait::async_trait]
391        impl Connector for TestConnector {
392            fn id(&self) -> ConnectorId {
393                ConnectorId::new("test")
394            }
395
396            fn descriptor(&self) -> ConnectorDescriptor {
397                ConnectorDescriptor::new(self.id(), "Test", AuthKind::None)
398            }
399
400            async fn verify(
401                &self,
402                _account: &AccountId,
403            ) -> Result<origin_connector::AccountIdentity> {
404                unimplemented!("not needed for this test")
405            }
406        }
407
408        let app = ApplicationBuilder::in_memory()
409            .connector(TestConnector)
410            .build()
411            .unwrap();
412
413        assert_eq!(
414            app.platform().connectors.ids(),
415            vec![ConnectorId::new("test")]
416        );
417        assert!(
418            app.platform()
419                .connectors
420                .require(&ConnectorId::new("absent"))
421                .is_err()
422        );
423    }
424
425    #[test]
426    fn a_failing_module_names_itself() {
427        let error = ApplicationBuilder::in_memory()
428            .module(FailingModule)
429            .build()
430            .unwrap_err();
431
432        assert!(error.to_string().contains("failing"), "got: {error}");
433    }
434}