refactoring, cleanup
This commit is contained in:
9 files changed
+514
-201
No files matched your search
Generated
+7
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(¬e_path, &archive_path, &todo_md_path, &archive_md_path) {
|
if migration::needs_migration(¬e_path, &archive_path, &todo_md_path, &archive_md_path) {
|
||||||
if let Err(e) = migration::perform_migration(¬e_path, &archive_path, &todo_md_path, &archive_md_path) {
|
migration::perform_migration(¬e_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
@@ -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
@@ -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(¬e_path, &archive_path, &todo_md_path, &archive_md_path));
|
assert!(!needs_migration(
|
||||||
|
¬e_path,
|
||||||
|
&archive_path,
|
||||||
|
&todo_md_path,
|
||||||
|
&archive_md_path
|
||||||
|
));
|
||||||
|
|
||||||
// Create old file
|
// Create old file
|
||||||
fs::write(¬e_path, "test").unwrap();
|
fs::write(¬e_path, "test").unwrap();
|
||||||
|
|
||||||
// Old file exists, no new files - migration needed
|
// Old file exists, no new files - migration needed
|
||||||
assert!(needs_migration(¬e_path, &archive_path, &todo_md_path, &archive_md_path));
|
assert!(needs_migration(
|
||||||
|
¬e_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(¬e_path, &archive_path, &todo_md_path, &archive_md_path));
|
assert!(!needs_migration(
|
||||||
|
¬e_path,
|
||||||
|
&archive_path,
|
||||||
|
&todo_md_path,
|
||||||
|
&archive_md_path
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+25
-10
@@ -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
@@ -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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."));
|
||||||
|
}
|
||||||
Reference in new issue
Block a user