1use crate::api::ApiError;
6use crate::api::json::{Entry, Summary};
7use crate::api::text::line;
8use crate::brain::{Brain, NewEntry};
9use crate::model::{Asked, DecisionState, Kind, RepoLink, Status, Task, parse_links, parse_tags};
10use crate::projects::ProjectInfo;
11use anyhow::{Context, Result, bail};
12use std::io::Read;
13use std::time::Duration;
14
15const CONTEXT_USAGE: &str = "\
18Commands: `kb show ID` (everything about an entry), `search WORDS`, \
19`add [--note|--decision] [--tag T] [--link ID] [--body TEXT|-] TITLE`, \
20`comment ID TEXT|-`, `ask ID TEXT|-`, `wait ID`, `start|park|done|todo ID`, `link ID OTHER…` (`REPO#ID` in another repository). Add `--json` for JSON.";
21
22pub const USAGE: &str = "\
24 add [--note|--decision] [--tag TAG]… [--link ID]… [--body TEXT|-] [--start] TITLE…
25 create a task (or note, decision) and print its id; `-` reads the body from stdin
26 show ID print an entry compactly: state, time, description, comments, related, history
27 search WORDS…
28 find entries by title, tags, description or comments (#tag: tags only)
29 start|park|done|todo ID
30 change a task's status (start runs its clock; a person runs one task at a time)
31 comment ID TEXT…|-
32 add a comment, e.g. progress notes
33 ask ID TEXT…|-
34 ask the user a question on a task and park it until a person answers with a comment
35 wait ID [--timeout MINUTES]
36 block until a person answers what task ID waits on (or decision ID is accepted or
37 rejected), then print the answer; gives up after 8 minutes (0: never)
38 propose ID [--tag TAG]… [--link ID]… [--body TEXT|-] TITLE…
39 propose a decision for a task: linked to it, which asks about it and is parked
40 accept ID [TEXT…|-]
41 reject ID TEXT…|-
42 accept or reject a decision, with why (needed to reject); answers the tasks waiting on it
43 describe ID TEXT…|-
44 replace the description
45 link ID OTHER…
46 link an entry to others: an id here, or REPO#ID in another repository of the project
47 project [describe|notes TEXT…|-]
48 show the project's description (shared by its repositories) and this repository's role,
49 or replace one
50 context what's going on: active tasks, decisions and notes, for an agent to read first
51 (all but project take --json to print JSON instead)";
52
53#[derive(Debug, PartialEq, Eq)]
55pub struct EntryArgs {
56 pub command: EntryCommand,
58 pub json: bool,
60}
61
62#[derive(Debug, PartialEq, Eq)]
64pub enum EntryCommand {
65 Add(AddArgs),
67 Show(u32),
69 Search(String),
71 SetStatus(u32, Status),
73 Comment(u32, Text),
75 Ask(u32, Text),
77 Wait(u32, Option<Duration>),
79 Propose(u32, AddArgs),
81 Resolve(u32, DecisionState, Option<Text>),
83 Describe(u32, Text),
85 Link(u32, Vec<u32>, Vec<RepoLink>),
87 Context,
89 Project(Option<(ProjectPart, Text)>),
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum ProjectPart {
97 Description,
99 Notes,
101}
102
103#[derive(Debug, PartialEq, Eq)]
105pub enum Text {
106 Given(String),
108 Stdin,
110}
111
112#[derive(Debug, PartialEq, Eq, Default)]
114pub struct AddArgs {
115 pub kind: Kind,
117 pub title: String,
119 pub tags: Vec<String>,
121 pub links: Vec<u32>,
123 pub repo_links: Vec<RepoLink>,
125 pub body: Option<Text>,
127 pub start: bool,
129}
130
131impl EntryArgs {
132 pub fn parse(name: &str, args: impl Iterator<Item = String>) -> Result<Option<Self>> {
134 let mut json = false;
136 let mut rest = Vec::new();
137 for arg in args {
138 if arg == "--json" {
139 json = true;
140 } else {
141 rest.push(arg);
142 }
143 }
144 let status = match name {
145 "start" => Some(Status::Doing),
146 "park" => Some(Status::Parked),
147 "done" => Some(Status::Done),
148 "todo" => Some(Status::Todo),
149 _ => None,
150 };
151 let command = match (name, status) {
152 (_, Some(status)) => EntryCommand::SetStatus(first_id(&mut rest)?, status),
153 ("add", None) => EntryCommand::Add(AddArgs::parse(rest.drain(..))?),
154 ("show", None) => EntryCommand::Show(first_id(&mut rest)?),
155 ("search", None) => EntryCommand::Search(std::mem::take(&mut rest).join(" ")),
156 ("comment", None) => {
157 let (id, text) = id_and_text(&mut rest)?;
158 EntryCommand::Comment(id, text)
159 }
160 ("ask", None) => {
161 let (id, text) = id_and_text(&mut rest)?;
162 EntryCommand::Ask(id, text)
163 }
164 ("wait", None) => {
165 let id = first_id(&mut rest)?;
166 EntryCommand::Wait(id, wait_timeout(&mut rest)?)
167 }
168 ("propose", None) => {
169 let id = first_id(&mut rest)?;
170 let decision = AddArgs::parse(rest.drain(..))?;
171 if decision.kind != Kind::Task || decision.start {
172 bail!("propose makes a decision: leave out --note, --decision and --start");
173 }
174 EntryCommand::Propose(id, decision)
175 }
176 ("accept", None) => {
177 let id = first_id(&mut rest)?;
178 let reason = if rest.is_empty() { None } else { Some(rest_as_text(&mut rest)?) };
179 EntryCommand::Resolve(id, DecisionState::Accepted, reason)
180 }
181 ("reject", None) => {
182 let id = first_id(&mut rest)?;
183 let reason = rest_as_text(&mut rest).context("reject needs why: TEXT, or - to read it from stdin")?;
184 EntryCommand::Resolve(id, DecisionState::Rejected, Some(reason))
185 }
186 ("describe", None) => {
187 let (id, text) = id_and_text(&mut rest)?;
188 EntryCommand::Describe(id, text)
189 }
190 ("link", None) => {
191 let id = first_id(&mut rest)?;
192 let (others, repo_links) = parse_links(&std::mem::take(&mut rest).join(" "));
193 if others.is_empty() && repo_links.is_empty() {
194 bail!("link needs the ids to link to");
195 }
196 EntryCommand::Link(id, others, repo_links)
197 }
198 ("context", None) => EntryCommand::Context,
199 ("project", None) => EntryCommand::Project(project_args(&mut rest)?),
200 _ => return Ok(None),
201 };
202 if let Some(extra) = rest.first() {
203 bail!("unexpected argument `{extra}`");
204 }
205 Ok(Some(Self { command, json }))
206 }
207
208 pub fn run(self, mut brain: Brain) -> Result<()> {
210 let json = self.json;
211 let show = matches!(self.command, EntryCommand::Show(_));
212 let id = match self.command {
213 EntryCommand::Add(args) => brain.add(args.into_new_entry()?)?,
214 EntryCommand::Show(id) => {
215 brain.entry(id)?;
216 id
217 }
218 EntryCommand::Search(query) => return print_search(&brain, &query, json),
219 EntryCommand::SetStatus(id, status) => {
220 brain.change_status(id, status)?;
221 id
222 }
223 EntryCommand::Comment(id, text) => {
224 brain.comment(id, &text.read()?)?;
225 id
226 }
227 EntryCommand::Ask(id, text) => {
228 brain.ask(id, &text.read()?)?;
229 id
230 }
231 EntryCommand::Wait(..) if json => bail!("wait prints the answer as text only"),
232 EntryCommand::Wait(id, timeout) => return wait(&brain, id, timeout),
233 EntryCommand::Propose(task, args) => {
234 let decision = brain.propose(task, args.into_new_entry()?)?;
235 if !json {
236 println!("{} · {}", line(brain.entry(decision)?), line(brain.entry(task)?));
237 return Ok(());
238 }
239 decision
240 }
241 EntryCommand::Resolve(id, state, reason) => {
242 let reason = reason.map(Text::read).transpose()?.unwrap_or_default();
243 brain.resolve(id, state, &reason)?;
244 id
245 }
246 EntryCommand::Describe(id, text) => {
247 brain.describe(id, &text.read()?)?;
248 id
249 }
250 EntryCommand::Link(id, others, repo_links) => {
251 brain.link(id, &others, &repo_links)?;
252 id
253 }
254 EntryCommand::Context if json => bail!("context is markdown only; use search or show with --json"),
255 EntryCommand::Project(_) if json => bail!("project is markdown only"),
256 EntryCommand::Project(change) => return run_project(&mut brain, change),
257 EntryCommand::Context => {
258 let usage = if brain.project.is_some() { "" } else { CONTEXT_USAGE };
259 println!("{}{usage}", brain.context_markdown());
260 return Ok(());
261 }
262 };
263 print_entry(&brain, id, json, show)
264 }
265}
266
267impl AddArgs {
268 fn parse(mut args: impl Iterator<Item = String>) -> Result<Self> {
270 let mut add = Self::default();
271 let mut title = Vec::new();
272 while let Some(arg) = args.next() {
273 match arg.as_str() {
274 "--note" => add.kind = Kind::Note,
275 "--decision" => add.kind = Kind::Decision,
276 "--start" => add.start = true,
277 "--tag" => add.tags.extend(parse_tags(&args.next().context("--tag needs a value")?)),
278 "--link" => {
279 let (links, repo_links) = parse_links(&args.next().context("--link needs an id")?);
280 add.links.extend(links);
281 add.repo_links.extend(repo_links);
282 }
283 "--body" => {
284 let body = args.next().context("--body needs text, or - to read stdin")?;
285 add.body = Some(if body == "-" { Text::Stdin } else { Text::Given(body) });
286 }
287 other if other.starts_with("--") => bail!("unknown add option `{other}`"),
288 _ => title.push(arg),
289 }
290 }
291 add.title = title.join(" ");
292 if add.title.trim().is_empty() {
293 bail!("add needs a title");
294 }
295 if add.start && add.kind != Kind::Task {
296 bail!("only tasks can be started");
297 }
298 Ok(add)
299 }
300
301 fn into_new_entry(self) -> Result<NewEntry> {
303 Ok(NewEntry {
304 kind: self.kind,
305 title: self.title,
306 tags: self.tags,
307 links: self.links,
308 repo_links: self.repo_links,
309 description: self.body.map(Text::read).transpose()?,
310 start: self.start,
311 })
312 }
313}
314
315impl Text {
316 fn read(self) -> Result<String> {
318 match self {
319 Text::Given(text) => Ok(text),
320 Text::Stdin => {
321 let mut text = String::new();
322 std::io::stdin().read_to_string(&mut text).context("reading stdin")?;
323 Ok(text)
324 }
325 }
326 }
327}
328
329fn first_id(args: &mut Vec<String>) -> Result<u32> {
331 if args.is_empty() {
332 bail!("an entry id is needed");
333 }
334 let arg = args.remove(0);
335 arg.trim_start_matches('#').parse().with_context(|| format!("`{arg}` isn't an entry id"))
336}
337
338fn id_and_text(args: &mut Vec<String>) -> Result<(u32, Text)> {
340 let id = first_id(args)?;
341 Ok((id, rest_as_text(args)?))
342}
343
344fn rest_as_text(args: &mut Vec<String>) -> Result<Text> {
346 let words = std::mem::take(args);
347 match words.as_slice() {
348 [] => bail!("text is needed, or - to read it from stdin"),
349 [dash] if dash == "-" => Ok(Text::Stdin),
350 _ => Ok(Text::Given(words.join(" "))),
351 }
352}
353
354fn project_args(args: &mut Vec<String>) -> Result<Option<(ProjectPart, Text)>> {
356 if args.is_empty() {
357 return Ok(None);
358 }
359 let part = match args.remove(0).as_str() {
360 "describe" => ProjectPart::Description,
361 "notes" => ProjectPart::Notes,
362 other => bail!("unknown project command `{other}`: describe or notes"),
363 };
364 Ok(Some((part, rest_as_text(args)?)))
365}
366
367fn project_text(project: &ProjectInfo) -> String {
371 fn shown(text: &str) -> &str {
373 if text.is_empty() { "(empty)" } else { text }
374 }
375 let card = format!("## Project {}\n\n{}\n", project.name, shown(&project.description));
376 if project.siblings.is_empty() && project.notes.is_empty() {
377 return card;
378 }
379 format!("{card}\n## Repository {}\n\n{}\n", project.repo, shown(&project.notes))
380}
381
382fn run_project(brain: &mut Brain, change: Option<(ProjectPart, Text)>) -> Result<()> {
384 let Some(project) = &brain.project else {
385 bail!("this isn't a project: `kb init` makes it one, or use --project NAME");
386 };
387 let name = project.name.clone();
388 match change {
389 None => print!("{}", project_text(project)),
390 Some((ProjectPart::Description, text)) => {
391 brain.set_project_description(&text.read()?)?;
392 println!("project {name}: description saved");
393 }
394 Some((ProjectPart::Notes, text)) => {
395 brain.set_repo_notes(&text.read()?)?;
396 println!("project {name}: repository role saved");
397 }
398 }
399 Ok(())
400}
401
402const WAIT_POLL: Duration = Duration::from_secs(1);
405
406const WAIT_DEFAULT: Duration = Duration::from_secs(8 * 60);
412
413fn wait_timeout(args: &mut Vec<String>) -> Result<Option<Duration>> {
417 if args.is_empty() {
418 let Some(value) = std::env::var_os("KANBRAIN_WAIT_TIMEOUT") else {
419 return Ok(Some(WAIT_DEFAULT));
420 };
421 return minutes(&value.to_string_lossy()).context("KANBRAIN_WAIT_TIMEOUT must be a number of minutes");
422 }
423 if args.remove(0) != "--timeout" || args.is_empty() {
424 bail!("wait takes an id, then optionally --timeout MINUTES");
425 }
426 minutes(&args.remove(0))
427}
428
429fn minutes(arg: &str) -> Result<Option<Duration>> {
431 let minutes: u64 = arg.parse().with_context(|| format!("`{arg}` isn't a number of minutes"))?;
432 Ok((minutes > 0).then(|| Duration::from_secs(minutes * 60)))
433}
434
435fn wait(brain: &Brain, id: u32, timeout: Option<Duration>) -> Result<()> {
439 let started = std::time::Instant::now();
440 loop {
441 let (tasks, _) = brain.store.load()?;
442 let task = tasks.iter().find(|t| t.id == id).ok_or(ApiError::NotFound(id))?;
443 if let Some(said) = settled(task) {
444 println!("{said}");
445 return Ok(());
446 }
447 if timeout.is_some_and(|limit| started.elapsed() >= limit) {
448 bail!("no answer on #{id} yet: stop here and don't wait again; once answered, it comes first in `context`");
449 }
450 std::thread::sleep(WAIT_POLL);
451 }
452}
453
454fn settled(task: &Task) -> Option<String> {
458 let head = format!("#{} {}", task.id, task.title);
459 if task.kind == Kind::Decision {
460 if task.decision == DecisionState::Proposed {
461 return None;
462 }
463 let verdict = task.decision.as_str();
464 return Some(match task.comments.last() {
465 Some(why) => format!("{head}: {verdict}\n\n{}", why.body),
466 None => format!("{head}: {verdict}"),
467 });
468 }
469 match task.asked() {
470 Some(Asked::Waiting(_)) => None,
471 Some(Asked::Answered(answer)) => Some(format!("{head}: answered\n\n{}", answer.body)),
472 None => Some(format!("{head}: not waiting for an answer ({})", task.status.as_str())),
473 }
474}
475
476fn print_entry(brain: &Brain, id: u32, json: bool, full: bool) -> Result<()> {
479 let task = brain.entry(id)?;
480 if json {
481 println!("{}", serde_json::to_string(&Entry::new(brain, task))?);
482 } else if full {
483 print!("{}", brain.entry_text(id)?);
484 } else {
485 println!("{}", line(task));
486 }
487 Ok(())
488}
489
490fn print_search(brain: &Brain, query: &str, json: bool) -> Result<()> {
492 if json {
493 let summaries: Vec<Summary> = brain.search(query).into_iter().map(Summary::new).collect();
494 println!("{}", serde_json::to_string(&summaries)?);
495 } else {
496 print!("{}", brain.search_text(query));
497 }
498 Ok(())
499}
500
501#[cfg(test)]
502#[path = "../../tests/cli/entries.rs"]
503mod tests;