use std::path::PathBuf;
use clap::{
ArgAction,
Subcommand
};
use conciliator::{
Claw,
Conciliator,
edit::Edited,
inline,
List,
Paint,
print::Formatter,
Wrap
};
use greg::{
calendar::zone::Shift,
Calendar,
Point,
Scale,
Span,
Utc
};
use rusqlite::{
Connection,
Error as SqlError,
OpenFlags
};
use crate::{
Nunc,
Result
};
use crate::core::{
Log,
Plan,
Tree,
Zone
};
use crate::data::{
activity::ActOrCat,
entry::print_oneline,
SetEntry,
StreakRule
};
use crate::util::format::{
format_by,
LookupFormatter
};
use super::super::arg;
#[derive(Subcommand)]
pub(crate) enum Cmd {
#[clap(subcommand)]
Create(CreateCmd),
#[clap(subcommand)]
Show(ShowCmd),
#[clap(subcommand)]
Edit(EditCmd),
#[clap(subcommand)]
Remove(RemoveCmd),
#[clap(subcommand)]
Db(DbCmd)
}
#[derive(Subcommand)]
pub(crate) enum CreateCmd {
Activity {
path: String,
name: String
},
Category {
path: String,
name: String
},
Streak {
#[clap(short, long)]
activity: Option<arg::Activity>,
#[clap(short, long)]
category: Option<arg::Category>,
#[clap(short, long, value_parser = arg::parse_span)]
interval: Span,
#[clap(short, long, value_parser = arg::parse_span)]
min_duration: Span,
#[clap(short, long, value_parser = arg::parse_span)]
when: Option<arg::TimeArg>
}
}
#[derive(Subcommand)]
pub(crate) enum ShowCmd {
Tree,
Activity,
Category,
Streak
}
#[derive(Subcommand)]
pub(crate) enum EditCmd {
Activity {
activity: arg::Activity
},
Entry {
#[clap(short, long)]
when: Option<arg::TimeArg>,
#[clap(short, long)]
activity: Option<arg::Activity>,
#[clap(short = 'l', long = "last", action = ArgAction::Count)]
offset: u8,
},
}
#[derive(Subcommand)]
pub(crate) enum RemoveCmd {
Activity {
activity: arg::Activity
},
Category {
category: arg::Category
},
Entry {
#[clap(short, long)]
when: Option<arg::TimeArg>,
#[clap(short, long)]
activity: Option<arg::Activity>,
#[clap(short = 'l', long = "last", action = ArgAction::Count)]
offset: u8,
},
Streak {
#[clap(short, long)]
activity: Option<arg::Activity>,
#[clap(short, long)]
category: Option<arg::Category>,
#[clap(short, long, value_parser = arg::parse_span)]
when: Option<arg::TimeArg>
}
}
#[derive(Subcommand)]
pub(crate) enum DbCmd {
Absorb {
#[clap(short, long)]
dry_run: bool,
other_db: PathBuf
},
}
impl Cmd {
pub fn run(self, con: &Claw, nunc: &Nunc) -> Result<()> {
match self {
Self::Create(cmd) => match cmd {
CreateCmd::Activity{path, name} => cmd_create_activity(
con,
nunc,
path,
name
),
CreateCmd::Category{path, name} => cmd_create_category(
con,
nunc,
path,
name
),
CreateCmd::Streak {
activity,
category,
interval,
min_duration,
when
} => cmd_create_streak(
con,
nunc,
activity,
category,
interval,
min_duration,
when
)
},
Self::Show(cmd) => cmd.run(con, nunc),
Self::Edit(cmd) => cmd.run(con, nunc),
Self::Remove(cmd) => cmd.run(con, nunc),
Self::Db(cmd) => match cmd {
DbCmd::Absorb {dry_run, other_db} => cmd_absorb(
con,
nunc,
dry_run,
other_db
),
}
}
}
}
impl ShowCmd {
pub fn run(self, con: &Claw, nunc: &Nunc) -> Result<()> {
match self {
Self::Tree => {
let name = nunc.db.path()
.map(|n| format!("Tree ({n})"))
.unwrap_or_else(|| String::from("Tree (in-memory)"));
con.print(nunc.print_tree(&name)?);
},
Self::Activity => {
let acts = nunc.get_activities()?;
let list = List::new("Activities", acts.iter());
if list.print_and_count(con) == 0 {
con.error("No activities!");
}
},
Self::Category => {
let cats = nunc.get_categories()?;
let list = List::new("Categories", cats.iter());
if list.print_and_count(con) == 0 {
con.error("No categories!");
}
},
Self::Streak => {
use crate::core::Plan;
let streaks = nunc.list_streaks()?;
let count = List::new("Streaks", streaks.iter())
.with_formatter(LookupFormatter::new(nunc)?)
.print_and_count(con);
if count == 0 {
con.error("No streaks!");
}
},
}
Ok(())
}
}
impl EditCmd {
pub fn run(self, con: &Claw, nunc: &Nunc) -> Result<()> {
match self {
Self::Activity {activity} => Self::activity(con, nunc, activity),
Self::Entry {when, activity, offset} => Self::entry(
con,
nunc,
offset,
when,
activity
),
}
}
fn activity(con: &Claw, nunc: &Nunc, act: arg::Activity) -> Result<()> {
let act = act.validate(nunc, con)?;
match con.edit(act.name.as_str()) {
Edited::Ok(new_name) => {
let new_name = new_name.trim().to_string();
let path = nunc.get_path_string(act.category_id)?;
let mut resolved = act.resolve(path);
let mut info = con.info(..);
info.push(&resolved).push(" → ");
resolved.name = new_name;
info.push(&resolved);
drop(info);
if con.confirm(false, "Rename activity?") {
let act = resolved.revert();
match nunc.rename_activity(&act)? {
true => {
con.status("Successfully renamed activity!");
},
false => ret_errmsg!("Failed to rename activity!")
}
}
else {
con.info("Activity not renamed");
}
Ok(())
},
Edited::Cancelled => {
con.warn("Edit aborted!");
Ok(())
},
Edited::Err(e) => Err(e.into())
}
}
fn entry(
con: &Claw,
nunc: &Nunc,
offset: u8,
when: Option<arg::TimeArg>,
act: Option<arg::Activity>)
-> Result<()>
{
let entry = arg::select_entry(con, nunc, offset, when, act)?;
match entry.edit(con, nunc)? {
Some(ent) if nunc.update(&ent)? => {
con.status("Successfully updated entry!");
Ok(())
},
Some(_) => ret_errmsg!("Entry update failed"),
None => Ok(())
}
}
}
impl RemoveCmd {
pub fn run(self, con: &Claw, nunc: &Nunc) -> Result<()> {
match self {
Self::Activity {activity} => Self::activity(con, nunc, activity),
Self::Category {category} => Self::category(con, nunc, category),
Self::Entry {when, activity, offset} => Self::entry(
con,
nunc,
offset,
when,
activity
),
Self::Streak {activity, category, when} => Self::streak(
con,
nunc,
activity,
category,
when
)
}
}
fn activity(con: &Claw, nunc: &Nunc, act: arg::Activity) -> Result<()> {
let act = act.validate(nunc, con)?;
let cat_id = act.category_id;
let resolved = act.resolve(nunc.get_path_string(cat_id)?);
con.info("Activity ").push(&resolved);
match con.confirm(false, "Delete this activity?") {
true => {
let act = resolved.revert();
match nunc.delete_activity(&act) {
Ok(true) => {
con.status("Successfully deleted activity!");
},
Ok(false) => ret_errmsg!("Failed to delete activity!"),
Err(err) => {
if let SqlError::SqliteFailure(_, Some(ref s)) = err
&& s == "FOREIGN KEY constraint failed"
{
con.error(
"Cannot delete an activity if there are \
entries or streaks associated with it!"
);
}
else {
con.error(..).push(err);
}
ret_errmsg!("Failed to delete activity!")
}
}
},
false => {
con.info("Activity not deleted!");
}
};
Ok(())
}
fn category(con: &Claw, nunc: &Nunc, cat: arg::Category) -> Result<()> {
let cat = cat.validate(nunc, con)?;
let path = nunc.resolve_path_string(&cat.path)?;
let resolved = cat.resolve(path);
con.info("Category ").push(&resolved);
match con.confirm(false, "Delete this category?") {
true => {
let cat = resolved.revert();
match nunc.delete_category(&cat) {
Ok(true) => {
con.status("Successfully deleted category!");
},
Ok(false) => ret_errmsg!("Failed to delete category!"),
Err(err) => {
if let SqlError::SqliteFailure(_, Some(ref s)) = err
&& s == "FOREIGN KEY constraint failed"
{
con.error(
"Cannot delete a category if it contains other \
categories or activities, or there are streaks \
associated with it!"
);
}
else {
con.error(..).push(err);
}
ret_errmsg!("Failed to delete category!")
}
}
},
false => {
con.info("Category not deleted!");
}
};
Ok(())
}
fn entry(
con: &Claw,
nunc: &Nunc,
offset: u8,
when: Option<arg::TimeArg>,
act: Option<arg::Activity>)
-> Result<()>
{
let entry = arg::select_entry(con, nunc, offset, when, act)?;
let activity = nunc.get_activity(entry.activity_id)?;
let cal = nunc.load_calendar()?;
let start = cal.lookup(entry.start);
let stop = entry.stop.map(|p| cal.lookup(p));
print_oneline(&mut con.info(..), &activity.name, start, stop);
if con.confirm(false, "Delete this entry?") {
if nunc.delete(&entry)? {
con.status("Successfully deleted the entry!");
}
else {ret_errmsg!("Failed to delete the entry!")}
}
else {
con.status("Entry not deleted!");
}
Ok(())
}
fn streak(
con: &Claw,
nunc: &Nunc,
activity: Option<arg::Activity>,
category: Option<arg::Category>,
when: Option<arg::TimeArg>)
-> Result<()>
{
let cal = nunc.load_calendar()?;
let end = match when {
Some(t) => {
let validated = t.resolve_with(&cal);
con.status("Parsed stop time: ").push(cal.lookup(validated));
validated
},
None => Point::now()
};
let streak = match (activity, category) {
(Some(_), Some(_)) | (None, None) => ret_errmsg!(
"Either an activity or a category must be specified!"
),
(Some(act_arg), None) => {
let activity = act_arg.validate(nunc, con)?;
let Some(streak) = nunc.get_active_activity_streak(
Point::now(),
&activity)?
else {
con.error("No active streak for ").push(&activity);
ret_errmsg!("There's no streak to stop!");
};
streak
},
(None, Some(cat_arg)) => {
let category = cat_arg.validate(nunc, con)?;
let Some(streak) = nunc.get_active_category_streak(
Point::now(),
&category)?
else {
con.error("No active streak for ").push(&category);
ret_errmsg!("There's no streak to stop!");
};
streak
}
};
let subject = streak.rule.get_subject(nunc)?;
LookupFormatter::new(nunc)?.format(&mut con.info(..), &streak);
if streak.end.is_some() {
ret_errmsg!("Streak has already been stopped!");
}
if con.confirm(false, "Stop tracking this streak?") {
match nunc.end(&streak, end) {
Ok(true) => {
con.status("Stopped streak for ").push(&subject);
Ok(())
}
Ok(false) => ret_errmsg!("Failed to stop streak!"),
Err(err) => {
con.error("Failed to stop streak for ").push(&subject);
Err(err.into())
}
}
}
else {
con.info("Streak not stopped!");
Ok(())
}
}
}
pub(crate) fn cmd_absorb(
con: &Claw,
nunc: &Nunc,
dry_run: bool,
other_path: PathBuf)
-> Result<()>
{
let name = match other_path.file_name().map(|n| n.to_string_lossy()) {
Some(n) => Wrap::Beta(n.into_owned()),
None => ret_errmsg!("Invalid db path {other_path:?}!")
};
let other_db = Connection::open_with_flags(
&other_path,
OpenFlags::SQLITE_OPEN_READ_ONLY
)?;
con.status("Opened ").push(&name);
let other = Nunc::from_connection(other_db)?;
let main_tree = nunc.load_into_memory()?;
if main_tree != other.load_into_memory()? {
ret_errmsg!("Trees don't match!")
}
con.info("Loading entries");
let main_set = nunc.load_set()?;
let other_set = other.load_set()?;
let sets = [
(&Wrap::Beta("main".to_owned()), main_set.len()),
(&name, other_set.len())
];
List::new("entry sets loaded", sets.iter())
.with_wrap(|(name, len)| inline!(*name, ": ", *len, " entries"))
.print_to(con);
let calendar = nunc.load_calendar().unwrap();
let entries_to_absorb: Vec<_> = other_set
.difference(&main_set)
.cloned()
.collect();
let none_to_absorb = List::headless(entries_to_absorb.iter())
.with_count(inline!("entries to absorb from ", &name))
.with_formatter(LookupFormatter::new(nunc)?)
.print_and_count(con)
.eq(&0);
if none_to_absorb {
con.status("No entries to absorb from ").push(name);
return Ok(())
}
if !dry_run && con.confirm(false, "Absorb these entries?") {
con.status(format_args!(
"Absorbing {} entries",
entries_to_absorb.len()
));
for entry in entries_to_absorb {
let SetEntry {activity_id, start, stop} = entry;
let activity = nunc.get_activity(activity_id)?;
match stop {
Some(stop) => {
nunc.create(&activity, start, stop)?;
let start_zoned = calendar.lookup(start);
let stop_zoned = calendar.lookup(stop);
let mut line = con.status("Created ");
print_oneline(
&mut line,
&activity.name,
start_zoned,
Some(stop_zoned)
);
line.print();
},
None => {
nunc.start(&activity, start)?;
con.status("Started ")
.push(activity)
.push(" at ")
.push(calendar.lookup(start));
}
}
}
}
Ok(())
}
pub(crate) fn cmd_create_activity(
con: &Claw,
nunc: &Nunc,
path: String,
name: String)
-> Result<()>
{
match nunc.find_category_at(&path)? {
Some(cat) => {
let id = nunc.create_activity(&cat, &name)?;
let act = nunc.get_activity(id)?;
con.status("Created ").push(&act).push_plain("!");
Ok(())
},
None if path == "/" => ret_errmsg!(
"Activities cannot be created in the root!"
),
None => ret_errmsg!("Invalid category path {path:?}!")
}
}
pub(crate) fn cmd_create_category(
con: &Claw,
nunc: &Nunc,
path: String,
name: String)
-> Result<()>
{
match nunc.find_category_at(&path)? {
Some(cat) => {
let id = nunc.create_category(&cat, &name)?;
let cat = nunc.get_category(id)?;
con.status("Created ").push(&cat).push_plain("!");
Ok(())
},
None if path == "/" => {
let id = nunc.create_root_category(&name)?;
let cat = nunc.get_category(id)?;
con.status("Created ").push(&cat).push_plain("!");
Ok(())
},
None => ret_errmsg!("Invalid category path {path:?}!")
}
}
pub(crate) fn cmd_create_streak(
con: &Claw,
nunc: &Nunc,
activity: Option<arg::Activity>,
category: Option<arg::Category>,
interval: Span,
min_duration: Span,
when: Option<arg::TimeArg>)
-> Result<()>
{
let cal = nunc.load_calendar()?;
let begin = match when {
Some(t) => {
let validated = t.resolve_with(&cal);
con.status("Parsed start time: ").push(cal.lookup(validated));
validated
},
None => Point::now()
};
let (rule, subject) = match (activity, category) {
(Some(_), Some(_)) | (None, None) => ret_errmsg!(
"Either an activity or a category must be specified!"
),
(Some(act_arg), None) => {
let activity = act_arg.validate(nunc, con)?;
let rule = StreakRule::activity(&activity, min_duration);
let subject = ActOrCat::Activity(activity);
(rule, subject)
},
(None, Some(cat_arg)) => {
let category = cat_arg.validate(nunc, con)?;
let rule = StreakRule::category(&category, min_duration);
let subject = ActOrCat::Category(category);
(rule, subject)
}
};
let (scale, _count) = interval.scale_div();
if scale < Scale::Days {
ret_errmsg!("Streak intervals shorter than a day are not supported");
}
let begin_utc = cal.0.apply(begin);
let true_begin = Calendar(Utc).date_floor(scale, begin_utc);
let begin_dt = Calendar(Utc).lookup(true_begin);
let begin_str = format_by(begin_dt, scale);
con.info(&subject)
.push(": do ")
.push(rule.required())
.push(" every ")
.push(interval)
.push(", starting ")
.push(begin_str);
if con.confirm(false, "Create this streak definition?") {
match nunc.begin(rule, begin, interval) {
Ok(streak) => {
con.status("Successfully created streak definition for ")
.push(&subject);
assert_eq!(
streak.begin,
true_begin,
"streak beginning in DB does not match previous calculation"
);
Ok(())
}
Err(err) => {
con.error("Failed to create streak definition for ")
.push(&subject);
Err(err.into())
}
}
}
else {
con.info("Streak not created!");
Ok(())
}
}
#[test]
fn create_activity() -> Result<()>{
let tester = crate::cli::Tester::init()?;
tester.run(["create", "category", "/", "root"])?;
tester.run(["create", "activity", "/", "root_act"]).unwrap_err();
tester.run(["create", "activity", "/root", "act"])?;
tester.run(["create", "activity", "/root", "act"]).unwrap_err();
tester.run(["create", "activity", "/fake", "act"]).unwrap_err();
Ok(())
}
#[test]
fn create_category() -> Result<()>{
let tester = crate::cli::Tester::init()?;
tester.run(["create", "category", "/", "root"])?;
tester.run(["create", "category", "/root", "sub"])?;
tester.run(["create", "category", "/root", "sub"]).unwrap_err();
tester.run(["create", "category", "/fake", "sub"]).unwrap_err();
Ok(())
}
#[test]
#[ignore = "interactive"]
fn disambiguate_categories() -> Result<()>{
let tester = crate::cli::Tester::init()?;
tester.run(["create", "category", "/", "root"])?;
tester.run(["create", "category", "/", "root2"])?;
tester.run(["create", "category", "/root", "root2"])?;
tester.run(["create", "streak", "-c", "root2", "-i", "1d", "-m", "10m"])?;
Ok(())
}
#[test]
#[ignore = "needs confirm"]
fn delete_activity() -> Result<()>{
let tester = crate::cli::Tester::init()?;
tester.run(["create", "category", "/", "root"])?;
tester.run(["create", "activity", "/root", "act"])?;
tester.run(["remove", "activity", "act"])?;
tester.run(["start", "act"]).unwrap_err();
tester.run(["create", "activity", "/root", "act"])?;
tester.run(["start", "act"])?;
tester.run(["remove", "activity", "act"]).unwrap_err();
tester.run(["create", "activity", "/root", "act2"])?;
tester.run(["create", "streak", "-a", "act2", "-i", "1d", "-m", "10m"])?;
tester.run(["remove", "activity", "act2"]).unwrap_err();
Ok(())
}
#[test]
#[ignore = "needs confirm"]
fn delete_category() -> Result<()>{
let tester = crate::cli::Tester::init()?;
tester.run(["create", "category", "/", "cat"])?;
tester.run(["create", "activity", "/cat", "act"])?;
tester.run(["remove", "category", "cat"]).unwrap_err();
tester.run(["remove", "activity", "act"])?;
tester.run(["remove", "category", "cat"])?;
tester.run(["create", "category", "/", "cat"])?;
tester.run(["create", "category", "/cat", "sub"])?;
tester.run(["remove", "category", "cat"]).unwrap_err();
tester.run(["remove", "category", "sub"])?;
tester.run(["remove", "category", "cat"])?;
tester.run(["create", "category", "/", "cat"])?;
tester.run(["create", "streak", "-c", "cat", "-i", "1d", "-m", "10m"])?;
tester.run(["remove", "category", "cat"]).unwrap_err();
Ok(())
}
#[test]
#[ignore = "needs confirm"]
fn begin_end() -> Result<()>{
let tester = crate::cli::Tester::init()?;
tester.run(["create", "category", "/", "root"])?;
tester.run(["create", "category", "/root", "cat"])?;
tester.run(["create", "streak", "-c", "cat", "-i", "1d", "-m", "10m"])?;
tester.run(["remove", "category", "cat"]).unwrap_err();
tester.run(["remove", "streak", "-c", "cat"])?;
tester.run(["remove", "streak", "-c", "cat"]).unwrap_err();
tester.run(["remove", "category", "cat"]).unwrap_err();
tester.run(["create", "activity", "/root", "act"])?;
tester.run(["create", "streak", "-a", "act", "-i", "1d", "-m", "10m"])?;
tester.run(["remove", "activity", "act"]).unwrap_err();
tester.run(["remove", "streak", "-a", "act"])?;
tester.run(["remove", "streak", "-a", "act"]).unwrap_err();
tester.run(["remove", "activity", "act"]).unwrap_err();
Ok(())
}