Skip to main content

mnemo/
migrations.rs

1//! Système de migrations du schéma SQLite, basé sur `PRAGMA user_version`.
2//!
3//! Principes :
4//! - Chaque base porte une version de schéma entière (`PRAGMA user_version`).
5//!   SQLite vaut `0` par défaut : c'est le cas des bases historiques de mnemo
6//!   (créées avant l'introduction des migrations) **et** des bases neuves.
7//! - Les migrations sont appliquées en séquence, de la version courante jusqu'à
8//!   [`SCHEMA_VERSION`]. Chaque étape est **idempotente** et **non
9//!   destructive** : on ne supprime ni ne réécrit jamais de données existantes.
10//! - `mnemo doctor` lit la version sans jamais migrer (lecture seule).
11//!
12//! Historique des versions :
13//! - `1` : schéma de base (table `commands`).
14//! - `2` : ajout du contexte Git (`git_root`, `git_branch`, `git_remote`,
15//!   `session_id`).
16
17use anyhow::{Context, Result};
18use rusqlite::Connection;
19
20/// Version cible du schéma. Toute base ouverte par mnemo est migrée jusqu'ici.
21pub const SCHEMA_VERSION: i64 = 2;
22
23/// Résultat d'une application de migrations.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct Outcome {
26    /// Version du schéma avant migration.
27    pub from: i64,
28    /// Version du schéma après migration (== [`SCHEMA_VERSION`]).
29    pub to: i64,
30}
31
32impl Outcome {
33    /// Vrai si au moins une migration a effectivement été appliquée.
34    pub fn migrated(&self) -> bool {
35        self.from != self.to
36    }
37}
38
39/// Lit la version de schéma stockée dans la base (`PRAGMA user_version`).
40pub 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
47/// Écrit la version de schéma dans la base.
48fn set_schema_version(conn: &Connection, version: i64) -> Result<()> {
49    // `PRAGMA user_version` n'accepte pas de paramètre lié : on formate un
50    // entier validé, sans interpolation de donnée externe.
51    conn.execute_batch(&format!("PRAGMA user_version = {version};"))
52        .context("écriture de PRAGMA user_version")?;
53    Ok(())
54}
55
56/// Applique toutes les migrations en attente jusqu'à [`SCHEMA_VERSION`].
57///
58/// Renvoie les versions avant/après. Sûr à appeler à chaque ouverture de base :
59/// si la base est déjà à jour, aucune écriture n'est effectuée.
60pub 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
87/// v0 → v1 : schéma de base. `CREATE TABLE IF NOT EXISTS` rend l'étape
88/// idempotente, y compris pour les bases historiques qui possèdent déjà la
89/// table `commands` mais dont `user_version` valait encore `0`.
90fn 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
108/// v1 → v2 : ajout des colonnes de contexte Git. Les colonnes sont ajoutées une
109/// à une et uniquement si elles sont absentes, ce qui rend l'étape idempotente
110/// et tolérante à une migration partielle interrompue. Aucune donnée existante
111/// n'est touchée : les nouvelles colonnes valent `NULL` pour les lignes déjà
112/// présentes.
113fn 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
128/// Indique si une colonne existe dans une table (via `PRAGMA table_info`).
129fn 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    /// Construit une base « héritée » au schéma v1 : table `commands` sans les
147    /// colonnes Git, et `user_version` laissé à 0 (comme les bases d'avant les
148    /// migrations).
149    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        // La donnée historique est toujours là, colonnes Git à NULL.
204        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        // Deuxième passage : aucune migration, aucune erreur.
225        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        // Troisième passage pour être sûr.
231        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}