Initial release: Citrine v1.0.0

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>
This commit is contained in:
syntaxbullet
2026-02-14 17:49:32 +01:00
commit a6b6cddb49
17 changed files with 1898 additions and 0 deletions

9
tests/fixtures/sample.citrine vendored Normal file
View File

@@ -0,0 +1,9 @@
## Backend
[a4f9] [ ] Implement caching layer @priority:high @backend
[b2e1] [>] Refactor auth module @assigned:cursor
[c3d8] [-] Deploy to staging @blocked:waiting-for-credentials
[f1a2] [x] Set up Docker config @completed:2025-02-14
## Frontend
[d4b3] [ ] Build settings page
[e5c2] [>] Fix mobile layout issues @priority:medium

204
tests/integration.rs Normal file
View File

@@ -0,0 +1,204 @@
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()
}