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, a [`Step`] a line.
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 crate::model::{Task, counted};
10use crate::projects::{self, Repo, RepoCard};
11use crate::store::{self, PROJECT_DIR, Store};
12use anyhow::{Context, Result, bail};
13use std::fmt;
14use std::fs;
15use std::path::Path;
16
17/// One thing a change to projects did, as `kb` says it on a line of its own, with what it was when a caller acts on
18/// it: the kanban app says a move, or the deletion of the tasks, rather than the first line.
19#[derive(Debug, Clone, PartialEq, Eq)]
20#[non_exhaustive]
21pub struct Step {
22    /// What it was.
23    pub kind: StepKind,
24    /// What it says.
25    pub line: String,
26}
27
28/// What a [`Step`] was, for the steps a caller acts on; any other is [`StepKind::Other`].
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum StepKind {
32    /// The repository moved to another project (`moved web from project …`).
33    Moved,
34    /// Links between it and its old project's repositories were dropped.
35    DroppedLinks,
36    /// Its `.brain/` was deleted, its tasks, notes and decisions with it.
37    Deleted,
38    /// `init` found the `.brain/` there already.
39    AlreadyExisted,
40    /// Its project keeps its card for its other repositories.
41    KeptCard,
42    /// Anything else.
43    Other,
44}
45
46impl Step {
47    /// A step of `kind` saying `line`.
48    pub(crate) fn new(kind: StepKind, line: String) -> Self {
49        Self { kind, line }
50    }
51
52    /// A step no caller acts on, saying `line`.
53    pub(crate) fn other(line: impl Into<String>) -> Self {
54        Self::new(StepKind::Other, line.into())
55    }
56}
57
58impl fmt::Display for Step {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        f.write_str(&self.line)
61    }
62}
63
64/// What the deprecated functions return: each step's line.
65pub(crate) fn lines(steps: Result<Vec<Step>>) -> Result<Vec<String>> {
66    Ok(steps?.into_iter().map(|step| step.line).collect())
67}
68
69/// [`rename_project_steps`], each step as its line.
70#[deprecated(since = "0.6.3", note = "use `rename_project_steps`, which says what each line is")]
71pub fn rename_project(central: &Path, old: &str, new: &str) -> Result<Vec<String>> {
72    lines(rename_project_steps(central, old, new))
73}
74
75/// Renames project `old` to `new`: the registry, its shared card, and each of its repositories' cards.
76/// Returns what it did, a [`Step`] a line.
77pub fn rename_project_steps(central: &Path, old: &str, new: &str) -> Result<Vec<Step>> {
78    projects::check_name(new)?;
79    // From the checks to the cards: a project named `new` meanwhile would take this one's repositories.
80    let _lock = crate::store::Store::at(central)?.lock()?;
81    let repos = projects::load(central)?;
82    if !repos.iter().any(|r| r.project == old) {
83        bail!("no project `{old}`");
84    }
85    if old == new {
86        return Ok(vec![Step::other(format!("project `{old}` already has that name"))]);
87    }
88    if repos.iter().any(|r| r.project == new) {
89        bail!("there's already a project `{new}`: move this one's repositories to it instead");
90    }
91    projects::rename(central, old, new)?;
92    // A brain a newer kanbrain wrote keeps its card on the old name, which `init --all` fixes once `kb` reads it.
93    let (left, current): (Vec<&Repo>, Vec<&Repo>) =
94        repos.iter().filter(|r| r.project == old && r.dir.is_dir()).partition(|r| crate::migrate::too_new(&r.dir));
95    for repo in current {
96        if let Some(card) = projects::read_repo_card(&repo.dir) {
97            projects::write_repo_card(&repo.dir, &RepoCard { project: new.to_string(), ..card })?;
98        }
99    }
100    let done = format!("renamed project `{old}` to `{new}`; its repositories and card follow");
101    if left.is_empty() {
102        return Ok(vec![Step::other(done)]);
103    }
104    let names: Vec<String> = left.iter().map(|r| format!("`{}`'s", r.name())).collect();
105    Ok(vec![Step::other(format!(
106        "{done}, but {} .brain/project.md, left alone, keeps `{old}` until `kb update`",
107        names.join(", ")
108    ))])
109}
110
111/// [`rename_repo_steps`], each step as its line.
112#[deprecated(since = "0.6.3", note = "use `rename_repo_steps`, which says what each line is")]
113pub fn rename_repo(central: &Path, dir: &Path, new: &str) -> Result<Vec<String>> {
114    lines(rename_repo_steps(central, dir, new))
115}
116
117/// Renames the repository whose `.brain/` is `dir` to `new` in its project, and rewrites its siblings' links to
118/// it (`old#3` becomes `new#3`).
119/// Returns what it did, a [`Step`] a line.
120pub fn rename_repo_steps(central: &Path, dir: &Path, new: &str) -> Result<Vec<Step>> {
121    let repo = registered(central, dir)?;
122    let old = repo.name();
123    if old == new {
124        return Ok(vec![Step::other(format!("repository `{old}` already has that name"))]);
125    }
126    let new = projects::repo_name(central, &repo.project, &repo.dir, Some(new))?;
127    refuse_too_new(central, &repo)?;
128    let card = projects::read_repo_card(&repo.dir).unwrap_or_else(|| RepoCard {
129        project: repo.project.clone(),
130        repo: None,
131        notes: String::new(),
132    });
133    let stored = (new != repo.folder()).then(|| new.clone());
134    projects::write_repo_card(&repo.dir, &RepoCard { repo: stored, ..card })?;
135    let rewritten = rewrite_sibling_links(central, &repo, |link_repo| (link_repo == old).then(|| new.clone()))?;
136    let mut done: Vec<Step> =
137        vec![Step::other(format!("renamed repository `{old}` of project `{}` to `{new}`", repo.project))];
138    if rewritten > 0 {
139        let entries = counted(rewritten, "entry", "entries");
140        done.push(Step::other(format!("rewrote the links to it in {entries} of its other repositories")));
141    }
142    Ok(done)
143}
144
145/// [`move_repo_steps`], each step as its line.
146#[deprecated(since = "0.6.3", note = "use `move_repo_steps`, which says what each line is")]
147pub fn move_repo(central: &Path, dir: &Path, to: &str) -> Result<Vec<String>> {
148    lines(move_repo_steps(central, dir, to))
149}
150
151/// Moves the repository whose `.brain/` is `dir` to project `to`, created if new, as `kb init to` there would.
152/// Links between it and its old project's repositories are dropped on both sides: they'd name repositories of
153/// another project.
154/// Returns what it did, a [`Step`] a line.
155pub fn move_repo_steps(central: &Path, dir: &Path, to: &str) -> Result<Vec<Step>> {
156    let repo = registered(central, dir)?;
157    if repo.project == to {
158        return Ok(vec![Step::other(format!("{} is already a repository of project `{to}`", repo.name()))]);
159    }
160    projects::check_name(to)?;
161    let root = repo.dir.parent().filter(|_| repo.dir.is_dir()).context("its folder is gone: remove it instead")?;
162    // Its name in the project it leaves: joining another may cost it a name of its own.
163    let name = repo.name();
164    refuse_too_new(central, &repo)?;
165    let dropped = unlink(central, &repo)?;
166    // A name of its own (`kb project repo`) goes with it, unless the project it joins has one by that name.
167    let own = projects::read_repo_card(&repo.dir).and_then(|card| card.repo);
168    // Only the name's own trouble (taken there) names it after its folder: anything else is the move's.
169    let refused = own.as_deref().and_then(|name| projects::repo_name(central, to, &repo.dir, Some(name)).err());
170    let keep = own.as_deref().filter(|_| refused.is_none());
171    let joined = super::init::join(root, Some(to), keep, central)?;
172    // Said as a move, not as the `init` it runs: "… already exists" would lead.
173    let mut done = vec![Step::new(StepKind::Moved, format!("moved {name} from project `{}` to `{to}`", repo.project))];
174    if let (Some(name), Some(why)) = (&own, refused) {
175        done.push(Step::other(format!("named after its folder in `{to}`, as `{name}` can't be: {why:#}")));
176    }
177    done.extend(joined.into_iter().filter(|step| step.kind != StepKind::AlreadyExisted));
178    // Its old project, left with no repository: the description goes to the new project when that has none, rather
179    // than be lost, and the card goes, as `deinit` takes it.
180    if !projects::load(central)?.iter().any(|r| r.project == repo.project) {
181        let description = projects::read_description(central, &repo.project);
182        if !description.is_empty() {
183            if projects::read_description(central, to).is_empty() {
184                projects::write_description(central, to, &description)?;
185                done.push(Step::other(format!(
186                    "project `{}`'s description went with it to `{to}`, which had none",
187                    repo.project
188                )));
189            } else {
190                done.push(Step::other(format!(
191                    "project `{}`'s description is dropped: `{to}` keeps its own",
192                    repo.project
193                )));
194            }
195        }
196        if projects::remove_description(central, &repo.project)? {
197            done.push(Step::other(format!(
198                "removed project `{}`'s card from the central brain: it had no other repository",
199                repo.project
200            )));
201        }
202    }
203    if dropped > 0 {
204        let links = counted(dropped, "link", "links");
205        let line = format!("dropped {links} between it and project `{}`'s repositories", repo.project);
206        done.push(Step::new(StepKind::DroppedLinks, line));
207    }
208    // They describe the repository, which moved with them, but were written among its old project's.
209    if projects::read_repo_card(&repo.dir).is_some_and(|card| !card.notes.is_empty()) {
210        done.push(Step::other(
211            "kept its role's notes, written for its old project: `kb project notes -` replaces them",
212        ));
213    }
214    Ok(done)
215}
216
217/// [`remove_repo_steps`], each step as its line.
218#[deprecated(since = "0.6.3", note = "use `remove_repo_steps`, which says what each line is")]
219pub fn remove_repo(central: &Path, dir: &Path, purge: bool) -> Result<Vec<String>> {
220    lines(remove_repo_steps(central, dir, purge))
221}
222
223/// Takes the repository whose `.brain/` is `dir` out of its project, as `kb deinit` does, and drops its old
224/// siblings' links to it. With `purge`, its `.brain/` goes too: its tasks, notes and decisions.
225/// Returns what it did, a [`Step`] a line.
226pub fn remove_repo_steps(central: &Path, dir: &Path, purge: bool) -> Result<Vec<Step>> {
227    let repo = registered(central, dir)?;
228    refuse_too_new(central, &repo)?;
229    unlink(central, &repo)?;
230    let mut done = super::init::deinit(&repo.dir, central)?;
231    if !repo.dir.is_dir() {
232        return Ok(done);
233    }
234    if !purge {
235        done.push(Step::other(format!(
236            "kept {} with its tasks, notes and decisions: commands run here still use it (`--purge`, or the \
237                 app's Unlink and delete tasks, deletes them)",
238            repo.dir.display()
239        )));
240        return Ok(done);
241    }
242    // Only ever a `.brain/` folder: `dir` comes from the registry, which a hand edit could point elsewhere.
243    if repo.dir.file_name().is_none_or(|n| n != PROJECT_DIR) {
244        bail!("{} isn't a `.brain/` folder: deleted nothing", repo.dir.display());
245    }
246    let cache = store::cache_of(&repo.dir)?;
247    fs::remove_dir_all(&repo.dir).with_context(|| format!("deleting {}", repo.dir.display()))?;
248    let line = format!("deleted {}: its tasks, notes and decisions are gone", repo.dir.display());
249    done.push(Step::new(StepKind::Deleted, line));
250    // Best effort: it's the cache, and on Windows a lock this process holds open can't be deleted yet.
251    for path in cache {
252        let _ = if path.is_dir() { fs::remove_dir_all(&path) } else { fs::remove_file(&path) };
253    }
254    Ok(done)
255}
256
257/// [`remove_project_steps`], each step as its line.
258#[deprecated(since = "0.6.3", note = "use `remove_project_steps`, which says what each line is")]
259pub fn remove_project(central: &Path, project: &str, purge: bool) -> Result<Vec<String>> {
260    lines(remove_project_steps(central, project, purge))
261}
262
263/// Takes every repository of `project` out, as [`remove_repo`] does each, which removes the project's card with
264/// the last.
265/// Returns what it did, a [`Step`] a line.
266pub fn remove_project_steps(central: &Path, project: &str, purge: bool) -> Result<Vec<Step>> {
267    let repos: Vec<Repo> = projects::load(central)?.into_iter().filter(|r| r.project == project).collect();
268    if repos.is_empty() {
269        bail!("no project `{project}`");
270    }
271    let mut done = Vec::new();
272    for repo in repos {
273        // Each but the last says the card stays for the others, which go next: untrue once they have.
274        let steps = remove_repo_steps(central, &repo.dir, purge)?;
275        done.extend(steps.into_iter().filter(|step| step.kind != StepKind::KeptCard));
276    }
277    Ok(done)
278}
279
280/// The registered repository whose `.brain/` is `dir`.
281fn registered(central: &Path, dir: &Path) -> Result<Repo> {
282    projects::load(central)?
283        .into_iter()
284        .find(|r| projects::same_dir(&r.dir, dir))
285        .with_context(|| format!("{} isn't a registered repository", dir.display()))
286}
287
288/// Refuses, before anything is written, a change to `repo` when its brain or another repository's of its project is
289/// one a newer kanbrain wrote: the links between them change on both sides, and such a brain can be neither read nor
290/// written.
291fn refuse_too_new(central: &Path, repo: &Repo) -> Result<()> {
292    crate::migrate::refuse_too_new(&repo.dir)?;
293    for sibling in siblings(central, repo)? {
294        crate::migrate::refuse_too_new(&sibling.dir).with_context(|| {
295            format!(
296                "changed nothing, as `{}`, another repository of project `{}`, would change too",
297                sibling.name(),
298                repo.project
299            )
300        })?;
301    }
302    Ok(())
303}
304
305/// Drops the links between `repo` and the other repositories of its project, on both sides. Returns how many
306/// entries changed.
307fn unlink(central: &Path, repo: &Repo) -> Result<usize> {
308    let name = repo.name();
309    let siblings: Vec<String> = siblings(central, repo)?.iter().map(Repo::name).collect();
310    let mut changed = rewrite_sibling_links(central, repo, |link_repo| (link_repo == name).then(String::new))?;
311    if repo.dir.is_dir() {
312        changed += rewrite_links(&repo.dir, |link_repo| siblings.contains(&link_repo.to_string()).then(String::new))?;
313    }
314    Ok(changed)
315}
316
317/// The project's other repositories whose folders are there.
318fn siblings(central: &Path, repo: &Repo) -> Result<Vec<Repo>> {
319    Ok(projects::load(central)?
320        .into_iter()
321        .filter(|r| r.project == repo.project && !projects::same_dir(&r.dir, &repo.dir) && r.dir.is_dir())
322        .collect())
323}
324
325/// Rewrites, in every other repository of `repo`'s project, the links whose repository `change` maps: to the
326/// name it returns, or dropped for an empty one. Returns how many entries changed.
327fn rewrite_sibling_links(central: &Path, repo: &Repo, change: impl Fn(&str) -> Option<String>) -> Result<usize> {
328    siblings(central, repo)?.iter().map(|sibling| rewrite_links(&sibling.dir, &change)).sum()
329}
330
331/// Rewrites the links of the brain in `dir` as [`rewrite_sibling_links`] does, under its write lock.
332fn rewrite_links(dir: &Path, change: impl Fn(&str) -> Option<String>) -> Result<usize> {
333    let store = Store::at(dir)?;
334    let _lock = store.lock()?;
335    let (tasks, _) = store.load_entries()?;
336    let mut changed = 0;
337    for mut task in tasks {
338        if rewrite(&mut task, &change) {
339            store.save(&mut task)?;
340            changed += 1;
341        }
342    }
343    Ok(changed)
344}
345
346/// Applies `change` to `task`'s links to other repositories; returns whether any changed.
347fn rewrite(task: &mut Task, change: impl Fn(&str) -> Option<String>) -> bool {
348    let mut changed = false;
349    task.repo_links.retain_mut(|link| match change(&link.repo) {
350        Some(name) if name.is_empty() => {
351            changed = true;
352            false
353        }
354        Some(name) => {
355            changed |= name != link.repo;
356            link.repo = name;
357            true
358        }
359        None => true,
360    });
361    changed
362}
363
364#[cfg(test)]
365#[path = "../../tests/cli/manage.rs"]
366mod tests;