1use 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#[derive(Debug, Clone, PartialEq, Eq)]
20#[non_exhaustive]
21pub struct Step {
22 pub kind: StepKind,
24 pub line: String,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30#[non_exhaustive]
31pub enum StepKind {
32 Moved,
34 DroppedLinks,
36 Deleted,
38 AlreadyExisted,
40 KeptCard,
42 Other,
44}
45
46impl Step {
47 pub(crate) fn new(kind: StepKind, line: String) -> Self {
49 Self { kind, line }
50 }
51
52 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
64pub(crate) fn lines(steps: Result<Vec<Step>>) -> Result<Vec<String>> {
66 Ok(steps?.into_iter().map(|step| step.line).collect())
67}
68
69#[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
75pub fn rename_project_steps(central: &Path, old: &str, new: &str) -> Result<Vec<Step>> {
78 projects::check_name(new)?;
79 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 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#[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
117pub 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#[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
151pub 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 let name = repo.name();
164 refuse_too_new(central, &repo)?;
165 let dropped = unlink(central, &repo)?;
166 let own = projects::read_repo_card(&repo.dir).and_then(|card| card.repo);
168 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 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 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 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#[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
223pub 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 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 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#[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
263pub 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 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
280fn 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
288fn 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
305fn 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
317fn 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
325fn 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
331fn 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
346fn 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;