Skip to main content

kanbrain_core/cli/
mod.rs

1//! The command line: which command to run, with its options.
2//!
3//! - [`entries`]: commands that read or change entries by id, for agents and scripts.
4//! - [`mod@init`]: a project's `.brain/`, and the section telling its agents about it; `deinit` undoes it.
5//! - [`claude`]: the Claude Code hooks `init` adds: the context at each session start, and [`hook`]'s checks.
6//! - [`hook`]: `kb hook prompt|stop|edit|tool`, which hold an agent to describing an undescribed project first, to
7//!   commenting on the tasks it started, and to `kb` for the brain's files, and tell it of the user's comments.
8//! - [`completions`]: `kb completions SHELL`, tab completion, and the ids, projects and tags it offers.
9//! - [`update`]: `kb update` and the app's, the latest release over this one, through the installer, once no
10//!   agent is working.
11//!
12//! What the command line parses to ([`Command`], [`EntryCommand`], their variants and parts) is `#[non_exhaustive]`,
13//! so a new command, or a new option of one, isn't a breaking change of this crate's API.
14
15mod claude;
16mod completions;
17mod entries;
18mod hook;
19mod init;
20mod manage;
21pub mod update;
22
23pub use entries::{AddArgs, EntryArgs, EntryCommand, ProjectChange, ProjectPart, TagChange, Text};
24pub use init::init;
25pub use manage::{move_repo, remove_project, remove_repo, rename_project, rename_repo};
26
27use crate::api::json::Summary;
28use crate::brain::{Brain, Group, ReportView};
29use crate::model::{Asked, Author, Kind, Status, Task, fmt_clock, now, parse_tags};
30use crate::period::{Period, Unit};
31use crate::store::{self, Store};
32use crate::{export, platform, projects};
33use anyhow::{Context, Result, anyhow, bail};
34use chrono::{DateTime, Local};
35use std::fmt::Write;
36use std::io::{IsTerminal, Read};
37use std::path::{Path, PathBuf};
38
39/// The binary running the command line, named in the usage text and `--version`.
40#[derive(Debug, Clone, Copy)]
41pub struct Program {
42    /// The command's name.
43    pub name: &'static str,
44}
45
46impl Program {
47    /// Parses the arguments after the program name and runs the command.
48    /// A command-line error comes with the usage of the command it's about.
49    pub fn main(self, args: impl IntoIterator<Item = String>) -> Result<()> {
50        let (project, args) = take_project(args.into_iter().collect()).map_err(|e| self.misused(&e, None))?;
51        let name = args.first().cloned();
52        // `kb add --help` and `kb help add`: that command's usage, as an error after it would show.
53        // Right after the command, or `project`'s subcommand, only: in a comment's words (`kb comment 2 pass -h to
54        // it`), it's text.
55        let help = |flag: &String| flag == "--help" || flag == "-h";
56        let asked = match args.as_slice() {
57            [word, command] if word == "help" => Some(command.as_str()),
58            [command, flag, ..] if help(flag) => Some(command.as_str()),
59            [command, _, flag, ..] if command == "project" && help(flag) => Some(command.as_str()),
60            _ => None,
61        };
62        if let Some(command) = asked {
63            let lines = self.usage_of(command);
64            if lines.is_empty() {
65                bail!("unknown command `{command}`\n\n`{} --help` lists every command", self.name);
66            }
67            println!("usage:\n{}", lines.join("\n"));
68            return Ok(());
69        }
70        let command = Command::parse(args).map_err(|e| self.misused(&e, name.as_deref()))?;
71        command.run(self, project.as_deref())
72    }
73
74    /// `error`, then the usage lines of `command` (when it's one), and where the rest is: the whole usage would bury
75    /// the one line that matters.
76    fn misused(self, error: &anyhow::Error, command: Option<&str>) -> anyhow::Error {
77        let lines = command.map(|c| self.usage_of(c)).unwrap_or_default();
78        let help = format!("`{} --help` lists every command", self.name);
79        if lines.is_empty() {
80            anyhow!("{error:#}\n\n{help}")
81        } else {
82            anyhow!("{error:#}\n\nusage:\n{}\n\n{help}", lines.join("\n"))
83        }
84    }
85
86    /// The usage lines of `command`, from the whole usage; none for what isn't one.
87    fn usage_of(self, command: &str) -> Vec<String> {
88        let usage = usage(self);
89        let header = format!("  {} ", self.name);
90        let mut lines = Vec::new();
91        let (mut inside, mut bare) = (false, false);
92        for line in usage.lines() {
93            if let Some(rest) = line.strip_prefix(&header) {
94                let names = rest.split_whitespace().next().unwrap_or_default();
95                // `accept` shares the description under `reject`'s line: a matched line with none yet, on it or
96                // under it, takes the next.
97                inside = names.split('|').any(|n| n == command) || bare;
98                bare = inside && !bare && !rest.contains("   ");
99            } else if !(inside && line.starts_with("   ")) {
100                inside = false;
101            } else {
102                bare = false;
103            }
104            if inside {
105                lines.push(line.to_string());
106            }
107        }
108        lines
109    }
110}
111
112/// Takes `--project NAME` out of the arguments, wherever it is before a `--` (see [`options`]): it picks the brain for
113/// any command.
114fn take_project(mut args: Vec<String>) -> Result<(Option<String>, Vec<String>)> {
115    let Some(at) = options(&args).iter().position(|a| a == "--project") else { return Ok((None, args)) };
116    if at + 1 >= args.len() {
117        bail!("--project needs a project name (or `{}`)", projects::CENTRAL);
118    }
119    let name = args.remove(at + 1);
120    args.remove(at);
121    Ok((Some(name), args))
122}
123
124/// The arguments that may be options: those before a `--`, after which every word is text (`kb comment 3 -- use
125/// --json`), as most command lines have it.
126pub(crate) fn options(args: &[String]) -> &[String] {
127    args.iter().position(|a| a == "--").map_or(args, |at| &args[..at])
128}
129
130/// Printed by `--help` and after a command-line error. The status, period and group names come
131/// from `Status`, `Unit` and `Group`, so they're only written there.
132pub fn usage(program: Program) -> String {
133    let name = program.name;
134    let pad = " ".repeat(name.len());
135    format!(
136        "{name} — project memory for you and your coding agents: tasks, notes and decisions in markdown files
137
138usage:
139  {name} list [{statuses}|all] [notes] [decisions] [--tag TAG] [--json]
140  {pad}           print tasks as markdown, one section per status, then notes and decisions
141  {pad}           (default: {default}; several can be given; notes or decisions alone
142  {pad}           list only those)
143  {name} init [NAME] [--repo REPO]
144  {pad}           make this folder a project: its own .brain/, registered as NAME (default: the
145  {pad}           folder's name) with a card for the application, and a section telling its agents
146  {pad}           about it in CLAUDE.md / AGENTS.md (run again to update them); in a project with
147  {pad}           several, the repository is named REPO (default: the folder's, made unique)
148  {name} init --all
149  {pad}           update the agents' section and hooks in every registered project to this version's
150  {pad}           (the installer and update run it)
151  {name} deinit [--purge]
152  {pad}           stop this folder (or --project NAME) being a project: unregister it, remove its
153  {pad}           section from CLAUDE.md / AGENTS.md, the other repositories' links to it and, with
154  {pad}           its last repository, the project's card; its .brain/ is kept unless --purge
155  {name} hook prompt|stop|edit|tool
156  {pad}           what the Claude Code hooks `init` adds run: they hold an agent to describing an
157  {pad}           undescribed project first, to commenting on the tasks it started, and to `kb` for
158  {pad}           the brain's files, and tell it of your comments on its task with its next tool
159  {pad}           (they read the hook's JSON on stdin)
160  {name} status    print the running tasks, in whichever project, and how many wait for an answer
161  {pad}           (for status bars)
162  {name} report [{units}] [--group {groups}] [--tag TAG] [--save]
163  {pad}           print the current period's tracked time as markdown
164  {pad}           (default: {unit}, grouped by {group}); --save also writes it to reports/
165{entries}
166  {name} path      print the data directory in use (a project's .brain/ inside it)
167  {name} update    install the latest release over this one (and the other app, if it's beside it)
168  {name} completions bash|zsh|fish
169  {pad}           print a tab completion script for the shell (commands, ids, projects, tags)
170  {name} --version print the version
171
172--project NAME, anywhere on the line, runs a command on a registered project's brain (or `central`).
173--json prints JSON instead, for list, add, show, search, start|park|done|todo, comment, ask, propose, accept,
174reject, describe, rename, tag and link.
175-- ends a text's options: every word after it is the text (`{name} comment 3 -- use --json`).
176Data lives in the nearest .brain/ folder here or above (see init); else in the central brain:
177$KANBRAIN_DIR, else the platform data dir (e.g. ~/.local/share/kanbrain).
178The kanban board, `kanbrain` without arguments, shows the same data.
179
180kanbrain by singudotdev (https://github.com/singudotdev/kanbrain). AGPL-3.0-only with the additional terms in
181NOTICE; no warranty.",
182        statuses = Status::names("|"),
183        default = DEFAULT_LIST.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "),
184        units = Unit::names("|"),
185        groups = Group::names("|"),
186        unit = DEFAULT_REPORT_UNIT.as_str(),
187        group = Group::default().as_str(),
188        entries = entries::USAGE
189            .lines()
190            .map(|line| {
191                // Commands start at the margin and get the program's name; descriptions under them are
192                // indented further and get blanks instead, so they stay aligned.
193                let rest = line.strip_prefix("  ").unwrap_or(line);
194                format!("  {} {rest}", if rest.starts_with(' ') { &pad } else { name })
195            })
196            .collect::<Vec<_>>()
197            .join("\n"),
198    )
199}
200
201/// What `kb list` shows unless statuses are given: everything that isn't done.
202const DEFAULT_LIST: &[Status] = &[Status::Doing, Status::Todo, Status::Parked];
203
204/// The period `kb report` shows unless another one is given.
205const DEFAULT_REPORT_UNIT: Unit = Unit::Week;
206
207/// What to do, as chosen on the command line.
208#[derive(Debug, PartialEq, Eq)]
209#[non_exhaustive]
210pub enum Command {
211    /// `list`: print tasks as markdown.
212    #[non_exhaustive]
213    List(ListArgs),
214    /// `init [NAME] [--repo REPO]`: make the current directory a project, named `NAME` or after the folder,
215    /// and the repository `REPO` in it.
216    #[non_exhaustive]
217    Init(Option<String>, Option<String>),
218    /// `init --all`: bring every registered repository's agent instructions up to this version's.
219    #[non_exhaustive]
220    InitAll,
221    /// `deinit [--purge]`: undo `init` for the current directory's project, or the one `--project` names;
222    /// `true` also deletes its `.brain/`.
223    #[non_exhaustive]
224    Deinit(bool),
225    /// `hook prompt|stop|edit|tool`: what Claude Code's hooks run (see [`hook`]).
226    #[non_exhaustive]
227    Hook(hook::HookEvent),
228    /// `status`: print the running tasks for status bars.
229    #[non_exhaustive]
230    Status,
231    /// `report`: print a time report as markdown.
232    #[non_exhaustive]
233    Report(ReportArgs),
234    /// `add`, `show`, `search`, `start`, `comment`, …: entries by id, for agents and scripts.
235    #[non_exhaustive]
236    Entries(EntryArgs),
237    /// `path`: print the data folder.
238    #[non_exhaustive]
239    Path,
240    /// `update`: install the latest release.
241    #[non_exhaustive]
242    Update,
243    /// `completions SHELL`: print a tab completion script.
244    #[non_exhaustive]
245    Completions(completions::Shell),
246    /// `complete ids|projects|tags …`: what the completion scripts offer, one per line. The words after
247    /// it are the command line being completed, ignored but for its `--project`.
248    #[non_exhaustive]
249    Complete(completions::Candidates),
250    /// `--version` / `-V`.
251    #[non_exhaustive]
252    Version,
253    /// `--help` / `-h`.
254    #[non_exhaustive]
255    Help,
256}
257
258/// Options of `kb list`.
259#[derive(Debug, PartialEq, Eq)]
260#[non_exhaustive]
261pub struct ListArgs {
262    /// Task statuses to show, in list order.
263    pub statuses: Vec<Status>,
264    /// Notes and/or decisions to show after the tasks, in that order.
265    pub kinds: Vec<Kind>,
266    /// Only entries with this tag.
267    pub tag: Option<String>,
268    /// Print a JSON array instead of markdown.
269    pub json: bool,
270}
271
272/// Options of `kb report`.
273#[derive(Debug, PartialEq, Eq)]
274#[non_exhaustive]
275pub struct ReportArgs {
276    /// Period length; the current one is reported.
277    pub unit: Unit,
278    /// Rows per task or per tag.
279    pub group: Group,
280    /// Only count tasks with this tag.
281    pub tag: Option<String>,
282    /// Also write the report to `reports/`.
283    pub save: bool,
284}
285
286impl Command {
287    /// Parses the arguments after the program name.
288    pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self> {
289        let mut args = args.into_iter();
290        let command = match args.next().as_deref() {
291            Some("list") => Command::List(ListArgs::parse(args.by_ref())?),
292            Some("init") => {
293                let words: Vec<String> = args.by_ref().collect();
294                match words.as_slice() {
295                    [all] if all == "--all" => Command::InitAll,
296                    _ => init_args(words)?,
297                }
298            }
299            Some("deinit") => Command::Deinit(match args.next() {
300                None => false,
301                Some(purge) if purge == "--purge" => true,
302                Some(other) => bail!("unexpected argument `{other}`"),
303            }),
304            Some("hook") => {
305                let name =
306                    args.next().with_context(|| format!("hook needs one of {}", hook::HookEvent::names(", ")))?;
307                Command::Hook(
308                    hook::HookEvent::parse(&name)
309                        .with_context(|| format!("unknown hook `{name}`: {}", hook::HookEvent::names(", ")))?,
310                )
311            }
312            Some("status") => Command::Status,
313            Some("report") => Command::Report(ReportArgs::parse(args.by_ref())?),
314            Some("path") => Command::Path,
315            Some("update") => Command::Update,
316            Some("completions") => {
317                let name = args
318                    .next()
319                    .with_context(|| format!("completions needs one of {}", completions::Shell::names(", ")))?;
320                Command::Completions(
321                    completions::Shell::parse(&name)
322                        .with_context(|| format!("unknown shell `{name}`: {}", completions::Shell::names(", ")))?,
323                )
324            }
325            Some("complete") => {
326                let name = args
327                    .next()
328                    .with_context(|| format!("complete needs one of {}", completions::Candidates::names(", ")))?;
329                let what = completions::Candidates::parse(&name).with_context(|| {
330                    format!("unknown candidates `{name}`: {}", completions::Candidates::names(", "))
331                })?;
332                args.by_ref().for_each(drop);
333                Command::Complete(what)
334            }
335            Some("-V" | "--version") => Command::Version,
336            None | Some("-h" | "--help" | "help") => Command::Help,
337            Some(other) => match EntryArgs::parse(other, args.by_ref())? {
338                Some(entries) => Command::Entries(entries),
339                None => bail!("unknown command `{other}`"),
340            },
341        };
342        if let Some(extra) = args.next() {
343            bail!("unexpected argument `{extra}`");
344        }
345        Ok(command)
346    }
347
348    /// Runs the command on the brain of `project` (by name), else the one found from here.
349    /// Everything except `--version` and `--help` refuses to run as root.
350    pub fn run(self, program: Program, project: Option<&str>) -> Result<()> {
351        match self {
352            Command::Version => {
353                println!("{} {}", program.name, env!("CARGO_PKG_VERSION"));
354                return Ok(());
355            }
356            Command::Help => {
357                println!("{}", usage(program));
358                return Ok(());
359            }
360            Command::Completions(shell) => {
361                print!("{}", completions::script(shell, program.name));
362                return Ok(());
363            }
364            Command::Update => {
365                platform::ensure_not_root()?;
366                return update::update(&projects::central_dir()?);
367            }
368            Command::Init(name, repo) => {
369                platform::ensure_not_root()?;
370                if project.is_some() {
371                    bail!("init works on the current directory; leave out --project");
372                }
373                let root = std::env::current_dir().context("reading the current directory")?;
374                init::init(&root, name.as_deref(), repo.as_deref(), &projects::central_dir()?)?
375                    .iter()
376                    .for_each(|line| println!("{line}"));
377                return Ok(());
378            }
379            Command::InitAll => {
380                platform::ensure_not_root()?;
381                init::init_all(&projects::central_dir()?)?.iter().for_each(|line| println!("{line}"));
382                return Ok(());
383            }
384            Command::Hook(event) => {
385                // Claude Code sends the event as JSON on stdin; typed at a terminal, there's none to wait for.
386                let mut input = String::new();
387                if !std::io::stdin().is_terminal() {
388                    std::io::stdin().read_to_string(&mut input).context("reading the hook's input")?;
389                }
390                let here = std::env::current_dir().context("reading the current directory")?;
391                if let Some(out) = hook::respond(event, &input, &projects::central_dir()?, &here) {
392                    println!("{out}");
393                }
394                return Ok(());
395            }
396            Command::Deinit(purge) => {
397                platform::ensure_not_root()?;
398                let central = projects::central_dir()?;
399                let dir = deinit_dir(&central, project)?;
400                manage::remove_repo(&central, &dir, purge)?.iter().for_each(|line| println!("{line}"));
401                return Ok(());
402            }
403            _ => platform::ensure_not_root()?,
404        }
405        let store = Store::open_named(project)?;
406        let central = projects::central_dir()?;
407        match self {
408            Command::List(args) => {
409                // Opened as the entry commands open it, repairs and all, so the ids it lists are the ones they take:
410                // a sync conflict copy listed under its original's id would be ambiguous.
411                let _lock = store.lock()?;
412                let brain = Brain::open_in(store, &central)?;
413                say_skipped(&brain);
414                let tasks = &brain.tasks;
415                if args.json {
416                    println!("{}", list_json(tasks, &args)?);
417                } else {
418                    match list_markdown(tasks, &args) {
419                        // As `search` says it, rather than nothing a reader could take for a failure.
420                        empty if empty.is_empty() => println!("nothing to list"),
421                        listed => print!("{listed}"),
422                    }
423                }
424                Ok(())
425            }
426            Command::Status => {
427                println!("{}", status_line(&central, now())?);
428                Ok(())
429            }
430            Command::Entries(args) => run_entries(store, &central, args),
431            Command::Report(args) => print_report(store, &args),
432            Command::Path => {
433                println!("{}", store.dir().display());
434                Ok(())
435            }
436            Command::Complete(what) => {
437                completions::candidates(what, store, &central)?.iter().for_each(|line| println!("{line}"));
438                Ok(())
439            }
440            // Handled above, before touching the data folder.
441            Command::Version
442            | Command::Help
443            | Command::Completions(_)
444            | Command::Update
445            | Command::Init(..)
446            | Command::InitAll
447            | Command::Deinit(_)
448            | Command::Hook(_) => Ok(()),
449        }
450    }
451}
452
453impl ListArgs {
454    /// Parses the options after `list`.
455    fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
456        let mut statuses = Vec::new();
457        let mut kinds = Vec::new();
458        let mut tag = None;
459        let mut json = false;
460        while let Some(arg) = args.next() {
461            match arg.as_str() {
462                "--json" => json = true,
463                "notes" | "note" => kinds.push(Kind::Note),
464                "decisions" | "decision" => kinds.push(Kind::Decision),
465                "--tag" => {
466                    let value = args.next().context("--tag needs a value")?;
467                    // One tag: a second would be dropped without a word.
468                    let mut tags = parse_tags(&value).into_iter();
469                    tag = tags.next();
470                    if tags.next().is_some() {
471                        bail!("--tag takes one tag, not `{value}`");
472                    }
473                }
474                "all" => statuses.extend(Status::ALL),
475                // Anything else must be a status name.
476                other => match Status::parse(other) {
477                    Some(status) => statuses.push(status),
478                    None => bail!("unknown list option `{other}`"),
479                },
480            }
481        }
482        // Asking only for notes or decisions lists only those.
483        if statuses.is_empty() && kinds.is_empty() {
484            statuses.extend(DEFAULT_LIST);
485        }
486        // List order, without repeats.
487        statuses.sort_by_key(|s| s.position());
488        statuses.dedup();
489        kinds.sort_by_key(|k| k.position());
490        kinds.dedup();
491        Ok(Self { statuses, kinds, tag, json })
492    }
493}
494
495impl ReportArgs {
496    /// Parses the options after `report`.
497    fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
498        let mut report = ReportArgs { unit: DEFAULT_REPORT_UNIT, group: Group::default(), tag: None, save: false };
499        while let Some(arg) = args.next() {
500            match arg.as_str() {
501                "--group" => {
502                    let groups = Group::names(" or ");
503                    let value = args.next().with_context(|| format!("--group needs {groups}"))?;
504                    report.group = Group::parse(&value).with_context(|| format!("--group must be {groups}"))?;
505                }
506                "--tag" => {
507                    let value = args.next().context("--tag needs a value")?;
508                    report.tag = parse_tags(&value).into_iter().next();
509                }
510                "--save" => report.save = true,
511                // Anything else must be a period name.
512                other => match Unit::parse(other) {
513                    Some(unit) => report.unit = unit,
514                    None => bail!("unknown report option `{other}`"),
515                },
516            }
517        }
518        Ok(report)
519    }
520}
521
522/// Environment variables coding agents set in the commands they run, so `kb` can tell them from a
523/// person at a terminal: the generic `AGENT` and `AI_AGENT`, then Claude Code's, Codex's and Gemini CLI's.
524const AGENT_VARIABLES: &[&str] =
525    &["AGENT", "AI_AGENT", "CLAUDECODE", "CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID", "GEMINI_CLI"];
526
527/// `init`'s arguments: an optional project name, and `--repo REPO` anywhere.
528fn init_args(words: Vec<String>) -> Result<Command> {
529    let mut words = words.into_iter();
530    let (mut name, mut repo) = (None, None);
531    while let Some(word) = words.next() {
532        match word.as_str() {
533            "--repo" => repo = Some(words.next().context("--repo needs the repository's name")?),
534            other if other.starts_with("--") => bail!("unknown init option `{other}`"),
535            _ if name.is_none() => name = Some(word),
536            _ => bail!("unexpected argument `{word}`"),
537        }
538    }
539    Ok(Command::Init(name, repo))
540}
541
542/// Who this command runs for, recorded on the comments and status changes it makes: `KANBRAIN_AUTHOR`
543/// (`human` or `agent`, in any case) if set and not empty, as the agents' variables are read, else an agent if one
544/// of [`AGENT_VARIABLES`] is set, else a person. `var` reads a variable, as `std::env::var_os` does for the command
545/// line.
546fn author(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Result<Author> {
547    if let Some(value) = var("KANBRAIN_AUTHOR").filter(|v| !v.is_empty()) {
548        let value = value.to_string_lossy();
549        return Author::parse(&value)
550            .with_context(|| format!("KANBRAIN_AUTHOR must be {}, not `{value}`", Author::names(" or ")));
551    }
552    let agent = AGENT_VARIABLES.iter().any(|name| var(name).is_some_and(|v| !v.is_empty()));
553    Ok(if agent { Author::Agent } else { Author::Human })
554}
555
556/// One line for status bars: `#id title HH:MM:SS` for each running session, or `idle`, then how many tasks wait for an
557/// answer, if any. Looks in every brain, wherever it's run from, since each runs its own tasks; a project's task says
558/// which project, and several are joined: `shop/api #3 Fix login 00:42:17 · blog #1 Post 00:05:00 · 2 waiting`. A brain
559/// it can't read (one a newer kanbrain wrote, say) is left out rather than the whole line lost.
560fn status_line(central: &Path, now: DateTime<Local>) -> Result<String> {
561    let mut brains = vec![(None, central.to_path_buf())];
562    let repos = projects::load(central)?;
563    brains.extend(repos.iter().map(|r| (Some(r.label(&repos)), r.dir.clone())));
564    let mut running = Vec::new();
565    let mut waiting = 0;
566    for (name, dir) in brains.into_iter().filter(|(_, dir)| dir.is_dir()) {
567        let Ok((tasks, _)) = Store::at(&dir).and_then(|store| store.load()) else { continue };
568        let project = name.map(|n| format!("{n} ")).unwrap_or_default();
569        for (task, clock) in tasks.iter().filter_map(|t| Some((t, t.running_for(now)?))) {
570            running.push(format!("{project}#{} {} {}", task.id, task.title, fmt_clock(clock)));
571        }
572        waiting += tasks.iter().filter(|t| matches!(t.asked(), Some(Asked::Waiting(_)))).count();
573    }
574    if running.is_empty() {
575        running.push("idle".to_string());
576    }
577    if waiting > 0 {
578        running.push(format!("{waiting} waiting"));
579    }
580    Ok(running.join(" · "))
581}
582
583/// The `.brain/` `deinit` takes out: `project`'s, else the one here. Not through `Store::open_named`, which refuses a
584/// deleted repository: this is how it's taken out.
585fn deinit_dir(central: &Path, project: Option<&str>) -> Result<PathBuf> {
586    let central_refused = || anyhow!("the central brain isn't a project: there's nothing to deinit");
587    match project {
588        Some(projects::CENTRAL) => Err(central_refused()),
589        Some(label) => match projects::find(central, label) {
590            Ok(repo) => Ok(repo.dir),
591            Err(_) if label.eq_ignore_ascii_case(projects::CENTRAL) => Err(central_refused()),
592            Err(e) => Err(e),
593        },
594        None => {
595            let here = std::env::current_dir().context("reading the current directory")?;
596            store::find_project(&here)
597                .context("no .brain/ here or above: run deinit inside a project, or name one with --project")
598        }
599    }
600}
601
602/// Runs one of the entry commands on the brain in `store`.
603fn run_entries(store: Store, central: &Path, mut args: EntryArgs) -> Result<()> {
604    let author = author(|name| std::env::var_os(name))?;
605    // Before the lock: `-` waits for stdin, maybe for someone typing, and every other `kb` and the app would wait
606    // meanwhile.
607    args.read_stdin()?;
608    // A project change writes the central brain's registry or cards too, under its lock: taken first, in the order
609    // the app takes them, or this and the app could each hold one lock and wait forever for the other's.
610    let _central = match args.command {
611        EntryCommand::Project(Some(_)) => Some(Store::at(central)?.lock()?),
612        _ => None,
613    };
614    // Held from reading the files to writing them, so parallel commands each see the others' changes; opening may
615    // write too, as it repairs. `wait` lets go once open, not to hold it while it waits.
616    let lock = store.lock()?;
617    let brain = Brain { author, ..Brain::open_in(store, central)? };
618    say_skipped(&brain);
619    let _lock = (!matches!(args.command, EntryCommand::Wait(..))).then_some(lock);
620    args.run(brain)
621}
622
623/// Says on stderr which entries' files couldn't be read: left out silently, an agent told `no entry #7` would add
624/// the task again, though its file is there. A file is an entry's when it's broken as only one is (a bad history
625/// line, say), or named as one (`0007-title.md`); markdown of other names, or dated (`2024-01-15.md`), with no id is
626/// left unsaid: a central brain kept in a notes vault holds many.
627fn say_skipped(brain: &Brain) {
628    let entry_file = |warning: &&String| store::entry_named(warning) || !warning.contains(crate::model::MISSING_ID);
629    for warning in brain.warnings.iter().filter(entry_file) {
630        eprintln!("kb: skipped {warning}; fix the file or remove it");
631    }
632}
633
634/// Prints the current period's report as markdown, and saves it with `--save`.
635fn print_report(store: Store, args: &ReportArgs) -> Result<()> {
636    // Opening may repair files, and `--save` writes one.
637    let _lock = store.lock()?;
638    let brain = Brain::open(store)?;
639    let view = ReportView { group: args.group, tag: args.tag.clone(), ..ReportView::new(Period::current(args.unit)) };
640    print!("{}", export::to_markdown(&brain.report(&view), &view, now()));
641    if args.save {
642        eprintln!("saved {}", brain.export_report(&view)?.display());
643    }
644    Ok(())
645}
646
647/// What `list` prints, in order: one section per status (tasks only), then notes, then decisions,
648/// each entry carrying `args.tag` if one was given.
649fn list_sections<'a>(tasks: &'a [Task], args: &ListArgs) -> Vec<(&'static str, Vec<&'a Task>)> {
650    let tagged = |t: &&Task| args.tag.as_ref().is_none_or(|tag| t.tags.contains(tag));
651    let mut sections = Vec::new();
652    for &status in &args.statuses {
653        let mut matching: Vec<&Task> =
654            tasks.iter().filter(|t| t.is_task() && t.status == status).filter(tagged).collect();
655        matching.sort_by(|a, b| b.status_since().cmp(&a.status_since()).then(b.id.cmp(&a.id)));
656        sections.push((status.as_str(), matching));
657    }
658    for &kind in &args.kinds {
659        let mut matching: Vec<&Task> = tasks.iter().filter(|t| t.kind == kind).filter(tagged).collect();
660        matching.sort_by_key(|t| std::cmp::Reverse(t.id));
661        sections.push((kind_heading(kind), matching));
662    }
663    sections.retain(|(_, entries)| !entries.is_empty());
664    sections
665}
666
667/// The `list` word and section heading for notes and decisions.
668fn kind_heading(kind: Kind) -> &'static str {
669    match kind {
670        Kind::Task => "tasks",
671        Kind::Note => "notes",
672        Kind::Decision => "decisions",
673    }
674}
675
676/// One `## heading` section per non-empty section, each entry as `- #id title #tag …`, then ` · ` what its section
677/// doesn't say (`waiting for an answer`, `proposed`…).
678fn list_markdown(tasks: &[Task], args: &ListArgs) -> String {
679    let mut out = String::new();
680    for (heading, entries) in list_sections(tasks, args) {
681        if !out.is_empty() {
682            out.push('\n');
683        }
684        writeln!(out, "## {heading}\n").expect("writing to a String cannot fail");
685        for task in entries {
686            out.push_str("- ");
687            crate::api::text::head(&mut out, task).expect("writing to a String cannot fail");
688            // As `context` says it: a parked task's question, a decision not in force.
689            if let Some(note) = crate::api::text::state_note(task) {
690                write!(out, " · {note}").expect("writing to a String cannot fail");
691            }
692            out.push('\n');
693        }
694    }
695    out
696}
697
698/// The same entries as `list_markdown`, in the same order, as a JSON array of summaries on one line, as every
699/// command's JSON is.
700fn list_json(tasks: &[Task], args: &ListArgs) -> Result<String> {
701    let entries: Vec<Summary> = list_sections(tasks, args).into_iter().flat_map(|(_, e)| e).map(Summary::new).collect();
702    Ok(serde_json::to_string(&entries)?)
703}
704
705#[cfg(test)]
706#[path = "../../tests/cli.rs"]
707mod tests;