Compare commits
5 Commits
5093303e8d
...
c0a71bc285
| Author | SHA1 | Date | |
|---|---|---|---|
| c0a71bc285 | |||
| 1dea592fb2 | |||
| 4139733c5c | |||
| c11782564c | |||
| 2ffce0347e |
41
CLAUDE.md
Normal file
41
CLAUDE.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo build # build the project
|
||||||
|
cargo run -- <cmd> # run a subcommand (e.g. cargo run -- init)
|
||||||
|
cargo test # run all tests
|
||||||
|
cargo test <name> # run a single test by name
|
||||||
|
cargo clippy # lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## What This Project Is
|
||||||
|
|
||||||
|
`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>`
|
||||||
|
|
||||||
|
**Reader workflow:** `tour start` → `tour next [n]` / `tour prev [n]`
|
||||||
|
|
||||||
|
## 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/<N>/` — one numbered directory per step
|
||||||
|
- `.tour/session` — tracks current reader position as `STEP=<n>`
|
||||||
|
|
||||||
|
**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`)
|
||||||
|
|
||||||
|
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).
|
||||||
@@ -1,24 +1,37 @@
|
|||||||
|
use crate::error::CommitError;
|
||||||
|
use crate::utils::{copy_path, is_descendant_of_current_dir, is_file_in_dir};
|
||||||
use crate::TOUR_DIR;
|
use crate::TOUR_DIR;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::path::{Path, PathBuf};
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
pub fn commit(files: Vec<PathBuf>, message: String) -> Result<(), std::io::Error> {
|
pub fn commit(files: Vec<PathBuf>, message: String) -> Result<(), CommitError> {
|
||||||
// let files = files.iter().map(|p| p.as_ref()).collect();
|
let tour_dir = Path::new(TOUR_DIR);
|
||||||
//
|
|
||||||
// let dir = std::fs::read_dir(format!("{}/steps", TOUR_DIR))?;
|
for file in &files {
|
||||||
//
|
if !is_descendant_of_current_dir(file)? {
|
||||||
// // USE /steps to find number of next step
|
return Err(CommitError::NotADescendantOfCurrentDir(file.clone()));
|
||||||
// // let step_number =
|
}
|
||||||
//
|
if is_file_in_dir(file, tour_dir)? {
|
||||||
// fs::create_dir_all(format!("{}/{}", TOUR_DIR, "steps/0/files"))?;
|
return Err(CommitError::InsideTourDir(file.clone()));
|
||||||
//
|
}
|
||||||
// // Copy files listed by command to step 0
|
}
|
||||||
// let dest = format!("{}/steps/0/files/", TOUR_DIR);
|
|
||||||
// crate::utils::copy_files(files, dest.as_ref())?;
|
let steps_dir = tour_dir.join("steps");
|
||||||
//
|
let step_num = fs::read_dir(&steps_dir)?
|
||||||
// // Copy message
|
.filter_map(|e| e.ok())
|
||||||
// let mut message_file = fs::File::create(format!("{}/steps/{}/message", TOUR_DIR, step_number))?;
|
.filter(|e| e.path().is_dir())
|
||||||
// write!(message_file, "{}", message)?;
|
.count();
|
||||||
|
|
||||||
|
let step_dir = steps_dir.join(step_num.to_string());
|
||||||
|
fs::create_dir_all(&step_dir)?;
|
||||||
|
|
||||||
|
for file in &files {
|
||||||
|
copy_path(file, &step_dir)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::write(step_dir.join("message"), &message)?;
|
||||||
|
crate::info::update_last_modified()?;
|
||||||
|
|
||||||
|
println!("Step {}: {}", step_num, message);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/error.rs
Normal file
31
src/error.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use std::io;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CommitError {
|
||||||
|
NotADescendantOfCurrentDir(PathBuf),
|
||||||
|
InsideTourDir(PathBuf),
|
||||||
|
Io(io::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for CommitError {
|
||||||
|
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::Io(e) => write!(f, "IO error: {}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for CommitError {}
|
||||||
|
|
||||||
|
impl From<io::Error> for CommitError {
|
||||||
|
fn from(e: io::Error) -> Self {
|
||||||
|
Self::Io(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/info.rs
Normal file
138
src/info.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::io::{self, Write};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const INFO_PATH: &str = "./.tour/info";
|
||||||
|
|
||||||
|
struct TourInfo {
|
||||||
|
author: String,
|
||||||
|
description: String,
|
||||||
|
language: String,
|
||||||
|
created: String,
|
||||||
|
updated: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TourInfo {
|
||||||
|
fn parse(content: &str) -> Self {
|
||||||
|
let mut author = String::new();
|
||||||
|
let mut description = String::new();
|
||||||
|
let mut language = String::new();
|
||||||
|
let mut created = String::new();
|
||||||
|
let mut updated = String::new();
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
if let Some((key, value)) = line.split_once('=') {
|
||||||
|
match key {
|
||||||
|
"author" => author = value.to_string(),
|
||||||
|
"description" => description = value.to_string(),
|
||||||
|
"language" => language = value.to_string(),
|
||||||
|
"created" => created = value.to_string(),
|
||||||
|
"updated" => updated = value.to_string(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TourInfo { author, description, language, created, updated }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"author={}\ndescription={}\nlanguage={}\ncreated={}\nupdated={}\n",
|
||||||
|
self.author, self.description, self.language, self.created, self.updated
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_info() -> Result<(), io::Error> {
|
||||||
|
macro_rules! prompt {
|
||||||
|
($msg:expr) => {{
|
||||||
|
print!($msg);
|
||||||
|
io::stdout().flush()?;
|
||||||
|
let mut buf = String::new();
|
||||||
|
io::stdin().read_line(&mut buf)?;
|
||||||
|
buf.trim().to_string()
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
let author = prompt!("Author: ");
|
||||||
|
let description = prompt!("Description: ");
|
||||||
|
let language = prompt!("Language: ");
|
||||||
|
let today = current_date();
|
||||||
|
|
||||||
|
let info = TourInfo {
|
||||||
|
author,
|
||||||
|
description,
|
||||||
|
language,
|
||||||
|
created: today.clone(),
|
||||||
|
updated: today,
|
||||||
|
};
|
||||||
|
|
||||||
|
fs::write(INFO_PATH, info.serialize())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_info() -> Result<(), io::Error> {
|
||||||
|
let content = fs::read_to_string(INFO_PATH).map_err(|_| {
|
||||||
|
io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
|
"No tour info found. Run `tour init` to set up a tour.",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let info = TourInfo::parse(&content);
|
||||||
|
println!("Author: {}", info.author);
|
||||||
|
println!("Description: {}", info.description);
|
||||||
|
println!("Language: {}", info.language);
|
||||||
|
println!("Created: {}", info.created);
|
||||||
|
println!("Updated: {}", info.updated);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_last_modified() -> Result<(), io::Error> {
|
||||||
|
let content = fs::read_to_string(INFO_PATH).unwrap_or_default();
|
||||||
|
let mut info = TourInfo::parse(&content);
|
||||||
|
info.updated = current_date();
|
||||||
|
fs::write(INFO_PATH, info.serialize())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn info() -> Result<(), io::Error> {
|
||||||
|
get_info()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_date() -> String {
|
||||||
|
let secs = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_secs();
|
||||||
|
let (y, m, d) = days_to_ymd((secs / 86400) as u32);
|
||||||
|
format!("{:04}-{:02}-{:02}", y, m, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn days_to_ymd(mut days: u32) -> (u32, u32, u32) {
|
||||||
|
let mut year = 1970u32;
|
||||||
|
loop {
|
||||||
|
let days_in_year = if is_leap(year) { 366 } else { 365 };
|
||||||
|
if days < days_in_year {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
days -= days_in_year;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
let month_days = if is_leap(year) {
|
||||||
|
[31u32, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||||
|
} else {
|
||||||
|
[31u32, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||||
|
};
|
||||||
|
let mut month = 1u32;
|
||||||
|
for (i, &md) in month_days.iter().enumerate() {
|
||||||
|
if days < md {
|
||||||
|
month = i as u32 + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
days -= md;
|
||||||
|
}
|
||||||
|
(year, month, days + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_leap(year: u32) -> bool {
|
||||||
|
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
||||||
|
}
|
||||||
31
src/init.rs
31
src/init.rs
@@ -1,25 +1,20 @@
|
|||||||
// Create a .tour folder
|
// Create a .tour folder
|
||||||
// Populate with tour/steps/0/message
|
// Create directory ./.tour/steps that stores tutorial steps
|
||||||
// Populate with tour/steps/0/files/file1
|
// Create file ./.tour/session that logs sessions information
|
||||||
// Populate with tour/steps/0/files/file2
|
|
||||||
|
|
||||||
use crate::TOUR_DIR;
|
use crate::TOUR_DIR;
|
||||||
use crate::utils::copy_files;
|
use std::fs::DirBuilder;
|
||||||
|
|
||||||
use std::fs;
|
|
||||||
use std::fs::File;
|
|
||||||
use std::io::Write;
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
pub fn init(files: Vec<PathBuf>, message: String) -> Result<(), std::io::Error> {
|
// Creates the directory for tour
|
||||||
// // Convert PathBuf to &Path
|
pub fn init() -> Result<(), std::io::Error> {
|
||||||
// let files = files.iter().map(|p| p.as_path()).collect();
|
let tour_dir = PathBuf::from(TOUR_DIR);
|
||||||
//
|
|
||||||
// // Check TOUR_DIR exists (it shouldn't because user calls init)
|
|
||||||
// if fs::exists(TOUR_DIR)? {
|
|
||||||
// panic!("{} folder exists", TOUR_DIR);
|
|
||||||
// }
|
|
||||||
// // Create dir recursively
|
|
||||||
|
|
||||||
Ok(())
|
DirBuilder::new()
|
||||||
|
.recursive(true)
|
||||||
|
.create(tour_dir.join("steps"))?;
|
||||||
|
|
||||||
|
std::fs::File::create(tour_dir.join("session"))?;
|
||||||
|
|
||||||
|
crate::info::set_info()
|
||||||
}
|
}
|
||||||
|
|||||||
37
src/main.rs
37
src/main.rs
@@ -4,13 +4,14 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
mod commit;
|
mod commit;
|
||||||
mod end;
|
mod end;
|
||||||
|
mod error;
|
||||||
mod init;
|
mod init;
|
||||||
mod next;
|
mod step;
|
||||||
mod prev;
|
|
||||||
mod utils;
|
mod utils;
|
||||||
|
mod info;
|
||||||
|
|
||||||
const TOUR_DIR: &str = "./.tour";
|
const TOUR_DIR: &str = "./.tour";
|
||||||
const DEFAULT_SESSION: &str = "./.tour/session";
|
const SESSION_PATH: &str = "./.tour/session";
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(author, version, about, long_about = None)]
|
#[command(author, version, about, long_about = None)]
|
||||||
@@ -22,12 +23,8 @@ struct Args {
|
|||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Commands {
|
enum Commands {
|
||||||
// Create a new tour
|
// Create a new tour
|
||||||
Init {
|
Init,
|
||||||
#[arg(value_name = "FILES")]
|
|
||||||
files: Vec<PathBuf>,
|
|
||||||
#[arg(short, value_name = "MESSAGE")]
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
// Add steps to the tour
|
// Add steps to the tour
|
||||||
Commit {
|
Commit {
|
||||||
files: Vec<PathBuf>,
|
files: Vec<PathBuf>,
|
||||||
@@ -35,6 +32,7 @@ enum Commands {
|
|||||||
#[arg(short, long, value_name = "MESSAGE")]
|
#[arg(short, long, value_name = "MESSAGE")]
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Finish the tour
|
// Finish the tour
|
||||||
End {
|
End {
|
||||||
#[arg(short, long, value_name = "MESSAGE")]
|
#[arg(short, long, value_name = "MESSAGE")]
|
||||||
@@ -46,21 +44,36 @@ enum Commands {
|
|||||||
#[arg(short, value_name = "NUM STEPS")]
|
#[arg(short, value_name = "NUM STEPS")]
|
||||||
n: Option<i32>,
|
n: Option<i32>,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Go to previous step of tour
|
// Go to previous step of tour
|
||||||
Prev {
|
Prev {
|
||||||
#[arg(short, value_name = "NUM STEPS")]
|
#[arg(short, value_name = "NUM STEPS")]
|
||||||
n: Option<i32>,
|
n: Option<i32>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Go to a specific step of tour
|
||||||
|
Step {
|
||||||
|
#[arg(value_name = "STEP")]
|
||||||
|
n: i32,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Go to beginning of tour
|
||||||
|
Start,
|
||||||
|
|
||||||
|
Info,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn Error>> {
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
match args.command {
|
match args.command {
|
||||||
Some(Commands::Init { files, message }) => crate::init::init(files, message)?,
|
Some(Commands::Init) => crate::init::init()?,
|
||||||
Some(Commands::Commit { files, message }) => crate::commit::commit(files, message)?,
|
Some(Commands::Commit { files, message }) => crate::commit::commit(files, message)?,
|
||||||
Some(Commands::End { message }) => crate::end::end(message)?,
|
Some(Commands::End { message }) => crate::end::end(message)?,
|
||||||
Some(Commands::Next { n }) => crate::next::next(n)?,
|
Some(Commands::Next { n }) => crate::step::next(n)?,
|
||||||
Some(Commands::Prev { n }) => crate::prev::prev(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()?,
|
||||||
_ => println!("command not found"),
|
_ => println!("command not found"),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
pub fn next(n: Option<i32>) -> Result<(), std::io::Error> {
|
|
||||||
let n = n.unwrap_or(1);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
pub fn prev(n: Option<i32>) -> Result<(), std::io::Error> {
|
|
||||||
let n = n.unwrap_or(1);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
237
src/step.rs
Normal file
237
src/step.rs
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
use crate::utils::get_tour_step;
|
||||||
|
use crate::SESSION_PATH;
|
||||||
|
use crate::TOUR_DIR;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
const RED: &str = "\x1b[31m";
|
||||||
|
const GREEN: &str = "\x1b[32m";
|
||||||
|
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()))?;
|
||||||
|
|
||||||
|
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),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
step(n - current_step())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next(n: Option<i32>) -> Result<(), io::Error> {
|
||||||
|
step(n.unwrap_or(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prev(n: Option<i32>) -> 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::<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;
|
||||||
|
|
||||||
|
let cwd = std::env::current_dir()?;
|
||||||
|
let old_files = snapshot_files(&cwd)?;
|
||||||
|
|
||||||
|
// Clear CWD except .tour/
|
||||||
|
for entry in fs::read_dir(&cwd)? {
|
||||||
|
let entry = entry?;
|
||||||
|
if entry.file_name() == ".tour" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
fs::remove_dir_all(&path)?;
|
||||||
|
} else {
|
||||||
|
fs::remove_file(&path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy step contents into CWD (skipping the message file)
|
||||||
|
let step_dir = Path::new(TOUR_DIR).join("steps").join(new_step.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()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the new step
|
||||||
|
fs::write(SESSION_PATH, format!("STEP={}", new_step))?;
|
||||||
|
|
||||||
|
let new_files = snapshot_files(&cwd)?;
|
||||||
|
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());
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn snapshot_files(root: &Path) -> Result<BTreeMap<PathBuf, String>, io::Error> {
|
||||||
|
let mut files = BTreeMap::new();
|
||||||
|
collect_files(root, root, &mut files)?;
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_files(
|
||||||
|
root: &Path,
|
||||||
|
dir: &Path,
|
||||||
|
files: &mut BTreeMap<PathBuf, String>,
|
||||||
|
) -> Result<(), io::Error> {
|
||||||
|
for entry in fs::read_dir(dir)? {
|
||||||
|
let entry = entry?;
|
||||||
|
if entry.file_name() == ".tour" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() {
|
||||||
|
collect_files(root, &path, files)?;
|
||||||
|
} else {
|
||||||
|
let relative = path.strip_prefix(root).unwrap_or(&path).to_path_buf();
|
||||||
|
let content = fs::read_to_string(&path).unwrap_or_default();
|
||||||
|
files.insert(relative, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_changes(old: &BTreeMap<PathBuf, String>, new: &BTreeMap<PathBuf, String>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path in old.keys() {
|
||||||
|
if !new.contains_key(path) {
|
||||||
|
println!("{RED} deleted: {}{RESET}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn print_diff(old: &str, new: &str) {
|
||||||
|
let old_lines: Vec<&str> = old.lines().collect();
|
||||||
|
let new_lines: Vec<&str> = new.lines().collect();
|
||||||
|
|
||||||
|
let m = old_lines.len();
|
||||||
|
let n = new_lines.len();
|
||||||
|
|
||||||
|
// LCS table
|
||||||
|
let mut dp = vec![vec![0usize; n + 1]; m + 1];
|
||||||
|
for i in 1..=m {
|
||||||
|
for j in 1..=n {
|
||||||
|
if old_lines[i - 1] == new_lines[j - 1] {
|
||||||
|
dp[i][j] = dp[i - 1][j - 1] + 1;
|
||||||
|
} else {
|
||||||
|
dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backtrack
|
||||||
|
let mut ops: Vec<(char, &str)> = Vec::new();
|
||||||
|
let (mut i, mut j) = (m, n);
|
||||||
|
while i > 0 || j > 0 {
|
||||||
|
if i > 0 && j > 0 && old_lines[i - 1] == new_lines[j - 1] {
|
||||||
|
ops.push((' ', old_lines[i - 1]));
|
||||||
|
i -= 1;
|
||||||
|
j -= 1;
|
||||||
|
} else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
|
||||||
|
ops.push(('+', new_lines[j - 1]));
|
||||||
|
j -= 1;
|
||||||
|
} else {
|
||||||
|
ops.push(('-', old_lines[i - 1]));
|
||||||
|
i -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ops.reverse();
|
||||||
|
|
||||||
|
// Print with context: only show changed lines and up to 2 surrounding context lines
|
||||||
|
const CTX: usize = 2;
|
||||||
|
let changed: Vec<usize> = ops
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, (op, _))| *op != ' ')
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if changed.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut visible = vec![false; ops.len()];
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut prev_visible = false;
|
||||||
|
for (idx, (op, line)) in ops.iter().enumerate() {
|
||||||
|
if !visible[idx] {
|
||||||
|
if prev_visible {
|
||||||
|
println!(" ...");
|
||||||
|
}
|
||||||
|
prev_visible = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
prev_visible = true;
|
||||||
|
match op {
|
||||||
|
'+' => println!("{GREEN} + {line}{RESET}"),
|
||||||
|
'-' => println!("{RED} - {line}{RESET}"),
|
||||||
|
_ => println!(" {line}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
63
src/utils.rs
63
src/utils.rs
@@ -2,23 +2,62 @@ use std::fs;
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
pub fn copy_files(files: Vec<&Path>, dest_dir: &Path) -> Result<(), io::Error> {
|
use crate::SESSION_PATH;
|
||||||
for file in files {
|
use crate::TOUR_DIR;
|
||||||
// Get the relative path components
|
|
||||||
let dest_path = dest_dir.join(file);
|
|
||||||
|
|
||||||
// Create parent directories if they don't exist
|
/// Copies a file or directory into dest_dir, preserving relative path structure.
|
||||||
if let Some(parent) = dest_path.parent() {
|
/// 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()?;
|
||||||
|
src.strip_prefix(&cwd).unwrap_or(src).to_path_buf()
|
||||||
|
} else {
|
||||||
|
src.to_path_buf()
|
||||||
|
};
|
||||||
|
|
||||||
|
if src.is_dir() {
|
||||||
|
let dest = dest_dir.join(&relative_src);
|
||||||
|
fs::create_dir_all(&dest)?;
|
||||||
|
for entry in fs::read_dir(src)? {
|
||||||
|
let entry = entry?;
|
||||||
|
copy_path(&entry.path(), dest_dir)?;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let dest = dest_dir.join(&relative_src);
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
fs::create_dir_all(parent)?;
|
fs::create_dir_all(parent)?;
|
||||||
}
|
}
|
||||||
|
fs::copy(src, &dest)?;
|
||||||
// Copy the file
|
|
||||||
fs::copy(file, dest_path)?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn session_step(session_file: &Path) -> Result<u32, Box<dyn std::error::Error>> {
|
pub fn is_descendant_of_current_dir(file: &Path) -> Result<bool, io::Error> {
|
||||||
// Read ./.tour/session to find what step user is currently looking at
|
is_file_in_dir(file, &std::env::current_dir()?)
|
||||||
Ok((0))
|
}
|
||||||
|
|
||||||
|
pub fn is_file_in_dir(file: &Path, dir: &Path) -> Result<bool, io::Error> {
|
||||||
|
let file_canon = file.canonicalize()?;
|
||||||
|
let dir_canon = dir.canonicalize()?;
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user