use crate::brain::tools::write_opencrabs_file::*;
use crate::brain::tools::{Tool, ToolExecutionContext};
use tempfile::TempDir;
use uuid::Uuid;
fn ctx() -> ToolExecutionContext {
ToolExecutionContext::new(Uuid::new_v4())
}
fn tool() -> WriteOpenCrabsFileTool {
WriteOpenCrabsFileTool
}
#[test]
fn test_tool_name_and_requires_approval() {
let t = tool();
assert_eq!(t.name(), "write_opencrabs_file");
assert!(t.requires_approval(), "writes must require approval");
}
#[test]
fn test_input_schema_required_fields() {
let schema = tool().input_schema();
let required = schema["required"].as_array().unwrap();
assert!(required.iter().any(|v| v.as_str() == Some("path")));
assert!(required.iter().any(|v| v.as_str() == Some("operation")));
}
fn named_profile_home(dir: &TempDir, name: &str) -> std::path::PathBuf {
dir.path().join(".opencrabs").join("profiles").join(name)
}
fn default_home(dir: &TempDir) -> std::path::PathBuf {
dir.path().join(".opencrabs")
}
#[test]
fn test_home_is_named_profile_predicate() {
let dir = TempDir::new().unwrap();
assert!(
home_is_named_profile(&named_profile_home(&dir, "ops")),
"base/profiles/ops is a named-profile home"
);
assert!(
home_is_named_profile(&named_profile_home(&dir, "family")),
"base/profiles/family is a named-profile home"
);
assert!(
!home_is_named_profile(&default_home(&dir)),
"base is NOT a named-profile home"
);
assert!(
!home_is_named_profile(&dir.path().join("profiles")),
"a directory merely named profiles (no name segment under it) is not a profile home"
);
}
#[test]
fn test_leading_profiles_segment_predicate() {
assert!(leading_profiles_segment("profiles/ops/TOOLS.md"));
assert!(
leading_profiles_segment("./profiles/ops/TOOLS.md"),
"a leading CurDir must be skipped, not treated as the first segment"
);
assert!(leading_profiles_segment("profiles"));
assert!(!leading_profiles_segment("MEMORY.md"));
assert!(!leading_profiles_segment("memory/profiles/note.md"));
assert!(!leading_profiles_segment(""));
}
#[test]
fn test_empty_path_rejected() {
let dir = TempDir::new().unwrap();
let home = named_profile_home(&dir, "ops");
assert!(validate_opencrabs_path(&home, "").is_err());
}
#[test]
fn test_absolute_path_rejected() {
let dir = TempDir::new().unwrap();
let home = named_profile_home(&dir, "ops");
assert!(validate_opencrabs_path(&home, "/etc/passwd").is_err());
assert!(validate_opencrabs_path(&home, "~/MEMORY.md").is_err());
}
#[test]
fn test_path_traversal_rejected() {
let dir = TempDir::new().unwrap();
let home = named_profile_home(&dir, "ops");
assert!(validate_opencrabs_path(&home, "../etc/passwd").is_err());
assert!(validate_opencrabs_path(&home, "../../secrets").is_err());
assert!(validate_opencrabs_path(&home, "subdir/../../etc/passwd").is_err());
}
#[test]
fn test_valid_brain_files_accepted() {
let dir = TempDir::new().unwrap();
let home = named_profile_home(&dir, "ops");
assert!(validate_opencrabs_path(&home, "MEMORY.md").is_ok());
assert!(validate_opencrabs_path(&home, "USER.md").is_ok());
assert!(validate_opencrabs_path(&home, "SOUL.md").is_ok());
}
#[test]
fn test_valid_config_files_accepted() {
let dir = TempDir::new().unwrap();
let home = named_profile_home(&dir, "ops");
assert!(validate_opencrabs_path(&home, "commands.toml").is_ok());
assert!(validate_opencrabs_path(&home, "config.toml").is_ok());
}
#[test]
fn test_valid_subdirectory_paths_accepted() {
let dir = TempDir::new().unwrap();
let home = named_profile_home(&dir, "ops");
assert!(validate_opencrabs_path(&home, "memory/2026-03-02.md").is_ok());
assert!(validate_opencrabs_path(&home, "agents/session/context.json").is_ok());
}
#[test]
fn test_profiles_prefix_duplication_rejected() {
let dir = TempDir::new().unwrap();
let home = named_profile_home(&dir, "ops");
let result = validate_opencrabs_path(&home, "profiles/ops/TOOLS.md");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("duplicates it"),
"Error message should name the real rule: {err}"
);
assert!(
err.contains("profiles/ops/TOOLS.md"),
"Error should echo the bad path: {err}"
);
assert!(validate_opencrabs_path(&home, "profiles/ops/MEMORY.md").is_err());
assert!(
validate_opencrabs_path(&home, "./profiles/ops/TOOLS.md").is_err(),
"the leading './' spelling must not bypass the gate"
);
}
#[test]
fn test_profiles_sibling_path_accepted_for_default_home() {
let dir = TempDir::new().unwrap();
let home = default_home(&dir);
assert!(
validate_opencrabs_path(&home, "profiles/family/USER.md").is_ok(),
"profiles/family/USER.md is the sibling profile's real home"
);
assert!(
validate_opencrabs_path(&home, "profiles/ops/TOOLS.md").is_ok(),
"profiles/ops/TOOLS.md is the sibling profile's real home"
);
assert!(
validate_opencrabs_path(&home, "./profiles/family/USER.md").is_ok(),
"identical target, identical verdict — the ./ spelling is accepted too"
);
}
#[test]
fn test_default_home_still_rejects_traversal() {
let dir = TempDir::new().unwrap();
let home = default_home(&dir);
assert!(validate_opencrabs_path(&home, "../etc/passwd").is_err());
assert!(validate_opencrabs_path(&home, "/etc/passwd").is_err());
assert!(validate_opencrabs_path(&home, "~/MEMORY.md").is_err());
assert!(validate_opencrabs_path(&home, "").is_err());
}
#[tokio::test]
async fn test_unknown_operation_returns_error() {
let result = tool()
.execute(
serde_json::json!({"path": "MEMORY.md", "operation": "delete"}),
&ctx(),
)
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("Unknown operation"));
}
#[tokio::test]
async fn test_overwrite_missing_content_returns_error() {
let result = tool()
.execute(
serde_json::json!({"path": "MEMORY.md", "operation": "overwrite"}),
&ctx(),
)
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("content is required"));
}
#[tokio::test]
async fn test_append_missing_content_returns_error() {
let result = tool()
.execute(
serde_json::json!({"path": "MEMORY.md", "operation": "append"}),
&ctx(),
)
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("content is required"));
}
#[tokio::test]
async fn test_replace_missing_old_text_returns_error() {
let result = tool()
.execute(
serde_json::json!({"path": "MEMORY.md", "operation": "replace", "new_text": "x"}),
&ctx(),
)
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("old_text is required"));
}
#[tokio::test]
async fn test_replace_missing_new_text_returns_error() {
let result = tool()
.execute(
serde_json::json!({"path": "MEMORY.md", "operation": "replace", "old_text": "x"}),
&ctx(),
)
.await
.unwrap();
assert!(!result.success);
assert!(result.error.unwrap().contains("new_text is required"));
}
#[test]
fn test_overwrite_roundtrip_via_fs() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("MEMORY.md");
std::fs::write(&path, "initial content").unwrap();
std::fs::write(&path, "updated content").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "updated content");
}
#[test]
fn test_append_roundtrip_via_fs() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("MEMORY.md");
std::fs::write(&path, "## Section\n").unwrap();
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(&path)
.unwrap();
f.write_all(b"\nnew note").unwrap();
let result = std::fs::read_to_string(&path).unwrap();
assert!(result.contains("## Section"));
assert!(result.contains("new note"));
}
#[test]
fn test_replace_roundtrip_via_fs() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("MEMORY.md");
std::fs::write(&path, "old value here").unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let updated = content.replacen("old value", "new value", 1);
std::fs::write(&path, &updated).unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "new value here");
}
#[test]
fn test_subdirectory_created_on_write() {
let dir = TempDir::new().unwrap();
let subdir = dir.path().join("memory");
let path = subdir.join("note.md");
std::fs::create_dir_all(&subdir).unwrap();
std::fs::write(&path, "content").unwrap();
assert!(path.exists());
}
#[test]
fn test_replace_nfc_normalizes_both_sides() {
use unicode_normalization::UnicodeNormalization;
let dir = TempDir::new().unwrap();
let path = dir.path().join("MEMORY.md");
let nfd_content: String = "café résumé".nfd().collect();
std::fs::write(&path, &nfd_content).unwrap();
let search_nfc: String = "café".nfc().collect();
let replace_nfc: String = "coffee".nfc().collect();
let search_nfd: String = search_nfc.nfd().collect();
assert_ne!(
search_nfc.as_bytes(),
search_nfd.as_bytes(),
"NFC and NFD should differ for accented chars"
);
let raw_file = std::fs::read_to_string(&path).unwrap();
assert!(
!raw_file.contains(search_nfc.as_str()),
"raw contains should fail with mixed normalization"
);
let file_nfc: String = raw_file.nfc().collect();
let search_nfc_norm: String = search_nfc.nfc().collect();
assert!(
file_nfc.contains(search_nfc_norm.as_str()),
"NFC-normalized contains should match"
);
let updated = file_nfc.replacen(search_nfc_norm.as_str(), replace_nfc.as_str(), 1);
std::fs::write(&path, &updated).unwrap();
let result = std::fs::read_to_string(&path).unwrap();
assert!(
result.contains("coffee"),
"replacement should have succeeded: got {:?}",
result
);
assert!(
result.contains("résumé"),
"unrelated text should be preserved: got {:?}",
result
);
}
#[test]
fn test_replace_unicode_symbols_via_nfc() {
use unicode_normalization::UnicodeNormalization;
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.md");
let content = "price: 10×2 ≤ 25 — final";
std::fs::write(&path, content).unwrap();
let search = "10×2";
let replacement = "10x2";
let raw = std::fs::read_to_string(&path).unwrap();
let file_nfc: String = raw.nfc().collect();
let search_nfc: String = search.nfc().collect();
assert!(
file_nfc.contains(search_nfc.as_str()),
"multiplication sign should match after NFC normalization"
);
let updated = file_nfc.replacen(search_nfc.as_str(), replacement, 1);
std::fs::write(&path, &updated).unwrap();
let result = std::fs::read_to_string(&path).unwrap();
assert!(
result.contains("10x2"),
"replacement succeeded: {:?}",
result
);
assert!(
result.contains("≤"),
"other Unicode preserved: {:?}",
result
);
assert!(result.contains("—"), "em-dash preserved: {:?}", result);
}
#[tokio::test]
async fn test_execute_writes_real_file_under_temp_home() {
use crate::config::profile::with_home_override_async;
let dir = TempDir::new().unwrap();
let home = default_home(&dir);
std::fs::create_dir_all(&home).unwrap();
let ctx = ctx();
let t = tool();
let result = with_home_override_async(home.clone(), async {
t.execute(
serde_json::json!({
"path": "memory/2026-03-02.md",
"operation": "overwrite",
"content": "hello from the boundary test",
}),
&ctx,
)
.await
})
.await
.unwrap();
assert!(result.success, "write should succeed: {:?}", result.error);
let landed = std::fs::read_to_string(home.join("memory/2026-03-02.md")).unwrap();
assert_eq!(landed, "hello from the boundary test");
}
#[tokio::test]
async fn test_execute_rejects_profiles_prefix_and_writes_nothing() {
use crate::config::profile::with_home_override_async;
let dir = TempDir::new().unwrap();
let home = named_profile_home(&dir, "ops");
std::fs::create_dir_all(&home).unwrap();
let ctx = ctx();
let t = tool();
for spelling in ["profiles/ops/TOOLS.md", "./profiles/ops/TOOLS.md"] {
let result = with_home_override_async(home.clone(), async {
t.execute(
serde_json::json!({
"path": spelling,
"operation": "overwrite",
"content": "must never land",
}),
&ctx,
)
.await
})
.await
.unwrap();
assert!(!result.success, "{spelling} must be rejected");
assert!(
result
.error
.as_deref()
.unwrap_or("")
.contains("duplicates it"),
"{spelling}: error should explain the duplication, got {:?}",
result.error
);
assert!(
!home.join(spelling).exists(),
"{spelling} must not exist on disk"
);
}
}