1156 lines
37 KiB
Rust
Executable File
1156 lines
37 KiB
Rust
Executable File
use clap::Parser;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_yaml;
|
|
use std::io::{BufRead, Read, Write};
|
|
|
|
fn main() {
|
|
let flags = Flags::new().expect("failed to flags");
|
|
let files = flags.files().expect("failed to files");
|
|
|
|
if !flags.dry_run {
|
|
for file in files.files.iter() {
|
|
file.persist_stage()
|
|
.expect("failed to persist staged changes to log file");
|
|
file.stage_persisted().expect("failed to stage log files");
|
|
}
|
|
|
|
if let Some(add) = flags.add {
|
|
let task = match flags.add_schedule {
|
|
None => Task(serde_yaml::Value::String(add)),
|
|
Some(add_schedule) => {
|
|
let mut m = serde_yaml::Mapping::new();
|
|
m.insert("schedule".into(), add_schedule.into());
|
|
m.insert("todo".into(), add.into());
|
|
Task(serde_yaml::Value::Mapping(m))
|
|
}
|
|
};
|
|
files.files[0]
|
|
.append(Delta::add(task))
|
|
.expect("failed to add");
|
|
files.files[0]
|
|
.stage_persisted()
|
|
.expect("failed to stage added");
|
|
}
|
|
}
|
|
|
|
for file in files.files.iter() {
|
|
println!(
|
|
"{} => {}",
|
|
file.file,
|
|
serde_yaml::to_string(&file.events().unwrap().snapshot().unwrap()).unwrap(),
|
|
);
|
|
}
|
|
|
|
if flags.edit {
|
|
edit::files(&files);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Parser)]
|
|
struct Flags {
|
|
#[arg(short = 'f', long = "path", default_value = "$PTTODO_FILE")]
|
|
path: String,
|
|
|
|
#[arg(short = 'a', long = "add")]
|
|
add: Option<String>,
|
|
|
|
#[arg(short = 'e', long = "edit", default_value = "false")]
|
|
edit: bool,
|
|
|
|
#[arg(short = 'd', long = "dry-run", default_value = "false")]
|
|
dry_run: bool,
|
|
|
|
#[arg(short = 's', long = "add-schedule")]
|
|
add_schedule: Option<String>,
|
|
}
|
|
|
|
impl Flags {
|
|
pub fn new() -> Result<Flags, String> {
|
|
let mut result = Flags::parse();
|
|
|
|
if result.path.get(..1) == Some("$") {
|
|
result.path = match std::env::var(result.path.get(1..).unwrap()) {
|
|
Ok(v) => Ok(v),
|
|
Err(msg) => Err(format!("'{}' unset: {}", result.path, msg)),
|
|
}?;
|
|
}
|
|
|
|
let _ = result.files()?;
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
pub fn files(&self) -> Result<Files, String> {
|
|
Self::files_with(&self.path)
|
|
}
|
|
|
|
pub fn files_with(p: &String) -> Result<Files, String> {
|
|
let metadata = match std::fs::metadata(p.clone()) {
|
|
Ok(v) => Ok(v),
|
|
Err(msg) => Err(format!("failed to load {}: {}", p.clone(), msg)),
|
|
}?;
|
|
let files = match metadata.is_dir() {
|
|
false => Ok(vec![p.clone()]),
|
|
true => match std::fs::read_dir(p.clone()) {
|
|
Ok(paths) => Ok(paths
|
|
.filter(|x| x.is_ok())
|
|
.map(|x| x.unwrap())
|
|
.filter(|x| x.metadata().unwrap().is_file())
|
|
.map(|x| x.path().display().to_string())
|
|
.filter(|x| !x.contains("/."))
|
|
.collect()),
|
|
Err(msg) => Err(format!("failed to read {}: {}", p.clone(), msg)),
|
|
},
|
|
}?;
|
|
assert!(files.len() > 0, "no files");
|
|
Ok(Files::new(&files))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test_flags {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_flags_files_unhidden_only() {
|
|
tests::with_dir(|d| {
|
|
std::fs::File::create(d.path().join("plain")).unwrap();
|
|
std::fs::File::create(d.path().join(".hidden")).unwrap();
|
|
|
|
let flags = Flags {
|
|
path: d.path().to_str().unwrap().to_string(),
|
|
add: None,
|
|
edit: false,
|
|
dry_run: true,
|
|
add_schedule: None,
|
|
};
|
|
let files = flags.files().expect("failed to files from dir");
|
|
assert_eq!(1, files.files.len());
|
|
});
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct Files {
|
|
files: Vec<File>,
|
|
}
|
|
|
|
impl Files {
|
|
pub fn new(files: &Vec<String>) -> Files {
|
|
let mut files = files.clone();
|
|
files.sort();
|
|
Files {
|
|
files: files.into_iter().map(|x| File::new(&x)).collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct File {
|
|
file: String,
|
|
}
|
|
|
|
impl File {
|
|
pub fn new(file: &String) -> File {
|
|
File { file: file.clone() }
|
|
}
|
|
|
|
pub fn events(&self) -> Result<Events, String> {
|
|
Events::new(&self.file)
|
|
}
|
|
|
|
pub fn persist_stage(&self) -> Result<(), String> {
|
|
let old_snapshot = self.events()?.last_snapshot();
|
|
let stage_mod_time = std::fs::metadata(&self.file)
|
|
.unwrap()
|
|
.modified()
|
|
.unwrap()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs();
|
|
self.persist_delta_at(old_snapshot, self.stage()?, stage_mod_time)
|
|
}
|
|
|
|
pub fn stage_persisted(&self) -> Result<(), String> {
|
|
let persisted_as_snapshot = self.events()?.snapshot()?;
|
|
if persisted_as_snapshot != self.events()?.last_snapshot() {
|
|
self.append(Delta::snapshot(persisted_as_snapshot.clone()))?;
|
|
}
|
|
let plaintext = serde_yaml::to_string(&persisted_as_snapshot).unwrap();
|
|
let mut f = std::fs::File::create(&self.file).expect("failed to open file for writing");
|
|
writeln!(f, "{}", plaintext).expect("failed to write");
|
|
Ok(())
|
|
}
|
|
|
|
pub fn persist_delta(&self, before: Vec<Task>, after: Vec<Task>) -> Result<(), String> {
|
|
self.persist_delta_at(before, after, Delta::now_time())
|
|
}
|
|
|
|
fn persist_delta_at(
|
|
&self,
|
|
before: Vec<Task>,
|
|
after: Vec<Task>,
|
|
now: u64,
|
|
) -> Result<(), String> {
|
|
for before in before.iter() {
|
|
if !after.contains(before) {
|
|
self.append(Delta::remove_at(before.clone(), now))?;
|
|
let now = Delta::now_time();
|
|
if let Some(due) = before.next_due(now.clone()) {
|
|
if due >= now {
|
|
self.append(Delta::add_at(before.clone(), now))?;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for after in after.iter() {
|
|
if !before.contains(after) {
|
|
self.append(Delta::add_at(after.clone(), now))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn stage(&self) -> Result<Vec<Task>, String> {
|
|
let mut r = match std::fs::File::open(self.file.clone()) {
|
|
Ok(f) => Ok(f),
|
|
Err(msg) => Err(format!("could not open {}: {}", &self.file, msg)),
|
|
}?;
|
|
|
|
let mut buff = String::new();
|
|
match r.read_to_string(&mut buff) {
|
|
Err(msg) => Err(format!("failed reading {}: {}", &self.file, msg)),
|
|
_ => Ok({}),
|
|
}?;
|
|
|
|
let mut result = vec![];
|
|
match serde_yaml::from_str::<Vec<serde_yaml::Value>>(&buff) {
|
|
Ok(v) => {
|
|
result.extend(v.iter().map(|x| Task(x.clone())));
|
|
Ok({})
|
|
}
|
|
Err(msg) => Err(format!("failed parsing {}: {}", &self.file, msg)),
|
|
}?;
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
fn append(&self, delta: Delta) -> Result<(), String> {
|
|
use std::fs::OpenOptions;
|
|
let hostname = gethostname::gethostname();
|
|
assert!(hostname.len() > 0, "empty hostname");
|
|
let log = format!(
|
|
"{}{}",
|
|
Events::log_prefix(&self.file),
|
|
hostname.into_string().unwrap()
|
|
);
|
|
let mut file = match OpenOptions::new()
|
|
.write(true)
|
|
.append(true)
|
|
.create(true)
|
|
.open(&log)
|
|
{
|
|
Ok(f) => Ok(f),
|
|
Err(msg) => Err(format!("failed to open {} for appending: {}", &log, msg)),
|
|
}?;
|
|
let line = serde_json::to_string(&delta).unwrap();
|
|
match writeln!(file, "{}", line) {
|
|
Ok(_) => Ok(()),
|
|
Err(msg) => Err(format!("failed to append: {}", msg)),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test_file {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_file_empty_empty() {
|
|
tests::with_dir(|d| {
|
|
tests::write_file(&d, "plain", "[]");
|
|
|
|
let f = File::new(&d.path().join("plain").to_str().unwrap().to_string());
|
|
|
|
assert_eq!(0, f.events().unwrap().0.len());
|
|
assert_eq!(0, f.stage().unwrap().len());
|
|
tests::file_contains(&d, "plain", "[]");
|
|
|
|
f.persist_stage().unwrap();
|
|
assert_eq!(0, f.events().unwrap().0.len());
|
|
assert_eq!(0, f.stage().unwrap().len());
|
|
tests::file_contains(&d, "plain", "[]");
|
|
|
|
f.stage_persisted().unwrap();
|
|
assert_eq!(0, f.events().unwrap().0.len());
|
|
assert_eq!(0, f.stage().unwrap().len());
|
|
tests::file_contains(&d, "plain", "[]");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_file_empty_stage_fills_events() {
|
|
tests::with_dir(|d| {
|
|
tests::write_file(&d, "plain", "[hello, world]");
|
|
|
|
let f = File::new(&d.path().join("plain").to_str().unwrap().to_string());
|
|
|
|
assert_eq!(0, f.events().unwrap().0.len());
|
|
assert_eq!(2, f.stage().unwrap().len());
|
|
tests::file_contains(&d, "plain", "[hello, world]");
|
|
|
|
f.persist_stage().unwrap();
|
|
assert_eq!(2, f.events().unwrap().0.len());
|
|
assert_eq!(2, f.stage().unwrap().len());
|
|
tests::file_contains(&d, "plain", "hello");
|
|
tests::file_contains(&d, "plain", "world");
|
|
|
|
f.stage_persisted().unwrap();
|
|
assert_eq!(3, f.events().unwrap().0.len());
|
|
assert_eq!(2, f.stage().unwrap().len());
|
|
tests::file_contains(&d, "plain", "- hello\n- world");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_file_persist_empty_drains_events() {
|
|
tests::with_dir(|d| {
|
|
tests::write_file(&d, "plain", "[]");
|
|
tests::write_file(
|
|
&d,
|
|
".plain.host_a",
|
|
r#"
|
|
{"ts":1, "op":"Add", "task": "initial"}
|
|
{"ts":3, "op":"Add", "task": {"k":"v"}}
|
|
{"ts":3, "op":"Snapshot", "task": null, "tasks": ["initial", 1, {"k":"v"}]}
|
|
"#,
|
|
);
|
|
tests::write_file(
|
|
&d,
|
|
".plain.host_b",
|
|
r#"
|
|
{"ts":2, "op":"Add", "task": 1}
|
|
"#,
|
|
);
|
|
|
|
let f = File::new(&d.path().join("plain").to_str().unwrap().to_string());
|
|
|
|
assert_eq!(4, f.events().unwrap().0.len());
|
|
assert_eq!(0, f.stage().unwrap().len());
|
|
tests::file_contains(&d, "plain", "[]");
|
|
|
|
f.persist_stage().unwrap();
|
|
assert_eq!(7, f.events().unwrap().0.len());
|
|
assert_eq!(0, f.stage().unwrap().len());
|
|
tests::file_contains(&d, "plain", "[]");
|
|
|
|
f.stage_persisted().unwrap();
|
|
assert_eq!(
|
|
0,
|
|
f.events().unwrap().snapshot().unwrap().len(),
|
|
"{:?}",
|
|
f.events().unwrap().snapshot().unwrap(),
|
|
);
|
|
assert_eq!(
|
|
8,
|
|
f.events().unwrap().0.len(),
|
|
"{:?}",
|
|
f.events().unwrap().0
|
|
);
|
|
assert_eq!(0, f.stage().unwrap().len(), "{:?}", f.stage().unwrap());
|
|
tests::file_contains(&d, "plain", "[]");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_file_deletion_to_persist() {
|
|
tests::with_dir(|d| {
|
|
tests::write_file(&d, "plain", "- initial\n- 1");
|
|
tests::write_file(
|
|
&d,
|
|
".plain.host_a",
|
|
r#"
|
|
{"ts":1, "op":"Add", "task": "initial"}
|
|
{"ts":3, "op":"Add", "task": {"k":"v"}}
|
|
"#,
|
|
);
|
|
tests::write_file(
|
|
&d,
|
|
".plain.host_b",
|
|
r#"
|
|
{"ts":2, "op":"Add", "task": 1}
|
|
{"ts":2, "op":"Snapshot", "task": null, "tasks": ["initial", 1]}
|
|
"#,
|
|
);
|
|
|
|
let f = File::new(&d.path().join("plain").to_str().unwrap().to_string());
|
|
|
|
assert_eq!(4, f.events().unwrap().0.len());
|
|
assert_eq!(2, f.stage().unwrap().len());
|
|
tests::file_contains(&d, "plain", "- initial\n- 1");
|
|
|
|
f.persist_stage().unwrap();
|
|
assert_eq!(4, f.events().unwrap().0.len());
|
|
assert_eq!(2, f.stage().unwrap().len());
|
|
tests::file_contains(&d, "plain", "- initial\n- 1");
|
|
|
|
f.stage_persisted().unwrap();
|
|
assert_eq!(5, f.events().unwrap().0.len());
|
|
assert_eq!(3, f.stage().unwrap().len());
|
|
tests::file_contains(&d, "plain", "- initial\n- 1\n- k: v");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_persist_stage() {
|
|
tests::with_dir(|d| {
|
|
tests::write_file(&d, "plain", "- old\n- new");
|
|
tests::write_file(
|
|
&d,
|
|
".plain.host",
|
|
format!(
|
|
r#"
|
|
{{"ts":1, "op":"Add", "task": "removed"}}
|
|
{{"ts":2, "op":"Add", "task": "old"}}
|
|
{{"ts":2, "op":"Snapshot", "task": null, "tasks": ["removed", "old"]}}
|
|
{{"ts":{}, "op":"Add", "task": "persisted but not snapshotted"}}
|
|
"#,
|
|
Delta::now_time() + 50,
|
|
)
|
|
.as_str(),
|
|
);
|
|
|
|
let f = File::new(&d.path().join("plain").to_str().unwrap().to_string());
|
|
|
|
assert_eq!(4, f.events().unwrap().0.len());
|
|
assert_eq!(2, f.stage().unwrap().len());
|
|
tests::file_contains(&d, "plain", "old");
|
|
tests::file_contains(&d, "plain", "new");
|
|
|
|
f.persist_stage().unwrap();
|
|
assert_eq!(
|
|
6,
|
|
f.events().unwrap().0.len(),
|
|
"events: {:?}",
|
|
f.events().unwrap()
|
|
);
|
|
assert_eq!(2, f.stage().unwrap().len());
|
|
tests::file_contains(&d, "plain", "new");
|
|
|
|
f.stage_persisted().unwrap();
|
|
assert_eq!(
|
|
7,
|
|
f.events().unwrap().0.len(),
|
|
"{:?}",
|
|
f.events().unwrap().0
|
|
);
|
|
assert_eq!(3, f.stage().unwrap().len(), "{:?}", f.stage().unwrap());
|
|
tests::file_contains(&d, "plain", "new");
|
|
tests::file_contains(&d, "plain", "old");
|
|
tests::file_contains(&d, "plain", "persisted but not snapshotted");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_schedule_date_future() {
|
|
tests::with_dir(|d| {
|
|
tests::write_file(&d, "plain", "[]");
|
|
let f = File::new(&d.path().join("plain").to_str().unwrap().to_string());
|
|
|
|
let mut m = serde_yaml::Mapping::new();
|
|
m.insert("schedule".into(), "2036-01-02".into());
|
|
let task = Task(serde_yaml::Value::Mapping(m));
|
|
|
|
f.append(Delta::add(task)).unwrap();
|
|
assert_eq!(
|
|
1,
|
|
f.events().unwrap().0.len(),
|
|
"{:?}",
|
|
f.events().unwrap().0
|
|
);
|
|
assert_eq!(0, f.stage().unwrap().len(), "{:?}", f.stage());
|
|
|
|
f.persist_stage().unwrap();
|
|
assert_eq!(
|
|
1,
|
|
f.events().unwrap().0.len(),
|
|
"{:?}",
|
|
f.events().unwrap().0
|
|
);
|
|
assert_eq!(0, f.stage().unwrap().len(), "{:?}", f.stage());
|
|
|
|
f.stage_persisted().unwrap();
|
|
assert_eq!(
|
|
1,
|
|
f.events().unwrap().0.len(),
|
|
"{:?}",
|
|
f.events().unwrap().0
|
|
);
|
|
assert_eq!(0, f.stage().unwrap().len(), "{:?}", f.stage());
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_schedule_date_future_with_snapshot_between_scheduled_and_fired() {
|
|
tests::with_dir(|d| {
|
|
tests::write_file(&d, "plain", "- stage");
|
|
tests::write_file(
|
|
&d,
|
|
".plain.host",
|
|
format!(
|
|
r#"
|
|
{{"ts":1, "op":"Add", "task": "stage"}}
|
|
{{"ts":2, "op":"Snapshot", "task": null, "tasks": ["removed", "old"]}}
|
|
"#,
|
|
Delta::now_time() + 50,
|
|
)
|
|
.as_str(),
|
|
);
|
|
let f = File::new(&d.path().join("plain").to_str().unwrap().to_string());
|
|
|
|
let mut m = serde_yaml::Mapping::new();
|
|
m.insert("schedule".into(), "2036-01-02".into());
|
|
let task = Task(serde_yaml::Value::Mapping(m));
|
|
|
|
f.append(Delta::add(task)).unwrap();
|
|
assert_eq!(
|
|
1,
|
|
f.events().unwrap().0.len(),
|
|
"{:?}",
|
|
f.events().unwrap().0
|
|
);
|
|
assert_eq!(0, f.stage().unwrap().len(), "{:?}", f.stage());
|
|
|
|
f.persist_stage().unwrap();
|
|
assert_eq!(
|
|
1,
|
|
f.events().unwrap().0.len(),
|
|
"{:?}",
|
|
f.events().unwrap().0
|
|
);
|
|
assert_eq!(0, f.stage().unwrap().len(), "{:?}", f.stage());
|
|
|
|
f.stage_persisted().unwrap();
|
|
assert_eq!(
|
|
1,
|
|
f.events().unwrap().0.len(),
|
|
"{:?}",
|
|
f.events().unwrap().0
|
|
);
|
|
assert_eq!(0, f.stage().unwrap().len(), "{:?}", f.stage());
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_schedule_date_past() {
|
|
tests::with_dir(|d| {
|
|
tests::write_file(&d, "plain", "[]");
|
|
let f = File::new(&d.path().join("plain").to_str().unwrap().to_string());
|
|
|
|
let mut m = serde_yaml::Mapping::new();
|
|
m.insert("schedule".into(), "2006-01-02".into());
|
|
let task = Task(serde_yaml::Value::Mapping(m));
|
|
|
|
f.append(Delta::add(task)).unwrap();
|
|
assert_eq!(
|
|
1,
|
|
f.events().unwrap().0.len(),
|
|
"{:?}",
|
|
f.events().unwrap().0
|
|
);
|
|
assert_eq!(0, f.stage().unwrap().len(), "{:?}", f.stage());
|
|
|
|
f.persist_stage().unwrap();
|
|
assert_eq!(
|
|
1,
|
|
f.events().unwrap().0.len(),
|
|
"events after 1 add scheduled: {:?}",
|
|
f.events().unwrap().0
|
|
);
|
|
assert_eq!(
|
|
1,
|
|
f.events().unwrap().snapshot().unwrap().len(),
|
|
"events.snapshot after 1 add scheduled: {:?}",
|
|
f.events().unwrap().snapshot().unwrap(),
|
|
);
|
|
tests::file_contains(&d, "plain", "[]");
|
|
assert_eq!(
|
|
0,
|
|
f.stage().unwrap().len(),
|
|
"stage after 1 add scheduled: {:?}",
|
|
f.stage()
|
|
);
|
|
|
|
f.stage_persisted().unwrap();
|
|
assert_eq!(
|
|
2,
|
|
f.events().unwrap().0.len(),
|
|
"{:?}",
|
|
f.events().unwrap().0
|
|
);
|
|
assert_eq!(1, f.stage().unwrap().len(), "{:?}", f.stage());
|
|
});
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct Delta {
|
|
ts: u64,
|
|
op: Op,
|
|
task: Task,
|
|
tasks: Option<Vec<Task>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
enum Op {
|
|
Add,
|
|
Remove,
|
|
Snapshot,
|
|
}
|
|
|
|
impl Delta {
|
|
pub fn new(ts: u64, op: Op, task: Task) -> Delta {
|
|
Delta {
|
|
ts: ts,
|
|
op: op,
|
|
task: task,
|
|
tasks: None,
|
|
}
|
|
}
|
|
|
|
pub fn snapshot(tasks: Vec<Task>) -> Delta {
|
|
Delta {
|
|
ts: Self::now_time(),
|
|
op: Op::Snapshot,
|
|
task: Task(serde_yaml::Value::Null),
|
|
tasks: Some(tasks),
|
|
}
|
|
}
|
|
|
|
pub fn add(task: Task) -> Delta {
|
|
Self::add_at(task, Self::now_time())
|
|
}
|
|
|
|
pub fn add_at(task: Task, at: u64) -> Delta {
|
|
Self::new(at, Op::Add, task)
|
|
}
|
|
|
|
pub fn remove_at(task: Task, at: u64) -> Delta {
|
|
Self::new(at, Op::Remove, task)
|
|
}
|
|
|
|
fn now_time() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs()
|
|
.try_into()
|
|
.unwrap()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
struct Task(serde_yaml::Value);
|
|
|
|
impl Task {
|
|
pub fn _due(&self, after: u64) -> bool {
|
|
match self.next_due(after) {
|
|
Some(ts) => Delta::now_time() > ts,
|
|
None => true,
|
|
}
|
|
}
|
|
|
|
pub fn next_due(&self, after: u64) -> Option<u64> {
|
|
match self.schedule() {
|
|
Some(schedule) => self.parse_schedule_next(schedule, after),
|
|
None => Some(1),
|
|
}
|
|
}
|
|
|
|
fn parse_schedule_next(&self, schedule: String, after: u64) -> Option<u64> {
|
|
let mut schedule = schedule;
|
|
|
|
if regex::Regex::new(r"^[0-9]+h$").unwrap().is_match(&schedule) {
|
|
let hours = &schedule[..schedule.len() - 1];
|
|
match hours.parse::<u64>() {
|
|
Ok(hours) => return Some(after + hours * 60 * 60),
|
|
_ => {}
|
|
};
|
|
}
|
|
|
|
if regex::Regex::new(r"[0-9]{4}-[0-9]{2}-[0-9]{2}$")
|
|
.unwrap()
|
|
.is_match(&schedule)
|
|
{
|
|
schedule += "T00";
|
|
}
|
|
|
|
if regex::Regex::new(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}$")
|
|
.unwrap()
|
|
.is_match(&schedule)
|
|
{
|
|
let date = schedule.clone() + ":00:00";
|
|
match chrono::NaiveDateTime::parse_from_str(&date, "%Y-%m-%dT%H:%M:%S") {
|
|
Ok(datehour) => {
|
|
let seconds = datehour.format("%s").to_string();
|
|
match seconds.parse::<u64>() {
|
|
Ok(n) => return Some(n),
|
|
_ => {}
|
|
};
|
|
}
|
|
Err(msg) => panic!("{}", msg),
|
|
};
|
|
}
|
|
|
|
if regex::Regex::new(r"^([^ ]+ ){4}[^ ]+$")
|
|
.unwrap()
|
|
.is_match(&schedule)
|
|
{
|
|
let after = chrono::DateTime::from_timestamp(after as i64, 0).unwrap();
|
|
if let Ok(next) = cron_parser::parse(&schedule, &after) {
|
|
let seconds = next.format("%s").to_string();
|
|
match seconds.parse::<u64>() {
|
|
Ok(n) => return Some(n),
|
|
_ => {}
|
|
};
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn schedule(&self) -> Option<String> {
|
|
match &self.0 {
|
|
serde_yaml::Value::Mapping(m) => match m.get("schedule".to_string()) {
|
|
Some(schedule) => match schedule {
|
|
serde_yaml::Value::String(s) => Some(s.clone()),
|
|
_ => None,
|
|
},
|
|
_ => None,
|
|
},
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test_task {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_unscheduled() {
|
|
let task = Task(serde_yaml::Value::String("hello world".to_string()));
|
|
assert_eq!(None, task.schedule());
|
|
assert_eq!(Some(1 as u64), task.next_due(100));
|
|
assert!(task._due(100));
|
|
}
|
|
|
|
#[test]
|
|
fn test_scheduled_date_before() {
|
|
let mut m = serde_yaml::Mapping::new();
|
|
m.insert("schedule".into(), "2006-01-02".into());
|
|
let task = Task(serde_yaml::Value::Mapping(m));
|
|
assert_eq!(Some("2006-01-02".to_string()), task.schedule());
|
|
assert_eq!(Some(1136160000 as u64), task.next_due(100));
|
|
assert!(task._due(100));
|
|
}
|
|
|
|
#[test]
|
|
fn test_scheduled_date_after() {
|
|
let mut m = serde_yaml::Mapping::new();
|
|
m.insert("schedule".into(), "2036-01-02".into());
|
|
let task = Task(serde_yaml::Value::Mapping(m));
|
|
assert_eq!(Some("2036-01-02".to_string()), task.schedule());
|
|
assert_eq!(Some(2082844800 as u64), task.next_due(100));
|
|
assert!(!task._due(100));
|
|
}
|
|
|
|
#[test]
|
|
fn test_scheduled_hour_after() {
|
|
let mut m = serde_yaml::Mapping::new();
|
|
m.insert("schedule".into(), "2036-01-02T16".into());
|
|
let task = Task(serde_yaml::Value::Mapping(m));
|
|
assert_eq!(Some("2036-01-02T16".to_string()), task.schedule());
|
|
assert_eq!(Some(2082902400 as u64), task.next_due(100));
|
|
assert!(!task._due(100));
|
|
}
|
|
|
|
#[test]
|
|
fn test_scheduled_duration() {
|
|
let mut m = serde_yaml::Mapping::new();
|
|
m.insert("schedule".into(), "1h".into());
|
|
let task = Task(serde_yaml::Value::Mapping(m));
|
|
assert_eq!(Some("1h".to_string()), task.schedule());
|
|
assert_eq!(Some(3700), task.next_due(100));
|
|
assert!(task._due(100));
|
|
}
|
|
|
|
#[test]
|
|
fn test_scheduled_cron() {
|
|
let mut m = serde_yaml::Mapping::new();
|
|
m.insert("schedule".into(), "* * * * *".into());
|
|
let task = Task(serde_yaml::Value::Mapping(m));
|
|
assert_eq!(Some("* * * * *".to_string()), task.schedule());
|
|
assert_eq!(Some(120 as u64), task.next_due(100));
|
|
assert!(task._due(100));
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct Events(Vec<Delta>);
|
|
|
|
impl Events {
|
|
pub fn new(file: &String) -> Result<Events, String> {
|
|
let logs = match std::fs::read_dir(Self::dir(&file)) {
|
|
Ok(files) => Ok(files
|
|
.filter(|x| x.is_ok())
|
|
.map(|x| x.unwrap())
|
|
.filter(|x| x.metadata().unwrap().is_file())
|
|
.map(|x| x.path().display().to_string())
|
|
.filter(|x| x.starts_with(&Self::log_prefix(&file)))
|
|
.collect::<Vec<String>>()),
|
|
Err(msg) => Err(format!("failed to read dir {}: {}", Self::dir(&file), msg)),
|
|
}?;
|
|
|
|
let mut result: Vec<Delta> = vec![];
|
|
for log in logs.iter() {
|
|
match std::fs::File::open(&log) {
|
|
Ok(f) => {
|
|
for line in std::io::BufReader::new(f).lines() {
|
|
let line = line.unwrap();
|
|
let line = line.trim();
|
|
if line.len() > 0 {
|
|
let delta = match serde_json::from_str(&line) {
|
|
Ok(v) => Ok(v),
|
|
Err(msg) => Err(format!("failed to parse line {}: {}", &line, msg)),
|
|
}?;
|
|
result.push(delta);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
Err(msg) => Err(format!("failed to read {}: {}", &log, msg)),
|
|
}?;
|
|
}
|
|
|
|
result.sort_by(|a, b| a.ts.cmp(&b.ts));
|
|
|
|
Ok(Events(result))
|
|
}
|
|
|
|
fn log_prefix(file: &String) -> String {
|
|
format!("{}/.{}.", Self::dir(&file), Self::basename(&file)).to_string()
|
|
}
|
|
|
|
fn dir(file: &String) -> String {
|
|
let path = std::path::Path::new(&file);
|
|
path.parent()
|
|
.expect("cannot get dirname")
|
|
.to_str()
|
|
.expect("cannot stringify dirname")
|
|
.to_string()
|
|
}
|
|
|
|
pub fn basename(file: &String) -> String {
|
|
let path = std::path::Path::new(&file);
|
|
path.file_name()
|
|
.expect("cannot get basename")
|
|
.to_str()
|
|
.expect("cannot stringify basename")
|
|
.to_string()
|
|
}
|
|
|
|
fn last_snapshot(&self) -> Vec<Task> {
|
|
let reversed_events = {
|
|
let mut e = self.0.clone();
|
|
e.reverse();
|
|
e
|
|
};
|
|
for event in reversed_events.iter() {
|
|
match &event.op {
|
|
Op::Snapshot => return event.tasks.clone().unwrap(),
|
|
_ => {}
|
|
};
|
|
}
|
|
vec![]
|
|
}
|
|
|
|
fn snapshot(&self) -> Result<Vec<Task>, String> {
|
|
let mut result = vec![];
|
|
for event in self.0.iter() {
|
|
match &event.op {
|
|
Op::Add => match event.task.next_due(event.ts) {
|
|
Some(next_due) => match next_due <= Delta::now_time() {
|
|
true => result.push(event.task.clone()),
|
|
false => {}
|
|
},
|
|
None => result.push(event.task.clone()),
|
|
},
|
|
Op::Remove => {
|
|
let mut i = (result.len() - 1) as i32;
|
|
while i >= 0 {
|
|
if event.task == result[i as usize] {
|
|
result.remove(i as usize);
|
|
if i == result.len() as i32 {
|
|
i -= 1
|
|
}
|
|
} else {
|
|
i -= 1;
|
|
}
|
|
}
|
|
}
|
|
Op::Snapshot => result = event.tasks.clone().unwrap(),
|
|
};
|
|
}
|
|
Ok(result)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test_events {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_events_op_snapshot() {
|
|
tests::with_dir(|d| {
|
|
tests::write_file(&d, "plain", "- who cares");
|
|
tests::write_file(
|
|
&d,
|
|
".plain.some_host",
|
|
r#"
|
|
{"ts":1, "op":"Snapshot", "task":"", "tasks":["snapshotted"]}
|
|
"#,
|
|
);
|
|
|
|
let events =
|
|
Events::new(&d.path().join("plain").to_str().unwrap().to_string()).unwrap();
|
|
assert_eq!(1, events.0.len(), "events: {:?}", events);
|
|
|
|
let snapshot = events.snapshot().unwrap();
|
|
assert_eq!(1, snapshot.len());
|
|
assert_eq!(
|
|
serde_yaml::Value::String("snapshotted".to_string()),
|
|
snapshot[0].0
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_events_oplog_to_snapshot_one() {
|
|
tests::with_dir(|d| {
|
|
tests::write_file(&d, "plain", "- persisted\n- stage only");
|
|
tests::write_file(
|
|
&d,
|
|
".plain.some_host",
|
|
r#"
|
|
{"ts":1, "op":"Add", "task":"persisted"}
|
|
"#,
|
|
);
|
|
|
|
let events =
|
|
Events::new(&d.path().join("plain").to_str().unwrap().to_string()).unwrap();
|
|
assert_eq!(1, events.0.len(), "events: {:?}", events);
|
|
|
|
let snapshot = events.snapshot().unwrap();
|
|
assert_eq!(1, snapshot.len());
|
|
assert_eq!(
|
|
serde_yaml::Value::String("persisted".to_string()),
|
|
snapshot[0].0
|
|
);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_events_oplog_to_snapshot_complex() {
|
|
tests::with_dir(|d| {
|
|
tests::write_file(&d, "plain", "- ignored");
|
|
tests::write_file(
|
|
&d,
|
|
".plain.host_a",
|
|
r#"
|
|
{"ts":1, "op":"Add", "task":"persisted"}
|
|
{"ts":3, "op":"Add", "task":"persisted 3"}
|
|
{"ts":2, "op":"Add", "task":"persisted 2"}
|
|
{"ts":6, "op":"Remove", "task":"persisted 5"}
|
|
{"ts":6, "op":"Add", "task":"persisted 5'"}
|
|
{"ts":7, "op":"Remove", "task":"persisted 4"}
|
|
"#,
|
|
);
|
|
tests::write_file(
|
|
&d,
|
|
".plain.host_b",
|
|
r#"
|
|
{"ts":4, "op":"Add", "task":"persisted 4"}
|
|
{"ts":5, "op":"Add", "task":"persisted 5"}
|
|
"#,
|
|
);
|
|
|
|
let events =
|
|
Events::new(&d.path().join("plain").to_str().unwrap().to_string()).unwrap();
|
|
|
|
let snapshot = events.snapshot().unwrap();
|
|
assert_eq!(4, snapshot.len());
|
|
assert_eq!(
|
|
serde_yaml::Value::String("persisted".to_string()),
|
|
snapshot[0].0
|
|
);
|
|
assert_eq!(
|
|
serde_yaml::Value::String("persisted 2".to_string()),
|
|
snapshot[1].0
|
|
);
|
|
assert_eq!(
|
|
serde_yaml::Value::String("persisted 3".to_string()),
|
|
snapshot[2].0
|
|
);
|
|
assert_eq!(
|
|
serde_yaml::Value::String("persisted 5'".to_string()),
|
|
snapshot[3].0
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
mod tests {
|
|
use super::*;
|
|
|
|
pub fn with_dir(mut foo: impl FnMut(tempdir::TempDir)) {
|
|
foo(tempdir::TempDir::new("").unwrap());
|
|
}
|
|
|
|
pub fn write_file(d: &tempdir::TempDir, fname: &str, content: &str) {
|
|
let mut f = std::fs::File::create(d.path().join(&fname)).unwrap();
|
|
writeln!(f, "{}", &content).unwrap();
|
|
f.sync_all().unwrap();
|
|
}
|
|
|
|
pub fn file_contains(d: &tempdir::TempDir, fname: &str, content: &str) {
|
|
let p = d.path().join(&fname);
|
|
let file_content = file_content(&p.to_str().unwrap().to_string());
|
|
assert!(
|
|
file_content.contains(content),
|
|
"expected {:?} but got {:?}",
|
|
content,
|
|
file_content
|
|
);
|
|
}
|
|
|
|
pub fn file_content(p: &String) -> String {
|
|
std::fs::read_to_string(p).unwrap()
|
|
}
|
|
|
|
pub fn list_dir(d: &tempdir::TempDir) -> Vec<String> {
|
|
std::fs::read_dir(d)
|
|
.unwrap()
|
|
.filter(|x| x.is_ok())
|
|
.map(|x| x.unwrap())
|
|
.filter(|x| x.metadata().unwrap().is_file())
|
|
.map(|x| x.path().display().to_string())
|
|
.filter(|x| !x.contains("/."))
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
mod edit {
|
|
use super::*;
|
|
|
|
pub fn files(files: &Files) {
|
|
tests::with_dir(|d| {
|
|
build_dir(&d, &files).expect("failed to build dir");
|
|
edit_dir(&d).expect("failed to edit dir");
|
|
persist_edits(&d, &files).expect("couldnt persist edited");
|
|
});
|
|
}
|
|
|
|
fn build_dir(d: &tempdir::TempDir, files: &Files) -> Result<(), String> {
|
|
for file in files.files.iter() {
|
|
let content = tests::file_content(&file.file);
|
|
tests::write_file(d, Events::basename(&file.file).as_str(), content.as_str());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn edit_dir(d: &tempdir::TempDir) -> Result<(), String> {
|
|
let mut cmd = std::process::Command::new("vim");
|
|
cmd.stdout(std::process::Stdio::inherit());
|
|
cmd.stdin(std::process::Stdio::inherit());
|
|
cmd.stderr(std::process::Stdio::inherit());
|
|
cmd.arg("-p");
|
|
for f in tests::list_dir(&d).iter() {
|
|
cmd.arg(Events::basename(f));
|
|
}
|
|
cmd.current_dir(d.path().display().to_string());
|
|
match cmd.output() {
|
|
Ok(_) => Ok(()),
|
|
Err(msg) => Err(format!("failed to vim: {}", msg)),
|
|
}
|
|
}
|
|
|
|
fn persist_edits(d: &tempdir::TempDir, files: &Files) -> Result<(), String> {
|
|
let new_files = Flags::files_with(&d.path().display().to_string())?;
|
|
assert_eq!(files.files.len(), new_files.files.len());
|
|
|
|
for i in 0..files.files.len() {
|
|
let file = &files.files[i];
|
|
let before = file.stage()?;
|
|
let after = new_files.files[i].stage()?;
|
|
file.persist_delta(before, after)?;
|
|
file.stage_persisted()?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test_edit {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_build_empty() {
|
|
tests::with_dir(|d| {
|
|
build_dir(&d, &Files { files: vec![] }).expect("failed to build empty dir");
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_with_files() {
|
|
tests::with_dir(|d1| {
|
|
tests::write_file(&d1, "file_a", "hello world a");
|
|
tests::write_file(&d1, "file_b", "hello world b");
|
|
let p1 = d1.path().join("file_a").display().to_string();
|
|
let p2 = d1.path().join("file_b").display().to_string();
|
|
let files = Files::new(&vec![p1, p2]);
|
|
tests::with_dir(|d2| {
|
|
build_dir(&d2, &files).expect("failed to build non-empty dir");
|
|
tests::file_contains(&d2, "file_a", "hello world a");
|
|
tests::file_contains(&d2, "file_b", "hello world b");
|
|
});
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn test_persist_edits_with_files() {
|
|
let hostname = gethostname::gethostname().into_string().unwrap();
|
|
let log_file = format!(".file.{}", hostname);
|
|
let log_file = log_file.as_str();
|
|
tests::with_dir(|d1| {
|
|
tests::write_file(&d1, "file", "- foo");
|
|
let p = d1.path().join("file").display().to_string();
|
|
let files = Files::new(&vec![p]);
|
|
files.files[0].persist_stage().expect("failed to init log");
|
|
tests::file_contains(&d1, log_file, r#""foo""#);
|
|
tests::with_dir(|d2| {
|
|
build_dir(&d2, &files).expect("failed to build dir");
|
|
tests::file_contains(&d2, "file", "- foo");
|
|
tests::write_file(&d2, "file", "- foobar\n- bar");
|
|
|
|
persist_edits(&d2, &files).expect("failed to persist edits");
|
|
tests::file_contains(&d1, "file", "- foobar\n- bar");
|
|
tests::file_contains(&d1, log_file, r#""foo""#);
|
|
tests::file_contains(&d1, log_file, r#""foobar""#);
|
|
tests::file_contains(&d1, log_file, r#""bar""#);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|