use assert_cmd::Command;
use predicates::prelude::*;
use spool::domain::MemoryScope;
use spool::lifecycle_store::{
LifecycleStore, ProposeMemoryRequest, RecordMemoryRequest, TransitionMetadata,
lifecycle_root_from_config, propose_ai_memory, record_manual_memory,
};
use std::fs;
use tempfile::tempdir;
#[test]
fn get_outputs_prompt_context() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(vault_dir.join("20-Areas")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/spool 项目档案.md"),
r#"---
tags: [rust, cli]
---
# spool 项目档案
这是 spool 项目背景。
repo_path 识别很重要。
"#,
)
.unwrap();
fs::write(
vault_dir.join("20-Areas/AI协作偏好.md"),
"# AI协作偏好\n\n先理解边界,再动手。\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[output]
default_format = "prompt"
max_chars = 12000
max_notes = 8
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects", "20-Areas"]
default_tags = ["rust", "cli"]
[[projects.modules]]
id = "routing"
path_prefixes = ["src/engine"]
keywords = ["repo_path", "route"]
[[scenes]]
id = "planning"
keywords = ["规划", "方案"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"实现 repo_path route 规划",
"--cwd",
repo_dir.to_str().unwrap(),
"--files",
"src/engine/project_matcher.rs",
"--format",
"prompt",
])
.assert()
.success()
.stdout(predicate::str::contains("项目:spool"))
.stdout(predicate::str::contains("候选笔记"));
}
#[test]
fn explain_outputs_route_trace() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/spool.md"),
"# spool\n\nroute planning context\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
[[scenes]]
id = "planning"
keywords = ["planning"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"explain",
"--config",
config_path.to_str().unwrap(),
"--task",
"planning route",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::contains("# route explain"))
.stdout(predicate::str::contains("## project"))
.stdout(predicate::str::contains("matched_project_id: spool"))
.stdout(predicate::str::contains("note_roots: 10-Projects"))
.stdout(predicate::str::contains("scan_roots: 10-Projects"))
.stdout(predicate::str::contains("note_count: 1"));
}
#[test]
fn get_target_should_affect_prompt_header() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/spool.md"),
"# spool\n\ncontext\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"spool context",
"--cwd",
repo_dir.to_str().unwrap(),
"--target",
"codex",
"--format",
"prompt",
])
.assert()
.success()
.stdout(predicate::str::contains("给 Codex 使用的精简上下文"));
}
#[test]
fn get_should_rank_title_heading_and_wikilink_matches_above_body_only_matches() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/routing-guide.md"),
"# Repo Path Routing Guide\n\n## Project Matcher\n\nSee [[Project Matcher]] for routing details.\n",
)
.unwrap();
fs::write(
vault_dir.join("10-Projects/notes.md"),
"# Implementation Notes\n\nBackground paragraph with routing mentioned once.\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[output]
default_format = "markdown"
max_chars = 12000
max_notes = 8
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
[[projects.modules]]
id = "routing"
path_prefixes = ["src/engine"]
keywords = ["routing"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
let assert = Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"Refine Repo-Path routing",
"--cwd",
repo_dir.to_str().unwrap(),
"--files",
"src/engine/RepoPathMatcher.rs",
"--format",
"markdown",
])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let guide_index = stdout.find("## 10-Projects/routing-guide.md").unwrap();
let notes_index = stdout.find("## 10-Projects/notes.md").unwrap();
assert!(guide_index < notes_index, "stdout was:\n{stdout}");
}
#[test]
fn get_should_prioritize_structured_frontmatter_memory() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/auth-constraints.md"),
r#"---
memory_type: constraint
project_id: spool
sensitivity: internal
source_of_truth: true
retrieval_priority: high
---
# Auth Constraints
Authentication design constraints and invariants.
"#,
)
.unwrap();
fs::write(
vault_dir.join("10-Projects/auth-notes.md"),
"# Auth Constraints\n\nAuthentication design constraints and invariants.\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[output]
default_format = "markdown"
max_chars = 12000
max_notes = 8
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
let assert = Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"review auth constraints",
"--cwd",
repo_dir.to_str().unwrap(),
"--format",
"markdown",
])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let curated_index = stdout.find("## 10-Projects/auth-constraints.md").unwrap();
let generic_index = stdout.find("## 10-Projects/auth-notes.md").unwrap();
assert!(curated_index < generic_index, "stdout was:\n{stdout}");
}
#[test]
fn scene_preferred_notes_should_influence_candidates() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(vault_dir.join("20-Areas")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/spool.md"),
"# spool\n\ngeneral context\n",
)
.unwrap();
fs::write(
vault_dir.join("20-Areas/AI协作偏好.md"),
"# AI协作偏好\n\npreferred scene note\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["./10-Projects", "20-Areas/../20-Areas"]
[[scenes]]
id = "planning"
keywords = ["planning"]
preferred_notes = ["./20-Areas/../20-Areas/AI协作偏好.md"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"explain",
"--config",
config_path.to_str().unwrap(),
"--task",
"planning",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::contains("preferred by scene planning"))
.stdout(predicate::str::contains(
"preferred_notes: 20-Areas/AI协作偏好.md",
));
}
#[test]
fn get_uses_config_default_format_when_flag_missing() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/spool.md"),
"# spool\n\ncontext\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[output]
default_format = "json"
max_chars = 12000
max_notes = 8
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"spool context",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::contains("\"route\""));
}
#[test]
fn invalid_default_format_should_fail_fast() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/bad.md"),
"---\ntags: [oops\n---\n# bad\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[output]
default_format = "yaml"
max_chars = 12000
max_notes = 8
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"spool context",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.failure()
.stderr(
predicate::str::contains("unknown variant `yaml`")
.or(predicate::str::contains("output.default_format")),
);
}
#[test]
fn wakeup_project_json_outputs_canonical_packet() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/project.md"),
r#"---
memory_type: project
project_id: spool
sensitivity: internal
source_of_truth: true
retrieval_priority: high
---
# spool Project
Current project context.
"#,
)
.unwrap();
fs::write(
vault_dir.join("10-Projects/constraints.md"),
r#"---
memory_type: constraint
project_id: spool
sensitivity: internal
source_of_truth: true
retrieval_priority: high
---
# Constraints
Keep Obsidian as curated truth.
"#,
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[output]
default_format = "json"
max_chars = 12000
max_notes = 8
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
let assert = Command::cargo_bin("spool")
.unwrap()
.args([
"wakeup",
"--config",
config_path.to_str().unwrap(),
"--task",
"design MCP interface",
"--cwd",
repo_dir.to_str().unwrap(),
"--files",
"src/app.rs",
"--target",
"claude",
"--profile",
"project",
])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let packet: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(packet["version"], "wakeup.v1");
assert_eq!(packet["profile"], "project");
assert_eq!(packet["target"], "claude");
assert_eq!(packet["query"]["task"], "design MCP interface");
assert_eq!(packet["query"]["files"][0], "src/app.rs");
assert!(packet.get("identity").is_some());
assert!(packet.get("working_style").is_some());
assert!(packet.get("active_context").is_some());
assert!(packet.get("priorities").is_some());
assert!(packet.get("constraints").is_some());
assert!(packet.get("decisions").is_some());
assert!(packet.get("incidents").is_some());
assert!(packet.get("recommended_notes").is_some());
assert!(packet.get("provenance").is_some());
assert!(packet.get("policy").is_some());
}
#[test]
fn wakeup_should_inject_knowledge_index_when_available() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/project.md"),
"---\nmemory_type: project\nproject_id: spool\nsensitivity: internal\n---\n# spool\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[output]
default_format = "json"
max_chars = 8000
max_notes = 4
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"record-manual",
"--config",
config_path.to_str().unwrap(),
"--title",
"User pref",
"--summary",
"prefer concise output",
"--memory-type",
"preference",
"--scope",
"user",
"--source-ref",
"manual:cli",
"--user-id",
"long",
])
.assert()
.success();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"record-manual",
"--config",
config_path.to_str().unwrap(),
"--title",
"Project decision X",
"--summary",
"chose approach A",
"--memory-type",
"decision",
"--scope",
"project",
"--source-ref",
"manual:cli",
"--user-id",
"long",
"--project-id",
"spool",
])
.assert()
.success();
let assert = Command::cargo_bin("spool")
.unwrap()
.args([
"wakeup",
"--config",
config_path.to_str().unwrap(),
"--task",
"resume work",
"--cwd",
repo_dir.to_str().unwrap(),
"--target",
"claude",
"--profile",
"project",
])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let packet: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let index = packet["knowledge_index"]
.as_str()
.expect("knowledge_index should be present");
assert!(index.contains("User-Level"));
assert!(index.contains("User pref"));
assert!(index.contains("Project: spool"));
assert!(index.contains("Project decision X"));
}
#[test]
fn wakeup_should_omit_knowledge_index_when_file_missing() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/project.md"),
"---\nmemory_type: project\nproject_id: spool\n---\n# spool\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[output]
default_format = "json"
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
let assert = Command::cargo_bin("spool")
.unwrap()
.args([
"wakeup",
"--config",
config_path.to_str().unwrap(),
"--task",
"resume work",
"--cwd",
repo_dir.to_str().unwrap(),
"--target",
"claude",
"--profile",
"project",
])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let packet: serde_json::Value = serde_json::from_str(&stdout).unwrap();
let index = packet.get("knowledge_index");
assert!(
index.is_none() || index == Some(&serde_json::Value::Null),
"knowledge_index should be absent or null when INDEX.md missing"
);
}
#[test]
fn wakeup_developer_profile_should_use_developer_roots() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("00-Identity")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("00-Identity/preferences.md"),
r#"---
memory_type: preference
sensitivity: internal
source_of_truth: true
---
# Collaboration Preferences
Prefer concise responses and explicit provenance.
"#,
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[developer]
note_roots = ["00-Identity"]
"#,
vault_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
let assert = Command::cargo_bin("spool")
.unwrap()
.args([
"wakeup",
"--config",
config_path.to_str().unwrap(),
"--task",
"developer wakeup",
"--cwd",
repo_dir.to_str().unwrap(),
"--profile",
"developer",
])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let packet: serde_json::Value = serde_json::from_str(&stdout).unwrap();
assert_eq!(packet["profile"], "developer");
assert_eq!(packet["identity"]["active_profile"], "developer");
assert_eq!(packet["identity"]["developer_roots"][0], "00-Identity");
assert!(
!packet["working_style"]["items"]
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn wakeup_should_render_markdown_and_prompt_formats() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/project.md"),
r#"---
memory_type: project
project_id: spool
sensitivity: internal
source_of_truth: true
retrieval_priority: high
---
# spool Project
Current project context.
"#,
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"wakeup",
"--config",
config_path.to_str().unwrap(),
"--task",
"markdown wakeup",
"--cwd",
repo_dir.to_str().unwrap(),
"--profile",
"project",
"--format",
"markdown",
])
.assert()
.success()
.stdout(predicate::str::contains("# wakeup packet"));
Command::cargo_bin("spool")
.unwrap()
.args([
"wakeup",
"--config",
config_path.to_str().unwrap(),
"--task",
"prompt wakeup",
"--cwd",
repo_dir.to_str().unwrap(),
"--profile",
"project",
"--format",
"prompt",
"--target",
"codex",
])
.assert()
.success()
.stdout(predicate::str::contains("给 Codex 使用的 wake-up packet"));
}
#[test]
fn empty_frontmatter_should_be_allowed() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/empty-frontmatter.md"),
"---\n---\n# spool\n\ncontext\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[output]
default_format = "prompt"
max_chars = 12000
max_notes = 8
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"spool context",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.success();
}
#[test]
fn parent_directory_should_not_match_project() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_root = temp.path().join("workspace");
let repo_dir = repo_root.join("spool");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/spool.md"),
"# spool\n\ncontext\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"explain",
"--config",
config_path.to_str().unwrap(),
"--task",
"spool context",
"--cwd",
repo_root.to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::contains("no project matched cwd"));
}
#[test]
fn invalid_frontmatter_should_fail_fast() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/bad.md"),
"---\ntags: [oops\n---\n# bad\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"spool context",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.failure();
}
#[test]
fn unclosed_frontmatter_should_fail_fast() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/unclosed.md"),
"---\ntags: [rust, cli]\n# body leaked into frontmatter\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"spool context",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.failure();
}
#[test]
fn fenced_code_block_headings_should_not_split_sections() {
let body = "# Title\n\n```rust\n# not a heading\n```\n\n## Real\ncontent\n";
let sections = spool::vault::markdown::extract_sections(body);
assert_eq!(sections.len(), 2);
assert_eq!(sections[0].heading.as_deref(), Some("Title"));
assert!(sections[0].content.contains("# not a heading"));
assert_eq!(sections[1].heading.as_deref(), Some("Real"));
}
#[test]
fn tilde_fenced_code_block_headings_should_not_split_sections() {
let body = "# Title\n\n~~~md\n# not a heading\n~~~\n\n## Real\ncontent\n";
let sections = spool::vault::markdown::extract_sections(body);
assert_eq!(sections.len(), 2);
assert_eq!(sections[0].heading.as_deref(), Some("Title"));
assert!(sections[0].content.contains("# not a heading"));
assert_eq!(sections[1].heading.as_deref(), Some("Real"));
}
#[test]
fn scan_should_fail_when_file_exceeds_size_limit() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
let oversized = format!("# big\n\n{}", "a".repeat(200));
fs::write(vault_dir.join("10-Projects/big.md"), oversized).unwrap();
let config = format!(
r#"[vault]
root = "{}"
[vault.limits]
max_files = 10
max_file_bytes = 32
max_total_bytes = 1024
max_depth = 8
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"spool context",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::contains("max_file_bytes"));
}
#[test]
fn large_markdown_outside_note_roots_should_not_fail_scan() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(vault_dir.join("99-Archive")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/spool.md"),
"# spool\n\ncontext\n",
)
.unwrap();
fs::write(
vault_dir.join("99-Archive/big.md"),
format!("# big\n\n{}", "a".repeat(200)),
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[vault.limits]
max_files = 10
max_file_bytes = 32
max_total_bytes = 1024
max_depth = 8
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"spool context",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.success();
}
#[test]
fn large_note_set_should_limit_candidates_to_configured_max_notes() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(vault_dir.join("20-Areas")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
for index in 0..120 {
let root = if index % 3 == 0 {
"10-Projects"
} else {
"20-Areas"
};
let filename = format!("{root}/note-{index:03}.md");
let body = if index % 10 == 0 {
format!(
"# Repo Path Routing {index}\n\n## Project Matcher\n\nRouting note {index} mentions [[Project Matcher]] and repo_path.\n"
)
} else {
format!("# General Note {index}\n\nBackground information {index}.\n")
};
fs::write(vault_dir.join(filename), body).unwrap();
}
let config = format!(
r#"[vault]
root = "{}"
[output]
default_format = "json"
max_chars = 20000
max_notes = 5
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects", "20-Areas"]
[[projects.modules]]
id = "routing"
path_prefixes = ["src/engine"]
keywords = ["routing", "repo_path"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
let output = Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"Refine Repo-Path routing",
"--cwd",
repo_dir.to_str().unwrap(),
"--files",
"src/engine/RepoPathMatcher.rs",
"--format",
"json",
])
.output()
.unwrap();
assert!(output.status.success());
let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let candidates = parsed["route"]["candidates"].as_array().unwrap();
assert_eq!(candidates.len(), 5);
for candidate in candidates {
let breakdown = candidate["score_breakdown"]
.as_array()
.expect("score_breakdown array missing on candidate");
assert!(
!breakdown.is_empty(),
"score_breakdown must be non-empty for scored candidate: {candidate}"
);
let summed: i64 = breakdown
.iter()
.map(|c| c["weight"].as_i64().unwrap_or(0))
.sum();
let score = candidate["score"].as_i64().unwrap_or(0);
assert_eq!(
summed, score,
"breakdown weights must sum to score: {candidate}"
);
for contrib in breakdown {
assert!(
contrib["source"].is_string(),
"contribution.source must be string: {contrib}"
);
assert!(
contrib["field"].is_string(),
"contribution.field must be string: {contrib}"
);
assert!(
contrib["term"].is_string(),
"contribution.term must be string: {contrib}"
);
}
let confidence = candidate["confidence"]
.as_str()
.expect("confidence must be a string on every candidate");
assert!(
matches!(confidence, "high" | "medium" | "low"),
"confidence must be high|medium|low, got: {confidence}"
);
}
}
#[test]
fn source_of_truth_note_should_report_high_confidence() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
let note = "---\nmemory_type: constraint\nsource_of_truth: true\n---\n# Auth Constraints\n\nNever store tokens in localStorage.\n";
fs::write(vault_dir.join("10-Projects/auth-constraints.md"), note).unwrap();
let config = format!(
r#"[vault]
root = "{}"
[output]
default_format = "json"
max_chars = 20000
max_notes = 5
[[projects]]
id = "myapp"
name = "myapp"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
let output = Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"auth token storage",
"--cwd",
repo_dir.to_str().unwrap(),
"--format",
"json",
])
.output()
.unwrap();
assert!(output.status.success());
let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let candidates = parsed["route"]["candidates"].as_array().unwrap();
assert!(!candidates.is_empty());
let first = &candidates[0];
assert_eq!(
first["confidence"].as_str().unwrap(),
"high",
"source_of_truth note must have confidence=high"
);
}
#[test]
fn missing_note_root_should_fail_instead_of_scanning_whole_vault() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("99-Archive")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("99-Archive/big.md"),
format!("# big\n\n{}", "a".repeat(200)),
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[vault.limits]
max_files = 10
max_file_bytes = 32
max_total_bytes = 1024
max_depth = 8
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"spool context",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::contains(
"configured note_root does not exist",
));
}
#[test]
fn unmatched_project_should_fail_instead_of_scanning_whole_vault() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/spool.md"),
"# spool\n\ncontext\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[[projects]]
id = "other"
name = "other"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
temp.path().join("other-repo").display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"spool context",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::contains("no project matched cwd"));
}
#[test]
fn matched_project_without_note_roots_should_fail_fast() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(&vault_dir).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
let config = format!(
r#"[vault]
root = "{}"
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"spool context",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::contains(
"matched project has no note_roots configured",
));
}
#[test]
fn config_relative_paths_should_resolve_from_config_dir() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let config_dir = temp.path().join("configs");
let workspace_dir = temp.path().join("workspace");
let repo_dir = workspace_dir.join("spool");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&config_dir).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/spool.md"),
"# spool\n\ncontext\n",
)
.unwrap();
let config = r#"[vault]
root = "../vault"
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["../workspace/spool"]
note_roots = ["10-Projects"]
"#;
let config_path = config_dir.join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.current_dir(temp.path())
.args([
"explain",
"--config",
"configs/spool.toml",
"--task",
"spool context",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::contains("## project\n- id: spool"));
}
#[test]
fn note_root_should_not_escape_vault_root() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let outside_dir = temp.path().join("outside");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&outside_dir).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(outside_dir.join("secret.md"), "# secret\n\noutside\n").unwrap();
let config = format!(
r#"[vault]
root = "{}"
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["../outside"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"spool context",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::contains(
"relative note path must not escape root",
));
}
#[test]
fn preferred_note_should_not_escape_root() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/spool.md"),
"# spool\n\ncontext\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
[[scenes]]
id = "planning"
keywords = ["planning"]
preferred_notes = ["../outside.md"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"explain",
"--config",
config_path.to_str().unwrap(),
"--task",
"planning",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.failure()
.stderr(predicate::str::contains(
"relative note path must not escape root",
));
}
#[test]
fn frontmatter_tags_should_match_default_tags() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/spool.md"),
"---\ntags: [rust, cli]\n---\n# spool\n\ncontext\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
default_tags = ["rust"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"explain",
"--config",
config_path.to_str().unwrap(),
"--task",
"spool context",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::contains("matched frontmatter tag rust"));
}
#[test]
fn canonicalized_repo_path_should_match_cwd() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let workspace_dir = temp.path().join("workspace");
let repo_dir = workspace_dir.join("spool");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/spool.md"),
"# spool\n\ncontext\n",
)
.unwrap();
let relative_repo_path = workspace_dir.join("..").join("workspace").join("spool");
let config = format!(
r#"[vault]
root = "{}"
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
relative_repo_path.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"explain",
"--config",
config_path.to_str().unwrap(),
"--task",
"spool context",
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::contains("## project\n- id: spool"));
}
fn setup_lifecycle_cli_fixture() -> (tempfile::TempDir, std::path::PathBuf, String, String) {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
fs::create_dir_all(&vault_dir).unwrap();
let config_path = temp.path().join("spool.toml");
fs::write(
&config_path,
format!("[vault]\nroot = \"{}\"\n", vault_dir.display()),
)
.unwrap();
let lifecycle_root = lifecycle_root_from_config(temp.path());
let store = LifecycleStore::new(lifecycle_root.as_path());
let manual = record_manual_memory(
&store,
RecordMemoryRequest {
title: "简洁输出".to_string(),
summary: "偏好简洁".to_string(),
memory_type: "preference".to_string(),
scope: MemoryScope::User,
source_ref: "manual:gui".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let proposal = propose_ai_memory(
&store,
ProposeMemoryRequest {
title: "测试偏好".to_string(),
summary: "先 smoke 再收口".to_string(),
memory_type: "workflow".to_string(),
scope: MemoryScope::User,
source_ref: "session:1".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
(temp, config_path, proposal.record_id, manual.record_id)
}
#[test]
fn memory_list_and_show_should_render_lifecycle_records() {
let (_temp, config_path, proposal_record_id, _manual_record_id) = setup_lifecycle_cli_fixture();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"list",
"--config",
config_path.to_str().unwrap(),
"--view",
"pending-review",
])
.assert()
.success()
.stdout(predicate::str::contains("# Pending review"))
.stdout(predicate::str::contains(&proposal_record_id))
.stdout(predicate::str::contains("测试偏好"));
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"show",
"--config",
config_path.to_str().unwrap(),
"--record-id",
&proposal_record_id,
])
.assert()
.success()
.stdout(predicate::str::contains("# Memory record"))
.stdout(predicate::str::contains("state: candidate"))
.stdout(predicate::str::contains("先 smoke 再收口"));
}
#[test]
fn memory_actions_should_update_lifecycle_state() {
let (_temp, config_path, proposal_record_id, manual_record_id) = setup_lifecycle_cli_fixture();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"accept",
"--config",
config_path.to_str().unwrap(),
"--record-id",
&proposal_record_id,
])
.assert()
.success()
.stdout(predicate::str::contains("action: accept"))
.stdout(predicate::str::contains("state: accepted"))
.stdout(predicate::str::contains("pending_review: false"))
.stdout(predicate::str::contains("wakeup_ready: true"));
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"promote",
"--config",
config_path.to_str().unwrap(),
"--record-id",
&proposal_record_id,
])
.assert()
.success()
.stdout(predicate::str::contains("action: promote"))
.stdout(predicate::str::contains("state: canonical"))
.stdout(predicate::str::contains("wakeup_ready: true"));
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"archive",
"--config",
config_path.to_str().unwrap(),
"--record-id",
&manual_record_id,
])
.assert()
.success()
.stdout(predicate::str::contains("action: archive"))
.stdout(predicate::str::contains("state: archived"))
.stdout(predicate::str::contains("wakeup_ready: false"));
}
#[test]
fn memory_actions_should_fail_for_invalid_transition() {
let (_temp, config_path, _proposal_record_id, manual_record_id) = setup_lifecycle_cli_fixture();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"accept",
"--config",
config_path.to_str().unwrap(),
"--record-id",
&manual_record_id,
])
.assert()
.failure()
.stderr(predicate::str::contains("invalid lifecycle transition"));
}
#[test]
fn memory_create_commands_should_add_manual_and_proposed_records() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
fs::create_dir_all(&vault_dir).unwrap();
let config_path = temp.path().join("spool.toml");
fs::write(
&config_path,
format!("[vault]\nroot = \"{}\"\n", vault_dir.display()),
)
.unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"record-manual",
"--config",
config_path.to_str().unwrap(),
"--title",
"简洁输出",
"--summary",
"偏好简洁",
"--memory-type",
"preference",
"--scope",
"user",
"--source-ref",
"manual:cli",
"--user-id",
"long",
"--sensitivity",
"internal",
])
.assert()
.success()
.stdout(predicate::str::contains("# Lifecycle create"))
.stdout(predicate::str::contains("kind: record_manual"))
.stdout(predicate::str::contains("state: accepted"))
.stdout(predicate::str::contains("wakeup_ready: true"));
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"propose",
"--config",
config_path.to_str().unwrap(),
"--title",
"测试偏好",
"--summary",
"先 smoke 再收口",
"--memory-type",
"workflow",
"--scope",
"user",
"--source-ref",
"session:1",
"--user-id",
"long",
"--project-id",
"spool",
])
.assert()
.success()
.stdout(predicate::str::contains("kind: propose"))
.stdout(predicate::str::contains("state: candidate"))
.stdout(predicate::str::contains("pending_review: true"));
}
#[test]
fn record_manual_should_generate_vault_index_md() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
fs::create_dir_all(&vault_dir).unwrap();
let config_path = temp.path().join("spool.toml");
fs::write(
&config_path,
format!("[vault]\nroot = \"{}\"\n", vault_dir.display()),
)
.unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"record-manual",
"--config",
config_path.to_str().unwrap(),
"--title",
"索引自动生成",
"--summary",
"验证 INDEX.md 回写",
"--memory-type",
"preference",
"--scope",
"user",
"--source-ref",
"manual:cli",
"--user-id",
"long",
])
.assert()
.success();
let index_path = vault_dir.join("INDEX.md");
assert!(
index_path.exists(),
"INDEX.md should be generated after record-manual"
);
let body = fs::read_to_string(&index_path).unwrap();
assert!(body.contains("# Spool Knowledge Index"));
assert!(body.contains("索引自动生成"));
assert!(body.contains("[preference]"));
}
#[test]
fn memory_lint_should_report_clean_when_ledger_has_no_issues() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
fs::create_dir_all(&vault_dir).unwrap();
let config_path = temp.path().join("spool.toml");
fs::write(
&config_path,
format!("[vault]\nroot = \"{}\"\n", vault_dir.display()),
)
.unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"record-manual",
"--config",
config_path.to_str().unwrap(),
"--title",
"lint clean",
"--summary",
"clean ledger",
"--memory-type",
"preference",
"--scope",
"user",
"--source-ref",
"manual:cli",
"--user-id",
"long",
])
.assert()
.success();
Command::cargo_bin("spool")
.unwrap()
.args(["memory", "lint", "--config", config_path.to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("Wiki Lint Report"))
.stdout(predicate::str::contains("1 active records"))
.stdout(predicate::str::contains("知识库干净"));
}
#[test]
fn memory_sync_index_dry_run_should_not_create_file() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
fs::create_dir_all(&vault_dir).unwrap();
let config_path = temp.path().join("spool.toml");
fs::write(
&config_path,
format!("[vault]\nroot = \"{}\"\n", vault_dir.display()),
)
.unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"sync-index",
"--config",
config_path.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::contains("dry-run"))
.stdout(predicate::str::contains("--apply"));
assert!(
!vault_dir.join("INDEX.md").exists(),
"dry-run should not create INDEX.md"
);
}
#[test]
fn memory_sync_index_apply_should_create_and_be_idempotent() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
fs::create_dir_all(&vault_dir).unwrap();
let config_path = temp.path().join("spool.toml");
fs::write(
&config_path,
format!("[vault]\nroot = \"{}\"\n", vault_dir.display()),
)
.unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"record-manual",
"--config",
config_path.to_str().unwrap(),
"--title",
"Sync test",
"--summary",
"testing sync-index",
"--memory-type",
"preference",
"--scope",
"user",
"--source-ref",
"manual:cli",
"--user-id",
"long",
])
.assert()
.success();
let index_path = vault_dir.join("INDEX.md");
if index_path.exists() {
fs::remove_file(&index_path).unwrap();
}
assert!(!index_path.exists());
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"sync-index",
"--config",
config_path.to_str().unwrap(),
"--apply",
])
.assert()
.success()
.stdout(predicate::str::contains("Created"));
assert!(index_path.exists());
let body = fs::read_to_string(&index_path).unwrap();
assert!(body.contains("Sync test"));
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"sync-index",
"--config",
config_path.to_str().unwrap(),
"--apply",
])
.assert()
.success()
.stdout(predicate::str::contains("Unchanged"));
}
#[test]
fn memory_history_should_return_full_event_sequence() {
let (_temp, config_path, proposal_record_id, _manual_record_id) = setup_lifecycle_cli_fixture();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"accept",
"--config",
config_path.to_str().unwrap(),
"--record-id",
&proposal_record_id,
])
.assert()
.success();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"history",
"--config",
config_path.to_str().unwrap(),
"--record-id",
&proposal_record_id,
])
.assert()
.success()
.stdout(predicate::str::contains("# Memory history"))
.stdout(predicate::str::contains("events: 2"))
.stdout(predicate::str::contains("action: propose_ai"))
.stdout(predicate::str::contains("action: accept"));
}
#[test]
fn memory_history_should_fail_with_not_found_for_unknown_record_id() {
let (_temp, config_path, _proposal_record_id, _manual_record_id) =
setup_lifecycle_cli_fixture();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"history",
"--config",
config_path.to_str().unwrap(),
"--record-id",
"definitely-missing-id",
])
.assert()
.failure()
.stderr(predicate::str::contains(
"memory record not found: definitely-missing-id",
));
}
#[test]
fn memory_read_commands_should_use_daemon_when_configured() {
let (_temp, config_path, proposal_record_id, _manual_record_id) = setup_lifecycle_cli_fixture();
let daemon_bin = assert_cmd::cargo::cargo_bin("spool-daemon");
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"list",
"--config",
config_path.to_str().unwrap(),
"--view",
"pending-review",
"--daemon-bin",
daemon_bin.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::contains(&proposal_record_id));
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"show",
"--config",
config_path.to_str().unwrap(),
"--record-id",
&proposal_record_id,
"--daemon-bin",
daemon_bin.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::contains("state: candidate"));
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"history",
"--config",
config_path.to_str().unwrap(),
"--record-id",
&proposal_record_id,
"--daemon-bin",
daemon_bin.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::contains("action: propose_ai"));
}
#[test]
fn memory_read_commands_should_fallback_when_daemon_is_unavailable() {
let (_temp, config_path, proposal_record_id, _manual_record_id) = setup_lifecycle_cli_fixture();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"list",
"--config",
config_path.to_str().unwrap(),
"--view",
"pending-review",
"--daemon-bin",
"/definitely/missing/spool-daemon",
])
.assert()
.success()
.stdout(predicate::str::contains(&proposal_record_id));
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"show",
"--config",
config_path.to_str().unwrap(),
"--record-id",
&proposal_record_id,
"--daemon-bin",
"/definitely/missing/spool-daemon",
])
.assert()
.success()
.stdout(predicate::str::contains("state: candidate"));
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"history",
"--config",
config_path.to_str().unwrap(),
"--record-id",
&proposal_record_id,
"--daemon-bin",
"/definitely/missing/spool-daemon",
])
.assert()
.success()
.stdout(predicate::str::contains("action: propose_ai"));
}
#[test]
fn memory_commands_should_surface_write_metadata() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
fs::create_dir_all(&vault_dir).unwrap();
let config_path = temp.path().join("spool.toml");
fs::write(
&config_path,
format!("[vault]\nroot = \"{}\"\n", vault_dir.display()),
)
.unwrap();
let assert = Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"record-manual",
"--config",
config_path.to_str().unwrap(),
"--title",
"简洁输出",
"--summary",
"偏好简洁",
"--memory-type",
"preference",
"--scope",
"user",
"--source-ref",
"manual:cli",
"--user-id",
"long",
"--actor",
"codex",
"--reason",
"captured from explicit user instruction",
"--evidence-refs",
"obsidian://preferences,session:1",
])
.assert()
.success()
.stdout(predicate::str::contains("actor: codex"))
.stdout(predicate::str::contains(
"reason: captured from explicit user instruction",
))
.stdout(predicate::str::contains(
"evidence_refs: obsidian://preferences, session:1",
));
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let record_id_line = stdout
.lines()
.find(|line| line.contains("record_id"))
.unwrap()
.to_string();
let record_id = record_id_line.split('`').nth(1).unwrap().to_string();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"show",
"--config",
config_path.to_str().unwrap(),
"--record-id",
&record_id,
])
.assert()
.success()
.stdout(predicate::str::contains("actor: codex"))
.stdout(predicate::str::contains(
"reason: captured from explicit user instruction",
))
.stdout(predicate::str::contains(
"evidence_refs: obsidian://preferences, session:1",
));
}
#[test]
fn memory_action_commands_should_surface_transition_metadata() {
let (_temp, config_path, proposal_record_id, _manual_record_id) = setup_lifecycle_cli_fixture();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"accept",
"--config",
config_path.to_str().unwrap(),
"--record-id",
&proposal_record_id,
"--actor",
"long",
"--reason",
"approved after review",
"--evidence-refs",
"session:1,session:2",
])
.assert()
.success()
.stdout(predicate::str::contains("actor: long"))
.stdout(predicate::str::contains("reason: approved after review"))
.stdout(predicate::str::contains(
"evidence_refs: session:1, session:2",
));
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"history",
"--config",
config_path.to_str().unwrap(),
"--record-id",
&proposal_record_id,
])
.assert()
.success()
.stdout(predicate::str::contains("actor: long"))
.stdout(predicate::str::contains("reason: approved after review"))
.stdout(predicate::str::contains(
"evidence_refs: session:1, session:2",
));
}
#[test]
fn get_should_include_lifecycle_canonical_memory_section() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
fs::write(
vault_dir.join("10-Projects/spool.md"),
"# spool\n\nrepo_path routing baseline note.\n",
)
.unwrap();
let config = format!(
r#"[vault]
root = "{}"
[output]
default_format = "prompt"
max_chars = 12000
max_notes = 8
max_lifecycle = 5
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
let lifecycle_root = lifecycle_root_from_config(temp.path());
fs::create_dir_all(&lifecycle_root).unwrap();
let store = LifecycleStore::new(&lifecycle_root);
record_manual_memory(
&store,
RecordMemoryRequest {
title: "避免 mock 测试".to_string(),
summary: "production migration 曾因 mock 过度而失败".to_string(),
memory_type: "constraint".to_string(),
scope: MemoryScope::User,
source_ref: "cli-smoke".to_string(),
project_id: None,
user_id: None,
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"实现 repo_path route 规划",
"--cwd",
repo_dir.to_str().unwrap(),
"--format",
"prompt",
])
.assert()
.success()
.stdout(predicate::str::contains("记忆(accepted / canonical)"))
.stdout(predicate::str::contains("避免 mock 测试"));
}
#[test]
fn memory_sync_vault_should_create_canonical_note_and_be_idempotent() {
let temp = tempdir().unwrap();
let vault_root = temp.path().join("vault");
fs::create_dir_all(&vault_root).unwrap();
let config_path = temp.path().join("spool.toml");
fs::write(
&config_path,
format!("[vault]\nroot = \"{}\"\n", vault_root.display()),
)
.unwrap();
let lifecycle_root = lifecycle_root_from_config(temp.path());
let store = LifecycleStore::new(lifecycle_root.as_path());
let manual = record_manual_memory(
&store,
RecordMemoryRequest {
title: "偏好摘要".to_string(),
summary: "用户偏好简洁".to_string(),
memory_type: "preference".to_string(),
scope: MemoryScope::User,
source_ref: "manual:cli".to_string(),
project_id: None,
user_id: None,
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"sync-vault",
"--config",
config_path.to_str().unwrap(),
"--dry-run",
])
.assert()
.success()
.stdout(predicate::str::contains("vault sync summary (dry-run)"))
.stdout(predicate::str::contains("would_create: 1"));
let note_path = vault_root
.join("50-Memory-Ledger/Extracted")
.join(format!("{}.md", manual.record_id));
assert!(!note_path.exists(), "dry-run must not write");
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"sync-vault",
"--config",
config_path.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::contains("created: 1"));
let content = fs::read_to_string(¬e_path).unwrap();
assert!(content.contains(&format!("record_id: {}", manual.record_id)));
assert!(content.contains("state: accepted"));
assert!(content.contains("# 偏好摘要"));
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"sync-vault",
"--config",
config_path.to_str().unwrap(),
])
.assert()
.success()
.stdout(predicate::str::contains("unchanged: 1"));
}
fn setup_mcp_smoke_fixture() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
let temp = tempdir().unwrap();
let home = temp.path().to_path_buf();
let config_path = home.join("spool.toml");
fs::write(&config_path, "[vault]\nroot=\"/tmp/spool-smoke\"\n").unwrap();
let binary_path = home.join("fake-spool-mcp");
fs::write(&binary_path, "#!/bin/sh\nexit 0\n").unwrap();
(temp, config_path, binary_path)
}
fn run_spool_with_home(home: &std::path::Path, args: &[&str]) -> assert_cmd::assert::Assert {
Command::cargo_bin("spool")
.unwrap()
.env("HOME", home)
.args(args)
.assert()
}
#[test]
fn mcp_install_should_register_spool_entry_in_claude_config() {
let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
let home = temp.path();
let claude_json = home.join(".claude.json");
run_spool_with_home(
home,
&[
"mcp",
"install",
"--client",
"claude",
"--config",
config_path.to_str().unwrap(),
"--binary-path",
binary_path.to_str().unwrap(),
],
)
.success()
.stdout(predicate::str::contains("status: installed"));
let raw = fs::read_to_string(&claude_json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed["mcpServers"]["spool"]["type"], "stdio");
assert_eq!(
parsed["mcpServers"]["spool"]["command"].as_str().unwrap(),
binary_path.to_string_lossy()
);
assert_eq!(
parsed["mcpServers"]["spool"]["args"][0].as_str().unwrap(),
"--config"
);
}
#[test]
fn mcp_install_dry_run_should_not_create_file() {
let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
let home = temp.path();
let claude_json = home.join(".claude.json");
run_spool_with_home(
home,
&[
"mcp",
"install",
"--client",
"claude",
"--config",
config_path.to_str().unwrap(),
"--binary-path",
binary_path.to_str().unwrap(),
"--dry-run",
],
)
.success()
.stdout(predicate::str::contains("status: dry-run"));
assert!(!claude_json.exists(), "dry-run must not write file");
}
#[test]
fn mcp_install_should_be_idempotent_on_repeat() {
let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
let home = temp.path();
let args = [
"mcp",
"install",
"--client",
"claude",
"--config",
config_path.to_str().unwrap(),
"--binary-path",
binary_path.to_str().unwrap(),
];
run_spool_with_home(home, &args).success();
run_spool_with_home(home, &args)
.success()
.stdout(predicate::str::contains("status: unchanged"));
}
#[test]
fn mcp_install_conflict_without_force_should_exit_nonzero() {
let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
let home = temp.path();
let claude_json = home.join(".claude.json");
let preexisting = serde_json::json!({
"mcpServers": {
"spool": {"type": "stdio", "command": "/old/path", "args": []}
}
});
fs::write(
&claude_json,
serde_json::to_string_pretty(&preexisting).unwrap(),
)
.unwrap();
run_spool_with_home(
home,
&[
"mcp",
"install",
"--client",
"claude",
"--config",
config_path.to_str().unwrap(),
"--binary-path",
binary_path.to_str().unwrap(),
],
)
.failure()
.stdout(predicate::str::contains("status: conflict"));
let raw = fs::read_to_string(&claude_json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed["mcpServers"]["spool"]["command"], "/old/path");
}
#[test]
fn mcp_install_with_force_should_overwrite_and_backup() {
let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
let home = temp.path();
let claude_json = home.join(".claude.json");
let preexisting = serde_json::json!({
"mcpServers": {
"spool": {"type": "stdio", "command": "/old/path", "args": []}
}
});
fs::write(
&claude_json,
serde_json::to_string_pretty(&preexisting).unwrap(),
)
.unwrap();
run_spool_with_home(
home,
&[
"mcp",
"install",
"--client",
"claude",
"--config",
config_path.to_str().unwrap(),
"--binary-path",
binary_path.to_str().unwrap(),
"--force",
],
)
.success()
.stdout(predicate::str::contains("status: installed"));
let raw = fs::read_to_string(&claude_json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(
parsed["mcpServers"]["spool"]["command"].as_str().unwrap(),
binary_path.to_string_lossy()
);
let dir_entries: Vec<_> = fs::read_dir(home).unwrap().flatten().collect();
let has_backup = dir_entries.iter().any(|e| {
e.file_name()
.to_string_lossy()
.starts_with(".claude.json.bak-spool-")
});
assert!(has_backup, "force install must leave a backup");
}
#[test]
fn mcp_uninstall_should_remove_spool_entry_only() {
let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
let home = temp.path();
let claude_json = home.join(".claude.json");
run_spool_with_home(
home,
&[
"mcp",
"install",
"--client",
"claude",
"--config",
config_path.to_str().unwrap(),
"--binary-path",
binary_path.to_str().unwrap(),
],
)
.success();
let mut doc: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&claude_json).unwrap()).unwrap();
doc["mcpServers"]["pencil"] = serde_json::json!({"command": "/keep/me"});
fs::write(&claude_json, serde_json::to_string_pretty(&doc).unwrap()).unwrap();
run_spool_with_home(home, &["mcp", "uninstall", "--client", "claude"])
.success()
.stdout(predicate::str::contains("status: removed"));
let raw = fs::read_to_string(&claude_json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert!(parsed["mcpServers"].get("spool").is_none());
assert_eq!(parsed["mcpServers"]["pencil"]["command"], "/keep/me");
}
#[test]
fn mcp_uninstall_when_nothing_installed_should_succeed() {
let temp = tempdir().unwrap();
let home = temp.path();
run_spool_with_home(home, &["mcp", "uninstall", "--client", "claude"])
.success()
.stdout(predicate::str::contains("status: not-installed"));
}
#[test]
fn mcp_doctor_should_pass_after_install() {
let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
let home = temp.path();
run_spool_with_home(
home,
&[
"mcp",
"install",
"--client",
"claude",
"--config",
config_path.to_str().unwrap(),
"--binary-path",
binary_path.to_str().unwrap(),
],
)
.success();
run_spool_with_home(
home,
&[
"mcp",
"doctor",
"--client",
"claude",
"--config",
config_path.to_str().unwrap(),
"--binary-path",
binary_path.to_str().unwrap(),
],
)
.success()
.stdout(predicate::str::contains("[ ok ] claude_config_exists"))
.stdout(predicate::str::contains(
"[ ok ] mcp_servers_spool_registered",
))
.stdout(predicate::str::contains("[ ok ] spool_mcp_binary"));
}
#[test]
fn mcp_doctor_should_fail_when_binary_missing() {
let (temp, config_path, _binary_path) = setup_mcp_smoke_fixture();
let home = temp.path();
let missing = home.join("definitely-not-there");
run_spool_with_home(
home,
&[
"mcp",
"doctor",
"--client",
"claude",
"--config",
config_path.to_str().unwrap(),
"--binary-path",
missing.to_str().unwrap(),
],
)
.failure()
.stdout(predicate::str::contains("[FAIL] spool_mcp_binary"));
}
#[test]
fn mcp_install_should_emit_json_on_request() {
let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
let home = temp.path();
let assert = run_spool_with_home(
home,
&[
"mcp",
"install",
"--client",
"claude",
"--config",
config_path.to_str().unwrap(),
"--binary-path",
binary_path.to_str().unwrap(),
"--format",
"json",
],
)
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
let parsed: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap();
assert_eq!(parsed["client"], "claude");
assert_eq!(parsed["status"], "installed");
}
#[test]
fn mcp_install_should_emit_hooks_commands_and_skill_payload() {
let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
let home = temp.path();
run_spool_with_home(
home,
&[
"mcp",
"install",
"--client",
"claude",
"--config",
config_path.to_str().unwrap(),
"--binary-path",
binary_path.to_str().unwrap(),
],
)
.success();
let hooks_dir = home.join(".claude").join("hooks");
let expected_hooks = [
"spool-SessionStart.sh",
"spool-UserPromptSubmit.sh",
"spool-PostToolUse.sh",
"spool-Stop.sh",
"spool-PreCompact.sh",
];
for name in expected_hooks {
let p = hooks_dir.join(name);
assert!(p.exists(), "missing hook {}", p.display());
let body = fs::read_to_string(&p).unwrap();
assert!(
body.contains(config_path.to_str().unwrap()),
"{} should reference config path after substitution",
p.display()
);
assert!(
!body.contains("@@spool_BIN@@"),
"{} placeholder leaked",
p.display()
);
}
for name in [
"spool-wakeup.md",
"spool-capture.md",
"spool-review.md",
"spool-doctor.md",
] {
assert!(
home.join(".claude").join("commands").join(name).exists(),
"missing command {}",
name
);
}
assert!(
home.join(".claude")
.join("skills")
.join("spool-runtime")
.join("SKILL.md")
.exists()
);
let settings_raw = fs::read_to_string(home.join(".claude").join("settings.json")).unwrap();
let settings: serde_json::Value = serde_json::from_str(&settings_raw).unwrap();
for event in [
"SessionStart",
"UserPromptSubmit",
"PostToolUse",
"Stop",
"PreCompact",
] {
let entries = settings["hooks"][event].as_array().unwrap();
assert!(
entries.iter().any(|e| e["hooks"][0]["command"]
.as_str()
.is_some_and(|c| c.contains("spool-"))),
"{} entry missing in settings.json",
event
);
}
}
#[test]
fn mcp_install_should_preserve_pre_existing_hook_entries() {
let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
let home = temp.path();
fs::create_dir_all(home.join(".claude")).unwrap();
let preexisting = serde_json::json!({
"hooks": {
"SessionStart": [
{"matcher": "", "hooks": [{"type": "command", "command": "bd prime"}]}
]
}
});
fs::write(
home.join(".claude").join("settings.json"),
serde_json::to_string_pretty(&preexisting).unwrap(),
)
.unwrap();
run_spool_with_home(
home,
&[
"mcp",
"install",
"--client",
"claude",
"--config",
config_path.to_str().unwrap(),
"--binary-path",
binary_path.to_str().unwrap(),
],
)
.success();
let settings: serde_json::Value = serde_json::from_str(
&fs::read_to_string(home.join(".claude").join("settings.json")).unwrap(),
)
.unwrap();
let entries = settings["hooks"]["SessionStart"].as_array().unwrap();
assert_eq!(entries.len(), 2, "bd prime + spool must coexist");
assert_eq!(entries[0]["hooks"][0]["command"], "bd prime");
}
#[test]
fn mcp_uninstall_should_remove_hooks_commands_and_skills() {
let (temp, config_path, binary_path) = setup_mcp_smoke_fixture();
let home = temp.path();
run_spool_with_home(
home,
&[
"mcp",
"install",
"--client",
"claude",
"--config",
config_path.to_str().unwrap(),
"--binary-path",
binary_path.to_str().unwrap(),
],
)
.success();
run_spool_with_home(home, &["mcp", "uninstall", "--client", "claude"]).success();
assert!(
!home
.join(".claude")
.join("hooks")
.join("spool-SessionStart.sh")
.exists()
);
assert!(
!home
.join(".claude")
.join("commands")
.join("spool-wakeup.md")
.exists()
);
assert!(
!home
.join(".claude")
.join("skills")
.join("spool-runtime")
.exists()
);
}
#[test]
fn hook_session_start_should_emit_wakeup_marker() {
let temp = tempdir().unwrap();
let vault = temp.path().join("vault");
fs::create_dir_all(vault.join("10-Projects")).unwrap();
fs::write(
vault.join("10-Projects/proj.md"),
r#"---
memory_type: decision
project_id: spool
retrieval_priority: high
---
# 决策: 用 cargo install
"#,
)
.unwrap();
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let config = format!(
r#"[vault]
root = "{}"
[output]
default_format = "prompt"
max_chars = 4000
max_notes = 4
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects"]
"#,
vault.display(),
cwd.display()
);
let cfg = temp.path().join("spool.toml");
fs::write(&cfg, config).unwrap();
let assert = Command::cargo_bin("spool")
.unwrap()
.args([
"hook",
"session-start",
"--config",
cfg.to_str().unwrap(),
"--cwd",
cwd.to_str().unwrap(),
])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
assert!(stdout.contains("<spool-memory>"));
assert!(stdout.contains("</spool-memory>"));
}
#[test]
fn hook_session_start_should_work_even_with_trellis_present() {
let temp = tempdir().unwrap();
let cwd = temp.path().join("repo");
fs::create_dir_all(cwd.join(".trellis")).unwrap();
fs::write(cwd.join(".trellis").join(".developer"), "name=long").unwrap();
let vault = temp.path().join("vault");
fs::create_dir_all(vault.join("10-Projects")).unwrap();
fs::write(
vault.join("10-Projects/constraint.md"),
"---\nmemory_type: constraint\n---\n# Never use eval\n\nAvoid eval in production.\n",
)
.unwrap();
let cfg = temp.path().join("spool.toml");
fs::write(
&cfg,
format!(
"[vault]\nroot = \"{}\"\n\n[output]\nmax_chars = 8000\nmax_notes = 4\n\n[[projects]]\nid = \"test\"\nname = \"test\"\nrepo_paths = [\"{}\"]\nnote_roots = [\"10-Projects\"]\n",
vault.display(),
cwd.display()
),
)
.unwrap();
let assert = Command::cargo_bin("spool")
.unwrap()
.args([
"hook",
"session-start",
"--config",
cfg.to_str().unwrap(),
"--cwd",
cwd.to_str().unwrap(),
])
.assert()
.success();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
assert!(stdout.contains("<spool-memory>"));
assert!(stdout.contains("Constraints"));
}
#[test]
fn hook_post_tool_use_should_append_distill_queue_line() {
let temp = tempdir().unwrap();
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let cfg = temp.path().join("spool.toml");
fs::write(&cfg, "x=1").unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"hook",
"post-tool-use",
"--config",
cfg.to_str().unwrap(),
"--cwd",
cwd.to_str().unwrap(),
"--tool-name",
"Bash",
"--payload",
"ls -la",
])
.assert()
.success();
let queue = cwd.join(".spool").join("distill-pending.queue");
assert!(queue.exists());
let body = fs::read_to_string(&queue).unwrap();
assert!(body.lines().count() >= 1);
assert!(body.contains("\"tool_name\":\"Bash\""));
}
#[test]
fn hook_user_prompt_should_write_last_prompt_marker() {
let temp = tempdir().unwrap();
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let cfg = temp.path().join("spool.toml");
fs::write(&cfg, "x=1").unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"hook",
"user-prompt",
"--config",
cfg.to_str().unwrap(),
"--cwd",
cwd.to_str().unwrap(),
])
.assert()
.success();
assert!(cwd.join(".spool").join("last-prompt.unix").exists());
}
#[test]
fn hook_stop_and_pre_compact_should_drop_markers() {
let temp = tempdir().unwrap();
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let cfg = temp.path().join("spool.toml");
fs::write(&cfg, "x=1").unwrap();
for hook in ["stop", "pre-compact"] {
Command::cargo_bin("spool")
.unwrap()
.args([
"hook",
hook,
"--config",
cfg.to_str().unwrap(),
"--cwd",
cwd.to_str().unwrap(),
])
.assert()
.success();
}
assert!(cwd.join(".spool").join("last-stop.unix").exists());
assert!(cwd.join(".spool").join("last-pre-compact.unix").exists());
}
#[test]
fn hook_should_exit_zero_on_internal_failure() {
let temp = tempdir().unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"hook",
"user-prompt",
"--config",
temp.path()
.join("definitely-missing.toml")
.to_str()
.unwrap(),
"--cwd",
temp.path().to_str().unwrap(),
])
.assert()
.success();
}
#[test]
fn hook_stop_should_persist_self_tag_as_accepted_record() {
let temp = tempdir().unwrap();
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let cfg = temp.path().join("spool.toml");
fs::write(&cfg, "[vault]\nroot = \"/tmp/spool-r3b-smoke\"\n").unwrap();
let transcript_path = temp.path().join("session.jsonl");
let user_line = serde_json::json!({
"type": "user",
"message": {
"role": "user",
"content": "记一下:cargo install 是默认安装路径"
}
})
.to_string();
fs::write(&transcript_path, format!("{user_line}\n")).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"hook",
"stop",
"--config",
cfg.to_str().unwrap(),
"--cwd",
cwd.to_str().unwrap(),
"--transcript-path",
transcript_path.to_str().unwrap(),
])
.assert()
.success();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"list",
"--config",
cfg.to_str().unwrap(),
"--view",
"wakeup-ready",
])
.assert()
.success()
.stdout(predicate::str::contains("cargo install 是默认安装路径"))
.stdout(predicate::str::contains("(preference)"));
}
#[test]
fn hook_stop_should_resolve_transcript_via_hook_input_json() {
let temp = tempdir().unwrap();
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let cfg = temp.path().join("spool.toml");
fs::write(&cfg, "[vault]\nroot = \"/tmp/spool-r3b-smoke2\"\n").unwrap();
let transcript_path = temp.path().join("session.jsonl");
let line = serde_json::json!({
"type": "user",
"message": {"role": "user", "content": "我决定用 cargo install 落到 ~/.cargo/bin"}
})
.to_string();
fs::write(&transcript_path, format!("{line}\n")).unwrap();
let hook_input = serde_json::json!({
"session_id": "abc-123",
"transcript_path": transcript_path.to_string_lossy(),
"hook_event_name": "Stop"
})
.to_string();
Command::cargo_bin("spool")
.unwrap()
.args([
"hook",
"stop",
"--config",
cfg.to_str().unwrap(),
"--cwd",
cwd.to_str().unwrap(),
"--hook-input",
&hook_input,
])
.assert()
.success();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"list",
"--config",
cfg.to_str().unwrap(),
"--view",
"wakeup-ready",
])
.assert()
.success()
.stdout(predicate::str::contains("(decision)"))
.stdout(predicate::str::contains("cargo install"));
}
#[test]
fn hook_stop_should_drop_self_tag_containing_secret() {
let temp = tempdir().unwrap();
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let cfg = temp.path().join("spool.toml");
fs::write(&cfg, "[vault]\nroot = \"/tmp/spool-r3b-secret\"\n").unwrap();
let transcript_path = temp.path().join("session.jsonl");
let line = serde_json::json!({
"type": "user",
"message": {
"role": "user",
"content": "记一下: prod token sk-abcDEF1234567890ABCDEFGHIJ stays here"
}
})
.to_string();
fs::write(&transcript_path, format!("{line}\n")).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"hook",
"stop",
"--config",
cfg.to_str().unwrap(),
"--cwd",
cwd.to_str().unwrap(),
"--transcript-path",
transcript_path.to_str().unwrap(),
])
.assert()
.success();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"list",
"--config",
cfg.to_str().unwrap(),
"--view",
"wakeup-ready",
])
.assert()
.success()
.stdout(predicate::str::contains("Wakeup-ready"))
.stdout(predicate::str::contains("(preference)").not())
.stdout(predicate::str::contains("(decision)").not());
}
#[test]
fn session_start_hook_should_complete_within_500ms() {
let temp = tempdir().unwrap();
let vault_dir = temp.path().join("vault");
let repo_dir = temp.path().join("repo");
fs::create_dir_all(vault_dir.join("10-Projects")).unwrap();
fs::create_dir_all(vault_dir.join("20-Areas")).unwrap();
fs::create_dir_all(&repo_dir).unwrap();
for i in 0..200 {
let root = if i % 2 == 0 {
"10-Projects"
} else {
"20-Areas"
};
let filename = format!("{root}/note-{i:03}.md");
let body = if i % 10 == 0 {
format!(
"---\nmemory_type: decision\nproject_id: spool\nretrieval_priority: high\n---\n# Decision {i}\n\nArchitectural decision about routing {i}.\n"
)
} else if i % 7 == 0 {
format!(
"---\nmemory_type: constraint\nsource_of_truth: true\n---\n# Constraint {i}\n\nNever do X in context {i}.\n"
)
} else {
format!("# General Note {i}\n\nBackground information for note {i}.\n")
};
fs::write(vault_dir.join(filename), body).unwrap();
}
let config = format!(
r#"[vault]
root = "{}"
[output]
default_format = "prompt"
max_chars = 12000
max_notes = 8
[[projects]]
id = "spool"
name = "spool"
repo_paths = ["{}"]
note_roots = ["10-Projects", "20-Areas"]
[[projects.modules]]
id = "routing"
path_prefixes = ["src/engine"]
keywords = ["routing", "decision"]
"#,
vault_dir.display(),
repo_dir.display()
);
let config_path = temp.path().join("spool.toml");
fs::write(&config_path, config).unwrap();
let start = std::time::Instant::now();
let assert = Command::cargo_bin("spool")
.unwrap()
.args([
"hook",
"session-start",
"--config",
config_path.to_str().unwrap(),
"--cwd",
repo_dir.to_str().unwrap(),
])
.assert()
.success();
let elapsed = start.elapsed();
let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
assert!(
stdout.contains("<spool-memory>"),
"stdout must contain spool-memory marker, got:\n{stdout}"
);
assert!(
elapsed.as_millis() < 500,
"session-start hook took {}ms (budget: 500ms)",
elapsed.as_millis()
);
}
#[test]
fn hook_stop_should_propose_incident_candidate_for_repeated_frustration() {
let temp = tempdir().unwrap();
let cwd = temp.path().join("repo");
fs::create_dir_all(&cwd).unwrap();
let cfg = temp.path().join("spool.toml");
fs::write(&cfg, "[vault]\nroot = \"/tmp/spool-r3c-extraction\"\n").unwrap();
let transcript_path = temp.path().join("session.jsonl");
let entries = [
serde_json::json!({"type": "user", "message": {"role": "user", "content": "试一下 cargo test"}}),
serde_json::json!({"type": "user", "message": {"role": "user", "content": "还是错了"}}),
serde_json::json!({"type": "user", "message": {"role": "user", "content": "改了之后又失败了"}}),
];
let mut body = String::new();
for e in entries {
body.push_str(&e.to_string());
body.push('\n');
}
fs::write(&transcript_path, body).unwrap();
Command::cargo_bin("spool")
.unwrap()
.args([
"hook",
"stop",
"--config",
cfg.to_str().unwrap(),
"--cwd",
cwd.to_str().unwrap(),
"--transcript-path",
transcript_path.to_str().unwrap(),
])
.assert()
.success();
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"list",
"--config",
cfg.to_str().unwrap(),
"--view",
"pending-review",
])
.assert()
.success()
.stdout(predicate::str::contains("(incident)"))
.stdout(predicate::str::contains("又失败了"));
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"list",
"--config",
cfg.to_str().unwrap(),
"--view",
"wakeup-ready",
])
.assert()
.success()
.stdout(predicate::str::contains("(incident)").not());
}