From 306876675e4a41469e3673e7dd749dd8b70ddcbd Mon Sep 17 00:00:00 2001 From: Octavian Tocan Date: Fri, 24 Apr 2026 17:33:20 +0200 Subject: [PATCH 1/2] =?UTF-8?q?step=203:=20config=20=E2=80=94=20file=20I/O?= =?UTF-8?q?=20and=20serialization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust concepts: std::fs, serde deserialization, Default trait, Path/PathBuf Source files: src/core/config.ts (182 lines) This PR is documentation only — no Rust code to review. Read the lesson, implement the task, make the tests pass. --- lessons/03-config-file-io-serde.md | 181 +++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 lessons/03-config-file-io-serde.md diff --git a/lessons/03-config-file-io-serde.md b/lessons/03-config-file-io-serde.md new file mode 100644 index 0000000..234d3aa --- /dev/null +++ b/lessons/03-config-file-io-serde.md @@ -0,0 +1,181 @@ +# Step 3: Config — File I/O and Serialization + +## Architecture Context + +Open `src/core/config.ts` in the [original wt-cli](https://github.com/OctavianTocan/wt-cli/blob/main/src/core/config.ts). + +config.ts is responsible for loading the project's configuration. It does four things: + +1. **Reads** either `wt.config.json` or `package.json#wt` from the project root +2. **Validates** the JSON against a schema (using zod — a runtime type checker) +3. **Applies defaults** for any missing fields (worktreeDir defaults to "tree", staleDays to 30, etc.) +4. **Returns** a `LoadedConfig` with the validated config, the source it came from, and the root path + +This module is the first one that combines process I/O (finding the git root via `getCurrentWorktreeRoot`) with file I/O (reading JSON) and structured parsing (validating + defaults). Every command calls `loadConfig()` as one of its first steps. + +**Why this is step 3:** You've learned structs/enums (step 1) and process spawning (step 2). Now you combine them: read a file from disk, parse it into the types you defined, handle missing files, apply defaults. This is where Rust's `serde` ecosystem shows its power — JSON parsing in Rust is both safer and faster than in TypeScript. + +--- + +## Rust Concepts + +### Concept 1: File I/O with std::fs + +Reading a file in Rust: + +```rust +use std::fs; +use std::path::Path; + +let content = fs::read_to_string("wt.config.json")?; +``` + +That's it. `read_to_string` returns `Result`. The `?` propagates the error if the file doesn't exist or can't be read. + +Checking if a file exists: +```rust +let path = Path::new("wt.config.json"); +if path.exists() { + let content = fs::read_to_string(path)?; +} +``` + +**vs TypeScript:** `fs.readFileSync(path, "utf8")` — similar, but Rust makes the error handling explicit instead of throwing. + +**Common gotcha:** `Path::new("file")` creates a path reference but doesn't check if the file exists. `.exists()` does a syscall. Don't check-then-read (race condition) — just read and handle the error. But for this module, we check because we want to try multiple candidate files. + +### Concept 2: Serde — Serialization and Deserialization + +`serde` is Rust's universal serialization framework. With `#[derive(Deserialize)]` on your types, you can parse JSON directly into them: + +```rust +use serde::Deserialize; + +#[derive(Deserialize)] +struct WtConfig { + worktree_dir: String, + stale_days: u32, +} + +let config: WtConfig = serde_json::from_str(&json_string)?; +``` + +If the JSON doesn't match the struct, you get a clear error: "missing field `worktree_dir`" or "invalid type: expected u32, got string." + +**Defaults with serde:** TypeScript's zod has `.default()`. Serde has `#[serde(default)]`: + +```rust +#[derive(Deserialize)] +struct WtConfig { + #[serde(default = "default_worktree_dir")] + worktree_dir: String, + #[serde(default)] + stale_days: u32, // uses u32::default() = 0 +} + +fn default_worktree_dir() -> String { "tree".to_string() } +``` + +If the field is missing from JSON, serde calls the default function. If you just write `#[serde(default)]`, it uses the type's `Default` trait implementation. + +**Common gotcha:** All fields must be deserializable. If you have `Option`, serde treats missing JSON fields as `None` automatically. If you have `Vec`, you need `#[serde(default)]` or the JSON must include the field. + +--- + +## Your Task + +**File:** `src/config.rs` (create this file) + +**Update `src/types.rs`** — add `#[serde(default)]` annotations to WtConfig fields that have defaults: +- `worktree_dir` defaults to `"tree"` +- `main_branch` defaults to `"main"` +- `default_base` defaults to `"main"` +- `remote` defaults to `"origin"` +- `auto_setup` defaults to `true` +- `stale_days` defaults to `30` +- `setup` defaults to `SetupConfig { steps: vec![] }` +- `lifecycle_scripts` defaults to `LifecycleScripts { postsetup: None, preclean: None }` + +Implement `src/config.rs` with these functions: + +**`pub fn get_default_config() -> WtConfig`** +- Returns a WtConfig with all default values (matching the TypeScript `getDefaultConfig()`) + +**`pub fn load_config(cwd: &str) -> Result`** +- First, find the git root by running `git rev-parse --show-toplevel` (use your `process::run_process`) +- Then try reading `wt.config.json` from that root — if it exists, parse it as WtConfig +- If not found, try reading `package.json` — if it exists, parse it and look for a `"wt"` key +- If neither found, return the default config with source = "defaults" +- On any parse error (malformed JSON), return Err with a descriptive message +- Merge defaults with provided values: start with defaults, then overlay parsed fields + +Add a helper `fn read_json_file(path: &str) -> Result` that reads and parses JSON from a file. + +Update `src/main.rs`: +```rust +mod types; +mod process; +mod config; + +fn main() { + println!("wt-cli — Rust rewrite in progress"); +} +``` + +--- + +## Test Skeleton + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_has_expected_values() { + // Call get_default_config() + // Assert worktree_dir == "tree", stale_days == 30, remote == "origin" + } + + #[test] + fn parse_minimal_json_uses_defaults() { + // Create a JSON string: "{}" + // Parse it into WtConfig with serde_json + // Assert defaults are applied for missing fields + } + + #[test] + fn parse_partial_json_overrides_defaults() { + // Create JSON: {"worktreeDir": "custom-tree", "staleDays": 14} + // Parse into WtConfig + // Assert worktree_dir is "custom-tree" but main_branch is still "main" (default) + } + + #[test] + fn invalid_json_returns_error() { + // Create a string that's not valid JSON: "not json" + // Try to parse it as WtConfig + // Assert the result is Err(...) + } + + #[test] + fn load_config_returns_default_when_no_files() { + // In a temp directory with no config files + // Call load_config(temp_dir) + // Assert source is "defaults" + } + + #[test] + fn load_config_reads_wt_config_json() { + // In a temp directory with a wt.config.json file + // Write {"worktreeDir": "my-trees"} to it + // (This test requires a git repo, so you may need to `git init` in the temp dir) + // Call load_config(temp_dir) + // Assert config.worktree_dir == "my-trees" + } +} +``` + +For temp directory testing, add `tempfile = "3"` to `[dev-dependencies]` in Cargo.toml (it's already there). + +Place the test module at the bottom of `src/config.rs`. Run `cargo test` to check. From b6f9d0eb0714ca411832b1288cad046c5d96382e Mon Sep 17 00:00:00 2001 From: Octavian Tocan Date: Fri, 24 Apr 2026 23:09:48 +0200 Subject: [PATCH 2/2] step 3: rewrite lesson with TS source code and translation walkthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added the full original config.ts and a line-by-line translation walkthrough explaining zod→serde, async readFile→sync std::fs, candidate loop→match-on-Result, and PathBuf for path operations. --- lessons/03-config-file-io-serde.md | 321 +++++++++++++++++++++++++---- 1 file changed, 279 insertions(+), 42 deletions(-) diff --git a/lessons/03-config-file-io-serde.md b/lessons/03-config-file-io-serde.md index 234d3aa..f48c6ac 100644 --- a/lessons/03-config-file-io-serde.md +++ b/lessons/03-config-file-io-serde.md @@ -17,34 +17,250 @@ This module is the first one that combines process I/O (finding the git root via --- -## Rust Concepts +## Original Code (Before) + +```typescript +// src/core/config.ts — the full file (182 lines) + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { z } from "zod"; +import type { LoadedConfig, SetupStep, WtConfig } from "../types.js"; +import { getCurrentWorktreeRoot } from "./git.js"; + +// Zod schemas for validation (this is the TS equivalent of serde) +const installStepSchema = z.object({ + type: z.literal("install"), + command: z.string().optional(), + optional: z.boolean().optional(), +}).strict(); + +const copyStepSchema = z.object({ + type: z.literal("copy"), + from: z.union([z.string(), z.array(z.string()).min(1)]), + to: z.string(), + exclude: z.array(z.string()).optional(), + optional: z.boolean().optional(), +}).strict(); + +// ...more schemas... + +const canonicalConfigSchema = z.object({ + worktreeDir: z.string().default("tree"), + mainBranch: z.string().default("main"), + devBranch: z.string().optional(), + defaultBase: z.string().default("main"), + remote: z.string().default("origin"), + autoSetup: z.boolean().default(true), + staleDays: z.number().int().positive().default(30), + setup: z.object({ steps: z.array(...) }).default({ steps: [] }), + lifecycleScripts: z.object({ ... }).default({}), +}).strict(); + +// Default config factory +export function getDefaultConfig(): WtConfig { + return canonicalConfigSchema.parse({ + setup: { + steps: [ + { type: "install", command: "auto" }, + { type: "copy", from: ".env*", to: ".", exclude: [".env.example"], optional: true }, + { type: "verify", path: "node_modules", label: "Dependencies installed" }, + ], + }, + lifecycleScripts: { postsetup: "wt:postsetup", preclean: "wt:preclean" }, + }); +} + +// Parse with defaults +export function parseConfigData(raw: Partial = {}): WtConfig { + const defaults = getDefaultConfig(); + return canonicalConfigSchema.parse({ + ...defaults, + ...raw, + setup: { ...defaults.setup, ...raw.setup }, + lifecycleScripts: { ...defaults.lifecycleScripts, ...raw.lifecycleScripts }, + }); +} + +// Read JSON from file +async function readJsonFile(path: string): Promise { + const content = await readFile(path, "utf8"); + return JSON.parse(content); +} + +// Main loader +export async function loadConfig(cwd = process.cwd()): Promise { + const rootPath = getCurrentWorktreeRoot(cwd); + const candidates = [ + { source: "wt.config.json", path: join(rootPath, "wt.config.json") }, + { source: "package.json#wt", path: join(rootPath, "package.json") }, + ]; + + for (const candidate of candidates) { + try { + const parsed = await readJsonFile(candidate.path); + if (candidate.source === "package.json#wt") { + const pkg = packageJsonSchema.parse(parsed); + if (pkg.wt) { + return { + config: parseConfigData(pkg.wt as Partial), + source: candidate.source, + rootPath, + }; + } + continue; + } + + return { + config: parseConfigData(parsed as Partial), + source: candidate.source, + rootPath, + }; + } catch (error) { + if (isMissingFileError(error)) continue; + throw new Error(`Failed to load ${candidate.source}: ${message}`); + } + } + + return { config: getDefaultConfig(), source: "defaults", rootPath }; +} +``` + +--- + +## Translation Walkthrough -### Concept 1: File I/O with std::fs +### Mapping 1: Zod schemas → serde annotations -Reading a file in Rust: +**TypeScript (with Zod):** +```typescript +const canonicalConfigSchema = z.object({ + worktreeDir: z.string().default("tree"), + mainBranch: z.string().default("main"), + staleDays: z.number().int().positive().default(30), + autoSetup: z.boolean().default(true), + devBranch: z.string().optional(), +}); +``` +**Rust (with serde):** ```rust -use std::fs; -use std::path::Path; +#[derive(Deserialize)] +pub struct WtConfig { + #[serde(default = "default_worktree_dir")] + pub worktree_dir: String, + #[serde(default = "default_main_branch")] + pub main_branch: String, + #[serde(default = "default_stale_days")] + pub stale_days: u32, + #[serde(default = "default_true")] + pub auto_setup: bool, + #[serde(default)] + pub dev_branch: Option, +} +``` + +What changed and why: +- **Zod → serde.** Both do the same job: parse + validate + apply defaults. Zod is a runtime library. Serde is a compile-time derive macro — it generates the parsing code at build time. +- `z.string().default("tree")` → `#[serde(default = "default_worktree_dir")]`. You write a function that returns the default value. If the JSON field is missing, serde calls it. +- `z.string().optional()` → `Option` with `#[serde(default)]`. `Option` already defaults to `None`, so `#[serde(default)]` is enough — no custom function needed. +- `z.number().int().positive()` → `u32`. Rust's type system enforces "positive integer" at compile time. No runtime validation needed for that constraint. +- You also need to add `#[serde(rename_all = "camelCase")]` on the struct — this tells serde to map `worktree_dir` (Rust) ↔ `worktreeDir` (JSON). Without it, serde looks for snake_case in the JSON. -let content = fs::read_to_string("wt.config.json")?; +### Mapping 2: async readFile → sync std::fs + +**TypeScript:** +```typescript +async function readJsonFile(path: string): Promise { + const content = await readFile(path, "utf8"); + return JSON.parse(content); +} ``` -That's it. `read_to_string` returns `Result`. The `?` propagates the error if the file doesn't exist or can't be read. +**Rust:** +```rust +fn read_json_file(path: &str) -> Result { + let content = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read {}: {}", path, e))?; + serde_json::from_str(&content) + .map_err(|e| format!("Invalid JSON in {}: {}", path, e)) +} +``` + +What changed and why: +- `async/await` → synchronous. The TS version is async because Node's fs.promises are async. In Rust, `std::fs::read_to_string` is sync. For a CLI tool that reads one config file, sync is simpler and fine. (If you needed to read hundreds of files concurrently, you'd use tokio's async fs.) +- `JSON.parse(content)` → `serde_json::from_str(&content)`. Both parse JSON. The difference: `JSON.parse` returns `any` (you hope it's the right shape). `serde_json::from_str` returns a typed `Result` — the compiler verifies the type matches. +- `Promise` → `Result`. We return `serde_json::Value` (equivalent to `unknown`/`any` in JS) because we don't know the shape yet — we'll parse it into `WtConfig` after checking which file we're reading. + +### Mapping 3: loadConfig candidate loop + +**TypeScript:** +```typescript +const candidates = [ + { source: "wt.config.json", path: join(rootPath, "wt.config.json") }, + { source: "package.json#wt", path: join(rootPath, "package.json") }, +]; + +for (const candidate of candidates) { + try { + const parsed = await readJsonFile(candidate.path); + // ...handle package.json vs wt.config.json differently + return { config: parseConfigData(parsed), source: candidate.source, rootPath }; + } catch (error) { + if (isMissingFileError(error)) continue; + throw new Error(`Failed to load ${candidate.source}: ${message}`); + } +} -Checking if a file exists: +return { config: getDefaultConfig(), source: "defaults", rootPath }; +``` + +**Rust:** ```rust -let path = Path::new("wt.config.json"); -if path.exists() { - let content = fs::read_to_string(path)?; +let candidates = vec![ + ("wt.config.json", root_path.join("wt.config.json")), + ("package.json#wt", root_path.join("package.json")), +]; + +for (source, path) in candidates { + match read_json_file(path.to_str().unwrap()) { + Ok(parsed) => { + if source == "package.json#wt" { + if let Some(wt) = parsed.get("wt") { + let config: WtConfig = serde_json::from_value(wt.clone()) + .map_err(|e| format!("Invalid config in package.json#wt: {}", e))?; + return Ok(LoadedConfig { config, source: source.to_string(), root_path }); + } + continue; + } + let config: WtConfig = serde_json::from_value(parsed) + .map_err(|e| format!("Invalid config in {}: {}", source, e))?; + return Ok(LoadedConfig { config, source: source.to_string(), root_path }); + } + Err(e) if e.contains("No such file") => continue, // file doesn't exist, try next + Err(e) => return Err(e), // malformed JSON, fail + } } + +Ok(LoadedConfig { + config: get_default_config(), + source: "defaults".to_string(), + root_path, +}) ``` -**vs TypeScript:** `fs.readFileSync(path, "utf8")` — similar, but Rust makes the error handling explicit instead of throwing. +What changed and why: +- The overall structure is identical: try candidates in order, skip missing files, fail on malformed JSON, fall back to defaults. +- `try/catch` → `match` on the Result. The `Ok/Err` pattern replaces the try-catch. `Err(e) if e.contains(...)` is like a catch-with-condition. +- `join(rootPath, "wt.config.json")` → `root_path.join("wt.config.json")`. PathBuf's `.join()` does the same thing as Node's `path.join()`. +- `parsed.get("wt")` — serde_json::Value has a `.get()` method that works like accessing a JS object property. Returns `Option<&Value>`. +- The Rust version is more verbose because every fallible operation is explicit. But the logic flow is the same. -**Common gotcha:** `Path::new("file")` creates a path reference but doesn't check if the file exists. `.exists()` does a syscall. Don't check-then-read (race condition) — just read and handle the error. But for this module, we check because we want to try multiple candidate files. +--- + +## Rust Concepts -### Concept 2: Serde — Serialization and Deserialization +### Concept 1: Serde — Serialization and Deserialization `serde` is Rust's universal serialization framework. With `#[derive(Deserialize)]` on your types, you can parse JSON directly into them: @@ -62,8 +278,7 @@ let config: WtConfig = serde_json::from_str(&json_string)?; If the JSON doesn't match the struct, you get a clear error: "missing field `worktree_dir`" or "invalid type: expected u32, got string." -**Defaults with serde:** TypeScript's zod has `.default()`. Serde has `#[serde(default)]`: - +**Defaults with serde:** ```rust #[derive(Deserialize)] struct WtConfig { @@ -76,27 +291,61 @@ struct WtConfig { fn default_worktree_dir() -> String { "tree".to_string() } ``` -If the field is missing from JSON, serde calls the default function. If you just write `#[serde(default)]`, it uses the type's `Default` trait implementation. +### Concept 2: Path and PathBuf + +```rust +use std::path::PathBuf; -**Common gotcha:** All fields must be deserializable. If you have `Option`, serde treats missing JSON fields as `None` automatically. If you have `Vec`, you need `#[serde(default)]` or the JSON must include the field. +let root = PathBuf::from("/some/project"); +let config_path = root.join("wt.config.json"); // /some/project/wt.config.json +let path_str = config_path.to_str().unwrap(); // convert to &str for fs operations +``` + +`PathBuf` is like Node's `path.join()` result — an owned, mutable path. `Path` is the borrowed version (like `&str` vs `String`). For this module, `PathBuf` is what you'll use most. --- ## Your Task -**File:** `src/config.rs` (create this file) +**Update `src/types.rs`** — add serde derives and default annotations to WtConfig. You need to add `serde` to the derive list and add `#[serde(default)]` annotations: -**Update `src/types.rs`** — add `#[serde(default)]` annotations to WtConfig fields that have defaults: -- `worktree_dir` defaults to `"tree"` -- `main_branch` defaults to `"main"` -- `default_base` defaults to `"main"` -- `remote` defaults to `"origin"` -- `auto_setup` defaults to `true` -- `stale_days` defaults to `30` -- `setup` defaults to `SetupConfig { steps: vec![] }` -- `lifecycle_scripts` defaults to `LifecycleScripts { postsetup: None, preclean: None }` +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WtConfig { + #[serde(default = "default_tree")] + pub worktree_dir: String, + #[serde(default = "default_main")] + pub main_branch: String, + #[serde(default)] + pub dev_branch: Option, + #[serde(default = "default_main")] + pub default_base: String, + #[serde(default = "default_origin")] + pub remote: String, + #[serde(default = "default_true")] + pub auto_setup: bool, + #[serde(default = "default_stale_days")] + pub stale_days: u32, + #[serde(default)] + pub setup: SetupConfig, + #[serde(default)] + pub lifecycle_scripts: LifecycleScripts, +} +``` + +Add default functions at the top of the file: +```rust +fn default_tree() -> String { "tree".to_string() } +fn default_main() -> String { "main".to_string() } +fn default_origin() -> String { "origin".to_string() } +fn default_true() -> bool { true } +fn default_stale_days() -> u32 { 30 } +``` -Implement `src/config.rs` with these functions: +Also add `Serialize, Deserialize` to the derives on `SetupStep`, `CopySource`, `LifecycleScripts`, and `SetupConfig`. + +**Create `src/config.rs`** with these functions: **`pub fn get_default_config() -> WtConfig`** - Returns a WtConfig with all default values (matching the TypeScript `getDefaultConfig()`) @@ -107,9 +356,8 @@ Implement `src/config.rs` with these functions: - If not found, try reading `package.json` — if it exists, parse it and look for a `"wt"` key - If neither found, return the default config with source = "defaults" - On any parse error (malformed JSON), return Err with a descriptive message -- Merge defaults with provided values: start with defaults, then overlay parsed fields -Add a helper `fn read_json_file(path: &str) -> Result` that reads and parses JSON from a file. +Add a helper `fn read_json_file(path: &str) -> Result`. Update `src/main.rs`: ```rust @@ -164,18 +412,7 @@ mod tests { // Call load_config(temp_dir) // Assert source is "defaults" } - - #[test] - fn load_config_reads_wt_config_json() { - // In a temp directory with a wt.config.json file - // Write {"worktreeDir": "my-trees"} to it - // (This test requires a git repo, so you may need to `git init` in the temp dir) - // Call load_config(temp_dir) - // Assert config.worktree_dir == "my-trees" - } } ``` -For temp directory testing, add `tempfile = "3"` to `[dev-dependencies]` in Cargo.toml (it's already there). - Place the test module at the bottom of `src/config.rs`. Run `cargo test` to check.