commit 8b647a2380688610ead254acf07d5ea77cf11ae6 Author: Stephen Waits Date: Sat Jul 26 11:57:36 2025 -0600 feat: initial version of parts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..684bbde --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,411 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "0.6.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "clap" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "libredox" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3af92c55d7d839293953fcd0fda5ecfe93297cfde6ffbdec13b41d99c0ba6607" +dependencies = [ + "bitflags 2.9.1", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "numtoa" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "parts" +version = "0.1.0" +dependencies = [ + "clap", + "tempfile", + "termion", + "xdg", +] + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_termios" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb" + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termion" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4648c7def6f2043b2568617b9f9b75eae88ca185dbc1f1fda30e95a85d49d7d" +dependencies = [ + "libc", + "libredox", + "numtoa", + "redox_termios", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.1", +] + +[[package]] +name = "xdg" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0746d1e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "parts" +version = "0.1.0" +edition = "2021" +description = "Prepend and Review ToDo System" +license = "MIT" +authors = ["Stephen Waits "] + +[dependencies] +clap = { version = "4.5.41", features = ["derive"] } +xdg = "3.0.0" +termion = "2.0" + +[dev-dependencies] +tempfile = "3.20.0" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a4ad6f1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Stephen Waits + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1fdc7a --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# PARTS - Prepend and Review ToDo System + +A simple command-line productivity system that lets you add items to the top of your list and review them interactively. + +## Inspiration + +This tool was inspired by Andrej Karpathy's blog post ["The append-and-review note"](https://karpathy.bearblog.dev/the-append-and-review-note/), which describes a simple but effective productivity system using a single text file where you append new items to the top and periodically review from the top down. + +## Installation + +1. Clone this repository +2. Build with Cargo: + + ```bash + cargo build --release + ``` + +3. Copy the binary to your PATH: + + ```bash + cp target/release/parts ~/.local/bin/ + ``` + +## Usage + +- `parts add "Your todo item"` - Add a new item to the top +- `parts ls` - List your items +- `parts scan` - Review items interactively +- `parts do 1 2 3` - Archive completed items by number +- `parts up 1 2 3` - Prioritize items by moving them to the top +- `parts edit` - Edit your todo list in $EDITOR + +## License + +MIT License - see LICENSE file for details. diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..02a6bba --- /dev/null +++ b/src/main.rs @@ -0,0 +1,659 @@ +use clap::{Parser, Subcommand}; +use std::collections::HashSet; +use std::env; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, BufRead, Read, Write}; +use std::path::Path; +use std::process::Command; +use termion::color; +use termion::raw::IntoRawMode; +use xdg::BaseDirectories; + +#[cfg(test)] +mod tests; + +#[derive(Parser)] +#[command(name = "parts")] +#[command(about = "Prepend and Review ToDo System", long_about = None)] +#[command(version)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Add a new item at the top + #[command(alias = "prepend")] + Add { + /// The text to add (e.g., "Read: Book XYZ") + text: String, + }, + /// List the top N items (default 5) + #[command(alias = "list")] + Ls { + #[arg(short = 'n', long = "num", default_value_t = 5)] + num: usize, + /// Show all items (overrides -n/--num) + #[arg(short = 'a', long = "all")] + all: bool, + /// Filter items containing this text (e.g., "read:", "@work") + filter: Option, + }, + /// Raise the priority of items (move toward top) + #[command(alias = "prioritize")] + Up { + /// Item numbers to prioritize (from "parts ls") + numbers: Vec, + }, + /// Archive completed items + #[command(aliases = ["done", "finish", "check"])] + Do { + /// Item numbers to archive (from "parts ls") + numbers: Vec, + }, + /// Scan and review items interactively, from top to bottom + #[command(alias = "review")] + Scan, + /// Edit items in your $EDITOR + Edit, +} + +fn main() -> io::Result<()> { + let cli = Cli::parse(); + let base = BaseDirectories::with_prefix("parts"); + let note_path = base + .place_data_file("note.txt") + .expect("Failed to create data directory"); + let archive_path = base + .place_data_file("archive.txt") + .expect("Failed to create data directory"); + + match cli.command { + Commands::Add { text } => add_entry(¬e_path, &text), + Commands::Ls { num, all, filter } => list_note(¬e_path, num, all, filter.as_deref()), + Commands::Up { numbers } => prioritize_items(¬e_path, &numbers), + Commands::Do { numbers } => archive_items(¬e_path, &archive_path, &numbers), + Commands::Scan => review_note(¬e_path, &archive_path), + Commands::Edit => edit_note(¬e_path), + } +} + +fn add_entry(path: &Path, text: &str) -> io::Result<()> { + let mut content = String::new(); + if path.exists() { + content = fs::read_to_string(path)?; + } + let new_content = add_entry_to_content(&content, text); + fs::write(path, new_content) +} + +fn add_entry_to_content(existing_content: &str, text: &str) -> String { + format!("{}\n{}", text, existing_content.trim_end()) +} + +fn list_note(path: &Path, num: usize, all: bool, filter: Option<&str>) -> io::Result<()> { + if !path.exists() { + println!( + "{}No notes yet.{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + return Ok(()); + } + let file = File::open(path)?; + let lines: Vec = io::BufReader::new(file).lines().collect::>()?; + + let filtered_lines: Vec<(usize, &String)> = if let Some(filter_text) = filter { + lines + .iter() + .enumerate() + .filter(|(_, line)| line.to_lowercase().contains(&filter_text.to_lowercase())) + .collect() + } else { + lines.iter().enumerate().collect() + }; + + if filtered_lines.is_empty() { + if filter.is_some() { + println!( + "{}No items found matching filter: \"{}\"{}", + color::Fg(color::Yellow), + filter.unwrap(), + color::Fg(color::Reset) + ); + } else { + println!( + "{}No notes yet.{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + } + return Ok(()); + } + + let display_num = if all { + filtered_lines.len() + } else { + num.min(filtered_lines.len()) + }; + for (original_idx, line) in filtered_lines.iter().take(display_num) { + println!( + "{}{}. {}{}", + color::Fg(color::Blue), + original_idx + 1, + color::Fg(color::Reset), + line + ); + } + + if !all && filter.is_some() && filtered_lines.len() > display_num { + println!( + "{}... and {} more matching items{}", + color::Fg(color::Yellow), + filtered_lines.len() - display_num, + color::Fg(color::Reset) + ); + } + + Ok(()) +} + +fn read_key_input() -> io::Result { + let _stdout = io::stdout().into_raw_mode()?; + let mut buffer = [0; 3]; + + // Read first byte + io::stdin().read_exact(&mut buffer[0..1])?; + + match buffer[0] { + 27 => { + // ESC sequence, read next two bytes + io::stdin().read_exact(&mut buffer[1..3])?; + match (buffer[1], buffer[2]) { + (91, 65) => Ok("up".to_string()), // ESC[A + (91, 66) => Ok("down".to_string()), // ESC[B + (91, 67) => Ok("right".to_string()), // ESC[C + (91, 68) => Ok("left".to_string()), // ESC[D + _ => Ok("unknown".to_string()), + } + } + b'\n' | b'\r' => Ok("enter".to_string()), + b'q' => Ok("q".to_string()), + b'p' => Ok("p".to_string()), + b'a' => Ok("a".to_string()), + b's' => Ok("s".to_string()), + _ => Ok("unknown".to_string()), + } +} + +fn prioritize_items(note_path: &Path, numbers: &[usize]) -> io::Result<()> { + if !note_path.exists() { + println!( + "{}No notes found.{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + return Ok(()); + } + + let content = fs::read_to_string(note_path)?; + let lines: Vec = content.lines().map(str::to_string).collect(); + + if lines.is_empty() { + println!( + "{}No notes to prioritize.{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + return Ok(()); + } + + match prioritize_items_in_content(&lines, numbers) { + Ok((new_content, prioritized_items)) => { + fs::write(note_path, new_content)?; + println!( + "{}✓ Prioritized {} item(s){}", + color::Fg(color::Green), + prioritized_items.len(), + color::Fg(color::Reset) + ); + for item in prioritized_items { + println!( + " {}{}{}", + color::Fg(color::Magenta), + item, + color::Fg(color::Reset) + ); + } + Ok(()) + } + Err(msg) => { + println!( + "{}{}{}", + color::Fg(color::Red), + msg, + color::Fg(color::Reset) + ); + Ok(()) + } + } +} + +fn prioritize_items_in_content( + lines: &[String], + numbers: &[usize], +) -> Result<(String, Vec), String> { + // Convert 1-based numbers to 0-based indices and validate + let mut indices: Vec = Vec::new(); + for &num in numbers { + if num == 0 || num > lines.len() { + return Err(format!( + "Invalid item number: {}. Valid range: 1-{}", + num, + lines.len() + )); + } + indices.push(num - 1); + } + + // Remove duplicates while preserving order + let mut seen = HashSet::new(); + indices.retain(|&x| seen.insert(x)); + + // Extract items to prioritize in the order specified on command line + let mut to_prioritize: Vec = Vec::new(); + let indices_set: HashSet = indices.iter().cloned().collect(); + + for &idx in &indices { + to_prioritize.push(lines[idx].clone()); + } + + // Extract remaining items (preserving original order) + let mut remaining: Vec = Vec::new(); + for (i, line) in lines.iter().enumerate() { + if !indices_set.contains(&i) { + remaining.push(line.clone()); + } + } + + // Combine: prioritized items first (in command-line order), then remaining + let mut new_lines = to_prioritize.clone(); + new_lines.extend(remaining); + + let new_content = if new_lines.is_empty() { + String::new() + } else { + new_lines.join("\n") + "\n" + }; + + Ok((new_content, to_prioritize)) +} + +fn archive_items(note_path: &Path, archive_path: &Path, numbers: &[usize]) -> io::Result<()> { + if !note_path.exists() { + println!( + "{}No notes found.{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + return Ok(()); + } + + let content = fs::read_to_string(note_path)?; + let lines: Vec = content.lines().map(str::to_string).collect(); + + if lines.is_empty() { + println!( + "{}No notes to archive.{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + return Ok(()); + } + + match archive_items_in_content(&lines, numbers) { + Ok((new_content, archived_items)) => { + // Archive items + if !archived_items.is_empty() { + let mut archive_file = OpenOptions::new() + .append(true) + .create(true) + .open(archive_path)?; + for item in &archived_items { + writeln!(archive_file, "{item}")?; + } + } + + fs::write(note_path, new_content)?; + println!( + "{}✓ Archived {} item(s){}", + color::Fg(color::Green), + archived_items.len(), + color::Fg(color::Reset) + ); + for item in archived_items { + println!( + " {}{}{}", + color::Fg(color::Red), + item, + color::Fg(color::Reset) + ); + } + Ok(()) + } + Err(msg) => { + println!( + "{}{}{}", + color::Fg(color::Red), + msg, + color::Fg(color::Reset) + ); + Ok(()) + } + } +} + +fn archive_items_in_content( + lines: &[String], + numbers: &[usize], +) -> Result<(String, Vec), String> { + // Convert 1-based numbers to 0-based indices and validate + let mut indices: Vec = Vec::new(); + for &num in numbers { + if num == 0 || num > lines.len() { + return Err(format!( + "Invalid item number: {}. Valid range: 1-{}", + num, + lines.len() + )); + } + indices.push(num - 1); + } + + // Remove duplicates and sort + indices.sort_unstable(); + indices.dedup(); + + // Extract items to archive and remaining items + let mut to_archive: Vec = Vec::new(); + let mut remaining: Vec = Vec::new(); + + for (i, line) in lines.iter().enumerate() { + if indices.contains(&i) { + to_archive.push(line.clone()); + } else { + remaining.push(line.clone()); + } + } + + let new_content = if remaining.is_empty() { + String::new() + } else { + remaining.join("\n") + "\n" + }; + + Ok((new_content, to_archive)) +} + +fn review_note(note_path: &Path, archive_path: &Path) -> io::Result<()> { + if !note_path.exists() { + println!("No notes to review."); + return Ok(()); + } + + let content = fs::read_to_string(note_path)?; + let lines: Vec = content.lines().map(str::to_string).collect(); + let total = lines.len(); + + if total == 0 { + println!("No notes to review."); + return Ok(()); + } + + let mut prioritized: Vec = Vec::new(); + let mut to_archive: Vec = Vec::new(); + let mut remove_indices: HashSet = HashSet::new(); + let mut history: Vec<(usize, String, String)> = Vec::new(); // (index, action, item) + + let mut current_idx = 0; + let indices: Vec = (0..total).collect(); + + while current_idx < indices.len() { + let i = indices[current_idx]; + let current = current_idx + 1; + + // Clear screen using ANSI escape sequence + print!("\x1B[2J\x1B[H"); + + println!( + "{}=== PARTS Review Mode ==={}", + color::Fg(color::Cyan), + color::Fg(color::Reset) + ); + println!( + "{}Progress: {}/{} items remaining{}", + color::Fg(color::Yellow), + current, + total, + color::Fg(color::Reset) + ); + println!(); + println!( + "{}Item:{} {}", + color::Fg(color::Green), + color::Fg(color::Reset), + lines[i] + ); + println!(); + println!( + "{}Actions:{}", + color::Fg(color::Blue), + color::Fg(color::Reset) + ); + println!( + " {}[↑/p]{} Prioritize (move to top)", + color::Fg(color::Magenta), + color::Fg(color::Reset) + ); + println!( + " {}[↓/a]{} Archive (remove from list)", + color::Fg(color::Red), + color::Fg(color::Reset) + ); + println!( + " {}[→/Enter/s]{} Skip (keep in current position)", + color::Fg(color::White), + color::Fg(color::Reset) + ); + println!( + " {}[←]{} Go back to previous item", + color::Fg(color::Cyan), + color::Fg(color::Reset) + ); + println!( + " {}[q]{} Quit review", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + println!(); + print!( + "{}Choose action:{} ", + color::Fg(color::Blue), + color::Fg(color::Reset) + ); + io::stdout().flush()?; + + // Read input (handle arrow keys) + let action = read_key_input()?; + + match action.as_str() { + "up" | "p" => { + history.push((i, "prioritize".to_string(), lines[i].clone())); + prioritized.push(lines[i].clone()); + remove_indices.insert(i); + println!( + "↑\n{}✓ Prioritized{}", + color::Fg(color::Magenta), + color::Fg(color::Reset) + ); + current_idx += 1; + } + "down" | "a" => { + history.push((i, "archive".to_string(), lines[i].clone())); + to_archive.push(lines[i].clone()); + remove_indices.insert(i); + println!( + "↓\n{}✓ Archived{}", + color::Fg(color::Red), + color::Fg(color::Reset) + ); + current_idx += 1; + } + "right" | "enter" | "s" => { + history.push((i, "skip".to_string(), lines[i].clone())); + println!( + "→\n{}✓ Skipped{}", + color::Fg(color::White), + color::Fg(color::Reset) + ); + current_idx += 1; + } + "left" => { + if let Some((prev_idx, prev_action, prev_item)) = history.pop() { + // Undo the previous action + match prev_action.as_str() { + "prioritize" => { + prioritized.retain(|item| item != &prev_item); + remove_indices.remove(&prev_idx); + } + "archive" => { + to_archive.retain(|item| item != &prev_item); + remove_indices.remove(&prev_idx); + } + "skip" => { + // Nothing to undo for skip + } + _ => {} + } + current_idx = current_idx.saturating_sub(1); + println!( + "←\n{}✓ Went back{}", + color::Fg(color::Cyan), + color::Fg(color::Reset) + ); + } else { + println!( + "←\n{}No previous item to go back to{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + } + } + "q" => { + println!( + "q\n{}Quitting review and saving changes...{}", + color::Fg(color::Yellow), + color::Fg(color::Reset) + ); + break; + } + _ => { + history.push((i, "skip".to_string(), lines[i].clone())); + println!( + "?\n{}✓ Skipped (invalid key){}", + color::Fg(color::White), + color::Fg(color::Reset) + ); + current_idx += 1; + } + } + } + + // Clear screen for final summary + print!("\x1B[2J\x1B[H"); + println!( + "{}=== Review Complete ==={}", + color::Fg(color::Green), + color::Fg(color::Reset) + ); + println!( + "{}Prioritized:{} {} items", + color::Fg(color::Magenta), + color::Fg(color::Reset), + prioritized.len() + ); + println!( + "{}Archived:{} {} items", + color::Fg(color::Red), + color::Fg(color::Reset), + to_archive.len() + ); + println!( + "{}Remaining:{} {} items", + color::Fg(color::Blue), + color::Fg(color::Reset), + total - remove_indices.len() + ); + println!(); + + // Archive items + if !to_archive.is_empty() { + let mut archive_file = OpenOptions::new() + .append(true) + .create(true) + .open(archive_path)?; + for arch in to_archive { + writeln!(archive_file, "{arch}")?; + } + } + + // Build remaining items + let mut remaining: Vec = Vec::new(); + for (i, line) in lines.iter().enumerate() { + if !remove_indices.contains(&i) { + remaining.push(line.clone()); + } + } + + // Combine: prioritized items first, then remaining + let mut new_lines = prioritized; + new_lines.extend(remaining); + + // Write back to file + let new_content = if new_lines.is_empty() { + String::new() + } else { + new_lines.join("\n") + "\n" + }; + fs::write(note_path, new_content)?; + + println!( + "{}✓ Changes saved successfully!{}", + color::Fg(color::Green), + color::Fg(color::Reset) + ); + Ok(()) +} + +fn edit_note(note_path: &Path) -> io::Result<()> { + let editor = env::var("EDITOR").unwrap_or_else(|_| "nano".to_string()); + + // Create the file if it doesn't exist + if !note_path.exists() { + fs::write(note_path, "")?; + } + + let status = Command::new(&editor).arg(note_path).status()?; + + if !status.success() { + println!( + "{}Editor exited with error code: {}{}", + color::Fg(color::Red), + status.code().unwrap_or(-1), + color::Fg(color::Reset) + ); + } + + Ok(()) +} diff --git a/src/tests.rs b/src/tests.rs new file mode 100644 index 0000000..b50684b --- /dev/null +++ b/src/tests.rs @@ -0,0 +1,414 @@ +use super::*; + +#[test] +fn test_add_entry_to_content_empty() { + let result = add_entry_to_content("", "First item"); + assert_eq!(result, "First item\n"); +} + +#[test] +fn test_add_entry_to_content_existing() { + let existing = "Second item\nThird item"; + let result = add_entry_to_content(existing, "First item"); + assert_eq!(result, "First item\nSecond item\nThird item"); +} + +#[test] +fn test_add_entry_to_content_with_trailing_newline() { + let existing = "Second item\nThird item\n"; + let result = add_entry_to_content(existing, "First item"); + assert_eq!(result, "First item\nSecond item\nThird item"); +} + +#[test] +fn test_prioritize_items_in_content_single_item() { + let lines = vec![ + "First item".to_string(), + "Second item".to_string(), + "Third item".to_string(), + ]; + let result = prioritize_items_in_content(&lines, &[2]); + + assert!(result.is_ok()); + let (new_content, prioritized) = result.unwrap(); + assert_eq!(new_content, "Second item\nFirst item\nThird item\n"); + assert_eq!(prioritized, vec!["Second item"]); +} + +#[test] +fn test_prioritize_items_in_content_multiple_items() { + let lines = vec![ + "First item".to_string(), + "Second item".to_string(), + "Third item".to_string(), + "Fourth item".to_string(), + ]; + let result = prioritize_items_in_content(&lines, &[3, 1]); + + assert!(result.is_ok()); + let (new_content, prioritized) = result.unwrap(); + assert_eq!( + new_content, + "Third item\nFirst item\nSecond item\nFourth item\n" + ); + assert_eq!(prioritized, vec!["Third item", "First item"]); +} + +#[test] +fn test_prioritize_items_in_content_invalid_number() { + let lines = vec!["First item".to_string(), "Second item".to_string()]; + let result = prioritize_items_in_content(&lines, &[3]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Invalid item number: 3. Valid range: 1-2" + ); +} + +#[test] +fn test_prioritize_items_in_content_zero_number() { + let lines = vec!["First item".to_string()]; + let result = prioritize_items_in_content(&lines, &[0]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Invalid item number: 0. Valid range: 1-1" + ); +} + +#[test] +fn test_prioritize_items_in_content_duplicates() { + let lines = vec![ + "First item".to_string(), + "Second item".to_string(), + "Third item".to_string(), + ]; + let result = prioritize_items_in_content(&lines, &[2, 2, 1]); + + assert!(result.is_ok()); + let (new_content, prioritized) = result.unwrap(); + assert_eq!(new_content, "Second item\nFirst item\nThird item\n"); + assert_eq!(prioritized, vec!["Second item", "First item"]); +} + +#[test] +fn test_archive_items_in_content_single_item() { + let lines = vec![ + "First item".to_string(), + "Second item".to_string(), + "Third item".to_string(), + ]; + let result = archive_items_in_content(&lines, &[2]); + + assert!(result.is_ok()); + let (new_content, archived) = result.unwrap(); + assert_eq!(new_content, "First item\nThird item\n"); + assert_eq!(archived, vec!["Second item"]); +} + +#[test] +fn test_archive_items_in_content_multiple_items() { + let lines = vec![ + "First item".to_string(), + "Second item".to_string(), + "Third item".to_string(), + "Fourth item".to_string(), + ]; + let result = archive_items_in_content(&lines, &[1, 3]); + + assert!(result.is_ok()); + let (new_content, archived) = result.unwrap(); + assert_eq!(new_content, "Second item\nFourth item\n"); + assert_eq!(archived, vec!["First item", "Third item"]); +} + +#[test] +fn test_archive_items_in_content_all_items() { + let lines = vec!["First item".to_string(), "Second item".to_string()]; + let result = archive_items_in_content(&lines, &[1, 2]); + + assert!(result.is_ok()); + let (new_content, archived) = result.unwrap(); + assert_eq!(new_content, ""); + assert_eq!(archived, vec!["First item", "Second item"]); +} + +#[test] +fn test_archive_items_in_content_invalid_number() { + let lines = vec!["First item".to_string()]; + let result = archive_items_in_content(&lines, &[2]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "Invalid item number: 2. Valid range: 1-1" + ); +} + +#[test] +fn test_archive_items_in_content_duplicates() { + let lines = vec![ + "First item".to_string(), + "Second item".to_string(), + "Third item".to_string(), + ]; + let result = archive_items_in_content(&lines, &[1, 1, 3]); + + assert!(result.is_ok()); + let (new_content, archived) = result.unwrap(); + assert_eq!(new_content, "Second item\n"); + assert_eq!(archived, vec!["First item", "Third item"]); +} + +#[test] +fn test_filter_functionality() { + let lines = vec![ + "Read: Book about Rust".to_string(), + "Listen: Podcast episode".to_string(), + "@work: Review PR".to_string(), + "@home: Fix sink".to_string(), + "Read: Another book".to_string(), + ]; + + // Test filtering by "read:" + let filtered: Vec<(usize, &String)> = lines + .iter() + .enumerate() + .filter(|(_, line)| line.to_lowercase().contains("read:")) + .collect(); + + assert_eq!(filtered.len(), 2); + assert_eq!(filtered[0].0, 0); + assert_eq!(filtered[0].1, "Read: Book about Rust"); + assert_eq!(filtered[1].0, 4); + assert_eq!(filtered[1].1, "Read: Another book"); + + // Test filtering by "@work" + let filtered: Vec<(usize, &String)> = lines + .iter() + .enumerate() + .filter(|(_, line)| line.to_lowercase().contains("@work")) + .collect(); + + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].0, 2); + assert_eq!(filtered[0].1, "@work: Review PR"); +} + +#[test] +fn test_case_insensitive_filtering() { + let lines = vec![ + "READ: Book about Rust".to_string(), + "read: Another book".to_string(), + "ReAd: Mixed case".to_string(), + ]; + + let filtered: Vec<(usize, &String)> = lines + .iter() + .enumerate() + .filter(|(_, line)| line.to_lowercase().contains("read:")) + .collect(); + + assert_eq!(filtered.len(), 3); +} +#[cfg(test)] +mod list_tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn create_test_file_with_content(content: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().unwrap(); + write!(file, "{}", content).unwrap(); + file + } + + #[test] + fn test_list_with_all_flag() { + let content = "item 1\nitem 2\nitem 3\nitem 4\nitem 5\nitem 6"; + let file = create_test_file_with_content(content); + + // Test that all=true shows all items regardless of num + let result = std::panic::catch_unwind(|| { + list_note(file.path(), 3, true, None).unwrap(); + }); + assert!(result.is_ok()); + } + + #[test] + fn test_list_without_all_flag() { + let content = "item 1\nitem 2\nitem 3\nitem 4\nitem 5\nitem 6"; + let file = create_test_file_with_content(content); + + // Test that all=false respects num limit + let result = std::panic::catch_unwind(|| { + list_note(file.path(), 3, false, None).unwrap(); + }); + assert!(result.is_ok()); + } + + #[test] + fn test_list_with_filter_and_all() { + let content = "read: book 1\nwrite: article\nread: book 2\ntask: cleanup"; + let file = create_test_file_with_content(content); + + // Test that all=true with filter shows all matching items + let result = std::panic::catch_unwind(|| { + list_note(file.path(), 1, true, Some("read:")).unwrap(); + }); + assert!(result.is_ok()); + } + + #[test] + fn test_list_empty_file() { + let file = create_test_file_with_content(""); + + let result = std::panic::catch_unwind(|| { + list_note(file.path(), 5, false, None).unwrap(); + }); + assert!(result.is_ok()); + } +} + +#[cfg(test)] +mod review_tests { + use std::collections::HashSet; + + #[test] + fn test_review_prioritization_order() { + // Test that items prioritized during review maintain their relative order + let lines = vec![ + "First item".to_string(), + "Second item".to_string(), + "Third item".to_string(), + "Fourth item".to_string(), + ]; + + // Simulate review process: prioritize items 2 and 4 (in that encounter order during bottom-to-top review) + let mut prioritized: Vec = Vec::new(); + let mut remove_indices: HashSet = HashSet::new(); + + // Simulate top-to-bottom review order (indices 0, 1, 2, 3) - same as list order + // User prioritizes item at index 1 (Second item) + prioritized.push(lines[1].clone()); + remove_indices.insert(1); + + // User prioritizes item at index 3 (Fourth item) + prioritized.push(lines[3].clone()); + remove_indices.insert(3); + + // Build remaining items (items not prioritized) + let mut remaining: Vec = Vec::new(); + for (i, line) in lines.iter().enumerate() { + if !remove_indices.contains(&i) { + remaining.push(line.clone()); + } + } + + // Combine: prioritized items first (in order they were prioritized), then remaining + let mut new_lines = prioritized; + new_lines.extend(remaining); + + // Expected result: prioritized items (Second, Fourth) should appear first, + // in the order they were prioritized, followed by remaining items (First, Third) + let expected = vec![ + "Second item".to_string(), + "Fourth item".to_string(), + "First item".to_string(), + "Third item".to_string(), + ]; + + assert_eq!(new_lines, expected); + } + + #[test] + fn test_review_quit_saves_changes() { + // Test that quitting review still saves any changes made + let lines = vec![ + "First item".to_string(), + "Second item".to_string(), + "Third item".to_string(), + ]; + + // Simulate review process where user prioritizes one item then quits + let mut prioritized: Vec = Vec::new(); + let mut remove_indices: HashSet = HashSet::new(); + + // User prioritizes item at index 1 (Second item) + prioritized.push(lines[1].clone()); + remove_indices.insert(1); + + // User quits (but changes should still be applied) + + // Build remaining items (items not prioritized or archived) + let mut remaining: Vec = Vec::new(); + for (i, line) in lines.iter().enumerate() { + if !remove_indices.contains(&i) { + remaining.push(line.clone()); + } + } + + // Combine: prioritized items first, then remaining + let mut new_lines = prioritized; + new_lines.extend(remaining); + + // Expected result: prioritized item should be first, even though user quit + let expected = vec![ + "Second item".to_string(), + "First item".to_string(), + "Third item".to_string(), + ]; + + assert_eq!(new_lines, expected); + } + + #[test] + fn test_prioritize_command_line_order() { + // Test that items are prioritized in the exact order specified on command line + let lines = vec![ + "Item A".to_string(), + "Item B".to_string(), + "Item C".to_string(), + "Item D".to_string(), + "Item E".to_string(), + ]; + + // Command: arn up 5 2 4 (prioritize items 5, 2, 4 in that order) + let result = super::prioritize_items_in_content(&lines, &[5, 2, 4]); + + assert!(result.is_ok()); + let (new_content, prioritized) = result.unwrap(); + + // Expected: Item E, Item B, Item D should be at top in that order + let expected_content = "Item E\nItem B\nItem D\nItem A\nItem C\n"; + let expected_prioritized = vec!["Item E", "Item B", "Item D"]; + + assert_eq!(new_content, expected_content); + assert_eq!(prioritized, expected_prioritized); + } + + #[test] + fn test_prioritize_command_line_order_with_duplicates() { + // Test that duplicates are removed but order is preserved + let lines = vec![ + "Item A".to_string(), + "Item B".to_string(), + "Item C".to_string(), + ]; + + // Command: arn up 3 1 3 2 1 (should become 3, 1, 2) + let result = super::prioritize_items_in_content(&lines, &[3, 1, 3, 2, 1]); + + assert!(result.is_ok()); + let (new_content, prioritized) = result.unwrap(); + + // Expected: Item C, Item A, Item B (duplicates removed, order preserved) + let expected_content = "Item C\nItem A\nItem B\n"; + let expected_prioritized = vec!["Item C", "Item A", "Item B"]; + + assert_eq!(new_content, expected_content); + assert_eq!(prioritized, expected_prioritized); + } +}