use std::fs;
use std::io::{BufRead as _, Read as _, Write as _};
use std::process::{Command, Stdio};
#[cfg(unix)]
use std::time::Duration;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
fn aise() -> Command {
static STATE: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
let state = STATE.get_or_init(|| tempfile::tempdir().unwrap()).path();
let home = state.join("home");
let config = state.join("config/config.toml");
fs::create_dir_all(&home).unwrap();
fs::create_dir_all(config.parent().unwrap()).unwrap();
if !config.exists() {
fs::write(&config, "").unwrap();
}
let mut command = Command::new(env!("CARGO_BIN_EXE_aise"));
command
.env("AI_SESSION_SEARCH_CONFIG", &config)
.env("HOME", &home)
.env("USERPROFILE", &home)
.env("XDG_CONFIG_HOME", home.join(".config"))
.env("APPDATA", home.join("AppData/Roaming"))
.env("LOCALAPPDATA", home.join("AppData/Local"));
command
}
fn isolated_config_paths_args(root: &std::path::Path) -> Vec<String> {
let config = root.join("config.toml");
fs::write(&config, "").unwrap();
vec![
"--config".into(),
config.display().to_string(),
"--database".into(),
root.join("index.db").display().to_string(),
"--cache-dir".into(),
root.join("cache").display().to_string(),
"config".into(),
"paths".into(),
]
}
fn write_disabled_provider_config(root: &std::path::Path) -> std::path::PathBuf {
let config = root.join("config.toml");
fs::write(
&config,
format!(
r#"[index]
db_path = {:?}
cache_dir = {:?}
[providers.claude]
enabled = false
paths = []
[providers.claude-desktop]
enabled = false
paths = []
[providers.codex]
enabled = false
paths = []
[providers.cursor]
enabled = false
paths = []
[providers.antigravity]
enabled = false
paths = []
[providers.pi]
enabled = false
paths = []
[providers.prime-agent]
enabled = false
paths = []
[providers.aistudio]
enabled = false
paths = []
[providers.gemini-cli]
enabled = false
paths = []
"#,
root.join("index.db").display().to_string(),
root.join("cache").display().to_string(),
),
)
.unwrap();
config
}
fn isolated_integration_install(
root: &std::path::Path,
config: &std::path::Path,
dry_run: bool,
) -> std::process::Output {
let home = root.join("home");
fs::create_dir_all(&home).unwrap();
let codex_config = home.join(".codex").join("config.toml");
let executable = env!("CARGO_BIN_EXE_aise");
let mut command = aise();
command
.env("HOME", &home)
.env("USERPROFILE", &home)
.env("XDG_CONFIG_HOME", home.join(".config"))
.env("APPDATA", home.join("AppData/Roaming"))
.env("LOCALAPPDATA", home.join("AppData/Local"))
.args([
"--config",
config.to_str().unwrap(),
"integrations",
"install",
"--client",
"codex",
"--exclude-client",
"codex",
"--codex-config",
codex_config.to_str().unwrap(),
"--binary",
executable,
"--no-aliases",
"--no-instructions",
"--no-skill",
]);
if dry_run {
command.arg("--dry-run");
}
command.output().unwrap()
}
#[test]
fn message_search_describe_reads_configuration_without_creating_an_index() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
let home = root.path().join("home");
fs::create_dir_all(&home).unwrap();
let output = aise()
.env("HOME", &home)
.env("XDG_CONFIG_HOME", home.join(".config"))
.args([
"--config",
config.to_str().unwrap(),
"messages",
"search",
"--describe",
])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let specification: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(
specification["configured_default"]["extent"],
serde_json::json!({"kind": "all_results", "offset": 0})
);
assert_eq!(
specification["registry"]["parameters"]
.as_array()
.unwrap()
.len(),
28
);
assert!(specification["registry"]["rules"]
.as_array()
.unwrap()
.iter()
.all(|descriptor| descriptor["rule"].is_string() && descriptor["message"].is_string()));
let mcp_output = aise()
.env("HOME", &home)
.env("XDG_CONFIG_HOME", home.join(".config"))
.args([
"--config",
config.to_str().unwrap(),
"messages",
"search",
"--describe",
"--describe-surface",
"mcp",
])
.output()
.unwrap();
assert!(
mcp_output.status.success(),
"{}",
String::from_utf8_lossy(&mcp_output.stderr)
);
let mcp_specification: serde_json::Value = serde_json::from_slice(&mcp_output.stdout).unwrap();
assert_eq!(
mcp_specification["configured_default"]["extent"],
serde_json::json!({"kind": "page", "limit": 15, "offset": 0})
);
assert_eq!(
mcp_specification["configured_default"]["include"],
serde_json::json!(["normalized_session_metadata"])
);
assert!(
!root.path().join("index.db").exists(),
"configuration introspection must not create a database"
);
assert!(
!root.path().join("cache").exists(),
"configuration introspection must not create or refresh an index cache"
);
}
#[test]
fn integration_install_starts_initial_indexing_but_dry_run_does_not() {
let applied_root = tempfile::tempdir().unwrap();
let applied_config = write_disabled_provider_config(applied_root.path());
let output = isolated_integration_install(applied_root.path(), &applied_config, false);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("started session index preparation in the background"),
"successful integration installation must explain the asynchronous readiness lifecycle: {stdout}"
);
assert!(
stdout.contains("aise doctor"),
"the kickoff message must name the durable status command: {stdout}"
);
let database = applied_root.path().join("index.db");
for _ in 0..200 {
if database.is_file() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
assert!(
database.is_file(),
"the detached canonical refresh must create the configured database after installation"
);
let dry_run_root = tempfile::tempdir().unwrap();
let dry_run_config = write_disabled_provider_config(dry_run_root.path());
let output = isolated_integration_install(dry_run_root.path(), &dry_run_config, true);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
!dry_run_root.path().join("index.db").exists(),
"a dry-run must not start background indexing"
);
assert!(
!String::from_utf8(output.stdout)
.unwrap()
.contains("started session index preparation"),
"a dry-run must not claim that indexing started"
);
}
#[test]
fn integration_install_refreshes_a_recent_index_from_an_older_parser_contract() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
let database = root.path().join("index.db");
let db = ai_session_search::db::Db::open(&database).unwrap();
db.mark_schema_current().unwrap();
db.mark_auto_reindex_complete().unwrap();
drop(db);
let connection = rusqlite::Connection::open(&database).unwrap();
connection
.execute(
"delete from index_metadata
where key like 'auto_reindex_parser_contract_%'",
[],
)
.unwrap();
drop(connection);
let output = isolated_integration_install(root.path(), &config, false);
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let mut recorded_words = 0;
for _ in 0..200 {
recorded_words = rusqlite::Connection::open(&database)
.unwrap()
.query_row(
"select count(*) from index_metadata
where key like 'auto_reindex_parser_contract_%'",
[],
|row| row.get::<_, i64>(0),
)
.unwrap();
if recorded_words == 4 {
break;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
assert_eq!(
recorded_words, 4,
"the detached installer refresh must record the current parser contract despite a recent \
completion timestamp"
);
}
#[test]
fn integration_install_with_no_selected_components_does_not_start_indexing() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
let home = root.path().join("home");
fs::create_dir_all(&home).unwrap();
let output = aise()
.env("HOME", &home)
.env("XDG_CONFIG_HOME", home.join(".config"))
.env("PATH", "")
.args([
"--config",
config.to_str().unwrap(),
"integrations",
"install",
"--client",
"codex",
"--exclude-client",
"codex",
"--no-aliases",
"--no-instructions",
"--no-skill",
])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
String::from_utf8(output.stdout)
.unwrap()
.contains("No supported MCP client config was detected"),
"the command must explain why no integration work occurred"
);
assert!(
!root.path().join("index.db").exists(),
"selecting no integration components must not start background indexing"
);
}
#[test]
fn integration_install_preserves_success_when_initial_indexing_is_postponed() {
let root = tempfile::tempdir().unwrap();
let config = root.path().join("invalid-config.toml");
fs::write(&config, "this is not valid TOML = [").unwrap();
let output = isolated_integration_install(root.path(), &config, false);
assert!(
output.status.success(),
"a post-install indexing failure must not roll back valid integration files: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(
root.path().join("home/.codex/config.toml").is_file(),
"the successfully installed integration must remain in place"
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains(
"integration installation succeeded, but session index preparation could not start"
),
"the warning must distinguish installed integrations from postponed indexing: {stderr}"
);
assert!(
stderr.contains("Fix the configuration") && stderr.contains("aise reindex"),
"the warning must give the smallest recovery action: {stderr}"
);
}
#[test]
fn doctor_json_with_unindexed_explanations_is_one_structured_document() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
ai_session_search::db::Db::open(&root.path().join("index.db")).unwrap();
let output = aise()
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"doctor",
"--format",
"json",
"--explain-unindexed",
])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let document: serde_json::Value =
serde_json::from_slice(&output.stdout).unwrap_or_else(|error| {
panic!(
"doctor JSON must not be followed by human text: {error}: {}",
String::from_utf8_lossy(&output.stdout)
)
});
assert_eq!(
document["unindexed_file_explanations"],
serde_json::json!([])
);
}
#[test]
fn the_registration_environment_sets_the_result_ceiling_of_the_server_it_launches() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
let output = aise()
.env("AI_SESSION_SEARCH_MAX_TOOL_RESULT_CHARS", "12345")
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"config",
"show",
"--format",
"json",
])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let shown: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(
shown["mcp"]["max_tool_result_chars"],
serde_json::json!(12_345),
"AI_SESSION_SEARCH_MAX_TOOL_RESULT_CHARS is advertised in the over-ceiling error, in \
config.example.toml, and in the config.rs doc comment, so a registration that sets it \
must get it"
);
}
#[test]
fn an_unusable_registration_ceiling_names_the_variable_and_its_accepted_range() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
for unusable in ["0", "-1", "not-a-number"] {
let output = aise()
.env("AI_SESSION_SEARCH_MAX_TOOL_RESULT_CHARS", unusable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"config",
"show",
"--format",
"json",
])
.output()
.unwrap();
assert!(
!output.status.success(),
"{unusable:?} was accepted as a result ceiling"
);
let complaint = String::from_utf8_lossy(&output.stderr);
assert!(
complaint.contains("AI_SESSION_SEARCH_MAX_TOOL_RESULT_CHARS"),
"the error must name the variable the deployment set: {complaint}"
);
assert!(
complaint.contains('1'),
"the error must state the accepted range, not only that the value was rejected: \
{complaint}"
);
}
}
#[test]
fn config_origins_explains_both_release_critical_mcp_bounds() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
let output = aise()
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"config",
"origins",
])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let origins: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
for (value, expected) in [
("search_messages_limit", "typed default"),
("max_tool_result_chars", "typed default"),
] {
assert_eq!(
origins[value].as_str(),
Some(expected),
"config origins must say where {value} came from, not only what it is: {origins}"
);
}
}
#[test]
fn inferred_skill_execution_uses_the_indexed_read_lifecycle_and_structured_report() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
ai_session_search::db::Db::open(&root.path().join("index.db")).unwrap();
let output = aise()
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"skills",
"corrections",
"--limit",
"1",
"--format",
"json",
])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let report: serde_json::Value =
serde_json::from_slice(&output.stdout).unwrap_or_else(|error| {
panic!(
"skill execution must emit one structured report: {error}: {}",
String::from_utf8_lossy(&output.stdout)
)
});
assert_eq!(report["requested_selector"]["name"], "corrections");
assert_eq!(report["resolved_skill"]["name"], "corrections");
assert_eq!(
report["resolved_skill"]["selected_location"]["kind"],
"embedded"
);
assert_eq!(report["output"]["capability"], "message-classification");
assert_eq!(
report["output"]["result"]["report"]["matches"],
serde_json::json!([])
);
assert_eq!(report["output"]["result"]["receipt"]["name"], "corrections");
}
#[test]
fn skill_run_json_can_bound_classified_message_delivery() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
let executable = env!("CARGO_BIN_EXE_aise");
Command::new(executable)
.args(["--config", config.to_str().unwrap(), "reindex"])
.output()
.unwrap();
let conn = rusqlite::Connection::open(root.path().join("index.db")).unwrap();
conn.execute_batch(
"insert into sessions (
id, provider, provider_session_id, preview_text, source_path,
parse_version, discovery_source
) values ('claude:bounded-skill', 'claude', 'bounded-skill', '', '/bounded-skill.jsonl', 'test', 'fixture');
insert into messages (session_id, provider, seq, role, kind, authorship, record_relation, content)
values ('claude:bounded-skill', 'claude', 0, 'user', 'conversation', 'human', 'original',
'prefix prefix prefix prefix prefix prefix prefix prefix prefix prefix you forgot this step suffix suffix suffix suffix suffix suffix suffix suffix suffix');",
)
.unwrap();
drop(conn);
let output = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"skills",
"corrections",
"--limit",
"1",
"--field-view-chars",
"40",
"--match-view-chars",
"minimal",
"--format",
"json",
])
.output()
.unwrap();
assert!(
output.status.success(),
"presentation controls must be accepted: {}",
String::from_utf8_lossy(&output.stderr)
);
let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
let matched = &report["output"]["result"]["report"]["matches"][0];
assert_eq!(matched["classification"]["category"], "skip_step");
assert_eq!(matched["classification"]["matched_text"], "you forgot");
assert!(
matched["presentation"]["field_view"]["text"]
.as_str()
.unwrap()
.chars()
.count()
<= 40
);
assert_eq!(matched["presentation"]["match_view"]["text"], "you forgot");
assert!(
matched.get("content").is_none(),
"bounded delivery must not retain the full classified message"
);
}
#[test]
fn explicit_skill_path_runs_its_adjacent_typed_capability() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
ai_session_search::db::Db::open(&root.path().join("index.db")).unwrap();
let skill = root.path().join("my-review");
fs::create_dir_all(&skill).unwrap();
fs::write(
skill.join("SKILL.md"),
"---\nname: my-review\ndescription: test message classification\nmetadata:\n version: \
2.1.0\n---\n\ninstructions\n",
)
.unwrap();
fs::write(
skill.join("aise-capability.toml"),
"schema_version = 1\nkind = \"message-classification\"\n\n\
[[categories]]\nname = \"clobber\"\npatterns = ['''\\byou overwrote\\b''']\n",
)
.unwrap();
let output = aise()
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"skills",
skill.to_str().unwrap(),
"--limit",
"1",
"--format",
"json",
])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(
report["requested_selector"]["path"],
skill.to_string_lossy().as_ref()
);
assert_eq!(report["resolved_skill"]["name"], "my-review");
assert_eq!(report["output"]["capability"], "message-classification");
assert_eq!(
report["output"]["result"]["report"]["matches"],
serde_json::json!([])
);
assert_eq!(report["output"]["result"]["receipt"]["name"], "my-review");
assert_eq!(report["output"]["result"]["receipt"]["version"], "2.1.0");
assert_eq!(
report["output"]["result"]["receipt"]["sha256"]
.as_str()
.map(str::len),
Some(64)
);
}
#[cfg(unix)]
#[test]
fn config_paths_and_package_status_keep_separate_concepts() {
let root = tempfile::tempdir().unwrap();
let first = root.path().join("first bin");
let second = root.path().join("second bin");
fs::create_dir_all(&first).unwrap();
fs::create_dir_all(&second).unwrap();
for directory in [&first, &second] {
let candidate = directory.join("aise");
fs::write(&candidate, "#!/bin/sh\nexit 0\n").unwrap();
fs::set_permissions(candidate, fs::Permissions::from_mode(0o755)).unwrap();
}
let path = std::env::join_paths([&first, &second]).unwrap();
let config_output = aise()
.args(isolated_config_paths_args(root.path()))
.env("PATH", &path)
.output()
.unwrap();
assert!(
config_output.status.success(),
"{}",
String::from_utf8_lossy(&config_output.stderr)
);
let config_stdout = String::from_utf8(config_output.stdout).unwrap();
assert!(
config_stdout.contains(&format!(
"Config: {}",
root.path().join("config.toml").display()
)),
"{config_stdout}"
);
assert!(!config_stdout.contains("Executable:"), "{config_stdout}");
assert!(!config_stdout.contains("PATH aise"), "{config_stdout}");
assert!(
config_stdout.contains("AI Studio roots:"),
"{config_stdout}"
);
assert!(
config_stdout.contains("Gemini CLI roots:"),
"{config_stdout}"
);
let package_output = aise()
.args(["package", "status"])
.env("PATH", &path)
.output()
.unwrap();
assert!(
package_output.status.success(),
"{}",
String::from_utf8_lossy(&package_output.stderr)
);
let package_stdout = String::from_utf8(package_output.stdout).unwrap();
assert!(
package_stdout.contains(&format!(
"Runtime process executable: {}",
env!("CARGO_BIN_EXE_aise")
)),
"{package_stdout}"
);
assert!(
package_stdout.contains(&format!(
"First aise on PATH: {}",
first.join("aise").display()
)),
"{package_stdout}"
);
let candidates = format!(
"All aise on PATH: {}, {}",
first.join("aise").display(),
second.join("aise").display()
);
assert!(package_stdout.contains(&candidates), "{package_stdout}");
assert!(
package_stdout
.contains("Warning: multiple aise executables are on PATH; the first candidate wins."),
"{package_stdout}"
);
assert!(
package_stdout.contains("Installation owner: unknown"),
"{package_stdout}"
);
assert!(!package_stdout.contains("Config:"), "{package_stdout}");
let mut config_json_args = isolated_config_paths_args(root.path());
config_json_args.extend(["--format".into(), "json".into()]);
let config_json_output = aise()
.args(config_json_args)
.env("PATH", &path)
.output()
.unwrap();
assert!(
config_json_output.status.success(),
"{}",
String::from_utf8_lossy(&config_json_output.stderr)
);
let config_report: serde_json::Value =
serde_json::from_slice(&config_json_output.stdout).unwrap();
let provider_roots = config_report["provider_roots"].as_array().unwrap();
assert_eq!(provider_roots.len(), 9, "{config_report}");
let providers = provider_roots
.iter()
.map(|entry| entry["provider"].as_str().unwrap())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
providers,
std::collections::BTreeSet::from([
"claude",
"claude-desktop",
"codex",
"cursor",
"antigravity",
"pi",
"prime-agent",
"aistudio",
"gemini-cli",
])
);
let package_json_output = aise()
.args(["package", "status", "--format", "json"])
.env("PATH", &path)
.env("HTTPS_PROXY", "http://127.0.0.1:1")
.output()
.unwrap();
assert!(
package_json_output.status.success(),
"{}",
String::from_utf8_lossy(&package_json_output.stderr)
);
let package_report: serde_json::Value =
serde_json::from_slice(&package_json_output.stdout).unwrap();
assert_eq!(package_report["installation_owner"], "unknown");
assert!(package_report.get("runtime_process_executable").is_some());
assert!(package_report.get("config_file").is_none());
}
#[cfg(unix)]
#[test]
fn short_reader_pipeline_never_prints_a_broken_pipe_panic() {
let root = tempfile::tempdir().unwrap();
let executable = env!("CARGO_BIN_EXE_aise");
let mut child = Command::new(executable)
.args(isolated_config_paths_args(root.path()))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut first_line = String::new();
std::io::BufReader::new(child.stdout.take().unwrap())
.read_line(&mut first_line)
.unwrap();
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(!stderr.contains("panicked"), "{stderr}");
assert!(!stderr.contains("Broken pipe"), "{stderr}");
assert!(!first_line.is_empty());
}
#[test]
fn explicit_reindex_makes_a_new_empty_index_immediately_readable() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
let executable = env!("CARGO_BIN_EXE_aise");
let reindex = Command::new(executable)
.args(["--config", config.to_str().unwrap(), "reindex"])
.output()
.unwrap();
assert!(
reindex.status.success(),
"{}",
String::from_utf8_lossy(&reindex.stderr)
);
let list = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"list",
"--format",
"json",
])
.output()
.unwrap();
assert!(
list.status.success(),
"{}",
String::from_utf8_lossy(&list.stderr)
);
assert_eq!(String::from_utf8(list.stdout).unwrap().trim(), "[]");
}
#[test]
fn cli_full_reindex_promotes_v3_and_releases_exclusive_database_lock() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
let executable = env!("CARGO_BIN_EXE_aise");
let create = Command::new(executable)
.args(["--config", config.to_str().unwrap(), "reindex"])
.output()
.unwrap();
assert!(
create.status.success(),
"{}",
String::from_utf8_lossy(&create.stderr)
);
let db_path = root.path().join("index.db");
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute_batch(
"drop trigger messages_ai;
drop trigger messages_ad;
drop trigger messages_au;
drop table messages_trigram_terms;
drop table messages_trigram_vocab;
drop table messages_trigram;
pragma user_version=3;",
)
.unwrap();
drop(conn);
let promote = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"reindex",
"--full",
])
.output()
.unwrap();
assert!(
promote.status.success(),
"{}",
String::from_utf8_lossy(&promote.stderr)
);
let reader =
rusqlite::Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.unwrap();
let state: (i64, String, bool, bool) = reader
.query_row(
"select (select user_version from pragma_user_version),
(select journal_mode from pragma_journal_mode),
exists(select 1 from sqlite_schema where name='messages_trigram_vocab'),
not exists(select 1 from sqlite_schema where name='trigram_postings')",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
)
.unwrap();
assert_eq!(state, (5, "wal".into(), true, true));
}
#[test]
fn cli_search_self_heals_v4_hybrid_missing_trigram_from_intact_messages() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
let executable = env!("CARGO_BIN_EXE_aise");
let create = Command::new(executable)
.args(["--config", config.to_str().unwrap(), "reindex"])
.output()
.unwrap();
assert!(
create.status.success(),
"{}",
String::from_utf8_lossy(&create.stderr)
);
let db_path = root.path().join("index.db");
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute_batch(
r#"insert into sessions (
id, provider, provider_session_id, preview_text, source_path,
parse_version, discovery_source
) values ('claude:heal', 'claude', 'heal', '', '/heal.jsonl', 'test', 'fixture');
insert into messages (
session_id, provider, seq, role, kind, tool_name, content
) values (
'claude:heal', 'claude', 0, 'user', 'message', null,
'selfhealneedle12345 rebuild me from intact messages'
);
drop trigger if exists messages_ai;
drop trigger if exists messages_ad;
drop trigger if exists messages_au;
drop table if exists messages_trigram_terms;
drop table if exists messages_trigram_vocab;
drop table if exists messages_trigram;
create table trigram_postings(tg text primary key, ids blob not null, df integer not null);
create table trigram_meta(key text primary key, value integer not null);
pragma user_version=4;"#,
)
.unwrap();
let messages_before: i64 = conn
.query_row("select count(*) from messages", [], |row| row.get(0))
.unwrap();
assert_eq!(
messages_before, 1,
"fixture must retain the base message row"
);
drop(conn);
let search = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"before-query",
"messages",
"search",
"selfhealneedle12345",
"--field",
"content",
"--format",
"json",
])
.output()
.unwrap();
assert!(
search.status.success(),
"writable search must self-heal a v4 hybrid index, got failure: {}",
String::from_utf8_lossy(&search.stderr)
);
let response: serde_json::Value =
serde_json::from_slice(&search.stdout).unwrap_or_else(|error| {
panic!(
"self-healed search must emit a JSON response: {error}: {}",
String::from_utf8_lossy(&search.stdout)
)
});
assert_eq!(
response["page"]["returned"], 1,
"self-healed search must return the intact message: {response}"
);
assert_eq!(response["response_schema_version"], 1);
assert_eq!(
response["results"][0]["message_ref"]["session_id"],
"claude:heal"
);
let reader =
rusqlite::Connection::open_with_flags(&db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.unwrap();
let (uv, has_trigram, has_vocab, obsolete_gone, messages_after): (i64, bool, bool, bool, i64) =
reader
.query_row(
"select (select user_version from pragma_user_version),
exists(select 1 from sqlite_schema where name='messages_trigram'),
exists(select 1 from sqlite_schema where name='messages_trigram_vocab'),
not exists(select 1 from sqlite_schema where name='trigram_postings'),
(select count(*) from messages)",
[],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
},
)
.unwrap();
assert_eq!(
(uv, has_trigram, has_vocab, obsolete_gone, messages_after),
(ai_session_search::db::SCHEMA_VERSION, true, true, true, 1)
);
}
#[test]
fn cli_message_search_covers_three_modes_by_three_fields_on_read_only_open() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
let executable = env!("CARGO_BIN_EXE_aise");
let create = Command::new(executable)
.args(["--config", config.to_str().unwrap(), "reindex"])
.output()
.unwrap();
assert!(
create.status.success(),
"{}",
String::from_utf8_lossy(&create.stderr)
);
let db_path = root.path().join("index.db");
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute_batch(
r#"insert into sessions (
id, provider, provider_session_id, preview_text, source_path,
parse_version, discovery_source
) values ('claude:matrix', 'claude', 'matrix', '', '/matrix.jsonl', 'test', 'fixture');
insert into messages (
session_id, provider, seq, role, kind, tool_name, content
) values (
'claude:matrix', 'claude', 0, 'tool', 'tool_call', 'exec_command',
'{"args":{"cmd":"cargo test --workspace"},"kind":"tool_call","tool_name":"exec_command"}'
);
insert into messages (
session_id, provider, seq, role, kind, tool_name, content
) values (
'claude:matrix', 'claude', 1, 'tool', 'tool_call', 'read_file',
'{"args":{"cmd":"open notes.md"},"kind":"tool_call","tool_name":"read_file"}'
);"#,
)
.unwrap();
drop(conn);
let cases = [
("content", "literal", "cargo test"),
("content", "regex", r"cargo\s+test"),
("content", "fuzzy", "crgo tst"),
("tool-name", "literal", "exec"),
("tool-name", "regex", r"^exec_"),
("tool-name", "fuzzy", "excmd"),
("tool-argument", "literal", "cargo test"),
("tool-argument", "regex", r"cargo\s+test"),
("tool-argument", "fuzzy", "crgo tst"),
];
for (field, mode, query) in cases {
let mut args = vec![
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"messages",
"search",
query,
"--field",
field,
"--kind",
"tool-call",
"--session-id",
"claude:matrix",
"--limit",
"10",
"--query-mode",
mode,
"--format",
"json",
];
if field == "tool-argument" {
args.extend(["--argument-path", "/cmd"]);
}
let output = Command::new(executable).args(&args).output().unwrap();
assert!(
output.status.success(),
"{field}/{mode}: {}",
String::from_utf8_lossy(&output.stderr)
);
let response: serde_json::Value =
serde_json::from_slice(&output.stdout).unwrap_or_else(|error| {
panic!(
"{field}/{mode}: {error}: {}",
String::from_utf8_lossy(&output.stdout)
)
});
assert_eq!(
response["page"]["returned"], 1,
"{field}/{mode}: {response}"
);
assert_eq!(response["response_schema_version"], 1, "{field}/{mode}");
assert_eq!(
response["results"][0]["message_ref"]["session_id"], "claude:matrix",
"{field}/{mode}"
);
assert_eq!(
response["results"][0]["message_ref"]["message_seq"], 0,
"{field}/{mode}"
);
}
let explained = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"messages",
"search",
"cargo test",
"--receipt-level",
"summary",
"--limit",
"1",
"--format",
"json",
])
.output()
.unwrap();
assert!(explained.status.success());
let explained_json: serde_json::Value = serde_json::from_slice(&explained.stdout).unwrap();
assert!(explained_json["receipt"]["search_explanation"].is_object());
assert!(explained_json["receipt"].get("parameter_origins").is_none());
let explain_stderr = String::from_utf8(explained.stderr).unwrap();
assert!(explain_stderr.contains("[explain]"), "{explain_stderr}");
assert!(!explain_stderr.contains("[origins]"), "{explain_stderr}");
let full_receipt = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"messages",
"search",
"cargo test",
"--receipt-level",
"full",
"--limit",
"1",
"--format",
"json",
])
.output()
.unwrap();
assert!(full_receipt.status.success());
let full_json: serde_json::Value = serde_json::from_slice(&full_receipt.stdout).unwrap();
assert!(full_json["receipt"]["parameter_origins"].is_object());
assert!(full_json["receipt"]["ordered_digest"]
.as_str()
.is_some_and(|digest| digest.starts_with("sha256:")));
let full_stderr = String::from_utf8(full_receipt.stderr).unwrap();
assert!(full_stderr.contains("[explain]"), "{full_stderr}");
assert!(full_stderr.contains("[origins]"), "{full_stderr}");
assert!(full_stderr.contains("\"receipt_level\""), "{full_stderr}");
let first_page = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"messages",
"search",
"tool_call",
"--limit",
"1",
"--format",
"json",
])
.output()
.unwrap();
assert!(first_page.status.success());
let first_response: serde_json::Value = serde_json::from_slice(&first_page.stdout).unwrap();
assert_eq!(first_response["page"]["returned"], 1);
assert_eq!(first_response["page"]["next_offset"], 1);
assert_eq!(first_response["page"]["consistency"], "per-call");
let first_stderr = String::from_utf8(first_page.stderr).unwrap();
assert!(first_stderr.contains("--offset 1"), "{first_stderr}");
assert!(first_stderr.contains("--all-results"), "{first_stderr}");
let omitted_limit = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"messages",
"search",
"tool_call",
"--receipt-level",
"full",
"--format",
"json",
])
.output()
.unwrap();
assert!(omitted_limit.status.success());
let omitted_response: serde_json::Value =
serde_json::from_slice(&omitted_limit.stdout).unwrap();
assert_eq!(omitted_response["page"]["returned"], 2);
assert_eq!(
omitted_response["effective_request"]["extent"]["kind"],
"all_results"
);
assert!(omitted_response["page"]["limit"].is_null());
assert!(omitted_response["page"]["next_offset"].is_null());
let final_page = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"messages",
"search",
"tool_call",
"--limit",
"1",
"--offset",
"1",
"--format",
"json",
])
.output()
.unwrap();
assert!(final_page.status.success());
let final_response: serde_json::Value = serde_json::from_slice(&final_page.stdout).unwrap();
assert_eq!(final_response["page"]["returned"], 1);
assert!(final_response["page"]["next_offset"].is_null());
let final_stderr = String::from_utf8(final_page.stderr).unwrap();
assert!(!final_stderr.contains("--offset 2"), "{final_stderr}");
let invalid = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"messages",
"search",
"[",
"--query-mode",
"regex",
"--format",
"json",
])
.output()
.unwrap();
assert!(!invalid.status.success());
assert!(invalid.stdout.is_empty());
assert!(String::from_utf8_lossy(&invalid.stderr).contains("invalid regex"));
let ignored_argument_path = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"messages",
"search",
"cargo",
"--field",
"content",
"--argument-path",
"/cmd",
"--format",
"json",
])
.output()
.unwrap();
assert!(!ignored_argument_path.status.success());
assert!(ignored_argument_path.stdout.is_empty());
assert!(String::from_utf8_lossy(&ignored_argument_path.stderr)
.contains("argument_path requires field='tool_argument'"));
let jsonl = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"messages",
"search",
"tool_call",
"--all-results",
"--receipt-level",
"full",
"--include",
"normalized_session_metadata,raw_provider_metadata,runtime_diagnostics",
"--format",
"jsonl",
])
.output()
.unwrap();
assert!(jsonl.status.success());
let records = String::from_utf8(jsonl.stdout)
.unwrap()
.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
.collect::<Vec<_>>();
assert_eq!(
records
.iter()
.map(|record| record["type"].as_str())
.collect::<Vec<_>>(),
[
Some("search_metadata"),
Some("search_included"),
Some("hit"),
Some("hit"),
Some("search_end")
]
);
assert!(records[0]["effective_request"].is_object());
assert_eq!(records[0]["coordinate_unit"], "unicode_scalar");
assert!(records[0]["included"]["runtime_diagnostics"].is_object());
assert!(records[1]["included"]["normalized_session_metadata"].is_object());
assert!(records[1]["included"]["raw_provider_metadata"].is_object());
assert!(records[2]["result"]["message_ref"].is_object());
assert_eq!(records[4]["page"]["returned"], 2);
assert!(records[4]["receipt"]["ordered_digest"]
.as_str()
.is_some_and(|digest| digest.starts_with("sha256:")));
assert!(records.iter().all(|record| {
record.get("hits").is_none()
&& record.get("context_windows").is_none()
&& record.get("content_extent").is_none()
}));
let context_plain = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"messages",
"search",
"tool_call",
"--all-results",
"--context-before",
"1",
"--context-after",
"1",
"--format",
"plain",
])
.output()
.unwrap();
assert!(
context_plain.status.success(),
"{}",
String::from_utf8_lossy(&context_plain.stderr)
);
let context_plain_stdout = String::from_utf8(context_plain.stdout).unwrap();
let context_rows = context_plain_stdout
.lines()
.map(|line| line.split('\t').map(str::to_owned).collect::<Vec<_>>())
.collect::<Vec<_>>();
assert_eq!(
context_rows
.iter()
.map(|row| row[2].as_str())
.collect::<Vec<_>>(),
["0", "1"],
"overlapping context windows must emit each session/sequence row once"
);
assert!(
context_rows
.iter()
.all(|row| !row[6].is_empty() && row[6].contains("tool_call")),
"a row first observed as future context must be marked when it becomes a later anchor: {context_rows:?}"
);
let materialized_context_plain = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"messages",
"search",
"tool_call",
"--limit",
"10",
"--context-before",
"1",
"--context-after",
"1",
"--format",
"plain",
])
.output()
.unwrap();
assert!(materialized_context_plain.status.success());
assert_eq!(
context_plain_stdout,
String::from_utf8(materialized_context_plain.stdout).unwrap(),
"bounded exhaustive context output must preserve the finite materialized row contract"
);
}
#[cfg(unix)]
#[test]
fn killing_active_read_only_query_releases_connection_and_preserves_writer_progress() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
let executable = env!("CARGO_BIN_EXE_aise");
let create = Command::new(executable)
.args(["--config", config.to_str().unwrap(), "reindex"])
.output()
.unwrap();
assert!(create.status.success());
let db_path = root.path().join("index.db");
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute_batch(
"create table query_load (value integer primary key);
with recursive values_to_insert(value) as (
values(1) union all select value + 1 from values_to_insert where value < 1000
) insert into query_load select value from values_to_insert;",
)
.unwrap();
drop(conn);
let mut child = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"db",
"query",
"select count(*) from query_load a cross join query_load b cross join query_load c",
"--timeout-ms",
"0",
"--format",
"json",
])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.unwrap();
std::thread::sleep(Duration::from_millis(100));
if let Some(status) = child.try_wait().unwrap() {
let mut stderr = String::new();
child
.stderr
.take()
.unwrap()
.read_to_string(&mut stderr)
.unwrap();
panic!("load query ended before kill with {status}: {stderr}");
}
child.kill().unwrap();
let status = child.wait().unwrap();
assert!(!status.success());
let writer = rusqlite::Connection::open(&db_path).unwrap();
let quick_check: String = writer
.query_row("pragma quick_check", [], |row| row.get(0))
.unwrap();
assert_eq!(quick_check, "ok");
writer
.execute_batch(
"begin immediate;
insert into index_metadata(key, value) values ('post_kill_writer', 1)
on conflict(key) do update set value=excluded.value;
commit;",
)
.unwrap();
}
#[test]
fn one_two_four_and_eight_cli_readers_return_identical_json_pages() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
let executable = env!("CARGO_BIN_EXE_aise");
assert!(Command::new(executable)
.args(["--config", config.to_str().unwrap(), "reindex"])
.output()
.unwrap()
.status
.success());
let conn = rusqlite::Connection::open(root.path().join("index.db")).unwrap();
conn.execute_batch(
"insert into sessions (
id, provider, provider_session_id, preview_text, source_path,
parse_version, discovery_source
) values ('claude:parallel', 'claude', 'parallel', '', '/parallel.jsonl', 'test', 'fixture');
insert into messages (session_id, provider, seq, role, kind, content)
values ('claude:parallel', 'claude', 0, 'user', 'conversation', 'parallel reader sentinel');",
)
.unwrap();
drop(conn);
let run = || {
Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"messages",
"search",
"parallel reader sentinel",
"--limit",
"1",
"--format",
"json",
])
.output()
.unwrap()
};
let expected = run();
assert!(expected.status.success());
for clients in [1, 2, 4, 8] {
let outputs = std::thread::scope(|scope| {
(0..clients)
.map(|_| scope.spawn(run))
.collect::<Vec<_>>()
.into_iter()
.map(|handle| handle.join().unwrap())
.collect::<Vec<_>>()
});
for output in outputs {
assert!(
output.status.success(),
"{clients} clients: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(output.stdout, expected.stdout, "{clients} clients");
}
}
}
#[cfg(unix)]
#[test]
fn a_reader_answers_while_another_process_holds_the_index_writer_lock() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
let executable = env!("CARGO_BIN_EXE_aise");
assert!(Command::new(executable)
.args(["--config", config.to_str().unwrap(), "reindex"])
.output()
.unwrap()
.status
.success());
let db_path = root.path().join("index.db");
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute_batch(
"insert into sessions (
id, provider, provider_session_id, preview_text, source_path,
parse_version, discovery_source
) values ('claude:contended', 'claude', 'contended', '', '/contended.jsonl', 'test', 'fixture');
insert into messages (session_id, provider, seq, role, kind, content)
values ('claude:contended', 'claude', 0, 'user', 'conversation', 'writer contention sentinel');",
)
.unwrap();
drop(conn);
let lock_path = ai_session_search::indexer::index_update_lock_path(&db_path);
let lock_file = fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.unwrap();
let mut lock = fd_lock::RwLock::new(lock_file);
let held = lock
.write()
.expect("nothing else holds the writer lock yet");
let mut reader = Command::new(executable)
.args([
"--config",
config.to_str().unwrap(),
"messages",
"search",
"writer contention sentinel",
"--limit",
"1",
"--format",
"json",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(20);
let status = loop {
match reader.try_wait().unwrap() {
Some(status) => break status,
None if std::time::Instant::now() >= deadline => {
let _ = reader.kill();
let _ = reader.wait();
drop(held);
panic!(
"the reader was still running after 20 s while another process held the \
writer lock; reads must not wait on a reindex"
);
}
None => std::thread::sleep(Duration::from_millis(25)),
}
};
let mut stdout = String::new();
reader
.stdout
.take()
.unwrap()
.read_to_string(&mut stdout)
.unwrap();
let mut stderr = String::new();
reader
.stderr
.take()
.unwrap()
.read_to_string(&mut stderr)
.unwrap();
drop(held);
assert!(status.success(), "reader failed under contention: {stderr}");
assert!(
stdout.contains("writer contention sentinel"),
"the reader returned without its own row under contention: {stdout}"
);
}
#[test]
fn cli_resume_confirmation_reads_stdin_and_cancels_without_spawning_provider() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
let executable = env!("CARGO_BIN_EXE_aise");
let provider_bin = root.path().join("provider-bin");
fs::create_dir_all(&provider_bin).unwrap();
let codex = provider_bin.join("codex");
fs::write(&codex, "").unwrap();
#[cfg(unix)]
fs::set_permissions(&codex, fs::Permissions::from_mode(0o755)).unwrap();
let inherited_path = std::env::var_os("PATH").unwrap_or_default();
let provider_path = std::env::join_paths(
std::iter::once(provider_bin).chain(std::env::split_paths(&inherited_path)),
)
.unwrap();
let create = Command::new(executable)
.args(["--config", config.to_str().unwrap(), "reindex"])
.output()
.unwrap();
assert!(create.status.success());
let conn = rusqlite::Connection::open(root.path().join("index.db")).unwrap();
conn.execute(
"insert into sessions (
id, provider, provider_session_id, cwd, preview_text, source_path,
parse_version, discovery_source
) values ('codex:resume-test', 'codex', 'resume-test', ?1, '', '/resume.jsonl',
'test', 'fixture')",
[root.path().to_string_lossy().as_ref()],
)
.unwrap();
drop(conn);
let mut child = Command::new(executable)
.env("PATH", provider_path)
.args([
"--config",
config.to_str().unwrap(),
"--index-refresh",
"existing-only",
"resume",
"codex:resume-test",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.take().unwrap().write_all(b"n\n").unwrap();
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("codex resume resume-test"), "{stdout}");
assert!(stdout.contains("resume cancelled"), "{stdout}");
}
#[test]
fn a_registration_ceiling_is_enforced_by_the_server_it_launched() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
ai_session_search::db::Db::open(&root.path().join("index.db")).unwrap();
let mut child = aise()
.env("AI_SESSION_SEARCH_MAX_TOOL_RESULT_CHARS", "1")
.args(["--config", config.to_str().unwrap(), "mcp", "serve"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
writeln!(
stdin,
r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2024-11-05","capabilities":{{}},"clientInfo":{{"name":"registration-ceiling-test","version":"1"}}}}}}"#
)
.unwrap();
writeln!(
stdin,
r#"{{"jsonrpc":"2.0","method":"notifications/initialized","params":{{}}}}"#
)
.unwrap();
writeln!(
stdin,
r#"{{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{{"name":"get_index_status","arguments":{{"index_refresh":"existing-only"}}}}}}"#
)
.unwrap();
drop(stdin);
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let call = String::from_utf8(output.stdout)
.unwrap()
.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
.find(|response| response["id"] == 2)
.expect("the tool call was answered");
assert_eq!(
call["result"]["isError"],
serde_json::json!(true),
"a one-character ceiling must refuse every result: {call}"
);
let reported = call["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
reported.contains("ceiling of 1"),
"the error must state the ceiling it enforced, which is the one the registration set \
rather than the 48000 default: {reported}"
);
}
#[test]
fn mcp_stdio_exits_cleanly_after_client_closes_stdin() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
ai_session_search::db::Db::open(&root.path().join("index.db")).unwrap();
let executable = env!("CARGO_BIN_EXE_aise");
let mut child = Command::new(executable)
.args(["--config", config.to_str().unwrap(), "mcp", "serve"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
writeln!(
stdin,
r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2024-11-05","capabilities":{{}},"clientInfo":{{"name":"process-lifecycle-test","version":"1"}}}}}}"#
)
.unwrap();
writeln!(
stdin,
r#"{{"jsonrpc":"2.0","method":"notifications/initialized","params":{{}}}}"#
)
.unwrap();
writeln!(
stdin,
r#"{{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{{}}}}"#
)
.unwrap();
writeln!(
stdin,
r#"{{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{{"name":"run_skill_capability","arguments":{{"skill":{{"name":"corrections"}},"limit":1,"index_refresh":"existing-only"}}}}}}"#
)
.unwrap();
drop(stdin);
let output = child.wait_with_output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let responses = String::from_utf8(output.stdout)
.unwrap()
.lines()
.map(|line| serde_json::from_str::<serde_json::Value>(line).unwrap())
.collect::<Vec<_>>();
assert_eq!(responses.len(), 3);
assert_eq!(responses[0]["id"], 1);
assert_eq!(responses[1]["id"], 2);
assert_eq!(responses[2]["id"], 3);
assert_eq!(
responses[2]["result"]["structuredContent"]["run"]["resolved_skill"]["name"],
"corrections"
);
}
fn write_test_skill(
root: &std::path::Path,
name: &str,
frontmatter: &str,
capability: Option<&str>,
) {
let dir = root.join("skills").join(name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("SKILL.md"), frontmatter).unwrap();
if let Some(capability) = capability {
fs::write(dir.join("aise-capability.toml"), capability).unwrap();
}
}
fn skills_config(root: &std::path::Path) -> std::path::PathBuf {
let config = write_disabled_provider_config(root);
let mut text = fs::read_to_string(&config).unwrap();
text.push_str(&format!(
"[skills]\nsearch_paths = [{:?}]\n",
root.join("skills").display().to_string()
));
fs::write(&config, text).unwrap();
config
}
#[test]
fn every_leaf_command_names_another_command() {
fn help_of(path: &[&str]) -> String {
let output = aise().args(path).arg("--help").output().unwrap();
assert!(
output.status.success(),
"`aise {} --help` failed: {}",
path.join(" "),
String::from_utf8_lossy(&output.stderr)
);
let text = String::from_utf8(output.stdout).unwrap();
text.split("Shared options (parsed globally")
.next()
.unwrap_or_default()
.to_owned()
}
fn subcommands(path: &[&str]) -> Vec<String> {
let text = help_of(path);
let Some(start) = text.find("\nCommands:\n") else {
return Vec::new();
};
text[start + "\nCommands:\n".len()..]
.lines()
.take_while(|line| line.starts_with(" ") || line.trim().is_empty())
.filter_map(|line| {
let trimmed = line.strip_prefix(" ")?;
if trimmed.starts_with(' ') {
return None;
}
let name = trimmed.split_whitespace().next()?;
(name != "help").then(|| name.to_owned())
})
.collect()
}
let mut leaves: Vec<Vec<String>> = Vec::new();
for top in subcommands(&[]) {
let nested = subcommands(&[top.as_str()]);
if nested.is_empty() {
leaves.push(vec![top]);
} else {
for child in nested {
leaves.push(vec![top.clone(), child]);
}
}
}
assert!(
leaves.len() >= 40,
"only {} leaf commands were discovered; the help format changed and this check is \
measuring nothing",
leaves.len()
);
let mut dead_ends = Vec::new();
for leaf in &leaves {
let path: Vec<&str> = leaf.iter().map(String::as_str).collect();
let text = help_of(&path);
let own = format!("aise {}", leaf.join(" "));
let group = format!("aise {}", leaf[0]);
let mut cites = false;
for (index, _) in text.match_indices("aise ") {
let rest = &text[index..];
let words: Vec<&str> = rest.split_whitespace().take(3).collect();
for width in [3, 2] {
if words.len() < width {
continue;
}
let candidate = words[..width].join(" ");
let candidate = candidate
.trim_end_matches(['`', ',', '.', ';', ':'])
.to_owned();
if candidate != own && candidate != group && candidate.starts_with("aise ") {
cites = true;
}
}
}
if !cites {
dead_ends.push(own);
}
}
assert!(
dead_ends.is_empty(),
"{} leaf commands name no other command, so a reader who picked the wrong one has no \
route out: {dead_ends:?}",
dead_ends.len()
);
}
#[test]
fn value_taking_flags_state_a_default_or_what_omission_does() {
fn help_of(path: &[&str]) -> String {
let output = aise().args(path).arg("--help").output().unwrap();
String::from_utf8(output.stdout)
.unwrap()
.split("Shared options (parsed globally")
.next()
.unwrap_or_default()
.to_owned()
}
fn subcommands(path: &[&str]) -> Vec<String> {
let text = help_of(path);
let Some(start) = text.find("\nCommands:\n") else {
return Vec::new();
};
text[start + "\nCommands:\n".len()..]
.lines()
.take_while(|line| line.starts_with(" ") || line.trim().is_empty())
.filter_map(|line| {
let trimmed = line.strip_prefix(" ")?;
if trimmed.starts_with(' ') {
return None;
}
let name = trimmed.split_whitespace().next()?;
(name != "help").then(|| name.to_owned())
})
.collect()
}
let mut leaves: Vec<Vec<String>> = Vec::new();
for top in subcommands(&[]) {
let nested = subcommands(&[top.as_str()]);
if nested.is_empty() {
leaves.push(vec![top]);
} else {
for child in nested {
leaves.push(vec![top.clone(), child]);
}
}
}
let mut value_taking = 0usize;
let mut unstated = Vec::new();
let mut unstated_prefix = Vec::new();
for leaf in &leaves {
let path: Vec<&str> = leaf.iter().map(String::as_str).collect();
let text = help_of(&path);
let lines: Vec<&str> = text.lines().collect();
let mut index = 0;
while index < lines.len() {
let line = lines[index];
let trimmed = line.trim_start();
let indent = line.len() - trimmed.len();
let is_option = (2..=6).contains(&indent)
&& (trimmed.starts_with("--")
|| (trimmed.len() > 4
&& trimmed.starts_with('-')
&& trimmed[2..].starts_with(", --")));
if !is_option {
index += 1;
continue;
}
let takes_value = trimmed.contains('<');
let mut description = match trimmed.rfind('>') {
Some(value_end) => trimmed[value_end + 1..].trim().to_owned(),
None => String::new(),
};
index += 1;
while index < lines.len()
&& (lines[index].starts_with(" ") || lines[index].trim().is_empty())
{
description.push(' ');
description.push_str(lines[index].trim());
index += 1;
}
if !takes_value {
continue;
}
value_taking += 1;
let lowered = description.to_lowercase();
let states = lowered.contains("[default:")
|| lowered.contains("omit")
|| lowered.contains("default")
|| lowered.contains("unset")
|| lowered.contains("required");
if !states {
unstated.push(format!("aise {} {}", leaf.join(" "), trimmed));
}
let flag = trimmed.split_whitespace().next().unwrap_or_default();
let prefix_matcher = matches!(
flag,
"--workspace-path"
| "--transcript-path"
| "--exclude-workspace-path"
| "--exclude-transcript-path"
| "--path"
| "--exclude-path"
);
if prefix_matcher
&& !(lowered.contains("component boundary") || lowered.contains("descendant"))
{
unstated_prefix.push(format!("aise {} {flag}", leaf.join(" ")));
}
}
}
assert!(
unstated_prefix.is_empty(),
"{} path filters do not say that they match the directory and its component-boundary \
descendants, so a caller cannot tell whether a sibling directory is excluded: \
{unstated_prefix:#?}",
unstated_prefix.len()
);
assert!(
value_taking >= 150,
"only {value_taking} value-taking flags were found; the help format changed and this \
check is measuring nothing"
);
assert!(
unstated.is_empty(),
"{} value-taking flags state neither a default nor what omitting them does. A reader \
cannot tell whether omitting one means everything, nothing, or a configured value, and \
those are three answers this surface actually uses on flags that sit next to each \
other: {unstated:#?}",
unstated.len()
);
}
#[test]
fn dynamic_skill_help_exits_zero_on_stdout_without_opening_configuration() {
let output = aise()
.args(["skills", "corrections", "--help"])
.env(
"AI_SESSION_SEARCH_CONFIG",
"/path/that/must/not/be/opened/for-help.toml",
)
.output()
.unwrap();
assert!(
output.status.success(),
"help must exit zero: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("Usage: aise skills <SKILL>"), "{stdout}");
assert!(!stdout.contains("aise-skill-capability"), "{stdout}");
assert!(stdout.contains("--session-id"));
assert!(
!stdout.contains("Flattened into each command"),
"public help must not expose Rust/Clap implementation notes: {stdout}"
);
assert!(
output.stderr.is_empty(),
"successful help belongs only on stdout: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn skills_validate_exits_nonzero_and_keeps_the_report_on_stdout() {
let root = tempfile::tempdir().unwrap();
write_test_skill(
root.path(),
"Bad_Name",
"---\nname: other\n---\n\nbody\n",
Some("schema_version = 1\nkind = \"message-classification\"\nweights = 3\n"),
);
let config = skills_config(root.path());
let output = aise()
.args([
"--config",
&config.display().to_string(),
"skills",
"validate",
&root
.path()
.join("skills")
.join("Bad_Name")
.display()
.to_string(),
"--format",
"json",
])
.output()
.unwrap();
assert!(
!output.status.success(),
"an invalid skill must fail the process, or no script can gate on it"
);
let stdout = String::from_utf8(output.stdout).unwrap();
let report: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|err| panic!("stdout must stay parseable JSON: {err}\n{stdout}"));
assert_eq!(report["valid"], serde_json::json!(false));
assert_eq!(
report["diagnostics"].as_array().map(Vec::len),
Some(2),
"every problem is reported at once, not one per run: {report:#}"
);
for diagnostic in report["diagnostics"].as_array().unwrap() {
assert!(
diagnostic["fix"]
.as_str()
.is_some_and(|fix| !fix.is_empty()),
"each diagnostic names a fix, not only a refusal: {diagnostic:#}"
);
}
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("is not a valid skill") && stderr.contains("2 problems"),
"the verdict goes to stderr so stdout stays machine-readable: {stderr}"
);
}
#[test]
fn skills_list_shows_the_built_in_skill_beside_a_user_authored_skill() {
let root = tempfile::tempdir().unwrap();
write_test_skill(
root.path(),
"team-rules",
"---\nname: team-rules\ndescription: Team correction categories.\nmetadata:\n version: 0.2.0\n---\n\nbody\n",
Some(concat!(
"schema_version = 1\nkind = \"message-classification\"\n\n",
"[[categories]]\nname = \"clobber\"\npatterns = [\"\\\\byou overwrote\\\\b\"]\n"
)),
);
let config = skills_config(root.path());
let valid = aise()
.args([
"--config",
&config.display().to_string(),
"skills",
"validate",
&root
.path()
.join("skills")
.join("team-rules")
.display()
.to_string(),
])
.output()
.unwrap();
assert!(
valid.status.success(),
"a well-formed skill must pass: {}",
String::from_utf8_lossy(&valid.stderr)
);
assert!(
String::from_utf8(valid.stdout).unwrap().contains("valid:"),
"success must SAY it succeeded; an empty report reads as a broken command"
);
let listed = aise()
.args([
"--config",
&config.display().to_string(),
"skills",
"list",
"--format",
"json",
])
.output()
.unwrap();
assert!(listed.status.success());
let rows: serde_json::Value = serde_json::from_str(&String::from_utf8(listed.stdout).unwrap())
.expect("skills list --format json is one JSON document");
let names: Vec<&str> = rows
.as_array()
.unwrap()
.iter()
.map(|row| row["name"].as_str().unwrap())
.collect();
assert_eq!(
names,
vec!["corrections", "ai-session-search", "team-rules"],
"the listing must include both built-in packages and every configured user skill"
);
let team = &rows.as_array().unwrap()[2];
assert_eq!(team["ownership"], serde_json::json!("user"));
assert_eq!(team["capability_status"], serde_json::json!("ok"));
assert_eq!(team["package_version"], serde_json::json!("0.2.0"));
assert_eq!(team["capability_sha256"].as_str().map(str::len), Some(64));
let built_in_show = aise()
.args([
"--config",
&config.display().to_string(),
"skills",
"show",
"ai-session-search",
"--format",
"json",
])
.output()
.unwrap();
assert!(
built_in_show.status.success(),
"the harness-only skill promised by show --help must resolve: {}",
String::from_utf8_lossy(&built_in_show.stderr)
);
let built_in: serde_json::Value =
serde_json::from_str(&String::from_utf8(built_in_show.stdout).unwrap()).unwrap();
assert_eq!(built_in["name"], serde_json::json!("ai-session-search"));
assert_eq!(built_in["path"], serde_json::json!("(built in)"));
assert_eq!(
built_in["capability_status"],
serde_json::json!("harness-only")
);
let execution = aise()
.args([
"--config",
&config.display().to_string(),
"skills",
"team-rules",
"--format",
"json",
])
.output()
.unwrap();
assert!(
execution.status.success(),
"`skills team-rules` must resolve and execute the discovered skill: {}",
String::from_utf8_lossy(&execution.stderr)
);
}
#[test]
fn a_scaffolded_skill_is_discoverable_validatable_and_selectable() {
let root = tempfile::tempdir().unwrap();
let config = skills_config(root.path());
let output_dir = root.path().join("skills");
let skill_root = output_dir.join("my-rules");
let dry = aise()
.args([
"--config",
&config.display().to_string(),
"skills",
"create",
"my-rules",
"--output-dir",
&output_dir.display().to_string(),
"--capability",
"message-classification",
"--dry-run",
])
.output()
.unwrap();
assert!(dry.status.success());
assert!(
!skill_root.exists(),
"--dry-run must write nothing, and this is the assertion that proves it"
);
let created = aise()
.args([
"--config",
&config.display().to_string(),
"skills",
"create",
"my-rules",
"--output-dir",
&output_dir.display().to_string(),
"--capability",
"message-classification",
])
.output()
.unwrap();
assert!(
created.status.success(),
"{}",
String::from_utf8_lossy(&created.stderr)
);
assert!(skill_root.join("SKILL.md").is_file());
assert!(skill_root.join("aise-capability.toml").is_file());
assert!(
!fs::read_to_string(skill_root.join("SKILL.md"))
.unwrap()
.contains("ai-session-search-managed-skill"),
"a scaffold is the caller's, so it must carry no managed marker"
);
let again = aise()
.args([
"--config",
&config.display().to_string(),
"skills",
"create",
"my-rules",
"--output-dir",
&output_dir.display().to_string(),
])
.output()
.unwrap();
assert!(
!again.status.success(),
"creating over an existing directory could overwrite the caller's own files"
);
let validated = aise()
.args([
"--config",
&config.display().to_string(),
"skills",
"validate",
&skill_root.display().to_string(),
])
.output()
.unwrap();
assert!(
validated.status.success(),
"what `create` writes must pass what `validate` checks: {}",
String::from_utf8_lossy(&validated.stderr)
);
let execution = aise()
.args([
"--config",
&config.display().to_string(),
"skills",
"my-rules",
"--format",
"json",
])
.output()
.unwrap();
assert!(
execution.status.success(),
"a freshly scaffolded capability skill must be executable: {}",
String::from_utf8_lossy(&execution.stderr)
);
}
#[test]
fn skill_run_json_names_selector_resolution_and_output_even_when_matches_are_empty() {
let root = tempfile::tempdir().unwrap();
let config = write_disabled_provider_config(root.path());
ai_session_search::db::Db::open(&root.path().join("index.db")).unwrap();
let output = aise()
.args([
"--config",
&config.display().to_string(),
"skills",
"corrections",
"--format",
"json",
])
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).unwrap();
let report: serde_json::Value = serde_json::from_str(&stdout)
.unwrap_or_else(|err| panic!("one JSON document: {err}\n{stdout}"));
assert!(
report.is_object(),
"the report is an object, not the pre-1.0 bare match array: {report:#}"
);
assert_eq!(
report
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect::<std::collections::BTreeSet<_>>(),
std::collections::BTreeSet::from(["output", "requested_selector", "resolved_skill"]),
"the top level is exactly the public SkillRunReport envelope"
);
assert_eq!(report["requested_selector"]["name"], "corrections");
assert_eq!(report["resolved_skill"]["name"], "corrections");
assert_eq!(
report["resolved_skill"]["selected_location"]["kind"],
"embedded"
);
assert_eq!(
report["resolved_skill"]["execution_source"]["kind"],
"embedded"
);
assert_eq!(report["output"]["capability"], "message-classification");
assert_eq!(
report["output"]["result"]["report"]["matches"],
serde_json::json!([])
);
let policies = report["output"]["result"]["report"]["policies"]
.as_array()
.unwrap();
assert_eq!(
policies.len(),
1,
"the built-in skill evaluates exactly its embedded capability: {report:#}"
);
assert_eq!(policies[0]["name"], serde_json::json!("corrections"));
assert_eq!(
policies[0]["sha256"].as_str().map(str::len),
Some(64),
"the digest is what makes a run reproducible; a name and version alone are not"
);
}
#[cfg(unix)]
#[test]
fn skills_update_works_with_a_config_outside_the_default_location() {
let root = tempfile::tempdir().unwrap();
let home = root.path().join("home");
fs::create_dir_all(home.join(".codex")).unwrap();
let config = root.path().join("elsewhere/config.toml");
fs::create_dir_all(config.parent().unwrap()).unwrap();
fs::write(&config, "").unwrap();
let run = |args: &[&str]| {
aise()
.env("HOME", &home)
.env("AI_SESSION_SEARCH_CONFIG", &config)
.env("AI_SESSION_SEARCH_SKIP_RELEASE_NOTIFICATION", "1")
.args(args)
.output()
.unwrap()
};
let installed = run(&[
"integrations",
"install",
"--client",
"codex",
"--no-aliases",
"--binary",
env!("CARGO_BIN_EXE_aise"),
]);
assert!(
installed.status.success(),
"{}",
String::from_utf8_lossy(&installed.stderr)
);
for args in [
&["skills", "update", "--dry-run"][..],
&["skills", "update"],
] {
let updated = run(args);
assert!(
updated.status.success(),
"{args:?}: {}",
String::from_utf8_lossy(&updated.stderr)
);
}
let skill = config.parent().unwrap().join("skills/ai-session-search");
assert!(skill.is_dir());
fs::remove_dir_all(&skill).unwrap();
let updated = run(&["skills", "update"]);
assert!(
updated.status.success(),
"{}",
String::from_utf8_lossy(&updated.stderr)
);
assert!(!skill.exists(), "a deleted skill must stay deleted");
}