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