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::{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
26/// What a project's description holds, as `context` and the hooks ask for it, in one wording so they can't drift
27/// apart; the agent section spells it out too, wrapped, and a test holds it to this. A literal for `concat!`. The
28/// bounds are spelled out because agents asked for "a few lines" still added tables, columns and function
29/// signatures, details the code already holds and that go stale.
30macro_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
39/// In the central brain: every registered repository, `- project: path of its .brain`.
40pub const REGISTRY_FILE: &str = "projects.md";
41/// In a repository's `.brain/`: the project it belongs to (the `#` title) and notes about it.
42pub const PROJECT_FILE: &str = "project.md";
43/// In the central brain: the projects' cards, one `<name>.md` each.
44pub(crate) const CARDS_DIR: &str = "projects";
45/// What the central brain is called where projects are listed; no project can take the name.
46pub const CENTRAL: &str = "central";
47
48/// What a new project's card says until someone describes the project.
49const 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/// A registered repository of a project.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Repo {
55    /// The project it belongs to; repositories with the same project share its card.
56    pub project: String,
57    /// Its `.brain/` folder.
58    pub dir: PathBuf,
59}
60
61impl Repo {
62    /// The repository's name in its project: the one its card gives, else the folder holding its `.brain/`.
63    pub fn name(&self) -> String {
64        read_repo_card(&self.dir).and_then(|card| card.repo).unwrap_or_else(|| self.folder())
65    }
66
67    /// The name of the folder holding its `.brain/`, the repository's name unless its card gives another.
68    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    /// How it's named among `repos`: `shop` when it's the project's only repository, else
76    /// `shop/api`. This is what `--project` takes.
77    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/// A repository's view of its project: the shared card, and its own notes.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ProjectInfo {
89    /// The project's name.
90    pub name: String,
91    /// The application's horizontal context (markdown), shared by its repositories.
92    pub description: String,
93    /// The repository's name.
94    pub repo: String,
95    /// This repository's role among the project's (markdown); empty with a single repository.
96    pub notes: String,
97    /// The project's other repositories, as `(name, .brain folder)`.
98    pub siblings: Vec<(String, PathBuf)>,
99}
100
101/// The central brain's folder: `$KANBRAIN_DIR`, else the platform data folder. Errors when `$KANBRAIN_DIR` is a
102/// relative path, which would name another folder from each folder `kb` runs in.
103pub fn central_dir() -> Result<PathBuf> {
104    central_dir_from(std::env::var_os("KANBRAIN_DIR"), data_dir)
105}
106
107/// The platform's data folder.
108#[cfg(not(test))]
109fn data_dir() -> Option<PathBuf> {
110    dirs::data_dir()
111}
112
113/// In this crate's tests, a temporary folder instead: a test that loses its central brain (a mutant of how
114/// `KANBRAIN_DIR` is read) once wrote into the real one of whoever ran them.
115#[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
121/// [`central_dir`] for the value `KANBRAIN_DIR` has, `data_dir` giving the platform's data folder.
122fn central_dir_from(var: Option<OsString>, data_dir: impl FnOnce() -> Option<PathBuf>) -> Result<PathBuf> {
123    // Set but empty is unset (`KANBRAIN_DIR= kb …`): taken as is, it made whatever folder `kb` ran in the central
124    // brain, writing `format` there.
125    if let Some(dir) = var.filter(|v| !v.is_empty()).map(PathBuf::from) {
126        // Hooks run from each project's folder, so a relative one would split the central brain among them. On
127        // Windows `\todo` (rooted, no drive) and `D:todo` count as relative: each depends on the current folder.
128        if !dir.is_absolute() {
129            let what = dir.display();
130            // Only a shell expands `~`, and not inside quotes: what sets the variable without one passes it on as is.
131            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
144/// The registered repositories, in the order they were registered. No registry means none yet.
145pub 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        // Plain, as `init` registers it now: released versions registered Windows' verbatim form (`\\?\C:\…`).
156        .map(|(project, dir)| Repo { project: project.trim().to_string(), dir: simplified(Path::new(dir.trim())) })
157        .collect())
158}
159
160/// The repository `label` names (`shop`, or `shop/api` when the project has several), in any case when that
161/// names only one; the error lists the labels there are.
162pub 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    // Typed as it's said (`acme corp` for `Acme Corp`), when that names only one.
168    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    // A project of several repositories exists, but `--project` takes one of them.
178    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
185/// Registers the repository in `dir` as part of `project`, moving it if it was registered under
186/// another project. Two live repositories with the same name in one project are refused, since
187/// `--project` and the app's filter tell them apart by name; a registration whose folder is gone
188/// gives way.
189pub 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    // By folder, not path: `init` registers the canonical one, which another registration's may not be (`/var`
195    // is `/private/var` on macOS, Windows prefixes `\\?\`), and a move must replace it, not add a second.
196    if repos.iter().any(|r| r.project == project && same_dir(&r.dir, dir)) {
197        // As it was, unless a released version wrote it with Windows' verbatim prefix, which `load` read plain.
198        let text = store::read_text(&central.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
215/// Takes the repository whose `.brain/` is `dir` out of the registry, and returns it; `None` if it
216/// wasn't registered. Its folder may be gone: a deleted repository can still be taken out.
217pub 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
226/// The name the repository whose `.brain/` is `dir` takes in `project`: `wanted` if given, refused when another
227/// of the project's repositories has it; else the one its card gives if it stays in that project, else its
228/// folder's. When another repository has that one (two folders named `app` in different places), the folder
229/// above goes before it (`backend-app`), then a number: two repositories of a project can't share a name, as
230/// `--project` and links (`app#3`) tell them apart by it.
231pub fn repo_name(central: &Path, project: &str, dir: &Path, wanted: Option<&str>) -> Result<String> {
232    // One whose folder is gone keeps its name while it's registered, for its folder may come back (an unmounted
233    // disk): taken, two repositories would answer to it.
234    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    // 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;
253    // so does a name a card kept from before 0.4.0 that links can't hold (`My Work-app`, `-app`).
254    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
263/// Names are written as `- name: path` lines and typed after `--project`, where `/` separates the
264/// repository, so they're one line without `:` or `/`, and not the central brain's name.
265pub fn check_name(name: &str) -> Result<()> {
266    // Nor start with `-`: typed after `project rename` or `move`, it's an option mistyped, not a name.
267    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    // In any case: `--project Central` reaches the central brain, and a project `Central` would sit beside it.
274    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
280/// The central brain's write lock, held from reading the registry to writing it back: two `kb init`s at once
281/// would otherwise each register their repository over the other's.
282fn lock(central: &Path) -> Result<store::BrainLock> {
283    Store::at(central)?.lock()
284}
285
286/// Writes the registry.
287fn save(central: &Path, repos: &[Repo]) -> Result<()> {
288    Store::at(central)?;
289    store::write_file(&central.join(REGISTRY_FILE), &render(repos))
290}
291
292/// The registry's text, as [`save`] writes it.
293fn 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/// What a repository's `.brain/project.md` says about it, travelling with its code (and its git):
305///
306/// ```markdown
307/// # shop
308/// - repo: web
309///
310/// The store: UI only.
311/// ```
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct RepoCard {
314    /// The project it belongs to: the `#` title.
315    pub project: String,
316    /// Its name in the project, when it isn't its folder's: the `- repo:` line right under the title. In the
317    /// card rather than the central registry, so links to it (`web#3`) read the same on every machine.
318    pub repo: Option<String>,
319    /// Its role among the project's repositories (markdown).
320    pub notes: String,
321}
322
323/// A `.brain/` folder's card; `None` if it has none (not a project's repository).
324pub 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    // Only right under the title: notes start after a blank line, so a note's own `- repo:` stays a note.
330    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
337/// Writes a `.brain/` folder's card.
338pub 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
350/// Repository names are typed after `--project NAME/` and written in links as `REPO#ID`, which a space, `,` or `#`
351/// would split, so they're one word without those or `:` and `/`.
352pub 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
361/// A character a repository name can't hold (see [`check_repo_name`]).
362fn not_in_repo_name(c: char) -> bool {
363    c.is_whitespace() || matches!(c, ',' | '#' | ':' | '/')
364}
365
366/// A folder's name made a repository name: the characters one can't hold become `-` (`My App` is `My-App`).
367pub(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
373/// The folder of the projects' shared cards in the central brain.
374pub fn cards_dir(central: &Path) -> PathBuf {
375    central.join(CARDS_DIR)
376}
377
378/// Where a project's shared card is in the central brain: `projects/shop.md` for a name that is its own slug, as
379/// every card was before 0.4.0. Any other name (`My_App`, `Shop`, `日記`) adds a hash of itself, since names that
380/// slug alike (`my_app` and `my-app`; every name in another script slugs to `task`) shared one card, each
381/// overwriting the other's description.
382fn 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
389/// The project a card is titled with (`# shop`), which is whose it is.
390fn 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
395/// The card a project kept before 0.4.0 under its slug alone, when it has one there that is its own.
396fn 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
401/// The card holding the project's description: its own, else the one it kept before 0.4.0. One titled with
402/// another project, which a name slugging alike overwrote, isn't it.
403fn 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
408/// The project's shared description; empty if it has no card, or only the placeholder `init` wrote,
409/// so nobody reads the placeholder as a description (an agent pays for it in every `context`).
410pub 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
416/// What a card says under its `# name` title, trimmed.
417fn 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
421/// Writes the project's shared card. Another project's card where it goes, one whose name slugs alike from before
422/// 0.4.0, moves to its own place first rather than be overwritten; the project's own card from then goes.
423pub fn write_description(central: &Path, project: &str, description: &str) -> Result<()> {
424    // Under the lock, as the registry: whose card is where is read, then written.
425    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
442/// Creates the project's shared card with a placeholder if it has none yet; returns whether it did.
443pub 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
452/// Renames project `old` to `new` in the registry and its shared card (the file and its title). Its
453/// repositories' own cards are the caller's: their folders may be elsewhere, or gone.
454pub 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    // The old card already where the new one goes (`Shop`'s from before 0.4.0, renamed `shop`): rewritten there,
465    // not moved aside as another project's would be.
466    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
474/// Deletes the project's shared card; returns whether there was one. Only its own: one a name slugging alike
475/// wrote over it before 0.4.0 is that project's.
476pub 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
483/// What the repository in `dir` knows of its project: `None` outside a project. Without `central`,
484/// only what its own card says.
485pub 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            // The registry's name over the card's: a rename while this folder was away (an unmounted disk) left
491            // the card on the old one, which `kb init --all` then fixes.
492            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
506/// Whether two paths are the same folder, however they're written: a registered path may go through a
507/// symlink (`/var` is `/private/var` on macOS) or be typed by hand, and still name the folder opened.
508pub 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
512/// `path`'s canonical form, as `init` registers it and the app opens it: [`fs::canonicalize`], then
513/// [`simplified`], so a repository reads `C:\code\api` in `projects.md`, `kb`'s output and the app rather than
514/// `\\?\C:\code\api`, and matches the same path typed or given without the prefix.
515pub fn canonical(path: &Path) -> std::io::Result<PathBuf> {
516    fs::canonicalize(path).map(|path| simplified(&path))
517}
518
519/// `path` without Windows' verbatim prefix, which canonicalizing a path there adds (`\\?\C:\…` as `C:\…`,
520/// `\\?\UNC\server\share\…` as `\\server\share\…`), when the plain path names the same folder; else, or when
521/// it isn't verbatim, `path` as it is. Pure, so it's the same on every system: elsewhere no path starts with
522/// `\\?\` but one read from a registry Windows wrote.
523pub 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
527/// `path` without its verbatim prefix where Windows reads the plain one alike, as the `dunce` crate decides it
528/// (drive paths only there; a share's here, by the same rules): Windows takes a verbatim path's names literally,
529/// but trims a plain one's trailing dots and spaces, reads `con`, `nul`, `lpt1.txt`… as devices, and stops at
530/// `MAX_PATH` in programs that aren't long-path aware (an editor given the path). Any of those, or anything it
531/// doesn't recognize (`\\?\Volume{…}`, `..`, a doubled `\\`), keeps the prefix.
532fn 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
550/// Whether Windows reads `name` the same in a plain path as in a verbatim one, as `dunce` checks it, with Windows'
551/// own device rules: a name is a device by what's before its first dot (`con.tar.gz` is `CON`), trimmed of spaces,
552/// and `COM¹`, `LPT³`, `CONIN$` are devices too.
553fn 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;