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 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 rename ID TITLE…
49 replace the title (and its file's name)
50 tag ID +TAG|-TAG…
51 add tags to an entry (+TAG) or remove them (-TAG), in order
52 link ID OTHER…
53 link an entry to others: an id here, or REPO#ID in another repository of the project
54 project [describe|notes TEXT…|-]
55 show the project's description (shared by its repositories) and this repository's role,
56 or replace one
57 project rename NAME… | repo NAME | move PROJECT… | remove [--purge]
58 rename the project, or this repository in it; move this repository to another project;
59 take every repository of the project out (--purge: and delete their tasks)
60 context what's going on: active tasks, decisions and notes, for an agent to read first";
61
62#[derive(Debug, PartialEq, Eq)]
64#[non_exhaustive]
65pub struct EntryArgs {
66 pub command: EntryCommand,
68 pub json: bool,
70}
71
72#[derive(Debug, PartialEq, Eq)]
74#[non_exhaustive]
75pub enum EntryCommand {
76 #[non_exhaustive]
78 Add(AddArgs),
79 #[non_exhaustive]
81 Show(u32),
82 #[non_exhaustive]
84 Search(String),
85 #[non_exhaustive]
87 SetStatus(u32, Status),
88 #[non_exhaustive]
90 Comment(u32, Text),
91 #[non_exhaustive]
93 Ask(u32, Text),
94 #[non_exhaustive]
96 Wait(u32, Option<Duration>),
97 #[non_exhaustive]
99 Propose(u32, AddArgs),
100 #[non_exhaustive]
102 Resolve(u32, DecisionState, Option<Text>),
103 #[non_exhaustive]
105 Describe(u32, Text),
106 #[non_exhaustive]
108 Rename(u32, String),
109 #[non_exhaustive]
111 Tag(u32, Vec<TagChange>),
112 #[non_exhaustive]
114 Link(u32, Vec<u32>, Vec<RepoLink>),
115 #[non_exhaustive]
117 Context,
118 #[non_exhaustive]
120 Project(Option<ProjectChange>),
121}
122
123#[derive(Debug, PartialEq, Eq)]
125#[non_exhaustive]
126pub enum ProjectChange {
127 #[non_exhaustive]
129 Text(ProjectPart, Text),
130 #[non_exhaustive]
132 Rename(String, bool),
133 #[non_exhaustive]
135 Repo(String),
136 #[non_exhaustive]
138 Move(String),
139 #[non_exhaustive]
141 Remove(bool),
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146#[non_exhaustive]
147pub enum ProjectPart {
148 #[non_exhaustive]
150 Description,
151 #[non_exhaustive]
153 Notes,
154}
155
156#[derive(Debug, PartialEq, Eq)]
158#[non_exhaustive]
159pub enum TagChange {
160 #[non_exhaustive]
162 Add(String),
163 #[non_exhaustive]
165 Remove(String),
166}
167
168#[derive(Debug, PartialEq, Eq)]
170#[non_exhaustive]
171pub enum Text {
172 #[non_exhaustive]
174 Given(String),
175 #[non_exhaustive]
177 Stdin,
178}
179
180#[derive(Debug, PartialEq, Eq, Default)]
182#[non_exhaustive]
183pub struct AddArgs {
184 pub kind: Kind,
186 pub title: String,
188 pub tags: Vec<String>,
190 pub links: Vec<u32>,
192 pub repo_links: Vec<RepoLink>,
194 pub body: Option<Text>,
196 pub start: bool,
198}
199
200impl EntryArgs {
201 pub fn read_stdin(&mut self) -> Result<()> {
203 let text = match &mut self.command {
204 EntryCommand::Add(args) | EntryCommand::Propose(_, args) => args.body.as_mut(),
205 EntryCommand::Comment(_, text)
206 | EntryCommand::Ask(_, text)
207 | EntryCommand::Describe(_, text)
208 | EntryCommand::Project(Some(ProjectChange::Text(_, text))) => Some(text),
209 EntryCommand::Resolve(_, _, reason) => reason.as_mut(),
210 EntryCommand::Show(_)
211 | EntryCommand::Search(_)
212 | EntryCommand::SetStatus(..)
213 | EntryCommand::Wait(..)
214 | EntryCommand::Rename(..)
215 | EntryCommand::Tag(..)
216 | EntryCommand::Link(..)
217 | EntryCommand::Context
218 | EntryCommand::Project(_) => None,
219 };
220 text.map_or(Ok(()), Text::read_stdin)
221 }
222
223 pub fn parse(name: &str, args: impl Iterator<Item = String>) -> Result<Option<Self>> {
225 let mut json = false;
227 let mut rest = Vec::new();
228 let mut text = false;
229 for arg in args {
230 text |= arg == "--";
231 if arg == "--json" && !text {
232 json = true;
233 } else {
234 rest.push(arg);
235 }
236 }
237 let status = match name {
238 "start" => Some(Status::Doing),
239 "park" => Some(Status::Parked),
240 "done" => Some(Status::Done),
241 "todo" => Some(Status::Todo),
242 _ => None,
243 };
244 let command = match (name, status) {
245 (_, Some(status)) => EntryCommand::SetStatus(first_id(&mut rest)?, status),
246 ("add", None) => EntryCommand::Add(AddArgs::parse(rest.drain(..), "add needs a title")?),
247 ("show", None) => EntryCommand::Show(first_id(&mut rest)?),
248 ("search", None) => {
249 match search_words(std::mem::take(&mut rest)) {
250 words if words.trim().is_empty() => {
252 bail!("search needs words to look for, or a #tag; `kb list` lists them all")
253 }
254 words => EntryCommand::Search(words),
255 }
256 }
257 ("comment", None) => {
258 let (id, text) = id_and_text(&mut rest, "comment needs its text")?;
259 EntryCommand::Comment(id, text)
260 }
261 ("ask", None) => {
262 let (id, text) = id_and_text(&mut rest, "ask needs the question")?;
263 EntryCommand::Ask(id, text)
264 }
265 ("wait", None) => {
266 let id = first_id(&mut rest)?;
267 EntryCommand::Wait(id, wait_timeout(&mut rest)?)
268 }
269 ("propose", None) => {
270 let id = first_id(&mut rest)?;
271 let decision = AddArgs::parse(rest.drain(..), "propose needs the decision's title")?;
272 if decision.kind != Kind::Task || decision.start {
273 bail!("propose makes a decision: leave out --note, --decision and --start");
274 }
275 EntryCommand::Propose(id, decision)
276 }
277 ("accept", None) => {
278 let id = first_id(&mut rest)?;
279 let reason =
280 if rest.is_empty() { None } else { Some(rest_as_text(&mut rest, "accept needs its comment")?) };
281 EntryCommand::Resolve(id, DecisionState::Accepted, reason)
282 }
283 ("reject", None) => {
284 let id = first_id(&mut rest)?;
285 let reason = rest_as_text(&mut rest, "reject needs why")?;
286 EntryCommand::Resolve(id, DecisionState::Rejected, Some(reason))
287 }
288 ("describe", None) => {
289 let (id, text) = id_and_text(&mut rest, "describe needs the description")?;
290 EntryCommand::Describe(id, text)
291 }
292 ("rename", None) => {
293 let id = first_id(&mut rest)?;
294 EntryCommand::Rename(id, new_title(std::mem::take(&mut rest))?)
295 }
296 ("tag", None) => {
297 let id = first_id(&mut rest)?;
298 EntryCommand::Tag(id, tag_changes(std::mem::take(&mut rest))?)
299 }
300 ("link", None) => {
301 let id = first_id(&mut rest)?;
302 let (others, repo_links) = typed_links(&std::mem::take(&mut rest).join(" "))?;
303 if others.is_empty() && repo_links.is_empty() {
304 bail!("link needs the ids to link to");
305 }
306 EntryCommand::Link(id, others, repo_links)
307 }
308 ("context", None) => EntryCommand::Context,
309 ("project", None) => EntryCommand::Project(project_args(&mut rest)?),
310 _ => return Ok(None),
311 };
312 if let Some(extra) = rest.first() {
313 bail!("unexpected argument `{extra}`");
314 }
315 Ok(Some(Self { command, json }))
316 }
317
318 pub fn run(self, mut brain: Brain) -> Result<()> {
320 let json = self.json;
321 let show = matches!(self.command, EntryCommand::Show(_));
322 let id = match self.command {
323 EntryCommand::Add(args) => brain.add(args.into_new_entry()?)?,
324 EntryCommand::Show(id) => {
325 brain.entry(id)?;
326 id
327 }
328 EntryCommand::Search(query) => return print_search(&brain, &query, json),
329 EntryCommand::SetStatus(id, status) => {
330 if !brain.set_status(id, status)? {
332 eprintln!("#{id} was already {}: nothing changed", status.as_str());
333 }
334 id
335 }
336 EntryCommand::Comment(id, text) => {
337 brain.add_comment(id, &text.read()?)?;
338 id
339 }
340 EntryCommand::Ask(id, text) => {
341 brain.ask(id, &text.read()?)?;
342 id
343 }
344 EntryCommand::Wait(..) if json => bail!("wait prints the answer as text only"),
345 EntryCommand::Wait(id, timeout) => return wait(&brain, id, timeout),
346 EntryCommand::Propose(task, args) => {
347 let decision = brain.propose(task, args.into_new_entry()?)?;
348 if !json {
349 println!("{} · {}", line(brain.entry(decision)?), line(brain.entry(task)?));
350 return Ok(());
351 }
352 decision
353 }
354 EntryCommand::Resolve(id, state, reason) => {
355 let reason = reason.map(Text::read).transpose()?.unwrap_or_default();
356 if !brain.resolve(id, state, &reason)? {
357 eprintln!("#{id} was already {}: nothing changed", state.as_str());
358 }
359 id
360 }
361 EntryCommand::Describe(id, text) => {
362 brain.set_description(id, &text.read()?)?;
363 id
364 }
365 EntryCommand::Rename(id, title) => {
366 if !retitle(&mut brain, id, &title)? {
367 eprintln!("#{id} already had that title: nothing changed");
368 }
369 id
370 }
371 EntryCommand::Tag(id, changes) => {
372 if !retag(&mut brain, id, &changes)? {
373 eprintln!("#{id}'s tags were already so: nothing changed");
374 }
375 id
376 }
377 EntryCommand::Link(id, others, repo_links) => {
378 brain.link(id, &others, &repo_links)?;
379 id
380 }
381 EntryCommand::Context if json => bail!("context is markdown only; use search or show with --json"),
382 EntryCommand::Project(_) if json => bail!("project is markdown only"),
383 EntryCommand::Project(change) => return run_project(&mut brain, change),
384 EntryCommand::Context => {
385 let usage = if brain.project.is_some() { "" } else { CONTEXT_USAGE };
386 println!("{}{usage}", brain.context_markdown());
387 return Ok(());
388 }
389 };
390 print_entry(&brain, id, json, show)
391 }
392}
393
394impl AddArgs {
395 fn parse(mut args: impl Iterator<Item = String>, no_title: &str) -> Result<Self> {
397 let mut add = Self::default();
398 let mut title = Vec::new();
399 while let Some(arg) = args.next() {
400 match arg.as_str() {
401 "--note" => add.kind = Kind::Note,
402 "--decision" => add.kind = Kind::Decision,
403 "--start" => add.start = true,
404 "--tag" => add.tags.extend(parse_tags(&args.next().context("--tag needs a value")?)),
405 "--link" => {
406 let (links, repo_links) = typed_links(&args.next().context("--link needs an id")?)?;
407 add.links.extend(links);
408 add.repo_links.extend(repo_links);
409 }
410 "--body" => {
411 let body = args.next().context("--body needs text, or - to read stdin")?;
412 add.body = Some(if body == "-" { Text::Stdin } else { Text::Given(body) });
413 }
414 "--" => title.extend(args.by_ref()),
416 other if other.starts_with("--") => bail!("unknown add option `{other}`"),
417 _ => title.push(arg),
418 }
419 }
420 add.title = title.join(" ");
421 if add.title.trim().is_empty() {
422 bail!("{no_title}");
423 }
424 if add.start && add.kind != Kind::Task {
425 bail!("only tasks can be started");
426 }
427 Ok(add)
428 }
429
430 fn into_new_entry(self) -> Result<NewEntry> {
432 Ok(NewEntry {
433 kind: self.kind,
434 title: self.title,
435 tags: self.tags,
436 links: self.links,
437 repo_links: self.repo_links,
438 description: self.body.map(Text::read).transpose()?,
439 start: self.start,
440 })
441 }
442}
443
444impl Text {
445 fn read_stdin(&mut self) -> Result<()> {
447 if *self == Text::Stdin {
448 *self = Text::Given(stdin_text()?);
449 }
450 Ok(())
451 }
452
453 fn read(self) -> Result<String> {
455 match self {
456 Text::Given(text) => Ok(text),
457 Text::Stdin => stdin_text(),
458 }
459 }
460}
461
462fn stdin_text() -> Result<String> {
465 let mut text = String::new();
466 std::io::stdin().read_to_string(&mut text).context("reading stdin")?;
467 if text.trim().is_empty() {
468 bail!("nothing came on stdin for `-`: give the text there, e.g. with a heredoc (<<'EOF' … EOF)");
469 }
470 Ok(text)
471}
472
473fn search_words(mut words: Vec<String>) -> String {
476 if let Some(at) = words.iter().position(|w| w == "--") {
477 words.remove(at);
478 }
479 words.join(" ")
480}
481
482fn typed_links(value: &str) -> Result<(Vec<u32>, Vec<RepoLink>)> {
484 if let Some(word) = not_a_link(value) {
485 bail!("`{word}` isn't an entry id (12 or #12) or another repository's entry (REPO#ID)");
486 }
487 Ok(parse_links(value))
488}
489
490fn new_title(words: Vec<String>) -> Result<String> {
493 let mut title = Vec::new();
494 let mut words = words.into_iter();
495 while let Some(word) = words.next() {
496 match word.as_str() {
497 "--" => title.extend(words.by_ref()),
498 other if other.starts_with("--") => bail!("unknown rename option `{other}`"),
499 _ => title.push(word),
500 }
501 }
502 let title = title.join(" ");
503 if title.trim().is_empty() {
504 bail!("rename needs the new title");
505 }
506 Ok(title)
507}
508
509fn retitle(brain: &mut Brain, id: u32, title: &str) -> Result<bool> {
512 let entry = brain.entry(id)?;
513 let title = one_line(title);
514 if title == entry.title {
515 return Ok(false);
516 }
517 let tags = entry.tags.clone();
518 brain.update(id, &title, tags)?;
519 Ok(true)
520}
521
522const TAG_SIGNS: &str = "+TAG adds a tag, -TAG removes one";
524
525fn tag_changes(words: Vec<String>) -> Result<Vec<TagChange>> {
528 let mut changes = Vec::new();
529 for word in words {
530 let change = match word.as_bytes() {
531 [b'-', b'-'] => bail!("unexpected argument `--`"),
533 [b'-', b'-', ..] => bail!("unknown tag option `{word}`: {TAG_SIGNS}"),
534 [b'+', ..] => TagChange::Add,
535 [b'-', ..] => TagChange::Remove,
536 _ => bail!("`{word}`: {TAG_SIGNS}"),
537 };
538 let tags = parse_tags(&word[1..]);
539 if tags.is_empty() {
540 bail!("`{word}` names no tag");
541 }
542 changes.extend(tags.into_iter().map(change));
543 }
544 if changes.is_empty() {
545 bail!("tag needs what to change: {TAG_SIGNS}");
546 }
547 Ok(changes)
548}
549
550fn retag(brain: &mut Brain, id: u32, changes: &[TagChange]) -> Result<bool> {
553 let entry = brain.entry(id)?;
554 let mut tags = entry.tags.clone();
555 for change in changes {
556 match change {
557 TagChange::Add(tag) if !tags.contains(tag) => tags.push(tag.clone()),
558 TagChange::Add(_) => {}
559 TagChange::Remove(tag) => tags.retain(|t| t != tag),
560 }
561 }
562 if tags == entry.tags {
563 return Ok(false);
564 }
565 let title = entry.title.clone();
566 brain.update(id, &title, tags)?;
567 Ok(true)
568}
569
570fn first_id(args: &mut Vec<String>) -> Result<u32> {
572 if args.is_empty() {
573 bail!("an entry id is needed");
574 }
575 let arg = args.remove(0);
576 arg.trim_start_matches('#').parse().map_err(|_| anyhow!("`{arg}` isn't an entry id"))
578}
579
580fn id_and_text(args: &mut Vec<String>, needs: &str) -> Result<(u32, Text)> {
582 let id = first_id(args)?;
583 Ok((id, rest_as_text(args, needs)?))
584}
585
586fn rest_as_text(args: &mut Vec<String>, needs: &str) -> Result<Text> {
588 let mut words = std::mem::take(args);
589 if let Some(at) = words.iter().position(|w| w == "--") {
591 words.remove(at);
592 }
593 match words.as_slice() {
594 [] => bail!("{needs}: TEXT, or - to read it from stdin"),
595 [dash] if dash == "-" => Ok(Text::Stdin),
596 _ => Ok(Text::Given(words.join(" "))),
597 }
598}
599
600fn project_args(args: &mut Vec<String>) -> Result<Option<ProjectChange>> {
602 if args.is_empty() {
603 return Ok(None);
604 }
605 let command = args.remove(0);
606 let words = |args: &mut Vec<String>, what: &str| match std::mem::take(args).join(" ") {
608 name if name.trim().is_empty() => bail!("project {command} needs {what}"),
609 name => Ok(name),
610 };
611 Ok(Some(match command.as_str() {
612 "describe" => {
613 ProjectChange::Text(ProjectPart::Description, rest_as_text(args, "project describe needs the description")?)
614 }
615 "notes" => ProjectChange::Text(ProjectPart::Notes, rest_as_text(args, "project notes needs the notes")?),
616 "rename" => {
617 let literal = args.first().is_some_and(|a| a == "--");
618 if literal {
619 args.remove(0);
620 }
621 ProjectChange::Rename(words(args, "the new name")?, literal)
622 }
623 "repo" => ProjectChange::Repo(words(args, "the repository's new name")?),
624 "move" => ProjectChange::Move(words(args, "the project to move to")?),
625 "remove" => {
626 let purge = args.first().is_some_and(|a| a == "--purge");
627 if purge {
628 args.remove(0);
629 }
630 ProjectChange::Remove(purge)
631 }
632 other => bail!("unknown project command `{other}`: describe, notes, rename, repo, move or remove"),
633 }))
634}
635
636fn project_text(project: &ProjectInfo) -> String {
640 fn shown(text: &str) -> &str {
642 if text.is_empty() { "(empty)" } else { text }
643 }
644 let card = format!("## Project {}\n\n{}\n", project.name, shown(&project.description));
645 if project.siblings.is_empty() && project.notes.is_empty() {
646 return card;
647 }
648 format!("{card}\n## Repository {}\n\n{}\n", project.repo, shown(&project.notes))
649}
650
651fn run_project(brain: &mut Brain, change: Option<ProjectChange>) -> Result<()> {
653 let Some(project) = &brain.project else {
654 bail!("this isn't a project: `kb init` makes it one, or use --project NAME");
655 };
656 let name = project.name.clone();
657 let central = brain.central.clone().context("the central brain isn't known here")?;
658 let dir = brain.store.dir().to_path_buf();
659 let done = match change {
660 None => {
661 print!("{}", project_text(project));
662 return Ok(());
663 }
664 Some(ProjectChange::Text(ProjectPart::Description, text)) => {
665 brain.set_project_description(&text.read()?)?;
666 vec![format!("project {name}: description saved")]
667 }
668 Some(ProjectChange::Text(ProjectPart::Notes, text)) => {
669 brain.set_repo_notes(&text.read()?)?;
670 vec![format!("project {name}: repository role saved")]
671 }
672 Some(ProjectChange::Rename(new, false))
674 if new.strip_prefix(&name).is_some_and(|rest| rest.starts_with(' ')) =>
675 {
676 let rest = new[name.len()..].trim();
677 bail!(
678 "`project rename` takes the new name only, for this project: `kb project rename {rest}`; to name it \
679 `{new}`, `kb project rename -- {new}`"
680 )
681 }
682 Some(ProjectChange::Rename(new, _)) => manage::rename_project(¢ral, &name, &new)?,
683 Some(ProjectChange::Repo(new)) => manage::rename_repo(¢ral, &dir, &new)?,
684 Some(ProjectChange::Move(to)) => manage::move_repo(¢ral, &dir, &to)?,
685 Some(ProjectChange::Remove(purge)) => manage::remove_project(¢ral, &name, purge)?,
686 };
687 for line in &done {
688 println!("{line}");
689 }
690 Ok(())
691}
692
693const WAIT_POLL: Duration = Duration::from_secs(1);
696
697const WAIT_DEFAULT: Duration = Duration::from_secs(8 * 60);
703
704fn wait_timeout(args: &mut Vec<String>) -> Result<Option<Duration>> {
708 if args.is_empty() {
709 let Some(value) = std::env::var_os("KANBRAIN_WAIT_TIMEOUT") else {
710 return Ok(Some(WAIT_DEFAULT));
711 };
712 return minutes(&value.to_string_lossy()).context("KANBRAIN_WAIT_TIMEOUT must be a number of minutes");
713 }
714 if args.remove(0) != "--timeout" || args.is_empty() {
715 bail!("wait takes an id, then optionally --timeout MINUTES");
716 }
717 minutes(&args.remove(0))
718}
719
720fn minutes(arg: &str) -> Result<Option<Duration>> {
722 let minutes: u64 = arg.parse().with_context(|| format!("`{arg}` isn't a number of minutes"))?;
723 let seconds = minutes.checked_mul(60).with_context(|| format!("`{arg}` minutes is longer than any wait"))?;
724 Ok((minutes > 0).then(|| Duration::from_secs(seconds)))
725}
726
727fn wait(brain: &Brain, id: u32, timeout: Option<Duration>) -> Result<()> {
731 let started = std::time::Instant::now();
732 let mut watch = Watch::default();
733 loop {
734 if let Some(said) = watch.look(id, brain.store.load().map(|(tasks, _)| tasks)) {
735 println!("{}", said?);
736 return Ok(());
737 }
738 if timeout.is_some_and(|limit| started.elapsed() >= limit) {
739 bail!("no answer on #{id} yet: stop here and don't wait again; once answered, it comes first in `context`");
740 }
741 std::thread::sleep(WAIT_POLL);
742 }
743}
744
745#[derive(Default)]
748struct Watch {
749 asked_at: Option<chrono::DateTime<chrono::Local>>,
751 looked: bool,
754 missed: bool,
757}
758
759impl Watch {
760 fn look(&mut self, id: u32, read: Result<Vec<Task>>) -> Option<Result<String>> {
763 let first = !std::mem::replace(&mut self.looked, true);
764 match read {
766 Ok(tasks) => match tasks.into_iter().find(|t| t.id == id) {
767 None if first || self.missed => Some(Err(ApiError::NotFound(id).into())),
768 None => {
769 self.missed = true;
770 None
771 }
772 Some(task) => {
773 self.missed = false;
774 if let Some(Asked::Waiting(question)) = task.asked() {
775 self.asked_at.get_or_insert(question.created);
776 }
777 settled(&task, self.asked_at).map(Ok)
778 }
779 },
780 Err(e) if first => Some(Err(e)),
781 Err(_) => None,
782 }
783 }
784}
785
786fn settled(task: &Task, asked_at: Option<chrono::DateTime<chrono::Local>>) -> Option<String> {
790 let head = format!("#{} {}", task.id, task.title);
791 if task.kind == Kind::Decision {
792 if task.decision == DecisionState::Proposed {
793 return None;
794 }
795 let verdict = task.decision.as_str();
796 return Some(match task.comments.last() {
797 Some(why) => format!("{head}: {verdict}\n\n{}", why.body),
798 None => format!("{head}: {verdict}"),
799 });
800 }
801 match task.asked() {
802 Some(Asked::Waiting(_)) => None,
803 Some(Asked::Answered(answer)) => Some(format!("{head}: answered\n\n{}", answer.body)),
804 None if task.is_task() => {
805 let answers = |c: &&crate::model::Comment| c.author == crate::model::Author::Human || c.answer;
806 let answer = asked_at
807 .and_then(|at| task.comments.iter().rev().find(|c| c.created >= at && !c.question && answers(c)));
808 Some(match answer {
809 Some(answer) => format!("{head}: answered, now {}\n\n{}", task.status.as_str(), answer.body),
810 None => format!("{head}: not waiting for an answer ({})", task.status.as_str()),
811 })
812 }
813 None => Some(format!("{head}: a note, not waiting for an answer")),
815 }
816}
817
818#[derive(Serialize)]
821struct Shown<'a> {
822 #[serde(flatten)]
824 entry: Entry<'a>,
825 #[serde(skip_serializing_if = "Vec::is_empty")]
827 repo_linked_from: Vec<String>,
828}
829
830impl<'a> Shown<'a> {
831 fn new(brain: &Brain, task: &'a Task) -> Self {
833 let here = brain.project.as_ref().map(|p| RepoLink { repo: p.repo.clone(), id: task.id });
834 let repo_linked_from = brain
835 .repo_related(task.id)
836 .into_iter()
837 .filter(|(_, other)| here.as_ref().is_some_and(|here| other.repo_links.contains(here)))
838 .map(|(repo, other)| RepoLink { repo, id: other.id }.to_string())
839 .collect();
840 Self { entry: Entry::new(brain, task), repo_linked_from }
841 }
842}
843
844fn print_entry(brain: &Brain, id: u32, json: bool, full: bool) -> Result<()> {
847 let task = brain.entry(id)?;
848 if json {
849 println!("{}", serde_json::to_string(&Shown::new(brain, task))?);
850 } else if full {
851 print!("{}", brain.entry_text(id)?);
852 } else {
853 println!("{}", line(task));
854 }
855 Ok(())
856}
857
858fn print_search(brain: &Brain, query: &str, json: bool) -> Result<()> {
860 if json {
861 let summaries: Vec<Summary> = brain.search(query).into_iter().map(Summary::new).collect();
862 println!("{}", serde_json::to_string(&summaries)?);
863 } else {
864 print!("{}", brain.search_text(query));
865 }
866 Ok(())
867}
868
869#[cfg(test)]
870#[path = "../../tests/cli/entries.rs"]
871mod tests;