use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
use tempfile::TempDir;
fn memex() -> Command {
Command::cargo_bin("memex").unwrap()
}
fn init_in(dir: &TempDir) {
memex()
.current_dir(dir.path())
.arg("init")
.write_stdin("My test project\n")
.assert()
.success();
}
fn create_node(dir: &TempDir, goal: &str) -> String {
let output = memex()
.current_dir(dir.path())
.args(["node", "create", "--goal", goal])
.assert()
.success()
.get_output()
.stdout
.clone();
let text = String::from_utf8(output).unwrap();
text.lines()
.find(|l| l.starts_with("Created node:"))
.unwrap()
.split_whitespace()
.last()
.unwrap()[..8]
.to_string()
}
#[test]
fn init_creates_memex_directory() {
let tmp = TempDir::new().unwrap();
memex()
.current_dir(tmp.path())
.arg("init")
.write_stdin("My project\n")
.assert()
.success()
.stdout(predicate::str::contains("Initialized .memex/"));
assert!(tmp.path().join(".memex").is_dir());
assert!(tmp.path().join(".memex/nodes").is_dir());
assert!(tmp.path().join(".memex/config.toml").exists());
assert!(tmp.path().join(".memex/.gitignore").exists());
let gitignore = fs::read_to_string(tmp.path().join(".memex/.gitignore")).unwrap();
assert!(gitignore.contains("state.json"));
}
#[test]
fn init_does_not_overwrite_existing_gitignore() {
let tmp = TempDir::new().unwrap();
let memex_dir = tmp.path().join(".memex");
fs::create_dir_all(&memex_dir).unwrap();
fs::write(memex_dir.join(".gitignore"), "custom-content\n").unwrap();
memex()
.current_dir(tmp.path())
.arg("init")
.write_stdin("My project\n")
.assert()
.success();
let content = fs::read_to_string(memex_dir.join(".gitignore")).unwrap();
assert_eq!(content, "custom-content\n");
}
#[test]
fn init_twice_warns_already_exists() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
memex()
.current_dir(tmp.path())
.arg("init")
.write_stdin("irrelevant\n")
.assert()
.success()
.stderr(predicate::str::contains("already"));
}
#[test]
fn command_without_init_fails() {
let tmp = TempDir::new().unwrap();
memex()
.current_dir(tmp.path())
.args(["node", "list"])
.assert()
.failure()
.stderr(predicate::str::contains("No .memex directory found"));
}
#[test]
fn node_create_with_goal_flag() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
let short_id = create_node(&tmp, "Build a search index");
let nodes_dir = tmp.path().join(".memex/nodes");
let entries: Vec<_> = fs::read_dir(&nodes_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_name().to_string_lossy().starts_with(&short_id))
.collect();
assert_eq!(entries.len(), 1, "expected node file for {short_id}");
}
#[test]
fn node_create_sets_active() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "My active task");
memex()
.current_dir(tmp.path())
.args(["node", "show"])
.assert()
.success()
.stdout(predicate::str::contains("My active task"));
}
#[test]
fn node_create_with_parent_flag() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
let parent_id = create_node(&tmp, "Parent node");
let child_id = create_node(&tmp, "Child node");
let _ = child_id;
memex()
.current_dir(tmp.path())
.args(["graph", "view"])
.assert()
.success()
.stdout(predicate::str::contains(&parent_id));
}
#[test]
fn node_create_unknown_parent_fails() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
memex()
.current_dir(tmp.path())
.args(["node", "create", "--parent", "00000000", "--goal", "Orphan"])
.assert()
.failure();
}
#[test]
fn node_edit_goal_flag() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Original goal");
memex()
.current_dir(tmp.path())
.args(["node", "edit", "--goal", "Updated goal"])
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["node", "show"])
.assert()
.success()
.stdout(predicate::str::contains("Updated goal"))
.stdout(predicate::str::contains("Original goal").not());
}
#[test]
fn node_edit_decision_flag() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Planning");
memex()
.current_dir(tmp.path())
.args([
"node",
"edit",
"--decision",
"Use PostgreSQL",
"--decision",
"Use Rust",
])
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["node", "show"])
.assert()
.success()
.stdout(predicate::str::contains("Use PostgreSQL"))
.stdout(predicate::str::contains("Use Rust"));
}
#[test]
fn node_edit_artifact_flag() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Work");
memex()
.current_dir(tmp.path())
.args(["node", "edit", "--artifact", "src/main.rs"])
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["node", "show"])
.assert()
.success()
.stdout(predicate::str::contains("src/main.rs"));
}
#[test]
fn node_edit_open_thread_flag() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Design");
memex()
.current_dir(tmp.path())
.args(["node", "edit", "--open-thread", "How to handle auth?"])
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["node", "show"])
.assert()
.success()
.stdout(predicate::str::contains("How to handle auth?"));
}
#[test]
fn node_edit_summary_and_goal_conflict() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Task");
memex()
.current_dir(tmp.path())
.args([
"node",
"edit",
"--summary",
r#"goal = "Full TOML"
decisions = []
rejected_approaches = []
open_threads = []
key_artifacts = []"#,
"--goal",
"conflicting",
])
.assert()
.failure()
.stderr(
predicate::str::contains("cannot be combined").or(predicate::str::contains("conflict")),
);
}
#[test]
fn node_edit_empty_goal_fails() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Task");
memex()
.current_dir(tmp.path())
.args(["node", "edit", "--goal", ""])
.assert()
.failure()
.stderr(predicate::str::contains("cannot be empty").or(predicate::str::contains("empty")));
}
#[test]
fn node_auto_dry_run_prints_diff_without_writing() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Auto-target");
memex()
.current_dir(tmp.path())
.args(["node", "auto", "--from-stdin"])
.write_stdin(
r#"{"decisions":["Use SQLite"],"open_threads":["audit fts5"],"key_artifacts":["src/db.rs"]}"#,
)
.assert()
.success()
.stdout(predicate::str::contains("+ Decisions:"))
.stdout(predicate::str::contains("Use SQLite"))
.stdout(predicate::str::contains("audit fts5"))
.stdout(predicate::str::contains("src/db.rs"))
.stdout(predicate::str::contains("Run with --apply"));
memex()
.current_dir(tmp.path())
.args(["node", "show"])
.assert()
.success()
.stdout(predicate::str::contains("Use SQLite").not())
.stdout(predicate::str::contains("audit fts5").not());
}
#[test]
fn node_auto_apply_writes_all_fields() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Auto-apply");
memex()
.current_dir(tmp.path())
.args(["node", "auto", "--from-stdin", "--apply"])
.write_stdin(
r#"{
"decisions":["Use SQLite"],
"rejected_approaches":[{"description":"Postgres","reason":"Too heavyweight"}],
"open_threads":["audit fts5"],
"key_artifacts":["src/db.rs"]
}"#,
)
.assert()
.success()
.stdout(predicate::str::contains("Applied 1 decision"));
memex()
.current_dir(tmp.path())
.args(["node", "show"])
.assert()
.success()
.stdout(predicate::str::contains("Use SQLite"))
.stdout(predicate::str::contains("Postgres — Too heavyweight"))
.stdout(predicate::str::contains("audit fts5"))
.stdout(predicate::str::contains("src/db.rs"));
}
#[test]
fn node_auto_apply_is_idempotent() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Idempotent");
let payload = r#"{"decisions":["Use SQLite"],"key_artifacts":["src/db.rs"]}"#;
memex()
.current_dir(tmp.path())
.args(["node", "auto", "--from-stdin", "--apply"])
.write_stdin(payload)
.assert()
.success()
.stdout(predicate::str::contains("Applied"));
memex()
.current_dir(tmp.path())
.args(["node", "auto", "--from-stdin", "--apply"])
.write_stdin(payload)
.assert()
.success()
.stdout(predicate::str::contains("No changes"));
}
#[test]
fn node_auto_dedupes_on_normalized_text() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Dedupe");
memex()
.current_dir(tmp.path())
.args(["node", "auto", "--from-stdin", "--apply"])
.write_stdin(r#"{"decisions":["Use SQLite for storage"]}"#)
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["node", "auto", "--from-stdin", "--apply"])
.write_stdin(r#"{"decisions":[" use sqlite for storage. "]}"#)
.assert()
.success()
.stdout(predicate::str::contains("No changes"));
}
#[test]
fn node_auto_requires_from_stdin() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Needs-flag");
memex()
.current_dir(tmp.path())
.args(["node", "auto"])
.assert()
.failure()
.stderr(predicate::str::contains("--from-stdin is required"));
}
#[test]
fn node_auto_empty_payload_errors() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Empty");
memex()
.current_dir(tmp.path())
.args(["node", "auto", "--from-stdin"])
.write_stdin("")
.assert()
.failure()
.stderr(predicate::str::contains("Empty payload"));
}
#[test]
fn node_auto_invalid_json_errors() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Bad-json");
memex()
.current_dir(tmp.path())
.args(["node", "auto", "--from-stdin"])
.write_stdin("not json")
.assert()
.failure()
.stderr(predicate::str::contains("Failed to parse JSON"));
}
#[test]
fn node_auto_apply_and_dry_run_are_exclusive() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Conflict");
memex()
.current_dir(tmp.path())
.args(["node", "auto", "--from-stdin", "--apply", "--dry-run"])
.write_stdin("{}")
.assert()
.failure()
.stderr(predicate::str::contains("cannot be used with"));
}
#[test]
fn node_auto_targets_explicit_id() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
let target = create_node(&tmp, "Target");
create_node(&tmp, "Different active");
memex()
.current_dir(tmp.path())
.args(["node", "auto", &target, "--from-stdin", "--apply"])
.write_stdin(r#"{"decisions":["Routed to target"]}"#)
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["node", "show", &target])
.assert()
.success()
.stdout(predicate::str::contains("Routed to target"));
}
#[test]
fn node_auto_no_active_node_errors() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
memex()
.current_dir(tmp.path())
.args(["node", "auto", "deadbeef", "--from-stdin"])
.write_stdin(r#"{"decisions":["x"]}"#)
.assert()
.failure();
}
#[test]
fn node_auto_partial_payload_only_new_section() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Partial");
memex()
.current_dir(tmp.path())
.args(["node", "auto", "--from-stdin"])
.write_stdin(r#"{"open_threads":["follow up later"]}"#)
.assert()
.success()
.stdout(predicate::str::contains("+ Open Threads:"))
.stdout(predicate::str::contains("+ Decisions:").not())
.stdout(predicate::str::contains("+ Rejected Approaches:").not())
.stdout(predicate::str::contains("+ Key Artifacts:").not());
}
#[test]
fn node_resolve_marks_resolved() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Task to resolve");
memex()
.current_dir(tmp.path())
.args(["node", "resolve"])
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["node", "show"])
.assert()
.success()
.stdout(predicate::str::contains("Resolved"));
}
#[test]
fn node_resolve_already_resolved_errors() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Task");
memex()
.current_dir(tmp.path())
.args(["node", "resolve"])
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["node", "resolve"])
.assert()
.failure()
.stderr(
predicate::str::contains("already resolved").or(predicate::str::contains("already")),
);
}
#[test]
fn node_abandon_marks_abandoned() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Abandoned task");
memex()
.current_dir(tmp.path())
.args(["node", "abandon"])
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["node", "show"])
.assert()
.success()
.stdout(predicate::str::contains("Abandoned"));
}
#[test]
fn node_reopen_resolved_node() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Task");
memex()
.current_dir(tmp.path())
.args(["node", "resolve"])
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["node", "reopen"])
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["node", "show"])
.assert()
.success()
.stdout(predicate::str::contains("Active"));
}
#[test]
fn node_reopen_already_active_errors() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Task");
memex()
.current_dir(tmp.path())
.args(["node", "reopen"])
.assert()
.failure()
.stderr(predicate::str::contains("already active").or(predicate::str::contains("already")));
}
#[test]
fn node_resolve_with_force_flag() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Task to force-resolve");
memex()
.current_dir(tmp.path())
.args(["node", "resolve", "--force"])
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["node", "show"])
.assert()
.success()
.stdout(predicate::str::contains("Resolved"));
}
#[test]
fn node_resolve_with_short_force_flag() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Task to force-resolve short");
memex()
.current_dir(tmp.path())
.args(["node", "resolve", "-y"])
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["node", "show"])
.assert()
.success()
.stdout(predicate::str::contains("Resolved"));
}
#[test]
fn node_abandon_with_force_flag() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Task to force-abandon");
memex()
.current_dir(tmp.path())
.args(["node", "abandon", "--force"])
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["node", "show"])
.assert()
.success()
.stdout(predicate::str::contains("Abandoned"));
}
#[test]
fn node_show_by_short_id() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
let short_id = create_node(&tmp, "Specific node");
memex()
.current_dir(tmp.path())
.args(["node", "show", &short_id])
.assert()
.success()
.stdout(predicate::str::contains("Specific node"));
}
#[test]
fn node_list_shows_all_nodes() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Alpha node");
create_node(&tmp, "Beta node");
memex()
.current_dir(tmp.path())
.args(["node", "list"])
.assert()
.success()
.stdout(predicate::str::contains("Alpha node"))
.stdout(predicate::str::contains("Beta node"));
}
#[test]
fn node_list_marks_active() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Active node");
let output = memex()
.current_dir(tmp.path())
.args(["node", "list"])
.assert()
.success()
.get_output()
.stdout
.clone();
let text = String::from_utf8(output).unwrap();
assert!(
text.lines().any(|l| l.starts_with('*')),
"Expected a line starting with '*' in:\n{text}"
);
}
#[test]
fn graph_view_single_node() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Lone node");
memex()
.current_dir(tmp.path())
.args(["graph", "view"])
.assert()
.success()
.stdout(predicate::str::contains("Conversation Graph"))
.stdout(predicate::str::contains("Legend:"));
}
#[test]
fn graph_view_tree_connectors() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
let parent_id = create_node(&tmp, "Parent");
let _child_id = create_node(&tmp, "Child"); let _ = parent_id;
memex()
.current_dir(tmp.path())
.args(["graph", "view"])
.assert()
.success()
.stdout(predicate::str::contains("──"));
}
#[test]
fn search_finds_goal() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Build a database indexer");
memex()
.current_dir(tmp.path())
.args(["search", "database"])
.assert()
.success()
.stdout(predicate::str::contains(">>database<<"));
}
#[test]
fn search_case_insensitive() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Build a DATABASE indexer");
memex()
.current_dir(tmp.path())
.args(["search", "database"])
.assert()
.success()
.stdout(predicate::str::contains(">>DATABASE<<"));
}
#[test]
fn search_no_match() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Build a web server");
memex()
.current_dir(tmp.path())
.args(["search", "zzznomatch"])
.assert()
.success()
.stdout(predicate::str::contains("No nodes found matching"));
}
#[test]
fn search_finds_decisions() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Design system");
memex()
.current_dir(tmp.path())
.args(["node", "edit", "--decision", "Use microservices"])
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["search", "microservices"])
.assert()
.success()
.stdout(predicate::str::contains("decision[0]"));
}
#[test]
fn context_markdown_output() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Root project");
memex()
.current_dir(tmp.path())
.args(["context", "--format", "markdown"])
.assert()
.success()
.stdout(predicate::str::contains("## Project Context"))
.stdout(predicate::str::contains("**Goal:**"));
}
#[test]
fn context_xml_output() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Root project");
memex()
.current_dir(tmp.path())
.args(["context", "--format", "xml"])
.assert()
.success()
.stdout(predicate::str::contains("<memex_context>"))
.stdout(predicate::str::contains("<goal>"));
}
#[test]
fn context_plain_output() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Root project");
memex()
.current_dir(tmp.path())
.args(["context", "--format", "plain"])
.assert()
.success()
.stdout(predicate::str::contains("PROJECT CONTEXT"));
}
#[test]
fn context_invalid_format_fails() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
memex()
.current_dir(tmp.path())
.args(["context", "--format", "json"])
.assert()
.failure()
.stderr(predicate::str::contains("Unknown format"));
}
#[test]
fn context_depth_trimming() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
let root_id = create_node(&tmp, "Root node");
let level1_id = create_node(&tmp, "Level one node");
let level2_id = create_node(&tmp, "Level two node");
let _current_id = create_node(&tmp, "Current node");
let _ = (root_id, level1_id, level2_id);
memex()
.current_dir(tmp.path())
.args(["context", "--format", "plain", "--depth", "1"])
.assert()
.success()
.stdout(predicate::str::contains("Level one node").not())
.stdout(predicate::str::contains("Current node"));
}
#[test]
fn pr_context_emits_marker_and_no_match_message_when_empty() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Root");
memex()
.current_dir(tmp.path())
.args(["pr-context", "--file", "src/nothing.rs"])
.assert()
.success()
.stdout(predicate::str::contains("<!-- memex-pr-context -->"))
.stdout(predicate::str::contains(
"No prior memex nodes touched the changed files.",
));
}
#[test]
fn pr_context_lists_node_with_matching_artifact() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Root");
let child = create_node(&tmp, "Touched main");
memex()
.current_dir(tmp.path())
.args(["node", "edit", "--artifact", "src/main.rs"])
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["pr-context", "--file", "src/main.rs"])
.assert()
.success()
.stdout(predicate::str::contains(&child))
.stdout(predicate::str::contains("Touched main"))
.stdout(predicate::str::contains("**Artifacts:** src/main.rs"));
}
#[test]
fn pr_context_excludes_non_matching_nodes() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Root");
let on_main = create_node(&tmp, "Touched main");
memex()
.current_dir(tmp.path())
.args(["node", "edit", "--artifact", "src/main.rs"])
.assert()
.success();
let on_store = create_node(&tmp, "Touched store");
memex()
.current_dir(tmp.path())
.args(["node", "edit", "--artifact", "src/store.rs"])
.assert()
.success();
memex()
.current_dir(tmp.path())
.args(["pr-context", "--file", "src/main.rs"])
.assert()
.success()
.stdout(predicate::str::contains(&on_main))
.stdout(predicate::str::contains(&on_store).not());
}
#[test]
fn pr_context_limit_caps_rendered_nodes() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Root");
for i in 0..4 {
let _id = create_node(&tmp, &format!("Touch {}", i));
memex()
.current_dir(tmp.path())
.args(["node", "edit", "--artifact", "src/main.rs"])
.assert()
.success();
}
memex()
.current_dir(tmp.path())
.args(["pr-context", "--limit", "2", "--file", "src/main.rs"])
.assert()
.success()
.stdout(predicate::str::contains("2 older matching nodes omitted"));
}
#[test]
fn pr_context_supports_custom_marker() {
let tmp = TempDir::new().unwrap();
init_in(&tmp);
create_node(&tmp, "Root");
memex()
.current_dir(tmp.path())
.args([
"pr-context",
"--marker",
"<!-- custom-marker -->",
"--file",
"src/main.rs",
])
.assert()
.success()
.stdout(predicate::str::contains("<!-- custom-marker -->"))
.stdout(predicate::str::contains("<!-- memex-pr-context -->").not());
}