Skip to main content

things_mcp/
state.rs

1//! Application state shared across MCP tool invocations.
2//!
3//! Built once at startup: loads config, resolves the DB path, runs schema
4//! probe, takes a startup backup (unless test-DB mode is in effect), and
5//! builds the reader pool.
6
7use std::path::PathBuf;
8use std::sync::Arc;
9
10use crate::core::{
11    applescript::{
12        admin::TagAdmin,
13        driver::{AppleScriptDriver, OsascriptDriver},
14    },
15    backup,
16    config::{self, Config},
17    reader::{
18        fts::{self, FtsCapability},
19        pool::ReaderPool,
20        schema,
21    },
22    writer::{
23        executor::{Executor, OpenCommandExecutor},
24        secret::SecretString,
25        writer::{SafetyMode, Writer, WriterCfg},
26    },
27};
28
29#[derive(Clone)]
30pub struct AppState {
31    pub config: Arc<Config>,
32    pub db_path: PathBuf,
33    pub pool: ReaderPool,
34    pub test_db_mode: bool,
35    pub allow_writes_on_test_db: bool,
36    pub fts: Option<FtsCapability>,
37    pub writer: Arc<Writer>,
38    pub tag_admin: Arc<TagAdmin>,
39}
40
41pub struct AppStateOptions {
42    pub env_db_path: Option<PathBuf>,
43    pub home_dir: PathBuf,
44    pub config_path: PathBuf,
45    pub allow_writes_on_test_db: bool,
46    /// Test-only: inject a `RecordingExecutor` (or any other) in place of the
47    /// production `OpenCommandExecutor`. `None` in production code paths.
48    pub executor_override: Option<Arc<dyn Executor>>,
49    /// Test-only: inject a `RecordingAppleScript` (or any other) in place of
50    /// the production `OsascriptDriver`. `None` in production code paths.
51    pub applescript_override: Option<Arc<dyn AppleScriptDriver>>,
52}
53
54impl AppState {
55    pub async fn build(opts: AppStateOptions) -> anyhow::Result<Self> {
56        let mut cfg = Config::load_from(&opts.config_path)?;
57        let test_db_mode = opts.env_db_path.is_some();
58
59        let (db_path, _hit) =
60            config::resolve_db_path(&mut cfg, opts.env_db_path.as_deref(), &opts.home_dir)?;
61        if !test_db_mode {
62            // Persist the resolved path back for next start.
63            cfg.save_to(&opts.config_path)?;
64        }
65        schema::probe(&db_path)?;
66
67        if !test_db_mode {
68            let backup_dir = cfg.backup.directory.clone().unwrap_or_else(|| {
69                config::config_dir()
70                    .unwrap_or_else(|_| PathBuf::from("."))
71                    .join("backups")
72            });
73            match backup::snapshot(&db_path, &backup_dir) {
74                Ok(b) => {
75                    tracing::info!("backup ok: {} ({} bytes)", b.path.display(), b.bytes);
76                    let dropped = backup::rotate(&backup_dir, cfg.backup.retain)?;
77                    if dropped > 0 {
78                        tracing::info!("rotated {} old backups", dropped);
79                    }
80                }
81                Err(e) => tracing::warn!("backup failed (continuing): {e:#}"),
82            }
83        }
84
85        let pool = ReaderPool::new(db_path.clone(), 4).await?;
86        let fts = pool
87            .with_conn(|c| fts::detect(c))
88            .await
89            .unwrap_or(None);
90        match &fts {
91            Some(cap) => tracing::info!(
92                "FTS5 capability: detected (table={}, columns={:?})",
93                cap.table,
94                cap.columns
95            ),
96            None => tracing::info!("FTS5 capability: not detected; search uses LIKE fallback"),
97        }
98        let executor: Arc<dyn Executor> = opts
99            .executor_override
100            .clone()
101            .unwrap_or_else(|| Arc::new(OpenCommandExecutor));
102
103        let safety = if test_db_mode {
104            if opts.allow_writes_on_test_db {
105                SafetyMode::DryRun
106            } else {
107                SafetyMode::Forbidden
108            }
109        } else {
110            SafetyMode::Live
111        };
112
113        let auth = std::env::var("THINGS_AUTH_TOKEN")
114            .ok()
115            .or_else(|| cfg.things.auth_token.clone())
116            .map(SecretString::new);
117
118        let writer = Arc::new(Writer {
119            executor,
120            pool: pool.clone(),
121            auth,
122            cfg: WriterCfg {
123                poll_timeout: std::time::Duration::from_millis(cfg.writer.poll_timeout_ms),
124                poll_interval: std::time::Duration::from_millis(cfg.writer.poll_interval_ms),
125            },
126            safety,
127        });
128
129        let applescript: Arc<dyn AppleScriptDriver> = opts
130            .applescript_override
131            .clone()
132            .unwrap_or_else(|| Arc::new(OsascriptDriver));
133
134        let tag_admin = Arc::new(TagAdmin {
135            driver: applescript,
136            safety,
137        });
138
139        Ok(Self {
140            config: Arc::new(cfg),
141            db_path,
142            pool,
143            test_db_mode,
144            allow_writes_on_test_db: opts.allow_writes_on_test_db,
145            fts,
146            writer,
147            tag_admin,
148        })
149    }
150}