use std::{ io::Write, process::{Command, Stdio}, }; use doing_taskpaper::Entry; use log::warn; use crate::Result; /// Choose a single entry from a list using fzf (if available) or a dialoguer fallback. pub fn choose_entry(entries: &[Entry]) -> Result> { if has_fzf() { choose_fzf(entries) } else { warn!("fzf not found on $PATH, falling back to built-in menu"); choose_dialoguer(entries) } } /// Convert a dialoguer error into a doing `Error`. pub fn dialoguer_error(e: impl std::fmt::Display) -> crate::Error { crate::Error::Io(std::io::Error::other(format!("input error: {e}"))) } pub fn has_fzf() -> bool { Command::new("fzf") .arg("--version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .is_ok_and(|s| s.success()) } /// Select multiple entries from a list using a dialoguer multi-select menu. pub fn select_entries(entries: &[Entry]) -> Result> { let items: Vec = entries.iter().map(format_entry).collect(); let selections = dialoguer::MultiSelect::new() .with_prompt("Select entries") .items(&items) .interact_opt() .map_err(dialoguer_error)?; let Some(selections) = selections else { return Ok(Vec::new()); }; Ok(selections.into_iter().map(|i| entries[i].clone()).collect()) } fn choose_dialoguer(entries: &[Entry]) -> Result> { let items: Vec = entries.iter().map(format_entry).collect(); let selection = dialoguer::Select::new() .with_prompt("Choose an entry") .items(&items) .interact_opt() .map_err(dialoguer_error)?; Ok(selection.map(|i| entries[i].clone())) } fn choose_fzf(entries: &[Entry]) -> Result> { let items: Vec = entries.iter().map(format_entry).collect(); let input = items.join("\n"); let mut child = Command::new("fzf") .arg("--select-1") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .spawn() .map_err(crate::Error::Io)?; if let Some(mut stdin) = child.stdin.take() { stdin.write_all(input.as_bytes()).map_err(crate::Error::Io)?; } let output = child.wait_with_output().map_err(crate::Error::Io)?; if !output.status.success() { return Ok(None); } let chosen = String::from_utf8(output.stdout) .map_err(|e| crate::Error::Io(std::io::Error::other(e)))? .trim() .to_string(); let index = items.iter().position(|item| *item == chosen); Ok(index.map(|i| entries[i].clone())) } fn format_entry(entry: &Entry) -> String { let date = entry.date().format(crate::cli::DONE_DATE_FORMAT); format!("{date} | {}", entry.full_title()) } #[cfg(test)] mod test { use super::*; mod format_entry { use chrono::{Local, TimeZone}; use doing_taskpaper::{Note, Tag, Tags}; use super::*; #[test] fn it_formats_entry_without_tags() { let entry = Entry::new( Local.with_ymd_and_hms(2024, 3, 17, 14, 0, 0).unwrap(), "Test task", Tags::new(), Note::new(), "Currently", None::, ); let formatted = format_entry(&entry); assert!(formatted.contains("Test task")); assert!(formatted.contains("2024-03-17")); } #[test] fn it_formats_entry_with_tags() { let entry = Entry::new( Local.with_ymd_and_hms(2024, 3, 17, 14, 0, 0).unwrap(), "Test task", Tags::from_iter(vec![Tag::new("project", None::)]), Note::new(), "Currently", None::, ); let formatted = format_entry(&entry); assert!(formatted.contains("Test task")); assert!(formatted.contains("@project")); } } mod has_fzf { use super::*; #[test] fn it_returns_a_bool() { let _ = has_fzf(); } } }