Skip to main content

oxi/
services.rs

1//! Composition root for oxi-cli.
2//!
3//! Wires concrete file-based port implementations (from `oxi-fs`) to the
4//! `Oxi` engine. Future run modes (TUI / print / RPC) build on top of
5//! the `Oxi` produced here.
6//!
7//! # Migration note
8//!
9//! The legacy `App` struct in `lib.rs` is the **single-user interactive**
10//! composition (TUI-driven, in-process). This module is the
11//! **port-based** composition: a `Oxi` engine with persistence, auth,
12//! config, and skills wired via `oxi_sdk::OxiBuilder::with_port_*`.
13//!
14//! Both paths are expected to coexist during the migration. New run modes
15//! should consume `build_oxi(...)` from this module; the legacy `App`
16//! path remains for the interactive TUI until it is fully migrated.
17
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21use anyhow::{Context, Result};
22
23use oxi_sdk::fs::{
24    FileAuthProvider, FileConfigStore, FilePersonaProvider, FileSkillLoader, FileStateStore,
25    SimpleAccessGate, TomlCapabilityResolver,
26};
27use oxi_sdk::inmem::{
28    CountingResourceMonitor, InMemoryCronScheduler, InMemoryMemoryStore, InProcessEventBus,
29};
30use oxi_sdk::Oxi;
31
32/// Resolved paths under the oxi home directory.
33#[derive(Debug, Clone)]
34pub struct OxiPaths {
35    /// Root directory (`$OXI_HOME` or `$HOME/.oxi`).
36    pub home: PathBuf,
37    /// `auth.json` location.
38    pub auth: PathBuf,
39    /// `settings.toml` location.
40    pub config: PathBuf,
41    /// Sessions directory.
42    pub sessions: PathBuf,
43    /// Skills root.
44    pub skills: PathBuf,
45}
46
47impl OxiPaths {
48    /// Resolve from the conventional home directory.
49    pub fn from_home(home: impl Into<PathBuf>) -> Self {
50        let home = home.into();
51        Self {
52            auth: home.join("auth.json"),
53            config: home.join("settings.toml"),
54            sessions: home.join("sessions"),
55            skills: home.join("skills"),
56            home,
57        }
58    }
59
60    /// Default — uses `$OXI_HOME` or `$HOME/.oxi`.
61    pub fn default_paths() -> Result<Self> {
62        oxi_sdk::fs::home_dir()
63            .map(Self::from_home)
64            .context("could not resolve oxi home directory")
65    }
66}
67
68/// Build an `Oxi` engine wired with file-based port implementations.
69///
70/// This is the **composition root** for oxi-cli. It is intentionally
71/// side-effect-light: it does not touch the network or start any task.
72/// Run modes (TUI, print, RPC) take the returned `Oxi` and run.
73pub fn build_oxi(paths: &OxiPaths) -> Result<Oxi> {
74    ensure_parent(&paths.auth)?;
75    ensure_parent(&paths.config)?;
76    ensure_parent(&paths.sessions)?;
77
78    let oxi = oxi_sdk::OxiBuilder::new()
79        .with_builtins()
80        .with_state(Arc::new(FileStateStore::new(&paths.sessions)))
81        .with_auth(Arc::new(FileAuthProvider::new(&paths.auth)))
82        .with_config(Arc::new(FileConfigStore::new(&paths.config)))
83        .with_skills(Arc::new(FileSkillLoader::single(&paths.skills)))
84        .with_personas(Arc::new(FilePersonaProvider::new(
85            paths.home.join("personas"),
86        )))
87        .with_access(Arc::new(SimpleAccessGate::from_file(
88            paths.home.join("access.toml"),
89        )))
90        .with_capabilities(Arc::new(TomlCapabilityResolver::from_file(
91            paths.home.join("capabilities.toml"),
92        )))
93        .with_event_bus(InProcessEventBus::new(64))
94        .with_memory(Arc::new(InMemoryMemoryStore::new()))
95        .with_cron(Arc::new(InMemoryCronScheduler::new()))
96        .with_resources(Arc::new(CountingResourceMonitor::new()))
97        .build();
98
99    Ok(oxi)
100}
101
102fn ensure_parent(path: &Path) -> Result<()> {
103    if let Some(parent) = path.parent() {
104        std::fs::create_dir_all(parent)
105            .with_context(|| format!("create_dir_all {}", parent.display()))?;
106    }
107    Ok(())
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn paths_are_consistent() {
116        let p = OxiPaths::from_home("/tmp/oxi-test");
117        assert!(p.auth.starts_with("/tmp/oxi-test"));
118        assert!(p.config.starts_with("/tmp/oxi-test"));
119        assert!(p.sessions.starts_with("/tmp/oxi-test"));
120        assert!(p.skills.starts_with("/tmp/oxi-test"));
121    }
122
123    #[test]
124    fn build_oxi_succeeds() {
125        let tmp = tempfile::TempDir::new().unwrap();
126        let paths = OxiPaths::from_home(tmp.path());
127        let oxi = build_oxi(&paths).unwrap();
128        // State is wired (even if we don't call it).
129        let _ = oxi.ports().state;
130    }
131}