├── .circleci └── config.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── jira-cli ├── Cargo.toml └── src │ ├── main.rs │ ├── models │ ├── comment.rs │ ├── mod.rs │ ├── ticket.rs │ ├── ticket_draft.rs │ ├── ticket_patch.rs │ └── title.rs │ ├── persistence.rs │ └── store.rs ├── jira-wip ├── Cargo.toml └── src │ ├── koans │ ├── 00_greetings │ │ └── 00_greetings.rs │ ├── 01_ticket │ │ ├── 01_ticket.rs │ │ ├── 02_status.rs │ │ ├── 03_validation.rs │ │ ├── 04_visibility.rs │ │ ├── 05_ownership.rs │ │ ├── 06_traits.rs │ │ ├── 07_derive.rs │ │ └── 08_recap.rs │ ├── 02_ticket_store │ │ ├── 01_store.rs │ │ ├── 02_option.rs │ │ ├── 03_id_generation.rs │ │ ├── 04_metadata.rs │ │ ├── 05_type_as_constraints.rs │ │ ├── 06_result.rs │ │ ├── 07_vec.rs │ │ ├── 08_delete_and_update.rs │ │ └── 09_store_recap.rs │ └── 03_cli │ │ ├── 00_cli.rs │ │ ├── 01_persistence.rs │ │ └── 02_the_end.rs │ ├── main.rs │ └── path_to_enlightenment.rs └── koans-framework ├── Cargo.toml └── src ├── lib.rs └── main.rs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build-and-test: 4 | docker: 5 | - image: circleci/rust 6 | environment: 7 | # Fail the build if there are warnings 8 | RUSTFLAGS: '-D warnings' 9 | steps: 10 | - checkout 11 | - run: 12 | name: Version information 13 | command: rustc --version; cargo --version; rustup --version 14 | - run: 15 | name: Calculate dependencies 16 | command: cargo generate-lockfile 17 | - restore_cache: 18 | keys: 19 | - v2-cargo-cache-{{ arch }}-{{ checksum "Cargo.lock" }} 20 | - run: 21 | name: Build all targets 22 | command: cargo build 23 | - save_cache: 24 | paths: 25 | - /usr/local/cargo/registry 26 | - target/debug/.fingerprint 27 | - target/debug/build 28 | - target/debug/deps 29 | key: v2-cargo-cache-{{ arch }}-{{ checksum "Cargo.lock" }} 30 | - run: 31 | name: Run all tests 32 | command: cargo test 33 | 34 | security: 35 | docker: 36 | - image: circleci/rust 37 | steps: 38 | - checkout 39 | - run: 40 | name: Version information 41 | command: rustc --version; cargo --version; rustup --version 42 | - run: 43 | name: Install dependency auditing tool 44 | command: cargo install cargo-audit 45 | - run: 46 | name: Check for known security issues in dependencies 47 | command: cargo audit 48 | 49 | format-and-lint: 50 | docker: 51 | - image: circleci/rust 52 | steps: 53 | - checkout 54 | - run: 55 | name: Version information 56 | command: rustc --version; cargo --version; rustup --version 57 | - run: 58 | name: Install formatter 59 | command: rustup component add rustfmt 60 | - run: 61 | name: Install Clippy 62 | command: rustup component add clippy 63 | - run: 64 | name: Formatting 65 | command: cargo fmt --all -- --check 66 | - run: 67 | name: Linting 68 | command: cargo clippy -- -D warnings 69 | 70 | workflows: 71 | version: 2 72 | build-test: 73 | jobs: 74 | - build-and-test: 75 | filters: 76 | tags: 77 | only: /.*/ 78 | - security: 79 | filters: 80 | tags: 81 | only: /.*/ 82 | - format-and-lint: 83 | filters: 84 | tags: 85 | only: /.*/ 86 | 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # These are backup files generated by rustfmt 6 | **/*.rs.bk 7 | 8 | 9 | #Added by cargo 10 | # 11 | #already existing elements are commented out 12 | 13 | /target 14 | #**/*.rs.bk 15 | .idea 16 | 17 | # ignore VS Code launch.json file 18 | **/launch.json 19 | 20 | # Ignore Vim tag files 21 | tags 22 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "0.7.9" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "d5e63fd144e18ba274ae7095c0197a870a7b9468abc801dd62f190d80817d2ec" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "ansi_term" 16 | version = "0.11.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" 19 | dependencies = [ 20 | "winapi", 21 | ] 22 | 23 | [[package]] 24 | name = "anyhow" 25 | version = "1.0.26" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "7825f6833612eb2414095684fcf6c635becf3ce97fe48cf6421321e93bfbd53c" 28 | 29 | [[package]] 30 | name = "arrayref" 31 | version = "0.3.6" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" 34 | 35 | [[package]] 36 | name = "arrayvec" 37 | version = "0.5.1" 38 | source = "registry+https://github.com/rust-lang/crates.io-index" 39 | checksum = "cff77d8686867eceff3105329d4698d96c2391c176d5d03adc90c7389162b5b8" 40 | 41 | [[package]] 42 | name = "atty" 43 | version = "0.2.14" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 46 | dependencies = [ 47 | "hermit-abi", 48 | "libc", 49 | "winapi", 50 | ] 51 | 52 | [[package]] 53 | name = "autocfg" 54 | version = "0.1.7" 55 | source = "registry+https://github.com/rust-lang/crates.io-index" 56 | checksum = "1d49d90015b3c36167a20fe2810c5cd875ad504b39cff3d4eae7977e6b7c1cb2" 57 | 58 | [[package]] 59 | name = "autocfg" 60 | version = "1.0.0" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d" 63 | 64 | [[package]] 65 | name = "base64" 66 | version = "0.11.0" 67 | source = "registry+https://github.com/rust-lang/crates.io-index" 68 | checksum = "b41b7ea54a0c9d92199de89e20e58d49f02f8e699814ef3fdf266f6f748d15c7" 69 | 70 | [[package]] 71 | name = "bitflags" 72 | version = "1.2.1" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 75 | 76 | [[package]] 77 | name = "blake2b_simd" 78 | version = "0.5.10" 79 | source = "registry+https://github.com/rust-lang/crates.io-index" 80 | checksum = "d8fb2d74254a3a0b5cac33ac9f8ed0e44aa50378d9dbb2e5d83bd21ed1dc2c8a" 81 | dependencies = [ 82 | "arrayref", 83 | "arrayvec", 84 | "constant_time_eq", 85 | ] 86 | 87 | [[package]] 88 | name = "c2-chacha" 89 | version = "0.2.3" 90 | source = "registry+https://github.com/rust-lang/crates.io-index" 91 | checksum = "214238caa1bf3a496ec3392968969cab8549f96ff30652c9e56885329315f6bb" 92 | dependencies = [ 93 | "ppv-lite86", 94 | ] 95 | 96 | [[package]] 97 | name = "cfg-if" 98 | version = "0.1.10" 99 | source = "registry+https://github.com/rust-lang/crates.io-index" 100 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 101 | 102 | [[package]] 103 | name = "chrono" 104 | version = "0.4.11" 105 | source = "registry+https://github.com/rust-lang/crates.io-index" 106 | checksum = "80094f509cf8b5ae86a4966a39b3ff66cd7e2a3e594accec3743ff3fabeab5b2" 107 | dependencies = [ 108 | "num-integer", 109 | "num-traits", 110 | "serde", 111 | "time", 112 | ] 113 | 114 | [[package]] 115 | name = "clap" 116 | version = "2.33.0" 117 | source = "registry+https://github.com/rust-lang/crates.io-index" 118 | checksum = "5067f5bb2d80ef5d68b4c87db81601f0b75bca627bc2ef76b141d7b846a3c6d9" 119 | dependencies = [ 120 | "ansi_term", 121 | "atty", 122 | "bitflags", 123 | "strsim", 124 | "textwrap", 125 | "unicode-width", 126 | "vec_map", 127 | ] 128 | 129 | [[package]] 130 | name = "constant_time_eq" 131 | version = "0.1.5" 132 | source = "registry+https://github.com/rust-lang/crates.io-index" 133 | checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" 134 | 135 | [[package]] 136 | name = "crossbeam-utils" 137 | version = "0.7.0" 138 | source = "registry+https://github.com/rust-lang/crates.io-index" 139 | checksum = "ce446db02cdc3165b94ae73111e570793400d0794e46125cc4056c81cbb039f4" 140 | dependencies = [ 141 | "autocfg 0.1.7", 142 | "cfg-if", 143 | "lazy_static", 144 | ] 145 | 146 | [[package]] 147 | name = "directories" 148 | version = "2.0.2" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "551a778172a450d7fc12e629ca3b0428d00f6afa9a43da1b630d54604e97371c" 151 | dependencies = [ 152 | "cfg-if", 153 | "dirs-sys", 154 | ] 155 | 156 | [[package]] 157 | name = "dirs-sys" 158 | version = "0.3.4" 159 | source = "registry+https://github.com/rust-lang/crates.io-index" 160 | checksum = "afa0b23de8fd801745c471deffa6e12d248f962c9fd4b4c33787b055599bde7b" 161 | dependencies = [ 162 | "cfg-if", 163 | "libc", 164 | "redox_users", 165 | "winapi", 166 | ] 167 | 168 | [[package]] 169 | name = "dtoa" 170 | version = "0.4.5" 171 | source = "registry+https://github.com/rust-lang/crates.io-index" 172 | checksum = "4358a9e11b9a09cf52383b451b49a169e8d797b68aa02301ff586d70d9661ea3" 173 | 174 | [[package]] 175 | name = "fake" 176 | version = "2.2.0" 177 | source = "registry+https://github.com/rust-lang/crates.io-index" 178 | checksum = "ff8e7a538f2da19a663b11eb08943dd80dbfdc2c64720042edd1fcf36a02bb76" 179 | dependencies = [ 180 | "rand", 181 | ] 182 | 183 | [[package]] 184 | name = "getrandom" 185 | version = "0.1.14" 186 | source = "registry+https://github.com/rust-lang/crates.io-index" 187 | checksum = "7abc8dd8451921606d809ba32e95b6111925cd2906060d2dcc29c070220503eb" 188 | dependencies = [ 189 | "cfg-if", 190 | "libc", 191 | "wasi", 192 | ] 193 | 194 | [[package]] 195 | name = "heck" 196 | version = "0.3.1" 197 | source = "registry+https://github.com/rust-lang/crates.io-index" 198 | checksum = "20564e78d53d2bb135c343b3f47714a56af2061f1c928fdb541dc7b9fdd94205" 199 | dependencies = [ 200 | "unicode-segmentation", 201 | ] 202 | 203 | [[package]] 204 | name = "hermit-abi" 205 | version = "0.1.6" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "eff2656d88f158ce120947499e971d743c05dbcbed62e5bd2f38f1698bbc3772" 208 | dependencies = [ 209 | "libc", 210 | ] 211 | 212 | [[package]] 213 | name = "jira-cli" 214 | version = "0.1.0" 215 | dependencies = [ 216 | "directories", 217 | "fake", 218 | "paw", 219 | "serde", 220 | "serde_yaml", 221 | "structopt", 222 | ] 223 | 224 | [[package]] 225 | name = "jira-wip" 226 | version = "0.1.0" 227 | dependencies = [ 228 | "chrono", 229 | "directories", 230 | "fake", 231 | "paw", 232 | "serde", 233 | "serde_yaml", 234 | "structopt", 235 | "tempfile", 236 | ] 237 | 238 | [[package]] 239 | name = "koans" 240 | version = "0.1.0" 241 | dependencies = [ 242 | "anyhow", 243 | "paw", 244 | "read_input", 245 | "regex", 246 | "structopt", 247 | "yansi", 248 | ] 249 | 250 | [[package]] 251 | name = "lazy_static" 252 | version = "1.4.0" 253 | source = "registry+https://github.com/rust-lang/crates.io-index" 254 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 255 | 256 | [[package]] 257 | name = "libc" 258 | version = "0.2.66" 259 | source = "registry+https://github.com/rust-lang/crates.io-index" 260 | checksum = "d515b1f41455adea1313a4a2ac8a8a477634fbae63cc6100e3aebb207ce61558" 261 | 262 | [[package]] 263 | name = "linked-hash-map" 264 | version = "0.5.4" 265 | source = "registry+https://github.com/rust-lang/crates.io-index" 266 | checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" 267 | 268 | [[package]] 269 | name = "memchr" 270 | version = "2.3.3" 271 | source = "registry+https://github.com/rust-lang/crates.io-index" 272 | checksum = "3728d817d99e5ac407411fa471ff9800a778d88a24685968b36824eaf4bee400" 273 | 274 | [[package]] 275 | name = "num-integer" 276 | version = "0.1.42" 277 | source = "registry+https://github.com/rust-lang/crates.io-index" 278 | checksum = "3f6ea62e9d81a77cd3ee9a2a5b9b609447857f3d358704331e4ef39eb247fcba" 279 | dependencies = [ 280 | "autocfg 1.0.0", 281 | "num-traits", 282 | ] 283 | 284 | [[package]] 285 | name = "num-traits" 286 | version = "0.2.11" 287 | source = "registry+https://github.com/rust-lang/crates.io-index" 288 | checksum = "c62be47e61d1842b9170f0fdeec8eba98e60e90e5446449a0545e5152acd7096" 289 | dependencies = [ 290 | "autocfg 1.0.0", 291 | ] 292 | 293 | [[package]] 294 | name = "paw" 295 | version = "1.0.0" 296 | source = "registry+https://github.com/rust-lang/crates.io-index" 297 | checksum = "09c0fc9b564dbc3dc2ed7c92c0c144f4de340aa94514ce2b446065417c4084e9" 298 | dependencies = [ 299 | "paw-attributes", 300 | "paw-raw", 301 | ] 302 | 303 | [[package]] 304 | name = "paw-attributes" 305 | version = "1.0.2" 306 | source = "registry+https://github.com/rust-lang/crates.io-index" 307 | checksum = "0f35583365be5d148e959284f42526841917b7bfa09e2d1a7ad5dde2cf0eaa39" 308 | dependencies = [ 309 | "proc-macro2", 310 | "quote", 311 | "syn", 312 | ] 313 | 314 | [[package]] 315 | name = "paw-raw" 316 | version = "1.0.0" 317 | source = "registry+https://github.com/rust-lang/crates.io-index" 318 | checksum = "7f0b59668fe80c5afe998f0c0bf93322bf2cd66cafeeb80581f291716f3467f2" 319 | 320 | [[package]] 321 | name = "ppv-lite86" 322 | version = "0.2.6" 323 | source = "registry+https://github.com/rust-lang/crates.io-index" 324 | checksum = "74490b50b9fbe561ac330df47c08f3f33073d2d00c150f719147d7c54522fa1b" 325 | 326 | [[package]] 327 | name = "proc-macro-error" 328 | version = "0.4.8" 329 | source = "registry+https://github.com/rust-lang/crates.io-index" 330 | checksum = "875077759af22fa20b610ad4471d8155b321c89c3f2785526c9839b099be4e0a" 331 | dependencies = [ 332 | "proc-macro-error-attr", 333 | "proc-macro2", 334 | "quote", 335 | "rustversion", 336 | "syn", 337 | ] 338 | 339 | [[package]] 340 | name = "proc-macro-error-attr" 341 | version = "0.4.8" 342 | source = "registry+https://github.com/rust-lang/crates.io-index" 343 | checksum = "c5717d9fa2664351a01ed73ba5ef6df09c01a521cb42cb65a061432a826f3c7a" 344 | dependencies = [ 345 | "proc-macro2", 346 | "quote", 347 | "rustversion", 348 | "syn", 349 | "syn-mid", 350 | ] 351 | 352 | [[package]] 353 | name = "proc-macro2" 354 | version = "1.0.8" 355 | source = "registry+https://github.com/rust-lang/crates.io-index" 356 | checksum = "3acb317c6ff86a4e579dfa00fc5e6cca91ecbb4e7eb2df0468805b674eb88548" 357 | dependencies = [ 358 | "unicode-xid", 359 | ] 360 | 361 | [[package]] 362 | name = "quote" 363 | version = "1.0.2" 364 | source = "registry+https://github.com/rust-lang/crates.io-index" 365 | checksum = "053a8c8bcc71fcce321828dc897a98ab9760bef03a4fc36693c231e5b3216cfe" 366 | dependencies = [ 367 | "proc-macro2", 368 | ] 369 | 370 | [[package]] 371 | name = "rand" 372 | version = "0.7.3" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" 375 | dependencies = [ 376 | "getrandom", 377 | "libc", 378 | "rand_chacha", 379 | "rand_core", 380 | "rand_hc", 381 | ] 382 | 383 | [[package]] 384 | name = "rand_chacha" 385 | version = "0.2.1" 386 | source = "registry+https://github.com/rust-lang/crates.io-index" 387 | checksum = "03a2a90da8c7523f554344f921aa97283eadf6ac484a6d2a7d0212fa7f8d6853" 388 | dependencies = [ 389 | "c2-chacha", 390 | "rand_core", 391 | ] 392 | 393 | [[package]] 394 | name = "rand_core" 395 | version = "0.5.1" 396 | source = "registry+https://github.com/rust-lang/crates.io-index" 397 | checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" 398 | dependencies = [ 399 | "getrandom", 400 | ] 401 | 402 | [[package]] 403 | name = "rand_hc" 404 | version = "0.2.0" 405 | source = "registry+https://github.com/rust-lang/crates.io-index" 406 | checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" 407 | dependencies = [ 408 | "rand_core", 409 | ] 410 | 411 | [[package]] 412 | name = "read_input" 413 | version = "0.8.4" 414 | source = "registry+https://github.com/rust-lang/crates.io-index" 415 | checksum = "b57518cc6538a2eb7dce826e24fa51d0b7cf8e744ee10c7f56259cdec40050e5" 416 | 417 | [[package]] 418 | name = "redox_syscall" 419 | version = "0.1.56" 420 | source = "registry+https://github.com/rust-lang/crates.io-index" 421 | checksum = "2439c63f3f6139d1b57529d16bc3b8bb855230c8efcc5d3a896c8bea7c3b1e84" 422 | 423 | [[package]] 424 | name = "redox_users" 425 | version = "0.3.4" 426 | source = "registry+https://github.com/rust-lang/crates.io-index" 427 | checksum = "09b23093265f8d200fa7b4c2c76297f47e681c655f6f1285a8780d6a022f7431" 428 | dependencies = [ 429 | "getrandom", 430 | "redox_syscall", 431 | "rust-argon2", 432 | ] 433 | 434 | [[package]] 435 | name = "regex" 436 | version = "1.3.4" 437 | source = "registry+https://github.com/rust-lang/crates.io-index" 438 | checksum = "322cf97724bea3ee221b78fe25ac9c46114ebb51747ad5babd51a2fc6a8235a8" 439 | dependencies = [ 440 | "aho-corasick", 441 | "memchr", 442 | "regex-syntax", 443 | "thread_local", 444 | ] 445 | 446 | [[package]] 447 | name = "regex-syntax" 448 | version = "0.6.16" 449 | source = "registry+https://github.com/rust-lang/crates.io-index" 450 | checksum = "1132f845907680735a84409c3bebc64d1364a5683ffbce899550cd09d5eaefc1" 451 | 452 | [[package]] 453 | name = "remove_dir_all" 454 | version = "0.5.2" 455 | source = "registry+https://github.com/rust-lang/crates.io-index" 456 | checksum = "4a83fa3702a688b9359eccba92d153ac33fd2e8462f9e0e3fdf155239ea7792e" 457 | dependencies = [ 458 | "winapi", 459 | ] 460 | 461 | [[package]] 462 | name = "rust-argon2" 463 | version = "0.7.0" 464 | source = "registry+https://github.com/rust-lang/crates.io-index" 465 | checksum = "2bc8af4bda8e1ff4932523b94d3dd20ee30a87232323eda55903ffd71d2fb017" 466 | dependencies = [ 467 | "base64", 468 | "blake2b_simd", 469 | "constant_time_eq", 470 | "crossbeam-utils", 471 | ] 472 | 473 | [[package]] 474 | name = "rustversion" 475 | version = "1.0.2" 476 | source = "registry+https://github.com/rust-lang/crates.io-index" 477 | checksum = "b3bba175698996010c4f6dce5e7f173b6eb781fce25d2cfc45e27091ce0b79f6" 478 | dependencies = [ 479 | "proc-macro2", 480 | "quote", 481 | "syn", 482 | ] 483 | 484 | [[package]] 485 | name = "serde" 486 | version = "1.0.104" 487 | source = "registry+https://github.com/rust-lang/crates.io-index" 488 | checksum = "414115f25f818d7dfccec8ee535d76949ae78584fc4f79a6f45a904bf8ab4449" 489 | dependencies = [ 490 | "serde_derive", 491 | ] 492 | 493 | [[package]] 494 | name = "serde_derive" 495 | version = "1.0.104" 496 | source = "registry+https://github.com/rust-lang/crates.io-index" 497 | checksum = "128f9e303a5a29922045a830221b8f78ec74a5f544944f3d5984f8ec3895ef64" 498 | dependencies = [ 499 | "proc-macro2", 500 | "quote", 501 | "syn", 502 | ] 503 | 504 | [[package]] 505 | name = "serde_yaml" 506 | version = "0.8.17" 507 | source = "registry+https://github.com/rust-lang/crates.io-index" 508 | checksum = "15654ed4ab61726bf918a39cb8d98a2e2995b002387807fa6ba58fdf7f59bb23" 509 | dependencies = [ 510 | "dtoa", 511 | "linked-hash-map", 512 | "serde", 513 | "yaml-rust", 514 | ] 515 | 516 | [[package]] 517 | name = "strsim" 518 | version = "0.8.0" 519 | source = "registry+https://github.com/rust-lang/crates.io-index" 520 | checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" 521 | 522 | [[package]] 523 | name = "structopt" 524 | version = "0.3.9" 525 | source = "registry+https://github.com/rust-lang/crates.io-index" 526 | checksum = "a1bcbed7d48956fcbb5d80c6b95aedb553513de0a1b451ea92679d999c010e98" 527 | dependencies = [ 528 | "clap", 529 | "lazy_static", 530 | "structopt-derive", 531 | ] 532 | 533 | [[package]] 534 | name = "structopt-derive" 535 | version = "0.4.2" 536 | source = "registry+https://github.com/rust-lang/crates.io-index" 537 | checksum = "095064aa1f5b94d14e635d0a5684cf140c43ae40a0fd990708d38f5d669e5f64" 538 | dependencies = [ 539 | "heck", 540 | "proc-macro-error", 541 | "proc-macro2", 542 | "quote", 543 | "syn", 544 | ] 545 | 546 | [[package]] 547 | name = "syn" 548 | version = "1.0.14" 549 | source = "registry+https://github.com/rust-lang/crates.io-index" 550 | checksum = "af6f3550d8dff9ef7dc34d384ac6f107e5d31c8f57d9f28e0081503f547ac8f5" 551 | dependencies = [ 552 | "proc-macro2", 553 | "quote", 554 | "unicode-xid", 555 | ] 556 | 557 | [[package]] 558 | name = "syn-mid" 559 | version = "0.5.0" 560 | source = "registry+https://github.com/rust-lang/crates.io-index" 561 | checksum = "7be3539f6c128a931cf19dcee741c1af532c7fd387baa739c03dd2e96479338a" 562 | dependencies = [ 563 | "proc-macro2", 564 | "quote", 565 | "syn", 566 | ] 567 | 568 | [[package]] 569 | name = "tempfile" 570 | version = "3.1.0" 571 | source = "registry+https://github.com/rust-lang/crates.io-index" 572 | checksum = "7a6e24d9338a0a5be79593e2fa15a648add6138caa803e2d5bc782c371732ca9" 573 | dependencies = [ 574 | "cfg-if", 575 | "libc", 576 | "rand", 577 | "redox_syscall", 578 | "remove_dir_all", 579 | "winapi", 580 | ] 581 | 582 | [[package]] 583 | name = "textwrap" 584 | version = "0.11.0" 585 | source = "registry+https://github.com/rust-lang/crates.io-index" 586 | checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" 587 | dependencies = [ 588 | "unicode-width", 589 | ] 590 | 591 | [[package]] 592 | name = "thread_local" 593 | version = "1.0.1" 594 | source = "registry+https://github.com/rust-lang/crates.io-index" 595 | checksum = "d40c6d1b69745a6ec6fb1ca717914848da4b44ae29d9b3080cbee91d72a69b14" 596 | dependencies = [ 597 | "lazy_static", 598 | ] 599 | 600 | [[package]] 601 | name = "time" 602 | version = "0.1.42" 603 | source = "registry+https://github.com/rust-lang/crates.io-index" 604 | checksum = "db8dcfca086c1143c9270ac42a2bbd8a7ee477b78ac8e45b19abfb0cbede4b6f" 605 | dependencies = [ 606 | "libc", 607 | "redox_syscall", 608 | "winapi", 609 | ] 610 | 611 | [[package]] 612 | name = "unicode-segmentation" 613 | version = "1.6.0" 614 | source = "registry+https://github.com/rust-lang/crates.io-index" 615 | checksum = "e83e153d1053cbb5a118eeff7fd5be06ed99153f00dbcd8ae310c5fb2b22edc0" 616 | 617 | [[package]] 618 | name = "unicode-width" 619 | version = "0.1.7" 620 | source = "registry+https://github.com/rust-lang/crates.io-index" 621 | checksum = "caaa9d531767d1ff2150b9332433f32a24622147e5ebb1f26409d5da67afd479" 622 | 623 | [[package]] 624 | name = "unicode-xid" 625 | version = "0.2.0" 626 | source = "registry+https://github.com/rust-lang/crates.io-index" 627 | checksum = "826e7639553986605ec5979c7dd957c7895e93eabed50ab2ffa7f6128a75097c" 628 | 629 | [[package]] 630 | name = "vec_map" 631 | version = "0.8.1" 632 | source = "registry+https://github.com/rust-lang/crates.io-index" 633 | checksum = "05c78687fb1a80548ae3250346c3db86a80a7cdd77bda190189f2d0a0987c81a" 634 | 635 | [[package]] 636 | name = "wasi" 637 | version = "0.9.0+wasi-snapshot-preview1" 638 | source = "registry+https://github.com/rust-lang/crates.io-index" 639 | checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" 640 | 641 | [[package]] 642 | name = "winapi" 643 | version = "0.3.8" 644 | source = "registry+https://github.com/rust-lang/crates.io-index" 645 | checksum = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6" 646 | dependencies = [ 647 | "winapi-i686-pc-windows-gnu", 648 | "winapi-x86_64-pc-windows-gnu", 649 | ] 650 | 651 | [[package]] 652 | name = "winapi-i686-pc-windows-gnu" 653 | version = "0.4.0" 654 | source = "registry+https://github.com/rust-lang/crates.io-index" 655 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 656 | 657 | [[package]] 658 | name = "winapi-x86_64-pc-windows-gnu" 659 | version = "0.4.0" 660 | source = "registry+https://github.com/rust-lang/crates.io-index" 661 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 662 | 663 | [[package]] 664 | name = "yaml-rust" 665 | version = "0.4.5" 666 | source = "registry+https://github.com/rust-lang/crates.io-index" 667 | checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" 668 | dependencies = [ 669 | "linked-hash-map", 670 | ] 671 | 672 | [[package]] 673 | name = "yansi" 674 | version = "0.5.0" 675 | source = "registry+https://github.com/rust-lang/crates.io-index" 676 | checksum = "9fc79f4a1e39857fc00c3f662cbf2651c771f00e9c15fe2abc341806bd46bd71" 677 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "jira-cli", 4 | "jira-wip", 5 | "koans-framework" 6 | ] 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Luca Palmieri 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Build your own JIRA with Rust 2 | 3 | You will be working through a series of test-driven exercises, or koans, to learn Rust while building your own JIRA clone! 4 | 5 | This workshop is designed for people who have experience using other programming languages and are just getting 6 | started with Rust. 7 | If you run into any issue with the assumed level of Rust knowledge, please ping us and we'll sort it together! 8 | 9 | ## Requirements 10 | 11 | - **Rust** (follow instructions [here](https://www.rust-lang.org/tools/install)). 12 | If Rust is already installed on your system, make sure you are running on the latest compiler version (`cargo --version`). 13 | If not, update using `rustup update` (or another appropriate command depending on how you installed Rust on your system). 14 | - _(Optional)_ An IDE with Rust autocompletion support. 15 | We recommend one of the following: 16 | - [IntelliJ IDEA](https://www.jetbrains.com/idea/) with the [`intellij-rust`](https://intellij-rust.github.io) plugin; 17 | - [Visual Studio Code](https://code.visualstudio.com) with the [`rust-analyzer`](https://marketplace.visualstudio.com/items?itemName=matklad.rust-analyzer) extension. 18 | Checkout the [`rust-in-peace`](https://marketplace.visualstudio.com/items?itemName=gilescope.rust-in-peace) extension for a battery-included Rust setup on VS Code. 19 | 20 | ## Getting started 21 | 22 | ```bash 23 | git clone git@github.com:LukeMathWalker/build-your-own-jira-with-rust.git 24 | cd build-your-own-jira-with-rust 25 | 26 | # Our `koans` CLI, you will need it to work through the exercises. 27 | # You can run `koans --help` to check that everything is running properly 28 | cargo install -f --path koans-framework 29 | 30 | # Work on your solution in a branch. 31 | git checkout -b my-solution 32 | 33 | # Get started! 34 | koans --path jira-wip 35 | ``` 36 | 37 | Follow the instructions shown in the terminal to get started with the first koan. 38 | 39 | Run this command from the top-level folder 40 | ```bash 41 | koans --path jira-wip 42 | ``` 43 | to verify your current solutions and move forward in the workshop. 44 | 45 | Enjoy! 46 | 47 | ## References 48 | 49 | Throughout the workshop, the following resources might turn out to be useful: 50 | 51 | * [Rust Book](https://doc.rust-lang.org/book/) 52 | * [Rust documentation](https://doc.rust-lang.org/std/) (you can also open the documentation offline with `rustup doc`!) 53 | 54 | 55 | ## Solutions 56 | 57 | Under `jira-cli`, you can find a worked-out solution. 58 | 59 | You can build it running: 60 | ```bash 61 | cargo build --bin jira-cli 62 | ``` 63 | 64 | You can try it out running: 65 | ```bash 66 | cargo run --bin jira-cli -- --help 67 | ``` 68 | 69 | You can run its tests running: 70 | ```bash 71 | cargo test --bin jira-cli 72 | ``` 73 | 74 | You can browse its documentation with: 75 | ```bash 76 | # We rely on the nightly compiler for automatic semantic link generation 77 | cargo +nightly doc --manifest-path jira-cli/Cargo.toml --open 78 | ``` 79 | -------------------------------------------------------------------------------- /jira-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "jira-cli" 3 | version = "0.1.0" 4 | authors = ["LukeMathWalker "] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | fake = { version = "2" } 11 | structopt = { version = "0.3", features = ["paw"] } 12 | paw = "1" 13 | directories = "2" 14 | serde = { version = "1", features = ["derive"] } 15 | serde_yaml = "0.8" 16 | -------------------------------------------------------------------------------- /jira-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::new_without_default)] 2 | 3 | use crate::models::{Comment, Status, TicketDraft, TicketPatch, Title}; 4 | use std::error::Error; 5 | use std::str::FromStr; 6 | 7 | pub mod models; 8 | pub mod persistence; 9 | pub mod store; 10 | 11 | #[derive(structopt::StructOpt)] 12 | /// A small command-line interface to interact with a toy Jira clone, IronJira. 13 | pub enum Command { 14 | /// Create a ticket on your board. 15 | Create { 16 | /// Description of the ticket. 17 | #[structopt(long)] 18 | description: String, 19 | /// Title of your ticket - it cannot be empty! 20 | #[structopt(long)] 21 | title: String, 22 | }, 23 | /// Edit the details of an existing ticket. 24 | Edit { 25 | #[structopt(long)] 26 | ticket_id: u64, 27 | #[structopt(long)] 28 | title: Option, 29 | #[structopt(long)] 30 | description: Option, 31 | }, 32 | /// Delete a ticket from the store passing the ticket id. 33 | Delete { 34 | #[structopt(long)] 35 | ticket_id: u64, 36 | }, 37 | /// List all existing tickets. 38 | List, 39 | /// Move a ticket to a new status. 40 | Move { 41 | #[structopt(long)] 42 | ticket_id: u64, 43 | #[structopt(long)] 44 | status: Status, 45 | }, 46 | /// Add a comment to a ticket 47 | Comment { 48 | #[structopt(long)] 49 | ticket_id: u64, 50 | /// Add a comment on the ticket - cannot be empty! 51 | #[structopt(long)] 52 | comment: String, 53 | }, 54 | } 55 | 56 | impl FromStr for Status { 57 | type Err = Box; 58 | 59 | fn from_str(s: &str) -> Result { 60 | let s = s.to_lowercase(); 61 | let status = match s.as_str() { 62 | "todo" | "to-do" => Status::ToDo, 63 | "inprogress" | "in-progress" => Status::InProgress, 64 | "blocked" => Status::Blocked, 65 | "done" => Status::Done, 66 | _ => panic!("The status you specified is not valid. Valid values: todo, inprogress, blocked and done.") 67 | }; 68 | Ok(status) 69 | } 70 | } 71 | 72 | fn main() -> Result<(), Box> { 73 | // Parse the command-line arguments. 74 | let command = ::parse_args()?; 75 | // Load the store from disk. If missing, a brand new one will be created. 76 | let mut ticket_store = persistence::load(); 77 | match command { 78 | Command::Create { description, title } => { 79 | let draft = TicketDraft { 80 | title: Title::new(title)?, 81 | description, 82 | }; 83 | ticket_store.create(draft); 84 | } 85 | Command::Edit { 86 | ticket_id, 87 | title, 88 | description, 89 | } => { 90 | let title = title.map(Title::new).transpose()?; 91 | let ticket_patch = TicketPatch { title, description }; 92 | match ticket_store.update_ticket(ticket_id, ticket_patch) { 93 | Some(_) => println!("Ticket {:?} was updated.", ticket_id), 94 | None => println!( 95 | "There was no ticket associated to the ticket id {:?}", 96 | ticket_id 97 | ), 98 | } 99 | } 100 | Command::Delete { ticket_id } => match ticket_store.delete(ticket_id) { 101 | Some(deleted_ticket) => println!( 102 | "The following ticket has been deleted:\n{:?}", 103 | deleted_ticket 104 | ), 105 | None => println!( 106 | "There was no ticket associated to the ticket id {:?}", 107 | ticket_id 108 | ), 109 | }, 110 | Command::List => { 111 | let ticket_list = ticket_store 112 | .list() 113 | .into_iter() 114 | .map(|t| format!("{}", t)) 115 | .collect::>() 116 | .join("\n\n"); 117 | println!("{}", ticket_list); 118 | } 119 | Command::Move { ticket_id, status } => { 120 | match ticket_store.update_ticket_status(ticket_id, status) { 121 | Some(_) => println!( 122 | "Status of ticket {:?} was updated to {:?}", 123 | ticket_id, status 124 | ), 125 | None => println!( 126 | "There was no ticket associated to the ticket id {:?}", 127 | ticket_id 128 | ), 129 | } 130 | } 131 | Command::Comment { ticket_id, comment } => { 132 | let new_comment = Comment::new(comment)?; 133 | match ticket_store.add_comment_to_ticket(ticket_id, new_comment) { 134 | Some(_) => println!("Comment has been added to ticket {:?}", ticket_id), 135 | None => println!( 136 | "There was no ticket associated to the ticket id {:?}", 137 | ticket_id 138 | ), 139 | } 140 | } 141 | } 142 | // Save the store state to disk after we have completed our action. 143 | persistence::save(&ticket_store); 144 | Ok(()) 145 | } 146 | -------------------------------------------------------------------------------- /jira-cli/src/models/comment.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::error::Error; 3 | use std::fmt; 4 | use std::fmt::Formatter; 5 | 6 | #[derive(PartialEq, Debug, Clone, Hash, Eq, Serialize, Deserialize)] 7 | /// Represents a comment on a [Ticket](Ticket) 8 | /// Wraps a string and checks that it is not empty when set 9 | pub struct Comment { 10 | comment: String, 11 | } 12 | 13 | #[derive(PartialEq, Debug, Clone)] 14 | /// Error if a comment cannot be created 15 | pub struct CommentError { 16 | details: String, 17 | } 18 | 19 | /// Sets the error message for a comment if it cannot be created 20 | impl CommentError { 21 | fn new(msg: &str) -> CommentError { 22 | CommentError { 23 | details: msg.to_string(), 24 | } 25 | } 26 | } 27 | 28 | /// Format CommentError for user display purposes 29 | impl fmt::Display for CommentError { 30 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 31 | write!(f, "{}", self.details) 32 | } 33 | } 34 | 35 | impl Error for CommentError { 36 | fn description(&self) -> &str { 37 | &self.details 38 | } 39 | } 40 | 41 | /// Creates a Comment for a [Ticket](Ticket) 42 | /// Results in a [CommentError](CommentError) if the string passed in is empty 43 | impl Comment { 44 | pub fn new(comment: String) -> Result { 45 | if comment.is_empty() { 46 | Err(CommentError::new("Comment cannot be empty")) 47 | } else { 48 | Ok(Comment { comment }) 49 | } 50 | } 51 | } 52 | 53 | impl std::fmt::Display for Comment { 54 | fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { 55 | write!(f, "{}", self.comment) 56 | } 57 | } 58 | 59 | #[cfg(test)] 60 | mod comment_tests { 61 | use crate::models::Comment; 62 | 63 | #[test] 64 | fn creating_empty_comment_should_fail() { 65 | // arrange 66 | // act 67 | let new_comment = Comment::new("".to_string()); 68 | // assert 69 | assert!(new_comment.is_err()); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /jira-cli/src/models/mod.rs: -------------------------------------------------------------------------------- 1 | mod comment; 2 | mod ticket; 3 | mod ticket_draft; 4 | mod ticket_patch; 5 | mod title; 6 | 7 | pub use comment::*; 8 | pub use ticket::*; 9 | pub use ticket_draft::*; 10 | pub use ticket_patch::*; 11 | pub use title::*; 12 | -------------------------------------------------------------------------------- /jira-cli/src/models/ticket.rs: -------------------------------------------------------------------------------- 1 | use crate::models::{Comment, Title}; 2 | use serde::export::fmt::Error; 3 | use serde::export::Formatter; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | pub type TicketId = u64; 7 | 8 | #[derive(PartialEq, Debug, Clone, Hash, Eq)] 9 | /// A ticket saved in the [TicketStore](TicketStore). 10 | /// 11 | /// **Invariant**: you can only build a ticket instance by retrieving it 12 | /// from the [TicketStore](TicketStore). 13 | #[derive(Serialize, Deserialize)] 14 | pub struct Ticket { 15 | /// The id of the ticket. Randomly generated from the [TicketStore](TicketStore), guaranteed to be unique. 16 | pub id: TicketId, 17 | pub title: Title, 18 | pub description: String, 19 | pub status: Status, 20 | pub comments: Vec, 21 | } 22 | 23 | impl std::fmt::Display for Ticket { 24 | fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { 25 | writeln!( 26 | f, 27 | "Ticket:\n\tId:{:?}\n\tTitle:{}\n\tDescription:{}\n\tStatus:{:?}\n\tComments:", 28 | self.id, self.title, self.description, self.status 29 | )?; 30 | for comment in self.comments.iter() { 31 | writeln!(f, "\t- {}", comment)?; 32 | } 33 | Ok(()) 34 | } 35 | } 36 | 37 | /// The status of a [Ticket](Ticket). 38 | #[derive(PartialEq, Debug, Copy, Clone, Hash, Eq, Serialize, Deserialize)] 39 | pub enum Status { 40 | ToDo, 41 | InProgress, 42 | Blocked, 43 | Done, 44 | } 45 | 46 | #[derive(PartialEq, Debug)] 47 | /// A ticket that was deleted from the store. 48 | /// 49 | /// Using the new-type pattern to distinguish it from [Ticket](Ticket). 50 | pub struct DeletedTicket(pub Ticket); 51 | -------------------------------------------------------------------------------- /jira-cli/src/models/ticket_draft.rs: -------------------------------------------------------------------------------- 1 | use crate::models::Title; 2 | 3 | #[derive(PartialEq, Debug, Clone)] 4 | /// The content of the ticket, not yet saved in the [TicketStore](TicketStore::create). 5 | pub struct TicketDraft { 6 | // The [Title](Title) of a ticket 7 | pub title: Title, 8 | pub description: String, 9 | } 10 | -------------------------------------------------------------------------------- /jira-cli/src/models/ticket_patch.rs: -------------------------------------------------------------------------------- 1 | use crate::models::Title; 2 | 3 | #[derive(PartialEq, Debug, Clone)] 4 | /// The content of the ticket, to be updated in the [TicketStore](TicketStore::create). 5 | pub struct TicketPatch { 6 | // The [Title](Title) of a ticket 7 | pub title: Option, 8 | pub description: Option<String>, 9 | } 10 | -------------------------------------------------------------------------------- /jira-cli/src/models/title.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use std::error::Error; 3 | use std::fmt; 4 | use std::fmt::Formatter; 5 | 6 | #[derive(PartialEq, Debug, Clone, Hash, Eq, Serialize, Deserialize)] 7 | /// The title of a [Ticket](Ticket) 8 | /// Wraps a string and checks that it's not empty when set 9 | pub struct Title { 10 | title: String, 11 | } 12 | 13 | #[derive(PartialEq, Debug, Clone)] 14 | /// Error if a title cannot be created 15 | pub struct TitleError { 16 | details: String, 17 | } 18 | 19 | /// Sets the error message for a title if it cannot be created 20 | impl TitleError { 21 | fn new(msg: &str) -> TitleError { 22 | TitleError { 23 | details: msg.to_string(), 24 | } 25 | } 26 | } 27 | 28 | impl fmt::Display for TitleError { 29 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 30 | write!(f, "{}", self.details) 31 | } 32 | } 33 | 34 | impl Error for TitleError { 35 | fn description(&self) -> &str { 36 | &self.details 37 | } 38 | } 39 | 40 | impl Title { 41 | /// Creates a Title for a [Ticket](Ticket) 42 | /// results in a [TitleError](TitleError) if the string passed in is empty 43 | pub fn new(title: String) -> Result<Title, TitleError> { 44 | if title.is_empty() { 45 | Err(TitleError::new("Title Cannot be empty")) 46 | } else { 47 | Ok(Title { title }) 48 | } 49 | } 50 | } 51 | 52 | impl std::fmt::Display for Title { 53 | fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { 54 | write!(f, "{}", self.title)?; 55 | Ok(()) 56 | } 57 | } 58 | 59 | #[cfg(test)] 60 | mod title_tests { 61 | use crate::models::Title; 62 | 63 | #[test] 64 | fn creating_an_empty_title_should_fail() { 65 | //arrange 66 | //act 67 | let new_title = Title::new("".to_string()); 68 | 69 | assert!(new_title.is_err()) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /jira-cli/src/persistence.rs: -------------------------------------------------------------------------------- 1 | use crate::store::TicketStore; 2 | use directories::ProjectDirs; 3 | use std::fs::read_to_string; 4 | use std::path::PathBuf; 5 | 6 | // `PROJECT_NAME`, `ORGANISATION_NAME` and `QUALIFIER` are used to determine 7 | // where to store configuration files and secrets for an application 8 | // according to the convention of the underlying operating system. 9 | // 10 | // `qualifier_name` is only relevant for MacOS - we leave it blank. 11 | const PROJECT_NAME: &str = "IronJIRA"; 12 | const ORGANISATION_NAME: &str = "RustLDNUserGroup"; 13 | const QUALIFIER: &str = ""; 14 | 15 | const TICKET_STORE: &str = "ticket_store.yaml"; 16 | 17 | fn data_store_filename() -> PathBuf { 18 | // Get the directory where we are supposed to store data 19 | // according to the convention of the underlying operating system. 20 | // 21 | // The operation could fail if some OS environment variables are not set (e.g. $HOME) 22 | let project_dir = ProjectDirs::from(QUALIFIER, ORGANISATION_NAME, PROJECT_NAME) 23 | .expect("Failed to determine path of the configuration directory."); 24 | let data_dir = project_dir.data_dir(); 25 | println!("Data storage directory: {:?}", data_dir); 26 | 27 | // Create the data directory, if missing. 28 | // It also takes care of creating intermediate sub-directory, if necessary. 29 | std::fs::create_dir_all(data_dir).expect("Failed to create data directory."); 30 | 31 | // Path to the file storing our tickets 32 | data_dir.join(TICKET_STORE) 33 | } 34 | 35 | /// Fetch authentication parameters from a configuration file, if available. 36 | pub fn load() -> TicketStore { 37 | let filename = data_store_filename(); 38 | // Read the data in memory, storing the value in a string 39 | println!("Reading data from {:?}", filename); 40 | match read_to_string(filename) { 41 | Ok(data) => { 42 | // Deserialize configuration from YAML format 43 | serde_yaml::from_str(&data).expect("Failed to parse serialised data.") 44 | } 45 | Err(e) => match e.kind() { 46 | // The file is missing - this is the first time you are using IronJira! 47 | std::io::ErrorKind::NotFound => { 48 | // Return default configuration 49 | TicketStore::new() 50 | } 51 | // Something went wrong - crash the CLI with an error message. 52 | _ => panic!("Failed to read data."), 53 | }, 54 | } 55 | } 56 | 57 | /// Save tickets on disk in the right file. 58 | pub fn save(ticket_store: &TicketStore) { 59 | let filename = data_store_filename(); 60 | // Serialize data to YAML format 61 | let content = serde_yaml::to_string(ticket_store).expect("Failed to serialize tickets"); 62 | // Save to disk 63 | println!("Saving tickets to {:?}", filename); 64 | std::fs::write(filename, content).expect("Failed to write tickets to disk.") 65 | } 66 | -------------------------------------------------------------------------------- /jira-cli/src/store.rs: -------------------------------------------------------------------------------- 1 | use crate::models::{Comment, DeletedTicket, Status, Ticket, TicketDraft, TicketId, TicketPatch}; 2 | use serde::{Deserialize, Serialize}; 3 | use std::collections::HashMap; 4 | 5 | /// In-memory database where we store the saved [`Ticket`]s. 6 | #[derive(Serialize, Deserialize)] 7 | pub struct TicketStore { 8 | /// Current state of the internal sequence, used for id generation in generate_id. 9 | current_id: u64, 10 | /// The collection of stored tickets. 11 | data: HashMap<TicketId, Ticket>, 12 | } 13 | 14 | impl TicketStore { 15 | /// Create a new empty [`TicketStore`] instance. 16 | pub fn new() -> Self { 17 | Self { 18 | current_id: 0, 19 | data: HashMap::new(), 20 | } 21 | } 22 | 23 | /// Given a ticket draft, it generates a unique identifier, it persists 24 | /// the new ticket in the store (assigning it a [ToDo status](Status::ToDo)) and returns 25 | /// the ticket identifier. 26 | pub fn create(&mut self, draft: TicketDraft) -> TicketId { 27 | let id = self.generate_id(); 28 | let ticket = Ticket { 29 | id, 30 | description: draft.description, 31 | title: draft.title, 32 | status: Status::ToDo, 33 | comments: Vec::new(), 34 | }; 35 | self.data.insert(ticket.id, ticket); 36 | id 37 | } 38 | 39 | /// Remove a [Ticket] from the store. 40 | /// Returns None if the [Ticket](Ticket) is not there or [DeletedTicket](DeletedTicket) if there was one. 41 | pub fn delete(&mut self, ticket_id: TicketId) -> Option<DeletedTicket> { 42 | self.data.remove(&ticket_id).map(DeletedTicket) 43 | } 44 | 45 | /// Returns list off all inserted [Ticket](Ticket) 46 | /// Returns an empty list of tickets is there are no tickets in the store 47 | pub fn list(&self) -> Vec<&Ticket> { 48 | self.data.iter().map(|(_, ticket)| ticket).collect() 49 | } 50 | 51 | /// Generate a unique id by incrementing monotonically a private counter. 52 | fn generate_id(&mut self) -> TicketId { 53 | self.current_id += 1; 54 | self.current_id 55 | } 56 | 57 | /// Retrieve a [Ticket] given an identifier. Returns `None` if there is no ticket with such an identifier. 58 | pub fn get(&self, id: TicketId) -> Option<&Ticket> { 59 | self.data.get(&id) 60 | } 61 | 62 | // Update a [Ticket] given an identifier and new [TicketPatch]. Returns `None` if there is no ticket with such an identifier. 63 | pub fn update_ticket(&mut self, id: TicketId, patch: TicketPatch) -> Option<()> { 64 | self.data.get_mut(&id).map(|t| { 65 | if let Some(title) = patch.title { 66 | t.title = title; 67 | } 68 | if let Some(description) = patch.description { 69 | t.description = description; 70 | } 71 | }) 72 | } 73 | 74 | // Update a [Ticket] [Status] given an identifier and new [Status]. Returns `None` if there is no ticket with such an identifier. 75 | pub fn update_ticket_status(&mut self, id: TicketId, status: Status) -> Option<()> { 76 | self.data.get_mut(&id).map(|t| t.status = status) 77 | } 78 | 79 | pub fn add_comment_to_ticket(&mut self, id: TicketId, comment: Comment) -> Option<()> { 80 | self.data.get_mut(&id).map(|t| t.comments.push(comment)) 81 | } 82 | } 83 | 84 | #[cfg(test)] 85 | mod tests { 86 | use crate::models::{Comment, Status, Ticket, TicketDraft, TicketPatch, Title}; 87 | use crate::store::TicketStore; 88 | use fake::{Fake, Faker}; 89 | use std::collections::HashSet; 90 | 91 | #[test] 92 | fn create_ticket_test() { 93 | //arrange 94 | let draft = TicketDraft { 95 | title: Title::new(Faker.fake()).expect("Title should exist"), 96 | description: Faker.fake(), 97 | }; 98 | 99 | let mut ticket_store = TicketStore::new(); 100 | 101 | //act 102 | let ticket_id = ticket_store.create(draft.clone()); 103 | 104 | //assert 105 | let ticket = ticket_store 106 | .get(ticket_id) 107 | .expect("Failed to retrieve ticket."); 108 | assert_eq!(ticket.title, draft.title); 109 | assert_eq!(ticket.description, draft.description); 110 | assert_eq!(ticket.status, Status::ToDo); 111 | } 112 | 113 | #[test] 114 | fn delete_ticket_test() { 115 | //arrange 116 | let draft = TicketDraft { 117 | title: Title::new(Faker.fake()).expect("Title should exist"), 118 | description: Faker.fake(), 119 | }; 120 | 121 | let mut ticket_store = TicketStore::new(); 122 | let ticket_id = ticket_store.create(draft.clone()); 123 | let inserted_ticket = ticket_store 124 | .get(ticket_id) 125 | .expect("Failed to retrieve ticket") 126 | .to_owned(); 127 | 128 | //act 129 | let deleted_ticket = ticket_store 130 | .delete(ticket_id) 131 | .expect("There was no ticket to delete."); 132 | 133 | //assert 134 | assert_eq!(deleted_ticket.0, inserted_ticket); 135 | let ticket = ticket_store.get(ticket_id); 136 | assert_eq!(ticket, None); 137 | } 138 | 139 | #[test] 140 | fn deleting_a_ticket_that_does_not_exist_returns_none() { 141 | //arrange 142 | let mut ticket_store = TicketStore::new(); 143 | 144 | //act 145 | let deleted_ticket = ticket_store.delete(Faker.fake()); 146 | 147 | //assert 148 | assert_eq!(deleted_ticket, None); 149 | } 150 | 151 | #[test] 152 | fn listing_tickets_of_an_empty_store_returns_an_empty_collection() { 153 | // Arrange 154 | let ticket_store = TicketStore::new(); 155 | 156 | // Act 157 | let tickets = ticket_store.list(); 158 | 159 | // Assert 160 | assert!(tickets.is_empty()) 161 | } 162 | 163 | #[test] 164 | fn listing_tickets_should_return_them_all() { 165 | // Arrange 166 | let mut ticket_store = TicketStore::new(); 167 | let n_tickets = Faker.fake::<u16>() as usize; 168 | let tickets: HashSet<_> = (0..n_tickets) 169 | .map(|_| generate_and_persist_ticket(&mut ticket_store)) 170 | .collect(); 171 | 172 | // Act 173 | let retrieved_tickets = ticket_store.list(); 174 | 175 | // Assert 176 | assert_eq!(retrieved_tickets.len(), n_tickets); 177 | let retrieved_tickets: HashSet<_> = retrieved_tickets 178 | .into_iter() 179 | .map(|t| t.to_owned()) 180 | .collect(); 181 | assert_eq!(tickets, retrieved_tickets); 182 | } 183 | 184 | fn generate_and_persist_ticket(store: &mut TicketStore) -> Ticket { 185 | // arrange 186 | let draft = TicketDraft { 187 | title: Title::new(Faker.fake()).expect("Failed to get a title"), 188 | description: Faker.fake(), 189 | }; 190 | let ticket_id = store.create(draft); 191 | store 192 | .get(ticket_id) 193 | .expect("Failed to retrieve ticket") 194 | .to_owned() 195 | } 196 | 197 | #[test] 198 | fn updating_ticket_info_via_patch_should_update_ticket() { 199 | // arrange 200 | let mut ticket_store = TicketStore::new(); 201 | 202 | let ticket = generate_and_persist_ticket(&mut ticket_store); 203 | 204 | let patch = TicketPatch { 205 | title: Some(Title::new(Faker.fake()).expect("Failed to get a title")), 206 | description: Some(Faker.fake()), 207 | }; 208 | 209 | let expected = patch.clone(); 210 | 211 | //act 212 | ticket_store.update_ticket(ticket.id, patch); 213 | 214 | //assert 215 | let updated_ticket = ticket_store 216 | .get(ticket.id) 217 | .expect("Failed to retrieve ticket."); 218 | 219 | assert_eq!( 220 | updated_ticket.title, 221 | expected.title.expect("Failed to get a title") 222 | ); 223 | 224 | assert_eq!( 225 | updated_ticket.description, 226 | expected.description.expect("Failed to get a Description") 227 | ); 228 | } 229 | 230 | #[test] 231 | fn updating_ticket_with_no_patch_values_should_not_fail_or_change_values() { 232 | //arrange 233 | let draft = TicketDraft { 234 | title: Title::new(Faker.fake()).expect("Failed to get a title"), 235 | description: Faker.fake(), 236 | }; 237 | 238 | let mut ticket_store = TicketStore::new(); 239 | 240 | let ticket_id = ticket_store.create(draft.clone()); 241 | 242 | let patch = TicketPatch { 243 | title: None, 244 | description: None, 245 | }; 246 | 247 | //act 248 | ticket_store.update_ticket(ticket_id, patch); 249 | 250 | //assert 251 | let updated_ticket = ticket_store 252 | .get(ticket_id) 253 | .expect("Failed to retrieve ticket."); 254 | 255 | assert_eq!(updated_ticket.title, draft.title); 256 | 257 | assert_eq!(updated_ticket.description, draft.description); 258 | } 259 | 260 | #[test] 261 | fn updating_ticket_status_should_change_ticket_to_new_status() { 262 | //arrange 263 | let mut ticket_store = TicketStore::new(); 264 | 265 | let ticket = generate_and_persist_ticket(&mut ticket_store); 266 | 267 | //act 268 | ticket_store.update_ticket_status(ticket.id, Status::Done); 269 | 270 | //assert 271 | let updated_ticket = ticket_store 272 | .get(ticket.id) 273 | .expect("Failed to retrieve ticket."); 274 | 275 | assert_eq!(updated_ticket.status, Status::Done) 276 | } 277 | 278 | #[test] 279 | fn add_comment_to_ticket() { 280 | //arrange 281 | let mut ticket_store = TicketStore::new(); 282 | let ticket = generate_and_persist_ticket(&mut ticket_store); 283 | let comment = Comment::new("Test Comment".to_string()).unwrap(); 284 | let expected = comment.clone(); 285 | 286 | //act 287 | let result = ticket_store.add_comment_to_ticket(ticket.id, comment); 288 | //assert 289 | assert!(result.is_some()); 290 | let ticket = ticket_store.get(ticket.id).unwrap(); 291 | assert_eq!(ticket.comments, vec![expected]); 292 | } 293 | 294 | #[test] 295 | fn add_comment_to_invalid_ticket_id_returns_none() { 296 | let faker = fake::Faker; 297 | 298 | //arrange 299 | let mut ticket_store = TicketStore::new(); 300 | let comment = Comment::new("Test comment".to_string()).unwrap(); 301 | 302 | //act 303 | let result = ticket_store.add_comment_to_ticket(faker.fake(), comment); 304 | 305 | //assert 306 | assert!(result.is_none()); 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /jira-wip/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "jira-wip" 3 | version = "0.1.0" 4 | authors = ["LukeMathWalker <rust@lpalmieri.com>"] 5 | edition = "2018" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | chrono = { version = "0.4", features = ["serde"] } 11 | structopt = { version = "0.3", features = ["paw"] } 12 | paw = "1" 13 | directories = "2" 14 | serde = { version = "1", features = ["derive"] } 15 | serde_yaml = "0.8" 16 | 17 | [dev-dependencies] 18 | fake = "2" 19 | tempfile = "3" 20 | -------------------------------------------------------------------------------- /jira-wip/src/koans/00_greetings/00_greetings.rs: -------------------------------------------------------------------------------- 1 | /// It's our pleasure to welcome you to the Rust London Code Dojo! 2 | /// You will be learning Rust while building your own JIRA clone! 3 | /// 4 | /// The material is structured as a series of exercises, or koans. 5 | /// 6 | /// A koan is a riddle or puzzle that Zen Buddhists use during meditation to help them 7 | /// unravel greater truths about the world and about themselves. 8 | /// 9 | /// Will you get the chance to unveil deeper insights about yourself during this session? 10 | /// Maybe, maybe not. 11 | /// But we'll try out best to take you from "what is this Rust thing?" 12 | /// to "Look, ma! I can do this programming thing with it!". 13 | /// 14 | /// If everything goes well, at the end of the session you will: 15 | /// - have implemented a CLI to interact with a file-based no-server JIRA clone; 16 | /// - know enough about `Rust` and its ecosystem to go on and have fun with it! 17 | /// 18 | /// **Practicalities**: 19 | /// - each koan is a sub-folder in the `koans` folder; 20 | /// - each folder contains multiple test files with one or more tests in each of it; 21 | /// - you can move along your journey with `koans --path jira-wip`: 22 | /// - if you have successfully completed an exercise (or you just started this workshop) 23 | /// the console output will ask if you want to move forward and 24 | /// tell you the name of the next koan you should get started with if you say "yes"; 25 | /// - if something is wrong with your test cases, the console output will contain 26 | /// the compiler errors or test failures that you should investigate. 27 | /// 28 | /// ~ Enjoy! ~ 29 | /// 30 | #[cfg(test)] 31 | mod tests { 32 | #[test] 33 | /// This is your starting block! 34 | /// 35 | /// In each test, you are expected to replace __ or todo!() in order to make test pass. 36 | /// 37 | /// Sometimes a one-liner (or a literal value) will be enough. 38 | /// Sometimes you will have to write a bit more to get the job done. 39 | /// 40 | /// If you get stuck, don't hesitate to ping us! 41 | fn the_beginning_of_your_journey() { 42 | let i_am_ready_to_start = __; 43 | 44 | assert!(i_am_ready_to_start); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /jira-wip/src/koans/01_ticket/01_ticket.rs: -------------------------------------------------------------------------------- 1 | /// We will begin our journey of building our own JIRA clone defining the cornerstone of 2 | /// JIRA's experience: the ticket. 3 | /// For now we want to limit ourselves to the essentials: each ticket will have a title 4 | /// and a description. 5 | /// No, not an ID yet. We will get to that in due time. 6 | /// 7 | /// There are various ways to represent a set of related pieces of information in Rust. 8 | /// We'll go for a `struct`: a struct is quite similar to what you would call a class or 9 | /// an object in object-oriented programming languages. 10 | /// It is a collection of fields, each one with its own name. 11 | /// Given that Rust is a strongly-typed language, we also need to specify a type for each 12 | /// of those fields. 13 | /// 14 | /// Our definition of Ticket is incomplete - can you replace __ with what is missing to make 15 | /// this snippet compile and the tests below succeed? 16 | /// 17 | /// You can find more about structs in the Rust Book: https://doc.rust-lang.org/book/ch05-01-defining-structs.html 18 | pub struct Ticket { 19 | title: String, 20 | __: __ 21 | } 22 | 23 | /// `cfg` stands for configuration flag. 24 | /// The #[cfg(_)] attribute is used to mark a section of the code for conditional compilation 25 | /// based on the value of the specified flag. 26 | /// #[cfg(test)] is used to mark sections of our codebase that should only be compiled 27 | /// when running `cargo test`... 28 | /// Yes, tests! 29 | /// 30 | /// You can put tests in different places in a Rust project, depending on what you are 31 | /// trying to do: unit testing of private functions and methods, testing an internal API, 32 | /// integration testing your crate from the outside, etc. 33 | /// You can find more details on test organisation in the Rust book: 34 | /// https://doc.rust-lang.org/book/ch11-03-test-organization.html 35 | /// 36 | /// Let it be said that tests are first-class citizens in the Rust ecosystem and you are 37 | /// provided with a barebone test framework out of the box. 38 | #[cfg(test)] 39 | mod tests { 40 | use super::*; 41 | 42 | /// The #[test] attribute is used to mark a function as a test for the compiler. 43 | /// Tests take no arguments: when we run `cargo test`, this function will be invoked. 44 | /// If it runs without raising any issue, the test is considered green - it passed. 45 | /// If it panics (raises a fatal exception), then the test is considered red - it failed. 46 | /// 47 | /// `cargo test` reports on the number of failed tests at the end of each run, with some 48 | /// associated diagnostics to make it easier to understand what went wrong exactly. 49 | #[test] 50 | fn your_first_ticket() { 51 | /// `let` is used to create a variable: we are binding a new `Ticket` struct 52 | /// to the name `ticket_one`. 53 | /// 54 | /// We said before that Rust is strongly typed, nonetheless we haven't specified 55 | /// a type for `ticket_one`. 56 | /// As most modern strongly typed programming languages, Rust provides type inference: 57 | /// the compiler is smart enough to figure out the type of variables based on 58 | /// their usage and it won't bother you unless the type is ambiguous. 59 | let ticket_one = Ticket { 60 | /// This `.into()` method call is here for a reason, but give us time. 61 | /// We'll get there when it's the right moment. 62 | title: "A ticket title".into(), 63 | description: "A heart-breaking description".into() 64 | }; 65 | 66 | /// `assert_eq` is a macro (notice the ! at the end of the name). 67 | /// It checks that the left argument (the expected value) is identical 68 | /// to the right argument (the computed value). 69 | /// If they are not, it panics - Rust's (almost) non-recoverable way to terminate a program. 70 | /// In the case of tests, this is caught by the test framework and the test is marked as failed. 71 | assert_eq!(ticket_one.title, "A ticket title"); 72 | /// Field syntax: you use a dot to access the field of a struct. 73 | assert_eq!(ticket_one.description, "A heart-breaking description"); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /jira-wip/src/koans/01_ticket/02_status.rs: -------------------------------------------------------------------------------- 1 | /// Ticket have two purposes in JIRA: capturing information about a task and tracking the 2 | /// completion of the task itself. 3 | /// 4 | /// Let's add a new field to our `Ticket` struct, `status`. 5 | /// For the time being, we'll work under the simplified assumption that the set of statuses 6 | /// for a ticket is fixed and can't be customised by the user. 7 | /// A ticket is either in the to-do column, in progress, blocked or done. 8 | /// What is the best way to represent this information in Rust? 9 | struct Ticket { 10 | title: String, 11 | description: String, 12 | status: Status, 13 | } 14 | 15 | /// Rust's enums are perfect for this usecase. 16 | /// Enum stands for enumeration: a type encoding the constraint that only a finite set of 17 | /// values is possible. 18 | /// Enums are great to encode semantic information in your code: making domain constraints 19 | /// explicit. 20 | /// 21 | /// Each possible value of an enum is called a variant. By convention, they are Pascal-cased. 22 | /// Check out the Rust book for more details on enums: 23 | /// https://doc.rust-lang.org/book/ch06-01-defining-an-enum.html 24 | /// 25 | /// Let's create a variant for each of the allowed statuses of our tickets. 26 | pub enum Status { 27 | ToDo, 28 | __ 29 | } 30 | 31 | 32 | #[cfg(test)] 33 | mod tests { 34 | use super::*; 35 | 36 | #[test] 37 | fn a_blocked_ticket() { 38 | // Let's create a blocked ticket. 39 | let ticket = Ticket { 40 | title: "A ticket title".into(), 41 | description: "A heart-breaking description".into(), 42 | status: __ 43 | }; 44 | 45 | // Let's check that the status corresponds to what we expect. 46 | // We can use pattern matching to take a different course of action based on the enum 47 | // variant we are looking at. 48 | // The Rust compiler will make sure that the match statement is exhaustive: it has to 49 | // handle all variants in our enums. 50 | // If not, the compiler will complain and reject our program. 51 | // 52 | // This is extremely useful when working on evolving codebases: if tomorrow we decide 53 | // that tickets can also have `Backlog` as their status, the Rust compiler will 54 | // highlight all code locations where we need to account for the new variant. 55 | // No way to forget! 56 | // 57 | // Checkout the Rust Book for more details: 58 | // https://doc.rust-lang.org/book/ch06-02-match.html 59 | match ticket.status { 60 | // Variant => Expression 61 | Status::Blocked => println!("Great, as expected!"), 62 | // If we want to take the same action for multiple variants, we can use a | to list them. 63 | // Variant | Variant | ... | Variant => Expression 64 | // 65 | // We are panicking in this case, thus making the test fail if this branch of our 66 | // match statement gets executed. 67 | Status::ToDo | Status::InProgress | Status::Done => panic!("The ticket is not blocked!") 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /jira-wip/src/koans/01_ticket/03_validation.rs: -------------------------------------------------------------------------------- 1 | enum Status { 2 | ToDo, 3 | InProgress, 4 | Blocked, 5 | Done, 6 | } 7 | 8 | struct Ticket { 9 | title: String, 10 | description: String, 11 | status: Status, 12 | } 13 | 14 | /// So far we have allowed any string as a valid title and description. 15 | /// That's not what would happen in JIRA: we wouldn't allow tickets with an empty title, 16 | /// for example. 17 | /// Both title and description would also have length limitations: the Divine Comedy probably 18 | /// shouldn't be allowed as a ticket description. 19 | /// 20 | /// We want to define a function that takes in a title, a description and a status and 21 | /// performs validation: it panics if validation fails, it returns a `Ticket` if validation 22 | /// succeeds. 23 | /// 24 | /// We will learn a better way to handle recoverable errors such as this one further along, 25 | /// but let's rely on panic for the time being. 26 | fn create_ticket(title: String, description: String, status: Status) -> Ticket { 27 | todo!() 28 | } 29 | 30 | #[cfg(test)] 31 | mod tests { 32 | use super::*; 33 | use fake::Fake; 34 | 35 | /// The #[should_panic] attribute inverts the usual behaviour for tests: if execution of 36 | /// the test's function body causes a panic, the test is green; otherwise, it's red. 37 | /// 38 | /// This is quite handy to test unhappy path: in our case, what happens when invalid input 39 | /// is passed to `create_ticket`. 40 | #[test] 41 | #[should_panic] 42 | fn title_cannot_be_empty() { 43 | // We don't really care about the description in this test. 44 | // Hence we generate a random string, with length between 0 and 3000 characters 45 | // using `fake`, a handy crate to generate random test data. 46 | // 47 | // We are using Rust's range syntax, 0..3000 - the lower-bound is included, the 48 | // upper-bound is excluded. 49 | // You can include the upper-bound using 0..=3000. 50 | let description = (0..3000).fake(); 51 | 52 | create_ticket("".into(), description, Status::ToDo); 53 | } 54 | 55 | #[test] 56 | #[should_panic] 57 | fn title_cannot_be_longer_than_fifty_chars() { 58 | let description = (0..3000).fake(); 59 | // Let's generate a title longer than 51 chars. 60 | let title = (51..10_000).fake(); 61 | 62 | create_ticket(title, description, Status::ToDo); 63 | } 64 | 65 | #[test] 66 | #[should_panic] 67 | fn description_cannot_be_longer_than_3000_chars() { 68 | let description = (3001..10_000).fake(); 69 | let title = (1..50).fake(); 70 | 71 | create_ticket(title, description, Status::ToDo); 72 | } 73 | 74 | #[test] 75 | fn valid_tickets_can_be_created() { 76 | let description = (0..3000).fake(); 77 | let title = (1..50).fake(); 78 | let status = Status::Done; 79 | 80 | create_ticket(title, description, status); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /jira-wip/src/koans/01_ticket/04_visibility.rs: -------------------------------------------------------------------------------- 1 | /// You might have noticed the `mod XX` at the beginning of our koans' tests. 2 | /// `mod` stands for module: it's one of the tools Rust gives you to organise your code. 3 | /// In particular, modules have an impact on the visibility of your structs, enums and functions. 4 | /// 5 | /// We want to use this koan to explore the impact that modules have on the structure of your 6 | /// projects and how you can leverage them to enforce encapsulation. 7 | /// 8 | /// You can find out more about modules and visibility in the Rust book: 9 | /// https://doc.rust-lang.org/book/ch07-00-managing-growing-projects-with-packages-crates-and-modules.html 10 | pub mod ticket { 11 | /// Structs, enums and functions defined in a module are visible to all other structs, 12 | /// enums and functions in the same module - e.g. we can use `Ticket` in the signature 13 | /// of `create_ticket` as our return type. 14 | /// 15 | /// That is no longer the case outside of the module where they are defined: all entities 16 | /// in Rust are private by default, unless they prefixed with `pub`. 17 | /// 18 | /// The same applies to fields in a struct. 19 | /// Functions defined within the same module of a struct have access to all the fields of 20 | /// the struct (e.g. `create_ticket` can create a `Ticket` by specifying its fields). 21 | /// Outside of the module, those fields are inaccessible because they are considered 22 | /// private by default, unless prefixed with pub. 23 | enum Status { 24 | ToDo, 25 | InProgress, 26 | Blocked, 27 | Done, 28 | } 29 | 30 | struct Ticket { 31 | title: String, 32 | description: String, 33 | status: Status, 34 | } 35 | 36 | fn create_ticket(title: String, description: String, status: Status) -> Ticket { 37 | if title.is_empty() { 38 | panic!("Title cannot be empty!"); 39 | } 40 | if title.len() > 50 { 41 | panic!("A title cannot be longer than 50 characters!"); 42 | } 43 | if description.len() > 3000 { 44 | panic!("A description cannot be longer than 3000 characters!"); 45 | } 46 | 47 | // Functions implicitly return the result of their last expression so we can omit 48 | // the `return` keyword here. 49 | Ticket { 50 | title, 51 | description, 52 | status, 53 | } 54 | } 55 | } 56 | 57 | #[cfg(test)] 58 | mod tests { 59 | /// Add the necessary `pub` modifiers in the code above to avoid having the compiler 60 | /// complaining about this use statement. 61 | use super::ticket::{create_ticket, Status, Ticket}; 62 | 63 | /// Be careful though! We don't want this function to compile after you have changed 64 | /// visibility to make the use statement compile! 65 | /// Once you have verified that it indeed doesn't compile, comment it out. 66 | fn should_not_be_possible() { 67 | let ticket: Ticket = 68 | create_ticket("A title".into(), "A description".into(), Status::ToDo); 69 | 70 | // You should be seeing this error when trying to run this koan: 71 | // 72 | // error[E0616]: field `description` of struct `path_to_enlightenment::visibility::ticket::Ticket` is private 73 | // --> jira-wip/src/koans/01_ticket/04_visibility.rs:81:24 74 | // | 75 | // 81 | assert_eq!(ticket.description, "A description"); 76 | // | ^^^^^^^^^^^^^^^^^^ 77 | // 78 | // Once you have verified that the below does not compile, 79 | // comment the line out to move on to the next koan! 80 | assert_eq!(ticket.description, "A description"); 81 | } 82 | 83 | fn encapsulation_cannot_be_violated() { 84 | // This should be impossible as well, with a similar error as the one encountered above. 85 | // (It will throw a compilation error only after you have commented the faulty line 86 | // in the previous test - next compilation stage!) 87 | // 88 | // This proves that `create_ticket` is now the only way to get a `Ticket` instance. 89 | // It's impossible to create a ticket with an illegal title or description! 90 | // 91 | // Once you have verified that the below does not compile, 92 | // comment the lines out to move on to the next koan! 93 | let ticket = Ticket { 94 | title: "A title".into(), 95 | description: "A description".into(), 96 | status: Status::ToDo, 97 | }; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /jira-wip/src/koans/01_ticket/05_ownership.rs: -------------------------------------------------------------------------------- 1 | /// Using modules and visibility modifiers we have now fully encapsulated the fields of our Ticket. 2 | /// There is no way to create a Ticket instance skipping our validation. 3 | /// At the same time though, we have made it impossible to access the fields of our struct, 4 | /// because they are private! 5 | /// 6 | /// Let's fix that introducing a bunch of accessor methods providing **read-only** access 7 | /// to the fields in a ticket. 8 | 9 | /// Let's import the Status enum we defined in the previous exercise, we won't have to modify it. 10 | use super::visibility::ticket::Status; 11 | 12 | /// Re-defining Ticket here because methods who need to access private fields 13 | /// have to be defined in the same module of the struct itself, as we saw in the previous 14 | /// exercise. 15 | pub struct Ticket { 16 | title: String, 17 | description: String, 18 | status: Status 19 | } 20 | 21 | /// Methods on a struct are defined in `impl` blocks. 22 | impl Ticket { 23 | /// The syntax looks very similar to the syntax to define functions. 24 | /// There is only one peculiarity: if you want to access the struct in a method, 25 | /// you need to take `self` as your first parameter in the method signature. 26 | /// 27 | /// You have three options, depending on what you are trying to accomplish: 28 | /// - self 29 | /// - &self 30 | /// - &mut self 31 | /// 32 | /// We are now touching for the first time the topic of ownership, enforced by 33 | /// the compiler via the (in)famous borrow-checker. 34 | /// 35 | /// In Rust, each value has an owner, statically determined at compile-time. 36 | /// There is only one owner for each value at any given time. 37 | /// Tracking ownership at compile-time is what makes it possible for Rust not to have 38 | /// garbage collection without requiring the developer to manage memory explicitly 39 | /// (most of the times). 40 | /// 41 | /// What can an owner do with a value `a`? 42 | /// It can mutate it. 43 | /// It can move ownership to another function or variable. 44 | /// It can lend many immutable references (`&a`) to that value to other functions or variables. 45 | /// It can lend a **single** mutable reference (`&mut a`) to that value to another 46 | /// function or variable. 47 | /// 48 | /// What can you do with a shared immutable reference (`&a`) to a value? 49 | /// You can read the value and create more immutable references. 50 | /// 51 | /// What can you do with a single mutable reference (`&mut a`) to a value? 52 | /// You can mutate the underlying value. 53 | /// 54 | /// Ownership is embedded in the type system: each function has to declare in its signature 55 | /// what kind of ownership level it requires for all its arguments. 56 | /// If the caller cannot fulfill those requirements, they cannot call the function. 57 | /// 58 | /// In our case, we only need to read a field of our Ticket struct: it will be enough to ask 59 | /// for an immutable reference to our struct. 60 | /// 61 | /// If this sounds a bit complicated/vague, hold on: it will get clearer as you 62 | /// move through the exercises and work your way through a bunch of compiler errors: 63 | /// the compiler is the best pair programming buddy to get familiar with ownership 64 | /// and its rules. 65 | /// To read more on ownership check: 66 | /// https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html 67 | pub fn title(&self) -> &String { 68 | /// We are returning an immutable reference (&) to our title field. 69 | /// This will allow us to access this field without being able to mutate it: 70 | /// encapsulation is guaranteed and we can rest assured that our invariants 71 | /// cannot be violated. 72 | &self.title 73 | } 74 | 75 | /// Replace __ with the proper types to get accessor methods for the other two fields. 76 | /// If you are asking yourself why we are returning &str instead of &String, check out: 77 | /// https://blog.thoughtram.io/string-vs-str-in-rust/ 78 | pub fn description(__) -> __ { 79 | todo!() 80 | } 81 | 82 | pub fn status(__) -> __ { 83 | todo!() 84 | } 85 | } 86 | 87 | pub fn create_ticket(title: String, description: String, status: Status) -> Ticket { 88 | if title.is_empty() { 89 | panic!("Title cannot be empty!"); 90 | } 91 | if title.len() > 50 { 92 | panic!("A title cannot be longer than 50 characters!"); 93 | } 94 | if description.len() > 3000 { 95 | panic!("A description cannot be longer than 3000 characters!"); 96 | } 97 | 98 | Ticket { 99 | title, 100 | description, 101 | status, 102 | } 103 | } 104 | 105 | #[cfg(test)] 106 | mod tests { 107 | use super::{create_ticket, Ticket}; 108 | use super::super::visibility::ticket::Status; 109 | 110 | fn verify_without_tampering() { 111 | let ticket: Ticket = create_ticket("A title".into(), "A description".into(), Status::ToDo); 112 | 113 | /// Instead of accessing the field `ticket.description` we are calling the accessor 114 | /// method, `ticket.description()`, which returns us a reference to the field value 115 | /// and allows us to verify its value without having the chance to modify it. 116 | assert_eq!(ticket.description(), "A description"); 117 | assert_eq!(ticket.title(), "A title"); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /jira-wip/src/koans/01_ticket/06_traits.rs: -------------------------------------------------------------------------------- 1 | use crate::path_to_enlightenment::visibility::ticket::Status; 2 | 3 | /// You might have noticed that in the test for the previous koan we haven't checked if 4 | /// the status returned by `.status()` matched the status we passed to `create_ticket`. 5 | /// 6 | /// That's because `assert_eq!(ticket.status(), Status::ToDo)` would have failed to compiled: 7 | /// 8 | /// error[E0369]: binary operation `==` cannot be applied to type `&path_to_enlightenment::visibility::ticket::Status` 9 | /// --> jira-wip/src/koans/01_ticket/05_ownership.rs:128:13 10 | /// | 11 | /// 128 | assert_eq!(ticket.status(), Status::ToDo); 12 | /// | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 13 | /// | | 14 | /// | &path_to_enlightenment::visibility::ticket::Status 15 | /// | path_to_enlightenment::visibility::ticket::Status 16 | /// | 17 | /// = note: an implementation of `std::cmp::PartialEq` might be missing for `&path_to_enlightenment::visibility::ticket::Status` 18 | /// 19 | /// `assert_eq` requires that its arguments implement the `PartialEq` trait. 20 | /// What is a trait? 21 | /// Traits in Rust are very similar to interfaces in other programming languages: 22 | /// a trait describes a behaviour/capability. 23 | /// For example: 24 | /// 25 | /// ``` 26 | /// pub trait Pay { 27 | /// fn pay(self, amount: u64, currency: String) -> u64 28 | /// } 29 | /// ``` 30 | /// 31 | /// In practical terms, a trait defines the signature of a collection of methods. 32 | /// To implement a trait, a struct or an enum have to implement those methods 33 | /// in an `impl Trait` block: 34 | /// 35 | /// ``` 36 | /// impl Pay for TaxPayer { 37 | /// fn pay(self, amount: u64, currency: String) -> u64 { 38 | /// todo!() 39 | /// } 40 | /// } 41 | /// ``` 42 | /// 43 | /// `PartialEq` is the trait that powers the == operator. 44 | /// Its definition looks something like this (simplified): 45 | /// ``` 46 | /// pub trait PartialEq { 47 | /// fn eq(&self, other: &Self) -> bool 48 | /// } 49 | /// ``` 50 | /// It's slightly more complicated, with generic parameters, to allow comparing different types. 51 | /// But let's roll with this simplified version for now. 52 | /// 53 | /// Let's implement it for Status! 54 | impl PartialEq for Status { 55 | fn eq(&self, other: &Status) -> bool { 56 | // If you need to refresh the `match` syntax, checkout 57 | // https://doc.rust-lang.org/book/ch06-02-match.html 58 | match (self, other) { 59 | __ 60 | } 61 | } 62 | } 63 | 64 | #[cfg(test)] 65 | mod tests { 66 | use super::*; 67 | 68 | #[test] 69 | fn test_equality() { 70 | // Your goal is to make this test compile. 71 | assert_eq!(Status::ToDo == Status::ToDo, true); 72 | assert_eq!(Status::Done == Status::ToDo, false); 73 | assert_eq!(Status::InProgress == Status::ToDo, false); 74 | assert_eq!(Status::InProgress == Status::InProgress, true); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /jira-wip/src/koans/01_ticket/07_derive.rs: -------------------------------------------------------------------------------- 1 | /// Cool, we learned what a trait is and how to implement one. 2 | /// I am sure you agree with us though: implementing PartialEq was quite tedious 3 | /// and repetitive, a computer can surely do a better job without having to trouble us! 4 | /// 5 | /// The Rust team feels your pain, hence a handy feature: derive macros. 6 | /// Derive macros are a code-generation tool: before code compilation kicks-in, derive 7 | /// macros take as input the code they have been applied to (as a stream of tokens!) 8 | /// and they have a chance to generate other code, as needed. 9 | /// 10 | /// For example, this `#[derive(PartialEq)]` will take as input the definition of our 11 | /// enum and generate an implementation of PartialEq which is exactly equivalent to 12 | /// the one we rolled out manually in the previous koan. 13 | /// You can check the code generated by derive macros using `cargo expand`: 14 | /// https://github.com/dtolnay/cargo-expand 15 | /// 16 | /// ```sh 17 | /// cargo install cargo-expand 18 | /// cargo expand -p jira-wip path_to_enlightenment::derive 19 | /// ``` 20 | /// 21 | /// PartialEq is not the only trait whose implementation can be derived automatically! 22 | #[derive(PartialEq)] 23 | pub enum Status { 24 | ToDo, 25 | InProgress, 26 | Blocked, 27 | Done, 28 | } 29 | 30 | #[cfg(test)] 31 | mod tests { 32 | use super::*; 33 | 34 | #[test] 35 | fn assertions() { 36 | // Your goal is to make this test compile. 37 | assert_eq!(Status::ToDo, Status::ToDo); 38 | assert_ne!(Status::Done, Status::ToDo); 39 | assert_ne!(Status::InProgress, Status::ToDo); 40 | assert_eq!(Status::InProgress, Status::InProgress); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /jira-wip/src/koans/01_ticket/08_recap.rs: -------------------------------------------------------------------------------- 1 | /// We have come quite a long way now: from how to define a struct to traits and derive macros, 2 | /// touching on tests, module system, visibility, ownership and method syntax. 3 | /// Take a deep breath, stretch a bit, review what we have done. 4 | /// 5 | /// Then get ready to dive in the next section! 6 | 7 | #[derive(PartialEq, Debug)] 8 | pub enum Status { 9 | ToDo, 10 | InProgress, 11 | Blocked, 12 | Done, 13 | } 14 | 15 | pub struct Ticket { 16 | title: String, 17 | description: String, 18 | status: Status, 19 | } 20 | 21 | impl Ticket { 22 | pub fn title(&self) -> &String { 23 | &self.title 24 | } 25 | 26 | pub fn description(&self) -> &String { 27 | &self.description 28 | } 29 | 30 | pub fn status(&self) -> &Status { 31 | &self.status 32 | } 33 | } 34 | 35 | pub fn create_ticket(title: String, description: String, status: Status) -> Ticket { 36 | if title.is_empty() { 37 | panic!("Title cannot be empty!"); 38 | } 39 | if title.len() > 50 { 40 | panic!("A title cannot be longer than 50 characters!"); 41 | } 42 | if description.len() > 3000 { 43 | panic!("A description cannot be longer than 3000 characters!"); 44 | } 45 | 46 | Ticket { 47 | title, 48 | description, 49 | status, 50 | } 51 | } 52 | 53 | #[cfg(test)] 54 | mod tests { 55 | #[test] 56 | fn the_next_step_of_your_journey() { 57 | let i_am_ready_to_continue = __; 58 | 59 | assert!(i_am_ready_to_continue); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /jira-wip/src/koans/02_ticket_store/01_store.rs: -------------------------------------------------------------------------------- 1 | /// It's time to shift focus: our tickets are doing well, but they need a home. 2 | /// A place where we can store them, search for them, retrieve them. 3 | /// 4 | /// We can use many different data structures to store and manage our tickets. 5 | /// JIRA users rely heavily on ticket identifiers, e.g. RUST-2018 or COVID-19. 6 | /// It's a unique label that unambiguously identifies a single ticket, 7 | /// generally `<board name>-<ticket number>`. 8 | /// We don't have the concept of a board yet, so we'll roll with a simple numerical id. 9 | /// 10 | /// What is the simplest data structure that allows us to fetch a ticket given its id? 11 | /// It makes sense for us to use a HashMap, also known as a dictionary in other languages. 12 | /// You can read more about the HashMap in Rust here: 13 | /// https://doc.rust-lang.org/std/collections/struct.HashMap.html 14 | use std::collections::HashMap; 15 | /// Let's import what we worked on in the previous set of exercises. 16 | use super::recap::Ticket; 17 | 18 | /// First we will create a TicketStore struct, with a `data` field of type HashMap. 19 | /// 20 | /// HashMap is a *generic* struct: we need to specify two types, one for the key, and one for 21 | /// the stored value - HashMap<K, V>. 22 | /// 23 | /// Let's set the value type to our Ticket, and we will use an unsigned integer for our ids. 24 | struct TicketStore { 25 | /// The collection of stored tickets. 26 | data: HashMap<u32, Ticket>, 27 | } 28 | 29 | impl TicketStore { 30 | /// Methods do not have to take self as a parameter. 31 | /// This is the equivalent of a class/static method in other programming languages. 32 | /// It can be invoked using `TicketStore::new()`. 33 | pub fn new() -> TicketStore { 34 | TicketStore { 35 | // Note that the compiler can infer the types for our HashMaps' key-value pairs. 36 | data: HashMap::new(), 37 | } 38 | } 39 | 40 | /// We take `&mut self` because we will have to mutate our HashMap to insert a new 41 | /// key-value pair. 42 | pub fn save(&mut self, ticket: Ticket, id: u32) { 43 | todo!() 44 | } 45 | 46 | pub fn get(&self, id: &u32) -> &Ticket { 47 | todo!() 48 | } 49 | } 50 | 51 | #[cfg(test)] 52 | mod tests { 53 | use super::super::recap::{create_ticket, Status}; 54 | use super::*; 55 | use fake::{Fake, Faker}; 56 | 57 | /// Now let's put our TicketStore to use 58 | /// 59 | /// We are going to create a ticket, save it in our TicketStore and finally validate that 60 | /// the ticket we have saved in our store is indeed the same ticket we created. 61 | #[test] 62 | fn a_ticket_with_a_home() { 63 | let ticket = generate_ticket(Status::ToDo); 64 | 65 | // Pay special attention to the 'mut' keyword here: variables are immutable 66 | // by default in Rust. 67 | // The `mut` keyword is used to signal that you must pay special attention to the 68 | // variable as it's likely to change later on in the function body. 69 | let mut store = TicketStore::new(); 70 | let ticket_id = Faker.fake(); 71 | 72 | // Here we need to create a clone of our `ticket` because `save` takes the `ticket` 73 | // argument as value, thus taking ownership of its value out of the caller function 74 | // into the method. 75 | // But we need `ticket`'s value after this method call, to verify it matches what 76 | // we retrieve. 77 | // Hence the need to clone it, creating a copy of the value and passing that copy to 78 | // the `save` method. 79 | // 80 | // (You might have to go back to the `recap` koan to derive a couple more traits 81 | // for Ticket and Status...) 82 | store.save(ticket.clone(), ticket_id); 83 | 84 | assert_eq!(store.get(&ticket_id), &ticket); 85 | } 86 | 87 | /// We want our `get` method to panic when looking for an id to which there is no ticket 88 | /// associated (for now). 89 | /// 90 | /// Rust has a way to handle this failure mode more gracefully, we will take a look 91 | /// at it later. 92 | #[test] 93 | #[should_panic] 94 | fn a_missing_ticket() { 95 | let ticket_store = TicketStore::new(); 96 | let ticket_id = Faker.fake(); 97 | 98 | ticket_store.get(&ticket_id); 99 | } 100 | 101 | /// This is not our desired behaviour for the final version of the ticket store 102 | /// but it will do for now. 103 | #[test] 104 | fn inserting_a_ticket_with_an_existing_id_overwrites_previous_ticket() { 105 | let first_ticket = generate_ticket(Status::ToDo); 106 | let second_ticket = generate_ticket(Status::ToDo); 107 | let ticket_id = Faker.fake(); 108 | let mut store = TicketStore::new(); 109 | 110 | store.save(first_ticket.clone(), ticket_id); 111 | assert_eq!(store.get(&ticket_id), &first_ticket); 112 | 113 | store.save(second_ticket.clone(), ticket_id); 114 | assert_eq!(store.get(&ticket_id), &second_ticket); 115 | } 116 | 117 | fn generate_ticket(status: Status) -> Ticket { 118 | let description = (0..3000).fake(); 119 | let title = (1..50).fake(); 120 | 121 | create_ticket(title, description, status) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /jira-wip/src/koans/02_ticket_store/02_option.rs: -------------------------------------------------------------------------------- 1 | use super::recap::Ticket; 2 | use std::collections::HashMap; 3 | 4 | struct TicketStore { 5 | data: HashMap<u32, Ticket>, 6 | } 7 | 8 | impl TicketStore { 9 | pub fn new() -> TicketStore { 10 | TicketStore { 11 | data: HashMap::new(), 12 | } 13 | } 14 | 15 | pub fn save(&mut self, ticket: Ticket, id: u32) { 16 | self.data.insert(id, ticket); 17 | } 18 | 19 | /// Trying to implement `get` in the previous koan might have caused you some issues due 20 | /// to a signature mismatch: `get` on a HashMap returns an `Option<&Ticket>`, 21 | /// not a `&Ticket`. 22 | /// 23 | /// What is an Option? 24 | /// 25 | /// In a nutshell, Rust does not have `null`: if a function returns a `Ticket` there is 26 | /// no way for that `Ticket` not to be there. 27 | /// If there is indeed the possibility of the function not being able to return a `Ticket`, 28 | /// we need to express it in its return type. 29 | /// That's where `Option` comes in (`Option` as in `Option`al, or at least that how 30 | /// I think about it). 31 | /// `Option` is an enum: 32 | /// 33 | /// ``` 34 | /// enum Option<T> { 35 | /// Some(T), 36 | /// None 37 | /// } 38 | /// ``` 39 | /// `T` is a generic type parameter here: as we saw for HashMap, Rust allows you to be 40 | /// generic over the types in your container. 41 | /// The `None` variant means that the value is missing. 42 | /// The `Some` variant instead tells you that you have a value. 43 | /// 44 | /// There is no way you can use the value in an `Option` without first checking the variant, 45 | /// hence it is impossible to "forget" to handle `None` when writing code. 46 | /// The compiler obliges you to handle both the happy and the unhappy case. 47 | /// 48 | /// For more details on `Option`, there is an exhaustive introduction in the Rust book: 49 | /// https://doc.rust-lang.org/1.29.0/book/2018-edition/ch06-01-defining-an-enum.html#the-option-enum-and-its-advantages-over-null-values 50 | pub fn get(&self, id: &u32) -> Option<&Ticket> { 51 | todo!() 52 | } 53 | } 54 | 55 | #[cfg(test)] 56 | mod tests { 57 | use super::super::recap::{create_ticket, Status}; 58 | use super::*; 59 | use fake::{Fake, Faker}; 60 | 61 | #[test] 62 | fn a_ticket_with_a_home() { 63 | let ticket = generate_ticket(Status::ToDo); 64 | let mut store = TicketStore::new(); 65 | let ticket_id = Faker.fake(); 66 | 67 | store.save(ticket.clone(), ticket_id); 68 | 69 | // Notice that, even when a ticket with the specified id exists in the store, 70 | // it's returned as the `Some` variant of an `Option<&Ticket>`. 71 | assert_eq!(store.get(&ticket_id), Some(&ticket)); 72 | } 73 | 74 | /// We want our `get` method to return `None` now, instead of panicking when looking for 75 | /// an id to which there is no ticket associated. 76 | #[test] 77 | fn a_missing_ticket() { 78 | let ticket_store = TicketStore::new(); 79 | let ticket_id = Faker.fake(); 80 | 81 | assert_eq!(ticket_store.get(&ticket_id), None); 82 | } 83 | 84 | #[test] 85 | fn inserting_a_ticket_with_an_existing_id_overwrites_previous_ticket() { 86 | let first_ticket = generate_ticket(Status::ToDo); 87 | let second_ticket = generate_ticket(Status::ToDo); 88 | let mut store = TicketStore::new(); 89 | let ticket_id = Faker.fake(); 90 | 91 | store.save(first_ticket.clone(), ticket_id); 92 | assert_eq!(store.get(&ticket_id), Some(&first_ticket)); 93 | 94 | store.save(second_ticket.clone(), ticket_id); 95 | assert_eq!(store.get(&ticket_id), Some(&second_ticket)); 96 | } 97 | 98 | fn generate_ticket(status: Status) -> Ticket { 99 | let description = (0..3000).fake(); 100 | let title = (1..50).fake(); 101 | 102 | create_ticket(title, description, status) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /jira-wip/src/koans/02_ticket_store/03_id_generation.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use super::recap::Ticket; 3 | 4 | /// Let's define a type-alias for our ticket id. 5 | /// It's a lightweight technique to add a semantic layer to the underlying data type. 6 | /// 7 | /// The underlying type remains `u32`. 8 | /// This remains valid code: 9 | /// ``` 10 | /// let number: u32 = 1; 11 | /// let ticket_id: TicketId = number; 12 | /// ``` 13 | /// If we want to be sure we aren't mixing up ticket ids and `u32` variables with 14 | /// a different semantic meaning, we would have to create a new type, 15 | /// e.g. `struct TicketId(u32)`. 16 | /// For now this doesn't feel necessary - we don't have many `u32`s flying around. 17 | pub type TicketId = u32; 18 | 19 | // Feel free to add more fields to `TicketStore` to solve this koan! 20 | struct TicketStore { 21 | data: HashMap<TicketId, Ticket>, 22 | } 23 | 24 | impl TicketStore { 25 | pub fn new() -> TicketStore 26 | { 27 | TicketStore { 28 | data: HashMap::new(), 29 | } 30 | } 31 | 32 | /// So far we have taken the `id` as one the parameters of our `save` method. 33 | /// 34 | /// What happens when you call save passing two different tickets with the same id? 35 | /// We have enforced with a test our expectation: the second ticket overwrites the first. 36 | /// The other option would have been to error out. 37 | /// 38 | /// This isn't how JIRA works: you don't get to choose the id of your ticket, 39 | /// it's generated for you and its uniqueness is guaranteed. 40 | /// There is also another peculiarity: ids are integers and they are monotonically 41 | /// increasing (the first ticket on a board will be `BOARDNAME-1`, the second 42 | /// `BOARDNAME-2` and so on). 43 | /// 44 | /// We want the same behaviour in our clone, IronJira. 45 | /// `TicketStore` will take care of generating an id for our ticket and the id 46 | /// will be returned by `save` after insertion. 47 | pub fn save(&mut self, ticket: Ticket) -> TicketId 48 | { 49 | let id = self.generate_id(); 50 | self.data.insert(id, ticket); 51 | id 52 | } 53 | 54 | pub fn get(&self, id: &TicketId) -> Option<&Ticket> { 55 | self.data.get(id) 56 | } 57 | 58 | fn generate_id(__) -> TicketId { 59 | todo!() 60 | } 61 | } 62 | 63 | #[cfg(test)] 64 | mod tests { 65 | use super::*; 66 | use super::super::recap::{create_ticket, Status}; 67 | use fake::{Faker, Fake}; 68 | 69 | #[test] 70 | fn a_ticket_with_a_home() 71 | { 72 | let ticket = generate_ticket(Status::ToDo); 73 | let mut store = TicketStore::new(); 74 | 75 | let ticket_id = store.save(ticket.clone()); 76 | 77 | assert_eq!(store.get(&ticket_id), Some(&ticket)); 78 | assert_eq!(ticket_id, 1); 79 | } 80 | 81 | #[test] 82 | fn a_missing_ticket() 83 | { 84 | let ticket_store = TicketStore::new(); 85 | let ticket_id = Faker.fake(); 86 | 87 | assert_eq!(ticket_store.get(&ticket_id), None); 88 | } 89 | 90 | #[test] 91 | fn id_generation_is_monotonic() 92 | { 93 | let n_tickets = 100; 94 | let mut store = TicketStore::new(); 95 | 96 | for expected_id in 1..n_tickets { 97 | let ticket = generate_ticket(Status::ToDo); 98 | let ticket_id = store.save(ticket); 99 | assert_eq!(expected_id, ticket_id); 100 | } 101 | } 102 | 103 | fn generate_ticket(status: Status) -> Ticket { 104 | let description = (0..3000).fake(); 105 | let title = (1..50).fake(); 106 | 107 | create_ticket(title, description, status) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /jira-wip/src/koans/02_ticket_store/04_metadata.rs: -------------------------------------------------------------------------------- 1 | use super::id_generation::TicketId; 2 | use super::recap::Status; 3 | /// `chrono` is the go-to crate in the Rust ecosystem when working with time. 4 | /// `DateTime` deals with timezone-aware datetimes - it takes the timezone as a type parameter. 5 | /// `DateTime<Utc>` is the type for datetimes expressed in the coordinated universal time. 6 | /// See: 7 | /// - https://en.wikipedia.org/wiki/Coordinated_Universal_Time 8 | /// - https://docs.rs/chrono/0.4.11/chrono/ 9 | use chrono::{DateTime, Utc}; 10 | use std::collections::HashMap; 11 | 12 | struct TicketStore { 13 | data: HashMap<TicketId, Ticket>, 14 | current_id: TicketId, 15 | } 16 | 17 | /// When we retrieve a ticket we saved, we'd like to receive with it a bunch of metadata: 18 | /// - the generated id; 19 | /// - the datetime of its creation. 20 | /// 21 | /// Make the necessary changes without touching the types of the inputs and the returned 22 | /// objects in our methods! 23 | /// You can make inputs mutable, if needed. 24 | impl TicketStore { 25 | pub fn new() -> TicketStore { 26 | TicketStore { 27 | data: HashMap::new(), 28 | current_id: 0, 29 | } 30 | } 31 | 32 | pub fn save(&mut self, ticket: Ticket) -> TicketId { 33 | let id = self.generate_id(); 34 | self.data.insert(id, ticket); 35 | id 36 | } 37 | 38 | pub fn get(&self, id: &TicketId) -> Option<&Ticket> { 39 | self.data.get(id) 40 | } 41 | 42 | fn generate_id(&mut self) -> TicketId { 43 | self.current_id += 1; 44 | self.current_id 45 | } 46 | } 47 | 48 | #[derive(Debug, Clone, PartialEq)] 49 | pub struct Ticket { 50 | title: String, 51 | description: String, 52 | status: Status, 53 | } 54 | 55 | impl Ticket { 56 | pub fn title(&self) -> &String { 57 | &self.title 58 | } 59 | 60 | pub fn description(&self) -> &String { 61 | &self.description 62 | } 63 | 64 | pub fn status(&self) -> &Status { 65 | &self.status 66 | } 67 | 68 | // The datetime when the ticket was saved in the store, if it was saved. 69 | pub fn created_at(&self) -> __ { 70 | todo!() 71 | } 72 | 73 | // The id associated with the ticket when it was saved in the store, if it was saved. 74 | pub fn id(&self) -> __ { 75 | todo!() 76 | } 77 | } 78 | 79 | pub fn create_ticket(title: String, description: String, status: Status) -> Ticket { 80 | if title.is_empty() { 81 | panic!("Title cannot be empty!"); 82 | } 83 | if title.len() > 50 { 84 | panic!("A title cannot be longer than 50 characters!"); 85 | } 86 | if description.len() > 3000 { 87 | panic!("A description cannot be longer than 3000 characters!"); 88 | } 89 | 90 | Ticket { 91 | title, 92 | description, 93 | status, 94 | } 95 | } 96 | 97 | #[cfg(test)] 98 | mod tests { 99 | use super::*; 100 | use fake::{Fake, Faker}; 101 | 102 | #[test] 103 | fn ticket_creation() { 104 | let ticket = generate_ticket(Status::ToDo); 105 | 106 | assert!(ticket.id().is_none()); 107 | assert!(ticket.created_at().is_none()); 108 | } 109 | 110 | #[test] 111 | fn a_ticket_with_a_home() { 112 | let ticket = generate_ticket(Status::ToDo); 113 | let mut store = TicketStore::new(); 114 | 115 | let ticket_id = store.save(ticket.clone()); 116 | let retrieved_ticket = store.get(&ticket_id).unwrap(); 117 | 118 | assert_eq!(Some(&ticket_id), retrieved_ticket.id()); 119 | assert_eq!(&ticket.title, retrieved_ticket.title()); 120 | assert_eq!(&ticket.description, retrieved_ticket.description()); 121 | assert_eq!(&ticket.status, retrieved_ticket.status()); 122 | assert!(retrieved_ticket.created_at().is_some()); 123 | } 124 | 125 | #[test] 126 | fn a_missing_ticket() { 127 | let ticket_store = TicketStore::new(); 128 | let ticket_id = Faker.fake(); 129 | 130 | assert_eq!(ticket_store.get(&ticket_id), None); 131 | } 132 | 133 | #[test] 134 | fn id_generation_is_monotonic() { 135 | let n_tickets = 100; 136 | let mut store = TicketStore::new(); 137 | 138 | for expected_id in 1..n_tickets { 139 | let ticket = generate_ticket(Status::ToDo); 140 | let ticket_id = store.save(ticket); 141 | assert_eq!(expected_id, ticket_id); 142 | } 143 | } 144 | 145 | fn generate_ticket(status: Status) -> Ticket { 146 | let description = (0..3000).fake(); 147 | let title = (1..50).fake(); 148 | 149 | create_ticket(title, description, status) 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /jira-wip/src/koans/02_ticket_store/05_type_as_constraints.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use chrono::{DateTime, Utc}; 3 | use super::recap::Status; 4 | use super::id_generation::TicketId; 5 | 6 | /// We know that id and creation time will never be there before a ticket is saved, 7 | /// while they will always be populated after `save` has been called. 8 | /// 9 | /// The approach we followed in the previous koan has its limitations: every time we 10 | /// access `id` and `created_at` we need to keep track of the "life stage" of our ticket. 11 | /// Has it been saved yet? Is it safe to unwrap those `Option`s? 12 | /// That is unnecessary cognitive load and leads to errors down the line, 13 | /// when writing new code or refactoring existing functionality. 14 | /// 15 | /// We can do better. 16 | /// We can use types to better model our domain and constrain the behaviour of our code. 17 | /// 18 | /// Before `TicketStore::save` is called, we are dealing with a `TicketDraft`. 19 | /// No `created_at`, no `id`, no `status`. 20 | /// On the other side, `TicketStore::get` will return a `Ticket`, with a `created_at` and 21 | /// an `id`. 22 | /// 23 | /// There will be no way to create a `Ticket` without passing through the store: 24 | /// we will enforce `save` as the only way to produce a `Ticket` from a `TicketDraft`. 25 | /// This will ensure as well that all tickets start in a `ToDo` status. 26 | /// 27 | /// Less room for errors, less ambiguity, you can understand the domain constraints 28 | /// by looking at the signatures of the functions in our code. 29 | /// 30 | /// On the topic of type-driven development, checkout: 31 | /// - https://fsharpforfunandprofit.com/series/designing-with-types.html 32 | /// - https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/ 33 | /// - https://www.youtube.com/watch?v=PLFl95c-IiU 34 | /// 35 | #[derive(Debug, Clone, PartialEq)] 36 | pub struct TicketDraft { 37 | __ 38 | } 39 | 40 | #[derive(Debug, Clone, PartialEq)] 41 | pub struct Ticket { 42 | __ 43 | } 44 | 45 | struct TicketStore { 46 | data: HashMap<TicketId, Ticket>, 47 | current_id: TicketId, 48 | } 49 | 50 | impl TicketStore { 51 | pub fn new() -> TicketStore 52 | { 53 | TicketStore { 54 | data: HashMap::new(), 55 | current_id: 0, 56 | } 57 | } 58 | 59 | pub fn save(&mut self, draft: TicketDraft) -> TicketId 60 | { 61 | let id = self.generate_id(); 62 | 63 | // We can use the "raw" constructor for `Ticket` here because the 64 | // store is defined in the same module of `Ticket`. 65 | // If you are importing `Ticket` from another module, 66 | // `TicketStore::get` will indeed be the only way to get your hands on 67 | // an instance of `Ticket`. 68 | // This enforces our desired invariant: saving a draft in the store 69 | // is the only way to "create" a `Ticket`. 70 | let ticket = Ticket { 71 | 72 | }; 73 | self.data.insert(id, ticket); 74 | id 75 | } 76 | 77 | pub fn get(&self, id: &TicketId) -> Option<&Ticket> { 78 | self.data.get(id) 79 | } 80 | 81 | fn generate_id(&mut self) -> TicketId { 82 | self.current_id += 1; 83 | self.current_id 84 | } 85 | } 86 | 87 | impl TicketDraft { 88 | pub fn title(&self) -> &String { todo!() } 89 | pub fn description(&self) -> &String { todo!() } 90 | } 91 | 92 | impl Ticket { 93 | pub fn title(&self) -> &String { todo!() } 94 | pub fn description(&self) -> &String { todo!() } 95 | pub fn status(&self) -> &Status { todo!() } 96 | pub fn created_at(&self) -> &DateTime<Utc> { todo!() } 97 | pub fn id(&self) -> &TicketId { todo!() } 98 | } 99 | 100 | pub fn create_ticket_draft(title: String, description: String) -> TicketDraft { 101 | if title.is_empty() { 102 | panic!("Title cannot be empty!"); 103 | } 104 | if title.len() > 50 { 105 | panic!("A title cannot be longer than 50 characters!"); 106 | } 107 | if description.len() > 3000 { 108 | panic!("A description cannot be longer than 3000 characters!"); 109 | } 110 | 111 | TicketDraft { 112 | title, 113 | description, 114 | } 115 | } 116 | 117 | #[cfg(test)] 118 | mod tests { 119 | use super::*; 120 | use fake::{Faker, Fake}; 121 | 122 | #[test] 123 | fn a_ticket_with_a_home() 124 | { 125 | let draft = generate_ticket_draft(); 126 | let mut store = TicketStore::new(); 127 | 128 | let ticket_id = store.save(draft.clone()); 129 | let retrieved_ticket = store.get(&ticket_id).unwrap(); 130 | 131 | assert_eq!(&ticket_id, retrieved_ticket.id()); 132 | assert_eq!(&draft.title, retrieved_ticket.title()); 133 | assert_eq!(&draft.description, retrieved_ticket.description()); 134 | assert_eq!(&Status::ToDo, retrieved_ticket.status()); 135 | } 136 | 137 | #[test] 138 | fn a_missing_ticket() 139 | { 140 | let ticket_store = TicketStore::new(); 141 | let ticket_id = Faker.fake(); 142 | 143 | assert_eq!(ticket_store.get(&ticket_id), None); 144 | } 145 | 146 | #[test] 147 | fn id_generation_is_monotonic() 148 | { 149 | let n_tickets = 100; 150 | let mut store = TicketStore::new(); 151 | 152 | for expected_id in 1..n_tickets { 153 | let draft = generate_ticket_draft(); 154 | let ticket_id = store.save(draft); 155 | assert_eq!(expected_id, ticket_id); 156 | } 157 | } 158 | 159 | fn generate_ticket_draft() -> TicketDraft { 160 | let description = (0..3000).fake(); 161 | let title = (1..50).fake(); 162 | 163 | create_ticket_draft(title, description) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /jira-wip/src/koans/02_ticket_store/06_result.rs: -------------------------------------------------------------------------------- 1 | use super::id_generation::TicketId; 2 | use super::recap::Status; 3 | use chrono::{DateTime, Utc}; 4 | use std::collections::HashMap; 5 | use std::error::Error; 6 | 7 | /// The structure of our code is coming along quite nicely: it looks and feels like idiomatic 8 | /// Rust and it models appropriately the domain we are tackling, JIRA. 9 | /// 10 | /// There is still something we can improve though: our validation logic when creating a new 11 | /// draft. 12 | /// Our previous function, `create_ticket_draft`, panicked when either the title or 13 | /// the description failed our validation checks. 14 | /// The caller has no idea that this can happen - the function signature looks quite innocent: 15 | /// ``` 16 | /// pub fn create_ticket_draft(title: String, description: String, status: Status) -> TicketDraft 17 | /// ``` 18 | /// Panics are generally not "caught" by the caller: they are meant to be used for states 19 | /// that your program cannot recover from. 20 | /// 21 | /// For expected error scenarios, we can do a better job using `Result`: 22 | /// ``` 23 | /// pub fn create_ticket_draft(title: String, description: String, status: Status) -> Result<TicketDraft, ValidationError> 24 | /// ``` 25 | /// `Result` is an enum defined in the standard library, just like `Option`. 26 | /// While `Option` encodes the possibility that some data might be missing, `Result` 27 | /// encodes the idea that an operation can fail. 28 | /// 29 | /// Its definition looks something like this: 30 | /// ``` 31 | /// pub enum Result<T, E> { 32 | /// Ok(T), 33 | /// Err(E) 34 | /// } 35 | /// ``` 36 | /// The `Ok` variant is used to return the outcome of the function if its execution was successful. 37 | /// The `Err` variant is used to return an error describing what went wrong. 38 | /// 39 | /// The error type, `E`, has to implement the `Error` trait from the standard library. 40 | /// Let's archive our old `create_ticket_draft` function and let's define a new 41 | /// `TicketDraft::new` method returning a `Result` to better set expectations with the caller. 42 | #[derive(Debug, Clone, PartialEq)] 43 | pub struct TicketDraft { 44 | title: String, 45 | description: String, 46 | } 47 | 48 | impl TicketDraft { 49 | pub fn title(&self) -> &String { 50 | &self.title 51 | } 52 | pub fn description(&self) -> &String { 53 | &self.description 54 | } 55 | 56 | pub fn new(title: String, description: String) -> Result<TicketDraft, ValidationError> { 57 | if title.is_empty() { 58 | return Err(ValidationError("Title cannot be empty!".to_string())); 59 | } 60 | if title.len() > 50 { 61 | todo!() 62 | } 63 | if description.len() > 3000 { 64 | todo!() 65 | } 66 | 67 | let draft = TicketDraft { title, description }; 68 | Ok(draft) 69 | } 70 | } 71 | 72 | /// Our error struct, to be returned when validation fails. 73 | /// It's a wrapper around a string, the validation error message. 74 | /// Structs without field names are called tuple structs, you can read more about them 75 | /// in the Rust book: 76 | /// https://doc.rust-lang.org/book/ch05-01-defining-structs.html#using-tuple-structs-without-named-fields-to-create-different-types 77 | #[derive(PartialEq, Debug, Clone)] 78 | pub struct ValidationError(String); 79 | 80 | /// To use `ValidationError` as the `Err` variant in a `Result` we need to implement 81 | /// the `Error` trait. 82 | /// 83 | /// The `Error` trait requires that our struct implements the `Debug` and `Display` traits, 84 | /// because errors might be bubbled up all the way until they are shown to the end user. 85 | /// We can derive `Debug`, but `Display` has to be implemented explicitly: 86 | /// `Display` rules how your struct is printed out for user-facing input, hence it cannot be 87 | /// derived automatically. 88 | impl Error for ValidationError {} 89 | 90 | impl std::fmt::Display for ValidationError { 91 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 92 | write!(f, "{}", self.0) 93 | } 94 | } 95 | 96 | #[derive(Debug, Clone, PartialEq)] 97 | pub struct Ticket { 98 | id: TicketId, 99 | title: String, 100 | description: String, 101 | status: Status, 102 | created_at: DateTime<Utc>, 103 | } 104 | 105 | struct TicketStore { 106 | data: HashMap<TicketId, Ticket>, 107 | current_id: TicketId, 108 | } 109 | 110 | impl TicketStore { 111 | pub fn new() -> TicketStore { 112 | TicketStore { 113 | data: HashMap::new(), 114 | current_id: 0, 115 | } 116 | } 117 | 118 | pub fn save(&mut self, draft: TicketDraft) -> TicketId { 119 | let id = self.generate_id(); 120 | let ticket = Ticket { 121 | id, 122 | title: draft.title, 123 | description: draft.description, 124 | status: Status::ToDo, 125 | created_at: Utc::now(), 126 | }; 127 | self.data.insert(id, ticket); 128 | id 129 | } 130 | 131 | pub fn get(&self, id: &TicketId) -> Option<&Ticket> { 132 | self.data.get(id) 133 | } 134 | 135 | fn generate_id(&mut self) -> TicketId { 136 | self.current_id += 1; 137 | self.current_id 138 | } 139 | } 140 | 141 | impl Ticket { 142 | pub fn title(&self) -> &String { 143 | &self.title 144 | } 145 | pub fn description(&self) -> &String { 146 | &self.description 147 | } 148 | pub fn status(&self) -> &Status { 149 | &self.status 150 | } 151 | pub fn created_at(&self) -> &DateTime<Utc> { 152 | &self.created_at 153 | } 154 | pub fn id(&self) -> &TicketId { 155 | &self.id 156 | } 157 | } 158 | 159 | #[cfg(test)] 160 | mod tests { 161 | use super::*; 162 | use fake::{Fake, Faker}; 163 | 164 | #[test] 165 | fn title_cannot_be_empty() { 166 | let description = (0..3000).fake(); 167 | 168 | let result = TicketDraft::new("".into(), description); 169 | assert!(result.is_err()) 170 | } 171 | 172 | #[test] 173 | fn title_cannot_be_longer_than_fifty_chars() { 174 | let description = (0..3000).fake(); 175 | // Let's generate a title longer than 51 chars. 176 | let title = (51..10_000).fake(); 177 | 178 | let result = TicketDraft::new(title, description); 179 | assert!(result.is_err()) 180 | } 181 | 182 | #[test] 183 | fn description_cannot_be_longer_than_3000_chars() { 184 | let description = (3001..10_000).fake(); 185 | let title = (1..50).fake(); 186 | 187 | let result = TicketDraft::new(title, description); 188 | assert!(result.is_err()) 189 | } 190 | 191 | #[test] 192 | fn a_ticket_with_a_home() { 193 | let draft = generate_ticket_draft(); 194 | let mut store = TicketStore::new(); 195 | 196 | let ticket_id = store.save(draft.clone()); 197 | let retrieved_ticket = store.get(&ticket_id).unwrap(); 198 | 199 | assert_eq!(&ticket_id, retrieved_ticket.id()); 200 | assert_eq!(&draft.title, retrieved_ticket.title()); 201 | assert_eq!(&draft.description, retrieved_ticket.description()); 202 | assert_eq!(&Status::ToDo, retrieved_ticket.status()); 203 | } 204 | 205 | #[test] 206 | fn a_missing_ticket() { 207 | let ticket_store = TicketStore::new(); 208 | let ticket_id = Faker.fake(); 209 | 210 | assert_eq!(ticket_store.get(&ticket_id), None); 211 | } 212 | 213 | #[test] 214 | fn id_generation_is_monotonic() { 215 | let n_tickets = 100; 216 | let mut store = TicketStore::new(); 217 | 218 | for expected_id in 1..n_tickets { 219 | let draft = generate_ticket_draft(); 220 | let ticket_id = store.save(draft); 221 | assert_eq!(expected_id, ticket_id); 222 | } 223 | } 224 | 225 | fn generate_ticket_draft() -> TicketDraft { 226 | let description = (0..3000).fake(); 227 | let title = (1..50).fake(); 228 | 229 | TicketDraft::new(title, description).expect("Failed to create ticket") 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /jira-wip/src/koans/02_ticket_store/07_vec.rs: -------------------------------------------------------------------------------- 1 | use super::id_generation::TicketId; 2 | use super::recap::Status; 3 | use chrono::{DateTime, Utc}; 4 | use std::collections::HashMap; 5 | use std::error::Error; 6 | 7 | /// Let's turn our attention again to our `TicketStore`. 8 | /// We can create a ticket, we can retrieve a ticket. 9 | /// 10 | /// Let's implement a `list` method to retrieve all tickets currently in the store. 11 | struct TicketStore { 12 | data: HashMap<TicketId, Ticket>, 13 | current_id: TicketId, 14 | } 15 | 16 | impl TicketStore { 17 | pub fn new() -> TicketStore { 18 | TicketStore { 19 | data: HashMap::new(), 20 | current_id: 0, 21 | } 22 | } 23 | 24 | pub fn save(&mut self, draft: TicketDraft) -> TicketId { 25 | let id = self.generate_id(); 26 | let ticket = Ticket { 27 | id, 28 | title: draft.title, 29 | description: draft.description, 30 | status: Status::ToDo, 31 | created_at: Utc::now(), 32 | }; 33 | self.data.insert(id, ticket); 34 | id 35 | } 36 | 37 | pub fn get(&self, id: &TicketId) -> Option<&Ticket> { 38 | self.data.get(id) 39 | } 40 | 41 | /// List will return a `Vec`. 42 | /// Check the Rust book for a primer: https://doc.rust-lang.org/book/ch08-01-vectors.html 43 | /// The Rust documentation for `HashMap` will also be handy: 44 | /// https://doc.rust-lang.org/std/collections/struct.HashMap.html 45 | pub fn list(&self) -> Vec<&Ticket> { 46 | todo!() 47 | } 48 | 49 | fn generate_id(&mut self) -> TicketId { 50 | self.current_id += 1; 51 | self.current_id 52 | } 53 | } 54 | 55 | #[derive(Debug, Clone, PartialEq)] 56 | pub struct TicketDraft { 57 | title: String, 58 | description: String, 59 | } 60 | 61 | impl TicketDraft { 62 | pub fn title(&self) -> &String { 63 | &self.title 64 | } 65 | pub fn description(&self) -> &String { 66 | &self.description 67 | } 68 | 69 | pub fn new(title: String, description: String) -> Result<TicketDraft, ValidationError> { 70 | if title.is_empty() { 71 | return Err(ValidationError("Title cannot be empty!".to_string())); 72 | } 73 | if title.len() > 50 { 74 | return Err(ValidationError( 75 | "A title cannot be longer than 50 characters!".to_string(), 76 | )); 77 | } 78 | if description.len() > 3000 { 79 | return Err(ValidationError( 80 | "A description cannot be longer than 3000 characters!".to_string(), 81 | )); 82 | } 83 | 84 | let draft = TicketDraft { title, description }; 85 | Ok(draft) 86 | } 87 | } 88 | 89 | #[derive(PartialEq, Debug, Clone)] 90 | pub struct ValidationError(String); 91 | 92 | impl Error for ValidationError {} 93 | 94 | impl std::fmt::Display for ValidationError { 95 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 96 | write!(f, "{}", self.0) 97 | } 98 | } 99 | 100 | #[derive(Debug, Clone, PartialEq)] 101 | pub struct Ticket { 102 | id: TicketId, 103 | title: String, 104 | description: String, 105 | status: Status, 106 | created_at: DateTime<Utc>, 107 | } 108 | 109 | impl Ticket { 110 | pub fn title(&self) -> &String { 111 | &self.title 112 | } 113 | pub fn description(&self) -> &String { 114 | &self.description 115 | } 116 | pub fn status(&self) -> &Status { 117 | &self.status 118 | } 119 | pub fn created_at(&self) -> &DateTime<Utc> { 120 | &self.created_at 121 | } 122 | pub fn id(&self) -> &TicketId { 123 | &self.id 124 | } 125 | } 126 | 127 | #[cfg(test)] 128 | mod tests { 129 | use super::*; 130 | use fake::{Fake, Faker}; 131 | 132 | #[test] 133 | fn list_returns_all_tickets() { 134 | let n_tickets = 100; 135 | let mut store = TicketStore::new(); 136 | 137 | for _ in 0..n_tickets { 138 | let draft = generate_ticket_draft(); 139 | store.save(draft); 140 | } 141 | 142 | assert_eq!(n_tickets, store.list().len()); 143 | } 144 | 145 | #[test] 146 | fn on_a_single_ticket_list_and_get_agree() { 147 | let mut store = TicketStore::new(); 148 | 149 | let draft = generate_ticket_draft(); 150 | let id = store.save(draft); 151 | 152 | assert_eq!(vec![store.get(&id).unwrap()], store.list()); 153 | } 154 | 155 | #[test] 156 | fn list_returns_an_empty_vec_on_an_empty_store() { 157 | let store = TicketStore::new(); 158 | 159 | assert!(store.list().is_empty()); 160 | } 161 | 162 | #[test] 163 | fn title_cannot_be_empty() { 164 | let description = (0..3000).fake(); 165 | 166 | let result = TicketDraft::new("".into(), description); 167 | assert!(result.is_err()) 168 | } 169 | 170 | #[test] 171 | fn title_cannot_be_longer_than_fifty_chars() { 172 | let description = (0..3000).fake(); 173 | // Let's generate a title longer than 51 chars. 174 | let title = (51..10_000).fake(); 175 | 176 | let result = TicketDraft::new(title, description); 177 | assert!(result.is_err()) 178 | } 179 | 180 | #[test] 181 | fn description_cannot_be_longer_than_3000_chars() { 182 | let description = (3001..10_000).fake(); 183 | let title = (1..50).fake(); 184 | 185 | let result = TicketDraft::new(title, description); 186 | assert!(result.is_err()) 187 | } 188 | 189 | #[test] 190 | fn a_ticket_with_a_home() { 191 | let draft = generate_ticket_draft(); 192 | let mut store = TicketStore::new(); 193 | 194 | let ticket_id = store.save(draft.clone()); 195 | let retrieved_ticket = store.get(&ticket_id).unwrap(); 196 | 197 | assert_eq!(&ticket_id, retrieved_ticket.id()); 198 | assert_eq!(&draft.title, retrieved_ticket.title()); 199 | assert_eq!(&draft.description, retrieved_ticket.description()); 200 | assert_eq!(&Status::ToDo, retrieved_ticket.status()); 201 | } 202 | 203 | #[test] 204 | fn a_missing_ticket() { 205 | let ticket_store = TicketStore::new(); 206 | let ticket_id = Faker.fake(); 207 | 208 | assert_eq!(ticket_store.get(&ticket_id), None); 209 | } 210 | 211 | #[test] 212 | fn id_generation_is_monotonic() { 213 | let n_tickets = 100; 214 | let mut store = TicketStore::new(); 215 | 216 | for expected_id in 1..n_tickets { 217 | let draft = generate_ticket_draft(); 218 | let ticket_id = store.save(draft); 219 | assert_eq!(expected_id, ticket_id); 220 | } 221 | } 222 | 223 | fn generate_ticket_draft() -> TicketDraft { 224 | let description = (0..3000).fake(); 225 | let title = (1..50).fake(); 226 | 227 | TicketDraft::new(title, description).expect("Failed to create ticket") 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /jira-wip/src/koans/02_ticket_store/08_delete_and_update.rs: -------------------------------------------------------------------------------- 1 | use super::id_generation::TicketId; 2 | use super::recap::Status; 3 | use chrono::{DateTime, Utc}; 4 | use std::collections::HashMap; 5 | use std::error::Error; 6 | 7 | /// There are only two pieces missing: deleting a ticket and updating a ticket 8 | /// in our `TicketStore`. 9 | /// The update functionality will give us the possibility to change the `status` of 10 | /// a ticket, the holy grail of our JIRA clone. 11 | struct TicketStore { 12 | data: HashMap<TicketId, Ticket>, 13 | current_id: TicketId, 14 | } 15 | 16 | impl TicketStore { 17 | pub fn new() -> TicketStore { 18 | TicketStore { 19 | data: HashMap::new(), 20 | current_id: 0, 21 | } 22 | } 23 | 24 | pub fn save(&mut self, draft: TicketDraft) -> TicketId { 25 | let id = self.generate_id(); 26 | let timestamp = Utc::now(); 27 | let ticket = Ticket { 28 | id, 29 | title: draft.title, 30 | description: draft.description, 31 | status: Status::ToDo, 32 | created_at: timestamp.clone(), 33 | // A new field, to keep track of the last time a ticket has been touched. 34 | // It starts in sync with `created_at`, it gets updated when a ticket is updated. 35 | updated_at: timestamp, 36 | }; 37 | self.data.insert(id, ticket); 38 | id 39 | } 40 | 41 | pub fn get(&self, id: &TicketId) -> Option<&Ticket> { 42 | self.data.get(id) 43 | } 44 | 45 | pub fn list(&self) -> Vec<&Ticket> { 46 | self.data.values().collect() 47 | } 48 | 49 | /// We take in an `id` and a `patch` struct: this allows us to constrain which of the 50 | /// fields in a `Ticket` can actually be updated. 51 | /// For example, we don't want users to be able to update the `id` or 52 | /// the `created_at` field. 53 | /// 54 | /// If we had chosen a different strategy, e.g. implementing a `get_mut` method 55 | /// to retrieve a mutable reference to a ticket and give the caller the possibility to edit 56 | /// it as they wanted, we wouldn't have been able to uphold the same guarantees. 57 | /// 58 | /// If the `id` passed in matches a ticket in the store, we return the edited ticket. 59 | /// If it doesn't, we return `None`. 60 | pub fn update(&mut self, id: &TicketId, patch: TicketPatch) -> Option<&Ticket> { 61 | todo!() 62 | } 63 | 64 | /// If the `id` passed in matches a ticket in the store, we return the deleted ticket 65 | /// with some additional metadata. 66 | /// If it doesn't, we return `None`. 67 | pub fn delete(&mut self, id: &TicketId) -> Option<DeletedTicket> { 68 | todo!() 69 | } 70 | 71 | fn generate_id(&mut self) -> TicketId { 72 | self.current_id += 1; 73 | self.current_id 74 | } 75 | } 76 | 77 | /// We don't want to relax our constraints on what is an acceptable title or an acceptable 78 | /// description for a ticket. 79 | /// This means that we need to validate the `title` and the `description` in our `TicketPatch` 80 | /// using the same rules we use for our `TicketDraft`. 81 | /// 82 | /// To keep it DRY, we introduce two new types whose constructors guarantee the invariants 83 | /// we care about. 84 | #[derive(Debug, Clone, PartialEq)] 85 | pub struct TicketTitle(String); 86 | 87 | impl TicketTitle { 88 | pub fn new(title: String) -> Result<Self, ValidationError> { 89 | if title.is_empty() { 90 | return Err(ValidationError("Title cannot be empty!".to_string())); 91 | } 92 | if title.len() > 50 { 93 | return Err(ValidationError( 94 | "A title cannot be longer than 50 characters!".to_string(), 95 | )); 96 | } 97 | Ok(Self(title)) 98 | } 99 | } 100 | 101 | #[derive(Debug, Clone, PartialEq)] 102 | pub struct TicketDescription(String); 103 | 104 | impl TicketDescription { 105 | pub fn new(description: String) -> Result<Self, ValidationError> { 106 | if description.len() > 3000 { 107 | Err(ValidationError( 108 | "A description cannot be longer than 3000 characters!".to_string(), 109 | )) 110 | } else { 111 | Ok(Self(description)) 112 | } 113 | } 114 | } 115 | 116 | /// `TicketPatch` constrains the fields that we consider editable. 117 | /// 118 | /// If a field is set the `Some`, its value will be updated to the specified value. 119 | /// If a field is set to `None`, the field remains unchanged. 120 | #[derive(Debug, Clone, PartialEq)] 121 | pub struct TicketPatch { 122 | pub title: Option<TicketTitle>, 123 | pub description: Option<TicketDescription>, 124 | pub status: Option<Status>, 125 | } 126 | 127 | /// With validation baked in our types, we don't have to worry anymore about the visibility 128 | /// of those fields. 129 | /// Our `TicketPatch` and our `TicketDraft` don't have an identity, an id, like a `Ticket` 130 | /// saved in the store. 131 | /// They are value objects, not entities, to borrow some terminology from Domain Driven Design. 132 | /// 133 | /// As long as we know that our invariants are upheld, we can let the user modify them 134 | /// as much as they please. 135 | /// We can thus get rid of the constructor and all the accessor methods. Pretty sweet, uh? 136 | #[derive(Debug, Clone, PartialEq)] 137 | pub struct TicketDraft { 138 | pub title: TicketTitle, 139 | pub description: TicketDescription, 140 | } 141 | 142 | /// A light wrapper around a deleted ticket to store some metadata (the deletion timestamp). 143 | /// If we had a user system in place, we would also store the identity of the user 144 | /// who performed the deletion. 145 | #[derive(Debug, Clone, PartialEq)] 146 | pub struct DeletedTicket { 147 | ticket: Ticket, 148 | deleted_at: DateTime<Utc>, 149 | } 150 | 151 | impl DeletedTicket { 152 | pub fn ticket(&self) -> &Ticket { 153 | &self.ticket 154 | } 155 | pub fn deleted_at(&self) -> &DateTime<Utc> { 156 | &self.deleted_at 157 | } 158 | } 159 | 160 | #[derive(PartialEq, Debug, Clone)] 161 | pub struct ValidationError(String); 162 | 163 | impl Error for ValidationError {} 164 | 165 | impl std::fmt::Display for ValidationError { 166 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 167 | write!(f, "{}", self.0) 168 | } 169 | } 170 | 171 | #[derive(Debug, Clone, PartialEq)] 172 | pub struct Ticket { 173 | id: TicketId, 174 | title: TicketTitle, 175 | description: TicketDescription, 176 | status: Status, 177 | created_at: DateTime<Utc>, 178 | updated_at: DateTime<Utc>, 179 | } 180 | 181 | impl Ticket { 182 | pub fn title(&self) -> &TicketTitle { 183 | &self.title 184 | } 185 | pub fn description(&self) -> &TicketDescription { 186 | &self.description 187 | } 188 | pub fn status(&self) -> &Status { 189 | &self.status 190 | } 191 | pub fn created_at(&self) -> &DateTime<Utc> { 192 | &self.created_at 193 | } 194 | pub fn id(&self) -> &TicketId { 195 | &self.id 196 | } 197 | pub fn updated_at(&self) -> &DateTime<Utc> { 198 | &self.updated_at 199 | } 200 | } 201 | 202 | #[cfg(test)] 203 | mod tests { 204 | use super::*; 205 | use fake::{Fake, Faker}; 206 | use std::time::Duration; 207 | 208 | #[test] 209 | fn updating_nothing_leaves_the_updatable_fields_unchanged() { 210 | let mut store = TicketStore::new(); 211 | let draft = generate_ticket_draft(); 212 | let ticket_id = store.save(draft.clone()); 213 | 214 | let patch = TicketPatch { 215 | title: None, 216 | description: None, 217 | status: None, 218 | }; 219 | let updated_ticket = store.update(&ticket_id, patch).unwrap(); 220 | 221 | assert_eq!(draft.title, updated_ticket.title); 222 | assert_eq!(draft.description, updated_ticket.description); 223 | assert_eq!(Status::ToDo, updated_ticket.status); 224 | } 225 | 226 | #[test] 227 | fn trying_to_update_a_missing_ticket_returns_none() { 228 | let mut store = TicketStore::new(); 229 | let ticket_id = Faker.fake(); 230 | let patch = generate_ticket_patch(Status::Done); 231 | 232 | assert_eq!(store.update(&ticket_id, patch), None); 233 | } 234 | 235 | #[test] 236 | fn update_works() { 237 | let mut store = TicketStore::new(); 238 | let draft = generate_ticket_draft(); 239 | let patch = generate_ticket_patch(Status::Done); 240 | let ticket_id = store.save(draft.clone()); 241 | 242 | // Let's wait a bit, otherwise `created_at` and `updated_at` 243 | // might turn out identical (ᴗ˳ᴗ) 244 | std::thread::sleep(Duration::from_millis(100)); 245 | let updated_ticket = store.update(&ticket_id, patch.clone()).unwrap(); 246 | 247 | assert_eq!(patch.title.unwrap(), updated_ticket.title); 248 | assert_eq!(patch.description.unwrap(), updated_ticket.description); 249 | assert_eq!(patch.status.unwrap(), updated_ticket.status); 250 | assert_ne!(updated_ticket.created_at(), updated_ticket.updated_at()); 251 | } 252 | 253 | #[test] 254 | fn delete_works() { 255 | let mut store = TicketStore::new(); 256 | let draft = generate_ticket_draft(); 257 | let ticket_id = store.save(draft.clone()); 258 | let ticket = store.get(&ticket_id).unwrap().to_owned(); 259 | 260 | let deleted_ticket = store.delete(&ticket_id).unwrap(); 261 | 262 | assert_eq!(deleted_ticket.ticket(), &ticket); 263 | assert_eq!(store.get(&ticket_id), None); 264 | } 265 | 266 | #[test] 267 | fn deleting_a_missing_ticket_returns_none() { 268 | let mut store = TicketStore::new(); 269 | let ticket_id = Faker.fake(); 270 | 271 | assert_eq!(store.delete(&ticket_id), None); 272 | } 273 | 274 | #[test] 275 | fn list_returns_all_tickets() { 276 | let n_tickets = 100; 277 | let mut store = TicketStore::new(); 278 | 279 | for _ in 0..n_tickets { 280 | let draft = generate_ticket_draft(); 281 | store.save(draft); 282 | } 283 | 284 | assert_eq!(n_tickets, store.list().len()); 285 | } 286 | 287 | #[test] 288 | fn on_a_single_ticket_list_and_get_agree() { 289 | let mut store = TicketStore::new(); 290 | 291 | let draft = generate_ticket_draft(); 292 | let id = store.save(draft); 293 | 294 | assert_eq!(vec![store.get(&id).unwrap()], store.list()); 295 | } 296 | 297 | #[test] 298 | fn list_returns_an_empty_vec_on_an_empty_store() { 299 | let store = TicketStore::new(); 300 | 301 | assert!(store.list().is_empty()); 302 | } 303 | 304 | #[test] 305 | fn title_cannot_be_empty() { 306 | assert!(TicketTitle::new("".into()).is_err()) 307 | } 308 | 309 | #[test] 310 | fn title_cannot_be_longer_than_fifty_chars() { 311 | // Let's generate a title longer than 51 chars. 312 | let title = (51..10_000).fake(); 313 | 314 | assert!(TicketTitle::new(title).is_err()) 315 | } 316 | 317 | #[test] 318 | fn description_cannot_be_longer_than_3000_chars() { 319 | let description = (3001..10_000).fake(); 320 | 321 | assert!(TicketDescription::new(description).is_err()) 322 | } 323 | 324 | #[test] 325 | fn a_ticket_with_a_home() { 326 | let draft = generate_ticket_draft(); 327 | let mut store = TicketStore::new(); 328 | 329 | let ticket_id = store.save(draft.clone()); 330 | let retrieved_ticket = store.get(&ticket_id).unwrap(); 331 | 332 | assert_eq!(&ticket_id, retrieved_ticket.id()); 333 | assert_eq!(&draft.title, retrieved_ticket.title()); 334 | assert_eq!(&draft.description, retrieved_ticket.description()); 335 | assert_eq!(&Status::ToDo, retrieved_ticket.status()); 336 | assert_eq!(retrieved_ticket.created_at(), retrieved_ticket.updated_at()); 337 | } 338 | 339 | #[test] 340 | fn a_missing_ticket() { 341 | let ticket_store = TicketStore::new(); 342 | let ticket_id = Faker.fake(); 343 | 344 | assert_eq!(ticket_store.get(&ticket_id), None); 345 | } 346 | 347 | #[test] 348 | fn id_generation_is_monotonic() { 349 | let n_tickets = 100; 350 | let mut store = TicketStore::new(); 351 | 352 | for expected_id in 1..n_tickets { 353 | let draft = generate_ticket_draft(); 354 | let ticket_id = store.save(draft); 355 | assert_eq!(expected_id, ticket_id); 356 | } 357 | } 358 | 359 | #[test] 360 | fn ids_are_not_reused() { 361 | let n_tickets = 100; 362 | let mut store = TicketStore::new(); 363 | 364 | for expected_id in 1..n_tickets { 365 | let draft = generate_ticket_draft(); 366 | let ticket_id = store.save(draft); 367 | assert_eq!(expected_id, ticket_id); 368 | assert!(store.delete(&ticket_id).is_some()); 369 | } 370 | } 371 | 372 | fn generate_ticket_draft() -> TicketDraft { 373 | let description = TicketDescription::new((0..3000).fake()).unwrap(); 374 | let title = TicketTitle::new((1..50).fake()).unwrap(); 375 | 376 | TicketDraft { title, description } 377 | } 378 | 379 | fn generate_ticket_patch(status: Status) -> TicketPatch { 380 | let patch = generate_ticket_draft(); 381 | 382 | TicketPatch { 383 | title: Some(patch.title), 384 | description: Some(patch.description), 385 | status: Some(status), 386 | } 387 | } 388 | } 389 | -------------------------------------------------------------------------------- /jira-wip/src/koans/02_ticket_store/09_store_recap.rs: -------------------------------------------------------------------------------- 1 | //! The core work is now complete: we have implemented the functionality we wanted to have in 2 | //! our JIRA clone. 3 | //! 4 | //! Nonetheless, we still can't probe our system interactively: there is no user interface. 5 | //! That will be the focus of the next (and last) section. 6 | //! 7 | //! Take your time to review what you did - you have come a long way! 8 | use super::id_generation::TicketId; 9 | use chrono::{DateTime, Utc}; 10 | use serde::{Deserialize, Serialize}; 11 | use std::collections::HashMap; 12 | use std::error::Error; 13 | 14 | #[derive(Debug, PartialEq)] 15 | pub struct TicketStore { 16 | data: HashMap<TicketId, Ticket>, 17 | current_id: TicketId, 18 | } 19 | 20 | impl TicketStore { 21 | pub fn new() -> TicketStore { 22 | TicketStore { 23 | data: HashMap::new(), 24 | current_id: 0, 25 | } 26 | } 27 | 28 | pub fn save(&mut self, draft: TicketDraft) -> TicketId { 29 | let id = self.generate_id(); 30 | let timestamp = Utc::now(); 31 | let ticket = Ticket { 32 | id, 33 | title: draft.title, 34 | description: draft.description, 35 | status: Status::ToDo, 36 | created_at: timestamp.clone(), 37 | updated_at: timestamp, 38 | }; 39 | self.data.insert(id, ticket); 40 | id 41 | } 42 | 43 | pub fn get(&self, id: &TicketId) -> Option<&Ticket> { 44 | self.data.get(id) 45 | } 46 | 47 | pub fn list(&self) -> Vec<&Ticket> { 48 | self.data.values().collect() 49 | } 50 | 51 | pub fn update(&mut self, id: &TicketId, patch: TicketPatch) -> Option<&Ticket> { 52 | if let Some(ticket) = self.data.get_mut(id) { 53 | if let Some(title) = patch.title { 54 | ticket.title = title 55 | } 56 | if let Some(description) = patch.description { 57 | ticket.description = description 58 | } 59 | if let Some(status) = patch.status { 60 | ticket.status = status 61 | } 62 | 63 | ticket.updated_at = Utc::now(); 64 | 65 | Some(ticket) 66 | } else { 67 | None 68 | } 69 | } 70 | 71 | pub fn delete(&mut self, id: &TicketId) -> Option<DeletedTicket> { 72 | self.data.remove(id).map(|ticket| DeletedTicket { 73 | ticket, 74 | deleted_at: Utc::now(), 75 | }) 76 | } 77 | 78 | fn generate_id(&mut self) -> TicketId { 79 | self.current_id += 1; 80 | self.current_id 81 | } 82 | } 83 | 84 | #[derive(Debug, Clone, PartialEq)] 85 | pub struct TicketTitle(String); 86 | 87 | impl TicketTitle { 88 | pub fn new(title: String) -> Result<Self, ValidationError> { 89 | if title.is_empty() { 90 | return Err(ValidationError("Title cannot be empty!".to_string())); 91 | } 92 | if title.len() > 50 { 93 | return Err(ValidationError( 94 | "A title cannot be longer than 50 characters!".to_string(), 95 | )); 96 | } 97 | Ok(Self(title)) 98 | } 99 | } 100 | 101 | #[derive(Debug, Clone, PartialEq)] 102 | pub struct TicketDescription(String); 103 | 104 | impl TicketDescription { 105 | pub fn new(description: String) -> Result<Self, ValidationError> { 106 | if description.len() > 3000 { 107 | Err(ValidationError( 108 | "A description cannot be longer than 3000 characters!".to_string(), 109 | )) 110 | } else { 111 | Ok(Self(description)) 112 | } 113 | } 114 | } 115 | 116 | #[derive(Debug, Clone, PartialEq)] 117 | pub struct TicketPatch { 118 | pub title: Option<TicketTitle>, 119 | pub description: Option<TicketDescription>, 120 | pub status: Option<Status>, 121 | } 122 | 123 | #[derive(Debug, Clone, PartialEq)] 124 | pub struct TicketDraft { 125 | pub title: TicketTitle, 126 | pub description: TicketDescription, 127 | } 128 | 129 | #[derive(Debug, Clone, PartialEq)] 130 | pub struct DeletedTicket { 131 | ticket: Ticket, 132 | deleted_at: DateTime<Utc>, 133 | } 134 | 135 | impl DeletedTicket { 136 | pub fn ticket(&self) -> &Ticket { 137 | &self.ticket 138 | } 139 | pub fn deleted_at(&self) -> &DateTime<Utc> { 140 | &self.deleted_at 141 | } 142 | } 143 | 144 | #[derive(PartialEq, Debug, Clone)] 145 | pub struct ValidationError(String); 146 | 147 | impl Error for ValidationError {} 148 | 149 | impl std::fmt::Display for ValidationError { 150 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 151 | write!(f, "{}", self.0) 152 | } 153 | } 154 | 155 | #[derive(PartialEq, Debug, Clone)] 156 | pub enum Status { 157 | ToDo, 158 | InProgress, 159 | Blocked, 160 | Done, 161 | } 162 | 163 | #[derive(Debug, Clone, PartialEq)] 164 | pub struct Ticket { 165 | id: TicketId, 166 | title: TicketTitle, 167 | description: TicketDescription, 168 | status: Status, 169 | created_at: DateTime<Utc>, 170 | updated_at: DateTime<Utc>, 171 | } 172 | 173 | impl Ticket { 174 | pub fn title(&self) -> &TicketTitle { 175 | &self.title 176 | } 177 | pub fn description(&self) -> &TicketDescription { 178 | &self.description 179 | } 180 | pub fn status(&self) -> &Status { 181 | &self.status 182 | } 183 | pub fn created_at(&self) -> &DateTime<Utc> { 184 | &self.created_at 185 | } 186 | pub fn id(&self) -> &TicketId { 187 | &self.id 188 | } 189 | pub fn updated_at(&self) -> &DateTime<Utc> { 190 | &self.updated_at 191 | } 192 | } 193 | 194 | #[cfg(test)] 195 | mod tests { 196 | #[test] 197 | fn the_next_step_of_your_journey() { 198 | let i_am_ready_to_continue = __; 199 | 200 | assert!(i_am_ready_to_continue); 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /jira-wip/src/koans/03_cli/00_cli.rs: -------------------------------------------------------------------------------- 1 | //! There are many ways to expose the functionality we built to a user: an API, a GUI, etc. 2 | //! We will go with something simpler, yet good enough to probe at our implementation 3 | //! and touch with our own hands the fruit of our labor: a command line application, a CLI. 4 | //! 5 | //! Rust is well-equipped to write CLIs: we will be using `structopt`, a crate 6 | //! that provides a derive macro to define a CLI interface declaratively. 7 | //! 8 | //! We define the structure of our commands, annotating each field appropriately, 9 | //! and `#[derive(structopt::StructOpt)]` takes care of generating all the code 10 | //! required to parse the user input as well as generating a detailed `--help` page 11 | //! for the CLI itself and each of its subcommands. 12 | //! 13 | //! Comments on each of the field and each of the `Command` variant will be shown in the 14 | //! help page of those commands! 15 | //! 16 | //! You can learn more about `structopt` looking at their documentation: 17 | //! https://docs.rs/structopt/0.3.12/structopt/ 18 | //! 19 | //! You can see the code generated by `structopt` using `cargo expand`: 20 | //! https://github.com/dtolnay/cargo-expand 21 | //! 22 | //! Fill in the missing fields! 23 | //! 24 | //! When you are ready, uncomment the appropriate lines from src/main.rs and 25 | //! run `cargo run --bin jira-wip` in your terminal! 26 | use super::store_recap::{TicketStore, Status, TicketDraft, TicketPatch, TicketTitle, TicketDescription}; 27 | use super::id_generation::TicketId; 28 | use std::error::Error; 29 | use std::str::FromStr; 30 | use std::fmt::Formatter; 31 | 32 | #[derive(structopt::StructOpt, Clone)] 33 | /// A small command-line interface to interact with a toy Jira clone, IronJira. 34 | pub enum Command { 35 | /// Create a ticket on your board. 36 | Create { 37 | __ 38 | }, 39 | /// Edit the details of an existing ticket. 40 | Edit { 41 | /// Id of the ticket you want to edit. 42 | #[structopt(long)] 43 | id: TicketId, 44 | /// New status of the ticket. 45 | #[structopt(long)] 46 | status: Option<Status>, 47 | /// New description of the ticket. 48 | #[structopt(long)] 49 | description: Option<TicketDescription>, 50 | /// New title for your ticket. 51 | #[structopt(long)] 52 | title: Option<TicketTitle>, 53 | }, 54 | /// Delete a ticket from the store passing the ticket id. 55 | Delete { 56 | __ 57 | }, 58 | /// List all existing tickets. 59 | List, 60 | } 61 | 62 | /// `structopt` relies on `FromStr` to know how to parse our custom structs and enums 63 | /// from the string passed in as input by a user. 64 | /// 65 | /// Parsing is fallible: we need to declare what error type we are going to return if 66 | /// things go wrong and implement the `from_str` function. 67 | impl FromStr for Status { 68 | type Err = ParsingError; 69 | 70 | fn from_str(s: &str) -> Result<Self, Self::Err> { 71 | __ 72 | } 73 | } 74 | 75 | impl FromStr for TicketTitle { 76 | __ 77 | } 78 | 79 | impl FromStr for TicketDescription { 80 | __ 81 | } 82 | 83 | /// Our error struct for parsing failures. 84 | #[derive(Debug)] 85 | pub struct ParsingError(String); 86 | 87 | impl Error for ParsingError { } 88 | 89 | impl std::fmt::Display for ParsingError { 90 | fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> { 91 | write!(f, "{}", self.0) 92 | } 93 | } 94 | 95 | /// The core function: given a mutable reference to a `TicketStore` and a `Command`, 96 | /// carry out the action specified by the user. 97 | /// We use `Box<dyn Error>` to avoid having to specify the exact failure modes of our 98 | /// top-level handler. 99 | /// 100 | /// `dyn Error` is the syntax of a trait object, a more advanced topic that we will not be 101 | /// touching in this workshop. 102 | /// Check its section in the Rust book if you are curious: 103 | /// https://doc.rust-lang.org/book/ch17-02-trait-objects.html#using-trait-objects-that-allow-for-values-of-different-types 104 | pub fn handle_command(ticket_store: &mut TicketStore, command: Command) -> Result<(), Box<dyn Error>> { 105 | match command { 106 | Command::Create { description, title } => { 107 | todo!() 108 | } 109 | Command::Edit { 110 | id, 111 | title, 112 | description, 113 | status, 114 | } => { 115 | todo!() 116 | } 117 | Command::Delete { ticket_id } => match ticket_store.delete(&ticket_id) { 118 | Some(deleted_ticket) => println!( 119 | "The following ticket has been deleted:\n{:?}", 120 | deleted_ticket 121 | ), 122 | None => println!( 123 | "There was no ticket associated to the ticket id {:?}", 124 | ticket_id 125 | ), 126 | }, 127 | Command::List => { 128 | todo!() 129 | } 130 | } 131 | Ok(()) 132 | } 133 | 134 | #[cfg(test)] 135 | mod tests { 136 | use super::*; 137 | 138 | #[test] 139 | fn invalid_status_fails_to_be_parsed() 140 | { 141 | let invalid_status = "Not a good status"; 142 | assert!(Status::from_str(invalid_status).is_err()); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /jira-wip/src/koans/03_cli/01_persistence.rs: -------------------------------------------------------------------------------- 1 | //! Playing around with the CLI using `cargo run --bin jira-wip` you might have noticed 2 | //! that is quite tricky to actually exercise all the functionality we implemented: 3 | //! the store is created anew for every execution, nothing is persisted! 4 | //! 5 | //! Time to put a remedy to that: we want to persist our store to disk between CLI invocations, 6 | //! reloading it before performing the next command. 7 | //! 8 | //! We will be relying on the `serde` crate (`Ser`ialisation/`De`serialisation): 9 | //! it can serialise data to many different file formats as long as your struct or enums 10 | //! implement serde's `Serialize` trait. 11 | //! `Deserialize`, instead, is needed for the opposite journey. 12 | //! 13 | //! You don't need to implement this manually: just add `#[derive(Serialize, Deserialize)]` 14 | //! where needed in `store_recap` - the `load` and `save` functions should just work afterwards! 15 | //! 16 | //! Update `src/main.rs` appropriately afterwards to use the fruit of your labor! 17 | use super::store_recap::TicketStore; 18 | use std::fs::read_to_string; 19 | use std::path::Path; 20 | 21 | /// Fetch authentication parameters from a configuration file, if available. 22 | pub fn load(path: &Path) -> TicketStore { 23 | println!("Reading data from {:?}", path); 24 | // Read the data in memory, storing the value in a string 25 | match read_to_string(path) { 26 | Ok(data) => { 27 | // Deserialize configuration from YAML format 28 | serde_yaml::from_str(&data).expect("Failed to parse serialised data.") 29 | } 30 | Err(e) => match e.kind() { 31 | // The file is missing - this is the first time you are using IronJira! 32 | std::io::ErrorKind::NotFound => { 33 | // Return default configuration 34 | TicketStore::new() 35 | } 36 | // Something went wrong - crash the CLI with an error message. 37 | _ => panic!("Failed to read data."), 38 | }, 39 | } 40 | } 41 | 42 | /// Save tickets on disk in the right file. 43 | pub fn save(ticket_store: &TicketStore, path: &Path) { 44 | // Serialize data to YAML format 45 | let content = serde_yaml::to_string(ticket_store).expect("Failed to serialize tickets"); 46 | println!("Saving tickets to {:?}", path); 47 | // Save to disk 48 | std::fs::write(path, content).expect("Failed to write tickets to disk.") 49 | } 50 | 51 | #[cfg(test)] 52 | mod tests { 53 | use super::super::store_recap::{ 54 | Status, TicketDescription, TicketDraft, TicketStore, TicketTitle, 55 | }; 56 | use super::*; 57 | use fake::Fake; 58 | use tempfile::NamedTempFile; 59 | 60 | #[test] 61 | fn load_what_you_save() { 62 | let mut store = TicketStore::new(); 63 | let draft = generate_ticket_draft(); 64 | store.save(draft); 65 | 66 | // We use the `tempfile` crate to generate a temporary path on the fly 67 | // which will be cleaned up at the end of the test. 68 | // See https://docs.rs/tempfile/3.1.0/tempfile/ for more details. 69 | let temp_path = NamedTempFile::new().unwrap().into_temp_path(); 70 | 71 | save(&store, temp_path.as_ref()); 72 | let loaded_store = load(temp_path.as_ref()); 73 | 74 | assert_eq!(store, loaded_store); 75 | } 76 | 77 | fn generate_ticket_draft() -> TicketDraft { 78 | let description = TicketDescription::new((0..3000).fake()).unwrap(); 79 | let title = TicketTitle::new((1..50).fake()).unwrap(); 80 | 81 | TicketDraft { title, description } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /jira-wip/src/koans/03_cli/02_the_end.rs: -------------------------------------------------------------------------------- 1 | /// It has been our pleasure to have you at the Rust London Code Dojo! 2 | /// 3 | /// We hope that the workshop helped you move forward in your Rust journey, 4 | /// having fun toying around with rebuilding JIRA. 5 | /// 6 | /// If you have any feedback on the workshop, please reach out to rust@lpalmieri.com 7 | /// If you found any typo, mistake or you think a section could be worded better, 8 | /// please open a PR! 9 | /// 10 | /// ~ See you next time! ~ 11 | /// 12 | #[cfg(test)] 13 | mod the_end { 14 | #[test] 15 | fn the_end_of_your_journey() { 16 | let i_am_done = __; 17 | 18 | assert!(i_am_done); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /jira-wip/src/main.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | #![allow(unused_doc_comments)] 3 | use directories::ProjectDirs; 4 | use std::error::Error; 5 | use std::path::PathBuf; 6 | 7 | /// The `main` function is the entry point of your application. 8 | /// 9 | /// It gets called when you invoke `cargo run --bin jira-wip` and 10 | /// its executed when a user runs the binary you generated by compiling your project 11 | /// (`cargo build` -> `/target/debug/jira-wip` / `cargo build --release` -> `/target/release/jira-wip`) 12 | /// 13 | /// Over the course of this workshop we will modify this entry point to build a fully fledged 14 | /// command line application. 15 | /// 16 | /// Brace yourself! 17 | fn main() -> Result<(), Box<dyn Error>> { 18 | // Uncomment these lines after 02_ticket_store/09_store_recap 19 | /* 20 | // Comment these line after 03_cli/01_persistence 21 | use path_to_enlightenment::store_recap::TicketStore; 22 | let mut ticket_store = TicketStore::new(); 23 | */ 24 | 25 | // Uncomment these lines after 03_cli/01_persistence 26 | /* 27 | use path_to_enlightenment::persistence::{save, load}; 28 | // Load the store from disk. If missing, a brand new one will be created. 29 | let mut ticket_store = load(&data_store_filename()); 30 | */ 31 | 32 | // Uncomment these lines after 03_cli/00_cli 33 | /* 34 | use path_to_enlightenment::cli::{Command, handle_command}; 35 | // Parse the command-line arguments. 36 | let command = <Command as paw::ParseArgs>::parse_args()?; 37 | handle_command(&mut ticket_store, command)?; 38 | */ 39 | 40 | // Uncomment these lines after 03_cli/01_persistence 41 | /* 42 | // Save the store state to disk after we have completed our action. 43 | save(&ticket_store, &data_store_filename()); 44 | */ 45 | Ok(()) 46 | } 47 | 48 | mod path_to_enlightenment; 49 | 50 | // `PROJECT_NAME`, `ORGANISATION_NAME` and `QUALIFIER` are used to determine 51 | // where to store configuration files and secrets for an application 52 | // according to the convention of the underlying operating system. 53 | // 54 | // `qualifier_name` is only relevant for MacOS - we leave it blank. 55 | const PROJECT_NAME: &str = "IronJIRAWip"; 56 | const ORGANISATION_NAME: &str = "RustLDNUserGroup"; 57 | const QUALIFIER: &str = ""; 58 | 59 | const TICKET_STORE: &str = "ticket_store.yaml"; 60 | 61 | /// Determine the right location to store data based on the user OS. 62 | /// It relies on the `directories` crate - see https://crates.io/crates/directories for more information. 63 | fn data_store_filename() -> PathBuf { 64 | // Get the directory where we are supposed to store data 65 | // according to the convention of the underlying operating system. 66 | // 67 | // The operation could fail if some OS environment variables are not set (e.g. $HOME) 68 | let project_dir = ProjectDirs::from(QUALIFIER, ORGANISATION_NAME, PROJECT_NAME) 69 | .expect("Failed to determine path of the configuration directory."); 70 | let data_dir = project_dir.data_dir(); 71 | println!("Data storage directory: {:?}", data_dir); 72 | 73 | // Create the data directory, if missing. 74 | // It also takes care of creating intermediate sub-directory, if necessary. 75 | std::fs::create_dir_all(data_dir).expect("Failed to create data directory."); 76 | 77 | // Path to the file storing our tickets 78 | data_dir.join(TICKET_STORE) 79 | } 80 | -------------------------------------------------------------------------------- /jira-wip/src/path_to_enlightenment.rs: -------------------------------------------------------------------------------- 1 | // This file is handled by `koans-framework` so you will not need to modify it manually. 2 | -------------------------------------------------------------------------------- /koans-framework/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "koans" 3 | version = "0.1.0" 4 | authors = ["LukeMathWalker <rust@lpalmieri.com>"] 5 | edition = "2018" 6 | 7 | [dependencies] 8 | regex = "1" 9 | read_input = "0.8" 10 | structopt = { version = "0.3", features = ["paw"] } 11 | paw = "1" 12 | anyhow = "1" 13 | yansi = "0.5" 14 | 15 | [[bin]] 16 | name = "koans" 17 | path = "src/main.rs" 18 | 19 | [lib] 20 | name = "koans" 21 | path = "src/lib.rs" 22 | -------------------------------------------------------------------------------- /koans-framework/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use regex::Regex; 3 | use std::ffi::OsString; 4 | use std::fmt::Formatter; 5 | use std::fs::{read_dir, OpenOptions}; 6 | use std::io::{BufRead, BufReader, ErrorKind, Write}; 7 | use std::path::PathBuf; 8 | 9 | pub struct KoanConfiguration { 10 | pub base_path: PathBuf, 11 | } 12 | 13 | impl KoanConfiguration { 14 | pub fn new<P: Into<PathBuf>>(base_path: P) -> Result<Self, anyhow::Error> { 15 | let c = Self { 16 | base_path: base_path.into(), 17 | }; 18 | 19 | if !c.manifest_path().exists() { 20 | let error_path = if c.manifest_path().is_absolute() { 21 | c.manifest_path() 22 | } else { 23 | std::env::current_dir().unwrap().join(c.manifest_path()) 24 | }; 25 | return Err(anyhow!("{:?} does not exist.", error_path)); 26 | } 27 | 28 | Ok(c) 29 | } 30 | 31 | pub fn koans_path(&self) -> PathBuf { 32 | self.base_path.join("src").join("koans") 33 | } 34 | 35 | pub fn enlightenment_path(&self) -> PathBuf { 36 | self.base_path.join("src").join("path_to_enlightenment.rs") 37 | } 38 | 39 | pub fn manifest_path(&self) -> PathBuf { 40 | self.base_path.join("Cargo.toml") 41 | } 42 | } 43 | 44 | pub struct KoanCollection { 45 | configuration: KoanConfiguration, 46 | koans: Vec<Koan>, 47 | } 48 | 49 | impl KoanCollection { 50 | pub fn new<P: Into<PathBuf>>(base_path: P) -> Result<Self, anyhow::Error> { 51 | let configuration = KoanConfiguration::new(base_path)?; 52 | let mut koans: Vec<(OsString, OsString)> = read_dir(configuration.koans_path()) 53 | .unwrap() 54 | .map(|f| { 55 | let entry = f.unwrap(); 56 | // Each entry in path has to be a directory! 57 | assert!( 58 | entry.file_type().unwrap().is_dir(), 59 | "Each entry in {:?} has to be a directory", 60 | &configuration.koans_path() 61 | ); 62 | let directory_name = entry.file_name(); 63 | read_dir(entry.path()) 64 | .unwrap() 65 | .map(move |f| (directory_name.to_owned(), f.unwrap().file_name())) 66 | }) 67 | .flatten() 68 | .collect(); 69 | // Sort them in lexicographical order - koans are prefixed with `dd_` 70 | koans.sort(); 71 | 72 | Ok(Self { 73 | configuration, 74 | koans: koans.into_iter().map(|f| f.into()).collect(), 75 | }) 76 | } 77 | 78 | pub fn configuration(&self) -> &KoanConfiguration { 79 | &self.configuration 80 | } 81 | 82 | pub fn n_opened(&self) -> usize { 83 | match OpenOptions::new() 84 | .read(true) 85 | .open(&self.configuration.enlightenment_path()) 86 | { 87 | Ok(f) => BufReader::new(&f) 88 | .lines() 89 | .filter(|l| !l.as_ref().unwrap().is_empty()) 90 | .filter(|l| &l.as_ref().unwrap().trim()[..2] != "//") // Ignores comments 91 | .map(|l| { 92 | if l.unwrap().contains("mod") { 93 | // Count the number of module declarations 94 | 1 95 | } else { 96 | 0 97 | } 98 | }) 99 | .sum(), 100 | Err(e) => { 101 | match e.kind() { 102 | ErrorKind::NotFound => { 103 | let file = OpenOptions::new() 104 | .create_new(true) 105 | .write(true) 106 | .open(&self.configuration.enlightenment_path()) 107 | .expect("Failed to open a write buffer."); 108 | // Initialise as an empty file 109 | write!(&file, "").expect("Failed to initialise enlightenment file."); 110 | 0 111 | } 112 | _ => panic!("Cannot read path to enlightenment file."), 113 | } 114 | } 115 | } 116 | } 117 | 118 | pub fn opened(&self) -> impl Iterator<Item = &Koan> { 119 | self.koans.iter().take(self.n_opened()) 120 | } 121 | 122 | pub fn next(&self) -> Option<&Koan> { 123 | let n_opened = self.n_opened(); 124 | if n_opened == self.koans.len() { 125 | None 126 | } else { 127 | Some(&self.koans[n_opened]) 128 | } 129 | } 130 | 131 | pub fn open_next(&mut self) -> Result<&Koan, ()> { 132 | let mut file = OpenOptions::new() 133 | .read(true) 134 | .append(true) 135 | .write(true) 136 | .open(&self.configuration.enlightenment_path()) 137 | .unwrap(); 138 | 139 | let koan = self.next(); 140 | if let Some(koan) = koan { 141 | let koan_filename: String = koan.into(); 142 | let include = format!( 143 | "#[path = \"koans/{}.rs\"]\nmod {};\n", 144 | koan_filename, koan.name 145 | ); 146 | writeln!(file, "{}", include).unwrap(); 147 | Ok(koan) 148 | } else { 149 | Err(()) 150 | } 151 | } 152 | } 153 | 154 | #[derive(Clone)] 155 | pub struct Koan { 156 | pub parent_name: String, 157 | pub parent_number: String, 158 | pub name: String, 159 | pub number: usize, 160 | } 161 | 162 | impl std::fmt::Display for Koan { 163 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { 164 | write!( 165 | f, 166 | "({:02}) {} - ({:02}) {}", 167 | self.parent_number, self.parent_name, self.number, self.name 168 | ) 169 | } 170 | } 171 | 172 | impl From<(OsString, OsString)> for Koan { 173 | fn from(x: (OsString, OsString)) -> Self { 174 | let (parent_dir_name, filename) = x; 175 | let filename = filename.into_string().unwrap(); 176 | let parent_dir_name = parent_dir_name.into_string().unwrap(); 177 | 178 | let re = Regex::new(r"(?P<number>\d{2})_(?P<name>\w+)\.rs").unwrap(); 179 | let (name, number) = match re.captures(&filename) { 180 | None => panic!("Failed to parse koan name."), 181 | Some(s) => { 182 | let name = s["name"].into(); 183 | let number = s["number"].parse().unwrap(); 184 | (name, number) 185 | } 186 | }; 187 | 188 | let re = Regex::new(r"(?P<number>\d{2})_(?P<name>\w+)").unwrap(); 189 | let (parent_name, parent_number) = match re.captures(&parent_dir_name) { 190 | None => panic!("Failed to parse dir name."), 191 | Some(s) => { 192 | let name = s["name"].into(); 193 | let number = s["number"].parse().unwrap(); 194 | (name, number) 195 | } 196 | }; 197 | 198 | Koan { 199 | parent_name, 200 | parent_number, 201 | name, 202 | number, 203 | } 204 | } 205 | } 206 | 207 | impl Into<String> for &Koan { 208 | fn into(self) -> String { 209 | format!( 210 | "{:02}_{}/{:02}_{}", 211 | &self.parent_number, &self.parent_name, &self.number, &self.name 212 | ) 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /koans-framework/src/main.rs: -------------------------------------------------------------------------------- 1 | use koans::KoanCollection; 2 | use read_input::prelude::*; 3 | use std::error::Error; 4 | use std::ffi::OsString; 5 | use std::path::{Path, PathBuf}; 6 | use yansi::Paint; 7 | 8 | /// A small CLI to manage test-driven workshops and tutorials in Rust. 9 | /// 10 | /// Each exercise is called koan and comes with a set of associated tests. 11 | /// A suite of koans is called `collection`. 12 | /// 13 | /// Invoking `koans` runs tests for all the koans you have opened so far in a collection 14 | /// to check if your solutions are correct. 15 | /// If everything runs smoothly, you will asked if you want to move forward to the next koan. 16 | #[derive(structopt::StructOpt)] 17 | pub struct Command { 18 | /// Path to the koan collection you want to work on. 19 | /// Both absolute and relative paths are supported. 20 | /// 21 | /// E.g. `koans --path jira-wip` if `jira-wip` is a sub-directory of your current 22 | /// working directory and `jira-wip/Cargo.toml` leads to the Cargo file of the koans 23 | /// collection. 24 | #[structopt(long, parse(from_os_str))] 25 | pub path: PathBuf, 26 | } 27 | 28 | fn main() -> Result<(), Box<dyn Error>> { 29 | let command = <Command as paw::ParseArgs>::parse_args()?; 30 | // Enable ANSI colour support on Windows, is it's supported. 31 | // Disable it entirely otherwise. 32 | if cfg!(windows) && !Paint::enable_windows_ascii() { 33 | Paint::disable(); 34 | } 35 | let mut koans = KoanCollection::new(&command.path)?; 36 | match seek_the_path(&koans) { 37 | TestOutcome::Success => { 38 | match koans.next() { 39 | Some(next_koan) => { 40 | println!("\t{}\n", info_style().paint("Eternity lies ahead of us, and behind. Your path is not yet finished. 🍂")); 41 | 42 | let open_next = input::<String>() 43 | .repeat_msg(format!( 44 | "Do you want to open the next koan, {}? [y/n] ", 45 | next_koan 46 | )) 47 | .err("Please answer either yes or no.") 48 | .add_test(|s| parse_bool(s).is_some()) 49 | .get(); 50 | 51 | if parse_bool(&open_next).unwrap() { 52 | let next_koan = koans.open_next().expect("Failed to open the next koan"); 53 | println!( 54 | "{} {}", 55 | next_style().paint("\n\tAhead of you lies"), 56 | next_style().bold().paint(format!("{}", &next_koan)), 57 | ); 58 | } 59 | } 60 | None => { 61 | println!( 62 | "{}\n\t{}\n", 63 | success_style().paint("\n\tThere will be no more tasks."), 64 | info_style().paint("What is the sound of one hand clapping (for you)? 🌟") 65 | ); 66 | } 67 | } 68 | } 69 | TestOutcome::Failure { details } => { 70 | println!( 71 | "\n\t{}\n\n{}\n\n", 72 | info_style().paint( 73 | "Meditate on your approach and return. Mountains are merely mountains.\n\n" 74 | ), 75 | cargo_style().paint(&String::from_utf8_lossy(&details).to_string()) 76 | ); 77 | } 78 | }; 79 | Ok(()) 80 | } 81 | 82 | fn parse_bool(s: &str) -> Option<bool> { 83 | match s.to_ascii_lowercase().as_str() { 84 | "yes" | "y" => Some(true), 85 | "no" | "n" => Some(false), 86 | _ => None, 87 | } 88 | } 89 | 90 | fn seek_the_path(koans: &KoanCollection) -> TestOutcome { 91 | print!(" \n\n"); 92 | println!("{}", info_style().dimmed().paint("Running tests...\n")); 93 | for koan in koans.opened() { 94 | let koan_outcome = run_tests(&koans.configuration().manifest_path(), Some(&koan.name)); 95 | match koan_outcome { 96 | TestOutcome::Success => { 97 | println!("{}", success_style().paint(format!("\t🚀 {}", &koan))); 98 | } 99 | TestOutcome::Failure { details } => { 100 | println!("{}", failure_style().paint(format!("\t❌ {}", &koan))); 101 | return TestOutcome::Failure { details }; 102 | } 103 | } 104 | } 105 | TestOutcome::Success 106 | } 107 | 108 | fn run_tests(manifest_path: &Path, filter: Option<&str>) -> TestOutcome { 109 | // Tell cargo to return colored output, unless we are on Windows and the terminal 110 | // doesn't support it. 111 | let color_option = if cfg!(windows) && !Paint::enable_windows_ascii() { 112 | "never" 113 | } else { 114 | "always" 115 | }; 116 | 117 | let mut args: Vec<OsString> = vec![ 118 | "test".into(), 119 | "--manifest-path".into(), 120 | manifest_path.into(), 121 | "-q".into(), 122 | "--color".into(), 123 | color_option.into(), 124 | ]; 125 | 126 | if let Some(test_filter) = filter { 127 | args.push(test_filter.into()); 128 | } 129 | 130 | let output = std::process::Command::new("cargo") 131 | .args(args) 132 | .output() 133 | .expect("Failed to run tests"); 134 | 135 | if output.status.success() { 136 | TestOutcome::Success 137 | } else { 138 | TestOutcome::Failure { 139 | details: [output.stdout, output.stderr].concat(), 140 | } 141 | } 142 | } 143 | 144 | #[derive(PartialEq)] 145 | enum TestOutcome { 146 | Success, 147 | Failure { details: Vec<u8> }, 148 | } 149 | 150 | pub fn info_style() -> yansi::Style { 151 | yansi::Style::new(yansi::Color::Default) 152 | } 153 | pub fn cargo_style() -> yansi::Style { 154 | yansi::Style::new(yansi::Color::Default).dimmed() 155 | } 156 | pub fn next_style() -> yansi::Style { 157 | yansi::Style::new(yansi::Color::Yellow) 158 | } 159 | pub fn success_style() -> yansi::Style { 160 | yansi::Style::new(yansi::Color::Green) 161 | } 162 | pub fn failure_style() -> yansi::Style { 163 | yansi::Style::new(yansi::Color::Red) 164 | } 165 | --------------------------------------------------------------------------------