KeiSeiKit-1.0/_primitives/_rust/keisei/tests/integration.rs
Parfii-bot 4bbc95fd7c feat(v0.22): keisei schema v4 + Scope::Auto + templated hint + registry (Track A)
BREAKING schema bump v3 → v4. Backward-compat via untagged serde
for v1/v2/v3 read-paths — no user-visible regression.

1. Schema v4 — multi-brain marker
   AttachRecord inverted: each Attachment carries its own brain_path
   + brain_name + scope + attached_at. Enables brain-A to Claude-
   Code (user scope) + brain-B to Cursor (project scope) in ONE
   marker. v1/v2/v3 auto-migrate via config_migrate.rs (NEW, 114
   LOC) — silent stderr notice on first v0.22 read.

2. Scope::Auto — default CLI behaviour
   'keisei attach <brain>' no longer defaults User scope blindly.
   New Scope::Auto resolves per-adapter via auto_scope():
     claude-code: CWD/.claude/ present → Project else User
     cursor:      CWD/.cursor/ present → Project else User
     continue:    User (no project concept)
     zed:         User (global settings only)
   'keisei mount' stays host-wide (always User fan-out).

3. Templated post_attach_hint
   fn post_attach_hint(&self, brain: &Brain, scope: Scope) -> String
   Each adapter interpolates brain name + scope. Example for
   claude-code: 'run /help in Claude Code (user scope) — verify
   '<brain-name>' is in mcpServers'.

4. Adapter registry
   adapters/_registry.rs (NEW, 32 LOC) — single canonical list
   of 4 adapters. adapter::all() delegates. 5th adapter = one
   line change, one place.

5. Dead code cleanup
   Error::NotAttached + Error::AdapterFailed removed. Grep-verified
   zero references.

6. config.rs decomposition (200 LOC rule)
   config.rs 224 → 197 LOC.
   time.rs (NEW, 90 LOC) — now_utc_string + format_epoch_utc +
     civil_from_days Howard Hinnant + 5 unit tests
     (epoch-0, leap day 2020-02-29, century-non-leap 2100-03-01,
      arbitrary 2026-04-22, RFC3339 shape).
   config_migrate.rs (NEW, 114 LOC) — WireRecord migration.

REAL VERIFICATION:
  cargo test -p keisei --release: 46 passed 0 failed
    (5 time::tests + 41 integration — 30 existing adjusted to v4 + 11 new)

Tests added:
  marker_v3_migrates_to_v4
  two_brains_can_be_attached_simultaneously
  detach_removes_single_brain_preserves_others
  scope_auto_resolves_to_{project,user}_*
  cursor_auto_scope_respects_cwd_dot_cursor
  post_attach_hint_interpolates_brain_name
  adapter_registry_lists_all_four
  dead_error_variants_removed
  time_now_utc_string_has_rfc3339_shape
  fresh_marker_has_schema_version_4

Agent corrected 3 of the spec's epoch anchor timestamps
(1583020800 → 1582977600 for 2020-02-29T12:00:00Z, 1776870000 →
1776877200 for 2026-04-22T17:00:00Z); century-non-leap anchor
4107542400 → 2100-03-01 was already correct.

Known pre-existing: continue_adapter.rs 206 LOC (was 204; +2 for
signature widening). Out-of-scope for this track.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 21:00:13 +08:00

1330 lines
45 KiB
Rust

//! Integration tests for the `keisei` CLI primitives.
//!
//! Constructor Pattern: one scenario per test, one assertion target.
//! Each test runs with `KEISEI_HOME` pointed at a tempdir so nothing
//! touches the real `~/.claude` / `~/.keisei`.
//!
//! Sources are loaded via `#[path]` — mirror of the kei-ledger pattern.
#[path = "../src/error.rs"]
mod error;
#[path = "../src/paths.rs"]
mod paths;
#[path = "../src/scope.rs"]
mod scope;
#[path = "../src/time.rs"]
mod time;
#[path = "../src/brain.rs"]
mod brain;
#[path = "../src/brain_validate.rs"]
mod brain_validate;
#[path = "../src/config.rs"]
mod config;
#[path = "../src/config_migrate.rs"]
mod config_migrate;
#[path = "../src/display.rs"]
mod display;
#[path = "../src/fsx.rs"]
mod fsx;
#[path = "../src/adapters/mod.rs"]
mod adapters;
#[path = "../src/adapter.rs"]
mod adapter;
#[path = "../src/attach.rs"]
mod attach;
#[path = "../src/status.rs"]
mod status;
#[path = "../src/mount.rs"]
mod mount;
#[path = "../src/detach.rs"]
mod detach;
#[path = "../src/list.rs"]
mod list;
use crate::scope::Scope;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tempfile::TempDir;
// `KEISEI_HOME` is process-global; tests must run serially around the
// env var. One global Mutex is enough for our few tests.
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
_lock: std::sync::MutexGuard<'static, ()>,
_home: TempDir,
home_path: PathBuf,
}
impl EnvGuard {
fn home(&self) -> &Path {
&self.home_path
}
}
fn setup_home() -> EnvGuard {
let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let home = tempfile::tempdir().unwrap();
// Ensure the Claude-Code adapter's `detect()` succeeds: it requires
// either CWD/.claude/settings.json OR $KEISEI_HOME/.claude/ to exist.
fs::create_dir_all(home.path().join(".claude")).unwrap();
std::env::set_var("KEISEI_HOME", home.path());
let home_path = home.path().to_path_buf();
EnvGuard {
_lock: lock,
_home: home,
home_path,
}
}
/// Variant of `setup_home` that does NOT pre-create the `.claude` dir.
/// Used by tests that want to verify "no client detected" failure paths.
fn setup_home_bare() -> EnvGuard {
let lock = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let home = tempfile::tempdir().unwrap();
std::env::set_var("KEISEI_HOME", home.path());
let home_path = home.path().to_path_buf();
EnvGuard {
_lock: lock,
_home: home,
home_path,
}
}
/// Path the Claude-Code adapter writes at user scope, given the
/// current `$KEISEI_HOME`. Used by tests that pre-seed or inspect
/// the client config file directly.
fn claude_user_settings() -> PathBuf {
paths::resolve_home().join(".claude").join("settings.json")
}
fn write_brain(root: &Path, schema: u32) -> PathBuf {
fs::create_dir_all(root.join("bin")).unwrap();
fs::write(root.join("bin/kei-mcp-server-test"), b"#!/bin/sh\n").unwrap();
let manifest = format!(
r#"[brain]
schema_version = {schema}
name = "test-brain"
created = "2026-04-22T00:00:00Z"
[paths]
memory = "memory/"
artifacts = "artifacts/"
manifests = "manifests/"
mcp_server = "bin/kei-mcp-server-test"
"#
);
fs::write(root.join("manifest.toml"), manifest).unwrap();
root.to_path_buf()
}
#[test]
fn attach_then_status_happy_path() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path(), Scope::User).expect("attach ok");
// Marker file exists with correct fields.
let rec = config::read().unwrap().expect("record present");
assert_eq!(rec.schema_version, 4);
assert_eq!(rec.attachments.len(), 1);
assert_eq!(rec.attachments[0].brain_name, "test-brain");
assert!(rec.has_client("claude-code"), "claude-code should be in attachments");
assert!(rec.attachments[0].attached_at.ends_with('Z'));
// Status runs without error when attached.
status::run().expect("status ok after attach");
}
#[test]
fn attach_missing_manifest_errors() {
let _g = setup_home();
let empty = tempfile::tempdir().unwrap();
// No manifest.toml written.
let err = attach::run(empty.path(), Scope::User).unwrap_err();
assert!(
matches!(err, error::Error::BrainNotFound(_)),
"got {err:?}"
);
}
#[test]
fn attach_unsupported_schema_errors() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 99);
let err = attach::run(brain_dir.path(), Scope::User).unwrap_err();
assert!(
matches!(err, error::Error::UnsupportedSchema { found: 99 }),
"got {err:?}"
);
}
#[test]
fn status_without_attach_is_clean() {
let _g = setup_home();
// No marker file anywhere.
assert!(config::read().unwrap().is_none());
status::run().expect("status ok when not attached");
}
#[test]
fn attach_writes_marker_with_expected_fields() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path(), Scope::User).expect("attach ok");
let rec = config::read().unwrap().expect("record present");
// brain_path stored as canonicalized absolute path.
assert_eq!(rec.attachments.len(), 1);
let a = &rec.attachments[0];
assert!(Path::new(&a.brain_path).is_absolute());
assert_eq!(a.brain_name, "test-brain");
assert_eq!(a.client_type, "claude-code");
assert_eq!(a.scope, Scope::User);
assert!(
!a.config_path.is_empty(),
"config_path should be populated on v2+ write"
);
// Marker file itself lives under $KEISEI_HOME/.keisei/.
let marker = config::attached_path();
assert!(marker.is_file(), "marker not written at {}", marker.display());
assert!(
marker.ends_with(".keisei/attached.toml"),
"marker not in new location: {}",
marker.display()
);
// Settings.json got written and contains the server entry.
let settings = claude_user_settings();
assert!(settings.is_file(), "settings.json not written");
let text = fs::read_to_string(&settings).unwrap();
assert!(text.contains("mcpServers"), "mcpServers key missing");
assert!(text.contains("keisei"), "keisei mcp entry missing");
}
// -----------------------------------------------------------------------
// v0.19 tests (multi-client).
// -----------------------------------------------------------------------
#[test]
fn mount_with_claude_code_only_detected() {
let _g = setup_home();
// Only .claude/ exists (setup_home creates it). No .cursor, .continue,
// no Zed dirs. Mount should detect exactly one client.
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
mount::run(brain_dir.path()).expect("mount ok");
let rec = config::read().unwrap().expect("record present");
assert_eq!(
rec.attachments.len(),
1,
"only claude-code should be attached, got {:?}",
rec.client_names()
);
assert_eq!(rec.attachments[0].client_type, "claude-code");
assert_eq!(rec.attachments[0].scope, Scope::User);
}
#[test]
fn mount_with_no_client_detected() {
let _g = setup_home_bare();
// Bare home — no .claude, no .cursor, no .continue, no Zed dirs.
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
let err = mount::run(brain_dir.path()).unwrap_err();
assert!(
matches!(err, error::Error::NoClientDetected),
"got {err:?}"
);
// Marker must NOT be written on failure.
assert!(config::read().unwrap().is_none());
}
#[test]
fn detach_round_trip() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path(), Scope::User).expect("attach ok");
let settings = claude_user_settings();
assert!(settings.is_file());
// Sanity: keisei entry is present BEFORE detach.
let before: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap();
assert!(
before
.get("mcpServers")
.and_then(|s| s.get("keisei"))
.is_some(),
"keisei entry missing pre-detach: {before}"
);
detach::run().expect("detach ok");
// Marker gone.
assert!(
config::read().unwrap().is_none(),
"marker not deleted after detach"
);
// settings.json still exists; keisei entry stripped.
assert!(settings.is_file());
let after: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap();
let has_keisei = after
.get("mcpServers")
.and_then(|s| s.get("keisei"))
.is_some();
assert!(!has_keisei, "keisei entry survived detach: {after}");
}
#[test]
fn detach_preserves_other_mcp_servers() {
let _g = setup_home();
let settings = claude_user_settings();
// Pre-populate with a user's pre-existing MCP server.
fs::write(
&settings,
r#"{
"mcpServers": {
"other": { "command": "/usr/local/bin/other-mcp", "args": [] }
},
"userPref": 42
}"#,
)
.unwrap();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path(), Scope::User).expect("attach ok");
detach::run().expect("detach ok");
let after: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap();
// `other` mcp server survives.
assert!(
after
.get("mcpServers")
.and_then(|s| s.get("other"))
.is_some(),
"pre-existing 'other' server lost: {after}"
);
// keisei is gone.
assert!(
after
.get("mcpServers")
.and_then(|s| s.get("keisei"))
.is_none(),
"keisei entry survived detach: {after}"
);
// Unrelated top-level key preserved.
assert_eq!(after.get("userPref").and_then(|v| v.as_i64()), Some(42));
}
#[test]
fn list_adapters_prints_expected_rows() {
// list just enumerates adapter::all() — no home needed, but we lock
// the env to keep `detect()` reads deterministic.
let _g = setup_home();
// Sanity check: all four adapter names are registered.
let names: Vec<String> = adapter::all().iter().map(|a| a.name().to_string()).collect();
assert!(names.contains(&"claude-code".to_string()));
assert!(names.contains(&"cursor".to_string()));
assert!(names.contains(&"continue".to_string()));
assert!(names.contains(&"zed".to_string()));
// Command itself runs without error.
list::run().expect("list-adapters ok");
}
// -----------------------------------------------------------------------
// v0.19 audit-hardening tests (SEC-H1 / H2 / H3 path + name + symlink).
// -----------------------------------------------------------------------
/// Write a brain manifest with a caller-chosen `mcp_server` string. Used
/// to exercise the path-escape rejection paths. Does NOT create the
/// `mcp_server` file itself — the manifest-time rejection fires before
/// canonicalization would ever run.
fn write_brain_raw_mcp(root: &Path, mcp_server: &str) -> PathBuf {
let manifest = format!(
r#"[brain]
schema_version = 1
name = "test-brain"
created = "2026-04-22T00:00:00Z"
[paths]
mcp_server = "{mcp_server}"
"#
);
fs::write(root.join("manifest.toml"), manifest).unwrap();
root.to_path_buf()
}
/// Write a brain manifest with a caller-chosen `name`. Used to exercise
/// the name-regex rejection path. `mcp_server` still points at a real
/// file so name-validation runs before path-canonicalization.
fn write_brain_raw_name(root: &Path, name: &str) -> PathBuf {
fs::create_dir_all(root.join("bin")).unwrap();
fs::write(root.join("bin/kei-mcp-server-test"), b"#!/bin/sh\n").unwrap();
let manifest = format!(
r#"[brain]
schema_version = 1
name = "{name}"
created = "2026-04-22T00:00:00Z"
[paths]
mcp_server = "bin/kei-mcp-server-test"
"#
);
fs::write(root.join("manifest.toml"), manifest).unwrap();
root.to_path_buf()
}
#[test]
fn manifest_with_absolute_mcp_server_rejected() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
// Malicious manifest: absolute path to arbitrary host binary.
write_brain_raw_mcp(brain_dir.path(), "/usr/bin/curl");
let err = attach::run(brain_dir.path(), Scope::User).unwrap_err();
assert!(
matches!(err, error::Error::PathEscape(_)),
"expected PathEscape, got {err:?}"
);
// Containment: marker MUST NOT be written.
assert!(config::read().unwrap().is_none());
}
#[test]
fn manifest_with_parent_traversal_rejected() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain_raw_mcp(brain_dir.path(), "../../etc/passwd");
let err = attach::run(brain_dir.path(), Scope::User).unwrap_err();
assert!(
matches!(err, error::Error::PathEscape(_)),
"expected PathEscape, got {err:?}"
);
assert!(config::read().unwrap().is_none());
}
#[test]
fn manifest_with_invalid_name_rejected() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
// "claude-ide!" contains `!` — forbidden by ^[a-z][a-z0-9_-]{0,63}$.
write_brain_raw_name(brain_dir.path(), "claude-ide!");
let err = attach::run(brain_dir.path(), Scope::User).unwrap_err();
assert!(
matches!(err, error::Error::InvalidName(ref s) if s == "claude-ide!"),
"expected InvalidName(\"claude-ide!\"), got {err:?}"
);
}
#[test]
fn brain_path_is_symlink_rejected() {
let _g = setup_home();
// Target brain is legitimate...
let target = tempfile::tempdir().unwrap();
write_brain(target.path(), 1);
// ...but caller passes a symlink pointing at it (USB/host pivot).
let link_parent = tempfile::tempdir().unwrap();
let link = link_parent.path().join("brain-link");
#[cfg(unix)]
std::os::unix::fs::symlink(target.path(), &link).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_dir(target.path(), &link).unwrap();
let err = attach::run(&link, Scope::User).unwrap_err();
assert!(
matches!(err, error::Error::BrainIsSymlink { .. }),
"expected BrainIsSymlink, got {err:?}"
);
// No marker, no config pollution.
assert!(config::read().unwrap().is_none());
}
#[test]
fn attach_refuses_to_clobber_existing_mcp_entry() {
let _g = setup_home();
// Pre-populate settings.json with a DIFFERENT `keisei` entry.
let settings = claude_user_settings();
fs::create_dir_all(settings.parent().unwrap()).unwrap();
fs::write(
&settings,
r#"{
"mcpServers": {
"keisei": {
"command": "/tmp/not-our-binary",
"args": ["--evil"]
}
}
}"#,
)
.unwrap();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
let err = attach::run(brain_dir.path(), Scope::User).unwrap_err();
assert!(
matches!(err, error::Error::NameConflict { .. }),
"expected NameConflict, got {err:?}"
);
// User's pre-existing entry survives intact.
let after: Value = serde_json::from_str(&fs::read_to_string(&settings).unwrap()).unwrap();
let cmd = after
.get("mcpServers")
.and_then(|s| s.get("keisei"))
.and_then(|k| k.get("command"))
.and_then(|c| c.as_str())
.unwrap_or("");
assert_eq!(
cmd, "/tmp/not-our-binary",
"pre-existing keisei entry was mutated; attach should have been a no-op on conflict"
);
// Marker MUST NOT be written on conflict.
assert!(config::read().unwrap().is_none());
}
#[test]
fn schema_v1_to_v2_migration() {
let _g = setup_home();
// Hand-write a v1 marker at the NEW location (location migration is
// tested separately). v1 schema has flat `client_type` and no list.
let marker = config::attached_path();
if let Some(parent) = marker.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(
&marker,
r#"brain_path = "/tmp/brain-v1"
brain_name = "old-brain"
client_type = "claude-code"
attached_at = "2026-04-22T00:00:00Z"
"#,
)
.unwrap();
let rec = config::read().unwrap().expect("v1 marker should parse");
assert_eq!(rec.schema_version, 4);
assert_eq!(
rec.attachments.len(),
1,
"v1 client_type should migrate to single attachment"
);
let a = &rec.attachments[0];
assert_eq!(a.brain_name, "old-brain");
assert_eq!(a.brain_path, "/tmp/brain-v1");
assert_eq!(a.client_type, "claude-code");
// v1 didn't carry config_path; migration leaves it blank.
assert_eq!(a.config_path, "");
// v1 didn't carry scope; default is User.
assert_eq!(a.scope, Scope::User);
assert_eq!(a.attached_at, "2026-04-22T00:00:00Z");
assert!(rec.has_client("claude-code"));
}
// -----------------------------------------------------------------------
// v0.19.2 polish tests (M1 perms / L9 sanitize / L12 manifest size).
// -----------------------------------------------------------------------
#[cfg(unix)]
#[test]
fn marker_file_has_0600_perms_on_unix() {
use std::os::unix::fs::PermissionsExt;
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path(), Scope::User).expect("attach ok");
let marker = config::attached_path();
let mode = fs::metadata(&marker).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"marker perms = {:04o}; expected 0600 (owner r/w only)",
mode
);
}
#[test]
fn status_sanitizes_control_chars_in_brain_name() {
// Unit test on the sanitize primitive — simpler and tighter than
// capturing stdout from `status::run`. L9 wiring in `status.rs` and
// `attach.rs` calls through `display::sanitize_display` at every
// manifest-sourced printf site, so asserting the primitive is enough.
assert_eq!(
display::sanitize_display("evil\x1b[2Jpayload"),
"evil?[2Jpayload"
);
// Space / regular ASCII pass through.
assert_eq!(display::sanitize_display("my brain 01"), "my brain 01");
// DEL (0x7F) is scrubbed too.
assert_eq!(display::sanitize_display("a\x7Fb"), "a?b");
}
/// Write a brain with an artificially large manifest.toml for the size-
/// bound rejection test. `min_bytes` is the lower bound on the resulting
/// file size; the manifest body is padded with a block-comment-like
/// filler string (`\n# xxxxxx...`) until the total exceeds `min_bytes`.
fn write_brain_with_oversize_manifest(root: &Path, min_bytes: usize) -> PathBuf {
fs::create_dir_all(root.join("bin")).unwrap();
fs::write(root.join("bin/kei-mcp-server-test"), b"#!/bin/sh\n").unwrap();
let mut manifest = String::from(
r#"[brain]
schema_version = 1
name = "test-brain"
created = "2026-04-22T00:00:00Z"
[paths]
mcp_server = "bin/kei-mcp-server-test"
"#,
);
// Pad with toml-legal trailing comments to grow past the 64 KiB cap.
let filler = format!("# {}\n", "x".repeat(120));
while manifest.len() < min_bytes {
manifest.push_str(&filler);
}
fs::write(root.join("manifest.toml"), manifest).unwrap();
root.to_path_buf()
}
// -----------------------------------------------------------------------
// v0.20 schema-v2 + post_attach_hint tests.
// -----------------------------------------------------------------------
/// Write a schema-v2 brain manifest carrying every supported platform in
/// the `[paths.mcp_server]` table, plus a stub binary for the current
/// host so the canonicalizer is happy.
fn write_brain_v2_all_platforms(root: &Path) -> PathBuf {
fs::create_dir_all(root.join("bin")).unwrap();
// Stub binaries for all five supported host tuples. We create them
// all so any host running the suite finds its own entry.
for name in &[
"kei-mcp-server-darwin-arm64",
"kei-mcp-server-darwin-x64",
"kei-mcp-server-linux-x64",
"kei-mcp-server-linux-arm64",
"kei-mcp-server-windows-x64.exe",
] {
fs::write(root.join("bin").join(name), b"#!/bin/sh\n").unwrap();
}
let manifest = r#"[brain]
schema_version = 2
name = "test-brain-v2"
created = "2026-04-22T00:00:00Z"
[paths]
memory = "memory/"
[paths.mcp_server]
darwin-arm64 = "bin/kei-mcp-server-darwin-arm64"
darwin-x64 = "bin/kei-mcp-server-darwin-x64"
linux-x64 = "bin/kei-mcp-server-linux-x64"
linux-arm64 = "bin/kei-mcp-server-linux-arm64"
windows-x64 = "bin/kei-mcp-server-windows-x64.exe"
"#;
fs::write(root.join("manifest.toml"), manifest).unwrap();
root.to_path_buf()
}
/// Write a v2 brain that only has `linux-x64` — used on macOS to exercise
/// the `NoPlatformBinary` error path.
fn write_brain_v2_linux_only(root: &Path) -> PathBuf {
fs::create_dir_all(root.join("bin")).unwrap();
fs::write(
root.join("bin/kei-mcp-server-linux-x64"),
b"#!/bin/sh\n",
)
.unwrap();
let manifest = r#"[brain]
schema_version = 2
name = "test-brain-linux"
created = "2026-04-22T00:00:00Z"
[paths.mcp_server]
linux-x64 = "bin/kei-mcp-server-linux-x64"
"#;
fs::write(root.join("manifest.toml"), manifest).unwrap();
root.to_path_buf()
}
#[test]
fn manifest_too_large_rejected() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
// 100 KiB manifest — well above the 64 KiB cap.
write_brain_with_oversize_manifest(brain_dir.path(), 100 * 1024);
let err = attach::run(brain_dir.path(), Scope::User).unwrap_err();
assert!(
matches!(
err,
error::Error::ManifestTooLarge { size, max }
if size > max && max == 64 * 1024
),
"expected ManifestTooLarge {{ size > max, max == 65536 }}, got {err:?}"
);
// Containment: marker MUST NOT be written on rejection.
assert!(config::read().unwrap().is_none());
}
#[test]
fn schema_v2_current_platform_resolves() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain_v2_all_platforms(brain_dir.path());
let brain = brain::Brain::load(brain_dir.path()).expect("v2 brain loads");
let path = brain.mcp_server_path().expect("current platform resolves");
assert!(path.is_file(), "resolved binary missing at {}", path.display());
// Resolved path must live under the brain root.
let root = brain_dir.path().canonicalize().unwrap();
assert!(
path.starts_with(&root),
"resolved path {} not under root {}",
path.display(),
root.display()
);
}
#[cfg(target_os = "macos")]
#[test]
fn schema_v2_missing_current_platform_errors() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain_v2_linux_only(brain_dir.path());
let brain = brain::Brain::load(brain_dir.path())
.expect("v2 brain without current-platform binary still loads");
let err = brain.mcp_server_path().unwrap_err();
match err {
error::Error::NoPlatformBinary { ref available, .. } => {
assert_eq!(available, &vec!["linux-x64".to_string()]);
}
other => panic!("expected NoPlatformBinary, got {other:?}"),
}
}
#[test]
fn schema_v1_still_readable_with_v2_code() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
// `write_brain` emits schema_version = 1 + single-string mcp_server.
write_brain(brain_dir.path(), 1);
let brain = brain::Brain::load(brain_dir.path()).expect("v1 brain still loads under v2 code");
let path = brain.mcp_server_path().expect("v1 resolves without platform map");
assert!(path.is_file(), "v1-resolved binary missing at {}", path.display());
}
#[test]
fn post_attach_hint_is_adapter_specific() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
let brain = brain::Brain::load(brain_dir.path()).expect("brain loads");
let adapters = adapter::all();
let by_name = |n: &str| -> String {
adapters
.iter()
.find(|a| a.name() == n)
.unwrap_or_else(|| panic!("adapter {n} missing"))
.post_attach_hint(&brain, Scope::User)
.to_string()
};
let claude = by_name("claude-code");
let cursor = by_name("cursor");
let cont = by_name("continue");
let zed = by_name("zed");
assert!(
claude.contains("/help"),
"claude-code hint lost /help marker: {claude}"
);
assert!(
cursor.contains("Reload Window"),
"cursor hint lost 'Reload Window' marker: {cursor}"
);
assert!(
cont.contains("Continue"),
"continue hint lost 'Continue' marker: {cont}"
);
assert!(
zed.contains(":reload"),
"zed hint lost ':reload' marker: {zed}"
);
}
// -----------------------------------------------------------------------
// v0.21 — SSoT relocation + Scope enum tests.
// -----------------------------------------------------------------------
#[test]
fn legacy_marker_migrates_on_first_read() {
let g = setup_home();
// Seed a v2 marker at the LEGACY path
// ($KEISEI_HOME/.claude/keisei-attached.toml), with no new-location
// file. Simulates an upgrade from v0.20 → v0.21.
let legacy = g.home().join(".claude").join("keisei-attached.toml");
fs::create_dir_all(legacy.parent().unwrap()).unwrap();
let body = r#"brain_path = "/tmp/legacy-brain"
brain_name = "legacy-brain"
attached_at = "2026-04-22T00:00:00Z"
[[attachments]]
client_type = "claude-code"
config_path = "/tmp/fake/settings.json"
"#;
fs::write(&legacy, body).unwrap();
// New-location MUST NOT exist yet.
let current = g.home().join(".keisei").join("attached.toml");
assert!(
!current.exists(),
"new marker pre-existed before read(): {}",
current.display()
);
// read() performs the one-shot migration (location + schema).
let rec = config::read().unwrap().expect("migrated record present");
assert_eq!(rec.schema_version, 4);
assert_eq!(rec.attachments.len(), 1);
let a = &rec.attachments[0];
assert_eq!(a.brain_name, "legacy-brain");
assert_eq!(a.brain_path, "/tmp/legacy-brain");
assert_eq!(a.client_type, "claude-code");
// Default scope for pre-v0.21 markers is User.
assert_eq!(a.scope, Scope::User);
// Post-conditions: new file exists, legacy file gone.
assert!(
current.is_file(),
"migration did not create new marker at {}",
current.display()
);
assert!(
!legacy.exists(),
"legacy marker still present at {} after migration",
legacy.display()
);
}
#[test]
fn attach_with_project_scope_writes_local_config() {
let _g = setup_home();
// Run from a CWD where the adapter's project-scope target lives.
let workdir = tempfile::tempdir().unwrap();
let prev_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(workdir.path()).unwrap();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path(), Scope::Project).expect("attach --scope=project ok");
// Project-local file must be written (under the CWD we set above).
let project_settings = workdir.path().join(".claude").join("settings.json");
assert!(
project_settings.is_file(),
"project-scope settings.json missing at {}",
project_settings.display()
);
// User-scope file must NOT have been created by this attach.
let user_settings = claude_user_settings();
assert!(
!user_settings.is_file(),
"user-scope settings.json leaked when scope=project: {}",
user_settings.display()
);
// Marker records scope=Project.
let rec = config::read().unwrap().expect("record");
assert_eq!(rec.attachments.len(), 1);
assert_eq!(rec.attachments[0].scope, Scope::Project);
std::env::set_current_dir(prev_cwd).unwrap();
}
#[test]
fn attach_user_scope_still_default() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
// main.rs default is Scope::User — exercise the path explicitly here.
attach::run(brain_dir.path(), Scope::User).expect("attach ok");
let rec = config::read().unwrap().expect("record");
assert_eq!(rec.attachments.len(), 1);
assert_eq!(rec.attachments[0].scope, Scope::User);
assert!(claude_user_settings().is_file());
}
#[test]
fn scope_unsupported_by_adapter_errors() {
let _g = setup_home();
// Force the Zed adapter to the front of detection by pre-creating its
// settings dir, and suppress claude-code's dir so detect_active picks Zed.
// Remove the .claude dir that setup_home pre-created.
let home = paths::resolve_home();
fs::remove_dir_all(home.join(".claude")).ok();
// Also suppress any CWD-local .claude (claude_code's detect checks CWD).
let workdir = tempfile::tempdir().unwrap();
let prev_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(workdir.path()).unwrap();
// Seed Zed's config dir (platform-specific).
let zed_dir = if cfg!(target_os = "macos") {
home.join("Library/Application Support/Zed")
} else {
home.join(".config/zed")
};
fs::create_dir_all(&zed_dir).unwrap();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
// Zed declares supported_scopes() = [User], so project scope must error.
let err = attach::run(brain_dir.path(), Scope::Project).unwrap_err();
assert!(
matches!(err, error::Error::ScopeUnsupported { ref client, .. } if client == "zed"),
"expected ScopeUnsupported for zed, got {err:?}"
);
// Marker must not be written on validation failure.
assert!(config::read().unwrap().is_none());
std::env::set_current_dir(prev_cwd).unwrap();
}
#[test]
fn detach_respects_scope_from_marker() {
let _g = setup_home();
// Attach at project scope in a workdir.
let workdir = tempfile::tempdir().unwrap();
let prev_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(workdir.path()).unwrap();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path(), Scope::Project).expect("attach project ok");
let project_settings = workdir.path().join(".claude").join("settings.json");
assert!(project_settings.is_file(), "project settings absent post-attach");
let before: Value =
serde_json::from_str(&fs::read_to_string(&project_settings).unwrap()).unwrap();
assert!(
before.get("mcpServers").and_then(|s| s.get("keisei")).is_some(),
"keisei entry missing pre-detach: {before}"
);
detach::run().expect("detach ok");
// keisei entry gone from project-scope file.
let after: Value =
serde_json::from_str(&fs::read_to_string(&project_settings).unwrap()).unwrap();
let has_keisei = after
.get("mcpServers")
.and_then(|s| s.get("keisei"))
.is_some();
assert!(!has_keisei, "keisei entry survived detach: {after}");
// Marker gone.
assert!(config::read().unwrap().is_none());
std::env::set_current_dir(prev_cwd).unwrap();
}
// -----------------------------------------------------------------------
// v0.21.1 HIGH-3 — sanitize_display covers detach/mount, not just status.
// -----------------------------------------------------------------------
/// Write a marker file directly with a brain_name containing ANSI control
/// bytes — this bypasses the adapter's name-regex which would otherwise
/// reject it at attach time. We want to verify that the DETACH path still
/// scrubs the name before printing (defensive-in-depth).
#[test]
fn detach_sanitizes_control_chars_in_marker_fields() {
use crate::config::{AttachRecord, Attachment};
let _g = setup_home();
// Hand-craft a v4 marker so every displayed field carries an escape
// sequence. detach must sanitize before printing.
let rec = AttachRecord::new(vec![Attachment {
brain_path: "/tmp/evil\x1b[2Jbrain".to_string(),
brain_name: "evil\x1b[2Jname".to_string(),
client_type: "claude-code".to_string(),
config_path: "/tmp/evil\x1b[2Jcfg".to_string(),
scope: Scope::User,
attached_at: "2026-04-22T00:00:00Z".to_string(),
}]);
config::write(&rec).unwrap();
// Run detach — any println!/eprintln! that leaks control bytes from
// brain_path / brain_name / client_type / reason fails the escape
// sanitization contract. We can't easily capture stdout here, but
// the unit test `status_sanitizes_control_chars_in_brain_name` plus
// the source-level grep below give us defence in depth.
detach::run().expect("detach runs without panic");
// Every print_summary arg is sanitized — we verify by inspecting the
// detach.rs source for the `sanitize_display(` guard on each.
let src = include_str!("../src/detach.rs");
for needle in [
"sanitize_display(&a.brain_path)",
"sanitize_display(client)",
"sanitize_display(reason)",
] {
assert!(
src.contains(needle),
"detach.rs must sanitize display of {needle}: missing guard"
);
}
}
#[test]
fn mount_sanitizes_control_chars_in_error_reason() {
// Same contract as above — mount.rs must sanitize every user-visible
// printf path. We verify by source inspection; an integration with
// stdout capture would require std::process::Command round-trips.
let src = include_str!("../src/mount.rs");
for needle in [
"sanitize_display(brain.name())",
"sanitize_display(&s.client_type)",
"sanitize_display(&s.config_path)",
"sanitize_display(client)",
"sanitize_display(reason)",
] {
assert!(
src.contains(needle),
"mount.rs must sanitize display of {needle}: missing guard"
);
}
}
// -----------------------------------------------------------------------
// v0.22 — schema v4 multi-brain + Scope::Auto + templated hint + registry.
// -----------------------------------------------------------------------
/// Write a distinct second brain manifest under `root` so multi-brain
/// tests can attach two different brains in one session.
fn write_second_brain(root: &Path, name: &str) -> PathBuf {
fs::create_dir_all(root.join("bin")).unwrap();
fs::write(root.join("bin/kei-mcp-server-test"), b"#!/bin/sh\n").unwrap();
let manifest = format!(
r#"[brain]
schema_version = 1
name = "{name}"
created = "2026-04-22T00:00:00Z"
[paths]
mcp_server = "bin/kei-mcp-server-test"
"#
);
fs::write(root.join("manifest.toml"), manifest).unwrap();
root.to_path_buf()
}
#[test]
fn marker_v3_migrates_to_v4() {
let _g = setup_home();
// Hand-write a v3 marker (shared brain fields + scope per attachment).
let marker = config::attached_path();
fs::create_dir_all(marker.parent().unwrap()).unwrap();
fs::write(
&marker,
r#"brain_path = "/tmp/brain-v3"
brain_name = "brain-v3"
attached_at = "2026-04-22T00:00:00Z"
[[attachments]]
client_type = "claude-code"
config_path = "/tmp/settings.json"
scope = "user"
[[attachments]]
client_type = "cursor"
config_path = "/tmp/mcp.json"
scope = "project"
"#,
)
.unwrap();
let rec = config::read().unwrap().expect("record present");
assert_eq!(rec.schema_version, 4);
assert_eq!(rec.attachments.len(), 2);
for a in &rec.attachments {
assert_eq!(a.brain_name, "brain-v3");
assert_eq!(a.brain_path, "/tmp/brain-v3");
assert_eq!(a.attached_at, "2026-04-22T00:00:00Z");
}
assert_eq!(rec.attachments[0].client_type, "claude-code");
assert_eq!(rec.attachments[0].scope, Scope::User);
assert_eq!(rec.attachments[1].client_type, "cursor");
assert_eq!(rec.attachments[1].scope, Scope::Project);
}
#[test]
fn two_brains_can_be_attached_simultaneously() {
let _g = setup_home();
// Seed a cursor dir so both adapters can take their own brain.
let home = paths::resolve_home();
fs::create_dir_all(home.join(".cursor")).unwrap();
// Brain A → claude-code at user scope.
let brain_a_dir = tempfile::tempdir().unwrap();
write_brain(brain_a_dir.path(), 1);
attach::run(brain_a_dir.path(), Scope::User).expect("attach brain-a ok");
// Hand-write marker with a second attachment (simulates second
// `attach` run that would have picked up a different adapter).
// We use the merge path directly — simpler than forcing cursor
// to be the detected client.
let rec1 = config::read().unwrap().expect("record present");
assert_eq!(rec1.attachments.len(), 1);
// Simulate a second attach that adds a cursor attachment with a
// different brain_path. We merge by appending, as attach::run does.
let brain_b_dir = tempfile::tempdir().unwrap();
write_second_brain(brain_b_dir.path(), "brain-b");
let canon_b = brain_b_dir.path().canonicalize().unwrap();
let rec2 = config::AttachRecord::new(vec![
rec1.attachments[0].clone(),
config::Attachment {
brain_path: canon_b.to_string_lossy().into_owned(),
brain_name: "brain-b".to_string(),
client_type: "cursor".to_string(),
config_path: home
.join(".cursor/mcp.json")
.to_string_lossy()
.into_owned(),
scope: Scope::User,
attached_at: config::now_utc_string(),
},
]);
config::write(&rec2).unwrap();
let final_rec = config::read().unwrap().expect("record present");
assert_eq!(final_rec.attachments.len(), 2);
// Distinct brain_paths prove multi-brain co-existence.
assert_ne!(
final_rec.attachments[0].brain_path, final_rec.attachments[1].brain_path,
"attachments should point at different brains"
);
assert!(final_rec.has_client("claude-code"));
assert!(final_rec.has_client("cursor"));
assert_eq!(final_rec.brain_names().len(), 2);
}
#[test]
fn detach_removes_single_brain_preserves_others() {
let _g = setup_home();
// Hand-craft a v4 marker with two attachments to different clients
// and different brains, then call detach. Marker should be removed
// entirely (detach is all-or-nothing), but per-client cleanup must
// fire for each attachment.
let home = paths::resolve_home();
let claude_settings = home.join(".claude/settings.json");
fs::create_dir_all(claude_settings.parent().unwrap()).unwrap();
fs::write(
&claude_settings,
r#"{
"mcpServers": {
"keisei": { "command": "/tmp/fake", "args": [] },
"other": { "command": "/tmp/other", "args": [] }
}
}"#,
)
.unwrap();
let rec = config::AttachRecord::new(vec![config::Attachment {
brain_path: "/tmp/brain-a".to_string(),
brain_name: "brain-a".to_string(),
client_type: "claude-code".to_string(),
config_path: claude_settings.to_string_lossy().into_owned(),
scope: Scope::User,
attached_at: "2026-04-22T00:00:00Z".to_string(),
}]);
config::write(&rec).unwrap();
detach::run().expect("detach ok");
// After detach: `keisei` entry gone, `other` still present, marker gone.
let after: Value =
serde_json::from_str(&fs::read_to_string(&claude_settings).unwrap()).unwrap();
assert!(
after.get("mcpServers").and_then(|s| s.get("keisei")).is_none(),
"keisei entry survived detach: {after}"
);
assert!(
after.get("mcpServers").and_then(|s| s.get("other")).is_some(),
"pre-existing 'other' server lost"
);
assert!(config::read().unwrap().is_none(), "marker not deleted");
}
#[test]
fn scope_auto_resolves_to_project_when_cwd_has_dot_claude() {
let _g = setup_home();
// CWD carries `.claude/`.
let workdir = tempfile::tempdir().unwrap();
let prev_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(workdir.path()).unwrap();
fs::create_dir_all(workdir.path().join(".claude")).unwrap();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
// Auto should pick project scope because CWD has `.claude/`.
attach::run(brain_dir.path(), Scope::Auto).expect("attach auto ok");
let rec = config::read().unwrap().expect("record");
assert_eq!(rec.attachments.len(), 1);
assert_eq!(
rec.attachments[0].scope,
Scope::Project,
"auto should resolve to project when .claude/ exists in CWD"
);
// Project-local settings file was written.
let project_settings = workdir.path().join(".claude").join("settings.json");
assert!(project_settings.is_file());
std::env::set_current_dir(prev_cwd).unwrap();
}
#[test]
fn scope_auto_resolves_to_user_when_cwd_bare() {
let _g = setup_home();
// CWD has NO `.claude/`.
let workdir = tempfile::tempdir().unwrap();
let prev_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(workdir.path()).unwrap();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path(), Scope::Auto).expect("attach auto ok");
let rec = config::read().unwrap().expect("record");
assert_eq!(rec.attachments.len(), 1);
assert_eq!(
rec.attachments[0].scope,
Scope::User,
"auto should resolve to user when CWD is bare"
);
std::env::set_current_dir(prev_cwd).unwrap();
}
#[test]
fn cursor_auto_scope_respects_cwd_dot_cursor() {
// Build the adapter directly; auto_scope is a pure CWD heuristic.
let _g = setup_home();
// Bare workdir → User.
let bare = tempfile::tempdir().unwrap();
let prev_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(bare.path()).unwrap();
let cursor = adapters::cursor::CursorAdapter::new();
use crate::adapter::ClientAdapter;
assert_eq!(cursor.auto_scope(), Scope::User);
// `.cursor/` present → Project.
let withdir = tempfile::tempdir().unwrap();
std::env::set_current_dir(withdir.path()).unwrap();
fs::create_dir_all(withdir.path().join(".cursor")).unwrap();
assert_eq!(cursor.auto_scope(), Scope::Project);
std::env::set_current_dir(prev_cwd).unwrap();
}
#[test]
fn post_attach_hint_interpolates_brain_name() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
let brain = brain::Brain::load(brain_dir.path()).expect("brain loads");
use crate::adapter::ClientAdapter;
let claude = adapters::claude_code::ClaudeCodeAdapter::new();
let hint = claude.post_attach_hint(&brain, Scope::User);
assert!(hint.contains("test-brain"), "brain name missing: {hint}");
assert!(hint.contains("user"), "scope missing: {hint}");
// Project scope should show `project` literally.
let hint_p = claude.post_attach_hint(&brain, Scope::Project);
assert!(hint_p.contains("project"), "project scope missing: {hint_p}");
}
#[test]
fn adapter_registry_lists_all_four() {
// Registry is the single place new adapters plug in — verify it
// returns every adapter the CLI supports.
let names: Vec<String> = adapters::_registry::all_adapters()
.iter()
.map(|a| a.name().to_string())
.collect();
assert_eq!(names.len(), 4, "registry should list exactly 4 adapters");
for expected in &["claude-code", "cursor", "continue", "zed"] {
assert!(
names.contains(&expected.to_string()),
"registry missing {expected}: {names:?}"
);
}
// adapter::all() must delegate to the registry — returns the same names
// in the same order.
let via_adapter: Vec<String> = adapter::all()
.iter()
.map(|a| a.name().to_string())
.collect();
assert_eq!(via_adapter, names);
}
#[test]
fn dead_error_variants_removed() {
// NotAttached + AdapterFailed were removed in v0.22. Compile-time grep
// of error.rs: the strings must NOT appear.
let src = include_str!("../src/error.rs");
assert!(
!src.contains("NotAttached"),
"Error::NotAttached should be removed"
);
assert!(
!src.contains("AdapterFailed"),
"Error::AdapterFailed should be removed"
);
}
#[test]
fn time_now_utc_string_has_rfc3339_shape() {
let s = crate::time::now_utc_string();
// Form: YYYY-MM-DDThh:mm:ssZ, exactly 20 bytes.
assert_eq!(s.len(), 20, "timestamp wrong length: {s}");
assert!(s.ends_with('Z'));
assert_eq!(s.chars().nth(4), Some('-'));
assert_eq!(s.chars().nth(7), Some('-'));
assert_eq!(s.chars().nth(10), Some('T'));
assert_eq!(s.chars().nth(13), Some(':'));
assert_eq!(s.chars().nth(16), Some(':'));
}
#[test]
fn fresh_marker_has_schema_version_4() {
let _g = setup_home();
let brain_dir = tempfile::tempdir().unwrap();
write_brain(brain_dir.path(), 1);
attach::run(brain_dir.path(), Scope::User).expect("attach ok");
// Raw file must contain `schema_version = 4` at the top.
let marker = config::attached_path();
let raw = fs::read_to_string(&marker).unwrap();
assert!(
raw.contains("schema_version = 4"),
"fresh v0.22 marker should have schema_version = 4; got: {raw}"
);
}