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