diff --git a/CLAUDE.md b/CLAUDE.md index ec8e628..2e65743 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,26 +16,42 @@ cargo clippy # lint `tour` is a CLI tool for creating and navigating code tutorials as a series of snapshots. Authors create tours by committing file snapshots with explanations; readers step through them with `next`/`prev`. -**Author workflow:** `tour init` → `tour commit -m ` (repeat) → `tour end -m ` +**Author workflow:** `tour init` → `tour add ` → `tour commit -m ` (repeat) → `tour end -m ` -**Reader workflow:** `tour start` → `tour next [n]` / `tour prev [n]` +**Reader workflow:** `tour start` → `tour next [n]` / `tour prev [n]` / `tour step ` / `tour reset` ## Architecture Entry point is `main.rs`, which uses clap's derive macro to parse subcommands and dispatch to per-command modules. **On-disk format** (stored in `.tour/` in the project being toured): -- `.tour/steps//` — one numbered directory per step +- `.tour/steps//` — one numbered directory per step (each step is a complete file snapshot) +- `.tour/steps//message` — the commit message for that step - `.tour/session` — tracks current reader position as `STEP=` +- `.tour/staged` — list of files staged for the next commit +- `.tour/info` — tour metadata (author, description, language, dates) +- `.tour/removed` — list of files marked for removal in the next commit +- `.tour/ended` — marker file indicating the tour is finalized **Module layout:** -- `init.rs` — creates `.tour/steps/` and `.tour/session` -- `commit.rs` — validates files, then saves them as a new numbered step -- `end.rs` — finalizes the tour -- `next.rs` / `prev.rs` — advance/retreat the session step -- `utils.rs` — shared helpers: `copy_files`, `get_session_step`, `get_tour_step`, path validation -- `error.rs` — custom error types (currently `CommitError`) +- `init.rs` — creates `.tour/` structure, collects tour metadata, updates `.gitignore` +- `add.rs` — stages files for the next commit; `get_staged()` reads the staged file list +- `unstage.rs` — removes files from staging +- `commit.rs` — commits staged files as a new step with carry-forward from previous step; only clears staging when staging was used +- `rm.rs` — marks files for removal in the next commit (skipped during carry-forward) +- `end.rs` — finalizes the tour (writes `.tour/ended` marker) +- `step.rs` — navigation: `next`, `prev`, `step_n`; handles file replacement, diff display, binary detection +- `reset.rs` — resets tour session and removes tracked files from working directory +- `status.rs` — shows current step position and staged files +- `list.rs` — lists all steps with their messages +- `info.rs` — tour metadata (author, description, language, dates) +- `utils.rs` — shared helpers: `require_tour`, `get_current_step`, `get_tour_step`, `copy_tree`, path validation +- `error.rs` — unified `TourError` enum with `Display` impl for user-facing messages; includes `CorruptedTour` for integrity checks -Constants `TOUR_DIR` and `SESSION_PATH` are defined in `main.rs` and imported via `crate::`. +**Key design decisions:** +- Each step is a **complete snapshot** — `commit.rs` carries forward files from the previous step before overlaying new ones +- `TourError` is the unified error type across all modules, with `From` for automatic conversion +- `main()` catches errors and prints them with `Display` format (not `Debug`) for user-friendly messages +- Constants `TOUR_DIR` and `SESSION_PATH` are defined in `main.rs` and imported via `crate::` -**Status:** Early development. `next`, `prev`, and `end` are stubbed. `commit` validates paths but hasn't yet written the step to disk. `utils::get_tour_step` has a dead `Ok(0)` after a `match` expression (unreachable code). +**Testing:** Integration tests in `tests/integration.rs` use `tempfile` crate to create isolated tour directories and test via `Command`-based process spawning. diff --git a/Cargo.lock b/Cargo.lock index d366974..a6c7de7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,24 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.5.59" @@ -98,24 +116,150 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[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 = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -134,6 +278,73 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[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", +] + +[[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.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "strsim" version = "0.11.1" @@ -151,11 +362,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "tour" version = "0.1.0" dependencies = [ "clap", + "tempfile", ] [[package]] @@ -164,12 +389,70 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -184,3 +467,97 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[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 4bb35ec..58cc095 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,3 +5,6 @@ edition = "2024" [dependencies] clap = { version = "4.5.59", features = ["derive"] } + +[dev-dependencies] +tempfile = "3.26.0" diff --git a/readme.md b/readme.md index 6e1ebc0..16890ad 100644 --- a/readme.md +++ b/readme.md @@ -1,72 +1,101 @@ -# Goals +# tour -The purpose of this command will be to walk through code tutorials +A CLI tool for creating and navigating code tutorials as a series of file snapshots. -It will be essentially the same as git, however without branches. +## Install -Through a series of commits, you can walk through code changes and see what has changed (like git diff) - -It will work as such - -```` ```sh -# The format of this is: +cargo install --path . +``` -command args -# -> -# output -# output line 2 +## Quick Start -# CREATING A TOUR -tour init -m "After running *cargo init* we get our template" +### Creating a tour + +```sh +# Initialize a new tour in the current directory +tour init + +# Stage and commit files as steps +tour add src/main.rs Cargo.toml +tour commit -m "After running cargo init we get our template" + +# Or commit with inline file arguments tour commit src/lib.rs -m "In lib.rs we add some functions" -tour commit src/main.rs -m "We import our newly made function from lib.rs" -tour end -m "Now your tour is complete and you can use rust modules!" +tour commit src/main.rs -m "We import our newly made function from lib.rs" + +# Finalize the tour +tour end -m "Now your tour is complete and you can use rust modules!" +``` + +### Taking a tour + +```sh tour start -# -> -# New files: -# ./cargo.lock -# ./cargo.toml -# src/main.rs +# new: Cargo.toml +# new: src/main.rs # -# Explanation -# After running *cargo init* we get our template +# Step 1/3: After running cargo init we get our template tour next -# -> -# New files: -# src/lib.rs -# Explanation -# In lib.rs we add some functions +# new: src/lib.rs +# +# Step 2/3: In lib.rs we add some functions tour prev -# -> -# New files: -# ./cargo.lock -# ./cargo.toml -# src/main.rs +# deleted: src/lib.rs # -# Explanation -# After running *cargo init* we get our template +# Step 1/3: After running cargo init we get our template tour next 2 -# -> -# No new files. +# new: src/lib.rs +# modified: src/main.rs +# + use lib::my_function; +# +# Step 3/3: We import our newly made function from lib.rs -# Changes: -# src/main.rs - -# Explanation -# We import our newly made function from lib.rs - -tour next -# -> -# Tutorial Finished -# Explanation -# Now your tour is complete and you can use rust modules! - -# EXTRAS: -tour author -> Add information about the author if there are questions +tour step 1 # jump to any step by number ``` -```` + +## Commands + +### Author workflow + +| Command | Description | +|---------|-------------| +| `tour init` | Set up a new tour in the current directory | +| `tour add ` | Stage files for the next commit | +| `tour unstage ` | Remove files from staging | +| `tour commit -m ` | Commit staged files as a new step | +| `tour commit -m ` | Stage and commit files in one step | +| `tour end -m ` | Finalize the tour | + +### Reader workflow + +| Command | Description | +|---------|-------------| +| `tour start` | Load the first step | +| `tour next [n]` | Advance n steps (default 1) | +| `tour prev [n]` | Go back n steps (default 1) | +| `tour step ` | Jump to step n | + +### Other + +| Command | Description | +|---------|-------------| +| `tour info` | Show tour metadata | +| `tour status` | Show current step and staged files | +| `tour list` | List all steps with messages | +| `tour help` | Show help message | + +## How it works + +Each step is stored as a complete file snapshot in `.tour/steps//`. When navigating between steps, `tour` replaces tracked files in your working directory and shows a diff of what changed — new files, modified files (with line-level diffs), and deleted files. + +The `.tour/` directory contains: +- `steps/` — numbered directories, each holding the full file state for that step plus a `message` file +- `session` — tracks the reader's current position +- `info` — tour metadata (author, description, language, dates) +- `staged` — files staged for the next commit +- `ended` — marker indicating the tour is finalized diff --git a/src/add.rs b/src/add.rs index d99fea2..d25b881 100644 --- a/src/add.rs +++ b/src/add.rs @@ -1,5 +1,5 @@ -use crate::error::CommitError; -use crate::utils::{is_descendant_of_current_dir, is_file_in_dir}; +use crate::error::TourError; +use crate::utils::{is_descendant_of_current_dir, is_file_in_dir, require_tour}; use crate::TOUR_DIR; use std::fs::{self, OpenOptions}; use std::io::Write; @@ -7,26 +7,37 @@ use std::path::{Path, PathBuf}; pub const STAGED_PATH: &str = "./.tour/staged"; -pub fn add(files: Vec) -> Result<(), CommitError> { +pub fn add(files: Vec) -> Result<(), TourError> { + require_tour()?; let tour_dir = Path::new(TOUR_DIR); for file in &files { + if !file.exists() { + return Err(TourError::FileNotFound(file.clone())); + } if !is_descendant_of_current_dir(file)? { - return Err(CommitError::NotADescendantOfCurrentDir(file.clone())); + return Err(TourError::NotADescendant(file.clone())); } if is_file_in_dir(file, tour_dir)? { - return Err(CommitError::InsideTourDir(file.clone())); + return Err(TourError::InsideTourDir(file.clone())); } } + let existing = get_staged()?; + let existing_set: std::collections::HashSet = existing.into_iter().collect(); + let mut staged = OpenOptions::new() .append(true) .create(true) .open(STAGED_PATH)?; for file in &files { - writeln!(staged, "{}", file.display())?; - println!("staged: {}", file.display()); + if existing_set.contains(file) { + println!("already staged: {}", file.display()); + } else { + writeln!(staged, "{}", file.display())?; + println!("staged: {}", file.display()); + } } Ok(()) diff --git a/src/commit.rs b/src/commit.rs index aebcc4e..ce643e8 100644 --- a/src/commit.rs +++ b/src/commit.rs @@ -1,17 +1,25 @@ use crate::add::{clear_staged, get_staged}; -use crate::error::CommitError; -use crate::utils::{copy_path, is_descendant_of_current_dir, is_file_in_dir}; +use crate::error::TourError; +use crate::rm::{clear_removed, get_removed}; +use crate::utils::{copy_path, get_tour_step, is_descendant_of_current_dir, is_file_in_dir, require_tour}; use crate::TOUR_DIR; +use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; -pub fn commit(files: Vec, message: String) -> Result<(), CommitError> { +pub fn commit(files: Vec, message: String) -> Result<(), TourError> { + require_tour()?; let tour_dir = Path::new(TOUR_DIR); - let files = if files.is_empty() { + if tour_dir.join("ended").exists() { + return Err(TourError::TourEnded); + } + + let used_staging = files.is_empty(); + let files = if used_staging { let staged = get_staged()?; if staged.is_empty() { - return Err(CommitError::NothingToCommit); + return Err(TourError::NothingToCommit); } staged } else { @@ -19,31 +27,83 @@ pub fn commit(files: Vec, message: String) -> Result<(), CommitError> { }; for file in &files { + if !file.exists() { + return Err(TourError::FileNotFound(file.clone())); + } if !is_descendant_of_current_dir(file)? { - return Err(CommitError::NotADescendantOfCurrentDir(file.clone())); + return Err(TourError::NotADescendant(file.clone())); } if is_file_in_dir(file, tour_dir)? { - return Err(CommitError::InsideTourDir(file.clone())); + return Err(TourError::InsideTourDir(file.clone())); } } - let steps_dir = tour_dir.join("steps"); - let step_num = fs::read_dir(&steps_dir)? - .filter_map(|e| e.ok()) - .filter(|e| e.path().is_dir()) - .count(); + let removed = get_removed()?; + let removed_set: HashSet = removed.into_iter().collect(); + let step_num = get_tour_step()? as usize; + let steps_dir = tour_dir.join("steps"); let step_dir = steps_dir.join(step_num.to_string()); fs::create_dir_all(&step_dir)?; + // Carry forward files from previous step (excluding removed files) + if step_num > 0 { + let prev_dir = steps_dir.join((step_num - 1).to_string()); + if prev_dir.exists() { + carry_forward(&prev_dir, &prev_dir, &step_dir, &removed_set)?; + } + } + + // Overlay new files (these take precedence over carried-forward ones) for file in &files { copy_path(file, &step_dir)?; } fs::write(step_dir.join("message"), &message)?; - clear_staged()?; + + // Only clear staging if we used it + if used_staging { + clear_staged()?; + } + clear_removed()?; crate::info::update_last_modified()?; - println!("Step {}: {}", step_num, message); + println!("Step {}: {}", step_num + 1, message); + Ok(()) +} + +fn carry_forward( + step_root: &Path, + src: &Path, + dest_root: &Path, + removed: &HashSet, +) -> Result<(), std::io::Error> { + for entry in fs::read_dir(src)? { + let entry = entry?; + let relative = entry + .path() + .strip_prefix(step_root) + .unwrap_or(&entry.path()) + .to_path_buf(); + + if relative == Path::new("message") { + continue; + } + + if removed.contains(&relative) { + continue; + } + + let dest = dest_root.join(&relative); + if entry.path().is_dir() { + fs::create_dir_all(&dest)?; + carry_forward(step_root, &entry.path(), dest_root, removed)?; + } else { + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(entry.path(), &dest)?; + } + } Ok(()) } diff --git a/src/end.rs b/src/end.rs index 08c5d9e..73a78ea 100644 --- a/src/end.rs +++ b/src/end.rs @@ -1,3 +1,25 @@ -pub fn end(message: String) -> Result<(), std::io::Error> { +use crate::error::TourError; +use crate::utils::{get_tour_step, require_tour}; +use crate::TOUR_DIR; +use std::fs; +use std::path::Path; + +pub fn end(message: String) -> Result<(), TourError> { + require_tour()?; + + let end_marker = Path::new(TOUR_DIR).join("ended"); + if end_marker.exists() { + return Err(TourError::TourEnded); + } + + let step_count = get_tour_step()?; + if step_count == 0 { + return Err(TourError::NoSteps); + } + + fs::write(&end_marker, &message)?; + crate::info::update_last_modified()?; + + println!("Tour ended with {} steps: {}", step_count, message); Ok(()) } diff --git a/src/error.rs b/src/error.rs index ae3405e..f85b2aa 100644 --- a/src/error.rs +++ b/src/error.rs @@ -2,33 +2,53 @@ use std::io; use std::path::PathBuf; #[derive(Debug)] -pub enum CommitError { - NotADescendantOfCurrentDir(PathBuf), - InsideTourDir(PathBuf), +pub enum TourError { + NoTour, + TourAlreadyExists, + TourEnded, NothingToCommit, + NoSteps, + NotADescendant(PathBuf), + InsideTourDir(PathBuf), + FileNotFound(PathBuf), + StepOutOfRange { step: u32, total: u32 }, + CorruptedTour(String), Io(io::Error), } -impl std::fmt::Display for CommitError { +impl std::fmt::Display for TourError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::NotADescendantOfCurrentDir(path) => { - write!(f, "File {:?} is not a descendant of the working directory.", path) - } - Self::InsideTourDir(path) => { - write!(f, "File {:?} is inside a .tour directory, which is not allowed.", path) + Self::NoTour => { + write!(f, "No tour found in this directory. Run `tour init` first.") } + Self::TourAlreadyExists => write!(f, "A tour already exists in this directory."), + Self::TourEnded => write!(f, "Tour has already been ended."), Self::NothingToCommit => { write!(f, "Nothing to commit. Use `tour add ` to stage files first.") } - Self::Io(e) => write!(f, "IO error: {}", e), + Self::NoSteps => { + write!(f, "Cannot end a tour with no steps. Use `tour commit` to add steps first.") + } + Self::NotADescendant(p) => { + write!(f, "File {:?} is not a descendant of the working directory.", p) + } + Self::InsideTourDir(p) => { + write!(f, "File {:?} is inside a .tour directory, which is not allowed.", p) + } + Self::FileNotFound(p) => write!(f, "File not found: {}", p.display()), + Self::StepOutOfRange { step, total } => { + write!(f, "Step {} is out of range (1-{}).", step, total) + } + Self::CorruptedTour(msg) => write!(f, "Tour data is corrupted: {}", msg), + Self::Io(e) => write!(f, "{}", e), } } } -impl std::error::Error for CommitError {} +impl std::error::Error for TourError {} -impl From for CommitError { +impl From for TourError { fn from(e: io::Error) -> Self { Self::Io(e) } diff --git a/src/info.rs b/src/info.rs index 0b562b1..e1eff1a 100644 --- a/src/info.rs +++ b/src/info.rs @@ -94,8 +94,10 @@ pub fn update_last_modified() -> Result<(), io::Error> { fs::write(INFO_PATH, info.serialize()) } -pub fn info() -> Result<(), io::Error> { - get_info() +pub fn info() -> Result<(), crate::error::TourError> { + crate::utils::require_tour()?; + get_info()?; + Ok(()) } fn current_date() -> String { @@ -134,5 +136,5 @@ fn days_to_ymd(mut days: u32) -> (u32, u32, u32) { } fn is_leap(year: u32) -> bool { - (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 + (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400) } diff --git a/src/init.rs b/src/init.rs index 3d5c0f8..00a4266 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,20 +1,51 @@ -// Create a .tour folder -// Create directory ./.tour/steps that stores tutorial steps -// Create file ./.tour/session that logs sessions information - +use crate::error::TourError; use crate::TOUR_DIR; -use std::fs::DirBuilder; -use std::path::PathBuf; +use std::fs::{self, DirBuilder, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; -// Creates the directory for tour -pub fn init() -> Result<(), std::io::Error> { +pub fn init() -> Result<(), TourError> { let tour_dir = PathBuf::from(TOUR_DIR); + if tour_dir.exists() { + return Err(TourError::TourAlreadyExists); + } + DirBuilder::new() .recursive(true) .create(tour_dir.join("steps"))?; - std::fs::File::create(tour_dir.join("session"))?; + fs::File::create(tour_dir.join("session"))?; - crate::info::set_info() + crate::info::set_info()?; + update_gitignore()?; + + Ok(()) +} + +fn update_gitignore() -> Result<(), std::io::Error> { + let gitignore = Path::new(".gitignore"); + let entries = [".tour/session", ".tour/staged", ".tour/removed"]; + + let existing = fs::read_to_string(gitignore).unwrap_or_default(); + let mut to_add = Vec::new(); + for entry in &entries { + if !existing.lines().any(|l| l.trim() == *entry) { + to_add.push(*entry); + } + } + + if !to_add.is_empty() { + let mut file = OpenOptions::new() + .append(true) + .create(true) + .open(gitignore)?; + if !existing.is_empty() && !existing.ends_with('\n') { + writeln!(file)?; + } + for entry in to_add { + writeln!(file, "{}", entry)?; + } + } + Ok(()) } diff --git a/src/list.rs b/src/list.rs new file mode 100644 index 0000000..26591a3 --- /dev/null +++ b/src/list.rs @@ -0,0 +1,22 @@ +use crate::error::TourError; +use crate::utils::{get_tour_step, require_tour}; +use crate::TOUR_DIR; +use std::fs; +use std::path::Path; + +pub fn list() -> Result<(), TourError> { + require_tour()?; + let total = get_tour_step()?; + + if total == 0 { + println!("No steps yet."); + return Ok(()); + } + + for i in 0..total { + let step_dir = Path::new(TOUR_DIR).join("steps").join(i.to_string()); + let message = fs::read_to_string(step_dir.join("message")).unwrap_or_default(); + println!(" {}. {}", i + 1, message.trim()); + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 275f6ea..ffa1427 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,4 @@ use clap::{Parser, Subcommand}; -use std::error::Error; use std::path::PathBuf; mod add; @@ -8,14 +7,19 @@ mod end; mod error; mod info; mod init; +mod list; +mod reset; +mod rm; +mod status; mod step; +mod unstage; mod utils; const TOUR_DIR: &str = "./.tour"; const SESSION_PATH: &str = "./.tour/session"; #[derive(Parser)] -#[command(author, version, about, long_about = None, disable_help_subcommand = true)] +#[command(author, version, about = "Create and navigate code tutorials as a series of snapshots", arg_required_else_help = true)] struct Args { #[command(subcommand)] command: Option, @@ -23,15 +27,20 @@ struct Args { #[derive(Subcommand)] enum Commands { - // Create a new tour + /// Set up a new tour in the current directory Init, - // Stage files for the next commit + /// Stage files for the next commit Add { files: Vec, }, - // Add steps to the tour + /// Remove files from staging + Unstage { + files: Vec, + }, + + /// Commit staged files as a new step Commit { files: Vec, @@ -39,76 +48,72 @@ enum Commands { message: String, }, - // Finish the tour + /// Mark files for removal in the next commit + Rm { + files: Vec, + }, + + /// Finalise the tour End { #[arg(short, long, value_name = "MESSAGE")] message: String, }, - // Go to next step of tour + /// Advance n steps (default 1) Next { #[arg(short, value_name = "NUM STEPS")] - n: Option, + n: Option, }, - // Go to previous step of tour + /// Go back n steps (default 1) Prev { #[arg(short, value_name = "NUM STEPS")] - n: Option, + n: Option, }, - // Go to a specific step of tour + /// Jump to step n Step { #[arg(value_name = "STEP")] - n: i32, + n: u32, }, - // Go to beginning of tour + /// Load the first step Start, + /// Show tour metadata Info, - // Show help - Help, + /// Show current step and staged files + Status, + + /// List all steps with messages + List, + + /// Reset tour session and remove tracked files + Reset, } -fn help() { - println!( - "\ -\x1b[1mtour\x1b[0m — create and navigate code tutorials as a series of snapshots - -\x1b[1mAUTHOR WORKFLOW\x1b[0m - tour init Set up a new tour in the current directory - tour add Stage files for the next commit - tour commit [-m ] Commit staged files as a new step - tour commit -m Stage and commit files in one step - tour end -m Finalise the tour - -\x1b[1mREADER WORKFLOW\x1b[0m - tour start Load the first step - tour next [n] Advance n steps (default 1) - tour prev [n] Go back n steps (default 1) - tour step Jump to step n - -\x1b[1mOTHER\x1b[0m - tour info Show tour metadata - tour help Show this help message" - ); -} - -fn main() -> Result<(), Box> { +fn main() { let args = Args::parse(); - match args.command { - Some(Commands::Init) => crate::init::init()?, - Some(Commands::Add { files }) => crate::add::add(files)?, - Some(Commands::Commit { files, message }) => crate::commit::commit(files, message)?, - Some(Commands::End { message }) => crate::end::end(message)?, - Some(Commands::Next { n }) => crate::step::next(n)?, - Some(Commands::Prev { n }) => crate::step::prev(n)?, - Some(Commands::Step { n }) => crate::step::step_n(n)?, - Some(Commands::Start) => crate::step::step_n(0)?, - Some(Commands::Info) => crate::info::info()?, - Some(Commands::Help) | None => help(), + let result = match args.command { + Some(Commands::Init) => crate::init::init(), + Some(Commands::Add { files }) => crate::add::add(files), + Some(Commands::Unstage { files }) => crate::unstage::unstage(files), + Some(Commands::Commit { files, message }) => crate::commit::commit(files, message), + Some(Commands::Rm { files }) => crate::rm::rm(files), + Some(Commands::End { message }) => crate::end::end(message), + Some(Commands::Next { n }) => crate::step::next(n), + Some(Commands::Prev { n }) => crate::step::prev(n), + Some(Commands::Step { n }) => crate::step::step_n(n), + Some(Commands::Start) => crate::step::step_n(1), + Some(Commands::Info) => crate::info::info(), + Some(Commands::Status) => crate::status::status(), + Some(Commands::List) => crate::list::list(), + Some(Commands::Reset) => crate::reset::reset(), + None => Ok(()), + }; + if let Err(e) = result { + eprintln!("Error: {}", e); + std::process::exit(1); } - Ok(()) } diff --git a/src/reset.rs b/src/reset.rs new file mode 100644 index 0000000..9c8203d --- /dev/null +++ b/src/reset.rs @@ -0,0 +1,18 @@ +use crate::error::TourError; +use crate::step; +use crate::utils::require_tour; +use crate::SESSION_PATH; +use std::fs; + +pub fn reset() -> Result<(), TourError> { + require_tour()?; + + let cwd = std::env::current_dir()?; + let tracked = step::get_tracked_files()?; + step::remove_tracked_files(&cwd, &tracked)?; + + let _ = fs::remove_file(SESSION_PATH); + + println!("Tour session reset. Tracked files removed from working directory."); + Ok(()) +} diff --git a/src/rm.rs b/src/rm.rs new file mode 100644 index 0000000..bc190ff --- /dev/null +++ b/src/rm.rs @@ -0,0 +1,44 @@ +use crate::error::TourError; +use crate::utils::require_tour; +use std::collections::HashSet; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::PathBuf; + +pub const REMOVED_PATH: &str = "./.tour/removed"; + +pub fn rm(files: Vec) -> Result<(), TourError> { + require_tour()?; + + let existing = get_removed()?; + let existing_set: HashSet = existing.into_iter().collect(); + + let mut removed_file = OpenOptions::new() + .append(true) + .create(true) + .open(REMOVED_PATH)?; + + for file in &files { + if existing_set.contains(file) { + println!("already marked for removal: {}", file.display()); + } else { + writeln!(removed_file, "{}", file.display())?; + println!("marked for removal: {}", file.display()); + } + } + + Ok(()) +} + +pub fn get_removed() -> Result, std::io::Error> { + let content = fs::read_to_string(REMOVED_PATH).unwrap_or_default(); + Ok(content + .lines() + .filter(|l| !l.is_empty()) + .map(PathBuf::from) + .collect()) +} + +pub fn clear_removed() -> Result<(), std::io::Error> { + fs::write(REMOVED_PATH, "") +} diff --git a/src/status.rs b/src/status.rs new file mode 100644 index 0000000..a1c3e89 --- /dev/null +++ b/src/status.rs @@ -0,0 +1,32 @@ +use crate::add::get_staged; +use crate::error::TourError; +use crate::utils::{get_current_step, get_tour_step, require_tour}; +use crate::TOUR_DIR; +use std::path::Path; + +pub fn status() -> Result<(), TourError> { + require_tour()?; + + let total = get_tour_step()?; + let ended = Path::new(TOUR_DIR).join("ended").exists(); + let current = get_current_step(); + + println!("Tour: {} steps{}", total, if ended { " (ended)" } else { "" }); + + match current { + Some(step) => println!("Current step: {}/{}", step + 1, total), + None => println!("Current step: not started"), + } + + let staged = get_staged()?; + if staged.is_empty() { + println!("Staged files: none"); + } else { + println!("Staged files:"); + for file in &staged { + println!(" {}", file.display()); + } + } + + Ok(()) +} diff --git a/src/step.rs b/src/step.rs index 41ed664..e0458a1 100644 --- a/src/step.rs +++ b/src/step.rs @@ -1,4 +1,5 @@ -use crate::utils::get_tour_step; +use crate::error::TourError; +use crate::utils::{copy_tree, get_current_step, get_tour_step, require_tour}; use crate::SESSION_PATH; use crate::TOUR_DIR; use std::collections::{BTreeMap, BTreeSet}; @@ -12,102 +13,81 @@ const CYAN: &str = "\x1b[36m"; const BOLD: &str = "\x1b[1m"; const RESET: &str = "\x1b[0m"; -pub fn step_n(n: i32) -> Result<(), io::Error> { - let total = get_tour_step() - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; +/// Jump to step `n` (1-based, as shown to the user). +pub fn step_n(n: u32) -> Result<(), TourError> { + require_tour()?; + let total = get_tour_step()?; - if n < 0 || n >= total as i32 { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("Step {} is out of range (0-{})", n, total - 1), - )); + if n < 1 || n > total { + return Err(TourError::StepOutOfRange { step: n, total }); } - step(n - current_step()) + go_to_step(n - 1, total) } -pub fn next(n: Option) -> Result<(), io::Error> { - step(n.unwrap_or(1)) -} - -pub fn prev(n: Option) -> Result<(), io::Error> { - step(-n.unwrap_or(1)) -} - -/// Returns the current step as a signed integer. -/// Returns -1 when the session has no step yet (reader hasn't started). -fn current_step() -> i32 { - fs::read_to_string(SESSION_PATH) - .ok() - .and_then(|s| { - s.split("STEP=") - .nth(1) - .and_then(|v| v.trim().parse::().ok()) - }) - .unwrap_or(-1) -} - -fn step(delta: i32) -> Result<(), io::Error> { - let current = current_step(); - let total = get_tour_step() - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?; - - let new_step = current + delta; - if new_step < 0 || new_step >= total as i32 { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("Step {} is out of range (0-{})", new_step, total - 1), - )); +pub fn next(n: Option) -> Result<(), TourError> { + require_tour()?; + let total = get_tour_step()?; + let delta = n.unwrap_or(1); + let target = match get_current_step() { + Some(c) => c.saturating_add(delta), + None if delta > 0 => delta - 1, + None => return Err(TourError::StepOutOfRange { step: 0, total }), + }; + if target >= total { + return Err(TourError::StepOutOfRange { + step: target + 1, + total, + }); } - let new_step = new_step as u32; + go_to_step(target, total) +} +pub fn prev(n: Option) -> Result<(), TourError> { + require_tour()?; + let total = get_tour_step()?; + let delta = n.unwrap_or(1); + let current = get_current_step().ok_or(TourError::StepOutOfRange { step: 0, total })?; + let target = current + .checked_sub(delta) + .ok_or(TourError::StepOutOfRange { step: 0, total })?; + go_to_step(target, total) +} + +fn go_to_step(target: u32, total: u32) -> Result<(), TourError> { let cwd = std::env::current_dir()?; let tracked = get_tracked_files()?; let old_files = snapshot_tracked_files(&cwd, &tracked)?; - // Remove only tracked files from CWD - for relative in &tracked { - let full = cwd.join(relative); - if full.is_file() { - fs::remove_file(&full)?; - } - } + remove_tracked_files(&cwd, &tracked)?; // Copy step contents into CWD (skipping the message file) - let step_dir = Path::new(TOUR_DIR).join("steps").join(new_step.to_string()); + let step_dir = Path::new(TOUR_DIR).join("steps").join(target.to_string()); for entry in fs::read_dir(&step_dir)? { let entry = entry?; if entry.file_name() == "message" { continue; } - copy_into(&entry.path(), &cwd.join(entry.file_name()))?; + copy_tree(&entry.path(), &cwd.join(entry.file_name()))?; } // Persist the new step - fs::write(SESSION_PATH, format!("STEP={}", new_step))?; + fs::write(SESSION_PATH, format!("STEP={}", target))?; let new_files = snapshot_tracked_files(&cwd, &tracked)?; print_changes(&old_files, &new_files); let message = fs::read_to_string(step_dir.join("message")).unwrap_or_default(); - println!("\n{BOLD}Step {new_step}/{total}:{RESET} {}", message.trim()); + println!( + "\n{BOLD}Step {}/{total}:{RESET} {}", + target + 1, + message.trim() + ); Ok(()) } -fn snapshot_tracked_files(root: &Path, tracked: &BTreeSet) -> Result, io::Error> { - let mut files = BTreeMap::new(); - for relative in tracked { - let full = root.join(relative); - if full.is_file() { - let content = fs::read_to_string(&full).unwrap_or_default(); - files.insert(relative.clone(), content); - } - } - Ok(files) -} - -fn get_tracked_files() -> Result, io::Error> { +pub fn get_tracked_files() -> Result, io::Error> { let steps_dir = Path::new(TOUR_DIR).join("steps"); let mut tracked = BTreeSet::new(); @@ -124,6 +104,30 @@ fn get_tracked_files() -> Result, io::Error> { Ok(tracked) } +pub fn remove_tracked_files(cwd: &Path, tracked: &BTreeSet) -> Result<(), io::Error> { + let mut dirs_to_check = BTreeSet::new(); + for relative in tracked { + let full = cwd.join(relative); + if full.is_file() { + fs::remove_file(&full)?; + if let Some(parent) = relative.parent() + && parent != Path::new("") + { + dirs_to_check.insert(cwd.join(parent)); + } + } + } + // Remove empty directories (deepest first) + let mut dirs: Vec<_> = dirs_to_check.into_iter().collect(); + dirs.sort_by_key(|b| std::cmp::Reverse(b.components().count())); + for dir in dirs { + if dir.is_dir() && dir.read_dir().map(|mut d| d.next().is_none()).unwrap_or(false) { + let _ = fs::remove_dir(&dir); + } + } + Ok(()) +} + fn collect_step_files( step_root: &Path, dir: &Path, @@ -145,13 +149,34 @@ fn collect_step_files( Ok(()) } -fn print_changes(old: &BTreeMap, new: &BTreeMap) { +/// Snapshots tracked files from root. Returns None for binary files (invalid UTF-8). +fn snapshot_tracked_files( + root: &Path, + tracked: &BTreeSet, +) -> Result>, io::Error> { + let mut files = BTreeMap::new(); + for relative in tracked { + let full = root.join(relative); + if full.is_file() { + files.insert(relative.clone(), fs::read_to_string(&full).ok()); + } + } + Ok(files) +} + +fn print_changes( + old: &BTreeMap>, + new: &BTreeMap>, +) { for (path, new_content) in new { match old.get(path) { None => println!("{GREEN} new: {}{RESET}", path.display()), Some(old_content) if old_content != new_content => { println!("{CYAN} modified: {}{RESET}", path.display()); - print_diff(old_content, new_content); + match (old_content, new_content) { + (Some(o), Some(n)) => print_diff(o, n), + _ => println!(" (binary file)"), + } } _ => {} } @@ -217,8 +242,8 @@ fn print_diff(old: &str, new: &str) { for &ci in &changed { let lo = ci.saturating_sub(CTX); let hi = (ci + CTX + 1).min(ops.len()); - for v in lo..hi { - visible[v] = true; + for item in visible.iter_mut().take(hi).skip(lo) { + *item = true; } } @@ -239,16 +264,3 @@ fn print_diff(old: &str, new: &str) { } } } - -fn copy_into(src: &Path, dest: &Path) -> Result<(), io::Error> { - if src.is_dir() { - fs::create_dir_all(dest)?; - for entry in fs::read_dir(src)? { - let entry = entry?; - copy_into(&entry.path(), &dest.join(entry.file_name()))?; - } - } else { - fs::copy(src, dest)?; - } - Ok(()) -} diff --git a/src/unstage.rs b/src/unstage.rs new file mode 100644 index 0000000..40ecfb2 --- /dev/null +++ b/src/unstage.rs @@ -0,0 +1,25 @@ +use crate::add::{get_staged, STAGED_PATH}; +use crate::error::TourError; +use crate::utils::require_tour; +use std::collections::HashSet; +use std::fs; +use std::path::PathBuf; + +pub fn unstage(files: Vec) -> Result<(), TourError> { + require_tour()?; + let staged = get_staged()?; + let to_remove: HashSet<&PathBuf> = files.iter().collect(); + + let remaining: Vec<&PathBuf> = staged.iter().filter(|f| !to_remove.contains(f)).collect(); + let content: String = remaining.iter().map(|f| format!("{}\n", f.display())).collect(); + fs::write(STAGED_PATH, content)?; + + for file in &files { + if staged.contains(file) { + println!("unstaged: {}", file.display()); + } else { + println!("not staged: {}", file.display()); + } + } + Ok(()) +} diff --git a/src/utils.rs b/src/utils.rs index e483532..732ae40 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -2,11 +2,54 @@ use std::fs; use std::io; use std::path::Path; +use crate::error::TourError; use crate::SESSION_PATH; use crate::TOUR_DIR; +pub fn require_tour() -> Result<(), TourError> { + if !Path::new(TOUR_DIR).exists() { + return Err(TourError::NoTour); + } + Ok(()) +} + +pub fn get_current_step() -> Option { + fs::read_to_string(SESSION_PATH) + .ok() + .and_then(|s| { + s.split("STEP=") + .nth(1) + .and_then(|v| v.trim().parse::().ok()) + }) +} + +pub fn get_tour_step() -> Result { + let steps_dir = Path::new(TOUR_DIR).join("steps"); + let mut indices: Vec = fs::read_dir(&steps_dir)? + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .filter_map(|e| e.file_name().to_str()?.parse::().ok()) + .collect(); + + if indices.is_empty() { + return Ok(0); + } + + indices.sort(); + let count = indices.len() as u32; + + for (i, &idx) in indices.iter().enumerate() { + if idx != i as u32 { + return Err(TourError::CorruptedTour( + format!("step directories are not sequential (expected {}, found {})", i, idx), + )); + } + } + + Ok(count) +} + /// Copies a file or directory into dest_dir, preserving relative path structure. -/// e.g. `src/main.rs` → `dest_dir/src/main.rs` pub fn copy_path(src: &Path, dest_dir: &Path) -> Result<(), io::Error> { let relative_src = if src.is_absolute() { let cwd = std::env::current_dir()?; @@ -32,6 +75,24 @@ pub fn copy_path(src: &Path, dest_dir: &Path) -> Result<(), io::Error> { Ok(()) } +/// Recursively copies src to dest. If src is a directory, copies its contents +/// into dest. If src is a file, copies it to dest. +pub fn copy_tree(src: &Path, dest: &Path) -> Result<(), io::Error> { + if src.is_dir() { + fs::create_dir_all(dest)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + copy_tree(&entry.path(), &dest.join(entry.file_name()))?; + } + } else { + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(src, dest)?; + } + Ok(()) +} + pub fn is_descendant_of_current_dir(file: &Path) -> Result { is_file_in_dir(file, &std::env::current_dir()?) } @@ -41,23 +102,3 @@ pub fn is_file_in_dir(file: &Path, dir: &Path) -> Result { let dir_canon = dir.canonicalize()?; Ok(file_canon.starts_with(&dir_canon)) } - -pub fn get_session_step() -> Result> { - let session = fs::read_to_string(SESSION_PATH)?; - let step = session - .split("STEP=") - .nth(1) - .ok_or("no STEP in session")? - .trim() - .parse::()?; - Ok(step) -} - -pub fn get_tour_step() -> Result> { - let steps_dir = Path::new(TOUR_DIR).join("steps"); - let count = fs::read_dir(&steps_dir)? - .filter_map(|e| e.ok()) - .filter(|e| e.path().is_dir()) - .count() as u32; - Ok(count) -} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..675d589 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,475 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +fn tour_cmd() -> Command { + Command::new(env!("CARGO_BIN_EXE_tour")) +} + +fn setup_dir() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + dir +} + +fn init_tour(dir: &Path) { + let status = tour_cmd() + .arg("init") + .current_dir(dir) + .stdin(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + if let Some(ref mut stdin) = child.stdin { + writeln!(stdin, "test-author")?; + writeln!(stdin, "test-desc")?; + writeln!(stdin, "rust")?; + } + child.wait() + }) + .expect("failed to run init"); + assert!(status.success(), "tour init failed"); +} + +fn create_test_file(dir: &Path, name: &str, content: &str) { + let path = dir.join(name); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&path, content).unwrap(); +} + +// -- init tests -- + +#[test] +fn test_init_creates_tour_dir() { + let dir = setup_dir(); + init_tour(dir.path()); + + assert!(dir.path().join(".tour").exists()); + assert!(dir.path().join(".tour/steps").exists()); + assert!(dir.path().join(".tour/session").exists()); + assert!(dir.path().join(".tour/info").exists()); +} + +#[test] +fn test_commands_fail_without_init() { + let dir = setup_dir(); + + let output = tour_cmd() + .args(["commit", "foo.rs", "-m", "msg"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("No tour found") || stderr.contains("tour init") || stderr.contains("NoTour"), + "Expected helpful error, got: {}", stderr); +} + +// -- add / commit tests -- + +#[test] +fn test_add_and_commit() { + let dir = setup_dir(); + init_tour(dir.path()); + create_test_file(dir.path(), "hello.txt", "hello world"); + + // Add + let output = tour_cmd() + .args(["add", "hello.txt"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success(), "add failed: {}", String::from_utf8_lossy(&output.stderr)); + + // Commit from staged + let output = tour_cmd() + .args(["commit", "-m", "first step"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success(), "commit failed: {}", String::from_utf8_lossy(&output.stderr)); + + // Step dir should exist with the file + let step_dir = dir.path().join(".tour/steps/0"); + assert!(step_dir.exists()); + assert!(step_dir.join("hello.txt").exists()); + assert!(step_dir.join("message").exists()); + + // Staged should be cleared + let staged = fs::read_to_string(dir.path().join(".tour/staged")).unwrap(); + assert!(staged.trim().is_empty()); +} + +#[test] +fn test_add_deduplicates() { + let dir = setup_dir(); + init_tour(dir.path()); + create_test_file(dir.path(), "a.txt", "content"); + + // Add twice + tour_cmd().args(["add", "a.txt"]).current_dir(dir.path()).output().unwrap(); + tour_cmd().args(["add", "a.txt"]).current_dir(dir.path()).output().unwrap(); + + let staged = fs::read_to_string(dir.path().join(".tour/staged")).unwrap(); + let lines: Vec<&str> = staged.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(lines.len(), 1, "expected 1 staged file, got: {:?}", lines); +} + +#[test] +fn test_commit_with_inline_files() { + let dir = setup_dir(); + init_tour(dir.path()); + create_test_file(dir.path(), "main.rs", "fn main() {}"); + + let output = tour_cmd() + .args(["commit", "main.rs", "-m", "add main"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(dir.path().join(".tour/steps/0/main.rs").exists()); +} + +// -- end tests -- + +#[test] +fn test_end_tour() { + let dir = setup_dir(); + init_tour(dir.path()); + create_test_file(dir.path(), "f.txt", "data"); + tour_cmd().args(["commit", "f.txt", "-m", "step"]).current_dir(dir.path()).output().unwrap(); + + let output = tour_cmd() + .args(["end", "-m", "done"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success(), "end failed: {}", String::from_utf8_lossy(&output.stderr)); + assert!(dir.path().join(".tour/ended").exists()); +} + +#[test] +fn test_end_prevents_further_commits() { + let dir = setup_dir(); + init_tour(dir.path()); + create_test_file(dir.path(), "f.txt", "data"); + tour_cmd().args(["commit", "f.txt", "-m", "step"]).current_dir(dir.path()).output().unwrap(); + tour_cmd().args(["end", "-m", "done"]).current_dir(dir.path()).output().unwrap(); + + create_test_file(dir.path(), "g.txt", "more"); + let output = tour_cmd() + .args(["commit", "g.txt", "-m", "nope"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(!output.status.success()); +} + +#[test] +fn test_end_without_steps_fails() { + let dir = setup_dir(); + init_tour(dir.path()); + + let output = tour_cmd() + .args(["end", "-m", "empty"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(!output.status.success()); +} + +// -- step navigation tests -- + +#[test] +fn test_start_and_next_prev() { + let dir = setup_dir(); + init_tour(dir.path()); + + create_test_file(dir.path(), "f.txt", "step0"); + tour_cmd().args(["commit", "f.txt", "-m", "first"]).current_dir(dir.path()).output().unwrap(); + + create_test_file(dir.path(), "f.txt", "step1"); + tour_cmd().args(["commit", "f.txt", "-m", "second"]).current_dir(dir.path()).output().unwrap(); + + // Start + let output = tour_cmd() + .arg("start") + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success(), "start failed: {}", String::from_utf8_lossy(&output.stderr)); + + let content = fs::read_to_string(dir.path().join("f.txt")).unwrap(); + assert_eq!(content, "step0"); + + // Next + let output = tour_cmd() + .arg("next") + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success(), "next failed: {}", String::from_utf8_lossy(&output.stderr)); + + let content = fs::read_to_string(dir.path().join("f.txt")).unwrap(); + assert_eq!(content, "step1"); + + // Prev + let output = tour_cmd() + .arg("prev") + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success(), "prev failed: {}", String::from_utf8_lossy(&output.stderr)); + + let content = fs::read_to_string(dir.path().join("f.txt")).unwrap(); + assert_eq!(content, "step0"); +} + +#[test] +fn test_step_n_jumps_to_step() { + let dir = setup_dir(); + init_tour(dir.path()); + + create_test_file(dir.path(), "f.txt", "v0"); + tour_cmd().args(["commit", "f.txt", "-m", "s0"]).current_dir(dir.path()).output().unwrap(); + create_test_file(dir.path(), "f.txt", "v1"); + tour_cmd().args(["commit", "f.txt", "-m", "s1"]).current_dir(dir.path()).output().unwrap(); + create_test_file(dir.path(), "f.txt", "v2"); + tour_cmd().args(["commit", "f.txt", "-m", "s2"]).current_dir(dir.path()).output().unwrap(); + + // Start at step 1, then jump to step 3 (1-based) + tour_cmd().arg("start").current_dir(dir.path()).output().unwrap(); + let output = tour_cmd() + .args(["step", "3"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success(), "step 3 failed: {}", String::from_utf8_lossy(&output.stderr)); + + let content = fs::read_to_string(dir.path().join("f.txt")).unwrap(); + assert_eq!(content, "v2"); +} + +#[test] +fn test_step_out_of_range() { + let dir = setup_dir(); + init_tour(dir.path()); + + create_test_file(dir.path(), "f.txt", "v0"); + tour_cmd().args(["commit", "f.txt", "-m", "s0"]).current_dir(dir.path()).output().unwrap(); + tour_cmd().arg("start").current_dir(dir.path()).output().unwrap(); + + // Step 0 is out of range (1-based, only 1 step) + let output = tour_cmd() + .args(["step", "0"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(!output.status.success()); + + // Step 2 is out of range (only 1 step) + let output = tour_cmd() + .args(["step", "2"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(!output.status.success()); +} + +// -- unstage tests -- + +#[test] +fn test_unstage() { + let dir = setup_dir(); + init_tour(dir.path()); + create_test_file(dir.path(), "a.txt", "aaa"); + create_test_file(dir.path(), "b.txt", "bbb"); + + tour_cmd().args(["add", "a.txt", "b.txt"]).current_dir(dir.path()).output().unwrap(); + + let output = tour_cmd() + .args(["unstage", "a.txt"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success(), "unstage failed: {}", String::from_utf8_lossy(&output.stderr)); + + let staged = fs::read_to_string(dir.path().join(".tour/staged")).unwrap(); + assert!(!staged.contains("a.txt"), "a.txt should be unstaged"); + assert!(staged.contains("b.txt"), "b.txt should still be staged"); +} + +// -- list tests -- + +#[test] +fn test_list_steps() { + let dir = setup_dir(); + init_tour(dir.path()); + + create_test_file(dir.path(), "f.txt", "v0"); + tour_cmd().args(["commit", "f.txt", "-m", "first step"]).current_dir(dir.path()).output().unwrap(); + create_test_file(dir.path(), "f.txt", "v1"); + tour_cmd().args(["commit", "f.txt", "-m", "second step"]).current_dir(dir.path()).output().unwrap(); + + let output = tour_cmd() + .arg("list") + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("first step"), "expected 'first step' in: {}", stdout); + assert!(stdout.contains("second step"), "expected 'second step' in: {}", stdout); +} + +// -- carry-forward tests -- + +#[test] +fn test_files_carry_forward() { + let dir = setup_dir(); + init_tour(dir.path()); + + create_test_file(dir.path(), "a.txt", "aaa"); + create_test_file(dir.path(), "b.txt", "bbb"); + tour_cmd().args(["commit", "a.txt", "b.txt", "-m", "step 1"]).current_dir(dir.path()).output().unwrap(); + + // Second step only modifies a.txt + create_test_file(dir.path(), "a.txt", "aaa modified"); + tour_cmd().args(["commit", "a.txt", "-m", "step 2"]).current_dir(dir.path()).output().unwrap(); + + // b.txt should be carried forward to step 2 + assert!(dir.path().join(".tour/steps/1/b.txt").exists(), "b.txt should be carried forward"); + + // Navigate to step 2 and verify both files exist + tour_cmd().arg("start").current_dir(dir.path()).output().unwrap(); + tour_cmd().arg("next").current_dir(dir.path()).output().unwrap(); + + let a = fs::read_to_string(dir.path().join("a.txt")).unwrap(); + assert_eq!(a, "aaa modified"); + let b = fs::read_to_string(dir.path().join("b.txt")).unwrap(); + assert_eq!(b, "bbb"); +} + +// -- init guard tests -- + +#[test] +fn test_init_twice_fails() { + let dir = setup_dir(); + init_tour(dir.path()); + + let output = tour_cmd() + .arg("init") + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(!output.status.success(), "second init should fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("already exists"), "expected 'already exists', got: {}", stderr); +} + +// -- gitignore tests -- + +#[test] +fn test_init_creates_gitignore() { + let dir = setup_dir(); + init_tour(dir.path()); + + let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert!(gitignore.contains(".tour/session"), "gitignore should contain .tour/session"); + assert!(gitignore.contains(".tour/staged"), "gitignore should contain .tour/staged"); +} + +// -- add validation tests -- + +#[test] +fn test_add_nonexistent_file() { + let dir = setup_dir(); + init_tour(dir.path()); + + let output = tour_cmd() + .args(["add", "nonexistent.txt"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("not found") || stderr.contains("No such file") || stderr.contains("FileNotFound"), + "Expected file not found error, got: {}", stderr); +} + +// -- subdirectory tests -- + +#[test] +fn test_subdirectory_files() { + let dir = setup_dir(); + init_tour(dir.path()); + + create_test_file(dir.path(), "src/main.rs", "fn main() {}"); + create_test_file(dir.path(), "src/lib.rs", "pub fn hello() {}"); + + let output = tour_cmd() + .args(["commit", "src/main.rs", "src/lib.rs", "-m", "add source"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success(), "commit failed: {}", String::from_utf8_lossy(&output.stderr)); + + // Verify step directory has subdirectory structure + assert!(dir.path().join(".tour/steps/0/src/main.rs").exists()); + assert!(dir.path().join(".tour/steps/0/src/lib.rs").exists()); + + // Modify one file and commit step 2 + create_test_file(dir.path(), "src/main.rs", "fn main() { println!(\"hi\"); }"); + let output = tour_cmd() + .args(["commit", "src/main.rs", "-m", "modify main"]) + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success()); + + // lib.rs should be carried forward + assert!(dir.path().join(".tour/steps/1/src/lib.rs").exists()); + + // Start at step 1 and verify + tour_cmd().arg("start").current_dir(dir.path()).output().unwrap(); + let content = fs::read_to_string(dir.path().join("src/main.rs")).unwrap(); + assert_eq!(content, "fn main() {}"); + let content = fs::read_to_string(dir.path().join("src/lib.rs")).unwrap(); + assert_eq!(content, "pub fn hello() {}"); + + // Go to step 2 and verify modified file + carried-forward file + tour_cmd().arg("next").current_dir(dir.path()).output().unwrap(); + let content = fs::read_to_string(dir.path().join("src/main.rs")).unwrap(); + assert_eq!(content, "fn main() { println!(\"hi\"); }"); + let content = fs::read_to_string(dir.path().join("src/lib.rs")).unwrap(); + assert_eq!(content, "pub fn hello() {}"); + + // Go back to step 1 and verify cleanup + tour_cmd().arg("prev").current_dir(dir.path()).output().unwrap(); + let content = fs::read_to_string(dir.path().join("src/main.rs")).unwrap(); + assert_eq!(content, "fn main() {}"); +} + +// -- status tests -- + +#[test] +fn test_status_shows_info() { + let dir = setup_dir(); + init_tour(dir.path()); + + create_test_file(dir.path(), "f.txt", "data"); + tour_cmd().args(["commit", "f.txt", "-m", "step"]).current_dir(dir.path()).output().unwrap(); + + let output = tour_cmd() + .arg("status") + .current_dir(dir.path()) + .output() + .unwrap(); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("1 steps"), "expected step count in: {}", stdout); + assert!(stdout.contains("not started"), "expected 'not started' in: {}", stdout); +}