1use anyhow::{Context, Result};
18use rusqlite::Connection;
19
20pub const SCHEMA_VERSION: i64 = 2;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct Outcome {
26 pub from: i64,
28 pub to: i64,
30}
31
32impl Outcome {
33 pub fn migrated(&self) -> bool {
35 self.from != self.to
36 }
37}
38
39pub fn schema_version(conn: &Connection) -> Result<i64> {
41 let v: i64 = conn
42 .query_row("PRAGMA user_version", [], |row| row.get(0))
43 .context("lecture de PRAGMA user_version")?;
44 Ok(v)
45}
46
47fn set_schema_version(conn: &Connection, version: i64) -> Result<()> {
49 conn.execute_batch(&format!("PRAGMA user_version = {version};"))
52 .context("écriture de PRAGMA user_version")?;
53 Ok(())
54}
55
56pub fn apply(conn: &Connection) -> Result<Outcome> {
61 let from = schema_version(conn)?;
62
63 if from > SCHEMA_VERSION {
64 anyhow::bail!(
65 "base créée par une version plus récente de mnemo (schéma v{from}, \
66 cette version gère v{SCHEMA_VERSION}). Mettez mnemo à jour."
67 );
68 }
69
70 let mut version = from;
71 while version < SCHEMA_VERSION {
72 match version {
73 0 => migrate_0_to_1(conn)?,
74 1 => migrate_1_to_2(conn)?,
75 other => anyhow::bail!("aucune migration définie pour le schéma v{other}"),
76 }
77 version += 1;
78 set_schema_version(conn, version)?;
79 }
80
81 Ok(Outcome {
82 from,
83 to: SCHEMA_VERSION,
84 })
85}
86
87fn migrate_0_to_1(conn: &Connection) -> Result<()> {
91 conn.execute_batch(
92 "CREATE TABLE IF NOT EXISTS commands (
93 id INTEGER PRIMARY KEY,
94 command TEXT NOT NULL,
95 cwd TEXT,
96 shell TEXT,
97 hostname TEXT,
98 exit_code INTEGER,
99 created_at TEXT NOT NULL,
100 hash TEXT UNIQUE
101 );
102 CREATE INDEX IF NOT EXISTS idx_commands_created_at ON commands(created_at);",
103 )
104 .context("migration v0 -> v1 (schéma de base)")?;
105 Ok(())
106}
107
108fn migrate_1_to_2(conn: &Connection) -> Result<()> {
114 for column in ["git_root", "git_branch", "git_remote", "session_id"] {
115 if !column_exists(conn, "commands", column)? {
116 conn.execute_batch(&format!("ALTER TABLE commands ADD COLUMN {column} TEXT;"))
117 .with_context(|| format!("migration v1 -> v2 (ajout colonne {column})"))?;
118 }
119 }
120 conn.execute_batch(
121 "CREATE INDEX IF NOT EXISTS idx_commands_git_root ON commands(git_root);
122 CREATE INDEX IF NOT EXISTS idx_commands_git_branch ON commands(git_branch);",
123 )
124 .context("migration v1 -> v2 (index Git)")?;
125 Ok(())
126}
127
128fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool> {
130 let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
131 let mut rows = stmt.query([])?;
132 while let Some(row) = rows.next()? {
133 let name: String = row.get(1)?;
134 if name == column {
135 return Ok(true);
136 }
137 }
138 Ok(false)
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use rusqlite::Connection;
145
146 fn legacy_v1_db() -> Connection {
150 let conn = Connection::open_in_memory().unwrap();
151 conn.execute_batch(
152 "CREATE TABLE commands (
153 id INTEGER PRIMARY KEY,
154 command TEXT NOT NULL,
155 cwd TEXT,
156 shell TEXT,
157 hostname TEXT,
158 exit_code INTEGER,
159 created_at TEXT NOT NULL,
160 hash TEXT UNIQUE
161 );",
162 )
163 .unwrap();
164 conn.execute(
165 "INSERT INTO commands (command, cwd, created_at, hash)
166 VALUES ('ls -la', '/tmp', '2026-06-13 10:00:00', 'deadbeef')",
167 [],
168 )
169 .unwrap();
170 conn
171 }
172
173 fn has_column(conn: &Connection, column: &str) -> bool {
174 column_exists(conn, "commands", column).unwrap()
175 }
176
177 #[test]
178 fn migration_v1_vers_v2_ajoute_les_colonnes_git() {
179 let conn = legacy_v1_db();
180 assert_eq!(schema_version(&conn).unwrap(), 0);
181
182 let outcome = apply(&conn).unwrap();
183 assert_eq!(outcome.from, 0);
184 assert_eq!(outcome.to, SCHEMA_VERSION);
185 assert!(outcome.migrated());
186 assert_eq!(schema_version(&conn).unwrap(), SCHEMA_VERSION);
187
188 for col in ["git_root", "git_branch", "git_remote", "session_id"] {
189 assert!(has_column(&conn, col), "colonne {col} attendue");
190 }
191 }
192
193 #[test]
194 fn ancienne_base_reste_utilisable_apres_migration() {
195 let conn = legacy_v1_db();
196 let before: i64 = conn
197 .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0))
198 .unwrap();
199 assert_eq!(before, 1);
200
201 apply(&conn).unwrap();
202
203 let after: i64 = conn
205 .query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0))
206 .unwrap();
207 assert_eq!(after, 1);
208 let git_root: Option<String> = conn
209 .query_row(
210 "SELECT git_root FROM commands WHERE command = 'ls -la'",
211 [],
212 |r| r.get(0),
213 )
214 .unwrap();
215 assert!(git_root.is_none());
216 }
217
218 #[test]
219 fn migration_idempotente() {
220 let conn = legacy_v1_db();
221 let first = apply(&conn).unwrap();
222 assert!(first.migrated());
223
224 let second = apply(&conn).unwrap();
226 assert_eq!(second.from, SCHEMA_VERSION);
227 assert_eq!(second.to, SCHEMA_VERSION);
228 assert!(!second.migrated());
229
230 let third = apply(&conn).unwrap();
232 assert!(!third.migrated());
233 assert_eq!(schema_version(&conn).unwrap(), SCHEMA_VERSION);
234 }
235
236 #[test]
237 fn base_neuve_atteint_la_version_cible() {
238 let conn = Connection::open_in_memory().unwrap();
239 assert_eq!(schema_version(&conn).unwrap(), 0);
240 let outcome = apply(&conn).unwrap();
241 assert_eq!(outcome.to, SCHEMA_VERSION);
242 for col in ["git_root", "git_branch", "git_remote", "session_id"] {
243 assert!(has_column(&conn, col));
244 }
245 }
246
247 #[test]
248 fn base_plus_recente_est_refusee() {
249 let conn = Connection::open_in_memory().unwrap();
250 set_schema_version(&conn, SCHEMA_VERSION + 1).unwrap();
251 let err = apply(&conn).unwrap_err();
252 assert!(err.to_string().contains("version plus récente"));
253 }
254}