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