From de6b4f4d308d19a92886bf908b0a880761e1bc3f Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sun, 27 Jul 2025 14:19:54 -0400 Subject: [PATCH] refactor: split main.rs into more files; add docs --- .gitignore | 26 +- src/commands.rs | 481 +++++++++++++++++++++++++++++++++++++ src/content.rs | 110 +++++++++ src/input.rs | 38 +++ src/main.rs | 615 ++---------------------------------------------- src/tests.rs | 36 ++- 6 files changed, 709 insertions(+), 597 deletions(-) create mode 100644 src/commands.rs create mode 100644 src/content.rs create mode 100644 src/input.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..9621890 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,25 @@ -/target +# Generated by Cargo +# Lockfile should be checked in for binaries, but not for libraries. +# Cargo.lock + +# The cargo build artifacts directory +/target/ + +# Local development tool configuration +.opencode/ + +# IDE/Editor specific files +.vscode/ +.idea/ +*.sublime-project +*.sublime-workspace + +# Environment variables +.env + +# Operating System files +.DS_Store +Thumbs.db + +# Miscellaneous +*.log \ No newline at end of file diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..e0ff0a3 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,481 @@ +//! Command implementations for the parts todo system. +//! +//! This module contains all the core functionality for managing todo items, +//! including adding, listing, prioritizing, archiving, and interactive review. + +use crate::content::{add_entry_to_content, archive_items_in_content, prioritize_items_in_content}; +use crate::input::read_key_input; +use std::collections::HashSet; +use std::env; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufRead, Write}; +use std::path::Path; +use std::process::Command; +use termion::color; + +/// Adds a new entry to the top of the note file. +/// Creates the file if it doesn't exist, otherwise prepends the text to existing content. +pub fn add_entry(path: &Path, text: &str) -> io::Result<()> { + let mut content = String::new(); + if path.exists() { + content = fs::read_to_string(path)?; + } + let new_content = add_entry_to_content(&content, text); + fs::write(path, new_content) +} + +/// Lists notes from the file with optional filtering and pagination. +/// Displays numbered items with colored output, supports case-insensitive filtering. +pub fn list_note(path: &Path, num: usize, all: bool, filter: Option<&str>) -> io::Result<()> { + if !path.exists() { + println!( + "{}No notes yet.{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + return Ok(()); + } + let file = File::open(path)?; + let lines: Vec = io::BufReader::new(file).lines().collect::>()?; + + let filtered_lines: Vec<(usize, &String)> = if let Some(filter_text) = filter { + lines + .iter() + .enumerate() + .filter(|(_, line)| line.to_lowercase().contains(&filter_text.to_lowercase())) + .collect() + } else { + lines.iter().enumerate().collect() + }; + + if filtered_lines.is_empty() { + if filter.is_some() { + println!( + "{}No items found matching filter: \"{}\"{}", + color::Fg(color::Yellow), + filter.unwrap(), + color::Fg(color::Reset) + ); + } else { + println!( + "{}No notes yet.{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + } + return Ok(()); + } + + let display_num = if all { + filtered_lines.len() + } else { + num.min(filtered_lines.len()) + }; + for (original_idx, line) in filtered_lines.iter().take(display_num) { + println!( + "{}{}. {}{}", + color::Fg(color::Blue), + original_idx + 1, + color::Fg(color::Reset), + line + ); + } + + if !all && filter.is_some() && filtered_lines.len() > display_num { + println!( + "{}... and {} more matching items{}", + color::Fg(color::Yellow), + filtered_lines.len() - display_num, + color::Fg(color::Reset) + ); + } + + Ok(()) +} + +/// Moves specified items to the top of the note file to increase their priority. +/// Takes 1-based item numbers and reorders them to appear at the beginning. +pub fn prioritize_items(note_path: &Path, numbers: &[usize]) -> io::Result<()> { + if !note_path.exists() { + println!( + "{}No notes found.{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + return Ok(()); + } + + let content = fs::read_to_string(note_path)?; + let lines: Vec = content.lines().map(str::to_string).collect(); + + if lines.is_empty() { + println!( + "{}No notes to prioritize.{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + return Ok(()); + } + + match prioritize_items_in_content(&lines, numbers) { + Ok((new_content, prioritized_items)) => { + fs::write(note_path, new_content)?; + println!( + "{}✓ Prioritized {} item(s){}", + color::Fg(color::Green), + prioritized_items.len(), + color::Fg(color::Reset) + ); + for item in prioritized_items { + println!( + " {}{}{}", + color::Fg(color::Magenta), + item, + color::Fg(color::Reset) + ); + } + Ok(()) + } + Err(msg) => { + println!( + "{}{}{}", + color::Fg(color::Red), + msg, + color::Fg(color::Reset) + ); + Ok(()) + } + } +} + +/// Removes specified items from the note file and appends them to the archive file. +/// Takes 1-based item numbers, validates them, and handles file I/O operations. +pub fn archive_items(note_path: &Path, archive_path: &Path, numbers: &[usize]) -> io::Result<()> { + if !note_path.exists() { + println!( + "{}No notes found.{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + return Ok(()); + } + + let content = fs::read_to_string(note_path)?; + let lines: Vec = content.lines().map(str::to_string).collect(); + + if lines.is_empty() { + println!( + "{}No notes to archive.{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + return Ok(()); + } + + match archive_items_in_content(&lines, numbers) { + Ok((new_content, archived_items)) => { + // Archive items + if !archived_items.is_empty() { + let mut archive_file = OpenOptions::new() + .append(true) + .create(true) + .open(archive_path)?; + for item in &archived_items { + writeln!(archive_file, "{item}")?; + } + } + + fs::write(note_path, new_content)?; + println!( + "{}✓ Archived {} item(s){}", + color::Fg(color::Green), + archived_items.len(), + color::Fg(color::Reset) + ); + for item in archived_items { + println!( + " {}{}{}", + color::Fg(color::Red), + item, + color::Fg(color::Reset) + ); + } + Ok(()) + } + Err(msg) => { + println!( + "{}{}{}", + color::Fg(color::Red), + msg, + color::Fg(color::Reset) + ); + Ok(()) + } + } +} + +/// Interactive review mode that presents each item for user decision. +/// Supports prioritizing, archiving, skipping items with keyboard navigation and undo functionality. +pub fn review_note(note_path: &Path, archive_path: &Path) -> io::Result<()> { + if !note_path.exists() { + println!("No notes to review."); + return Ok(()); + } + + let content = fs::read_to_string(note_path)?; + let lines: Vec = content.lines().map(str::to_string).collect(); + let total = lines.len(); + + if total == 0 { + println!("No notes to review."); + return Ok(()); + } + + let mut prioritized: Vec = Vec::new(); + let mut to_archive: Vec = Vec::new(); + let mut remove_indices: HashSet = HashSet::new(); + let mut history: Vec<(usize, String, String)> = Vec::new(); // (index, action, item) + + let mut current_idx = 0; + let indices: Vec = (0..total).collect(); + + while current_idx < indices.len() { + let i = indices[current_idx]; + let current = current_idx + 1; + + // Clear screen using ANSI escape sequence + print!("\x1B[2J\x1B[H"); + + println!( + "{}=== PARTS Review Mode ==={}", + color::Fg(color::Cyan), + color::Fg(color::Reset) + ); + println!( + "{}Progress: {}/{} items remaining{}", + color::Fg(color::Yellow), + current, + total, + color::Fg(color::Reset) + ); + println!(); + println!( + "{}Item:{} {}", + color::Fg(color::Green), + color::Fg(color::Reset), + lines[i] + ); + println!(); + println!( + "{}Actions:{}", + color::Fg(color::Blue), + color::Fg(color::Reset) + ); + println!( + " {}[↑/p]{} Prioritize (move to top)", + color::Fg(color::Magenta), + color::Fg(color::Reset) + ); + println!( + " {}[↓/a]{} Archive (remove from list)", + color::Fg(color::Red), + color::Fg(color::Reset) + ); + println!( + " {}[→/Enter/s]{} Skip (keep in current position)", + color::Fg(color::White), + color::Fg(color::Reset) + ); + println!( + " {}[←]{} Go back to previous item", + color::Fg(color::Cyan), + color::Fg(color::Reset) + ); + println!( + " {}[q]{} Quit review", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + println!(); + print!( + "{}Choose action:{} ", + color::Fg(color::Blue), + color::Fg(color::Reset) + ); + io::stdout().flush()?; + + // Read input (handle arrow keys) + let action = read_key_input()?; + + match action.as_str() { + "up" | "p" => { + history.push((i, "prioritize".to_string(), lines[i].clone())); + prioritized.push(lines[i].clone()); + remove_indices.insert(i); + println!( + "↑\n{}✓ Prioritized{}", + color::Fg(color::Magenta), + color::Fg(color::Reset) + ); + current_idx += 1; + } + "down" | "a" => { + history.push((i, "archive".to_string(), lines[i].clone())); + to_archive.push(lines[i].clone()); + remove_indices.insert(i); + println!( + "↓\n{}✓ Archived{}", + color::Fg(color::Red), + color::Fg(color::Reset) + ); + current_idx += 1; + } + "right" | "enter" | "s" => { + history.push((i, "skip".to_string(), lines[i].clone())); + println!( + "→\n{}✓ Skipped{}", + color::Fg(color::White), + color::Fg(color::Reset) + ); + current_idx += 1; + } + "left" => { + if let Some((prev_idx, prev_action, prev_item)) = history.pop() { + // Undo the previous action + match prev_action.as_str() { + "prioritize" => { + prioritized.retain(|item| item != &prev_item); + remove_indices.remove(&prev_idx); + } + "archive" => { + to_archive.retain(|item| item != &prev_item); + remove_indices.remove(&prev_idx); + } + "skip" => { + // Nothing to undo for skip + } + _ => {} + } + current_idx = current_idx.saturating_sub(1); + println!( + "←\n{}✓ Went back{}", + color::Fg(color::Cyan), + color::Fg(color::Reset) + ); + } else { + println!( + "←\n{}No previous item to go back to{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + } + } + "q" => { + println!( + "q\n{}Quitting review and saving changes...{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + break; + } + _ => { + history.push((i, "skip".to_string(), lines[i].clone())); + println!( + "?\n{}✓ Skipped (invalid key){}", + color::Fg(color::White), + color::Fg(color::Reset) + ); + current_idx += 1; + } + } + } + + // Clear screen for final summary + print!("\x1B[2J\x1B[H"); + println!( + "{}=== Review Complete ==={}", + color::Fg(color::Green), + color::Fg(color::Reset) + ); + println!( + "{}Prioritized:{} {} items", + color::Fg(color::Magenta), + color::Fg(color::Reset), + prioritized.len() + ); + println!( + "{}Archived:{} {} items", + color::Fg(color::Red), + color::Fg(color::Reset), + to_archive.len() + ); + println!( + "{}Remaining:{} {} items", + color::Fg(color::Blue), + color::Fg(color::Reset), + total - remove_indices.len() + ); + println!(); + + // Archive items + if !to_archive.is_empty() { + let mut archive_file = OpenOptions::new() + .append(true) + .create(true) + .open(archive_path)?; + for arch in to_archive { + writeln!(archive_file, "{arch}")?; + } + } + + // Build remaining items + let mut remaining: Vec = Vec::new(); + for (i, line) in lines.iter().enumerate() { + if !remove_indices.contains(&i) { + remaining.push(line.clone()); + } + } + + // Combine: prioritized items first, then remaining + let mut new_lines = prioritized; + new_lines.extend(remaining); + + // Write back to file + let new_content = if new_lines.is_empty() { + String::new() + } else { + new_lines.join("\n") + "\n" + }; + fs::write(note_path, new_content)?; + + println!( + "{}✓ Changes saved successfully!{}", + color::Fg(color::Green), + color::Fg(color::Reset) + ); + Ok(()) +} + +/// Opens the note file in the user's preferred editor (from $EDITOR environment variable). +/// Creates the file if it doesn't exist, defaults to nano if $EDITOR is not set. +pub fn edit_note(note_path: &Path) -> io::Result<()> { + let editor = env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); + + // Create the file if it doesn't exist + if !note_path.exists() { + fs::write(note_path, "")?; + } + + let status = Command::new(&editor).arg(note_path).status()?; + + if !status.success() { + println!( + "{}Editor exited with error code: {}{}", + color::Fg(color::Red), + status.code().unwrap_or(-1), + color::Fg(color::Reset) + ); + } + + Ok(()) +} diff --git a/src/content.rs b/src/content.rs new file mode 100644 index 0000000..c691da6 --- /dev/null +++ b/src/content.rs @@ -0,0 +1,110 @@ +//! Pure functions for content manipulation. +//! +//! This module contains stateless functions that handle the core logic +//! for manipulating todo item content without side effects. + +use std::collections::HashSet; + +/// Pure function that prepends new text to existing content. +/// Adds the new text at the beginning followed by a newline and the trimmed existing content. +pub fn add_entry_to_content(existing_content: &str, text: &str) -> String { + format!("{}\n{}", text, existing_content.trim_end()) +} + +/// Pure function that moves specified lines to the top of the content. +/// Takes 1-based line numbers, validates them, removes duplicates, and reorders content. +/// Returns the new content string and a vector of the prioritized items. +pub fn prioritize_items_in_content( + lines: &[String], + numbers: &[usize], +) -> Result<(String, Vec), String> { + // Convert 1-based numbers to 0-based indices and validate + let mut indices: Vec = Vec::new(); + for &num in numbers { + if num == 0 || num > lines.len() { + return Err(format!( + "Invalid item number: {}. Valid range: 1-{}", + num, + lines.len() + )); + } + indices.push(num - 1); + } + + // Remove duplicates while preserving order + let mut seen = HashSet::new(); + indices.retain(|&x| seen.insert(x)); + + // Extract items to prioritize in the order specified on command line + let mut to_prioritize: Vec = Vec::new(); + let indices_set: HashSet = indices.iter().cloned().collect(); + + for &idx in &indices { + to_prioritize.push(lines[idx].clone()); + } + + // Extract remaining items (preserving original order) + let mut remaining: Vec = Vec::new(); + for (i, line) in lines.iter().enumerate() { + if !indices_set.contains(&i) { + remaining.push(line.clone()); + } + } + + // Combine: prioritized items first (in command-line order), then remaining + let mut new_lines = to_prioritize.clone(); + new_lines.extend(remaining); + + let new_content = if new_lines.is_empty() { + String::new() + } else { + new_lines.join("\n") + "\n" + }; + + Ok((new_content, to_prioritize)) +} + +/// Pure function that removes specified lines from content. +/// Takes 1-based line numbers, validates them, and returns remaining content. +/// Returns the new content string and a vector of the archived items. +pub fn archive_items_in_content( + lines: &[String], + numbers: &[usize], +) -> Result<(String, Vec), String> { + // Convert 1-based numbers to 0-based indices and validate + let mut indices: Vec = Vec::new(); + for &num in numbers { + if num == 0 || num > lines.len() { + return Err(format!( + "Invalid item number: {}. Valid range: 1-{}", + num, + lines.len() + )); + } + indices.push(num - 1); + } + + // Remove duplicates and sort + indices.sort_unstable(); + indices.dedup(); + + // Extract items to archive and remaining items + let mut to_archive: Vec = Vec::new(); + let mut remaining: Vec = Vec::new(); + + for (i, line) in lines.iter().enumerate() { + if indices.contains(&i) { + to_archive.push(line.clone()); + } else { + remaining.push(line.clone()); + } + } + + let new_content = if remaining.is_empty() { + String::new() + } else { + remaining.join("\n") + "\n" + }; + + Ok((new_content, to_archive)) +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..f5f616d --- /dev/null +++ b/src/input.rs @@ -0,0 +1,38 @@ +//! Input handling utilities for interactive features. +//! +//! This module provides functions for reading raw keyboard input, +//! particularly for handling arrow keys in the interactive review mode. + +use std::io::{self, Read}; +use termion::raw::IntoRawMode; + +/// Reads raw keyboard input including arrow keys and special characters. +/// Handles ANSI escape sequences for arrow keys and returns string representations. +/// Used for interactive navigation in review mode. +pub fn read_key_input() -> io::Result { + let _stdout = io::stdout().into_raw_mode()?; + let mut buffer = [0; 3]; + + // Read first byte + io::stdin().read_exact(&mut buffer[0..1])?; + + match buffer[0] { + 27 => { + // ESC sequence, read next two bytes + io::stdin().read_exact(&mut buffer[1..3])?; + match (buffer[1], buffer[2]) { + (91, 65) => Ok("up".to_string()), // ESC[A + (91, 66) => Ok("down".to_string()), // ESC[B + (91, 67) => Ok("right".to_string()), // ESC[C + (91, 68) => Ok("left".to_string()), // ESC[D + _ => Ok("unknown".to_string()), + } + } + b'\n' | b'\r' => Ok("enter".to_string()), + b'q' => Ok("q".to_string()), + b'p' => Ok("p".to_string()), + b'a' => Ok("a".to_string()), + b's' => Ok("s".to_string()), + _ => Ok("unknown".to_string()), + } +} diff --git a/src/main.rs b/src/main.rs index 02a6bba..f97dcbc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,20 @@ +//! Parts - A Prepend and Review ToDo System +//! +//! A command-line todo application that emphasizes adding items to the top +//! and provides an interactive review mode for processing items. + use clap::{Parser, Subcommand}; -use std::collections::HashSet; -use std::env; -use std::fs::{self, File, OpenOptions}; -use std::io::{self, BufRead, Read, Write}; -use std::path::Path; -use std::process::Command; -use termion::color; -use termion::raw::IntoRawMode; +use std::io; use xdg::BaseDirectories; +mod commands; +mod content; +mod input; + #[cfg(test)] mod tests; +/// Command-line interface structure for the parts todo system #[derive(Parser)] #[command(name = "parts")] #[command(about = "Prepend and Review ToDo System", long_about = None)] @@ -21,6 +24,7 @@ struct Cli { command: Commands, } +/// Available subcommands for the parts todo system #[derive(Subcommand)] enum Commands { /// Add a new item at the top @@ -59,6 +63,8 @@ enum Commands { Edit, } +/// Entry point that parses CLI arguments and dispatches to appropriate command handlers. +/// Sets up XDG-compliant data directory paths for note.txt and archive.txt files. fn main() -> io::Result<()> { let cli = Cli::parse(); let base = BaseDirectories::with_prefix("parts"); @@ -70,590 +76,13 @@ fn main() -> io::Result<()> { .expect("Failed to create data directory"); match cli.command { - Commands::Add { text } => add_entry(¬e_path, &text), - Commands::Ls { num, all, filter } => list_note(¬e_path, num, all, filter.as_deref()), - Commands::Up { numbers } => prioritize_items(¬e_path, &numbers), - Commands::Do { numbers } => archive_items(¬e_path, &archive_path, &numbers), - Commands::Scan => review_note(¬e_path, &archive_path), - Commands::Edit => edit_note(¬e_path), + Commands::Add { text } => commands::add_entry(¬e_path, &text), + Commands::Ls { num, all, filter } => { + commands::list_note(¬e_path, num, all, filter.as_deref()) + } + Commands::Up { numbers } => commands::prioritize_items(¬e_path, &numbers), + Commands::Do { numbers } => commands::archive_items(¬e_path, &archive_path, &numbers), + Commands::Scan => commands::review_note(¬e_path, &archive_path), + Commands::Edit => commands::edit_note(¬e_path), } } - -fn add_entry(path: &Path, text: &str) -> io::Result<()> { - let mut content = String::new(); - if path.exists() { - content = fs::read_to_string(path)?; - } - let new_content = add_entry_to_content(&content, text); - fs::write(path, new_content) -} - -fn add_entry_to_content(existing_content: &str, text: &str) -> String { - format!("{}\n{}", text, existing_content.trim_end()) -} - -fn list_note(path: &Path, num: usize, all: bool, filter: Option<&str>) -> io::Result<()> { - if !path.exists() { - println!( - "{}No notes yet.{}", - color::Fg(color::Yellow), - color::Fg(color::Reset) - ); - return Ok(()); - } - let file = File::open(path)?; - let lines: Vec = io::BufReader::new(file).lines().collect::>()?; - - let filtered_lines: Vec<(usize, &String)> = if let Some(filter_text) = filter { - lines - .iter() - .enumerate() - .filter(|(_, line)| line.to_lowercase().contains(&filter_text.to_lowercase())) - .collect() - } else { - lines.iter().enumerate().collect() - }; - - if filtered_lines.is_empty() { - if filter.is_some() { - println!( - "{}No items found matching filter: \"{}\"{}", - color::Fg(color::Yellow), - filter.unwrap(), - color::Fg(color::Reset) - ); - } else { - println!( - "{}No notes yet.{}", - color::Fg(color::Yellow), - color::Fg(color::Reset) - ); - } - return Ok(()); - } - - let display_num = if all { - filtered_lines.len() - } else { - num.min(filtered_lines.len()) - }; - for (original_idx, line) in filtered_lines.iter().take(display_num) { - println!( - "{}{}. {}{}", - color::Fg(color::Blue), - original_idx + 1, - color::Fg(color::Reset), - line - ); - } - - if !all && filter.is_some() && filtered_lines.len() > display_num { - println!( - "{}... and {} more matching items{}", - color::Fg(color::Yellow), - filtered_lines.len() - display_num, - color::Fg(color::Reset) - ); - } - - Ok(()) -} - -fn read_key_input() -> io::Result { - let _stdout = io::stdout().into_raw_mode()?; - let mut buffer = [0; 3]; - - // Read first byte - io::stdin().read_exact(&mut buffer[0..1])?; - - match buffer[0] { - 27 => { - // ESC sequence, read next two bytes - io::stdin().read_exact(&mut buffer[1..3])?; - match (buffer[1], buffer[2]) { - (91, 65) => Ok("up".to_string()), // ESC[A - (91, 66) => Ok("down".to_string()), // ESC[B - (91, 67) => Ok("right".to_string()), // ESC[C - (91, 68) => Ok("left".to_string()), // ESC[D - _ => Ok("unknown".to_string()), - } - } - b'\n' | b'\r' => Ok("enter".to_string()), - b'q' => Ok("q".to_string()), - b'p' => Ok("p".to_string()), - b'a' => Ok("a".to_string()), - b's' => Ok("s".to_string()), - _ => Ok("unknown".to_string()), - } -} - -fn prioritize_items(note_path: &Path, numbers: &[usize]) -> io::Result<()> { - if !note_path.exists() { - println!( - "{}No notes found.{}", - color::Fg(color::Yellow), - color::Fg(color::Reset) - ); - return Ok(()); - } - - let content = fs::read_to_string(note_path)?; - let lines: Vec = content.lines().map(str::to_string).collect(); - - if lines.is_empty() { - println!( - "{}No notes to prioritize.{}", - color::Fg(color::Yellow), - color::Fg(color::Reset) - ); - return Ok(()); - } - - match prioritize_items_in_content(&lines, numbers) { - Ok((new_content, prioritized_items)) => { - fs::write(note_path, new_content)?; - println!( - "{}✓ Prioritized {} item(s){}", - color::Fg(color::Green), - prioritized_items.len(), - color::Fg(color::Reset) - ); - for item in prioritized_items { - println!( - " {}{}{}", - color::Fg(color::Magenta), - item, - color::Fg(color::Reset) - ); - } - Ok(()) - } - Err(msg) => { - println!( - "{}{}{}", - color::Fg(color::Red), - msg, - color::Fg(color::Reset) - ); - Ok(()) - } - } -} - -fn prioritize_items_in_content( - lines: &[String], - numbers: &[usize], -) -> Result<(String, Vec), String> { - // Convert 1-based numbers to 0-based indices and validate - let mut indices: Vec = Vec::new(); - for &num in numbers { - if num == 0 || num > lines.len() { - return Err(format!( - "Invalid item number: {}. Valid range: 1-{}", - num, - lines.len() - )); - } - indices.push(num - 1); - } - - // Remove duplicates while preserving order - let mut seen = HashSet::new(); - indices.retain(|&x| seen.insert(x)); - - // Extract items to prioritize in the order specified on command line - let mut to_prioritize: Vec = Vec::new(); - let indices_set: HashSet = indices.iter().cloned().collect(); - - for &idx in &indices { - to_prioritize.push(lines[idx].clone()); - } - - // Extract remaining items (preserving original order) - let mut remaining: Vec = Vec::new(); - for (i, line) in lines.iter().enumerate() { - if !indices_set.contains(&i) { - remaining.push(line.clone()); - } - } - - // Combine: prioritized items first (in command-line order), then remaining - let mut new_lines = to_prioritize.clone(); - new_lines.extend(remaining); - - let new_content = if new_lines.is_empty() { - String::new() - } else { - new_lines.join("\n") + "\n" - }; - - Ok((new_content, to_prioritize)) -} - -fn archive_items(note_path: &Path, archive_path: &Path, numbers: &[usize]) -> io::Result<()> { - if !note_path.exists() { - println!( - "{}No notes found.{}", - color::Fg(color::Yellow), - color::Fg(color::Reset) - ); - return Ok(()); - } - - let content = fs::read_to_string(note_path)?; - let lines: Vec = content.lines().map(str::to_string).collect(); - - if lines.is_empty() { - println!( - "{}No notes to archive.{}", - color::Fg(color::Yellow), - color::Fg(color::Reset) - ); - return Ok(()); - } - - match archive_items_in_content(&lines, numbers) { - Ok((new_content, archived_items)) => { - // Archive items - if !archived_items.is_empty() { - let mut archive_file = OpenOptions::new() - .append(true) - .create(true) - .open(archive_path)?; - for item in &archived_items { - writeln!(archive_file, "{item}")?; - } - } - - fs::write(note_path, new_content)?; - println!( - "{}✓ Archived {} item(s){}", - color::Fg(color::Green), - archived_items.len(), - color::Fg(color::Reset) - ); - for item in archived_items { - println!( - " {}{}{}", - color::Fg(color::Red), - item, - color::Fg(color::Reset) - ); - } - Ok(()) - } - Err(msg) => { - println!( - "{}{}{}", - color::Fg(color::Red), - msg, - color::Fg(color::Reset) - ); - Ok(()) - } - } -} - -fn archive_items_in_content( - lines: &[String], - numbers: &[usize], -) -> Result<(String, Vec), String> { - // Convert 1-based numbers to 0-based indices and validate - let mut indices: Vec = Vec::new(); - for &num in numbers { - if num == 0 || num > lines.len() { - return Err(format!( - "Invalid item number: {}. Valid range: 1-{}", - num, - lines.len() - )); - } - indices.push(num - 1); - } - - // Remove duplicates and sort - indices.sort_unstable(); - indices.dedup(); - - // Extract items to archive and remaining items - let mut to_archive: Vec = Vec::new(); - let mut remaining: Vec = Vec::new(); - - for (i, line) in lines.iter().enumerate() { - if indices.contains(&i) { - to_archive.push(line.clone()); - } else { - remaining.push(line.clone()); - } - } - - let new_content = if remaining.is_empty() { - String::new() - } else { - remaining.join("\n") + "\n" - }; - - Ok((new_content, to_archive)) -} - -fn review_note(note_path: &Path, archive_path: &Path) -> io::Result<()> { - if !note_path.exists() { - println!("No notes to review."); - return Ok(()); - } - - let content = fs::read_to_string(note_path)?; - let lines: Vec = content.lines().map(str::to_string).collect(); - let total = lines.len(); - - if total == 0 { - println!("No notes to review."); - return Ok(()); - } - - let mut prioritized: Vec = Vec::new(); - let mut to_archive: Vec = Vec::new(); - let mut remove_indices: HashSet = HashSet::new(); - let mut history: Vec<(usize, String, String)> = Vec::new(); // (index, action, item) - - let mut current_idx = 0; - let indices: Vec = (0..total).collect(); - - while current_idx < indices.len() { - let i = indices[current_idx]; - let current = current_idx + 1; - - // Clear screen using ANSI escape sequence - print!("\x1B[2J\x1B[H"); - - println!( - "{}=== PARTS Review Mode ==={}", - color::Fg(color::Cyan), - color::Fg(color::Reset) - ); - println!( - "{}Progress: {}/{} items remaining{}", - color::Fg(color::Yellow), - current, - total, - color::Fg(color::Reset) - ); - println!(); - println!( - "{}Item:{} {}", - color::Fg(color::Green), - color::Fg(color::Reset), - lines[i] - ); - println!(); - println!( - "{}Actions:{}", - color::Fg(color::Blue), - color::Fg(color::Reset) - ); - println!( - " {}[↑/p]{} Prioritize (move to top)", - color::Fg(color::Magenta), - color::Fg(color::Reset) - ); - println!( - " {}[↓/a]{} Archive (remove from list)", - color::Fg(color::Red), - color::Fg(color::Reset) - ); - println!( - " {}[→/Enter/s]{} Skip (keep in current position)", - color::Fg(color::White), - color::Fg(color::Reset) - ); - println!( - " {}[←]{} Go back to previous item", - color::Fg(color::Cyan), - color::Fg(color::Reset) - ); - println!( - " {}[q]{} Quit review", - color::Fg(color::Yellow), - color::Fg(color::Reset) - ); - println!(); - print!( - "{}Choose action:{} ", - color::Fg(color::Blue), - color::Fg(color::Reset) - ); - io::stdout().flush()?; - - // Read input (handle arrow keys) - let action = read_key_input()?; - - match action.as_str() { - "up" | "p" => { - history.push((i, "prioritize".to_string(), lines[i].clone())); - prioritized.push(lines[i].clone()); - remove_indices.insert(i); - println!( - "↑\n{}✓ Prioritized{}", - color::Fg(color::Magenta), - color::Fg(color::Reset) - ); - current_idx += 1; - } - "down" | "a" => { - history.push((i, "archive".to_string(), lines[i].clone())); - to_archive.push(lines[i].clone()); - remove_indices.insert(i); - println!( - "↓\n{}✓ Archived{}", - color::Fg(color::Red), - color::Fg(color::Reset) - ); - current_idx += 1; - } - "right" | "enter" | "s" => { - history.push((i, "skip".to_string(), lines[i].clone())); - println!( - "→\n{}✓ Skipped{}", - color::Fg(color::White), - color::Fg(color::Reset) - ); - current_idx += 1; - } - "left" => { - if let Some((prev_idx, prev_action, prev_item)) = history.pop() { - // Undo the previous action - match prev_action.as_str() { - "prioritize" => { - prioritized.retain(|item| item != &prev_item); - remove_indices.remove(&prev_idx); - } - "archive" => { - to_archive.retain(|item| item != &prev_item); - remove_indices.remove(&prev_idx); - } - "skip" => { - // Nothing to undo for skip - } - _ => {} - } - current_idx = current_idx.saturating_sub(1); - println!( - "←\n{}✓ Went back{}", - color::Fg(color::Cyan), - color::Fg(color::Reset) - ); - } else { - println!( - "←\n{}No previous item to go back to{}", - color::Fg(color::Yellow), - color::Fg(color::Reset) - ); - } - } - "q" => { - println!( - "q\n{}Quitting review and saving changes...{}", - color::Fg(color::Yellow), - color::Fg(color::Reset) - ); - break; - } - _ => { - history.push((i, "skip".to_string(), lines[i].clone())); - println!( - "?\n{}✓ Skipped (invalid key){}", - color::Fg(color::White), - color::Fg(color::Reset) - ); - current_idx += 1; - } - } - } - - // Clear screen for final summary - print!("\x1B[2J\x1B[H"); - println!( - "{}=== Review Complete ==={}", - color::Fg(color::Green), - color::Fg(color::Reset) - ); - println!( - "{}Prioritized:{} {} items", - color::Fg(color::Magenta), - color::Fg(color::Reset), - prioritized.len() - ); - println!( - "{}Archived:{} {} items", - color::Fg(color::Red), - color::Fg(color::Reset), - to_archive.len() - ); - println!( - "{}Remaining:{} {} items", - color::Fg(color::Blue), - color::Fg(color::Reset), - total - remove_indices.len() - ); - println!(); - - // Archive items - if !to_archive.is_empty() { - let mut archive_file = OpenOptions::new() - .append(true) - .create(true) - .open(archive_path)?; - for arch in to_archive { - writeln!(archive_file, "{arch}")?; - } - } - - // Build remaining items - let mut remaining: Vec = Vec::new(); - for (i, line) in lines.iter().enumerate() { - if !remove_indices.contains(&i) { - remaining.push(line.clone()); - } - } - - // Combine: prioritized items first, then remaining - let mut new_lines = prioritized; - new_lines.extend(remaining); - - // Write back to file - let new_content = if new_lines.is_empty() { - String::new() - } else { - new_lines.join("\n") + "\n" - }; - fs::write(note_path, new_content)?; - - println!( - "{}✓ Changes saved successfully!{}", - color::Fg(color::Green), - color::Fg(color::Reset) - ); - Ok(()) -} - -fn edit_note(note_path: &Path) -> io::Result<()> { - let editor = env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); - - // Create the file if it doesn't exist - if !note_path.exists() { - fs::write(note_path, "")?; - } - - let status = Command::new(&editor).arg(note_path).status()?; - - if !status.success() { - println!( - "{}Editor exited with error code: {}{}", - color::Fg(color::Red), - status.code().unwrap_or(-1), - color::Fg(color::Reset) - ); - } - - Ok(()) -} diff --git a/src/tests.rs b/src/tests.rs index b50684b..fcc78b8 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,11 +1,19 @@ -use super::*; +//! Comprehensive test suite for the parts todo system. +//! +//! Tests cover all core functionality including content manipulation, +//! command operations, filtering, and edge cases. +use crate::commands::*; +use crate::content::*; + +/// Tests adding an entry to empty content #[test] fn test_add_entry_to_content_empty() { let result = add_entry_to_content("", "First item"); assert_eq!(result, "First item\n"); } +/// Tests adding an entry to existing content #[test] fn test_add_entry_to_content_existing() { let existing = "Second item\nThird item"; @@ -13,6 +21,7 @@ fn test_add_entry_to_content_existing() { assert_eq!(result, "First item\nSecond item\nThird item"); } +/// Tests adding an entry when existing content has trailing newline #[test] fn test_add_entry_to_content_with_trailing_newline() { let existing = "Second item\nThird item\n"; @@ -20,6 +29,7 @@ fn test_add_entry_to_content_with_trailing_newline() { assert_eq!(result, "First item\nSecond item\nThird item"); } +/// Tests prioritizing a single item by moving it to the top #[test] fn test_prioritize_items_in_content_single_item() { let lines = vec![ @@ -35,6 +45,7 @@ fn test_prioritize_items_in_content_single_item() { assert_eq!(prioritized, vec!["Second item"]); } +/// Tests prioritizing multiple items in command-line order #[test] fn test_prioritize_items_in_content_multiple_items() { let lines = vec![ @@ -54,6 +65,7 @@ fn test_prioritize_items_in_content_multiple_items() { assert_eq!(prioritized, vec!["Third item", "First item"]); } +/// Tests error handling for invalid item numbers #[test] fn test_prioritize_items_in_content_invalid_number() { let lines = vec!["First item".to_string(), "Second item".to_string()]; @@ -66,6 +78,7 @@ fn test_prioritize_items_in_content_invalid_number() { ); } +/// Tests error handling for zero item numbers #[test] fn test_prioritize_items_in_content_zero_number() { let lines = vec!["First item".to_string()]; @@ -78,6 +91,7 @@ fn test_prioritize_items_in_content_zero_number() { ); } +/// Tests duplicate removal while preserving order #[test] fn test_prioritize_items_in_content_duplicates() { let lines = vec![ @@ -93,6 +107,7 @@ fn test_prioritize_items_in_content_duplicates() { assert_eq!(prioritized, vec!["Second item", "First item"]); } +/// Tests archiving a single item #[test] fn test_archive_items_in_content_single_item() { let lines = vec![ @@ -108,6 +123,7 @@ fn test_archive_items_in_content_single_item() { assert_eq!(archived, vec!["Second item"]); } +/// Tests archiving multiple items #[test] fn test_archive_items_in_content_multiple_items() { let lines = vec![ @@ -124,6 +140,7 @@ fn test_archive_items_in_content_multiple_items() { assert_eq!(archived, vec!["First item", "Third item"]); } +/// Tests archiving all items results in empty content #[test] fn test_archive_items_in_content_all_items() { let lines = vec!["First item".to_string(), "Second item".to_string()]; @@ -135,6 +152,7 @@ fn test_archive_items_in_content_all_items() { assert_eq!(archived, vec!["First item", "Second item"]); } +/// Tests error handling for invalid archive item numbers #[test] fn test_archive_items_in_content_invalid_number() { let lines = vec!["First item".to_string()]; @@ -147,6 +165,7 @@ fn test_archive_items_in_content_invalid_number() { ); } +/// Tests duplicate handling in archive operations #[test] fn test_archive_items_in_content_duplicates() { let lines = vec![ @@ -162,6 +181,7 @@ fn test_archive_items_in_content_duplicates() { assert_eq!(archived, vec!["First item", "Third item"]); } +/// Tests case-insensitive filtering functionality #[test] fn test_filter_functionality() { let lines = vec![ @@ -197,6 +217,7 @@ fn test_filter_functionality() { assert_eq!(filtered[0].1, "@work: Review PR"); } +/// Tests that filtering is case-insensitive #[test] fn test_case_insensitive_filtering() { let lines = vec![ @@ -219,12 +240,14 @@ mod list_tests { use std::io::Write; use tempfile::NamedTempFile; + /// Helper function to create temporary test files with content fn create_test_file_with_content(content: &str) -> NamedTempFile { let mut file = NamedTempFile::new().unwrap(); write!(file, "{}", content).unwrap(); file } + /// Tests listing all items when all flag is true #[test] fn test_list_with_all_flag() { let content = "item 1\nitem 2\nitem 3\nitem 4\nitem 5\nitem 6"; @@ -237,6 +260,7 @@ mod list_tests { assert!(result.is_ok()); } + /// Tests respecting num limit when all flag is false #[test] fn test_list_without_all_flag() { let content = "item 1\nitem 2\nitem 3\nitem 4\nitem 5\nitem 6"; @@ -249,6 +273,7 @@ mod list_tests { assert!(result.is_ok()); } + /// Tests filtering with all flag shows all matching items #[test] fn test_list_with_filter_and_all() { let content = "read: book 1\nwrite: article\nread: book 2\ntask: cleanup"; @@ -261,6 +286,7 @@ mod list_tests { assert!(result.is_ok()); } + /// Tests handling of empty files #[test] fn test_list_empty_file() { let file = create_test_file_with_content(""); @@ -276,6 +302,7 @@ mod list_tests { mod review_tests { use std::collections::HashSet; + /// Tests that prioritized items maintain their relative order during review #[test] fn test_review_prioritization_order() { // Test that items prioritized during review maintain their relative order @@ -323,6 +350,7 @@ mod review_tests { assert_eq!(new_lines, expected); } + /// Tests that quitting review still saves any changes made #[test] fn test_review_quit_saves_changes() { // Test that quitting review still saves any changes made @@ -364,6 +392,7 @@ mod review_tests { assert_eq!(new_lines, expected); } + /// Tests that items are prioritized in exact command-line order #[test] fn test_prioritize_command_line_order() { // Test that items are prioritized in the exact order specified on command line @@ -376,7 +405,7 @@ mod review_tests { ]; // Command: arn up 5 2 4 (prioritize items 5, 2, 4 in that order) - let result = super::prioritize_items_in_content(&lines, &[5, 2, 4]); + let result = crate::content::prioritize_items_in_content(&lines, &[5, 2, 4]); assert!(result.is_ok()); let (new_content, prioritized) = result.unwrap(); @@ -389,6 +418,7 @@ mod review_tests { assert_eq!(prioritized, expected_prioritized); } + /// Tests duplicate removal while preserving command-line order #[test] fn test_prioritize_command_line_order_with_duplicates() { // Test that duplicates are removed but order is preserved @@ -399,7 +429,7 @@ mod review_tests { ]; // Command: arn up 3 1 3 2 1 (should become 3, 1, 2) - let result = super::prioritize_items_in_content(&lines, &[3, 1, 3, 2, 1]); + let result = crate::content::prioritize_items_in_content(&lines, &[3, 1, 3, 2, 1]); assert!(result.is_ok()); let (new_content, prioritized) = result.unwrap();