diff --git a/pttodoer/Cargo.lock b/pttodoer/Cargo.lock index 41ab27c..840a0e3 100644 --- a/pttodoer/Cargo.lock +++ b/pttodoer/Cargo.lock @@ -2,18 +2,225 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" + +[[package]] +name = "anstyle-parse" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64c907d4e79225ac72e2a354c9ce84d50ebb4586dee56c82b3ee73004f537f5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" +dependencies = [ + "anstyle", + "windows-sys", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "cc" +version = "1.0.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "099a5357d84c4c61eb35fc8eafa9a79a902c2f76911e5747ced4e032edd8d9b4" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets", +] + +[[package]] +name = "clap" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" + +[[package]] +name = "colorchoice" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "croner" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516aad5374ea0ea75a0f0f4512fb4e7ad46c5eeff9971cb8ebc8fd74f1cd16c1" +dependencies = [ + "chrono", +] + [[package]] name = "equivalent" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "indexmap" version = "2.2.6" @@ -24,12 +231,60 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" + [[package]] name = "itoa" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +[[package]] +name = "js-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae743338b92ff9146ce83992f766a31066a91a8c84a45e0e9f21e7cf6de6d346" + +[[package]] +name = "log" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" + +[[package]] +name = "memchr" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + [[package]] name = "proc-macro2" version = "1.0.81" @@ -43,7 +298,13 @@ dependencies = [ name = "pttodoer" version = "0.1.0" dependencies = [ + "chrono", + "clap", + "croner", + "regex", + "serde", "serde_yaml", + "tempdir", ] [[package]] @@ -55,6 +316,81 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" +dependencies = [ + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "rdrand", + "winapi", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + +[[package]] +name = "regex" +version = "1.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + [[package]] name = "ryu" version = "1.0.18" @@ -63,18 +399,18 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "serde" -version = "1.0.200" +version = "1.0.202" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f" +checksum = "226b61a0d411b2ba5ff6d7f73a476ac4f8bb900373459cd00fab8512828ba395" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.200" +version = "1.0.202" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb" +checksum = "6048858004bcff69094cd972ed40a32500f153bd3be9f716b2eed2e8217c4838" dependencies = [ "proc-macro2", "quote", @@ -94,6 +430,12 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.61" @@ -105,6 +447,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempdir" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" +dependencies = [ + "rand", + "remove_dir_all", +] + [[package]] name = "unicode-ident" version = "1.0.12" @@ -116,3 +468,167 @@ name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" diff --git a/pttodoer/Cargo.toml b/pttodoer/Cargo.toml index 4ce5496..d531fdb 100644 --- a/pttodoer/Cargo.toml +++ b/pttodoer/Cargo.toml @@ -6,4 +6,10 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +chrono = "0.4.38" +clap = { version = "4.4.8", features = ["derive"] } +croner = "2.0.4" +regex = "1.10.4" +serde = { version = "1.0.202", features = [ "serde_derive" ] } serde_yaml = "0.9.34" +tempdir = "0.3.7" diff --git a/pttodoer/src/main.rs b/pttodoer/src/main.rs index 659b2d4..5e0e4cf 100644 --- a/pttodoer/src/main.rs +++ b/pttodoer/src/main.rs @@ -1,31 +1,1372 @@ use serde_yaml; +use serde::ser::{Serialize}; +use serde; +use chrono::{DateTime, Local}; +use chrono::naive::NaiveDateTime; +use regex::Regex; +use croner; +use clap::Parser; +use std::io::Write; +use tempdir::TempDir; fn main() { - println!("{:?}", Task::new()) + let flags = Flags::new(); + + let mut db = DB::new(flags.path).unwrap(); + + match flags.add.clone() { + Some(s) => { + let t = match flags.add_schedule { + Some(sch) => Task::from_string_schedule(s, sch), + None => Task::from_string(s), + }; + db.tasks_and_metadatas[0].tasks.0.push(t); + }, + _ => {}, + }; + + if flags.edit { + db = db.edit().expect("failed to edit") + } + + if flags.edit || flags.add.is_some() { + db.save(flags.dry_run).expect("failed to save"); + } + + if flags.list { + println!("{}", db.due().to_string()); + } +} + +#[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 = 's', long = "add-schedule")] + add_schedule: 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 = 'l', long = "list", default_value="true")] + list: bool, +} + +impl Flags { + fn new() -> Flags { + let mut result = Flags::parse(); + + if result.path.get(..1) == Some("$") { + result.path = std::env::var( + result.path.get(1..).unwrap() + ).expect(format!("'{}' unset", result.path).as_str()); + } + + result + } +} + +#[derive(Debug, PartialEq, Clone)] +pub struct DB { + tasks_and_metadatas: Vec, + cwd: String, +} + +impl DB { + pub fn new(path: String) -> Result { + let metadata = match std::fs::metadata(path.clone()) { + Ok(v) => Ok(v), + Err(msg) => Err(format!("failed to load {}: {}", path, msg)), + }?; + let mut files = vec![]; + if metadata.is_file() { + files.push(path.clone()); + } else if metadata.is_dir() { + match std::fs::read_dir(path.clone()) { + Ok(paths) => { + files.extend(paths + .filter(|x| x.is_ok()) + .map(|x| x.unwrap()) + .filter(|x| x.metadata().unwrap().is_file()) + .map(|x| x.path().display().to_string()) + ); + Ok(()) + }, + Err(msg) => Err(format!("failed to read {}: {}", path.clone(), msg)), + }?; + } + + let mut result = vec![]; + for file in files { + let item = TasksAndMetadata::new(file)?; + result.push(item); + } + Ok(DB{ + tasks_and_metadatas: result, + cwd: match metadata.is_file() { + true => std::path::Path::new(&path).parent().expect("root?").display().to_string(), + _ => path.clone(), + }, + }) + } + + fn new_empty(cwd: String) -> DB { + DB{tasks_and_metadatas: vec![], cwd: cwd.clone()} + } + + pub fn incomplete(&self) -> Tasks { + let mut result = Tasks::new(); + for set in &self.tasks_and_metadatas { + result.0.extend(set.tasks.incomplete().0); + } + result + } + + pub fn due(&self) -> Tasks { + let mut result = Tasks::new(); + for set in &self.tasks_and_metadatas { + result.0.extend(set.tasks.due().0); + } + result + } + + pub fn edit(&self) -> Result { + self._edit(|cwd: &String| { + std::process::Command::new("/bin/sh") + .current_dir(cwd) + .arg("-c") + .arg("vim -p ./*") + .spawn() + .expect("failed to start vim") + .wait() + .expect("failed to vim"); + }) + } + + fn _edit(&self, edit: impl Fn(&String)) -> Result { + let d = TempDir::new(&TS::now().to_string()).expect("failed to create a temp dir"); + for set in &self.tasks_and_metadatas { + let base = set.file.split("/").last().unwrap().to_string(); + let f = d.path().join(base); + set.save_due_as(f.display().to_string())?; + } + + loop { + let cwd = d.path().display().to_string(); + edit(&cwd); + let mut ok = true; + for f in std::fs::read_dir(&cwd).expect("failed to list edited files") { + ok = ok && TasksAndMetadata::new(f.expect("failed to list edited file").path().display().to_string()).is_ok(); + } + if ok { + break; + } + } + + let mut result = vec![]; + for f in std::fs::read_dir(d.path()).expect("failed to list edited files") { + let f = f.expect("failed to list edited file").path(); + let base = f.display().to_string().split("/").last().unwrap().to_string(); + let set = match self.tasks_and_metadatas + .iter() + .filter(|tasks_and_metadata| tasks_and_metadata.file.ends_with(format!("/{}", base).as_str())) + .nth(0) { + Some(set) => set.clone(), + None => TasksAndMetadata::new_with( + format!("{}/{}", + self.cwd, + base, + ), + TS::now(), + Tasks::new(), + ), + }; + + let edited = TasksAndMetadata::new(f.display().to_string()).expect("failed to read edited tasks"); + + let was_due = set.due(); + let mut now_due = vec![]; + let mut not_due = set.not_due().tasks.0; + + for task in &edited.tasks.0 { + if task.is_due() { + eprintln!("now_due.push({:?})", task.clone()); + now_due.push(task.clone()); + } else if !not_due.contains(task) { + eprintln!("not_due.push({:?})", task.clone()); + not_due.push(task.clone()); + } + } + + let mut already_due = vec![]; + let mut already_not_due = vec![]; + for task in &was_due.tasks.0 { + let mut without_ts = task.clone(); + without_ts.unset("ts".to_string()); + + let mut with_ts = task.clone(); + with_ts.set("ts".to_string(), TS::now().to_string()); + + if now_due.contains(&without_ts) { + } else if not_due.contains(task) { + } else if task.is_due() { + eprintln!("already_due.push({:?})", with_ts.clone()); + already_due.push(with_ts.clone()); + } else { + eprintln!("already_not_due.push({:?})", task.clone()); + already_not_due.push(task.clone()); + } + } + + let mut new_tasks = Tasks::new(); + new_tasks.0.extend(now_due); + new_tasks.0.extend(already_due); + new_tasks.0.extend(not_due); + new_tasks.0.extend(already_not_due); + + result.push(TasksAndMetadata::new_with( + set.file.clone(), + set.version.clone(), + new_tasks, + )); + } + + Ok(DB{tasks_and_metadatas: result, cwd: self.cwd.clone()}) + } + + pub fn save(&self, dry_run: bool) -> Result<(), String> { + for i in &self.tasks_and_metadatas { + i.save(dry_run)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod test_db { + use super::*; + + #[test] + fn resolved_scheduled_recurring() { + assert!(false, "not impl") + } + + #[test] + fn resolved_scheduled_non_recurring() { + assert!(false, "not impl") + } + + #[test] + fn conflicting_save_handled() { + use std::ops::Add; + + let d = TempDir::new(&TS::now().to_string()).expect("failed to create a temp dir"); + let d = d.path().display().to_string(); + let p = format!("{}/f", &d); + { + let mut f = std::fs::File::create(p.clone()).expect("failed to create a file in cwd"); + f.write_all(b"- x").expect("failed to create a file"); + } + + let mut db = DB::new(d.clone()).expect("failed to open tempd"); + assert_eq!(1, db.tasks_and_metadatas[0].tasks.len()); + + std::fs::File::open(p) + .expect("failed to open file to change modtime") + .set_modified(std::time::SystemTime::now().add(std::time::Duration::new(10, 0))) + .expect("failed to change modtime"); + + db.tasks_and_metadatas[0].tasks.0.push(Task::new()); + db.save(false).expect("save as conflicting update shouldve saved as a different file"); + + let db = DB::new(d.clone()).expect("failed to open tempd again"); + assert_eq!(2, db.tasks_and_metadatas.len()); + assert_eq!(1, db.tasks_and_metadatas[0].tasks.len()); + assert_eq!(2, db.tasks_and_metadatas[1].tasks.len()); + } + + + #[test] + fn edit_append_duplicate_keeps_both() { + use std::io::Seek; + + let db = new_test_db(1, 1); + let db_after = db._edit(|cwd| { + let files_in_dir = std::fs::read_dir(cwd).expect("failed to ls cwd").collect::>(); + assert_eq!(1, files_in_dir.len()); + let mut f = std::fs::OpenOptions::new() + .write(true) + .append(true) + .open(files_in_dir[0].as_ref().expect("failed to get file in dir").path()) + .expect("failed to open file for append"); + f.seek(std::io::SeekFrom::End(0)).expect("failed to seek to end"); + f.write_all(b"\n- f").expect("failed to append the file in cwd"); + f.write_all(b"\n- f").expect("failed to append the file in cwd"); + }).expect("failed to not edit"); + assert_ne!(db, db_after); + + assert_eq!(1, db_after.tasks_and_metadatas.len()); + assert_eq!(3, db_after.tasks_and_metadatas[0].tasks.len()); + assert_eq!(db.tasks_and_metadatas[0].tasks.0[0], db_after.tasks_and_metadatas[0].tasks.0[0]); + assert_eq!(Some("f".to_string()), db_after.tasks_and_metadatas[0].tasks.0[1].get("is".to_string())); + assert_eq!(Some("f".to_string()), db_after.tasks_and_metadatas[0].tasks.0[2].get("is".to_string())); + } + + #[test] + fn edit_schedule_future() { + let db = new_test_db(0, 0); + let db_after = db._edit(|cwd| { + let mut task = Task::new(); + task.set("k".to_string(), "v".to_string()); + task.set("schedule".to_string(), "2101-02-03".to_string()); + + let mut tasks = Tasks::new(); + tasks.0.push(task); + + let temp = TasksAndMetadata::new_with( + format!("{}/only", cwd), + TS::now(), + tasks, + ); + temp.save(false).expect("failed to save"); + }).expect("failed to not edit"); + assert_ne!(db, db_after); + + assert_eq!(1, db_after.tasks_and_metadatas.len()); + assert_eq!(1, db_after.tasks_and_metadatas[0].tasks.len()); + assert_eq!(0, db_after.tasks_and_metadatas[0].due().tasks.len()); + assert_eq!(1, db_after.tasks_and_metadatas[0].not_due().tasks.len()); + } + + #[test] + fn edit_schedule_past() { + let db = new_test_db(0, 0); + let db_after = db._edit(|cwd| { + let mut task = Task::new(); + task.set("k".to_string(), "v".to_string()); + task.set("schedule".to_string(), "2001-02-03".to_string()); + + let mut tasks = Tasks::new(); + tasks.0.push(task); + + let temp = TasksAndMetadata::new_with( + format!("{}/only", cwd), + TS::now(), + tasks, + ); + temp.save(false).expect("failed to save"); + }).expect("failed to not edit"); + assert_ne!(db, db_after); + + assert_eq!(1, db_after.tasks_and_metadatas.len()); + assert_eq!(1, db_after.tasks_and_metadatas[0].tasks.len()); + assert_eq!(1, db_after.tasks_and_metadatas[0].due().tasks.len()); + assert_eq!(0, db_after.tasks_and_metadatas[0].not_due().tasks.len()); + } + + + #[test] + fn edit_append_second_file() { + use std::io::Seek; + + let db = new_test_db(2, 1); + let db_after = db._edit(|cwd| { + let files_in_dir = std::fs::read_dir(cwd).expect("failed to ls cwd").collect::>(); + assert_eq!(2, files_in_dir.len()); + let mut f = std::fs::OpenOptions::new() + .write(true) + .append(true) + .open(files_in_dir[1].as_ref().expect("failed to get file in dir").path()) + .expect("failed to open file for append"); + f.seek(std::io::SeekFrom::End(0)).expect("failed to seek to end"); + f.write_all(b"\n- f").expect("failed to append the file in cwd"); + }).expect("failed to not edit"); + assert_ne!(db, db_after); + + assert_eq!(2, db_after.tasks_and_metadatas.len()); + assert_eq!(1, db_after.tasks_and_metadatas[0].tasks.len()); + assert_eq!(2, db_after.tasks_and_metadatas[1].tasks.len()); + assert_eq!(db.tasks_and_metadatas[1].tasks.0[0], db_after.tasks_and_metadatas[1].tasks.0[0]); + assert_eq!(Some("f".to_string()), db_after.tasks_and_metadatas[1].tasks.0[1].get("is".to_string())); + } + + #[test] + fn edit_append_current_file() { + use std::io::Seek; + + let db = new_test_db(1, 1); + let db_after = db._edit(|cwd| { + let files_in_dir = std::fs::read_dir(cwd).expect("failed to ls cwd").collect::>(); + assert_eq!(1, files_in_dir.len()); + let mut f = std::fs::OpenOptions::new() + .write(true) + .append(true) + .open(files_in_dir[0].as_ref().expect("failed to get file in dir").path()) + .expect("failed to open file for append"); + f.seek(std::io::SeekFrom::End(0)).expect("failed to seek to end"); + f.write_all(b"\n- f").expect("failed to append the file in cwd"); + }).expect("failed to not edit"); + assert_ne!(db, db_after); + + assert_eq!(1, db_after.tasks_and_metadatas.len()); + assert_eq!(2, db_after.tasks_and_metadatas[0].tasks.len()); + assert_eq!(db.tasks_and_metadatas[0].tasks.0[0], db_after.tasks_and_metadatas[0].tasks.0[0]); + assert_eq!(Some("f".to_string()), db_after.tasks_and_metadatas[0].tasks.0[1].get("is".to_string())); + } + + #[test] + fn edit_insert_new_second_file() { + let db = new_test_db(1, 1); + let db_after = db._edit(|cwd| { + let mut f = std::fs::File::create(format!("{}/new_second_file", cwd)).expect("failed to open a file in cwd"); + f.write_all(b"f").expect("failed to write a file in cwd"); + }).expect("failed to not edit"); + assert_ne!(db, db_after); + + assert_eq!(2, db_after.tasks_and_metadatas.len()); + assert_eq!(1, db_after.tasks_and_metadatas[1].tasks.len()); + assert_eq!(1, db_after.tasks_and_metadatas[0].tasks.len()); + } + + #[test] + fn edit_insert_empty() { + let db = new_test_db(0, 0); + let db_after = db._edit(|cwd| { + assert_eq!(0, std::fs::read_dir(cwd).expect("failed to ls cwd").collect::>().len()); + assert_ne!("", cwd); + + let mut f = std::fs::File::create(format!("{}/f", cwd)).expect("failed to open a file in cwd"); + f.write_all(b"f").expect("failed to write a file in cwd"); + + let mut g = std::fs::File::create(format!("{}/g", cwd)).expect("failed to open a file in cwd"); + g.write_all(b"- g: h\n- j: k").expect("failed to write a file in cwd"); + }).expect("failed to not edit"); + assert_ne!(db, db_after); + + assert_eq!(2, db_after.tasks_and_metadatas.len()); + assert_eq!(1, db_after.tasks_and_metadatas[1].tasks.len()); + assert_eq!(2, db_after.tasks_and_metadatas[0].tasks.len()); + } + + #[test] + fn edit_noop_empty() { + let db = new_test_db(0, 0); + let db_after = db._edit(|_| {}).expect("failed to not edit"); + assert_eq!(0, db.tasks_and_metadatas.len()); + assert_eq!(db, db_after); + } + + fn new_test_db(files: i32, tasks_per_file: i32) -> DB { + let mut db = DB::new_empty("".to_string()); + + for i in 0..files { + let mut tasks = Tasks::new(); + for j in 0..tasks_per_file { + tasks.0.push(Task::from_string(format!("{{\"hello\": \"world[{}]\"}}", j))); + } + db.tasks_and_metadatas.push(TasksAndMetadata::new_with( + format!("{}.yaml", i), + TS::now(), + tasks, + )); + } + db + } + + #[test] + fn read_dir_files() { + let db = DB::new("./src/testdata/taskss.d/files.d".to_string()).expect("failed to construct from dir of files"); + assert_eq!(2, db.tasks_and_metadatas.len()); + assert_eq!(2, db.due().len()); + assert_eq!(2, db.incomplete().len()); + } + + #[test] + fn read_dir_file() { + let db = DB::new("./src/testdata/taskss.d/file.d".to_string()).expect("failed to construct from dir of a single file"); + assert_eq!(1, db.tasks_and_metadatas.len()); + assert_eq!(1, db.due().len()); + assert_eq!(1, db.incomplete().len()); + } + + #[test] + fn read_single_file() { + let db = DB::new("./src/testdata/taskss.d/single_file.yaml".to_string()).expect("failed to construct from single file"); + assert_eq!(1, db.tasks_and_metadatas.len()); + assert_eq!(1, db.due().len()); + assert_eq!(2, db.incomplete().len()); + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct TasksAndMetadata { + tasks: Tasks, + file: String, + version: TS, +} + +impl TasksAndMetadata { + pub fn new_with(file: String, version: TS, tasks: Tasks) -> TasksAndMetadata { + TasksAndMetadata{ + file: file, + version: version, + tasks: tasks, + } + } + + pub fn new(file: String) -> Result { + let version = file_version(file.clone())?; + match Tasks::from_file(file.clone()) { + Ok(tasks) => Ok(TasksAndMetadata{ + tasks: tasks, + file: file, + version: version, + }), + Err(msg) => Err(msg), + } + } + + pub fn due(&self) -> TasksAndMetadata { + let mut cloned = self.clone(); + cloned.tasks = cloned.tasks.due(); + cloned + } + + pub fn not_due(&self) -> TasksAndMetadata { + let mut cloned = self.clone(); + cloned.tasks = cloned.tasks.not_due(); + cloned + } + + pub fn save(&self, dry_run: bool) -> Result<(), String> { + let version = match file_version(self.file.clone()){ + Ok(v) => v, + Err(_) => self.version.clone(), + }; + let mut file = self.file.clone(); + if version != self.version { + file = format!("{}.{}", &self.file, TS::now().to_string()); + eprintln!("saving conflicting {} as {}", &self.file, &file); + } + + match dry_run { + true => { + match &file == &self.file { + true => eprintln!("# {}", &file), + false => eprintln!("# {} <- {}", &file, &self.file), + }; + eprintln!("{}", self.tasks.to_string()); + Ok(()) + }, + false => self.save_as(file), + } + } + + pub fn save_due_as(&self, file: String) -> Result<(), String> { + self.due().save_as(file) + } + + pub fn save_as(&self, file: String) -> Result<(), String> { + match std::fs::File::create(&file) { + Ok(mut f) => { + match f.write_all(self.tasks.to_string().as_bytes()) { + Ok(_) => Ok(()), + Err(msg) => Err(format!("failed to write {}: {}", file, msg)), + } + }, + Err(msg) => Err(format!("failed to create {}: {}", file, msg)), + } + } + + //fn debug(&self) -> String { + // format!( + // "# {}\n{}", + // self.file, + // self.tasks.to_string(), + // ) + //} +} + +fn file_version(file: String) -> Result { + match std::fs::metadata(file.clone()) { + Ok(m) => Ok(TS::from_system_time(m.modified().unwrap())), + Err(msg) => Err(format!("couldnt get version from {}: {}", file, msg)), + } +} + +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq)] +pub struct Tasks(Vec); + +impl Tasks { + pub fn new() -> Tasks { + Tasks(vec![]) + } + + pub fn from_file(path: String) -> Result { + let r = match std::fs::File::open(path.clone()) { + Ok(f) => Ok(f), + Err(msg) => Err(format!("could not open {}: {}", path, msg)), + }?; + match Tasks::from_reader(r) { + Ok(tasks) => Ok(tasks), + Err(msg) => Err(format!("failed to load from {}: {}", path, msg)), + } + } + + pub fn from_reader(r: impl std::io::Read) -> Result { + let result = Tasks::_from_reader(r)?; + if !result.is_legacy() { + return Ok(result); + } + let mut v2 = Tasks::new(); + for k in vec!["todo", "scheduled", "done"] { + v2.0.extend( + result.0[0] + .get_value(k.to_string()) + .or(Some(serde_yaml::Value::Null)) + .unwrap() + .as_sequence() + .or(Some(&vec![])) + .unwrap() + .iter() + .map(|v| { + Task::from_value(match k { + "done" => { + let mut t = Task::new(); + t.set_value( + "_done".to_string(), + v.clone(), + ); + serde_yaml::Value::from(t.0) + }, + _ => v.clone(), + }) + }) + ); + } + Ok(v2) + } + + fn _from_reader(mut r: impl std::io::Read) -> Result { + let mut buff = String::new(); + match r.read_to_string(&mut buff) { + Err(msg) => { + return Err(format!("failed to read body: {}", msg)); + }, + _ => {} + }; + + let mut result = Tasks::new(); + match serde_yaml::from_str::>(&buff) { + Ok(v) => { + result.0.extend(v.iter().map(|x| { + Task::from_value(x.clone()) + })); + Ok(result) + }, + Err(msg) => match Task::from_str(buff) { + Ok(t) => { + result.0.push(t); + Ok(result) + }, + Err(_) => Err(format!("failed to parse yaml: {}", msg)), + }, + } + } + + pub fn to_string(&self) -> String { + let mut buffer = Vec::new(); + let mut serializer = serde_yaml::Serializer::new(&mut buffer); + _ = serde_yaml::Value::Sequence( + self.0.iter() + .map(|x| { + let mut x = x.clone(); + if x.is_due() { + x.unset("ts".to_string()); + } + x + }) + .map(|x| { + let is = x.get_value("is".to_string()); + if x.0.len() == 1 && is.is_some() { + return is.unwrap(); + } + serde_yaml::Value::from(x.0.clone()) + }) + .map(|x| serde_yaml::Value::from(x.clone())) + .collect() + ).serialize(&mut serializer) + .expect("failed to serialize"); + String::from_utf8(buffer).expect("illegal utf8 characters found") + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn incomplete(&self) -> Tasks { + Tasks(self.0.iter() + .filter(|x| !x.is_done()) + .map(|x| x.clone()) + .collect() + ) + } + + pub fn not_due(&self) -> Tasks { + Tasks(self.0.iter() + .filter(|x| !x.is_due()) + .map(|x| x.clone()) + .collect() + ) + } + + pub fn due(&self) -> Tasks { + Tasks(self.0.iter() + .filter(|x| x.is_due()) + .map(|x| x.clone()) + .collect() + ) + } + + fn is_legacy(&self) -> bool { + self.len() == 1 + && self.0[0].get_value("done".to_string()).is_some() + && self.0[0].get_value("scheduled".to_string()).is_some() + && self.0[0].get_value("todo".to_string()).is_some() + } +} + +#[cfg(test)] +mod test_tasks { + use super::*; + + #[test] + fn due() { + let tasks = Tasks::from_file("./src/testdata/tasks_due_scheduled_done.yaml".to_string()).expect("failed to open file"); + eprintln!("{:?}", tasks); + assert_eq!(2, tasks.due().len()); + } + + #[test] + fn from_reader_legacy() { + let tasks = Tasks::from_reader( + std::fs::File::open("./src/testdata/legacy.yaml").expect("failed to open file") + ).expect("failed to read file"); + assert_eq!(8, tasks.len()); + assert_eq!(5, tasks.due().len()); + assert_eq!("a".to_string(), tasks.due().0[0].get("is".to_string()).expect("missing 0th is")); + assert_eq!("b".to_string(), tasks.due().0[1].get("todo".to_string()).expect("missing 1st todo")); + assert_eq!("c".to_string(), tasks.due().0[2].get("is".to_string()).expect("missing 2nd is")); + assert_eq!("d".to_string(), tasks.due().0[3].get("todo".to_string()).expect("missing 3rd todo")); + assert_eq!("e".to_string(), tasks.due().0[4].get("todo".to_string()).expect("missing 4th todo")); + } + + #[test] + fn from_reader() { + let tasks = Tasks::from_reader( + std::fs::File::open("./src/testdata/mvp.yaml").expect("failed to open file") + ).expect("failed to read file"); + assert_eq!(2, tasks.0.len()); + assert_eq!(1, tasks.0[0].0.len()); + assert!(tasks.0[0].get("is".to_string()).is_some()); + assert_eq!("x".to_string(), tasks.0[0].get("is".to_string()).unwrap()); + assert_eq!(1, tasks.0[1].0.len()); + assert_eq!("y and z".to_string(), tasks.0[1].get("is".to_string()).unwrap()); + } +} + + +#[derive(Debug, serde::Serialize, serde::Deserialize, Clone, PartialEq)] +pub struct Task(serde_yaml::Mapping); + +impl Task { + pub fn new() -> Task { + Task(serde_yaml::Mapping::new()) + } + + pub fn from_string(s: String) -> Task { + let mut t = Task::new(); + t.set("is".to_string(), s); + t + } + + pub fn from_string_schedule(s: String, schedule: String) -> Task { + let mut t = Task::new(); + t.set("is".to_string(), s); + t.set("schedule".to_string(), schedule); + t.set("ts".to_string(), TS::now().to_string()); + t + } + + pub fn from_reader(mut r: impl std::io::Read) -> Result { + let mut buff = String::new(); + match r.read_to_string(&mut buff) { + Err(msg) => Err(format!("failed to read body: {}", msg)), + _ => Task::from_str(buff), + } + } + + pub fn from_str(s: String) -> Result { + match serde_yaml::from_str::(&s) { + Ok(v) => Ok(Task::from_value(v)), + Err(msg) => Err(format!("failed to read value: {}", msg)), + } + } + + pub fn from_value(v: serde_yaml::Value) -> Task { + let mut result = Task::new(); + match v.as_mapping() { + Some(m) => { result.0 = m.clone(); }, + None => { result.set_value("is".to_string(), v); }, + }; + result + } + + pub fn is_done(&self) -> bool { + self.get_value("_done".to_string()).is_some() + } + + pub fn is_due(&self) -> bool { + self.is_due_at(TS::now()) && !self.is_done() + } + + fn is_due_at(&self, now: TS) -> bool { + match self.when() { + Some(when) => { + now.unix() >= when.next(self.ts()).unix() + }, + None => true, + } + } + + fn when(&self) -> Option { + match self.get("schedule".to_string()) { + Some(v) => match When::new(v) { + Ok(when) => Some(when), + Err(msg) => { + eprintln!("Task.when(): {}", msg); + return None; + }, + }, + None => None, + } + } + + fn ts(&self) -> TS { + match self.get("ts".to_string()) { + Some(v) => match TS::new(v) { + Ok(ts) => ts, + Err(_) => TS::from_unix(0), + }, + None => TS::from_unix(0), + } + } + + fn get(&self, k: String) -> Option { + match self.get_value(k) { + None => None, + Some(v) => match v.as_str() { + Some(s) => Some(s.to_string()), + None => None, + }, + } + } + + fn get_value(&self, k: String) -> Option { + match self.0.get(k) { + Some(v) => Some(v.clone()), + None => None, + } + } + + fn unset(&mut self, k: String) { + self.0.remove(serde_yaml::Value::String(k)); + } + + #[allow(dead_code)] // used in test + fn set(&mut self, k: String, v: String) { + self.0.insert( + serde_yaml::Value::String(k), + serde_yaml::Value::String(v), + ); + } + + fn set_value(&mut self, k: String, v: serde_yaml::Value) { + self.0.insert( + serde_yaml::Value::String(k), + v + ); + } +} + +#[cfg(test)] +mod test_task { + use super::*; + + #[test] + fn from_str() { + assert!(Task::from_str("{ invalid".to_string()).is_err()); + assert!(Task::from_str("1".to_string()).is_ok()); + assert!(Task::from_str("'1'".to_string()).is_ok()); + assert!(Task::from_str("null".to_string()).is_ok()); + assert!(Task::from_str("true".to_string()).is_ok()); + assert!(Task::from_str("[]".to_string()).is_ok()); + assert!(Task::from_str("{}".to_string()).is_ok()); + } + + #[test] + fn from_reader() { + let task = Task::from_reader( + std::fs::File::open("./src/testdata/mvp.uuid-123-456-xyz.yaml").expect("failed to open file") + ).expect("failed to read 123..."); + assert_eq!(1, task.0.len()); + assert!(task.get("is".to_string()).is_some()); + assert_eq!("plaintext".to_string(), task.get("is".to_string()).unwrap()); + + let task = Task::from_reader( + std::fs::File::open("./src/testdata/mvp.uuid-789-012-abc.yaml").expect("failed to open file") + ).expect("failed to read 789..."); + assert_eq!(3, task.0.len()); + assert!(task.get("is".to_string()).is_none()); + assert_eq!("todo here".to_string(), task.get("todo".to_string()).unwrap()); + assert_eq!("* * * * *".to_string(), task.get("schedule".to_string()).unwrap()); + assert_eq!("hello world\n".to_string(), task.get("details".to_string()).unwrap()); + assert!(task.is_due()); + } + + #[test] + fn is_done() { + let mut t = Task::new(); + t.set("_done".to_string(), "anything".to_string()); + eprintln!("{:?}", t.0); + assert!(t.is_done()); + + t.set_value("_done".to_string(), serde_yaml::Value::Null); + eprintln!("{:?}", t.0); + assert!(t.is_done()); + } + + #[test] + fn crud() { + let mut t = Task::new(); + t.set("k".to_string(), "v".to_string()); + assert_eq!( + t.get("k".to_string()), + Some("v".to_string()), + ); + } + + #[test] + fn is_due_duration() { + let mut t = Task::new(); + assert!(t.is_due()); + let then = TS::new("2000-01-02T15:00Z".to_string()).unwrap(); + let now = TS::new("2000-01-02T16:00Z".to_string()).unwrap(); + t.set("ts".to_string(), then.to_string()); + t.set("schedule".to_string(), "1h".to_string()); + assert!(!t.is_due_at(TS::from_unix(now.unix()-1))); + assert!(t.is_due_at(TS::from_unix(now.unix()))); + assert!(t.is_due_at(TS::from_unix(now.unix()+1))); + } + + #[test] + fn is_due_schedule() { + let mut t = Task::new(); + assert!(t.is_due()); + let then = TS::new("2000-01-02T15:00Z".to_string()).unwrap(); + let now = TS::new("2000-01-02T16:00Z".to_string()).unwrap(); + t.set("ts".to_string(), then.to_string()); + t.set("schedule".to_string(), "2000-01-02T16:00Z".to_string()); + assert!(!t.is_due_at(TS::from_unix(now.unix()-1))); + assert!(t.is_due_at(TS::from_unix(now.unix()))); + assert!(t.is_due_at(TS::from_unix(now.unix()+1))); + } + + #[test] + fn is_due_cron() { + let mut t = Task::new(); + assert!(t.is_due()); + let then = TS::new("2000-01-02T15:00Z".to_string()).unwrap(); + let now = TS::new("2000-01-02T16:00Z".to_string()).unwrap(); + t.set("ts".to_string(), then.to_string()); + t.set("schedule".to_string(), "0 16 * * *".to_string()); + assert!(!t.is_due_at(TS::from_unix(now.unix()-1))); + assert!(t.is_due_at(TS::from_unix(now.unix()))); + assert!(t.is_due_at(TS::from_unix(now.unix()+1))); + } } #[derive(Debug)] -struct Task(serde_yaml::Mapping); - //pub todo: String, - //pub when: Option, - //pub detail: Option, - //pub sub: Vec, - -impl Task { - fn is_due(&self) -> bool { - assert!(false); - false - } -} - -/* -#[derive(Debug, Clone)] struct When(String); impl When { - fn is_due(&self) -> bool { - assert!(false); - false + fn new(src: String) -> Result { + match Self::new_duration(src.clone()) { + Some(x) => { return Ok(x); }, + None => {}, + }; + match Self::new_ts(src.clone()) { + Some(x) => { return Ok(x); }, + None => {}, + }; + match Self::new_cron(src.clone()) { + Some(x) => { return Ok(x); }, + None => {}, + }; + Err(format!("cannot parse when: {}", src)) + } + + fn new_duration(src: String) -> Option { + match Duration::new(src.clone()) { + Ok(_) => Some(When{0: src.clone()}), + _ => None, + } + } + + fn new_ts(src: String) -> Option { + match TS::new(src.clone()) { + Ok(_) => Some(When{0: src.clone()}), + _ => None, + } + } + + fn new_cron(src: String) -> Option { + match Cron::new(src.clone()) { + Ok(_) => Some(When{0: src.clone()}), + _ => None, + } + } + + fn next(&self, now: TS) -> TS { + match Duration::new(self.0.clone()) { + Ok(duration) => { + return TS::from_unix( + now.unix() + duration.0 + ); + }, + _ => {}, + }; + match TS::new(self.0.clone()) { + Ok(ts) => { + return ts; + }, + _ => {}, + }; + match Cron::new(self.0.clone()) { + Ok(x) => { + return x.next(now); + }, + _ => {}, + }; + assert!(false, "invalid when cooked"); + now + } +} + +#[cfg(test)] +mod test_when { + use super::*; + + #[test] + fn parse() { + match When::new("1d2h3m".to_string()) { + Ok(when) => { + assert_eq!( + 1714521600 + 60*3 + 60*60*2 + 60*60*24*1, + when.next( + TS::new("2024-05-01T00".to_string()) + .unwrap() + ).unix() + ); + }, + Err(err) => assert!(false, "failed to parse when: {}", err), + }; + match When::new("2024-05-01T00:00Z".to_string()) { + Ok(when) => { + assert_eq!( + 1714521600 , + when.next( + TS::new("2024-05-01T00".to_string()) + .unwrap() + ).unix() + ); + }, + Err(err) => assert!(false, "failed to parse when: {}", err), + }; + match When::new("0 1 * * *".to_string()) { + Ok(when) => { + assert_eq!( + 1714521600 + 60*60, + when.next(TS::from_unix(1714521600)).unix() + ); + }, + Err(err) => assert!(false, "failed to parse when: {}", err), + }; + } +} + +#[derive(Debug)] +struct Cron(String); + +impl Cron { + fn new(src: String) -> Result { + match croner::Cron::new(src.as_str()).parse() { + Ok(_) => Ok(Cron{0: src}), + Err(msg) => Err(format!("bad cron: {}", msg)), + } + } + + fn next(&self, now: TS) -> TS { + match croner::Cron::new(self.0.as_str()).parse() { + Ok(c) => match c.find_next_occurrence( + &DateTime::from_timestamp(now.unix() as i64, 0).unwrap(), + true, + ) { + Ok(dt) => { + return TS::from_unix(dt.timestamp() as u64); + }, + Err(_) => TS::from_unix(0), + }, + _ => TS::from_unix(0), + } + } +} + +#[cfg(test)] +mod test_cron { + use super::*; + + #[test] + fn parse() { + match Cron::new("* * * * *".to_string()) { + Ok(_) => {} + Err(err) => assert!(false, "failed to parse cron: {}", err), + }; + match Cron::new("1 * * * *".to_string()) { + Ok(c) => assert_eq!(1714525200+60, c.next(TS::from_unix(1714525200)).unix()), + Err(err) => assert!(false, "failed to parse cron: {}", err), + }; + } +} + + +#[derive(Debug)] +struct Duration(u64); + +impl Duration { + fn new(src: String) -> Result { + if src.len() == 0 { + return Err("no empty duration".to_string()); + } + let duration = Regex::new(r"^([0-9]+d)?([0-9]+h)?([0-9]+m)?$").unwrap(); + match duration.is_match(&src) { + false => { return Err("ill formatted duration".to_string()); }, + _ => {}, + }; + let caps = duration.captures(&src).unwrap(); + let mut sum: u64 = 0; + match caps.get(1) { + Some(d) => { sum += 60 * 60 * 24 * Self::to_n(d.as_str()); }, + _ => {}, + }; + match caps.get(2) { + Some(h) => { sum += 60 * 60 * Self::to_n(h.as_str()); }, + _ => {}, + }; + match caps.get(3) { + Some(m) => { sum += 60 * Self::to_n(m.as_str()); }, + _ => {}, + }; + Ok(Duration{0: sum}) + } + + fn to_n(s: &str) -> u64 { + let s = s.to_string(); + let (s, _) = s.split_at(s.len()-1); + match s.parse::() { + Ok(n) => n, + _ => 0, + } + } +} + +#[cfg(test)] +mod test_duration { + use super::*; + + #[test] + fn parse() { + match Duration::new("1d2h3m".to_string()) { + Ok(d) => assert_eq!(60*60*24 + 60*60*2 + 60*3, d.0), + Err(err) => assert!(false, "failed to parse duration: {}", err), + }; + match Duration::new("1d".to_string()) { + Ok(d) => assert_eq!(60*60*24, d.0), + Err(err) => assert!(false, "failed to parse duration: {}", err), + }; + match Duration::new("2h".to_string()) { + Ok(d) => assert_eq!(60*60*2, d.0), + Err(err) => assert!(false, "failed to parse duration: {}", err), + }; + match Duration::new("3m".to_string()) { + Ok(d) => assert_eq!(60*3, d.0), + Err(err) => assert!(false, "failed to parse duration: {}", err), + }; + } +} + +#[derive(Debug, PartialEq, Clone)] +pub struct TS(u64); + +impl TS { + fn now() -> TS { + Self::from_unix(Local::now().timestamp() as u64) + } + + fn from_system_time(st: std::time::SystemTime) -> TS { + TS::from_unix(st.duration_since(std::time::SystemTime::UNIX_EPOCH).unwrap().as_secs()) + } + + fn from_unix(src: u64) -> TS { + TS{0: src} + } + + fn new(src: String) -> Result { + // %Y-%m-%dT%H:%MZ + match DateTime::parse_from_str( + &format!("{} +0000", src), + "%Y-%m-%dT%H:%MZ %z", + ) { + Ok(v) => { return Ok(TS(v.timestamp() as u64)) }, + _ => {}, + }; + + // %Y-%m-%dT%H + match NaiveDateTime::parse_from_str( + &format!("{}:00", src), + "%Y-%m-%dT%H:%M", + ) { + Ok(v) => { return Ok(TS(v.and_utc().timestamp() as u64)) }, + _ => {}, + }; + + // %Y-%m-%d + match NaiveDateTime::parse_from_str( + &format!("{}T00:00", src), + "%Y-%m-%dT%H:%M", + ) { + Ok(v) => { return Ok(TS(v.and_utc().timestamp() as u64)) }, + _ => {}, + }; + + // Sun Dec 3 23:29:27 EST 2023 + match DateTime::parse_from_str( + &format!("{}", src) + .replace("PDT", "-0800") + .replace("MDT", "-0700") + .replace("EDT", "-0400") + .replace("PST", "-0700") + .replace("MST", "-0600") + .replace("EST", "-0500") + .replace(" 1 ", " 01 ") + .replace(" 2 ", " 02 ") + .replace(" 3 ", " 03 ") + .replace(" 4 ", " 04 ") + .replace(" 5 ", " 05 ") + .replace(" 6 ", " 06 ") + .replace(" 7 ", " 07 ") + .replace(" 8 ", " 08 ") + .replace(" 9 ", " 09 ") + .replace(" ", " "), + "%a %b %d %H:%M:%S %z %Y", + ) { + Ok(v) => Ok(TS(v.timestamp() as u64)), + Err(msg) => Err(format!("failed to parse legacy golang time: {}", msg)), + } + } + + fn unix(&self) -> u64 { + self.0 + } + + #[allow(dead_code)] // used in test + fn to_string(&self) -> String { + DateTime::from_timestamp(self.0 as i64, 0) + .unwrap() + .format("%Y-%m-%dT%H:%MZ") + .to_string() + } +} + +#[cfg(test)] +mod test_ts { + use super::*; + + #[test] + fn parse() { + match TS::new("Tue Nov 7 07:33:11 PST 2023".to_string()) { + Ok(ts) => { + assert_eq!("2023-11-07T14:33Z", ts.to_string()); + }, + Err(err) => assert!(false, "failed to parse ts: {}", err), + }; + match TS::new("Sun Jun 4 05:24:32 PDT 2023".to_string()) { + Ok(ts) => { + assert_eq!("2023-06-04T13:24Z", ts.to_string()); + }, + Err(err) => assert!(false, "failed to parse ts: {}", err), + }; + match TS::new("Sat Nov 4 08:36:01 MDT 2023".to_string()) { + Ok(ts) => { + assert_eq!("2023-11-04T15:36Z", ts.to_string()); + }, + Err(err) => assert!(false, "failed to parse ts: {}", err), + }; + match TS::new("Sun Nov 5 22:27:17 MST 2023".to_string()) { + Ok(ts) => { + assert_eq!("2023-11-06T04:27Z", ts.to_string()); + }, + Err(err) => assert!(false, "failed to parse ts: {}", err), + }; + match TS::new("Thu Apr 18 16:17:41 EDT 2024".to_string()) { + Ok(ts) => { + assert_eq!("2024-04-18T20:17Z", ts.to_string()); + }, + Err(err) => assert!(false, "failed to parse ts: {}", err), + }; + match TS::new("Sun Dec 3 23:29:27 EST 2023".to_string()) { + Ok(ts) => { + assert_eq!("2023-12-04T04:29Z", ts.to_string()); + }, + Err(err) => assert!(false, "failed to parse ts: {}", err), + }; + match TS::new("2024-05-01T00:00Z".to_string()) { + Ok(ts) => { + assert_eq!(1714521600, ts.unix()); + assert_eq!("2024-05-01T00:00Z", ts.to_string()); + }, + Err(err) => assert!(false, "failed to parse ts: {}", err), + }; + match TS::new("2024-05-01T00".to_string()) { + Ok(ts) => { + assert_eq!(1714521600, ts.unix()); + assert_eq!("2024-05-01T00:00Z", ts.to_string()); + }, + Err(err) => assert!(false, "failed to parse ts: {}", err), + }; + match TS::new("2024-05-01".to_string()) { + Ok(ts) => { + assert_eq!(1714521600, ts.unix()); + assert_eq!("2024-05-01T00:00Z", ts.to_string()); + }, + Err(err) => assert!(false, "failed to parse ts: {}", err), + }; } } -*/ diff --git a/pttodoer/src/testdata/.mvp.yaml.done b/pttodoer/src/testdata/.mvp.yaml.done index f47c99f..a1fe40a 100644 --- a/pttodoer/src/testdata/.mvp.yaml.done +++ b/pttodoer/src/testdata/.mvp.yaml.done @@ -1,4 +1,4 @@ - todo: a b and c - schedule: * * * * * + schedule: '* * * * *' ts: 123 - d e f diff --git a/pttodoer/src/testdata/legacy.yaml b/pttodoer/src/testdata/legacy.yaml new file mode 100644 index 0000000..cd2c153 --- /dev/null +++ b/pttodoer/src/testdata/legacy.yaml @@ -0,0 +1,17 @@ +todo: +- a +- todo: b + schedule: 2000-01-01 +scheduled: +- c +- todo: d + schedule: 2000-02-01 +- todo: e + ts: Sun Dec 3 23:29:27 EST 2023 + schedule: '0 0 1 1 *' +- todo: f + schedule: 2099-02-02 +done: +- g +- todo: h + ts: Sun Dec 3 23:29:27 EST 2023 diff --git a/pttodoer/src/testdata/mvp.uuid-789-012-abc.yaml b/pttodoer/src/testdata/mvp.uuid-789-012-abc.yaml index bd3f4fd..37ef90c 100644 --- a/pttodoer/src/testdata/mvp.uuid-789-012-abc.yaml +++ b/pttodoer/src/testdata/mvp.uuid-789-012-abc.yaml @@ -1,4 +1,4 @@ todo: todo here -schedule: * * * * * +schedule: '* * * * *' details: | hello world diff --git a/pttodoer/src/testdata/tasks_due_scheduled_done.yaml b/pttodoer/src/testdata/tasks_due_scheduled_done.yaml new file mode 100644 index 0000000..3654126 --- /dev/null +++ b/pttodoer/src/testdata/tasks_due_scheduled_done.yaml @@ -0,0 +1,10 @@ +- 1do: due + schedule: 2006-04-05 +- 2do: scheduled + schedule: 2099-04-05 +- 3do: repeating scheduled + schedule: 0 0 1 1 * + ts: 2001-02-03T04:05:06Z +- _done: + do: done + schedule: 2006-04-05 diff --git a/pttodoer/src/testdata/taskss.d/file.d/file.yaml b/pttodoer/src/testdata/taskss.d/file.d/file.yaml new file mode 100644 index 0000000..d4c7c05 --- /dev/null +++ b/pttodoer/src/testdata/taskss.d/file.d/file.yaml @@ -0,0 +1 @@ +- file diff --git a/pttodoer/src/testdata/taskss.d/files.d/a.yaml b/pttodoer/src/testdata/taskss.d/files.d/a.yaml new file mode 100644 index 0000000..46ae6f5 --- /dev/null +++ b/pttodoer/src/testdata/taskss.d/files.d/a.yaml @@ -0,0 +1 @@ +- a diff --git a/pttodoer/src/testdata/taskss.d/files.d/b.yaml b/pttodoer/src/testdata/taskss.d/files.d/b.yaml new file mode 100644 index 0000000..7880ae6 --- /dev/null +++ b/pttodoer/src/testdata/taskss.d/files.d/b.yaml @@ -0,0 +1 @@ +- b diff --git a/pttodoer/src/testdata/taskss.d/single_file.yaml b/pttodoer/src/testdata/taskss.d/single_file.yaml new file mode 100644 index 0000000..47b487a --- /dev/null +++ b/pttodoer/src/testdata/taskss.d/single_file.yaml @@ -0,0 +1,4 @@ +- todo +- do: scheduled + schedule: 2099-01-01 +- _done: any