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, #[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, } impl Flags { pub fn new() -> Result { 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 { Self::files_with(&self.path) } pub fn files_with(p: &String) -> Result { 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, } impl Files { pub fn new(files: &Vec) -> 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::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, after: Vec) -> Result<(), String> { self.persist_delta_at(before, after, Delta::now_time()) } fn persist_delta_at( &self, before: Vec, after: Vec, 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, 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::>(&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>, } #[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) -> 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 { match self.schedule() { Some(schedule) => self.parse_schedule_next(schedule, after), None => Some(1), } } fn parse_schedule_next(&self, schedule: String, after: u64) -> Option { 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::() { 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::() { 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::() { Ok(n) => return Some(n), _ => {} }; } } None } fn schedule(&self) -> Option { 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); impl Events { pub fn new(file: &String) -> Result { 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::>()), Err(msg) => Err(format!("failed to read dir {}: {}", Self::dir(&file), msg)), }?; let mut result: Vec = 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 { 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, 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 { 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""#); }); }); } } }