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 `context` and the hooks ask for it, in one wording so they can't drift
26/// apart; the agent section spells it out too, wrapped, and a test holds it to this. A literal for `concat!`. 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.
43pub(crate) const 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
51/// A registered repository of a project.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Repo {
54    /// The project it belongs to; repositories with the same project share its card.
55    pub project: String,
56    /// Its `.brain/` folder.
57    pub dir: PathBuf,
58}
59
60impl Repo {
61    /// The repository's name in its project: the one its card gives, else the folder holding its `.brain/`.
62    pub fn name(&self) -> String {
63        read_repo_card(&self.dir).and_then(|card| card.repo).unwrap_or_else(|| self.folder())
64    }
65
66    /// The name of the folder holding its `.brain/`, the repository's name unless its card gives another.
67    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    /// How it's named among `repos`: `shop` when it's the project's only repository, else
75    /// `shop/api`. This is what `--project` takes.
76    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/// A repository's view of its project: the shared card, and its own notes.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct ProjectInfo {
88    /// The project's name.
89    pub name: String,
90    /// The application's horizontal context (markdown), shared by its repositories.
91    pub description: String,
92    /// The repository's name.
93    pub repo: String,
94    /// This repository's role among the project's (markdown); empty with a single repository.
95    pub notes: String,
96    /// The project's other repositories, as `(name, .brain folder)`.
97    pub siblings: Vec<(String, PathBuf)>,
98}
99
100/// The central brain's folder: `$KANBRAIN_DIR`, else the platform data folder.
101pub 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
108/// The registered repositories, in the order they were registered. No registry means none yet.
109pub 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
123/// The repository `label` names (`shop`, or `shop/api` when the project has several), in any case when that
124/// names only one; the error lists the labels there are.
125pub 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    // Typed as it's said (`acme corp` for `Acme Corp`), when that names only one.
131    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    // A project of several repositories exists, but `--project` takes one of them.
141    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
148/// Registers the repository in `dir` as part of `project`, moving it if it was registered under
149/// another project. Two live repositories with the same name in one project are refused, since
150/// `--project` and the app's filter tell them apart by name; a registration whose folder is gone
151/// gives way.
152pub 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    // By folder, not path: `init` registers the canonical one, which another registration's may not be (`/var`
158    // is `/private/var` on macOS, Windows prefixes `\\?\`), and a move must replace it, not add a second.
159    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
176/// Takes the repository whose `.brain/` is `dir` out of the registry, and returns it; `None` if it
177/// wasn't registered. Its folder may be gone: a deleted repository can still be taken out.
178pub 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
187/// The name the repository whose `.brain/` is `dir` takes in `project`: `wanted` if given, refused when another
188/// of the project's repositories has it; else the one its card gives if it stays in that project, else its
189/// folder's. When another repository has that one (two folders named `app` in different places), the folder
190/// above goes before it (`backend-app`), then a number: two repositories of a project can't share a name, as
191/// `--project` and links (`app#3`) tell them apart by it.
192pub fn repo_name(central: &Path, project: &str, dir: &Path, wanted: Option<&str>) -> Result<String> {
193    // One whose folder is gone keeps its name while it's registered, for its folder may come back (an unmounted
194    // disk): taken, two repositories would answer to it.
195    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    // A folder named `My App` makes a name links can hold (`My-App#3`), kept on the card as it isn't the folder's;
214    // so does a name a card kept from before 0.4.0 that links can't hold (`My Work-app`, `-app`).
215    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
224/// Names are written as `- name: path` lines and typed after `--project`, where `/` separates the
225/// repository, so they're one line without `:` or `/`, and not the central brain's name.
226pub fn check_name(name: &str) -> Result<()> {
227    // Nor start with `-`: typed after `project rename` or `move`, it's an option mistyped, not a name.
228    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    // In any case: `--project Central` reaches the central brain, and a project `Central` would sit beside it.
235    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
241/// The central brain's write lock, held from reading the registry to writing it back: two `kb init`s at once
242/// would otherwise each register their repository over the other's.
243fn lock(central: &Path) -> Result<store::BrainLock> {
244    Store::at(central)?.lock()
245}
246
247/// Writes the registry.
248fn 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(&central.join(REGISTRY_FILE), &text)
258}
259
260/// What a repository's `.brain/project.md` says about it, travelling with its code (and its git):
261///
262/// ```markdown
263/// # shop
264/// - repo: web
265///
266/// The store: UI only.
267/// ```
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub struct RepoCard {
270    /// The project it belongs to: the `#` title.
271    pub project: String,
272    /// Its name in the project, when it isn't its folder's: the `- repo:` line right under the title. In the
273    /// card rather than the central registry, so links to it (`web#3`) read the same on every machine.
274    pub repo: Option<String>,
275    /// Its role among the project's repositories (markdown).
276    pub notes: String,
277}
278
279/// A `.brain/` folder's card; `None` if it has none (not a project's repository).
280pub 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    // Only right under the title: notes start after a blank line, so a note's own `- repo:` stays a note.
286    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
293/// Writes a `.brain/` folder's card.
294pub 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
306/// Repository names are typed after `--project NAME/` and written in links as `REPO#ID`, which a space, `,` or `#`
307/// would split, so they're one word without those or `:` and `/`.
308pub 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
317/// A character a repository name can't hold (see [`check_repo_name`]).
318fn not_in_repo_name(c: char) -> bool {
319    c.is_whitespace() || matches!(c, ',' | '#' | ':' | '/')
320}
321
322/// A folder's name made a repository name: the characters one can't hold become `-` (`My App` is `My-App`).
323pub(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
329/// The folder of the projects' shared cards in the central brain.
330pub fn cards_dir(central: &Path) -> PathBuf {
331    central.join(CARDS_DIR)
332}
333
334/// Where a project's shared card is in the central brain: `projects/shop.md` for a name that is its own slug, as
335/// every card was before 0.4.0. Any other name (`My_App`, `Shop`, `日記`) adds a hash of itself, since names that
336/// slug alike (`my_app` and `my-app`; every name in another script slugs to `task`) shared one card, each
337/// overwriting the other's description.
338fn 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
345/// The project a card is titled with (`# shop`), which is whose it is.
346fn 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
351/// The card a project kept before 0.4.0 under its slug alone, when it has one there that is its own.
352fn 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
357/// The card holding the project's description: its own, else the one it kept before 0.4.0. One titled with
358/// another project, which a name slugging alike overwrote, isn't it.
359fn 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
364/// The project's shared description; empty if it has no card, or only the placeholder `init` wrote,
365/// so nobody reads the placeholder as a description (an agent pays for it in every `context`).
366pub 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
372/// What a card says under its `# name` title, trimmed.
373fn 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
377/// Writes the project's shared card. Another project's card where it goes, one whose name slugs alike from before
378/// 0.4.0, moves to its own place first rather than be overwritten; the project's own card from then goes.
379pub fn write_description(central: &Path, project: &str, description: &str) -> Result<()> {
380    // Under the lock, as the registry: whose card is where is read, then written.
381    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
397/// Creates the project's shared card with a placeholder if it has none yet; returns whether it did.
398pub 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
407/// Renames project `old` to `new` in the registry and its shared card (the file and its title). Its
408/// repositories' own cards are the caller's: their folders may be elsewhere, or gone.
409pub 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    // The old card already where the new one goes (`Shop`'s from before 0.4.0, renamed `shop`): rewritten there,
420    // not moved aside as another project's would be.
421    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
429/// Deletes the project's shared card; returns whether there was one. Only its own: one a name slugging alike
430/// wrote over it before 0.4.0 is that project's.
431pub 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
438/// What the repository in `dir` knows of its project: `None` outside a project. Without `central`,
439/// only what its own card says.
440pub 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            // The registry's name over the card's: a rename while this folder was away (an unmounted disk) left
446            // the card on the old one, which `kb init --all` then fixes.
447            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
461/// Whether two paths are the same folder, however they're written: a registered path may go through a
462/// symlink (`/var` is `/private/var` on macOS) or be typed by hand, and still name the folder opened.
463pub 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;