Skip to main content

kanbrain_core/
projects.rs

1//! Projects: work that keeps its tasks next to its code, in one or more repositories. Each repository
2//! has its own `.brain/` (its own tasks, numbered from 1) and belongs to a project; several services
3//! of one project are several repositories with the same project name. `init` registers each in
4//! the central brain's `projects.md`, so the kanban app can show every project and filter by one,
5//! or by one of its repositories.
6//!
7//! What every repository of a project shares is its card, `projects/<name>.md` in the central brain:
8//! the application's horizontal context (what it is, its services and how they connect, conventions
9//! they share). A repository adds its role among them (what it owns, exposes and consumes) in its
10//! `.brain/project.md`, whose title also says which project it belongs to. How to work on one
11//! repository's code (build, test, style) is its `CLAUDE.md` / `AGENTS.md`'s, which agents already
12//! read, so none of it is repeated here.
13//!
14//! Entries outside any project live in the central brain: `$KANBRAIN_DIR`, else the platform
15//! data folder.
16
17use 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
25/// What a project's description holds, as the agent section, `context` and the hooks ask for it, in one
26/// wording so they can't drift apart. A literal for `concat!`, since the agent section is a `const`. The
27/// bounds are spelled out because agents asked for "a few lines" still added tables, columns and function
28/// signatures, details the code already holds and that go stale.
29macro_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
38/// In the central brain: every registered repository, `- project: path of its .brain`.
39pub const REGISTRY_FILE: &str = "projects.md";
40/// In a repository's `.brain/`: the project it belongs to (the `#` title) and notes about it.
41pub const PROJECT_FILE: &str = "project.md";
42/// In the central brain: the projects' cards, one `<name>.md` each.
43const CARDS_DIR: &str = "projects";
44/// What the central brain is called where projects are listed; no project can take the name.
45pub const CENTRAL: &str = "central";
46
47/// What a new project's card says until someone describes the project.
48const 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/// The placeholder of cards made by earlier versions, still read as no description.
51const OLD_DESCRIPTION_TEMPLATE: &str = "What this project is for, its stack and its conventions: \
52what an agent should know before touching any of its repositories.";
53
54/// A registered repository of a project.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Repo {
57    /// The project it belongs to; repositories with the same project share its card.
58    pub project: String,
59    /// Its `.brain/` folder.
60    pub dir: PathBuf,
61}
62
63impl Repo {
64    /// The repository's name in its project: the one its card gives, else the folder holding its `.brain/`.
65    pub fn name(&self) -> String {
66        read_repo_card(&self.dir).and_then(|card| card.repo).unwrap_or_else(|| self.folder())
67    }
68
69    /// The name of the folder holding its `.brain/`, the repository's name unless its card gives another.
70    pub fn folder(&self) -> String {
71        self.dir
72            .parent()
73            .and_then(Path::file_name)
74            .map_or_else(|| self.project.clone(), |n| n.to_string_lossy().into_owned())
75    }
76
77    /// How it's named among `repos`: `shop` when it's the project's only repository, else
78    /// `shop/api`. This is what `--project` takes.
79    pub fn label(&self, repos: &[Repo]) -> String {
80        if repos.iter().filter(|r| r.project == self.project).count() > 1 {
81            format!("{}/{}", self.project, self.name())
82        } else {
83            self.project.clone()
84        }
85    }
86}
87
88/// A repository's view of its project: the shared card, and its own notes.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct ProjectInfo {
91    /// The project's name.
92    pub name: String,
93    /// The application's horizontal context (markdown), shared by its repositories.
94    pub description: String,
95    /// The repository's name.
96    pub repo: String,
97    /// This repository's role among the project's (markdown); empty with a single repository.
98    pub notes: String,
99    /// The project's other repositories, as `(name, .brain folder)`.
100    pub siblings: Vec<(String, PathBuf)>,
101}
102
103/// The central brain's folder: `$KANBRAIN_DIR`, else the platform data folder.
104pub fn central_dir() -> Result<PathBuf> {
105    if let Some(dir) = std::env::var_os("KANBRAIN_DIR") {
106        return Ok(dir.into());
107    }
108    Ok(dirs::data_dir().context("could not determine the data directory; set KANBRAIN_DIR")?.join("kanbrain"))
109}
110
111/// The registered repositories, in the order they were registered. No registry means none yet.
112pub fn load(central: &Path) -> Result<Vec<Repo>> {
113    let path = central.join(REGISTRY_FILE);
114    let text = match fs::read_to_string(&path) {
115        Ok(text) => text,
116        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
117        Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
118    };
119    Ok(text
120        .lines()
121        .filter_map(|line| line.strip_prefix("- ")?.split_once(": "))
122        .map(|(project, dir)| Repo { project: project.trim().to_string(), dir: PathBuf::from(dir.trim()) })
123        .collect())
124}
125
126/// The repository `label` names (`shop`, or `shop/api` when the project has several); the error
127/// lists the labels there are.
128pub fn find(central: &Path, label: &str) -> Result<Repo> {
129    let repos = load(central)?;
130    if let Some(repo) = repos.iter().find(|r| r.label(&repos) == label) {
131        return Ok(repo.clone());
132    }
133    if repos.is_empty() {
134        bail!("no project `{label}`: none is registered yet (see `kb init`)");
135    }
136    let labels: Vec<String> = repos.iter().map(|r| r.label(&repos)).collect();
137    let hint = if repos.iter().any(|r| r.project == label) { " (it has several repositories: name one)" } else { "" };
138    bail!("no project `{label}`{hint}; there are {}, and `{CENTRAL}`", labels.join(", "))
139}
140
141/// Registers the repository in `dir` as part of `project`, moving it if it was registered under
142/// another project. Two live repositories with the same name in one project are refused, since
143/// `--project` and the app's filter tell them apart by name; a registration whose folder is gone
144/// gives way.
145pub fn register(central: &Path, project: &str, dir: &Path) -> Result<()> {
146    check_name(project)?;
147    let mut repos = load(central)?;
148    let new = Repo { project: project.to_string(), dir: dir.to_path_buf() };
149    // By folder, not path: `init` registers the canonical one, which another registration's may not be (`/var`
150    // is `/private/var` on macOS, Windows prefixes `\\?\`), and a move must replace it, not add a second.
151    if repos.iter().any(|r| r.project == project && same_dir(&r.dir, dir)) {
152        return Ok(());
153    }
154    repos.retain(|r| !same_dir(&r.dir, dir));
155    if let Some(twin) = repos.iter().find(|r| r.project == project && r.name() == new.name() && r.dir.is_dir()) {
156        bail!(
157            "project `{project}` already has a repository named `{}`, at {}; name this one with `kb init --repo NAME`, \
158             or rename that one with `kb project repo NAME`",
159            new.name(),
160            twin.dir.display()
161        );
162    }
163    repos.retain(|r| !(r.project == project && r.name() == new.name()));
164    repos.push(new);
165    save(central, &repos)
166}
167
168/// Takes the repository whose `.brain/` is `dir` out of the registry, and returns it; `None` if it
169/// wasn't registered. Its folder may be gone: a deleted repository can still be taken out.
170pub fn unregister(central: &Path, dir: &Path) -> Result<Option<Repo>> {
171    let mut repos = load(central)?;
172    let Some(at) = repos.iter().position(|r| same_dir(&r.dir, dir)) else { return Ok(None) };
173    let repo = repos.remove(at);
174    save(central, &repos)?;
175    Ok(Some(repo))
176}
177
178/// The name the repository whose `.brain/` is `dir` takes in `project`: `wanted` if given, refused when another
179/// of the project's repositories has it; else the one its card gives if it stays in that project, else its
180/// folder's. When another repository has that one (two folders named `app` in different places), the folder
181/// above goes before it (`backend-app`), then a number: two repositories of a project can't share a name, as
182/// `--project` and links (`app#3`) tell them apart by it.
183pub fn repo_name(central: &Path, project: &str, dir: &Path, wanted: Option<&str>) -> Result<String> {
184    let taken: Vec<String> = load(central)?
185        .into_iter()
186        .filter(|r| r.project == project && !same_dir(&r.dir, dir) && r.dir.is_dir())
187        .map(|r| r.name())
188        .collect();
189    if let Some(wanted) = wanted {
190        check_repo_name(wanted)?;
191        if taken.iter().any(|t| t == wanted) {
192            bail!("project `{project}` already has a repository named `{wanted}`; pick another");
193        }
194        return Ok(wanted.to_string());
195    }
196    let here = Repo { project: project.to_string(), dir: dir.to_path_buf() };
197    let card = read_repo_card(dir).filter(|card| card.project == project).and_then(|card| card.repo);
198    let own = card.unwrap_or_else(|| here.folder());
199    let above = dir.parent().and_then(Path::parent).and_then(Path::file_name).map(|n| n.to_string_lossy());
200    let candidates = std::iter::once(own.clone())
201        .chain(above.map(|above| format!("{above}-{own}")))
202        .chain((2..).map(|n| format!("{own}-{n}")));
203    Ok(candidates.into_iter().find(|name| !taken.contains(name)).expect("the numbered names never run out"))
204}
205
206/// Names are written as `- name: path` lines and typed after `--project`, where `/` separates the
207/// repository, so they're one line without `:` or `/`, and not the central brain's name.
208pub fn check_name(name: &str) -> Result<()> {
209    if name.trim().is_empty() || name.contains([':', '/', '\n']) || name.trim() != name {
210        bail!("`{name}` can't be a project name: it needs to be one line, without `:`, `/` or surrounding spaces");
211    }
212    if name == CENTRAL {
213        bail!("`{CENTRAL}` is the name of the central brain; pick another: `kb init NAME`");
214    }
215    Ok(())
216}
217
218/// Writes the registry.
219fn save(central: &Path, repos: &[Repo]) -> Result<()> {
220    Store::at(central)?;
221    let mut text = String::from(
222        "# Projects\n\nRepositories with their own `.brain/`, registered by `kb init`; several lines \
223         with the same name are one project's repositories. The kanban app shows them all.\n\n",
224    );
225    for r in repos {
226        writeln!(text, "- {}: {}", r.project, r.dir.display()).expect("writing to a String cannot fail");
227    }
228    store::write_file(&central.join(REGISTRY_FILE), &text)
229}
230
231/// What a repository's `.brain/project.md` says about it, travelling with its code (and its git):
232///
233/// ```markdown
234/// # shop
235/// - repo: web
236///
237/// The store: UI only.
238/// ```
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct RepoCard {
241    /// The project it belongs to: the `#` title.
242    pub project: String,
243    /// Its name in the project, when it isn't its folder's: the `- repo:` line right under the title. In the
244    /// card rather than the central registry, so links to it (`web#3`) read the same on every machine.
245    pub repo: Option<String>,
246    /// Its role among the project's repositories (markdown).
247    pub notes: String,
248}
249
250/// A `.brain/` folder's card; `None` if it has none (not a project's repository).
251pub fn read_repo_card(dir: &Path) -> Option<RepoCard> {
252    let text = fs::read_to_string(dir.join(PROJECT_FILE)).ok()?;
253    let mut lines = text.lines();
254    let project = lines.by_ref().find_map(|l| l.strip_prefix("# "))?.trim().to_string();
255    let rest: Vec<&str> = lines.collect();
256    // Only right under the title: notes start after a blank line, so a note's own `- repo:` stays a note.
257    let (repo, notes) = match rest.first().and_then(|l| l.strip_prefix("- repo:")) {
258        Some(name) => (Some(name.trim().to_string()).filter(|n| !n.is_empty()), &rest[1..]),
259        None => (None, &rest[..]),
260    };
261    Some(RepoCard { project, repo, notes: notes.join("\n").trim().to_string() })
262}
263
264/// Writes a `.brain/` folder's card.
265pub fn write_repo_card(dir: &Path, card: &RepoCard) -> Result<()> {
266    let mut text = format!("# {}\n", card.project);
267    if let Some(repo) = &card.repo {
268        writeln!(text, "- repo: {repo}").expect("writing to a String cannot fail");
269    }
270    let notes = card.notes.trim();
271    if !notes.is_empty() {
272        write!(text, "\n{notes}\n").expect("writing to a String cannot fail");
273    }
274    store::write_file(&dir.join(PROJECT_FILE), &text)
275}
276
277/// Repository names are typed after `--project NAME/` and written in links as `REPO#ID`, which a space, `,` or `#`
278/// would split, so they're one word without those or `:` and `/`.
279pub fn check_repo_name(name: &str) -> Result<()> {
280    if name.is_empty() || name.contains(|c: char| c.is_whitespace() || matches!(c, ',' | '#' | ':' | '/')) {
281        bail!("`{name}` can't be a repository name: it needs to be one word, without `,`, `#`, `:` or `/`");
282    }
283    Ok(())
284}
285
286/// Where a project's shared card is in the central brain.
287fn card_path(central: &Path, project: &str) -> PathBuf {
288    central.join(CARDS_DIR).join(format!("{}.md", slugify(project)))
289}
290
291/// The project's shared description; empty if it has no card, or only the placeholder `init` wrote,
292/// so nobody reads the placeholder as a description (an agent pays for it in every `context`).
293pub fn read_description(central: &Path, project: &str) -> String {
294    let text = fs::read_to_string(card_path(central, project)).unwrap_or_default();
295    let body = text.lines().skip_while(|l| !l.starts_with("# ")).skip(1).collect::<Vec<_>>().join("\n");
296    match body.trim() {
297        DESCRIPTION_TEMPLATE | OLD_DESCRIPTION_TEMPLATE => String::new(),
298        described => described.to_string(),
299    }
300}
301
302/// Writes the project's shared card.
303pub fn write_description(central: &Path, project: &str, description: &str) -> Result<()> {
304    store::write_file(&card_path(central, project), &format!("# {project}\n\n{}\n", description.trim()))
305}
306
307/// Creates the project's shared card with a placeholder if it has none yet; returns whether it did.
308pub fn ensure_description(central: &Path, project: &str) -> Result<bool> {
309    if card_path(central, project).is_file() {
310        return Ok(false);
311    }
312    write_description(central, project, DESCRIPTION_TEMPLATE)?;
313    Ok(true)
314}
315
316/// Renames project `old` to `new` in the registry and its shared card (the file and its title). Its
317/// repositories' own cards are the caller's: their folders may be elsewhere, or gone.
318pub fn rename(central: &Path, old: &str, new: &str) -> Result<()> {
319    let mut repos = load(central)?;
320    for repo in repos.iter_mut().filter(|r| r.project == old) {
321        new.clone_into(&mut repo.project);
322    }
323    save(central, &repos)?;
324    let from = card_path(central, old);
325    match fs::read_to_string(&from) {
326        Ok(text) => {
327            let body = text.lines().skip_while(|l| !l.starts_with("# ")).skip(1).collect::<Vec<_>>().join("\n");
328            store::write_file(&card_path(central, new), &format!("# {new}\n\n{}\n", body.trim()))?;
329            if card_path(central, new) != from {
330                fs::remove_file(&from).with_context(|| format!("removing {}", from.display()))?;
331            }
332            Ok(())
333        }
334        Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
335        Err(e) => Err(e).with_context(|| format!("reading {}", from.display())),
336    }
337}
338
339/// Deletes the project's shared card; returns whether there was one.
340pub fn remove_description(central: &Path, project: &str) -> Result<bool> {
341    let path = card_path(central, project);
342    match fs::remove_file(&path) {
343        Ok(()) => Ok(true),
344        Err(e) if e.kind() == ErrorKind::NotFound => Ok(false),
345        Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
346    }
347}
348
349/// What the repository in `dir` knows of its project: `None` outside a project. Without `central`,
350/// only what its own card says.
351pub fn info(dir: &Path, central: Option<&Path>) -> Option<ProjectInfo> {
352    let RepoCard { project: name, notes, .. } = read_repo_card(dir)?;
353    let repo = Repo { project: name.clone(), dir: dir.to_path_buf() }.name();
354    let (description, siblings) = match central {
355        Some(central) => {
356            let siblings = load(central)
357                .unwrap_or_default()
358                .into_iter()
359                .filter(|r| r.project == name && !same_dir(&r.dir, dir))
360                .map(|r| (r.name(), r.dir))
361                .collect();
362            (read_description(central, &name), siblings)
363        }
364        None => (String::new(), Vec::new()),
365    };
366    Some(ProjectInfo { name, description, repo, notes, siblings })
367}
368
369/// Whether two paths are the same folder, however they're written: a registered path may go through a
370/// symlink (`/var` is `/private/var` on macOS) or be typed by hand, and still name the folder opened.
371pub fn same_dir(a: &Path, b: &Path) -> bool {
372    a == b || fs::canonicalize(a).is_ok_and(|a| fs::canonicalize(b).ok() == Some(a))
373}
374
375#[cfg(test)]
376#[path = "../tests/projects.rs"]
377mod tests;