Minimalist plaintext project-based todo CLI in Rust. Commands: init, add, list, set, done, rm. Supports .citrine + .citrine.local merging, partial hash matching, status filtering, and colorized output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
205 lines
6.1 KiB
Rust
205 lines
6.1 KiB
Rust
use std::process::Command;
|
|
use std::path::PathBuf;
|
|
use tempfile::TempDir;
|
|
|
|
fn citrine_bin() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_BIN_EXE_citrine"))
|
|
}
|
|
|
|
fn run_citrine(dir: &std::path::Path, args: &[&str]) -> std::process::Output {
|
|
Command::new(citrine_bin())
|
|
.current_dir(dir)
|
|
.args(args)
|
|
.output()
|
|
.expect("Failed to execute citrine")
|
|
}
|
|
|
|
fn stdout(output: &std::process::Output) -> String {
|
|
String::from_utf8_lossy(&output.stdout).to_string()
|
|
}
|
|
|
|
fn stderr(output: &std::process::Output) -> String {
|
|
String::from_utf8_lossy(&output.stderr).to_string()
|
|
}
|
|
|
|
#[test]
|
|
fn test_init() {
|
|
let dir = TempDir::new().unwrap();
|
|
let out = run_citrine(dir.path(), &["init"]);
|
|
assert!(out.status.success());
|
|
assert!(stdout(&out).contains("Initialized citrine"));
|
|
assert!(dir.path().join(".citrine").exists());
|
|
assert!(dir.path().join(".gitignore").exists());
|
|
let gitignore = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
|
|
assert!(gitignore.contains(".citrine.local"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_init_idempotent() {
|
|
let dir = TempDir::new().unwrap();
|
|
run_citrine(dir.path(), &["init"]);
|
|
let out = run_citrine(dir.path(), &["init"]);
|
|
assert!(out.status.success());
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_and_list() {
|
|
let dir = TempDir::new().unwrap();
|
|
run_citrine(dir.path(), &["init"]);
|
|
|
|
let out = run_citrine(dir.path(), &["add", "My first task"]);
|
|
assert!(out.status.success());
|
|
assert!(stdout(&out).contains("Added task"));
|
|
assert!(stdout(&out).contains("My first task"));
|
|
|
|
let out = run_citrine(dir.path(), &["list"]);
|
|
assert!(out.status.success());
|
|
assert!(stdout(&out).contains("My first task"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_local() {
|
|
let dir = TempDir::new().unwrap();
|
|
run_citrine(dir.path(), &["init"]);
|
|
|
|
let out = run_citrine(dir.path(), &["add", "Local task", "--local"]);
|
|
assert!(out.status.success());
|
|
assert!(stdout(&out).contains("(local)"));
|
|
assert!(dir.path().join(".citrine.local").exists());
|
|
|
|
// Should appear in list
|
|
let out = run_citrine(dir.path(), &["list"]);
|
|
assert!(stdout(&out).contains("Local task"));
|
|
|
|
// Should appear in list --local
|
|
let out = run_citrine(dir.path(), &["list", "--local"]);
|
|
assert!(stdout(&out).contains("Local task"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_not_initialized() {
|
|
let dir = TempDir::new().unwrap();
|
|
let out = run_citrine(dir.path(), &["add", "Some task"]);
|
|
assert!(!out.status.success());
|
|
assert_eq!(out.status.code(), Some(3));
|
|
assert!(stderr(&out).contains("Not initialized"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_set_status() {
|
|
let dir = TempDir::new().unwrap();
|
|
run_citrine(dir.path(), &["init"]);
|
|
let out = run_citrine(dir.path(), &["add", "Test task"]);
|
|
let hash = extract_hash(&stdout(&out));
|
|
|
|
let out = run_citrine(dir.path(), &["set", &hash, "progress"]);
|
|
assert!(out.status.success());
|
|
assert!(stdout(&out).contains("→"));
|
|
|
|
let out = run_citrine(dir.path(), &["list"]);
|
|
assert!(stdout(&out).contains("[>]"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_done_adds_completed() {
|
|
let dir = TempDir::new().unwrap();
|
|
run_citrine(dir.path(), &["init"]);
|
|
let out = run_citrine(dir.path(), &["add", "Finish this"]);
|
|
let hash = extract_hash(&stdout(&out));
|
|
|
|
let out = run_citrine(dir.path(), &["done", &hash]);
|
|
assert!(out.status.success());
|
|
assert!(stdout(&out).contains("@completed:"));
|
|
|
|
let content = std::fs::read_to_string(dir.path().join(".citrine")).unwrap();
|
|
assert!(content.contains("[x]"));
|
|
assert!(content.contains("@completed:"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rm() {
|
|
let dir = TempDir::new().unwrap();
|
|
run_citrine(dir.path(), &["init"]);
|
|
let out = run_citrine(dir.path(), &["add", "Remove me"]);
|
|
let hash = extract_hash(&stdout(&out));
|
|
|
|
let out = run_citrine(dir.path(), &["rm", &hash]);
|
|
assert!(out.status.success());
|
|
assert!(stdout(&out).contains("Removed"));
|
|
|
|
let out = run_citrine(dir.path(), &["list"]);
|
|
assert!(!stdout(&out).contains("Remove me"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_partial_hash() {
|
|
let dir = TempDir::new().unwrap();
|
|
run_citrine(dir.path(), &["init"]);
|
|
let out = run_citrine(dir.path(), &["add", "Partial hash test"]);
|
|
let hash = extract_hash(&stdout(&out));
|
|
let partial = &hash[..2];
|
|
|
|
let out = run_citrine(dir.path(), &["set", partial, "progress"]);
|
|
assert!(out.status.success());
|
|
}
|
|
|
|
#[test]
|
|
fn test_invalid_status() {
|
|
let dir = TempDir::new().unwrap();
|
|
run_citrine(dir.path(), &["init"]);
|
|
let out = run_citrine(dir.path(), &["add", "Task"]);
|
|
let hash = extract_hash(&stdout(&out));
|
|
|
|
let out = run_citrine(dir.path(), &["set", &hash, "invalid"]);
|
|
assert!(!out.status.success());
|
|
assert_eq!(out.status.code(), Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn test_hash_not_found() {
|
|
let dir = TempDir::new().unwrap();
|
|
run_citrine(dir.path(), &["init"]);
|
|
let out = run_citrine(dir.path(), &["set", "ffff", "done"]);
|
|
assert!(!out.status.success());
|
|
assert!(stderr(&out).contains("Task not found"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_status_filter() {
|
|
let dir = TempDir::new().unwrap();
|
|
run_citrine(dir.path(), &["init"]);
|
|
|
|
run_citrine(dir.path(), &["add", "Todo task"]);
|
|
let out = run_citrine(dir.path(), &["add", "Done task"]);
|
|
let hash = extract_hash(&stdout(&out));
|
|
run_citrine(dir.path(), &["done", &hash]);
|
|
|
|
let out = run_citrine(dir.path(), &["list", "--status", "todo"]);
|
|
let s = stdout(&out);
|
|
assert!(s.contains("Todo task"));
|
|
assert!(!s.contains("Done task"));
|
|
|
|
let out = run_citrine(dir.path(), &["list", "--status", "done"]);
|
|
let s = stdout(&out);
|
|
assert!(!s.contains("Todo task"));
|
|
assert!(s.contains("Done task"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_main_and_local() {
|
|
let dir = TempDir::new().unwrap();
|
|
run_citrine(dir.path(), &["init"]);
|
|
run_citrine(dir.path(), &["add", "Main task"]);
|
|
run_citrine(dir.path(), &["add", "Local task", "--local"]);
|
|
|
|
let out = run_citrine(dir.path(), &["list"]);
|
|
let s = stdout(&out);
|
|
assert!(s.contains("Main task"));
|
|
assert!(s.contains("Local task"));
|
|
}
|
|
|
|
fn extract_hash(output: &str) -> String {
|
|
let re = regex::Regex::new(r"\[([0-9a-f]{4})\]").unwrap();
|
|
re.captures(output).unwrap()[1].to_string()
|
|
}
|