#![doc = include_str!("../README.md")]
use std::collections::{HashMap, VecDeque};
use std::fmt::{self, Display, Formatter};
use std::fs::{self, File};
use std::io::Write;
use std::ops::Add;
use std::path::PathBuf;
use chrono::{prelude::*, Duration};
use thiserror::Error;
mod parse;
#[derive(Debug, Clone)]
struct Config {
activities_path: PathBuf, activities_path_backup: PathBuf,
archived_tags_path: PathBuf,
}
impl Config {
#[cfg(not(any(test, feature = "test-utils")))]
fn set_dir() -> PathBuf {
dirs::data_dir().expect("Unable to find user's data directory.")
}
#[cfg(any(test, feature = "test-utils"))]
fn set_dir() -> PathBuf {
std::env::temp_dir()
}
fn setup() -> Config {
let mut data_dir = Config::set_dir();
data_dir.push("timestudy");
if !data_dir.exists() {
fs::create_dir(&data_dir)
.expect("Unable to create directory for timestudy in user's data directory.");
}
let mut activities_path = data_dir.clone();
activities_path.push("activities");
if !activities_path.exists() {
File::create(&activities_path).expect("Unable to create file for activities.");
}
let mut activities_path_backup = data_dir.clone();
activities_path_backup.push("activities.bk");
if !activities_path_backup.exists() {
File::create(&activities_path_backup)
.expect("Unable to create backup file for activities.");
}
let mut archived_tags_path = data_dir;
archived_tags_path.push("archived_tags");
if !archived_tags_path.exists() {
File::create(&archived_tags_path).expect("Unable to create file for archiving tags.");
}
Config {
activities_path,
activities_path_backup,
archived_tags_path,
}
}
}
#[derive(Error, Debug)]
pub enum TsError {
#[error("current activity already exists")]
AlreadyExistingCurrentActivity,
#[error("no existing current activity")]
NoExistingCurrentActivity,
#[error("time cannot be in future")]
TimeCannotBeInFuture,
#[error("stop time cannot precede start time")]
StopTimeCannotPrecedeStartTime,
#[error("activity does not exist")]
ActivityDoesNotExist,
#[error("activities cannot overlap")]
ActivitiesCannotOverlap,
#[error("disallowed character used in tag")]
DisallowedTagCharacter,
#[error("disallowed character used in description")]
DisallowedDescCharacter,
#[error("activity already contains that tag")]
AlreadyTagged,
#[error("activity does not contain that tag")]
NoMatchingTag,
#[error("tag already archived")]
TagAlreadyArchived,
#[error("tag not archived")]
TagNotArchived,
#[error("no activities")]
NoActivities,
#[error(transparent)]
ParseError {
#[from]
source: parse::ParseError,
},
}
#[derive(Debug, Clone)]
pub struct Activity {
index: Option<usize>, pub start: DateTime<Utc>,
pub end: Option<DateTime<Utc>>,
pub tags: Vec<String>,
pub description: Option<String>,
}
impl Display for Activity {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
let mut line = format!("{:#?}", self.start);
if let Some(v) = self.end {
line = format!("{line} - {:#?}", v);
}
line = format!(
"{line} # {:#}",
&self
.tags
.iter()
.map(|t| format!(" {t}"))
.collect::<String>()
);
match &self.description {
Some(v) => line = format!("{line} # {v}"),
None => line = format!("{line} #"),
}
write!(f, "{:#}", line)
}
}
impl Activity {
fn new(
start: DateTime<Utc>,
end: Option<DateTime<Utc>>,
tags: Vec<String>,
description: Option<String>,
) -> Self {
Self {
index: None,
start,
end,
tags,
description,
}
}
fn save(mut self, mut activities: VecDeque<Activity>) -> Result<(), TsError> {
if self.start > Utc::now() {
return Err(TsError::TimeCannotBeInFuture);
}
if let Some(v) = self.end {
if v < self.start {
return Err(TsError::StopTimeCannotPrecedeStartTime);
}
if v > Utc::now() {
return Err(TsError::TimeCannotBeInFuture);
}
}
tag_chars_ok(&self.tags.iter().map(String::as_str).collect::<Vec<&str>>())?;
if let Some(ref mut v) = self.description {
desc_chars_ok(v)?;
*v = v.lines().collect();
}
self.check_overlap(activities.clone())?;
match self.index {
None => activities.push_back(self),
Some(v) => {
if activities.get(v).is_none() {
return Err(TsError::ActivityDoesNotExist);
}
activities[v] = self
}
}
Activity::write_all_to_file(Config::setup(), activities)
}
fn write_all_to_file(config: Config, activities: VecDeque<Activity>) -> Result<(), TsError> {
fs::copy(&config.activities_path, config.activities_path_backup).unwrap();
File::options()
.write(true)
.truncate(true)
.open(&config.activities_path)
.expect("Unable to open/truncate activities file.");
fs::write(
config.activities_path,
activities
.iter()
.map(|x| x.to_string() + "\n")
.collect::<String>(),
)
.expect("Unable to write to file. Compare with backup to ensure no data was lost.");
Ok(())
}
pub fn current() -> Result<Option<Activity>, TsError> {
let mut activities = activities()?;
match activities.pop_front() {
None => Ok(None),
Some(v) => match v.end {
None => Ok(Some(v)),
Some(_) => Ok(None),
},
}
}
pub fn most_recent_past() -> Result<Option<Activity>, TsError> {
Ok(past_activities()?.pop_front())
}
pub fn get_index(&self) -> Option<usize> {
self.index
}
pub fn get(index: usize) -> Result<Activity, TsError> {
match activities()?.get(index) {
Some(v) => Ok(v.clone()),
None => Err(TsError::ActivityDoesNotExist),
}
}
pub fn start(
dt: Option<DateTime<Utc>>,
tags: Vec<String>,
description: Option<String>,
) -> Result<(), TsError> {
let activities = activities()?;
let current = match activities.clone().pop_front() {
None => None,
Some(v) => match v.end {
None => Some(v),
Some(_) => None,
},
};
if current.is_some() {
return Err(TsError::AlreadyExistingCurrentActivity);
}
let dt = match dt {
Some(v) => v,
None => Utc::now(),
};
Activity::new(dt, None, tags, description).save(activities)?;
Ok(())
}
pub fn stop(dt: Option<DateTime<Utc>>) -> Result<(), TsError> {
let activities = activities()?;
match activities.clone().pop_front() {
None => Err(TsError::NoExistingCurrentActivity),
Some(mut current_activity) => match current_activity.end {
None => {
match dt {
Some(end) => current_activity.end = Some(end),
None => current_activity.end = Some(Utc::now()),
}
Ok(current_activity.save(activities)?)
}
Some(_) => Err(TsError::NoExistingCurrentActivity),
},
}
}
pub fn track(
start: DateTime<Utc>,
end: DateTime<Utc>,
tags: Vec<String>,
description: Option<String>,
) -> Result<(), TsError> {
Activity::new(start, Some(end), tags, description).save(activities()?)?;
Ok(())
}
pub fn delete(index: usize) -> Result<(), TsError> {
let mut activities = activities()?;
match activities.remove(index) {
None => return Err(TsError::ActivityDoesNotExist),
Some(_) => {
Activity::write_all_to_file(Config::setup(), activities)?;
}
}
let all_tags = tags()?;
let mut archived_tags = archived_tags();
for (i, archived_tag) in archived_tags.iter_mut().enumerate() {
if !all_tags.contains(archived_tag) {
archived_tags.remove(i);
break;
}
}
let mut f = File::create(Config::setup().archived_tags_path)
.expect("Cannot open archived tags file.");
for tag in archived_tags {
writeln!(f, "{tag}").expect("Cannot write to archived tags file.");
}
Ok(())
}
pub fn edit(
mut self,
start: DateTime<Utc>,
end: Option<DateTime<Utc>>,
tags: Vec<String>,
description: Option<String>,
) -> Result<(), TsError> {
self.start = start;
self.end = end;
self.tags = tags;
self.description = description;
self.save(activities()?)?;
Ok(())
}
pub fn add_tag(mut self, tag_to_add: String) -> Result<(), TsError> {
tag_chars_ok(&vec![&tag_to_add])?;
if !self.tags.contains(&tag_to_add) {
self.tags.push(tag_to_add);
} else {
return Err(TsError::AlreadyTagged);
}
self.save(activities()?)?;
Ok(())
}
pub fn remove_tag(mut self, tag_to_remove: String) -> Result<(), TsError> {
if self.tags.is_empty() {
return Err(TsError::NoMatchingTag);
}
tag_chars_ok(&vec![&tag_to_remove])?;
let start_len = self.tags.len();
self.tags.retain(|x| *x != tag_to_remove);
if start_len == self.tags.len() {
return Err(TsError::NoMatchingTag);
}
self.save(activities()?)?;
Ok(())
}
fn check_overlap(&self, mut activities: VecDeque<Activity>) -> Result<(), TsError> {
if activities.is_empty() {
return Ok(());
}
if let Some(v) = self.index {
activities.remove(v);
if activities.is_empty() {
return Ok(());
}
}
for other_activity in activities {
match other_activity.end {
None => {
if self.start > other_activity.start {
return Err(TsError::ActivitiesCannotOverlap);
}
if let Some(v) = self.end {
if v > other_activity.start {
return Err(TsError::ActivitiesCannotOverlap);
}
}
}
Some(other_end) => {
match self.end {
None => {
if self.start < other_end {
return Err(TsError::ActivitiesCannotOverlap);
}
}
Some(new_end) => {
if (self.start > other_activity.start) && (self.start < other_end) {
return Err(TsError::ActivitiesCannotOverlap);
}
if (self.start < other_activity.start)
&& (new_end > other_activity.start)
{
return Err(TsError::ActivitiesCannotOverlap);
}
if (self.start > other_activity.start) && (new_end < other_end) {
return Err(TsError::ActivitiesCannotOverlap);
}
if (self.start < other_activity.start) && (new_end > other_end) {
return Err(TsError::ActivitiesCannotOverlap);
}
if (self.start == other_activity.start) || (new_end == other_end) {
return Err(TsError::ActivitiesCannotOverlap);
}
}
}
}
}
}
Ok(())
}
}
pub fn activities() -> Result<VecDeque<Activity>, TsError> {
let mut activities = parse::parse_file(&Config::setup().activities_path)?;
activities.sort_unstable_by_key(|a| a.start);
activities.reverse();
for (i, activity) in activities.iter_mut().enumerate() {
activity.index = Some(i)
}
Ok(VecDeque::from(activities))
}
pub fn past_activities() -> Result<VecDeque<Activity>, TsError> {
let mut activities = activities()?;
if let Some(v) = activities.front() {
if v.end.is_none() {
activities.pop_front();
}
}
Ok(activities)
}
pub fn tag_stats(activities: Vec<Activity>) -> HashMap<String, (i32, Duration)> {
let mut tag_statistics: HashMap<String, (i32, Duration)> = HashMap::new();
let now = Utc::now();
for activity in activities {
let duration = if let Some(v) = activity.end {
v - activity.start
} else {
now - activity.start
};
for tag in activity.tags {
tag_statistics
.entry(tag)
.and_modify(|(count, sum)| {
*count += 1;
*sum = sum.add(duration);
})
.or_insert((1, duration));
}
}
tag_statistics
}
pub fn delete_tag(tag: String) -> Result<(), TsError> {
let mut tag_removed = false;
tag_chars_ok(&vec![&tag])?;
let mut activities = activities()?;
if activities.is_empty() {
return Err(TsError::NoActivities);
} else {
for activity in activities.iter_mut() {
if !activity.tags.is_empty() {
if let Some(u) = activity.tags.iter().position(|x| x == &tag) {
activity.tags.remove(u);
tag_removed = true;
}
}
}
if tag_removed {
Activity::write_all_to_file(Config::setup(), activities)?
} else {
return Err(TsError::NoMatchingTag);
}
}
let mut archived_tags = archived_tags();
if let Some(v) = archived_tags.iter().position(|x| x == &tag) {
archived_tags.remove(v);
let mut f = File::create(Config::setup().archived_tags_path)
.expect("Cannot open archived tags file.");
for tag in archived_tags {
writeln!(f, "{tag}").expect("Cannot write to archived tags file.");
}
}
Ok(())
}
pub fn rename_tag(old_name: String, new_name: String) -> Result<(), TsError> {
let mut tag_renamed = false;
tag_chars_ok(&vec![&new_name])?;
let mut activities = activities()?;
if activities.is_empty() {
return Err(TsError::NoActivities);
} else {
for activity in activities.iter_mut() {
if !activity.tags.is_empty() {
if let Some(u) = activity.tags.iter().position(|x| x == &old_name) {
activity.tags.remove(u);
if !activity.tags.contains(&new_name) {
activity.tags.push(new_name.clone());
}
tag_renamed = true;
}
}
}
if tag_renamed {
Activity::write_all_to_file(Config::setup(), activities)?;
} else {
return Err(TsError::NoMatchingTag);
}
}
let mut archived_tags = archived_tags();
if let Some(v) = archived_tags.iter().position(|x| x == &old_name) {
archived_tags.remove(v);
archived_tags.push(new_name);
let mut f = File::create(Config::setup().archived_tags_path)
.expect("Cannot open archived tags file.");
for tag in archived_tags {
writeln!(f, "{tag}").expect("Cannot write to archived tags file.");
}
}
Ok(())
}
pub fn tags() -> Result<Vec<String>, TsError> {
let mut tags: Vec<String> = activities()?
.iter()
.filter(|x| !x.tags.is_empty())
.flat_map(|x| x.tags.clone())
.collect();
tags.sort();
tags.dedup();
Ok(tags)
}
pub fn archived_tags() -> Vec<String> {
std::fs::read_to_string(Config::setup().archived_tags_path)
.expect("Cannot open archived tags file.")
.lines()
.map(|x| x.to_string())
.collect()
}
pub fn archive_tag(tag: String) -> Result<(), TsError> {
let archived_tags = archived_tags();
let all_tags = tags()?;
if !all_tags.contains(&tag) {
Err(TsError::NoMatchingTag)
} else if archived_tags.contains(&tag) {
Err(TsError::TagAlreadyArchived)
} else {
let mut f = File::options()
.append(true)
.open(Config::setup().archived_tags_path)
.expect("Cannot open archived tags file.");
writeln!(f, "{tag}").expect("Cannot write to archived tags file.");
Ok(())
}
}
pub fn unarchive_tag(tag: String) -> Result<(), TsError> {
let mut archived_tags = archived_tags();
let all_tags = tags()?;
if !all_tags.contains(&tag) {
Err(TsError::NoMatchingTag)
} else if !archived_tags.contains(&tag) {
Err(TsError::TagNotArchived)
} else {
archived_tags.retain(|x| *x != tag);
let config = Config::setup();
File::options()
.write(true)
.truncate(true)
.open(&config.archived_tags_path)
.expect("Cannot open archived tags file.");
fs::write(
config.archived_tags_path,
archived_tags
.iter()
.map(|x| x.to_string() + "\n")
.collect::<String>(),
)
.expect("Cannot write to archived tags file.");
Ok(())
}
}
fn tag_chars_ok(tags: &Vec<&str>) -> Result<(), TsError> {
for tag in tags {
if tag.contains('#') || tag.contains(' ') || tag.contains(':') {
return Err(TsError::DisallowedTagCharacter);
}
}
Ok(())
}
fn desc_chars_ok(description: &str) -> Result<(), TsError> {
if description.contains('#') {
return Err(TsError::DisallowedDescCharacter);
}
Ok(())
}
pub mod test_utils {
use super::*;
pub fn clean_up() {
let config = Config::setup();
if config.activities_path.exists() {
fs::remove_file(config.activities_path).expect("Unable to delete temp data file.");
}
if config.archived_tags_path.exists() {
fs::remove_file(config.archived_tags_path).expect("Unable to delete temp data file.");
}
}
pub fn create_activities(num: usize) {
test_utils::clean_up();
for _ in 0..num {
Activity::start(None, vec![], None).unwrap();
Activity::stop(None).unwrap();
}
}
pub fn create_activity_with_tag(tag: String) {
test_utils::clean_up();
Activity::start(None, vec![tag], None).unwrap();
}
pub fn timed_activities() {
test_utils::clean_up();
Activity::track(
Utc.with_ymd_and_hms(2022, 7, 19, 1, 30, 0).unwrap(),
Utc.with_ymd_and_hms(2022, 7, 19, 2, 30, 0).unwrap(),
vec![],
None,
)
.unwrap();
Activity::track(
Utc.with_ymd_and_hms(2022, 7, 19, 3, 00, 0).unwrap(),
Utc.with_ymd_and_hms(2022, 7, 19, 4, 00, 0).unwrap(),
vec!["rust".to_string(), "work".to_string()],
None,
)
.unwrap();
Activity::track(
Utc.with_ymd_and_hms(2022, 7, 19, 4, 30, 0).unwrap(),
Utc.with_ymd_and_hms(2022, 7, 19, 5, 30, 0).unwrap(),
vec!["rust".to_string()],
None,
)
.unwrap();
Activity::start(
Some(Utc.with_ymd_and_hms(2022, 7, 19, 6, 00, 0).unwrap()),
vec![],
None,
)
.unwrap();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn setup_creates_file() {
let config = Config::setup();
assert!(config.activities_path.is_file());
}
#[test]
fn overlap_start() {
test_utils::timed_activities();
let new = Activity::new(
Utc.with_ymd_and_hms(2022, 7, 19, 2, 45, 0).unwrap(),
Some(Utc.with_ymd_and_hms(2022, 7, 19, 3, 15, 0).unwrap()),
vec![],
None,
);
assert!(matches!(
new.check_overlap(activities().unwrap()),
Err(TsError::ActivitiesCannotOverlap)
));
}
#[test]
fn bad_index_errs() {
test_utils::timed_activities();
let mut activities = activities().unwrap();
let mut a = activities.pop_front().unwrap();
a.index = Some(10);
assert!(matches!(
a.save(activities),
Err(TsError::ActivityDoesNotExist)
));
}
#[test]
fn overlap_end() {
test_utils::timed_activities();
let new = Activity::new(
Utc.with_ymd_and_hms(2022, 7, 19, 3, 45, 0).unwrap(),
Some(Utc.with_ymd_and_hms(2022, 7, 19, 4, 15, 0).unwrap()),
vec![],
None,
);
assert!(matches!(
new.check_overlap(activities().unwrap()),
Err(TsError::ActivitiesCannotOverlap)
));
}
#[test]
fn overlap_within_existing_activity() {
test_utils::timed_activities();
let new = Activity::new(
Utc.with_ymd_and_hms(2022, 7, 19, 3, 15, 0).unwrap(),
Some(Utc.with_ymd_and_hms(2022, 7, 19, 3, 30, 0).unwrap()),
vec![],
None,
);
assert!(matches!(
new.check_overlap(activities().unwrap()),
Err(TsError::ActivitiesCannotOverlap)
));
}
#[test]
fn overlap_around_existing_activity() {
test_utils::timed_activities();
let new = Activity::new(
Utc.with_ymd_and_hms(2022, 7, 19, 2, 45, 0).unwrap(),
Some(Utc.with_ymd_and_hms(2022, 7, 19, 4, 15, 0).unwrap()),
vec![],
None,
);
assert!(matches!(
new.check_overlap(activities().unwrap()),
Err(TsError::ActivitiesCannotOverlap)
));
}
#[test]
fn no_overlap() {
test_utils::timed_activities();
let new = Activity::new(
Utc.with_ymd_and_hms(2022, 7, 19, 4, 10, 0).unwrap(),
Some(Utc.with_ymd_and_hms(2022, 7, 19, 4, 20, 0).unwrap()),
vec![],
None,
);
assert!(matches!(new.check_overlap(activities().unwrap()), Ok(())));
}
#[test]
fn disallowed_chars_in_tags_cause_err() {
assert!(matches!(
tag_chars_ok(&vec!["test#1"]),
Err(TsError::DisallowedTagCharacter)
));
assert!(matches!(
tag_chars_ok(&vec!["#test1"]),
Err(TsError::DisallowedTagCharacter)
));
assert!(matches!(
tag_chars_ok(&vec!["test1#"]),
Err(TsError::DisallowedTagCharacter)
));
assert!(matches!(
tag_chars_ok(&vec!["test 1"]),
Err(TsError::DisallowedTagCharacter)
));
assert!(matches!(
tag_chars_ok(&vec!["test:1"]),
Err(TsError::DisallowedTagCharacter)
));
}
#[test]
fn disallowed_chars_in_description_cause_err() {
assert!(matches!(
desc_chars_ok("description #1"),
Err(TsError::DisallowedDescCharacter)
));
}
}