Skip to main content

kanbrain_core/cli/
entries.rs

1//! Commands that read or change entries by id, for agents and scripts: `add`, `show`, `search`,
2//! `start`/`park`/`done`/`todo`, `comment`, `ask`, `wait`, `propose`, `accept`/`reject`, `describe`, `link`
3//! and `context`. Each prints a short confirmation, or with `--json`, the entry (or entries) as JSON.
4
5use super::manage;
6use crate::api::ApiError;
7use crate::api::json::{Entry, Summary};
8use crate::api::text::line;
9use crate::brain::{Brain, NewEntry};
10use crate::model::{Asked, DecisionState, Kind, RepoLink, Status, Task, not_a_link, parse_links, parse_tags};
11use crate::projects::ProjectInfo;
12use anyhow::{Context, Result, anyhow, bail};
13use serde::Serialize;
14use std::io::Read;
15use std::time::Duration;
16
17/// After the context: how to act on what it lists, for agents that haven't seen the CLI before. Left
18/// out in a project, whose agents have the commands in the section `init` wrote.
19const CONTEXT_USAGE: &str = "\
20Commands: `kb show ID` (everything about an entry), `search WORDS`, \
21`add [--note|--decision] [--tag T] [--link ID] [--body TEXT|-] TITLE`, \
22`comment ID TEXT|-`, `ask ID TEXT|-`, `wait ID`, `start|park|done|todo ID`, \
23`link ID OTHER…` (`REPO#ID` in another repository). Add `--json` for JSON.";
24
25/// Usage lines for these commands, appended to the main usage text.
26pub const USAGE: &str = "\
27  add [--note|--decision] [--tag TAG]… [--link ID]… [--body TEXT|-] [--start] TITLE…
28            create a task (or note, decision) and print its id; `-` reads the body from stdin
29  show ID   print an entry compactly: state, time, description, comments, related, history
30  search WORDS…
31            find entries by title, tags, description or comments (#tag: tags only)
32  start|park|done|todo ID
33            change a task's status (start runs its clock; a person runs one task at a time)
34  comment ID TEXT…|-
35            add a comment, e.g. progress notes
36  ask ID TEXT…|-
37            ask the user a question on a task and park it until a person answers with a comment
38  wait ID [--timeout MINUTES]
39            block until a person answers what task ID waits on (or decision ID is accepted or
40            rejected), then print the answer; gives up after 8 minutes (0: never)
41  propose ID [--tag TAG]… [--link ID]… [--body TEXT|-] TITLE…
42            propose a decision for a task: linked to it, which asks about it and is parked
43  accept ID [TEXT…|-]
44  reject ID TEXT…|-
45            accept or reject a decision, with why (needed to reject); answers a question that names it (#ID)
46  describe ID TEXT…|-
47            replace the description
48  link ID OTHER…
49            link an entry to others: an id here, or REPO#ID in another repository of the project
50  project [describe|notes TEXT…|-]
51            show the project's description (shared by its repositories) and this repository's role,
52            or replace one
53  project rename NAME… | repo NAME | move PROJECT… | remove [--purge]
54            rename the project, or this repository in it; move this repository to another project;
55            take every repository of the project out (--purge: and delete their tasks)
56  context   what's going on: active tasks, decisions and notes, for an agent to read first";
57
58/// One of the entry commands, and whether to print JSON.
59#[derive(Debug, PartialEq, Eq)]
60pub struct EntryArgs {
61    /// What to do.
62    pub command: EntryCommand,
63    /// `--json`: print the result as JSON.
64    pub json: bool,
65}
66
67/// What to do to which entry.
68#[derive(Debug, PartialEq, Eq)]
69pub enum EntryCommand {
70    /// `add`: create an entry.
71    Add(AddArgs),
72    /// `show ID`.
73    Show(u32),
74    /// `search WORDS…`.
75    Search(String),
76    /// `start|park|done|todo ID`.
77    SetStatus(u32, Status),
78    /// `comment ID TEXT…|-`.
79    Comment(u32, Text),
80    /// `ask ID TEXT…|-`: a question to the user, parking the task.
81    Ask(u32, Text),
82    /// `wait ID [--timeout MINUTES]`: block until a person answers, or the time is up (`None`: never).
83    Wait(u32, Option<Duration>),
84    /// `propose ID … TITLE…`: a proposed decision for the task.
85    Propose(u32, AddArgs),
86    /// `accept ID [TEXT…|-]` / `reject ID TEXT…|-`: a decision accepted or rejected, and why.
87    Resolve(u32, DecisionState, Option<Text>),
88    /// `describe ID TEXT…|-`.
89    Describe(u32, Text),
90    /// `link ID OTHER…`: ids here, and `REPO#ID` in the project's other repositories.
91    Link(u32, Vec<u32>, Vec<RepoLink>),
92    /// `context`.
93    Context,
94    /// `project`: show the project's description and this repository's role, or change the project.
95    Project(Option<ProjectChange>),
96}
97
98/// What `project` changes.
99#[derive(Debug, PartialEq, Eq)]
100pub enum ProjectChange {
101    /// `describe|notes TEXT…|-`: replace one of its texts.
102    Text(ProjectPart, Text),
103    /// `rename NAME…`: the project's name; `true` when given after `--`, as meant whatever it starts with.
104    Rename(String, bool),
105    /// `repo NAME`: this repository's name in its project.
106    Repo(String),
107    /// `move PROJECT…`: this repository to another project, created if new.
108    Move(String),
109    /// `remove [--purge]`: take every repository of the project out of it; `true` also deletes their tasks.
110    Remove(bool),
111}
112
113/// What `project` changes in one of its texts.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum ProjectPart {
116    /// `describe`: the project's description, on the card all its repositories share.
117    Description,
118    /// `notes`: this repository's role among the project's, in its `.brain/project.md`.
119    Notes,
120}
121
122/// Text given on the command line, or `-` to read it from stdin (for multi-line markdown).
123#[derive(Debug, PartialEq, Eq)]
124pub enum Text {
125    /// The words after the id, joined by spaces.
126    Given(String),
127    /// Read stdin to the end.
128    Stdin,
129}
130
131/// Options of `add`.
132#[derive(Debug, PartialEq, Eq, Default)]
133pub struct AddArgs {
134    /// `--note` / `--decision`; a task otherwise.
135    pub kind: Kind,
136    /// The remaining words.
137    pub title: String,
138    /// `--tag`, repeatable.
139    pub tags: Vec<String>,
140    /// `--link`, repeatable: ids here.
141    pub links: Vec<u32>,
142    /// `--link REPO#ID`: entries of the project's other repositories.
143    pub repo_links: Vec<RepoLink>,
144    /// `--body`.
145    pub body: Option<Text>,
146    /// `--start`.
147    pub start: bool,
148}
149
150impl EntryArgs {
151    /// Reads the text given as `-` from stdin, if any.
152    pub fn read_stdin(&mut self) -> Result<()> {
153        let text = match &mut self.command {
154            EntryCommand::Add(args) | EntryCommand::Propose(_, args) => args.body.as_mut(),
155            EntryCommand::Comment(_, text)
156            | EntryCommand::Ask(_, text)
157            | EntryCommand::Describe(_, text)
158            | EntryCommand::Project(Some(ProjectChange::Text(_, text))) => Some(text),
159            EntryCommand::Resolve(_, _, reason) => reason.as_mut(),
160            EntryCommand::Show(_)
161            | EntryCommand::Search(_)
162            | EntryCommand::SetStatus(..)
163            | EntryCommand::Wait(..)
164            | EntryCommand::Link(..)
165            | EntryCommand::Context
166            | EntryCommand::Project(_) => None,
167        };
168        text.map_or(Ok(()), Text::read_stdin)
169    }
170
171    /// Parses `name` and the arguments after it, if `name` is one of these commands.
172    pub fn parse(name: &str, args: impl Iterator<Item = String>) -> Result<Option<Self>> {
173        // `--json` may go anywhere before a `--` (see `cli::options`).
174        let mut json = false;
175        let mut rest = Vec::new();
176        let mut text = false;
177        for arg in args {
178            text |= arg == "--";
179            if arg == "--json" && !text {
180                json = true;
181            } else {
182                rest.push(arg);
183            }
184        }
185        let status = match name {
186            "start" => Some(Status::Doing),
187            "park" => Some(Status::Parked),
188            "done" => Some(Status::Done),
189            "todo" => Some(Status::Todo),
190            _ => None,
191        };
192        let command = match (name, status) {
193            (_, Some(status)) => EntryCommand::SetStatus(first_id(&mut rest)?, status),
194            ("add", None) => EntryCommand::Add(AddArgs::parse(rest.drain(..), "add needs a title")?),
195            ("show", None) => EntryCommand::Show(first_id(&mut rest)?),
196            ("search", None) => {
197                match search_words(std::mem::take(&mut rest)) {
198                    // Every entry would match: `list` is for that.
199                    words if words.trim().is_empty() => {
200                        bail!("search needs words to look for, or a #tag; `kb list` lists them all")
201                    }
202                    words => EntryCommand::Search(words),
203                }
204            }
205            ("comment", None) => {
206                let (id, text) = id_and_text(&mut rest, "comment needs its text")?;
207                EntryCommand::Comment(id, text)
208            }
209            ("ask", None) => {
210                let (id, text) = id_and_text(&mut rest, "ask needs the question")?;
211                EntryCommand::Ask(id, text)
212            }
213            ("wait", None) => {
214                let id = first_id(&mut rest)?;
215                EntryCommand::Wait(id, wait_timeout(&mut rest)?)
216            }
217            ("propose", None) => {
218                let id = first_id(&mut rest)?;
219                let decision = AddArgs::parse(rest.drain(..), "propose needs the decision's title")?;
220                if decision.kind != Kind::Task || decision.start {
221                    bail!("propose makes a decision: leave out --note, --decision and --start");
222                }
223                EntryCommand::Propose(id, decision)
224            }
225            ("accept", None) => {
226                let id = first_id(&mut rest)?;
227                let reason =
228                    if rest.is_empty() { None } else { Some(rest_as_text(&mut rest, "accept needs its comment")?) };
229                EntryCommand::Resolve(id, DecisionState::Accepted, reason)
230            }
231            ("reject", None) => {
232                let id = first_id(&mut rest)?;
233                let reason = rest_as_text(&mut rest, "reject needs why")?;
234                EntryCommand::Resolve(id, DecisionState::Rejected, Some(reason))
235            }
236            ("describe", None) => {
237                let (id, text) = id_and_text(&mut rest, "describe needs the description")?;
238                EntryCommand::Describe(id, text)
239            }
240            ("link", None) => {
241                let id = first_id(&mut rest)?;
242                let (others, repo_links) = typed_links(&std::mem::take(&mut rest).join(" "))?;
243                if others.is_empty() && repo_links.is_empty() {
244                    bail!("link needs the ids to link to");
245                }
246                EntryCommand::Link(id, others, repo_links)
247            }
248            ("context", None) => EntryCommand::Context,
249            ("project", None) => EntryCommand::Project(project_args(&mut rest)?),
250            _ => return Ok(None),
251        };
252        if let Some(extra) = rest.first() {
253            bail!("unexpected argument `{extra}`");
254        }
255        Ok(Some(Self { command, json }))
256    }
257
258    /// Runs the command against `brain` and prints the result.
259    pub fn run(self, mut brain: Brain) -> Result<()> {
260        let json = self.json;
261        let show = matches!(self.command, EntryCommand::Show(_));
262        let id = match self.command {
263            EntryCommand::Add(args) => brain.add(args.into_new_entry()?)?,
264            EntryCommand::Show(id) => {
265                brain.entry(id)?;
266                id
267            }
268            EntryCommand::Search(query) => return print_search(&brain, &query, json),
269            EntryCommand::SetStatus(id, status) => {
270                // On stderr, so stdout stays the entry's line for scripts.
271                if !brain.set_status(id, status)? {
272                    eprintln!("#{id} was already {}: nothing changed", status.as_str());
273                }
274                id
275            }
276            EntryCommand::Comment(id, text) => {
277                brain.add_comment(id, &text.read()?)?;
278                id
279            }
280            EntryCommand::Ask(id, text) => {
281                brain.ask(id, &text.read()?)?;
282                id
283            }
284            EntryCommand::Wait(..) if json => bail!("wait prints the answer as text only"),
285            EntryCommand::Wait(id, timeout) => return wait(&brain, id, timeout),
286            EntryCommand::Propose(task, args) => {
287                let decision = brain.propose(task, args.into_new_entry()?)?;
288                if !json {
289                    println!("{} · {}", line(brain.entry(decision)?), line(brain.entry(task)?));
290                    return Ok(());
291                }
292                decision
293            }
294            EntryCommand::Resolve(id, state, reason) => {
295                let reason = reason.map(Text::read).transpose()?.unwrap_or_default();
296                if !brain.resolve(id, state, &reason)? {
297                    eprintln!("#{id} was already {}: nothing changed", state.as_str());
298                }
299                id
300            }
301            EntryCommand::Describe(id, text) => {
302                brain.set_description(id, &text.read()?)?;
303                id
304            }
305            EntryCommand::Link(id, others, repo_links) => {
306                brain.link(id, &others, &repo_links)?;
307                id
308            }
309            EntryCommand::Context if json => bail!("context is markdown only; use search or show with --json"),
310            EntryCommand::Project(_) if json => bail!("project is markdown only"),
311            EntryCommand::Project(change) => return run_project(&mut brain, change),
312            EntryCommand::Context => {
313                let usage = if brain.project.is_some() { "" } else { CONTEXT_USAGE };
314                println!("{}{usage}", brain.context_markdown());
315                return Ok(());
316            }
317        };
318        print_entry(&brain, id, json, show)
319    }
320}
321
322impl AddArgs {
323    /// Parses the options and title words after `add` or `propose ID`; `no_title` is the error without a title.
324    fn parse(mut args: impl Iterator<Item = String>, no_title: &str) -> Result<Self> {
325        let mut add = Self::default();
326        let mut title = Vec::new();
327        while let Some(arg) = args.next() {
328            match arg.as_str() {
329                "--note" => add.kind = Kind::Note,
330                "--decision" => add.kind = Kind::Decision,
331                "--start" => add.start = true,
332                "--tag" => add.tags.extend(parse_tags(&args.next().context("--tag needs a value")?)),
333                "--link" => {
334                    let (links, repo_links) = typed_links(&args.next().context("--link needs an id")?)?;
335                    add.links.extend(links);
336                    add.repo_links.extend(repo_links);
337                }
338                "--body" => {
339                    let body = args.next().context("--body needs text, or - to read stdin")?;
340                    add.body = Some(if body == "-" { Text::Stdin } else { Text::Given(body) });
341                }
342                // Every word after it is the title's, however it starts.
343                "--" => title.extend(args.by_ref()),
344                other if other.starts_with("--") => bail!("unknown add option `{other}`"),
345                _ => title.push(arg),
346            }
347        }
348        add.title = title.join(" ");
349        if add.title.trim().is_empty() {
350            bail!("{no_title}");
351        }
352        if add.start && add.kind != Kind::Task {
353            bail!("only tasks can be started");
354        }
355        Ok(add)
356    }
357
358    /// The entry to create, reading the body from stdin if asked to.
359    fn into_new_entry(self) -> Result<NewEntry> {
360        Ok(NewEntry {
361            kind: self.kind,
362            title: self.title,
363            tags: self.tags,
364            links: self.links,
365            repo_links: self.repo_links,
366            description: self.body.map(Text::read).transpose()?,
367            start: self.start,
368        })
369    }
370}
371
372impl Text {
373    /// Reads stdin to the end for `-`, so the text is given from then on.
374    fn read_stdin(&mut self) -> Result<()> {
375        if *self == Text::Stdin {
376            *self = Text::Given(stdin_text()?);
377        }
378        Ok(())
379    }
380
381    /// The text, reading stdin to the end for `-`.
382    fn read(self) -> Result<String> {
383        match self {
384            Text::Given(text) => Ok(text),
385            Text::Stdin => stdin_text(),
386        }
387    }
388}
389
390/// Everything on stdin, for a `-`. Nothing there is refused: it's a heredoc left out (an agent's shell tool gives an
391/// empty stdin) far more often than a wish to clear the text, and `describe` would save it over the description.
392fn stdin_text() -> Result<String> {
393    let mut text = String::new();
394    std::io::stdin().read_to_string(&mut text).context("reading stdin")?;
395    if text.trim().is_empty() {
396        bail!("nothing came on stdin for `-`: give the text there, e.g. with a heredoc (<<'EOF' … EOF)");
397    }
398    Ok(text)
399}
400
401/// The words `search` looks for, without the first `--`, which only ends the options: `kb search -- --` looks for
402/// `--`.
403fn search_words(mut words: Vec<String>) -> String {
404    if let Some(at) = words.iter().position(|w| w == "--") {
405        words.remove(at);
406    }
407    words.join(" ")
408}
409
410/// The links typed in `value`; a word that isn't one is refused rather than left out.
411fn typed_links(value: &str) -> Result<(Vec<u32>, Vec<RepoLink>)> {
412    if let Some(word) = not_a_link(value) {
413        bail!("`{word}` isn't an entry id (12 or #12) or another repository's entry (REPO#ID)");
414    }
415    Ok(parse_links(value))
416}
417
418/// The first argument as an entry id (`12` or `#12`).
419fn first_id(args: &mut Vec<String>) -> Result<u32> {
420    if args.is_empty() {
421        bail!("an entry id is needed");
422    }
423    let arg = args.remove(0);
424    // The parser's own words ("invalid digit found in string") say nothing more to whoever typed it.
425    arg.trim_start_matches('#').parse().map_err(|_| anyhow!("`{arg}` isn't an entry id"))
426}
427
428/// An id, then the text: the remaining words, or `-` alone for stdin; `needs` says what's missing without it.
429fn id_and_text(args: &mut Vec<String>, needs: &str) -> Result<(u32, Text)> {
430    let id = first_id(args)?;
431    Ok((id, rest_as_text(args, needs)?))
432}
433
434/// The remaining words as one text, or `-` alone for stdin; `needs` says what's missing without them.
435fn rest_as_text(args: &mut Vec<String>, needs: &str) -> Result<Text> {
436    let mut words = std::mem::take(args);
437    // Only says where options end (see `cli::options`); the text is what follows.
438    if let Some(at) = words.iter().position(|w| w == "--") {
439        words.remove(at);
440    }
441    match words.as_slice() {
442        [] => bail!("{needs}: TEXT, or - to read it from stdin"),
443        [dash] if dash == "-" => Ok(Text::Stdin),
444        _ => Ok(Text::Given(words.join(" "))),
445    }
446}
447
448/// What follows `project`: nothing, to show it; else what to change.
449fn project_args(args: &mut Vec<String>) -> Result<Option<ProjectChange>> {
450    if args.is_empty() {
451        return Ok(None);
452    }
453    let command = args.remove(0);
454    // A project's name may have spaces; a repository's is one word, which `project::check_repo_name` checks.
455    let words = |args: &mut Vec<String>, what: &str| match std::mem::take(args).join(" ") {
456        name if name.trim().is_empty() => bail!("project {command} needs {what}"),
457        name => Ok(name),
458    };
459    Ok(Some(match command.as_str() {
460        "describe" => {
461            ProjectChange::Text(ProjectPart::Description, rest_as_text(args, "project describe needs the description")?)
462        }
463        "notes" => ProjectChange::Text(ProjectPart::Notes, rest_as_text(args, "project notes needs the notes")?),
464        "rename" => {
465            let literal = args.first().is_some_and(|a| a == "--");
466            if literal {
467                args.remove(0);
468            }
469            ProjectChange::Rename(words(args, "the new name")?, literal)
470        }
471        "repo" => ProjectChange::Repo(words(args, "the repository's new name")?),
472        "move" => ProjectChange::Move(words(args, "the project to move to")?),
473        "remove" => {
474            let purge = args.first().is_some_and(|a| a == "--purge");
475            if purge {
476                args.remove(0);
477            }
478            ProjectChange::Remove(purge)
479        }
480        other => bail!("unknown project command `{other}`: describe, notes, rename, repo, move or remove"),
481    }))
482}
483
484/// What `kb project` shows: the project's description and, in a project of several repositories (or
485/// with notes written anyway), this repository's role. A single repository has no role to tell apart,
486/// so an empty one isn't listed as missing.
487fn project_text(project: &ProjectInfo) -> String {
488    /// The text, or that there's none yet.
489    fn shown(text: &str) -> &str {
490        if text.is_empty() { "(empty)" } else { text }
491    }
492    let card = format!("## Project {}\n\n{}\n", project.name, shown(&project.description));
493    if project.siblings.is_empty() && project.notes.is_empty() {
494        return card;
495    }
496    format!("{card}\n## Repository {}\n\n{}\n", project.repo, shown(&project.notes))
497}
498
499/// Shows the project's description and this repository's role, or changes the project.
500fn run_project(brain: &mut Brain, change: Option<ProjectChange>) -> Result<()> {
501    let Some(project) = &brain.project else {
502        bail!("this isn't a project: `kb init` makes it one, or use --project NAME");
503    };
504    let name = project.name.clone();
505    let central = brain.central.clone().context("the central brain isn't known here")?;
506    let dir = brain.store.dir().to_path_buf();
507    let done = match change {
508        None => {
509            print!("{}", project_text(project));
510            return Ok(());
511        }
512        Some(ProjectChange::Text(ProjectPart::Description, text)) => {
513            brain.set_project_description(&text.read()?)?;
514            vec![format!("project {name}: description saved")]
515        }
516        Some(ProjectChange::Text(ProjectPart::Notes, text)) => {
517            brain.set_repo_notes(&text.read()?)?;
518            vec![format!("project {name}: repository role saved")]
519        }
520        // `rename OLD NEW`, as other tools have it, would name the project "OLD NEW", names having spaces.
521        Some(ProjectChange::Rename(new, false))
522            if new.strip_prefix(&name).is_some_and(|rest| rest.starts_with(' ')) =>
523        {
524            let rest = new[name.len()..].trim();
525            bail!(
526                "`project rename` takes the new name only, for this project: `kb project rename {rest}`; to name it \
527                 `{new}`, `kb project rename -- {new}`"
528            )
529        }
530        Some(ProjectChange::Rename(new, _)) => manage::rename_project(&central, &name, &new)?,
531        Some(ProjectChange::Repo(new)) => manage::rename_repo(&central, &dir, &new)?,
532        Some(ProjectChange::Move(to)) => manage::move_repo(&central, &dir, &to)?,
533        Some(ProjectChange::Remove(purge)) => manage::remove_project(&central, &name, purge)?,
534    };
535    for line in &done {
536        println!("{line}");
537    }
538    Ok(())
539}
540
541/// How often `wait` reads the brain again. Seconds, as a person takes far longer to answer; the files
542/// are re-read rather than watched, so a waiting agent costs no dependency and no model call.
543const WAIT_POLL: Duration = Duration::from_secs(1);
544
545/// How long `wait` waits without `--timeout`. Claude Code run headless (`claude -p`) doesn't exit while a
546/// background command runs, so a wait nobody answers would hold it forever; past this, an answer is no
547/// longer a conversation, and the next session's `context` picks it up. Under the 10 minutes Claude Code
548/// gives a tool call at most, so the agent gets this message, which says to stop, rather than a killed
549/// command it takes as a hiccup and runs again.
550const WAIT_DEFAULT: Duration = Duration::from_secs(8 * 60);
551
552/// `--timeout MINUTES` after `wait`'s id: `None` for 0 (no limit). Without it, `KANBRAIN_WAIT_TIMEOUT`
553/// (same minutes), so a run with nobody to answer, such as `e2e/agent.sh`, can make every wait short
554/// without changing what agents are told; else the default.
555fn wait_timeout(args: &mut Vec<String>) -> Result<Option<Duration>> {
556    if args.is_empty() {
557        let Some(value) = std::env::var_os("KANBRAIN_WAIT_TIMEOUT") else {
558            return Ok(Some(WAIT_DEFAULT));
559        };
560        return minutes(&value.to_string_lossy()).context("KANBRAIN_WAIT_TIMEOUT must be a number of minutes");
561    }
562    if args.remove(0) != "--timeout" || args.is_empty() {
563        bail!("wait takes an id, then optionally --timeout MINUTES");
564    }
565    minutes(&args.remove(0))
566}
567
568/// A number of minutes as a time limit, `None` for 0 (no limit).
569fn minutes(arg: &str) -> Result<Option<Duration>> {
570    let minutes: u64 = arg.parse().with_context(|| format!("`{arg}` isn't a number of minutes"))?;
571    let seconds = minutes.checked_mul(60).with_context(|| format!("`{arg}` minutes is longer than any wait"))?;
572    Ok((minutes > 0).then(|| Duration::from_secs(seconds)))
573}
574
575/// Blocks until entry `id` no longer waits on a person, then prints what they said; an error once
576/// `timeout` passes without it. An agent runs it in the background after `ask` or `propose`, where its
577/// tool wakes it when it exits: one wake-up with the answer, instead of checking by turns.
578fn wait(brain: &Brain, id: u32, timeout: Option<Duration>) -> Result<()> {
579    let started = std::time::Instant::now();
580    let mut watch = Watch::default();
581    loop {
582        if let Some(said) = watch.look(id, brain.store.load().map(|(tasks, _)| tasks)) {
583            println!("{}", said?);
584            return Ok(());
585        }
586        if timeout.is_some_and(|limit| started.elapsed() >= limit) {
587            bail!("no answer on #{id} yet: stop here and don't wait again; once answered, it comes first in `context`");
588        }
589        std::thread::sleep(WAIT_POLL);
590    }
591}
592
593/// What `wait` keeps from one look at the brain to the next, apart from the clock so a test can play
594/// a run of looks without sleeping through them.
595#[derive(Default)]
596struct Watch {
597    /// The question it found waiting: answered and then moved before the next look, the answer is still what's said.
598    asked_at: Option<chrono::DateTime<chrono::Local>>,
599    /// Whether it looked before: the first look fails at once on a brain it can't read or without the entry, as
600    /// waiting on either would only wait out the time limit.
601    looked: bool,
602    /// Whether the entry was missing from the last look: its file renamed for a new title between listing the
603    /// folder and reading it is back by the next one, so only missing twice in a row it was deleted.
604    missed: bool,
605}
606
607impl Watch {
608    /// Takes one look's read of the brain: `Some` ends the wait, with what to print or why it can't go on;
609    /// `None` looks again.
610    fn look(&mut self, id: u32, read: Result<Vec<Task>>) -> Option<Result<String>> {
611        let first = !std::mem::replace(&mut self.looked, true);
612        // After the first look, a read that fails (the folder caught mid-change) is looked again at next time.
613        match read {
614            Ok(tasks) => match tasks.into_iter().find(|t| t.id == id) {
615                None if first || self.missed => Some(Err(ApiError::NotFound(id).into())),
616                None => {
617                    self.missed = true;
618                    None
619                }
620                Some(task) => {
621                    self.missed = false;
622                    if let Some(Asked::Waiting(question)) = task.asked() {
623                        self.asked_at.get_or_insert(question.created);
624                    }
625                    settled(&task, self.asked_at).map(Ok)
626                }
627            },
628            Err(e) if first => Some(Err(e)),
629            Err(_) => None,
630        }
631    }
632}
633
634/// What `wait` prints once `task` no longer waits on a person, or `None` while it does: a decision that
635/// left proposed, with its verdict's comment; a task's answer, also when it was moved after it (the question
636/// `wait` found waiting was asked at `asked_at`); or that the task waits on nothing (never asked, or picked up since).
637fn settled(task: &Task, asked_at: Option<chrono::DateTime<chrono::Local>>) -> Option<String> {
638    let head = format!("#{} {}", task.id, task.title);
639    if task.kind == Kind::Decision {
640        if task.decision == DecisionState::Proposed {
641            return None;
642        }
643        let verdict = task.decision.as_str();
644        return Some(match task.comments.last() {
645            Some(why) => format!("{head}: {verdict}\n\n{}", why.body),
646            None => format!("{head}: {verdict}"),
647        });
648    }
649    match task.asked() {
650        Some(Asked::Waiting(_)) => None,
651        Some(Asked::Answered(answer)) => Some(format!("{head}: answered\n\n{}", answer.body)),
652        None if task.is_task() => {
653            let answers = |c: &&crate::model::Comment| c.author == crate::model::Author::Human || c.answer;
654            let answer = asked_at
655                .and_then(|at| task.comments.iter().rev().find(|c| c.created >= at && !c.question && answers(c)));
656            Some(match answer {
657                Some(answer) => format!("{head}: answered, now {}\n\n{}", task.status.as_str(), answer.body),
658                None => format!("{head}: not waiting for an answer ({})", task.status.as_str()),
659            })
660        }
661        // A note has no status to name.
662        None => Some(format!("{head}: a note, not waiting for an answer")),
663    }
664}
665
666/// An entry's JSON as `show --json` prints it: [`Entry`], and the entries of the project's other repositories
667/// linking to it, as `web#1`, which `show` lists under Related along with its own `repo_links`.
668#[derive(Serialize)]
669struct Shown<'a> {
670    /// Its fields first, at the top level.
671    #[serde(flatten)]
672    entry: Entry<'a>,
673    /// Left out when empty, as `Entry`'s links are.
674    #[serde(skip_serializing_if = "Vec::is_empty")]
675    repo_linked_from: Vec<String>,
676}
677
678impl<'a> Shown<'a> {
679    /// Reads the project's other repositories for the links to `task`.
680    fn new(brain: &Brain, task: &'a Task) -> Self {
681        let here = brain.project.as_ref().map(|p| RepoLink { repo: p.repo.clone(), id: task.id });
682        let repo_linked_from = brain
683            .repo_related(task.id)
684            .into_iter()
685            .filter(|(_, other)| here.as_ref().is_some_and(|here| other.repo_links.contains(here)))
686            .map(|(repo, other)| RepoLink { repo, id: other.id }.to_string())
687            .collect();
688        Self { entry: Entry::new(brain, task), repo_linked_from }
689    }
690}
691
692/// Prints the entry: as one line of JSON, as compact text (`show`), or as a one-line confirmation.
693/// JSON is on one line since its readers are programs, and agents pay for the indentation.
694fn print_entry(brain: &Brain, id: u32, json: bool, full: bool) -> Result<()> {
695    let task = brain.entry(id)?;
696    if json {
697        println!("{}", serde_json::to_string(&Shown::new(brain, task))?);
698    } else if full {
699        print!("{}", brain.entry_text(id)?);
700    } else {
701        println!("{}", line(task));
702    }
703    Ok(())
704}
705
706/// Prints search results, one `- #id title (state) #tag` line each, or every match as JSON.
707fn print_search(brain: &Brain, query: &str, json: bool) -> Result<()> {
708    if json {
709        let summaries: Vec<Summary> = brain.search(query).into_iter().map(Summary::new).collect();
710        println!("{}", serde_json::to_string(&summaries)?);
711    } else {
712        print!("{}", brain.search_text(query));
713    }
714    Ok(())
715}
716
717#[cfg(test)]
718#[path = "../../tests/cli/entries.rs"]
719mod tests;