diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 0000000..b5e5ec6 --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,789 @@ +//! 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. + +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use tempfile::TempDir; + +/// Test fixture that provides isolated temporary directories for each test +struct TestEnv { + _temp_dir: TempDir, + data_dir: PathBuf, + binary_path: PathBuf, +} + +impl TestEnv { + fn new() -> Self { + 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)); + } + + 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) + .args(args) + .env("XDG_DATA_HOME", &self.data_dir) + .stdout(Stdio::piped()) + .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() + } +} + +#[derive(Debug)] +struct CommandResult { + status: i32, + stdout: String, + stderr: String, +} + +impl CommandResult { + fn assert_success(&self) { + if self.status != 0 { + 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() { + panic!("Command unexpectedly succeeded: stdout: {}", self.stdout); + } + } +} + +#[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 to Default: 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(); + let line_count = result.stdout.lines().count(); + // 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(); + assert!(!result.stdout.contains("more items")); + } +} + +#[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")); + + // 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")); + assert!(result.stdout.contains("c. Subtask 3")); + assert!(result.stdout.contains("d. Subtask 4")); + assert!(result.stdout.contains("e. Subtask 5")); + } +} + +#[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(); + let lines: Vec<&str> = result.stdout.lines().collect(); + assert!(lines[0].contains("1. Task A")); // Moved to top + 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(); + let lines: Vec<&str> = result.stdout.lines().collect(); + assert!(lines[0].contains("1. Task A")); // 4th item moved to top + assert!(lines[1].contains("2. Task C")); // 2nd item moved to second + 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 + result.assert_failure(); + } +} + +#[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")); + assert!(archive.contains("Write code")); + assert!(archive.contains("Write 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")); + } +} + +#[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(); + assert!(result.stdout.contains("read: Book about Rust")); + 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() + .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() + .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\"")); + } +} + +#[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]); + result.assert_failure(); + } + } + } + + #[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"]); + result.assert_failure(); + + let result = env.run_ldr(&["rm", "1a"]); + result.assert_failure(); + } +} + +#[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 + .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")); + assert!(todos.contains("## Default")); + } +} + +#[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("## Default")); + 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## Default\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")); + assert!(!todos.contains("Old task")); + } +} + +#[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")); + assert!(result.stdout.contains("2. C")); + assert!(result.stdout.contains("3. D")); + assert!(result.stdout.contains("4. B")); + } + + #[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")); + } +} \ No newline at end of file