feat: initial version of parts

This commit is contained in:
swaits committed 2025-07-27 13:55:48 -04:00
commit 8b647a2380
7 files changed
+1556

No files matched your search

+1
View File
@@ -0,0 +1 @@
/target
Generated
+411
View File
@@ -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"
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "parts"
version = "0.1.0"
edition = "2021"
description = "Prepend and Review ToDo System"
license = "MIT"
authors = ["Stephen Waits <steve@waits.net>"]
[dependencies]
clap = { version = "4.5.41", features = ["derive"] }
xdg = "3.0.0"
termion = "2.0"
[dev-dependencies]
tempfile = "3.20.0"
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Stephen Waits <steve@waits.net>
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.
+35
View File
@@ -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.
+659
View File
@@ -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<String>,
},
/// Raise the priority of items (move toward top)
#[command(alias = "prioritize")]
Up {
/// Item numbers to prioritize (from "parts ls")
numbers: Vec<usize>,
},
/// Archive completed items
#[command(aliases = ["done", "finish", "check"])]
Do {
/// Item numbers to archive (from "parts ls")
numbers: Vec<usize>,
},
/// 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(&note_path, &text),
Commands::Ls { num, all, filter } => list_note(&note_path, num, all, filter.as_deref()),
Commands::Up { numbers } => prioritize_items(&note_path, &numbers),
Commands::Do { numbers } => archive_items(&note_path, &archive_path, &numbers),
Commands::Scan => review_note(&note_path, &archive_path),
Commands::Edit => edit_note(&note_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<String> = io::BufReader::new(file).lines().collect::<Result<_, _>>()?;
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<String> {
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<String> = 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>), String> {
// Convert 1-based numbers to 0-based indices and validate
let mut indices: Vec<usize> = 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<String> = Vec::new();
let indices_set: HashSet<usize> = indices.iter().cloned().collect();
for &idx in &indices {
to_prioritize.push(lines[idx].clone());
}
// Extract remaining items (preserving original order)
let mut remaining: Vec<String> = 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<String> = 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>), String> {
// Convert 1-based numbers to 0-based indices and validate
let mut indices: Vec<usize> = 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<String> = Vec::new();
let mut remaining: Vec<String> = 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<String> = 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<String> = Vec::new();
let mut to_archive: Vec<String> = Vec::new();
let mut remove_indices: HashSet<usize> = HashSet::new();
let mut history: Vec<(usize, String, String)> = Vec::new(); // (index, action, item)
let mut current_idx = 0;
let indices: Vec<usize> = (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<String> = 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(())
}
+414
View File
@@ -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<String> = Vec::new();
let mut remove_indices: HashSet<usize> = 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<String> = 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<String> = Vec::new();
let mut remove_indices: HashSet<usize> = 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<String> = 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);
}
}