Skip to main content

kanbrain_core/
migrate.rs

1//! Data format versions and migrations. A brain's files (entries, `tags.md`, `last-id`, a project's cards and
2//! registry) follow a format numbered in its `format` file, [`FORMAT`] for those this version writes. Formats
3//! change only with a migration, which brings a brain from the one before: when a brain is opened to be changed,
4//! and for every registered brain on `kb update`, the migrations it lacks run in order, under its write lock,
5//! after a copy of it goes to the user's cache. A brain in a newer format than this version knows is refused
6//! entirely, read or written: an older `kb` reading a teammate's `.brain/` from git would misread what it doesn't
7//! know, and rewriting it would lose it.
8//!
9//! To change the format: add the step to [`MIGRATIONS`], bump [`FORMAT`], add a fixture of the old format to the
10//! tests, and say so in the release notes. A step is never removed, since a brain may skip versions, and works on
11//! the files' text rather than today's types, which later formats change.
12
13use crate::store::{self, Store};
14use anyhow::{Context, Result};
15use std::fmt;
16use std::fs;
17use std::io::ErrorKind;
18use std::path::{Path, PathBuf};
19
20/// The data format this version reads and writes.
21pub const FORMAT: u32 = 1;
22
23/// The file beside a brain's entries holding its format. A brain without one is format 1: from before formats
24/// were numbered, which the first format is.
25pub const FORMAT_FILE: &str = "format";
26
27/// One step from a format to the next.
28pub struct Migration {
29    /// The format it brings a brain to, from the one before.
30    pub to: u32,
31    /// What it changes, as `kb update` reports it.
32    pub what: &'static str,
33    /// Changes the brain's files. It runs under the brain's write lock, once: the format is written after it.
34    pub run: fn(&Store) -> Result<()>,
35}
36
37/// Every step, in order: `MIGRATIONS[i]` brings a brain from format `i + 1` to `i + 2`.
38pub const MIGRATIONS: &[Migration] = &[];
39
40/// A brain in a format newer than [`FORMAT`]: written by a newer kanbrain, so this one leaves it alone.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct TooNew {
43    /// The brain's folder.
44    pub dir: PathBuf,
45    /// Its format.
46    pub format: u32,
47}
48
49impl fmt::Display for TooNew {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        write!(
52            f,
53            "{} was written by a newer kanbrain (data format {}; this one knows up to {FORMAT}): update it with \
54             `kb update`",
55            self.dir.display(),
56            self.format
57        )
58    }
59}
60
61impl std::error::Error for TooNew {}
62
63/// The format of the brain in `dir`.
64pub fn format_of(dir: &Path) -> Result<u32> {
65    let path = dir.join(FORMAT_FILE);
66    match store::read_text(&path) {
67        // Formats count from 1: a 0 would have migrations start before the first.
68        Ok(text) => text
69            .trim()
70            .parse()
71            .ok()
72            .filter(|&format| format >= 1)
73            .with_context(|| format!("{} isn't a format number", path.display())),
74        Err(e) if e.kind() == ErrorKind::NotFound => Ok(1),
75        Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
76    }
77}
78
79/// Refuses, with [`TooNew`], a brain in a format newer than [`FORMAT`].
80pub fn check(dir: &Path) -> Result<()> {
81    match format_of(dir)? {
82        format if format > FORMAT => Err(TooNew { dir: dir.to_path_buf(), format }.into()),
83        _ => Ok(()),
84    }
85}
86
87/// Brings the brain in `store` to [`FORMAT`], and returns what it did, one line each; nothing for a brain already
88/// there.
89pub fn migrate(store: &Store) -> Result<Vec<String>> {
90    migrate_with(store, MIGRATIONS, FORMAT)
91}
92
93/// [`migrate`], with the steps and the format they lead to given, so tests can run steps of their own.
94fn migrate_with(store: &Store, migrations: &[Migration], target: u32) -> Result<Vec<String>> {
95    let dir = store.dir();
96    let behind = |format: u32| -> Result<bool> {
97        if format > target {
98            return Err(TooNew { dir: dir.to_path_buf(), format }.into());
99        }
100        Ok(format < target)
101    };
102    if !behind(format_of(dir)?)? {
103        return Ok(Vec::new());
104    }
105    let _lock = store.lock()?;
106    // Again under the lock: another `kb` may have migrated it meanwhile.
107    let from = format_of(dir)?;
108    if !behind(from)? {
109        return Ok(Vec::new());
110    }
111    let backup = back_up(dir, from, target)?;
112    let mut done = vec![format!("backed up {} to {} before migrating it", dir.display(), backup.display())];
113    for step in &migrations[(from - 1) as usize..(target - 1) as usize] {
114        (step.run)(store).with_context(|| format!("migrating {} to format {}", dir.display(), step.to))?;
115        store::write_file(&dir.join(FORMAT_FILE), &format!("{}\n", step.to))?;
116        done.push(format!("migrated {} to data format {}: {}", dir.display(), step.to, step.what));
117    }
118    Ok(done)
119}
120
121/// Copies the brain in `dir`, every file and folder in it, to `<cache>/kanbrain/backups/<key>/<time>-<from>-<to>`,
122/// and returns where.
123fn back_up(dir: &Path, from: u32, to: u32) -> Result<PathBuf> {
124    let at = crate::model::now().format("%Y%m%dT%H%M%S");
125    let backup = store::cache_dir_of(dir, "backups")?.join(format!("{at}-format-{from}-to-{to}"));
126    copy_dir(dir, &backup)?;
127    Ok(backup)
128}
129
130/// Copies `from` into `to`, created, recursively.
131fn copy_dir(from: &Path, to: &Path) -> Result<()> {
132    fs::create_dir_all(to).with_context(|| format!("creating {}", to.display()))?;
133    for entry in fs::read_dir(from).with_context(|| format!("reading {}", from.display()))? {
134        let entry = entry?;
135        let target = to.join(entry.file_name());
136        if entry.file_type()?.is_dir() {
137            copy_dir(&entry.path(), &target)?;
138        } else {
139            fs::copy(entry.path(), &target).with_context(|| format!("copying {}", entry.path().display()))?;
140        }
141    }
142    Ok(())
143}
144
145#[cfg(test)]
146#[path = "../tests/migrate.rs"]
147mod tests;