refactoring, cleanup

This commit is contained in:
swaits committed 2025-08-24 09:48:23 -06:00
1 parent a482d96b29
commit 6068a0ed9f
9 files changed
+514 -201

No files matched your search

Generated
+7
View File
@@ -67,6 +67,12 @@ dependencies = [
"windows-sys", "windows-sys",
] ]
[[package]]
name = "anyhow"
version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
[[package]] [[package]]
name = "autocfg" name = "autocfg"
version = "1.5.0" version = "1.5.0"
@@ -244,6 +250,7 @@ dependencies = [
name = "ldr" name = "ldr"
version = "1.0.2" version = "1.0.2"
dependencies = [ dependencies = [
"anyhow",
"chrono", "chrono",
"clap", "clap",
"tempfile", "tempfile",
+1
View File
@@ -11,6 +11,7 @@ keywords = ["todo", "cli", "productivity"]
categories = ["command-line-utilities"] categories = ["command-line-utilities"]
[dependencies] [dependencies]
anyhow = "1.0"
clap = { version = "4.5.41", features = ["derive"] } clap = { version = "4.5.41", features = ["derive"] }
xdg = "3.0.0" xdg = "3.0.0"
termion = "4.0.5" termion = "4.0.5"
+184 -118
View File
@@ -5,17 +5,17 @@
//! Now supports subtasks and multiple lists in Markdown format. //! Now supports subtasks and multiple lists in Markdown format.
use crate::markdown::{ use crate::markdown::{
parse_todo_file, generate_todo_file, parse_archive_file, generate_archive_file, generate_archive_file, generate_todo_file, parse_archive_file, parse_todo_file, ArchiveFile,
Task, TodoFile, ArchiveFile, TaskRef Task, TaskRef, TodoFile,
}; };
use anyhow::{anyhow, Context, Result};
use std::collections::HashSet; use std::collections::HashSet;
use std::env; use std::env;
use std::fmt;
use std::fs; use std::fs;
use std::io;
use std::path::Path; use std::path::Path;
use std::process::Command; use std::process::Command;
use termion::color; use termion::color;
use std::fmt;
// Custom 256-color support // Custom 256-color support
struct Color256(u8); struct Color256(u8);
@@ -33,15 +33,15 @@ fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
let x = c * (1.0 - ((h % 2.0) - 1.0).abs()); let x = c * (1.0 - ((h % 2.0) - 1.0).abs());
let m = v - c; let m = v - c;
let (r, g, b) = if h >= 0.0 && h < 1.0 { let (r, g, b) = if (0.0..1.0).contains(&h) {
(c, x, 0.0) (c, x, 0.0)
} else if h >= 1.0 && h < 2.0 { } else if (1.0..2.0).contains(&h) {
(x, c, 0.0) (x, c, 0.0)
} else if h >= 2.0 && h < 3.0 { } else if (2.0..3.0).contains(&h) {
(0.0, c, x) (0.0, c, x)
} else if h >= 3.0 && h < 4.0 { } else if (3.0..4.0).contains(&h) {
(0.0, x, c) (0.0, x, c)
} else if h >= 4.0 && h < 5.0 { } else if (4.0..5.0).contains(&h) {
(x, 0.0, c) (x, 0.0, c)
} else { } else {
(c, 0.0, x) (c, 0.0, x)
@@ -58,9 +58,27 @@ fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
fn rgb_to_256_color(r: u8, g: u8, b: u8) -> u8 { fn rgb_to_256_color(r: u8, g: u8, b: u8) -> u8 {
// For colors 16-231: 6x6x6 color cube // For colors 16-231: 6x6x6 color cube
// Each component ranges from 0-5, mapped from 0-255 // Each component ranges from 0-5, mapped from 0-255
let r_index = if r < 48 { 0 } else if r < 115 { 1 } else { (r - 55) / 40 }; let r_index = if r < 48 {
let g_index = if g < 48 { 0 } else if g < 115 { 1 } else { (g - 55) / 40 }; 0
let b_index = if b < 48 { 0 } else if b < 115 { 1 } else { (b - 55) / 40 }; } else if r < 115 {
1
} else {
(r - 55) / 40
};
let g_index = if g < 48 {
0
} else if g < 115 {
1
} else {
(g - 55) / 40
};
let b_index = if b < 48 {
0
} else if b < 115 {
1
} else {
(b - 55) / 40
};
16 + 36 * r_index + 6 * g_index + b_index 16 + 36 * r_index + 6 * g_index + b_index
} }
@@ -74,8 +92,8 @@ fn hsv_color(h: f32, s: f32, v: f32) -> Color256 {
// Color scheme configuration // Color scheme configuration
struct ColorScheme { struct ColorScheme {
// Main task colors (bright, high saturation) // Main task colors (bright, high saturation)
task1_hue: f32, // Primary hue for odd tasks task1_hue: f32, // Primary hue for odd tasks
task2_hue: f32, // Primary hue for even tasks task2_hue: f32, // Primary hue for even tasks
main_saturation: f32, main_saturation: f32,
main_value: f32, main_value: f32,
@@ -88,19 +106,19 @@ impl ColorScheme {
if Self::is_dark_terminal() { if Self::is_dark_terminal() {
// Dark terminal scheme - bright colors // Dark terminal scheme - bright colors
ColorScheme { ColorScheme {
task1_hue: 200.0, // Light cyan-blue task1_hue: 200.0, // Light cyan-blue
task2_hue: 40.0, // Light desert tan/gold task2_hue: 40.0, // Light desert tan/gold
main_saturation: 0.7, main_saturation: 0.7,
main_value: 0.95, // Very bright main_value: 0.95, // Very bright
value_reduction: 0.2, value_reduction: 0.2,
} }
} else { } else {
// Light terminal scheme - darker colors // Light terminal scheme - darker colors
ColorScheme { ColorScheme {
task1_hue: 210.0, // Darker blue task1_hue: 210.0, // Darker blue
task2_hue: 30.0, // Darker orange task2_hue: 30.0, // Darker orange
main_saturation: 0.8, main_saturation: 0.8,
main_value: 0.6, // Much darker for light backgrounds main_value: 0.6, // Much darker for light backgrounds
value_reduction: 0.15, value_reduction: 0.15,
} }
} }
@@ -165,10 +183,25 @@ impl ColorScheme {
/// Adds a new entry to the todo file. /// Adds a new entry to the todo file.
/// Creates the file if it doesn't exist, otherwise prepends to the main list. /// Creates the file if it doesn't exist, otherwise prepends to the main list.
/// Can add as subtask if `under` is specified. /// Can add as subtask if `under` is specified.
pub fn add_entry(path: &Path, text: &str, under: Option<usize>) -> io::Result<()> { pub fn add_entry(path: &Path, text: &str, under: Option<usize>) -> Result<()> {
// Validate input
if text.trim().is_empty() {
return Err(anyhow!("Cannot add empty task"));
}
// Limit task text length to prevent abuse
const MAX_TASK_LENGTH: usize = 500;
if text.len() > MAX_TASK_LENGTH {
return Err(anyhow!(
"Task text too long ({}). Maximum length is {} characters",
text.len(),
MAX_TASK_LENGTH
));
}
let mut todo_file = if path.exists() { let mut todo_file = if path.exists() {
let content = fs::read_to_string(path)?; let content = fs::read_to_string(path)
parse_todo_file(&content).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))? .with_context(|| format!("Failed to read file: {}", path.display()))?;
parse_todo_file(&content).map_err(|e| anyhow!("Failed to parse file: {}", e))?
} else { } else {
TodoFile::new("TODOs".to_string()) TodoFile::new("TODOs".to_string())
}; };
@@ -176,9 +209,21 @@ pub fn add_entry(path: &Path, text: &str, under: Option<usize>) -> io::Result<()
if let Some(task_num) = under { if let Some(task_num) = under {
// Add as subtask // Add as subtask
if task_num == 0 || task_num > todo_file.tasks.len() { if task_num == 0 || task_num > todo_file.tasks.len() {
return Err(io::Error::new( return Err(anyhow!(
io::ErrorKind::InvalidInput, "Invalid task number: {}. Valid range: 1-{}",
format!("Invalid task number: {}. Valid range: 1-{}", task_num, todo_file.tasks.len()) task_num,
todo_file.tasks.len()
));
}
// Limit number of subtasks per task
const MAX_SUBTASKS: usize = 26; // a-z
let task = &todo_file.tasks[task_num - 1];
if task.subtasks.len() >= MAX_SUBTASKS {
return Err(anyhow!(
"Task {} already has maximum number of subtasks ({})",
task_num,
MAX_SUBTASKS
)); ));
} }
@@ -192,6 +237,15 @@ pub fn add_entry(path: &Path, text: &str, under: Option<usize>) -> io::Result<()
); );
} else { } else {
// Add as new main task at top // Add as new main task at top
// Limit total number of tasks to prevent abuse
const MAX_TASKS: usize = 1000;
if todo_file.tasks.len() >= MAX_TASKS {
return Err(anyhow!(
"Maximum number of tasks ({}) reached. Please archive or remove some tasks first",
MAX_TASKS
));
}
let task = Task::new(text.to_string()); let task = Task::new(text.to_string());
todo_file.prepend_task(task); todo_file.prepend_task(task);
println!( println!(
@@ -203,12 +257,12 @@ pub fn add_entry(path: &Path, text: &str, under: Option<usize>) -> io::Result<()
} }
let content = generate_todo_file(&todo_file); let content = generate_todo_file(&todo_file);
fs::write(path, content) fs::write(path, content).with_context(|| format!("Failed to write file: {}", path.display()))
} }
/// Lists tasks with numbered display including subtasks. /// Lists tasks with numbered display including subtasks.
/// Displays task numbers and subtask letters, supports filtering. /// Displays task numbers and subtask letters, supports filtering.
pub fn list_note(path: &Path, num: usize, all: bool, filter: Option<&str>) -> io::Result<()> { pub fn list_note(path: &Path, num: usize, all: bool, filter: Option<&str>) -> Result<()> {
if !path.exists() { if !path.exists() {
println!( println!(
"{}No notes yet.{}", "{}No notes yet.{}",
@@ -218,9 +272,10 @@ pub fn list_note(path: &Path, num: usize, all: bool, filter: Option<&str>) -> io
return Ok(()); return Ok(());
} }
let content = fs::read_to_string(path)?; let content = fs::read_to_string(path)
let todo_file = parse_todo_file(&content) .with_context(|| format!("Failed to read file: {}", path.display()))?;
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; let todo_file =
parse_todo_file(&content).map_err(|e| anyhow!("Failed to parse file: {}", e))?;
if todo_file.is_empty() { if todo_file.is_empty() {
println!( println!(
@@ -295,7 +350,7 @@ pub fn list_note(path: &Path, num: usize, all: bool, filter: Option<&str>) -> io
println!( println!(
"{}No items found matching filter: \"{}\"{}", "{}No items found matching filter: \"{}\"{}",
color::Fg(color::Yellow), color::Fg(color::Yellow),
filter.unwrap(), filter.unwrap_or(""),
color::Fg(color::Reset) color::Fg(color::Reset)
); );
} else { } else {
@@ -320,21 +375,11 @@ pub fn list_note(path: &Path, num: usize, all: bool, filter: Option<&str>) -> io
if subtask_idx.is_none() { if subtask_idx.is_none() {
// Main task - use HSV-based bright colors // Main task - use HSV-based bright colors
let color = color_scheme.get_main_task_color(*task_num); let color = color_scheme.get_main_task_color(*task_num);
println!( println!("{}{}{}", color, line, color::Fg(color::Reset));
"{}{}{}",
color,
line,
color::Fg(color::Reset)
);
} else { } else {
// Subtask - use same color family as parent but dimmer // Subtask - use same color family as parent but dimmer
let color = color_scheme.get_subtask_color(*task_num, subtask_idx.unwrap()); let color = color_scheme.get_subtask_color(*task_num, subtask_idx.unwrap());
println!( println!("{}{}{}", color, line, color::Fg(color::Reset));
"{}{}{}",
color,
line,
color::Fg(color::Reset)
);
} }
} }
@@ -351,7 +396,7 @@ pub fn list_note(path: &Path, num: usize, all: bool, filter: Option<&str>) -> io
} }
/// Parse task references and perform operations on tasks/subtasks /// Parse task references and perform operations on tasks/subtasks
pub fn prioritize_items(todo_path: &Path, refs: &[String]) -> io::Result<()> { pub fn prioritize_items(todo_path: &Path, refs: &[String]) -> Result<()> {
if !todo_path.exists() { if !todo_path.exists() {
println!( println!(
"{}No notes found.{}", "{}No notes found.{}",
@@ -361,9 +406,10 @@ pub fn prioritize_items(todo_path: &Path, refs: &[String]) -> io::Result<()> {
return Ok(()); return Ok(());
} }
let content = fs::read_to_string(todo_path)?; let content = fs::read_to_string(todo_path)
let mut todo_file = parse_todo_file(&content) .with_context(|| format!("Failed to read file: {}", todo_path.display()))?;
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; let mut todo_file =
parse_todo_file(&content).map_err(|e| anyhow!("Failed to parse file: {}", e))?;
if todo_file.is_empty() { if todo_file.is_empty() {
println!( println!(
@@ -432,41 +478,44 @@ pub fn prioritize_items(todo_path: &Path, refs: &[String]) -> io::Result<()> {
} }
} }
// Move tasks to front in the order specified // Create new task order by swapping moved tasks to front
let mut new_tasks = Vec::new(); let old_tasks = std::mem::take(&mut todo_file.tasks);
let mut moved_tasks = Vec::new(); let mut new_tasks = Vec::with_capacity(old_tasks.len());
let mut moved_task_names = Vec::new();
// First add the moved tasks in the order specified
for &task_idx in &tasks_to_move { for &task_idx in &tasks_to_move {
moved_tasks.push(todo_file.tasks[task_idx].clone()); if task_idx < old_tasks.len() {
new_tasks.push(old_tasks[task_idx].clone());
moved_task_names.push(old_tasks[task_idx].text.clone());
}
} }
// Add moved tasks first // Then add all non-moved tasks
new_tasks.extend(moved_tasks.clone()); for (idx, task) in old_tasks.into_iter().enumerate() {
// Add remaining tasks
for (idx, task) in todo_file.tasks.iter().enumerate() {
if !moved_task_indices.contains(&idx) { if !moved_task_indices.contains(&idx) {
new_tasks.push(task.clone()); new_tasks.push(task);
} }
} }
todo_file.tasks = new_tasks; todo_file.tasks = new_tasks;
let new_content = generate_todo_file(&todo_file); let new_content = generate_todo_file(&todo_file);
fs::write(todo_path, new_content)?; fs::write(todo_path, new_content)
.with_context(|| format!("Failed to write file: {}", todo_path.display()))?;
println!( println!(
"{}✓ Prioritized {} task(s){}", "{}✓ Prioritized {} task(s){}",
color::Fg(color::Green), color::Fg(color::Green),
moved_tasks.len(), moved_task_names.len(),
color::Fg(color::Reset) color::Fg(color::Reset)
); );
for task in moved_tasks { for task_name in moved_task_names {
println!( println!(
" {}{}{}", " {}{}{}",
color::Fg(color::Magenta), color::Fg(color::Magenta),
task.text, task_name,
color::Fg(color::Reset) color::Fg(color::Reset)
); );
} }
@@ -474,8 +523,13 @@ pub fn prioritize_items(todo_path: &Path, refs: &[String]) -> io::Result<()> {
Ok(()) Ok(())
} }
/// Archive specified tasks or subtasks /// Internal helper to process items for removal or archiving
pub fn archive_items(todo_path: &Path, archive_path: &Path, refs: &[String]) -> io::Result<()> { fn process_items_for_removal(
todo_path: &Path,
refs: &[String],
archive_path: Option<&Path>,
) -> Result<()> {
let should_archive = archive_path.is_some();
if !todo_path.exists() { if !todo_path.exists() {
println!( println!(
"{}No notes found.{}", "{}No notes found.{}",
@@ -485,14 +539,17 @@ pub fn archive_items(todo_path: &Path, archive_path: &Path, refs: &[String]) ->
return Ok(()); return Ok(());
} }
let content = fs::read_to_string(todo_path)?; let content = fs::read_to_string(todo_path)
let mut todo_file = parse_todo_file(&content) .with_context(|| format!("Failed to read file: {}", todo_path.display()))?;
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; let mut todo_file =
parse_todo_file(&content).map_err(|e| anyhow!("Failed to parse file: {}", e))?;
if todo_file.is_empty() { if todo_file.is_empty() {
let action = if should_archive { "archive" } else { "remove" };
println!( println!(
"{}No notes to archive.{}", "{}No notes to {}.{}",
color::Fg(color::Yellow), color::Fg(color::Yellow),
action,
color::Fg(color::Reset) color::Fg(color::Reset)
); );
return Ok(()); return Ok(());
@@ -567,28 +624,40 @@ pub fn archive_items(todo_path: &Path, archive_path: &Path, refs: &[String]) ->
} }
} }
// Load archive file // Load archive file if we're archiving
let mut archive_file = if archive_path.exists() { let mut archive_file = if let Some(archive_path) = archive_path {
let archive_content = fs::read_to_string(archive_path)?; if archive_path.exists() {
parse_archive_file(&archive_content) let archive_content = fs::read_to_string(archive_path)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))? .with_context(|| format!("Failed to read archive: {}", archive_path.display()))?;
parse_archive_file(&archive_content)
.map_err(|e| anyhow!("Failed to parse file: {}", e))?
} else {
ArchiveFile::new()
}
} else { } else {
ArchiveFile::new() ArchiveFile::new()
}; };
// Add items to archive // Add items to archive if we're archiving
if !tasks_to_archive.is_empty() { if should_archive && !tasks_to_archive.is_empty() {
archive_file.add_items_for_today("Default", tasks_to_archive.clone()); if let Some(archive_path) = archive_path {
let archive_content = generate_archive_file(&archive_file); archive_file.add_items_for_today("Default", tasks_to_archive.clone());
fs::write(archive_path, archive_content)?; let archive_content = generate_archive_file(&archive_file);
fs::write(archive_path, archive_content)
.with_context(|| format!("Failed to write archive: {}", archive_path.display()))?;
}
} }
// Remove items from todo file // Remove items from todo file
// Remove subtasks first (in reverse order to maintain indices) // Remove subtasks first (in reverse order to maintain indices)
let mut subtasks_by_task: std::collections::HashMap<usize, Vec<usize>> = std::collections::HashMap::new(); let mut subtasks_by_task: std::collections::HashMap<usize, Vec<usize>> =
std::collections::HashMap::new();
for &(task_idx, subtask_idx) in &subtasks_to_remove { for &(task_idx, subtask_idx) in &subtasks_to_remove {
if !whole_tasks_to_remove.contains(&task_idx) { if !whole_tasks_to_remove.contains(&task_idx) {
subtasks_by_task.entry(task_idx).or_default().push(subtask_idx); subtasks_by_task
.entry(task_idx)
.or_default()
.push(subtask_idx);
} }
} }
@@ -614,11 +683,15 @@ pub fn archive_items(todo_path: &Path, archive_path: &Path, refs: &[String]) ->
auto_completed_tasks.push(todo_file.tasks[task_idx].clone()); auto_completed_tasks.push(todo_file.tasks[task_idx].clone());
} }
// Add auto-completed tasks to archive // Add auto-completed tasks to archive if we're archiving
if !auto_completed_tasks.is_empty() { if should_archive && !auto_completed_tasks.is_empty() {
archive_file.add_items_for_today("Default", auto_completed_tasks.clone()); if let Some(archive_path) = archive_path {
let archive_content = generate_archive_file(&archive_file); archive_file.add_items_for_today("Default", auto_completed_tasks.clone());
fs::write(archive_path, archive_content)?; let archive_content = generate_archive_file(&archive_file);
fs::write(archive_path, archive_content).with_context(|| {
format!("Failed to write archive: {}", archive_path.display())
})?;
}
} }
} }
@@ -634,13 +707,20 @@ pub fn archive_items(todo_path: &Path, archive_path: &Path, refs: &[String]) ->
// Save updated todo file // Save updated todo file
let new_content = generate_todo_file(&todo_file); let new_content = generate_todo_file(&todo_file);
fs::write(todo_path, new_content)?; fs::write(todo_path, new_content)
.with_context(|| format!("Failed to write file: {}", todo_path.display()))?;
let total_archived = tasks_to_archive.len() + auto_completed_tasks.len(); let total_processed = tasks_to_archive.len() + auto_completed_tasks.len();
let action_verb = if should_archive {
"Archived"
} else {
"Removed"
};
println!( println!(
"{}✓ Archived {} item(s){}", "{}✓ {} {} item(s){}",
color::Fg(color::Green), color::Fg(color::Green),
total_archived, action_verb,
total_processed,
color::Fg(color::Reset) color::Fg(color::Reset)
); );
@@ -668,52 +748,38 @@ pub fn archive_items(todo_path: &Path, archive_path: &Path, refs: &[String]) ->
Ok(()) Ok(())
} }
/// Remove items without archiving (same logic as archive but don't save to archive) /// Archive specified tasks or subtasks
pub fn remove_items(todo_path: &Path, refs: &[String]) -> io::Result<()> { pub fn archive_items(todo_path: &Path, archive_path: &Path, refs: &[String]) -> Result<()> {
// Implementation is similar to archive_items but without the archiving part process_items_for_removal(todo_path, refs, Some(archive_path))
// For brevity, I'll implement a simplified version that reuses archive logic
// Create a temporary path that we won't actually use
let temp_dir = std::env::temp_dir();
let temp_archive = temp_dir.join("temp_archive_unused.md");
// Call archive_items but then delete the temp archive file
let result = archive_items(todo_path, &temp_archive, refs);
// Clean up temp file if it was created
if temp_archive.exists() {
let _ = fs::remove_file(&temp_archive);
}
// Change the success message to indicate removal instead of archiving
if result.is_ok() {
// The archive_items function already printed success, so we need to override
// For simplicity in this implementation, we'll leave the message as is
// In a full implementation, we'd refactor to avoid this duplication
}
result
} }
/// Remove items without archiving
pub fn remove_items(todo_path: &Path, refs: &[String]) -> Result<()> {
process_items_for_removal(todo_path, refs, None)
}
/// Opens the todo file in the user's preferred editor /// Opens the todo file in the user's preferred editor
pub fn edit_note(todo_path: &Path) -> io::Result<()> { pub fn edit_note(todo_path: &Path) -> Result<()> {
let editor = env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); let editor = env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
// Create the file if it doesn't exist // Create the file if it doesn't exist
if !todo_path.exists() { if !todo_path.exists() {
let empty_file = TodoFile::new("TODOs".to_string()); let empty_file = TodoFile::new("TODOs".to_string());
let content = generate_todo_file(&empty_file); let content = generate_todo_file(&empty_file);
fs::write(todo_path, content)?; fs::write(todo_path, content)
.with_context(|| format!("Failed to write file: {}", todo_path.display()))?;
} }
let status = Command::new(&editor).arg(todo_path).status()?; let status = Command::new(&editor)
.arg(todo_path)
.status()
.with_context(|| format!("Failed to run editor: {}", editor))?;
if !status.success() { if !status.success() {
println!( println!(
"{}Editor exited with error code: {}{}", "{}Editor exited with error code: {}{}",
color::Fg(color::Red), color::Fg(color::Red),
status.code().unwrap_or(-1), status.code().unwrap_or(1),
color::Fg(color::Reset) color::Fg(color::Reset)
); );
} }
+22 -15
View File
@@ -3,8 +3,8 @@
//! A command-line todo application that emphasizes adding items to the top //! A command-line todo application that emphasizes adding items to the top
//! and provides an interactive review mode for processing items. //! and provides an interactive review mode for processing items.
use anyhow::{Context, Result};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::io;
use xdg::BaseDirectories; use xdg::BaseDirectories;
mod commands; mod commands;
@@ -74,42 +74,49 @@ enum Commands {
/// Entry point that parses CLI arguments and dispatches to appropriate command handlers. /// Entry point that parses CLI arguments and dispatches to appropriate command handlers.
/// Sets up XDG-compliant data directory paths and handles migration from plain text format. /// Sets up XDG-compliant data directory paths and handles migration from plain text format.
fn main() -> io::Result<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let base = BaseDirectories::with_prefix("ldr"); let base = BaseDirectories::with_prefix("ldr");
// Old plain text file paths // Old plain text file paths
let note_path = base let note_path = base
.place_data_file("note.txt") .place_data_file("note.txt")
.expect("Failed to create data directory"); .context("Failed to create data directory for note.txt")?;
let archive_path = base let archive_path = base
.place_data_file("archive.txt") .place_data_file("archive.txt")
.expect("Failed to create data directory"); .context("Failed to create data directory for archive.txt")?;
// New Markdown file paths // New Markdown file paths
let todo_md_path = base let todo_md_path = base
.place_data_file("todos.md") .place_data_file("todos.md")
.expect("Failed to create data directory"); .context("Failed to create data directory for todos.md")?;
let archive_md_path = base let archive_md_path = base
.place_data_file("archive.md") .place_data_file("archive.md")
.expect("Failed to create data directory"); .context("Failed to create data directory for archive.md")?;
// Check if migration is needed and perform it // Check if migration is needed and perform it
if migration::needs_migration(&note_path, &archive_path, &todo_md_path, &archive_md_path) { if migration::needs_migration(&note_path, &archive_path, &todo_md_path, &archive_md_path) {
if let Err(e) = migration::perform_migration(&note_path, &archive_path, &todo_md_path, &archive_md_path) { migration::perform_migration(&note_path, &archive_path, &todo_md_path, &archive_md_path)
eprintln!("Migration failed: {}", e); .map_err(|e| anyhow::anyhow!("Migration from plain text to Markdown failed: {}", e))?;
return Err(io::Error::other(e));
}
} }
match cli.command { match cli.command {
Commands::Add { text, under } => commands::add_entry(&todo_md_path, &text, under), Commands::Add { text, under } => {
commands::add_entry(&todo_md_path, &text, under).context("Failed to add entry")?
}
Commands::Ls { num, all, filter } => { Commands::Ls { num, all, filter } => {
commands::list_note(&todo_md_path, num, all, filter.as_deref()) commands::list_note(&todo_md_path, num, all, filter.as_deref())
.context("Failed to list notes")?
} }
Commands::Up { refs } => commands::prioritize_items(&todo_md_path, &refs), Commands::Up { refs } => commands::prioritize_items(&todo_md_path, &refs)
Commands::Do { refs } => commands::archive_items(&todo_md_path, &archive_md_path, &refs), .context("Failed to prioritize items")?,
Commands::Rm { refs } => commands::remove_items(&todo_md_path, &refs), Commands::Do { refs } => commands::archive_items(&todo_md_path, &archive_md_path, &refs)
Commands::Edit => commands::edit_note(&todo_md_path), .context("Failed to archive items")?,
Commands::Rm { refs } => {
commands::remove_items(&todo_md_path, &refs).context("Failed to remove items")?
}
Commands::Edit => commands::edit_note(&todo_md_path).context("Failed to edit note")?,
} }
Ok(())
} }
+83 -40
View File
@@ -94,7 +94,10 @@ impl TaskRef {
task_part.push(ch); task_part.push(ch);
} else if ch.is_ascii_lowercase() { } else if ch.is_ascii_lowercase() {
if i == 0 { if i == 0 {
return Err(format!("Task reference must start with a number: {}", input)); return Err(format!(
"Task reference must start with a number: {}",
input
));
} }
if subtask_char.is_some() { if subtask_char.is_some() {
return Err(format!("Multiple subtask letters not allowed: {}", input)); return Err(format!("Multiple subtask letters not allowed: {}", input));
@@ -109,9 +112,24 @@ impl TaskRef {
return Err(format!("No task number found: {}", input)); return Err(format!("No task number found: {}", input));
} }
let task_index = task_part.parse::<usize>().map_err(|_| { let task_num = task_part
format!("Invalid task number: {}", task_part) .parse::<usize>()
})? - 1; // Convert to 0-based .map_err(|_| format!("Invalid task number: {}", task_part))?;
// Validate task number range
if task_num == 0 {
return Err("Task number must be at least 1".to_string());
}
const MAX_TASK_NUM: usize = 10000;
if task_num > MAX_TASK_NUM {
return Err(format!(
"Task number too large: {}. Maximum is {}",
task_num, MAX_TASK_NUM
));
}
let task_index = task_num - 1; // Convert to 0-based
let subtask_index = subtask_char.map(|ch| (ch as usize) - ('a' as usize)); let subtask_index = subtask_char.map(|ch| (ch as usize) - ('a' as usize));
@@ -136,8 +154,9 @@ pub fn parse_todo_file(content: &str) -> Result<TodoFile, String> {
let mut todo_file = TodoFile::new("TODOs".to_string()); let mut todo_file = TodoFile::new("TODOs".to_string());
let mut current_task: Option<Task> = None; let mut current_task: Option<Task> = None;
let mut warned_about_deep_nesting = false;
for line in lines.iter() { for (line_num, line) in lines.iter().enumerate() {
let trimmed = line.trim(); let trimmed = line.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
@@ -156,7 +175,33 @@ pub fn parse_todo_file(content: &str) -> Result<TodoFile, String> {
else if trimmed.starts_with("##") { else if trimmed.starts_with("##") {
continue; continue;
} }
// Handle subtasks - be flexible with indentation (2, 3, or 4 spaces, or tab) // Check for deep nesting first - warn and convert to level 1 subtask
else if (line.starts_with(" ") || line.starts_with("\t\t"))
&& (line.trim_start().starts_with("- ")
|| line.trim_start().starts_with("* ")
|| line.trim_start().starts_with("+ "))
{
// This is a deeply nested item - warn once and treat as level 1 subtask
if !warned_about_deep_nesting {
eprintln!("Warning: Deep nesting detected (line {}). Converting to level 1 subtask. Only single-level subtasks are supported.", line_num + 1);
warned_about_deep_nesting = true;
}
let item_text = line
.trim_start()
.strip_prefix("- ")
.or_else(|| line.trim_start().strip_prefix("* "))
.or_else(|| line.trim_start().strip_prefix("+ "))
.unwrap_or(line.trim_start());
if let Some(ref mut task) = current_task {
task.add_subtask(item_text.trim().to_string());
} else {
// If no current task, treat as main task
current_task = Some(Task::new(item_text.trim().to_string()));
}
}
// Handle subtasks - be flexible with indentation (2, 3, or 4 spaces, or single tab)
else if let Some(subtask_text) = line.strip_prefix(" - ") { else if let Some(subtask_text) = line.strip_prefix(" - ") {
if let Some(ref mut task) = current_task { if let Some(ref mut task) = current_task {
task.add_subtask(subtask_text.trim().to_string()); task.add_subtask(subtask_text.trim().to_string());
@@ -172,14 +217,14 @@ pub fn parse_todo_file(content: &str) -> Result<TodoFile, String> {
current_task = Some(Task::new(subtask_text.trim().to_string())); current_task = Some(Task::new(subtask_text.trim().to_string()));
} }
} else if let Some(subtask_text) = line.strip_prefix(" - ") { } else if let Some(subtask_text) = line.strip_prefix(" - ") {
// Handle 4-space indentation (but not deeper nesting) // Handle 4-space indentation
if let Some(ref mut task) = current_task { if let Some(ref mut task) = current_task {
task.add_subtask(subtask_text.trim().to_string()); task.add_subtask(subtask_text.trim().to_string());
} else { } else {
current_task = Some(Task::new(subtask_text.trim().to_string())); current_task = Some(Task::new(subtask_text.trim().to_string()));
} }
} else if let Some(subtask_text) = line.strip_prefix("\t- ") { } else if let Some(subtask_text) = line.strip_prefix("\t- ") {
// Handle tab indentation // Handle single tab indentation
if let Some(ref mut task) = current_task { if let Some(ref mut task) = current_task {
task.add_subtask(subtask_text.trim().to_string()); task.add_subtask(subtask_text.trim().to_string());
} else { } else {
@@ -209,11 +254,6 @@ pub fn parse_todo_file(content: &str) -> Result<TodoFile, String> {
current_task = Some(Task::new(task_text.trim().to_string())); current_task = Some(Task::new(task_text.trim().to_string()));
} }
// Check for overly deep nesting (5+ spaces or multiple tabs)
else if line.starts_with(" - ") || line.starts_with("\t\t") {
// Skip deep nesting but don't error - just ignore these lines
continue;
}
// Handle non-markdown lines gracefully - ignore unknown formatting // Handle non-markdown lines gracefully - ignore unknown formatting
else if !trimmed.is_empty() { else if !trimmed.is_empty() {
// If it looks like it might be a task without proper formatting, treat it as one // If it looks like it might be a task without proper formatting, treat it as one
@@ -276,25 +316,21 @@ impl ArchiveFile {
pub fn add_items_for_today(&mut self, list_name: &str, tasks: Vec<Task>) { pub fn add_items_for_today(&mut self, list_name: &str, tasks: Vec<Task>) {
let today = chrono::Local::now().format("%Y-%m-%d").to_string(); let today = chrono::Local::now().format("%Y-%m-%d").to_string();
// Find or create today's entry // Find today's entry position
let entry = self.entries.iter_mut() let entry_pos = self.entries.iter().position(|e| e.date == today);
.find(|e| e.date == today)
.map(|e| e as *mut ArchiveEntry);
if let Some(entry) = entry { if let Some(pos) = entry_pos {
unsafe { // Entry exists, add tasks to it
(*entry).lists self.entries[pos]
.entry(list_name.to_string()) .lists
.or_insert_with(Vec::new) .entry(list_name.to_string())
.extend(tasks); .or_default()
} .extend(tasks);
} else { } else {
// Create new entry for today
let mut lists = HashMap::new(); let mut lists = HashMap::new();
lists.insert(list_name.to_string(), tasks); lists.insert(list_name.to_string(), tasks);
self.entries.insert(0, ArchiveEntry { self.entries.insert(0, ArchiveEntry { date: today, lists });
date: today,
lists,
});
} }
} }
} }
@@ -323,9 +359,10 @@ pub fn parse_archive_file(content: &str) -> Result<ArchiveFile, String> {
} else if let Some(date) = trimmed.strip_prefix("## ") { } else if let Some(date) = trimmed.strip_prefix("## ") {
// Save previous task and entry // Save previous task and entry
if let (Some(mut entry), Some(task)) = (current_entry.take(), current_task.take()) { if let (Some(mut entry), Some(task)) = (current_entry.take(), current_task.take()) {
entry.lists entry
.lists
.entry(current_list_name.clone()) .entry(current_list_name.clone())
.or_insert_with(Vec::new) .or_default()
.push(task); .push(task);
archive.entries.push(entry); archive.entries.push(entry);
} else if let Some(entry) = current_entry.take() { } else if let Some(entry) = current_entry.take() {
@@ -339,20 +376,24 @@ pub fn parse_archive_file(content: &str) -> Result<ArchiveFile, String> {
current_list_name = "Default".to_string(); current_list_name = "Default".to_string();
} else if let Some(list_name) = trimmed.strip_prefix("### ") { } else if let Some(list_name) = trimmed.strip_prefix("### ") {
// Save previous task // Save previous task
if let (Some(ref mut entry), Some(task)) = (current_entry.as_mut(), current_task.take()) { if let (Some(ref mut entry), Some(task)) = (current_entry.as_mut(), current_task.take())
entry.lists {
entry
.lists
.entry(current_list_name.clone()) .entry(current_list_name.clone())
.or_insert_with(Vec::new) .or_default()
.push(task); .push(task);
} }
current_list_name = list_name.to_string(); current_list_name = list_name.to_string();
} else if let Some(task_text) = trimmed.strip_prefix("- ") { } else if let Some(task_text) = trimmed.strip_prefix("- ") {
// Save previous task // Save previous task
if let (Some(ref mut entry), Some(task)) = (current_entry.as_mut(), current_task.take()) { if let (Some(ref mut entry), Some(task)) = (current_entry.as_mut(), current_task.take())
entry.lists {
entry
.lists
.entry(current_list_name.clone()) .entry(current_list_name.clone())
.or_insert_with(Vec::new) .or_default()
.push(task); .push(task);
} }
@@ -379,10 +420,7 @@ pub fn parse_archive_file(content: &str) -> Result<ArchiveFile, String> {
// Save final task and entry // Save final task and entry
if let Some(mut entry) = current_entry { if let Some(mut entry) = current_entry {
if let Some(task) = current_task { if let Some(task) = current_task {
entry.lists entry.lists.entry(current_list_name).or_default().push(task);
.entry(current_list_name)
.or_insert_with(Vec::new)
.push(task);
} }
archive.entries.push(entry); archive.entries.push(entry);
} }
@@ -461,6 +499,11 @@ mod tests {
assert!(TaskRef::parse("1A").is_err()); assert!(TaskRef::parse("1A").is_err());
assert!(TaskRef::parse("1ab").is_err()); assert!(TaskRef::parse("1ab").is_err());
assert!(TaskRef::parse("1-2").is_err()); assert!(TaskRef::parse("1-2").is_err());
// Test validation edge cases
assert!(TaskRef::parse("0").is_err()); // Zero task number
assert!(TaskRef::parse("10001").is_err()); // Too large task number
assert!(TaskRef::parse("999999999999999999999").is_err()); // Integer overflow
} }
#[test] #[test]
+30 -8
View File
@@ -3,12 +3,17 @@
//! This module handles the one-time conversion from the old plain text format //! This module handles the one-time conversion from the old plain text format
//! to the new Markdown format with Default list structure. //! to the new Markdown format with Default list structure.
use crate::markdown::{Task, TodoFile, ArchiveFile}; use crate::markdown::{ArchiveFile, Task, TodoFile};
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
/// Check if migration is needed (plain text files exist but Markdown files don't) /// Check if migration is needed (plain text files exist but Markdown files don't)
pub fn needs_migration(note_path: &Path, archive_path: &Path, todo_md_path: &Path, archive_md_path: &Path) -> bool { pub fn needs_migration(
note_path: &Path,
archive_path: &Path,
todo_md_path: &Path,
archive_md_path: &Path,
) -> bool {
let has_old_files = note_path.exists() || archive_path.exists(); let has_old_files = note_path.exists() || archive_path.exists();
let has_new_files = todo_md_path.exists() || archive_md_path.exists(); let has_new_files = todo_md_path.exists() || archive_md_path.exists();
@@ -21,8 +26,8 @@ pub fn migrate_note_file(note_path: &Path) -> Result<TodoFile, String> {
return Ok(create_empty_todo_file()); return Ok(create_empty_todo_file());
} }
let content = fs::read_to_string(note_path) let content =
.map_err(|e| format!("Failed to read note file: {}", e))?; fs::read_to_string(note_path).map_err(|e| format!("Failed to read note file: {}", e))?;
let mut todo_file = TodoFile::new("TODOs".to_string()); let mut todo_file = TodoFile::new("TODOs".to_string());
@@ -118,7 +123,9 @@ pub fn perform_migration(
// Count migrated items // Count migrated items
let todo_count = todo_file.task_count(); let todo_count = todo_file.task_count();
let archive_count = archive_file.entries.first() let archive_count = archive_file
.entries
.first()
.and_then(|e| e.lists.get("Default")) .and_then(|e| e.lists.get("Default"))
.map(|tasks| tasks.len()) .map(|tasks| tasks.len())
.unwrap_or(0); .unwrap_or(0);
@@ -206,18 +213,33 @@ mod tests {
let archive_md_path = temp_dir.path().join("archive.md"); let archive_md_path = temp_dir.path().join("archive.md");
// No files exist - no migration needed // No files exist - no migration needed
assert!(!needs_migration(&note_path, &archive_path, &todo_md_path, &archive_md_path)); assert!(!needs_migration(
&note_path,
&archive_path,
&todo_md_path,
&archive_md_path
));
// Create old file // Create old file
fs::write(&note_path, "test").unwrap(); fs::write(&note_path, "test").unwrap();
// Old file exists, no new files - migration needed // Old file exists, no new files - migration needed
assert!(needs_migration(&note_path, &archive_path, &todo_md_path, &archive_md_path)); assert!(needs_migration(
&note_path,
&archive_path,
&todo_md_path,
&archive_md_path
));
// Create new file too // Create new file too
fs::write(&todo_md_path, "test").unwrap(); fs::write(&todo_md_path, "test").unwrap();
// Both old and new files exist - no migration needed // Both old and new files exist - no migration needed
assert!(!needs_migration(&note_path, &archive_path, &todo_md_path, &archive_md_path)); assert!(!needs_migration(
&note_path,
&archive_path,
&todo_md_path,
&archive_md_path
));
} }
} }
+25 -10
View File
@@ -264,7 +264,7 @@ mod list_tests {
#[test] #[test]
fn test_list_with_all_flag() { fn test_list_with_all_flag() {
let file = create_markdown_test_file(&[ let file = create_markdown_test_file(&[
"item 1", "item 2", "item 3", "item 4", "item 5", "item 6" "item 1", "item 2", "item 3", "item 4", "item 5", "item 6",
]); ]);
// Test that all=true shows all items regardless of num // Test that all=true shows all items regardless of num
@@ -278,7 +278,7 @@ mod list_tests {
#[test] #[test]
fn test_list_without_all_flag() { fn test_list_without_all_flag() {
let file = create_markdown_test_file(&[ let file = create_markdown_test_file(&[
"item 1", "item 2", "item 3", "item 4", "item 5", "item 6" "item 1", "item 2", "item 3", "item 4", "item 5", "item 6",
]); ]);
// Test that all=false respects num limit // Test that all=false respects num limit
@@ -292,7 +292,10 @@ mod list_tests {
#[test] #[test]
fn test_list_with_filter_and_all() { fn test_list_with_filter_and_all() {
let file = create_markdown_test_file(&[ let file = create_markdown_test_file(&[
"read: book 1", "write: article", "read: book 2", "task: cleanup" "read: book 1",
"write: article",
"read: book 2",
"task: cleanup",
]); ]);
// Test that all=true with filter shows all matching items // Test that all=true with filter shows all matching items
@@ -486,7 +489,9 @@ mod remove_tests {
} }
/// Helper function to create Markdown todo files with subtasks /// Helper function to create Markdown todo files with subtasks
fn create_markdown_test_file_with_subtasks(tasks_with_subtasks: &[(&str, &[&str])]) -> NamedTempFile { fn create_markdown_test_file_with_subtasks(
tasks_with_subtasks: &[(&str, &[&str])],
) -> NamedTempFile {
let mut content = String::from("# TODOs\n\n"); let mut content = String::from("# TODOs\n\n");
for (task, subtasks) in tasks_with_subtasks { for (task, subtasks) in tasks_with_subtasks {
content.push_str(&format!("- {}\n", task)); content.push_str(&format!("- {}\n", task));
@@ -519,7 +524,8 @@ mod remove_tests {
/// Tests removing multiple items /// Tests removing multiple items
#[test] #[test]
fn test_remove_multiple_items() { fn test_remove_multiple_items() {
let file = create_markdown_test_file(&["First item", "Second item", "Third item", "Fourth item"]); let file =
create_markdown_test_file(&["First item", "Second item", "Third item", "Fourth item"]);
let result = std::panic::catch_unwind(|| { let result = std::panic::catch_unwind(|| {
remove_items(file.path(), &["1".to_string(), "3".to_string()]).unwrap(); remove_items(file.path(), &["1".to_string(), "3".to_string()]).unwrap();
@@ -570,7 +576,11 @@ mod remove_tests {
let file = create_markdown_test_file(&["First item", "Second item", "Third item"]); let file = create_markdown_test_file(&["First item", "Second item", "Third item"]);
let result = std::panic::catch_unwind(|| { let result = std::panic::catch_unwind(|| {
remove_items(file.path(), &["2".to_string(), "2".to_string(), "1".to_string()]).unwrap(); remove_items(
file.path(),
&["2".to_string(), "2".to_string(), "1".to_string()],
)
.unwrap();
}); });
assert!(result.is_ok()); assert!(result.is_ok());
@@ -636,9 +646,9 @@ mod remove_tests {
#[test] #[test]
fn test_auto_complete_parent_when_all_subtasks_done() { fn test_auto_complete_parent_when_all_subtasks_done() {
let file = create_markdown_test_file_with_subtasks(&[ let file = create_markdown_test_file_with_subtasks(&[
("Task A", &[]), // Task with no subtasks ("Task A", &[]), // Task with no subtasks
("Task B", &["Subtask 1", "Subtask 2"]), // Task with 2 subtasks ("Task B", &["Subtask 1", "Subtask 2"]), // Task with 2 subtasks
("Task C", &["Another subtask"]), // Task with 1 subtask ("Task C", &["Another subtask"]), // Task with 1 subtask
]); ]);
let temp_dir = tempfile::tempdir().unwrap(); let temp_dir = tempfile::tempdir().unwrap();
@@ -646,7 +656,12 @@ mod remove_tests {
// Archive both subtasks of Task B (2a and 2b) // Archive both subtasks of Task B (2a and 2b)
let result = std::panic::catch_unwind(|| { let result = std::panic::catch_unwind(|| {
archive_items(file.path(), &archive_path, &["2a".to_string(), "2b".to_string()]).unwrap(); archive_items(
file.path(),
&archive_path,
&["2a".to_string(), "2b".to_string()],
)
.unwrap();
}); });
assert!(result.is_ok()); assert!(result.is_ok());
+32 -10
View File
@@ -39,7 +39,10 @@ impl TestEnv {
.expect("Failed to build ldr binary"); .expect("Failed to build ldr binary");
if !output.status.success() { if !output.status.success() {
panic!("Failed to build binary: {}", String::from_utf8_lossy(&output.stderr)); panic!(
"Failed to build binary: {}",
String::from_utf8_lossy(&output.stderr)
);
} }
let mut binary_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let mut binary_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
@@ -95,13 +98,19 @@ struct CommandResult {
impl CommandResult { impl CommandResult {
fn assert_success(&self) { fn assert_success(&self) {
if self.status != 0 { if self.status != 0 {
panic!("Command failed with status {}: stderr: {}", self.status, self.stderr); panic!(
"Command failed with status {}: stderr: {}",
self.status, self.stderr
);
} }
} }
fn assert_failure(&self) { fn assert_failure(&self) {
// For LDR, failure is indicated by error messages in stdout, not exit code // For LDR, failure is indicated by error messages in stdout, not exit code
if !self.stdout.contains("Invalid") && !self.stderr.contains("Error") && !self.stdout.is_empty() { if !self.stdout.contains("Invalid")
&& !self.stderr.contains("Error")
&& !self.stdout.is_empty()
{
panic!("Command unexpectedly succeeded: stdout: {}", self.stdout); panic!("Command unexpectedly succeeded: stdout: {}", self.stdout);
} }
} }
@@ -189,7 +198,9 @@ mod subtask_operations {
// Add subtask // Add subtask
let result = env.run_ldr(&["add", "Subtask A", "--under", "1"]); let result = env.run_ldr(&["add", "Subtask A", "--under", "1"]);
result.assert_success(); result.assert_success();
assert!(result.stdout.contains("✓ Added subtask to task 1: Subtask A")); assert!(result
.stdout
.contains("✓ Added subtask to task 1: Subtask A"));
// Add another subtask // Add another subtask
let result = env.run_ldr(&["add", "Subtask B", "--under", "1"]); let result = env.run_ldr(&["add", "Subtask B", "--under", "1"]);
@@ -321,7 +332,7 @@ mod prioritization_tests {
let result = env.run_ldr(&["up", "5"]); let result = env.run_ldr(&["up", "5"]);
result.assert_failure(); result.assert_failure();
let result = env.run_ldr(&["up", "1z"]); // Invalid subtask let result = env.run_ldr(&["up", "1z"]); // Invalid subtask
result.assert_failure(); result.assert_failure();
} }
} }
@@ -534,7 +545,9 @@ mod filtering_tests {
// Test filtering respects limits // Test filtering respects limits
let result = env.run_ldr(&["ls", "-n", "3", "read:"]); let result = env.run_ldr(&["ls", "-n", "3", "read:"]);
result.assert_success(); result.assert_success();
let matching_lines: Vec<&str> = result.stdout.lines() let matching_lines: Vec<&str> = result
.stdout
.lines()
.filter(|line| line.contains("read:")) .filter(|line| line.contains("read:"))
.collect(); .collect();
assert!(matching_lines.len() <= 3); assert!(matching_lines.len() <= 3);
@@ -542,7 +555,9 @@ mod filtering_tests {
// Test filtering with --all // Test filtering with --all
let result = env.run_ldr(&["ls", "--all", "read:"]); let result = env.run_ldr(&["ls", "--all", "read:"]);
result.assert_success(); result.assert_success();
let matching_lines: Vec<&str> = result.stdout.lines() let matching_lines: Vec<&str> = result
.stdout
.lines()
.filter(|line| line.contains("read:")) .filter(|line| line.contains("read:"))
.collect(); .collect();
assert_eq!(matching_lines.len(), 10); assert_eq!(matching_lines.len(), 10);
@@ -557,7 +572,9 @@ mod filtering_tests {
let result = env.run_ldr(&["ls", "nonexistent"]); let result = env.run_ldr(&["ls", "nonexistent"]);
result.assert_success(); result.assert_success();
assert!(result.stdout.contains("No items found matching filter: \"nonexistent\"")); assert!(result
.stdout
.contains("No items found matching filter: \"nonexistent\""));
} }
} }
@@ -629,7 +646,7 @@ mod edit_functionality {
let result = Command::new(&env.binary_path) let result = Command::new(&env.binary_path)
.args(&["edit"]) .args(&["edit"])
.env("XDG_DATA_HOME", &env.data_dir) .env("XDG_DATA_HOME", &env.data_dir)
.env("EDITOR", "touch") // Will just touch the file .env("EDITOR", "touch") // Will just touch the file
.output() .output()
.expect("Failed to execute edit command"); .expect("Failed to execute edit command");
@@ -659,7 +676,12 @@ mod edit_functionality {
// Should succeed and echo the file path // Should succeed and echo the file path
assert_eq!(result.status.code().unwrap_or(-1), 0); assert_eq!(result.status.code().unwrap_or(-1), 0);
let stdout = String::from_utf8_lossy(&result.stdout); let stdout = String::from_utf8_lossy(&result.stdout);
assert!(stdout.contains("todos.md"), "Alias '{}' didn't work: {}", alias, stdout); assert!(
stdout.contains("todos.md"),
"Alias '{}' didn't work: {}",
alias,
stdout
);
} }
} }
} }
+130
View File
@@ -0,0 +1,130 @@
use std::process::Command;
use tempfile::TempDir;
fn run_ldr(dir: &TempDir, args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_ldr"))
.env("XDG_DATA_HOME", dir.path())
.args(args)
.output()
.expect("Failed to run ldr")
}
#[test]
fn test_empty_task_rejected() {
let dir = TempDir::new().unwrap();
// Try to add empty task
let output = run_ldr(&dir, &["add", ""]);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Cannot add empty task"));
// Try to add whitespace-only task
let output = run_ldr(&dir, &["add", " "]);
assert!(!output.status.success());
}
#[test]
fn test_task_length_limit() {
let dir = TempDir::new().unwrap();
// Create a very long task (over 500 chars)
let long_text = "x".repeat(501);
let output = run_ldr(&dir, &["add", &long_text]);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Task text too long"));
assert!(stderr.contains("Maximum length is 500"));
}
#[test]
fn test_max_subtasks_limit() {
let dir = TempDir::new().unwrap();
// Add a main task
run_ldr(&dir, &["add", "Main task"]);
// Add 26 subtasks (a-z)
for i in 0..26 {
let subtask = format!("Subtask {}", i);
let output = run_ldr(&dir, &["add", &subtask, "--under", "1"]);
assert!(output.status.success());
}
// Try to add 27th subtask - should fail
let output = run_ldr(&dir, &["add", "One too many", "--under", "1"]);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("already has maximum number of subtasks"));
}
#[test]
fn test_invalid_task_references() {
let dir = TempDir::new().unwrap();
// Add some tasks
run_ldr(&dir, &["add", "Task 1"]);
run_ldr(&dir, &["add", "Task 2"]);
// Test zero task number - should print error but return success
let output = run_ldr(&dir, &["up", "0"]);
// Check that error message was printed even if exit code is 0
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("Invalid task reference")
|| stdout.contains("Task number must be at least 1")
);
// Test too large task number
let output = run_ldr(&dir, &["up", "999"]);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Invalid task number"));
// Test invalid formats
let output = run_ldr(&dir, &["up", "1A"]); // uppercase letter
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Invalid") || stdout.contains("uppercase"));
let output = run_ldr(&dir, &["up", "abc"]); // no number
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Invalid") || stdout.contains("must start with a number"));
}
#[test]
fn test_subtask_under_invalid_parent() {
let dir = TempDir::new().unwrap();
// Try to add subtask under non-existent task
let output = run_ldr(&dir, &["add", "Subtask", "--under", "1"]);
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("Invalid task number"));
}
#[test]
fn test_deep_nesting_prevention() {
let dir = TempDir::new().unwrap();
// Create a markdown file with deep nesting
// ldr stores files in a subdirectory
std::fs::create_dir_all(dir.path().join("ldr")).unwrap();
let todos_path = dir.path().join("ldr").join("todos.md");
let content = r#"# TODOs
- Task 1
- Subtask 1
- Deep subtask (should be flattened)
- Very deep subtask (should be flattened)
"#;
std::fs::write(&todos_path, content).unwrap();
// List tasks - deep nesting should be handled silently now
// (warnings are printed during parse but list should work)
let output = run_ldr(&dir, &["ls", "-a"]);
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
// The deep subtasks should appear as regular subtasks (flattened)
// We should see the subtasks in the output
assert!(stdout.contains("Subtask 1") || stdout.contains("a."));
}