1use crate::model::{infallible, slugify};
18use crate::store::{self, Store};
19use anyhow::{Context, Result, bail};
20use std::ffi::OsString;
21use std::fmt::Write;
22use std::fs;
23use std::io::ErrorKind;
24use std::path::{Path, PathBuf};
25
26macro_rules! description_scope {
31 () => {
32 "what the app is, its services and how they connect, conventions they share: the big picture, in at most \
33 five lines, without tables, columns, function signatures, files or dependencies, which the code \
34 already says"
35 };
36}
37pub(crate) use description_scope;
38
39pub const REGISTRY_FILE: &str = "projects.md";
41pub const PROJECT_FILE: &str = "project.md";
43pub(crate) const CARDS_DIR: &str = "projects";
45pub const CENTRAL: &str = "central";
47
48const DESCRIPTION_TEMPLATE: &str = "What the application is, its services and how they connect, and the \
50conventions they share: what an agent should know before touching any of its repositories.";
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Repo {
55 pub project: String,
57 pub dir: PathBuf,
59}
60
61impl Repo {
62 pub fn name(&self) -> String {
64 read_repo_card(&self.dir).and_then(|card| card.repo).unwrap_or_else(|| self.folder())
65 }
66
67 pub fn folder(&self) -> String {
69 self.dir
70 .parent()
71 .and_then(Path::file_name)
72 .map_or_else(|| self.project.clone(), |n| n.to_string_lossy().into_owned())
73 }
74
75 pub fn label(&self, repos: &[Repo]) -> String {
78 if repos.iter().filter(|r| r.project == self.project).count() > 1 {
79 format!("{}/{}", self.project, self.name())
80 } else {
81 self.project.clone()
82 }
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ProjectInfo {
89 pub name: String,
91 pub description: String,
93 pub repo: String,
95 pub notes: String,
97 pub siblings: Vec<(String, PathBuf)>,
99}
100
101pub fn central_dir() -> Result<PathBuf> {
104 central_dir_from(std::env::var_os("KANBRAIN_DIR"), data_dir)
105}
106
107#[cfg(not(test))]
109fn data_dir() -> Option<PathBuf> {
110 dirs::data_dir()
111}
112
113#[cfg(test)]
116#[expect(clippy::unnecessary_wraps, reason = "the signature of `dirs::data_dir`, which it stands in for")]
117fn data_dir() -> Option<PathBuf> {
118 Some(std::env::temp_dir().join("kanbrain-tests-data"))
119}
120
121fn central_dir_from(var: Option<OsString>, data_dir: impl FnOnce() -> Option<PathBuf>) -> Result<PathBuf> {
123 if let Some(dir) = var.filter(|v| !v.is_empty()).map(PathBuf::from) {
126 if !dir.is_absolute() {
129 let what = dir.display();
130 if dir.as_os_str().as_encoded_bytes().starts_with(b"~") {
132 bail!(
133 "KANBRAIN_DIR must be an absolute path, not `{what}`: `~` isn't expanded there (as when quoted, or \
134 in a systemd environment.d file); write it with the home folder's full path"
135 );
136 }
137 bail!("KANBRAIN_DIR must be an absolute path, not `{what}`: set it to the folder's full path");
138 }
139 return Ok(dir);
140 }
141 Ok(data_dir().context("could not determine the data directory; set KANBRAIN_DIR")?.join("kanbrain"))
142}
143
144pub fn load(central: &Path) -> Result<Vec<Repo>> {
146 let path = central.join(REGISTRY_FILE);
147 let text = match store::read_text(&path) {
148 Ok(text) => text,
149 Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
150 Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
151 };
152 Ok(text
153 .lines()
154 .filter_map(|line| line.strip_prefix("- ")?.split_once(": "))
155 .map(|(project, dir)| Repo { project: project.trim().to_string(), dir: simplified(Path::new(dir.trim())) })
157 .collect())
158}
159
160pub fn find(central: &Path, label: &str) -> Result<Repo> {
163 let repos = load(central)?;
164 if let Some(repo) = repos.iter().find(|r| r.label(&repos) == label) {
165 return Ok(repo.clone());
166 }
167 let lower = label.to_lowercase();
169 let mut alike = repos.iter().filter(|r| r.label(&repos).to_lowercase() == lower);
170 if let (Some(repo), None) = (alike.next(), alike.next()) {
171 return Ok(repo.clone());
172 }
173 if repos.is_empty() {
174 bail!("no project `{label}`: none is registered yet (see `kb init`)");
175 }
176 let labels: Vec<String> = repos.iter().map(|r| r.label(&repos)).collect();
177 let several: Vec<String> = repos.iter().filter(|r| r.project == label).map(|r| r.label(&repos)).collect();
179 if !several.is_empty() {
180 bail!("project `{label}` has several repositories: name one, {}", several.join(", "));
181 }
182 bail!("no project `{label}`; there are {}, and `{CENTRAL}`", labels.join(", "))
183}
184
185pub fn register(central: &Path, project: &str, dir: &Path) -> Result<()> {
190 check_name(project)?;
191 let _lock = lock(central)?;
192 let mut repos = load(central)?;
193 let new = Repo { project: project.to_string(), dir: dir.to_path_buf() };
194 if repos.iter().any(|r| r.project == project && same_dir(&r.dir, dir)) {
197 let text = store::read_text(¢ral.join(REGISTRY_FILE)).unwrap_or_default();
199 return if text.contains(r"\\?\") && text != render(&repos) { save(central, &repos) } else { Ok(()) };
200 }
201 repos.retain(|r| !same_dir(&r.dir, dir));
202 if let Some(twin) = repos.iter().find(|r| r.project == project && r.name() == new.name() && r.dir.is_dir()) {
203 bail!(
204 "project `{project}` already has a repository named `{}`, at {}; name this one with `kb init --repo NAME`, \
205 or rename that one with `kb project repo NAME`",
206 new.name(),
207 twin.dir.display()
208 );
209 }
210 repos.retain(|r| !(r.project == project && r.name() == new.name()));
211 repos.push(new);
212 save(central, &repos)
213}
214
215pub fn unregister(central: &Path, dir: &Path) -> Result<Option<Repo>> {
218 let _lock = lock(central)?;
219 let mut repos = load(central)?;
220 let Some(at) = repos.iter().position(|r| same_dir(&r.dir, dir)) else { return Ok(None) };
221 let repo = repos.remove(at);
222 save(central, &repos)?;
223 Ok(Some(repo))
224}
225
226pub fn repo_name(central: &Path, project: &str, dir: &Path, wanted: Option<&str>) -> Result<String> {
232 let others: Vec<Repo> =
235 load(central)?.into_iter().filter(|r| r.project == project && !same_dir(&r.dir, dir)).collect();
236 let taken: Vec<String> = others.iter().map(Repo::name).collect();
237 if let Some(wanted) = wanted {
238 check_repo_name(wanted)?;
239 if others.iter().any(|r| r.dir.is_dir() && r.name() == wanted) {
240 bail!("project `{project}` already has a repository named `{wanted}`; pick another");
241 }
242 if taken.iter().any(|t| t == wanted) {
243 bail!(
244 "project `{project}` has a repository named `{wanted}` whose folder is gone: take it out with \
245 `kb --project {project}/{wanted} deinit`, or pick another name"
246 );
247 }
248 return Ok(wanted.to_string());
249 }
250 let here = Repo { project: project.to_string(), dir: dir.to_path_buf() };
251 let card = read_repo_card(dir).filter(|card| card.project == project).and_then(|card| card.repo);
252 let own = card.unwrap_or_else(|| here.folder());
255 let own = if check_repo_name(&own).is_ok() { own } else { repo_name_of_folder(&own) };
256 let above = dir.parent().and_then(Path::parent).and_then(Path::file_name).map(|n| n.to_string_lossy());
257 let candidates = std::iter::once(own.clone())
258 .chain(above.map(|above| format!("{}-{own}", repo_name_of_folder(&above))))
259 .chain((2..).map(|n| format!("{own}-{n}")));
260 Ok(candidates.into_iter().find(|name| !taken.contains(name)).expect("the numbered names never run out"))
261}
262
263pub fn check_name(name: &str) -> Result<()> {
266 if name.trim().is_empty() || name.contains([':', '/', '\n', '\r']) || name.trim() != name || name.starts_with('-') {
268 bail!(
269 "`{name}` can't be a project name: it needs to be one line, without `:`, `/`, surrounding spaces or a \
270 leading `-`"
271 );
272 }
273 if name.eq_ignore_ascii_case(CENTRAL) {
275 bail!("`{CENTRAL}` is the name of the central brain; pick another: `kb init NAME`");
276 }
277 Ok(())
278}
279
280fn lock(central: &Path) -> Result<store::BrainLock> {
283 Store::at(central)?.lock()
284}
285
286fn save(central: &Path, repos: &[Repo]) -> Result<()> {
288 Store::at(central)?;
289 store::write_file(¢ral.join(REGISTRY_FILE), &render(repos))
290}
291
292fn render(repos: &[Repo]) -> String {
294 let mut text = String::from(
295 "# Projects\n\nRepositories with their own `.brain/`, registered by `kb init`; several lines \
296 with the same name are one project's repositories. The kanban app shows them all.\n\n",
297 );
298 for r in repos {
299 infallible(writeln!(text, "- {}: {}", r.project, r.dir.display()));
300 }
301 text
302}
303
304#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct RepoCard {
314 pub project: String,
316 pub repo: Option<String>,
319 pub notes: String,
321}
322
323pub fn read_repo_card(dir: &Path) -> Option<RepoCard> {
325 let text = store::read_text(&dir.join(PROJECT_FILE)).ok()?;
326 let mut lines = text.lines();
327 let project = lines.by_ref().find_map(|l| l.strip_prefix("# "))?.trim().to_string();
328 let rest: Vec<&str> = lines.collect();
329 let (repo, notes) = match rest.first().and_then(|l| l.strip_prefix("- repo:")) {
331 Some(name) => (Some(name.trim().to_string()).filter(|n| !n.is_empty()), &rest[1..]),
332 None => (None, &rest[..]),
333 };
334 Some(RepoCard { project, repo, notes: notes.join("\n").trim().to_string() })
335}
336
337pub fn write_repo_card(dir: &Path, card: &RepoCard) -> Result<()> {
339 let mut text = format!("# {}\n", card.project);
340 if let Some(repo) = &card.repo {
341 infallible(writeln!(text, "- repo: {repo}"));
342 }
343 let notes = card.notes.trim();
344 if !notes.is_empty() {
345 infallible(write!(text, "\n{notes}\n"));
346 }
347 store::write_file(&dir.join(PROJECT_FILE), &text)
348}
349
350pub fn check_repo_name(name: &str) -> Result<()> {
353 if name.is_empty() || name.contains(not_in_repo_name) || name.starts_with('-') {
354 bail!(
355 "`{name}` can't be a repository name: it needs to be one word, without `,`, `#`, `:`, `/` or a leading `-`"
356 );
357 }
358 Ok(())
359}
360
361fn not_in_repo_name(c: char) -> bool {
363 c.is_whitespace() || matches!(c, ',' | '#' | ':' | '/')
364}
365
366pub(crate) fn repo_name_of_folder(folder: &str) -> String {
368 let name: String = folder.chars().map(|c| if not_in_repo_name(c) { '-' } else { c }).collect();
369 let name = name.trim_matches('-');
370 if name.is_empty() { "repo".to_string() } else { name.to_string() }
371}
372
373pub fn cards_dir(central: &Path) -> PathBuf {
375 central.join(CARDS_DIR)
376}
377
378fn card_path(central: &Path, project: &str) -> PathBuf {
383 let slug = slugify(project);
384 let name =
385 if slug == project { slug } else { format!("{slug}-{:08x}", store::fnv1a(project.as_bytes()) & 0xffff_ffff) };
386 cards_dir(central).join(format!("{name}.md"))
387}
388
389fn card_owner(path: &Path) -> Option<String> {
391 let text = store::read_text(path).ok()?;
392 text.lines().find_map(|l| l.strip_prefix("# ")).map(|name| name.trim().to_string())
393}
394
395fn legacy_card(central: &Path, project: &str) -> Option<PathBuf> {
397 let path = cards_dir(central).join(format!("{}.md", slugify(project)));
398 (path != card_path(central, project) && card_owner(&path).as_deref() == Some(project)).then_some(path)
399}
400
401fn current_card(central: &Path, project: &str) -> Option<PathBuf> {
404 let path = card_path(central, project);
405 if card_owner(&path).as_deref() == Some(project) { Some(path) } else { legacy_card(central, project) }
406}
407
408pub fn read_description(central: &Path, project: &str) -> String {
411 let text = current_card(central, project).and_then(|path| store::read_text(&path).ok()).unwrap_or_default();
412 let body = card_body(&text);
413 if body == DESCRIPTION_TEMPLATE { String::new() } else { body }
414}
415
416fn card_body(text: &str) -> String {
418 text.lines().skip_while(|l| !l.starts_with("# ")).skip(1).collect::<Vec<_>>().join("\n").trim().to_string()
419}
420
421pub fn write_description(central: &Path, project: &str, description: &str) -> Result<()> {
424 let _lock = lock(central)?;
426 let path = card_path(central, project);
427 if let Some(owner) = card_owner(&path).filter(|owner| owner != project) {
428 let theirs = card_path(central, &owner);
429 if theirs != path && !theirs.exists() {
430 store::rename(&path, &theirs)
431 .with_context(|| format!("moving {} to {}", path.display(), theirs.display()))?;
432 }
433 }
434 let legacy = legacy_card(central, project);
435 store::write_file(&path, &format!("# {project}\n\n{}\n", description.trim()))?;
436 if let Some(legacy) = legacy {
437 store::remove_file(&legacy).with_context(|| format!("removing {}", legacy.display()))?;
438 }
439 Ok(())
440}
441
442pub fn ensure_description(central: &Path, project: &str) -> Result<bool> {
444 let _lock = lock(central)?;
445 if current_card(central, project).is_some() {
446 return Ok(false);
447 }
448 write_description(central, project, DESCRIPTION_TEMPLATE)?;
449 Ok(true)
450}
451
452pub fn rename(central: &Path, old: &str, new: &str) -> Result<()> {
455 let _lock = lock(central)?;
456 let mut repos = load(central)?;
457 for repo in repos.iter_mut().filter(|r| r.project == old) {
458 new.clone_into(&mut repo.project);
459 }
460 save(central, &repos)?;
461 let Some(from) = current_card(central, old) else { return Ok(()) };
462 let text = store::read_text(&from).with_context(|| format!("reading {}", from.display()))?;
463 let body = card_body(&text);
464 if card_path(central, new) == from {
467 return store::write_file(&from, &format!("# {new}\n\n{}\n", body.trim()));
468 }
469 write_description(central, new, &body)?;
470 store::remove_file(&from).with_context(|| format!("removing {}", from.display()))?;
471 Ok(())
472}
473
474pub fn remove_description(central: &Path, project: &str) -> Result<bool> {
477 let _lock = lock(central)?;
478 let Some(path) = current_card(central, project) else { return Ok(false) };
479 store::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
480 Ok(true)
481}
482
483pub fn info(dir: &Path, central: Option<&Path>) -> Option<ProjectInfo> {
486 let RepoCard { project: name, notes, .. } = read_repo_card(dir)?;
487 let (name, description, siblings) = match central {
488 Some(central) => {
489 let repos = load(central).unwrap_or_default();
490 let name = repos.iter().find(|r| same_dir(&r.dir, dir)).map_or(name, |r| r.project.clone());
493 let siblings = repos
494 .into_iter()
495 .filter(|r| r.project == name && !same_dir(&r.dir, dir))
496 .map(|r| (r.name(), r.dir))
497 .collect();
498 (name.clone(), read_description(central, &name), siblings)
499 }
500 None => (name, String::new(), Vec::new()),
501 };
502 let repo = Repo { project: name.clone(), dir: dir.to_path_buf() }.name();
503 Some(ProjectInfo { name, description, repo, notes, siblings })
504}
505
506pub fn same_dir(a: &Path, b: &Path) -> bool {
509 a == b || fs::canonicalize(a).is_ok_and(|a| fs::canonicalize(b).ok() == Some(a))
510}
511
512pub fn canonical(path: &Path) -> std::io::Result<PathBuf> {
516 fs::canonicalize(path).map(|path| simplified(&path))
517}
518
519pub fn simplified(path: &Path) -> PathBuf {
524 path.to_str().and_then(without_verbatim_prefix).map_or_else(|| path.to_path_buf(), PathBuf::from)
525}
526
527fn without_verbatim_prefix(path: &str) -> Option<String> {
533 const MAX_PATH: usize = 260;
534 let (plain, names, least) = if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
535 (r"\\", rest, 2)
536 } else {
537 let rest = path.strip_prefix(r"\\?\")?;
538 let (drive, names) = (rest.get(..3)?, rest.get(3..)?);
539 let [letter, b':', b'\\'] = drive.as_bytes() else { return None };
540 if !letter.is_ascii_alphabetic() {
541 return None;
542 }
543 (drive, names, 0)
544 };
545 let plain_names = names.is_empty() || names.split('\\').all(plain_name);
546 let enough = names.split('\\').filter(|name| !name.is_empty()).count() >= least;
547 (plain_names && enough && path.encode_utf16().count() <= MAX_PATH).then(|| format!("{plain}{names}"))
548}
549
550fn plain_name(name: &str) -> bool {
554 let forbidden = |c: char| matches!(c, '\0'..='\x1f' | '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*');
555 let base = name.split('.').next().unwrap_or(name).trim_end_matches(' ').to_ascii_uppercase();
556 let numbered = ["COM", "LPT"].iter().filter_map(|port| base.strip_prefix(port)).any(|n| {
557 let mut digits = n.chars();
558 digits.next().is_some_and(|c| c.is_ascii_digit() || matches!(c, '¹' | '²' | '³')) && digits.next().is_none()
559 });
560 let device = numbered || ["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"].contains(&base.as_str());
561 !name.is_empty() && !name.contains(forbidden) && !name.ends_with([' ', '.']) && !device
562}
563
564#[cfg(test)]
565#[path = "../tests/projects.rs"]
566mod tests;