use serde::{Serialize, Deserialize}; use std::io::{Read, Write}; use std::fs::File; use std::time::{SystemTime, UNIX_EPOCH, Duration}; use std::ops::{Add, Sub}; use clap::Parser; use chrono::{TimeZone, Local, Timelike}; use regex::Regex; use serde_yaml::from_str; #[derive(Debug, Parser)] struct Flags { #[arg(short = 'f', long = "file")] f: String, #[arg(short = 'l', long = "log")] log: bool, #[arg(short = 's', long = "since")] since: Option, #[arg(short = 'a', long = "add")] add: Option, #[arg(short = 't', long = "tag")] tag: Option, #[arg(short = 'c', long = "clock")] clock: bool, #[arg(short = 'd', long = "duration")] duration: Option, #[arg(short = 'v', long = "verbose")] verbose: bool, #[arg(short = 'p', long = "precision")] precision: Option, } fn main() { let mut flags = Flags::parse(); flags.log = flags.log || flags.since.is_some(); let duration = parse_duration(&flags.duration).unwrap(); clock(&flags.f, &(flags.clock || flags.duration.is_some()), &duration).unwrap(); add(&flags.f, &flags.add, &flags.tag, &0).unwrap(); log(&flags.f, &flags.log, &flags.since, &flags.verbose, &flags.precision.unwrap_or(0)).unwrap(); } fn clock(f: &String, clock: &bool, duration: &u64) -> Result<(), String> { match clock { true => add(&f, &Some("".to_string()), &None, duration), false => Ok(()), } } fn add(f: &String, x: &Option, tag: &Option, duration: &u64) -> Result<(), String> { match x { Some(x) => { let mut tsheet = load(&f)?; tsheet.add( x.to_string(), tag.clone().unwrap_or("".to_string()), *duration, ); save(&f, tsheet)?; }, None => {}, }; Ok(()) } #[derive(Debug, Serialize, Clone)] struct Log { t: String, d: f32, xs: Vec, } #[derive(Debug, Serialize, Clone)] struct LogX { d: f32, x: String, } fn log(f: &String, enabled: &bool, since: &Option, verbose: &bool, precision: &u32) -> Result<(), String> { if !enabled { return Ok(()); } let since = parse_time(since)?; if *verbose { eprintln!("since = {} ({})", system_time_to_unix_seconds(&since), timestamp(&system_time_to_unix_seconds(&since), &2)); } let tsheet = load(&f)?; let tsheet = tsheet.since(since); if *verbose { eprintln!("tsheet = {:?}", &tsheet); } let mut result = vec![]; let mut curr = Log{t: "".to_string(), d: 0.0, xs: vec![]}; let mut currt = "".to_string(); for i in 0..tsheet.xs.len() { let x = &tsheet.xs[i]; if *verbose { eprintln!("{} != {}?", &curr.t, x.timestamp(&precision)); } if currt != x.timestamp(&0) { if curr.xs.len() > 0 { if *verbose { eprintln!("push {:?}", &curr.xs); } result.push(curr.clone()); } curr.xs.truncate(0); curr.t = x.timestamp(&precision); currt = x.timestamp(&0); curr.d = 0.0; } let mut d = 1.0; if x.x.len() == 0 { d = 0.0; } else if i > 0 { d = ((tsheet.xs[i].t - tsheet.xs[i-1].t) as f32 / (60.0*60.0)) as f32; } if *verbose { eprintln!("d={} x='{}'", &x.x, &d); } match x.x.len() { 0 => {}, _ => { curr.t = x.timestamp(&precision); currt = x.timestamp(&0); curr.xs.push(LogX{d: d, x: x.x.clone()}); }, }; } if curr.xs.len() > 0 { if *verbose { eprintln!("push {:?} (final)", &curr.xs); } result.push(curr.clone()); } let mut total_d = 0.0; for i in result.iter_mut() { i.d = i.xs.iter().map(|x| x.d).sum(); total_d += i.d; if *verbose { eprintln!("{} = {:?}", &i.d, &i.xs.iter().map(|x| x.d).collect::>()); } } for log in &result { for x in log.xs.clone() { if x.x.len() > 0 { match precision { 0 => println!("{} ({:.0}) {} ({:.1})", log.t, log.d, x.x, x.d), 1 => println!("{} ({:.1}) {} ({:.2})", log.t, log.d, x.x, x.d), _ => println!("{} ({:.2}) {} ({:.3})", log.t, log.d, x.x, x.d), } } } } match precision { 0 => eprintln!("({:.0}h over {} dates)", total_d, result.len()), 1 => eprintln!("({:.1}h over {} dates)", total_d, result.len()), _ => eprintln!("({:.2}h over {} dates)", total_d, result.len()), } Ok(()) } #[cfg(test)] mod test_parse { use super::*; #[test] fn test_duration() { assert_eq!(0, parse_duration(&None).unwrap()); assert_eq!(1, parse_duration(&Some("1s".to_string())).unwrap()); assert_eq!(33, parse_duration(&Some("33s".to_string())).unwrap()); assert_eq!(666, parse_duration(&Some("666s".to_string())).unwrap()); assert_eq!(60, parse_duration(&Some("1m".to_string())).unwrap()); assert_eq!(62, parse_duration(&Some("1m2s".to_string())).unwrap()); assert_eq!(3600, parse_duration(&Some("1h".to_string())).unwrap()); assert_eq!(3723, parse_duration(&Some("1h2m3s".to_string())).unwrap()); } } fn parse_duration(d: &Option) -> Result { match d { Some(d) => { let mut sum: u64 = 0; let re = Regex::new(r"^(?[0-9]+h)?(?[0-9]+m)?(?[0-9]+s)?$").unwrap(); match re.captures(d) { Some(captures) => { match captures.name("seconds") { Some(n) => { let n = n.as_str().to_string(); let n = n.trim_end_matches('s'); sum += from_str::(n).unwrap(); }, None => {}, }; match captures.name("minutes") { Some(n) => { let n = n.as_str().to_string(); let n = n.trim_end_matches('m'); sum += 60 * from_str::(n).unwrap(); }, None => {}, }; match captures.name("hours") { Some(n) => { let n = n.as_str().to_string(); let n = n.trim_end_matches('h'); sum += 60 * 60 * from_str::(n).unwrap(); }, None => {}, }; Ok(sum) }, None => Ok(0), } }, None => Ok(0), } } fn parse_time(since: &Option) -> Result { match since { Some(since) => { match chrono::NaiveDate::parse_from_str(since, "%Y-%m-%d") { Ok(nd) => { let ndt = nd.and_hms_opt(1, 1, 1).unwrap(); let dt = Local.from_local_datetime(&ndt).unwrap(); Ok(UNIX_EPOCH.add( Duration::from_secs(dt.timestamp() as u64) )) }, Err(msg) => Err(format!("failed to parse {}: {}", since, msg)), } }, None => Ok(SystemTime::now().sub(Duration::from_secs(Local::now().hour() as u64*60*60))), } } #[derive(Debug, PartialEq, Serialize, Deserialize)] struct TSheet { xs: Vec, } #[derive(Debug, PartialEq, Serialize, Deserialize, Clone, Ord, Eq, PartialOrd)] struct X { t: i64, x: String, tag: String, } fn save(path: &String, tsheet: TSheet) -> Result<(), String> { match File::create(path.clone()) { Ok(mut writer) => _save(&mut writer, tsheet), Err(reason) => Err(format!("failed to open {} to save tsheet: {}", path, reason)), } } fn _save(writer: &mut dyn Write, tsheet: TSheet) -> Result<(), String> { let mut w = serde_yaml::Serializer::new(writer); match tsheet.serialize(&mut w) { Ok(_) => Ok(()), Err(reason) => Err(format!("failed to serialize tsheet: {}", reason)), } } fn load(path: &String) -> Result { match File::open(path.clone()) { Ok(mut reader) => _load(&mut reader), Err(reason) => Err(format!("failed to read tsheet {}: {}", path, reason)), } } fn _load(reader: &mut dyn Read) -> Result { match serde_yaml::from_reader::<&mut dyn Read, TSheet>(reader) { Ok(tsheet) => Ok(tsheet), Err(err) => Err(format!("failed to parse tsheet: {}", err)), } } #[cfg(test)] mod test_save_load { use super::*; #[test] fn test_empty() { let got = _load(&mut "xs: []".as_bytes()).expect("failed to parse 'xs: []' tsheet"); assert_eq!(got, TSheet{xs: vec![]}); let mut w = vec![]; _save(&mut w, got).expect("failed saving tsheet to writer"); assert_eq!(String::from_utf8(w).unwrap(), "xs: []\n".to_string()); } #[test] fn test_testdata_standalone_yaml() { let want = TSheet{xs: vec![ X{t: 1, x: "def".to_string(), tag: "abc".to_string()}, X{t: 2, x: "ghi".to_string(), tag: "".to_string()}, ]}; assert_eq!( load(&"./src/testdata/standalone.yaml".to_string()).expect("cant load standalone.yaml"), want, ); let mut w = vec![]; _save(&mut w, want).expect("failed saving tsheet to writer"); assert_eq!(String::from_utf8(w).unwrap(), "xs:\n- t: 1\n x: def\n tag: abc\n- t: 2\n x: ghi\n tag: ''\n".to_string()); } } impl TSheet { fn since(&self, t: SystemTime) -> TSheet { let mut result = TSheet{xs: vec![]}; self.xs.iter() .filter(|x| x.ts() >= t) .for_each(|x| result.xs.push(x.clone())); result } fn add(&mut self, x: String, tag: String, duration: u64) { let now = system_time_to_unix_seconds(&SystemTime::now()); self.xs.push(new_x( now - duration as i64, x, tag, )); } } impl X { fn ts(&self) -> SystemTime { UNIX_EPOCH.add(Duration::from_secs(self.t.try_into().unwrap())) } fn timestamp(&self, precision: &u32) -> String { timestamp(&self.t, &precision) } } fn system_time_to_unix_seconds(st: &SystemTime) -> i64 { st.duration_since(UNIX_EPOCH).unwrap().as_secs() as i64 } fn timestamp(t: &i64, precision: &u32) -> String { let dt = Local.timestamp_opt(*t, 0).unwrap(); match precision { 0 => dt.format("%Y-%m-%d").to_string(), 1 => dt.format("%Y-%m-%dT%H:%M").to_string(), _ => dt.format("%Y-%m-%dT%H:%M:%S").to_string(), } } fn new_x(t: i64, x: String, tag: String) -> X { X{ t: t, x: x, tag: tag, } } #[cfg(test)] mod test_tsheet { use super::*; #[test] fn test_add() { let mut given = TSheet{xs: vec![ X{t: 1, x: "def".to_string(), tag: "abc".to_string()}, ]}; given.add("ghi".to_string(), "".to_string(), 0); assert_eq!(given.xs.len(), 2); assert!(given.xs[1].t != 1); assert_eq!(given.xs[1].x, "ghi".to_string()); assert_eq!(given.xs[1].tag, "".to_string()); } #[test] fn test_since_date() { let given = TSheet{xs: vec![ X{t: 1, x: "def".to_string(), tag: "abc".to_string()}, X{t: 2, x: "ghi".to_string(), tag: "".to_string()}, X{t: 3, x: "jkl".to_string(), tag: "".to_string()}, ]}; let want = TSheet{xs: vec![ X{t: 2, x: "ghi".to_string(), tag: "".to_string()}, X{t: 3, x: "jkl".to_string(), tag: "".to_string()}, ]}; let got = given.since(UNIX_EPOCH.add(Duration::from_secs(2))); assert_eq!(got, want); } }