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 self.dir
67 .parent()
68 .and_then(Path::file_name)
69 .map_or_else(|| self.project.clone(), |n| n.to_string_lossy().into_owned())
70 }
71
72 pub fn label(&self, repos: &[Repo]) -> String {
75 if repos.iter().filter(|r| r.project == self.project).count() > 1 {
76 format!("{}/{}", self.project, self.name())
77 } else {
78 self.project.clone()
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct ProjectInfo {
86 pub name: String,
88 pub description: String,
90 pub repo: String,
92 pub notes: String,
94 pub siblings: Vec<(String, PathBuf)>,
96}
97
98pub fn central_dir() -> Result<PathBuf> {
100 if let Some(dir) = std::env::var_os("KANBRAIN_DIR") {
101 return Ok(dir.into());
102 }
103 Ok(dirs::data_dir().context("could not determine the data directory; set KANBRAIN_DIR")?.join("kanbrain"))
104}
105
106pub fn load(central: &Path) -> Result<Vec<Repo>> {
108 let path = central.join(REGISTRY_FILE);
109 let text = match fs::read_to_string(&path) {
110 Ok(text) => text,
111 Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
112 Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
113 };
114 Ok(text
115 .lines()
116 .filter_map(|line| line.strip_prefix("- ")?.split_once(": "))
117 .map(|(project, dir)| Repo { project: project.trim().to_string(), dir: PathBuf::from(dir.trim()) })
118 .collect())
119}
120
121pub fn find(central: &Path, label: &str) -> Result<Repo> {
124 let repos = load(central)?;
125 if let Some(repo) = repos.iter().find(|r| r.label(&repos) == label) {
126 return Ok(repo.clone());
127 }
128 if repos.is_empty() {
129 bail!("no project `{label}`: none is registered yet (see `kb init`)");
130 }
131 let labels: Vec<String> = repos.iter().map(|r| r.label(&repos)).collect();
132 let hint = if repos.iter().any(|r| r.project == label) { " (it has several repositories: name one)" } else { "" };
133 bail!("no project `{label}`{hint}; there are {}, and `{CENTRAL}`", labels.join(", "))
134}
135
136pub fn register(central: &Path, project: &str, dir: &Path) -> Result<()> {
141 check_name(project)?;
142 let mut repos = load(central)?;
143 let new = Repo { project: project.to_string(), dir: dir.to_path_buf() };
144 if repos.contains(&new) {
145 return Ok(());
146 }
147 repos.retain(|r| r.dir != dir);
148 if let Some(twin) = repos.iter().find(|r| r.project == project && r.name() == new.name() && r.dir.is_dir()) {
149 bail!(
150 "project `{project}` already has a repository named `{}`, at {}; rename one of the folders",
151 new.name(),
152 twin.dir.display()
153 );
154 }
155 repos.retain(|r| !(r.project == project && r.name() == new.name()));
156 repos.push(new);
157 save(central, &repos)
158}
159
160pub fn unregister(central: &Path, dir: &Path) -> Result<Option<Repo>> {
163 let mut repos = load(central)?;
164 let Some(at) = repos.iter().position(|r| same_dir(&r.dir, dir)) else { return Ok(None) };
165 let repo = repos.remove(at);
166 save(central, &repos)?;
167 Ok(Some(repo))
168}
169
170fn check_name(name: &str) -> Result<()> {
173 if name.trim().is_empty() || name.contains([':', '/', '\n']) || name.trim() != name {
174 bail!("`{name}` can't be a project name: it needs to be one line, without `:`, `/` or surrounding spaces");
175 }
176 if name == CENTRAL {
177 bail!("`{CENTRAL}` is the name of the central brain; pick another: `kb init NAME`");
178 }
179 Ok(())
180}
181
182fn save(central: &Path, repos: &[Repo]) -> Result<()> {
184 Store::at(central)?;
185 let mut text = String::from(
186 "# Projects\n\nRepositories with their own `.brain/`, registered by `kb init`; several lines \
187 with the same name are one project's repositories. The kanban app shows them all.\n\n",
188 );
189 for r in repos {
190 writeln!(text, "- {}: {}", r.project, r.dir.display()).expect("writing to a String cannot fail");
191 }
192 store::write_file(¢ral.join(REGISTRY_FILE), &text)
193}
194
195pub fn read_repo_card(dir: &Path) -> Option<(String, String)> {
197 let text = fs::read_to_string(dir.join(PROJECT_FILE)).ok()?;
198 let mut lines = text.lines();
199 let name = lines.by_ref().find_map(|l| l.strip_prefix("# "))?.trim().to_string();
200 Some((name, lines.collect::<Vec<_>>().join("\n").trim().to_string()))
201}
202
203pub fn write_repo_card(dir: &Path, project: &str, notes: &str) -> Result<()> {
205 let notes = notes.trim();
206 let text = if notes.is_empty() { format!("# {project}\n") } else { format!("# {project}\n\n{notes}\n") };
207 store::write_file(&dir.join(PROJECT_FILE), &text)
208}
209
210fn card_path(central: &Path, project: &str) -> PathBuf {
212 central.join(CARDS_DIR).join(format!("{}.md", slugify(project)))
213}
214
215pub fn read_description(central: &Path, project: &str) -> String {
218 let text = fs::read_to_string(card_path(central, project)).unwrap_or_default();
219 let body = text.lines().skip_while(|l| !l.starts_with("# ")).skip(1).collect::<Vec<_>>().join("\n");
220 match body.trim() {
221 DESCRIPTION_TEMPLATE | OLD_DESCRIPTION_TEMPLATE => String::new(),
222 described => described.to_string(),
223 }
224}
225
226pub fn write_description(central: &Path, project: &str, description: &str) -> Result<()> {
228 store::write_file(&card_path(central, project), &format!("# {project}\n\n{}\n", description.trim()))
229}
230
231pub fn ensure_description(central: &Path, project: &str) -> Result<bool> {
233 if card_path(central, project).is_file() {
234 return Ok(false);
235 }
236 write_description(central, project, DESCRIPTION_TEMPLATE)?;
237 Ok(true)
238}
239
240pub fn remove_description(central: &Path, project: &str) -> Result<bool> {
242 let path = card_path(central, project);
243 match fs::remove_file(&path) {
244 Ok(()) => Ok(true),
245 Err(e) if e.kind() == ErrorKind::NotFound => Ok(false),
246 Err(e) => Err(e).with_context(|| format!("removing {}", path.display())),
247 }
248}
249
250pub fn info(dir: &Path, central: Option<&Path>) -> Option<ProjectInfo> {
253 let (name, notes) = read_repo_card(dir)?;
254 let repo = Repo { project: name.clone(), dir: dir.to_path_buf() }.name();
255 let (description, siblings) = match central {
256 Some(central) => {
257 let siblings = load(central)
258 .unwrap_or_default()
259 .into_iter()
260 .filter(|r| r.project == name && !same_dir(&r.dir, dir))
261 .map(|r| (r.name(), r.dir))
262 .collect();
263 (read_description(central, &name), siblings)
264 }
265 None => (String::new(), Vec::new()),
266 };
267 Some(ProjectInfo { name, description, repo, notes, siblings })
268}
269
270pub fn same_dir(a: &Path, b: &Path) -> bool {
273 a == b || fs::canonicalize(a).is_ok_and(|a| fs::canonicalize(b).ok() == Some(a))
274}
275
276#[cfg(test)]
277#[path = "../tests/projects.rs"]
278mod tests;