1use crate::model::slugify;
18use crate::store::{self, Store};
19use anyhow::{Context, Result, bail};
20use std::fmt::Write;
21use std::fs;
22use std::io::ErrorKind;
23use std::path::{Path, PathBuf};
24
25macro_rules! description_scope {
30 () => {
31 "what the app is, its services and how they connect, conventions they share: the big picture, in at most \
32 five lines, without tables, columns, function signatures, files or dependencies, which the code \
33 already says"
34 };
35}
36pub(crate) use description_scope;
37
38pub const REGISTRY_FILE: &str = "projects.md";
40pub const PROJECT_FILE: &str = "project.md";
42pub(crate) const CARDS_DIR: &str = "projects";
44pub const CENTRAL: &str = "central";
46
47const DESCRIPTION_TEMPLATE: &str = "What the application is, its services and how they connect, and the \
49conventions they share: what an agent should know before touching any of its repositories.";
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Repo {
54 pub project: String,
56 pub dir: PathBuf,
58}
59
60impl Repo {
61 pub fn name(&self) -> String {
63 read_repo_card(&self.dir).and_then(|card| card.repo).unwrap_or_else(|| self.folder())
64 }
65
66 pub fn folder(&self) -> String {
68 self.dir
69 .parent()
70 .and_then(Path::file_name)
71 .map_or_else(|| self.project.clone(), |n| n.to_string_lossy().into_owned())
72 }
73
74 pub fn label(&self, repos: &[Repo]) -> String {
77 if repos.iter().filter(|r| r.project == self.project).count() > 1 {
78 format!("{}/{}", self.project, self.name())
79 } else {
80 self.project.clone()
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ProjectInfo {
88 pub name: String,
90 pub description: String,
92 pub repo: String,
94 pub notes: String,
96 pub siblings: Vec<(String, PathBuf)>,
98}
99
100pub fn central_dir() -> Result<PathBuf> {
102 if let Some(dir) = std::env::var_os("KANBRAIN_DIR") {
103 return Ok(dir.into());
104 }
105 Ok(dirs::data_dir().context("could not determine the data directory; set KANBRAIN_DIR")?.join("kanbrain"))
106}
107
108pub fn load(central: &Path) -> Result<Vec<Repo>> {
110 let path = central.join(REGISTRY_FILE);
111 let text = match store::read_text(&path) {
112 Ok(text) => text,
113 Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
114 Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
115 };
116 Ok(text
117 .lines()
118 .filter_map(|line| line.strip_prefix("- ")?.split_once(": "))
119 .map(|(project, dir)| Repo { project: project.trim().to_string(), dir: PathBuf::from(dir.trim()) })
120 .collect())
121}
122
123pub fn find(central: &Path, label: &str) -> Result<Repo> {
126 let repos = load(central)?;
127 if let Some(repo) = repos.iter().find(|r| r.label(&repos) == label) {
128 return Ok(repo.clone());
129 }
130 let lower = label.to_lowercase();
132 let mut alike = repos.iter().filter(|r| r.label(&repos).to_lowercase() == lower);
133 if let (Some(repo), None) = (alike.next(), alike.next()) {
134 return Ok(repo.clone());
135 }
136 if repos.is_empty() {
137 bail!("no project `{label}`: none is registered yet (see `kb init`)");
138 }
139 let labels: Vec<String> = repos.iter().map(|r| r.label(&repos)).collect();
140 let several: Vec<String> = repos.iter().filter(|r| r.project == label).map(|r| r.label(&repos)).collect();
142 if !several.is_empty() {
143 bail!("project `{label}` has several repositories: name one, {}", several.join(", "));
144 }
145 bail!("no project `{label}`; there are {}, and `{CENTRAL}`", labels.join(", "))
146}
147
148pub fn register(central: &Path, project: &str, dir: &Path) -> Result<()> {
153 check_name(project)?;
154 let _lock = lock(central)?;
155 let mut repos = load(central)?;
156 let new = Repo { project: project.to_string(), dir: dir.to_path_buf() };
157 if repos.iter().any(|r| r.project == project && same_dir(&r.dir, dir)) {
160 return Ok(());
161 }
162 repos.retain(|r| !same_dir(&r.dir, dir));
163 if let Some(twin) = repos.iter().find(|r| r.project == project && r.name() == new.name() && r.dir.is_dir()) {
164 bail!(
165 "project `{project}` already has a repository named `{}`, at {}; name this one with `kb init --repo NAME`, \
166 or rename that one with `kb project repo NAME`",
167 new.name(),
168 twin.dir.display()
169 );
170 }
171 repos.retain(|r| !(r.project == project && r.name() == new.name()));
172 repos.push(new);
173 save(central, &repos)
174}
175
176pub fn unregister(central: &Path, dir: &Path) -> Result<Option<Repo>> {
179 let _lock = lock(central)?;
180 let mut repos = load(central)?;
181 let Some(at) = repos.iter().position(|r| same_dir(&r.dir, dir)) else { return Ok(None) };
182 let repo = repos.remove(at);
183 save(central, &repos)?;
184 Ok(Some(repo))
185}
186
187pub fn repo_name(central: &Path, project: &str, dir: &Path, wanted: Option<&str>) -> Result<String> {
193 let others: Vec<Repo> =
196 load(central)?.into_iter().filter(|r| r.project == project && !same_dir(&r.dir, dir)).collect();
197 let taken: Vec<String> = others.iter().map(Repo::name).collect();
198 if let Some(wanted) = wanted {
199 check_repo_name(wanted)?;
200 if others.iter().any(|r| r.dir.is_dir() && r.name() == wanted) {
201 bail!("project `{project}` already has a repository named `{wanted}`; pick another");
202 }
203 if taken.iter().any(|t| t == wanted) {
204 bail!(
205 "project `{project}` has a repository named `{wanted}` whose folder is gone: take it out with \
206 `kb --project {project}/{wanted} deinit`, or pick another name"
207 );
208 }
209 return Ok(wanted.to_string());
210 }
211 let here = Repo { project: project.to_string(), dir: dir.to_path_buf() };
212 let card = read_repo_card(dir).filter(|card| card.project == project).and_then(|card| card.repo);
213 let own = card.unwrap_or_else(|| here.folder());
216 let own = if check_repo_name(&own).is_ok() { own } else { repo_name_of_folder(&own) };
217 let above = dir.parent().and_then(Path::parent).and_then(Path::file_name).map(|n| n.to_string_lossy());
218 let candidates = std::iter::once(own.clone())
219 .chain(above.map(|above| format!("{}-{own}", repo_name_of_folder(&above))))
220 .chain((2..).map(|n| format!("{own}-{n}")));
221 Ok(candidates.into_iter().find(|name| !taken.contains(name)).expect("the numbered names never run out"))
222}
223
224pub fn check_name(name: &str) -> Result<()> {
227 if name.trim().is_empty() || name.contains([':', '/', '\n', '\r']) || name.trim() != name || name.starts_with('-') {
229 bail!(
230 "`{name}` can't be a project name: it needs to be one line, without `:`, `/`, surrounding spaces or a \
231 leading `-`"
232 );
233 }
234 if name.eq_ignore_ascii_case(CENTRAL) {
236 bail!("`{CENTRAL}` is the name of the central brain; pick another: `kb init NAME`");
237 }
238 Ok(())
239}
240
241fn lock(central: &Path) -> Result<store::BrainLock> {
244 Store::at(central)?.lock()
245}
246
247fn save(central: &Path, repos: &[Repo]) -> Result<()> {
249 Store::at(central)?;
250 let mut text = String::from(
251 "# Projects\n\nRepositories with their own `.brain/`, registered by `kb init`; several lines \
252 with the same name are one project's repositories. The kanban app shows them all.\n\n",
253 );
254 for r in repos {
255 writeln!(text, "- {}: {}", r.project, r.dir.display()).expect("writing to a String cannot fail");
256 }
257 store::write_file(¢ral.join(REGISTRY_FILE), &text)
258}
259
260#[derive(Debug, Clone, PartialEq, Eq)]
269pub struct RepoCard {
270 pub project: String,
272 pub repo: Option<String>,
275 pub notes: String,
277}
278
279pub fn read_repo_card(dir: &Path) -> Option<RepoCard> {
281 let text = store::read_text(&dir.join(PROJECT_FILE)).ok()?;
282 let mut lines = text.lines();
283 let project = lines.by_ref().find_map(|l| l.strip_prefix("# "))?.trim().to_string();
284 let rest: Vec<&str> = lines.collect();
285 let (repo, notes) = match rest.first().and_then(|l| l.strip_prefix("- repo:")) {
287 Some(name) => (Some(name.trim().to_string()).filter(|n| !n.is_empty()), &rest[1..]),
288 None => (None, &rest[..]),
289 };
290 Some(RepoCard { project, repo, notes: notes.join("\n").trim().to_string() })
291}
292
293pub fn write_repo_card(dir: &Path, card: &RepoCard) -> Result<()> {
295 let mut text = format!("# {}\n", card.project);
296 if let Some(repo) = &card.repo {
297 writeln!(text, "- repo: {repo}").expect("writing to a String cannot fail");
298 }
299 let notes = card.notes.trim();
300 if !notes.is_empty() {
301 write!(text, "\n{notes}\n").expect("writing to a String cannot fail");
302 }
303 store::write_file(&dir.join(PROJECT_FILE), &text)
304}
305
306pub fn check_repo_name(name: &str) -> Result<()> {
309 if name.is_empty() || name.contains(not_in_repo_name) || name.starts_with('-') {
310 bail!(
311 "`{name}` can't be a repository name: it needs to be one word, without `,`, `#`, `:`, `/` or a leading `-`"
312 );
313 }
314 Ok(())
315}
316
317fn not_in_repo_name(c: char) -> bool {
319 c.is_whitespace() || matches!(c, ',' | '#' | ':' | '/')
320}
321
322pub(crate) fn repo_name_of_folder(folder: &str) -> String {
324 let name: String = folder.chars().map(|c| if not_in_repo_name(c) { '-' } else { c }).collect();
325 let name = name.trim_matches('-');
326 if name.is_empty() { "repo".to_string() } else { name.to_string() }
327}
328
329pub fn cards_dir(central: &Path) -> PathBuf {
331 central.join(CARDS_DIR)
332}
333
334fn card_path(central: &Path, project: &str) -> PathBuf {
339 let slug = slugify(project);
340 let name =
341 if slug == project { slug } else { format!("{slug}-{:08x}", store::fnv1a(project.as_bytes()) & 0xffff_ffff) };
342 cards_dir(central).join(format!("{name}.md"))
343}
344
345fn card_owner(path: &Path) -> Option<String> {
347 let text = store::read_text(path).ok()?;
348 text.lines().find_map(|l| l.strip_prefix("# ")).map(|name| name.trim().to_string())
349}
350
351fn legacy_card(central: &Path, project: &str) -> Option<PathBuf> {
353 let path = cards_dir(central).join(format!("{}.md", slugify(project)));
354 (path != card_path(central, project) && card_owner(&path).as_deref() == Some(project)).then_some(path)
355}
356
357fn current_card(central: &Path, project: &str) -> Option<PathBuf> {
360 let path = card_path(central, project);
361 if card_owner(&path).as_deref() == Some(project) { Some(path) } else { legacy_card(central, project) }
362}
363
364pub fn read_description(central: &Path, project: &str) -> String {
367 let text = current_card(central, project).and_then(|path| store::read_text(&path).ok()).unwrap_or_default();
368 let body = card_body(&text);
369 if body == DESCRIPTION_TEMPLATE { String::new() } else { body }
370}
371
372fn card_body(text: &str) -> String {
374 text.lines().skip_while(|l| !l.starts_with("# ")).skip(1).collect::<Vec<_>>().join("\n").trim().to_string()
375}
376
377pub fn write_description(central: &Path, project: &str, description: &str) -> Result<()> {
380 let _lock = lock(central)?;
382 let path = card_path(central, project);
383 if let Some(owner) = card_owner(&path).filter(|owner| owner != project) {
384 let theirs = card_path(central, &owner);
385 if theirs != path && !theirs.exists() {
386 fs::rename(&path, &theirs).with_context(|| format!("moving {} to {}", path.display(), theirs.display()))?;
387 }
388 }
389 let legacy = legacy_card(central, project);
390 store::write_file(&path, &format!("# {project}\n\n{}\n", description.trim()))?;
391 if let Some(legacy) = legacy {
392 fs::remove_file(&legacy).with_context(|| format!("removing {}", legacy.display()))?;
393 }
394 Ok(())
395}
396
397pub fn ensure_description(central: &Path, project: &str) -> Result<bool> {
399 let _lock = lock(central)?;
400 if current_card(central, project).is_some() {
401 return Ok(false);
402 }
403 write_description(central, project, DESCRIPTION_TEMPLATE)?;
404 Ok(true)
405}
406
407pub fn rename(central: &Path, old: &str, new: &str) -> Result<()> {
410 let _lock = lock(central)?;
411 let mut repos = load(central)?;
412 for repo in repos.iter_mut().filter(|r| r.project == old) {
413 new.clone_into(&mut repo.project);
414 }
415 save(central, &repos)?;
416 let Some(from) = current_card(central, old) else { return Ok(()) };
417 let text = store::read_text(&from).with_context(|| format!("reading {}", from.display()))?;
418 let body = card_body(&text);
419 if card_path(central, new) == from {
422 return store::write_file(&from, &format!("# {new}\n\n{}\n", body.trim()));
423 }
424 write_description(central, new, &body)?;
425 fs::remove_file(&from).with_context(|| format!("removing {}", from.display()))?;
426 Ok(())
427}
428
429pub fn remove_description(central: &Path, project: &str) -> Result<bool> {
432 let _lock = lock(central)?;
433 let Some(path) = current_card(central, project) else { return Ok(false) };
434 fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
435 Ok(true)
436}
437
438pub fn info(dir: &Path, central: Option<&Path>) -> Option<ProjectInfo> {
441 let RepoCard { project: name, notes, .. } = read_repo_card(dir)?;
442 let (name, description, siblings) = match central {
443 Some(central) => {
444 let repos = load(central).unwrap_or_default();
445 let name = repos.iter().find(|r| same_dir(&r.dir, dir)).map_or(name, |r| r.project.clone());
448 let siblings = repos
449 .into_iter()
450 .filter(|r| r.project == name && !same_dir(&r.dir, dir))
451 .map(|r| (r.name(), r.dir))
452 .collect();
453 (name.clone(), read_description(central, &name), siblings)
454 }
455 None => (name, String::new(), Vec::new()),
456 };
457 let repo = Repo { project: name.clone(), dir: dir.to_path_buf() }.name();
458 Some(ProjectInfo { name, description, repo, notes, siblings })
459}
460
461pub fn same_dir(a: &Path, b: &Path) -> bool {
464 a == b || fs::canonicalize(a).is_ok_and(|a| fs::canonicalize(b).ok() == Some(a))
465}
466
467#[cfg(test)]
468#[path = "../tests/projects.rs"]
469mod tests;