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 and parks the running task)
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 let parked = brain.change_status(id, status)?;
221 if let (false, Some(parked)) = (json, parked) {
222 println!("{} · parked #{parked}", line(brain.entry(id)?));
223 return Ok(());
224 }
225 id
226 }
227 EntryCommand::Comment(id, text) => {
228 brain.comment(id, &text.read()?)?;
229 id
230 }
231 EntryCommand::Ask(id, text) => {
232 brain.ask(id, &text.read()?)?;
233 id
234 }
235 EntryCommand::Wait(..) if json => bail!("wait prints the answer as text only"),
236 EntryCommand::Wait(id, timeout) => return wait(&brain, id, timeout),
237 EntryCommand::Propose(task, args) => {
238 let decision = brain.propose(task, args.into_new_entry()?)?;
239 if !json {
240 println!("{} · {}", line(brain.entry(decision)?), line(brain.entry(task)?));
241 return Ok(());
242 }
243 decision
244 }
245 EntryCommand::Resolve(id, state, reason) => {
246 let reason = reason.map(Text::read).transpose()?.unwrap_or_default();
247 brain.resolve(id, state, &reason)?;
248 id
249 }
250 EntryCommand::Describe(id, text) => {
251 brain.describe(id, &text.read()?)?;
252 id
253 }
254 EntryCommand::Link(id, others, repo_links) => {
255 brain.link(id, &others, &repo_links)?;
256 id
257 }
258 EntryCommand::Context if json => bail!("context is markdown only; use search or show with --json"),
259 EntryCommand::Project(_) if json => bail!("project is markdown only"),
260 EntryCommand::Project(change) => return run_project(&mut brain, change),
261 EntryCommand::Context => {
262 let usage = if brain.project.is_some() { "" } else { CONTEXT_USAGE };
263 println!("{}{usage}", brain.context_markdown());
264 return Ok(());
265 }
266 };
267 print_entry(&brain, id, json, show)
268 }
269}
270
271impl AddArgs {
272 fn parse(mut args: impl Iterator<Item = String>) -> Result<Self> {
274 let mut add = Self::default();
275 let mut title = Vec::new();
276 while let Some(arg) = args.next() {
277 match arg.as_str() {
278 "--note" => add.kind = Kind::Note,
279 "--decision" => add.kind = Kind::Decision,
280 "--start" => add.start = true,
281 "--tag" => add.tags.extend(parse_tags(&args.next().context("--tag needs a value")?)),
282 "--link" => {
283 let (links, repo_links) = parse_links(&args.next().context("--link needs an id")?);
284 add.links.extend(links);
285 add.repo_links.extend(repo_links);
286 }
287 "--body" => {
288 let body = args.next().context("--body needs text, or - to read stdin")?;
289 add.body = Some(if body == "-" { Text::Stdin } else { Text::Given(body) });
290 }
291 other if other.starts_with("--") => bail!("unknown add option `{other}`"),
292 _ => title.push(arg),
293 }
294 }
295 add.title = title.join(" ");
296 if add.title.trim().is_empty() {
297 bail!("add needs a title");
298 }
299 if add.start && add.kind != Kind::Task {
300 bail!("only tasks can be started");
301 }
302 Ok(add)
303 }
304
305 fn into_new_entry(self) -> Result<NewEntry> {
307 Ok(NewEntry {
308 kind: self.kind,
309 title: self.title,
310 tags: self.tags,
311 links: self.links,
312 repo_links: self.repo_links,
313 description: self.body.map(Text::read).transpose()?,
314 start: self.start,
315 })
316 }
317}
318
319impl Text {
320 fn read(self) -> Result<String> {
322 match self {
323 Text::Given(text) => Ok(text),
324 Text::Stdin => {
325 let mut text = String::new();
326 std::io::stdin().read_to_string(&mut text).context("reading stdin")?;
327 Ok(text)
328 }
329 }
330 }
331}
332
333fn first_id(args: &mut Vec<String>) -> Result<u32> {
335 if args.is_empty() {
336 bail!("an entry id is needed");
337 }
338 let arg = args.remove(0);
339 arg.trim_start_matches('#').parse().with_context(|| format!("`{arg}` isn't an entry id"))
340}
341
342fn id_and_text(args: &mut Vec<String>) -> Result<(u32, Text)> {
344 let id = first_id(args)?;
345 Ok((id, rest_as_text(args)?))
346}
347
348fn rest_as_text(args: &mut Vec<String>) -> Result<Text> {
350 let words = std::mem::take(args);
351 match words.as_slice() {
352 [] => bail!("text is needed, or - to read it from stdin"),
353 [dash] if dash == "-" => Ok(Text::Stdin),
354 _ => Ok(Text::Given(words.join(" "))),
355 }
356}
357
358fn project_args(args: &mut Vec<String>) -> Result<Option<(ProjectPart, Text)>> {
360 if args.is_empty() {
361 return Ok(None);
362 }
363 let part = match args.remove(0).as_str() {
364 "describe" => ProjectPart::Description,
365 "notes" => ProjectPart::Notes,
366 other => bail!("unknown project command `{other}`: describe or notes"),
367 };
368 Ok(Some((part, rest_as_text(args)?)))
369}
370
371fn project_text(project: &ProjectInfo) -> String {
375 fn shown(text: &str) -> &str {
377 if text.is_empty() { "(empty)" } else { text }
378 }
379 let card = format!("## Project {}\n\n{}\n", project.name, shown(&project.description));
380 if project.siblings.is_empty() && project.notes.is_empty() {
381 return card;
382 }
383 format!("{card}\n## Repository {}\n\n{}\n", project.repo, shown(&project.notes))
384}
385
386fn run_project(brain: &mut Brain, change: Option<(ProjectPart, Text)>) -> Result<()> {
388 let Some(project) = &brain.project else {
389 bail!("this isn't a project: `kb init` makes it one, or use --project NAME");
390 };
391 let name = project.name.clone();
392 match change {
393 None => print!("{}", project_text(project)),
394 Some((ProjectPart::Description, text)) => {
395 brain.set_project_description(&text.read()?)?;
396 println!("project {name}: description saved");
397 }
398 Some((ProjectPart::Notes, text)) => {
399 brain.set_repo_notes(&text.read()?)?;
400 println!("project {name}: repository role saved");
401 }
402 }
403 Ok(())
404}
405
406const WAIT_POLL: Duration = Duration::from_secs(1);
409
410const WAIT_DEFAULT: Duration = Duration::from_secs(8 * 60);
416
417fn wait_timeout(args: &mut Vec<String>) -> Result<Option<Duration>> {
421 if args.is_empty() {
422 let Some(value) = std::env::var_os("KANBRAIN_WAIT_TIMEOUT") else {
423 return Ok(Some(WAIT_DEFAULT));
424 };
425 return minutes(&value.to_string_lossy()).context("KANBRAIN_WAIT_TIMEOUT must be a number of minutes");
426 }
427 if args.remove(0) != "--timeout" || args.is_empty() {
428 bail!("wait takes an id, then optionally --timeout MINUTES");
429 }
430 minutes(&args.remove(0))
431}
432
433fn minutes(arg: &str) -> Result<Option<Duration>> {
435 let minutes: u64 = arg.parse().with_context(|| format!("`{arg}` isn't a number of minutes"))?;
436 Ok((minutes > 0).then(|| Duration::from_secs(minutes * 60)))
437}
438
439fn wait(brain: &Brain, id: u32, timeout: Option<Duration>) -> Result<()> {
443 let started = std::time::Instant::now();
444 loop {
445 let (tasks, _) = brain.store.load()?;
446 let task = tasks.iter().find(|t| t.id == id).ok_or(ApiError::NotFound(id))?;
447 if let Some(said) = settled(task) {
448 println!("{said}");
449 return Ok(());
450 }
451 if timeout.is_some_and(|limit| started.elapsed() >= limit) {
452 bail!("no answer on #{id} yet: stop here and don't wait again; once answered, it comes first in `context`");
453 }
454 std::thread::sleep(WAIT_POLL);
455 }
456}
457
458fn settled(task: &Task) -> Option<String> {
462 let head = format!("#{} {}", task.id, task.title);
463 if task.kind == Kind::Decision {
464 if task.decision == DecisionState::Proposed {
465 return None;
466 }
467 let verdict = task.decision.as_str();
468 return Some(match task.comments.last() {
469 Some(why) => format!("{head}: {verdict}\n\n{}", why.body),
470 None => format!("{head}: {verdict}"),
471 });
472 }
473 match task.asked() {
474 Some(Asked::Waiting(_)) => None,
475 Some(Asked::Answered(answer)) => Some(format!("{head}: answered\n\n{}", answer.body)),
476 None => Some(format!("{head}: not waiting for an answer ({})", task.status.as_str())),
477 }
478}
479
480fn print_entry(brain: &Brain, id: u32, json: bool, full: bool) -> Result<()> {
483 let task = brain.entry(id)?;
484 if json {
485 println!("{}", serde_json::to_string(&Entry::new(brain, task))?);
486 } else if full {
487 print!("{}", brain.entry_text(id)?);
488 } else {
489 println!("{}", line(task));
490 }
491 Ok(())
492}
493
494fn print_search(brain: &Brain, query: &str, json: bool) -> Result<()> {
496 if json {
497 let summaries: Vec<Summary> = brain.search(query).into_iter().map(Summary::new).collect();
498 println!("{}", serde_json::to_string(&summaries)?);
499 } else {
500 print!("{}", brain.search_text(query));
501 }
502 Ok(())
503}
504
505#[cfg(test)]
506#[path = "../../tests/cli/entries.rs"]
507mod tests;