1use 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 pub executor_override: Option<Arc<dyn Executor>>,
49 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 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}