1pub 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 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 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
72pub 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}