pttodo/pttodoest/src/main.rs

568 lines
18 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 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<String>,
#[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<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> {
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<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 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<Delta> = 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<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, 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<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()
}
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<Vec<Task>, 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));
}
}