use spool::distill::pipeline::{DistillRequest, run as distill_run};
use spool::domain::{
MatchedProject, MemoryLifecycleState, MemoryRecord, MemoryScope, OutputFormat, RouteInput,
TargetTool,
};
use spool::engine::scorer::score_lifecycle_candidate;
use spool::engine::selector::select_lifecycle_candidates;
use spool::lifecycle_service::LifecycleService;
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use tempfile::tempdir;
#[test]
fn distill_creates_structured_record_from_self_tag() {
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 cwd = temp.path().join("my-project");
fs::create_dir_all(&cwd).unwrap();
let transcript_path = temp.path().join("session.jsonl");
let entries = [
serde_json::json!({
"type": "user",
"message": {"role": "user", "content": "记一下:以后数据库都用 SQLite,部署简单"}
}),
serde_json::json!({
"type": "user",
"message": {"role": "user", "content": "好的,继续实现 src/db.rs 的查询逻辑"}
}),
];
let mut body = String::new();
for e in &entries {
body.push_str(&e.to_string());
body.push('\n');
}
fs::write(&transcript_path, body).unwrap();
let request = DistillRequest::new(config_path.clone(), cwd.clone(), Some(transcript_path));
let report = distill_run(request).unwrap();
assert!(
!report.signals_persisted.is_empty(),
"distill should persist at least one self-tag signal, report: {:?}",
report
);
let service = LifecycleService::new();
let snapshot = service.load_workbench(&config_path).unwrap();
assert!(
!snapshot.wakeup_ready.is_empty(),
"self-tag should produce an accepted (wakeup-ready) record"
);
let record = &snapshot.wakeup_ready[0].record;
let has_sqlite = record.title.contains("SQLite")
|| record.title.contains("sqlite")
|| record.summary.contains("SQLite")
|| record.summary.contains("sqlite");
assert!(
has_sqlite,
"record should mention SQLite. title={}, summary={}",
record.title, record.summary
);
assert_eq!(record.state, MemoryLifecycleState::Accepted);
assert!(
!record.triggers.is_empty(),
"triggers should be inferred from summary: {:?}",
record.triggers
);
assert!(
record.applies_to.iter().any(|a| a == "my-project"),
"applies_to should contain project name from cwd: {:?}",
record.applies_to
);
assert!(
!record.entities.is_empty(),
"entities should be populated: {:?}",
record.entities
);
}
#[test]
fn structured_fields_improve_retrieval_ranking() {
let input = RouteInput {
task: "优化数据库查询性能".to_string(),
cwd: PathBuf::from("/tmp/repo"),
files: vec!["src/db.rs".to_string()],
target: TargetTool::Claude,
format: OutputFormat::Prompt,
};
let project = MatchedProject {
id: "myapp".to_string(),
name: "myapp".to_string(),
reason: "test".to_string(),
};
let record_a = MemoryRecord {
title: "数据库选型决策".to_string(),
summary: "以后数据库都用 SQLite,部署简单".to_string(),
memory_type: "decision".to_string(),
scope: MemoryScope::Project,
state: MemoryLifecycleState::Accepted,
origin: spool::domain::MemoryOrigin {
source_kind: spool::domain::MemorySourceKind::Manual,
source_ref: "test".to_string(),
},
project_id: Some("myapp".to_string()),
user_id: None,
sensitivity: None,
entities: vec!["SQLite".to_string(), "rusqlite".to_string()],
tags: vec!["database".to_string(), "storage".to_string()],
triggers: vec!["数据库".to_string(), "sqlite".to_string()],
related_files: vec!["src/db.rs".to_string()],
related_records: Vec::new(),
supersedes: None,
applies_to: vec!["myapp".to_string()],
valid_until: None,
};
let record_b = MemoryRecord {
title: "UI 框架选型".to_string(),
summary: "前端用 React + shadcn 组件库".to_string(),
memory_type: "decision".to_string(),
scope: MemoryScope::Project,
state: MemoryLifecycleState::Accepted,
origin: spool::domain::MemoryOrigin {
source_kind: spool::domain::MemorySourceKind::Manual,
source_ref: "test".to_string(),
},
project_id: Some("myapp".to_string()),
user_id: None,
sensitivity: None,
entities: vec!["React".to_string(), "shadcn".to_string()],
tags: vec!["frontend".to_string(), "ui".to_string()],
triggers: vec!["UI框架".to_string(), "react".to_string()],
related_files: vec!["src/components/App.tsx".to_string()],
related_records: Vec::new(),
supersedes: None,
applies_to: vec!["myapp".to_string()],
valid_until: None,
};
let candidate_a =
score_lifecycle_candidate(Some(&project), "rec-a", &record_a, &input, None, None);
let candidate_b =
score_lifecycle_candidate(Some(&project), "rec-b", &record_b, &input, None, None);
let score_a = candidate_a.as_ref().map(|c| c.score).unwrap_or(0);
let score_b = candidate_b.as_ref().map(|c| c.score).unwrap_or(0);
assert!(
score_a > score_b,
"Record A (database) should rank above Record B (frontend). A={}, B={}",
score_a,
score_b
);
let reasons_a = &candidate_a.unwrap().reasons;
let has_entity_match = reasons_a.iter().any(|r| r.contains("entity"));
let has_trigger_match = reasons_a.iter().any(|r| r.contains("trigger"));
let has_file_match = reasons_a.iter().any(|r| r.contains("related_file"));
assert!(
has_entity_match || has_trigger_match || has_file_match,
"structured fields should contribute to scoring. reasons: {:?}",
reasons_a
);
}
#[test]
fn relation_expansion_finds_linked_records() {
let input = RouteInput {
task: "优化数据库查询".to_string(),
cwd: PathBuf::from("/tmp/repo"),
files: vec!["src/db.rs".to_string()],
target: TargetTool::Claude,
format: OutputFormat::Prompt,
};
let project = MatchedProject {
id: "myapp".to_string(),
name: "myapp".to_string(),
reason: "test".to_string(),
};
let record_a = MemoryRecord {
title: "SQLite 查询优化".to_string(),
summary: "数据库查询需要加索引".to_string(),
memory_type: "decision".to_string(),
scope: MemoryScope::Project,
state: MemoryLifecycleState::Accepted,
origin: spool::domain::MemoryOrigin {
source_kind: spool::domain::MemorySourceKind::Manual,
source_ref: "test".to_string(),
},
project_id: Some("myapp".to_string()),
user_id: None,
sensitivity: None,
entities: vec!["SQLite".to_string()],
tags: vec!["database".to_string()],
triggers: vec!["数据库".to_string()],
related_files: vec!["src/db.rs".to_string()],
related_records: vec!["rec-b".to_string()],
supersedes: None,
applies_to: vec!["myapp".to_string()],
valid_until: None,
};
let record_b = MemoryRecord {
title: "部署流程约束".to_string(),
summary: "单文件部署,不依赖外部服务".to_string(),
memory_type: "constraint".to_string(),
scope: MemoryScope::Project,
state: MemoryLifecycleState::Accepted,
origin: spool::domain::MemoryOrigin {
source_kind: spool::domain::MemorySourceKind::Manual,
source_ref: "test".to_string(),
},
project_id: Some("other-project".to_string()),
user_id: None,
sensitivity: None,
entities: vec!["Docker".to_string()],
tags: vec!["deployment".to_string()],
triggers: vec!["部署".to_string()],
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
};
let records = vec![
("rec-a".to_string(), record_a),
("rec-b".to_string(), record_b),
];
let candidates =
select_lifecycle_candidates(Some(&project), &records, &input, 5, &HashSet::new(), None);
let has_a = candidates.iter().any(|c| c.record_id == "rec-a");
assert!(has_a, "Record A should be in results (direct match)");
let has_b = candidates.iter().any(|c| c.record_id == "rec-b");
assert!(
has_b,
"Record B should appear via relation expansion. candidates: {:?}",
candidates.iter().map(|c| &c.record_id).collect::<Vec<_>>()
);
let b_candidate = candidates.iter().find(|c| c.record_id == "rec-b").unwrap();
assert!(
b_candidate
.reasons
.iter()
.any(|r| r.contains("relation-expanded")),
"Record B should be marked as relation-expanded. reasons: {:?}",
b_candidate.reasons
);
}
#[test]
fn full_lifecycle_distill_to_retrieval() {
use assert_cmd::Command;
use predicates::prelude::*;
let temp = tempdir().unwrap();
let base = temp.path().canonicalize().unwrap();
let vault_dir = base.join("vault");
let repo_dir = base.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 Project\n\nProject context.\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 = base.join("spool.toml");
fs::write(&config_path, &config).unwrap();
let cwd = repo_dir.clone();
let transcript_path = base.join("session.jsonl");
let transcript_line = serde_json::json!({
"type": "user",
"message": {"role": "user", "content": "记一下:routing 模块必须先匹配 project"}
});
fs::write(&transcript_path, format!("{}\n", transcript_line)).unwrap();
let request = DistillRequest::new(config_path.clone(), cwd.clone(), Some(transcript_path));
let report = distill_run(request).unwrap();
assert!(
!report.signals_persisted.is_empty(),
"distill should persist the self-tag"
);
let service = LifecycleService::new();
let snapshot = service.load_workbench(&config_path).unwrap();
assert!(
!snapshot.wakeup_ready.is_empty(),
"ledger should have a wakeup-ready record after distill"
);
let record = &snapshot.wakeup_ready[0].record;
assert!(
record.summary.contains("routing") || record.summary.contains("project"),
"record summary should contain routing or project: {}",
record.summary
);
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"list",
"--config",
config_path.to_str().unwrap(),
"--view",
"wakeup-ready",
])
.assert()
.success()
.stdout(predicate::str::contains("routing"));
Command::cargo_bin("spool")
.unwrap()
.args([
"memory",
"record-manual",
"--config",
config_path.to_str().unwrap(),
"--title",
"routing 约束",
"--summary",
"routing 模块必须先匹配 project 再扫描",
"--memory-type",
"constraint",
"--scope",
"user",
"--source-ref",
"e2e-test",
])
.assert()
.success();
Command::cargo_bin("spool")
.unwrap()
.args([
"get",
"--config",
config_path.to_str().unwrap(),
"--task",
"实现 routing 模块",
"--cwd",
cwd.to_str().unwrap(),
"--format",
"prompt",
])
.assert()
.success()
.stdout(predicate::str::contains("记忆(accepted / canonical)"))
.stdout(predicate::str::contains("routing"));
}