use anyhow::{Context, Result};
use rusqlite::Connection;
pub const SCHEMA_VERSION: i64 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Outcome {
pub from: i64,
pub to: i64,
}
impl Outcome {
pub fn migrated(&self) -> bool {
self.from != self.to
}
}
pub fn schema_version(conn: &Connection) -> Result<i64> {
let v: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.context("lecture de PRAGMA user_version")?;
Ok(v)
}
fn set_schema_version(conn: &Connection, version: i64) -> Result<()> {
conn.execute_batch(&format!("PRAGMA user_version = {version};"))
.context("écriture de PRAGMA user_version")?;
Ok(())
}
pub fn apply(conn: &Connection) -> Result<Outcome> {
let from = schema_version(conn)?;
if from > SCHEMA_VERSION {
anyhow::bail!(
"base créée par une version plus récente de mnemo (schéma v{from}, \
cette version gère v{SCHEMA_VERSION}). Mettez mnemo à jour."
);
}
let mut version = from;
while version < SCHEMA_VERSION {
match version {
0 => migrate_0_to_1(conn)?,
1 => migrate_1_to_2(conn)?,
other => anyhow::bail!("aucune migration définie pour le schéma v{other}"),
}
version += 1;
set_schema_version(conn, version)?;
}
Ok(Outcome {
from,
to: SCHEMA_VERSION,
})
}
fn migrate_0_to_1(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS commands (
id INTEGER PRIMARY KEY,
command TEXT NOT NULL,
cwd TEXT,
shell TEXT,
hostname TEXT,
exit_code INTEGER,
created_at TEXT NOT NULL,
hash TEXT UNIQUE
);
CREATE INDEX IF NOT EXISTS idx_commands_created_at ON commands(created_at);",
)
.context("migration v0 -> v1 (schéma de base)")?;
Ok(())
}
fn migrate_1_to_2(conn: &Connection) -> Result<()> {
for column in ["git_root", "git_branch", "git_remote", "session_id"] {
if !column_exists(conn, "commands", column)? {
conn.execute_batch(&format!("ALTER TABLE commands ADD COLUMN {column} TEXT;"))
.with_context(|| format!("migration v1 -> v2 (ajout colonne {column})"))?;
}
}
conn.execute_batch(
"CREATE INDEX IF NOT EXISTS idx_commands_git_root ON commands(git_root);
CREATE INDEX IF NOT EXISTS idx_commands_git_branch ON commands(git_branch);",
)
.context("migration v1 -> v2 (index Git)")?;
Ok(())
}
fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool> {
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table})"))?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let name: String = row.get(1)?;
if name == column {
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn legacy_v1_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE commands (
id INTEGER PRIMARY KEY,
command TEXT NOT NULL,
cwd TEXT,
shell TEXT,
hostname TEXT,
exit_code INTEGER,
created_at TEXT NOT NULL,
hash TEXT UNIQUE
);",
)
.unwrap();
conn.execute(
"INSERT INTO commands (command, cwd, created_at, hash)
VALUES ('ls -la', '/tmp', '2026-06-13 10:00:00', 'deadbeef')",
[],
)
.unwrap();
conn
}
fn has_column(conn: &Connection, column: &str) -> bool {
column_exists(conn, "commands", column).unwrap()
}
#[test]
fn migration_v1_vers_v2_ajoute_les_colonnes_git() {
let conn = legacy_v1_db();
assert_eq!(schema_version(&conn).unwrap(), 0);
let outcome = apply(&conn).unwrap();
assert_eq!(outcome.from, 0);
assert_eq!(outcome.to, SCHEMA_VERSION);
assert!(outcome.migrated());
assert_eq!(schema_version(&conn).unwrap(), SCHEMA_VERSION);
for col in ["git_root", "git_branch", "git_remote", "session_id"] {
assert!(has_column(&conn, col), "colonne {col} attendue");
}
}
#[test]
fn ancienne_base_reste_utilisable_apres_migration() {
let conn = legacy_v1_db();
let before: i64 = conn
.query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0))
.unwrap();
assert_eq!(before, 1);
apply(&conn).unwrap();
let after: i64 = conn
.query_row("SELECT COUNT(*) FROM commands", [], |r| r.get(0))
.unwrap();
assert_eq!(after, 1);
let git_root: Option<String> = conn
.query_row(
"SELECT git_root FROM commands WHERE command = 'ls -la'",
[],
|r| r.get(0),
)
.unwrap();
assert!(git_root.is_none());
}
#[test]
fn migration_idempotente() {
let conn = legacy_v1_db();
let first = apply(&conn).unwrap();
assert!(first.migrated());
let second = apply(&conn).unwrap();
assert_eq!(second.from, SCHEMA_VERSION);
assert_eq!(second.to, SCHEMA_VERSION);
assert!(!second.migrated());
let third = apply(&conn).unwrap();
assert!(!third.migrated());
assert_eq!(schema_version(&conn).unwrap(), SCHEMA_VERSION);
}
#[test]
fn base_neuve_atteint_la_version_cible() {
let conn = Connection::open_in_memory().unwrap();
assert_eq!(schema_version(&conn).unwrap(), 0);
let outcome = apply(&conn).unwrap();
assert_eq!(outcome.to, SCHEMA_VERSION);
for col in ["git_root", "git_branch", "git_remote", "session_id"] {
assert!(has_column(&conn, col));
}
}
#[test]
fn base_plus_recente_est_refusee() {
let conn = Connection::open_in_memory().unwrap();
set_schema_version(&conn, SCHEMA_VERSION + 1).unwrap();
let err = apply(&conn).unwrap_err();
assert!(err.to_string().contains("version plus récente"));
}
}