1use 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
17const 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
25pub 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#[derive(Debug, PartialEq, Eq)]
60pub struct EntryArgs {
61 pub command: EntryCommand,
63 pub json: bool,
65}
66
67#[derive(Debug, PartialEq, Eq)]
69pub enum EntryCommand {
70 Add(AddArgs),
72 Show(u32),
74 Search(String),
76 SetStatus(u32, Status),
78 Comment(u32, Text),
80 Ask(u32, Text),
82 Wait(u32, Option<Duration>),
84 Propose(u32, AddArgs),
86 Resolve(u32, DecisionState, Option<Text>),
88 Describe(u32, Text),
90 Link(u32, Vec<u32>, Vec<RepoLink>),
92 Context,
94 Project(Option<ProjectChange>),
96}
97
98#[derive(Debug, PartialEq, Eq)]
100pub enum ProjectChange {
101 Text(ProjectPart, Text),
103 Rename(String, bool),
105 Repo(String),
107 Move(String),
109 Remove(bool),
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum ProjectPart {
116 Description,
118 Notes,
120}
121
122#[derive(Debug, PartialEq, Eq)]
124pub enum Text {
125 Given(String),
127 Stdin,
129}
130
131#[derive(Debug, PartialEq, Eq, Default)]
133pub struct AddArgs {
134 pub kind: Kind,
136 pub title: String,
138 pub tags: Vec<String>,
140 pub links: Vec<u32>,
142 pub repo_links: Vec<RepoLink>,
144 pub body: Option<Text>,
146 pub start: bool,
148}
149
150impl EntryArgs {
151 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 pub fn parse(name: &str, args: impl Iterator<Item = String>) -> Result<Option<Self>> {
173 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 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 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 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 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 "--" => 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 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 fn read_stdin(&mut self) -> Result<()> {
375 if *self == Text::Stdin {
376 *self = Text::Given(stdin_text()?);
377 }
378 Ok(())
379 }
380
381 fn read(self) -> Result<String> {
383 match self {
384 Text::Given(text) => Ok(text),
385 Text::Stdin => stdin_text(),
386 }
387 }
388}
389
390fn 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
401fn 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
410fn 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
418fn 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 arg.trim_start_matches('#').parse().map_err(|_| anyhow!("`{arg}` isn't an entry id"))
426}
427
428fn 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
434fn rest_as_text(args: &mut Vec<String>, needs: &str) -> Result<Text> {
436 let mut words = std::mem::take(args);
437 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
448fn 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 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
484fn project_text(project: &ProjectInfo) -> String {
488 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
499fn 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 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(¢ral, &name, &new)?,
531 Some(ProjectChange::Repo(new)) => manage::rename_repo(¢ral, &dir, &new)?,
532 Some(ProjectChange::Move(to)) => manage::move_repo(¢ral, &dir, &to)?,
533 Some(ProjectChange::Remove(purge)) => manage::remove_project(¢ral, &name, purge)?,
534 };
535 for line in &done {
536 println!("{line}");
537 }
538 Ok(())
539}
540
541const WAIT_POLL: Duration = Duration::from_secs(1);
544
545const WAIT_DEFAULT: Duration = Duration::from_secs(8 * 60);
551
552fn 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
568fn 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
575fn 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#[derive(Default)]
596struct Watch {
597 asked_at: Option<chrono::DateTime<chrono::Local>>,
599 looked: bool,
602 missed: bool,
605}
606
607impl Watch {
608 fn look(&mut self, id: u32, read: Result<Vec<Task>>) -> Option<Result<String>> {
611 let first = !std::mem::replace(&mut self.looked, true);
612 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
634fn 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 None => Some(format!("{head}: a note, not waiting for an answer")),
663 }
664}
665
666#[derive(Serialize)]
669struct Shown<'a> {
670 #[serde(flatten)]
672 entry: Entry<'a>,
673 #[serde(skip_serializing_if = "Vec::is_empty")]
675 repo_linked_from: Vec<String>,
676}
677
678impl<'a> Shown<'a> {
679 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
692fn 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
706fn 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;