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