Skip to main content

kanbrain_core/cli/
manage.rs

1//! Managing projects and their repositories after `init`: renaming a project or a repository, moving a
2//! repository to another project, and taking a repository or a whole project out, keeping their tasks or deleting
3//! them. The kanban app's project info and `kb project` run these. Each returns what it did, one line each.
4//!
5//! Links between a project's repositories name the other one (`web#3`), so a repository's name and project are
6//! part of its siblings' files: renaming it rewrites them, and moving or removing it drops them, on both sides,
7//! each sibling under its write lock.
8
9use super::init;
10use crate::model::Task;
11use crate::projects::{self, Repo, RepoCard};
12use crate::store::{PROJECT_DIR, Store};
13use anyhow::{Context, Result, bail};
14use std::fs;
15use std::path::Path;
16
17/// Renames project `old` to `new`: the registry, its shared card, and each of its repositories' cards.
18pub fn rename_project(central: &Path, old: &str, new: &str) -> Result<Vec<String>> {
19    projects::check_name(new)?;
20    let repos = projects::load(central)?;
21    if !repos.iter().any(|r| r.project == old) {
22        bail!("no project `{old}`");
23    }
24    if old == new {
25        return Ok(vec![format!("project `{old}` already has that name")]);
26    }
27    if repos.iter().any(|r| r.project == new) {
28        bail!("there's already a project `{new}`: move this one's repositories to it instead");
29    }
30    projects::rename(central, old, new)?;
31    for repo in repos.iter().filter(|r| r.project == old && r.dir.is_dir()) {
32        if let Some(card) = projects::read_repo_card(&repo.dir) {
33            projects::write_repo_card(&repo.dir, &RepoCard { project: new.to_string(), ..card })?;
34        }
35    }
36    Ok(vec![format!("renamed project `{old}` to `{new}`; its repositories and card follow")])
37}
38
39/// Renames the repository whose `.brain/` is `dir` to `new` in its project, and rewrites its siblings' links to
40/// it (`old#3` becomes `new#3`).
41pub fn rename_repo(central: &Path, dir: &Path, new: &str) -> Result<Vec<String>> {
42    let repo = registered(central, dir)?;
43    let old = repo.name();
44    if old == new {
45        return Ok(vec![format!("repository `{old}` already has that name")]);
46    }
47    let new = projects::repo_name(central, &repo.project, &repo.dir, Some(new))?;
48    let card = projects::read_repo_card(&repo.dir).unwrap_or_else(|| RepoCard {
49        project: repo.project.clone(),
50        repo: None,
51        notes: String::new(),
52    });
53    let stored = (new != repo.folder()).then(|| new.clone());
54    projects::write_repo_card(&repo.dir, &RepoCard { repo: stored, ..card })?;
55    let rewritten = rewrite_sibling_links(central, &repo, |link_repo| (link_repo == old).then(|| new.clone()))?;
56    let mut done = vec![format!("renamed repository `{old}` of project `{}` to `{new}`", repo.project)];
57    if rewritten > 0 {
58        done.push(format!("rewrote the links to it in {rewritten} entries of its other repositories"));
59    }
60    Ok(done)
61}
62
63/// Moves the repository whose `.brain/` is `dir` to project `to`, created if new, as `kb init to` there would.
64/// Links between it and its old project's repositories are dropped on both sides: they'd name repositories of
65/// another project.
66pub fn move_repo(central: &Path, dir: &Path, to: &str) -> Result<Vec<String>> {
67    let repo = registered(central, dir)?;
68    if repo.project == to {
69        return Ok(vec![format!("{} is already a repository of project `{to}`", repo.name())]);
70    }
71    projects::check_name(to)?;
72    let root = repo.dir.parent().filter(|_| repo.dir.is_dir()).context("its folder is gone: remove it instead")?;
73    let dropped = unlink(central, &repo)?;
74    let mut done = init::init(root, Some(to), None, central)?;
75    if dropped > 0 {
76        done.push(format!("dropped {dropped} links between it and project `{}`'s repositories", repo.project));
77    }
78    Ok(done)
79}
80
81/// Takes the repository whose `.brain/` is `dir` out of its project, as `kb deinit` does, and drops its old
82/// siblings' links to it. With `purge`, its `.brain/` goes too: its tasks, notes and decisions.
83pub fn remove_repo(central: &Path, dir: &Path, purge: bool) -> Result<Vec<String>> {
84    let repo = registered(central, dir)?;
85    unlink(central, &repo)?;
86    let mut done = init::deinit(&repo.dir, central)?;
87    if !repo.dir.is_dir() {
88        return Ok(done);
89    }
90    if !purge {
91        done.push(format!(
92            "kept {}: commands run here still use it; `deinit --purge` would have deleted its tasks, notes and \
93             decisions",
94            repo.dir.display()
95        ));
96        return Ok(done);
97    }
98    // Only ever a `.brain/` folder: `dir` comes from the registry, which a hand edit could point elsewhere.
99    if repo.dir.file_name().is_none_or(|n| n != PROJECT_DIR) {
100        bail!("{} isn't a `.brain/` folder: deleted nothing", repo.dir.display());
101    }
102    fs::remove_dir_all(&repo.dir).with_context(|| format!("deleting {}", repo.dir.display()))?;
103    done.push(format!("deleted {}: its tasks, notes and decisions are gone", repo.dir.display()));
104    Ok(done)
105}
106
107/// Takes every repository of `project` out, as [`remove_repo`] does each, which removes the project's card with
108/// the last.
109pub fn remove_project(central: &Path, project: &str, purge: bool) -> Result<Vec<String>> {
110    let repos: Vec<Repo> = projects::load(central)?.into_iter().filter(|r| r.project == project).collect();
111    if repos.is_empty() {
112        bail!("no project `{project}`");
113    }
114    let mut done = Vec::new();
115    for repo in repos {
116        done.extend(remove_repo(central, &repo.dir, purge)?);
117    }
118    Ok(done)
119}
120
121/// The registered repository whose `.brain/` is `dir`.
122fn registered(central: &Path, dir: &Path) -> Result<Repo> {
123    projects::load(central)?
124        .into_iter()
125        .find(|r| projects::same_dir(&r.dir, dir))
126        .with_context(|| format!("{} isn't a registered repository", dir.display()))
127}
128
129/// Drops the links between `repo` and the other repositories of its project, on both sides. Returns how many
130/// entries changed.
131fn unlink(central: &Path, repo: &Repo) -> Result<usize> {
132    let name = repo.name();
133    let siblings: Vec<String> = siblings(central, repo)?.iter().map(Repo::name).collect();
134    let mut changed = rewrite_sibling_links(central, repo, |link_repo| (link_repo == name).then(String::new))?;
135    if repo.dir.is_dir() {
136        changed += rewrite_links(&repo.dir, |link_repo| siblings.contains(&link_repo.to_string()).then(String::new))?;
137    }
138    Ok(changed)
139}
140
141/// The project's other repositories whose folders are there.
142fn siblings(central: &Path, repo: &Repo) -> Result<Vec<Repo>> {
143    Ok(projects::load(central)?
144        .into_iter()
145        .filter(|r| r.project == repo.project && !projects::same_dir(&r.dir, &repo.dir) && r.dir.is_dir())
146        .collect())
147}
148
149/// Rewrites, in every other repository of `repo`'s project, the links whose repository `change` maps: to the
150/// name it returns, or dropped for an empty one. Returns how many entries changed.
151fn rewrite_sibling_links(central: &Path, repo: &Repo, change: impl Fn(&str) -> Option<String>) -> Result<usize> {
152    siblings(central, repo)?.iter().map(|sibling| rewrite_links(&sibling.dir, &change)).sum()
153}
154
155/// Rewrites the links of the brain in `dir` as [`rewrite_sibling_links`] does, under its write lock.
156fn rewrite_links(dir: &Path, change: impl Fn(&str) -> Option<String>) -> Result<usize> {
157    let store = Store::at(dir)?;
158    let _lock = store.lock()?;
159    let (tasks, _) = store.load()?;
160    let mut changed = 0;
161    for mut task in tasks {
162        if rewrite(&mut task, &change) {
163            store.save(&mut task)?;
164            changed += 1;
165        }
166    }
167    Ok(changed)
168}
169
170/// Applies `change` to `task`'s links to other repositories; returns whether any changed.
171fn rewrite(task: &mut Task, change: impl Fn(&str) -> Option<String>) -> bool {
172    let before = task.repo_links.clone();
173    task.repo_links = before
174        .iter()
175        .filter_map(|link| match change(&link.repo) {
176            Some(name) if name.is_empty() => None,
177            Some(name) => Some(crate::model::RepoLink { repo: name, id: link.id }),
178            None => Some(link.clone()),
179        })
180        .collect();
181    task.repo_links != before
182}
183
184#[cfg(test)]
185#[path = "../../tests/cli/manage.rs"]
186mod tests;