From 291dd2c5f1321c9126b4cac978bcc936834623b2 Mon Sep 17 00:00:00 2001 From: Alan Vardy Date: Sat, 29 Jul 2023 12:27:06 -0700 Subject: [PATCH] Use structs for projects --- CHANGELOG.md | 2 + manual_test.sh | 4 - src/config.rs | 161 ++++++---------- src/main.rs | 73 +++----- src/projects.rs | 485 ++++++++++++++++++++---------------------------- src/test.rs | 65 ++++++- src/todoist.rs | 28 +-- 7 files changed, 361 insertions(+), 457 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dac4806..3d8ebfcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unreleased - Put date picker option first when scheduling +- Use structs for projects instead storing as a `HashMap` in config. This means that projects need to imported again with `project import`, sorry for the inconvenience. The tech debt around project handling was slowing down development. +- Remove `project add` as only `project import` can be used now ## 2023-06-27 v0.4.8 diff --git a/manual_test.sh b/manual_test.sh index 85905f15..75b331b4 100755 --- a/manual_test.sh +++ b/manual_test.sh @@ -14,10 +14,6 @@ commands=( "cargo run -- task list --scheduled" "cargo run -- project -h" "cargo run -- project list" -"cargo run -- project add --name test --id 2" -"cargo run -- project remove --project test" -"cargo run -- project add -n test -i 2" -"cargo run -- project remove -p test" "cargo run -- project empty --project Inbox" "cargo run -- project empty -p Inbox" "cargo run -- project schedule --project '🦾 Digital'" diff --git a/src/config.rs b/src/config.rs index 04fb5ca4..9f5d7711 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,10 @@ use crate::cargo::Version; -use crate::{cargo, color, input, time, VERSION}; +use crate::projects::Project; +use crate::{cargo, color, input, time, todoist, VERSION}; use chrono_tz::TZ_VARIANTS; use rand::distributions::{Alphanumeric, DistString}; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::collections::HashMap; use std::fs; use std::io::{Read, Write}; @@ -14,7 +14,8 @@ pub struct Config { /// The Todoist Api token pub token: String, /// List of Todoist projects and their project numbers - pub projects: HashMap, + #[serde(rename = "vecprojects")] + pub projects: Option>, /// Path to config file pub path: String, /// The ID of the next task @@ -29,9 +30,21 @@ pub struct Config { } impl Config { - pub fn add_project(&mut self, name: String, number: u32) { - let projects = &mut self.projects; - projects.insert(name, number); + pub fn reload_projects(self: &mut Config) -> Result { + let all_projects = todoist::projects(self)?; + let current_projects = self.projects.clone().unwrap_or_default(); + let current_project_ids: Vec = + current_projects.iter().map(|p| p.id.to_owned()).collect(); + + let updated_projects = all_projects + .iter() + .filter(|p| current_project_ids.contains(&p.id)) + .map(|p| p.to_owned()) + .collect::>(); + + self.projects = Some(updated_projects); + + Ok(color::green_string("✓")) } pub fn check_for_latest_version(self: Config) -> Result { @@ -115,7 +128,6 @@ impl Config { } pub fn new(token: &str) -> Result { - let projects: HashMap = HashMap::new(); Ok(Config { path: generate_path()?, token: String::from(token), @@ -126,7 +138,7 @@ impl Config { mock_url: None, mock_string: None, mock_select: None, - projects, + projects: Some(Vec::new()), }) } @@ -134,8 +146,27 @@ impl Config { Config::load(&self.path) } - pub fn remove_project(&mut self, name: &str) { - self.projects.remove(name); + pub fn add_project(&mut self, project: Project) { + let option_projects = &mut self.projects; + match option_projects { + Some(projects) => { + projects.push(project); + } + None => self.projects = Some(vec![project]), + } + } + + pub fn remove_project(&mut self, project: &Project) { + let projects = self + .projects + .clone() + .unwrap_or_default() + .iter() + .filter(|p| p.id != project.id) + .map(|p| p.to_owned()) + .collect::>(); + + self.projects = Some(projects); } pub fn save(&mut self) -> std::result::Result { @@ -241,13 +272,12 @@ mod tests { fn reload_config_should_work() { let config = test::fixtures::config(); let mut config = config.create().expect("Failed to create test config"); - config.add_project("testproj".to_string(), 1); - assert!(!&config.projects.is_empty()); + let project = test::fixtures::project(); + config.add_project(project); + let projects = config.projects.clone().unwrap_or_default(); + assert!(!&projects.is_empty()); - let reloaded_config = config.reload().expect("Failed to reload config"); - assert!(reloaded_config.projects.is_empty()); - - delete_config(&reloaded_config.path); + config.reload().expect("Failed to reload config"); } #[test] @@ -263,92 +293,23 @@ mod tests { #[test] fn add_project_should_work() { let mut config = test::fixtures::config(); - let mut projects: HashMap = HashMap::new(); - assert_eq!( - config, - Config { - token: String::from("alreadycreated"), - path: config.path.clone(), - next_id: None, - last_version_check: None, - projects: projects.clone(), - spinners: Some(true), - timezone: Some(String::from("US/Pacific")), - mock_url: None, - mock_string: None, - mock_select: None, - } - ); - config.add_project(String::from("test"), 1234); - projects.insert(String::from("test"), 1234); - assert_eq!( - config, - Config { - token: String::from("alreadycreated"), - path: config.path.clone(), - next_id: None, - last_version_check: None, - spinners: Some(true), - projects, - timezone: Some(String::from("US/Pacific")), - mock_url: None, - mock_string: None, - mock_select: None, - } - ); + let projects_count = config.projects.clone().unwrap_or_default().len(); + assert_eq!(projects_count, 1); + config.add_project(test::fixtures::project()); + let projects_count = config.projects.clone().unwrap_or_default().len(); + assert_eq!(projects_count, 2); } #[test] fn remove_project_should_work() { - let mut projects: HashMap = HashMap::new(); - projects.insert(String::from("test"), 1234); - projects.insert(String::from("test2"), 4567); - let mut config_with_two_projects = Config { - token: String::from("something"), - path: generate_path().unwrap(), - next_id: None, - spinners: Some(true), - last_version_check: None, - projects: projects.clone(), - timezone: Some(String::from("Asia/Pyongyang")), - mock_url: None, - mock_string: None, - mock_select: None, - }; - - assert_eq!( - config_with_two_projects, - Config { - token: String::from("something"), - path: config_with_two_projects.path.clone(), - next_id: None, - spinners: Some(true), - last_version_check: None, - projects: projects.clone(), - timezone: Some(String::from("Asia/Pyongyang")), - mock_url: None, - mock_string: None, - mock_select: None, - } - ); - config_with_two_projects.remove_project("test"); - let mut projects: HashMap = HashMap::new(); - projects.insert(String::from("test2"), 4567); - assert_eq!( - config_with_two_projects, - Config { - token: String::from("something"), - path: config_with_two_projects.path.clone(), - next_id: None, - last_version_check: None, - projects, - spinners: Some(true), - timezone: Some(String::from("Asia/Pyongyang")), - mock_url: None, - mock_string: None, - mock_select: None, - } - ); + let mut config = test::fixtures::config(); + let projects = config.projects.clone().unwrap_or_default(); + let project = projects.first().unwrap(); + let projects_count = config.projects.clone().unwrap_or_default().len(); + assert_eq!(projects_count, 1); + config.remove_project(project); + let projects_count = config.projects.clone().unwrap_or_default().len(); + assert_eq!(projects_count, 0); } #[test] @@ -370,7 +331,7 @@ mod tests { config, Ok(Config { token: String::new(), - projects: HashMap::new(), + projects: Some(Vec::new()), path: config.clone().unwrap().path, next_id: None, spinners: Some(true), @@ -395,7 +356,7 @@ mod tests { config, Ok(Config { token: String::new(), - projects: HashMap::new(), + projects: Some(Vec::new()), path: config.clone().unwrap().path, next_id: None, spinners: Some(true), diff --git a/src/main.rs b/src/main.rs index 74c1e534..805cc3ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ extern crate clap; use clap::{Arg, ArgAction, ArgMatches, Command}; use config::Config; use items::priority::Priority; +use projects::Project; mod cargo; mod color; @@ -23,6 +24,8 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); const AUTHOR: &str = "Alan Vardy "; const ABOUT: &str = "A tiny unofficial Todoist client"; +const NO_PROJECTS_ERR: &str = "No projects in config. Add projects with `tod project import`"; + #[cfg(not(tarpaulin_include))] fn main() { let matches = cmd().get_matches(); @@ -47,7 +50,6 @@ fn main() { }, Some(("project", project_matches)) => match project_matches.subcommand() { Some(("list", m)) => project_list(m), - Some(("add", m)) => project_add(m), Some(("remove", m)) => project_remove(m), Some(("rename", m)) => project_rename(m), Some(("process", m)) => project_process(m), @@ -129,10 +131,6 @@ fn cmd() -> Command { .subcommands([ Command::new("list").about("List all projects in config") .arg(config_arg()), - Command::new("add").about("Add a project to config (not Todoist)") - .arg(config_arg()) - .arg(name_arg()) - .arg(id_arg()), Command::new("remove").about("Remove a project from config (not Todoist)") .arg(config_arg()) .arg(flag_arg("auto", 'a', "Remove all projects from config that are not in Todoist")) @@ -192,16 +190,17 @@ fn task_create(matches: &ArgMatches) -> Result { let description = fetch_description(matches); let due = fetch_due(matches); - projects::add_item_to_project(&config, content, &project, priority, description, due) + todoist::add_item(&config, &content, &project, priority, description, due)?; + + Ok(color::green_string("✓")) } #[cfg(not(tarpaulin_include))] fn task_edit(matches: &ArgMatches) -> Result { let config = fetch_config(matches)?; - let project_name = fetch_project(matches, &config)?; - let project_id = projects::project_id(&config, &project_name)?; + let project = fetch_project(matches, &config)?; - projects::rename_items(&config, &project_id) + projects::rename_items(&config, &project) } #[cfg(not(tarpaulin_include))] fn task_list(matches: &ArgMatches) -> Result { @@ -236,18 +235,9 @@ fn task_complete(matches: &ArgMatches) -> Result { #[cfg(not(tarpaulin_include))] fn project_list(matches: &ArgMatches) -> Result { - let config = fetch_config(matches)?; - - projects::list(&config) -} - -#[cfg(not(tarpaulin_include))] -fn project_add(matches: &ArgMatches) -> Result { let mut config = fetch_config(matches)?; - let name = fetch_string(matches, &config, "name", "Enter project name or alias")?; - let id = fetch_string(matches, &config, "id", "Enter ID of project")?; - projects::add(&mut config, name, id) + projects::list(&mut config) } #[cfg(not(tarpaulin_include))] @@ -291,10 +281,10 @@ fn project_import(matches: &ArgMatches) -> Result { #[cfg(not(tarpaulin_include))] fn project_empty(matches: &ArgMatches) -> Result { - let config = fetch_config(matches)?; + let mut config = fetch_config(matches)?; let project = fetch_project(matches, &config)?; - projects::empty(&config, &project) + projects::empty(&mut config, &project) } #[cfg(not(tarpaulin_include))] @@ -372,17 +362,6 @@ fn config_arg() -> Arg { .help("Absolute path of configuration. Defaults to $XDG_CONFIG_HOME/tod.cfg") } -#[cfg(not(tarpaulin_include))] -fn id_arg() -> Arg { - Arg::new("id") - .short('i') - .long("id") - .num_args(1) - .required(false) - .value_name("ID") - .help("Identification key") -} - #[cfg(not(tarpaulin_include))] fn content_arg() -> Arg { Arg::new("content") @@ -416,17 +395,6 @@ fn due_arg() -> Arg { .help("Date date in format YYYY-MM-DD, YYYY-MM-DD HH:MM, or natural language") } -#[cfg(not(tarpaulin_include))] -fn name_arg() -> Arg { - Arg::new("name") - .short('n') - .long("name") - .num_args(1) - .required(false) - .value_name("PROJECT NAME") - .help("Name of project") -} - #[cfg(not(tarpaulin_include))] fn project_arg() -> Arg { Arg::new("project") @@ -480,14 +448,21 @@ fn fetch_string( } #[cfg(not(tarpaulin_include))] -fn fetch_project(matches: &ArgMatches, config: &Config) -> Result { +fn fetch_project(matches: &ArgMatches, config: &Config) -> Result { let project_content = matches.get_one::("project").map(|s| s.to_owned()); + let projects = config.projects.clone().unwrap_or_default(); + if projects.is_empty() { + return Err(NO_PROJECTS_ERR.to_string()); + } match project_content { - Some(string) => Ok(string), - None => { - let options = projects::project_names(config)?; - input::select("Select project", options, config.mock_select) - } + Some(project_name) => projects + .iter() + .find(|p| p.name == project_name.as_str()) + .map_or_else( + || Err("Could not find project in config".to_string()), + |p| Ok(p.to_owned()), + ), + None => input::select("Select project", projects, config.mock_select), } } diff --git a/src/projects.rs b/src/projects.rs index 36029123..d62ae769 100644 --- a/src/projects.rs +++ b/src/projects.rs @@ -1,5 +1,4 @@ use pad::PadStr; -use rayon::prelude::*; use std::fmt::Display; use crate::config::Config; @@ -7,16 +6,13 @@ use crate::input::DateTimeInput; use crate::items::priority::Priority; use crate::items::{FormatType, Item}; use crate::{color, input, items, projects, todoist}; -use serde::Deserialize; - -const ADD_ERR: &str = "Must provide project name and number, i.e. tod --add projectname 12345"; - -const NO_PROJECTS_ERR: &str = "No projects in config, please run `tod project import`"; +use rayon::prelude::*; +use serde::{Deserialize, Serialize}; const PAD_WIDTH: usize = 30; // Projects are split into sections -#[derive(PartialEq, Deserialize, Clone, Debug)] +#[derive(PartialEq, Eq, Serialize, Deserialize, Clone, Debug)] pub struct Project { pub id: String, pub name: String, @@ -55,91 +51,79 @@ pub fn json_to_projects(json: String) -> Result, String> { } /// List the projects in config with task counts -pub fn list(config: &Config) -> Result { - let mut projects: Vec = config - .projects - .par_iter() - .map(|(k, _)| project_name_with_count(config, k)) - .collect::>(); - if projects.is_empty() { - return Ok(String::from("No projects found")); - } - projects.sort(); - let mut buffer = String::new(); - buffer.push_str(&color::green_string("Projects").pad_to_width(PAD_WIDTH + 5)); - buffer.push_str(&color::green_string("# Tasks")); - - for key in projects { - buffer.push_str("\n - "); - buffer.push_str(&key); +pub fn list(config: &mut Config) -> Result { + config.reload_projects()?; + + if let Some(projects) = config.projects.clone() { + let mut projects = projects + .par_iter() + .map(|p| project_name_with_count(config, p)) + .collect::>(); + if projects.is_empty() { + return Ok(String::from("No projects found")); + } + projects.sort(); + let mut buffer = String::new(); + buffer.push_str(&color::green_string("Projects").pad_to_width(PAD_WIDTH + 5)); + buffer.push_str(&color::green_string("# Tasks")); + + for key in projects { + buffer.push_str("\n - "); + buffer.push_str(&key); + } + Ok(buffer) + } else { + Ok(String::from("No projects found")) } - Ok(buffer) } /// Formats a string with project name and the count that is a standard length -fn project_name_with_count(config: &Config, project_name: &str) -> String { - let count = match count_processable_items(config, project_name) { +fn project_name_with_count(config: &Config, project: &Project) -> String { + let count = match count_processable_items(config, project) { Ok(num) => format!("{}", num), Err(_) => String::new(), }; - format!( - "{}{}", - project_name.to_owned().pad_to_width(PAD_WIDTH), - count - ) + format!("{}{}", project.name.pad_to_width(PAD_WIDTH), count) } /// Gets the number of items for a project that are not in the future -fn count_processable_items(config: &Config, project_name: &str) -> Result { - let project_id = projects::project_id(config, project_name)?; - - let all_items = todoist::items_for_project(config, &project_id)?; +fn count_processable_items(config: &Config, project: &Project) -> Result { + let all_items = todoist::items_for_project(config, project)?; let count = items::filter_not_in_future(all_items, config)?.len(); Ok(count as u8) } /// Add a project to the projects HashMap in Config -pub fn add(config: &mut Config, name: String, id: String) -> Result { - let id = id.parse::().or(Err(ADD_ERR))?; - - config.add_project(name, id); +pub fn add(config: &mut Config, project: &Project) -> Result { + config.add_project(project.clone()); config.save() } /// Remove a project from the projects HashMap in Config -pub fn remove(config: &mut Config, project_name: &str) -> Result { - config.remove_project(project_name); +pub fn remove(config: &mut Config, project: &Project) -> Result { + config.remove_project(project); config.save() } /// Rename a project in config -pub fn rename(config: Config, project_name: &str) -> Result { - let new_name = input::string_with_default("Input new project name", project_name)?; +pub fn rename(config: Config, project: &Project) -> Result { + let new_name = input::string_with_default("Input new project name", &project.name)?; - let project_id = project_id(&config, project_name)?; let mut config = config; - add(&mut config, new_name, project_id)?; - remove(&mut config, project_name) -} - -pub fn project_id(config: &Config, project_name: &str) -> Result { - let project_id = config - .projects - .get(project_name) - .ok_or(format!( - "Project {project_name} not found, please add it to config" - ))? - .to_string(); - - Ok(project_id) + let new_project = Project { + name: new_name, + ..project.clone() + }; + add(&mut config, &new_project)?; + remove(&mut config, project) } /// Get the next item by priority and save its id to config -pub fn next(config: Config, project_name: &str) -> Result { - match fetch_next_item(&config, project_name) { +pub fn next(config: Config, project: &Project) -> Result { + match fetch_next_item(&config, project) { Ok(Some((item, remaining))) => { config.set_next_id(&item.id).save()?; let item_string = item.fmt(&config, FormatType::Single); @@ -150,9 +134,8 @@ pub fn next(config: Config, project_name: &str) -> Result { } } -fn fetch_next_item(config: &Config, project_name: &str) -> Result, String> { - let project_id = projects::project_id(config, project_name)?; - let items = todoist::items_for_project(config, &project_id)?; +fn fetch_next_item(config: &Config, project: &Project) -> Result, String> { + let items = todoist::items_for_project(config, project)?; let filtered_items = items::filter_not_in_future(items, config)?; let items = items::sort_by_value(filtered_items, config); @@ -172,7 +155,11 @@ pub fn remove_auto(config: &mut Config) -> Result { config.remove_project(project); } config.save()?; - let project_names = missing_projects.join(", "); + let project_names = missing_projects + .iter() + .map(|p| p.name.clone()) + .collect::>() + .join(", "); let message = format!("Auto removed: {project_names}"); Ok(color::green_string(&message)) } @@ -189,12 +176,12 @@ pub fn remove_all(config: &mut Config) -> Result { if selection == "Cancel" { return Ok(String::from("Cancelled")); } - let projects: Vec = config.projects.clone().into_keys().collect(); - if projects.is_empty() { + + if config.projects.clone().unwrap_or_default().is_empty() { return Ok(color::green_string("No projects to remove")); } - for project in &projects { + for project in &config.projects.clone().unwrap_or_default() { config.remove_project(project); } config.save()?; @@ -203,17 +190,16 @@ pub fn remove_all(config: &mut Config) -> Result { } /// Returns the projects that are not already in config -fn filter_missing_projects(config: &Config, projects: Vec) -> Vec { +fn filter_missing_projects(config: &Config, projects: Vec) -> Vec { let project_ids: Vec = projects.into_iter().map(|v| v.id).collect(); - let missing_project_names: Vec = config + config .projects .clone() + .unwrap_or_default() + .clone() .into_iter() - .filter(|(_k, v)| !project_ids.contains(&v.to_string())) - .map(|(k, _v)| k) - .collect(); - - missing_project_names + .filter(|p| !project_ids.contains(&p.id)) + .collect() } /// Fetch projects and prompt to add them to config one by one @@ -228,7 +214,13 @@ pub fn import(config: &mut Config) -> Result { /// Returns the projects that are not already in config fn filter_new_projects(config: &Config, projects: Vec) -> Vec { - let project_ids: Vec = config.projects.values().map(|v| v.to_string()).collect(); + let project_ids: Vec = config + .projects + .clone() + .unwrap_or_default() + .iter() + .map(|v| v.id.clone()) + .collect(); let new_projects: Vec = projects .into_iter() .filter(|p| !project_ids.contains(&p.id)) @@ -244,7 +236,7 @@ fn maybe_add_project(config: &mut Config, project: Project) -> Result { if string == "add" { - add(config, project.name, project.id) + add(config, &project) } else if string == "skip" { Ok(String::from("Skipped")) } else { @@ -256,9 +248,8 @@ fn maybe_add_project(config: &mut Config, project: Project) -> Result Result { - let project_id = projects::project_id(&config, project_name)?; - let items = todoist::items_for_project(&config, &project_id)?; +pub fn process_items(config: Config, project: &Project) -> Result { + let items = todoist::items_for_project(&config, project)?; let items = items::filter_not_in_future(items, &config)?; for item in items { config.set_next_id(&item.id).save()?; @@ -268,6 +259,7 @@ pub fn process_items(config: Config, project_name: &str) -> Result return Ok(color::green_string("Exited")), } } + let project_name = project.clone().name; Ok(color::green_string(&format!( "There are no more tasks in '{project_name}'" ))) @@ -294,10 +286,8 @@ fn handle_item(config: &Config, item: Item) -> Option> { } // Scheduled that are today and have a time on them (AKA appointments) -pub fn scheduled_items(config: &Config, project_name: &str) -> Result { - let project_id = projects::project_id(config, project_name)?; - - let items = todoist::items_for_project(config, &project_id)?; +pub fn scheduled_items(config: &Config, project: &Project) -> Result { + let items = todoist::items_for_project(config, project)?; let filtered_items = items::filter_today_and_has_time(items, config); if filtered_items.is_empty() { @@ -306,7 +296,8 @@ pub fn scheduled_items(config: &Config, project_name: &str) -> Result Result Result { - let project_tasks = todoist::items_for_project(config, project_id)?; +pub fn rename_items(config: &Config, project: &Project) -> Result { + let project_tasks = todoist::items_for_project(config, project)?; let selected_task = input::select( "Choose a task of the project:", @@ -338,13 +329,11 @@ pub fn rename_items(config: &Config, project_id: &str) -> Result } /// All items for a project -pub fn all_items(config: &Config, project_name: &str) -> Result { - let project_id = projects::project_id(config, project_name)?; - - let items = todoist::items_for_project(config, &project_id)?; +pub fn all_items(config: &Config, project: &Project) -> Result { + let items = todoist::items_for_project(config, project)?; let mut buffer = String::new(); - buffer.push_str(&color::green_string(&format!("Tasks for {project_name}"))); + buffer.push_str(&color::green_string(&format!("Tasks for {}", project.name))); for item in items::sort_by_datetime(items, config) { buffer.push('\n'); @@ -354,14 +343,13 @@ pub fn all_items(config: &Config, project_name: &str) -> Result } /// Empty a project by sending items to other projects one at a time -pub fn empty(config: &Config, project_name: &str) -> Result { - let id = projects::project_id(config, project_name)?; - - let items = todoist::items_for_project(config, &id)?; +pub fn empty(config: &mut Config, project: &Project) -> Result { + let items = todoist::items_for_project(config, project)?; if items.is_empty() { Ok(color::green_string(&format!( - "No tasks to empty from {project_name}" + "No tasks to empty from {}", + project.name ))) } else { projects::list(config)?; @@ -369,16 +357,15 @@ pub fn empty(config: &Config, project_name: &str) -> Result { move_item_to_project(config, item.to_owned())?; } Ok(color::green_string(&format!( - "Successfully emptied {project_name}" + "Successfully emptied {}", + project.name ))) } } /// Prioritize all unprioritized items in a project -pub fn prioritize_items(config: &Config, project_name: &str) -> Result { - let inbox_id = projects::project_id(config, project_name)?; - - let items = todoist::items_for_project(config, &inbox_id)?; +pub fn prioritize_items(config: &Config, project: &Project) -> Result { + let items = todoist::items_for_project(config, project)?; let unprioritized_items: Vec = items .into_iter() @@ -387,23 +374,23 @@ pub fn prioritize_items(config: &Config, project_name: &str) -> Result Result { - let project_id = projects::project_id(config, project_name)?; - - let items = todoist::items_for_project(config, &project_id)?; +pub fn schedule(config: &Config, project: &Project, filter: TaskFilter) -> Result { + let items = todoist::items_for_project(config, project)?; let filtered_items: Vec = items .into_iter() @@ -412,7 +399,8 @@ pub fn schedule(config: &Config, project_name: &str, filter: TaskFilter) -> Resu if filtered_items.is_empty() { Ok(color::green_string(&format!( - "No tasks to schedule in {project_name}" + "No tasks to schedule in {}", + project.name ))) } else { for item in filtered_items.iter() { @@ -434,7 +422,8 @@ pub fn schedule(config: &Config, project_name: &str, filter: TaskFilter) -> Resu }; } Ok(color::green_string(&format!( - "Successfully scheduled tasks in {project_name}" + "Successfully scheduled tasks in {}", + project.name ))) } } @@ -442,30 +431,30 @@ pub fn schedule(config: &Config, project_name: &str, filter: TaskFilter) -> Resu pub fn move_item_to_project(config: &Config, item: Item) -> Result { println!("{}", item.fmt(config, FormatType::Single)); - let mut options = project_names(config)?; - options.reverse(); - options.push("skip".to_string()); - options.push("complete".to_string()); - options.reverse(); - - let project_name = input::select( + let options = vec!["Pick project", "Complete", "Skip"] + .iter() + .map(|o| o.to_string()) + .collect::>(); + let selection = input::select( "Enter destination project name or complete:", options, config.mock_select, )?; - match project_name.as_str() { - "complete" => { + match selection.as_str() { + "Complete" => { todoist::complete_item(&config.set_next_id(&item.id))?; Ok(color::green_string("✓")) } - "skip" => Ok(color::green_string("Skipped")), + "Skip" => Ok(color::green_string("Skipped")), _ => { - let project_id = projects::project_id(config, &project_name)?; - let sections = todoist::sections_for_project(config, &project_id)?; + let projects = config.projects.clone().unwrap_or_default(); + let project = input::select("Select project", projects, config.mock_select)?; + + let sections = todoist::sections_for_project(config, &project)?; let section_names: Vec = sections.clone().into_iter().map(|x| x.name).collect(); if section_names.is_empty() { - todoist::move_item_to_project(config, item, &project_name) + todoist::move_item_to_project(config, item, &project) } else { let section_name = input::select("Select section", section_names, config.mock_select)?; @@ -480,39 +469,6 @@ pub fn move_item_to_project(config: &Config, item: Item) -> Result, - due: Option, -) -> Result { - let item = todoist::add_item(config, &content, priority, description, due)?; - - match project { - "inbox" | "i" => Ok(color::green_string("✓")), - project => { - todoist::move_item_to_project(config, item, project)?; - Ok(color::green_string("✓")) - } - } -} - -pub fn project_names(config: &Config) -> Result, String> { - let mut names = config - .projects - .keys() - .map(|k| k.to_owned()) - .collect::>(); - names.sort(); - if names.is_empty() { - Err(NO_PROJECTS_ERR.to_string()) - } else { - Ok(names) - } -} #[cfg(test)] mod tests { use super::*; @@ -527,32 +483,30 @@ mod tests { let config = test::fixtures::config().create().unwrap(); let mut config = config; + let binding = config.projects.clone().unwrap_or_default(); + let project = binding.first().unwrap(); - let result = add(&mut config, "cool_project".to_string(), "1".to_string()); - assert_eq!(result, Ok("✓".to_string())); - - let result = remove(&mut config, "cool_project"); + let result = remove(&mut config, project); + assert_eq!(Ok("✓".to_string()), result); + let result = add(&mut config, project); assert_eq!(Ok("✓".to_string()), result); } #[test] fn test_list() { let mut server = mockito::Server::new(); let mock = server - .mock("POST", "/sync/v9/projects/get_data") + .mock("GET", "/rest/v2/projects") .with_status(200) .with_header("content-type", "application/json") - .with_body(test::responses::items()) + .with_body(test::responses::projects()) .create(); let mut config = test::fixtures::config().mock_url(server.url()); - config.add_project(String::from("first"), 1); - config.add_project(String::from("second"), 2); + let str = "Projects # Tasks\n - Doomsday "; - let str = "Projects # Tasks\n - first 1\n - second 1"; - - assert_eq!(list(&config), Ok(String::from(str))); - mock.expect(2); + assert_eq!(list(&mut config), Ok(String::from(str))); + mock.expect(3); } #[test] @@ -565,9 +519,7 @@ mod tests { .with_body(test::responses::items()) .create(); - let mut config = test::fixtures::config().mock_url(server.url()); - - config.add_project(String::from("good"), 1); + let config = test::fixtures::config().mock_url(server.url()); let config_dir = dirs::config_dir().unwrap().to_str().unwrap().to_owned(); @@ -577,11 +529,13 @@ mod tests { mock_url: Some(server.url()), ..config }; + let binding = config_with_timezone.projects.clone().unwrap_or_default(); + let project = binding.first().unwrap(); config_with_timezone.clone().create().unwrap(); assert_eq!( - next(config_with_timezone, "good"), + next(config_with_timezone, project), Ok(format!( "Put out recycling\nDue: {TIME} ↻\n1 task(s) remaining" )) @@ -598,8 +552,7 @@ mod tests { .with_body(test::responses::items()) .create(); - let mut config = test::fixtures::config().mock_url(server.url()); - config.add_project(String::from("good"), 1); + let config = test::fixtures::config().mock_url(server.url()); let config_with_timezone = Config { timezone: Some(String::from("US/Pacific")), @@ -607,20 +560,15 @@ mod tests { ..config }; - // invalid project - assert_eq!( - scheduled_items(&config_with_timezone, "test"), - Err(String::from( - "Project test not found, please add it to config" - )) - ); + let binding = config_with_timezone.projects.clone().unwrap_or_default(); + let project = binding.first().unwrap(); // valid project - let result = scheduled_items(&config_with_timezone, "good"); + let result = scheduled_items(&config_with_timezone, project); assert_eq!( result, Ok(format!( - "Schedule for good\n- Put out recycling\n Due: {TIME} ↻" + "Schedule for myproject\n- Put out recycling\n Due: {TIME} ↻" )) ); } @@ -635,8 +583,7 @@ mod tests { .with_body(test::responses::items()) .create(); - let mut config = test::fixtures::config().mock_url(server.url()); - config.add_project(String::from("good"), 1); + let config = test::fixtures::config().mock_url(server.url()); let config_with_timezone = Config { timezone: Some(String::from("US/Pacific")), @@ -644,10 +591,13 @@ mod tests { ..config }; + let binding = config_with_timezone.projects.clone().unwrap_or_default(); + let project = binding.first().unwrap(); + assert_eq!( - all_items(&config_with_timezone, "good"), + all_items(&config_with_timezone, project), Ok(format!( - "Tasks for good\n- Put out recycling\n Due: {TIME} ↻" + "Tasks for myproject\n- Put out recycling\n Due: {TIME} ↻" )) ); mock.assert(); @@ -660,7 +610,7 @@ mod tests { .mock("GET", "/rest/v2/projects") .with_status(200) .with_header("content-type", "application/json") - .with_body(test::responses::projects()) + .with_body(test::responses::new_projects()) .create(); let mut config = test::fixtures::config() @@ -673,7 +623,12 @@ mod tests { mock.assert(); let config = config.reload().unwrap(); - let config_keys: Vec = config.projects.keys().map(|k| k.to_string()).collect(); + let config_keys: Vec = config + .projects + .unwrap_or_default() + .iter() + .map(|p| p.name.to_owned()) + .collect(); assert!(config_keys.contains(&"Doomsday".to_string())) } @@ -714,37 +669,24 @@ mod tests { .with_body(test::responses::sync()) .create(); - let mut config = test::fixtures::config() + let config = test::fixtures::config() .mock_url(server.url()) .mock_select(0) .create() .unwrap(); - let project_name = String::from("Project2"); - config.add_project(project_name.clone(), 123); - let result = process_items(config, &project_name); + let binding = config.projects.clone().unwrap_or_default(); + let project = binding.first().unwrap(); + + let result = process_items(config, project); assert_eq!( result, - Ok("There are no more tasks in 'Project2'".to_string()) + Ok("There are no more tasks in 'myproject'".to_string()) ); mock.assert(); mock2.assert(); } - #[test] - fn test_project_names() { - let mut config = test::fixtures::config(); - let result = project_names(&config); - let expected = Err(String::from(NO_PROJECTS_ERR)); - assert_eq!(result, expected); - - config.add_project(String::from("NEWPROJECT"), 123); - - let result = project_names(&config); - let expected: Result, String> = Ok(vec![String::from("NEWPROJECT")]); - assert_eq!(result, expected); - } - #[test] fn test_remove_auto() { let mut server = mockito::Server::new(); @@ -752,7 +694,7 @@ mod tests { .mock("GET", "/rest/v2/projects") .with_status(200) .with_header("content-type", "application/json") - .with_body(test::responses::projects()) + .with_body(test::responses::new_projects()) .create(); let mut config = test::fixtures::config() @@ -760,44 +702,24 @@ mod tests { .create() .unwrap(); - let result = project_names(&config); - let expected = Err(String::from(NO_PROJECTS_ERR)); - assert_eq!(result, expected); - - config.add_project(String::from("NEWPROJECT"), 123); - let result = remove_auto(&mut config); - let expected: Result = Ok(String::from("Auto removed: NEWPROJECT")); + let expected: Result = Ok(String::from("Auto removed: myproject")); assert_eq!(result, expected); mock.assert(); - assert_eq!( - project_names(&config), - Err(String::from( - "No projects in config, please run `tod project import`" - )) - ); + let projects = config.projects.clone().unwrap_or_default(); + assert_eq!(projects.is_empty(), true); } #[test] fn test_remove_all() { let mut config = test::fixtures::config().mock_select(1).create().unwrap(); - let result = project_names(&config); - let expected = Err(String::from(NO_PROJECTS_ERR)); - assert_eq!(result, expected); - - config.add_project(String::from("NEWPROJECT"), 123); - let result = remove_all(&mut config); let expected: Result = Ok(String::from("Removed all projects from config")); assert_eq!(result, expected); - assert_eq!( - project_names(&config), - Err(String::from( - "No projects in config, please run `tod project import`" - )) - ); + let projects = config.projects.clone().unwrap_or_default(); + assert_eq!(projects.is_empty(), true); } #[test] @@ -817,17 +739,33 @@ mod tests { .with_body(test::responses::sync()) .create(); + let mock3 = server + .mock("GET", "/rest/v2/sections?project_id=123") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(test::responses::sections()) + .create(); + + let mock4 = server + .mock("GET", "/rest/v2/projects") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(test::responses::projects()) + .create(); + let mut config = test::fixtures::config() .mock_url(server.url()) .mock_string("newtext") .mock_select(0); - config.add_project(String::from("projectname"), 123); - - let result = empty(&config, "projectname"); - assert_eq!(result, Ok(String::from("Successfully emptied projectname"))); + let binding = config.projects.clone().unwrap_or_default(); + let project = binding.first().unwrap(); + let result = empty(&mut config, project); + assert_eq!(result, Ok(String::from("Successfully emptied myproject"))); mock.expect(2); mock2.assert(); + mock3.assert(); + mock4.assert(); } #[test] @@ -840,23 +778,23 @@ mod tests { .with_body(test::responses::items()) .create(); - let mut config = test::fixtures::config().mock_url(server.url()); + let config = test::fixtures::config().mock_url(server.url()); - config.add_project(String::from("projectname"), 123); + let binding = config.projects.clone().unwrap_or_default(); + let project = binding.first().unwrap(); - let result = prioritize_items(&config, "projectname"); + let result = prioritize_items(&config, project); assert_eq!( result, - Ok(String::from("No tasks to prioritize in projectname")) + Ok(String::from("No tasks to prioritize in myproject")) ); mock.assert(); } #[test] fn test_move_item_to_project() { - let mut config = test::fixtures::config().mock_select(1); + let config = test::fixtures::config().mock_select(2); let item = test::fixtures::item(); - config.add_project("projectname".to_string(), 123); let result = move_item_to_project(&config, item); assert_eq!(result, Ok(String::from("Skipped"))); @@ -872,12 +810,13 @@ mod tests { .with_body(test::responses::items()) .create(); - let mut config = test::fixtures::config() + let config = test::fixtures::config() .mock_url(server.url()) .mock_select(0); - config.add_project("Project".to_string(), 123); + let binding = config.projects.clone().unwrap_or_default(); + let project = binding.first().unwrap(); - let result = rename_items(&config, "123"); + let result = rename_items(&config, project); assert_eq!( result, Ok("The content is the same, no need to change it".to_string()) @@ -901,62 +840,36 @@ mod tests { .with_body(test::responses::item()) .create(); - let mut config = test::fixtures::config() + let config = test::fixtures::config() .mock_url(server.url()) .mock_select(1) .mock_string("tod"); - config.add_project("Project".to_string(), 123); - let result = schedule(&config, "Project", TaskFilter::Unscheduled); + let binding = config.projects.clone().unwrap_or_default(); + let project = binding.first().unwrap(); + let result = schedule(&config, project, TaskFilter::Unscheduled); assert_eq!( result, - Ok("Successfully scheduled tasks in Project".to_string()) + Ok("Successfully scheduled tasks in myproject".to_string()) ); let config = config.mock_select(2); - let result = schedule(&config, "Project", TaskFilter::Overdue); - assert_eq!(result, Ok("No tasks to schedule in Project".to_string())); + let binding = config.projects.clone().unwrap_or_default(); + let project = binding.first().unwrap(); + let result = schedule(&config, project, TaskFilter::Overdue); + assert_eq!(result, Ok("No tasks to schedule in myproject".to_string())); let config = config.mock_select(3); - let result = schedule(&config, "Project", TaskFilter::Unscheduled); + let binding = config.projects.clone().unwrap_or_default(); + let project = binding.first().unwrap(); + let result = schedule(&config, project, TaskFilter::Unscheduled); assert_eq!( result, - Ok("Successfully scheduled tasks in Project".to_string()) + Ok("Successfully scheduled tasks in myproject".to_string()) ); mock.expect(2); mock2.expect(2); } - - #[test] - fn test_add_item_to_project() { - let mut server = mockito::Server::new(); - let mock = server - .mock("POST", "/rest/v2/tasks/") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(test::responses::item()) - .create(); - - let mock2 = server - .mock("POST", "/sync/v9/sync") - .with_status(200) - .with_header("content-type", "application/json") - .with_body(test::responses::sync()) - .create(); - - let mut config = test::fixtures::config() - .mock_url(server.url()) - .mock_select(0); - config.add_project("Project".to_string(), 123); - - let content = String::from("This is content"); - - let result = add_item_to_project(&config, content, "Project", Priority::None, None, None); - assert_eq!(result, Ok("✓".to_string())); - - mock.assert(); - mock2.assert(); - } } diff --git a/src/test.rs b/src/test.rs index e592e248..59bcd5d8 100644 --- a/src/test.rs +++ b/src/test.rs @@ -1,11 +1,9 @@ #[cfg(test)] pub mod fixtures { - use std::collections::HashMap; - use crate::{ - config::{self, Config}, - items::{DateInfo, Item}, - }; + use crate::config::{self, Config}; + use crate::items::{DateInfo, Item}; + use crate::projects::Project; pub fn item() -> Item { Item { @@ -27,7 +25,20 @@ pub mod fixtures { pub fn config() -> Config { Config { token: String::from("alreadycreated"), - projects: HashMap::new(), + projects: Some(vec![Project { + id: "123".to_string(), + name: "myproject".to_string(), + color: "blue".to_string(), + comment_count: 1, + order: 0, + is_shared: false, + is_favorite: false, + is_inbox_project: false, + is_team_inbox: false, + view_style: "List".to_string(), + url: "www.google.com".to_string(), + parent_id: None, + }]), path: config::generate_path().unwrap(), next_id: None, timezone: Some(String::from("US/Pacific")), @@ -38,6 +49,23 @@ pub mod fixtures { spinners: Some(true), } } + + pub fn project() -> Project { + Project { + id: "456".to_string(), + name: "newproject".to_string(), + color: "blue".to_string(), + comment_count: 1, + order: 0, + is_shared: false, + is_favorite: false, + is_inbox_project: false, + is_team_inbox: false, + view_style: "List".to_string(), + url: "www.google.com".to_string(), + parent_id: None, + } + } } #[cfg(test)] pub mod responses { @@ -198,7 +226,30 @@ pub mod responses { String::from( "[ { - \"id\": \"1234\", + \"id\": \"123\", + \"project_id\": \"5678\", + \"order\": 1, + \"comment_count\": 1, + \"is_shared\": false, + \"is_favorite\": false, + \"is_inbox_project\": false, + \"is_team_inbox\": false, + \"color\": \"blue\", + \"view_style\": \"list\", + \"url\": \"http://www.example.com/\", + \"name\": \"Doomsday\" + } + ] + ", + ) + } + + /// Has a new ID + pub fn new_projects() -> String { + String::from( + "[ + { + \"id\": \"890\", \"project_id\": \"5678\", \"order\": 1, \"comment_count\": 1, diff --git a/src/todoist.rs b/src/todoist.rs index 31c15724..5e293527 100644 --- a/src/todoist.rs +++ b/src/todoist.rs @@ -32,6 +32,7 @@ pub fn quick_add_item(config: &Config, content: &str) -> Result { pub fn add_item( config: &Config, content: &str, + project: &Project, priority: Priority, description: Option, due: Option, @@ -41,6 +42,8 @@ pub fn add_item( let mut body: HashMap = HashMap::new(); body.insert("content".to_owned(), Value::String(content.to_owned())); body.insert("description".to_owned(), Value::String(description)); + body.insert("project_id".to_owned(), Value::String(project.id.clone())); + body.insert("auto_reminder".to_owned(), Value::Bool(true)); body.insert( "priority".to_owned(), @@ -61,14 +64,15 @@ pub fn add_item( } /// Get a vector of all items for a project -pub fn items_for_project(config: &Config, project_id: &str) -> Result, String> { +pub fn items_for_project(config: &Config, project: &Project) -> Result, String> { let url = String::from(PROJECT_DATA_URL); - let body = json!({ "project_id": project_id }); + let body = json!({ "project_id": project.id }); let json = request::post_todoist_sync(config, url, body)?; items::json_to_items(json) } -pub fn sections_for_project(config: &Config, project_id: &str) -> Result, String> { +pub fn sections_for_project(config: &Config, project: &Project) -> Result, String> { + let project_id = &project.id; let url = format!("{SECTIONS_URL}?project_id={project_id}"); let json = request::get_todoist_rest(config, url)?; sections::json_to_sections(json) @@ -83,10 +87,9 @@ pub fn projects(config: &Config) -> Result, String> { pub fn move_item_to_project( config: &Config, item: Item, - project_name: &str, + project: &Project, ) -> Result { - let project_id = projects::project_id(config, project_name)?; - let body = json!({"commands": [{"type": "item_move", "uuid": request::new_uuid(), "args": {"id": item.id, "project_id": project_id}}]}); + let body = json!({"commands": [{"type": "item_move", "uuid": request::new_uuid(), "args": {"id": item.id, "project_id": project.id}}]}); let url = String::from(SYNC_URL); request::post_todoist_sync(config, url, body)?; @@ -206,9 +209,11 @@ mod tests { timezone: Some(String::from("US/Pacific")), ..config }; + let binding = config_with_timezone.projects.clone().unwrap_or_default(); + let project = binding.first().unwrap(); assert_eq!( - items_for_project(&config_with_timezone, "123123"), + items_for_project(&config_with_timezone, &project), Ok(vec![Item { id: String::from("999999"), content: String::from("Put out recycling"), @@ -259,15 +264,16 @@ mod tests { .create(); let item = test::fixtures::item(); - let project_name = "testy"; - let mut config = test::fixtures::config().mock_url(server.url()); - config.add_project(String::from(project_name), 1); + let config = test::fixtures::config().mock_url(server.url()); let config = Config { mock_url: Some(server.url()), ..config }; - let response = move_item_to_project(&config, item, project_name); + + let binding = config.projects.clone().unwrap_or_default(); + let project = binding.first().unwrap(); + let response = move_item_to_project(&config, item, project); mock.assert(); assert_eq!(response, Ok(String::from("✓")));