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:
2026-03-05 21:23:39 +00:00
parent 399a72f380
commit 507c61fe5f
19 changed files with 1513 additions and 268 deletions

View File

@@ -1,5 +1,5 @@
use crate::error::CommitError;
use crate::utils::{is_descendant_of_current_dir, is_file_in_dir};
use crate::error::TourError;
use crate::utils::{is_descendant_of_current_dir, is_file_in_dir, require_tour};
use crate::TOUR_DIR;
use std::fs::{self, OpenOptions};
use std::io::Write;
@@ -7,26 +7,37 @@ use std::path::{Path, PathBuf};
pub const STAGED_PATH: &str = "./.tour/staged";
pub fn add(files: Vec<PathBuf>) -> Result<(), CommitError> {
pub fn add(files: Vec<PathBuf>) -> Result<(), TourError> {
require_tour()?;
let tour_dir = Path::new(TOUR_DIR);
for file in &files {
if !file.exists() {
return Err(TourError::FileNotFound(file.clone()));
}
if !is_descendant_of_current_dir(file)? {
return Err(CommitError::NotADescendantOfCurrentDir(file.clone()));
return Err(TourError::NotADescendant(file.clone()));
}
if is_file_in_dir(file, tour_dir)? {
return Err(CommitError::InsideTourDir(file.clone()));
return Err(TourError::InsideTourDir(file.clone()));
}
}
let existing = get_staged()?;
let existing_set: std::collections::HashSet<PathBuf> = existing.into_iter().collect();
let mut staged = OpenOptions::new()
.append(true)
.create(true)
.open(STAGED_PATH)?;
for file in &files {
writeln!(staged, "{}", file.display())?;
println!("staged: {}", file.display());
if existing_set.contains(file) {
println!("already staged: {}", file.display());
} else {
writeln!(staged, "{}", file.display())?;
println!("staged: {}", file.display());
}
}
Ok(())

View File

@@ -1,17 +1,25 @@
use crate::add::{clear_staged, get_staged};
use crate::error::CommitError;
use crate::utils::{copy_path, is_descendant_of_current_dir, is_file_in_dir};
use crate::error::TourError;
use crate::rm::{clear_removed, get_removed};
use crate::utils::{copy_path, get_tour_step, is_descendant_of_current_dir, is_file_in_dir, require_tour};
use crate::TOUR_DIR;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
pub fn commit(files: Vec<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 files = if files.is_empty() {
if tour_dir.join("ended").exists() {
return Err(TourError::TourEnded);
}
let used_staging = files.is_empty();
let files = if used_staging {
let staged = get_staged()?;
if staged.is_empty() {
return Err(CommitError::NothingToCommit);
return Err(TourError::NothingToCommit);
}
staged
} else {
@@ -19,31 +27,83 @@ pub fn commit(files: Vec<PathBuf>, message: String) -> Result<(), CommitError> {
};
for file in &files {
if !file.exists() {
return Err(TourError::FileNotFound(file.clone()));
}
if !is_descendant_of_current_dir(file)? {
return Err(CommitError::NotADescendantOfCurrentDir(file.clone()));
return Err(TourError::NotADescendant(file.clone()));
}
if is_file_in_dir(file, tour_dir)? {
return Err(CommitError::InsideTourDir(file.clone()));
return Err(TourError::InsideTourDir(file.clone()));
}
}
let steps_dir = tour_dir.join("steps");
let step_num = fs::read_dir(&steps_dir)?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.count();
let removed = get_removed()?;
let removed_set: HashSet<PathBuf> = removed.into_iter().collect();
let step_num = get_tour_step()? as usize;
let steps_dir = tour_dir.join("steps");
let step_dir = steps_dir.join(step_num.to_string());
fs::create_dir_all(&step_dir)?;
// Carry forward files from previous step (excluding removed files)
if step_num > 0 {
let prev_dir = steps_dir.join((step_num - 1).to_string());
if prev_dir.exists() {
carry_forward(&prev_dir, &prev_dir, &step_dir, &removed_set)?;
}
}
// Overlay new files (these take precedence over carried-forward ones)
for file in &files {
copy_path(file, &step_dir)?;
}
fs::write(step_dir.join("message"), &message)?;
clear_staged()?;
// Only clear staging if we used it
if used_staging {
clear_staged()?;
}
clear_removed()?;
crate::info::update_last_modified()?;
println!("Step {}: {}", step_num, message);
println!("Step {}: {}", step_num + 1, message);
Ok(())
}
fn carry_forward(
step_root: &Path,
src: &Path,
dest_root: &Path,
removed: &HashSet<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(())
}

View File

@@ -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(())
}

View File

@@ -2,33 +2,53 @@ use std::io;
use std::path::PathBuf;
#[derive(Debug)]
pub enum CommitError {
NotADescendantOfCurrentDir(PathBuf),
InsideTourDir(PathBuf),
pub enum TourError {
NoTour,
TourAlreadyExists,
TourEnded,
NothingToCommit,
NoSteps,
NotADescendant(PathBuf),
InsideTourDir(PathBuf),
FileNotFound(PathBuf),
StepOutOfRange { step: u32, total: u32 },
CorruptedTour(String),
Io(io::Error),
}
impl std::fmt::Display for CommitError {
impl std::fmt::Display for TourError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotADescendantOfCurrentDir(path) => {
write!(f, "File {:?} is not a descendant of the working directory.", path)
}
Self::InsideTourDir(path) => {
write!(f, "File {:?} is inside a .tour directory, which is not allowed.", path)
Self::NoTour => {
write!(f, "No tour found in this directory. Run `tour init` first.")
}
Self::TourAlreadyExists => write!(f, "A tour already exists in this directory."),
Self::TourEnded => write!(f, "Tour has already been ended."),
Self::NothingToCommit => {
write!(f, "Nothing to commit. Use `tour add <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 {
Self::Io(e)
}

View File

@@ -94,8 +94,10 @@ pub fn update_last_modified() -> Result<(), io::Error> {
fs::write(INFO_PATH, info.serialize())
}
pub fn info() -> Result<(), io::Error> {
get_info()
pub fn info() -> Result<(), crate::error::TourError> {
crate::utils::require_tour()?;
get_info()?;
Ok(())
}
fn current_date() -> String {
@@ -134,5 +136,5 @@ fn days_to_ymd(mut days: u32) -> (u32, u32, u32) {
}
fn is_leap(year: u32) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
(year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
}

View File

@@ -1,20 +1,51 @@
// Create a .tour folder
// Create directory ./.tour/steps that stores tutorial steps
// Create file ./.tour/session that logs sessions information
use crate::error::TourError;
use crate::TOUR_DIR;
use std::fs::DirBuilder;
use std::path::PathBuf;
use std::fs::{self, DirBuilder, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
// Creates the directory for tour
pub fn init() -> Result<(), std::io::Error> {
pub fn init() -> Result<(), TourError> {
let tour_dir = PathBuf::from(TOUR_DIR);
if tour_dir.exists() {
return Err(TourError::TourAlreadyExists);
}
DirBuilder::new()
.recursive(true)
.create(tour_dir.join("steps"))?;
std::fs::File::create(tour_dir.join("session"))?;
fs::File::create(tour_dir.join("session"))?;
crate::info::set_info()
crate::info::set_info()?;
update_gitignore()?;
Ok(())
}
fn update_gitignore() -> Result<(), std::io::Error> {
let gitignore = Path::new(".gitignore");
let entries = [".tour/session", ".tour/staged", ".tour/removed"];
let existing = fs::read_to_string(gitignore).unwrap_or_default();
let mut to_add = Vec::new();
for entry in &entries {
if !existing.lines().any(|l| l.trim() == *entry) {
to_add.push(*entry);
}
}
if !to_add.is_empty() {
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open(gitignore)?;
if !existing.is_empty() && !existing.ends_with('\n') {
writeln!(file)?;
}
for entry in to_add {
writeln!(file, "{}", entry)?;
}
}
Ok(())
}

22
src/list.rs Normal file
View 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(())
}

View File

@@ -1,5 +1,4 @@
use clap::{Parser, Subcommand};
use std::error::Error;
use std::path::PathBuf;
mod add;
@@ -8,14 +7,19 @@ mod end;
mod error;
mod info;
mod init;
mod list;
mod reset;
mod rm;
mod status;
mod step;
mod unstage;
mod utils;
const TOUR_DIR: &str = "./.tour";
const SESSION_PATH: &str = "./.tour/session";
#[derive(Parser)]
#[command(author, version, about, long_about = None, disable_help_subcommand = true)]
#[command(author, version, about = "Create and navigate code tutorials as a series of snapshots", arg_required_else_help = true)]
struct Args {
#[command(subcommand)]
command: Option<Commands>,
@@ -23,15 +27,20 @@ struct Args {
#[derive(Subcommand)]
enum Commands {
// Create a new tour
/// Set up a new tour in the current directory
Init,
// Stage files for the next commit
/// Stage files for the next commit
Add {
files: Vec<PathBuf>,
},
// Add steps to the tour
/// Remove files from staging
Unstage {
files: Vec<PathBuf>,
},
/// Commit staged files as a new step
Commit {
files: Vec<PathBuf>,
@@ -39,76 +48,72 @@ enum Commands {
message: String,
},
// Finish the tour
/// Mark files for removal in the next commit
Rm {
files: Vec<PathBuf>,
},
/// Finalise the tour
End {
#[arg(short, long, value_name = "MESSAGE")]
message: String,
},
// Go to next step of tour
/// Advance n steps (default 1)
Next {
#[arg(short, value_name = "NUM STEPS")]
n: Option<i32>,
n: Option<u32>,
},
// Go to previous step of tour
/// Go back n steps (default 1)
Prev {
#[arg(short, value_name = "NUM STEPS")]
n: Option<i32>,
n: Option<u32>,
},
// Go to a specific step of tour
/// Jump to step n
Step {
#[arg(value_name = "STEP")]
n: i32,
n: u32,
},
// Go to beginning of tour
/// Load the first step
Start,
/// Show tour metadata
Info,
// Show help
Help,
/// Show current step and staged files
Status,
/// List all steps with messages
List,
/// Reset tour session and remove tracked files
Reset,
}
fn help() {
println!(
"\
\x1b[1mtour\x1b[0m — create and navigate code tutorials as a series of snapshots
\x1b[1mAUTHOR WORKFLOW\x1b[0m
tour init Set up a new tour in the current directory
tour add <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>> {
fn main() {
let args = Args::parse();
match args.command {
Some(Commands::Init) => crate::init::init()?,
Some(Commands::Add { files }) => crate::add::add(files)?,
Some(Commands::Commit { files, message }) => crate::commit::commit(files, message)?,
Some(Commands::End { message }) => crate::end::end(message)?,
Some(Commands::Next { n }) => crate::step::next(n)?,
Some(Commands::Prev { n }) => crate::step::prev(n)?,
Some(Commands::Step { n }) => crate::step::step_n(n)?,
Some(Commands::Start) => crate::step::step_n(0)?,
Some(Commands::Info) => crate::info::info()?,
Some(Commands::Help) | None => help(),
let result = match args.command {
Some(Commands::Init) => crate::init::init(),
Some(Commands::Add { files }) => crate::add::add(files),
Some(Commands::Unstage { files }) => crate::unstage::unstage(files),
Some(Commands::Commit { files, message }) => crate::commit::commit(files, message),
Some(Commands::Rm { files }) => crate::rm::rm(files),
Some(Commands::End { message }) => crate::end::end(message),
Some(Commands::Next { n }) => crate::step::next(n),
Some(Commands::Prev { n }) => crate::step::prev(n),
Some(Commands::Step { n }) => crate::step::step_n(n),
Some(Commands::Start) => crate::step::step_n(1),
Some(Commands::Info) => crate::info::info(),
Some(Commands::Status) => crate::status::status(),
Some(Commands::List) => crate::list::list(),
Some(Commands::Reset) => crate::reset::reset(),
None => Ok(()),
};
if let Err(e) = result {
eprintln!("Error: {}", e);
std::process::exit(1);
}
Ok(())
}

18
src/reset.rs Normal file
View 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
View 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
View 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(())
}

View File

@@ -1,4 +1,5 @@
use crate::utils::get_tour_step;
use crate::error::TourError;
use crate::utils::{copy_tree, get_current_step, get_tour_step, require_tour};
use crate::SESSION_PATH;
use crate::TOUR_DIR;
use std::collections::{BTreeMap, BTreeSet};
@@ -12,102 +13,81 @@ const CYAN: &str = "\x1b[36m";
const BOLD: &str = "\x1b[1m";
const RESET: &str = "\x1b[0m";
pub fn step_n(n: i32) -> Result<(), io::Error> {
let total = get_tour_step()
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
/// Jump to step `n` (1-based, as shown to the user).
pub fn step_n(n: u32) -> Result<(), TourError> {
require_tour()?;
let total = get_tour_step()?;
if n < 0 || n >= total as i32 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("Step {} is out of range (0-{})", n, total - 1),
));
if n < 1 || n > total {
return Err(TourError::StepOutOfRange { step: n, total });
}
step(n - current_step())
go_to_step(n - 1, total)
}
pub fn next(n: Option<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),
));
pub fn next(n: Option<u32>) -> Result<(), TourError> {
require_tour()?;
let total = get_tour_step()?;
let delta = n.unwrap_or(1);
let target = match get_current_step() {
Some(c) => c.saturating_add(delta),
None if delta > 0 => delta - 1,
None => return Err(TourError::StepOutOfRange { step: 0, total }),
};
if target >= total {
return Err(TourError::StepOutOfRange {
step: target + 1,
total,
});
}
let new_step = new_step as u32;
go_to_step(target, total)
}
pub fn prev(n: Option<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 tracked = get_tracked_files()?;
let old_files = snapshot_tracked_files(&cwd, &tracked)?;
// Remove only tracked files from CWD
for relative in &tracked {
let full = cwd.join(relative);
if full.is_file() {
fs::remove_file(&full)?;
}
}
remove_tracked_files(&cwd, &tracked)?;
// Copy step contents into CWD (skipping the message file)
let step_dir = Path::new(TOUR_DIR).join("steps").join(new_step.to_string());
let step_dir = Path::new(TOUR_DIR).join("steps").join(target.to_string());
for entry in fs::read_dir(&step_dir)? {
let entry = entry?;
if entry.file_name() == "message" {
continue;
}
copy_into(&entry.path(), &cwd.join(entry.file_name()))?;
copy_tree(&entry.path(), &cwd.join(entry.file_name()))?;
}
// Persist the new step
fs::write(SESSION_PATH, format!("STEP={}", new_step))?;
fs::write(SESSION_PATH, format!("STEP={}", target))?;
let new_files = snapshot_tracked_files(&cwd, &tracked)?;
print_changes(&old_files, &new_files);
let message = fs::read_to_string(step_dir.join("message")).unwrap_or_default();
println!("\n{BOLD}Step {new_step}/{total}:{RESET} {}", message.trim());
println!(
"\n{BOLD}Step {}/{total}:{RESET} {}",
target + 1,
message.trim()
);
Ok(())
}
fn snapshot_tracked_files(root: &Path, tracked: &BTreeSet<PathBuf>) -> Result<BTreeMap<PathBuf, String>, 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> {
pub fn get_tracked_files() -> Result<BTreeSet<PathBuf>, io::Error> {
let steps_dir = Path::new(TOUR_DIR).join("steps");
let mut tracked = BTreeSet::new();
@@ -124,6 +104,30 @@ fn get_tracked_files() -> Result<BTreeSet<PathBuf>, io::Error> {
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(
step_root: &Path,
dir: &Path,
@@ -145,13 +149,34 @@ fn collect_step_files(
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 {
match old.get(path) {
None => println!("{GREEN} new: {}{RESET}", path.display()),
Some(old_content) if old_content != new_content => {
println!("{CYAN} modified: {}{RESET}", path.display());
print_diff(old_content, new_content);
match (old_content, new_content) {
(Some(o), Some(n)) => print_diff(o, n),
_ => println!(" (binary file)"),
}
}
_ => {}
}
@@ -217,8 +242,8 @@ fn print_diff(old: &str, new: &str) {
for &ci in &changed {
let lo = ci.saturating_sub(CTX);
let hi = (ci + CTX + 1).min(ops.len());
for v in lo..hi {
visible[v] = true;
for item in visible.iter_mut().take(hi).skip(lo) {
*item = true;
}
}
@@ -239,16 +264,3 @@ fn print_diff(old: &str, new: &str) {
}
}
}
fn copy_into(src: &Path, dest: &Path) -> Result<(), io::Error> {
if src.is_dir() {
fs::create_dir_all(dest)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
copy_into(&entry.path(), &dest.join(entry.file_name()))?;
}
} else {
fs::copy(src, dest)?;
}
Ok(())
}

25
src/unstage.rs Normal file
View 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(())
}

View File

@@ -2,11 +2,54 @@ use std::fs;
use std::io;
use std::path::Path;
use crate::error::TourError;
use crate::SESSION_PATH;
use crate::TOUR_DIR;
pub fn require_tour() -> Result<(), TourError> {
if !Path::new(TOUR_DIR).exists() {
return Err(TourError::NoTour);
}
Ok(())
}
pub fn get_current_step() -> Option<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.
/// e.g. `src/main.rs` → `dest_dir/src/main.rs`
pub fn copy_path(src: &Path, dest_dir: &Path) -> Result<(), io::Error> {
let relative_src = if src.is_absolute() {
let cwd = std::env::current_dir()?;
@@ -32,6 +75,24 @@ pub fn copy_path(src: &Path, dest_dir: &Path) -> Result<(), io::Error> {
Ok(())
}
/// Recursively copies src to dest. If src is a directory, copies its contents
/// into dest. If src is a file, copies it to dest.
pub fn copy_tree(src: &Path, dest: &Path) -> Result<(), io::Error> {
if src.is_dir() {
fs::create_dir_all(dest)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
copy_tree(&entry.path(), &dest.join(entry.file_name()))?;
}
} else {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(src, dest)?;
}
Ok(())
}
pub fn is_descendant_of_current_dir(file: &Path) -> Result<bool, io::Error> {
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()?;
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)
}