koan_core/db/
connection.rs1use std::path::Path;
2
3use rusqlite::Connection;
4use thiserror::Error;
5
6use super::schema;
7use crate::config;
8
9#[derive(Debug, Error)]
10pub enum DbError {
11 #[error("sqlite error: {0}")]
12 Sqlite(#[from] rusqlite::Error),
13 #[error("io error: {0}")]
14 Io(#[from] std::io::Error),
15 #[error("refused unsafe bulk delete: {0}")]
18 UnsafeBulkDelete(String),
19}
20
21pub struct Database {
23 pub conn: Connection,
24}
25
26impl Database {
27 pub fn open(path: &Path) -> Result<Self, DbError> {
35 if let Some(parent) = path.parent() {
36 std::fs::create_dir_all(parent)?;
37 }
38
39 let conn = Connection::open(path)?;
40
41 #[cfg(unix)]
42 {
43 use std::os::unix::fs::PermissionsExt;
44 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
45 }
46
47 configure(&conn)?;
48
49 let _ = conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE)");
53
54 schema::create_tables(&conn)?;
55
56 Ok(Self { conn })
57 }
58
59 pub fn open_existing(path: &Path) -> Result<Self, DbError> {
65 let conn = Connection::open(path)?;
66 configure(&conn)?;
67 Ok(Self { conn })
68 }
69
70 pub fn open_default() -> Result<Self, DbError> {
72 Self::open(&config::db_path())
73 }
74}
75
76fn configure(conn: &Connection) -> Result<(), DbError> {
79 conn.pragma_update(None, "journal_mode", "wal")?;
81 conn.pragma_update(None, "foreign_keys", "on")?;
82 conn.pragma_update(None, "busy_timeout", 30000)?;
85 conn.pragma_update(None, "synchronous", "normal")?;
87 register_library_collation(conn)?;
90 Ok(())
91}
92
93pub(crate) fn register_library_collation(conn: &Connection) -> rusqlite::Result<()> {
109 conn.create_collation("LIBRARY", |a, b| {
110 sort_key(a).cmp(&sort_key(b)).then(a.cmp(b))
111 })
112}
113
114#[derive(PartialEq, Eq, PartialOrd, Ord)]
117enum Chunk {
118 Number(u128),
119 Text(String),
120}
121
122fn sort_key(s: &str) -> Vec<Chunk> {
123 use unicode_normalization::UnicodeNormalization;
124
125 let folded: String = s
128 .nfd()
129 .filter(|c| !matches!(*c as u32, 0x0300..=0x036F))
130 .flat_map(char::to_lowercase)
131 .collect();
132
133 let mut chunks = Vec::new();
134 let mut rest = folded.as_str();
135 while !rest.is_empty() {
136 let digits = rest
137 .find(|c: char| !c.is_ascii_digit())
138 .unwrap_or(rest.len());
139 if digits > 0 && rest.starts_with(|c: char| c.is_ascii_digit()) {
140 match rest[..digits].parse::<u128>() {
142 Ok(n) => chunks.push(Chunk::Number(n)),
143 Err(_) => chunks.push(Chunk::Text(rest[..digits].to_string())),
144 }
145 rest = &rest[digits..];
146 continue;
147 }
148 let text = rest
149 .find(|c: char| c.is_ascii_digit())
150 .unwrap_or(rest.len())
151 .max(1);
152 chunks.push(Chunk::Text(rest[..text].to_string()));
153 rest = &rest[text..];
154 }
155 chunks
156}
157
158#[cfg(test)]
159mod collation_tests {
160 use super::*;
161
162 fn sorted(names: &[&str]) -> Vec<String> {
163 let conn = Connection::open_in_memory().unwrap();
164 crate::db::schema::create_tables(&conn).unwrap();
165 conn.execute_batch("CREATE TABLE t (name TEXT)").unwrap();
166 for n in names {
167 conn.execute("INSERT INTO t VALUES (?1)", [n]).unwrap();
168 }
169 let mut stmt = conn
170 .prepare("SELECT name FROM t ORDER BY name COLLATE LIBRARY")
171 .unwrap();
172 let rows = stmt.query_map([], |r| r.get::<_, String>(0)).unwrap();
173 rows.map(Result::unwrap).collect()
174 }
175
176 #[test]
177 fn lowercase_does_not_sort_after_everything() {
178 assert_eq!(
179 sorted(&["Zebra", "aphex twin", "Boards of Canada"]),
180 ["aphex twin", "Boards of Canada", "Zebra"]
181 );
182 }
183
184 #[test]
185 fn accents_sort_with_their_base_letter() {
186 assert_eq!(
189 sorted(&["Zomby", "Âme", "Alva Noto"]),
190 ["Alva Noto", "Âme", "Zomby"]
191 );
192 }
193
194 #[test]
195 fn digit_runs_compare_as_numbers() {
196 assert_eq!(
197 sorted(&["Track 10", "Track 2", "Track 1"]),
198 ["Track 1", "Track 2", "Track 10"]
199 );
200 }
201
202 #[test]
203 fn names_differing_only_in_case_keep_a_stable_order() {
204 assert_eq!(
206 sorted(&["kraftwerk", "Kraftwerk"]),
207 ["Kraftwerk", "kraftwerk"]
208 );
209 }
210}