dependency tracking; code cleanup; removed option to discard; move to

trash rather than delete
This commit is contained in:
aaron-jack-manning 2022-08-25 16:40:56 +10:00
parent bc602c4d34
commit 170dbdcc2c
12 changed files with 287 additions and 110 deletions

1
.gitignore vendored
View File

@ -1,2 +1 @@
/target
/old

View File

@ -85,12 +85,6 @@ Then you can run `toru new` to create your first task.
## Roadmap
- Dependency tracker
- Store dependencies in a file and correctly update them upon creation and removal of notes
- Error if any circular dependencies are introduced
- Make sure dependencies written to file are only those that could be successfully created
- List dependencies as a tree on note view below info
- Disallow marking a task as complete if it has incomplete dependencies
- Options to configure and customise output of `list`
- Simple query language to select:
- which columns to include
@ -100,4 +94,3 @@ Then you can run `toru new` to create your first task.
- only include tasks with incomplete dependencies, and similarly only tasks which are not dependents
- due date, completion date, etc greater than or less than specific value
- If no values given, use a default query stored in `state.toml`
- Automatically added recurring notes system

View File

@ -1,3 +1,5 @@
use crate::tasks::Id;
use colored::Colorize;
// Yellow
@ -26,8 +28,8 @@ pub fn file(text : &str) -> colored::ColoredString {
}
// Blue
pub fn id(text : &str) -> colored::ColoredString {
text.truecolor(52, 152, 219)
pub fn id(id : Id) -> colored::ColoredString {
id.to_string().truecolor(52, 152, 219)
}
// Grey

View File

@ -60,7 +60,7 @@ impl Config {
Ok(())
},
None => {
Err(error::Error::Generic(format!("No vault named {} exists", colour::vault(&old_name))))
Err(error::Error::Generic(format!("No vault named {} exists", colour::vault(old_name))))
}
}

View File

@ -5,7 +5,9 @@ use std::process;
use crate::tasks;
use crate::error;
use crate::graph;
use crate::state;
use crate::colour;
use crate::tasks::Id;
pub fn open_editor(path : &path::Path, editor : &str) -> Result<process::ExitStatus, error::Error> {
@ -76,8 +78,24 @@ pub fn edit_raw(id : Id, vault_folder : path::PathBuf, editor : &str, state : &m
Err(error::Error::Generic(String::from("Name must not be purely numeric")))
}
else {
// Dependencies were edited so the graph needs to be updated.
if edited_task.data.dependencies != task.data.dependencies {
// This is where the other dependencies graph needs to be updated.
for dependency in &task.data.dependencies {
state.data.deps.remove_edge(id, *dependency);
}
for dependency in &edited_task.data.dependencies {
if state.data.deps.contains_node(*dependency) {
state.data.deps.insert_edge(id, *dependency)?;
}
else {
return Err(error::Error::Generic(format!("No task with an ID of {} exists", colour::id(*dependency))));
}
}
if let Some(cycle) = state.data.deps.find_cycle() {
return Err(error::Error::Generic(format!("Note edit aborted due to circular dependency: {}", graph::format_cycle(&cycle))));
}
}
// Name change means index needs to be updated.
if edited_task.data.name != task.data.name {
@ -90,7 +108,7 @@ pub fn edit_raw(id : Id, vault_folder : path::PathBuf, editor : &str, state : &m
task.save()?;
fs::remove_file(&temp_path)?;
trash::delete(&temp_path)?;
Ok(())
}

View File

@ -14,6 +14,7 @@ pub enum Error {
Utf8(str::Utf8Error),
Fmt(fmt::Error),
Generic(String),
Internal(String),
}
impl fmt::Display for Error {
@ -26,7 +27,8 @@ impl fmt::Display for Error {
Error::TomlSer(err) => write!(f, "{} {}", colour::error("Internal Error:"), err),
Error::Utf8(err) => write!(f, "{} {}", colour::error("Internal Error:"), err),
Error::Fmt(err) => write!(f, "{} {}", colour::error("Internal Error:"), err),
Error::Generic(message) => write!(f, "{}", message),
Error::Generic(message) => write!(f, "{} {}", colour::error("Error:"), message),
Error::Internal(message) => write!(f, "{} {}", colour::error("Internal Error:"), message),
}
}
}

134
src/graph.rs Normal file
View File

@ -0,0 +1,134 @@
use crate::error;
use crate::tasks;
use crate::colour;
use crate::tasks::Id;
use std::fmt::Write;
use std::collections::{HashSet, HashMap, BTreeSet};
use serde_with::{serde_as, DisplayFromStr};
#[serde_as]
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct Graph {
#[serde_as(as = "HashMap<DisplayFromStr, _>")]
pub edges : HashMap<Id, HashSet<Id>>,
}
impl Graph {
pub fn create(tasks : Vec<tasks::Task>) -> Self {
let mut edges = HashMap::with_capacity(tasks.len());
for task in tasks {
edges.insert(task.data.id, task.data.dependencies);
}
Self {
edges
}
}
pub fn contains_node(&self, node : Id) -> bool {
self.edges.contains_key(&node)
}
pub fn insert_node(&mut self, node : Id) -> bool {
self.edges.insert(node, HashSet::new()).is_none()
}
pub fn insert_edge(&mut self, first : Id, second : Id) -> Result<bool, error::Error> {
if !self.contains_node(first) || !self.contains_node(second) {
Err(error::Error::Internal(String::from("Attempt to insert an edge in the dependency graph with a node which wasn't present")))
}
else if first == second {
Err(error::Error::Generic(format!("Note with ID {} cannot depend on itself", colour::id(first))))
}
else {
let outgoing = self.edges.get_mut(&first).unwrap();
Ok(outgoing.insert(second))
}
}
pub fn remove_node(&mut self, node : Id) -> bool {
if self.edges.remove(&node).is_some() {
for outgoing in self.edges.values_mut() {
outgoing.remove(&node);
}
true
}
else {
false
}
}
pub fn remove_edge(&mut self, first : Id, second : Id) -> bool {
match self.edges.get_mut(&first) {
Some(outgoing) => {
outgoing.remove(&second)
},
None => {
false
}
}
}
pub fn find_cycle(&self) -> Option<Vec<Id>> {
// All unvisited nodes, populated with all nodes at the start, to not miss disconnected
// components.
let mut unvisited = BTreeSet::<Id>::new();
for node in self.edges.keys() {
unvisited.insert(*node);
}
while !unvisited.is_empty() {
let start = unvisited.iter().next().unwrap();
let result = self.find_cycle_local(*start, &mut unvisited, &mut HashSet::new());
if result.is_some() {
return result;
}
}
None
}
fn find_cycle_local(&self, start : Id, unvisited : &mut BTreeSet<Id>, current_path_visited : &mut HashSet<Id>) -> Option<Vec<Id>> {
// If already visited in the current path, then there is a cycle
if current_path_visited.contains(&start) {
Some(vec![start])
}
else {
unvisited.remove(&start);
current_path_visited.insert(start);
// Iterate over the outgoing edges
for node in self.edges.get(&start).unwrap() {
let result = self.find_cycle_local(*node, unvisited, current_path_visited);
if let Some(mut path) = result {
path.push(start);
return Some(path);
}
// Remove the searched node from the current_path_visited set because already
// reached full search depth on it.
current_path_visited.remove(node);
}
None
}
}
}
pub fn format_cycle(cycle : &Vec<Id>) -> String {
let mut formatted = String::new();
for (index, node) in cycle.iter().enumerate() {
write!(&mut formatted, "{}", colour::id(*node)).unwrap();
if index != cycle.len() - 1 {
formatted.push_str(" -> ");
}
}
formatted
}

View File

@ -3,6 +3,7 @@ use crate::error;
use crate::colour;
use crate::tasks::Id;
use std::fmt::Write;
use std::collections::HashMap;
use serde_with::{serde_as, DisplayFromStr};
@ -67,14 +68,14 @@ impl Index {
}
else {
let coloured_ids : Vec<_> =
ids.into_iter()
.map(|i| colour::id(&i.to_string()))
ids.iter()
.map(|i| colour::id(*i))
.collect();
let mut display_ids = String::new();
for id in coloured_ids {
display_ids.push_str(&format!("{}, ", id));
write!(&mut display_ids, "{}, ", id).unwrap();
}
if !display_ids.is_empty() {
@ -85,10 +86,9 @@ impl Index {
Err(error::Error::Generic(format!("Multiple notes (Ids: [{}]) by that name exist", display_ids)))
}
},
None => Err(error::Error::Generic(format!("A note by the name {} does not exist", colour::task_name(&name)))),
None => Err(error::Error::Generic(format!("A note by the name {} does not exist", colour::task_name(name)))),
}
}
}
}
}

View File

@ -5,6 +5,7 @@ mod index;
mod error;
mod tasks;
mod state;
mod graph;
mod stats;
mod config;
mod colour;
@ -49,16 +50,10 @@ enum Command {
#[clap(short, long)]
info : bool,
},
/// Delete a task completely.
/// Delete a task (move file to trash).
Delete {
id_or_name : String,
},
/// Deletes all discarded tasks.
Clean,
/// Discard a task without deleting the underlying file.
Discard {
id_or_name : String,
},
/// Mark a task as complete.
Complete {
id_or_name : String,
@ -165,10 +160,9 @@ fn main() {
match result {
Ok(()) => (),
Err(error::Error::Generic(message)) => {
println!("{} {}", colour::error("Error:"), message);
Err(err) => {
println!("{}", err);
}
result => println!("{:?}", result),
}
}
@ -250,21 +244,22 @@ fn program() -> Result<(), error::Error> {
match command {
New { name, info, tags, dependencies, priority, due } => {
let task = tasks::Task::new(name, info, tags, dependencies, priority, due, vault_folder, &mut state)?;
println!("Created task {} (ID: {})", colour::task_name(&task.data.name), colour::id(&task.data.id.to_string()));
println!("Created task {} (ID: {})", colour::task_name(&task.data.name), colour::id(task.data.id));
},
Delete { id_or_name } => {
let id = state.data.index.lookup(&id_or_name)?;
let task = tasks::Task::load(id, vault_folder, false)?;
let name = task.data.name.clone();
state.data.index.remove(task.data.name.clone(), task.data.id);
state.data.deps.remove_node(task.data.id);
task.delete()?;
println!("Deleted task {} (ID: {})", colour::task_name(&name), colour::id(&id.to_string()));
println!("Deleted task {} (ID: {})", colour::task_name(&name), colour::id(id));
},
View { id_or_name } => {
let id = state.data.index.lookup(&id_or_name)?;
let task = tasks::Task::load(id, vault_folder, true)?;
task.display()?;
task.display(vault_folder, &state)?;
},
Edit { id_or_name, info } => {
let id = state.data.index.lookup(&id_or_name)?;
@ -274,7 +269,7 @@ fn program() -> Result<(), error::Error> {
else {
edit::edit_raw(id, vault_folder.clone(), &config.editor, &mut state)?;
}
println!("Updated task {}", colour::id(&id.to_string()));
println!("Updated task {}", colour::id(id));
},
Track { id_or_name, hours, minutes } => {
let id = state.data.index.lookup(&id_or_name)?;
@ -294,27 +289,16 @@ fn program() -> Result<(), error::Error> {
}
}
},
Discard { id_or_name } => {
let id = state.data.index.lookup(&id_or_name)?;
let mut task = tasks::Task::load(id, vault_folder, false)?;
task.data.discarded = true;
task.save()?;
println!("Discarded task {}", colour::id(&id.to_string()));
},
Complete { id_or_name } => {
let id = state.data.index.lookup(&id_or_name)?;
let mut task = tasks::Task::load(id, vault_folder, false)?;
task.data.completed = Some(chrono::Local::now().naive_local());
task.save()?;
println!("Marked task {} as complete", colour::id(&id.to_string()));
println!("Marked task {} as complete", colour::id(id));
},
List {} => {
tasks::list(vault_folder)?;
},
Clean => {
tasks::clean(vault_folder)?;
println!("Deleted all discarded tasks");
}
// All commands which are dealt with in if let chain at start.
Vault(_) | Config(_) | Git { args : _ } | Svn { args : _ } | Switch { name : _ } | GitIgnore => unreachable!(),
}

View File

@ -1,6 +1,7 @@
use crate::error;
use crate::tasks;
use crate::index;
use crate::graph;
use crate::tasks::Id;
use std::fs;
@ -18,6 +19,7 @@ pub struct State {
pub struct InternalState {
pub next_id : Id,
pub index : index::Index,
pub deps : graph::Graph,
}
impl State {
@ -52,14 +54,15 @@ impl State {
}
}
// Calculating out the index.
// Calculating out the index and graph.
let tasks = tasks::Task::load_all(vault_location, true)?;
let index = index::Index::create(&tasks);
let deps = graph::Graph::create(tasks);
let data = InternalState {
next_id : u64::try_from(max_id + 1).unwrap(),
index,
deps,
};
let mut file = fs::File::options()

View File

@ -39,26 +39,24 @@ pub fn time_per_tag(days : u16, vault_folder : &path::Path) -> Result<(), error:
let mut times = BTreeMap::<String, tasks::Duration>::new();
for task in &tasks {
if !task.data.discarded {
let mut time = tasks::Duration::zero();
let mut time = tasks::Duration::zero();
for entry in &task.data.time_entries {
if chrono::Utc::now().naive_local().date() - entry.logged_date < chrono::Duration::days(i64::from(days)) {
time = time + entry.duration;
}
for entry in &task.data.time_entries {
if chrono::Utc::now().naive_local().date() - entry.logged_date < chrono::Duration::days(i64::from(days)) {
time = time + entry.duration;
}
}
let tag_count = task.data.tags.len();
let time_per_tag = time / tag_count;
let tag_count = task.data.tags.len();
let time_per_tag = time / tag_count;
for tag in &task.data.tags {
match times.get_mut(tag) {
Some(time) => {
*time = *time + time_per_tag;
},
None => {
times.insert(tag.clone(), time_per_tag);
}
for tag in &task.data.tags {
match times.get_mut(tag) {
Some(time) => {
*time = *time + time_per_tag;
},
None => {
times.insert(tag.clone(), time_per_tag);
}
}
}

View File

@ -1,5 +1,6 @@
use crate::error;
use crate::state;
use crate::graph;
use crate::colour;
use std::io;
@ -9,7 +10,7 @@ use std::ops;
use std::mem;
use std::path;
use std::io::{Write, Seek};
use std::collections::HashSet;
use std::collections::{HashSet, HashMap};
use colored::Colorize;
use chrono::SubsecRound;
@ -77,7 +78,6 @@ pub struct InternalTask {
pub due : Option<chrono::NaiveDateTime>,
pub created : chrono::NaiveDateTime,
pub completed : Option<chrono::NaiveDateTime>,
pub discarded : bool,
pub info : Option<String>,
pub time_entries : Vec<TimeEntry>,
}
@ -99,6 +99,19 @@ impl Task {
.create(true)
.open(&path)?;
// Adding to dependency graph appropriately.
state.data.deps.insert_node(id);
if !dependencies.is_empty() {
for dependency in &dependencies {
if state.data.deps.contains_node(*dependency) {
state.data.deps.insert_edge(id, *dependency)?;
}
else {
return Err(error::Error::Generic(format!("No task with an ID of {} exists", colour::id(*dependency))));
}
}
}
let data = InternalTask {
id,
name,
@ -110,7 +123,6 @@ impl Task {
time_entries : Vec::new(),
created : chrono::Local::now().naive_local(),
completed : None,
discarded : false,
};
let file_contents = toml::to_string(&data)?;
@ -160,17 +172,19 @@ impl Task {
Task::load_direct(path, read_only)
}
pub fn load_all(vault_folder : &path::Path, read_only : bool) -> Result<Vec<Self>, error::Error> {
let ids : Vec<Id> =
fs::read_dir(vault_folder.join("notes"))
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|p| p.is_file())
.map(|p| p.file_stem().unwrap().to_str().unwrap().to_string())
.filter_map(|n| n.parse::<Id>().ok())
.collect();
fn id_iter(vault_folder : &path::Path) -> impl Iterator<Item = u64> {
fs::read_dir(vault_folder.join("notes"))
.unwrap()
.map(|entry| entry.unwrap().path())
.filter(|p| p.is_file())
.map(|p| p.file_stem().unwrap().to_str().unwrap().to_string())
.filter_map(|n| n.parse::<Id>().ok())
}
let mut tasks = Vec::with_capacity(ids.len());
pub fn load_all(vault_folder : &path::Path, read_only : bool) -> Result<Vec<Self>, error::Error> {
let ids = Task::id_iter(vault_folder);
let mut tasks = Vec::new();
for id in ids {
tasks.push(Task::load(id, vault_folder, read_only)?);
}
@ -178,6 +192,17 @@ impl Task {
Ok(tasks)
}
pub fn load_all_as_map(vault_folder : &path::Path, read_only : bool) -> Result<HashMap<Id, Self>, error::Error> {
let ids = Task::id_iter(vault_folder);
let mut tasks = HashMap::new();
for id in ids {
tasks.insert(id, Task::load(id, vault_folder, read_only)?);
}
Ok(tasks)
}
pub fn path(&self) -> &path::Path {
&self.path
}
@ -188,7 +213,7 @@ impl Task {
Ok(path)
}
else {
Err(error::Error::Generic(format!("No task with the ID {} exists", colour::id(&id.to_string()))))
Err(error::Error::Generic(format!("No task with the ID {} exists", colour::id(id))))
}
}
@ -216,12 +241,12 @@ impl Task {
} = self;
mem::drop(file);
fs::remove_file(&path)?;
trash::delete(&path)?;
Ok(())
}
pub fn display(&self) -> Result<(), error::Error> {
pub fn display(&self, vault_folder : &path::Path, state : &state::State) -> Result<(), error::Error> {
fn line(len : usize) {
for _ in 0..len {
@ -231,12 +256,10 @@ impl Task {
}
let (heading, heading_length) = {
let id = &self.data.id.to_string();
let discarded = if self.data.discarded { String::from(" (discarded)") } else { String::new() };
(
format!("[{}] {} {}{}", if self.data.completed.is_some() {"X"} else {" "}, colour::id(id), colour::task_name(&self.data.name), colour::greyed_out(&discarded)),
5 + self.data.name.chars().count() + id.chars().count() + discarded.chars().count()
format!("[{}] {} {}", if self.data.completed.is_some() {"X"} else {" "}, colour::id(self.data.id), colour::task_name(&self.data.name)),
5 + self.data.name.chars().count() + self.data.id.to_string().chars().count()
)
};
@ -286,7 +309,12 @@ impl Task {
}
}
// dependencies as a tree
if !self.data.dependencies.is_empty() {
let tasks = Task::load_all_as_map(vault_folder, true)?;
println!("Dependencies:");
dependency_tree(self.data.id, &String::new(), true, &state.data.deps, &tasks);
}
Ok(())
}
@ -308,6 +336,43 @@ fn format_hash_set<T : fmt::Display>(set : &HashSet<T>) -> Result<String, error:
Ok(output)
}
fn dependency_tree(start : Id, prefix : &String, is_last_item : bool, graph : &graph::Graph, tasks : &HashMap<Id, Task>) {
let next = graph.edges.get(&start).unwrap();
{
let task = tasks.get(&start).unwrap();
let name = if task.data.completed.is_some() {
colour::greyed_out(&task.data.name)
}
else {
colour::task_name(&task.data.name)
};
if is_last_item {
println!("{}└──{} (ID: {})", prefix, name, colour::id(start))
}
else {
println!("{}├──{} (ID: {})", prefix, name, colour::id(start))
}
}
let count = next.len();
for (i, node) in next.iter().enumerate() {
let new_is_last_item = i == count - 1;
let new_prefix = if is_last_item {
format!("{} ", prefix)
}
else {
format!("{}", prefix)
};
dependency_tree(*node, &new_prefix, new_is_last_item, graph, tasks);
}
}
fn format_due_date(due : &chrono::NaiveDateTime, include_fuzzy_period : bool, colour : bool) -> String {
let remaining = *due - chrono::Local::now().naive_local();
@ -347,18 +412,10 @@ fn format_due_date(due : &chrono::NaiveDateTime, include_fuzzy_period : bool, co
}
else {
if remaining < chrono::Duration::zero() {
format!("{} {}", due.round_subsecs(0), format!("({} overdue)", fuzzy_period))
}
else if remaining < chrono::Duration::days(1) {
format!("{} {}", due.round_subsecs(0), format!("({} remaining)", fuzzy_period))
}
else if remaining < chrono::Duration::days(3) {
format!("{} {}", due.round_subsecs(0), format!("({} remaining)", fuzzy_period))
format!("{} ({} overdue)", due.round_subsecs(0), fuzzy_period)
}
else {
format!("{} {}", due.round_subsecs(0), format!("({} remaining)", fuzzy_period))
format!("{} ({} remaining)", due.round_subsecs(0), fuzzy_period)
}
}
}
@ -380,7 +437,7 @@ pub fn list(vault_folder : &path::Path) -> Result<(), error::Error> {
tasks.sort_by(|t1, t2| t2.data.priority.cmp(&t1.data.priority));
for task in tasks {
if !task.data.discarded && task.data.completed.is_none() {
if task.data.completed.is_none() {
let duration = TimeEntry::total(&task.data.time_entries);
@ -402,19 +459,6 @@ pub fn list(vault_folder : &path::Path) -> Result<(), error::Error> {
Ok(())
}
pub fn clean(vault_folder : &path::Path) -> Result<(), error::Error> {
let tasks = Task::load_all(vault_folder, false)?;
for task in tasks {
if task.data.discarded {
task.delete()?;
}
}
Ok(())
}
impl ops::Add for Duration {
type Output = Self;
@ -460,9 +504,9 @@ impl fmt::Display for Duration {
impl TimeEntry {
/// Adds up the times from a collection of time entries.
fn total(entries : &Vec<TimeEntry>) -> Duration {
fn total(entries : &[TimeEntry]) -> Duration {
entries
.into_iter()
.iter()
.map(|e| e.duration)
.fold(Duration::zero(), |a, d| a + d)
}