cached-context 0.3.0

File cache with diff tracking for AI coding agents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
//! Integration tests for cached-context MCP tools
//!
//! These tests verify the MCP tool interface works correctly

use cached_context::{CacheConfig, CacheStore};
use rmcp::model::CallToolResult;

/// Helper to create a test cache store
async fn create_test_store(temp_dir: &tempfile::TempDir) -> CacheStore {
    let config = CacheConfig {
        db_path: temp_dir.path().join("test_cache.db"),
        session_id: "test-session".to_string(),
        workdir: temp_dir.path().to_path_buf(),
    };
    let store = CacheStore::new(config).expect("Failed to create cache store");
    store.init().await.expect("Failed to init cache store");
    store
}

/// Extract text content from a CallToolResult
fn get_text_from_result(result: &CallToolResult) -> String {
    result
        .content
        .first()
        .and_then(|c| c.as_text())
        .map(|t| t.text.clone())
        .unwrap_or_default()
}

#[tokio::test]
async fn test_mcp_read_file_first_time() {
    // Setup
    let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let test_file = temp_dir.path().join("test.txt");
    std::fs::write(&test_file, "line1\nline2\nline3\n").expect("Failed to write test file");

    let store = create_test_store(&temp_dir).await;
    let service = cached_context::mcp::CachebroMcpService::new(store);

    // First read - should return full content
    let result = service
        .read_file(test_file.to_string_lossy().to_string(), None, None, false)
        .await
        .expect("read_file failed");

    let text = get_text_from_result(&result);

    // Should return full content (not "[unchanged]")
    assert!(
        !text.contains("[unchanged]"),
        "First read should return full content"
    );
    assert!(text.contains("line1"), "Should contain first line");
    assert!(text.contains("line2"), "Should contain second line");
    assert!(text.contains("line3"), "Should contain third line");
}

#[tokio::test]
async fn test_mcp_read_file_second_read_unchanged() {
    // Setup
    let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let test_file = temp_dir.path().join("test.txt");
    std::fs::write(&test_file, "line1\nline2\nline3\n").expect("Failed to write test file");

    let store = create_test_store(&temp_dir).await;
    let service = cached_context::mcp::CachebroMcpService::new(store);

    // First read - cache the file
    let _ = service
        .read_file(test_file.to_string_lossy().to_string(), None, None, false)
        .await
        .expect("First read_file failed");

    // Second read - should return "[unchanged]"
    let result = service
        .read_file(test_file.to_string_lossy().to_string(), None, None, false)
        .await
        .expect("read_file failed");

    let text = get_text_from_result(&result);

    assert!(
        text.contains("unchanged"),
        "Second read should return 'unchanged', got: {}",
        text
    );
}

#[tokio::test]
async fn test_mcp_read_file_changed() {
    // Setup
    let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let test_file = temp_dir.path().join("test.txt");
    std::fs::write(&test_file, "line1\nline2\nline3\n").expect("Failed to write test file");

    let store = create_test_store(&temp_dir).await;
    let service = cached_context::mcp::CachebroMcpService::new(store);

    // First read - cache the file
    let _ = service
        .read_file(test_file.to_string_lossy().to_string(), None, None, false)
        .await
        .expect("First read_file failed");

    // Modify the file
    std::fs::write(&test_file, "line1\nline2 modified\nline3\n")
        .expect("Failed to modify test file");

    // Third read - should return diff
    let result = service
        .read_file(test_file.to_string_lossy().to_string(), None, None, false)
        .await
        .expect("read_file failed");

    let text = get_text_from_result(&result);

    assert!(
        text.contains("lines changed"),
        "Changed file should return diff with lines changed info, got: {}",
        text
    );
    assert!(
        text.contains("---") || text.contains("+++") || text.contains("@@"),
        "Should contain diff markers"
    );
}

#[tokio::test]
async fn test_mcp_read_file_partial() {
    // Setup
    let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let test_file = temp_dir.path().join("test.txt");
    std::fs::write(
        &test_file,
        "line1\nline2\nline3\nline4\nline5\n",
    )
    .expect("Failed to write test file");

    let store = create_test_store(&temp_dir).await;
    let service = cached_context::mcp::CachebroMcpService::new(store);

    // Read with offset and limit
    let result = service
        .read_file(
            test_file.to_string_lossy().to_string(),
            Some(2),
            Some(2),
            false,
        )
        .await
        .expect("read_file failed");

    let text = get_text_from_result(&result);

    // Should return partial content
    assert!(
        text.contains("line2") && text.contains("line3"),
        "Should contain lines 2 and 3, got: {}",
        text
    );
    assert!(
        !text.contains("line1"),
        "Should not contain line1"
    );
}

#[tokio::test]
async fn test_mcp_cache_status() {
    // Setup
    let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let test_file = temp_dir.path().join("test.txt");
    std::fs::write(&test_file, "test content for tracking").expect("Failed to write test file");

    let store = create_test_store(&temp_dir).await;
    let service = cached_context::mcp::CachebroMcpService::new(store);

    // Read a file to generate stats
    let _ = service
        .read_file(test_file.to_string_lossy().to_string(), None, None, false)
        .await
        .expect("read_file failed");

    // Get cache status
    let result = service.cache_status().await.expect("cache_status failed");

    let text = get_text_from_result(&result);

    assert!(
        text.contains("cached-context status"),
        "Should contain status header"
    );
    assert!(
        text.contains("Files tracked:"),
        "Should contain files tracked info"
    );
}

#[tokio::test]
async fn test_mcp_cache_clear() {
    // Setup
    let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let test_file = temp_dir.path().join("test.txt");
    std::fs::write(&test_file, "test content").expect("Failed to write test file");

    let store = create_test_store(&temp_dir).await;
    let service = cached_context::mcp::CachebroMcpService::new(store);

    // Read a file to populate cache
    let _ = service
        .read_file(test_file.to_string_lossy().to_string(), None, None, false)
        .await
        .expect("read_file failed");

    // Clear the cache
    let result = service.cache_clear().await.expect("cache_clear failed");

    let text = get_text_from_result(&result);

    assert!(
        text.contains("Cache cleared"),
        "Should confirm cache cleared"
    );

    // Verify cache is cleared by checking status
    let status = service.cache_status().await.expect("cache_status failed");
    let status_text = get_text_from_result(&status);
    assert!(
        status_text.contains("Files tracked: 0"),
        "Cache should be empty after clear"
    );
}

#[tokio::test]
async fn test_mcp_read_files_batch() {
    // Setup
    let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let file1 = temp_dir.path().join("file1.txt");
    let file2 = temp_dir.path().join("file2.txt");
    std::fs::write(&file1, "content of file 1").expect("Failed to write file1");
    std::fs::write(&file2, "content of file 2").expect("Failed to write file2");

    let store = create_test_store(&temp_dir).await;
    let service = cached_context::mcp::CachebroMcpService::new(store);

    // Read both files
    let result = service
        .read_files(vec![
            file1.to_string_lossy().to_string(),
            file2.to_string_lossy().to_string(),
        ])
        .await
        .expect("read_files failed");

    let text = get_text_from_result(&result);

    assert!(text.contains("file1.txt"), "Should contain file1");
    assert!(text.contains("file2.txt"), "Should contain file2");
    assert!(
        text.contains("content of file 1"),
        "Should contain content of file 1"
    );
}

#[tokio::test]
async fn test_mcp_server_info() {
    // Setup
    let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let store = create_test_store(&temp_dir).await;
    let service = cached_context::mcp::CachebroMcpService::new(store);

    // Verify server info
    let info = service.get_info();
    assert_eq!(info.server_info.name, "cached-context");
    assert_eq!(info.server_info.version, "0.2.1");
}

/// D-3: read_files batch with mixed cached/changed/unchanged states
#[tokio::test]
async fn test_mcp_read_files_batch_mixed_states() {
    let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let file1 = temp_dir.path().join("file1.txt");
    let file2 = temp_dir.path().join("file2.txt");
    let file3 = temp_dir.path().join("file3.txt");
    std::fs::write(&file1, "file1 content\n").expect("Failed to write file1");
    std::fs::write(&file2, "file2 original\nline2\n").expect("Failed to write file2");
    std::fs::write(&file3, "file3 content\n").expect("Failed to write file3");

    let store = create_test_store(&temp_dir).await;
    let service = cached_context::mcp::CachebroMcpService::new(store);

    // First read of file1 and file2 to cache them
    let _ = service
        .read_file(file1.to_string_lossy().to_string(), None, None, false)
        .await
        .expect("First read file1 failed");
    let _ = service
        .read_file(file2.to_string_lossy().to_string(), None, None, false)
        .await
        .expect("First read file2 failed");

    // Modify file2
    std::fs::write(&file2, "file2 modified\nline2\n").expect("Failed to modify file2");

    // Batch read: file1 (unchanged), file2 (changed), file3 (first read)
    let result = service
        .read_files(vec![
            file1.to_string_lossy().to_string(),
            file2.to_string_lossy().to_string(),
            file3.to_string_lossy().to_string(),
        ])
        .await
        .expect("read_files failed");

    let text = get_text_from_result(&result);

    // file1: unchanged - should have "=== file1.txt ===" header and "unchanged" content
    assert!(
        text.contains("unchanged"),
        "file1 should show as unchanged, got: {}",
        text
    );

    // file2: changed - should have "[N lines changed out of M]" in header
    assert!(
        text.contains("lines changed out of"),
        "file2 should show changed header, got: {}",
        text
    );
    // Verify the TS-style header format: "=== path [N lines changed...] ==="
    assert!(
        text.contains("] ==="),
        "Changed file header should end with '] ===', got: {}",
        text
    );

    // file3: first read - should have its content
    assert!(
        text.contains("file3 content"),
        "file3 should show full content, got: {}",
        text
    );
}

/// D-4: MCP token savings footer is explicitly present in cached responses
#[tokio::test]
async fn test_mcp_token_savings_footer() {
    let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let test_file = temp_dir.path().join("test.txt");
    std::fs::write(&test_file, "some content for token test\n").expect("Failed to write test file");

    let store = create_test_store(&temp_dir).await;
    let service = cached_context::mcp::CachebroMcpService::new(store);

    // First read - caches the file
    let _ = service
        .read_file(test_file.to_string_lossy().to_string(), None, None, false)
        .await
        .expect("First read failed");

    // Second read - should be cached and include token savings footer
    let result = service
        .read_file(test_file.to_string_lossy().to_string(), None, None, false)
        .await
        .expect("Second read failed");

    let text = get_text_from_result(&result);

    assert!(
        text.contains("tokens saved this session"),
        "Cached response should include token savings footer, got: {}",
        text
    );
}

/// D-5: MCP-layer force=true bypasses cache and returns full content
#[tokio::test]
async fn test_mcp_read_file_force() {
    let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
    let test_file = temp_dir.path().join("test.txt");
    std::fs::write(&test_file, "original content\nline 2\n").expect("Failed to write test file");

    let store = create_test_store(&temp_dir).await;
    let service = cached_context::mcp::CachebroMcpService::new(store);

    // First read - caches the file
    let _ = service
        .read_file(test_file.to_string_lossy().to_string(), None, None, false)
        .await
        .expect("First read failed");

    // Second read with force=true - should return full content, not "unchanged"
    let result = service
        .read_file(test_file.to_string_lossy().to_string(), None, None, true)
        .await
        .expect("Force read failed");

    let text = get_text_from_result(&result);

    assert!(
        !text.contains("unchanged"),
        "Force read should NOT return 'unchanged', got: {}",
        text
    );
    assert!(
        text.contains("original content"),
        "Force read should return full content, got: {}",
        text
    );
    assert!(
        text.contains("line 2"),
        "Force read should return all lines, got: {}",
        text
    );
    // Force read is not cached, so no token savings footer
    assert!(
        !text.contains("tokens saved this session"),
        "Force read should NOT have token savings footer, got: {}",
        text
    );
}