1use 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
17pub fn rename_project(central: &Path, old: &str, new: &str) -> Result<Vec<String>> {
19 projects::check_name(new)?;
20 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
41pub 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
66pub 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 let name = repo.name();
78 let dropped = unlink(central, &repo)?;
79 let own = projects::read_repo_card(&repo.dir).and_then(|card| card.repo);
81 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 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 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 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
121pub 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 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 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
152pub 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 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
168fn 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
176fn 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
188fn 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
196fn 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
202fn 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
217fn 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;