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::db::templates::TaskTemplate;
41use crate::libs::pause::Pause;
42use crate::libs::prompt::ensure_interactive;
43use crate::libs::task::Task;
44
45/// Shows `prompt` over `labels` and returns the index the user picked.
46fn select(prompt: &str, labels: &[String]) -> Result<usize> {
47    Ok(Select::with_theme(&ColorfulTheme::default())
48        .with_prompt(prompt)
49        .items(labels)
50        .default(0)
51        .interact()?)
52}
53
54/// Picks an inbox issue, showing what each one is rather than just its key.
55///
56/// Returns the issue key. An empty inbox is a refusal rather than an empty
57/// picker: there is nothing to choose, and saying so names the command that
58/// would fill it.
59pub fn inbox_issue(items: &[JiraInboxItem], prompt: &str) -> Result<String> {
60    if items.is_empty() {
61        bail!("the inbox is empty - run `kasl inbox sync` to fetch assigned issues");
62    }
63    ensure_interactive("issue key is required; pass KEY outside a terminal")?;
64
65    let now = Local::now().naive_local();
66    let width = items.iter().map(|i| i.issue_key.len()).max().unwrap_or(0);
67    let labels: Vec<String> = items
68        .iter()
69        .map(|item| {
70            let pin = if item.pinned { "*" } else { " " };
71            let badge = item.badge(now).map(|b| format!("  [{b}]")).unwrap_or_default();
72            format!("{pin}{:width$}  {}{badge}", item.issue_key, item.summary)
73        })
74        .collect();
75
76    Ok(items[select(prompt, &labels)?].issue_key.clone())
77}
78
79/// Picks one or more tasks, showing name and completeness.
80///
81/// Returns the chosen ids. Multi-select because the commands that need this
82/// accept several ids, and picking them one at a time would be worse than
83/// typing them.
84pub fn tasks(tasks: &[Task], prompt: &str) -> Result<Vec<i32>> {
85    if tasks.is_empty() {
86        bail!("no tasks to choose from - `kasl task add` creates one");
87    }
88    ensure_interactive("task id is required; pass ID outside a terminal")?;
89
90    let labels: Vec<String> = tasks
91        .iter()
92        .map(|t| format!("[{}] {} ({}%)", t.id.unwrap_or(0), t.name, t.completeness.unwrap_or(0)))
93        .collect();
94
95    let chosen = MultiSelect::with_theme(&ColorfulTheme::default())
96        .with_prompt(prompt)
97        .items(&labels)
98        .interact()?;
99
100    Ok(chosen.into_iter().filter_map(|i| tasks[i].id).collect())
101}
102
103/// Picks a template by name, showing the task title it would create.
104pub fn template(templates: &[TaskTemplate], prompt: &str) -> Result<String> {
105    if templates.is_empty() {
106        bail!("no templates yet - run `kasl template add` first");
107    }
108    ensure_interactive("template name is required; pass NAME outside a terminal")?;
109
110    let width = templates.iter().map(|t| t.name.len()).max().unwrap_or(0);
111    let labels: Vec<String> = templates.iter().map(|t| format!("{:width$}  {}", t.name, t.task_name)).collect();
112
113    Ok(templates[select(prompt, &labels)?].name.clone())
114}
115
116/// Picks a tag by name, showing its colour where one is set.
117///
118/// Returns the tag name, which is what `tag edit` and `tag remove` accept
119/// alongside a numeric id.
120pub fn tag(tags: &[Tag], prompt: &str) -> Result<String> {
121    if tags.is_empty() {
122        bail!("no tags yet - run `kasl tag add` first");
123    }
124    ensure_interactive("tag name is required; pass TAG outside a terminal")?;
125
126    let width = tags.iter().map(|t| t.name.len()).max().unwrap_or(0);
127    let labels: Vec<String> = tags
128        .iter()
129        .map(|t| match &t.color {
130            Some(color) if !color.is_empty() => format!("{:width$}  {color}", t.name),
131            _ => t.name.clone(),
132        })
133        .collect();
134
135    Ok(tags[select(prompt, &labels)?].name.clone())
136}
137
138/// Picks a pause, showing when it started and how long it lasted.
139///
140/// Returns the pause id. Pause ids are database keys nobody memorises, which
141/// is exactly why removing one by hand needs this.
142pub fn pause(pauses: &[Pause], prompt: &str) -> Result<i32> {
143    if pauses.is_empty() {
144        bail!("no pauses recorded for that day - `kasl pauses list` shows what is there");
145    }
146    ensure_interactive("pause id is required; pass ID outside a terminal")?;
147
148    let labels: Vec<String> = pauses
149        .iter()
150        .map(|p| {
151            let start = p.start.format("%H:%M");
152            let end = p.end.map(|e| e.format("%H:%M").to_string()).unwrap_or_else(|| "…".to_string());
153            match p.duration {
154                Some(d) => format!("{start} - {end}  ({} min)", d.num_minutes()),
155                None => format!("{start} - {end}  (ongoing)"),
156            }
157        })
158        .collect();
159
160    Ok(pauses[select(prompt, &labels)?].id)
161}