1use 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
25macro_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
38pub const REGISTRY_FILE: &str = "projects.md";
40pub const PROJECT_FILE: &str = "project.md";
42const CARDS_DIR: &str = "projects";
44pub const CENTRAL: &str = "central";
46
47const 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.";
50const 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#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct Repo {
57 pub project: String,
59 pub dir: PathBuf,
61}
62
63impl Repo {
64 pub fn name(&self) -> String {
66 read_repo_card(&self.dir).and_then(|card| card.repo).unwrap_or_else(|| self.folder())
67 }
68
69 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 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#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct ProjectInfo {
91 pub name: String,
93 pub description: String,
95 pub repo: String,
97 pub notes: String,
99 pub siblings: Vec<(String, PathBuf)>,
101}
102
103pub 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
111pub 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
126pub 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
141pub 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 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
168pub 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
178pub 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
206pub 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
218fn 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(¢ral.join(REGISTRY_FILE), &text)
229}
230
231#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct RepoCard {
241 pub project: String,
243 pub repo: Option<String>,
246 pub notes: String,
248}
249
250pub 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 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
264pub 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
277pub 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
286fn card_path(central: &Path, project: &str) -> PathBuf {
288 central.join(CARDS_DIR).join(format!("{}.md", slugify(project)))
289}
290
291pub 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
302pub 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
307pub 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
316pub 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
339pub 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
349pub 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
369pub 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;