Fix bugs and improve robustness across the codebase
- Fix staged files being silently cleared when commit uses inline files - Refactor step navigation to use direct go_to_step instead of fragile delta math - Change step numbers from i32 to u32 (reject negative values at parse time) - Add tour rm command to mark files for removal during carry-forward - Add tour reset command to clear session and remove tracked files - Consolidate duplicate recursive copy functions into shared copy_tree in utils - Validate step directories are sequential (detect corruption) - Detect binary files in diffs instead of showing garbage - Use /// doc comments on enum variants so clap generates proper help text - Remove custom Help subcommand in favor of clap's built-in --help - Add CorruptedTour error variant for integrity checks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
38
CLAUDE.md
38
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`.
|
`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 <files> -m <msg>` (repeat) → `tour end -m <msg>`
|
**Author workflow:** `tour init` → `tour add <files>` → `tour commit -m <msg>` (repeat) → `tour end -m <msg>`
|
||||||
|
|
||||||
**Reader workflow:** `tour start` → `tour next [n]` / `tour prev [n]`
|
**Reader workflow:** `tour start` → `tour next [n]` / `tour prev [n]` / `tour step <n>` / `tour reset`
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Entry point is `main.rs`, which uses clap's derive macro to parse subcommands and dispatch to per-command modules.
|
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):
|
**On-disk format** (stored in `.tour/` in the project being toured):
|
||||||
- `.tour/steps/<N>/` — one numbered directory per step
|
- `.tour/steps/<N>/` — one numbered directory per step (each step is a complete file snapshot)
|
||||||
|
- `.tour/steps/<N>/message` — the commit message for that step
|
||||||
- `.tour/session` — tracks current reader position as `STEP=<n>`
|
- `.tour/session` — tracks current reader position as `STEP=<n>`
|
||||||
|
- `.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:**
|
**Module layout:**
|
||||||
- `init.rs` — creates `.tour/steps/` and `.tour/session`
|
- `init.rs` — creates `.tour/` structure, collects tour metadata, updates `.gitignore`
|
||||||
- `commit.rs` — validates files, then saves them as a new numbered step
|
- `add.rs` — stages files for the next commit; `get_staged()` reads the staged file list
|
||||||
- `end.rs` — finalizes the tour
|
- `unstage.rs` — removes files from staging
|
||||||
- `next.rs` / `prev.rs` — advance/retreat the session step
|
- `commit.rs` — commits staged files as a new step with carry-forward from previous step; only clears staging when staging was used
|
||||||
- `utils.rs` — shared helpers: `copy_files`, `get_session_step`, `get_tour_step`, path validation
|
- `rm.rs` — marks files for removal in the next commit (skipped during carry-forward)
|
||||||
- `error.rs` — custom error types (currently `CommitError`)
|
- `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<io::Error>` 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.
|
||||||
|
|||||||
377
Cargo.lock
generated
377
Cargo.lock
generated
@@ -52,6 +52,24 @@ dependencies = [
|
|||||||
"windows-sys",
|
"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]]
|
[[package]]
|
||||||
name = "clap"
|
name = "clap"
|
||||||
version = "4.5.59"
|
version = "4.5.59"
|
||||||
@@ -98,24 +116,150 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
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]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
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]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
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]]
|
[[package]]
|
||||||
name = "once_cell_polyfill"
|
name = "once_cell_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
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]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
@@ -134,6 +278,73 @@ dependencies = [
|
|||||||
"proc-macro2",
|
"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]]
|
[[package]]
|
||||||
name = "strsim"
|
name = "strsim"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -151,11 +362,25 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "tour"
|
name = "tour"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -164,12 +389,70 @@ version = "1.0.24"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-xid"
|
||||||
|
version = "0.2.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf8parse"
|
name = "utf8parse"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
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]]
|
[[package]]
|
||||||
name = "windows-link"
|
name = "windows-link"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -184,3 +467,97 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-link",
|
"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"
|
||||||
|
|||||||
@@ -5,3 +5,6 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
clap = { version = "4.5.59", features = ["derive"] }
|
clap = { version = "4.5.59", features = ["derive"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.26.0"
|
||||||
|
|||||||
135
readme.md
135
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
|
```sh
|
||||||
# The format of this is:
|
cargo install --path .
|
||||||
|
```
|
||||||
|
|
||||||
command args
|
## Quick Start
|
||||||
# ->
|
|
||||||
# output
|
|
||||||
# output line 2
|
|
||||||
|
|
||||||
# CREATING A TOUR
|
### Creating a tour
|
||||||
tour init -m "After running *cargo init* we get our template"
|
|
||||||
|
```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/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
|
tour start
|
||||||
# ->
|
# new: Cargo.toml
|
||||||
# New files:
|
# new: src/main.rs
|
||||||
# ./cargo.lock
|
|
||||||
# ./cargo.toml
|
|
||||||
# src/main.rs
|
|
||||||
#
|
#
|
||||||
# Explanation
|
# Step 1/3: After running cargo init we get our template
|
||||||
# After running *cargo init* we get our template
|
|
||||||
|
|
||||||
tour next
|
tour next
|
||||||
# ->
|
# new: src/lib.rs
|
||||||
# New files:
|
#
|
||||||
# src/lib.rs
|
# Step 2/3: In lib.rs we add some functions
|
||||||
# Explanation
|
|
||||||
# In lib.rs we add some functions
|
|
||||||
|
|
||||||
tour prev
|
tour prev
|
||||||
# ->
|
# deleted: src/lib.rs
|
||||||
# New files:
|
|
||||||
# ./cargo.lock
|
|
||||||
# ./cargo.toml
|
|
||||||
# src/main.rs
|
|
||||||
#
|
#
|
||||||
# Explanation
|
# Step 1/3: After running cargo init we get our template
|
||||||
# After running *cargo init* we get our template
|
|
||||||
|
|
||||||
tour next 2
|
tour next 2
|
||||||
# ->
|
# new: src/lib.rs
|
||||||
# No new files.
|
# modified: src/main.rs
|
||||||
|
# + use lib::my_function;
|
||||||
|
#
|
||||||
|
# Step 3/3: We import our newly made function from lib.rs
|
||||||
|
|
||||||
# Changes:
|
tour step 1 # jump to any step by number
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
````
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Author workflow
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `tour init` | Set up a new tour in the current directory |
|
||||||
|
| `tour add <files...>` | Stage files for the next commit |
|
||||||
|
| `tour unstage <files...>` | Remove files from staging |
|
||||||
|
| `tour commit -m <msg>` | Commit staged files as a new step |
|
||||||
|
| `tour commit <files...> -m <msg>` | Stage and commit files in one step |
|
||||||
|
| `tour end -m <msg>` | 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 <n>` | 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/<N>/`. 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
|
||||||
|
|||||||
21
src/add.rs
21
src/add.rs
@@ -1,5 +1,5 @@
|
|||||||
use crate::error::CommitError;
|
use crate::error::TourError;
|
||||||
use crate::utils::{is_descendant_of_current_dir, is_file_in_dir};
|
use crate::utils::{is_descendant_of_current_dir, is_file_in_dir, require_tour};
|
||||||
use crate::TOUR_DIR;
|
use crate::TOUR_DIR;
|
||||||
use std::fs::{self, OpenOptions};
|
use std::fs::{self, OpenOptions};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
@@ -7,27 +7,38 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
pub const STAGED_PATH: &str = "./.tour/staged";
|
pub const STAGED_PATH: &str = "./.tour/staged";
|
||||||
|
|
||||||
pub fn add(files: Vec<PathBuf>) -> Result<(), CommitError> {
|
pub fn add(files: Vec<PathBuf>) -> Result<(), TourError> {
|
||||||
|
require_tour()?;
|
||||||
let tour_dir = Path::new(TOUR_DIR);
|
let tour_dir = Path::new(TOUR_DIR);
|
||||||
|
|
||||||
for file in &files {
|
for file in &files {
|
||||||
|
if !file.exists() {
|
||||||
|
return Err(TourError::FileNotFound(file.clone()));
|
||||||
|
}
|
||||||
if !is_descendant_of_current_dir(file)? {
|
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)? {
|
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<PathBuf> = existing.into_iter().collect();
|
||||||
|
|
||||||
let mut staged = OpenOptions::new()
|
let mut staged = OpenOptions::new()
|
||||||
.append(true)
|
.append(true)
|
||||||
.create(true)
|
.create(true)
|
||||||
.open(STAGED_PATH)?;
|
.open(STAGED_PATH)?;
|
||||||
|
|
||||||
for file in &files {
|
for file in &files {
|
||||||
|
if existing_set.contains(file) {
|
||||||
|
println!("already staged: {}", file.display());
|
||||||
|
} else {
|
||||||
writeln!(staged, "{}", file.display())?;
|
writeln!(staged, "{}", file.display())?;
|
||||||
println!("staged: {}", file.display());
|
println!("staged: {}", file.display());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
use crate::add::{clear_staged, get_staged};
|
use crate::add::{clear_staged, get_staged};
|
||||||
use crate::error::CommitError;
|
use crate::error::TourError;
|
||||||
use crate::utils::{copy_path, is_descendant_of_current_dir, is_file_in_dir};
|
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 crate::TOUR_DIR;
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub fn commit(files: Vec<PathBuf>, message: String) -> Result<(), CommitError> {
|
pub fn commit(files: Vec<PathBuf>, message: String) -> Result<(), TourError> {
|
||||||
|
require_tour()?;
|
||||||
let tour_dir = Path::new(TOUR_DIR);
|
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()?;
|
let staged = get_staged()?;
|
||||||
if staged.is_empty() {
|
if staged.is_empty() {
|
||||||
return Err(CommitError::NothingToCommit);
|
return Err(TourError::NothingToCommit);
|
||||||
}
|
}
|
||||||
staged
|
staged
|
||||||
} else {
|
} else {
|
||||||
@@ -19,31 +27,83 @@ pub fn commit(files: Vec<PathBuf>, message: String) -> Result<(), CommitError> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for file in &files {
|
for file in &files {
|
||||||
|
if !file.exists() {
|
||||||
|
return Err(TourError::FileNotFound(file.clone()));
|
||||||
|
}
|
||||||
if !is_descendant_of_current_dir(file)? {
|
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)? {
|
if is_file_in_dir(file, tour_dir)? {
|
||||||
return Err(CommitError::InsideTourDir(file.clone()));
|
return Err(TourError::InsideTourDir(file.clone()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let removed = get_removed()?;
|
||||||
|
let removed_set: HashSet<PathBuf> = removed.into_iter().collect();
|
||||||
|
|
||||||
|
let step_num = get_tour_step()? as usize;
|
||||||
let steps_dir = tour_dir.join("steps");
|
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 step_dir = steps_dir.join(step_num.to_string());
|
let step_dir = steps_dir.join(step_num.to_string());
|
||||||
fs::create_dir_all(&step_dir)?;
|
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 {
|
for file in &files {
|
||||||
copy_path(file, &step_dir)?;
|
copy_path(file, &step_dir)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
fs::write(step_dir.join("message"), &message)?;
|
fs::write(step_dir.join("message"), &message)?;
|
||||||
|
|
||||||
|
// Only clear staging if we used it
|
||||||
|
if used_staging {
|
||||||
clear_staged()?;
|
clear_staged()?;
|
||||||
|
}
|
||||||
|
clear_removed()?;
|
||||||
crate::info::update_last_modified()?;
|
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<PathBuf>,
|
||||||
|
) -> 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/end.rs
24
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/error.rs
44
src/error.rs
@@ -2,33 +2,53 @@ use std::io;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum CommitError {
|
pub enum TourError {
|
||||||
NotADescendantOfCurrentDir(PathBuf),
|
NoTour,
|
||||||
InsideTourDir(PathBuf),
|
TourAlreadyExists,
|
||||||
|
TourEnded,
|
||||||
NothingToCommit,
|
NothingToCommit,
|
||||||
|
NoSteps,
|
||||||
|
NotADescendant(PathBuf),
|
||||||
|
InsideTourDir(PathBuf),
|
||||||
|
FileNotFound(PathBuf),
|
||||||
|
StepOutOfRange { step: u32, total: u32 },
|
||||||
|
CorruptedTour(String),
|
||||||
Io(io::Error),
|
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 {
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Self::NotADescendantOfCurrentDir(path) => {
|
Self::NoTour => {
|
||||||
write!(f, "File {:?} is not a descendant of the working directory.", path)
|
write!(f, "No tour found in this directory. Run `tour init` first.")
|
||||||
}
|
|
||||||
Self::InsideTourDir(path) => {
|
|
||||||
write!(f, "File {:?} is inside a .tour directory, which is not allowed.", path)
|
|
||||||
}
|
}
|
||||||
|
Self::TourAlreadyExists => write!(f, "A tour already exists in this directory."),
|
||||||
|
Self::TourEnded => write!(f, "Tour has already been ended."),
|
||||||
Self::NothingToCommit => {
|
Self::NothingToCommit => {
|
||||||
write!(f, "Nothing to commit. Use `tour add <files>` to stage files first.")
|
write!(f, "Nothing to commit. Use `tour add <files>` 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<io::Error> for CommitError {
|
impl From<io::Error> for TourError {
|
||||||
fn from(e: io::Error) -> Self {
|
fn from(e: io::Error) -> Self {
|
||||||
Self::Io(e)
|
Self::Io(e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,8 +94,10 @@ pub fn update_last_modified() -> Result<(), io::Error> {
|
|||||||
fs::write(INFO_PATH, info.serialize())
|
fs::write(INFO_PATH, info.serialize())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn info() -> Result<(), io::Error> {
|
pub fn info() -> Result<(), crate::error::TourError> {
|
||||||
get_info()
|
crate::utils::require_tour()?;
|
||||||
|
get_info()?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_date() -> String {
|
fn current_date() -> String {
|
||||||
@@ -134,5 +136,5 @@ fn days_to_ymd(mut days: u32) -> (u32, u32, u32) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_leap(year: u32) -> bool {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/init.rs
51
src/init.rs
@@ -1,20 +1,51 @@
|
|||||||
// Create a .tour folder
|
use crate::error::TourError;
|
||||||
// Create directory ./.tour/steps that stores tutorial steps
|
|
||||||
// Create file ./.tour/session that logs sessions information
|
|
||||||
|
|
||||||
use crate::TOUR_DIR;
|
use crate::TOUR_DIR;
|
||||||
use std::fs::DirBuilder;
|
use std::fs::{self, DirBuilder, OpenOptions};
|
||||||
use std::path::PathBuf;
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
// Creates the directory for tour
|
pub fn init() -> Result<(), TourError> {
|
||||||
pub fn init() -> Result<(), std::io::Error> {
|
|
||||||
let tour_dir = PathBuf::from(TOUR_DIR);
|
let tour_dir = PathBuf::from(TOUR_DIR);
|
||||||
|
|
||||||
|
if tour_dir.exists() {
|
||||||
|
return Err(TourError::TourAlreadyExists);
|
||||||
|
}
|
||||||
|
|
||||||
DirBuilder::new()
|
DirBuilder::new()
|
||||||
.recursive(true)
|
.recursive(true)
|
||||||
.create(tour_dir.join("steps"))?;
|
.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(())
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/list.rs
Normal file
22
src/list.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
109
src/main.rs
109
src/main.rs
@@ -1,5 +1,4 @@
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use std::error::Error;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
mod add;
|
mod add;
|
||||||
@@ -8,14 +7,19 @@ mod end;
|
|||||||
mod error;
|
mod error;
|
||||||
mod info;
|
mod info;
|
||||||
mod init;
|
mod init;
|
||||||
|
mod list;
|
||||||
|
mod reset;
|
||||||
|
mod rm;
|
||||||
|
mod status;
|
||||||
mod step;
|
mod step;
|
||||||
|
mod unstage;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
const TOUR_DIR: &str = "./.tour";
|
const TOUR_DIR: &str = "./.tour";
|
||||||
const SESSION_PATH: &str = "./.tour/session";
|
const SESSION_PATH: &str = "./.tour/session";
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[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 {
|
struct Args {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Commands>,
|
command: Option<Commands>,
|
||||||
@@ -23,15 +27,20 @@ struct Args {
|
|||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
// Create a new tour
|
/// Set up a new tour in the current directory
|
||||||
Init,
|
Init,
|
||||||
|
|
||||||
// Stage files for the next commit
|
/// Stage files for the next commit
|
||||||
Add {
|
Add {
|
||||||
files: Vec<PathBuf>,
|
files: Vec<PathBuf>,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Add steps to the tour
|
/// Remove files from staging
|
||||||
|
Unstage {
|
||||||
|
files: Vec<PathBuf>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Commit staged files as a new step
|
||||||
Commit {
|
Commit {
|
||||||
files: Vec<PathBuf>,
|
files: Vec<PathBuf>,
|
||||||
|
|
||||||
@@ -39,76 +48,72 @@ enum Commands {
|
|||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Finish the tour
|
/// Mark files for removal in the next commit
|
||||||
|
Rm {
|
||||||
|
files: Vec<PathBuf>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Finalise the tour
|
||||||
End {
|
End {
|
||||||
#[arg(short, long, value_name = "MESSAGE")]
|
#[arg(short, long, value_name = "MESSAGE")]
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Go to next step of tour
|
/// Advance n steps (default 1)
|
||||||
Next {
|
Next {
|
||||||
#[arg(short, value_name = "NUM STEPS")]
|
#[arg(short, value_name = "NUM STEPS")]
|
||||||
n: Option<i32>,
|
n: Option<u32>,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Go to previous step of tour
|
/// Go back n steps (default 1)
|
||||||
Prev {
|
Prev {
|
||||||
#[arg(short, value_name = "NUM STEPS")]
|
#[arg(short, value_name = "NUM STEPS")]
|
||||||
n: Option<i32>,
|
n: Option<u32>,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Go to a specific step of tour
|
/// Jump to step n
|
||||||
Step {
|
Step {
|
||||||
#[arg(value_name = "STEP")]
|
#[arg(value_name = "STEP")]
|
||||||
n: i32,
|
n: u32,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Go to beginning of tour
|
/// Load the first step
|
||||||
Start,
|
Start,
|
||||||
|
|
||||||
|
/// Show tour metadata
|
||||||
Info,
|
Info,
|
||||||
|
|
||||||
// Show help
|
/// Show current step and staged files
|
||||||
Help,
|
Status,
|
||||||
|
|
||||||
|
/// List all steps with messages
|
||||||
|
List,
|
||||||
|
|
||||||
|
/// Reset tour session and remove tracked files
|
||||||
|
Reset,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn help() {
|
fn main() {
|
||||||
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 <files...> Stage files for the next commit
|
|
||||||
tour commit [-m <msg>] Commit staged files as a new step
|
|
||||||
tour commit <files...> -m <msg> Stage and commit files in one step
|
|
||||||
tour end -m <msg> 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 <n> Jump to step n
|
|
||||||
|
|
||||||
\x1b[1mOTHER\x1b[0m
|
|
||||||
tour info Show tour metadata
|
|
||||||
tour help Show this help message"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
match args.command {
|
let result = match args.command {
|
||||||
Some(Commands::Init) => crate::init::init()?,
|
Some(Commands::Init) => crate::init::init(),
|
||||||
Some(Commands::Add { files }) => crate::add::add(files)?,
|
Some(Commands::Add { files }) => crate::add::add(files),
|
||||||
Some(Commands::Commit { files, message }) => crate::commit::commit(files, message)?,
|
Some(Commands::Unstage { files }) => crate::unstage::unstage(files),
|
||||||
Some(Commands::End { message }) => crate::end::end(message)?,
|
Some(Commands::Commit { files, message }) => crate::commit::commit(files, message),
|
||||||
Some(Commands::Next { n }) => crate::step::next(n)?,
|
Some(Commands::Rm { files }) => crate::rm::rm(files),
|
||||||
Some(Commands::Prev { n }) => crate::step::prev(n)?,
|
Some(Commands::End { message }) => crate::end::end(message),
|
||||||
Some(Commands::Step { n }) => crate::step::step_n(n)?,
|
Some(Commands::Next { n }) => crate::step::next(n),
|
||||||
Some(Commands::Start) => crate::step::step_n(0)?,
|
Some(Commands::Prev { n }) => crate::step::prev(n),
|
||||||
Some(Commands::Info) => crate::info::info()?,
|
Some(Commands::Step { n }) => crate::step::step_n(n),
|
||||||
Some(Commands::Help) | None => help(),
|
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(())
|
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/reset.rs
Normal file
18
src/reset.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
44
src/rm.rs
Normal file
44
src/rm.rs
Normal file
@@ -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<PathBuf>) -> Result<(), TourError> {
|
||||||
|
require_tour()?;
|
||||||
|
|
||||||
|
let existing = get_removed()?;
|
||||||
|
let existing_set: HashSet<PathBuf> = 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<Vec<PathBuf>, 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, "")
|
||||||
|
}
|
||||||
32
src/status.rs
Normal file
32
src/status.rs
Normal file
@@ -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(())
|
||||||
|
}
|
||||||
180
src/step.rs
180
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::SESSION_PATH;
|
||||||
use crate::TOUR_DIR;
|
use crate::TOUR_DIR;
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
@@ -12,102 +13,81 @@ const CYAN: &str = "\x1b[36m";
|
|||||||
const BOLD: &str = "\x1b[1m";
|
const BOLD: &str = "\x1b[1m";
|
||||||
const RESET: &str = "\x1b[0m";
|
const RESET: &str = "\x1b[0m";
|
||||||
|
|
||||||
pub fn step_n(n: i32) -> Result<(), io::Error> {
|
/// Jump to step `n` (1-based, as shown to the user).
|
||||||
let total = get_tour_step()
|
pub fn step_n(n: u32) -> Result<(), TourError> {
|
||||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
|
require_tour()?;
|
||||||
|
let total = get_tour_step()?;
|
||||||
|
|
||||||
if n < 0 || n >= total as i32 {
|
if n < 1 || n > total {
|
||||||
return Err(io::Error::new(
|
return Err(TourError::StepOutOfRange { step: n, total });
|
||||||
io::ErrorKind::InvalidInput,
|
|
||||||
format!("Step {} is out of range (0-{})", n, total - 1),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
step(n - current_step())
|
go_to_step(n - 1, total)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next(n: Option<i32>) -> Result<(), io::Error> {
|
pub fn next(n: Option<u32>) -> Result<(), TourError> {
|
||||||
step(n.unwrap_or(1))
|
require_tour()?;
|
||||||
}
|
let total = get_tour_step()?;
|
||||||
|
let delta = n.unwrap_or(1);
|
||||||
pub fn prev(n: Option<i32>) -> Result<(), io::Error> {
|
let target = match get_current_step() {
|
||||||
step(-n.unwrap_or(1))
|
Some(c) => c.saturating_add(delta),
|
||||||
}
|
None if delta > 0 => delta - 1,
|
||||||
|
None => return Err(TourError::StepOutOfRange { step: 0, total }),
|
||||||
/// Returns the current step as a signed integer.
|
};
|
||||||
/// Returns -1 when the session has no step yet (reader hasn't started).
|
if target >= total {
|
||||||
fn current_step() -> i32 {
|
return Err(TourError::StepOutOfRange {
|
||||||
fs::read_to_string(SESSION_PATH)
|
step: target + 1,
|
||||||
.ok()
|
total,
|
||||||
.and_then(|s| {
|
});
|
||||||
s.split("STEP=")
|
|
||||||
.nth(1)
|
|
||||||
.and_then(|v| v.trim().parse::<i32>().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),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
let new_step = new_step as u32;
|
go_to_step(target, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev(n: Option<u32>) -> 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 cwd = std::env::current_dir()?;
|
||||||
let tracked = get_tracked_files()?;
|
let tracked = get_tracked_files()?;
|
||||||
let old_files = snapshot_tracked_files(&cwd, &tracked)?;
|
let old_files = snapshot_tracked_files(&cwd, &tracked)?;
|
||||||
|
|
||||||
// Remove only tracked files from CWD
|
remove_tracked_files(&cwd, &tracked)?;
|
||||||
for relative in &tracked {
|
|
||||||
let full = cwd.join(relative);
|
|
||||||
if full.is_file() {
|
|
||||||
fs::remove_file(&full)?;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy step contents into CWD (skipping the message file)
|
// 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)? {
|
for entry in fs::read_dir(&step_dir)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
if entry.file_name() == "message" {
|
if entry.file_name() == "message" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
copy_into(&entry.path(), &cwd.join(entry.file_name()))?;
|
copy_tree(&entry.path(), &cwd.join(entry.file_name()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the new step
|
// 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)?;
|
let new_files = snapshot_tracked_files(&cwd, &tracked)?;
|
||||||
print_changes(&old_files, &new_files);
|
print_changes(&old_files, &new_files);
|
||||||
|
|
||||||
let message = fs::read_to_string(step_dir.join("message")).unwrap_or_default();
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot_tracked_files(root: &Path, tracked: &BTreeSet<PathBuf>) -> Result<BTreeMap<PathBuf, String>, io::Error> {
|
pub fn get_tracked_files() -> Result<BTreeSet<PathBuf>, 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<BTreeSet<PathBuf>, io::Error> {
|
|
||||||
let steps_dir = Path::new(TOUR_DIR).join("steps");
|
let steps_dir = Path::new(TOUR_DIR).join("steps");
|
||||||
let mut tracked = BTreeSet::new();
|
let mut tracked = BTreeSet::new();
|
||||||
|
|
||||||
@@ -124,6 +104,30 @@ fn get_tracked_files() -> Result<BTreeSet<PathBuf>, io::Error> {
|
|||||||
Ok(tracked)
|
Ok(tracked)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn remove_tracked_files(cwd: &Path, tracked: &BTreeSet<PathBuf>) -> 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(
|
fn collect_step_files(
|
||||||
step_root: &Path,
|
step_root: &Path,
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
@@ -145,13 +149,34 @@ fn collect_step_files(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_changes(old: &BTreeMap<PathBuf, String>, new: &BTreeMap<PathBuf, String>) {
|
/// Snapshots tracked files from root. Returns None for binary files (invalid UTF-8).
|
||||||
|
fn snapshot_tracked_files(
|
||||||
|
root: &Path,
|
||||||
|
tracked: &BTreeSet<PathBuf>,
|
||||||
|
) -> Result<BTreeMap<PathBuf, Option<String>>, 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<PathBuf, Option<String>>,
|
||||||
|
new: &BTreeMap<PathBuf, Option<String>>,
|
||||||
|
) {
|
||||||
for (path, new_content) in new {
|
for (path, new_content) in new {
|
||||||
match old.get(path) {
|
match old.get(path) {
|
||||||
None => println!("{GREEN} new: {}{RESET}", path.display()),
|
None => println!("{GREEN} new: {}{RESET}", path.display()),
|
||||||
Some(old_content) if old_content != new_content => {
|
Some(old_content) if old_content != new_content => {
|
||||||
println!("{CYAN} modified: {}{RESET}", path.display());
|
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 {
|
for &ci in &changed {
|
||||||
let lo = ci.saturating_sub(CTX);
|
let lo = ci.saturating_sub(CTX);
|
||||||
let hi = (ci + CTX + 1).min(ops.len());
|
let hi = (ci + CTX + 1).min(ops.len());
|
||||||
for v in lo..hi {
|
for item in visible.iter_mut().take(hi).skip(lo) {
|
||||||
visible[v] = true;
|
*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(())
|
|
||||||
}
|
|
||||||
|
|||||||
25
src/unstage.rs
Normal file
25
src/unstage.rs
Normal file
@@ -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<PathBuf>) -> 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(())
|
||||||
|
}
|
||||||
83
src/utils.rs
83
src/utils.rs
@@ -2,11 +2,54 @@ use std::fs;
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::error::TourError;
|
||||||
use crate::SESSION_PATH;
|
use crate::SESSION_PATH;
|
||||||
use crate::TOUR_DIR;
|
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<u32> {
|
||||||
|
fs::read_to_string(SESSION_PATH)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| {
|
||||||
|
s.split("STEP=")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|v| v.trim().parse::<u32>().ok())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_tour_step() -> Result<u32, TourError> {
|
||||||
|
let steps_dir = Path::new(TOUR_DIR).join("steps");
|
||||||
|
let mut indices: Vec<u32> = 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::<u32>().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.
|
/// 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> {
|
pub fn copy_path(src: &Path, dest_dir: &Path) -> Result<(), io::Error> {
|
||||||
let relative_src = if src.is_absolute() {
|
let relative_src = if src.is_absolute() {
|
||||||
let cwd = std::env::current_dir()?;
|
let cwd = std::env::current_dir()?;
|
||||||
@@ -32,6 +75,24 @@ pub fn copy_path(src: &Path, dest_dir: &Path) -> Result<(), io::Error> {
|
|||||||
Ok(())
|
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<bool, io::Error> {
|
pub fn is_descendant_of_current_dir(file: &Path) -> Result<bool, io::Error> {
|
||||||
is_file_in_dir(file, &std::env::current_dir()?)
|
is_file_in_dir(file, &std::env::current_dir()?)
|
||||||
}
|
}
|
||||||
@@ -41,23 +102,3 @@ pub fn is_file_in_dir(file: &Path, dir: &Path) -> Result<bool, io::Error> {
|
|||||||
let dir_canon = dir.canonicalize()?;
|
let dir_canon = dir.canonicalize()?;
|
||||||
Ok(file_canon.starts_with(&dir_canon))
|
Ok(file_canon.starts_with(&dir_canon))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_session_step() -> Result<u32, Box<dyn std::error::Error>> {
|
|
||||||
let session = fs::read_to_string(SESSION_PATH)?;
|
|
||||||
let step = session
|
|
||||||
.split("STEP=")
|
|
||||||
.nth(1)
|
|
||||||
.ok_or("no STEP in session")?
|
|
||||||
.trim()
|
|
||||||
.parse::<u32>()?;
|
|
||||||
Ok(step)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_tour_step() -> Result<u32, Box<dyn std::error::Error>> {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|||||||
475
tests/integration.rs
Normal file
475
tests/integration.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user