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, one_line, parse_links, parse_tags};
11use crate::projects::ProjectInfo;
12use crate::store;
13use anyhow::{Context, Result, anyhow, bail};
14use serde::Serialize;
15use std::io::Read;
16use std::time::Duration;
17
18const CONTEXT_USAGE: &str = "\
21Commands: `kb show ID` (everything about an entry), `search WORDS`, \
22`add [--note|--decision] [--tag T] [--link ID] [--body TEXT|-] TITLE`, \
23`comment ID TEXT|-`, `ask ID TEXT|-`, `wait ID`, `start|park|done|todo ID`, \
24`link ID OTHER…` (`REPO#ID` in another repository). Add `--json` for JSON.";
25
26pub const USAGE: &str = "\
28 add [--note|--decision] [--tag TAG]… [--link ID]… [--body TEXT|-] [--start] TITLE…
29 create a task (or note, decision) and print its id; `-` reads the body from stdin
30 show ID print an entry compactly: state, time, description, comments, related, history
31 search WORDS…
32 find entries by title, tags, description or comments (#tag: tags only)
33 start|park|done|todo ID
34 change a task's status (start runs its clock; a person runs one task at a time)
35 comment ID TEXT…|-
36 add a comment, e.g. progress notes
37 ask ID TEXT…|-
38 ask the user a question on a task and park it until a person answers with a comment
39 wait ID [--timeout MINUTES]
40 block until a person answers what task ID waits on (or decision ID is accepted or
41 rejected), then print the answer; gives up after 8 minutes (0: never)
42 propose ID [--tag TAG]… [--link ID]… [--body TEXT|-] TITLE…
43 propose a decision for a task: linked to it, which asks about it and is parked
44 accept ID [TEXT…|-]
45 reject ID TEXT…|-
46 accept or reject a decision, with why (needed to reject); answers a question that names it (#ID)
47 describe ID TEXT…|-
48 replace the description
49 rename ID TITLE…
50 replace the title (and its file's name)
51 tag ID +TAG|-TAG…
52 add tags to an entry (+TAG) or remove them (-TAG), in order
53 tag OLD NEW
54 rename a tag in every entry that has it
55 link ID OTHER…
56 link an entry to others: an id here, or REPO#ID in another repository of the project
57 project [describe|notes TEXT…|-]
58 show the project's description (shared by its repositories) and this repository's role,
59 or replace one
60 project rename NAME… | repo NAME | move PROJECT… | remove [--purge]
61 rename the project, or this repository in it; move this repository to another project;
62 take every repository of the project out (--purge: and delete their tasks)
63 context what's going on: active tasks, decisions and notes, for an agent to read first";
64
65#[derive(Debug, PartialEq, Eq)]
67#[non_exhaustive]
68pub struct EntryArgs {
69 pub command: EntryCommand,
71 pub json: bool,
73}
74
75#[derive(Debug, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum EntryCommand {
79 #[non_exhaustive]
81 Add(AddArgs),
82 #[non_exhaustive]
84 Show(u32),
85 #[non_exhaustive]
87 Search(String),
88 #[non_exhaustive]
90 SetStatus(u32, Status),
91 #[non_exhaustive]
93 Comment(u32, Text),
94 #[non_exhaustive]
96 Ask(u32, Text),
97 #[non_exhaustive]
99 Wait(u32, Option<Duration>),
100 #[non_exhaustive]
102 Propose(u32, AddArgs),
103 #[non_exhaustive]
105 Resolve(u32, DecisionState, Option<Text>),
106 #[non_exhaustive]
108 Describe(u32, Text),
109 #[non_exhaustive]
111 Rename(u32, String),
112 #[non_exhaustive]
114 Tag(u32, Vec<TagChange>),
115 #[non_exhaustive]
117 RenameTag(String, String, bool),
118 #[non_exhaustive]
120 Link(u32, Vec<u32>, Vec<RepoLink>),
121 #[non_exhaustive]
123 Context,
124 #[non_exhaustive]
126 Project(Option<ProjectChange>),
127}
128
129#[derive(Debug, PartialEq, Eq)]
131#[non_exhaustive]
132pub enum ProjectChange {
133 #[non_exhaustive]
135 Text(ProjectPart, Text),
136 #[non_exhaustive]
138 Rename(String, bool),
139 #[non_exhaustive]
141 Repo(String),
142 #[non_exhaustive]
144 Move(String),
145 #[non_exhaustive]
147 Remove(bool),
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152#[non_exhaustive]
153pub enum ProjectPart {
154 #[non_exhaustive]
156 Description,
157 #[non_exhaustive]
159 Notes,
160}
161
162#[derive(Debug, PartialEq, Eq)]
164#[non_exhaustive]
165pub enum TagChange {
166 #[non_exhaustive]
168 Add(String),
169 #[non_exhaustive]
171 Remove(String),
172}
173
174#[derive(Debug, PartialEq, Eq)]
176#[non_exhaustive]
177pub enum Text {
178 #[non_exhaustive]
180 Given(String),
181 #[non_exhaustive]
183 Stdin,
184}
185
186#[derive(Debug, PartialEq, Eq, Default)]
188#[non_exhaustive]
189pub struct AddArgs {
190 pub kind: Kind,
192 pub title: String,
194 pub tags: Vec<String>,
196 pub links: Vec<u32>,
198 pub repo_links: Vec<RepoLink>,
200 pub body: Option<Text>,
202 pub start: bool,
204}
205
206impl EntryArgs {
207 pub fn read_stdin(&mut self) -> Result<()> {
209 let text = match &mut self.command {
210 EntryCommand::Add(args) | EntryCommand::Propose(_, args) => args.body.as_mut(),
211 EntryCommand::Comment(_, text)
212 | EntryCommand::Ask(_, text)
213 | EntryCommand::Describe(_, text)
214 | EntryCommand::Project(Some(ProjectChange::Text(_, text))) => Some(text),
215 EntryCommand::Resolve(_, _, reason) => reason.as_mut(),
216 EntryCommand::Show(_)
217 | EntryCommand::Search(_)
218 | EntryCommand::SetStatus(..)
219 | EntryCommand::Wait(..)
220 | EntryCommand::Rename(..)
221 | EntryCommand::Tag(..)
222 | EntryCommand::RenameTag(..)
223 | EntryCommand::Link(..)
224 | EntryCommand::Context
225 | EntryCommand::Project(_) => None,
226 };
227 text.map_or(Ok(()), Text::read_stdin)
228 }
229
230 pub fn parse(name: &str, args: impl Iterator<Item = String>) -> Result<Option<Self>> {
232 let mut json = false;
234 let mut rest = Vec::new();
235 let mut text = false;
236 for arg in args {
237 text |= arg == "--";
238 if arg == "--json" && !text {
239 json = true;
240 } else {
241 rest.push(arg);
242 }
243 }
244 let status = match name {
245 "start" => Some(Status::Doing),
246 "park" => Some(Status::Parked),
247 "done" => Some(Status::Done),
248 "todo" => Some(Status::Todo),
249 _ => None,
250 };
251 let command = match (name, status) {
252 (_, Some(status)) => EntryCommand::SetStatus(first_id(&mut rest)?, status),
253 ("add", None) => EntryCommand::Add(AddArgs::parse(rest.drain(..), "add needs a title")?),
254 ("show", None) => EntryCommand::Show(first_id(&mut rest)?),
255 ("search", None) => {
256 match search_words(std::mem::take(&mut rest)) {
257 words if words.trim().is_empty() => {
259 bail!("search needs words to look for, or a #tag; `kb list` lists them all")
260 }
261 words => EntryCommand::Search(words),
262 }
263 }
264 ("comment", None) => {
265 let (id, text) = id_and_text(&mut rest, "comment needs its text")?;
266 EntryCommand::Comment(id, text)
267 }
268 ("ask", None) => {
269 let (id, text) = id_and_text(&mut rest, "ask needs the question")?;
270 EntryCommand::Ask(id, text)
271 }
272 ("wait", None) => {
273 let id = first_id(&mut rest)?;
274 EntryCommand::Wait(id, wait_timeout(&mut rest)?)
275 }
276 ("propose", None) => {
277 let id = first_id(&mut rest)?;
278 let decision = AddArgs::parse(rest.drain(..), "propose needs the decision's title")?;
279 if decision.kind != Kind::Task || decision.start {
280 bail!("propose makes a decision: leave out --note, --decision and --start");
281 }
282 EntryCommand::Propose(id, decision)
283 }
284 ("accept", None) => {
285 let id = first_id(&mut rest)?;
286 let reason =
287 if rest.is_empty() { None } else { Some(rest_as_text(&mut rest, "accept needs its comment")?) };
288 EntryCommand::Resolve(id, DecisionState::Accepted, reason)
289 }
290 ("reject", None) => {
291 let id = first_id(&mut rest)?;
292 let reason = rest_as_text(&mut rest, "reject needs why")?;
293 EntryCommand::Resolve(id, DecisionState::Rejected, Some(reason))
294 }
295 ("describe", None) => {
296 let (id, text) = id_and_text(&mut rest, "describe needs the description")?;
297 EntryCommand::Describe(id, text)
298 }
299 ("rename", None) => {
300 let id = first_id(&mut rest)?;
301 EntryCommand::Rename(id, new_title(std::mem::take(&mut rest))?)
302 }
303 ("tag", None) => match std::mem::take(&mut rest).as_slice() {
304 words @ [old, new] if !new.starts_with(['+', '-']) => {
306 let (old, new) = (one_tag(old)?, one_tag(new)?);
307 if old == new {
308 bail!("#{old} is already its name");
309 }
310 EntryCommand::RenameTag(old, new, words[0].starts_with('#'))
311 }
312 words => {
313 let mut words = words.to_vec();
314 let id = first_id(&mut words)?;
315 EntryCommand::Tag(id, tag_changes(words)?)
316 }
317 },
318 ("link", None) => {
319 let id = first_id(&mut rest)?;
320 let (others, repo_links) = typed_links(&std::mem::take(&mut rest).join(" "))?;
321 if others.is_empty() && repo_links.is_empty() {
322 bail!("link needs the ids to link to");
323 }
324 EntryCommand::Link(id, others, repo_links)
325 }
326 ("context", None) => EntryCommand::Context,
327 ("project", None) => EntryCommand::Project(project_args(&mut rest)?),
328 _ => return Ok(None),
329 };
330 if let Some(extra) = rest.first() {
331 bail!("unexpected argument `{extra}`");
332 }
333 Ok(Some(Self { command, json }))
334 }
335
336 pub fn run(self, mut brain: Brain) -> Result<()> {
338 let json = self.json;
339 let show = matches!(self.command, EntryCommand::Show(_));
340 let id = match self.command {
341 EntryCommand::Add(args) => brain.add(args.into_new_entry()?)?,
342 EntryCommand::Show(id) => {
343 brain.entry(id)?;
344 id
345 }
346 EntryCommand::Search(query) => return print_search(&brain, &query, json),
347 EntryCommand::SetStatus(id, status) => {
348 if !brain.set_status(id, status)? {
350 eprintln!("#{id} was already {}: nothing changed", status.as_str());
351 }
352 id
353 }
354 EntryCommand::Comment(id, text) => {
355 brain.add_comment(id, &text.read()?)?;
356 id
357 }
358 EntryCommand::Ask(id, text) => {
359 brain.ask(id, &text.read()?)?;
360 id
361 }
362 EntryCommand::Wait(..) if json => bail!("wait prints the answer as text only"),
363 EntryCommand::Wait(id, timeout) => return wait(&brain, id, timeout),
364 EntryCommand::Propose(task, args) => {
365 let decision = brain.propose(task, args.into_new_entry()?)?;
366 if !json {
367 println!("{} · {}", line(brain.entry(decision)?), line(brain.entry(task)?));
368 return Ok(());
369 }
370 decision
371 }
372 EntryCommand::Resolve(id, state, reason) => {
373 let reason = reason.map(Text::read).transpose()?.unwrap_or_default();
374 if !brain.resolve(id, state, &reason)? {
375 eprintln!("#{id} was already {}: nothing changed", state.as_str());
376 }
377 id
378 }
379 EntryCommand::Describe(id, text) => {
380 brain.set_description(id, &text.read()?)?;
381 id
382 }
383 EntryCommand::Rename(id, title) => {
384 if !retitle(&mut brain, id, &title)? {
385 eprintln!("#{id} already had that title: nothing changed");
386 }
387 id
388 }
389 EntryCommand::Tag(id, changes) => {
390 if !retag(&mut brain, id, &changes)? {
391 eprintln!("#{id}'s tags were already so: nothing changed");
392 }
393 id
394 }
395 EntryCommand::RenameTag(old, new, hashed) => return rename_tag(&mut brain, &old, &new, hashed, json),
396 EntryCommand::Link(id, others, repo_links) => {
397 brain.link(id, &others, &repo_links)?;
398 id
399 }
400 EntryCommand::Context if json => bail!("context is markdown only; use search or show with --json"),
401 EntryCommand::Project(_) if json => bail!("project is markdown only"),
402 EntryCommand::Project(change) => return run_project(&mut brain, change),
403 EntryCommand::Context => {
404 let dir = brain.store.dir();
407 if let Some(root) = dir.parent().filter(|_| dir.file_name() == Some(store::PROJECT_DIR.as_ref())) {
408 let _ = super::claude::update_hooks(root);
409 }
410 let usage = if brain.project.is_some() { "" } else { CONTEXT_USAGE };
411 println!("{}{usage}", brain.context_markdown());
412 return Ok(());
413 }
414 };
415 print_entry(&brain, id, json, show)
416 }
417}
418
419impl AddArgs {
420 fn parse(mut args: impl Iterator<Item = String>, no_title: &str) -> Result<Self> {
422 let mut add = Self::default();
423 let mut title = Vec::new();
424 while let Some(arg) = args.next() {
425 match arg.as_str() {
426 "--note" => add.kind = Kind::Note,
427 "--decision" => add.kind = Kind::Decision,
428 "--start" => add.start = true,
429 "--tag" => add.tags.extend(parse_tags(&args.next().context("--tag needs a value")?)),
430 "--link" => {
431 let (links, repo_links) = typed_links(&args.next().context("--link needs an id")?)?;
432 add.links.extend(links);
433 add.repo_links.extend(repo_links);
434 }
435 "--body" => {
436 let body = args.next().context("--body needs text, or - to read stdin")?;
437 add.body = Some(if body == "-" { Text::Stdin } else { Text::Given(body) });
438 }
439 "--" => title.extend(args.by_ref()),
441 other if other.starts_with("--") => bail!("unknown add option `{other}`"),
442 _ => title.push(arg),
443 }
444 }
445 add.title = title.join(" ");
446 if add.title.trim().is_empty() {
447 bail!("{no_title}");
448 }
449 if add.start && add.kind != Kind::Task {
450 bail!("only tasks can be started");
451 }
452 Ok(add)
453 }
454
455 fn into_new_entry(self) -> Result<NewEntry> {
457 Ok(NewEntry {
458 kind: self.kind,
459 title: self.title,
460 tags: self.tags,
461 links: self.links,
462 repo_links: self.repo_links,
463 description: self.body.map(Text::read).transpose()?,
464 start: self.start,
465 })
466 }
467}
468
469impl Text {
470 fn read_stdin(&mut self) -> Result<()> {
472 if *self == Text::Stdin {
473 *self = Text::Given(stdin_text()?);
474 }
475 Ok(())
476 }
477
478 fn read(self) -> Result<String> {
480 match self {
481 Text::Given(text) => Ok(text),
482 Text::Stdin => stdin_text(),
483 }
484 }
485}
486
487fn stdin_text() -> Result<String> {
490 let mut text = String::new();
491 std::io::stdin().read_to_string(&mut text).context("reading stdin")?;
492 if text.trim().is_empty() {
493 bail!("nothing came on stdin for `-`: give the text there, e.g. with a heredoc (<<'EOF' … EOF)");
494 }
495 Ok(text)
496}
497
498fn search_words(mut words: Vec<String>) -> String {
501 if let Some(at) = words.iter().position(|w| w == "--") {
502 words.remove(at);
503 }
504 words.join(" ")
505}
506
507fn typed_links(value: &str) -> Result<(Vec<u32>, Vec<RepoLink>)> {
509 if let Some(word) = not_a_link(value) {
510 bail!("`{word}` isn't an entry id (12 or #12) or another repository's entry (REPO#ID)");
511 }
512 Ok(parse_links(value))
513}
514
515fn new_title(words: Vec<String>) -> Result<String> {
518 let mut title = Vec::new();
519 let mut words = words.into_iter();
520 while let Some(word) = words.next() {
521 match word.as_str() {
522 "--" => title.extend(words.by_ref()),
523 other if other.starts_with("--") => bail!("unknown rename option `{other}`"),
524 _ => title.push(word),
525 }
526 }
527 let title = title.join(" ");
528 if title.trim().is_empty() {
529 bail!("rename needs the new title");
530 }
531 Ok(title)
532}
533
534fn retitle(brain: &mut Brain, id: u32, title: &str) -> Result<bool> {
537 let entry = brain.entry(id)?;
538 let title = one_line(title);
539 if title == entry.title {
540 return Ok(false);
541 }
542 let tags = entry.tags.clone();
543 brain.update(id, &title, tags)?;
544 Ok(true)
545}
546
547const TAG_SIGNS: &str = "+TAG adds a tag, -TAG removes one";
549
550fn tag_changes(words: Vec<String>) -> Result<Vec<TagChange>> {
553 let mut changes = Vec::new();
554 for word in words {
555 let change = match word.as_bytes() {
556 [b'-', b'-'] => bail!("unexpected argument `--`"),
558 [b'-', b'-', ..] => bail!("unknown tag option `{word}`: {TAG_SIGNS}"),
559 [b'+', ..] => TagChange::Add,
560 [b'-', ..] => TagChange::Remove,
561 _ => bail!("`{word}`: {TAG_SIGNS}"),
562 };
563 let tags = parse_tags(&word[1..]);
564 if tags.is_empty() {
565 bail!("`{word}` names no tag");
566 }
567 changes.extend(tags.into_iter().map(change));
568 }
569 if changes.is_empty() {
570 bail!("tag needs what to change: {TAG_SIGNS}");
571 }
572 Ok(changes)
573}
574
575fn retag(brain: &mut Brain, id: u32, changes: &[TagChange]) -> Result<bool> {
578 let entry = brain.entry(id)?;
579 let mut tags = entry.tags.clone();
580 for change in changes {
581 match change {
582 TagChange::Add(tag) if !tags.contains(tag) => tags.push(tag.clone()),
583 TagChange::Add(_) => {}
584 TagChange::Remove(tag) => tags.retain(|t| t != tag),
585 }
586 }
587 if tags == entry.tags {
588 return Ok(false);
589 }
590 let title = entry.title.clone();
591 brain.update(id, &title, tags)?;
592 Ok(true)
593}
594
595fn one_tag(word: &str) -> Result<String> {
597 match parse_tags(word).as_slice() {
598 [tag] => Ok(tag.clone()),
599 [] => bail!("`{word}` names no tag"),
600 _ => bail!("`{word}` names several tags: tag OLD NEW renames one"),
601 }
602}
603
604fn rename_tag(brain: &mut Brain, old: &str, new: &str, hashed: bool, json: bool) -> Result<()> {
608 if !hashed && old.parse().ok().and_then(|id| brain.task(id)).is_some() {
609 bail!("#{old} is an entry: to tag it, `kb tag {old} +{new}`; to rename tag #{old}, `kb tag '#{old}' {new}`");
610 }
611 let renamed = brain.rename_tag(old, new)?;
612 let entries = renamed.iter().filter_map(|&id| brain.task(id));
613 if json {
614 println!("{}", serde_json::to_string(&entries.map(Summary::new).collect::<Vec<_>>())?);
615 } else {
616 entries.for_each(|task| println!("{}", line(task)));
617 }
618 Ok(())
619}
620
621fn first_id(args: &mut Vec<String>) -> Result<u32> {
623 if args.is_empty() {
624 bail!("an entry id is needed");
625 }
626 let arg = args.remove(0);
627 arg.trim_start_matches('#').parse().map_err(|_| anyhow!("`{arg}` isn't an entry id"))
629}
630
631fn id_and_text(args: &mut Vec<String>, needs: &str) -> Result<(u32, Text)> {
633 let id = first_id(args)?;
634 Ok((id, rest_as_text(args, needs)?))
635}
636
637fn rest_as_text(args: &mut Vec<String>, needs: &str) -> Result<Text> {
639 let mut words = std::mem::take(args);
640 if let Some(at) = words.iter().position(|w| w == "--") {
642 words.remove(at);
643 }
644 match words.as_slice() {
645 [] => bail!("{needs}: TEXT, or - to read it from stdin"),
646 [dash] if dash == "-" => Ok(Text::Stdin),
647 _ => Ok(Text::Given(words.join(" "))),
648 }
649}
650
651fn project_args(args: &mut Vec<String>) -> Result<Option<ProjectChange>> {
653 if args.is_empty() {
654 return Ok(None);
655 }
656 let command = args.remove(0);
657 let words = |args: &mut Vec<String>, what: &str| match std::mem::take(args).join(" ") {
659 name if name.trim().is_empty() => bail!("project {command} needs {what}"),
660 name => Ok(name),
661 };
662 Ok(Some(match command.as_str() {
663 "describe" => {
664 ProjectChange::Text(ProjectPart::Description, rest_as_text(args, "project describe needs the description")?)
665 }
666 "notes" => ProjectChange::Text(ProjectPart::Notes, rest_as_text(args, "project notes needs the notes")?),
667 "rename" => {
668 let literal = args.first().is_some_and(|a| a == "--");
669 if literal {
670 args.remove(0);
671 }
672 ProjectChange::Rename(words(args, "the new name")?, literal)
673 }
674 "repo" => ProjectChange::Repo(words(args, "the repository's new name")?),
675 "move" => ProjectChange::Move(words(args, "the project to move to")?),
676 "remove" => {
677 let purge = args.first().is_some_and(|a| a == "--purge");
678 if purge {
679 args.remove(0);
680 }
681 ProjectChange::Remove(purge)
682 }
683 other => bail!("unknown project command `{other}`: describe, notes, rename, repo, move or remove"),
684 }))
685}
686
687fn project_text(project: &ProjectInfo) -> String {
691 fn shown(text: &str) -> &str {
693 if text.is_empty() { "(empty)" } else { text }
694 }
695 let card = format!("## Project {}\n\n{}\n", project.name, shown(&project.description));
696 if project.siblings.is_empty() && project.notes.is_empty() {
697 return card;
698 }
699 format!("{card}\n## Repository {}\n\n{}\n", project.repo, shown(&project.notes))
700}
701
702fn run_project(brain: &mut Brain, change: Option<ProjectChange>) -> Result<()> {
704 let Some(project) = &brain.project else {
705 bail!("this isn't a project: `kb init` makes it one, or use --project NAME");
706 };
707 let name = project.name.clone();
708 let central = brain.central.clone().context("the central brain isn't known here")?;
709 let dir = brain.store.dir().to_path_buf();
710 let done = match change {
711 None => {
712 print!("{}", project_text(project));
713 return Ok(());
714 }
715 Some(ProjectChange::Text(ProjectPart::Description, text)) => {
716 brain.set_project_description(&text.read()?)?;
717 vec![format!("project {name}: description saved")]
718 }
719 Some(ProjectChange::Text(ProjectPart::Notes, text)) => {
720 brain.set_repo_notes(&text.read()?)?;
721 vec![format!("project {name}: repository role saved")]
722 }
723 Some(ProjectChange::Rename(new, false))
725 if new.strip_prefix(&name).is_some_and(|rest| rest.starts_with(' ')) =>
726 {
727 let rest = new[name.len()..].trim();
728 bail!(
729 "`project rename` takes the new name only, for this project: `kb project rename {rest}`; to name it \
730 `{new}`, `kb project rename -- {new}`"
731 )
732 }
733 Some(ProjectChange::Rename(new, _)) => manage::rename_project(¢ral, &name, &new)?,
734 Some(ProjectChange::Repo(new)) => manage::rename_repo(¢ral, &dir, &new)?,
735 Some(ProjectChange::Move(to)) => manage::move_repo(¢ral, &dir, &to)?,
736 Some(ProjectChange::Remove(purge)) => manage::remove_project(¢ral, &name, purge)?,
737 };
738 for line in &done {
739 println!("{line}");
740 }
741 Ok(())
742}
743
744const WAIT_POLL: Duration = Duration::from_secs(1);
747
748const WAIT_DEFAULT: Duration = Duration::from_secs(8 * 60);
754
755fn wait_timeout(args: &mut Vec<String>) -> Result<Option<Duration>> {
759 if args.is_empty() {
760 let Some(value) = std::env::var_os("KANBRAIN_WAIT_TIMEOUT") else {
761 return Ok(Some(WAIT_DEFAULT));
762 };
763 return minutes(&value.to_string_lossy()).context("KANBRAIN_WAIT_TIMEOUT must be a number of minutes");
764 }
765 if args.remove(0) != "--timeout" || args.is_empty() {
766 bail!("wait takes an id, then optionally --timeout MINUTES");
767 }
768 minutes(&args.remove(0))
769}
770
771fn minutes(arg: &str) -> Result<Option<Duration>> {
773 let minutes: u64 = arg.parse().with_context(|| format!("`{arg}` isn't a number of minutes"))?;
774 let seconds = minutes.checked_mul(60).with_context(|| format!("`{arg}` minutes is longer than any wait"))?;
775 Ok((minutes > 0).then(|| Duration::from_secs(seconds)))
776}
777
778fn wait(brain: &Brain, id: u32, timeout: Option<Duration>) -> Result<()> {
782 let started = std::time::Instant::now();
783 let mut watch = Watch::default();
784 loop {
785 if let Some(said) = watch.look(id, brain.store.load().map(|(tasks, _)| tasks)) {
786 println!("{}", said?);
787 return Ok(());
788 }
789 if timeout.is_some_and(|limit| started.elapsed() >= limit) {
790 bail!("no answer on #{id} yet: stop here and don't wait again; once answered, it comes first in `context`");
791 }
792 std::thread::sleep(WAIT_POLL);
793 }
794}
795
796#[derive(Default)]
799struct Watch {
800 asked_at: Option<chrono::DateTime<chrono::Local>>,
802 looked: bool,
805 missed: bool,
808}
809
810impl Watch {
811 fn look(&mut self, id: u32, read: Result<Vec<Task>>) -> Option<Result<String>> {
814 let first = !std::mem::replace(&mut self.looked, true);
815 match read {
817 Ok(tasks) => match tasks.into_iter().find(|t| t.id == id) {
818 None if first || self.missed => Some(Err(ApiError::NotFound(id).into())),
819 None => {
820 self.missed = true;
821 None
822 }
823 Some(task) => {
824 self.missed = false;
825 if let Some(Asked::Waiting(question)) = task.asked() {
826 self.asked_at.get_or_insert(question.created);
827 }
828 settled(&task, self.asked_at).map(Ok)
829 }
830 },
831 Err(e) if first => Some(Err(e)),
832 Err(_) => None,
833 }
834 }
835}
836
837fn settled(task: &Task, asked_at: Option<chrono::DateTime<chrono::Local>>) -> Option<String> {
841 let head = format!("#{} {}", task.id, task.title);
842 if task.kind == Kind::Decision {
843 if task.decision == DecisionState::Proposed {
844 return None;
845 }
846 let verdict = task.decision.as_str();
847 return Some(match task.comments.last() {
848 Some(why) => format!("{head}: {verdict}\n\n{}", why.body),
849 None => format!("{head}: {verdict}"),
850 });
851 }
852 match task.asked() {
853 Some(Asked::Waiting(_)) => None,
854 Some(Asked::Answered(answer)) => Some(format!("{head}: answered\n\n{}", answer.body)),
855 None if task.is_task() => {
856 let answers = |c: &&crate::model::Comment| c.author == crate::model::Author::Human || c.answer;
857 let answer = asked_at
858 .and_then(|at| task.comments.iter().rev().find(|c| c.created >= at && !c.question && answers(c)));
859 Some(match answer {
860 Some(answer) => format!("{head}: answered, now {}\n\n{}", task.status.as_str(), answer.body),
861 None => format!("{head}: not waiting for an answer ({})", task.status.as_str()),
862 })
863 }
864 None => Some(format!("{head}: a note, not waiting for an answer")),
866 }
867}
868
869#[derive(Serialize)]
872struct Shown<'a> {
873 #[serde(flatten)]
875 entry: Entry<'a>,
876 #[serde(skip_serializing_if = "Vec::is_empty")]
878 repo_linked_from: Vec<String>,
879}
880
881impl<'a> Shown<'a> {
882 fn new(brain: &Brain, task: &'a Task) -> Self {
884 let here = brain.project.as_ref().map(|p| RepoLink { repo: p.repo.clone(), id: task.id });
885 let repo_linked_from = brain
886 .repo_related(task.id)
887 .into_iter()
888 .filter(|(_, other)| here.as_ref().is_some_and(|here| other.repo_links.contains(here)))
889 .map(|(repo, other)| RepoLink { repo, id: other.id }.to_string())
890 .collect();
891 Self { entry: Entry::new(brain, task), repo_linked_from }
892 }
893}
894
895fn print_entry(brain: &Brain, id: u32, json: bool, full: bool) -> Result<()> {
898 let task = brain.entry(id)?;
899 if json {
900 println!("{}", serde_json::to_string(&Shown::new(brain, task))?);
901 } else if full {
902 print!("{}", brain.entry_text(id)?);
903 } else {
904 println!("{}", line(task));
905 }
906 Ok(())
907}
908
909fn print_search(brain: &Brain, query: &str, json: bool) -> Result<()> {
911 if json {
912 let summaries: Vec<Summary> = brain.search(query).into_iter().map(Summary::new).collect();
913 println!("{}", serde_json::to_string(&summaries)?);
914 } else {
915 print!("{}", brain.search_text(query));
916 }
917 Ok(())
918}
919
920#[cfg(test)]
921#[path = "../../tests/cli/entries.rs"]
922mod tests;