refactor: split main.rs into more files; add docs

This commit is contained in:
swaits committed 2025-07-27 14:25:47 -04:00
1 parent 8b647a2380
commit de6b4f4d30
6 files changed
+709 -597

No files matched your search

+25 -1
View File
@@ -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
+481
View File
@@ -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<String> = io::BufReader::new(file).lines().collect::<Result<_, _>>()?;
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<String> = 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<String> = 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<String> = 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<String> = Vec::new();
let mut to_archive: Vec<String> = Vec::new();
let mut remove_indices: HashSet<usize> = HashSet::new();
let mut history: Vec<(usize, String, String)> = Vec::new(); // (index, action, item)
let mut current_idx = 0;
let indices: Vec<usize> = (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<String> = 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(())
}
+110
View File
@@ -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>), String> {
// Convert 1-based numbers to 0-based indices and validate
let mut indices: Vec<usize> = 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<String> = Vec::new();
let indices_set: HashSet<usize> = indices.iter().cloned().collect();
for &idx in &indices {
to_prioritize.push(lines[idx].clone());
}
// Extract remaining items (preserving original order)
let mut remaining: Vec<String> = 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>), String> {
// Convert 1-based numbers to 0-based indices and validate
let mut indices: Vec<usize> = 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<String> = Vec::new();
let mut remaining: Vec<String> = 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))
}
+38
View File
@@ -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<String> {
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()),
}
}
+22 -593
View File
@@ -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(&note_path, &text),
Commands::Ls { num, all, filter } => list_note(&note_path, num, all, filter.as_deref()),
Commands::Up { numbers } => prioritize_items(&note_path, &numbers),
Commands::Do { numbers } => archive_items(&note_path, &archive_path, &numbers),
Commands::Scan => review_note(&note_path, &archive_path),
Commands::Edit => edit_note(&note_path),
Commands::Add { text } => commands::add_entry(&note_path, &text),
Commands::Ls { num, all, filter } => {
commands::list_note(&note_path, num, all, filter.as_deref())
}
Commands::Up { numbers } => commands::prioritize_items(&note_path, &numbers),
Commands::Do { numbers } => commands::archive_items(&note_path, &archive_path, &numbers),
Commands::Scan => commands::review_note(&note_path, &archive_path),
Commands::Edit => commands::edit_note(&note_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<String> = io::BufReader::new(file).lines().collect::<Result<_, _>>()?;
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<String> {
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<String> = 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>), String> {
// Convert 1-based numbers to 0-based indices and validate
let mut indices: Vec<usize> = 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<String> = Vec::new();
let indices_set: HashSet<usize> = indices.iter().cloned().collect();
for &idx in &indices {
to_prioritize.push(lines[idx].clone());
}
// Extract remaining items (preserving original order)
let mut remaining: Vec<String> = 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<String> = 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>), String> {
// Convert 1-based numbers to 0-based indices and validate
let mut indices: Vec<usize> = 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<String> = Vec::new();
let mut remaining: Vec<String> = 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<String> = 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<String> = Vec::new();
let mut to_archive: Vec<String> = Vec::new();
let mut remove_indices: HashSet<usize> = HashSet::new();
let mut history: Vec<(usize, String, String)> = Vec::new(); // (index, action, item)
let mut current_idx = 0;
let indices: Vec<usize> = (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<String> = 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(())
}
+33 -3
View File
@@ -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();