use clap::{arg, CommandFactory, Parser, Subcommand};
use color_eyre::eyre::Result;
use color_eyre::Report;
use figment::providers::{Env, Format, Json};
use figment::Figment;
use leafslug_effects::git;
use leafslug_tsk::effectors;
use leafslug_tsk::tasklist;
use leafslug_tsk::effectors::{mark_task_as, tasks_by_state, todays_task};
use leafslug_tsk::task::{Area, State, StateKind, Task};
use std::env;
use std::path::PathBuf;
use time::format_description::{self, well_known};
use time::OffsetDateTime;
const DEFAULT_CONFIG_PATH: &str = "/home/a/.config/leafslug/tsk.json";
fn main() -> Result<()> {
color_eyre::install()?;
tracing_subscriber::fmt::init();
let args = Args::parse();
let config = Configs::try_from(&args.config.unwrap_or(PathBuf::from(DEFAULT_CONFIG_PATH)))?;
let time_offset = config
.time_offset
.ok_or(Report::msg("time offset for is not set"))
.map(|(h, m, s)| time::UtcOffset::from_hms(h, m, s))??;
let now = OffsetDateTime::now_utc().to_offset(time_offset);
let task_dir = config.task_path()?;
let project = git::repo_root(&env::current_dir()?)
.map(|x| git::repo_directory_name(&x))
.ok()
.and_then(Result::ok);
let Some(command) = args.command else {
return Ok(());
};
router(&command, now, &config, time_offset, project, &task_dir)?;
Ok(())
}
fn router(
command: &Commands,
now: OffsetDateTime,
config: &Configs,
time_offset: time::UtcOffset,
project: Option<String>,
task_dir: &PathBuf,
) -> Result<(), Report> {
match command {
Commands::New {
title,
description,
area,
people,
deadline,
best_starting_time,
projects,
} => {
let end = deadline
.as_ref()
.map(|s| leafslug_time::parse_date(s, time_offset))
.transpose()?;
let start = best_starting_time
.as_ref()
.map(|s| leafslug_time::parse_date(s, time_offset))
.transpose()?;
let projects = {
let mut buf = projects.clone().unwrap_or(vec![]);
if let Some(current_folder_project) = project {
buf.push(current_folder_project);
};
buf
};
let t = Task {
id: now.unix_timestamp(),
time_created: now,
state_log: vec![State::new(now, StateKind::ToDo)],
title: title.clone(),
description: description.clone(),
area: area.clone(),
people: people.clone().unwrap_or(vec![]),
projects,
start,
end,
};
let repo_root = git::repo_root(&config.task_path()?)?
.to_string_lossy()
.into_owned();
effectors::new_task(
&config.task_path()?,
&t,
&repo_root,
&config.task_file_formatting()?,
)?;
Ok::<(), Report>(())
}
Commands::List(task_list_command) => {
let tasklist = tasklist::task_list(task_dir)?;
match task_list_command {
TaskListCommand::Today => Ok(todays_task(
tasklist,
now.date(),
&project,
now,
&well_known::Rfc3339,
)?),
TaskListCommand::Todo => Ok(tasks_by_state(
tasklist,
|x| matches!(x, &StateKind::ToDo),
&project,
now,
&well_known::Rfc3339,
)?),
TaskListCommand::Done => Ok(tasks_by_state(
tasklist,
|x| matches!(x, &StateKind::Done),
&project,
now,
&well_known::Rfc3339,
)?),
TaskListCommand::Abandoned => Ok(tasks_by_state(
tasklist,
|x| matches!(x, StateKind::Abandoned(_)),
&project,
now,
&well_known::Rfc3339,
)?),
TaskListCommand::Backlogged => Ok(tasks_by_state(
tasklist,
|x| matches!(x, StateKind::Backlog),
&project,
now,
&well_known::Rfc3339,
)?),
}
}
Commands::Done { id } => {
let task_list = tasklist::task_list(task_dir)?;
let repo_root = git::repo_root(&config.task_path()?)?
.to_string_lossy()
.into_owned();
mark_task_as(
task_dir,
task_list,
&State::new(now, StateKind::Done),
&repo_root,
&id.iter().copied(),
)?;
Ok(())
}
Commands::Backlog { id } => {
let task_list = tasklist::task_list(task_dir)?;
let repo_root = git::repo_root(&config.task_path()?)?
.to_string_lossy()
.into_owned();
mark_task_as(
task_dir,
task_list,
&State::new(now, StateKind::Backlog),
&repo_root,
&id.iter().copied(),
)?;
Ok(())
}
Commands::Abandon { id, content } => {
let task_list = tasklist::task_list(task_dir)?;
let repo_root = git::repo_root(&config.task_path()?)?
.to_string_lossy()
.into_owned();
mark_task_as(
task_dir,
task_list,
&State::new(now, StateKind::Abandoned(content.to_owned())),
&repo_root,
&id.iter().copied(),
)?;
Ok(())
}
Commands::Todo { id } => {
let task_list = tasklist::task_list(task_dir)?;
let repo_root = git::repo_root(&config.task_path()?)?
.to_string_lossy()
.into_owned();
mark_task_as(
task_dir,
task_list,
&State::new(now, StateKind::ToDo),
&repo_root,
&id.iter().copied(),
)?;
Ok(())
}
Commands::Completions { shell } => {
shell.generate(&mut Args::command(), &mut std::io::stdout());
Ok(())
}
Commands::Bulk => {
let task_list = tasklist::task_list(task_dir)?;
let repo_root = git::repo_root(&config.task_path()?)?
.to_string_lossy()
.into_owned();
effectors::bulk_task_editor(task_list, now, &repo_root, task_dir)?;
Ok(())
}
Commands::Migrate => Ok(()),
}?;
Ok(())
}
#[derive(Clone, Debug, Parser)]
#[command(version,about="The tusky task manager", long_about = None, arg_required_else_help = true)]
pub struct Args {
#[arg(short, long, value_name = "FILE")]
pub config: Option<PathBuf>,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Clone, Debug, Subcommand)]
pub enum Commands {
Completions {
#[arg(value_enum)]
shell: clap_complete_command::Shell,
},
New {
#[arg(short = 't', long)]
title: String,
#[arg(short = 'd', long)]
description: Option<String>,
#[arg(short = 'a', long)]
area: Option<Area>,
#[arg(short = 'P', long)]
people: Option<Vec<String>>,
#[arg(short = 'p', long)]
projects: Option<Vec<String>>,
#[arg(short = 'D', long)]
deadline: Option<String>,
#[arg(short = 'S', long)]
best_starting_time: Option<String>,
},
#[command(subcommand)]
List(TaskListCommand),
Done {
id: Vec<i64>,
},
Backlog {
id: Vec<i64>,
},
Abandon {
id: Vec<i64>,
#[arg(last = true)]
content: Option<String>,
},
Todo {
id: Vec<i64>,
},
Bulk,
Migrate,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Subcommand)]
pub enum TaskListCommand {
Today,
Todo,
Done,
Abandoned,
Backlogged,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Configs {
pub time_offset: Option<(i8, i8, i8)>,
pub path: Option<PathBuf>,
pub file_name_format: Option<String>,
}
impl Configs {
pub fn task_path(&self) -> Result<PathBuf, Error> {
self.clone().path.ok_or(Error::DirDoesNotExist)
}
pub fn task_file_formatting(&self) -> Result<format_description::OwnedFormatItem, Error> {
Ok(format_description::parse_owned::<2>(
&self
.clone()
.file_name_format
.ok_or(Error::UnsetConfiguration(
"task.file_name_format".to_owned(),
))?,
)?)
}
}
impl TryFrom<PathBuf> for Configs {
type Error = Error;
fn try_from(value: PathBuf) -> Result<Self, Self::Error> {
Ok(Figment::new()
.merge(Json::file(value))
.merge(Env::prefixed("TSK_"))
.extract()?)
}
}
impl TryFrom<&PathBuf> for Configs {
type Error = Error;
fn try_from(value: &PathBuf) -> Result<Self, Self::Error> {
Ok(Figment::new()
.merge(Json::file(value))
.merge(Env::prefixed("TSK_"))
.extract()?)
}
}
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
pub enum Error {
#[error("could not extract configuration: {0}")]
ExtractionFailed(#[from] figment::Error),
#[error("The path to the directory is not given.")]
DirDoesNotExist,
#[error("{0} was not set in the the configuration.")]
UnsetConfiguration(String),
#[error("File format descriptor for journal is not valid: {0}.")]
TheFormatIsNotValid(#[from] time::error::InvalidFormatDescription),
}