From 399a72f3808f01a27e83d28d33115175f8ddd743 Mon Sep 17 00:00:00 2001 From: Adam French Date: Tue, 3 Mar 2026 11:16:22 +0000 Subject: [PATCH] Add staged files (so random files don't get removed) --- src/add.rs | 46 +++++++++++++++++++++++++++++++++++ src/commit.rs | 12 +++++++++ src/error.rs | 4 +++ src/main.rs | 40 +++++++++++++++++++++++++++--- src/step.rs | 67 ++++++++++++++++++++++++++++++++------------------- 5 files changed, 141 insertions(+), 28 deletions(-) create mode 100644 src/add.rs diff --git a/src/add.rs b/src/add.rs new file mode 100644 index 0000000..d99fea2 --- /dev/null +++ b/src/add.rs @@ -0,0 +1,46 @@ +use crate::error::CommitError; +use crate::utils::{is_descendant_of_current_dir, is_file_in_dir}; +use crate::TOUR_DIR; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +pub const STAGED_PATH: &str = "./.tour/staged"; + +pub fn add(files: Vec) -> Result<(), CommitError> { + let tour_dir = Path::new(TOUR_DIR); + + for file in &files { + if !is_descendant_of_current_dir(file)? { + return Err(CommitError::NotADescendantOfCurrentDir(file.clone())); + } + if is_file_in_dir(file, tour_dir)? { + return Err(CommitError::InsideTourDir(file.clone())); + } + } + + let mut staged = OpenOptions::new() + .append(true) + .create(true) + .open(STAGED_PATH)?; + + for file in &files { + writeln!(staged, "{}", file.display())?; + println!("staged: {}", file.display()); + } + + Ok(()) +} + +pub fn get_staged() -> Result, std::io::Error> { + let content = fs::read_to_string(STAGED_PATH).unwrap_or_default(); + Ok(content + .lines() + .filter(|l| !l.is_empty()) + .map(PathBuf::from) + .collect()) +} + +pub fn clear_staged() -> Result<(), std::io::Error> { + fs::write(STAGED_PATH, "") +} diff --git a/src/commit.rs b/src/commit.rs index e7c4455..aebcc4e 100644 --- a/src/commit.rs +++ b/src/commit.rs @@ -1,3 +1,4 @@ +use crate::add::{clear_staged, get_staged}; use crate::error::CommitError; use crate::utils::{copy_path, is_descendant_of_current_dir, is_file_in_dir}; use crate::TOUR_DIR; @@ -7,6 +8,16 @@ use std::path::{Path, PathBuf}; pub fn commit(files: Vec, message: String) -> Result<(), CommitError> { let tour_dir = Path::new(TOUR_DIR); + let files = if files.is_empty() { + let staged = get_staged()?; + if staged.is_empty() { + return Err(CommitError::NothingToCommit); + } + staged + } else { + files + }; + for file in &files { if !is_descendant_of_current_dir(file)? { return Err(CommitError::NotADescendantOfCurrentDir(file.clone())); @@ -30,6 +41,7 @@ pub fn commit(files: Vec, message: String) -> Result<(), CommitError> { } fs::write(step_dir.join("message"), &message)?; + clear_staged()?; crate::info::update_last_modified()?; println!("Step {}: {}", step_num, message); diff --git a/src/error.rs b/src/error.rs index a74d313..ae3405e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,6 +5,7 @@ use std::path::PathBuf; pub enum CommitError { NotADescendantOfCurrentDir(PathBuf), InsideTourDir(PathBuf), + NothingToCommit, Io(io::Error), } @@ -17,6 +18,9 @@ impl std::fmt::Display for CommitError { Self::InsideTourDir(path) => { write!(f, "File {:?} is inside a .tour directory, which is not allowed.", path) } + Self::NothingToCommit => { + write!(f, "Nothing to commit. Use `tour add ` to stage files first.") + } Self::Io(e) => write!(f, "IO error: {}", e), } } diff --git a/src/main.rs b/src/main.rs index 7b36ead..275f6ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,19 +2,20 @@ use clap::{Parser, Subcommand}; use std::error::Error; use std::path::PathBuf; +mod add; mod commit; mod end; mod error; +mod info; mod init; mod step; mod utils; -mod info; const TOUR_DIR: &str = "./.tour"; const SESSION_PATH: &str = "./.tour/session"; #[derive(Parser)] -#[command(author, version, about, long_about = None)] +#[command(author, version, about, long_about = None, disable_help_subcommand = true)] struct Args { #[command(subcommand)] command: Option, @@ -25,6 +26,11 @@ enum Commands { // Create a new tour Init, + // Stage files for the next commit + Add { + files: Vec, + }, + // Add steps to the tour Commit { files: Vec, @@ -61,12 +67,40 @@ enum Commands { Start, Info, + + // Show help + Help, +} + +fn help() { + println!( + "\ +\x1b[1mtour\x1b[0m — create and navigate code tutorials as a series of snapshots + +\x1b[1mAUTHOR WORKFLOW\x1b[0m + tour init Set up a new tour in the current directory + tour add Stage files for the next commit + tour commit [-m ] Commit staged files as a new step + tour commit -m Stage and commit files in one step + tour end -m Finalise the tour + +\x1b[1mREADER WORKFLOW\x1b[0m + tour start Load the first step + tour next [n] Advance n steps (default 1) + tour prev [n] Go back n steps (default 1) + tour step Jump to step n + +\x1b[1mOTHER\x1b[0m + tour info Show tour metadata + tour help Show this help message" + ); } fn main() -> Result<(), Box> { let args = Args::parse(); match args.command { Some(Commands::Init) => crate::init::init()?, + Some(Commands::Add { files }) => crate::add::add(files)?, Some(Commands::Commit { files, message }) => crate::commit::commit(files, message)?, Some(Commands::End { message }) => crate::end::end(message)?, Some(Commands::Next { n }) => crate::step::next(n)?, @@ -74,7 +108,7 @@ fn main() -> Result<(), Box> { 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"), + Some(Commands::Help) | None => help(), } Ok(()) } diff --git a/src/step.rs b/src/step.rs index a9948d5..41ed664 100644 --- a/src/step.rs +++ b/src/step.rs @@ -1,7 +1,7 @@ use crate::utils::get_tour_step; use crate::SESSION_PATH; use crate::TOUR_DIR; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::io; use std::path::{Path, PathBuf}; @@ -62,19 +62,14 @@ fn step(delta: i32) -> Result<(), io::Error> { let new_step = new_step as u32; let cwd = std::env::current_dir()?; - let old_files = snapshot_files(&cwd)?; + let tracked = get_tracked_files()?; + let old_files = snapshot_tracked_files(&cwd, &tracked)?; - // 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)?; + // Remove only tracked files from CWD + for relative in &tracked { + let full = cwd.join(relative); + if full.is_file() { + fs::remove_file(&full)?; } } @@ -91,7 +86,7 @@ fn step(delta: i32) -> Result<(), io::Error> { // Persist the new step fs::write(SESSION_PATH, format!("STEP={}", new_step))?; - let new_files = snapshot_files(&cwd)?; + let new_files = snapshot_tracked_files(&cwd, &tracked)?; print_changes(&old_files, &new_files); let message = fs::read_to_string(step_dir.join("message")).unwrap_or_default(); @@ -100,29 +95,51 @@ fn step(delta: i32) -> Result<(), io::Error> { Ok(()) } -fn snapshot_files(root: &Path) -> Result, io::Error> { +fn snapshot_tracked_files(root: &Path, tracked: &BTreeSet) -> Result, io::Error> { let mut files = BTreeMap::new(); - collect_files(root, root, &mut files)?; + 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 collect_files( - root: &Path, +fn get_tracked_files() -> Result, io::Error> { + let steps_dir = Path::new(TOUR_DIR).join("steps"); + let mut tracked = BTreeSet::new(); + + if !steps_dir.exists() { + return Ok(tracked); + } + + for entry in fs::read_dir(&steps_dir)? { + let entry = entry?; + if entry.path().is_dir() { + collect_step_files(&entry.path(), &entry.path(), &mut tracked)?; + } + } + Ok(tracked) +} + +fn collect_step_files( + step_root: &Path, dir: &Path, - files: &mut BTreeMap, + files: &mut BTreeSet, ) -> Result<(), io::Error> { for entry in fs::read_dir(dir)? { let entry = entry?; - if entry.file_name() == ".tour" { + let path = entry.path(); + let relative = path.strip_prefix(step_root).unwrap_or(&path).to_path_buf(); + if relative == Path::new("message") { continue; } - let path = entry.path(); if path.is_dir() { - collect_files(root, &path, files)?; + collect_step_files(step_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); + files.insert(relative); } } Ok(())