use crate::model::slugify;
use crate::store::{self, Store};
use anyhow::{Context, Result, bail};
use std::fmt::Write;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
macro_rules! description_scope {
() => {
"what the app is, its services and how they connect, conventions they share: the big picture, in at most \
five lines, without tables, columns, function signatures, files or dependencies, which the code \
already says"
};
}
pub(crate) use description_scope;
pub const REGISTRY_FILE: &str = "projects.md";
pub const PROJECT_FILE: &str = "project.md";
const CARDS_DIR: &str = "projects";
pub const CENTRAL: &str = "central";
const DESCRIPTION_TEMPLATE: &str = "What the application is, its services and how they connect, and the \
conventions they share: what an agent should know before touching any of its repositories.";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repo {
pub project: String,
pub dir: PathBuf,
}
impl Repo {
pub fn name(&self) -> String {
read_repo_card(&self.dir).and_then(|card| card.repo).unwrap_or_else(|| self.folder())
}
pub fn folder(&self) -> String {
self.dir
.parent()
.and_then(Path::file_name)
.map_or_else(|| self.project.clone(), |n| n.to_string_lossy().into_owned())
}
pub fn label(&self, repos: &[Repo]) -> String {
if repos.iter().filter(|r| r.project == self.project).count() > 1 {
format!("{}/{}", self.project, self.name())
} else {
self.project.clone()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectInfo {
pub name: String,
pub description: String,
pub repo: String,
pub notes: String,
pub siblings: Vec<(String, PathBuf)>,
}
pub fn central_dir() -> Result<PathBuf> {
if let Some(dir) = std::env::var_os("KANBRAIN_DIR") {
return Ok(dir.into());
}
Ok(dirs::data_dir().context("could not determine the data directory; set KANBRAIN_DIR")?.join("kanbrain"))
}
pub fn load(central: &Path) -> Result<Vec<Repo>> {
let path = central.join(REGISTRY_FILE);
let text = match store::read_text(&path) {
Ok(text) => text,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
};
Ok(text
.lines()
.filter_map(|line| line.strip_prefix("- ")?.split_once(": "))
.map(|(project, dir)| Repo { project: project.trim().to_string(), dir: PathBuf::from(dir.trim()) })
.collect())
}
pub fn find(central: &Path, label: &str) -> Result<Repo> {
let repos = load(central)?;
if let Some(repo) = repos.iter().find(|r| r.label(&repos) == label) {
return Ok(repo.clone());
}
let lower = label.to_lowercase();
let mut alike = repos.iter().filter(|r| r.label(&repos).to_lowercase() == lower);
if let (Some(repo), None) = (alike.next(), alike.next()) {
return Ok(repo.clone());
}
if repos.is_empty() {
bail!("no project `{label}`: none is registered yet (see `kb init`)");
}
let labels: Vec<String> = repos.iter().map(|r| r.label(&repos)).collect();
let several: Vec<String> = repos.iter().filter(|r| r.project == label).map(|r| r.label(&repos)).collect();
if !several.is_empty() {
bail!("project `{label}` has several repositories: name one, {}", several.join(", "));
}
bail!("no project `{label}`; there are {}, and `{CENTRAL}`", labels.join(", "))
}
pub fn register(central: &Path, project: &str, dir: &Path) -> Result<()> {
check_name(project)?;
let _lock = lock(central)?;
let mut repos = load(central)?;
let new = Repo { project: project.to_string(), dir: dir.to_path_buf() };
if repos.iter().any(|r| r.project == project && same_dir(&r.dir, dir)) {
return Ok(());
}
repos.retain(|r| !same_dir(&r.dir, dir));
if let Some(twin) = repos.iter().find(|r| r.project == project && r.name() == new.name() && r.dir.is_dir()) {
bail!(
"project `{project}` already has a repository named `{}`, at {}; name this one with `kb init --repo NAME`, \
or rename that one with `kb project repo NAME`",
new.name(),
twin.dir.display()
);
}
repos.retain(|r| !(r.project == project && r.name() == new.name()));
repos.push(new);
save(central, &repos)
}
pub fn unregister(central: &Path, dir: &Path) -> Result<Option<Repo>> {
let _lock = lock(central)?;
let mut repos = load(central)?;
let Some(at) = repos.iter().position(|r| same_dir(&r.dir, dir)) else { return Ok(None) };
let repo = repos.remove(at);
save(central, &repos)?;
Ok(Some(repo))
}
pub fn repo_name(central: &Path, project: &str, dir: &Path, wanted: Option<&str>) -> Result<String> {
let others: Vec<Repo> =
load(central)?.into_iter().filter(|r| r.project == project && !same_dir(&r.dir, dir)).collect();
let taken: Vec<String> = others.iter().map(Repo::name).collect();
if let Some(wanted) = wanted {
check_repo_name(wanted)?;
if others.iter().any(|r| r.dir.is_dir() && r.name() == wanted) {
bail!("project `{project}` already has a repository named `{wanted}`; pick another");
}
if taken.iter().any(|t| t == wanted) {
bail!(
"project `{project}` has a repository named `{wanted}` whose folder is gone: take it out with \
`kb --project {project}/{wanted} deinit`, or pick another name"
);
}
return Ok(wanted.to_string());
}
let here = Repo { project: project.to_string(), dir: dir.to_path_buf() };
let card = read_repo_card(dir).filter(|card| card.project == project).and_then(|card| card.repo);
let own = card.unwrap_or_else(|| here.folder());
let own = if check_repo_name(&own).is_ok() { own } else { repo_name_of_folder(&own) };
let above = dir.parent().and_then(Path::parent).and_then(Path::file_name).map(|n| n.to_string_lossy());
let candidates = std::iter::once(own.clone())
.chain(above.map(|above| format!("{}-{own}", repo_name_of_folder(&above))))
.chain((2..).map(|n| format!("{own}-{n}")));
Ok(candidates.into_iter().find(|name| !taken.contains(name)).expect("the numbered names never run out"))
}
pub fn check_name(name: &str) -> Result<()> {
if name.trim().is_empty() || name.contains([':', '/', '\n']) || name.trim() != name || name.starts_with('-') {
bail!(
"`{name}` can't be a project name: it needs to be one line, without `:`, `/`, surrounding spaces or a \
leading `-`"
);
}
if name.eq_ignore_ascii_case(CENTRAL) {
bail!("`{CENTRAL}` is the name of the central brain; pick another: `kb init NAME`");
}
Ok(())
}
fn lock(central: &Path) -> Result<store::BrainLock> {
Store::at(central)?.lock()
}
fn save(central: &Path, repos: &[Repo]) -> Result<()> {
Store::at(central)?;
let mut text = String::from(
"# Projects\n\nRepositories with their own `.brain/`, registered by `kb init`; several lines \
with the same name are one project's repositories. The kanban app shows them all.\n\n",
);
for r in repos {
writeln!(text, "- {}: {}", r.project, r.dir.display()).expect("writing to a String cannot fail");
}
store::write_file(¢ral.join(REGISTRY_FILE), &text)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoCard {
pub project: String,
pub repo: Option<String>,
pub notes: String,
}
pub fn read_repo_card(dir: &Path) -> Option<RepoCard> {
let text = store::read_text(&dir.join(PROJECT_FILE)).ok()?;
let mut lines = text.lines();
let project = lines.by_ref().find_map(|l| l.strip_prefix("# "))?.trim().to_string();
let rest: Vec<&str> = lines.collect();
let (repo, notes) = match rest.first().and_then(|l| l.strip_prefix("- repo:")) {
Some(name) => (Some(name.trim().to_string()).filter(|n| !n.is_empty()), &rest[1..]),
None => (None, &rest[..]),
};
Some(RepoCard { project, repo, notes: notes.join("\n").trim().to_string() })
}
pub fn write_repo_card(dir: &Path, card: &RepoCard) -> Result<()> {
let mut text = format!("# {}\n", card.project);
if let Some(repo) = &card.repo {
writeln!(text, "- repo: {repo}").expect("writing to a String cannot fail");
}
let notes = card.notes.trim();
if !notes.is_empty() {
write!(text, "\n{notes}\n").expect("writing to a String cannot fail");
}
store::write_file(&dir.join(PROJECT_FILE), &text)
}
pub fn check_repo_name(name: &str) -> Result<()> {
if name.is_empty() || name.contains(not_in_repo_name) || name.starts_with('-') {
bail!(
"`{name}` can't be a repository name: it needs to be one word, without `,`, `#`, `:`, `/` or a leading `-`"
);
}
Ok(())
}
fn not_in_repo_name(c: char) -> bool {
c.is_whitespace() || matches!(c, ',' | '#' | ':' | '/')
}
pub(crate) fn repo_name_of_folder(folder: &str) -> String {
let name: String = folder.chars().map(|c| if not_in_repo_name(c) { '-' } else { c }).collect();
let name = name.trim_matches('-');
if name.is_empty() { "repo".to_string() } else { name.to_string() }
}
pub fn cards_dir(central: &Path) -> PathBuf {
central.join(CARDS_DIR)
}
fn card_path(central: &Path, project: &str) -> PathBuf {
let slug = slugify(project);
let name =
if slug == project { slug } else { format!("{slug}-{:08x}", store::fnv1a(project.as_bytes()) & 0xffff_ffff) };
cards_dir(central).join(format!("{name}.md"))
}
fn card_owner(path: &Path) -> Option<String> {
let text = store::read_text(path).ok()?;
text.lines().find_map(|l| l.strip_prefix("# ")).map(|name| name.trim().to_string())
}
fn legacy_card(central: &Path, project: &str) -> Option<PathBuf> {
let path = cards_dir(central).join(format!("{}.md", slugify(project)));
(path != card_path(central, project) && card_owner(&path).as_deref() == Some(project)).then_some(path)
}
fn current_card(central: &Path, project: &str) -> Option<PathBuf> {
let path = card_path(central, project);
if card_owner(&path).as_deref() == Some(project) { Some(path) } else { legacy_card(central, project) }
}
pub fn read_description(central: &Path, project: &str) -> String {
let text = current_card(central, project).and_then(|path| store::read_text(&path).ok()).unwrap_or_default();
let body = card_body(&text);
if body == DESCRIPTION_TEMPLATE { String::new() } else { body }
}
fn card_body(text: &str) -> String {
text.lines().skip_while(|l| !l.starts_with("# ")).skip(1).collect::<Vec<_>>().join("\n").trim().to_string()
}
pub fn write_description(central: &Path, project: &str, description: &str) -> Result<()> {
let _lock = lock(central)?;
let path = card_path(central, project);
if let Some(owner) = card_owner(&path).filter(|owner| owner != project) {
let theirs = card_path(central, &owner);
if theirs != path && !theirs.exists() {
fs::rename(&path, &theirs).with_context(|| format!("moving {} to {}", path.display(), theirs.display()))?;
}
}
let legacy = legacy_card(central, project);
store::write_file(&path, &format!("# {project}\n\n{}\n", description.trim()))?;
if let Some(legacy) = legacy {
fs::remove_file(&legacy).with_context(|| format!("removing {}", legacy.display()))?;
}
Ok(())
}
pub fn ensure_description(central: &Path, project: &str) -> Result<bool> {
let _lock = lock(central)?;
if current_card(central, project).is_some() {
return Ok(false);
}
write_description(central, project, DESCRIPTION_TEMPLATE)?;
Ok(true)
}
pub fn rename(central: &Path, old: &str, new: &str) -> Result<()> {
let _lock = lock(central)?;
let mut repos = load(central)?;
for repo in repos.iter_mut().filter(|r| r.project == old) {
new.clone_into(&mut repo.project);
}
save(central, &repos)?;
let Some(from) = current_card(central, old) else { return Ok(()) };
let text = store::read_text(&from).with_context(|| format!("reading {}", from.display()))?;
let body = card_body(&text);
if card_path(central, new) == from {
return store::write_file(&from, &format!("# {new}\n\n{}\n", body.trim()));
}
write_description(central, new, &body)?;
fs::remove_file(&from).with_context(|| format!("removing {}", from.display()))?;
Ok(())
}
pub fn remove_description(central: &Path, project: &str) -> Result<bool> {
let _lock = lock(central)?;
let Some(path) = current_card(central, project) else { return Ok(false) };
fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
Ok(true)
}
pub fn info(dir: &Path, central: Option<&Path>) -> Option<ProjectInfo> {
let RepoCard { project: name, notes, .. } = read_repo_card(dir)?;
let (name, description, siblings) = match central {
Some(central) => {
let repos = load(central).unwrap_or_default();
let name = repos.iter().find(|r| same_dir(&r.dir, dir)).map_or(name, |r| r.project.clone());
let siblings = repos
.into_iter()
.filter(|r| r.project == name && !same_dir(&r.dir, dir))
.map(|r| (r.name(), r.dir))
.collect();
(name.clone(), read_description(central, &name), siblings)
}
None => (name, String::new(), Vec::new()),
};
let repo = Repo { project: name.clone(), dir: dir.to_path_buf() }.name();
Some(ProjectInfo { name, description, repo, notes, siblings })
}
pub fn same_dir(a: &Path, b: &Path) -> bool {
a == b || fs::canonicalize(a).is_ok_and(|a| fs::canonicalize(b).ok() == Some(a))
}
#[cfg(test)]
#[path = "../tests/projects.rs"]
mod tests;