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// One step for each format after the first, each to the format after its index's: `migrate` takes the steps up to
41// FORMAT by slicing, so a bumped FORMAT without its step (or a step without the bump) fails the build here rather
42// than panic on a user's brain.
43const _: () = {
44    assert!(MIGRATIONS.len() == (FORMAT - 1) as usize, "MIGRATIONS needs one step per format after the first");
45    let mut i = 0;
46    while i < MIGRATIONS.len() {
47        assert!(MIGRATIONS[i].to as usize == i + 2, "MIGRATIONS[i] brings a brain to format i + 2");
48        i += 1;
49    }
50};
51
52/// A brain in a format newer than [`FORMAT`]: written by a newer kanbrain, so this one leaves it alone.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct TooNew {
55    /// The brain's folder.
56    pub dir: PathBuf,
57    /// Its format.
58    pub format: u32,
59}
60
61impl fmt::Display for TooNew {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        write!(
64            f,
65            "{} was written by a newer kanbrain (data format {}; this one knows up to {FORMAT}): update it with \
66             `kb update`",
67            self.dir.display(),
68            self.format
69        )
70    }
71}
72
73impl std::error::Error for TooNew {}
74
75/// The format of the brain in `dir`.
76pub fn format_of(dir: &Path) -> Result<u32> {
77    let path = dir.join(FORMAT_FILE);
78    match store::read_text(&path) {
79        // Formats count from 1: a 0 would have migrations start before the first.
80        Ok(text) => text
81            .trim()
82            .parse()
83            .ok()
84            .filter(|&format| format >= 1)
85            .with_context(|| format!("{} isn't a format number", path.display())),
86        Err(e) if e.kind() == ErrorKind::NotFound => Ok(1),
87        Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
88    }
89}
90
91/// Refuses, with [`TooNew`], a brain in a format newer than [`FORMAT`].
92pub fn check(dir: &Path) -> Result<()> {
93    match format_of(dir)? {
94        format if format > FORMAT => Err(TooNew { dir: dir.to_path_buf(), format }.into()),
95        _ => Ok(()),
96    }
97}
98
99/// Refuses, with [`TooNew`], a brain a newer kanbrain wrote, which nothing may be written for: not its entries, nor
100/// its card, the repository's agent files around it or its links to the project's other repositories. Only that: a
101/// brain whose format can't be read (a damaged `format` file) is left to what opens it to say.
102pub(crate) fn refuse_too_new(dir: &Path) -> Result<()> {
103    match check(dir) {
104        Err(e) if e.is::<TooNew>() => Err(e),
105        _ => Ok(()),
106    }
107}
108
109/// Whether [`refuse_too_new`] refuses the brain in `dir`.
110pub(crate) fn too_new(dir: &Path) -> bool {
111    refuse_too_new(dir).is_err()
112}
113
114/// Brings the brain in `store` to [`FORMAT`], and returns what it did, one line each; nothing for a brain already
115/// there.
116pub fn migrate(store: &Store) -> Result<Vec<String>> {
117    migrate_with(store, MIGRATIONS, FORMAT)
118}
119
120/// [`migrate`], with the steps and the format they lead to given, so tests can run steps of their own.
121fn migrate_with(store: &Store, migrations: &[Migration], target: u32) -> Result<Vec<String>> {
122    let dir = store.dir();
123    let behind = |format: u32| -> Result<bool> {
124        if format > target {
125            return Err(TooNew { dir: dir.to_path_buf(), format }.into());
126        }
127        Ok(format < target)
128    };
129    if !behind(format_of(dir)?)? {
130        return Ok(Vec::new());
131    }
132    let _lock = store.lock()?;
133    // Again under the lock: another `kb` may have migrated it meanwhile.
134    let from = format_of(dir)?;
135    if !behind(from)? {
136        return Ok(Vec::new());
137    }
138    let backup = back_up(dir, from, target)?;
139    let mut done = vec![format!("backed up {} to {} before migrating it", dir.display(), backup.display())];
140    for step in &migrations[(from - 1) as usize..(target - 1) as usize] {
141        (step.run)(store).with_context(|| format!("migrating {} to format {}", dir.display(), step.to))?;
142        store::write_file(&dir.join(FORMAT_FILE), &format!("{}\n", step.to))?;
143        done.push(format!("migrated {} to data format {}: {}", dir.display(), step.to, step.what));
144    }
145    Ok(done)
146}
147
148/// Copies the brain in `dir`, every file and folder in it, to `<cache>/kanbrain/backups/<key>/<time>-<from>-<to>`,
149/// and returns where.
150fn back_up(dir: &Path, from: u32, to: u32) -> Result<PathBuf> {
151    let at = crate::model::now().format("%Y%m%dT%H%M%S");
152    let backup = store::cache_dir_of(dir, "backups")?.join(format!("{at}-format-{from}-to-{to}"));
153    copy_dir(dir, &backup)?;
154    Ok(backup)
155}
156
157/// Copies `from` into `to`, created, recursively.
158fn copy_dir(from: &Path, to: &Path) -> Result<()> {
159    fs::create_dir_all(to).with_context(|| format!("creating {}", to.display()))?;
160    for entry in fs::read_dir(from).with_context(|| format!("reading {}", from.display()))? {
161        let entry = entry?;
162        let target = to.join(entry.file_name());
163        if entry.file_type()?.is_dir() {
164            copy_dir(&entry.path(), &target)?;
165        } else {
166            fs::copy(entry.path(), &target).with_context(|| format!("copying {}", entry.path().display()))?;
167        }
168    }
169    Ok(())
170}
171
172#[cfg(test)]
173#[path = "../tests/migrate.rs"]
174mod tests;