use std::path::PathBuf;
use anyhow::{Context, Result};
use gradatum_index::links::title_to_slug;
#[derive(Debug, Clone)]
pub struct VaultRenameArgs {
pub root: PathBuf,
pub ancien: String,
pub nouveau: String,
pub tenant: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VaultRenameReport {
pub note_id: String,
pub slug: String,
}
pub async fn vault_rename(args: VaultRenameArgs) -> Result<VaultRenameReport> {
let db_path = args.root.join("vault/.gradatum/index.db");
if !db_path.exists() {
anyhow::bail!(
"index.db introuvable : {} — le worker doit avoir démarré au moins une fois",
db_path.display()
);
}
let ancien = args.ancien.clone();
let nouveau = args.nouveau.clone();
let tenant = args.tenant.clone();
tokio::task::spawn_blocking(move || run_rename_sync(&db_path, &tenant, &ancien, &nouveau))
.await
.context("spawn_blocking vault_rename")?
}
fn run_rename_sync(
db_path: &std::path::Path,
tenant: &str,
ancien: &str,
nouveau: &str,
) -> Result<VaultRenameReport> {
let conn =
rusqlite::Connection::open(db_path).context("ouverture index.db pour vault-rename")?;
conn.execute_batch("PRAGMA journal_mode=WAL;")
.context("PRAGMA journal_mode=WAL")?;
let note_id: String = conn
.query_row(
"SELECT id FROM notes
WHERE vault_id = ?1 AND title = ?2 AND status = 'live'
ORDER BY created ASC
LIMIT 1",
rusqlite::params![tenant, ancien],
|row| row.get(0),
)
.with_context(|| {
format!(
"note '{ancien}' introuvable (status='live', vault_id='{tenant}') — \
vérifier le titre exact ou que la note est active"
)
})?;
let slug = title_to_slug(ancien);
let renamed_at_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let tx = conn
.unchecked_transaction()
.context("début transaction vault-rename")?;
let rows_updated = tx
.execute(
"UPDATE notes SET title = ?1 WHERE id = ?2",
rusqlite::params![nouveau, note_id],
)
.context("UPDATE notes.title")?;
if rows_updated == 0 {
anyhow::bail!("UPDATE title n'a affecté aucune ligne pour note_id={note_id}");
}
tx.execute(
"INSERT OR REPLACE INTO redirect_table (title_slug, ulid, renamed_at) \
VALUES (?1, ?2, ?3)",
rusqlite::params![slug, note_id, renamed_at_ms],
)
.context("INSERT redirect_table")?;
tx.commit().context("commit transaction vault-rename")?;
Ok(VaultRenameReport { note_id, slug })
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
async fn setup_db_with_note(title: &str) -> (TempDir, PathBuf, String) {
use gradatum_core::scope::VaultId;
let tmp = TempDir::new().expect("TempDir vault_rename test");
let vault_path = tmp.path().join("vault");
let vault = gradatum_vault::Vault::create(&vault_path, VaultId::new("main"))
.await
.expect("Vault::create vault_rename test");
let idx = vault.index();
let ulid_str = ulid::Ulid::new().to_string();
idx.seed_note_with_fts(&ulid_str, "decisions", &format!("# {title}\ncorps."))
.await
.expect("seed_note_with_fts");
let nid = gradatum_core::identity::NoteId(
ulid::Ulid::from_string(&ulid_str).expect("ULID parse setup"),
);
idx.upsert_note_title(&nid, title)
.await
.expect("upsert_note_title setup");
let db_path = vault_path.join(".gradatum/index.db");
(tmp, db_path, ulid_str)
}
#[tokio::test]
async fn vault_rename_updates_title_and_creates_redirect() {
let (_tmp, db_path, ulid_str) = setup_db_with_note("Ancien Titre Test").await;
let root = db_path
.parent() .unwrap()
.parent() .unwrap()
.parent() .unwrap()
.to_path_buf();
let args = VaultRenameArgs {
root,
ancien: "Ancien Titre Test".to_string(),
nouveau: "Nouveau Titre Test".to_string(),
tenant: "main".to_string(),
};
let report = vault_rename(args)
.await
.expect("vault_rename ne doit pas échouer");
assert_eq!(
report.note_id, ulid_str,
"le ULID retourné doit correspondre"
);
assert_eq!(
report.slug,
title_to_slug("Ancien Titre Test"),
"le slug doit être la normalisation de l'ancien titre"
);
let conn = rusqlite::Connection::open(&db_path).expect("open db");
let new_title: String = conn
.query_row("SELECT title FROM notes WHERE id = ?1", [&ulid_str], |r| {
r.get(0)
})
.expect("SELECT title");
assert_eq!(
new_title, "Nouveau Titre Test",
"notes.title doit être mis à jour"
);
let redirect_ulid: String = conn
.query_row(
"SELECT ulid FROM redirect_table WHERE title_slug = ?1",
[&report.slug],
|r| r.get(0),
)
.expect("SELECT redirect_table");
assert_eq!(
redirect_ulid, ulid_str,
"redirect_table doit contenir le ULID"
);
}
#[tokio::test]
async fn vault_rename_returns_error_when_note_not_found() {
let (_tmp, db_path, _) = setup_db_with_note("Titre Existant").await;
let root = db_path
.parent()
.unwrap()
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let args = VaultRenameArgs {
root,
ancien: "Titre Inexistant XYZ".to_string(),
nouveau: "Nouveau".to_string(),
tenant: "main".to_string(),
};
let result = vault_rename(args).await;
assert!(
result.is_err(),
"titre inexistant doit retourner une erreur"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("introuvable"),
"message d'erreur doit mentionner 'introuvable' : {msg}"
);
}
}