Skip to main content

kasl/libs/
pick.rs

1//! Interactive fallbacks for arguments the user did not pass.
2//!
3//! The rule across the CLI: on a terminal a missing identifier opens a picker,
4//! and everywhere else the command fails exactly as it did before, so scripts
5//! and CI keep their old behaviour. A picker is a convenience for the human at
6//! the keyboard, never a new way for an unattended run to hang.
7//!
8//! Every picker here calls [`ensure_interactive`] first, for the reason spelled
9//! out in [`crate::libs::prompt`]: `dialoguer` reads stdin unconditionally and
10//! would otherwise block forever, or read EOF and report an empty answer as if
11//! the user had chosen nothing.
12//!
13//! The labels matter as much as the list. Picking an issue by key alone means
14//! reading `PROJ-4471` and guessing; the labels here carry the same summary,
15//! badge and duration the list views show, so the choice is made on what the
16//! row means rather than on its identifier.
17//!
18//! ## Usage
19//!
20//! ```rust,no_run
21//! use kasl::libs::pick;
22//! use kasl::db::jira_inbox::JiraInbox;
23//!
24//! # fn main() -> anyhow::Result<()> {
25//! // A command with an optional KEY resolves it like this.
26//! let key = match None::<String> {
27//!     Some(key) => key,
28//!     None => pick::inbox_issue(&JiraInbox::new()?.list_active(false)?, "Pick an issue")?,
29//! };
30//! # Ok(())
31//! # }
32//! ```
33
34use anyhow::{Result, bail};
35use chrono::Local;
36use dialoguer::{MultiSelect, Select, theme::ColorfulTheme};
37
38use crate::db::jira_inbox::JiraInboxItem;
39use crate::db::tags::Tag;
40use crate::libs::pause::Pause;
41use crate::libs::prompt::ensure_interactive;
42use crate::libs::task::Task;
43
44/// Shows `prompt` over `labels` and returns the index the user picked.
45fn select(prompt: &str, labels: &[String]) -> Result<usize> {
46    Ok(Select::with_theme(&ColorfulTheme::default())
47        .with_prompt(prompt)
48        .items(labels)
49        .default(0)
50        .interact()?)
51}
52
53/// Picks an inbox issue, showing what each one is rather than just its key.
54///
55/// Returns the issue key. An empty inbox is a refusal rather than an empty
56/// picker: there is nothing to choose, and saying so names the command that
57/// would fill it.
58pub fn inbox_issue(items: &[JiraInboxItem], prompt: &str) -> Result<String> {
59    if items.is_empty() {
60        bail!("the inbox is empty - run `kasl inbox sync` to fetch assigned issues");
61    }
62    ensure_interactive("issue key is required; pass KEY outside a terminal")?;
63
64    let now = Local::now().naive_local();
65    let width = items.iter().map(|i| i.issue_key.len()).max().unwrap_or(0);
66    let labels: Vec<String> = items
67        .iter()
68        .map(|item| {
69            let pin = if item.pinned { "*" } else { " " };
70            let badge = item.badge(now).map(|b| format!("  [{b}]")).unwrap_or_default();
71            format!("{pin}{:width$}  {}{badge}", item.issue_key, item.summary)
72        })
73        .collect();
74
75    Ok(items[select(prompt, &labels)?].issue_key.clone())
76}
77
78/// Picks one or more tasks, showing name and completeness.
79///
80/// Returns the chosen ids. Multi-select because the commands that need this
81/// accept several ids, and picking them one at a time would be worse than
82/// typing them.
83pub fn tasks(tasks: &[Task], prompt: &str) -> Result<Vec<i32>> {
84    if tasks.is_empty() {
85        bail!("no tasks to choose from - `kasl task add` creates one");
86    }
87    ensure_interactive("task id is required; pass ID outside a terminal")?;
88
89    let labels: Vec<String> = tasks
90        .iter()
91        .map(|t| format!("[{}] {} ({}%)", t.id.unwrap_or(0), t.name, t.completeness.unwrap_or(0)))
92        .collect();
93
94    let chosen = MultiSelect::with_theme(&ColorfulTheme::default())
95        .with_prompt(prompt)
96        .items(&labels)
97        .interact()?;
98
99    Ok(chosen.into_iter().filter_map(|i| tasks[i].id).collect())
100}
101
102/// Picks a tag by name, showing its colour where one is set.
103///
104/// Returns the tag name, which is what `tag edit` and `tag remove` accept
105/// alongside a numeric id.
106pub fn tag(tags: &[Tag], prompt: &str) -> Result<String> {
107    if tags.is_empty() {
108        bail!("no tags yet - run `kasl tag add` first");
109    }
110    ensure_interactive("tag name is required; pass TAG outside a terminal")?;
111
112    let width = tags.iter().map(|t| t.name.len()).max().unwrap_or(0);
113    let labels: Vec<String> = tags
114        .iter()
115        .map(|t| match &t.color {
116            Some(color) if !color.is_empty() => format!("{:width$}  {color}", t.name),
117            _ => t.name.clone(),
118        })
119        .collect();
120
121    Ok(tags[select(prompt, &labels)?].name.clone())
122}
123
124/// Picks a pause, showing when it started and how long it lasted.
125///
126/// Returns the pause id. Pause ids are database keys nobody memorises, which
127/// is exactly why removing one by hand needs this.
128pub fn pause(pauses: &[Pause], prompt: &str) -> Result<i32> {
129    if pauses.is_empty() {
130        bail!("no pauses recorded for that day - `kasl pauses list` shows what is there");
131    }
132    ensure_interactive("pause id is required; pass ID outside a terminal")?;
133
134    let labels: Vec<String> = pauses
135        .iter()
136        .map(|p| {
137            let start = p.start.format("%H:%M");
138            let end = p.end.map(|e| e.format("%H:%M").to_string()).unwrap_or_else(|| "…".to_string());
139            match p.duration {
140                Some(d) => format!("{start} - {end}  ({} min)", d.num_minutes()),
141                None => format!("{start} - {end}  (ongoing)"),
142            }
143        })
144        .collect();
145
146    Ok(pauses[select(prompt, &labels)?].id)
147}