hephaestus/crates/heph/tests/cli.rs
Erich Blume 3db026f6e5 feat: actionable conflicts, node restore, project move, task-aware show
- conflicts.resolve now applies the chosen value (local/remote) as a new
  task.set op — peers converge on the decision — instead of only marking
  the row resolved.
- heph node restore <id> — undo of node rm.
- heph project move <name> --parent <p>|--root — post-creation
  re-parenting from the CLI.
- heph show on a task prints its canonical-context doc body (the task
  node's own body is always null and was hiding the real content).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 11:03:13 -07:00

326 lines
9.6 KiB
Rust

//! CLI tests (tech-spec §9): run the real `heph` binary against a real `hephd`
//! over a unix socket, and assert output + side effects (export files).
use std::path::PathBuf;
use std::process::Command;
use std::thread;
use std::time::Duration;
use tokio::net::UnixListener;
use heph_core::{FixedClock, LocalStore};
use hephd::Daemon;
const NOW: i64 = 1_704_067_200_000;
/// Spawn a daemon on its own thread+runtime; return (socket, tempdir).
fn spawn_daemon() -> (PathBuf, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("heph.db");
let socket = dir.path().join("d.sock");
let store = LocalStore::open(&db, Box::new(FixedClock(NOW))).unwrap();
let socket_for_thread = socket.clone();
thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async move {
let listener = UnixListener::bind(&socket_for_thread).unwrap();
let _ = Daemon::new(store).serve(listener).await;
});
});
for _ in 0..200 {
if socket.exists() {
break;
}
thread::sleep(Duration::from_millis(5));
}
(socket, dir)
}
/// Run the `heph` binary against `socket`; return (stdout, success).
fn heph(socket: &std::path::Path, args: &[&str]) -> (String, bool) {
let out = Command::new(env!("CARGO_BIN_EXE_heph"))
.arg("--socket")
.arg(socket)
.args(args)
.output()
.expect("run heph");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
out.status.success(),
)
}
#[test]
fn task_then_next_shows_the_task() {
let (socket, _dir) = spawn_daemon();
let (out, ok) = heph(&socket, &["task", "Buy milk", "--attention", "red"]);
assert!(ok, "task create failed: {out}");
assert!(out.contains("Created task"), "{out}");
let (out, ok) = heph(&socket, &["next"]);
assert!(ok);
assert!(out.contains("[red]"), "{out}");
assert!(out.contains("Buy milk"), "{out}");
}
#[test]
fn next_on_empty_store_is_friendly() {
let (socket, _dir) = spawn_daemon();
let (out, ok) = heph(&socket, &["next"]);
assert!(ok);
assert!(out.contains("Nothing actionable"), "{out}");
}
/// The first whitespace token of a "Created task <id> …" line.
fn created_id(out: &str) -> String {
out.split_whitespace()
.nth(2)
.expect("id in output")
.to_string()
}
#[test]
fn task_with_iso_do_date_shows_formatted_date_in_next() {
let (socket, _dir) = spawn_daemon();
// A do-date in the past (relative to the daemon's fixed 2024-01-01 clock)
// so it is actionable and appears in `next`.
let (out, ok) = heph(
&socket,
&[
"task",
"Renew passport",
"-a",
"orange",
"--do-date",
"2023-12-25",
],
);
assert!(ok, "{out}");
let (out, ok) = heph(&socket, &["next"]);
assert!(ok);
assert!(out.contains("Renew passport"), "{out}");
// Within 2023 vs the 2024 clock → full YYYY-MM-DD form.
assert!(
out.contains("do:2023-12-25"),
"expected formatted do-date: {out}"
);
}
#[test]
fn edit_reschedules_and_clears_fields() {
let (socket, _dir) = spawn_daemon();
let (out, _) = heph(
&socket,
&[
"task",
"Taxes",
"--do-date",
"2023-01-01",
"--recur",
"yearly",
],
);
let id = created_id(&out);
// Reschedule do-date and clear recurrence.
let (out, ok) = heph(
&socket,
&["edit", &id, "--do-date", "2023-06-01", "--recur", "none"],
);
assert!(ok, "{out}");
let (out, ok) = heph(&socket, &["show", &id]);
assert!(ok, "{out}");
assert!(
out.contains("\"recurrence\": null"),
"recurrence cleared: {out}"
);
assert!(out.contains("\"do_date\""), "{out}");
}
#[test]
fn list_and_done_and_health() {
let (socket, _dir) = spawn_daemon();
let (out, _) = heph(&socket, &["task", "Mow lawn", "-a", "red"]);
let id = created_id(&out);
let (out, ok) = heph(&socket, &["list"]);
assert!(ok, "{out}");
assert!(out.contains("Mow lawn"), "{out}");
let (out, ok) = heph(&socket, &["health"]);
assert!(ok, "{out}");
assert!(out.contains("active"), "{out}");
let (out, ok) = heph(&socket, &["done", &id]);
assert!(ok, "{out}");
assert!(out.contains("done"), "{out}");
// Done tasks drop out of `next`.
let (out, ok) = heph(&socket, &["next"]);
assert!(ok);
assert!(
!out.contains("Mow lawn"),
"completed task should be gone: {out}"
);
}
#[test]
fn recur_preset_makes_a_recurring_task() {
let (socket, _dir) = spawn_daemon();
let (out, _) = heph(&socket, &["task", "Standup", "--recur", "weekdays"]);
let id = created_id(&out);
let (out, ok) = heph(&socket, &["show", &id]);
assert!(ok, "{out}");
assert!(out.contains("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"), "{out}");
}
#[test]
fn project_add_then_file_a_task_under_it() {
let (socket, _dir) = spawn_daemon();
let (out, ok) = heph(&socket, &["project", "add", "Maintenance"]);
assert!(ok, "{out}");
// A task can be filed under the project by name.
let (out, ok) = heph(
&socket,
&["task", "Replace filter", "--project", "Maintenance"],
);
assert!(ok, "{out}");
// An unknown project name is a clear error, not a silent miss.
let (out, ok) = heph(&socket, &["task", "Whatever", "--project", "Nonexistent"]);
assert!(!ok, "expected failure for unknown project: {out}");
}
#[test]
fn project_list_shows_projects_only() {
let (socket, _dir) = spawn_daemon();
heph(&socket, &["project", "add", "Maintenance"]);
heph(&socket, &["project", "add", "Coding"]);
// A task (and its context doc) must not show up in the project list.
heph(&socket, &["task", "Some task"]);
let (out, ok) = heph(&socket, &["project", "list"]);
assert!(ok, "{out}");
assert!(out.contains("Maintenance"), "{out}");
assert!(out.contains("Coding"), "{out}");
assert!(!out.contains("Some task"), "tasks are not projects: {out}");
}
#[test]
fn log_append_then_tail() {
let (socket, _dir) = spawn_daemon();
let (out, _) = heph(&socket, &["task", "Investigate bug"]);
let id = created_id(&out);
let (_, ok) = heph(
&socket,
&["log", &id, "looked", "at", "the", "stack", "trace"],
);
assert!(ok);
let (out, ok) = heph(&socket, &["log", &id]);
assert!(ok, "{out}");
assert!(out.contains("looked at the stack trace"), "{out}");
}
#[test]
fn export_writes_markdown_files() {
let (socket, dir) = spawn_daemon();
heph(&socket, &["doc", "Roof log", "--body", "# Roof"]);
let export_dir = dir.path().join("export");
let (out, ok) = heph(&socket, &["export", export_dir.to_str().unwrap()]);
assert!(ok, "export failed: {out}");
assert!(out.contains("Exported 1 nodes"), "{out}");
// The doc landed as a .md file under doc/.
let docs: Vec<_> = std::fs::read_dir(export_dir.join("doc"))
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(docs.len(), 1);
let text = std::fs::read_to_string(docs[0].path()).unwrap();
assert!(text.contains("title: \"Roof log\""), "{text}");
}
#[test]
fn node_rm_then_restore_round_trips() {
let (socket, _dir) = spawn_daemon();
let (out, ok) = heph(&socket, &["task", "Doomed", "--attention", "red"]);
assert!(ok, "{out}");
let id = out
.split_whitespace()
.find(|w| w.len() == 26) // the ULID in "Created task <id> …"
.expect("task id in output")
.to_string();
let (out, ok) = heph(&socket, &["node", "rm", &id]);
assert!(ok, "{out}");
let (out, _) = heph(&socket, &["list"]);
assert!(
!out.contains("Doomed"),
"tombstoned task still listed: {out}"
);
let (out, ok) = heph(&socket, &["node", "restore", &id]);
assert!(ok, "{out}");
assert!(out.contains("Restored"));
let (out, _) = heph(&socket, &["list"]);
assert!(out.contains("Doomed"), "restored task missing: {out}");
}
#[test]
fn project_move_reparents_and_detaches() {
let (socket, _dir) = spawn_daemon();
let (out, ok) = heph(&socket, &["project", "add", "Coding"]);
assert!(ok, "{out}");
let (out, ok) = heph(&socket, &["project", "add", "Hephaestus"]);
assert!(ok, "{out}");
let (out, ok) = heph(
&socket,
&["project", "move", "Hephaestus", "--parent", "Coding"],
);
assert!(ok, "{out}");
assert!(out.contains("under Coding"), "{out}");
// A cycle is rejected with a real error.
let (out, ok) = heph(
&socket,
&["project", "move", "Coding", "--parent", "Hephaestus"],
);
assert!(!ok, "cycle unexpectedly allowed: {out}");
let (out, ok) = heph(&socket, &["project", "move", "Hephaestus", "--root"]);
assert!(ok, "{out}");
assert!(out.contains("to the root"), "{out}");
}
#[test]
fn show_on_a_task_prints_its_context_doc_body() {
let (socket, _dir) = spawn_daemon();
let (out, ok) = heph(&socket, &["task", "Fix roof"]);
assert!(ok, "{out}");
let id = out
.split_whitespace()
.find(|w| w.len() == 26)
.expect("task id in output")
.to_string();
let (out, ok) = heph(&socket, &["context", &id, "--body", "buy shingles first"]);
assert!(ok, "{out}");
let (out, ok) = heph(&socket, &["show", &id]);
assert!(ok, "{out}");
assert!(out.contains("\"kind\": \"task\""), "{out}");
assert!(
out.contains("buy shingles"),
"show hid the context body: {out}"
);
}