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 patch: json_patch::PatchOperation = json_patch::PatchOperation::Add(json_patch::AddOperation { path: jsonptr::PointerBuf::parse("/-").expect("cannot create path to /-"), value: serde_json::json!(add), }); files.files[0] .append(Delta::now(patch)) .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 { panic!( "not impl: create tempfiles of now up-to-date-snapshots, diff vim'd, persist difs to logs, render logs" ); } } #[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, } 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 { let metadata = match std::fs::metadata(self.path.clone()) { Ok(v) => Ok(v), Err(msg) => Err(format!("failed to load {}: {}", self.path.clone(), msg)), }?; let files = match metadata.is_dir() { false => Ok(vec![self.path.clone()]), true => match std::fs::read_dir(self.path.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 {}: {}", self.path.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, }; 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 stage_persisted(&self) -> Result<(), String> { let stage = self.events()?.snapshot()?; let plaintext = serde_yaml::to_string(&stage).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_stage(&self) -> Result<(), String> { let persisted = self.events()?.snapshot()?; let persisted = serde_json::to_string(&persisted).unwrap(); let persisted = persisted.as_str(); let persisted: serde_json::Value = serde_json::from_str(&persisted).unwrap(); let stage = self.stage()?; let stage = serde_json::to_string(&stage).unwrap(); let stage: serde_json::Value = serde_json::from_str(stage.as_str()).unwrap(); let patches = json_patch::diff(&persisted, &stage); let deltas: Vec = patches .iter() .map(|patch| patch.clone()) .map(|patch| Delta::now(patch.clone())) .collect(); for delta in deltas.iter() { self.append(delta.clone())?; } 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, world]"); f.stage_persisted().unwrap(); assert_eq!(2, 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, "patch":{"op":"replace", "path":"", "value": ["initial"]}} {"ts":3, "patch":{"op":"add", "path":"/-", "value": {"k":"v"}}} "#, ); tests::write_file( &d, ".plain.host_b", r#" {"ts":2, "patch":{"op":"add", "path":"/-", "value": 1}} "#, ); let f = File::new(&d.path().join("plain").to_str().unwrap().to_string()); assert_eq!(3, f.events().unwrap().0.len()); assert_eq!(0, f.stage().unwrap().len()); tests::file_contains(&d, "plain", "[]"); f.persist_stage().unwrap(); assert_eq!(6, f.events().unwrap().0.len()); assert_eq!(0, f.stage().unwrap().len()); tests::file_contains(&d, "plain", "[]"); f.stage_persisted().unwrap(); assert_eq!(6, f.events().unwrap().0.len()); assert_eq!(0, f.stage().unwrap().len()); 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, "patch":{"op":"replace", "path":"", "value": ["initial"]}} {"ts":3, "patch":{"op":"add", "path":"/-", "value": {"k":"v"}}} "#, ); tests::write_file( &d, ".plain.host_b", r#" {"ts":2, "patch":{"op":"add", "path":"/-", "value": 1}} "#, ); let f = File::new(&d.path().join("plain").to_str().unwrap().to_string()); assert_eq!(3, 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!(4, f.events().unwrap().0.len()); assert_eq!(2, f.stage().unwrap().len()); tests::file_contains(&d, "plain", "- initial\n- 1"); }); } } #[derive(Debug, Clone, Serialize, Deserialize)] struct Delta { ts: u64, patch: json_patch::PatchOperation, } impl Delta { pub fn new(patch: json_patch::PatchOperation, ts: u64) -> Delta { Delta { patch: patch, ts: ts, } } pub fn now(patch: json_patch::PatchOperation) -> Delta { Self::new( patch, 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); #[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() } 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 snapshot(&self) -> Result, String> { let mut result = serde_json::json!([]); for event in self.0.iter() { match json_patch::patch(&mut result, vec![event.patch.clone()].as_slice()) { Ok(_) => Ok(()), Err(msg) => Err(format!( "failed to patch {} onto {}: {}", &event.patch, &result, msg )), }?; } match serde_json::from_str(serde_json::to_string(&result).unwrap().as_str()) { Ok(v) => Ok(v), Err(msg) => Err(format!("failed turning patched into events: {}", msg)), } } } #[cfg(test)] mod test_events { use super::*; #[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, "patch":{"op":"replace", "path":"", "value":["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, "patch":{"op":"replace", "path":"", "value":["persisted"]}} {"ts":3, "patch":{"op":"add", "path":"/-", "value":"persisted 3"}} {"ts":2, "patch":{"op":"add", "path":"/-", "value":"persisted 2"}} {"ts":6, "patch":{"op":"replace", "path":"/4", "value":"persisted 5'"}} {"ts":7, "patch":{"op":"remove", "path":"/3"}} "#, ); tests::write_file( &d, ".plain.host_b", r#" {"ts":4, "patch":{"op":"add", "path":"/-", "value":"persisted 4"}} {"ts":5, "patch":{"op":"add", "path":"/-", "value":"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 = std::fs::read_to_string(p).unwrap(); assert!(file_content.contains(content)); } }