use std::{env, fs};
use std::path::PathBuf;
use std::str::FromStr;
use chrono::{Days, Duration, Local, NaiveDate, NaiveTime};
use clap::{Parser, Subcommand, ArgAction, builder::ArgPredicate};
use serde::{Deserialize, Serialize};
pub use crate::task_manager::{TaskManager, TaskError, TaskResult};
pub mod task_manager;
#[derive(Debug, Parser)]
pub struct Cli {
#[command(subcommand)]
command: Command,
#[arg(long, value_name = "FILE", short = 'C', global = true)]
config: Option<PathBuf>,
}
#[derive(Debug, Subcommand)]
enum Command {
#[command()]
Start {
#[arg(value_name = "TASK")]
task: Option<String>,
#[arg(short, long, action = ArgAction::SetTrue, requires = "task")]
create: bool,
},
Stop {
#[arg(short, require_equals = true, value_name = "DAYS", requires = "duration")]
n: Option<u16>,
#[arg(short, long, require_equals = true, value_name = "MINUTES")]
duration: Option<u16>
},
Switch {
#[arg(value_name = "TASK")]
task: Option<String>,
#[arg(short, long, action = ArgAction::SetTrue, requires = "task")]
create: bool,
},
Report {
#[arg(short, short_alias = '0', long, action = ArgAction::SetTrue, default_value = "true", default_value_ifs = [
("yesterday", ArgPredicate::IsPresent, Some("false")),
("dates", ArgPredicate::IsPresent, Some("false"))
])]
today: bool,
#[arg(short, short_alias = '1', long, action = ArgAction::SetTrue)]
yesterday: bool,
#[arg(long = "dates", action = ArgAction::Append, value_name = "DATE", num_args = 0..)]
dates: Vec<NaiveDate>
},
Current,
Rename {
#[arg(value_name = "TASK")]
task: String,
#[arg(value_name = "NEW_NAME")]
new_name: String
},
List {
#[arg(short, default_value_t = 0, require_equals = true, value_name = "DAYS")]
n: u16
},
Delete {
#[arg(value_name = "TASK")]
task: String
},
}
#[derive(Debug, Serialize, Deserialize)]
struct Config {
data_dir: String,
day_start: String,
}
impl Default for Config {
fn default() -> Self {
Self {
data_dir: dirs::data_local_dir().expect("data_local_dir should exist")
.join("tasklog").to_str().expect("data_dir should be a valid string")
.to_string(),
day_start: "04:30".to_string(),
}
}
}
impl Config {
fn load(config_file: PathBuf) -> TaskResult<Self> {
Self::create_config_file_if_needed(&config_file)?;
let settings = config::Config::builder().add_source(
config::File::from(config_file)
).add_source(
config::Environment::with_prefix("TASKLOG")
).build()?;
Ok(settings.try_deserialize()?)
}
fn create_config_file_if_needed(config_file: &PathBuf) -> TaskResult<()> {
if !config_file.exists() {
if let Some(parent) = config_file.parent() {
fs::create_dir_all(parent)?;
}
let default_config = Config::default();
fs::write(config_file, toml::to_string(&default_config).expect("config should be serializable"))?;
}
Ok(())
}
}
pub fn handle(cli: Cli) -> TaskResult<()> {
let config = cli.config.unwrap_or_else(||
env::var("TASKLOG_CONFIG").map(PathBuf::from).unwrap_or_else(|_|
dirs::config_local_dir().expect("config_local_dir should exist")
.join("tasklog").join("settings.toml")
));
let config = Config::load(config)?;
fs::create_dir_all(PathBuf::from(&config.data_dir))?;
match cli.command {
Command::Start { task, create } => if create {
start_new(task.expect("task should exist when create flag is set"), &config)
} else {
match task {
Some(task) => resume(task, &config),
None => resume_last(&config),
}
},
Command::Stop { n, duration } => stop(n.unwrap_or(0), duration, &config),
Command::Switch { task, create } => if create {
switch_new(task.expect("task should exist when create flag is set"), &config)
} else {
match task {
Some(task) => switch(task, &config),
None => switch_previous(&config),
}
},
Command::Report { today, yesterday, dates } => report(today, yesterday, dates, &config),
Command::Current => current(&config),
Command::Rename { task, new_name } => rename(task, new_name, &config),
Command::List { n } => list(n, &config),
Command::Delete { task } => delete(task, &config),
}
}
fn process_mutating_action<T>(days_ago: u16, config: &Config, action: impl FnOnce(&mut TaskManager) -> TaskResult<T>) -> TaskResult<T> {
let today = date(days_ago, config)?;
let mut tasks = read_tasks(today, config)?;
let task_name = action(&mut tasks)?;
write_tasks(&tasks, today, config)?;
Ok(task_name)
}
fn resume(task_name: String, config: &Config) -> TaskResult<()> {
let task_name = process_mutating_action(0, config, |task_manager|
task_manager.resume_task(task_name, Local::now()))?;
println!("Resumed task: {task_name}");
Ok(())
}
fn start_new(task_name: String, config: &Config) -> TaskResult<()> {
let task_name = process_mutating_action(0, config, |task_manager|
task_manager.start_new_task(task_name, Local::now()))?;
println!("Started new task: {task_name}");
Ok(())
}
fn stop(days_ago: u16, duration: Option<u16>, config: &Config) -> TaskResult<()> {
let task_name = process_mutating_action(days_ago, config, |task_manager|
match duration {
None => task_manager.stop_current_task_with_time(Local::now()),
Some(minutes) => task_manager.stop_current_task_with_duration(Duration::minutes(minutes as i64), Local::now()),
}
)?;
println!("Stopped task: {task_name}");
Ok(())
}
fn resume_last(config: &Config) -> TaskResult<()> {
let task_name = process_mutating_action(0, config, |task_manager|
task_manager.resume_last_task(Local::now()))?;
println!("Resumed task: {task_name}");
Ok(())
}
fn switch(task_name: String, config: &Config) -> TaskResult<()> {
let task_name = process_mutating_action(0, config, |task_manager| task_manager.switch_task(task_name, Local::now()))?;
println!("Switched to task: {task_name}");
Ok(())
}
fn switch_new(task_name: String, config: &Config) -> TaskResult<()> {
let task_name = process_mutating_action(0, config, |task_manager| task_manager.switch_new_task(task_name, Local::now()))?;
println!("Switched to new task: {task_name}");
Ok(())
}
fn switch_previous(config: &Config) -> TaskResult<()> {
let task_name = process_mutating_action(0, config, |task_manager| task_manager.switch_last_task(Local::now()))?;
println!("Switched to task: {task_name}");
Ok(())
}
fn current(config: &Config) -> TaskResult<()> {
let today = date(0, config)?;
let task_manager = read_tasks(today, config)?;
match task_manager.current_task() {
None => println!("No task currently running"),
Some(task) => println!("Current task: {}", task),
}
Ok(())
}
fn list(days_ago: u16,config: &Config) -> TaskResult<()> {
let today = date(days_ago, config)?;
let task_manager = read_tasks(today, config)?;
let tasks = task_manager.list_tasks();
println!("{}", tasks.join("\n"));
Ok(())
}
fn delete(task_name: String, config: &Config) -> TaskResult<()> {
let task_name = process_mutating_action(0, config, |task_manager| task_manager.delete_task(task_name))?;
println!("Deleted task: {task_name}");
Ok(())
}
fn rename(task_name: String, new_name: String, config: &Config) -> TaskResult<()> {
let (task_name, new_name) = process_mutating_action(0, config, |task_manager| task_manager.rename_task(task_name, new_name))?;
println!("Renamed task: {task_name} to {new_name}");
Ok(())
}
fn report(today: bool, yesterday: bool, mut dates: Vec<NaiveDate>, config: &Config) -> TaskResult<()> {
if yesterday {
dates.push(date(1, config)?);
}
if today {
dates.push(date(0, config)?);
}
dates.sort();
dates.dedup();
for date in dates {
let task_manager = read_tasks(date, config)?;
let report = task_manager.generate_report(date, Local::now());
println!("\n{report}");
}
Ok(())
}
fn read_tasks(today: NaiveDate, config: &Config) -> TaskResult<TaskManager> {
let file = get_file(today, config)?;
let task_manager = match fs::read_to_string(file) {
Ok(data) => serde_json::from_str(&data)?,
Err(_) => TaskManager::default(),
};
Ok(task_manager)
}
fn write_tasks(tasks: &TaskManager, today: NaiveDate, config: &Config) -> TaskResult<()> {
let file = get_file(today, config)?;
let data = serde_json::to_string(&tasks).expect("should be able to serialize tasks");
fs::write(file, data)?;
Ok(())
}
fn get_file(today: NaiveDate, config: &Config) -> TaskResult<PathBuf> {
let today = today.format("%F.json").to_string();
let file = PathBuf::from(&config.data_dir).join(today);
Ok(file)
}
fn date(days_ago: u16, config: &Config) -> TaskResult<NaiveDate> {
let now = Local::now();
let time = now.time();
let day_start = NaiveTime::from_str(&config.day_start)
.map_err(|e| config::ConfigError::Foreign(Box::new(e)))?;
let today = if time >= day_start {
now.date_naive()
} else {
now.date_naive().checked_sub_days(Days::new(1)).expect("should be able to subtract 1 from today")
};
Ok(today.checked_sub_days(Days::new(days_ago as u64)).expect("should be able to subtract any u16 days from today"))
}