use crate::model::{Task, counted};
use crate::projects::{self, Repo, RepoCard};
use crate::store::{self, PROJECT_DIR, Store};
use anyhow::{Context, Result, bail};
use std::fmt;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Step {
pub kind: StepKind,
pub line: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum StepKind {
Moved,
DroppedLinks,
Deleted,
AlreadyExisted,
KeptCard,
Other,
}
impl Step {
pub(crate) fn new(kind: StepKind, line: String) -> Self {
Self { kind, line }
}
pub(crate) fn other(line: impl Into<String>) -> Self {
Self::new(StepKind::Other, line.into())
}
}
impl fmt::Display for Step {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.line)
}
}
pub(crate) fn lines(steps: Result<Vec<Step>>) -> Result<Vec<String>> {
Ok(steps?.into_iter().map(|step| step.line).collect())
}
#[deprecated(since = "0.6.3", note = "use `rename_project_steps`, which says what each line is")]
pub fn rename_project(central: &Path, old: &str, new: &str) -> Result<Vec<String>> {
lines(rename_project_steps(central, old, new))
}
pub fn rename_project_steps(central: &Path, old: &str, new: &str) -> Result<Vec<Step>> {
projects::check_name(new)?;
let _lock = crate::store::Store::at(central)?.lock()?;
let repos = projects::load(central)?;
if !repos.iter().any(|r| r.project == old) {
bail!("no project `{old}`");
}
if old == new {
return Ok(vec![Step::other(format!("project `{old}` already has that name"))]);
}
if repos.iter().any(|r| r.project == new) {
bail!("there's already a project `{new}`: move this one's repositories to it instead");
}
projects::rename(central, old, new)?;
let (left, current): (Vec<&Repo>, Vec<&Repo>) =
repos.iter().filter(|r| r.project == old && r.dir.is_dir()).partition(|r| crate::migrate::too_new(&r.dir));
for repo in current {
if let Some(card) = projects::read_repo_card(&repo.dir) {
projects::write_repo_card(&repo.dir, &RepoCard { project: new.to_string(), ..card })?;
}
}
let done = format!("renamed project `{old}` to `{new}`; its repositories and card follow");
if left.is_empty() {
return Ok(vec![Step::other(done)]);
}
let names: Vec<String> = left.iter().map(|r| format!("`{}`'s", r.name())).collect();
Ok(vec![Step::other(format!(
"{done}, but {} .brain/project.md, left alone, keeps `{old}` until `kb update`",
names.join(", ")
))])
}
#[deprecated(since = "0.6.3", note = "use `rename_repo_steps`, which says what each line is")]
pub fn rename_repo(central: &Path, dir: &Path, new: &str) -> Result<Vec<String>> {
lines(rename_repo_steps(central, dir, new))
}
pub fn rename_repo_steps(central: &Path, dir: &Path, new: &str) -> Result<Vec<Step>> {
let repo = registered(central, dir)?;
let old = repo.name();
if old == new {
return Ok(vec![Step::other(format!("repository `{old}` already has that name"))]);
}
let new = projects::repo_name(central, &repo.project, &repo.dir, Some(new))?;
refuse_too_new(central, &repo)?;
let card = projects::read_repo_card(&repo.dir).unwrap_or_else(|| RepoCard {
project: repo.project.clone(),
repo: None,
notes: String::new(),
});
let stored = (new != repo.folder()).then(|| new.clone());
projects::write_repo_card(&repo.dir, &RepoCard { repo: stored, ..card })?;
let rewritten = rewrite_sibling_links(central, &repo, |link_repo| (link_repo == old).then(|| new.clone()))?;
let mut done: Vec<Step> =
vec![Step::other(format!("renamed repository `{old}` of project `{}` to `{new}`", repo.project))];
if rewritten > 0 {
let entries = counted(rewritten, "entry", "entries");
done.push(Step::other(format!("rewrote the links to it in {entries} of its other repositories")));
}
Ok(done)
}
#[deprecated(since = "0.6.3", note = "use `move_repo_steps`, which says what each line is")]
pub fn move_repo(central: &Path, dir: &Path, to: &str) -> Result<Vec<String>> {
lines(move_repo_steps(central, dir, to))
}
pub fn move_repo_steps(central: &Path, dir: &Path, to: &str) -> Result<Vec<Step>> {
let repo = registered(central, dir)?;
if repo.project == to {
return Ok(vec![Step::other(format!("{} is already a repository of project `{to}`", repo.name()))]);
}
projects::check_name(to)?;
let root = repo.dir.parent().filter(|_| repo.dir.is_dir()).context("its folder is gone: remove it instead")?;
let name = repo.name();
refuse_too_new(central, &repo)?;
let dropped = unlink(central, &repo)?;
let own = projects::read_repo_card(&repo.dir).and_then(|card| card.repo);
let refused = own.as_deref().and_then(|name| projects::repo_name(central, to, &repo.dir, Some(name)).err());
let keep = own.as_deref().filter(|_| refused.is_none());
let joined = super::init::join(root, Some(to), keep, central)?;
let mut done = vec![Step::new(StepKind::Moved, format!("moved {name} from project `{}` to `{to}`", repo.project))];
if let (Some(name), Some(why)) = (&own, refused) {
done.push(Step::other(format!("named after its folder in `{to}`, as `{name}` can't be: {why:#}")));
}
done.extend(joined.into_iter().filter(|step| step.kind != StepKind::AlreadyExisted));
if !projects::load(central)?.iter().any(|r| r.project == repo.project) {
let description = projects::read_description(central, &repo.project);
if !description.is_empty() {
if projects::read_description(central, to).is_empty() {
projects::write_description(central, to, &description)?;
done.push(Step::other(format!(
"project `{}`'s description went with it to `{to}`, which had none",
repo.project
)));
} else {
done.push(Step::other(format!(
"project `{}`'s description is dropped: `{to}` keeps its own",
repo.project
)));
}
}
if projects::remove_description(central, &repo.project)? {
done.push(Step::other(format!(
"removed project `{}`'s card from the central brain: it had no other repository",
repo.project
)));
}
}
if dropped > 0 {
let links = counted(dropped, "link", "links");
let line = format!("dropped {links} between it and project `{}`'s repositories", repo.project);
done.push(Step::new(StepKind::DroppedLinks, line));
}
if projects::read_repo_card(&repo.dir).is_some_and(|card| !card.notes.is_empty()) {
done.push(Step::other(
"kept its role's notes, written for its old project: `kb project notes -` replaces them",
));
}
Ok(done)
}
#[deprecated(since = "0.6.3", note = "use `remove_repo_steps`, which says what each line is")]
pub fn remove_repo(central: &Path, dir: &Path, purge: bool) -> Result<Vec<String>> {
lines(remove_repo_steps(central, dir, purge))
}
pub fn remove_repo_steps(central: &Path, dir: &Path, purge: bool) -> Result<Vec<Step>> {
let repo = registered(central, dir)?;
refuse_too_new(central, &repo)?;
unlink(central, &repo)?;
let mut done = super::init::deinit(&repo.dir, central)?;
if !repo.dir.is_dir() {
return Ok(done);
}
if !purge {
done.push(Step::other(format!(
"kept {} with its tasks, notes and decisions: commands run here still use it (`--purge`, or the \
app's Unlink and delete tasks, deletes them)",
repo.dir.display()
)));
return Ok(done);
}
if repo.dir.file_name().is_none_or(|n| n != PROJECT_DIR) {
bail!("{} isn't a `.brain/` folder: deleted nothing", repo.dir.display());
}
let cache = store::cache_of(&repo.dir)?;
fs::remove_dir_all(&repo.dir).with_context(|| format!("deleting {}", repo.dir.display()))?;
let line = format!("deleted {}: its tasks, notes and decisions are gone", repo.dir.display());
done.push(Step::new(StepKind::Deleted, line));
for path in cache {
let _ = if path.is_dir() { fs::remove_dir_all(&path) } else { fs::remove_file(&path) };
}
Ok(done)
}
#[deprecated(since = "0.6.3", note = "use `remove_project_steps`, which says what each line is")]
pub fn remove_project(central: &Path, project: &str, purge: bool) -> Result<Vec<String>> {
lines(remove_project_steps(central, project, purge))
}
pub fn remove_project_steps(central: &Path, project: &str, purge: bool) -> Result<Vec<Step>> {
let repos: Vec<Repo> = projects::load(central)?.into_iter().filter(|r| r.project == project).collect();
if repos.is_empty() {
bail!("no project `{project}`");
}
let mut done = Vec::new();
for repo in repos {
let steps = remove_repo_steps(central, &repo.dir, purge)?;
done.extend(steps.into_iter().filter(|step| step.kind != StepKind::KeptCard));
}
Ok(done)
}
fn registered(central: &Path, dir: &Path) -> Result<Repo> {
projects::load(central)?
.into_iter()
.find(|r| projects::same_dir(&r.dir, dir))
.with_context(|| format!("{} isn't a registered repository", dir.display()))
}
fn refuse_too_new(central: &Path, repo: &Repo) -> Result<()> {
crate::migrate::refuse_too_new(&repo.dir)?;
for sibling in siblings(central, repo)? {
crate::migrate::refuse_too_new(&sibling.dir).with_context(|| {
format!(
"changed nothing, as `{}`, another repository of project `{}`, would change too",
sibling.name(),
repo.project
)
})?;
}
Ok(())
}
fn unlink(central: &Path, repo: &Repo) -> Result<usize> {
let name = repo.name();
let siblings: Vec<String> = siblings(central, repo)?.iter().map(Repo::name).collect();
let mut changed = rewrite_sibling_links(central, repo, |link_repo| (link_repo == name).then(String::new))?;
if repo.dir.is_dir() {
changed += rewrite_links(&repo.dir, |link_repo| siblings.contains(&link_repo.to_string()).then(String::new))?;
}
Ok(changed)
}
fn siblings(central: &Path, repo: &Repo) -> Result<Vec<Repo>> {
Ok(projects::load(central)?
.into_iter()
.filter(|r| r.project == repo.project && !projects::same_dir(&r.dir, &repo.dir) && r.dir.is_dir())
.collect())
}
fn rewrite_sibling_links(central: &Path, repo: &Repo, change: impl Fn(&str) -> Option<String>) -> Result<usize> {
siblings(central, repo)?.iter().map(|sibling| rewrite_links(&sibling.dir, &change)).sum()
}
fn rewrite_links(dir: &Path, change: impl Fn(&str) -> Option<String>) -> Result<usize> {
let store = Store::at(dir)?;
let _lock = store.lock()?;
let (tasks, _) = store.load_entries()?;
let mut changed = 0;
for mut task in tasks {
if rewrite(&mut task, &change) {
store.save(&mut task)?;
changed += 1;
}
}
Ok(changed)
}
fn rewrite(task: &mut Task, change: impl Fn(&str) -> Option<String>) -> bool {
let mut changed = false;
task.repo_links.retain_mut(|link| match change(&link.repo) {
Some(name) if name.is_empty() => {
changed = true;
false
}
Some(name) => {
changed |= name != link.repo;
link.repo = name;
true
}
None => true,
});
changed
}
#[cfg(test)]
#[path = "../../tests/cli/manage.rs"]
mod tests;