refactor: split main.rs into more files; add docs
This commit is contained in:
6 files changed
+709
-597
No files matched your search
+25
-1
@@ -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
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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<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
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user