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 Manage project 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, counted};
11use crate::projects::{self, Repo, RepoCard};
12use crate::store::{self, 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    // From the checks to the cards: a project named `new` meanwhile would take this one's repositories.
21    let _lock = crate::store::Store::at(central)?.lock()?;
22    let repos = projects::load(central)?;
23    if !repos.iter().any(|r| r.project == old) {
24        bail!("no project `{old}`");
25    }
26    if old == new {
27        return Ok(vec![format!("project `{old}` already has that name")]);
28    }
29    if repos.iter().any(|r| r.project == new) {
30        bail!("there's already a project `{new}`: move this one's repositories to it instead");
31    }
32    projects::rename(central, old, new)?;
33    for repo in repos.iter().filter(|r| r.project == old && r.dir.is_dir()) {
34        if let Some(card) = projects::read_repo_card(&repo.dir) {
35            projects::write_repo_card(&repo.dir, &RepoCard { project: new.to_string(), ..card })?;
36        }
37    }
38    Ok(vec![format!("renamed project `{old}` to `{new}`; its repositories and card follow")])
39}
40
41/// Renames the repository whose `.brain/` is `dir` to `new` in its project, and rewrites its siblings' links to
42/// it (`old#3` becomes `new#3`).
43pub fn rename_repo(central: &Path, dir: &Path, new: &str) -> Result<Vec<String>> {
44    let repo = registered(central, dir)?;
45    let old = repo.name();
46    if old == new {
47        return Ok(vec![format!("repository `{old}` already has that name")]);
48    }
49    let new = projects::repo_name(central, &repo.project, &repo.dir, Some(new))?;
50    let card = projects::read_repo_card(&repo.dir).unwrap_or_else(|| RepoCard {
51        project: repo.project.clone(),
52        repo: None,
53        notes: String::new(),
54    });
55    let stored = (new != repo.folder()).then(|| new.clone());
56    projects::write_repo_card(&repo.dir, &RepoCard { repo: stored, ..card })?;
57    let rewritten = rewrite_sibling_links(central, &repo, |link_repo| (link_repo == old).then(|| new.clone()))?;
58    let mut done = vec![format!("renamed repository `{old}` of project `{}` to `{new}`", repo.project)];
59    if rewritten > 0 {
60        let entries = counted(rewritten, "entry", "entries");
61        done.push(format!("rewrote the links to it in {entries} of its other repositories"));
62    }
63    Ok(done)
64}
65
66/// Moves the repository whose `.brain/` is `dir` to project `to`, created if new, as `kb init to` there would.
67/// Links between it and its old project's repositories are dropped on both sides: they'd name repositories of
68/// another project.
69pub fn move_repo(central: &Path, dir: &Path, to: &str) -> Result<Vec<String>> {
70    let repo = registered(central, dir)?;
71    if repo.project == to {
72        return Ok(vec![format!("{} is already a repository of project `{to}`", repo.name())]);
73    }
74    projects::check_name(to)?;
75    let root = repo.dir.parent().filter(|_| repo.dir.is_dir()).context("its folder is gone: remove it instead")?;
76    // Its name in the project it leaves: joining another may cost it a name of its own.
77    let name = repo.name();
78    let dropped = unlink(central, &repo)?;
79    // A name of its own (`kb project repo`) goes with it, unless the project it joins has one by that name.
80    let own = projects::read_repo_card(&repo.dir).and_then(|card| card.repo);
81    // Only the name's own trouble (taken there) names it after its folder: anything else is the move's.
82    let refused = own.as_deref().and_then(|name| projects::repo_name(central, to, &repo.dir, Some(name)).err());
83    let keep = own.as_deref().filter(|_| refused.is_none());
84    let joined = init::join(root, Some(to), keep, central)?;
85    // Said as a move, not as the `init` it runs: "… already exists" would lead.
86    let mut done = vec![format!("moved {name} from project `{}` to `{to}`", repo.project)];
87    if let (Some(name), Some(why)) = (&own, refused) {
88        done.push(format!("named after its folder in `{to}`, as `{name}` can't be: {why:#}"));
89    }
90    done.extend(joined.into_iter().filter(|line| !line.ends_with(" already exists")));
91    // Its old project, left with no repository: the description goes to the new project when that has none, rather
92    // than be lost, and the card goes, as `deinit` takes it.
93    if !projects::load(central)?.iter().any(|r| r.project == repo.project) {
94        let description = projects::read_description(central, &repo.project);
95        if !description.is_empty() {
96            if projects::read_description(central, to).is_empty() {
97                projects::write_description(central, to, &description)?;
98                done.push(format!("project `{}`'s description went with it to `{to}`, which had none", repo.project));
99            } else {
100                done.push(format!("project `{}`'s description is dropped: `{to}` keeps its own", repo.project));
101            }
102        }
103        if projects::remove_description(central, &repo.project)? {
104            done.push(format!(
105                "removed project `{}`'s card from the central brain: it had no other repository",
106                repo.project
107            ));
108        }
109    }
110    if dropped > 0 {
111        let links = counted(dropped, "link", "links");
112        done.push(format!("dropped {links} between it and project `{}`'s repositories", repo.project));
113    }
114    // They describe the repository, which moved with them, but were written among its old project's.
115    if projects::read_repo_card(&repo.dir).is_some_and(|card| !card.notes.is_empty()) {
116        done.push("kept its role's notes, written for its old project: `kb project notes -` replaces them".into());
117    }
118    Ok(done)
119}
120
121/// Takes the repository whose `.brain/` is `dir` out of its project, as `kb deinit` does, and drops its old
122/// siblings' links to it. With `purge`, its `.brain/` goes too: its tasks, notes and decisions.
123pub fn remove_repo(central: &Path, dir: &Path, purge: bool) -> Result<Vec<String>> {
124    let repo = registered(central, dir)?;
125    unlink(central, &repo)?;
126    let mut done = init::deinit(&repo.dir, central)?;
127    if !repo.dir.is_dir() {
128        return Ok(done);
129    }
130    if !purge {
131        done.push(format!(
132            "kept {} with its tasks, notes and decisions: commands run here still use it (`--purge`, or the app's \
133             Unlink and delete tasks, deletes them)",
134            repo.dir.display()
135        ));
136        return Ok(done);
137    }
138    // Only ever a `.brain/` folder: `dir` comes from the registry, which a hand edit could point elsewhere.
139    if repo.dir.file_name().is_none_or(|n| n != PROJECT_DIR) {
140        bail!("{} isn't a `.brain/` folder: deleted nothing", repo.dir.display());
141    }
142    let cache = store::cache_of(&repo.dir)?;
143    fs::remove_dir_all(&repo.dir).with_context(|| format!("deleting {}", repo.dir.display()))?;
144    done.push(format!("deleted {}: its tasks, notes and decisions are gone", repo.dir.display()));
145    // Best effort: it's the cache, and on Windows a lock this process holds open can't be deleted yet.
146    for path in cache {
147        let _ = if path.is_dir() { fs::remove_dir_all(&path) } else { fs::remove_file(&path) };
148    }
149    Ok(done)
150}
151
152/// Takes every repository of `project` out, as [`remove_repo`] does each, which removes the project's card with
153/// the last.
154pub fn remove_project(central: &Path, project: &str, purge: bool) -> Result<Vec<String>> {
155    let repos: Vec<Repo> = projects::load(central)?.into_iter().filter(|r| r.project == project).collect();
156    if repos.is_empty() {
157        bail!("no project `{project}`");
158    }
159    let mut done = Vec::new();
160    for repo in repos {
161        // Each but the last says the card stays for the others, which go next: untrue once they have.
162        let keeps = format!("project `{project}` keeps its card");
163        done.extend(remove_repo(central, &repo.dir, purge)?.into_iter().filter(|line| !line.starts_with(&keeps)));
164    }
165    Ok(done)
166}
167
168/// The registered repository whose `.brain/` is `dir`.
169fn registered(central: &Path, dir: &Path) -> Result<Repo> {
170    projects::load(central)?
171        .into_iter()
172        .find(|r| projects::same_dir(&r.dir, dir))
173        .with_context(|| format!("{} isn't a registered repository", dir.display()))
174}
175
176/// Drops the links between `repo` and the other repositories of its project, on both sides. Returns how many
177/// entries changed.
178fn unlink(central: &Path, repo: &Repo) -> Result<usize> {
179    let name = repo.name();
180    let siblings: Vec<String> = siblings(central, repo)?.iter().map(Repo::name).collect();
181    let mut changed = rewrite_sibling_links(central, repo, |link_repo| (link_repo == name).then(String::new))?;
182    if repo.dir.is_dir() {
183        changed += rewrite_links(&repo.dir, |link_repo| siblings.contains(&link_repo.to_string()).then(String::new))?;
184    }
185    Ok(changed)
186}
187
188/// The project's other repositories whose folders are there.
189fn siblings(central: &Path, repo: &Repo) -> Result<Vec<Repo>> {
190    Ok(projects::load(central)?
191        .into_iter()
192        .filter(|r| r.project == repo.project && !projects::same_dir(&r.dir, &repo.dir) && r.dir.is_dir())
193        .collect())
194}
195
196/// Rewrites, in every other repository of `repo`'s project, the links whose repository `change` maps: to the
197/// name it returns, or dropped for an empty one. Returns how many entries changed.
198fn rewrite_sibling_links(central: &Path, repo: &Repo, change: impl Fn(&str) -> Option<String>) -> Result<usize> {
199    siblings(central, repo)?.iter().map(|sibling| rewrite_links(&sibling.dir, &change)).sum()
200}
201
202/// Rewrites the links of the brain in `dir` as [`rewrite_sibling_links`] does, under its write lock.
203fn rewrite_links(dir: &Path, change: impl Fn(&str) -> Option<String>) -> Result<usize> {
204    let store = Store::at(dir)?;
205    let _lock = store.lock()?;
206    let (tasks, _) = store.load()?;
207    let mut changed = 0;
208    for mut task in tasks {
209        if rewrite(&mut task, &change) {
210            store.save(&mut task)?;
211            changed += 1;
212        }
213    }
214    Ok(changed)
215}
216
217/// Applies `change` to `task`'s links to other repositories; returns whether any changed.
218fn rewrite(task: &mut Task, change: impl Fn(&str) -> Option<String>) -> bool {
219    let mut changed = false;
220    task.repo_links.retain_mut(|link| match change(&link.repo) {
221        Some(name) if name.is_empty() => {
222            changed = true;
223            false
224        }
225        Some(name) => {
226            changed |= name != link.repo;
227            link.repo = name;
228            true
229        }
230        None => true,
231    });
232    changed
233}
234
235#[cfg(test)]
236#[path = "../../tests/cli/manage.rs"]
237mod tests;