From b78c3484e99410e4070c176f8b16e4c75aecb7ef Mon Sep 17 00:00:00 2001 From: Bob Belderbos Date: Sat, 14 Feb 2026 09:24:49 +0100 Subject: [PATCH 1/6] feat: add API key support to exercise downloader Read PYBITES_API_KEY env var and send X-API-Key header when set. Without it, only free exercises are downloaded. Co-Authored-By: Claude Opus 4.6 --- exercise_downloader/src/main.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/exercise_downloader/src/main.rs b/exercise_downloader/src/main.rs index 68ebbe4..ef5ab33 100644 --- a/exercise_downloader/src/main.rs +++ b/exercise_downloader/src/main.rs @@ -148,9 +148,19 @@ fn main() -> Result<(), Box> { // define the base_path (current directory / exercises) let base_path = env::current_dir().unwrap().join("exercises"); - print!("Downloading the exercises from Pybites Rust (rustplatform.com)"); + let api_key = env::var("PYBITES_API_KEY").ok(); + let client = reqwest::blocking::Client::new(); - let response = client.get("https://rustplatform.com/api/").send()?; + let mut request = client.get("https://rustplatform.com/api/"); + if let Some(ref key) = api_key { + println!("Authenticating with API key"); + request = request.header("X-API-Key", key); + } else { + println!("No API key set (PYBITES_API_KEY), downloading free exercises only"); + } + + print!("Downloading the exercises from Pybites Rust (rustplatform.com)"); + let response = request.send()?; println!(" ✅"); println!( "'exercises' will be created in the current directory ({})", From d5c9ea35256b61037da39a2bfdb7a9e5bb70e1ac Mon Sep 17 00:00:00 2001 From: Bob Belderbos Date: Sat, 14 Feb 2026 09:24:53 +0100 Subject: [PATCH 2/6] docs: add API key instructions to README Explain how to set PYBITES_API_KEY for downloading all premium exercises. Co-Authored-By: Claude Opus 4.6 --- README.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8296425..363ac4d 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,19 @@ Exercise downloader for https://rustplatform.com/ cargo install --git https://github.com/markgreene74/pybites_rust.git ``` - `cd` to the directory where you want to save the exercises -- run the downloader +- run the downloader (free exercises only): ```shell pybites-rust-download ``` +- to download **all** exercises (requires premium), set your API key: + ```shell + PYBITES_API_KEY=your-api-key-here pybites-rust-download + ``` + Or export it in your shell profile so you don't have to pass it every time: + ```shell + export PYBITES_API_KEY=your-api-key-here + ``` + You can find your API key on your [profile page](https://rustplatform.com/profile/). ### Compile it manually From 63e02fc68d54745004e1d9c8396a30775501b465 Mon Sep 17 00:00:00 2001 From: Bob Belderbos Date: Sat, 14 Feb 2026 09:32:31 +0100 Subject: [PATCH 3/6] refactor: extract write_all_exercises and auth_status_message for testability Pull the exercise-writing loop and auth status message out of main() into standalone functions that can be unit tested. Co-Authored-By: Claude Opus 4.6 --- exercise_downloader/src/main.rs | 371 +++++++++++++++++++++++++++++--- 1 file changed, 336 insertions(+), 35 deletions(-) diff --git a/exercise_downloader/src/main.rs b/exercise_downloader/src/main.rs index ef5ab33..42ef9a5 100644 --- a/exercise_downloader/src/main.rs +++ b/exercise_downloader/src/main.rs @@ -9,7 +9,7 @@ use std::io::Write; use std::path::Path; use std::time::SystemTime; -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, Clone)] struct Bite { name: String, slug: String, @@ -144,20 +144,56 @@ package_description\n" Ok(()) } +fn auth_status_message(api_key: &Option) -> &'static str { + if api_key.is_some() { + "Authenticating with API key" + } else { + "No API key set (PYBITES_API_KEY), downloading free exercises only" + } +} + +fn build_request( + client: &reqwest::blocking::Client, + url: &str, + api_key: Option<&str>, +) -> reqwest::blocking::RequestBuilder { + let mut request = client.get(url); + if let Some(key) = api_key { + request = request.header("X-API-Key", key); + } + request +} + +fn write_all_exercises(base_path: &Path, bites: &[Bite]) -> std::io::Result<()> { + fs::create_dir_all(base_path)?; + + for bite in bites { + let exercise_path = base_path.join(&bite.level).join(&bite.slug); + fs::create_dir_all(&exercise_path)?; + write_toml(&exercise_path, &bite.slug, &bite.libraries)?; + write_markdown( + &exercise_path, + &bite.name, + &bite.description, + &bite.level, + &bite.author, + )?; + write_exercise(&exercise_path, &bite.template)?; + } + + write_root_toml(base_path, bites)?; + write_root_readme(base_path, bites)?; + Ok(()) +} + fn main() -> Result<(), Box> { - // define the base_path (current directory / exercises) let base_path = env::current_dir().unwrap().join("exercises"); let api_key = env::var("PYBITES_API_KEY").ok(); let client = reqwest::blocking::Client::new(); - let mut request = client.get("https://rustplatform.com/api/"); - if let Some(ref key) = api_key { - println!("Authenticating with API key"); - request = request.header("X-API-Key", key); - } else { - println!("No API key set (PYBITES_API_KEY), downloading free exercises only"); - } + let request = build_request(&client, "https://rustplatform.com/api/", api_key.as_deref()); + println!("{}", auth_status_message(&api_key)); print!("Downloading the exercises from Pybites Rust (rustplatform.com)"); let response = request.send()?; @@ -167,46 +203,311 @@ fn main() -> Result<(), Box> { base_path.display() ); - // collect the arguments let args: Vec = env::args().collect(); - // just testing, print out the status and headers and exit if args.contains(&String::from("--test")) { println!("Status: {}", response.status()); println!("Headers:\n{:#?}", response.headers()); return Ok(()); } - // extract the exercises from the response let bites: Vec = response.json()?; println!("{:#?} exercises found!", bites.len()); println!(); - // make sure the base path (exercises) exists - fs::create_dir_all(&base_path)?; + write_all_exercises(&base_path, &bites)?; for bite in &bites { - print!("{:#?}", bite.name); - let slug = &bite.slug; - let exercise_path = &base_path.join(&bite.level).join(slug); - - // make sure the exercise directory exists - fs::create_dir_all(exercise_path)?; - // re-write/update the toml and md files but make a backup copy of the - // exercise file if it exists, in case it was already solved - write_toml(exercise_path, slug, &bite.libraries)?; - write_markdown( - exercise_path, - &bite.name, - &bite.description, - &bite.level, - &bite.author, - )?; - write_exercise(exercise_path, &bite.template)?; - println!(" ✅"); + println!("{:#?} ✅", bite.name); } - write_root_toml(&base_path, &bites)?; - write_root_readme(&base_path, &bites)?; - Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn sample_bite(name: &str, slug: &str, level: &str) -> Bite { + Bite { + name: name.to_string(), + slug: slug.to_string(), + description: "A test exercise".to_string(), + level: level.to_string(), + template: "fn main() {}".to_string(), + libraries: "serde = \"1.0\"\n".to_string(), + author: "testauthor".to_string(), + } + } + + #[test] + fn test_bite_deserialize() { + let json = r#"{ + "name": "Hello", + "slug": "hello", + "description": "desc", + "level": "intro", + "template": "fn main() {}", + "libraries": "", + "author": "bob" + }"#; + let bite: Bite = serde_json::from_str(json).unwrap(); + assert_eq!(bite.name, "Hello"); + assert_eq!(bite.slug, "hello"); + assert_eq!(bite.level, "intro"); + } + + #[test] + fn test_bite_deserialize_list() { + let json = r#"[ + {"name":"A","slug":"a","description":"d","level":"intro","template":"t","libraries":"","author":"x"}, + {"name":"B","slug":"b","description":"d","level":"easy","template":"t","libraries":"","author":"x"} + ]"#; + let bites: Vec = serde_json::from_str(json).unwrap(); + assert_eq!(bites.len(), 2); + assert_eq!(bites[0].slug, "a"); + assert_eq!(bites[1].slug, "b"); + } + + #[test] + fn test_write_toml() { + let dir = TempDir::new().unwrap(); + let libs = "serde = \"1.0\"\n".to_string(); + write_toml(dir.path(), "my-exercise", &libs).unwrap(); + + let content = fs::read_to_string(dir.path().join("Cargo.toml")).unwrap(); + assert!(content.contains("name = \"my-exercise\"")); + assert!(content.contains("edition = \"2024\"")); + assert!(content.contains("[dependencies]")); + assert!(content.contains("serde = \"1.0\"")); + } + + #[test] + fn test_write_toml_empty_libraries() { + let dir = TempDir::new().unwrap(); + let libs = String::new(); + write_toml(dir.path(), "bare-exercise", &libs).unwrap(); + + let content = fs::read_to_string(dir.path().join("Cargo.toml")).unwrap(); + assert!(content.contains("name = \"bare-exercise\"")); + assert!(content.contains("[dependencies]")); + } + + #[test] + fn test_write_exercise_creates_src_dir_and_lib() { + let dir = TempDir::new().unwrap(); + let template = "fn main() { println!(\"hello\"); }".to_string(); + write_exercise(dir.path(), &template).unwrap(); + + let lib_path = dir.path().join("src").join("lib.rs"); + assert!(lib_path.exists()); + let content = fs::read_to_string(lib_path).unwrap(); + assert_eq!(content, template); + } + + #[test] + fn test_write_exercise_backs_up_existing() { + let dir = TempDir::new().unwrap(); + let original = "fn original() {}".to_string(); + let updated = "fn updated() {}".to_string(); + + write_exercise(dir.path(), &original).unwrap(); + write_exercise(dir.path(), &updated).unwrap(); + + let lib_content = fs::read_to_string(dir.path().join("src").join("lib.rs")).unwrap(); + assert_eq!(lib_content, updated); + + let backup_files: Vec<_> = fs::read_dir(dir.path().join("src")) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().to_string_lossy().contains("lib.rs.")) + .collect(); + assert_eq!(backup_files.len(), 1); + + let backup_content = fs::read_to_string(backup_files[0].path()).unwrap(); + assert_eq!(backup_content, original); + } + + #[test] + fn test_write_markdown() { + let dir = TempDir::new().unwrap(); + write_markdown(dir.path(), "Test Bite", "Do the thing", "easy", "bob").unwrap(); + + let content = fs::read_to_string(dir.path().join("bite.md")).unwrap(); + assert!(content.contains("# Test Bite")); + assert!(content.contains("Level: easy")); + assert!(content.contains("Author: bob")); + assert!(content.contains("Do the thing")); + } + + #[test] + fn test_write_root_toml() { + let dir = TempDir::new().unwrap(); + let bites = vec![ + sample_bite("Hello", "hello", "intro"), + sample_bite("Advanced", "advanced", "medium"), + ]; + write_root_toml(dir.path(), &bites).unwrap(); + + let content = fs::read_to_string(dir.path().join("Cargo.toml")).unwrap(); + assert!(content.contains("[workspace]")); + assert!(content.contains("resolver = \"3\"")); + assert!(content.contains("\"intro/hello\"")); + assert!(content.contains("\"medium/advanced\"")); + } + + #[test] + fn test_write_root_toml_empty() { + let dir = TempDir::new().unwrap(); + let bites: Vec = vec![]; + write_root_toml(dir.path(), &bites).unwrap(); + + let content = fs::read_to_string(dir.path().join("Cargo.toml")).unwrap(); + assert!(content.contains("[workspace]")); + assert!(content.contains("members = [")); + } + + #[test] + fn test_write_root_readme() { + let dir = TempDir::new().unwrap(); + let bites = vec![ + sample_bite("Hello", "hello", "intro"), + sample_bite("Strings", "strings", "easy"), + ]; + write_root_readme(dir.path(), &bites).unwrap(); + + let content = fs::read_to_string(dir.path().join("README.md")).unwrap(); + assert!(content.contains("# Pybites Rust")); + assert!(content.contains("### Level: intro")); + assert!(content.contains("### Level: easy")); + assert!(content.contains("[intro/hello](intro/hello/bite.md)")); + assert!(content.contains("[easy/strings](easy/strings/bite.md)")); + } + + #[test] + fn test_write_root_readme_skips_unlisted_levels() { + let dir = TempDir::new().unwrap(); + let bites = vec![sample_bite("Hard One", "hard-one", "hard")]; + write_root_readme(dir.path(), &bites).unwrap(); + + let content = fs::read_to_string(dir.path().join("README.md")).unwrap(); + assert!(!content.contains("hard-one")); + } + + #[test] + fn test_auth_status_message_with_key() { + let key = Some("abc-123".to_string()); + assert_eq!(auth_status_message(&key), "Authenticating with API key"); + } + + #[test] + fn test_auth_status_message_without_key() { + let key: Option = None; + assert!(auth_status_message(&key).contains("No API key set")); + } + + #[test] + fn test_build_request_without_api_key() { + let client = reqwest::blocking::Client::new(); + let request = build_request(&client, "https://example.com/api/", None); + let built = request.build().unwrap(); + assert!(built.headers().get("X-API-Key").is_none()); + } + + #[test] + fn test_build_request_with_api_key() { + let client = reqwest::blocking::Client::new(); + let request = build_request(&client, "https://example.com/api/", Some("test-key-123")); + let built = request.build().unwrap(); + assert_eq!( + built.headers().get("X-API-Key").unwrap().to_str().unwrap(), + "test-key-123" + ); + } + + #[test] + fn test_build_request_url() { + let client = reqwest::blocking::Client::new(); + let request = build_request(&client, "https://example.com/api/", None); + let built = request.build().unwrap(); + assert_eq!(built.url().as_str(), "https://example.com/api/"); + } + + #[test] + fn test_build_request_is_get() { + let client = reqwest::blocking::Client::new(); + let request = build_request(&client, "https://example.com/api/", None); + let built = request.build().unwrap(); + assert_eq!(built.method(), reqwest::Method::GET); + } + + #[test] + fn test_write_all_exercises() { + let dir = TempDir::new().unwrap(); + let base = dir.path().join("exercises"); + let bites = vec![ + sample_bite("Hello", "hello", "intro"), + sample_bite("Strings", "strings", "easy"), + ]; + write_all_exercises(&base, &bites).unwrap(); + + assert!(base.join("intro").join("hello").join("Cargo.toml").exists()); + assert!(base.join("intro").join("hello").join("bite.md").exists()); + assert!( + base.join("intro") + .join("hello") + .join("src") + .join("lib.rs") + .exists() + ); + assert!( + base.join("easy") + .join("strings") + .join("Cargo.toml") + .exists() + ); + assert!(base.join("Cargo.toml").exists()); + assert!(base.join("README.md").exists()); + + let root_toml = fs::read_to_string(base.join("Cargo.toml")).unwrap(); + assert!(root_toml.contains("\"intro/hello\"")); + assert!(root_toml.contains("\"easy/strings\"")); + } + + #[test] + fn test_write_all_exercises_empty() { + let dir = TempDir::new().unwrap(); + let base = dir.path().join("exercises"); + let bites: Vec = vec![]; + write_all_exercises(&base, &bites).unwrap(); + + assert!(base.join("Cargo.toml").exists()); + assert!(base.join("README.md").exists()); + } + + #[test] + fn test_write_all_exercises_preserves_existing_work() { + let dir = TempDir::new().unwrap(); + let base = dir.path().join("exercises"); + let bites = vec![sample_bite("Hello", "hello", "intro")]; + + write_all_exercises(&base, &bites).unwrap(); + + let lib_path = base.join("intro").join("hello").join("src").join("lib.rs"); + fs::write(&lib_path, "fn my_solution() {}").unwrap(); + + write_all_exercises(&base, &bites).unwrap(); + + let lib_content = fs::read_to_string(&lib_path).unwrap(); + assert_eq!(lib_content, "fn main() {}"); + + let backup_files: Vec<_> = fs::read_dir(base.join("intro").join("hello").join("src")) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().to_string_lossy().contains("lib.rs.")) + .collect(); + assert_eq!(backup_files.len(), 1); + let backup_content = fs::read_to_string(backup_files[0].path()).unwrap(); + assert_eq!(backup_content, "fn my_solution() {}"); + } +} From 874564a659d2b17a35b339fb705377e9a96b102c Mon Sep 17 00:00:00 2001 From: Bob Belderbos Date: Sat, 14 Feb 2026 09:32:35 +0100 Subject: [PATCH 4/6] test: add tempfile and serde_json dev dependencies for unit tests Co-Authored-By: Claude Opus 4.6 --- exercise_downloader/Cargo.lock | 26 +++++++++++++++++--------- exercise_downloader/Cargo.toml | 4 ++++ 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/exercise_downloader/Cargo.lock b/exercise_downloader/Cargo.lock index 25cbe1c..b8fc44d 100644 --- a/exercise_downloader/Cargo.lock +++ b/exercise_downloader/Cargo.lock @@ -558,9 +558,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.175" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "linux-raw-sys" @@ -736,6 +736,8 @@ version = "0.1.3" dependencies = [ "reqwest", "serde", + "serde_json", + "tempfile", ] [[package]] @@ -817,9 +819,9 @@ checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags", "errno", @@ -937,15 +939,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -1054,9 +1056,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.22.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", "getrandom 0.3.3", @@ -1573,3 +1575,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/exercise_downloader/Cargo.toml b/exercise_downloader/Cargo.toml index ab7f562..8764638 100644 --- a/exercise_downloader/Cargo.toml +++ b/exercise_downloader/Cargo.toml @@ -10,3 +10,7 @@ serde = { version = "1.0.227", features = ["derive"] } [[bin]] name = "pybites-rust-download" path = "src/main.rs" + +[dev-dependencies] +serde_json = "1.0.149" +tempfile = "3.25.0" From 3ca384e104b15e8600ca17452e952dbd941e0103 Mon Sep 17 00:00:00 2001 From: Bob Belderbos Date: Sat, 14 Feb 2026 09:43:04 +0100 Subject: [PATCH 5/6] update gitignore with default exercises dir created running it locally --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2b8b986..743fa07 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ target ### custom section ### notes/ +exercises From 9d9a2939c7645fae8df6b92a4263b207a3f14791 Mon Sep 17 00:00:00 2001 From: Bob Belderbos Date: Sat, 14 Feb 2026 16:34:46 +0100 Subject: [PATCH 6/6] refactor: restore essential comments and move println into write_all_exercises Restore two comments removed during refactoring that explain *why* (--test flag purpose, backup rationale for solved exercises). Move per-exercise println into write_all_exercises for consistency. Co-Authored-By: Claude Opus 4.6 --- exercise_downloader/src/main.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/exercise_downloader/src/main.rs b/exercise_downloader/src/main.rs index 42ef9a5..d943165 100644 --- a/exercise_downloader/src/main.rs +++ b/exercise_downloader/src/main.rs @@ -103,7 +103,7 @@ fn write_exercise(path: &Path, template: &String) -> std::io::Result<()> { let filename = src_dir.join("lib.rs"); if fs::exists(&filename)? { - // backup the original lib.rs (exercise file) by adding a UNIX_EPOCH timestamp after .rs + // backup the exercise file in case it was already solved let now = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .unwrap() @@ -179,6 +179,7 @@ fn write_all_exercises(base_path: &Path, bites: &[Bite]) -> std::io::Result<()> &bite.author, )?; write_exercise(&exercise_path, &bite.template)?; + println!("{:#?} ✅", bite.name); } write_root_toml(base_path, bites)?; @@ -203,6 +204,7 @@ fn main() -> Result<(), Box> { base_path.display() ); + // just testing, print out the status and headers and exit let args: Vec = env::args().collect(); if args.contains(&String::from("--test")) { println!("Status: {}", response.status()); @@ -216,10 +218,6 @@ fn main() -> Result<(), Box> { write_all_exercises(&base_path, &bites)?; - for bite in &bites { - println!("{:#?} ✅", bite.name); - } - Ok(()) }