Compare commits
1 commit
be6c351166
...
d1b4281f40
| Author | SHA1 | Date | |
|---|---|---|---|
| d1b4281f40 |
17 changed files with 174 additions and 2058 deletions
269
Cargo.lock
generated
269
Cargo.lock
generated
|
|
@ -71,7 +71,7 @@ version = "1.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -82,7 +82,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anstyle",
|
"anstyle",
|
||||||
"once_cell_polyfill",
|
"once_cell_polyfill",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -163,28 +163,6 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "chrono-tz"
|
|
||||||
version = "0.9.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
|
|
||||||
dependencies = [
|
|
||||||
"chrono",
|
|
||||||
"chrono-tz-build",
|
|
||||||
"phf",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "chrono-tz-build"
|
|
||||||
version = "0.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
|
|
||||||
dependencies = [
|
|
||||||
"parse-zoneinfo",
|
|
||||||
"phf",
|
|
||||||
"phf_codegen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.6.0"
|
version = "4.6.0"
|
||||||
|
|
@ -254,23 +232,23 @@ checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "directories"
|
name = "directories"
|
||||||
version = "6.0.0"
|
version = "5.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d"
|
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs-sys",
|
"dirs-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dirs-sys"
|
name = "dirs-sys"
|
||||||
version = "0.5.0"
|
version = "0.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"option-ext",
|
"option-ext",
|
||||||
"redox_users",
|
"redox_users",
|
||||||
"windows-sys",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -292,7 +270,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -405,9 +383,9 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "2.13.1"
|
version = "2.13.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff"
|
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
|
|
@ -444,9 +422,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.94"
|
version = "0.3.92"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9"
|
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
|
|
@ -460,9 +438,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.184"
|
version = "0.2.183"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
|
|
@ -572,53 +550,6 @@ version = "4.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
|
checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "parse-zoneinfo"
|
|
||||||
version = "0.3.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24"
|
|
||||||
dependencies = [
|
|
||||||
"regex",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
|
||||||
dependencies = [
|
|
||||||
"phf_shared",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf_codegen"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
|
||||||
dependencies = [
|
|
||||||
"phf_generator",
|
|
||||||
"phf_shared",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf_generator"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
|
||||||
dependencies = [
|
|
||||||
"phf_shared",
|
|
||||||
"rand",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf_shared"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
|
||||||
dependencies = [
|
|
||||||
"siphasher",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pretty_assertions"
|
name = "pretty_assertions"
|
||||||
version = "1.4.1"
|
version = "1.4.1"
|
||||||
|
|
@ -682,30 +613,15 @@ version = "6.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand"
|
|
||||||
version = "0.8.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
|
||||||
dependencies = [
|
|
||||||
"rand_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rand_core"
|
|
||||||
version = "0.6.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_users"
|
name = "redox_users"
|
||||||
version = "0.5.2"
|
version = "0.4.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror",
|
"thiserror 1.0.69",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -753,7 +669,7 @@ dependencies = [
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys",
|
"linux-raw-sys",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -822,9 +738,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "1.1.1"
|
version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
@ -835,18 +751,11 @@ version = "1.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "siphasher"
|
|
||||||
version = "1.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "streamd"
|
name = "streamd"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"chrono-tz",
|
|
||||||
"clap",
|
"clap",
|
||||||
"clap_complete",
|
"clap_complete",
|
||||||
"directories",
|
"directories",
|
||||||
|
|
@ -859,7 +768,7 @@ dependencies = [
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"toml",
|
"toml",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
@ -912,7 +821,7 @@ dependencies = [
|
||||||
"getrandom 0.4.2",
|
"getrandom 0.4.2",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -922,7 +831,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustix",
|
"rustix",
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -935,13 +844,33 @@ dependencies = [
|
||||||
"unicode-width 0.2.2",
|
"unicode-width 0.2.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl 1.0.69",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -957,9 +886,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "1.1.2+spec-1.1.0"
|
version = "0.9.12+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
|
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
|
@ -967,32 +896,32 @@ dependencies = [
|
||||||
"toml_datetime",
|
"toml_datetime",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"toml_writer",
|
"toml_writer",
|
||||||
"winnow",
|
"winnow 0.7.15",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.1+spec-1.1.0"
|
version = "0.7.5+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_parser"
|
name = "toml_parser"
|
||||||
version = "1.1.2+spec-1.1.0"
|
version = "1.1.0+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow",
|
"winnow 1.0.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_writer"
|
name = "toml_writer"
|
||||||
version = "1.1.1+spec-1.1.0"
|
version = "1.1.0+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicase"
|
name = "unicase"
|
||||||
|
|
@ -1072,9 +1001,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen"
|
name = "wasm-bindgen"
|
||||||
version = "0.2.117"
|
version = "0.2.115"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0"
|
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
|
@ -1085,9 +1014,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro"
|
name = "wasm-bindgen-macro"
|
||||||
version = "0.2.117"
|
version = "0.2.115"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be"
|
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"quote",
|
"quote",
|
||||||
"wasm-bindgen-macro-support",
|
"wasm-bindgen-macro-support",
|
||||||
|
|
@ -1095,9 +1024,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-macro-support"
|
name = "wasm-bindgen-macro-support"
|
||||||
version = "0.2.117"
|
version = "0.2.115"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2"
|
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
|
|
@ -1108,9 +1037,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-bindgen-shared"
|
name = "wasm-bindgen-shared"
|
||||||
version = "0.2.117"
|
version = "0.2.115"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b"
|
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
@ -1155,7 +1084,7 @@ version = "0.1.11"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1217,6 +1146,15 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.48.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
|
|
@ -1226,6 +1164,69 @@ dependencies = [
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm",
|
||||||
|
"windows_aarch64_msvc",
|
||||||
|
"windows_i686_gnu",
|
||||||
|
"windows_i686_msvc",
|
||||||
|
"windows_x86_64_gnu",
|
||||||
|
"windows_x86_64_gnullvm",
|
||||||
|
"windows_x86_64_msvc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winnow"
|
||||||
|
version = "0.7.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winnow"
|
name = "winnow"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
|
|
|
||||||
|
|
@ -11,18 +11,17 @@ repository = "https://github.com/konstantinfickel/streamd"
|
||||||
clap = { version = "4", features = ["derive", "env"] }
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
clap_complete = "4"
|
clap_complete = "4"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
toml = "1.0"
|
toml = "0.9"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
miette = { version = "7", features = ["fancy"] }
|
miette = { version = "7", features = ["fancy"] }
|
||||||
pulldown-cmark = "0.13"
|
pulldown-cmark = "0.13"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
once_cell = "1"
|
once_cell = "1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
chrono-tz = "0.9"
|
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
indexmap = { version = "2", features = ["serde"] }
|
indexmap = { version = "2", features = ["serde"] }
|
||||||
itertools = "0.14"
|
itertools = "0.14"
|
||||||
directories = "6"
|
directories = "5"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
pretty_assertions = "=1.4.1"
|
pretty_assertions = "=1.4.1"
|
||||||
|
|
|
||||||
40
README.md
40
README.md
|
|
@ -21,50 +21,16 @@ Within files, `@`-prefixed markers at the beginning of paragraphs or headings de
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `streamd` / `streamd new` — Create a new timestamped markdown entry, opening your editor
|
- `streamd` / `streamd new` — Create a new timestamped markdown entry, opening your editor
|
||||||
- `streamd todo` — Show all open tasks (shards with `@Task` markers), numbered for easy reference
|
- `streamd todo` — Show all open tasks (shards with `@Task` markers)
|
||||||
- `streamd todo N edit` — Edit task N in your editor, jumping to the task's line
|
|
||||||
- `streamd todo N done` — Mark task N as done by inserting `@Done` after `@Task`
|
|
||||||
- `streamd todo --show-future` — Include tasks with future dates in the listing
|
|
||||||
- `streamd edit [number]` — Edit a stream file by index (most recent first)
|
- `streamd edit [number]` — Edit a stream file by index (most recent first)
|
||||||
- `streamd timesheet` — Generate time reports from `@Timesheet` markers
|
- `streamd timesheet` — Generate time reports from `@Timesheet` markers
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### User Configuration
|
Streamd reads its configuration from `~/.config/streamd/config.toml` (XDG standard). The main setting is `base_folder`, which points to the directory containing your stream files (defaults to the current working directory).
|
||||||
|
|
||||||
Streamd reads its user configuration from `~/.config/streamd/config.toml` (XDG standard). The main setting is `base_folder`, which points to the directory containing your stream files (defaults to the current working directory).
|
|
||||||
|
|
||||||
### Repository Configuration
|
|
||||||
|
|
||||||
For timesheet reporting, create a `.streamd.toml` file in your stream files directory:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
timezone = "Europe/Berlin" # Optional: timezone for day boundaries
|
|
||||||
|
|
||||||
[timesheet]
|
|
||||||
[[timesheet.periods]]
|
|
||||||
start = "2026-01-01"
|
|
||||||
end = "2026-06-30"
|
|
||||||
hours_per_week = 38.0
|
|
||||||
|
|
||||||
[[timesheet.periods]]
|
|
||||||
start = "2026-07-01"
|
|
||||||
end = "2026-12-31"
|
|
||||||
hours_per_week = 40.0
|
|
||||||
```
|
|
||||||
|
|
||||||
The timesheet command will calculate expected vs actual working hours based on these periods, showing:
|
|
||||||
- Daily breakdown with expected/actual hours
|
|
||||||
- Special day types (sick leave, vacation, holidays, flex days)
|
|
||||||
- Warnings for missing entries and overlapping timecards
|
|
||||||
- Monthly and cumulative balance
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Running `streamd` opens your editor to create a new entry. After saving, the file is renamed based on its timestamp and any markers found in the content.
|
Running `streamd` opens your editor to create a new entry. After saving, the file is renamed based on its timestamp and any markers found in the content.
|
||||||
|
|
||||||
Running `streamd todo` finds all shards marked as open tasks and displays them numbered in your terminal. Tasks with future dates are hidden by default (use `--show-future` to include them). Tasks are sorted by date with oldest first (task 1 is the oldest).
|
Running `streamd todo` finds all shards marked as open tasks and displays them as rich-formatted panels in your terminal.
|
||||||
|
|
||||||
You can quickly edit or complete tasks by number:
|
|
||||||
- `streamd todo 1 edit` opens task 1 in your editor at the correct line
|
|
||||||
- `streamd todo 1 done` marks task 1 as done by inserting `@Done` after `@Task`
|
|
||||||
|
|
|
||||||
|
|
@ -320,52 +320,6 @@ Process timesheet shards chronologically per day:
|
||||||
|
|
||||||
**Validation:** The last entry of each day MUST be a `Break` (cannot end day while working).
|
**Validation:** The last entry of each day MUST be a `Break` (cannot end day while working).
|
||||||
|
|
||||||
### R18a: Timesheet Report Configuration
|
|
||||||
|
|
||||||
The `.streamd.toml` file in the base folder configures timesheet periods:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
timezone = "Europe/Berlin" # Optional timezone for day boundaries
|
|
||||||
|
|
||||||
[timesheet]
|
|
||||||
[[timesheet.periods]]
|
|
||||||
start = "2026-01-01"
|
|
||||||
end = "2026-06-30"
|
|
||||||
hours_per_week = 38.0
|
|
||||||
|
|
||||||
[[timesheet.periods]]
|
|
||||||
start = "2026-07-01"
|
|
||||||
end = "2026-12-31"
|
|
||||||
hours_per_week = 40.0
|
|
||||||
```
|
|
||||||
|
|
||||||
**Configuration Rules:**
|
|
||||||
- Dates use ISO 8601 format (`YYYY-MM-DD`)
|
|
||||||
- Periods MUST NOT overlap (validation error if they do)
|
|
||||||
- Gaps between periods are allowed — days in gaps have 0 expected hours
|
|
||||||
- `hours_per_week` is distributed over Mon-Fri (e.g., 38h/week = 7.6h/day)
|
|
||||||
|
|
||||||
### R18b: Day Type Rules
|
|
||||||
|
|
||||||
| Day Type | Expected Hours | Actual Hours |
|
|
||||||
|----------|---------------|--------------|
|
|
||||||
| Regular work day | period.hours_per_week / 5 | Sum of timecards |
|
|
||||||
| Weekend (Sat/Sun) | 0 | Sum of timecards (hidden if 0) |
|
|
||||||
| Sick Leave (@SickLeave) | Normal expected | max(expected, worked) |
|
|
||||||
| Vacation (@VacationDay) | Normal expected | expected + worked |
|
|
||||||
| Holiday (@Holiday) | 0 | Sum of timecards |
|
|
||||||
| Flex Day (@UndertimeDay) | Normal expected | 0 |
|
|
||||||
| Day in gap (no period) | 0 | Sum of timecards + warning |
|
|
||||||
| Missing (no entries) | Normal expected | 0 + warning |
|
|
||||||
|
|
||||||
### R18c: Timesheet Report Warnings
|
|
||||||
|
|
||||||
The report generates warnings for:
|
|
||||||
|
|
||||||
1. **Missing days without explanation**: A weekday within a configured period has no timecard entries and no special day type marker
|
|
||||||
2. **Overlapping timecards**: Two or more timecards on the same day have overlapping time ranges
|
|
||||||
3. **Work outside configured periods**: Work logged on a day that falls outside all configured periods
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Query System
|
## Query System
|
||||||
|
|
@ -387,40 +341,11 @@ Provide recursive search through the shard tree:
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `streamd new` | Create new timestamped file, open editor, rename with markers on close |
|
| `streamd new` | Create new timestamped file, open editor, rename with markers on close |
|
||||||
| `streamd todo` | List all shards with `task: "open"`, numbered, hiding future tasks |
|
| `streamd todo` | List all shards with `task: "open"` |
|
||||||
| `streamd todo --show-future` | Include tasks with future dates in the todo listing |
|
|
||||||
| `streamd todo N edit` | Edit task N in editor, cursor positioned at task line |
|
|
||||||
| `streamd todo N done` | Mark task N as done by inserting `@Done` after `@Task` |
|
|
||||||
| `streamd edit [n]` | Edit nth file (supports negative indexing for recent files) |
|
| `streamd edit [n]` | Edit nth file (supports negative indexing for recent files) |
|
||||||
| `streamd timesheet` | Generate formatted timesheet report with expected/actual hours |
|
| `streamd timesheet` | Extract and export timesheet data as CSV |
|
||||||
| `streamd completions <shell>` | Generate shell completions (bash, zsh, fish, elvish, powershell) |
|
| `streamd completions <shell>` | Generate shell completions (bash, zsh, fish, elvish, powershell) |
|
||||||
|
|
||||||
### R21: Todo Command Behavior
|
|
||||||
|
|
||||||
**Task Numbering:**
|
|
||||||
- Tasks are numbered starting from 1 (oldest task = 1)
|
|
||||||
- Tasks are sorted by their `moment` field in ascending order
|
|
||||||
- Output format: `[N] --- file.md:line ---` followed by task content
|
|
||||||
|
|
||||||
**Future Task Filtering:**
|
|
||||||
- By default, tasks with `moment > now` are hidden from the listing
|
|
||||||
- The `--show-future` flag includes all tasks regardless of their moment
|
|
||||||
- When using `todo N edit` or `todo N done`, all tasks (including future) are considered for number lookup
|
|
||||||
|
|
||||||
**Edit Action (`todo N edit`):**
|
|
||||||
- Opens the task's file in `$EDITOR` (defaults to `vi`)
|
|
||||||
- Uses `+LINE` argument to position cursor at task's start line
|
|
||||||
- Errors if N is 0 or exceeds the task count
|
|
||||||
|
|
||||||
**Done Action (`todo N done`):**
|
|
||||||
- Reads the file and modifies the line at task's start_line
|
|
||||||
- Inserts ` @Done` immediately after `@Task`
|
|
||||||
- Preserves trailing newline if the original file had one
|
|
||||||
- Errors if:
|
|
||||||
- N is 0 or exceeds the task count
|
|
||||||
- Multiple `@Task` markers found on the same line
|
|
||||||
- No `@Task` marker found on the expected line
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Application Configuration
|
## Application Configuration
|
||||||
|
|
|
||||||
18
flake.lock
generated
18
flake.lock
generated
|
|
@ -40,11 +40,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775036584,
|
"lastModified": 1774861927,
|
||||||
"narHash": "sha256-zW0lyy7ZNNT/x8JhzFHBsP2IPx7ATZIPai4FJj12BgU=",
|
"narHash": "sha256-FB1fbeJQjaTMI2JFAa0LNMaYXiShiYbJA6puGQC4xdg=",
|
||||||
"owner": "cachix",
|
"owner": "cachix",
|
||||||
"repo": "git-hooks.nix",
|
"repo": "git-hooks.nix",
|
||||||
"rev": "4e0eb042b67d863b1b34b3f64d52ceb9cd926735",
|
"rev": "9c4469b68b62e122c3b3d2ab0ed3caeb04ff1ac4",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -76,11 +76,11 @@
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775036866,
|
"lastModified": 1774709303,
|
||||||
"narHash": "sha256-ZojAnPuCdy657PbTq5V0Y+AHKhZAIwSIT2cb8UgAz/U=",
|
"narHash": "sha256-D3Q07BbIA2KnTcSXIqqu9P586uWxN74zNoCH3h2ESHg=",
|
||||||
"owner": "nixos",
|
"owner": "nixos",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "6201e203d09599479a3b3450ed24fa81537ebc4e",
|
"rev": "8110df5ad7abf5d4c0f6fb0f8f978390e77f9685",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
@ -105,11 +105,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1775099554,
|
"lastModified": 1774840424,
|
||||||
"narHash": "sha256-3xBsGnGDLOFtnPZ1D3j2LU19wpAlYefRKTlkv648rU0=",
|
"narHash": "sha256-3Oi4mBKzOCFQYLUyEjyc0s5cnlNj1MzmhpVKoLptpe8=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "8d6387ed6d8e6e6672fd3ed4b61b59d44b124d99",
|
"rev": "d9f52b51548e76ab8b6e7d647763047ebdec835c",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|
|
||||||
|
|
@ -13,34 +13,13 @@ pub struct Cli {
|
||||||
pub command: Option<Commands>,
|
pub command: Option<Commands>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
|
||||||
pub enum TodoAction {
|
|
||||||
/// Edit a task by its number
|
|
||||||
Edit {
|
|
||||||
/// Task number to edit
|
|
||||||
number: usize,
|
|
||||||
},
|
|
||||||
/// Mark a task as done
|
|
||||||
Done {
|
|
||||||
/// Task number to mark as done
|
|
||||||
number: usize,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
pub enum Commands {
|
pub enum Commands {
|
||||||
/// Create a new stream file
|
/// Create a new stream file
|
||||||
New,
|
New,
|
||||||
|
|
||||||
/// Display open tasks
|
/// Display open tasks
|
||||||
Todo {
|
Todo,
|
||||||
/// Show tasks with dates in the future
|
|
||||||
#[arg(long)]
|
|
||||||
show_future: bool,
|
|
||||||
|
|
||||||
#[command(subcommand)]
|
|
||||||
action: Option<TodoAction>,
|
|
||||||
},
|
|
||||||
|
|
||||||
/// Edit a stream file by position
|
/// Edit a stream file by position
|
||||||
Edit {
|
Edit {
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,19 @@
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use chrono::Datelike;
|
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::config::Settings;
|
use crate::config::Settings;
|
||||||
|
|
||||||
const SEPARATOR_WIDTH: usize = 71;
|
|
||||||
const COLUMN_SEPARATOR_WIDTH: usize = 65;
|
|
||||||
use crate::error::StreamdError;
|
use crate::error::StreamdError;
|
||||||
use crate::extract::parse_markdown_file;
|
use crate::extract::parse_markdown_file;
|
||||||
use crate::localize::localize_stream_file;
|
use crate::localize::localize_stream_file;
|
||||||
use crate::models::LocalizedShard;
|
use crate::models::LocalizedShard;
|
||||||
use crate::timesheet::{
|
use crate::timesheet::{extract_timesheets, BasicTimesheetConfiguration};
|
||||||
extract_timesheets, generate_report, load_repository_config, BasicTimesheetConfiguration,
|
|
||||||
DayType, DayWarning, MonthReport, TimesheetReport,
|
|
||||||
};
|
|
||||||
|
|
||||||
fn load_all_shards(base_folder: &Path) -> Result<Vec<LocalizedShard>, StreamdError> {
|
fn all_files() -> Result<Vec<LocalizedShard>, StreamdError> {
|
||||||
|
let settings = Settings::load()?;
|
||||||
let mut shards = Vec::new();
|
let mut shards = Vec::new();
|
||||||
|
|
||||||
for entry in WalkDir::new(base_folder)
|
for entry in WalkDir::new(&settings.base_folder)
|
||||||
.max_depth(1)
|
.max_depth(1)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
|
|
@ -40,237 +33,20 @@ fn load_all_shards(base_folder: &Path) -> Result<Vec<LocalizedShard>, StreamdErr
|
||||||
Ok(shards)
|
Ok(shards)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format hours with sign for display.
|
|
||||||
fn format_diff(hours: f64) -> String {
|
|
||||||
if hours >= 0.0 {
|
|
||||||
format!("+{:.1}h", hours.abs())
|
|
||||||
} else {
|
|
||||||
format!("{:.1}h", hours)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Format hours for display without sign.
|
|
||||||
fn format_hours(hours: f64) -> String {
|
|
||||||
format!("{:.1}h", hours.abs())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the weekday abbreviation.
|
|
||||||
fn weekday_abbrev(date: chrono::NaiveDate) -> &'static str {
|
|
||||||
match date.weekday() {
|
|
||||||
chrono::Weekday::Mon => "Mon",
|
|
||||||
chrono::Weekday::Tue => "Tue",
|
|
||||||
chrono::Weekday::Wed => "Wed",
|
|
||||||
chrono::Weekday::Thu => "Thu",
|
|
||||||
chrono::Weekday::Fri => "Fri",
|
|
||||||
chrono::Weekday::Sat => "Sat",
|
|
||||||
chrono::Weekday::Sun => "Sun",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print the timesheet report header.
|
|
||||||
fn print_header() {
|
|
||||||
let double_line = "\u{2550}".repeat(SEPARATOR_WIDTH);
|
|
||||||
println!("{}", double_line);
|
|
||||||
println!(" TIMESHEET REPORT");
|
|
||||||
println!("{}", double_line);
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print a month report.
|
|
||||||
fn print_month(month: &MonthReport) {
|
|
||||||
let diff_str = format_diff(month.diff());
|
|
||||||
let month_title = format!("{} {}", month.month_name(), month.year);
|
|
||||||
|
|
||||||
// Month header with diff
|
|
||||||
print!("\u{2550}\u{2550}\u{2550} {} ", month_title);
|
|
||||||
let padding = 52 - month_title.len() - diff_str.len();
|
|
||||||
for _ in 0..padding {
|
|
||||||
print!("\u{2550}");
|
|
||||||
}
|
|
||||||
println!(" Diff: {} \u{2550}\u{2550}\u{2550}", diff_str);
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Column headers
|
|
||||||
let light_line = "\u{2500}".repeat(COLUMN_SEPARATOR_WIDTH);
|
|
||||||
println!(" Date Day Expected Actual Diff Type");
|
|
||||||
println!(" {}", light_line);
|
|
||||||
|
|
||||||
// Day rows
|
|
||||||
for day in &month.days {
|
|
||||||
let date_str = day.date.format("%Y-%m-%d").to_string();
|
|
||||||
let weekday = weekday_abbrev(day.date);
|
|
||||||
let expected = format_hours(day.expected_hours);
|
|
||||||
let actual = format_hours(day.actual_hours);
|
|
||||||
let diff = format_diff(day.diff());
|
|
||||||
|
|
||||||
let type_str = match day.day_type {
|
|
||||||
DayType::Regular => String::new(),
|
|
||||||
DayType::Missing if day.has_warnings() => "\u{26a0} Missing".to_string(),
|
|
||||||
_ => day.day_type.to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add overlap warning indicator
|
|
||||||
let type_str = if day
|
|
||||||
.warnings
|
|
||||||
.iter()
|
|
||||||
.any(|w| matches!(w, DayWarning::OverlappingTimecards { .. }))
|
|
||||||
{
|
|
||||||
if type_str.is_empty() {
|
|
||||||
"\u{26a0} Overlap".to_string()
|
|
||||||
} else {
|
|
||||||
format!("{} \u{26a0}", type_str)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
type_str
|
|
||||||
};
|
|
||||||
|
|
||||||
println!(
|
|
||||||
" {} {} {:>7} {:>7} {:>6} {}",
|
|
||||||
date_str, weekday, expected, actual, diff, type_str
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Monthly totals
|
|
||||||
println!(" {}", light_line);
|
|
||||||
println!(
|
|
||||||
" Monthly: {:>7} {:>7} {:>6}",
|
|
||||||
format_hours(month.total_expected()),
|
|
||||||
format_hours(month.total_actual()),
|
|
||||||
format_diff(month.diff())
|
|
||||||
);
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print the cumulative balance.
|
|
||||||
fn print_cumulative_balance(balance: f64) {
|
|
||||||
let light_line = "\u{2500}".repeat(SEPARATOR_WIDTH);
|
|
||||||
println!("{}", light_line);
|
|
||||||
println!(
|
|
||||||
" CUMULATIVE BALANCE: {}",
|
|
||||||
format_diff(balance)
|
|
||||||
);
|
|
||||||
println!("{}", light_line);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Print warnings section.
|
|
||||||
fn print_warnings(report: &TimesheetReport) {
|
|
||||||
if !report.has_warnings() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
println!();
|
|
||||||
println!("\u{26a0} Warnings:");
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Group warnings by type
|
|
||||||
let missing_warnings: Vec<_> = report
|
|
||||||
.warnings
|
|
||||||
.iter()
|
|
||||||
.filter(|w| matches!(w.warning, DayWarning::MissingWithoutExplanation))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let overlap_warnings: Vec<_> = report
|
|
||||||
.warnings
|
|
||||||
.iter()
|
|
||||||
.filter(|w| matches!(w.warning, DayWarning::OverlappingTimecards { .. }))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let outside_period_warnings: Vec<_> = report
|
|
||||||
.warnings
|
|
||||||
.iter()
|
|
||||||
.filter(|w| matches!(w.warning, DayWarning::OutsidePeriod { .. }))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if !missing_warnings.is_empty() {
|
|
||||||
println!(" Missing days without explanation:");
|
|
||||||
for w in &missing_warnings {
|
|
||||||
let weekday = weekday_abbrev(w.date);
|
|
||||||
println!(
|
|
||||||
" - {} ({}): No entries and no leave/holiday marker",
|
|
||||||
w.date.format("%Y-%m-%d"),
|
|
||||||
weekday
|
|
||||||
);
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
if !overlap_warnings.is_empty() {
|
|
||||||
println!(" Overlapping timecards:");
|
|
||||||
for w in &overlap_warnings {
|
|
||||||
if let DayWarning::OverlappingTimecards { first, second } = &w.warning {
|
|
||||||
println!(
|
|
||||||
" - {}: {}-{} overlaps with {}-{}",
|
|
||||||
w.date.format("%Y-%m-%d"),
|
|
||||||
first.0.format("%H:%M"),
|
|
||||||
first.1.format("%H:%M"),
|
|
||||||
second.0.format("%H:%M"),
|
|
||||||
second.1.format("%H:%M")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
|
|
||||||
if !outside_period_warnings.is_empty() {
|
|
||||||
println!(" Work logged outside configured periods:");
|
|
||||||
for w in &outside_period_warnings {
|
|
||||||
if let DayWarning::OutsidePeriod { hours_worked } = &w.warning {
|
|
||||||
println!(
|
|
||||||
" - {}: {:.1}h worked (no period configured)",
|
|
||||||
w.date.format("%Y-%m-%d"),
|
|
||||||
hours_worked
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run() -> Result<(), StreamdError> {
|
pub fn run() -> Result<(), StreamdError> {
|
||||||
let settings = Settings::load()?;
|
let all_shards = all_files()?;
|
||||||
let base_folder = Path::new(&settings.base_folder);
|
let mut sheets = extract_timesheets(&all_shards)?;
|
||||||
|
sheets.sort_by_key(|s| s.date);
|
||||||
|
|
||||||
// Load repository configuration
|
for sheet in sheets {
|
||||||
let repo_config = load_repository_config(base_folder)?;
|
println!("{}", sheet.date);
|
||||||
|
let times: Vec<String> = sheet
|
||||||
// Check if timesheet is configured
|
.timecards
|
||||||
let timesheet_config = match repo_config.timesheet {
|
.iter()
|
||||||
Some(config) => config,
|
.map(|card| format!("{},{}", card.from_time, card.to_time))
|
||||||
None => {
|
.collect();
|
||||||
println!("No timesheet configuration found in .streamd.toml");
|
println!("{}", times.join(","));
|
||||||
println!();
|
|
||||||
println!("Add a [timesheet] section with periods to enable timesheet reporting:");
|
|
||||||
println!();
|
|
||||||
println!(" [timesheet]");
|
|
||||||
println!(" [[timesheet.periods]]");
|
|
||||||
println!(" start = \"2026-01-01\"");
|
|
||||||
println!(" end = \"2026-12-31\"");
|
|
||||||
println!(" hours_per_week = 40.0");
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Load all markdown files and extract timesheets
|
|
||||||
let all_shards = load_all_shards(base_folder)?;
|
|
||||||
let timesheets = extract_timesheets(&all_shards)?;
|
|
||||||
|
|
||||||
// Generate the report
|
|
||||||
let report = generate_report(×heets, ×heet_config)?;
|
|
||||||
|
|
||||||
if report.months.is_empty() {
|
|
||||||
println!("No timesheet data found for the configured periods.");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Print the report
|
|
||||||
print_header();
|
|
||||||
|
|
||||||
for month in &report.months {
|
|
||||||
print_month(month);
|
|
||||||
}
|
|
||||||
|
|
||||||
print_cumulative_balance(report.cumulative_balance);
|
|
||||||
print_warnings(&report);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use chrono::Utc;
|
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
use crate::config::Settings;
|
use crate::config::Settings;
|
||||||
|
|
@ -35,26 +33,10 @@ fn all_files() -> Result<Vec<LocalizedShard>, StreamdError> {
|
||||||
Ok(shards)
|
Ok(shards)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn collect_open_tasks(show_future: bool) -> Result<Vec<LocalizedShard>, StreamdError> {
|
pub fn run() -> Result<(), StreamdError> {
|
||||||
let all_shards = all_files()?;
|
let all_shards = all_files()?;
|
||||||
let now = Utc::now();
|
|
||||||
|
|
||||||
let mut tasks: Vec<LocalizedShard> = find_shard_by_position(&all_shards, "task", "open")
|
for task_shard in find_shard_by_position(&all_shards, "task", "open") {
|
||||||
.into_iter()
|
|
||||||
.filter(|shard| show_future || shard.moment <= now)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Sort by moment ascending (oldest first = task 1)
|
|
||||||
tasks.sort_by(|a, b| a.moment.cmp(&b.moment));
|
|
||||||
|
|
||||||
Ok(tasks)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_list(show_future: bool) -> Result<(), StreamdError> {
|
|
||||||
let tasks = collect_open_tasks(show_future)?;
|
|
||||||
|
|
||||||
for (index, task_shard) in tasks.iter().enumerate() {
|
|
||||||
let task_number = index + 1; // 1-indexed
|
|
||||||
if let Some(file_path) = task_shard.location.get("file") {
|
if let Some(file_path) = task_shard.location.get("file") {
|
||||||
let content = fs::read_to_string(file_path)?;
|
let content = fs::read_to_string(file_path)?;
|
||||||
let lines: Vec<&str> = content.lines().collect();
|
let lines: Vec<&str> = content.lines().collect();
|
||||||
|
|
@ -62,10 +44,7 @@ pub fn run_list(show_future: bool) -> Result<(), StreamdError> {
|
||||||
let start = task_shard.start_line.saturating_sub(1);
|
let start = task_shard.start_line.saturating_sub(1);
|
||||||
let end = std::cmp::min(task_shard.end_line, lines.len());
|
let end = std::cmp::min(task_shard.end_line, lines.len());
|
||||||
|
|
||||||
println!(
|
println!("--- {}:{} ---", file_path, task_shard.start_line);
|
||||||
"[{}] --- {}:{} ---",
|
|
||||||
task_number, file_path, task_shard.start_line
|
|
||||||
);
|
|
||||||
for line in &lines[start..end] {
|
for line in &lines[start..end] {
|
||||||
println!("{}", line);
|
println!("{}", line);
|
||||||
}
|
}
|
||||||
|
|
@ -75,257 +54,3 @@ pub fn run_list(show_future: bool) -> Result<(), StreamdError> {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run_edit(number: usize) -> Result<(), StreamdError> {
|
|
||||||
// Always include all tasks for edit (user might want to edit a future task)
|
|
||||||
let tasks = collect_open_tasks(true)?;
|
|
||||||
|
|
||||||
if number == 0 || number > tasks.len() {
|
|
||||||
return Err(StreamdError::InvalidTaskNumber(number, tasks.len()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let task = &tasks[number - 1]; // Convert to 0-indexed
|
|
||||||
let file_path = task
|
|
||||||
.location
|
|
||||||
.get("file")
|
|
||||||
.ok_or(StreamdError::MissingFilePath)?;
|
|
||||||
|
|
||||||
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
|
|
||||||
let line_arg = format!("+{}", task.start_line);
|
|
||||||
|
|
||||||
let status = Command::new(&editor)
|
|
||||||
.arg(&line_arg)
|
|
||||||
.arg(file_path)
|
|
||||||
.status()?;
|
|
||||||
|
|
||||||
if !status.success() {
|
|
||||||
return Err(StreamdError::IoError(std::io::Error::other(
|
|
||||||
"Editor exited with non-zero status",
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run_done(number: usize) -> Result<(), StreamdError> {
|
|
||||||
// Always include all tasks for done (user might want to mark a future task as done)
|
|
||||||
let tasks = collect_open_tasks(true)?;
|
|
||||||
|
|
||||||
if number == 0 || number > tasks.len() {
|
|
||||||
return Err(StreamdError::InvalidTaskNumber(number, tasks.len()));
|
|
||||||
}
|
|
||||||
|
|
||||||
let task = &tasks[number - 1];
|
|
||||||
let file_path = task
|
|
||||||
.location
|
|
||||||
.get("file")
|
|
||||||
.ok_or(StreamdError::MissingFilePath)?;
|
|
||||||
|
|
||||||
let content = fs::read_to_string(file_path)?;
|
|
||||||
let mut lines: Vec<String> = content.lines().map(String::from).collect();
|
|
||||||
|
|
||||||
// Find the line containing @Task (should be at start_line)
|
|
||||||
let task_line_idx = task.start_line.saturating_sub(1);
|
|
||||||
if task_line_idx >= lines.len() {
|
|
||||||
return Err(StreamdError::InvalidLineNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
let line = &lines[task_line_idx];
|
|
||||||
|
|
||||||
// Check for multiple @Task occurrences
|
|
||||||
let task_count = line.matches("@Task").count();
|
|
||||||
if task_count > 1 {
|
|
||||||
return Err(StreamdError::MultipleTaskMarkers(
|
|
||||||
file_path.clone(),
|
|
||||||
task.start_line,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if task_count == 0 {
|
|
||||||
return Err(StreamdError::NoTaskMarker(
|
|
||||||
file_path.clone(),
|
|
||||||
task.start_line,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert @Done after @Task
|
|
||||||
let new_line = line.replacen("@Task", "@Task @Done", 1);
|
|
||||||
lines[task_line_idx] = new_line;
|
|
||||||
|
|
||||||
// Write back to file, preserving trailing newline if present
|
|
||||||
let new_content = if content.ends_with('\n') {
|
|
||||||
format!("{}\n", lines.join("\n"))
|
|
||||||
} else {
|
|
||||||
lines.join("\n")
|
|
||||||
};
|
|
||||||
fs::write(file_path, new_content)?;
|
|
||||||
|
|
||||||
println!("Marked task {} as done", number);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run() -> Result<(), StreamdError> {
|
|
||||||
run_list(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use chrono::{Duration, TimeZone};
|
|
||||||
use indexmap::IndexMap;
|
|
||||||
|
|
||||||
fn make_task_shard(moment: chrono::DateTime<Utc>, file: &str) -> LocalizedShard {
|
|
||||||
let mut location = IndexMap::new();
|
|
||||||
location.insert("file".to_string(), file.to_string());
|
|
||||||
location.insert("task".to_string(), "open".to_string());
|
|
||||||
|
|
||||||
LocalizedShard {
|
|
||||||
markers: vec!["Task".to_string()],
|
|
||||||
tags: vec![],
|
|
||||||
start_line: 1,
|
|
||||||
end_line: 1,
|
|
||||||
moment,
|
|
||||||
location,
|
|
||||||
children: vec![],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_insert_done_after_task() {
|
|
||||||
let line = "Some content @Task with more text";
|
|
||||||
let result = line.replacen("@Task", "@Task @Done", 1);
|
|
||||||
assert_eq!(result, "Some content @Task @Done with more text");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_insert_done_at_line_end() {
|
|
||||||
let line = "Some content @Task";
|
|
||||||
let result = line.replacen("@Task", "@Task @Done", 1);
|
|
||||||
assert_eq!(result, "Some content @Task @Done");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_insert_done_only_first_task() {
|
|
||||||
let line = "Some @Task content @Task again";
|
|
||||||
let result = line.replacen("@Task", "@Task @Done", 1);
|
|
||||||
assert_eq!(result, "Some @Task @Done content @Task again");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_task_count_single() {
|
|
||||||
let line = "Some content @Task with more text";
|
|
||||||
assert_eq!(line.matches("@Task").count(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_task_count_multiple() {
|
|
||||||
let line = "Some @Task content @Task again";
|
|
||||||
assert_eq!(line.matches("@Task").count(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_task_count_none() {
|
|
||||||
let line = "Some content without task marker";
|
|
||||||
assert_eq!(line.matches("@Task").count(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_filter_future_tasks_excludes_future_when_show_future_false() {
|
|
||||||
let now = Utc::now();
|
|
||||||
let past = now - Duration::hours(1);
|
|
||||||
let future = now + Duration::hours(1);
|
|
||||||
|
|
||||||
let tasks = vec![
|
|
||||||
make_task_shard(past, "past.md"),
|
|
||||||
make_task_shard(future, "future.md"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let filtered: Vec<_> = tasks
|
|
||||||
.into_iter()
|
|
||||||
.filter(|shard| shard.moment <= now)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
assert_eq!(filtered.len(), 1);
|
|
||||||
assert_eq!(filtered[0].location.get("file").unwrap(), "past.md");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_filter_future_tasks_includes_all_when_show_future_true() {
|
|
||||||
let now = Utc::now();
|
|
||||||
let past = now - Duration::hours(1);
|
|
||||||
let future = now + Duration::hours(1);
|
|
||||||
|
|
||||||
let tasks = vec![
|
|
||||||
make_task_shard(past, "past.md"),
|
|
||||||
make_task_shard(future, "future.md"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let filtered: Vec<_> = tasks.into_iter().filter(|_| true).collect();
|
|
||||||
|
|
||||||
assert_eq!(filtered.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_sort_tasks_by_moment_ascending() {
|
|
||||||
let oldest = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap();
|
|
||||||
let middle = Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap();
|
|
||||||
let newest = Utc.with_ymd_and_hms(2022, 1, 1, 0, 0, 0).unwrap();
|
|
||||||
|
|
||||||
let mut tasks = [
|
|
||||||
make_task_shard(newest, "newest.md"),
|
|
||||||
make_task_shard(oldest, "oldest.md"),
|
|
||||||
make_task_shard(middle, "middle.md"),
|
|
||||||
];
|
|
||||||
|
|
||||||
tasks.sort_by(|a, b| a.moment.cmp(&b.moment));
|
|
||||||
|
|
||||||
assert_eq!(tasks[0].location.get("file").unwrap(), "oldest.md");
|
|
||||||
assert_eq!(tasks[1].location.get("file").unwrap(), "middle.md");
|
|
||||||
assert_eq!(tasks[2].location.get("file").unwrap(), "newest.md");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_preserve_trailing_newline() {
|
|
||||||
let content_with_newline = "line1\nline2\n";
|
|
||||||
let content_without_newline = "line1\nline2";
|
|
||||||
|
|
||||||
assert!(content_with_newline.ends_with('\n'));
|
|
||||||
assert!(!content_without_newline.ends_with('\n'));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_invalid_task_number_zero() {
|
|
||||||
let result = StreamdError::InvalidTaskNumber(0, 5);
|
|
||||||
assert_eq!(
|
|
||||||
result.to_string(),
|
|
||||||
"Invalid task number 0: only 5 tasks available"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_invalid_task_number_exceeds_count() {
|
|
||||||
let result = StreamdError::InvalidTaskNumber(10, 3);
|
|
||||||
assert_eq!(
|
|
||||||
result.to_string(),
|
|
||||||
"Invalid task number 10: only 3 tasks available"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_multiple_task_markers_error_message() {
|
|
||||||
let result = StreamdError::MultipleTaskMarkers("/path/file.md".to_string(), 42);
|
|
||||||
assert_eq!(
|
|
||||||
result.to_string(),
|
|
||||||
"Multiple @Task markers found in /path/file.md:42 - cannot auto-insert @Done"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_no_task_marker_error_message() {
|
|
||||||
let result = StreamdError::NoTaskMarker("/path/file.md".to_string(), 42);
|
|
||||||
assert_eq!(
|
|
||||||
result.to_string(),
|
|
||||||
"No @Task marker found in /path/file.md:42"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
pub mod args;
|
pub mod args;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
|
||||||
pub use args::{Cli, Commands, TodoAction};
|
pub use args::{Cli, Commands};
|
||||||
|
|
|
||||||
15
src/error.rs
15
src/error.rs
|
|
@ -16,21 +16,6 @@ pub enum StreamdError {
|
||||||
|
|
||||||
#[error("TOML error: {0}")]
|
#[error("TOML error: {0}")]
|
||||||
TomlError(#[from] toml::de::Error),
|
TomlError(#[from] toml::de::Error),
|
||||||
|
|
||||||
#[error("Invalid task number {0}: only {1} tasks available")]
|
|
||||||
InvalidTaskNumber(usize, usize),
|
|
||||||
|
|
||||||
#[error("Task shard missing file path")]
|
|
||||||
MissingFilePath,
|
|
||||||
|
|
||||||
#[error("Invalid line number in task")]
|
|
||||||
InvalidLineNumber,
|
|
||||||
|
|
||||||
#[error("Multiple @Task markers found in {0}:{1} - cannot auto-insert @Done")]
|
|
||||||
MultipleTaskMarkers(String, usize),
|
|
||||||
|
|
||||||
#[error("No @Task marker found in {0}:{1}")]
|
|
||||||
NoTaskMarker(String, usize),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<StreamdError> for miette::Report {
|
impl From<StreamdError> for miette::Report {
|
||||||
|
|
|
||||||
11
src/main.rs
11
src/main.rs
|
|
@ -1,19 +1,12 @@
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use streamd::cli::{Cli, Commands, TodoAction};
|
use streamd::cli::{Cli, Commands};
|
||||||
|
|
||||||
fn main() -> miette::Result<()> {
|
fn main() -> miette::Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Some(Commands::New) => streamd::cli::commands::new::run()?,
|
Some(Commands::New) => streamd::cli::commands::new::run()?,
|
||||||
Some(Commands::Todo {
|
Some(Commands::Todo) => streamd::cli::commands::todo::run()?,
|
||||||
show_future,
|
|
||||||
action,
|
|
||||||
}) => match action {
|
|
||||||
None => streamd::cli::commands::todo::run_list(show_future)?,
|
|
||||||
Some(TodoAction::Edit { number }) => streamd::cli::commands::todo::run_edit(number)?,
|
|
||||||
Some(TodoAction::Done { number }) => streamd::cli::commands::todo::run_done(number)?,
|
|
||||||
},
|
|
||||||
Some(Commands::Edit { number }) => streamd::cli::commands::edit::run(number)?,
|
Some(Commands::Edit { number }) => streamd::cli::commands::edit::run(number)?,
|
||||||
Some(Commands::Timesheet) => streamd::cli::commands::timesheet::run()?,
|
Some(Commands::Timesheet) => streamd::cli::commands::timesheet::run()?,
|
||||||
Some(Commands::Completions { shell }) => {
|
Some(Commands::Completions { shell }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,258 +0,0 @@
|
||||||
use chrono::NaiveDate;
|
|
||||||
use serde::Deserialize;
|
|
||||||
|
|
||||||
use crate::error::StreamdError;
|
|
||||||
|
|
||||||
/// Configuration for timesheet periods and timezone.
|
|
||||||
#[derive(Debug, Clone, Deserialize, Default)]
|
|
||||||
pub struct TimesheetConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub periods: Vec<Period>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A period of time with expected working hours per week.
|
|
||||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
|
||||||
pub struct Period {
|
|
||||||
pub start: NaiveDate,
|
|
||||||
pub end: NaiveDate,
|
|
||||||
pub hours_per_week: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Period {
|
|
||||||
/// Calculate the expected hours per day (Mon-Fri distribution).
|
|
||||||
pub fn hours_per_day(&self) -> f64 {
|
|
||||||
self.hours_per_week / 5.0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if a date falls within this period.
|
|
||||||
pub fn contains(&self, date: NaiveDate) -> bool {
|
|
||||||
date >= self.start && date <= self.end
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TimesheetConfig {
|
|
||||||
/// Validate the timesheet configuration.
|
|
||||||
/// - Ensures no periods overlap
|
|
||||||
/// - Ensures start <= end for each period
|
|
||||||
pub fn validate(&self) -> Result<(), StreamdError> {
|
|
||||||
// Check each period has valid date range
|
|
||||||
for period in &self.periods {
|
|
||||||
if period.start > period.end {
|
|
||||||
return Err(StreamdError::ConfigError(format!(
|
|
||||||
"Period start date {} is after end date {}",
|
|
||||||
period.start, period.end
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for overlapping periods
|
|
||||||
for i in 0..self.periods.len() {
|
|
||||||
for j in (i + 1)..self.periods.len() {
|
|
||||||
if periods_overlap(&self.periods[i], &self.periods[j]) {
|
|
||||||
return Err(StreamdError::ConfigError(format!(
|
|
||||||
"Periods overlap: {}-{} and {}-{}",
|
|
||||||
self.periods[i].start,
|
|
||||||
self.periods[i].end,
|
|
||||||
self.periods[j].start,
|
|
||||||
self.periods[j].end
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the period that contains a given date.
|
|
||||||
pub fn find_period(&self, date: NaiveDate) -> Option<&Period> {
|
|
||||||
self.periods.iter().find(|p| p.contains(date))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if two periods overlap.
|
|
||||||
fn periods_overlap(a: &Period, b: &Period) -> bool {
|
|
||||||
a.start <= b.end && b.start <= a.end
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Repository-level configuration loaded from .streamd.toml.
|
|
||||||
#[derive(Debug, Clone, Deserialize, Default)]
|
|
||||||
pub struct RepositoryConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub timezone: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub timesheet: Option<TimesheetConfig>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
|
|
||||||
NaiveDate::from_ymd_opt(year, month, day).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_period_contains_date() {
|
|
||||||
let period = Period {
|
|
||||||
start: date(2026, 1, 1),
|
|
||||||
end: date(2026, 6, 30),
|
|
||||||
hours_per_week: 38.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(period.contains(date(2026, 1, 1)));
|
|
||||||
assert!(period.contains(date(2026, 3, 15)));
|
|
||||||
assert!(period.contains(date(2026, 6, 30)));
|
|
||||||
assert!(!period.contains(date(2025, 12, 31)));
|
|
||||||
assert!(!period.contains(date(2026, 7, 1)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_period_hours_per_day() {
|
|
||||||
let period = Period {
|
|
||||||
start: date(2026, 1, 1),
|
|
||||||
end: date(2026, 6, 30),
|
|
||||||
hours_per_week: 38.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!((period.hours_per_day() - 7.6).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_validate_valid_config() {
|
|
||||||
let config = TimesheetConfig {
|
|
||||||
periods: vec![
|
|
||||||
Period {
|
|
||||||
start: date(2026, 1, 1),
|
|
||||||
end: date(2026, 6, 30),
|
|
||||||
hours_per_week: 38.0,
|
|
||||||
},
|
|
||||||
Period {
|
|
||||||
start: date(2026, 7, 1),
|
|
||||||
end: date(2026, 12, 31),
|
|
||||||
hours_per_week: 40.0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(config.validate().is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_validate_overlapping_periods() {
|
|
||||||
let config = TimesheetConfig {
|
|
||||||
periods: vec![
|
|
||||||
Period {
|
|
||||||
start: date(2026, 1, 1),
|
|
||||||
end: date(2026, 6, 30),
|
|
||||||
hours_per_week: 38.0,
|
|
||||||
},
|
|
||||||
Period {
|
|
||||||
start: date(2026, 6, 15),
|
|
||||||
end: date(2026, 12, 31),
|
|
||||||
hours_per_week: 40.0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = config.validate();
|
|
||||||
assert!(result.is_err());
|
|
||||||
assert!(result.unwrap_err().to_string().contains("overlap"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_validate_start_after_end() {
|
|
||||||
let config = TimesheetConfig {
|
|
||||||
periods: vec![Period {
|
|
||||||
start: date(2026, 6, 30),
|
|
||||||
end: date(2026, 1, 1),
|
|
||||||
hours_per_week: 38.0,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = config.validate();
|
|
||||||
assert!(result.is_err());
|
|
||||||
assert!(result.unwrap_err().to_string().contains("after"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_find_period() {
|
|
||||||
let config = TimesheetConfig {
|
|
||||||
periods: vec![
|
|
||||||
Period {
|
|
||||||
start: date(2026, 1, 1),
|
|
||||||
end: date(2026, 6, 30),
|
|
||||||
hours_per_week: 38.0,
|
|
||||||
},
|
|
||||||
Period {
|
|
||||||
start: date(2026, 7, 1),
|
|
||||||
end: date(2026, 12, 31),
|
|
||||||
hours_per_week: 40.0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
let period = config.find_period(date(2026, 3, 15));
|
|
||||||
assert!(period.is_some());
|
|
||||||
assert!((period.unwrap().hours_per_week - 38.0).abs() < 0.0001);
|
|
||||||
|
|
||||||
let period = config.find_period(date(2026, 9, 15));
|
|
||||||
assert!(period.is_some());
|
|
||||||
assert!((period.unwrap().hours_per_week - 40.0).abs() < 0.0001);
|
|
||||||
|
|
||||||
let period = config.find_period(date(2025, 12, 15));
|
|
||||||
assert!(period.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_config_is_valid() {
|
|
||||||
let config = TimesheetConfig { periods: vec![] };
|
|
||||||
assert!(config.validate().is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_adjacent_periods_not_overlapping() {
|
|
||||||
let config = TimesheetConfig {
|
|
||||||
periods: vec![
|
|
||||||
Period {
|
|
||||||
start: date(2026, 1, 1),
|
|
||||||
end: date(2026, 6, 30),
|
|
||||||
hours_per_week: 38.0,
|
|
||||||
},
|
|
||||||
Period {
|
|
||||||
start: date(2026, 7, 1),
|
|
||||||
end: date(2026, 12, 31),
|
|
||||||
hours_per_week: 40.0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
assert!(config.validate().is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_deserialize_repository_config() {
|
|
||||||
let toml_str = r#"
|
|
||||||
timezone = "Europe/Berlin"
|
|
||||||
|
|
||||||
[timesheet]
|
|
||||||
[[timesheet.periods]]
|
|
||||||
start = "2026-01-01"
|
|
||||||
end = "2026-06-30"
|
|
||||||
hours_per_week = 38.0
|
|
||||||
|
|
||||||
[[timesheet.periods]]
|
|
||||||
start = "2026-07-01"
|
|
||||||
end = "2026-12-31"
|
|
||||||
hours_per_week = 40.0
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let config: RepositoryConfig = toml::from_str(toml_str).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(config.timezone, Some("Europe/Berlin".to_string()));
|
|
||||||
assert!(config.timesheet.is_some());
|
|
||||||
let timesheet = config.timesheet.unwrap();
|
|
||||||
assert_eq!(timesheet.periods.len(), 2);
|
|
||||||
assert!((timesheet.periods[0].hours_per_week - 38.0).abs() < 0.0001);
|
|
||||||
assert!((timesheet.periods[1].hours_per_week - 40.0).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -136,12 +136,8 @@ fn aggregate_timecard_day(points: &[TimesheetPoint]) -> Result<Option<Timesheet>
|
||||||
fn aggregate_timecards(points: &[TimesheetPoint]) -> Result<Vec<Timesheet>, StreamdError> {
|
fn aggregate_timecards(points: &[TimesheetPoint]) -> Result<Vec<Timesheet>, StreamdError> {
|
||||||
let mut timesheets = Vec::new();
|
let mut timesheets = Vec::new();
|
||||||
|
|
||||||
// Sort points by moment to ensure proper grouping
|
|
||||||
let mut sorted_points = points.to_vec();
|
|
||||||
sorted_points.sort_by_key(|p| p.moment);
|
|
||||||
|
|
||||||
// Group by date
|
// Group by date
|
||||||
for (_date, group) in &sorted_points.iter().chunk_by(|p| p.moment.date_naive()) {
|
for (_date, group) in &points.iter().chunk_by(|p| p.moment.date_naive()) {
|
||||||
let day_points: Vec<_> = group.cloned().collect();
|
let day_points: Vec<_> = group.cloned().collect();
|
||||||
if let Some(timesheet) = aggregate_timecard_day(&day_points)? {
|
if let Some(timesheet) = aggregate_timecard_day(&day_points)? {
|
||||||
timesheets.push(timesheet);
|
timesheets.push(timesheet);
|
||||||
|
|
|
||||||
|
|
@ -1,542 +0,0 @@
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::fs;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use chrono::{Datelike, NaiveDate, Weekday};
|
|
||||||
|
|
||||||
use crate::error::StreamdError;
|
|
||||||
use crate::models::{SpecialDayType, Timesheet};
|
|
||||||
|
|
||||||
use super::config::{RepositoryConfig, TimesheetConfig};
|
|
||||||
use super::report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport};
|
|
||||||
use super::validation::find_overlapping_timecards;
|
|
||||||
|
|
||||||
/// Load repository configuration from .streamd.toml file.
|
|
||||||
pub fn load_repository_config(base_folder: &Path) -> Result<RepositoryConfig, StreamdError> {
|
|
||||||
let config_path = base_folder.join(".streamd.toml");
|
|
||||||
|
|
||||||
if !config_path.exists() {
|
|
||||||
return Ok(RepositoryConfig::default());
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = fs::read_to_string(&config_path)?;
|
|
||||||
let config: RepositoryConfig = toml::from_str(&content)?;
|
|
||||||
|
|
||||||
// Validate timesheet config if present
|
|
||||||
if let Some(ref timesheet) = config.timesheet {
|
|
||||||
timesheet.validate()?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(config)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate total hours worked from timecards.
|
|
||||||
fn calculate_timecard_hours(timesheet: &Timesheet) -> f64 {
|
|
||||||
timesheet
|
|
||||||
.timecards
|
|
||||||
.iter()
|
|
||||||
.map(|tc| {
|
|
||||||
let duration = tc.to_time - tc.from_time;
|
|
||||||
duration.num_minutes() as f64 / 60.0
|
|
||||||
})
|
|
||||||
.sum()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Determine the day type based on timesheet data.
|
|
||||||
fn determine_day_type(date: NaiveDate, timesheet: Option<&Timesheet>, has_period: bool) -> DayType {
|
|
||||||
let weekday = date.weekday();
|
|
||||||
|
|
||||||
// Check weekend first
|
|
||||||
if weekday == Weekday::Sat || weekday == Weekday::Sun {
|
|
||||||
return DayType::Weekend;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if outside any period
|
|
||||||
if !has_period {
|
|
||||||
return DayType::OutsidePeriod;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check special day types from timesheet
|
|
||||||
if let Some(ts) = timesheet {
|
|
||||||
if let Some(special) = ts.special_day_type {
|
|
||||||
match special {
|
|
||||||
SpecialDayType::Vacation => return DayType::Vacation,
|
|
||||||
SpecialDayType::Holiday => return DayType::Holiday,
|
|
||||||
SpecialDayType::Undertime => return DayType::FlexDay,
|
|
||||||
SpecialDayType::Weekend => return DayType::Weekend,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ts.is_sick_leave {
|
|
||||||
return DayType::SickLeave;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Has timecards or special type
|
|
||||||
return DayType::Regular;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No timesheet entry for a weekday = Missing
|
|
||||||
DayType::Missing
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate expected hours for a day based on period config and day type.
|
|
||||||
fn calculate_expected_hours(day_type: DayType, hours_per_day: f64, _date: NaiveDate) -> f64 {
|
|
||||||
match day_type {
|
|
||||||
DayType::Regular => hours_per_day,
|
|
||||||
DayType::SickLeave => hours_per_day,
|
|
||||||
DayType::Vacation => hours_per_day,
|
|
||||||
DayType::Holiday => 0.0,
|
|
||||||
DayType::FlexDay => hours_per_day,
|
|
||||||
DayType::Weekend => 0.0,
|
|
||||||
DayType::Missing => hours_per_day,
|
|
||||||
DayType::OutsidePeriod => 0.0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate actual hours for a day based on day type rules.
|
|
||||||
fn calculate_actual_hours(day_type: DayType, timecard_hours: f64, expected_hours: f64) -> f64 {
|
|
||||||
match day_type {
|
|
||||||
DayType::Regular => timecard_hours,
|
|
||||||
DayType::SickLeave => expected_hours.max(timecard_hours),
|
|
||||||
DayType::Vacation => expected_hours + timecard_hours,
|
|
||||||
DayType::Holiday => timecard_hours,
|
|
||||||
DayType::FlexDay => 0.0,
|
|
||||||
DayType::Weekend => timecard_hours,
|
|
||||||
DayType::Missing => 0.0,
|
|
||||||
DayType::OutsidePeriod => timecard_hours,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generate a timesheet report from timesheets and configuration.
|
|
||||||
pub fn generate_report(
|
|
||||||
timesheets: &[Timesheet],
|
|
||||||
config: &TimesheetConfig,
|
|
||||||
) -> Result<TimesheetReport, StreamdError> {
|
|
||||||
if config.periods.is_empty() {
|
|
||||||
return Ok(TimesheetReport::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Index timesheets by date for quick lookup
|
|
||||||
let timesheets_by_date: HashMap<NaiveDate, &Timesheet> =
|
|
||||||
timesheets.iter().map(|ts| (ts.date, ts)).collect();
|
|
||||||
|
|
||||||
// Find the date range to report on
|
|
||||||
let earliest_period_start = config.periods.iter().map(|p| p.start).min().unwrap();
|
|
||||||
let latest_period_end = config.periods.iter().map(|p| p.end).max().unwrap();
|
|
||||||
|
|
||||||
// Limit to today
|
|
||||||
let today = chrono::Local::now().date_naive();
|
|
||||||
let end_date = latest_period_end.min(today);
|
|
||||||
|
|
||||||
// Group by month and generate reports
|
|
||||||
let mut month_reports: Vec<MonthReport> = Vec::new();
|
|
||||||
let mut all_warnings: Vec<ReportWarning> = Vec::new();
|
|
||||||
let mut cumulative_balance: f64 = 0.0;
|
|
||||||
|
|
||||||
// Iterate through all dates in the range
|
|
||||||
let mut current_date = earliest_period_start;
|
|
||||||
let mut current_month: Option<(i32, u32)> = None;
|
|
||||||
let mut current_month_days: Vec<DayReport> = Vec::new();
|
|
||||||
|
|
||||||
while current_date <= end_date {
|
|
||||||
let year = current_date.year();
|
|
||||||
let month = current_date.month();
|
|
||||||
|
|
||||||
// Check if we've moved to a new month
|
|
||||||
if current_month != Some((year, month)) {
|
|
||||||
// Save the previous month if it had days
|
|
||||||
if let Some((prev_year, prev_month)) = current_month {
|
|
||||||
if !current_month_days.is_empty() {
|
|
||||||
let month_report =
|
|
||||||
MonthReport::new(prev_year, prev_month).with_days(current_month_days);
|
|
||||||
cumulative_balance += month_report.diff();
|
|
||||||
month_reports.push(month_report);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
current_month = Some((year, month));
|
|
||||||
current_month_days = Vec::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find if this date falls within a period
|
|
||||||
let period = config.find_period(current_date);
|
|
||||||
let has_period = period.is_some();
|
|
||||||
let hours_per_day = period.map(|p| p.hours_per_day()).unwrap_or(0.0);
|
|
||||||
|
|
||||||
// Get timesheet for this date
|
|
||||||
let timesheet = timesheets_by_date.get(¤t_date).copied();
|
|
||||||
let timecard_hours = timesheet.map(calculate_timecard_hours).unwrap_or(0.0);
|
|
||||||
|
|
||||||
// Determine day type
|
|
||||||
let day_type = determine_day_type(current_date, timesheet, has_period);
|
|
||||||
|
|
||||||
// Skip weekends with no work and days outside periods with no work
|
|
||||||
let should_include = match day_type {
|
|
||||||
DayType::Weekend => timecard_hours > 0.0,
|
|
||||||
DayType::OutsidePeriod => timecard_hours > 0.0,
|
|
||||||
_ => has_period, // Only include days within periods
|
|
||||||
};
|
|
||||||
|
|
||||||
if should_include {
|
|
||||||
// Calculate expected and actual hours
|
|
||||||
let expected_hours = calculate_expected_hours(day_type, hours_per_day, current_date);
|
|
||||||
let actual_hours = calculate_actual_hours(day_type, timecard_hours, expected_hours);
|
|
||||||
|
|
||||||
let mut day_report =
|
|
||||||
DayReport::new(current_date, expected_hours, actual_hours, day_type);
|
|
||||||
|
|
||||||
// Collect warnings
|
|
||||||
let mut day_warnings: Vec<DayWarning> = Vec::new();
|
|
||||||
|
|
||||||
// Warning: Missing without explanation
|
|
||||||
if day_type == DayType::Missing {
|
|
||||||
day_warnings.push(DayWarning::MissingWithoutExplanation);
|
|
||||||
all_warnings.push(ReportWarning::new(
|
|
||||||
current_date,
|
|
||||||
DayWarning::MissingWithoutExplanation,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warning: Overlapping timecards
|
|
||||||
if let Some(ts) = timesheet {
|
|
||||||
let overlaps = find_overlapping_timecards(&ts.timecards);
|
|
||||||
for (first, second) in overlaps {
|
|
||||||
let warning = DayWarning::OverlappingTimecards { first, second };
|
|
||||||
day_warnings.push(warning.clone());
|
|
||||||
all_warnings.push(ReportWarning::new(current_date, warning));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warning: Work outside period
|
|
||||||
if day_type == DayType::OutsidePeriod && timecard_hours > 0.0 {
|
|
||||||
let warning = DayWarning::OutsidePeriod {
|
|
||||||
hours_worked: timecard_hours,
|
|
||||||
};
|
|
||||||
day_warnings.push(warning.clone());
|
|
||||||
all_warnings.push(ReportWarning::new(current_date, warning));
|
|
||||||
}
|
|
||||||
|
|
||||||
day_report = day_report.with_warnings(day_warnings);
|
|
||||||
current_month_days.push(day_report);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move to next day
|
|
||||||
current_date = current_date.succ_opt().unwrap_or(current_date);
|
|
||||||
if current_date == end_date && current_date <= latest_period_end {
|
|
||||||
// We've hit end_date, process this day then stop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Don't forget the last month
|
|
||||||
if let Some((year, month)) = current_month {
|
|
||||||
if !current_month_days.is_empty() {
|
|
||||||
let month_report = MonthReport::new(year, month).with_days(current_month_days);
|
|
||||||
cumulative_balance += month_report.diff();
|
|
||||||
month_reports.push(month_report);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort months in ascending order (oldest first, newest at bottom)
|
|
||||||
month_reports.sort_by(|a, b| {
|
|
||||||
let a_date = (a.year, a.month);
|
|
||||||
let b_date = (b.year, b.month);
|
|
||||||
a_date.cmp(&b_date)
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(TimesheetReport::new()
|
|
||||||
.with_months(month_reports)
|
|
||||||
.with_cumulative_balance(cumulative_balance)
|
|
||||||
.with_warnings(all_warnings))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::models::Timecard;
|
|
||||||
use crate::timesheet::Period;
|
|
||||||
use chrono::NaiveTime;
|
|
||||||
|
|
||||||
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
|
|
||||||
NaiveDate::from_ymd_opt(year, month, day).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn time(hour: u32, min: u32) -> NaiveTime {
|
|
||||||
NaiveTime::from_hms_opt(hour, min, 0).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_timesheet(date: NaiveDate, cards: Vec<(u32, u32, u32, u32)>) -> Timesheet {
|
|
||||||
Timesheet {
|
|
||||||
date,
|
|
||||||
is_sick_leave: false,
|
|
||||||
special_day_type: None,
|
|
||||||
timecards: cards
|
|
||||||
.into_iter()
|
|
||||||
.map(|(fh, fm, th, tm)| Timecard::new(time(fh, fm), time(th, tm)))
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_config(start: NaiveDate, end: NaiveDate, hours_per_week: f64) -> TimesheetConfig {
|
|
||||||
TimesheetConfig {
|
|
||||||
periods: vec![Period {
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
hours_per_week,
|
|
||||||
}],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_calculate_timecard_hours() {
|
|
||||||
let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 12, 0), (13, 0, 17, 0)]);
|
|
||||||
let hours = calculate_timecard_hours(&ts);
|
|
||||||
assert!((hours - 7.0).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_calculate_timecard_hours_with_minutes() {
|
|
||||||
let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 12, 30), (13, 0, 17, 15)]);
|
|
||||||
let hours = calculate_timecard_hours(&ts);
|
|
||||||
assert!((hours - 7.75).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_day_type_regular() {
|
|
||||||
// Monday with timesheet
|
|
||||||
let ts = make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]);
|
|
||||||
let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true);
|
|
||||||
assert_eq!(day_type, DayType::Regular);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_day_type_weekend() {
|
|
||||||
// Saturday
|
|
||||||
let day_type = determine_day_type(date(2026, 3, 7), None, true);
|
|
||||||
assert_eq!(day_type, DayType::Weekend);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_day_type_missing() {
|
|
||||||
// Monday with no timesheet
|
|
||||||
let day_type = determine_day_type(date(2026, 3, 2), None, true);
|
|
||||||
assert_eq!(day_type, DayType::Missing);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_day_type_vacation() {
|
|
||||||
let ts = Timesheet {
|
|
||||||
date: date(2026, 3, 2),
|
|
||||||
is_sick_leave: false,
|
|
||||||
special_day_type: Some(SpecialDayType::Vacation),
|
|
||||||
timecards: vec![],
|
|
||||||
};
|
|
||||||
let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true);
|
|
||||||
assert_eq!(day_type, DayType::Vacation);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_day_type_sick_leave() {
|
|
||||||
let ts = Timesheet {
|
|
||||||
date: date(2026, 3, 2),
|
|
||||||
is_sick_leave: true,
|
|
||||||
special_day_type: None,
|
|
||||||
timecards: vec![],
|
|
||||||
};
|
|
||||||
let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true);
|
|
||||||
assert_eq!(day_type, DayType::SickLeave);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_day_type_holiday() {
|
|
||||||
let ts = Timesheet {
|
|
||||||
date: date(2026, 3, 2),
|
|
||||||
is_sick_leave: false,
|
|
||||||
special_day_type: Some(SpecialDayType::Holiday),
|
|
||||||
timecards: vec![],
|
|
||||||
};
|
|
||||||
let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true);
|
|
||||||
assert_eq!(day_type, DayType::Holiday);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_day_type_flex_day() {
|
|
||||||
let ts = Timesheet {
|
|
||||||
date: date(2026, 3, 2),
|
|
||||||
is_sick_leave: false,
|
|
||||||
special_day_type: Some(SpecialDayType::Undertime),
|
|
||||||
timecards: vec![],
|
|
||||||
};
|
|
||||||
let day_type = determine_day_type(date(2026, 3, 2), Some(&ts), true);
|
|
||||||
assert_eq!(day_type, DayType::FlexDay);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_day_type_outside_period() {
|
|
||||||
let day_type = determine_day_type(date(2026, 3, 2), None, false);
|
|
||||||
assert_eq!(day_type, DayType::OutsidePeriod);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_expected_hours_regular() {
|
|
||||||
let hours = calculate_expected_hours(DayType::Regular, 7.6, date(2026, 3, 2));
|
|
||||||
assert!((hours - 7.6).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_expected_hours_holiday() {
|
|
||||||
let hours = calculate_expected_hours(DayType::Holiday, 7.6, date(2026, 3, 2));
|
|
||||||
assert!((hours - 0.0).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_expected_hours_weekend() {
|
|
||||||
let hours = calculate_expected_hours(DayType::Weekend, 7.6, date(2026, 3, 7));
|
|
||||||
assert!((hours - 0.0).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_actual_hours_regular() {
|
|
||||||
let hours = calculate_actual_hours(DayType::Regular, 8.0, 7.6);
|
|
||||||
assert!((hours - 8.0).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_actual_hours_sick_leave_max() {
|
|
||||||
// Sick leave: max(expected, worked)
|
|
||||||
let hours = calculate_actual_hours(DayType::SickLeave, 3.0, 7.6);
|
|
||||||
assert!((hours - 7.6).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_actual_hours_sick_leave_worked_more() {
|
|
||||||
// Sick leave where worked > expected
|
|
||||||
let hours = calculate_actual_hours(DayType::SickLeave, 9.0, 7.6);
|
|
||||||
assert!((hours - 9.0).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_actual_hours_vacation() {
|
|
||||||
// Vacation: expected + worked
|
|
||||||
let hours = calculate_actual_hours(DayType::Vacation, 2.0, 7.6);
|
|
||||||
assert!((hours - 9.6).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_actual_hours_flex_day() {
|
|
||||||
// Flex day: always 0
|
|
||||||
let hours = calculate_actual_hours(DayType::FlexDay, 5.0, 7.6);
|
|
||||||
assert!((hours - 0.0).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_report_empty_config() {
|
|
||||||
let config = TimesheetConfig { periods: vec![] };
|
|
||||||
let report = generate_report(&[], &config).unwrap();
|
|
||||||
assert!(report.months.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_report_single_day() {
|
|
||||||
// Monday 2026-03-02
|
|
||||||
let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])];
|
|
||||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 2), 40.0);
|
|
||||||
|
|
||||||
let report = generate_report(×heets, &config).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(report.months.len(), 1);
|
|
||||||
assert_eq!(report.months[0].days.len(), 1);
|
|
||||||
|
|
||||||
let day = &report.months[0].days[0];
|
|
||||||
assert_eq!(day.date, date(2026, 3, 2));
|
|
||||||
assert!((day.expected_hours - 8.0).abs() < 0.0001);
|
|
||||||
assert!((day.actual_hours - 8.0).abs() < 0.0001);
|
|
||||||
assert_eq!(day.day_type, DayType::Regular);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_report_detects_missing_day() {
|
|
||||||
// Period covers Mon-Tue, but only Mon has timesheet
|
|
||||||
let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])];
|
|
||||||
// March 2 is Monday, March 3 is Tuesday
|
|
||||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0);
|
|
||||||
|
|
||||||
let report = generate_report(×heets, &config).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(report.months[0].days.len(), 2);
|
|
||||||
|
|
||||||
// First day (Mon) should be regular
|
|
||||||
assert_eq!(report.months[0].days[0].day_type, DayType::Regular);
|
|
||||||
|
|
||||||
// Second day (Tue) should be missing
|
|
||||||
assert_eq!(report.months[0].days[1].day_type, DayType::Missing);
|
|
||||||
assert!(report.months[0].days[1].has_warnings());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_report_weekend_excluded_if_no_work() {
|
|
||||||
// Period covers Mon-Sun, but only Mon has timesheet
|
|
||||||
let timesheets = vec![make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)])];
|
|
||||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0);
|
|
||||||
|
|
||||||
let report = generate_report(×heets, &config).unwrap();
|
|
||||||
|
|
||||||
// Should only include Mon-Fri (5 days), not Sat-Sun
|
|
||||||
let days = &report.months[0].days;
|
|
||||||
for day in days {
|
|
||||||
let weekday = day.date.weekday();
|
|
||||||
assert!(weekday != Weekday::Sat && weekday != Weekday::Sun);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_report_weekend_included_if_work() {
|
|
||||||
// Work on Saturday
|
|
||||||
let timesheets = vec![
|
|
||||||
make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]),
|
|
||||||
make_timesheet(date(2026, 3, 7), vec![(10, 0, 14, 0)]), // Saturday
|
|
||||||
];
|
|
||||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 8), 40.0);
|
|
||||||
|
|
||||||
let report = generate_report(×heets, &config).unwrap();
|
|
||||||
|
|
||||||
// Should include Saturday
|
|
||||||
let has_saturday = report.months[0]
|
|
||||||
.days
|
|
||||||
.iter()
|
|
||||||
.any(|d| d.date == date(2026, 3, 7));
|
|
||||||
assert!(has_saturday);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_report_overlapping_timecards_warning() {
|
|
||||||
let ts = Timesheet {
|
|
||||||
date: date(2026, 3, 2),
|
|
||||||
is_sick_leave: false,
|
|
||||||
special_day_type: None,
|
|
||||||
timecards: vec![
|
|
||||||
Timecard::new(time(9, 0), time(12, 30)),
|
|
||||||
Timecard::new(time(12, 0), time(13, 0)),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 2), 40.0);
|
|
||||||
|
|
||||||
let report = generate_report(&[ts], &config).unwrap();
|
|
||||||
|
|
||||||
assert!(report.has_warnings());
|
|
||||||
assert!(report.months[0].days[0].has_warnings());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_report_cumulative_balance() {
|
|
||||||
// Two days: one with 8h (expected 8h), one with 10h (expected 8h)
|
|
||||||
let timesheets = vec![
|
|
||||||
make_timesheet(date(2026, 3, 2), vec![(9, 0, 17, 0)]), // 8h
|
|
||||||
make_timesheet(date(2026, 3, 3), vec![(8, 0, 18, 0)]), // 10h
|
|
||||||
];
|
|
||||||
let config = make_config(date(2026, 3, 2), date(2026, 3, 3), 40.0);
|
|
||||||
|
|
||||||
let report = generate_report(×heets, &config).unwrap();
|
|
||||||
|
|
||||||
// Balance should be +2h (18h actual - 16h expected)
|
|
||||||
assert!((report.cumulative_balance - 2.0).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +1,7 @@
|
||||||
mod config;
|
|
||||||
mod configuration;
|
mod configuration;
|
||||||
mod extract;
|
mod extract;
|
||||||
mod generator;
|
|
||||||
mod point_types;
|
mod point_types;
|
||||||
mod report;
|
|
||||||
mod validation;
|
|
||||||
|
|
||||||
pub use config::{Period, RepositoryConfig, TimesheetConfig};
|
|
||||||
pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG};
|
pub use configuration::{BasicTimesheetConfiguration, TIMESHEET_DIMENSION_NAME, TIMESHEET_TAG};
|
||||||
pub use extract::extract_timesheets;
|
pub use extract::extract_timesheets;
|
||||||
pub use generator::{generate_report, load_repository_config};
|
|
||||||
pub use point_types::TimesheetPointType;
|
pub use point_types::TimesheetPointType;
|
||||||
pub use report::{DayReport, DayType, DayWarning, MonthReport, ReportWarning, TimesheetReport};
|
|
||||||
pub use validation::find_overlapping_timecards;
|
|
||||||
|
|
|
||||||
|
|
@ -1,310 +0,0 @@
|
||||||
use chrono::{NaiveDate, NaiveTime};
|
|
||||||
use std::fmt;
|
|
||||||
|
|
||||||
/// Type of day for timesheet calculations.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum DayType {
|
|
||||||
/// Regular working day (Mon-Fri).
|
|
||||||
Regular,
|
|
||||||
/// Day with sick leave marker.
|
|
||||||
SickLeave,
|
|
||||||
/// Day with vacation marker.
|
|
||||||
Vacation,
|
|
||||||
/// Day with holiday marker.
|
|
||||||
Holiday,
|
|
||||||
/// Day with flex/undertime marker.
|
|
||||||
FlexDay,
|
|
||||||
/// Weekend day (Sat/Sun).
|
|
||||||
Weekend,
|
|
||||||
/// Missing: weekday with no entries and no explanation.
|
|
||||||
Missing,
|
|
||||||
/// Day outside any configured period.
|
|
||||||
OutsidePeriod,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for DayType {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
DayType::Regular => write!(f, ""),
|
|
||||||
DayType::SickLeave => write!(f, "Sick Leave"),
|
|
||||||
DayType::Vacation => write!(f, "Vacation"),
|
|
||||||
DayType::Holiday => write!(f, "Holiday"),
|
|
||||||
DayType::FlexDay => write!(f, "Flex Day"),
|
|
||||||
DayType::Weekend => write!(f, "Weekend"),
|
|
||||||
DayType::Missing => write!(f, "\u{26a0} Missing"),
|
|
||||||
DayType::OutsidePeriod => write!(f, "Outside Period"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Warning associated with a specific day.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub enum DayWarning {
|
|
||||||
/// Weekday has no entries and no leave/holiday marker.
|
|
||||||
MissingWithoutExplanation,
|
|
||||||
/// Two timecards overlap.
|
|
||||||
OverlappingTimecards {
|
|
||||||
first: (NaiveTime, NaiveTime),
|
|
||||||
second: (NaiveTime, NaiveTime),
|
|
||||||
},
|
|
||||||
/// Work logged outside any configured period.
|
|
||||||
OutsidePeriod { hours_worked: f64 },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for DayWarning {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
DayWarning::MissingWithoutExplanation => {
|
|
||||||
write!(f, "No entries and no leave/holiday marker")
|
|
||||||
}
|
|
||||||
DayWarning::OverlappingTimecards { first, second } => {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"{}-{} overlaps with {}-{}",
|
|
||||||
first.0.format("%H:%M"),
|
|
||||||
first.1.format("%H:%M"),
|
|
||||||
second.0.format("%H:%M"),
|
|
||||||
second.1.format("%H:%M")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DayWarning::OutsidePeriod { hours_worked } => {
|
|
||||||
write!(f, "{:.1}h worked (no period configured)", hours_worked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Report for a single day.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct DayReport {
|
|
||||||
pub date: NaiveDate,
|
|
||||||
pub expected_hours: f64,
|
|
||||||
pub actual_hours: f64,
|
|
||||||
pub day_type: DayType,
|
|
||||||
pub warnings: Vec<DayWarning>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DayReport {
|
|
||||||
pub fn new(date: NaiveDate, expected_hours: f64, actual_hours: f64, day_type: DayType) -> Self {
|
|
||||||
Self {
|
|
||||||
date,
|
|
||||||
expected_hours,
|
|
||||||
actual_hours,
|
|
||||||
day_type,
|
|
||||||
warnings: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_warning(mut self, warning: DayWarning) -> Self {
|
|
||||||
self.warnings.push(warning);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_warnings(mut self, warnings: Vec<DayWarning>) -> Self {
|
|
||||||
self.warnings = warnings;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate the difference between actual and expected hours.
|
|
||||||
pub fn diff(&self) -> f64 {
|
|
||||||
self.actual_hours - self.expected_hours
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if this day has any warnings.
|
|
||||||
pub fn has_warnings(&self) -> bool {
|
|
||||||
!self.warnings.is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Report for a single month.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct MonthReport {
|
|
||||||
pub year: i32,
|
|
||||||
pub month: u32,
|
|
||||||
pub days: Vec<DayReport>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MonthReport {
|
|
||||||
pub fn new(year: i32, month: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
year,
|
|
||||||
month,
|
|
||||||
days: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_days(mut self, days: Vec<DayReport>) -> Self {
|
|
||||||
self.days = days;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate total expected hours for the month.
|
|
||||||
pub fn total_expected(&self) -> f64 {
|
|
||||||
self.days.iter().map(|d| d.expected_hours).sum()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate total actual hours for the month.
|
|
||||||
pub fn total_actual(&self) -> f64 {
|
|
||||||
self.days.iter().map(|d| d.actual_hours).sum()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Calculate the difference for the month.
|
|
||||||
pub fn diff(&self) -> f64 {
|
|
||||||
self.total_actual() - self.total_expected()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the month name.
|
|
||||||
pub fn month_name(&self) -> String {
|
|
||||||
NaiveDate::from_ymd_opt(self.year, self.month, 1)
|
|
||||||
.map(|d| d.format("%B").to_string())
|
|
||||||
.unwrap_or_else(|| "Unknown".to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A warning to be displayed in the report summary.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ReportWarning {
|
|
||||||
pub date: NaiveDate,
|
|
||||||
pub warning: DayWarning,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ReportWarning {
|
|
||||||
pub fn new(date: NaiveDate, warning: DayWarning) -> Self {
|
|
||||||
Self { date, warning }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Complete timesheet report.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct TimesheetReport {
|
|
||||||
pub months: Vec<MonthReport>,
|
|
||||||
pub cumulative_balance: f64,
|
|
||||||
pub warnings: Vec<ReportWarning>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TimesheetReport {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
months: Vec::new(),
|
|
||||||
cumulative_balance: 0.0,
|
|
||||||
warnings: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_months(mut self, months: Vec<MonthReport>) -> Self {
|
|
||||||
self.months = months;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_cumulative_balance(mut self, balance: f64) -> Self {
|
|
||||||
self.cumulative_balance = balance;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_warnings(mut self, warnings: Vec<ReportWarning>) -> Self {
|
|
||||||
self.warnings = warnings;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if there are any warnings.
|
|
||||||
pub fn has_warnings(&self) -> bool {
|
|
||||||
!self.warnings.is_empty()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for TimesheetReport {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
|
|
||||||
NaiveDate::from_ymd_opt(year, month, day).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn time(hour: u32, min: u32) -> NaiveTime {
|
|
||||||
NaiveTime::from_hms_opt(hour, min, 0).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_day_report_diff() {
|
|
||||||
let report = DayReport::new(date(2026, 3, 2), 7.6, 8.2, DayType::Regular);
|
|
||||||
assert!((report.diff() - 0.6).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_day_report_negative_diff() {
|
|
||||||
let report = DayReport::new(date(2026, 3, 2), 7.6, 6.0, DayType::Regular);
|
|
||||||
assert!((report.diff() - (-1.6)).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_month_report_totals() {
|
|
||||||
let month = MonthReport::new(2026, 3).with_days(vec![
|
|
||||||
DayReport::new(date(2026, 3, 2), 7.6, 8.2, DayType::Regular),
|
|
||||||
DayReport::new(date(2026, 3, 3), 7.6, 7.6, DayType::Regular),
|
|
||||||
DayReport::new(date(2026, 3, 4), 7.6, 6.0, DayType::Regular),
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert!((month.total_expected() - 22.8).abs() < 0.0001);
|
|
||||||
assert!((month.total_actual() - 21.8).abs() < 0.0001);
|
|
||||||
assert!((month.diff() - (-1.0)).abs() < 0.0001);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_month_name() {
|
|
||||||
let month = MonthReport::new(2026, 3);
|
|
||||||
assert_eq!(month.month_name(), "March");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_day_warning_overlap_display() {
|
|
||||||
let warning = DayWarning::OverlappingTimecards {
|
|
||||||
first: (time(9, 0), time(12, 30)),
|
|
||||||
second: (time(12, 0), time(13, 0)),
|
|
||||||
};
|
|
||||||
assert_eq!(warning.to_string(), "09:00-12:30 overlaps with 12:00-13:00");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_day_warning_missing_display() {
|
|
||||||
let warning = DayWarning::MissingWithoutExplanation;
|
|
||||||
assert_eq!(
|
|
||||||
warning.to_string(),
|
|
||||||
"No entries and no leave/holiday marker"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_day_warning_outside_period_display() {
|
|
||||||
let warning = DayWarning::OutsidePeriod { hours_worked: 3.5 };
|
|
||||||
assert_eq!(warning.to_string(), "3.5h worked (no period configured)");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_day_report_with_warnings() {
|
|
||||||
let report = DayReport::new(date(2026, 3, 2), 7.6, 8.2, DayType::Regular).with_warning(
|
|
||||||
DayWarning::OverlappingTimecards {
|
|
||||||
first: (time(9, 0), time(12, 30)),
|
|
||||||
second: (time(12, 0), time(13, 0)),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(report.has_warnings());
|
|
||||||
assert_eq!(report.warnings.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_timesheeet_report_has_warnings() {
|
|
||||||
let report = TimesheetReport::new().with_warnings(vec![ReportWarning::new(
|
|
||||||
date(2026, 3, 4),
|
|
||||||
DayWarning::MissingWithoutExplanation,
|
|
||||||
)]);
|
|
||||||
|
|
||||||
assert!(report.has_warnings());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
use chrono::NaiveTime;
|
|
||||||
|
|
||||||
use crate::models::Timecard;
|
|
||||||
|
|
||||||
/// Check if two time ranges overlap.
|
|
||||||
fn timecards_overlap(a: &Timecard, b: &Timecard) -> bool {
|
|
||||||
a.from_time < b.to_time && b.from_time < a.to_time
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find all overlapping timecard pairs for a day.
|
|
||||||
/// Returns a list of tuples containing the two overlapping timecards.
|
|
||||||
pub fn find_overlapping_timecards(
|
|
||||||
timecards: &[Timecard],
|
|
||||||
) -> Vec<((NaiveTime, NaiveTime), (NaiveTime, NaiveTime))> {
|
|
||||||
let mut overlaps = Vec::new();
|
|
||||||
for i in 0..timecards.len() {
|
|
||||||
for j in (i + 1)..timecards.len() {
|
|
||||||
if timecards_overlap(&timecards[i], &timecards[j]) {
|
|
||||||
overlaps.push((
|
|
||||||
(timecards[i].from_time, timecards[i].to_time),
|
|
||||||
(timecards[j].from_time, timecards[j].to_time),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
overlaps
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn time(hour: u32, min: u32) -> NaiveTime {
|
|
||||||
NaiveTime::from_hms_opt(hour, min, 0).unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn card(from_h: u32, from_m: u32, to_h: u32, to_m: u32) -> Timecard {
|
|
||||||
Timecard::new(time(from_h, from_m), time(to_h, to_m))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_no_overlap_adjacent_timecards() {
|
|
||||||
let timecards = vec![card(9, 0, 12, 0), card(13, 0, 17, 0)];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
assert!(overlaps.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_no_overlap_exact_touch() {
|
|
||||||
// Touching at 12:00 is NOT an overlap (end time = start time)
|
|
||||||
let timecards = vec![card(9, 0, 12, 0), card(12, 0, 17, 0)];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
assert!(overlaps.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_partial_overlap() {
|
|
||||||
let timecards = vec![card(9, 0, 12, 30), card(12, 0, 13, 0)];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
assert_eq!(overlaps.len(), 1);
|
|
||||||
assert_eq!(overlaps[0].0, (time(9, 0), time(12, 30)));
|
|
||||||
assert_eq!(overlaps[0].1, (time(12, 0), time(13, 0)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_full_containment() {
|
|
||||||
// One timecard fully contains another
|
|
||||||
let timecards = vec![card(9, 0, 17, 0), card(10, 0, 11, 0)];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
assert_eq!(overlaps.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_exact_match_overlap() {
|
|
||||||
let timecards = vec![card(9, 0, 12, 0), card(9, 0, 12, 0)];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
assert_eq!(overlaps.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_multiple_overlaps_same_day() {
|
|
||||||
// First overlaps with second, and second overlaps with third
|
|
||||||
let timecards = vec![card(9, 0, 11, 0), card(10, 0, 13, 0), card(12, 0, 15, 0)];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
// 9-11 overlaps with 10-13, and 10-13 overlaps with 12-15
|
|
||||||
assert_eq!(overlaps.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_single_timecard_no_overlap() {
|
|
||||||
let timecards = vec![card(9, 0, 17, 0)];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
assert!(overlaps.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_empty_timecards_no_overlap() {
|
|
||||||
let timecards: Vec<Timecard> = vec![];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
assert!(overlaps.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_three_timecards_all_overlap() {
|
|
||||||
// All three overlap with each other
|
|
||||||
let timecards = vec![card(9, 0, 15, 0), card(10, 0, 16, 0), card(11, 0, 17, 0)];
|
|
||||||
let overlaps = find_overlapping_timecards(&timecards);
|
|
||||||
// 9-15 with 10-16, 9-15 with 11-17, and 10-16 with 11-17
|
|
||||||
assert_eq!(overlaps.len(), 3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue