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:
25
src/add.rs
25
src/add.rs
@@ -1,5 +1,5 @@
|
||||
use crate::error::CommitError;
|
||||
use crate::utils::{is_descendant_of_current_dir, is_file_in_dir};
|
||||
use crate::error::TourError;
|
||||
use crate::utils::{is_descendant_of_current_dir, is_file_in_dir, require_tour};
|
||||
use crate::TOUR_DIR;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
@@ -7,26 +7,37 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
pub const STAGED_PATH: &str = "./.tour/staged";
|
||||
|
||||
pub fn add(files: Vec<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(())
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
44
src/error.rs
44
src/error.rs
@@ -2,33 +2,53 @@ use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CommitError {
|
||||
NotADescendantOfCurrentDir(PathBuf),
|
||||
InsideTourDir(PathBuf),
|
||||
pub enum TourError {
|
||||
NoTour,
|
||||
TourAlreadyExists,
|
||||
TourEnded,
|
||||
NothingToCommit,
|
||||
NoSteps,
|
||||
NotADescendant(PathBuf),
|
||||
InsideTourDir(PathBuf),
|
||||
FileNotFound(PathBuf),
|
||||
StepOutOfRange { step: u32, total: u32 },
|
||||
CorruptedTour(String),
|
||||
Io(io::Error),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CommitError {
|
||||
impl std::fmt::Display for TourError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::NotADescendantOfCurrentDir(path) => {
|
||||
write!(f, "File {:?} is not a descendant of the working directory.", path)
|
||||
}
|
||||
Self::InsideTourDir(path) => {
|
||||
write!(f, "File {:?} is inside a .tour directory, which is not allowed.", path)
|
||||
Self::NoTour => {
|
||||
write!(f, "No tour found in this directory. Run `tour init` first.")
|
||||
}
|
||||
Self::TourAlreadyExists => write!(f, "A tour already exists in this directory."),
|
||||
Self::TourEnded => write!(f, "Tour has already been ended."),
|
||||
Self::NothingToCommit => {
|
||||
write!(f, "Nothing to commit. Use `tour add <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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
51
src/init.rs
51
src/init.rs
@@ -1,20 +1,51 @@
|
||||
// Create a .tour folder
|
||||
// Create directory ./.tour/steps that stores tutorial steps
|
||||
// Create file ./.tour/session that logs sessions information
|
||||
|
||||
use crate::error::TourError;
|
||||
use crate::TOUR_DIR;
|
||||
use std::fs::DirBuilder;
|
||||
use std::path::PathBuf;
|
||||
use std::fs::{self, DirBuilder, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// Creates the directory for tour
|
||||
pub fn init() -> Result<(), std::io::Error> {
|
||||
pub fn init() -> Result<(), TourError> {
|
||||
let tour_dir = PathBuf::from(TOUR_DIR);
|
||||
|
||||
if tour_dir.exists() {
|
||||
return Err(TourError::TourAlreadyExists);
|
||||
}
|
||||
|
||||
DirBuilder::new()
|
||||
.recursive(true)
|
||||
.create(tour_dir.join("steps"))?;
|
||||
|
||||
std::fs::File::create(tour_dir.join("session"))?;
|
||||
fs::File::create(tour_dir.join("session"))?;
|
||||
|
||||
crate::info::set_info()
|
||||
crate::info::set_info()?;
|
||||
update_gitignore()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_gitignore() -> Result<(), std::io::Error> {
|
||||
let gitignore = Path::new(".gitignore");
|
||||
let entries = [".tour/session", ".tour/staged", ".tour/removed"];
|
||||
|
||||
let existing = fs::read_to_string(gitignore).unwrap_or_default();
|
||||
let mut to_add = Vec::new();
|
||||
for entry in &entries {
|
||||
if !existing.lines().any(|l| l.trim() == *entry) {
|
||||
to_add.push(*entry);
|
||||
}
|
||||
}
|
||||
|
||||
if !to_add.is_empty() {
|
||||
let mut file = OpenOptions::new()
|
||||
.append(true)
|
||||
.create(true)
|
||||
.open(gitignore)?;
|
||||
if !existing.is_empty() && !existing.ends_with('\n') {
|
||||
writeln!(file)?;
|
||||
}
|
||||
for entry in to_add {
|
||||
writeln!(file, "{}", entry)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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 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
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::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
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::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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user