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, 2);
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            synced: true,
131        };
132        super::queries::insert_clip(&store, &clip).unwrap();
133        let rows = super::queries::list_clips(&store, None, None, None, false, 10).unwrap();
134        assert_eq!(rows.len(), 1);
135        assert_eq!(rows[0].id, "01HXABC");
136        assert_eq!(rows[0].content.as_deref(), Some(b"hello" as &[u8]));
137    }
138
139    #[test]
140    fn search_finds_text_clips() {
141        use super::models::StoredClip;
142        let store = Store::open(Path::new(":memory:")).unwrap();
143        for (i, body) in ["hello world", "foo bar", "hello foo"].iter().enumerate() {
144            super::queries::insert_clip(
145                &store,
146                &StoredClip {
147                    id: format!("01HXABC{i:03}"),
148                    source: "m".into(),
149                    source_key: None,
150                    content_type: "text/plain".into(),
151                    content: Some(body.as_bytes().to_vec()),
152                    media_path: None,
153                    byte_size: body.len() as i64,
154                    created_at: 1_700_000_000_000 + i as i64,
155                    pinned: false,
156                    pinned_at: None,
157                    synced: true,
158                },
159            )
160            .unwrap();
161        }
162        let hits = super::queries::search_clips(&store, "hello", 10).unwrap();
163        assert_eq!(hits.len(), 2);
164    }
165
166    #[test]
167    fn resolve_prefix_handles_ambiguity() {
168        use super::models::{ResolveError, StoredClip};
169        let store = Store::open(Path::new(":memory:")).unwrap();
170        for id in ["01HXABC001", "01HXABC002", "01HXDEF003"] {
171            super::queries::insert_clip(
172                &store,
173                &StoredClip {
174                    id: id.into(),
175                    source: "m".into(),
176                    source_key: None,
177                    content_type: "text/plain".into(),
178                    content: Some(b"x".to_vec()),
179                    media_path: None,
180                    byte_size: 1,
181                    created_at: 0,
182                    pinned: false,
183                    pinned_at: None,
184                    synced: true,
185                },
186            )
187            .unwrap();
188        }
189        assert!(matches!(
190            super::prefix::resolve_clip_id(&store, "01H"),
191            Err(ResolveError::TooShort)
192        ));
193        assert!(matches!(
194            super::prefix::resolve_clip_id(&store, "9999"),
195            Err(ResolveError::NotFound)
196        ));
197        let dup = super::prefix::resolve_clip_id(&store, "01HXABC");
198        assert!(matches!(dup, Err(ResolveError::Ambiguous { .. })));
199        let exact = super::prefix::resolve_clip_id(&store, "01HXDEF003").unwrap();
200        assert_eq!(exact, "01HXDEF003");
201    }
202}