refactoring, cleanup

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

No files matched your search

+186 -164
View File
@@ -1,5 +1,5 @@
//! Integration tests for the LDR CLI application
//!
//!
//! These tests verify the entire application works correctly by running actual CLI commands
//! with temporary data directories. Tests cover all functionality and edge cases according to POLS.
@@ -21,32 +21,35 @@ impl TestEnv {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
let data_dir = temp_dir.path().join("ldr");
fs::create_dir_all(&data_dir).expect("Failed to create ldr data directory");
// Build the binary if it doesn't exist or is out of date
let binary_path = Self::ensure_binary_built();
Self {
_temp_dir: temp_dir,
data_dir,
binary_path,
}
}
fn ensure_binary_built() -> PathBuf {
let output = Command::new("cargo")
.args(&["build", "--bin", "ldr"])
.output()
.expect("Failed to build ldr binary");
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"));
binary_path.push("target/debug/ldr");
binary_path
}
/// Run ldr command with arguments in the isolated environment
fn run_ldr(&self, args: &[&str]) -> CommandResult {
let output = Command::new(&self.binary_path)
@@ -56,29 +59,29 @@ impl TestEnv {
.stderr(Stdio::piped())
.output()
.expect("Failed to execute ldr command");
CommandResult {
status: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
}
}
/// Get the path to the todos.md file
fn todos_path(&self) -> PathBuf {
self.data_dir.join("ldr/todos.md")
}
/// Get the path to the archive.md file
fn archive_path(&self) -> PathBuf {
self.data_dir.join("ldr/archive.md")
}
/// Read the contents of todos.md
fn read_todos(&self) -> String {
fs::read_to_string(self.todos_path()).unwrap_or_default()
}
/// Read the contents of archive.md
fn read_archive(&self) -> String {
fs::read_to_string(self.archive_path()).unwrap_or_default()
@@ -95,13 +98,19 @@ struct CommandResult {
impl CommandResult {
fn assert_success(&self) {
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) {
// 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);
}
}
@@ -110,49 +119,49 @@ impl CommandResult {
#[cfg(test)]
mod basic_operations {
use super::*;
#[test]
fn test_add_and_list_basic() {
let env = TestEnv::new();
// Add first item
let result = env.run_ldr(&["add", "First task"]);
result.assert_success();
assert!(result.stdout.contains("✓ Added: First task"));
// Add second item (should go to top)
let result = env.run_ldr(&["add", "Second task"]);
result.assert_success();
// List items
let result = env.run_ldr(&["ls"]);
result.assert_success();
// Check that second task appears first (POLS: newest items at top)
// Note: Output includes ANSI color codes, so we check for task text
assert!(result.stdout.contains("1. Second task"));
assert!(result.stdout.contains("2. First task"));
}
#[test]
fn test_empty_list() {
let env = TestEnv::new();
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("No notes yet."));
}
#[test]
fn test_list_with_limits() {
let env = TestEnv::new();
// Add 10 items
for i in 1..=10 {
let result = env.run_ldr(&["add", &format!("Task {}", i)]);
result.assert_success();
}
// Test default limit (5)
let result = env.run_ldr(&["ls"]);
result.assert_success();
@@ -160,13 +169,13 @@ mod basic_operations {
// Should show 5 items plus "... and X more items" line
assert!(line_count >= 5);
assert!(result.stdout.contains("and 5 more items"));
// Test custom limit
let result = env.run_ldr(&["ls", "-n", "3"]);
result.assert_success();
let lines: Vec<&str> = result.stdout.lines().collect();
assert!(lines.len() >= 3);
// Test show all
let result = env.run_ldr(&["ls", "--all"]);
result.assert_success();
@@ -177,60 +186,62 @@ mod basic_operations {
#[cfg(test)]
mod subtask_operations {
use super::*;
#[test]
fn test_add_subtask() {
let env = TestEnv::new();
// Add main task
let result = env.run_ldr(&["add", "Main task"]);
result.assert_success();
// Add subtask
let result = env.run_ldr(&["add", "Subtask A", "--under", "1"]);
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
let result = env.run_ldr(&["add", "Subtask B", "--under", "1"]);
result.assert_success();
// List and verify structure
let result = env.run_ldr(&["ls"]);
result.assert_success();
// Verify main task and subtasks appear in order added
assert!(result.stdout.contains("1. Main task"));
assert!(result.stdout.contains("a. Subtask A")); // First subtask
assert!(result.stdout.contains("b. Subtask B")); // Second subtask
}
#[test]
fn test_add_subtask_invalid_parent() {
let env = TestEnv::new();
// Try to add subtask to non-existent task
let result = env.run_ldr(&["add", "Orphan subtask", "--under", "1"]);
result.assert_failure();
assert!(result.stderr.contains("Invalid task number: 1"));
}
#[test]
fn test_subtask_numbering() {
let env = TestEnv::new();
// Add main task
env.run_ldr(&["add", "Task with many subtasks"]);
// Add multiple subtasks
for i in 1..=5 {
let result = env.run_ldr(&["add", &format!("Subtask {}", i), "--under", "1"]);
result.assert_success();
}
let result = env.run_ldr(&["ls", "--all"]);
result.assert_success();
// Verify letter sequence (in order added: a, b, c, d, e)
assert!(result.stdout.contains("a. Subtask 1"));
assert!(result.stdout.contains("b. Subtask 2"));
@@ -243,21 +254,21 @@ mod subtask_operations {
#[cfg(test)]
mod prioritization_tests {
use super::*;
#[test]
fn test_prioritize_basic() {
let env = TestEnv::new();
// Add tasks
env.run_ldr(&["add", "Task A"]);
env.run_ldr(&["add", "Task B"]);
env.run_ldr(&["add", "Task C"]);
// Prioritize task 3 (Task A)
let result = env.run_ldr(&["up", "3"]);
result.assert_success();
assert!(result.stdout.contains("✓ Prioritized 1 task(s)"));
// Verify new order
let result = env.run_ldr(&["ls"]);
result.assert_success();
@@ -266,21 +277,21 @@ mod prioritization_tests {
assert!(lines[1].contains("2. Task C"));
assert!(lines[2].contains("3. Task B"));
}
#[test]
fn test_prioritize_multiple() {
let env = TestEnv::new();
// Add tasks
env.run_ldr(&["add", "Task A"]);
env.run_ldr(&["add", "Task B"]);
env.run_ldr(&["add", "Task C"]);
env.run_ldr(&["add", "Task D"]);
// Prioritize tasks 4, 2 (in that order)
let result = env.run_ldr(&["up", "4", "2"]);
result.assert_success();
// Verify command-line order preserved (POLS)
let result = env.run_ldr(&["ls"]);
result.assert_success();
@@ -290,38 +301,38 @@ mod prioritization_tests {
assert!(lines[2].contains("3. Task D")); // Remaining items
assert!(lines[3].contains("4. Task B"));
}
#[test]
fn test_prioritize_subtask_moves_parent() {
let env = TestEnv::new();
// Add tasks with subtasks
env.run_ldr(&["add", "Task A"]);
env.run_ldr(&["add", "Subtask A1", "--under", "1"]);
env.run_ldr(&["add", "Task B"]);
env.run_ldr(&["add", "Subtask B1", "--under", "2"]);
// Prioritize subtask - should move entire parent task
let result = env.run_ldr(&["up", "2a"]); // Task A is now at position 2
result.assert_success();
// Verify parent task moved (POLS: subtask reference affects parent)
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("1. Task A")); // Parent moved to top
assert!(result.stdout.contains("a. Subtask A1"));
}
#[test]
fn test_prioritize_invalid_reference() {
let env = TestEnv::new();
env.run_ldr(&["add", "Task A"]);
let result = env.run_ldr(&["up", "5"]);
result.assert_failure();
let result = env.run_ldr(&["up", "1z"]); // Invalid subtask
let result = env.run_ldr(&["up", "1z"]); // Invalid subtask
result.assert_failure();
}
}
@@ -329,92 +340,92 @@ mod prioritization_tests {
#[cfg(test)]
mod archiving_tests {
use super::*;
#[test]
fn test_archive_single_task() {
let env = TestEnv::new();
env.run_ldr(&["add", "Task to complete"]);
let result = env.run_ldr(&["do", "1"]);
result.assert_success();
assert!(result.stdout.contains("✓ Archived 1 item(s)"));
// Verify task removed from todos
let todos = env.read_todos();
assert!(!todos.contains("Task to complete"));
// Verify task in archive
let archive = env.read_archive();
assert!(archive.contains("Task to complete"));
assert!(archive.contains(&chrono::Local::now().format("%Y-%m-%d").to_string()));
}
#[test]
fn test_archive_subtask() {
let env = TestEnv::new();
env.run_ldr(&["add", "Main task"]);
env.run_ldr(&["add", "Subtask A", "--under", "1"]);
env.run_ldr(&["add", "Subtask B", "--under", "1"]);
// Archive one subtask
let result = env.run_ldr(&["do", "1a"]);
result.assert_success();
// Main task should still exist with remaining subtask
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("Main task"));
assert!(result.stdout.contains("Subtask B"));
assert!(!result.stdout.contains("Subtask A"));
// Archive should contain the subtask
let archive = env.read_archive();
assert!(archive.contains("Subtask A"));
}
#[test]
fn test_auto_complete_parent_when_all_subtasks_done() {
let env = TestEnv::new();
env.run_ldr(&["add", "Main task"]);
env.run_ldr(&["add", "Subtask A", "--under", "1"]);
env.run_ldr(&["add", "Subtask B", "--under", "1"]);
// Archive all subtasks
let result = env.run_ldr(&["do", "1a", "1b"]);
result.assert_success();
// Main task should be auto-completed (POLS: completing all subtasks completes parent)
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(!result.stdout.contains("Main task"));
// Archive should contain subtasks AND parent task
let archive = env.read_archive();
assert!(archive.contains("Subtask A"));
assert!(archive.contains("Subtask B"));
assert!(archive.contains("Main task"));
}
#[test]
fn test_archive_whole_task_with_subtasks() {
let env = TestEnv::new();
env.run_ldr(&["add", "Complete project"]);
env.run_ldr(&["add", "Write code", "--under", "1"]);
env.run_ldr(&["add", "Write tests", "--under", "1"]);
// Archive entire task (should include all subtasks)
let result = env.run_ldr(&["do", "1"]);
result.assert_success();
// Nothing should remain in todos
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("No notes yet"));
// Archive should contain complete task structure
let archive = env.read_archive();
assert!(archive.contains("Complete project"));
@@ -426,52 +437,52 @@ mod archiving_tests {
#[cfg(test)]
mod removal_tests {
use super::*;
#[test]
fn test_remove_vs_archive() {
let env = TestEnv::new();
env.run_ldr(&["add", "Task to remove"]);
env.run_ldr(&["add", "Task to archive"]);
// Remove one task
let result = env.run_ldr(&["rm", "2"]);
result.assert_success();
// Archive another task
let result = env.run_ldr(&["do", "1"]);
result.assert_success();
// Only archived task should be in archive
let archive = env.read_archive();
assert!(archive.contains("Task to archive"));
assert!(!archive.contains("Task to remove"));
// Both should be gone from todos
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("No notes yet"));
}
#[test]
fn test_remove_subtask() {
let env = TestEnv::new();
env.run_ldr(&["add", "Main task"]);
env.run_ldr(&["add", "Keep this", "--under", "1"]);
env.run_ldr(&["add", "Remove this", "--under", "1"]);
// Remove second subtask (1b)
let result = env.run_ldr(&["rm", "1b"]);
result.assert_success();
// Main task should remain with first subtask only
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("Main task"));
assert!(result.stdout.contains("Keep this"));
assert!(!result.stdout.contains("Remove this"));
// Archive should be empty
let archive = env.read_archive();
assert!(!archive.contains("Remove this"));
@@ -481,16 +492,16 @@ mod removal_tests {
#[cfg(test)]
mod filtering_tests {
use super::*;
#[test]
fn test_filtering_basic() {
let env = TestEnv::new();
env.run_ldr(&["add", "read: Book about Rust"]);
env.run_ldr(&["add", "write: Article about Go"]);
env.run_ldr(&["add", "read: Documentation"]);
env.run_ldr(&["add", "@work: Review PR"]);
// Filter by "read:"
let result = env.run_ldr(&["ls", "read:"]);
result.assert_success();
@@ -498,82 +509,88 @@ mod filtering_tests {
assert!(result.stdout.contains("read: Documentation"));
assert!(!result.stdout.contains("write: Article"));
assert!(!result.stdout.contains("@work: Review"));
// Filter by "@work"
let result = env.run_ldr(&["ls", "@work"]);
result.assert_success();
assert!(result.stdout.contains("@work: Review PR"));
assert!(!result.stdout.contains("read:"));
}
#[test]
fn test_filtering_case_insensitive() {
let env = TestEnv::new();
env.run_ldr(&["add", "READ: Book"]);
env.run_ldr(&["add", "read: Article"]);
env.run_ldr(&["add", "ReAd: Mixed"]);
let result = env.run_ldr(&["ls", "read:"]);
result.assert_success();
assert!(result.stdout.contains("READ: Book"));
assert!(result.stdout.contains("read: Article"));
assert!(result.stdout.contains("ReAd: Mixed"));
}
#[test]
fn test_filtering_with_limits() {
let env = TestEnv::new();
// Add many matching items
for i in 1..=10 {
env.run_ldr(&["add", &format!("read: Book {}", i)]);
}
env.run_ldr(&["add", "write: Article"]);
// Test filtering respects limits
let result = env.run_ldr(&["ls", "-n", "3", "read:"]);
result.assert_success();
let matching_lines: Vec<&str> = result.stdout.lines()
let matching_lines: Vec<&str> = result
.stdout
.lines()
.filter(|line| line.contains("read:"))
.collect();
assert!(matching_lines.len() <= 3);
// Test filtering with --all
let result = env.run_ldr(&["ls", "--all", "read:"]);
result.assert_success();
let matching_lines: Vec<&str> = result.stdout.lines()
let matching_lines: Vec<&str> = result
.stdout
.lines()
.filter(|line| line.contains("read:"))
.collect();
assert_eq!(matching_lines.len(), 10);
}
#[test]
fn test_filtering_no_matches() {
let env = TestEnv::new();
env.run_ldr(&["add", "Task A"]);
env.run_ldr(&["add", "Task B"]);
let result = env.run_ldr(&["ls", "nonexistent"]);
result.assert_success();
assert!(result.stdout.contains("No items found matching filter: \"nonexistent\""));
assert!(result
.stdout
.contains("No items found matching filter: \"nonexistent\""));
}
}
#[cfg(test)]
mod error_handling {
use super::*;
#[test]
fn test_invalid_task_references() {
let env = TestEnv::new();
env.run_ldr(&["add", "Task A"]);
// Test various invalid references
let invalid_refs = vec!["0", "99", "1z", "a1", "1ab", "1-2", ""];
for invalid_ref in invalid_refs {
if !invalid_ref.is_empty() {
let result = env.run_ldr(&["up", invalid_ref]);
@@ -581,37 +598,37 @@ mod error_handling {
}
}
}
#[test]
fn test_operations_on_empty_file() {
let env = TestEnv::new();
// Try operations on empty file
let result = env.run_ldr(&["up", "1"]);
result.assert_success(); // Should handle gracefully
assert!(result.stdout.contains("No notes found"));
let result = env.run_ldr(&["do", "1"]);
result.assert_success();
assert!(result.stdout.contains("No notes found"));
let result = env.run_ldr(&["rm", "1"]);
result.assert_success();
}
#[test]
fn test_subtask_references_without_parent() {
let env = TestEnv::new();
env.run_ldr(&["add", "Task without subtasks"]);
// Try to reference non-existent subtask
let result = env.run_ldr(&["up", "1a"]);
result.assert_failure();
let result = env.run_ldr(&["do", "1a"]);
let result = env.run_ldr(&["do", "1a"]);
result.assert_failure();
let result = env.run_ldr(&["rm", "1a"]);
result.assert_failure();
}
@@ -620,34 +637,34 @@ mod error_handling {
#[cfg(test)]
mod edit_functionality {
use super::*;
#[test]
fn test_edit_creates_file_if_not_exists() {
let env = TestEnv::new();
// Set EDITOR to a command that just touches the file and exits
let result = Command::new(&env.binary_path)
.args(&["edit"])
.env("XDG_DATA_HOME", &env.data_dir)
.env("EDITOR", "touch") // Will just touch the file
.env("EDITOR", "touch") // Will just touch the file
.output()
.expect("Failed to execute edit command");
// Should succeed (touch command succeeds)
assert_eq!(result.status.code().unwrap_or(-1), 0);
// File should exist with proper structure
let todos = env.read_todos();
assert!(todos.contains("# TODOs"));
}
#[test]
fn test_edit_aliases() {
let env = TestEnv::new();
// Test all aliases work by using echo to verify they're called
let aliases = vec!["edit", "e", "scan", "s", "review", "r"];
for alias in aliases {
let result = Command::new(&env.binary_path)
.args(&[alias])
@@ -655,11 +672,16 @@ mod edit_functionality {
.env("EDITOR", "/bin/echo")
.output()
.expect(&format!("Failed to execute {} command", alias));
// Should succeed and echo the file path
assert_eq!(result.status.code().unwrap_or(-1), 0);
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
);
}
}
}
@@ -667,62 +689,62 @@ mod edit_functionality {
#[cfg(test)]
mod migration_tests {
use super::*;
#[test]
fn test_migration_from_plain_text() {
let env = TestEnv::new();
// Create old-style plain text files
let old_note_path = env.data_dir.join("ldr/note.txt");
let old_archive_path = env.data_dir.join("ldr/archive.txt");
fs::create_dir_all(old_note_path.parent().unwrap()).unwrap();
// Write old-style content
fs::write(&old_note_path, "Task A\nTask B with details\nTask C\n").unwrap();
fs::write(&old_archive_path, "Completed task 1\nCompleted task 2\n").unwrap();
// Run any command to trigger migration
let result = env.run_ldr(&["ls"]);
result.assert_success();
// Check that markdown files were created
assert!(env.todos_path().exists());
assert!(env.archive_path().exists());
// Verify content was migrated correctly
let todos = env.read_todos();
assert!(todos.contains("# TODOs"));
assert!(todos.contains("- Task A"));
assert!(todos.contains("- Task B with details"));
assert!(todos.contains("- Task C"));
let archive = env.read_archive();
assert!(archive.contains("# Archive"));
assert!(archive.contains("- Completed task 1"));
assert!(archive.contains("- Completed task 2"));
// Verify old files were backed up (not deleted)
assert!(old_note_path.with_extension("txt.bak").exists());
assert!(old_archive_path.with_extension("txt.bak").exists());
}
#[test]
fn test_no_migration_when_markdown_exists() {
let env = TestEnv::new();
// Create markdown file first
fs::create_dir_all(env.todos_path().parent().unwrap()).unwrap();
fs::write(&env.todos_path(), "# TODOs\n\n- Existing task\n").unwrap();
// Create old-style file
let old_note_path = env.data_dir.join("ldr/note.txt");
fs::write(&old_note_path, "Old task\n").unwrap();
// Run command
let result = env.run_ldr(&["ls"]);
result.assert_success();
// Verify markdown file unchanged (no migration happened)
let todos = env.read_todos();
assert!(todos.contains("Existing task"));
@@ -733,34 +755,34 @@ mod migration_tests {
#[cfg(test)]
mod pols_compliance {
use super::*;
#[test]
fn test_newest_items_at_top() {
let env = TestEnv::new();
env.run_ldr(&["add", "Old task"]);
std::thread::sleep(std::time::Duration::from_millis(10));
env.run_ldr(&["add", "New task"]);
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("1. New task"));
assert!(result.stdout.contains("2. Old task"));
}
#[test]
fn test_prioritization_order_predictable() {
let env = TestEnv::new();
env.run_ldr(&["add", "A"]);
env.run_ldr(&["add", "B"]);
env.run_ldr(&["add", "C"]);
env.run_ldr(&["add", "D"]);
// Test prioritization is predictable
env.run_ldr(&["up", "4", "2"]); // Move A (4th) and C (2nd) to top
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("1. A"));
@@ -768,42 +790,42 @@ mod pols_compliance {
assert!(result.stdout.contains("3. D"));
assert!(result.stdout.contains("4. B"));
}
#[test]
#[test]
fn test_subtask_operations_affect_parent_logically() {
let env = TestEnv::new();
env.run_ldr(&["add", "Project A"]);
env.run_ldr(&["add", "Task 1", "--under", "1"]);
env.run_ldr(&["add", "Project B"]);
// Prioritizing subtask should move parent project
env.run_ldr(&["up", "2a"]); // Project A is now at position 2
let result = env.run_ldr(&["ls"]);
result.assert_success();
// Project A should be at top (moved because of subtask reference)
assert!(result.stdout.contains("1. Project A"));
assert!(result.stdout.contains("a. Task 1"));
}
#[test]
fn test_completion_cascades_logically() {
let env = TestEnv::new();
env.run_ldr(&["add", "Big project"]);
env.run_ldr(&["add", "Only subtask", "--under", "1"]);
// Completing the only subtask should complete parent
env.run_ldr(&["do", "1a"]);
let result = env.run_ldr(&["ls"]);
result.assert_success();
assert!(result.stdout.contains("No notes yet"));
// Both should be in archive
let archive = env.read_archive();
assert!(archive.contains("Only subtask"));
assert!(archive.contains("Big project"));
}
}
}
+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."));
}