use crate::brain::tools::plan_tool::{
MAX_CONTEXT_LENGTH, MAX_DESCRIPTION_LENGTH, MAX_PLAN_FILE_SIZE, MAX_TITLE_LENGTH, PlanTool,
default_complexity, validate_plan_file_path, validate_string,
};
use crate::brain::tools::{Tool, ToolExecutionContext};
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn validate_path_within_working_directory() {
let temp_dir = TempDir::new().unwrap();
let working_dir = temp_dir.path();
let session_id = uuid::Uuid::new_v4();
let plan_file = working_dir.join(format!(".opencrabs_plan_{}.json", session_id));
let result = validate_plan_file_path(&plan_file, working_dir);
assert!(result.is_ok());
}
#[test]
fn validate_path_outside_working_directory() {
let temp_dir = TempDir::new().unwrap();
let working_dir = temp_dir.path();
let session_id = uuid::Uuid::new_v4();
let plan_file = PathBuf::from("/tmp").join(format!(".opencrabs_plan_{}.json", session_id));
let result = validate_plan_file_path(&plan_file, working_dir);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("within the session directory")
);
}
#[test]
fn validate_path_traversal_attack() {
let temp_dir = TempDir::new().unwrap();
let working_dir = temp_dir.path();
let session_id = uuid::Uuid::new_v4();
let parent = working_dir.parent().unwrap_or(working_dir);
let plan_file = parent.join(format!(".opencrabs_plan_{}.json", session_id));
let result = validate_plan_file_path(&plan_file, working_dir);
assert!(result.is_err());
}
#[test]
fn validate_filename_pattern() {
let temp_dir = TempDir::new().unwrap();
let working_dir = temp_dir.path();
let plan_file = working_dir.join("invalid_plan.json");
let result = validate_plan_file_path(&plan_file, working_dir);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("must match pattern")
);
}
#[test]
fn validate_filename_requires_uuid() {
let temp_dir = TempDir::new().unwrap();
let working_dir = temp_dir.path();
let plan_file = working_dir.join(".opencrabs_plan_not-a-uuid.json");
let result = validate_plan_file_path(&plan_file, working_dir);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("valid UUID"));
}
#[test]
#[cfg(unix)]
fn validate_symlink_rejection() {
use std::os::unix::fs::symlink;
let temp_dir = TempDir::new().unwrap();
let working_dir = temp_dir.path();
let session_id = uuid::Uuid::new_v4();
let target_file = working_dir.join("target.json");
let plan_file = working_dir.join(format!(".opencrabs_plan_{}.json", session_id));
std::fs::write(&target_file, "{}").unwrap();
symlink(&target_file, &plan_file).unwrap();
let result = validate_plan_file_path(&plan_file, working_dir);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("symlink"));
}
#[test]
fn validate_string_empty() {
let result = validate_string("", 100, "Test field");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
}
#[test]
fn validate_string_whitespace_only() {
let result = validate_string(" ", 100, "Test field");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
}
#[test]
fn validate_string_exceeds_max_length() {
let long_string = "a".repeat(300);
let result = validate_string(&long_string, MAX_TITLE_LENGTH, "Title");
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("exceeds maximum length")
);
}
#[test]
fn validate_string_valid() {
let result = validate_string("Valid title", MAX_TITLE_LENGTH, "Title");
assert!(result.is_ok());
}
#[test]
fn max_plan_file_size_constant() {
assert_eq!(MAX_PLAN_FILE_SIZE, 10 * 1024 * 1024);
}
#[test]
fn input_validation_limits() {
assert_eq!(MAX_TITLE_LENGTH, 200);
assert_eq!(MAX_DESCRIPTION_LENGTH, 5000);
assert_eq!(MAX_CONTEXT_LENGTH, 5000);
}
#[test]
fn default_complexity_is_three() {
assert_eq!(default_complexity(), 3);
}
#[test]
fn validate_title_at_limit() {
let title = "a".repeat(MAX_TITLE_LENGTH);
let result = validate_string(&title, MAX_TITLE_LENGTH, "Title");
assert!(result.is_ok());
}
#[test]
fn validate_title_one_over_limit() {
let title = "a".repeat(MAX_TITLE_LENGTH + 1);
let result = validate_string(&title, MAX_TITLE_LENGTH, "Title");
assert!(result.is_err());
}
#[test]
fn validate_description_at_limit() {
let desc = "a".repeat(MAX_DESCRIPTION_LENGTH);
let result = validate_string(&desc, MAX_DESCRIPTION_LENGTH, "Description");
assert!(result.is_ok());
}
#[test]
fn validate_context_at_limit() {
let context = "a".repeat(MAX_CONTEXT_LENGTH);
let result = validate_string(&context, MAX_CONTEXT_LENGTH, "Context");
assert!(result.is_ok());
}
#[test]
fn filename_with_special_characters() {
let temp_dir = TempDir::new().unwrap();
let working_dir = temp_dir.path();
let plan_file = working_dir.join(".opencrabs_plan_../../etc/passwd.json");
let result = validate_plan_file_path(&plan_file, working_dir);
assert!(result.is_err());
}
#[test]
fn filename_with_null_byte() {
let temp_dir = TempDir::new().unwrap();
let working_dir = temp_dir.path();
let session_id = uuid::Uuid::new_v4();
let filename = format!(".opencrabs_plan_{}\0.json", session_id);
let plan_file = working_dir.join(filename);
let result = validate_plan_file_path(&plan_file, working_dir);
assert!(result.is_err() || plan_file.to_str().is_none());
}
#[test]
fn validate_plan_file_path_canonical() {
let temp_dir = TempDir::new().unwrap();
let working_dir = temp_dir.path();
let session_id = uuid::Uuid::new_v4();
let plan_file = working_dir.join(format!("./.opencrabs_plan_{}.json", session_id));
let result = validate_plan_file_path(&plan_file, working_dir);
let _ = result;
}
#[tokio::test]
async fn import_sample_plan_succeeds() {
let json = include_str!("../brain/tools/test_data/sample-coding-plan.json");
let tmp_dir = TempDir::new().unwrap();
let plan_file = tmp_dir.path().join("sample-coding-plan.json");
std::fs::write(&plan_file, json).unwrap();
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
let tool = PlanTool;
let input = serde_json::json!({
"operation": "init",
"file_path": plan_file.to_str().unwrap(),
});
let result = tool.execute(input, &ctx).await.unwrap();
assert!(result.success, "import must succeed on the sample plan");
assert!(result.output.contains("Imported plan"));
assert!(result.output.contains("7 tasks"));
}
#[tokio::test]
async fn import_rejects_file_over_size_cap() {
let tmp_dir = TempDir::new().unwrap();
let plan_file = tmp_dir.path().join("too_big.json");
let payload = vec![b'a'; 10 * 1024 * 1024 + 1];
std::fs::write(&plan_file, payload).unwrap();
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
let tool = PlanTool;
let input = serde_json::json!({
"operation": "init",
"file_path": plan_file.to_str().unwrap(),
});
let err = tool
.execute(input, &ctx)
.await
.expect_err("oversize import must error");
let msg = err.to_string();
assert!(
msg.contains("too large"),
"expected 'too large' size-cap error, got: {msg}"
);
}
#[tokio::test]
async fn import_rejects_invalid_json() {
let tmp_dir = TempDir::new().unwrap();
let plan_file = tmp_dir.path().join("bad.json");
std::fs::write(&plan_file, "{this is not valid json").unwrap();
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
let tool = PlanTool;
let input = serde_json::json!({
"operation": "init",
"file_path": plan_file.to_str().unwrap(),
});
let err = tool
.execute(input, &ctx)
.await
.expect_err("malformed JSON import must error");
let msg = err.to_string();
assert!(
msg.contains("Invalid plan JSON"),
"expected 'Invalid plan JSON' error, got: {msg}"
);
}
#[tokio::test]
async fn import_rejects_orphan_dependency_uuid() {
let bad_json = r#"{
"id": "00000000-0000-0000-0000-000000000000",
"session_id": "00000000-0000-0000-0000-000000000000",
"title": "Bad Deps",
"description": "Has a dep on a UUID not in the task list",
"status": "Draft",
"context": "",
"risks": [],
"test_strategy": "",
"technical_stack": [],
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
"approved_at": null,
"tasks": [
{
"id": "11111111-1111-1111-1111-111111111111",
"order": 1,
"title": "Orphan dep task",
"description": "Depends on a uuid that isn't here",
"task_type": "Edit",
"dependencies": ["99999999-9999-9999-9999-999999999999"],
"complexity": 1,
"acceptance_criteria": [],
"status": "Pending",
"notes": null,
"completed_at": null
}
]
}"#;
let tmp_dir = TempDir::new().unwrap();
let plan_file = tmp_dir.path().join("orphan_dep.json");
std::fs::write(&plan_file, bad_json).unwrap();
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
let tool = PlanTool;
let input = serde_json::json!({
"operation": "init",
"file_path": plan_file.to_str().unwrap(),
});
let err = tool
.execute(input, &ctx)
.await
.expect_err("orphan-dep import must error");
let msg = err.to_string();
assert!(
msg.contains("depends on unknown task id"),
"expected orphan-dep error, got: {msg}"
);
}
#[tokio::test]
#[cfg(unix)]
async fn import_rejects_symlink_at_target() {
let tmp_dir = TempDir::new().unwrap();
let real_file = tmp_dir.path().join("real.json");
std::fs::write(&real_file, "{}").unwrap();
let symlink_path = tmp_dir.path().join("link.json");
std::os::unix::fs::symlink(&real_file, &symlink_path).unwrap();
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
let tool = PlanTool;
let input = serde_json::json!({
"operation": "init",
"file_path": symlink_path.to_str().unwrap(),
});
let err = tool
.execute(input, &ctx)
.await
.expect_err("symlink target import must error");
let msg = err.to_string();
assert!(
msg.contains("symlink"),
"expected symlink rejection, got: {msg}"
);
}
async fn setup_plan_with_tasks(tool: &PlanTool, n: usize) -> ToolExecutionContext {
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
tool.execute(
serde_json::json!({
"operation": "init",
"title": "Flow test",
"description": "Exercising the 4-command flow"
}),
&ctx,
)
.await
.unwrap();
for i in 1..=n {
tool.execute(
serde_json::json!({
"operation": "add_task",
"title": format!("Task {i}"),
"description": format!("Description for task {i}"),
"task_type": "edit"
}),
&ctx,
)
.await
.unwrap();
}
ctx
}
#[tokio::test]
async fn start_returns_full_task_details() {
let tool = PlanTool;
let ctx = setup_plan_with_tasks(&tool, 2).await;
let result = tool
.execute(serde_json::json!({ "operation": "start" }), &ctx)
.await
.unwrap();
assert!(result.success);
assert!(
result.output.contains("Task #1") && result.output.contains("Description for task 1"),
"start must surface full details of task 1, got: {}",
result.output
);
}
#[tokio::test]
async fn start_is_idempotent_on_in_progress_task() {
let tool = PlanTool;
let ctx = setup_plan_with_tasks(&tool, 2).await;
tool.execute(serde_json::json!({ "operation": "start" }), &ctx)
.await
.unwrap();
let again = tool
.execute(serde_json::json!({ "operation": "start" }), &ctx)
.await
.unwrap();
assert!(again.success);
assert!(
again.output.contains("Task #1"),
"start with no args must resume the in-progress task, got: {}",
again.output
);
}
#[tokio::test]
async fn complete_auto_starts_next_task() {
let tool = PlanTool;
let ctx = setup_plan_with_tasks(&tool, 2).await;
tool.execute(serde_json::json!({ "operation": "start" }), &ctx)
.await
.unwrap();
let result = tool
.execute(
serde_json::json!({
"operation": "complete",
"task_order": 1,
"action": "success",
"output": "Task 1 done"
}),
&ctx,
)
.await
.unwrap();
assert!(result.success);
assert!(
result.output.contains("Task #1") && result.output.contains("completed"),
"completion must confirm task 1, got: {}",
result.output
);
assert!(
result.output.contains("Started Task #2")
&& result.output.contains("Description for task 2"),
"complete must auto-start task 2 with its details, got: {}",
result.output
);
}
#[tokio::test]
async fn complete_last_task_reports_plan_complete() {
let tool = PlanTool;
let ctx = setup_plan_with_tasks(&tool, 1).await;
tool.execute(serde_json::json!({ "operation": "start" }), &ctx)
.await
.unwrap();
let result = tool
.execute(
serde_json::json!({
"operation": "complete",
"task_order": 1,
"action": "success"
}),
&ctx,
)
.await
.unwrap();
assert!(
result.output.contains("Plan complete"),
"finishing the last task must report plan completion, got: {}",
result.output
);
}
#[tokio::test]
async fn start_specific_task_blocked_by_dependency() {
let tool = PlanTool;
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
tool.execute(
serde_json::json!({ "operation": "init", "title": "Deps", "description": "dep test" }),
&ctx,
)
.await
.unwrap();
tool.execute(
serde_json::json!({ "operation": "add_task", "title": "First", "description": "the first", "task_type": "edit" }),
&ctx,
)
.await
.unwrap();
tool.execute(
serde_json::json!({ "operation": "add_task", "title": "Second", "description": "needs first", "task_type": "edit", "dependencies": [1] }),
&ctx,
)
.await
.unwrap();
let result = tool
.execute(
serde_json::json!({ "operation": "start", "task_order": 2 }),
&ctx,
)
.await
.unwrap();
assert!(!result.success, "blocked start must not succeed");
let msg = result.error.unwrap_or(result.output);
assert!(
msg.contains("blocked"),
"starting a task with unmet dependencies must report it blocked, got: {msg}"
);
}
#[tokio::test]
async fn init_with_inline_tasks_creates_plan_and_tasks() {
let tool = PlanTool;
let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
let result = tool
.execute(
serde_json::json!({
"operation": "init",
"title": "Inline",
"description": "created with inline tasks",
"tasks": [
{ "title": "Alpha", "description": "first", "task_type": "edit" },
{ "title": "Beta", "description": "second", "task_type": "test" }
]
}),
&ctx,
)
.await
.unwrap();
assert!(result.success);
assert!(
result.output.contains("2 tasks")
&& result.output.contains("Alpha")
&& result.output.contains("Beta"),
"init must create the plan with both inline tasks, got: {}",
result.output
);
}