Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,366 @@
//! Integration tests for EditTool / WriteTool file-state cache integration
//! (TC-5.4 and TC-5.4-W series).
//!
//! Black-box tests: exercise Edit/Write tools through their public API with
//! a real filesystem and shared FileStateCache, validating "must Read first"
//! guard, staleness detection, and post-write cache updates.
use std::path::Path;
use std::sync::{Arc, RwLock};
use serde_json::json;
use nomi_config::file_cache::FileCacheConfig;
use nomi_tools::Tool;
use nomi_tools::edit::EditTool;
use nomi_tools::file_cache::{FileStateCache, file_mtime_ms};
use nomi_tools::read::ReadTool;
use nomi_tools::write::WriteTool;
fn make_cache() -> Arc<RwLock<FileStateCache>> {
let config = FileCacheConfig {
max_entries: 100,
max_size_bytes: 25 * 1024 * 1024,
enabled: true,
};
Arc::new(RwLock::new(FileStateCache::new(&config)))
}
/// Populate cache by actually reading the file through ReadTool.
async fn read_file(tool: &ReadTool, path: &Path) {
let input = json!({ "file_path": path.to_str().unwrap() });
let r = tool.execute(input).await;
assert!(!r.is_error, "read failed: {}", r.content);
}
const UNCHANGED_MARKER: &str = "File unchanged since last read";
// ==========================================================================
// TC-5.4: EditTool guard and staleness detection
// ==========================================================================
/// TC-5.4-01: Normal Read → Edit succeeds.
#[tokio::test]
async fn tc_5_4_01_read_then_edit() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("normal.txt");
std::fs::write(&file, "hello world").unwrap();
let cache = make_cache();
let read_tool = ReadTool::new(Some(cache.clone()), None);
let edit_tool = EditTool::new(Some(cache));
read_file(&read_tool, &file).await;
let input = json!({
"file_path": file.to_str().unwrap(),
"old_string": "hello",
"new_string": "goodbye"
});
let result = edit_tool.execute(input).await;
assert!(
!result.is_error,
"Edit after Read should succeed: {}",
result.content
);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "goodbye world");
}
/// TC-5.4-02: Edit without prior Read returns "must Read first" error.
#[tokio::test]
async fn tc_5_4_02_edit_without_read() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("no_read.txt");
std::fs::write(&file, "content").unwrap();
let cache = make_cache();
let edit_tool = EditTool::new(Some(cache));
let input = json!({
"file_path": file.to_str().unwrap(),
"old_string": "content",
"new_string": "new"
});
let result = edit_tool.execute(input).await;
assert!(result.is_error, "Edit without Read should fail");
assert!(
result.content.contains("must Read"),
"Error should mention 'must Read': {}",
result.content
);
// File must be unchanged.
assert_eq!(std::fs::read_to_string(&file).unwrap(), "content");
}
/// TC-5.4-03: External modification after Read triggers staleness error.
#[tokio::test]
async fn tc_5_4_03_external_modification_detected() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("stale.txt");
std::fs::write(&file, "original content").unwrap();
let cache = make_cache();
let read_tool = ReadTool::new(Some(cache.clone()), None);
let edit_tool = EditTool::new(Some(cache));
read_file(&read_tool, &file).await;
// External modification.
std::thread::sleep(std::time::Duration::from_millis(50));
std::fs::write(&file, "externally changed").unwrap();
let input = json!({
"file_path": file.to_str().unwrap(),
"old_string": "original content",
"new_string": "new"
});
let result = edit_tool.execute(input).await;
assert!(
result.is_error,
"Edit of externally modified file should fail"
);
assert!(
result.content.contains("modified externally"),
"Error should mention external modification: {}",
result.content
);
}
/// TC-5.4-04: Edit → Edit succeeds because first Edit updates the cache.
#[tokio::test]
async fn tc_5_4_04_edit_then_edit() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("double.txt");
std::fs::write(&file, "aaa bbb ccc").unwrap();
let cache = make_cache();
let read_tool = ReadTool::new(Some(cache.clone()), None);
let edit_tool = EditTool::new(Some(cache));
read_file(&read_tool, &file).await;
// First edit.
let input1 = json!({
"file_path": file.to_str().unwrap(),
"old_string": "aaa",
"new_string": "AAA"
});
let r1 = edit_tool.execute(input1).await;
assert!(!r1.is_error, "First edit failed: {}", r1.content);
// Second edit — should work because first edit updated cache mtime.
let input2 = json!({
"file_path": file.to_str().unwrap(),
"old_string": "bbb",
"new_string": "BBB"
});
let r2 = edit_tool.execute(input2).await;
assert!(!r2.is_error, "Second edit failed: {}", r2.content);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "AAA BBB ccc");
}
/// TC-5.4-05: With cache disabled (None), Edit works without prior Read.
#[tokio::test]
async fn tc_5_4_05_no_cache_edit_bypasses_guard() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("nocache.txt");
std::fs::write(&file, "hello").unwrap();
let edit_tool = EditTool::new(None);
let input = json!({
"file_path": file.to_str().unwrap(),
"old_string": "hello",
"new_string": "bye"
});
let result = edit_tool.execute(input).await;
assert!(
!result.is_error,
"Edit without cache should succeed: {}",
result.content
);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "bye");
}
/// TC-5.4-06: replace_all updates cache mtime correctly.
#[tokio::test]
async fn tc_5_4_06_replace_all_updates_cache() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("replaceall.txt");
std::fs::write(&file, "x-x-x-x").unwrap();
let cache = make_cache();
let read_tool = ReadTool::new(Some(cache.clone()), None);
let edit_tool = EditTool::new(Some(cache.clone()));
read_file(&read_tool, &file).await;
let input = json!({
"file_path": file.to_str().unwrap(),
"old_string": "x",
"new_string": "y",
"replace_all": true
});
let result = edit_tool.execute(input).await;
assert!(!result.is_error, "replace_all failed: {}", result.content);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "y-y-y-y");
// Verify cache mtime matches disk.
let disk_mtime = file_mtime_ms(&file).unwrap();
let mut c = cache.write().unwrap();
let cached = c.get(&file).expect("file should be in cache");
assert_eq!(cached.mtime_ms, disk_mtime);
}
// ==========================================================================
// TC-5.4-W: WriteTool cache update
// ==========================================================================
/// TC-5.4-W01: Write then Read returns "unchanged" (Write populates cache).
#[tokio::test]
async fn tc_5_4_w01_write_then_read_dedup() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("write_read.txt");
let cache = make_cache();
let write_tool = WriteTool::new(Some(cache.clone()));
let read_tool = ReadTool::new(Some(cache), None);
// Write creates file and populates cache.
let write_input = json!({
"file_path": file.to_str().unwrap(),
"content": "written content"
});
let wr = write_tool.execute(write_input).await;
assert!(!wr.is_error, "write failed: {}", wr.content);
// Read immediately after: should return "unchanged" because Write
// already cached the content with the correct mtime.
let read_input = json!({ "file_path": file.to_str().unwrap() });
let rr = read_tool.execute(read_input).await;
assert!(!rr.is_error);
assert!(
rr.content.contains(UNCHANGED_MARKER),
"Read after Write should return unchanged stub, got: {}",
rr.content
);
}
/// TC-5.4-W02: Write then Edit succeeds (Write populates cache for Edit guard).
#[tokio::test]
async fn tc_5_4_w02_write_then_edit() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("write_edit.txt");
let cache = make_cache();
let write_tool = WriteTool::new(Some(cache.clone()));
let edit_tool = EditTool::new(Some(cache));
let write_input = json!({
"file_path": file.to_str().unwrap(),
"content": "hello world"
});
let wr = write_tool.execute(write_input).await;
assert!(!wr.is_error, "write failed: {}", wr.content);
let edit_input = json!({
"file_path": file.to_str().unwrap(),
"old_string": "hello",
"new_string": "goodbye"
});
let er = edit_tool.execute(edit_input).await;
assert!(
!er.is_error,
"Edit after Write should succeed: {}",
er.content
);
assert_eq!(std::fs::read_to_string(&file).unwrap(), "goodbye world");
}
/// TC-5.4-W03: Write → Write → Read returns fresh content (mtime updated).
#[tokio::test]
async fn tc_5_4_w03_write_overwrite_then_read() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("overwrite.txt");
let cache = make_cache();
let write_tool = WriteTool::new(Some(cache.clone()));
let read_tool = ReadTool::new(Some(cache), None);
// First write.
let w1 = json!({
"file_path": file.to_str().unwrap(),
"content": "version 1"
});
write_tool.execute(w1).await;
// Brief delay to change mtime.
std::thread::sleep(std::time::Duration::from_millis(50));
// Second write (overwrite).
let w2 = json!({
"file_path": file.to_str().unwrap(),
"content": "version 2"
});
write_tool.execute(w2).await;
// Read: cache was updated by second Write, so should see "unchanged"
// (cache content matches disk content with matching mtime).
let read_input = json!({ "file_path": file.to_str().unwrap() });
let rr = read_tool.execute(read_input).await;
assert!(!rr.is_error);
// The cache was updated by the second Write with the new content,
// so Read should hit the dedup path.
assert!(
rr.content.contains(UNCHANGED_MARKER),
"Read after second Write should dedup, got: {}",
rr.content
);
// Verify disk has version 2.
assert_eq!(std::fs::read_to_string(&file).unwrap(), "version 2");
}
// ==========================================================================
// Supplementary: Cross-tool interaction tests
// ==========================================================================
/// Read → Edit → Read should dedup (Edit updated the cache).
#[tokio::test]
async fn read_edit_read_dedup() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("cross.txt");
std::fs::write(&file, "alpha beta").unwrap();
let cache = make_cache();
let read_tool = ReadTool::new(Some(cache.clone()), None);
let edit_tool = EditTool::new(Some(cache));
// Read.
read_file(&read_tool, &file).await;
// Edit.
let edit_input = json!({
"file_path": file.to_str().unwrap(),
"old_string": "alpha",
"new_string": "ALPHA"
});
let er = edit_tool.execute(edit_input).await;
assert!(!er.is_error);
// Read again: Edit updated the cache, so Read should see "unchanged".
let read_input = json!({ "file_path": file.to_str().unwrap() });
let rr = read_tool.execute(read_input).await;
assert!(!rr.is_error);
assert!(
rr.content.contains(UNCHANGED_MARKER),
"Read after Edit should dedup, got: {}",
rr.content
);
}
@@ -0,0 +1,324 @@
//! Integration tests for FileStateCache (TC-5.2 series from test-plan.md).
//!
//! Black-box tests targeting the public API of FileStateCache without
//! depending on internal implementation details.
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use nomi_config::file_cache::FileCacheConfig;
use nomi_tools::file_cache::{FileStateCache, file_mtime_ms, update_cache_after_write};
use nomi_types::file_state::FileState;
fn default_config() -> FileCacheConfig {
FileCacheConfig {
max_entries: 100,
max_size_bytes: 25 * 1024 * 1024,
enabled: true,
}
}
fn make_state(content: &str, mtime_ms: u64) -> FileState {
FileState {
content: content.to_string(),
mtime_ms,
offset: None,
limit: None,
}
}
/// TC-5.2-01: Insert and retrieve a file state entry.
#[test]
fn tc_5_2_01_insert_and_retrieve() {
let mut cache = FileStateCache::new(&default_config());
let path = PathBuf::from("/home/user/project/main.rs");
let state = make_state(" 1\tfn main() {}", 1_700_000_000_000);
cache.insert(path.clone(), state);
let retrieved = cache.get(&path).expect("entry should exist");
assert_eq!(retrieved.content, " 1\tfn main() {}");
assert_eq!(retrieved.mtime_ms, 1_700_000_000_000);
assert!(retrieved.offset.is_none());
assert!(retrieved.limit.is_none());
}
/// TC-5.2-02: Getting a non-existent key returns None.
#[test]
fn tc_5_2_02_nonexistent_key() {
let mut cache = FileStateCache::new(&default_config());
assert!(cache.get(Path::new("/no/such/file.rs")).is_none());
}
/// TC-5.2-03: LRU eviction when count exceeds max_entries.
#[test]
fn tc_5_2_03_lru_count_eviction() {
let config = FileCacheConfig {
max_entries: 3,
max_size_bytes: 10_000_000,
enabled: true,
};
let mut cache = FileStateCache::new(&config);
cache.insert(PathBuf::from("/f1"), make_state("1", 1));
cache.insert(PathBuf::from("/f2"), make_state("2", 2));
cache.insert(PathBuf::from("/f3"), make_state("3", 3));
// 4th insert should evict /f1 (the LRU)
cache.insert(PathBuf::from("/f4"), make_state("4", 4));
assert!(
cache.get(Path::new("/f1")).is_none(),
"/f1 should be evicted"
);
assert!(cache.get(Path::new("/f2")).is_some());
assert!(cache.get(Path::new("/f3")).is_some());
assert!(cache.get(Path::new("/f4")).is_some());
assert_eq!(cache.len(), 3);
}
/// TC-5.2-04: Path normalization ensures equivalent paths hit the same slot.
#[test]
fn tc_5_2_04_path_normalization() {
let mut cache = FileStateCache::new(&default_config());
// Insert with redundant `..` in path
cache.insert(
PathBuf::from("/project/src/../lib/file.rs"),
make_state("content", 100),
);
// Retrieve using canonical-style path
let got = cache
.get(Path::new("/project/lib/file.rs"))
.expect("normalized path should hit cache");
assert_eq!(got.content, "content");
// Only one entry in cache
assert_eq!(cache.len(), 1);
}
/// TC-5.2-05: clear() removes all entries and resets size accounting.
#[test]
fn tc_5_2_05_clear() {
let mut cache = FileStateCache::new(&default_config());
cache.insert(PathBuf::from("/a"), make_state("aaa", 1));
cache.insert(PathBuf::from("/b"), make_state("bbb", 2));
cache.insert(PathBuf::from("/c"), make_state("ccc", 3));
assert_eq!(cache.len(), 3);
assert!(cache.current_size_bytes() > 0);
cache.clear();
assert_eq!(cache.len(), 0);
assert!(cache.is_empty());
assert_eq!(cache.current_size_bytes(), 0);
assert!(cache.get(Path::new("/a")).is_none());
assert!(cache.get(Path::new("/b")).is_none());
assert!(cache.get(Path::new("/c")).is_none());
}
/// TC-5.2-06: remove() deletes a specific entry and returns it.
#[test]
fn tc_5_2_06_remove() {
let mut cache = FileStateCache::new(&default_config());
cache.insert(PathBuf::from("/target"), make_state("data", 1));
cache.insert(PathBuf::from("/keep"), make_state("keep", 2));
let removed = cache.remove(Path::new("/target"));
assert!(removed.is_some());
assert_eq!(removed.unwrap().content, "data");
assert!(cache.get(Path::new("/target")).is_none());
assert!(cache.get(Path::new("/keep")).is_some());
assert_eq!(cache.len(), 1);
}
/// TC-5.2-07: Byte-size limit triggers LRU eviction of old entries.
#[test]
fn tc_5_2_07_byte_size_eviction() {
let config = FileCacheConfig {
max_entries: 100,
max_size_bytes: 15, // tight byte budget
enabled: true,
};
let mut cache = FileStateCache::new(&config);
// Insert two 6-byte entries: total = 12, within budget
cache.insert(PathBuf::from("/a"), make_state("aaaaaa", 1)); // 6 bytes
cache.insert(PathBuf::from("/b"), make_state("bbbbbb", 2)); // 6 bytes
assert_eq!(cache.len(), 2);
assert_eq!(cache.current_size_bytes(), 12);
// Insert 6-byte entry: 12 + 6 = 18 > 15 -> evicts /a (LRU), total = 12
cache.insert(PathBuf::from("/c"), make_state("cccccc", 3));
assert!(cache.get(Path::new("/a")).is_none(), "/a should be evicted");
assert!(cache.get(Path::new("/b")).is_some());
assert!(cache.get(Path::new("/c")).is_some());
assert!(cache.current_size_bytes() <= 15);
}
/// TC-5.2-08: Inserting the same path twice updates (overwrites) the entry.
#[test]
fn tc_5_2_08_overwrite_update() {
let mut cache = FileStateCache::new(&default_config());
cache.insert(PathBuf::from("/file"), make_state("version1", 100));
cache.insert(PathBuf::from("/file"), make_state("version2-updated", 200));
let got = cache.get(Path::new("/file")).expect("entry should exist");
assert_eq!(got.content, "version2-updated");
assert_eq!(got.mtime_ms, 200);
assert_eq!(cache.len(), 1);
assert_eq!(cache.current_size_bytes(), "version2-updated".len());
}
/// Supplementary: LRU promotion via get() prevents eviction of accessed entries.
#[test]
fn lru_promotion_via_get() {
let config = FileCacheConfig {
max_entries: 3,
max_size_bytes: 10_000_000,
enabled: true,
};
let mut cache = FileStateCache::new(&config);
cache.insert(PathBuf::from("/oldest"), make_state("o", 1));
cache.insert(PathBuf::from("/middle"), make_state("m", 2));
cache.insert(PathBuf::from("/newest"), make_state("n", 3));
// Access /oldest to promote it; /middle becomes the new LRU
cache.get(Path::new("/oldest"));
// Insert /extra -> evicts /middle (now the LRU)
cache.insert(PathBuf::from("/extra"), make_state("e", 4));
assert!(
cache.get(Path::new("/oldest")).is_some(),
"/oldest was promoted and should survive"
);
assert!(
cache.get(Path::new("/middle")).is_none(),
"/middle should be evicted as the new LRU"
);
}
/// Supplementary: remove on a non-existent key returns None without panic.
#[test]
fn remove_nonexistent_returns_none() {
let mut cache = FileStateCache::new(&default_config());
assert!(cache.remove(Path::new("/ghost")).is_none());
}
/// Supplementary: partial read state (offset + limit) is preserved.
#[test]
fn partial_read_state_round_trip() {
let mut cache = FileStateCache::new(&default_config());
let state = FileState {
content: "partial".to_string(),
mtime_ms: 999,
offset: Some(10),
limit: Some(20),
};
cache.insert(PathBuf::from("/partial"), state);
let got = cache.get(Path::new("/partial")).unwrap();
assert_eq!(got.offset, Some(10));
assert_eq!(got.limit, Some(20));
}
// ==========================================================================
// TC-5.5: update_cache_after_write helper and config integration
// ==========================================================================
/// TC-5.5-01: Cache created with custom max_entries has correct capacity.
#[test]
fn tc_5_5_01_custom_capacity() {
let config = FileCacheConfig {
max_entries: 50,
max_size_bytes: 25 * 1024 * 1024,
enabled: true,
};
let mut cache = FileStateCache::new(&config);
// Insert 50 entries: all should fit.
for i in 0..50 {
cache.insert(PathBuf::from(format!("/f{}", i)), make_state("x", i));
}
assert_eq!(cache.len(), 50);
// 51st entry evicts the LRU.
cache.insert(PathBuf::from("/f50"), make_state("x", 50));
assert_eq!(cache.len(), 50);
assert!(
cache.get(Path::new("/f0")).is_none(),
"/f0 should be evicted at capacity 50"
);
}
/// update_cache_after_write stores line-numbered content with correct mtime.
#[test]
fn update_cache_after_write_stores_numbered_content() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("helper_test.txt");
let content = "line one\nline two\nline three";
std::fs::write(&file, content).unwrap();
let cache_arc = Arc::new(RwLock::new(FileStateCache::new(&default_config())));
update_cache_after_write(&cache_arc, &file, content);
let mut cache = cache_arc.write().unwrap();
let cached = cache.get(&file).expect("entry should exist after update");
// Content should be line-numbered.
assert!(cached.content.contains(" 1\tline one"));
assert!(cached.content.contains(" 2\tline two"));
assert!(cached.content.contains(" 3\tline three"));
// Mtime should match disk.
let disk_mtime = file_mtime_ms(&file).unwrap();
assert_eq!(cached.mtime_ms, disk_mtime);
// Offset and limit should be None (full file).
assert!(cached.offset.is_none());
assert!(cached.limit.is_none());
}
/// update_cache_after_write handles empty content.
#[test]
fn update_cache_after_write_empty_content() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("empty.txt");
std::fs::write(&file, "").unwrap();
let cache_arc = Arc::new(RwLock::new(FileStateCache::new(&default_config())));
update_cache_after_write(&cache_arc, &file, "");
let mut cache = cache_arc.write().unwrap();
let cached = cache.get(&file).expect("entry should exist");
assert_eq!(cached.content, "");
}
/// update_cache_after_write overwrites previous entry.
#[test]
fn update_cache_after_write_overwrites_previous() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("overwrite.txt");
std::fs::write(&file, "v1").unwrap();
let cache_arc = Arc::new(RwLock::new(FileStateCache::new(&default_config())));
update_cache_after_write(&cache_arc, &file, "v1");
// Brief delay for mtime change.
std::thread::sleep(std::time::Duration::from_millis(50));
std::fs::write(&file, "v2 updated").unwrap();
update_cache_after_write(&cache_arc, &file, "v2 updated");
let mut cache = cache_arc.write().unwrap();
let cached = cache.get(&file).unwrap();
assert!(cached.content.contains("v2 updated"));
assert_eq!(cached.mtime_ms, file_mtime_ms(&file).unwrap());
}
@@ -0,0 +1,254 @@
//! Integration tests for ReadTool dedup and cache integration (TC-5.3 series).
//!
//! Black-box tests: exercise ReadTool through its public API with a real
//! filesystem, validating dedup detection and cache update behavior.
use std::sync::{Arc, RwLock};
use serde_json::json;
use nomi_config::file_cache::FileCacheConfig;
use nomi_tools::Tool;
use nomi_tools::file_cache::FileStateCache;
use nomi_tools::read::ReadTool;
fn make_cache() -> Arc<RwLock<FileStateCache>> {
let config = FileCacheConfig {
max_entries: 100,
max_size_bytes: 25 * 1024 * 1024,
enabled: true,
};
Arc::new(RwLock::new(FileStateCache::new(&config)))
}
const UNCHANGED_MARKER: &str = "File unchanged since last read";
/// TC-5.3-01: First read returns full content with line numbers.
#[tokio::test]
async fn tc_5_3_01_first_read_returns_full_content() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("hello.rs");
std::fs::write(&file, "fn main() {\n println!(\"hello\");\n}\n").unwrap();
let cache = make_cache();
let tool = ReadTool::new(Some(cache), None);
let input = json!({ "file_path": file.to_str().unwrap() });
let result = tool.execute(input).await;
assert!(!result.is_error);
assert!(result.content.contains("1\tfn main()"));
assert!(result.content.contains("2\t println!"));
assert!(result.content.contains("3\t}"));
assert!(
!result.content.contains(UNCHANGED_MARKER),
"First read must not return the unchanged stub"
);
}
/// TC-5.3-02: Second read of the same unchanged file returns the dedup stub.
#[tokio::test]
async fn tc_5_3_02_dedup_on_unchanged_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("stable.txt");
std::fs::write(&file, "line one\nline two\n").unwrap();
let cache = make_cache();
let tool = ReadTool::new(Some(cache), None);
let input = json!({ "file_path": file.to_str().unwrap() });
// First read: full content.
let r1 = tool.execute(input.clone()).await;
assert!(!r1.is_error);
assert!(r1.content.contains("line one"));
// Second read: unchanged stub.
let r2 = tool.execute(input).await;
assert!(!r2.is_error);
assert!(
r2.content.contains(UNCHANGED_MARKER),
"Second read of unchanged file should return the dedup stub"
);
}
/// TC-5.3-03: After external modification, re-read returns new content.
#[tokio::test]
async fn tc_5_3_03_modified_file_returns_new_content() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("evolving.txt");
std::fs::write(&file, "version 1\n").unwrap();
let cache = make_cache();
let tool = ReadTool::new(Some(cache), None);
let input = json!({ "file_path": file.to_str().unwrap() });
let r1 = tool.execute(input.clone()).await;
assert!(r1.content.contains("version 1"));
// External modification — sleep to ensure mtime changes.
std::thread::sleep(std::time::Duration::from_millis(50));
std::fs::write(&file, "version 2\n").unwrap();
let r2 = tool.execute(input).await;
assert!(!r2.is_error);
assert!(
r2.content.contains("version 2"),
"After modification, read should return new content"
);
assert!(
!r2.content.contains(UNCHANGED_MARKER),
"Modified file must not return unchanged stub"
);
}
/// TC-5.3-04: Different offset/limit parameters are not deduped.
#[tokio::test]
async fn tc_5_3_04_different_range_no_dedup() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("multiline.txt");
let content: String = (1..=30).map(|i| format!("line {}\n", i)).collect();
std::fs::write(&file, &content).unwrap();
let cache = make_cache();
let tool = ReadTool::new(Some(cache), None);
let path_str = file.to_str().unwrap();
// Read lines 0..10.
let input1 = json!({ "file_path": path_str, "offset": 0, "limit": 10 });
let r1 = tool.execute(input1).await;
assert!(!r1.is_error);
assert!(r1.content.contains("line 1"));
// Read lines 10..20 — different range, should return full content.
let input2 = json!({ "file_path": path_str, "offset": 10, "limit": 10 });
let r2 = tool.execute(input2).await;
assert!(!r2.is_error);
assert!(
r2.content.contains("line 11"),
"Different offset/limit should return full content"
);
assert!(
!r2.content.contains(UNCHANGED_MARKER),
"Different range must not trigger dedup"
);
}
/// TC-5.3-05: With cache disabled (None), reads always return full content.
#[tokio::test]
async fn tc_5_3_05_cache_disabled_no_dedup() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("nocache.txt");
std::fs::write(&file, "always full\n").unwrap();
let tool = ReadTool::new(None, None);
let input = json!({ "file_path": file.to_str().unwrap() });
let r1 = tool.execute(input.clone()).await;
assert!(r1.content.contains("always full"));
let r2 = tool.execute(input).await;
assert!(
r2.content.contains("always full"),
"Without cache, second read should still return full content"
);
assert!(!r2.content.contains(UNCHANGED_MARKER));
}
/// TC-5.3-06: Reading a non-existent file returns an error and does not cache.
#[tokio::test]
async fn tc_5_3_06_nonexistent_file_error_no_cache() {
let cache = make_cache();
let tool = ReadTool::new(Some(cache.clone()), None);
let input = json!({ "file_path": "/tmp/does_not_exist_tc_5_3_06.txt" });
let result = tool.execute(input).await;
assert!(result.is_error);
assert!(result.content.contains("Failed to read file"));
// Cache should remain empty.
let c = cache.read().unwrap();
assert!(c.is_empty(), "Failed reads must not populate the cache");
}
/// TC-5.3-07: Empty file can be deduped on second read.
#[tokio::test]
async fn tc_5_3_07_empty_file_dedup() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("empty.txt");
std::fs::File::create(&file).unwrap();
let cache = make_cache();
let tool = ReadTool::new(Some(cache), None);
let input = json!({ "file_path": file.to_str().unwrap() });
let r1 = tool.execute(input.clone()).await;
assert!(!r1.is_error);
let r2 = tool.execute(input).await;
assert!(!r2.is_error);
assert!(
r2.content.contains(UNCHANGED_MARKER),
"Empty file should be deduped on second read"
);
}
/// Supplementary: Same range read twice returns dedup stub.
#[tokio::test]
async fn same_range_dedup() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("range.txt");
let content: String = (1..=20).map(|i| format!("line {}\n", i)).collect();
std::fs::write(&file, &content).unwrap();
let cache = make_cache();
let tool = ReadTool::new(Some(cache), None);
let input = json!({ "file_path": file.to_str().unwrap(), "offset": 5, "limit": 5 });
let r1 = tool.execute(input.clone()).await;
assert!(!r1.is_error);
assert!(r1.content.contains("line 6"));
let r2 = tool.execute(input).await;
assert!(!r2.is_error);
assert!(
r2.content.contains(UNCHANGED_MARKER),
"Same range on unchanged file should trigger dedup"
);
}
/// Supplementary: Cache entry is updated after modification + re-read.
#[tokio::test]
async fn cache_updated_after_modification() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("update.txt");
std::fs::write(&file, "v1\n").unwrap();
let cache = make_cache();
let tool = ReadTool::new(Some(cache.clone()), None);
let input = json!({ "file_path": file.to_str().unwrap() });
// First read: caches v1.
tool.execute(input.clone()).await;
// Modify.
std::thread::sleep(std::time::Duration::from_millis(50));
std::fs::write(&file, "v2\n").unwrap();
// Second read: returns v2 and updates cache.
let r2 = tool.execute(input.clone()).await;
assert!(r2.content.contains("v2"));
// Third read: should dedup on v2.
let r3 = tool.execute(input).await;
assert!(
r3.content.contains(UNCHANGED_MARKER),
"After re-read of modified file, cache should be updated and third read deduped"
);
}
@@ -0,0 +1,257 @@
//! Integration tests for enhanced tool descriptions (TC-4.2-01 through TC-4.2-08).
//!
//! These are black-box tests that verify each tool's description contains
//! the key guidance information specified in the test plan.
use std::path::PathBuf;
use nomi_tools::Tool;
use nomi_tools::bash::BashTool;
use nomi_tools::edit::EditTool;
use nomi_tools::glob::GlobTool;
use nomi_tools::grep::GrepTool;
use nomi_tools::read::ReadTool;
use nomi_tools::registry::ToolRegistry;
use nomi_tools::write::WriteTool;
fn test_cwd() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
// --- TC-4.2-01: Bash tool description contains key guidance ---
#[test]
fn bash_description_references_dedicated_tools() {
let tool = BashTool::new(test_cwd());
let desc = tool.description();
assert!(
desc.contains("Glob"),
"Bash description should cross-reference Glob tool"
);
assert!(
desc.contains("Grep"),
"Bash description should cross-reference Grep tool"
);
assert!(
desc.contains("Read"),
"Bash description should cross-reference Read tool"
);
assert!(
desc.contains("Edit"),
"Bash description should cross-reference Edit tool"
);
}
#[test]
fn bash_description_contains_timeout_info() {
let tool = BashTool::new(test_cwd());
let desc = tool.description();
assert!(
desc.contains("120") || desc.to_lowercase().contains("timeout"),
"Bash description should mention timeout"
);
}
#[test]
fn bash_description_contains_parallel_guidance() {
let tool = BashTool::new(test_cwd());
let desc = tool.description();
assert!(
desc.contains("parallel") || desc.contains("&&"),
"Bash description should contain parallel command guidance"
);
}
// --- TC-4.2-02: Read tool description contains usage constraints ---
#[test]
fn read_description_requires_absolute_path() {
let tool = ReadTool::new(None, None);
let desc = tool.description();
assert!(
desc.contains("absolute path"),
"Read description should mention absolute path requirement"
);
}
#[test]
fn read_description_mentions_line_numbers() {
let tool = ReadTool::new(None, None);
let desc = tool.description();
assert!(
desc.contains("line number"),
"Read description should explain line number output format"
);
}
#[test]
fn read_description_handles_binary() {
let tool = ReadTool::new(None, None);
let desc = tool.description();
assert!(
desc.to_lowercase().contains("binary"),
"Read description should mention binary file handling"
);
}
// --- TC-4.2-03: Edit tool description contains preconditions ---
#[test]
fn edit_description_requires_read_first() {
let tool = EditTool::new(None);
let desc = tool.description();
assert!(
desc.contains("Read"),
"Edit description should require Read before editing"
);
}
#[test]
fn edit_description_mentions_uniqueness() {
let tool = EditTool::new(None);
let desc = tool.description();
assert!(
desc.contains("unique"),
"Edit description should mention old_string uniqueness requirement"
);
}
#[test]
fn edit_description_mentions_replace_all() {
let tool = EditTool::new(None);
let desc = tool.description();
assert!(
desc.contains("replace_all"),
"Edit description should document replace_all option"
);
}
// --- TC-4.2-04: Write tool description contains operation semantics ---
#[test]
fn write_description_mentions_overwrite() {
let tool = WriteTool::new(None);
let desc = tool.description();
assert!(
desc.contains("overwrite") || desc.contains("overwrites"),
"Write description should explain overwrite semantics"
);
}
#[test]
fn write_description_requires_read_for_existing() {
let tool = WriteTool::new(None);
let desc = tool.description();
assert!(
desc.contains("Read"),
"Write description should mention reading existing files first"
);
}
#[test]
fn write_description_prefers_edit() {
let tool = WriteTool::new(None);
let desc = tool.description();
assert!(
desc.contains("Edit"),
"Write description should recommend Edit for modifications"
);
}
// --- TC-4.2-05: Glob tool description contains result limits ---
#[test]
fn glob_description_mentions_result_limit() {
let tool = GlobTool::new(test_cwd());
let desc = tool.description();
assert!(
desc.contains("100"),
"Glob description should mention the 100 result limit"
);
}
#[test]
fn glob_description_mentions_sort_order() {
let tool = GlobTool::new(test_cwd());
let desc = tool.description();
let lower = desc.to_lowercase();
assert!(
lower.contains("modification time") || lower.contains("newest"),
"Glob description should explain sort order"
);
}
// --- TC-4.2-06: Grep tool description contains mandatory usage rule ---
#[test]
fn grep_description_forbids_bash_grep() {
let tool = GrepTool::new(test_cwd());
let desc = tool.description();
assert!(
desc.contains("NEVER") || desc.contains("never"),
"Grep description should forbid using grep in Bash"
);
}
#[test]
fn grep_description_mentions_regex() {
let tool = GrepTool::new(test_cwd());
let desc = tool.description();
assert!(
desc.contains("regex"),
"Grep description should mention regex support"
);
}
#[test]
fn grep_description_mentions_result_limit() {
let tool = GrepTool::new(test_cwd());
let desc = tool.description();
assert!(
desc.contains("250"),
"Grep description should mention the 250 result limit"
);
}
// --- TC-4.3-09: Grep description accuracy fix (R-4.2-01) ---
#[test]
fn grep_description_does_not_say_at_most_matches() {
let tool = GrepTool::new(test_cwd());
let desc = tool.description();
assert!(
!desc.contains("at most 250 matches"),
"Grep description should not say 'at most 250 matches' (was per-file, not global)"
);
assert!(
desc.contains("capped at 250 lines"),
"Grep description should accurately describe the 250-line cap"
);
}
// --- TC-4.2-08: ToolDef propagation ---
#[test]
fn tool_def_description_matches_tool_instance() {
let mut registry = ToolRegistry::new();
registry.register(Box::new(BashTool::new(test_cwd())));
registry.register(Box::new(ReadTool::new(None, None)));
registry.register(Box::new(EditTool::new(None)));
registry.register(Box::new(WriteTool::new(None)));
registry.register(Box::new(GlobTool::new(test_cwd())));
registry.register(Box::new(GrepTool::new(test_cwd())));
let defs = registry.to_tool_defs();
for def in &defs {
let tool = registry
.get(&def.name)
.expect("tool should exist in registry");
assert_eq!(
def.description,
tool.description(),
"ToolDef description for '{}' should match Tool::description()",
def.name
);
}
}