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