use crate::config::Config;
use crate::errors::Error;
use crate::lists::Flag;
use crate::tasks::priority::{self, Priority};
use crate::{CommandResult, input, labels};
use auth_commands::AuthCommands;
use clap::{Parser, Subcommand};
use config_commands::ConfigCommands;
use list_commands::ListCommands;
use project_commands::ProjectCommands;
use reminder_commands::ReminderCommands;
use section_commands::SectionCommands;
use shell_commands::ShellCommands;
use std::fmt::Display;
use std::path::PathBuf;
use task_commands::TaskCommands;
use test_commands::TestCommands;
use tokio::sync::mpsc::UnboundedSender;
mod auth_commands;
mod config_commands;
mod list_commands;
mod project_commands;
mod reminder_commands;
mod section_commands;
mod shell_commands;
mod task_commands;
mod test_commands;
const NAME: &str = env!("CARGO_PKG_NAME");
const LONG_VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
env!("BUILD_TARGET"),
"-",
env!("BUILD_PROFILE"),
")"
);
const AUTHOR: &str = env!("CARGO_PKG_AUTHORS");
const ABOUT: &str = env!("CARGO_PKG_DESCRIPTION");
const NO_PROJECTS_ERR: &str = "No projects in config. Add projects with `tod project import`";
#[derive(Parser, Clone)]
#[command(name = NAME)]
#[command(author = AUTHOR)]
#[command(version = LONG_VERSION)]
#[command(about = ABOUT, long_about = None)]
#[command(arg_required_else_help(true))]
pub struct Cli {
#[arg(short, long, default_value_t = false)]
pub verbose: bool,
#[arg(short, long)]
pub config: Option<PathBuf>,
#[arg(short, long)]
pub timeout: Option<u64>,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand, Debug, Clone)]
pub enum Commands {
#[command(subcommand)]
#[clap(alias = "p")]
Project(ProjectCommands),
#[command(subcommand)]
#[clap(alias = "n")]
Section(SectionCommands),
#[command(subcommand)]
#[clap(alias = "t")]
Task(TaskCommands),
#[command(subcommand)]
#[clap(alias = "l")]
List(ListCommands),
#[command(subcommand)]
#[clap(alias = "r")]
Reminder(ReminderCommands),
#[command(subcommand)]
#[clap(alias = "c")]
Config(ConfigCommands),
#[command(subcommand)]
#[clap(alias = "a")]
Auth(AuthCommands),
#[command(subcommand)]
#[clap(alias = "s")]
Shell(ShellCommands),
#[command(subcommand)]
#[clap(alias = "e")]
Test(TestCommands),
}
enum FlagOptions {
Project,
Filter,
}
impl Display for FlagOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FlagOptions::Project => write!(f, "Project"),
FlagOptions::Filter => write!(f, "Filter"),
}
}
}
pub async fn select_command(cli: Cli, tx: UnboundedSender<Error>) -> Result<CommandResult, Error> {
if cli.verbose {
crate::debug::print(LONG_VERSION);
}
match &cli.command {
Commands::Auth(command) => auth_command(command, &cli).await,
Commands::Config(command) => config_command(command, &cli, &tx).await,
Commands::List(command) => list_command(command, &cli, &tx).await,
Commands::Project(command) => project_command(command, &cli, &tx).await,
Commands::Reminder(command) => reminder_command(command, &cli, &tx).await,
Commands::Section(command) => section_command(command, &cli, &tx).await,
Commands::Shell(command) => shell_command(command).await,
Commands::Task(command) => task_command(command, &cli, &tx).await,
Commands::Test(command) => test_command(command, &cli, &tx).await,
}
}
async fn shell_command(command: &ShellCommands) -> Result<CommandResult, Error> {
match command {
ShellCommands::Completions(args) => {
let result = shell_commands::completions(args).await;
Ok(build_command_result_without_config(result))
}
}
}
async fn reminder_command(
command: &ReminderCommands,
cli: &Cli,
tx: &UnboundedSender<Error>,
) -> Result<CommandResult, Error> {
match command {
ReminderCommands::List(args) => {
let mut config = fetch_config(cli, tx).await?;
let result = reminder_commands::list(&mut config, args).await;
Ok(build_command_result(result, &config))
}
}
}
async fn section_command(
command: &SectionCommands,
cli: &Cli,
tx: &UnboundedSender<Error>,
) -> Result<CommandResult, Error> {
match command {
SectionCommands::Create(args) => {
let config = fetch_config(cli, tx).await?;
let result = section_commands::create(&config, args).await;
Ok(build_command_result(result, &config))
}
}
}
async fn project_command(
command: &ProjectCommands,
cli: &Cli,
tx: &UnboundedSender<Error>,
) -> Result<CommandResult, Error> {
match command {
ProjectCommands::Create(args) => {
let mut config = fetch_config(cli, tx).await?;
let result = project_commands::create(&mut config, args).await;
Ok(build_command_result(result, &config))
}
ProjectCommands::List(args) => {
let mut config = fetch_config(cli, tx).await?;
let result = project_commands::list(&mut config, args).await;
Ok(build_command_result(result, &config))
}
ProjectCommands::Remove(args) => {
let mut config = fetch_config(cli, tx).await?;
let result = project_commands::remove(&mut config, args).await;
Ok(build_command_result(result, &config))
}
ProjectCommands::Rename(args) => {
let mut config = fetch_config(cli, tx).await?;
let result = project_commands::rename(&mut config, args).await;
Ok(build_command_result(result, &config))
}
ProjectCommands::Import(args) => {
let mut config = fetch_config(cli, tx).await?;
let result = project_commands::import(&mut config, args).await;
Ok(build_command_result(result, &config))
}
ProjectCommands::Empty(args) => {
let mut config = fetch_config(cli, tx).await?;
let result = project_commands::empty(&mut config, args).await;
Ok(build_command_result(result, &config))
}
ProjectCommands::Delete(args) => {
let mut config = fetch_config(cli, tx).await?;
let result = project_commands::delete(&mut config, args).await;
Ok(build_command_result(result, &config))
}
}
}
async fn task_command(
command: &TaskCommands,
cli: &Cli,
tx: &UnboundedSender<Error>,
) -> Result<CommandResult, Error> {
match command {
TaskCommands::QuickAdd(args) => {
let config = fetch_config(cli, tx).await?;
let result = task_commands::quick_add(&config, args).await;
Ok(build_command_result(result, &config))
}
TaskCommands::Create(args) => {
let config = fetch_config(cli, tx).await?;
let result = task_commands::create(config.clone(), args).await;
Ok(build_command_result(result, &config))
}
TaskCommands::Edit(args) => {
let config = fetch_config(cli, tx).await?;
let result = task_commands::edit(config.clone(), args).await;
Ok(build_command_result(result, &config))
}
TaskCommands::Next(args) => {
let config = fetch_config(cli, tx).await?;
let result = task_commands::next(config.clone(), args).await;
Ok(build_command_result(result, &config))
}
TaskCommands::Complete(args) => {
let config = fetch_config(cli, tx).await?;
let result = task_commands::complete(config.clone(), args).await;
Ok(build_command_result(result, &config))
}
TaskCommands::Comment(args) => {
let config = fetch_config(cli, tx).await?;
let result = task_commands::comment(config.clone(), args).await;
Ok(build_command_result(result, &config))
}
}
}
async fn list_command(
command: &ListCommands,
cli: &Cli,
tx: &UnboundedSender<Error>,
) -> Result<CommandResult, Error> {
match command {
ListCommands::View(args) => {
let mut config = fetch_config(cli, tx).await?;
let result = list_commands::view(&mut config, args).await;
Ok(build_command_result(result, &config))
}
ListCommands::Process(args) => {
let config = fetch_config(cli, tx).await?;
let result = list_commands::process(config.clone(), args).await;
Ok(build_command_result(result, &config))
}
ListCommands::Prioritize(args) => {
let config = fetch_config(cli, tx).await?;
let result = list_commands::prioritize(config.clone(), args).await;
Ok(build_command_result(result, &config))
}
ListCommands::Remind(args) => {
let config = fetch_config(cli, tx).await?;
let result = list_commands::remind(config.clone(), args).await;
Ok(build_command_result(result, &config))
}
ListCommands::Label(args) => {
let config = fetch_config(cli, tx).await?;
let result = list_commands::label(config.clone(), args).await;
Ok(build_command_result(result, &config))
}
ListCommands::Schedule(args) => {
let config = fetch_config(cli, tx).await?;
let result = list_commands::schedule(config.clone(), args).await;
Ok(build_command_result(result, &config))
}
ListCommands::Deadline(args) => {
let config = fetch_config(cli, tx).await?;
let result = list_commands::deadline(config.clone(), args).await;
Ok(build_command_result(result, &config))
}
ListCommands::Timebox(args) => {
let config = fetch_config(cli, tx).await?;
let result = list_commands::timebox(config.clone(), args).await;
Ok(build_command_result(result, &config))
}
ListCommands::Import(args) => {
let config = fetch_config(cli, tx).await?;
let result = list_commands::import(config.clone(), args).await;
Ok(build_command_result(result, &config))
}
}
}
async fn config_command(
command: &ConfigCommands,
cli: &Cli,
tx: &UnboundedSender<Error>,
) -> Result<CommandResult, Error> {
match command {
ConfigCommands::SetTimezone(args) => {
let config = fetch_config(cli, tx).await?;
let result = config_commands::set_timezone(config.clone(), args).await;
Ok(build_command_result(result, &config))
}
ConfigCommands::Edit(args) => {
let config = fetch_config(cli, tx).await?;
let result = config_commands::edit(config.clone(), args).await;
Ok(build_command_result(result, &config))
}
ConfigCommands::CheckVersion(args) => {
let result = config_commands::check_version(args, None).await;
Ok(build_command_result_without_config(result))
}
ConfigCommands::Check(_args) => {
let result = config_commands::check(cli.config.clone()).await;
Ok(build_command_result_without_config(result))
}
ConfigCommands::About(args) => {
let result = config_commands::about(args).await;
Ok(build_command_result_without_config(result))
}
ConfigCommands::Reset(args) => {
let result = crate::config::config_reset(cli.config.clone(), args.force).await;
Ok(build_command_result_without_config(result))
}
ConfigCommands::Open(_args) => {
let result = crate::config::config_open(cli.config.clone()).await;
Ok(build_command_result_without_config(result))
}
}
}
async fn auth_command(command: &AuthCommands, cli: &Cli) -> Result<CommandResult, Error> {
match command {
AuthCommands::Login(args) => {
let mut config = auth_commands::load_or_create_config(cli.config.clone()).await?;
let result = auth_commands::login(&mut config, args).await;
Ok(build_command_result(result, &config))
}
AuthCommands::Token(args) => {
let result = auth_commands::token(cli.config.clone(), args).await;
Ok(build_command_result_without_config(result))
}
}
}
async fn test_command(
command: &TestCommands,
cli: &Cli,
tx: &UnboundedSender<Error>,
) -> Result<CommandResult, Error> {
match command {
TestCommands::All(args) => {
let config = fetch_config(cli, tx).await?;
let result = test_commands::all(&config, args).await;
Ok(build_command_result(result, &config))
}
}
}
fn build_command_result(result: Result<String, Error>, config: &Config) -> CommandResult {
CommandResult {
bell_success: config.bell_on_success,
bell_failure: config.bell_on_failure,
result,
}
}
fn build_command_result_without_config(result: Result<String, Error>) -> CommandResult {
CommandResult {
bell_success: false,
bell_failure: true,
result,
}
}
async fn fetch_config(cli: &Cli, tx: &UnboundedSender<Error>) -> Result<Config, Error> {
let config = get_existing_config_exists(cli.config.clone()).await?;
let config = with_cli_context(config, cli, tx);
crate::debug::maybe_print_redacted_config(&config);
ensure_auth_present(&config, "fetch_config")?;
let config = config.check_for_latest_version().await?;
config.maybe_set_timezone().await
}
async fn get_existing_config_exists(config_path: Option<PathBuf>) -> Result<Config, Error> {
match crate::config::get_config(config_path).await {
Ok(config) => Ok(config),
Err(e) => Err(e),
}
}
fn with_cli_context(mut config: Config, cli: &Cli, tx: &UnboundedSender<Error>) -> Config {
config.args.verbose = cli.verbose;
config.args.timeout = cli.timeout;
config.internal.tx = Some(tx.clone());
config
}
fn ensure_auth_present(config: &Config, source: &str) -> Result<(), Error> {
if config
.token
.as_ref()
.is_none_or(|token| token.trim().is_empty())
{
Err(Error::new(
source,
"No auth present - run \"tod auth login\"",
))
} else {
Ok(())
}
}
fn fetch_string(
maybe_string: Option<&str>,
config: &Config,
prompt: &str,
) -> Result<String, Error> {
match maybe_string {
Some(string) => Ok(string.to_owned()),
None => input::string(prompt, config.mock_string.clone()),
}
}
async fn fetch_project(project_name: Option<&str>, config: &Config) -> Result<Flag, Error> {
let projects = config.projects().await?;
if projects.is_empty() {
return Err(Error::new("fetch_project", NO_PROJECTS_ERR));
}
match project_name {
Some(project_name) => projects
.iter()
.find(|p| p.name == project_name)
.map_or_else(
|| {
Err(Error::new(
"fetch_project",
"Could not find project in config",
))
},
|p| Ok(Flag::Project(p.to_owned())),
),
None => input::select(input::PROJECT, projects, config.mock_select).map(Flag::Project),
}
}
fn fetch_filter(filter: Option<&str>, config: &Config) -> Result<Flag, Error> {
if let Some(string) = filter {
Ok(Flag::Filter(string.to_owned()))
} else {
let string = input::string(input::FILTER, config.mock_string.clone())?;
Ok(Flag::Filter(string))
}
}
async fn fetch_project_or_filter(
project: Option<&str>,
filter: Option<&str>,
config: &Config,
) -> Result<Flag, Error> {
match (project, filter) {
(Some(_), None) => fetch_project(project, config).await,
(None, Some(_)) => fetch_filter(filter, config),
(Some(_), Some(_)) => Err(Error::new(
"project_or_filter",
"Must select project OR filter",
)),
(None, None) => {
let options = vec![FlagOptions::Project, FlagOptions::Filter];
match input::select(input::OPTION, options, config.mock_select)? {
FlagOptions::Project => fetch_project(project, config).await,
FlagOptions::Filter => fetch_filter(filter, config),
}
}
}
}
fn fetch_priority(priority: Option<u8>, config: &Config) -> Result<Priority, Error> {
if let Some(priority) = priority::from_integer(priority)? {
Ok(priority)
} else {
let options = vec![
Priority::None,
Priority::Low,
Priority::Medium,
Priority::High,
];
input::select(input::PRIORITY, options, config.mock_select)
}
}
async fn maybe_fetch_labels(config: &Config, labels: &[String]) -> Result<Vec<String>, Error> {
if labels.is_empty() {
let labels = labels::get_labels(config, false)
.await?
.into_iter()
.map(|l| l.name)
.collect();
Ok(labels)
} else {
Ok(labels.to_vec())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tasks::priority::Priority;
use crate::test;
use crate::test::responses::ResponseFromFile;
use crate::test_time::FixedTimeProvider;
use crate::time::TimeProviderEnum;
use tokio::sync::mpsc;
#[test]
fn build_command_result_uses_config_bell_settings() {
let mut config = Config::default_test();
config.bell_on_success = true;
config.bell_on_failure = false;
let result = build_command_result(Ok("ok".to_string()), &config);
assert!(result.bell_success);
assert!(!result.bell_failure);
assert!(matches!(result.result, Ok(text) if text == "ok"));
}
#[test]
fn build_command_result_without_config_uses_defaults() {
let result = build_command_result_without_config(Ok("ok".to_string()));
assert!(!result.bell_success);
assert!(result.bell_failure);
assert!(matches!(result.result, Ok(text) if text == "ok"));
}
#[test]
fn ensure_auth_present_errors_when_token_missing() {
let mut config = Config::default_test();
config.token = None;
let result = ensure_auth_present(&config, "test-source");
let error = result.expect_err("missing token should fail auth check");
assert!(error.message.contains("tod auth login"));
}
#[test]
fn ensure_auth_present_errors_when_token_whitespace() {
let mut config = Config::default_test();
config.token = Some(" ".to_string());
let result = ensure_auth_present(&config, "test-source");
let error = result.expect_err("whitespace token should fail auth check");
assert!(error.message.contains("tod auth login"));
}
#[test]
fn ensure_auth_present_succeeds_with_token() {
let mut config = Config::default_test();
config.token = Some("token".to_string());
let result = ensure_auth_present(&config, "test-source");
assert!(result.is_ok());
}
#[test]
fn with_cli_context_sets_verbose_timeout_and_tx() {
let (tx, _rx) = mpsc::unbounded_channel::<Error>();
let cli = Cli {
verbose: true,
config: None,
timeout: Some(42),
command: Commands::Test(TestCommands::All(test_commands::All {})),
};
let config = Config::default_test();
let result = with_cli_context(config, &cli, &tx);
assert!(result.args.verbose);
assert_eq!(result.args.timeout, Some(42));
assert!(result.internal.tx.is_some());
}
#[test]
fn fetch_filter_returns_provided_value() {
let config = Config::default_test();
let result = fetch_filter(Some("myfilter"), &config);
let flag = result.expect("filter should be returned");
assert!(matches!(flag, Flag::Filter(f) if f == "myfilter"));
}
#[test]
fn fetch_filter_uses_mock_string_when_none() {
let mut config = Config::default_test();
config.mock_string = Some("prompted-filter".to_string());
let result = fetch_filter(None, &config);
let flag = result.expect("filter should be prompted");
assert!(matches!(flag, Flag::Filter(f) if f == "prompted-filter"));
}
#[test]
fn fetch_priority_returns_from_valid_integer() {
let config = Config::default_test();
let result = fetch_priority(Some(4), &config);
let priority = result.expect("priority 4 should be High");
assert_eq!(priority, Priority::High);
}
#[test]
fn fetch_priority_uses_mock_select_when_none() {
let mut config = Config::default_test();
config.mock_select = Some(1);
let result = fetch_priority(None, &config);
let priority = result.expect("should select priority from list");
assert_eq!(priority, Priority::Low);
}
#[test]
fn fetch_string_returns_provided_value() {
let config = Config::default_test();
let result = fetch_string(Some("hello"), &config, "test prompt");
assert_eq!(result.expect("should return provided string"), "hello");
}
#[test]
fn fetch_string_uses_mock_string_when_none() {
let mut config = Config::default_test();
config.mock_string = Some("mocked input".to_string());
let result = fetch_string(None, &config, "test prompt");
assert_eq!(result.expect("should return mocked string"), "mocked input");
}
#[tokio::test]
async fn maybe_fetch_labels_returns_provided_labels() {
let config = Config::default_test();
let labels = vec!["label1".to_string(), "label2".to_string()];
let result = maybe_fetch_labels(&config, &labels).await;
assert_eq!(
result.expect("should return provided labels"),
vec!["label1", "label2"]
);
}
#[tokio::test]
async fn maybe_fetch_labels_fetches_from_api_when_empty() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/v1/labels?limit=200")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(ResponseFromFile::Labels.read().await)
.create_async()
.await;
let config = test::fixtures::config()
.await
.with_mock_url(server.url())
.with_time_provider(TimeProviderEnum::Fixed(FixedTimeProvider));
let result = maybe_fetch_labels(&config, &[]).await;
assert!(
result.is_ok(),
"should fetch labels from API when none provided"
);
assert!(!result.unwrap().is_empty());
mock.assert();
}
}