From 2c5fc0e550648f09e926a3149759bf685260ca63 Mon Sep 17 00:00:00 2001 From: Yang Ye Date: Wed, 22 Jul 2026 00:45:17 +0800 Subject: [PATCH 1/3] Add a rustlings-style runner alongside wr Bundles a `runner` binary (`cargo run -p runner`) that walks the exercises one by one: watch mode with auto-advance, file-change re-run, raw-mode keys (r/n/h/l/q), editor auto-open, and a 10s kill timeout for hung tests. The book now documents both `wr` and `runner` as ways to verify solutions. --- .gitignore | 1 + Cargo.lock | 635 +++++++------------------ Cargo.toml | 1 + book/src/01_intro/00_welcome.md | 25 +- book/src/01_intro/01_syntax.md | 2 +- runner/Cargo.toml | 10 + runner/src/main.rs | 800 ++++++++++++++++++++++++++++++++ 7 files changed, 988 insertions(+), 486 deletions(-) create mode 100644 runner/Cargo.toml create mode 100644 runner/src/main.rs diff --git a/.gitignore b/.gitignore index 9ca5c835c2..a872e0ec2b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target/ exercises/progress.db +.runner-state diff --git a/Cargo.lock b/Cargo.lock index 79493598b8..3a63008e5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,70 +2,11 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "aho-corasick" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" -dependencies = [ - "memchr", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" -dependencies = [ - "windows-sys 0.60.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.60.2", -] - [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arrays" @@ -95,19 +36,16 @@ dependencies = [ ] [[package]] -name = "bimap" -version = "0.6.3" +name = "bitflags" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "230c5f1ca6a325a32553f8640d31ac9b49f2411e901e427570154868b46da4f7" -dependencies = [ - "serde", -] +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "blocking" @@ -133,9 +71,9 @@ dependencies = [ [[package]] name = "bytes" -version = "1.10.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cancellation" @@ -157,46 +95,6 @@ dependencies = [ "ticket_fields", ] -[[package]] -name = "clap" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - [[package]] name = "client" version = "0.1.0" @@ -208,12 +106,6 @@ dependencies = [ name = "clone" version = "0.1.0" -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - [[package]] name = "combinators" version = "0.1.0" @@ -229,6 +121,21 @@ version = "0.1.0" name = "copy" version = "0.1.0" +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "deps" version = "0.1.0" @@ -249,12 +156,6 @@ version = "0.1.0" name = "drop" version = "0.1.0" -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - [[package]] name = "encapsulation" version = "0.1.0" @@ -266,12 +167,6 @@ dependencies = [ "common", ] -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - [[package]] name = "error_enums" version = "0.1.0" @@ -298,6 +193,16 @@ dependencies = [ "common", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "for_" version = "0.1.0" @@ -307,27 +212,21 @@ name = "from" version = "0.1.0" [[package]] -name = "future" -version = "0.1.0" +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" dependencies = [ - "tokio", + "libc", ] [[package]] -name = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +name = "future" +version = "0.1.0" dependencies = [ - "unicode-width", + "tokio", ] -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - [[package]] name = "hashmap" version = "0.1.0" @@ -339,12 +238,6 @@ dependencies = [ name = "heap" version = "0.1.0" -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - [[package]] name = "if_else" version = "0.1.0" @@ -382,13 +275,23 @@ dependencies = [ ] [[package]] -name = "indexmap" -version = "2.14.0" +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ - "equivalent", - "hashbrown", + "libc", ] [[package]] @@ -427,12 +330,6 @@ version = "0.1.0" name = "intro_08" version = "0.1.0" -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "iter" version = "0.1.0" @@ -448,19 +345,24 @@ dependencies = [ ] [[package]] -name = "itertools" -version = "0.13.0" +name = "kqueue" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ - "either", + "kqueue-sys", + "libc", ] [[package]] -name = "itoa" -version = "1.0.15" +name = "kqueue-sys" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] [[package]] name = "leaking" @@ -497,67 +399,27 @@ dependencies = [ ] [[package]] -name = "match_" -version = "0.1.0" - -[[package]] -name = "mdbook-core" -version = "0.5.3" +name = "log" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fc1c4da7fd9e2e412f3891428f9468fab890ed159723ed0892bb85a049ac1c1" -dependencies = [ - "anyhow", - "regex", - "serde", - "serde_json", - "toml", - "tracing", -] +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] -name = "mdbook-exercise-linker" +name = "match_" version = "0.1.0" -dependencies = [ - "anyhow", - "clap", - "mdbook-preprocessor", - "semver", - "serde_json", -] [[package]] -name = "mdbook-link-shortener" -version = "0.1.0" -dependencies = [ - "anyhow", - "bimap", - "clap", - "itertools", - "mdbook-preprocessor", - "pulldown-cmark", - "pulldown-cmark-to-cmark", - "semver", - "serde_json", -] - -[[package]] -name = "mdbook-preprocessor" -version = "0.5.3" +name = "mio" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eff7b4afaafd664a649a03b8891ad8199aef359a35b911ad6c31acab38ed209" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ - "anyhow", - "mdbook-core", - "serde", - "serde_json", + "libc", + "log", + "wasi", + "windows-sys 0.48.0", ] -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - [[package]] name = "mio" version = "1.2.0" @@ -577,6 +439,25 @@ version = "0.1.0" name = "mut_slice" version = "0.1.0" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.13.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "nullability" version = "0.1.0" @@ -584,18 +465,6 @@ dependencies = [ "common", ] -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "orphan" version = "0.1.0" @@ -685,34 +554,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "pulldown-cmark" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "679341d22c78c6c649893cbd6c3278dcbe9fc4faa62fea3a9296ae2b50c14625" -dependencies = [ - "bitflags", - "getopts", - "memchr", - "pulldown-cmark-escape", - "unicase", -] - -[[package]] -name = "pulldown-cmark-escape" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" - -[[package]] -name = "pulldown-cmark-to-cmark" -version = "15.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9c77db841443d89a57ae94f22d29c022f6d9f41b00bddbf1f4024dbaf4bdce1" -dependencies = [ - "pulldown-cmark", -] - [[package]] name = "quote" version = "1.0.45" @@ -728,42 +569,13 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] name = "references_in_memory" version = "0.1.0" -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - [[package]] name = "resizing" version = "0.1.0" @@ -775,6 +587,15 @@ dependencies = [ "ticket_fields", ] +[[package]] +name = "runner" +version = "0.1.0" +dependencies = [ + "anyhow", + "libc", + "notify", +] + [[package]] name = "runtime" version = "0.1.0" @@ -791,6 +612,15 @@ dependencies = [ "ticket_fields", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "saturating" version = "0.1.0" @@ -805,64 +635,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - [[package]] name = "setters" version = "0.1.0" @@ -940,12 +712,6 @@ dependencies = [ "common", ] -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "struct_" version = "0.1.0" @@ -1016,7 +782,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.0", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -1036,76 +802,6 @@ dependencies = [ "syn", ] -[[package]] -name = "toml" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow", -] - -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - [[package]] name = "trait_" version = "0.1.0" @@ -1125,24 +821,12 @@ dependencies = [ "ticket_fields", ] -[[package]] -name = "unicase" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" - [[package]] name = "unicode-ident" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "462eeb75aeb73aea900253ce739c8e18a67423fadf006037cd3ff27e82748a06" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "unwrap" version = "0.1.0" @@ -1150,12 +834,6 @@ dependencies = [ "common", ] -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "validation" version = "0.1.0" @@ -1182,6 +860,16 @@ version = "0.1.0" name = "visibility" version = "0.1.0" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1196,6 +884,15 @@ version = "0.1.0" name = "while_" version = "0.1.0" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -1204,9 +901,9 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ "windows-targets", ] @@ -1222,15 +919,13 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.5" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows-link", "windows_aarch64_gnullvm", "windows_aarch64_msvc", "windows_i686_gnu", - "windows_i686_gnullvm", "windows_i686_msvc", "windows_x86_64_gnu", "windows_x86_64_gnullvm", @@ -1239,57 +934,45 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.1" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] name = "windows_aarch64_msvc" -version = "0.53.1" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] name = "windows_i686_gnu" -version = "0.53.1" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] name = "windows_i686_msvc" -version = "0.53.1" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] name = "windows_x86_64_gnu" -version = "0.53.1" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.1" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "1.0.3" +version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] name = "without_channels" @@ -1297,9 +980,3 @@ version = "0.1.0" dependencies = [ "ticket_fields", ] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 8e8589b027..bc5d98318a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "exercises/*/*", "helpers/common", "helpers/ticket_fields", + "runner", ] resolver = "2" diff --git a/book/src/01_intro/00_welcome.md b/book/src/01_intro/00_welcome.md index db3134a772..421c43bbb2 100644 --- a/book/src/01_intro/00_welcome.md +++ b/book/src/01_intro/00_welcome.md @@ -78,19 +78,32 @@ To work through this course, you'll need: - [RustRover](https://www.jetbrains.com/rust/); - [Visual Studio Code](https://code.visualstudio.com) with the [`rust-analyzer`](https://marketplace.visualstudio.com/items?itemName=matklad.rust-analyzer) extension. -### Workshop runner, `wr` +### Verifying your solutions -To verify your solutions, we've also provided a tool to guide you through the course: the `wr` CLI, short for "workshop runner". -Install `wr` by following the instructions on [its website](https://mainmatter.github.io/rust-workshop-runner/). +There are two ways to verify your solutions as you work through the course — pick whichever you prefer. -Once you have `wr` installed, open a new terminal and navigate to the top-level folder of the repository. -Run the `wr` command to start the course: +#### `wr` (workshop runner) + +The original course tool. Install `wr` by following the instructions on [its website](https://mainmatter.github.io/rust-workshop-runner/), then from the top-level folder of the repository run: ```bash wr ``` -`wr` will verify the solution to the current exercise.\ +`wr` verifies the solution to the current exercise. + +#### `runner` (bundled in this repo) + +A small `rustlings`-style runner is included in this repository, so no separate install is needed. From the top-level folder run: + +```bash +cargo run -p runner +``` + +It compiles and runs the tests for the current exercise, auto-advances on success, and re-runs as soon as you save a change.\ +Press `Ctrl-C` to quit at any time — your progress is saved to `.runner-state` and resumed on the next run.\ +Run `cargo run -p runner -- help` for the full set of commands (`list`, `reset`, `check-all`). + Don't move on to the next section until you've solved the exercise for the current one. > We recommend committing your solutions to Git as you progress through the course, diff --git a/book/src/01_intro/01_syntax.md b/book/src/01_intro/01_syntax.md index ae75733310..482d0f7db4 100644 --- a/book/src/01_intro/01_syntax.md +++ b/book/src/01_intro/01_syntax.md @@ -5,7 +5,7 @@ Don't jump ahead!\ Complete the exercise for the previous section before you start this one.\ It's located in `exercises/01_intro/00_welcome`, in the [course GitHub's repository](https://github.com/mainmatter/100-exercises-to-learn-rust).\ -Use [`wr`](00_welcome.md#wr-the-workshop-runner) to start the course and verify your solutions. +Use [`wr`](00_welcome.md#verifying-your-solutions) or the bundled [`runner`](00_welcome.md#verifying-your-solutions) to start the course and verify your solutions. diff --git a/runner/Cargo.toml b/runner/Cargo.toml new file mode 100644 index 0000000000..b2b85cc4ed --- /dev/null +++ b/runner/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "runner" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +anyhow = "1" +libc = "0.2" +notify = "6" diff --git a/runner/src/main.rs b/runner/src/main.rs new file mode 100644 index 0000000000..efcdf1feb7 --- /dev/null +++ b/runner/src/main.rs @@ -0,0 +1,800 @@ +//! Runner for the "100 exercises to learn Rust" course. +//! +//! Replaces `wr`: discovers every exercise crate under `exercises/`, runs +//! `cargo test` for the current one, and offers a rustlings-style interactive +//! watch mode. +//! +//! Watch mode keys (single keypresses, no Enter needed): +//! r / Enter re-run the current exercise +//! n move to the next exercise (only once the current one passes) +//! h show the current exercise's notes (leading source comments) +//! l list all exercises with progress +//! q / Ctrl-C quit +//! (saving the current exercise's file also re-runs it) +//! +//! Usage: +//! cargo run -p runner # watch mode (default) +//! cargo run -p runner -- run [name] +//! cargo run -p runner -- list +//! cargo run -p runner -- reset [name] +//! cargo run -p runner -- check-all + +use anyhow::{bail, Context, Result}; +use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::{ + collections::HashSet, + env, fs, + io::{self, IsTerminal, Read, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::mpsc, + thread, + time::{Duration, Instant}, +}; + +const EXERCISES_DIR: &str = "exercises"; +const STATE_FILE: &str = ".runner-state"; + +const GREEN: &str = "\x1b[32m"; +const RED: &str = "\x1b[31m"; +const BOLD: &str = "\x1b[1m"; +const DIM: &str = "\x1b[2m"; +const RESET: &str = "\x1b[0m"; + +fn main() -> Result<()> { + let root = find_repo_root()?; + let exercises = discover_exercises(&root); + if exercises.is_empty() { + bail!("no exercise crates found under `{EXERCISES_DIR}/`"); + } + + let mut args = env::args().skip(1); + let cmd = args.next(); + let rest: Vec = args.collect(); + + match cmd.as_deref() { + None | Some("watch") => watch(&root, &exercises), + Some("run") => run_one(&root, &exercises, &rest), + Some("list") | Some("ls") => { + list(&root, &exercises); + Ok(()) + } + Some("reset") => reset(&root, &exercises, &rest), + Some("check-all") => check_all(&root, &exercises), + Some("help") | Some("-h") | Some("--help") => { + print_help(); + Ok(()) + } + Some(other) => bail!("unknown command `{other}` — run `runner help`"), + } +} + +// --- raw terminal mode (unix) ------------------------------------------------ +// +// Disables canonical mode + echo so each keypress is read immediately, without +// waiting for Enter and without being echoed. ISIG stays on so Ctrl-C still +// delivers SIGINT; a handler restores the terminal before exiting so the user's +// shell is never left in raw mode. + +#[cfg(unix)] +mod raw { + use std::io; + use std::os::unix::io::AsRawFd; + + static mut SAVED: Option<(i32, libc::termios)> = None; + + extern "C" fn on_sigint(_sig: libc::c_int) { + unsafe { + if let Some((fd, term)) = SAVED { + libc::tcsetattr(fd, libc::TCSANOW, &term); + } + libc::_exit(130); + } + } + + pub struct Guard { + fd: i32, + original: libc::termios, + } + + impl Guard { + pub fn enable() -> io::Result { + let fd = io::stdin().as_raw_fd(); + // SAFETY: `tcgetattr` only writes into the provided `termios`. + let mut original: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(fd, &mut original) } != 0 { + return Err(io::Error::last_os_error()); + } + let mut raw = original; + raw.c_lflag &= !(libc::ICANON | libc::ECHO); + + let guard = Guard { fd, original }; + // SAFETY: SAVED is written once on the main thread before the key + // reader is spawned and before any signal can be delivered; the + // handler only reads it. + unsafe { + SAVED = Some((fd, original)); + let mut sa: libc::sigaction = std::mem::zeroed(); + sa.sa_sigaction = on_sigint as *const () as usize; + sa.sa_flags = 0; + libc::sigemptyset(&mut sa.sa_mask); + libc::sigaction(libc::SIGINT, &sa, std::ptr::null_mut()); + } + if unsafe { libc::tcsetattr(fd, libc::TCSANOW, &raw) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(guard) + } + } + + impl Drop for Guard { + fn drop(&mut self) { + // Restore on normal exit / panic. + unsafe { + libc::tcsetattr(self.fd, libc::TCSANOW, &self.original); + } + } + } +} + +// --- discovery --------------------------------------------------------------- + +fn find_repo_root() -> Result { + let cwd = env::current_dir().context("couldn't read current directory")?; + let mut p: &Path = &cwd; + loop { + if p.join(EXERCISES_DIR).is_dir() { + return Ok(p.to_path_buf()); + } + match p.parent() { + Some(parent) => p = parent, + None => bail!( + "not inside the course repo (no `{EXERCISES_DIR}/` found upward from {})", + cwd.display() + ), + } + } +} + +/// Sorted list of exercise directories: `exercises//` each +/// containing a `Cargo.toml`. Sorted == intended learning order (dirs are +/// zero-padded). +fn discover_exercises(root: &Path) -> Vec { + let base = root.join(EXERCISES_DIR); + let mut dirs = Vec::new(); + if let Ok(chapters) = fs::read_dir(&base) { + for chapter in chapters.flatten() { + let cp = chapter.path(); + if !cp.is_dir() { + continue; + } + if let Ok(exs) = fs::read_dir(&cp) { + for ex in exs.flatten() { + let ep = ex.path(); + if ep.is_dir() && ep.join("Cargo.toml").exists() { + dirs.push(ep); + } + } + } + } + } + dirs.sort(); + dirs +} + +// --- state ------------------------------------------------------------------- + +fn state_path(root: &Path) -> PathBuf { + root.join(STATE_FILE) +} + +fn key_of(root: &Path, dir: &Path) -> String { + dir.strip_prefix(root) + .unwrap_or(dir) + .to_string_lossy() + .into_owned() +} + +fn load_done(root: &Path) -> HashSet { + fs::read_to_string(state_path(root)) + .map(|s| { + s.lines() + .map(|l| l.trim().to_owned()) + .filter(|l| !l.is_empty()) + .collect() + }) + .unwrap_or_default() +} + +fn save_done(root: &Path, done: &HashSet) -> Result<()> { + let mut lines: Vec<&str> = done.iter().map(String::as_str).collect(); + lines.sort_unstable(); + let mut body = lines.join("\n"); + if !body.is_empty() { + body.push('\n'); + } + fs::write(state_path(root), body).context("couldn't write state file") +} + +fn first_pending_idx( + root: &Path, + exercises: &[PathBuf], + done: &HashSet, +) -> Option { + exercises.iter().position(|d| !done.contains(&key_of(root, d))) +} + +fn find_exercise<'a>( + root: &Path, + exercises: &'a [PathBuf], + name: &str, +) -> Result<&'a PathBuf> { + let matches: Vec<_> = exercises + .iter() + .filter(|d| key_of(root, d).contains(name)) + .collect(); + match matches.len() { + 0 => bail!("no exercise matches `{name}`"), + 1 => Ok(matches[0]), + _ => { + let names = matches + .iter() + .map(|m| key_of(root, m)) + .collect::>() + .join(", "); + bail!("`{name}` is ambiguous, matches: {names}"); + } + } +} + +// --- running one exercise ---------------------------------------------------- + +/// Run an exercise's tests, returning (success, merged stdout+stderr output). +// ponytail: `sh -c '... 2>&1'` merges cargo's streams in the right order and +// keeps `--color always` so the captured output repaints with colour. +const BUILD_TIMEOUT: Duration = Duration::from_secs(300); +const RUN_TIMEOUT: Duration = Duration::from_secs(10); + +/// Run `cmd` in its own process group, capturing merged output, and SIGKILL the +/// whole group if it runs past `timeout`. Returns `(killed, exit_code, output)`. +/// Splitting the group matters: `cargo test` spawns the test binary as a child, +/// so killing only `cargo` would orphan a hung test (e.g. the `05_blocking` deadlock). +fn run_timed( + mut cmd: Command, + timeout: Duration, + label: &str, +) -> Result<(bool, Option, Vec)> { + cmd.stdin(Stdio::null()).stdout(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // 0 = child becomes leader of a new process group (pgid == child pid). + cmd.process_group(0); + } + let mut child = cmd + .spawn() + .with_context(|| format!("failed to spawn {label}"))?; + let mut stdout = child.stdout.take().expect("piped stdout"); + let reader = thread::spawn(move || { + let mut buf = Vec::new(); + let _ = stdout.read_to_end(&mut buf); + buf + }); + + let start = Instant::now(); + let mut killed = false; + let code = loop { + if let Some(status) = child.try_wait()? { + break status.code(); + } + if start.elapsed() >= timeout { + #[cfg(unix)] + unsafe { + // Kill the whole process group, not just the leader. + libc::kill(-(child.id() as libc::pid_t), libc::SIGKILL); + } + #[cfg(not(unix))] + { + let _ = child.kill(); + } + killed = true; + break child.wait().ok().and_then(|s| s.code()); + } + thread::sleep(Duration::from_millis(50)); + }; + + let output = reader.join().unwrap_or_default(); + Ok((killed, code, output)) +} + +/// Compile (generous timeout) then run the tests (10s kill timeout). Splitting +/// the two means a cold compile (e.g. tokio) is never killed, while a hung +/// program is. Returns `(success, merged output)`. +fn run_exercise(root: &Path, dir: &Path) -> Result<(bool, Vec)> { + let manifest = dir.join("Cargo.toml"); + let key = key_of(root, dir); + + // 1. Compile the tests. + let mut build = Command::new("sh"); + build + .arg("-c") + .arg("cargo test --manifest-path \"$0\" --no-run --color always 2>&1") + .arg(&manifest); + let (bkilled, bcode, mut bout) = run_timed(build, BUILD_TIMEOUT, &format!("`cargo test` build for {key}"))?; + if bkilled { + bout.extend_from_slice(b"\n\ncompile timed out\n"); + return Ok((false, bout)); + } + if bcode != Some(0) { + // Compile failed (or couldn't compile) — surface the compiler errors. + return Ok((false, bout)); + } + + // 2. Run the tests, bounded by RUN_TIMEOUT. + let mut run = Command::new("sh"); + run.arg("-c") + .arg("cargo test --manifest-path \"$0\" --color always 2>&1") + .arg(&manifest); + let (rkilled, rcode, mut rout) = run_timed(run, RUN_TIMEOUT, &format!("`cargo test` run for {key}"))?; + let mut success = rcode == Some(0); + if rkilled { + success = false; + rout.extend_from_slice( + format!("\n\n{RED}{BOLD}timed out after 10s — killed (the program didn't finish){RESET}\n") + .as_bytes(), + ); + } + Ok((success, rout)) +} + +/// Clear the screen and move the cursor to the top-left. +fn clear_screen() { + // [H = cursor home, [2J = clear screen, [3J = clear scrollback. + let _ = io::stdout().write_all(b"\x1b[H\x1b[2J\x1b[3J"); + let _ = io::stdout().flush(); +} + +/// The primary source file to open in an editor / show notes from. +fn source_file(dir: &Path) -> PathBuf { + let lib = dir.join("src/lib.rs"); + if lib.exists() { + lib + } else { + dir.join("src/main.rs") + } +} + +// --- editor auto-open -------------------------------------------------------- + +/// Decide which editor to launch, if any. Priority: +/// VS Code (when run inside its terminal) → `$VISUAL` → `$EDITOR` → none. +/// The command must return immediately; a blocking TUI editor (vim, etc.) will +/// fight this program for the terminal. +fn editor_cmd() -> Option<(String, Vec)> { + let in_vscode = env::var_os("TERM_PROGRAM").is_some_and(|v| v == "vscode"); + if in_vscode { + for prog in ["code", "codium"] { + if command_exists(prog) { + return Some((prog.into(), Vec::new())); + } + } + } + for var in ["VISUAL", "EDITOR"] { + if let Some(val) = env::var(var).ok().filter(|v| !v.is_empty()) { + let mut parts = val.split_whitespace(); + if let Some(program) = parts.next() { + let args = parts.map(String::from).collect(); + return Some((program.into(), args)); + } + } + } + None +} + +fn command_exists(prog: &str) -> bool { + Command::new(prog) + .arg("--version") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .is_ok_and(|s| s.success()) +} + +/// Open an exercise's source file in the editor, without blocking. Best-effort: +/// errors are ignored (the runner works fine without an editor). +fn open_editor(path: &Path) { + let Some((program, args)) = editor_cmd() else { + return; + }; + let path = path.to_path_buf(); + thread::spawn(move || { + let _ = Command::new(&program) + .args(&args) + .arg(&path) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + }); +} + +// --- watch events ------------------------------------------------------------ + +#[derive(Clone, Copy)] +enum InputEvent { + Run, + Next, + Hint, + List, + Quit, +} + +enum WatchEvent { + Key(InputEvent), + File(PathBuf), +} + +/// Blocking reader: maps single keypresses to input events. Runs on its own +/// thread so it never blocks the main loop while `cargo test` is running. +fn key_reader(sender: mpsc::Sender) { + let stdin = io::stdin(); + let mut lock = stdin.lock(); + let mut buf = [0u8; 1]; + loop { + match lock.read(&mut buf) { + Ok(0) => return, + Ok(_) => { + let ev = match buf[0] { + b'r' | b'R' | b'\r' | b'\n' => Some(InputEvent::Run), + b'n' | b'N' => Some(InputEvent::Next), + b'h' | b'H' => Some(InputEvent::Hint), + b'l' | b'L' => Some(InputEvent::List), + b'q' | b'Q' => Some(InputEvent::Quit), + _ => None, + }; + if let Some(e) = ev { + if sender.send(WatchEvent::Key(e)).is_err() { + return; + } + } + } + Err(_) => return, + } + } +} + +// --- watch mode -------------------------------------------------------------- + +fn watch(root: &Path, exercises: &[PathBuf]) -> Result<()> { + if !io::stdin().is_terminal() { + bail!("watch mode needs an interactive terminal; use `runner run` instead"); + } + + #[cfg(unix)] + let _raw = raw::Guard::enable().context("couldn't enable raw terminal mode")?; + #[cfg(not(unix))] + bail!("interactive watch mode is only supported on unix; use `runner run` instead"); + + let total = exercises.len(); + let mut done = load_done(root); + + let mut cur_idx = match first_pending_idx(root, exercises, &done) { + Some(i) => i, + None => { + celebrate(total); + return Ok(()); + } + }; + + let (tx, rx) = mpsc::channel(); + // File watcher: forward modify/create events for any exercise. + let tx_watch = tx.clone(); + let mut watcher = RecommendedWatcher::new( + move |res: notify::Result| { + if let Ok(ev) = res { + if matches!(ev.kind, EventKind::Modify(_) | EventKind::Create(_)) { + for p in ev.paths { + let _ = tx_watch.send(WatchEvent::File(p)); + } + } + } + }, + Config::default(), + ) + .context("couldn't create file watcher")?; + watcher + .watch(&root.join(EXERCISES_DIR), RecursiveMode::Recursive) + .context("couldn't watch `exercises/`")?; + + // Key reader thread. + thread::spawn(move || key_reader(tx)); + + open_editor(&source_file(&exercises[cur_idx])); + let mut output: Vec = Vec::new(); + let mut passed = false; + let mut show_notes = false; + run_and_render( + root, + exercises, + &done, + cur_idx, + &mut output, + &mut passed, + &mut show_notes, + )?; + + while let Ok(ev) = rx.recv() { + match ev { + WatchEvent::Key(InputEvent::Quit) => { + clear_screen(); + println!("{DIM}bye 👋 (progress saved){RESET}"); + break; + } + WatchEvent::Key(InputEvent::Run) => { + run_and_render( + root, + exercises, + &done, + cur_idx, + &mut output, + &mut passed, + &mut show_notes, + )?; + } + WatchEvent::Key(InputEvent::Next) => { + if !passed { + render(root, exercises, &done, cur_idx, passed, &output, show_notes); + continue; + } + done.insert(key_of(root, &exercises[cur_idx])); + save_done(root, &done)?; + match first_pending_idx(root, exercises, &done) { + None => { + celebrate(total); + return Ok(()); + } + Some(i) => cur_idx = i, + } + open_editor(&source_file(&exercises[cur_idx])); + run_and_render( + root, + exercises, + &done, + cur_idx, + &mut output, + &mut passed, + &mut show_notes, + )?; + } + WatchEvent::Key(InputEvent::Hint) => { + show_notes = !show_notes; + render(root, exercises, &done, cur_idx, passed, &output, show_notes); + } + WatchEvent::Key(InputEvent::List) => { + clear_screen(); + list(root, exercises); + println!("\n{DIM}press any key to return{RESET}"); + let _ = io::stdout().flush(); + } + WatchEvent::File(p) => { + if !p.starts_with(&exercises[cur_idx]) { + continue; + } + // Coalesce a burst of editor write events into one run. + thread::sleep(Duration::from_millis(60)); + while rx.try_recv().is_ok() {} + run_and_render( + root, + exercises, + &done, + cur_idx, + &mut output, + &mut passed, + &mut show_notes, + )?; + } + } + } + + Ok(()) +} + +/// The exercise's leading source comments, as a reminder string. +fn notes_for(root: &Path, dir: &Path) -> String { + let path = source_file(dir); + let Ok(content) = fs::read_to_string(&path) else { + return format!("{DIM}(couldn't read {}){RESET}", path.display()); + }; + let mut lines = Vec::new(); + for line in content.lines() { + let t = line.trim_start(); + if t.starts_with("//") || t.is_empty() { + if lines.len() >= 50 { + break; + } + lines.push(line); + } else { + break; + } + } + let label = path.strip_prefix(root).unwrap_or(&path).display(); + if lines.is_empty() { + format!("{BOLD}Notes — {label}{RESET}\n{DIM}(no leading notes in this file){RESET}") + } else { + format!( + "{BOLD}Notes — {label}{RESET}\n{DIM}{}{RESET}", + lines.join("\n") + ) + } +} + +/// Clear the screen and repaint the full current state from the top. +fn render( + root: &Path, + exercises: &[PathBuf], + done: &HashSet, + cur_idx: usize, + passed: bool, + output: &[u8], + show_notes: bool, +) { + clear_screen(); + let total = exercises.len(); + let key = key_of(root, &exercises[cur_idx]); + println!("{BOLD}exercise {}/{total}: {key}{RESET}\n", cur_idx + 1); + let _ = io::stdout().write_all(output); + let _ = io::stdout().write_all(b"\n\n"); + if show_notes { + println!("{}\n", notes_for(root, &exercises[cur_idx])); + } + if passed { + println!("{GREEN}{BOLD}✓ passed{RESET} — press {BOLD}n{RESET} for the next exercise\n"); + } else { + println!("{RED}{BOLD}✗ failed{RESET} — edit & save, or press {BOLD}r{RESET} to re-run\n"); + } + println!( + "{DIM}progress {}/{} {BOLD}r{RESET}{DIM}:run {BOLD}n{RESET}:next {BOLD}h{RESET}:notes {BOLD}l{RESET}:list {BOLD}q{RESET}:quit{RESET}", + done.len(), + total + ); + let _ = io::stdout().flush(); +} + +/// Clear, show a "checking" line, run the current exercise, then render. +fn run_and_render( + root: &Path, + exercises: &[PathBuf], + done: &HashSet, + cur_idx: usize, + output: &mut Vec, + passed: &mut bool, + show_notes: &mut bool, +) -> Result<()> { + clear_screen(); + println!( + "{DIM}Checking {}…{RESET}", + key_of(root, &exercises[cur_idx]) + ); + let _ = io::stdout().flush(); + let (ok, out) = run_exercise(root, &exercises[cur_idx])?; + *passed = ok; + *output = out; + *show_notes = false; + render(root, exercises, done, cur_idx, *passed, output, *show_notes); + Ok(()) +} + +// --- non-watch subcommands --------------------------------------------------- + +fn run_one(root: &Path, exercises: &[PathBuf], rest: &[String]) -> Result<()> { + let mut done = load_done(root); + let dir = match rest.first() { + Some(name) => find_exercise(root, exercises, name)?.clone(), + None => match first_pending_idx(root, exercises, &done) { + Some(i) => exercises[i].clone(), + None => { + celebrate(exercises.len()); + return Ok(()); + } + }, + }; + + println!("{BOLD}{DIM}▶ {}{RESET}", key_of(root, &dir)); + let (success, out) = run_exercise(root, &dir)?; + let _ = io::stdout().write_all(&out); + let _ = io::stdout().flush(); + if success { + done.insert(key_of(root, &dir)); + save_done(root, &done)?; + println!("{GREEN}{BOLD}✓ passed{RESET}"); + if let Some(i) = first_pending_idx(root, exercises, &done) { + println!("{DIM}next: {}{RESET}", key_of(root, &exercises[i])); + } + } else { + println!("{RED}{BOLD}✗ failed{RESET}"); + std::process::exit(1); + } + Ok(()) +} + +fn check_all(root: &Path, exercises: &[PathBuf]) -> Result<()> { + let mut done = HashSet::new(); + for dir in exercises { + println!("{BOLD}{DIM}▶ {}{RESET}", key_of(root, dir)); + let (success, out) = run_exercise(root, dir)?; + let _ = io::stdout().write_all(&out); + let _ = io::stdout().flush(); + if success { + done.insert(key_of(root, dir)); + save_done(root, &done)?; + } else { + println!("{RED}{BOLD}✗ stopped at {}{RESET}", key_of(root, dir)); + std::process::exit(1); + } + } + celebrate(exercises.len()); + Ok(()) +} + +fn reset(root: &Path, exercises: &[PathBuf], rest: &[String]) -> Result<()> { + match rest.first() { + Some(name) => { + let dir = find_exercise(root, exercises, name)?; + let key = key_of(root, dir); + let mut done = load_done(root); + if done.remove(&key) { + save_done(root, &done)?; + println!("reset {key}"); + } else { + println!("{DIM}{key} wasn't marked done{RESET}"); + } + } + None => { + let _ = fs::remove_file(state_path(root)); + println!("cleared all progress"); + } + } + Ok(()) +} + +fn list(root: &Path, exercises: &[PathBuf]) { + let done = load_done(root); + let n_done = exercises + .iter() + .filter(|d| done.contains(&key_of(root, d))) + .count(); + for (i, dir) in exercises.iter().enumerate() { + let key = key_of(root, dir); + let (mark, color) = if done.contains(&key) { + ("✓", GREEN) + } else { + ("✗", RED) + }; + println!("{DIM}{:>3}.{RESET} {color}{mark}{RESET} {key}", i + 1); + } + println!("\n{BOLD}{n_done}/{} done{RESET}", exercises.len()); +} + +fn celebrate(total: usize) { + println!("\n{GREEN}{BOLD}🎉 All {total} exercises complete! Well done.{RESET}\n"); +} + +fn print_help() { + println!("runner — proceed through the 100 Rust exercises one by one\n"); + println!("USAGE:"); + println!(" cargo run -p runner [-- [ARGS]]\n"); + println!("COMMANDS:"); + println!(" (default) / watch interactive: run current, re-run on save, n=next, q=quit"); + println!(" run [name] run one exercise once (first pending if no name); advance on pass"); + println!(" list show all exercises with pass/fail status"); + println!(" reset [name] clear progress (one exercise if name given)"); + println!(" check-all re-run every exercise, stop at first failure"); + println!(" help show this message\n"); + println!("WATCH KEYS: r run · n next · h notes · l list · q quit"); + println!("`name` matches any substring of the exercise path, e.g. `01_syntax`."); + println!( + "Editor auto-open: VS Code terminal → `code`; otherwise `$VISUAL`/`$EDITOR` (must be non-blocking)." + ); + println!("Done state is stored in {STATE_FILE} (gitignored)."); +} From 0e26c674d79e81fc8cc0b9bfd5330e7287485186 Mon Sep 17 00:00:00 2001 From: Yang Ye Date: Wed, 22 Jul 2026 08:56:33 +0800 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- runner/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runner/src/main.rs b/runner/src/main.rs index efcdf1feb7..46c065b579 100644 --- a/runner/src/main.rs +++ b/runner/src/main.rs @@ -574,7 +574,7 @@ fn watch(root: &Path, exercises: &[PathBuf]) -> Result<()> { WatchEvent::Key(InputEvent::List) => { clear_screen(); list(root, exercises); - println!("\n{DIM}press any key to return{RESET}"); + println!("\n{DIM}press r to re-run, h to toggle notes, n for next, or q to quit{RESET}"); let _ = io::stdout().flush(); } WatchEvent::File(p) => { From 76f82ea97b32abfbc7ef79fdda77a0942b21f10d Mon Sep 17 00:00:00 2001 From: Yang Ye Date: Wed, 22 Jul 2026 09:15:04 +0800 Subject: [PATCH 3/3] Address review feedback on the runner - forward every keypress (not just r/n/h/l/q), so "press any key to return" from the list is actually true - stop draining the whole event channel on a file change; coalesce only file events and keep queued key events - run cargo directly instead of shelling out to sh, so it works without a POSIX shell - note in the book that watch mode is macOS/Linux only --- book/src/01_intro/00_welcome.md | 4 + runner/src/main.rs | 205 ++++++++++++++++++-------------- 2 files changed, 118 insertions(+), 91 deletions(-) diff --git a/book/src/01_intro/00_welcome.md b/book/src/01_intro/00_welcome.md index 421c43bbb2..b84bfb0f7f 100644 --- a/book/src/01_intro/00_welcome.md +++ b/book/src/01_intro/00_welcome.md @@ -104,6 +104,10 @@ It compiles and runs the tests for the current exercise, auto-advances on succes Press `Ctrl-C` to quit at any time — your progress is saved to `.runner-state` and resumed on the next run.\ Run `cargo run -p runner -- help` for the full set of commands (`list`, `reset`, `check-all`). +> The interactive watch mode currently needs macOS or Linux (it relies on a raw +> terminal and process signals). On Windows, use `wr`, or the non-interactive +> `cargo run -p runner -- run` / `-- check-all` commands. + Don't move on to the next section until you've solved the exercise for the current one. > We recommend committing your solutions to Git as you progress through the course, diff --git a/runner/src/main.rs b/runner/src/main.rs index 46c065b579..255c0d0047 100644 --- a/runner/src/main.rs +++ b/runner/src/main.rs @@ -249,9 +249,9 @@ fn find_exercise<'a>( // --- running one exercise ---------------------------------------------------- -/// Run an exercise's tests, returning (success, merged stdout+stderr output). -// ponytail: `sh -c '... 2>&1'` merges cargo's streams in the right order and -// keeps `--color always` so the captured output repaints with colour. +/// Run an exercise's tests, returning (success, captured output). +// stdout and stderr are captured separately and concatenated (no shell); +// `--color always` keeps the output coloured when it's repainted. const BUILD_TIMEOUT: Duration = Duration::from_secs(300); const RUN_TIMEOUT: Duration = Duration::from_secs(10); @@ -264,7 +264,9 @@ fn run_timed( timeout: Duration, label: &str, ) -> Result<(bool, Option, Vec)> { - cmd.stdin(Stdio::null()).stdout(Stdio::piped()); + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); #[cfg(unix)] { use std::os::unix::process::CommandExt; @@ -275,11 +277,17 @@ fn run_timed( .spawn() .with_context(|| format!("failed to spawn {label}"))?; let mut stdout = child.stdout.take().expect("piped stdout"); - let reader = thread::spawn(move || { + let mut stderr = child.stderr.take().expect("piped stderr"); + let out_handle = thread::spawn(move || { let mut buf = Vec::new(); let _ = stdout.read_to_end(&mut buf); buf }); + let err_handle = thread::spawn(move || { + let mut buf = Vec::new(); + let _ = stderr.read_to_end(&mut buf); + buf + }); let start = Instant::now(); let mut killed = false; @@ -303,8 +311,11 @@ fn run_timed( thread::sleep(Duration::from_millis(50)); }; - let output = reader.join().unwrap_or_default(); - Ok((killed, code, output)) + // Cargo writes build progress/errors to stderr and test results to stdout; + // join both and put stderr first so build context precedes test output. + let mut merged = err_handle.join().unwrap_or_default(); + merged.append(&mut out_handle.join().unwrap_or_default()); + Ok((killed, code, merged)) } /// Compile (generous timeout) then run the tests (10s kill timeout). Splitting @@ -315,12 +326,16 @@ fn run_exercise(root: &Path, dir: &Path) -> Result<(bool, Vec)> { let key = key_of(root, dir); // 1. Compile the tests. - let mut build = Command::new("sh"); + let mut build = Command::new("cargo"); build - .arg("-c") - .arg("cargo test --manifest-path \"$0\" --no-run --color always 2>&1") - .arg(&manifest); - let (bkilled, bcode, mut bout) = run_timed(build, BUILD_TIMEOUT, &format!("`cargo test` build for {key}"))?; + .arg("test") + .arg("--manifest-path") + .arg(&manifest) + .arg("--no-run") + .arg("--color") + .arg("always"); + let (bkilled, bcode, mut bout) = + run_timed(build, BUILD_TIMEOUT, &format!("`cargo test` build for {key}"))?; if bkilled { bout.extend_from_slice(b"\n\ncompile timed out\n"); return Ok((false, bout)); @@ -331,11 +346,14 @@ fn run_exercise(root: &Path, dir: &Path) -> Result<(bool, Vec)> { } // 2. Run the tests, bounded by RUN_TIMEOUT. - let mut run = Command::new("sh"); - run.arg("-c") - .arg("cargo test --manifest-path \"$0\" --color always 2>&1") - .arg(&manifest); - let (rkilled, rcode, mut rout) = run_timed(run, RUN_TIMEOUT, &format!("`cargo test` run for {key}"))?; + let mut run = Command::new("cargo"); + run.arg("test") + .arg("--manifest-path") + .arg(&manifest) + .arg("--color") + .arg("always"); + let (rkilled, rcode, mut rout) = + run_timed(run, RUN_TIMEOUT, &format!("`cargo test` run for {key}"))?; let mut success = rcode == Some(0); if rkilled { success = false; @@ -421,22 +439,14 @@ fn open_editor(path: &Path) { // --- watch events ------------------------------------------------------------ -#[derive(Clone, Copy)] -enum InputEvent { - Run, - Next, - Hint, - List, - Quit, -} - enum WatchEvent { - Key(InputEvent), + Key(u8), File(PathBuf), } -/// Blocking reader: maps single keypresses to input events. Runs on its own -/// thread so it never blocks the main loop while `cargo test` is running. +/// Blocking reader: forwards every keypress as a raw byte, so the watch loop +/// can react to arbitrary keys (e.g. "press any key to return" from the list). +/// Runs on its own thread so it never blocks while `cargo test` is running. fn key_reader(sender: mpsc::Sender) { let stdin = io::stdin(); let mut lock = stdin.lock(); @@ -445,18 +455,8 @@ fn key_reader(sender: mpsc::Sender) { match lock.read(&mut buf) { Ok(0) => return, Ok(_) => { - let ev = match buf[0] { - b'r' | b'R' | b'\r' | b'\n' => Some(InputEvent::Run), - b'n' | b'N' => Some(InputEvent::Next), - b'h' | b'H' => Some(InputEvent::Hint), - b'l' | b'L' => Some(InputEvent::List), - b'q' | b'Q' => Some(InputEvent::Quit), - _ => None, - }; - if let Some(e) = ev { - if sender.send(WatchEvent::Key(e)).is_err() { - return; - } + if sender.send(WatchEvent::Key(buf[0])).is_err() { + return; } } Err(_) => return, @@ -507,7 +507,9 @@ fn watch(root: &Path, exercises: &[PathBuf]) -> Result<()> { .watch(&root.join(EXERCISES_DIR), RecursiveMode::Recursive) .context("couldn't watch `exercises/`")?; - // Key reader thread. + // Key reader thread. `tx_self` lets the loop re-queue key events that + // arrive during a file-change coalesce (so a quick `q`/`r` isn't dropped). + let tx_self = tx.clone(); thread::spawn(move || key_reader(tx)); open_editor(&source_file(&exercises[cur_idx])); @@ -526,64 +528,85 @@ fn watch(root: &Path, exercises: &[PathBuf]) -> Result<()> { while let Ok(ev) = rx.recv() { match ev { - WatchEvent::Key(InputEvent::Quit) => { - clear_screen(); - println!("{DIM}bye 👋 (progress saved){RESET}"); - break; - } - WatchEvent::Key(InputEvent::Run) => { - run_and_render( - root, - exercises, - &done, - cur_idx, - &mut output, - &mut passed, - &mut show_notes, - )?; - } - WatchEvent::Key(InputEvent::Next) => { - if !passed { + WatchEvent::Key(b) => match b { + b'q' | b'Q' => { + clear_screen(); + println!("{DIM}bye 👋 (progress saved){RESET}"); + break; + } + b'r' | b'R' | b'\r' | b'\n' => { + run_and_render( + root, + exercises, + &done, + cur_idx, + &mut output, + &mut passed, + &mut show_notes, + )?; + } + b'n' | b'N' => { + if !passed { + render(root, exercises, &done, cur_idx, passed, &output, show_notes); + continue; + } + done.insert(key_of(root, &exercises[cur_idx])); + save_done(root, &done)?; + match first_pending_idx(root, exercises, &done) { + None => { + celebrate(total); + return Ok(()); + } + Some(i) => cur_idx = i, + } + open_editor(&source_file(&exercises[cur_idx])); + run_and_render( + root, + exercises, + &done, + cur_idx, + &mut output, + &mut passed, + &mut show_notes, + )?; + } + b'h' | b'H' => { + show_notes = !show_notes; render(root, exercises, &done, cur_idx, passed, &output, show_notes); - continue; } - done.insert(key_of(root, &exercises[cur_idx])); - save_done(root, &done)?; - match first_pending_idx(root, exercises, &done) { - None => { - celebrate(total); - return Ok(()); + b'l' | b'L' => { + clear_screen(); + list(root, exercises); + println!("\n{DIM}press any key to return{RESET}"); + let _ = io::stdout().flush(); + // Any key returns to the exercise view. + while let Ok(next) = rx.recv() { + if matches!(next, WatchEvent::Key(_)) { + break; + } + // Ignore file events while the list is shown. } - Some(i) => cur_idx = i, + render(root, exercises, &done, cur_idx, passed, &output, show_notes); } - open_editor(&source_file(&exercises[cur_idx])); - run_and_render( - root, - exercises, - &done, - cur_idx, - &mut output, - &mut passed, - &mut show_notes, - )?; - } - WatchEvent::Key(InputEvent::Hint) => { - show_notes = !show_notes; - render(root, exercises, &done, cur_idx, passed, &output, show_notes); - } - WatchEvent::Key(InputEvent::List) => { - clear_screen(); - list(root, exercises); - println!("\n{DIM}press r to re-run, h to toggle notes, n for next, or q to quit{RESET}"); - let _ = io::stdout().flush(); - } + _ => {} // other keys are ignored in the exercise view + }, WatchEvent::File(p) => { if !p.starts_with(&exercises[cur_idx]) { continue; } - // Coalesce a burst of editor write events into one run. + // Let a burst of editor write events settle, then run once. thread::sleep(Duration::from_millis(60)); - while rx.try_recv().is_ok() {} + // Coalesce further file events, but preserve queued key events + // by re-queueing them (don't drain the whole channel). + let mut queued_keys = Vec::new(); + while let Ok(e) = rx.try_recv() { + if let k @ WatchEvent::Key(_) = e { + queued_keys.push(k); + } + } + for k in queued_keys { + let _ = tx_self.send(k); + } run_and_render( root, exercises,