Skip to main content

client_core/store/
mod.rs

1//! Local SQLite store for clips, devices, prefs. Shared by CLI and desktop.
2
3pub mod migration;
4pub mod models;
5pub mod prefix;
6pub mod queries;
7pub mod schema;
8
9use rusqlite::Connection;
10use std::path::{Path, PathBuf};
11use std::sync::Mutex;
12
13pub struct Store {
14    conn: Mutex<Connection>,
15    path: PathBuf,
16}
17
18#[derive(Debug, thiserror::Error)]
19pub enum StoreError {
20    #[error("sqlite: {0}")]
21    Sqlite(#[from] rusqlite::Error),
22    #[error("io: {0}")]
23    Io(#[from] std::io::Error),
24    #[error("migration: {0}")]
25    Migration(String),
26}
27
28impl Store {
29    /// Open or create a store at `path`. Applies pending migrations.
30    /// `:memory:` is recognised for tests.
31    pub fn open(path: &Path) -> Result<Self, StoreError> {
32        let is_memory = path == Path::new(":memory:");
33        let conn = if is_memory {
34            Connection::open_in_memory()?
35        } else {
36            if let Some(dir) = path.parent() {
37                std::fs::create_dir_all(dir)?;
38            }
39            Connection::open(path)?
40        };
41        conn.pragma_update(None, "journal_mode", "WAL")?;
42        conn.pragma_update(None, "synchronous", "NORMAL")?;
43        conn.pragma_update(None, "busy_timeout", 5000)?;
44        schema::apply_migrations(&conn)?;
45        let store = Self {
46            conn: Mutex::new(conn),
47            path: path.to_path_buf(),
48        };
49        // One-shot import from the desktop's legacy SQLite if present.
50        // Idempotent; skipped for in-memory test stores.
51        if !is_memory {
52            if let Ok(media) = default_media_root() {
53                let _ = migration::import_legacy_if_present(&store, &media, None);
54            }
55        }
56        Ok(store)
57    }
58
59    pub fn path(&self) -> &Path {
60        &self.path
61    }
62
63    pub(crate) fn with_conn<R>(
64        &self,
65        f: impl FnOnce(&Connection) -> Result<R, rusqlite::Error>,
66    ) -> Result<R, StoreError> {
67        let guard = self.conn.lock().expect("store mutex poisoned");
68        Ok(f(&guard)?)
69    }
70}
71
72/// Returns `<home>/.cinch`, creating it if necessary. Used as the storage
73/// root for both CLI and desktop on every supported platform.
74pub fn cinch_dir() -> std::io::Result<PathBuf> {
75    let home = dirs::home_dir()
76        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "home_dir unavailable"))?;
77    let dir = home.join(".cinch");
78    std::fs::create_dir_all(&dir)?;
79    Ok(dir)
80}
81
82pub fn default_db_path() -> std::io::Result<PathBuf> {
83    Ok(cinch_dir()?.join("store.db"))
84}
85
86pub fn default_media_root() -> std::io::Result<PathBuf> {
87    let dir = cinch_dir()?.join("media");
88    std::fs::create_dir_all(&dir)?;
89    Ok(dir)
90}
91
92pub fn lock_path() -> std::io::Result<PathBuf> {
93    Ok(cinch_dir()?.join("sync.lock"))
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn opens_in_memory_and_runs_migrations() {
102        let store = Store::open(Path::new(":memory:")).expect("open");
103        let version: i64 = store
104            .with_conn(|c| {
105                c.query_row(
106                    "SELECT CAST(value AS INTEGER) FROM meta WHERE key='schema_version'",
107                    [],
108                    |r| r.get(0),
109                )
110            })
111            .expect("read schema_version");
112        assert_eq!(version, 1);
113    }
114
115    #[test]
116    fn insert_and_list_clips_round_trip() {
117        use super::models::StoredClip;
118        let store = Store::open(Path::new(":memory:")).unwrap();
119        let clip = StoredClip {
120            id: "01HXABC".into(),
121            source: "atlas0".into(),
122            source_key: None,
123            content_type: "text/plain".into(),
124            content: Some(b"hello".to_vec()),
125            media_path: None,
126            byte_size: 5,
127            created_at: 1_700_000_000_000,
128            pinned: false,
129            pinned_at: None,
130        };
131        super::queries::insert_clip(&store, &clip).unwrap();
132        let rows = super::queries::list_clips(&store, None, None, None, false, 10).unwrap();
133        assert_eq!(rows.len(), 1);
134        assert_eq!(rows[0].id, "01HXABC");
135        assert_eq!(rows[0].content.as_deref(), Some(b"hello" as &[u8]));
136    }
137
138    #[test]
139    fn search_finds_text_clips() {
140        use super::models::StoredClip;
141        let store = Store::open(Path::new(":memory:")).unwrap();
142        for (i, body) in ["hello world", "foo bar", "hello foo"].iter().enumerate() {
143            super::queries::insert_clip(
144                &store,
145                &StoredClip {
146                    id: format!("01HXABC{i:03}"),
147                    source: "m".into(),
148                    source_key: None,
149                    content_type: "text/plain".into(),
150                    content: Some(body.as_bytes().to_vec()),
151                    media_path: None,
152                    byte_size: body.len() as i64,
153                    created_at: 1_700_000_000_000 + i as i64,
154                    pinned: false,
155                    pinned_at: None,
156                },
157            )
158            .unwrap();
159        }
160        let hits = super::queries::search_clips(&store, "hello", 10).unwrap();
161        assert_eq!(hits.len(), 2);
162    }
163
164    #[test]
165    fn resolve_prefix_handles_ambiguity() {
166        use super::models::{ResolveError, StoredClip};
167        let store = Store::open(Path::new(":memory:")).unwrap();
168        for id in ["01HXABC001", "01HXABC002", "01HXDEF003"] {
169            super::queries::insert_clip(
170                &store,
171                &StoredClip {
172                    id: id.into(),
173                    source: "m".into(),
174                    source_key: None,
175                    content_type: "text/plain".into(),
176                    content: Some(b"x".to_vec()),
177                    media_path: None,
178                    byte_size: 1,
179                    created_at: 0,
180                    pinned: false,
181                    pinned_at: None,
182                },
183            )
184            .unwrap();
185        }
186        assert!(matches!(
187            super::prefix::resolve_clip_id(&store, "01H"),
188            Err(ResolveError::TooShort)
189        ));
190        assert!(matches!(
191            super::prefix::resolve_clip_id(&store, "9999"),
192            Err(ResolveError::NotFound)
193        ));
194        let dup = super::prefix::resolve_clip_id(&store, "01HXABC");
195        assert!(matches!(dup, Err(ResolveError::Ambiguous { .. })));
196        let exact = super::prefix::resolve_clip_id(&store, "01HXDEF003").unwrap();
197        assert_eq!(exact, "01HXDEF003");
198    }
199}