├── .github └── workflows │ └── release.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── default_config.toml ├── default_config_development.toml ├── src ├── main.rs └── settings.rs └── test ├── run_tests.sh ├── target.txt ├── test_config.toml └── test_config_min_break.toml /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | 17 | - name: Create release 18 | id: create_release 19 | uses: actions/create-release@latest 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | with: 23 | tag_name: ${{ github.ref }} 24 | release_name: Release ${{ github.ref }} 25 | draft: false 26 | prerelease: false 27 | 28 | 29 | build-linux: 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - uses: actions/checkout@v2 34 | 35 | - name: Run tests 36 | run: cargo test --verbose 37 | 38 | - name: Run build 39 | run: cargo build --release && strip --strip-all ./target/release/tt && mv ./target/release/tt ./target/release/tt-linux 40 | 41 | - name: Upload release 42 | id: upload-release-linux 43 | uses: alexellis/upload-assets@0.2.3 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | with: 47 | upload_url: ${{ steps.create_release.outputs.upload_url }} 48 | asset_paths: '["./target/release/tt-linux"]' 49 | 50 | 51 | build-macos: 52 | runs-on: macos-latest 53 | 54 | steps: 55 | - uses: actions/checkout@v2 56 | 57 | - name: Run tests 58 | run: cargo test --verbose 59 | 60 | - name: Run build 61 | run: cargo build --release && mv ./target/release/tt ./target/release/tt-macos 62 | 63 | - name: Upload release 64 | id: upload-release-linux 65 | uses: alexellis/upload-assets@0.2.3 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | with: 69 | upload_url: ${{ steps.create_release.outputs.upload_url }} 70 | asset_paths: '["./target/release/tt-macos"]' 71 | 72 | 73 | build-windows: 74 | runs-on: windows-2019 75 | 76 | steps: 77 | - uses: actions/checkout@v2 78 | 79 | - name: Run tests 80 | run: cargo test --verbose 81 | 82 | - name: Run build 83 | env: 84 | RUSTFLAGS: -C target-feature=+crt-static 85 | run: cargo build --release 86 | 87 | - name: Upload release 88 | id: upload-release-windows 89 | uses: alexellis/upload-assets@0.2.3 90 | env: 91 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 92 | with: 93 | upload_url: ${{ steps.create_release.outputs.upload_url }} 94 | asset_paths: '["./target/release/tt.exe"]' 95 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | backup.json 3 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | [[package]] 4 | name = "ansi_term" 5 | version = "0.11.0" 6 | source = "registry+https://github.com/rust-lang/crates.io-index" 7 | checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" 8 | dependencies = [ 9 | "winapi", 10 | ] 11 | 12 | [[package]] 13 | name = "anyhow" 14 | version = "1.0.40" 15 | source = "registry+https://github.com/rust-lang/crates.io-index" 16 | checksum = "28b2cd92db5cbd74e8e5028f7e27dd7aa3090e89e4f2a197cc7c8dfb69c7063b" 17 | 18 | [[package]] 19 | name = "arrayvec" 20 | version = "0.5.2" 21 | source = "registry+https://github.com/rust-lang/crates.io-index" 22 | checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" 23 | 24 | [[package]] 25 | name = "atty" 26 | version = "0.2.14" 27 | source = "registry+https://github.com/rust-lang/crates.io-index" 28 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 29 | dependencies = [ 30 | "hermit-abi", 31 | "libc", 32 | "winapi", 33 | ] 34 | 35 | [[package]] 36 | name = "autocfg" 37 | version = "1.0.1" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 40 | 41 | [[package]] 42 | name = "bincode" 43 | version = "1.3.3" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" 46 | dependencies = [ 47 | "serde", 48 | ] 49 | 50 | [[package]] 51 | name = "bitflags" 52 | version = "1.2.1" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 55 | 56 | [[package]] 57 | name = "cfg-if" 58 | version = "1.0.0" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 61 | 62 | [[package]] 63 | name = "chrono" 64 | version = "0.4.19" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" 67 | dependencies = [ 68 | "libc", 69 | "num-integer", 70 | "num-traits", 71 | "serde", 72 | "time", 73 | "winapi", 74 | ] 75 | 76 | [[package]] 77 | name = "clap" 78 | version = "2.33.3" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" 81 | dependencies = [ 82 | "ansi_term", 83 | "atty", 84 | "bitflags", 85 | "strsim", 86 | "textwrap", 87 | "unicode-width", 88 | "vec_map", 89 | ] 90 | 91 | [[package]] 92 | name = "config" 93 | version = "0.11.0" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "1b1b9d958c2b1368a663f05538fc1b5975adce1e19f435acceae987aceeeb369" 96 | dependencies = [ 97 | "lazy_static", 98 | "nom", 99 | "serde", 100 | "toml", 101 | ] 102 | 103 | [[package]] 104 | name = "dirs-next" 105 | version = "2.0.0" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" 108 | dependencies = [ 109 | "cfg-if", 110 | "dirs-sys-next", 111 | ] 112 | 113 | [[package]] 114 | name = "dirs-sys-next" 115 | version = "0.1.2" 116 | source = "registry+https://github.com/rust-lang/crates.io-index" 117 | checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" 118 | dependencies = [ 119 | "libc", 120 | "redox_users", 121 | "winapi", 122 | ] 123 | 124 | [[package]] 125 | name = "getrandom" 126 | version = "0.2.2" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "c9495705279e7140bf035dde1f6e750c162df8b625267cd52cc44e0b156732c8" 129 | dependencies = [ 130 | "cfg-if", 131 | "libc", 132 | "wasi", 133 | ] 134 | 135 | [[package]] 136 | name = "heck" 137 | version = "0.3.2" 138 | source = "registry+https://github.com/rust-lang/crates.io-index" 139 | checksum = "87cbf45460356b7deeb5e3415b5563308c0a9b057c85e12b06ad551f98d0a6ac" 140 | dependencies = [ 141 | "unicode-segmentation", 142 | ] 143 | 144 | [[package]] 145 | name = "hermit-abi" 146 | version = "0.1.18" 147 | source = "registry+https://github.com/rust-lang/crates.io-index" 148 | checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" 149 | dependencies = [ 150 | "libc", 151 | ] 152 | 153 | [[package]] 154 | name = "iif" 155 | version = "1.2.0" 156 | source = "registry+https://github.com/rust-lang/crates.io-index" 157 | checksum = "1ed25482c704540064380ad95d8655c0b3eff080daa959c3fc3419f701b68939" 158 | 159 | [[package]] 160 | name = "itoa" 161 | version = "0.4.7" 162 | source = "registry+https://github.com/rust-lang/crates.io-index" 163 | checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" 164 | 165 | [[package]] 166 | name = "lazy_static" 167 | version = "1.4.0" 168 | source = "registry+https://github.com/rust-lang/crates.io-index" 169 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 170 | 171 | [[package]] 172 | name = "lexical-core" 173 | version = "0.7.5" 174 | source = "registry+https://github.com/rust-lang/crates.io-index" 175 | checksum = "21f866863575d0e1d654fbeeabdc927292fdf862873dc3c96c6f753357e13374" 176 | dependencies = [ 177 | "arrayvec", 178 | "bitflags", 179 | "cfg-if", 180 | "ryu", 181 | "static_assertions", 182 | ] 183 | 184 | [[package]] 185 | name = "libc" 186 | version = "0.2.92" 187 | source = "registry+https://github.com/rust-lang/crates.io-index" 188 | checksum = "56d855069fafbb9b344c0f962150cd2c1187975cb1c22c1522c240d8c4986714" 189 | 190 | [[package]] 191 | name = "memchr" 192 | version = "2.3.4" 193 | source = "registry+https://github.com/rust-lang/crates.io-index" 194 | checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" 195 | 196 | [[package]] 197 | name = "nom" 198 | version = "5.1.2" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af" 201 | dependencies = [ 202 | "lexical-core", 203 | "memchr", 204 | "version_check", 205 | ] 206 | 207 | [[package]] 208 | name = "num-integer" 209 | version = "0.1.44" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" 212 | dependencies = [ 213 | "autocfg", 214 | "num-traits", 215 | ] 216 | 217 | [[package]] 218 | name = "num-traits" 219 | version = "0.2.14" 220 | source = "registry+https://github.com/rust-lang/crates.io-index" 221 | checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" 222 | dependencies = [ 223 | "autocfg", 224 | ] 225 | 226 | [[package]] 227 | name = "proc-macro-error" 228 | version = "1.0.4" 229 | source = "registry+https://github.com/rust-lang/crates.io-index" 230 | checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" 231 | dependencies = [ 232 | "proc-macro-error-attr", 233 | "proc-macro2", 234 | "quote", 235 | "syn", 236 | "version_check", 237 | ] 238 | 239 | [[package]] 240 | name = "proc-macro-error-attr" 241 | version = "1.0.4" 242 | source = "registry+https://github.com/rust-lang/crates.io-index" 243 | checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" 244 | dependencies = [ 245 | "proc-macro2", 246 | "quote", 247 | "version_check", 248 | ] 249 | 250 | [[package]] 251 | name = "proc-macro2" 252 | version = "1.0.26" 253 | source = "registry+https://github.com/rust-lang/crates.io-index" 254 | checksum = "a152013215dca273577e18d2bf00fa862b89b24169fb78c4c95aeb07992c9cec" 255 | dependencies = [ 256 | "unicode-xid", 257 | ] 258 | 259 | [[package]] 260 | name = "quote" 261 | version = "1.0.9" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" 264 | dependencies = [ 265 | "proc-macro2", 266 | ] 267 | 268 | [[package]] 269 | name = "redox_syscall" 270 | version = "0.2.5" 271 | source = "registry+https://github.com/rust-lang/crates.io-index" 272 | checksum = "94341e4e44e24f6b591b59e47a8a027df12e008d73fd5672dbea9cc22f4507d9" 273 | dependencies = [ 274 | "bitflags", 275 | ] 276 | 277 | [[package]] 278 | name = "redox_users" 279 | version = "0.4.0" 280 | source = "registry+https://github.com/rust-lang/crates.io-index" 281 | checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" 282 | dependencies = [ 283 | "getrandom", 284 | "redox_syscall", 285 | ] 286 | 287 | [[package]] 288 | name = "ryu" 289 | version = "1.0.5" 290 | source = "registry+https://github.com/rust-lang/crates.io-index" 291 | checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" 292 | 293 | [[package]] 294 | name = "serde" 295 | version = "1.0.125" 296 | source = "registry+https://github.com/rust-lang/crates.io-index" 297 | checksum = "558dc50e1a5a5fa7112ca2ce4effcb321b0300c0d4ccf0776a9f60cd89031171" 298 | dependencies = [ 299 | "serde_derive", 300 | ] 301 | 302 | [[package]] 303 | name = "serde_derive" 304 | version = "1.0.125" 305 | source = "registry+https://github.com/rust-lang/crates.io-index" 306 | checksum = "b093b7a2bb58203b5da3056c05b4ec1fed827dcfdb37347a8841695263b3d06d" 307 | dependencies = [ 308 | "proc-macro2", 309 | "quote", 310 | "syn", 311 | ] 312 | 313 | [[package]] 314 | name = "serde_json" 315 | version = "1.0.64" 316 | source = "registry+https://github.com/rust-lang/crates.io-index" 317 | checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" 318 | dependencies = [ 319 | "itoa", 320 | "ryu", 321 | "serde", 322 | ] 323 | 324 | [[package]] 325 | name = "shellexpand" 326 | version = "2.1.0" 327 | source = "registry+https://github.com/rust-lang/crates.io-index" 328 | checksum = "83bdb7831b2d85ddf4a7b148aa19d0587eddbe8671a436b7bd1182eaad0f2829" 329 | dependencies = [ 330 | "dirs-next", 331 | ] 332 | 333 | [[package]] 334 | name = "static_assertions" 335 | version = "1.1.0" 336 | source = "registry+https://github.com/rust-lang/crates.io-index" 337 | checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" 338 | 339 | [[package]] 340 | name = "strsim" 341 | version = "0.8.0" 342 | source = "registry+https://github.com/rust-lang/crates.io-index" 343 | checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" 344 | 345 | [[package]] 346 | name = "structopt" 347 | version = "0.3.21" 348 | source = "registry+https://github.com/rust-lang/crates.io-index" 349 | checksum = "5277acd7ee46e63e5168a80734c9f6ee81b1367a7d8772a2d765df2a3705d28c" 350 | dependencies = [ 351 | "clap", 352 | "lazy_static", 353 | "structopt-derive", 354 | ] 355 | 356 | [[package]] 357 | name = "structopt-derive" 358 | version = "0.4.14" 359 | source = "registry+https://github.com/rust-lang/crates.io-index" 360 | checksum = "5ba9cdfda491b814720b6b06e0cac513d922fc407582032e8706e9f137976f90" 361 | dependencies = [ 362 | "heck", 363 | "proc-macro-error", 364 | "proc-macro2", 365 | "quote", 366 | "syn", 367 | ] 368 | 369 | [[package]] 370 | name = "syn" 371 | version = "1.0.68" 372 | source = "registry+https://github.com/rust-lang/crates.io-index" 373 | checksum = "3ce15dd3ed8aa2f8eeac4716d6ef5ab58b6b9256db41d7e1a0224c2788e8fd87" 374 | dependencies = [ 375 | "proc-macro2", 376 | "quote", 377 | "unicode-xid", 378 | ] 379 | 380 | [[package]] 381 | name = "textwrap" 382 | version = "0.11.0" 383 | source = "registry+https://github.com/rust-lang/crates.io-index" 384 | checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" 385 | dependencies = [ 386 | "unicode-width", 387 | ] 388 | 389 | [[package]] 390 | name = "time" 391 | version = "0.1.44" 392 | source = "registry+https://github.com/rust-lang/crates.io-index" 393 | checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" 394 | dependencies = [ 395 | "libc", 396 | "wasi", 397 | "winapi", 398 | ] 399 | 400 | [[package]] 401 | name = "timetracking" 402 | version = "1.5.21-alpha.0" 403 | dependencies = [ 404 | "anyhow", 405 | "bincode", 406 | "chrono", 407 | "config", 408 | "iif", 409 | "serde", 410 | "serde_json", 411 | "shellexpand", 412 | "structopt", 413 | ] 414 | 415 | [[package]] 416 | name = "toml" 417 | version = "0.5.8" 418 | source = "registry+https://github.com/rust-lang/crates.io-index" 419 | checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" 420 | dependencies = [ 421 | "serde", 422 | ] 423 | 424 | [[package]] 425 | name = "unicode-segmentation" 426 | version = "1.7.1" 427 | source = "registry+https://github.com/rust-lang/crates.io-index" 428 | checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" 429 | 430 | [[package]] 431 | name = "unicode-width" 432 | version = "0.1.8" 433 | source = "registry+https://github.com/rust-lang/crates.io-index" 434 | checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" 435 | 436 | [[package]] 437 | name = "unicode-xid" 438 | version = "0.2.1" 439 | source = "registry+https://github.com/rust-lang/crates.io-index" 440 | checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" 441 | 442 | [[package]] 443 | name = "vec_map" 444 | version = "0.8.2" 445 | source = "registry+https://github.com/rust-lang/crates.io-index" 446 | checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" 447 | 448 | [[package]] 449 | name = "version_check" 450 | version = "0.9.3" 451 | source = "registry+https://github.com/rust-lang/crates.io-index" 452 | checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" 453 | 454 | [[package]] 455 | name = "wasi" 456 | version = "0.10.0+wasi-snapshot-preview1" 457 | source = "registry+https://github.com/rust-lang/crates.io-index" 458 | checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" 459 | 460 | [[package]] 461 | name = "winapi" 462 | version = "0.3.9" 463 | source = "registry+https://github.com/rust-lang/crates.io-index" 464 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 465 | dependencies = [ 466 | "winapi-i686-pc-windows-gnu", 467 | "winapi-x86_64-pc-windows-gnu", 468 | ] 469 | 470 | [[package]] 471 | name = "winapi-i686-pc-windows-gnu" 472 | version = "0.4.0" 473 | source = "registry+https://github.com/rust-lang/crates.io-index" 474 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 475 | 476 | [[package]] 477 | name = "winapi-x86_64-pc-windows-gnu" 478 | version = "0.4.0" 479 | source = "registry+https://github.com/rust-lang/crates.io-index" 480 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 481 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "timetracking" 3 | description = "Simple time tracker with simple data format" 4 | version = "1.5.21-alpha.0" 5 | authors = ["hardliner66 "] 6 | edition = "2018" 7 | license-file = "LICENSE" 8 | repository = "https://github.com/hardliner66/timetracking" 9 | 10 | [[bin]] 11 | name = "tt" 12 | path = "src/main.rs" 13 | 14 | [dependencies] 15 | anyhow = "1.0.40" 16 | bincode = { version = "1.3.3", optional = true } 17 | chrono = { version = "0.4.19", features = ["serde"] } 18 | config = { version = "0.11.0", default-features = false, features = ["toml"] } 19 | iif = "1.2.0" 20 | serde = { version = "1.0.125", features = ["derive"] } 21 | serde_json = "1.0.64" 22 | shellexpand = "2.1.0" 23 | structopt = "0.3.21" 24 | 25 | [features] 26 | default = ["binary"] 27 | binary = ["bincode"] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Timetracking 2 | 3 | Simple command line time tracking application I wrote to keep track of how many hours I already spent working in a week. 4 | 5 | [![Crates.io](https://img.shields.io/crates/v/timetracking)](https://crates.io/crates/timetracking) 6 | 7 | ## Install 8 | ``` 9 | cargo install timetracking 10 | ``` 11 | 12 | ## Commandline 13 | ``` 14 | USAGE: 15 | tt [OPTIONS] [SUBCOMMAND] 16 | 17 | FLAGS: 18 | -h, --help Prints help information 19 | -V, --version Prints version information 20 | 21 | OPTIONS: 22 | -c, --config-file which config file to use 23 | -d, --data-file which data file to use. [default: ~/timetracking.bin] 24 | 25 | SUBCOMMANDS: 26 | cleanup starts an interactive cleanup session 27 | continue continue time tracking with last description 28 | export export data to file 29 | help Prints this message or the help of the given subcommand(s) 30 | import import data from json file 31 | list list all entries 32 | path show path to data file 33 | show show work time for given timespan 34 | start start time tracking 35 | status show info from the latest entry. Returns the exit code 0, if the time tracking is currently active 36 | and -1 if not 37 | stop stop time tracking 38 | ``` 39 | 40 | ## Example Usage 41 | Start tracking: 42 | `tt start` 43 | 44 | Stop tracking: 45 | `tt stop` 46 | 47 | Show work time of the current day: 48 | `tt show` 49 | 50 | Show work time of the current week: 51 | `tt show week` 52 | 53 | List all entries for the current day: 54 | `tt list` 55 | 56 | Export to json: 57 | `tt export backup.json` 58 | 59 | Import from json: 60 | `tt import backup.json` 61 | 62 | ## Config 63 | 64 | `tt` supports global config (`~/.config/timetracking/config.toml`), project config (`timetracking.project.toml`) and local config (`.timetracking.toml`). 65 | 66 | The following settings are supported: 67 | ```toml 68 | # the file where to save the events 69 | data_file = "~/timetracking.bin" 70 | 71 | # if true, calling start when already running inserts a stop event and a start event. 72 | auto_insert_stop = false 73 | 74 | # if true, tt will recursively search parent dirs for project settings 75 | enable_project_settings = true 76 | 77 | # minimum amount of minutes of break time per day. 78 | # if you have less than this amount of break per day, 79 | # the calculation will automatically add the additional 80 | # break time needed to get to this number 81 | min_daily_break = 0 82 | 83 | # last day of work week as chrono::Weekday. 84 | # allowed values are: mon, tue, wed, thu, fri, sat and sun 85 | last_day_of_work_week = "fri" 86 | 87 | # set the daily time goal 88 | [time_goal.daily] 89 | # work hours to reach in a work day (0-24) 90 | hours = 8 91 | 92 | # work minutes to reach in a work day (0-59) 93 | minutes = 0 94 | 95 | # set the weekly time goal 96 | [time_goal.weekly] 97 | # work hours to reach in a work week (0-168) 98 | hours = 40 99 | 100 | # work minutes to reach in a work week (0-59) 101 | minutes = 0 102 | ``` 103 | 104 | The order in which config files are read is: 105 | - global 106 | - project 107 | - local 108 | 109 | Configs override earlier loaded configs. 110 | 111 | Project configs are special and will be search recursively upwards, starting from the current directory. So if your in /a/b/c the search order will be: 112 | - /a/b/c/timetracking.project.toml 113 | - /a/b/timetracking.project.toml 114 | - /a/timetracking.project.toml 115 | - /timetracking.project.toml 116 | 117 | Project configs can be disabled in the global config file. 118 | 119 | ## Starship 120 | 121 | You can use the following snippet to show how much you worked today, 122 | while the time tracking is running. 123 | 124 | Just add it to your starship config (e.g.: ~/.config/starship.toml) 125 | ```yml 126 | [custom.worktime] 127 | command = """ tt show --format "{h}h {mm}m" """ 128 | when = "tt status" 129 | shell = "sh" 130 | ``` 131 | 132 | This is how it looks like: 133 | 134 | ![Starship Prompt](https://user-images.githubusercontent.com/2937272/114703152-38f71600-9d25-11eb-8fee-564d2efe2c8e.png) 135 | 136 | ## Data Format 137 | The data format is a bincode encoded vector of `TrackingEvent`, which can either be a start or stop event, containing the `DateTime` 138 | when the event happened and an optional description. If you want to use this data in a 3rd party application, you can export the 139 | data to json with `tt export data.json`. 140 | -------------------------------------------------------------------------------- /default_config.toml: -------------------------------------------------------------------------------- 1 | # the file where to save the events 2 | data_file = "~/timetracking.bin" 3 | 4 | # if true, calling start when already running inserts a stop event and a start event. 5 | auto_insert_stop = false 6 | 7 | # if true, tt will recursively search parent dirs for project settings 8 | enable_project_settings = true 9 | 10 | # minimum amount of minutes of break time per day. 11 | # if you have less than this amount of break per day, 12 | # the calculation will automatically add the additional 13 | # break time needed to get to this number 14 | min_daily_break = 0 15 | 16 | # last day of work week as chrono::Weekday. 17 | # allowed values are: mon, tue, wed, thu, fri, sat and sun 18 | last_day_of_work_week = "fri" 19 | 20 | # set the daily time goal 21 | [time_goal.daily] 22 | # work hours to reach in a work day (0-24) 23 | hours = 8 24 | 25 | # work minutes to reach in a work day (0-59) 26 | minutes = 0 27 | 28 | # set the weekly time goal 29 | [time_goal.weekly] 30 | # work hours to reach in a work week (0-168) 31 | hours = 40 32 | 33 | # work minutes to reach in a work week (0-59) 34 | minutes = 0 35 | -------------------------------------------------------------------------------- /default_config_development.toml: -------------------------------------------------------------------------------- 1 | data_file = "~/timetracking.json" 2 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use chrono::{prelude::*, serde::ts_seconds, Duration, NaiveDate, NaiveDateTime, NaiveTime}; 3 | use iif::iif; 4 | use serde::{Deserialize, Serialize}; 5 | use std::{fs::File, io::{self, Write}}; 6 | use std::path::{Path, PathBuf}; 7 | use structopt::StructOpt; 8 | 9 | mod settings; 10 | 11 | use settings::Settings; 12 | 13 | #[derive(Debug, StructOpt)] 14 | struct Options { 15 | #[cfg(feature = "binary")] 16 | /// which data file to use. [default: ~/timetracking.bin] 17 | #[structopt(short, long)] 18 | data_file: Option, 19 | 20 | #[cfg(not(feature = "binary"))] 21 | /// which data file to use. [default: ~/timetracking.json] 22 | #[structopt(short, long)] 23 | data_file: Option, 24 | 25 | /// which config file to use. 26 | #[structopt(short, long)] 27 | config_file: Option, 28 | 29 | #[structopt(subcommand)] 30 | command: Option, 31 | } 32 | 33 | #[derive(Default, Debug, StructOpt)] 34 | struct FilterData { 35 | /// show all entries after this point in time [defaults to current day 00:00:00] 36 | /// allowed formats are: "%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%H:%M:%S" 37 | #[structopt(short, long)] 38 | from: Option, 39 | 40 | /// show all entries before this point in time [defaults to start day 23:59:59] 41 | /// allowed formats are: "%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%H:%M:%S" 42 | #[structopt(short, long)] 43 | to: Option, 44 | 45 | /// filter entries. possible filter values: "week", "all" or part of the description 46 | filter: Option, 47 | } 48 | 49 | #[derive(Debug, StructOpt)] 50 | enum Command { 51 | // keep this at the top, otherwise rust analyzer will underline the whole struct until this 52 | // point as it thinks there is a problem, because it doesn't understand that this variant is 53 | // disabled via attribute. 54 | #[cfg(not(feature = "binary"))] 55 | /// export data to file 56 | Export { 57 | /// where to write the output file 58 | path: PathBuf, 59 | }, 60 | 61 | /// show info from the latest entry. Returns the exit code 0, if the time tracking is currently 62 | /// active and -1 if not. 63 | Status, 64 | 65 | /// starts an interactive cleanup session 66 | Cleanup, 67 | 68 | /// start time tracking 69 | Start { 70 | /// a description for the event 71 | description: Option, 72 | 73 | /// the time at which the event happend. 74 | /// format: "HH:MM:SS" or "YY-mm-dd HH:MM:SS" [defaults to current time] 75 | #[structopt(short, long)] 76 | at: Option, 77 | }, 78 | 79 | /// stop time tracking 80 | Stop { 81 | /// a description for the event 82 | description: Option, 83 | 84 | /// the time at which the event happend. 85 | /// format: "HH:MM:SS" or "YY-mm-dd HH:MM:SS" [defaults to current time] 86 | #[structopt(short, long)] 87 | at: Option, 88 | }, 89 | 90 | /// continue time tracking with last description 91 | Continue, 92 | 93 | /// list all entries 94 | List { 95 | #[structopt(flatten)] 96 | filter: FilterData, 97 | }, 98 | 99 | /// show path to data file 100 | Path, 101 | 102 | /// show work time for given timespan 103 | Show { 104 | #[structopt(flatten)] 105 | filter: FilterData, 106 | 107 | /// show only the time with no additional text 108 | #[structopt(short, long)] 109 | plain: bool, 110 | 111 | /// show time until the defined time goals are met. 112 | #[structopt(short, long)] 113 | remaining: bool, 114 | 115 | /// include seconds in time calculation 116 | #[structopt(short)] 117 | include_seconds: bool, 118 | 119 | /// show only the time with no additional text. [default: "{hh}:{mm}:{ss}"] 120 | #[structopt(long)] 121 | format: Option, 122 | }, 123 | #[cfg(feature = "binary")] 124 | /// export data to file 125 | Export { 126 | /// export in a human readable format. This format is for human reading only and cannot be 127 | /// imported 128 | #[structopt(short, long)] 129 | readable: bool, 130 | /// pretty print json 131 | #[structopt(short, long)] 132 | pretty: bool, 133 | /// where to write the output file 134 | path: PathBuf, 135 | }, 136 | #[cfg(feature = "binary")] 137 | /// import data from json file 138 | Import { 139 | /// which file to import 140 | path: PathBuf, 141 | }, 142 | } 143 | 144 | impl Default for Command { 145 | fn default() -> Self { 146 | Self::Show { 147 | filter: FilterData::default(), 148 | format: None, 149 | include_seconds: false, 150 | plain: false, 151 | remaining: false, 152 | } 153 | } 154 | } 155 | 156 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 157 | struct TrackingData { 158 | description: Option, 159 | 160 | #[serde(with = "ts_seconds")] 161 | time: DateTime, 162 | } 163 | 164 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] 165 | enum TrackingEvent { 166 | Start(TrackingData), 167 | Stop(TrackingData), 168 | } 169 | 170 | impl TrackingEvent { 171 | fn time(&self, include_seconds: bool) -> DateTime { 172 | match self { 173 | Self::Start(TrackingData { time, .. }) | Self::Stop(TrackingData { time, .. }) => { 174 | let time = *time; 175 | if include_seconds { 176 | time 177 | } else { 178 | time.with_second(0).expect("could not set seconds to zero") 179 | } 180 | } 181 | } 182 | } 183 | 184 | fn description(&self) -> Option { 185 | match self { 186 | Self::Start(TrackingData { description, .. }) 187 | | Self::Stop(TrackingData { description, .. }) => description.clone(), 188 | } 189 | } 190 | 191 | fn is_start(&self) -> bool { 192 | match self { 193 | Self::Start(_) => true, 194 | Self::Stop(_) => false, 195 | } 196 | } 197 | 198 | fn is_stop(&self) -> bool { 199 | match self { 200 | Self::Start(_) => false, 201 | Self::Stop(_) => true, 202 | } 203 | } 204 | } 205 | 206 | #[cfg_attr(test, derive(PartialEq, Eq))] 207 | #[derive(Debug, Clone, Copy)] 208 | enum DateOrDateTime { 209 | Date(NaiveDate), 210 | DateTime(NaiveDateTime), 211 | } 212 | 213 | impl From for DateOrDateTime { 214 | fn from(date: NaiveDate) -> Self { 215 | Self::Date(date) 216 | } 217 | } 218 | 219 | impl From for DateOrDateTime { 220 | fn from(date_time: NaiveDateTime) -> Self { 221 | Self::DateTime(date_time) 222 | } 223 | } 224 | 225 | #[cfg(feature = "binary")] 226 | fn read_data>(path: P) -> Result> { 227 | let data = std::fs::read(&path)?; 228 | Ok(bincode::deserialize(&data)?) 229 | } 230 | 231 | #[cfg(not(feature = "binary"))] 232 | fn read_data>(path: P) -> Result> { 233 | read_json_data(path) 234 | } 235 | 236 | fn read_json_data>(path: P) -> Result> { 237 | let data = std::fs::read_to_string(&path)?; 238 | Ok(serde_json::from_str(&data)?) 239 | } 240 | 241 | fn write_with_flush, C: AsRef<[u8]>>(path: P, contents: C) -> io::Result<()> { 242 | let mut f = File::create(path)?; 243 | f.write_all(contents.as_ref())?; 244 | f.flush()?; 245 | Ok(()) 246 | } 247 | 248 | #[cfg(feature = "binary")] 249 | fn write_data>(path: P, data: &[TrackingEvent]) -> Result<()> { 250 | 251 | let data = bincode::serialize(data).expect("could not serialize data"); 252 | 253 | let temp_path = path.as_ref().with_extension("bin.bak"); 254 | 255 | match write_with_flush(&temp_path, &data) { 256 | Ok(_) => { 257 | Ok(std::fs::rename(temp_path, path.as_ref())?) 258 | } 259 | Err(e) => Err(e.into()), 260 | } 261 | } 262 | 263 | fn write_json_data>(path: P, data: &[TrackingEvent], pretty: bool) -> Result<()> { 264 | let data = iif!( 265 | pretty, 266 | serde_json::to_string_pretty(data), 267 | serde_json::to_string(data) 268 | ) 269 | .expect("could not serialize data"); 270 | Ok(write_with_flush(&path, &data)?) 271 | } 272 | 273 | #[cfg(not(feature = "binary"))] 274 | fn write_data>(path: P, data: &[TrackingEvent]) -> Result<()> { 275 | write_json_data(path, data, false) 276 | } 277 | 278 | fn start_tracking( 279 | settings: &Settings, 280 | data: &mut Vec, 281 | description: Option, 282 | at: Option, 283 | ) -> Result<()> { 284 | let (should_add, last_description) = match data.last() { 285 | None => (true, None), 286 | Some(event) => (event.is_stop(), event.description()), 287 | }; 288 | if should_add || at.is_some() { 289 | data.push(TrackingEvent::Start(TrackingData { 290 | description, 291 | time: at.map_or_else(|| Ok(Local::now().into()), |at| parse_date_time(&at))?, 292 | })); 293 | } else if settings.auto_insert_stop && at.is_none() { 294 | match (description, last_description) { 295 | (Some(description), Some(last_description)) if description == last_description => { 296 | eprintln!( 297 | "Timetracking with the description \"{}\" is already running!", 298 | description 299 | ) 300 | } 301 | (description, _) => { 302 | data.push(TrackingEvent::Stop(TrackingData { 303 | description: None, 304 | time: Local::now().into(), 305 | })); 306 | data.push(TrackingEvent::Start(TrackingData { 307 | description, 308 | time: Local::now().into(), 309 | })); 310 | } 311 | } 312 | } else { 313 | eprintln!("Time tracking is already running!"); 314 | } 315 | 316 | Ok(()) 317 | } 318 | 319 | fn stop_tracking( 320 | data: &mut Vec, 321 | description: Option, 322 | at: Option, 323 | ) -> Result<()> { 324 | let should_add = match data.last() { 325 | None => true, 326 | Some(event) => event.is_start(), 327 | }; 328 | if should_add || at.is_some() { 329 | data.push(TrackingEvent::Stop(TrackingData { 330 | description, 331 | time: at.map_or_else(|| Ok(Local::now().into()), |at| parse_date_time(&at))?, 332 | })) 333 | } else { 334 | eprintln!("Time tracking is already stopped!"); 335 | } 336 | 337 | Ok(()) 338 | } 339 | 340 | fn continue_tracking(data: &mut Vec) { 341 | if let Some(TrackingEvent::Stop { .. }) = data.last() { 342 | if let Some(TrackingEvent::Start(TrackingData { description, .. })) = 343 | data.iter().rev().find(|t| t.is_start()).cloned() 344 | { 345 | data.push(TrackingEvent::Start(TrackingData { 346 | description, 347 | time: Local::now().into(), 348 | })) 349 | } 350 | } else { 351 | eprintln!("Time tracking couldn't be continued, because there are no entries. Use the start command instead!"); 352 | } 353 | } 354 | 355 | fn split_duration(duration: Duration) -> (i64, i64, i64) { 356 | let hours = duration.num_hours(); 357 | let hours_in_minutes = hours * 60; 358 | let hours_in_seconds = hours_in_minutes * 60; 359 | let minutes = duration.num_minutes() - hours_in_minutes; 360 | let minutes_in_seconds = minutes * 60; 361 | let seconds = duration.num_seconds() - hours_in_seconds - minutes_in_seconds; 362 | (hours, minutes, seconds) 363 | } 364 | 365 | fn filter_events( 366 | data: &[TrackingEvent], 367 | from: &Option, 368 | to: &Option, 369 | filter: &Option, 370 | ) -> Result> { 371 | let (filter, from, to) = match filter { 372 | Some(from) if from == "week" => { 373 | let now = Local::today(); 374 | let weekday = now.weekday(); 375 | let offset = weekday.num_days_from_monday(); 376 | let (monday_offset, sunday_offset) = (offset, 6 - offset); 377 | let from = DateOrDateTime::Date( 378 | (now - Duration::days(i64::from(monday_offset))).naive_local(), 379 | ); 380 | let to = DateOrDateTime::Date( 381 | (now + Duration::days(i64::from(sunday_offset))).naive_local(), 382 | ); 383 | (None, Some(from), Some(to)) 384 | } 385 | f => { 386 | let from = from.as_deref().map_or_else( 387 | || Ok(DateOrDateTime::Date(Local::today().naive_local())), 388 | parse_date_or_date_time, 389 | )?; 390 | 391 | let to = to 392 | .as_deref() 393 | .map(parse_date_or_date_time) 394 | .unwrap_or_else(|| { 395 | Ok(match from { 396 | DateOrDateTime::DateTime(from) => DateOrDateTime::Date(from.date()), 397 | from @ DateOrDateTime::Date(..) => from, 398 | }) 399 | })?; 400 | (f.clone(), Some(from), Some(to)) 401 | } 402 | }; 403 | let data_iterator = data 404 | .iter() 405 | .filter(|entry| { 406 | iif!( 407 | filter.clone().unwrap_or_default() == "all", 408 | true, 409 | match from { 410 | None => true, 411 | Some(DateOrDateTime::Date(from)) => { 412 | entry.time(true).timestamp_millis() 413 | >= TimeZone::from_local_date(&Local, &from) 414 | .unwrap() 415 | .and_time(NaiveTime::from_hms(0, 0, 0)) 416 | .expect("Failed to add time from date") 417 | .timestamp_millis() 418 | } 419 | Some(DateOrDateTime::DateTime(from)) => { 420 | entry.time(true).timestamp_millis() 421 | >= TimeZone::from_local_datetime(&Local, &from) 422 | .unwrap() 423 | .timestamp_millis() 424 | } 425 | } 426 | ) 427 | }) 428 | .filter(|entry| { 429 | iif!( 430 | filter.clone().unwrap_or_default() == "all", 431 | true, 432 | match to { 433 | None => true, 434 | Some(DateOrDateTime::Date(to)) => { 435 | entry.time(true).timestamp_millis() 436 | <= TimeZone::from_local_date(&Local, &to) 437 | .unwrap() 438 | .and_time(NaiveTime::from_hms(23, 59, 59)) 439 | .expect("Failed to add time from date") 440 | .timestamp_millis() 441 | } 442 | Some(DateOrDateTime::DateTime(to)) => { 443 | entry.time(true).timestamp_millis() 444 | <= TimeZone::from_local_datetime(&Local, &to) 445 | .unwrap() 446 | .timestamp_millis() 447 | } 448 | } 449 | ) 450 | }) 451 | .filter(|entry| match entry { 452 | TrackingEvent::Start(TrackingData { description, .. }) 453 | | TrackingEvent::Stop(TrackingData { description, .. }) => match (&filter, description) 454 | { 455 | (Some(filter), Some(description)) => { 456 | filter == "all" || description.contains(filter) 457 | } 458 | (Some(filter), None) => filter == "all", 459 | (None, _) => true, 460 | }, 461 | }) 462 | .skip_while(|entry| TrackingEvent::is_stop(entry)); 463 | 464 | Ok(data_iterator.cloned().collect()) 465 | } 466 | 467 | fn get_data_as_days(data: &[TrackingEvent]) -> Vec> { 468 | if data.is_empty() { 469 | return vec![]; 470 | } 471 | 472 | let mut current_day = data 473 | .first() 474 | .expect("Tracking event is empty") 475 | .time(true) 476 | .date(); 477 | let mut result = Vec::new(); 478 | let mut current = Vec::new(); 479 | for d in data { 480 | let date = d.time(true).date(); 481 | if current_day == date { 482 | current.push(d.clone()); 483 | } else { 484 | result.push(current); 485 | current = Vec::new(); 486 | current.push(d.clone()); 487 | current_day = date; 488 | } 489 | } 490 | if !current.is_empty() { 491 | result.push(current); 492 | } 493 | return result; 494 | } 495 | 496 | const CHECKED_ADD_DURATION_ERROR: &str = "couldn't add up durations"; 497 | 498 | fn get_time_from_day( 499 | settings: &Settings, 500 | data: &[TrackingEvent], 501 | include_seconds: bool, 502 | ) -> Duration { 503 | let mut data_iterator = data.iter(); 504 | let mut work_day = Duration::zero(); 505 | let mut first = None; 506 | let mut last = None; 507 | loop { 508 | let start = data_iterator.find(|e| e.is_start()); 509 | let stop = data_iterator.find(|e| e.is_stop()); 510 | match (start, stop) { 511 | (Some(start), Some(stop)) => { 512 | if let None = first { 513 | first = Some(start.time(include_seconds)); 514 | } 515 | last = Some(stop.time(include_seconds)); 516 | let duration = stop.time(include_seconds) - start.time(include_seconds); 517 | work_day = work_day 518 | .checked_add(&duration) 519 | .expect(CHECKED_ADD_DURATION_ERROR); 520 | } 521 | (Some(start), None) => { 522 | if let None = first { 523 | first = Some(start.time(include_seconds)); 524 | } 525 | let now = if include_seconds { 526 | Utc::now() 527 | } else { 528 | Utc::now().with_second(0).unwrap() 529 | }; 530 | last = Some(now); 531 | let duration = now - start.time(include_seconds); 532 | work_day = work_day 533 | .checked_add(&duration) 534 | .expect(CHECKED_ADD_DURATION_ERROR); 535 | break; 536 | } 537 | (_, _) => break, 538 | } 539 | } 540 | if settings.min_daily_break > 0 { 541 | let now = Utc::now(); 542 | let total = last.unwrap_or(now) - first.unwrap_or(now); 543 | let pause = total - work_day; 544 | let min_break_duration = Duration::minutes(i64::from(settings.min_daily_break)); 545 | if pause > Duration::zero() && pause < min_break_duration { 546 | let difference = min_break_duration - pause; 547 | work_day = work_day - difference; 548 | } 549 | } 550 | work_day.max(Duration::zero()) 551 | } 552 | 553 | fn get_time_from_events( 554 | settings: &Settings, 555 | data: &[TrackingEvent], 556 | include_seconds: bool, 557 | ) -> Duration { 558 | let days = get_data_as_days(data); 559 | let mut time = Duration::zero(); 560 | for day in days { 561 | let time_for_day = get_time_from_day(&settings, &day, include_seconds); 562 | time = time 563 | .checked_add(&time_for_day) 564 | .expect(CHECKED_ADD_DURATION_ERROR); 565 | } 566 | time 567 | } 568 | 569 | fn get_remaining_minutes(settings: &Settings, filter: &str, hours: i64, minutes: i64) -> i64 { 570 | let total = minutes + (hours * 60); 571 | let time_goal = if filter == "week" { 572 | &settings.time_goal.weekly 573 | } else { 574 | &settings.time_goal.daily 575 | }; 576 | let required = i64::from(time_goal.minutes) + (i64::from(time_goal.hours) * 60); 577 | required - total 578 | } 579 | 580 | fn show( 581 | settings: &Settings, 582 | data: &[TrackingEvent], 583 | filter: &FilterData, 584 | format: Option, 585 | include_seconds: bool, 586 | plain: bool, 587 | remaining: bool, 588 | ) -> Result<()> { 589 | let FilterData { from, to, filter } = filter; 590 | let filtered_data = filter_events(data, &from, &to, &filter)?; 591 | let work_time = get_time_from_events(&settings, &filtered_data, include_seconds); 592 | let (mut hours, mut minutes, mut seconds) = split_duration(work_time); 593 | 594 | let filter = filter.clone().unwrap_or_default(); 595 | if remaining { 596 | if (filter == "week" || filter.is_empty()) && from.is_none() && to.is_none() { 597 | seconds = 0; 598 | let mut remaining_minutes = get_remaining_minutes(&settings, &filter, hours, minutes); 599 | 600 | if filter != "week" { 601 | let filtered_data_week = 602 | filter_events(&data, &None, &None, &Some("week".to_string()))?; 603 | let week_work_time = 604 | get_time_from_events(&settings, &filtered_data_week, include_seconds); 605 | let (week_hours, week_minutes, _) = split_duration(week_work_time); 606 | let remaining_minutes_week = 607 | get_remaining_minutes(&settings, "week", week_hours, week_minutes); 608 | 609 | let today = Local::today().weekday(); 610 | 611 | if today == settings.last_day_of_work_week { 612 | // on last day in a work week, always show remaining minutes for week 613 | remaining_minutes = remaining_minutes_week; 614 | } else { 615 | // on all other days, show whichever is less 616 | remaining_minutes = remaining_minutes.min(remaining_minutes_week); 617 | } 618 | } 619 | 620 | remaining_minutes = remaining_minutes.max(0); 621 | 622 | hours = remaining_minutes / 60; 623 | minutes = remaining_minutes - (hours * 60); 624 | } else { 625 | eprintln!("Remaining only works when \"from\" and \"to\" are not set and with no filter or filter \"week\""); 626 | return Ok(()); 627 | } 628 | } 629 | let seconds_final = if include_seconds { seconds } else { 0 }; 630 | let format = format.unwrap_or_else(|| "{hh}:{mm}:{ss}".to_string()); 631 | let time = format 632 | .replace("{hh}", &format!("{:02}", hours)) 633 | .replace("{mm}", &format!("{:02}", minutes)) 634 | .replace("{ss}", &format!("{:02}", seconds_final)) 635 | .replace("{h}", &format!("{}", hours)) 636 | .replace("{m}", &format!("{}", minutes)) 637 | .replace("{s}", &format!("{}", seconds_final)); 638 | if plain { 639 | println!("{}", time); 640 | } else if remaining { 641 | println!("Remaining Work Time: {}", time); 642 | } else { 643 | println!("Work Time: {}", time); 644 | } 645 | 646 | Ok(()) 647 | } 648 | 649 | fn cleanup(data: &[TrackingEvent]) -> Vec { 650 | let mut cleaned = Vec::with_capacity(data.len()); 651 | 652 | let mut data_iter = data.iter(); 653 | let mut conflicting = Vec::new(); 654 | 655 | let mut is_start = None; 656 | 657 | let mut all_conflicting = Vec::new(); 658 | 659 | while let Some(e) = data_iter.next() { 660 | match is_start { 661 | None => { 662 | is_start = Some(e.is_start()); 663 | cleaned.push(e); 664 | } 665 | Some(true) => { 666 | if e.is_start() { 667 | if conflicting.is_empty() { 668 | if let Some(last_cleaned_e) = cleaned.pop() { 669 | conflicting.push(last_cleaned_e); 670 | } 671 | } 672 | conflicting.push(e); 673 | } else { 674 | if !conflicting.is_empty() { 675 | all_conflicting.push(conflicting); 676 | conflicting = Vec::new(); 677 | } 678 | cleaned.push(e); 679 | is_start.replace(false); 680 | } 681 | } 682 | Some(false) => { 683 | if e.is_stop() { 684 | if conflicting.is_empty() { 685 | if let Some(last_cleaned_e) = cleaned.pop() { 686 | conflicting.push(last_cleaned_e); 687 | } 688 | } 689 | conflicting.push(e); 690 | } else { 691 | if !conflicting.is_empty() { 692 | all_conflicting.push(conflicting); 693 | conflicting = Vec::new(); 694 | } 695 | cleaned.push(e); 696 | is_start.replace(true); 697 | } 698 | } 699 | } 700 | } 701 | 702 | for mut conflicting in all_conflicting { 703 | let event_type = iif!( 704 | conflicting 705 | .first() 706 | .expect("Nothing first tracking event founded") 707 | .is_start(), 708 | "start", 709 | "stop" 710 | ); 711 | println!("Repeated {} events found:", event_type); 712 | for (i, event) in conflicting.iter().enumerate() { 713 | println!( 714 | "({}) {}", 715 | i, 716 | to_human_readable( 717 | &format!("S{}", &event_type[1..]), 718 | &event.time(true).with_timezone(&Local), 719 | event.description() 720 | ) 721 | ); 722 | } 723 | loop { 724 | println!(); 725 | println!("Please enter the number of the entry to keep (|skip) [default: skip]: "); 726 | let mut input = String::new(); 727 | match io::stdin().read_line(&mut input) { 728 | Ok(_) => { 729 | let text = input.trim(); 730 | if text == "skip" || text.is_empty() { 731 | cleaned.append(&mut conflicting); 732 | break; 733 | } else { 734 | let parsed: Result = text.parse(); 735 | match parsed { 736 | Ok(n) => match conflicting.get(n) { 737 | Some(value) => { 738 | cleaned.push(value); 739 | break; 740 | } 741 | None => println!("Please use one of the numbers given above!"), 742 | }, 743 | Err(_) => println!("Could not parse number!"), 744 | } 745 | } 746 | } 747 | Err(_) => println!("Could not read from stdin!"), 748 | } 749 | } 750 | } 751 | 752 | cleaned.iter().map(Clone::clone).cloned().collect() 753 | } 754 | 755 | fn status(data: &[TrackingEvent]) { 756 | if let Some(event) = data.last() { 757 | let time = event.time(true).with_timezone(&Local); 758 | let active = event.is_start(); 759 | let text = iif!(active, "Start", "End"); 760 | if let Some(description) = event.description() { 761 | println!("Active: {}", active); 762 | println!("Description: {}", description,); 763 | println!( 764 | "{} Time: {:02}:{:02}:{:02}", 765 | text, 766 | time.hour(), 767 | time.minute(), 768 | time.second() 769 | ); 770 | } else { 771 | println!("Active: {}", active); 772 | println!( 773 | "{} Time: {:02}:{:02}:{:02}", 774 | text, 775 | time.hour(), 776 | time.minute(), 777 | time.second() 778 | ); 779 | } 780 | std::process::exit(iif!(active, 0, -1)); 781 | } else { 782 | println!("No Events found!"); 783 | std::process::exit(-1); 784 | } 785 | } 786 | 787 | fn to_human_readable( 788 | prefix: &str, 789 | time: &DateTime, 790 | description: Option, 791 | ) -> String { 792 | let description = description 793 | .map(|d| format!(" \"{}\"", d)) 794 | .unwrap_or_default(); 795 | format!( 796 | "{} at {:04}-{:02}-{:02} {:02}:{:02}:{:02}{}", 797 | prefix, 798 | time.year(), 799 | time.month(), 800 | time.day(), 801 | time.hour(), 802 | time.minute(), 803 | time.second(), 804 | description, 805 | ) 806 | } 807 | 808 | fn get_human_readable(data: &[TrackingEvent]) -> Vec { 809 | data.iter() 810 | .map(|event| match event { 811 | TrackingEvent::Start(TrackingData { time, description }) => { 812 | to_human_readable("Start", &time.with_timezone(&Local), description.clone()) 813 | } 814 | TrackingEvent::Stop(TrackingData { time, description }) => { 815 | to_human_readable("Stop ", &time.with_timezone(&Local), description.clone()) 816 | } 817 | }) 818 | .collect::>() 819 | } 820 | 821 | fn export_human_readable(path: String, data: &[TrackingEvent]) { 822 | let lines = get_human_readable(data); 823 | std::fs::write(path, lines.join("\n")).expect("could not export file"); 824 | } 825 | 826 | fn main() -> Result<()> { 827 | let Options { command, data_file, config_file } = Options::from_args(); 828 | 829 | let settings = Settings::new(&config_file)?; 830 | 831 | let path = match data_file { 832 | Some(path) => path, 833 | None => shellexpand::full(&settings.data_file)?.parse()?, 834 | }; 835 | let expanded_path = shellexpand::full(&path.to_string_lossy()) 836 | .expect("could not expand path") 837 | .to_string(); 838 | let mut data = read_data(&expanded_path).unwrap_or_default(); 839 | 840 | let data_changed = match command.unwrap_or_default() { 841 | Command::Start { description, at } => { 842 | start_tracking(&settings, &mut data, description, at)?; 843 | true 844 | } 845 | Command::Stop { description, at } => { 846 | stop_tracking(&mut data, description, at)?; 847 | true 848 | } 849 | Command::Continue => { 850 | continue_tracking(&mut data); 851 | true 852 | } 853 | Command::List { filter } => { 854 | let data = filter_events(&data, &filter.from, &filter.to, &filter.filter)?; 855 | for s in get_human_readable(&data) { 856 | println!("{}", s); 857 | } 858 | false 859 | } 860 | Command::Path => { 861 | println!("{}", expanded_path); 862 | false 863 | } 864 | Command::Show { 865 | format, 866 | filter, 867 | include_seconds, 868 | plain, 869 | remaining, 870 | } => { 871 | show( 872 | &settings, 873 | &data, 874 | &filter, 875 | format, 876 | include_seconds, 877 | plain, 878 | remaining, 879 | )?; 880 | false 881 | } 882 | Command::Status => { 883 | status(&data); 884 | false 885 | } 886 | Command::Cleanup => { 887 | data = cleanup(&data); 888 | true 889 | } 890 | #[cfg(not(feature = "binary"))] 891 | Command::Export { path } => { 892 | let expanded_path = shellexpand::full(&path.to_string_lossy()) 893 | .expect("could not expand path") 894 | .to_string(); 895 | export_human_readable(expanded_path, &data); 896 | false 897 | } 898 | 899 | #[cfg(feature = "binary")] 900 | Command::Export { 901 | path, 902 | readable, 903 | pretty, 904 | } => { 905 | let expanded_path = shellexpand::full(&path.to_string_lossy()) 906 | .expect("could not expand path") 907 | .to_string(); 908 | if readable { 909 | export_human_readable(expanded_path, &data); 910 | } else { 911 | write_json_data(expanded_path, &data, pretty).expect("Could not write file"); 912 | } 913 | false 914 | } 915 | #[cfg(feature = "binary")] 916 | Command::Import { path } => { 917 | data = read_json_data(path)?; 918 | true 919 | } 920 | #[allow(unreachable_patterns)] 921 | _ => unimplemented!(), 922 | }; 923 | 924 | if data_changed { 925 | data.sort_by_key(|e| e.time(true)); 926 | data.dedup(); 927 | write_data(expanded_path, &data).expect("Could not write file!"); 928 | } 929 | 930 | Ok(()) 931 | } 932 | 933 | fn parse_date_time(s: &str) -> Result> { 934 | let from_time = |s: &str| NaiveTime::parse_from_str(s, "%H:%M:%S"); 935 | let from_date_time = |s: &str| Local.datetime_from_str(s, "%Y-%m-%d %H:%M:%S"); 936 | 937 | from_time(s) 938 | .or_else(|_| from_time(&format!("{}:0", s))) 939 | .or_else(|_| from_time(&format!("{}:0:0", s))) 940 | .map_err(Into::into) 941 | .and_then(|time| Local::today().and_time(time).context("invalid time")) 942 | .or_else(|_| { 943 | from_date_time(s) 944 | .or_else(|_| from_date_time(&format!("{}:0", s))) 945 | .or_else(|_| from_date_time(&format!("{}:0:0", s))) 946 | }) 947 | .map(|date_time| date_time.with_timezone(&Utc)) 948 | .map_err(Into::into) 949 | } 950 | 951 | fn parse_date_or_date_time(s: &str) -> Result { 952 | if let Ok(date) = NaiveDate::parse_from_str(&s, "%Y-%m-%d") { 953 | return Ok(date.into()); 954 | } 955 | if let Ok(date_time) = NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S") { 956 | return Ok(date_time.into()); 957 | } 958 | 959 | parse_date_time(s).map(|date_time| date_time.with_timezone(&Local).naive_local().into()) 960 | } 961 | 962 | #[cfg(test)] 963 | mod tests { 964 | use super::*; 965 | 966 | #[test] 967 | fn test_parse_date_time() { 968 | assert_eq!( 969 | Local::now().date().and_hms(0, 0, 15).with_timezone(&Utc), 970 | parse_date_time("00:00:15").unwrap() 971 | ); 972 | assert_eq!( 973 | Local::now().date().and_hms(0, 15, 0).with_timezone(&Utc), 974 | parse_date_time("00:15").unwrap() 975 | ); 976 | assert_eq!( 977 | Local::now().date().and_hms(15, 0, 0).with_timezone(&Utc), 978 | parse_date_time("15").unwrap() 979 | ); 980 | 981 | assert_eq!( 982 | Local.ymd(2021, 4, 1).and_hms(0, 0, 15).with_timezone(&Utc), 983 | parse_date_time("2021-04-01 00:00:15").unwrap() 984 | ); 985 | assert_eq!( 986 | Local.ymd(2021, 4, 1).and_hms(0, 15, 0).with_timezone(&Utc), 987 | parse_date_time("2021-04-01 00:15").unwrap() 988 | ); 989 | assert_eq!( 990 | Local.ymd(2021, 4, 1).and_hms(15, 0, 0).with_timezone(&Utc), 991 | parse_date_time("2021-04-01 15").unwrap() 992 | ); 993 | } 994 | 995 | #[test] 996 | fn test_parse_date_or_date_time() { 997 | assert_eq!( 998 | DateOrDateTime::Date(NaiveDate::from_ymd(2020, 4, 1)), 999 | parse_date_or_date_time("2020-04-01").unwrap() 1000 | ); 1001 | assert_eq!( 1002 | DateOrDateTime::DateTime(NaiveDate::from_ymd(2020, 4, 1).and_hms(12, 15, 20)), 1003 | parse_date_or_date_time("2020-04-01 12:15:20").unwrap() 1004 | ); 1005 | assert_eq!( 1006 | DateOrDateTime::DateTime(NaiveDate::from_ymd(2020, 4, 1).and_hms(12, 0, 0)), 1007 | parse_date_or_date_time("2020-04-01 12").unwrap() 1008 | ); 1009 | } 1010 | } 1011 | -------------------------------------------------------------------------------- /src/settings.rs: -------------------------------------------------------------------------------- 1 | use config::{Config, ConfigError, Environment, File, FileFormat}; 2 | use chrono::Weekday; 3 | use serde::Deserialize; 4 | 5 | use std::path::Path; 6 | 7 | #[derive(Debug, Deserialize)] 8 | pub struct Time { 9 | pub hours: u8, 10 | pub minutes: u8, 11 | } 12 | 13 | #[derive(Debug, Deserialize)] 14 | pub struct TimeGoal { 15 | pub daily: Time, 16 | pub weekly: Time, 17 | } 18 | 19 | #[derive(Debug, Deserialize)] 20 | pub struct Settings { 21 | pub data_file: String, 22 | pub auto_insert_stop: bool, 23 | pub enable_project_settings: bool, 24 | pub time_goal: TimeGoal, 25 | pub min_daily_break: u8, 26 | pub last_day_of_work_week: Weekday, 27 | } 28 | 29 | fn add_file_if_exists(s: &mut Config, file: &str) -> Result { 30 | let result = if Path::new(file).exists() { 31 | s.merge(File::new(file, FileFormat::Toml).required(false))?; 32 | true 33 | } else { 34 | false 35 | }; 36 | Ok(result) 37 | } 38 | 39 | fn path_to_string_lossy>(path: P) -> String { 40 | path.as_ref().to_string_lossy().to_string() 41 | } 42 | 43 | impl Settings { 44 | pub fn new(config_file: &Option) -> Result { 45 | let mut s = Config::new(); 46 | 47 | // Start off by merging in the "default" configuration file 48 | s.merge(File::from_str( 49 | include_str!("../default_config.toml"), 50 | config::FileFormat::Toml, 51 | ))?; 52 | 53 | #[cfg(not(feature = "binary"))] 54 | s.merge(File::from_str( 55 | include_str!("../default_config_development.toml"), 56 | config::FileFormat::Toml, 57 | ))?; 58 | 59 | let config_path = shellexpand::full("~/.config/timetracking/config.toml") 60 | .expect("could not expand path") 61 | .to_string(); 62 | s.merge(File::with_name(config_path.as_str()).required(false))?; 63 | 64 | if s.get_bool("enable_project_settings")? { 65 | let current_dir = std::env::current_dir().expect("Could not get current directory"); 66 | let mut path = current_dir.as_path(); 67 | if !add_file_if_exists( 68 | &mut s, 69 | &format!("{}/timetracking.project.toml", path_to_string_lossy(&path)), 70 | )? { 71 | while let Some(parent) = path.parent() { 72 | if add_file_if_exists( 73 | &mut s, 74 | &format!("{}/timetracking.project.toml", path_to_string_lossy(&path)), 75 | )? { 76 | break; 77 | } 78 | path = parent; 79 | } 80 | } 81 | } 82 | 83 | s.merge(File::with_name(".timetracking.config").required(false))?; 84 | 85 | s.merge(Environment::with_prefix("tt"))?; 86 | 87 | if let Some(config_file) = config_file { 88 | if !add_file_if_exists(&mut s, config_file)? { 89 | eprintln!("Could not find specified config file!"); 90 | std::process::exit(-2); 91 | } 92 | } 93 | 94 | let daily_hours = s.get_int("time_goal.daily.hours")?; 95 | s.set("time_goal.daily.hours", daily_hours.min(24))?; 96 | let daily_minutes = s.get_int("time_goal.daily.minutes")?; 97 | s.set("time_goal.daily.minutes", daily_minutes.min(59))?; 98 | let weekly_hours = s.get_int("time_goal.weekly.hours")?; 99 | s.set("time_goal.weekly.hours", weekly_hours.min(168))?; 100 | let weekly_minutes = s.get_int("time_goal.weekly.minutes")?; 101 | s.set("time_goal.weekly.minutes", weekly_minutes.min(59))?; 102 | 103 | // You can deserialize (and thus freeze) the entire configuration as 104 | s.try_into() 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /test/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cargo build 3 | 4 | data=test.bin 5 | day1=2021-03-29 6 | day2=2021-03-30 7 | day3=2021-03-31 8 | day4=2021-04-01 9 | day5=2021-04-02 10 | 11 | config1=test_config.toml 12 | config2=test_config_min_break.toml 13 | 14 | output=output.txt 15 | target=target.txt 16 | 17 | if ! command -v faketime &> /dev/null 18 | then 19 | echo "faketime is needed for this script to work." 20 | echo "you can get it from: https://github.com/wolfcw/libfaketime" 21 | exit 22 | fi 23 | 24 | 25 | tt() { 26 | current_date=$1 27 | shift 28 | current_time=$1 29 | shift 30 | config=$1 31 | shift 32 | faketime "$current_date $current_time" ../target/debug/tt -c $config -d $data "$@" 33 | } 34 | 35 | rm $data 2> /dev/null 36 | rm $output 2> /dev/null 37 | touch $output 38 | 39 | tt $day1 "08:00" $config1 start 40 | tt $day1 "12:00" $config1 stop pause 41 | tt $day1 "12:15" $config1 start 42 | tt $day1 "16:15" $config1 stop 43 | echo "D1 - show -p:" >> $output 44 | tt $day1 "17:15" $config1 show -p >> $output 45 | echo "" >> $output 46 | echo "D1 - show -p -r:" >> $output 47 | tt $day1 "17:15" $config1 show -p -r >> $output 48 | echo "" >> $output 49 | 50 | echo "" >> $output 51 | echo "================" >> $output 52 | echo "" >> $output 53 | 54 | tt $day2 "08:00" $config2 start 55 | tt $day2 "12:00" $config2 stop pause 56 | tt $day2 "12:15" $config2 start 57 | tt $day2 "16:15" $config2 stop 58 | echo "D2 - show -p:" >> $output 59 | tt $day2 "17:15" $config2 show -p >> $output 60 | echo "" >> $output 61 | echo "D2 - show -p -r:" >> $output 62 | tt $day2 "17:15" $config2 show -p -r >> $output 63 | echo "" >> $output 64 | 65 | echo "" >> $output 66 | echo "================" >> $output 67 | echo "" >> $output 68 | 69 | tt $day3 "08:00" $config1 start 70 | tt $day3 "12:00" $config1 stop pause 71 | tt $day3 "12:15" $config1 start 72 | tt $day3 "15:15" $config1 stop 73 | echo "D3 - show -p week:" >> $output 74 | tt $day3 "17:15" $config2 show -p week >> $output 75 | echo "" >> $output 76 | 77 | echo "" >> $output 78 | echo "================" >> $output 79 | echo "" >> $output 80 | 81 | tt $day4 "08:00" $config1 start 82 | tt $day4 "12:00" $config1 stop pause 83 | tt $day4 "12:15" $config1 start 84 | echo "D4 - show -r -p week:" >> $output 85 | tt $day4 "16:00" $config1 show -r -p week >> $output 86 | echo "" >> $output 87 | echo "D4 - show -r -p:" >> $output 88 | tt $day4 "16:00" $config1 show -r -p >> $output 89 | echo "" >> $output 90 | tt $day4 "16:15" $config1 stop 91 | 92 | echo "" >> $output 93 | echo "================" >> $output 94 | echo "" >> $output 95 | 96 | tt $day5 "08:00" $config1 start 97 | tt $day5 "12:00" $config1 stop pause 98 | tt $day5 "12:15" $config1 start 99 | echo "D5 - show -r -p week:" >> $output 100 | tt $day5 "16:00" $config1 show -r -p week >> $output 101 | echo "" >> $output 102 | echo "D5 - show -r -p:" >> $output 103 | tt $day5 "16:00" $config1 show -r -p >> $output 104 | echo "" >> $output 105 | tt $day5 "16:15" $config1 stop 106 | 107 | if ! diff -q $output $target &>/dev/null; then 108 | >&2 echo "Test output changed." 109 | diff -u --color $output $target 110 | else 111 | echo "Test output is the same." 112 | fi 113 | 114 | rm $data 2> /dev/null 115 | rm $output 2> /dev/null 116 | -------------------------------------------------------------------------------- /test/target.txt: -------------------------------------------------------------------------------- 1 | D1 - show -p: 2 | 08:00:00 3 | 4 | D1 - show -p -r: 5 | 00:00:00 6 | 7 | 8 | ================ 9 | 10 | D2 - show -p: 11 | 07:45:00 12 | 13 | D2 - show -p -r: 14 | 00:15:00 15 | 16 | 17 | ================ 18 | 19 | D3 - show -p week: 20 | 22:15:00 21 | 22 | 23 | ================ 24 | 25 | D4 - show -r -p week: 26 | 09:15:00 27 | 28 | D4 - show -r -p: 29 | 00:15:00 30 | 31 | 32 | ================ 33 | 34 | D5 - show -r -p week: 35 | 01:15:00 36 | 37 | D5 - show -r -p: 38 | 01:15:00 39 | 40 | -------------------------------------------------------------------------------- /test/test_config.toml: -------------------------------------------------------------------------------- 1 | # the file where to save the events 2 | data_file = "~/timetracking.bin" 3 | 4 | # if true, calling start when already running inserts a stop event and a start event. 5 | auto_insert_stop = false 6 | 7 | # if true, tt will recursively search parent dirs for project settings 8 | enable_project_settings = true 9 | 10 | # minimum amount of minutes of break time per day. 11 | # if you have less than this amount of break per day, 12 | # the calculation will automatically add the additional 13 | # break time needed to get to this number 14 | min_daily_break = 0 15 | 16 | # set the daily time goal 17 | [time_goal.daily] 18 | # work hours to reach in a work day (0-24) 19 | hours = 8 20 | 21 | # work minutes to reach in a work day (0-59) 22 | minutes = 0 23 | 24 | # set the weekly time goal 25 | [time_goal.weekly] 26 | # work hours to reach in a work week (0-168) 27 | hours = 40 28 | 29 | # work minutes to reach in a work week (0-59) 30 | minutes = 0 31 | -------------------------------------------------------------------------------- /test/test_config_min_break.toml: -------------------------------------------------------------------------------- 1 | # the file where to save the events 2 | data_file = "~/timetracking.bin" 3 | 4 | # if true, calling start when already running inserts a stop event and a start event. 5 | auto_insert_stop = false 6 | 7 | # if true, tt will recursively search parent dirs for project settings 8 | enable_project_settings = true 9 | 10 | # minimum amount of minutes of break time per day. 11 | # if you have less than this amount of break per day, 12 | # the calculation will automatically add the additional 13 | # break time needed to get to this number 14 | min_daily_break = 30 15 | 16 | # set the daily time goal 17 | [time_goal.daily] 18 | # work hours to reach in a work day (0-24) 19 | hours = 8 20 | 21 | # work minutes to reach in a work day (0-59) 22 | minutes = 0 23 | 24 | # set the weekly time goal 25 | [time_goal.weekly] 26 | # work hours to reach in a work week (0-168) 27 | hours = 40 28 | 29 | # work minutes to reach in a work week (0-59) 30 | minutes = 0 31 | --------------------------------------------------------------------------------