opendev-tools-impl 0.1.4

Tool implementations for OpenDev: bash, file ops, git, web, memory, and more
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
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
//! Integration tests for tool implementations.
//!
//! These tests exercise the real tool implementations against the filesystem
//! and process subsystem. Each test uses temp directories for isolation.

use std::collections::HashMap;

use opendev_tools_core::{BaseTool, ToolContext};
use opendev_tools_impl::{BashTool, FileEditTool, FileReadTool, FileWriteTool, GrepTool};
use tempfile::TempDir;

fn make_args(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
    pairs
        .iter()
        .map(|(k, v)| (k.to_string(), v.clone()))
        .collect()
}

// ========================================================================
// Bash tool integration tests
// ========================================================================

/// Verify that BashTool actually executes a command and captures stdout.
#[tokio::test]
async fn bash_executes_command_and_captures_stdout() {
    let tmp = TempDir::new().unwrap();
    let tool = BashTool::new();
    let ctx = ToolContext::new(tmp.path());
    let args = make_args(&[("command", serde_json::json!("echo integration_test_marker"))]);

    let result = tool.execute(args, &ctx).await;
    assert!(result.success, "bash should succeed: {:?}", result.error);
    assert!(
        result
            .output
            .as_ref()
            .unwrap()
            .contains("integration_test_marker"),
        "stdout should contain marker"
    );
}

/// Verify that BashTool captures stderr alongside stdout.
#[tokio::test]
async fn bash_captures_stderr() {
    let tmp = TempDir::new().unwrap();
    let tool = BashTool::new();
    let ctx = ToolContext::new(tmp.path());
    let args = make_args(&[("command", serde_json::json!("echo err_marker >&2"))]);

    let result = tool.execute(args, &ctx).await;
    // The command succeeds (exit 0) even though it writes to stderr
    assert!(result.success);
    let output = result.output.unwrap();
    assert!(output.contains("err_marker"), "stderr should be captured");
}

/// Verify that BashTool respects the working directory.
#[tokio::test]
async fn bash_runs_in_correct_working_directory() {
    let tmp = TempDir::new().unwrap();
    std::fs::write(tmp.path().join("sentinel.txt"), "found_it").unwrap();

    let tool = BashTool::new();
    let ctx = ToolContext::new(tmp.path());
    let args = make_args(&[("command", serde_json::json!("cat sentinel.txt"))]);

    let result = tool.execute(args, &ctx).await;
    assert!(result.success);
    assert!(result.output.unwrap().contains("found_it"));
}

/// Verify that BashTool times out long-running commands.
#[tokio::test]
async fn bash_timeout_kills_long_running_command() {
    let tool = BashTool::new();
    let ctx = ToolContext::new("/tmp");
    let args = make_args(&[
        ("command", serde_json::json!("sleep 30")),
        ("timeout", serde_json::json!(1)),
    ]);

    let result = tool.execute(args, &ctx).await;
    assert!(!result.success, "timed-out command should fail");
    assert!(
        result.error.as_ref().unwrap().contains("timed out"),
        "error should mention timeout"
    );
}

/// Verify that BashTool reports non-zero exit codes.
#[tokio::test]
async fn bash_nonzero_exit_code_is_failure() {
    let tool = BashTool::new();
    let ctx = ToolContext::new("/tmp");
    let args = make_args(&[("command", serde_json::json!("exit 7"))]);

    let result = tool.execute(args, &ctx).await;
    assert!(!result.success);
    assert_eq!(
        result.metadata.get("exit_code"),
        Some(&serde_json::json!(7))
    );
}

/// Verify that BashTool blocks dangerous patterns.
#[tokio::test]
async fn bash_blocks_dangerous_rm_rf() {
    let tool = BashTool::new();
    let ctx = ToolContext::new("/tmp");
    let args = make_args(&[("command", serde_json::json!("rm -rf /"))]);

    let result = tool.execute(args, &ctx).await;
    assert!(!result.success);
    assert!(result.error.unwrap().contains("Blocked dangerous"));
}

/// Verify that BashTool pipes work correctly.
#[tokio::test]
async fn bash_pipes_work() {
    let tool = BashTool::new();
    let ctx = ToolContext::new("/tmp");
    let args = make_args(&[("command", serde_json::json!("echo 'a\nb\nc' | wc -l"))]);

    let result = tool.execute(args, &ctx).await;
    assert!(result.success);
    let output = result.output.unwrap().trim().to_string();
    assert!(
        output.contains("3"),
        "pipe should count 3 lines, got: {output}"
    );
}

// ========================================================================
// File write -> read -> edit lifecycle
// ========================================================================

/// Full file lifecycle: write a file, read it back, edit it, read again.
#[tokio::test]
async fn file_write_read_edit_lifecycle() {
    let tmp = TempDir::new().unwrap();
    let file_path = tmp.path().join("lifecycle.txt");
    let file_str = file_path.to_str().unwrap();

    // Step 1: Write
    let write_tool = FileWriteTool;
    let ctx = ToolContext::new(tmp.path());
    let write_args = make_args(&[
        ("file_path", serde_json::json!(file_str)),
        (
            "content",
            serde_json::json!("line one\nline two\nline three\n"),
        ),
    ]);
    let result = write_tool.execute(write_args, &ctx).await;
    assert!(result.success, "write should succeed: {:?}", result.error);
    assert_eq!(result.metadata["lines"], serde_json::json!(3));

    // Step 2: Read
    let read_tool = FileReadTool;
    let read_args = make_args(&[("file_path", serde_json::json!(file_str))]);
    let result = read_tool.execute(read_args, &ctx).await;
    assert!(result.success);
    let output = result.output.unwrap();
    assert!(output.contains("line one"));
    assert!(output.contains("line two"));
    assert!(output.contains("line three"));
    assert_eq!(result.metadata["total_lines"], serde_json::json!(3));

    // Step 3: Edit (replace "line two" with "line TWO")
    let edit_tool = FileEditTool;
    let edit_args = make_args(&[
        ("file_path", serde_json::json!(file_str)),
        ("old_string", serde_json::json!("line two")),
        ("new_string", serde_json::json!("line TWO")),
    ]);
    let result = edit_tool.execute(edit_args, &ctx).await;
    assert!(result.success, "edit should succeed: {:?}", result.error);
    assert_eq!(result.metadata["replacements"], serde_json::json!(1));

    // Step 4: Read again to verify edit
    let read_args = make_args(&[("file_path", serde_json::json!(file_str))]);
    let result = read_tool.execute(read_args, &ctx).await;
    assert!(result.success);
    let output = result.output.unwrap();
    assert!(output.contains("line TWO"), "edit should be visible");
    assert!(!output.contains("line two"), "old text should be gone");
}

/// Verify that FileWriteTool creates nested parent directories.
#[tokio::test]
async fn file_write_creates_nested_dirs() {
    let tmp = TempDir::new().unwrap();
    let nested = tmp.path().join("a").join("b").join("c").join("deep.txt");
    let ctx = ToolContext::new(tmp.path());

    let tool = FileWriteTool;
    let args = make_args(&[
        ("file_path", serde_json::json!(nested.to_str().unwrap())),
        ("content", serde_json::json!("deep content")),
    ]);
    let result = tool.execute(args, &ctx).await;
    assert!(result.success);
    assert_eq!(std::fs::read_to_string(&nested).unwrap(), "deep content");
}

/// Verify that FileEditTool handles trailing newline changes correctly.
#[tokio::test]
async fn file_edit_trailing_newline_handling() {
    let tmp = TempDir::new().unwrap();
    let file_path = tmp.path().join("trailing.txt");
    std::fs::write(&file_path, "hello world\n").unwrap();

    let tool = FileEditTool;
    let ctx = ToolContext::new(tmp.path());
    let args = make_args(&[
        ("file_path", serde_json::json!(file_path.to_str().unwrap())),
        ("old_string", serde_json::json!("hello world\n")),
        ("new_string", serde_json::json!("hello world")),
    ]);
    let result = tool.execute(args, &ctx).await;
    assert!(result.success, "trailing newline edit should succeed");

    let content = std::fs::read_to_string(&file_path).unwrap();
    assert_eq!(content, "hello world");
}

/// Verify that FileEditTool replace_all works on multiple occurrences.
#[tokio::test]
async fn file_edit_replace_all_multiple_occurrences() {
    let tmp = TempDir::new().unwrap();
    let file_path = tmp.path().join("multi.txt");
    std::fs::write(&file_path, "cat dog cat fish cat").unwrap();

    let tool = FileEditTool;
    let ctx = ToolContext::new(tmp.path());
    let args = make_args(&[
        ("file_path", serde_json::json!(file_path.to_str().unwrap())),
        ("old_string", serde_json::json!("cat")),
        ("new_string", serde_json::json!("bird")),
        ("replace_all", serde_json::json!(true)),
    ]);
    let result = tool.execute(args, &ctx).await;
    assert!(result.success);
    assert_eq!(result.metadata["replacements"], serde_json::json!(3));

    let content = std::fs::read_to_string(&file_path).unwrap();
    assert_eq!(content, "bird dog bird fish bird");
}

/// Verify that FileEditTool rejects non-unique match without replace_all.
#[tokio::test]
async fn file_edit_rejects_ambiguous_match() {
    let tmp = TempDir::new().unwrap();
    let file_path = tmp.path().join("ambiguous.txt");
    std::fs::write(&file_path, "aaa bbb aaa").unwrap();

    let tool = FileEditTool;
    let ctx = ToolContext::new(tmp.path());
    let args = make_args(&[
        ("file_path", serde_json::json!(file_path.to_str().unwrap())),
        ("old_string", serde_json::json!("aaa")),
        ("new_string", serde_json::json!("ccc")),
    ]);
    let result = tool.execute(args, &ctx).await;
    assert!(!result.success, "ambiguous match should fail");
    assert!(result.error.unwrap().contains("2 times"));
}

/// Verify that FileReadTool supports offset and limit.
#[tokio::test]
async fn file_read_offset_and_limit() {
    let tmp = TempDir::new().unwrap();
    let file_path = tmp.path().join("lines.txt");
    let content: String = (1..=20).map(|i| format!("line {i}\n")).collect();
    std::fs::write(&file_path, &content).unwrap();

    let tool = FileReadTool;
    let ctx = ToolContext::new(tmp.path());
    let args = make_args(&[
        ("file_path", serde_json::json!(file_path.to_str().unwrap())),
        ("offset", serde_json::json!(5)),
        ("limit", serde_json::json!(3)),
    ]);
    let result = tool.execute(args, &ctx).await;
    assert!(result.success);
    let output = result.output.unwrap();
    assert!(output.contains("line 5"), "should start at line 5");
    assert!(output.contains("line 7"), "should include line 7");
    assert!(!output.contains("line 8"), "should not include line 8");
    assert_eq!(result.metadata["lines_shown"], serde_json::json!(3));
}

/// Verify that FileReadTool detects binary files.
#[tokio::test]
async fn file_read_detects_binary() {
    let tmp = TempDir::new().unwrap();
    let file_path = tmp.path().join("binary.bin");
    std::fs::write(&file_path, &[0u8, 1, 2, 3, 0, 5, 6, 7]).unwrap();

    let tool = FileReadTool;
    let ctx = ToolContext::new(tmp.path());
    let args = make_args(&[("file_path", serde_json::json!(file_path.to_str().unwrap()))]);
    let result = tool.execute(args, &ctx).await;
    assert!(!result.success);
    assert!(result.error.unwrap().contains("Binary"));
}

// ========================================================================
// Search tool integration tests
// ========================================================================

/// Verify search finds patterns across multiple files.
#[tokio::test]
async fn search_finds_pattern_across_files() {
    let tmp = TempDir::new().unwrap();
    std::fs::write(tmp.path().join("a.rs"), "fn main() {\n    todo!()\n}\n").unwrap();
    std::fs::write(tmp.path().join("b.rs"), "fn helper() {}\n").unwrap();
    std::fs::write(tmp.path().join("c.txt"), "no functions here\n").unwrap();

    let tool = GrepTool;
    let ctx = ToolContext::new(tmp.path());
    let args = make_args(&[("pattern", serde_json::json!("fn \\w+"))]);

    let result = tool.execute(args, &ctx).await;
    assert!(result.success);
    let output = result.output.unwrap();
    assert!(output.contains("fn main"), "should find fn main");
    assert!(output.contains("fn helper"), "should find fn helper");
}

/// Verify search respects glob filter.
#[tokio::test]
async fn search_respects_glob_filter() {
    let tmp = TempDir::new().unwrap();
    std::fs::write(tmp.path().join("code.rs"), "fn foo() {}\n").unwrap();
    std::fs::write(tmp.path().join("readme.md"), "fn bar() {}\n").unwrap();

    let tool = GrepTool;
    let ctx = ToolContext::new(tmp.path());
    let args = make_args(&[
        ("pattern", serde_json::json!("fn ")),
        ("glob", serde_json::json!("*.rs")),
    ]);

    let result = tool.execute(args, &ctx).await;
    assert!(result.success);
    let output = result.output.unwrap();
    assert!(output.contains("foo"), "should find in .rs file");
    assert!(!output.contains("bar"), "should not find in .md file");
}

/// Verify search reports no matches gracefully.
#[tokio::test]
async fn search_no_matches_is_not_error() {
    let tmp = TempDir::new().unwrap();
    std::fs::write(tmp.path().join("file.txt"), "hello world\n").unwrap();

    let tool = GrepTool;
    let ctx = ToolContext::new(tmp.path());
    let args = make_args(&[("pattern", serde_json::json!("nonexistent_pattern_xyz"))]);

    let result = tool.execute(args, &ctx).await;
    assert!(result.success, "no-match should still be success");
    assert!(result.output.unwrap().contains("No matches"));
}

/// Verify search auto-promotes invalid regex to fixed-string mode instead of failing.
#[tokio::test]
async fn search_invalid_regex_becomes_fixed_string() {
    let tool = GrepTool;
    let tmp = tempfile::TempDir::new().unwrap();
    let dir = tmp.path().canonicalize().unwrap();
    std::fs::write(dir.join("test.txt"), "[unclosed is literal text\n").unwrap();
    let ctx = ToolContext::new(dir.to_str().unwrap());
    let args = make_args(&[("pattern", serde_json::json!("[unclosed"))]);

    let result = tool.execute(args, &ctx).await;
    assert!(
        result.success,
        "invalid regex should auto-promote to fixed-string search"
    );
}

// ========================================================================
// Git tool integration tests
// ========================================================================

/// Verify git dangerous patterns are detected via BashTool's is_dangerous.
#[test]
fn git_force_push_detected_as_dangerous() {
    use opendev_tools_impl::bash::is_dangerous_command;
    assert!(is_dangerous_command("git push --force origin main"));
    assert!(is_dangerous_command("git push -f origin main"));
    assert!(is_dangerous_command("git reset --hard HEAD~1"));
    assert!(is_dangerous_command("git clean -fd"));
    assert!(is_dangerous_command("git checkout -- ."));
    assert!(is_dangerous_command("git branch -D feature"));
    // Safe git commands should not be flagged
    assert!(!is_dangerous_command("git status"));
    assert!(!is_dangerous_command("git log"));
    assert!(!is_dangerous_command("git diff"));
    assert!(!is_dangerous_command("git push origin main"));
    assert!(!is_dangerous_command(
        "git push --force-with-lease origin main"
    ));
}

// ========================================================================
// Fuzzy matching (edit_replacers) integration tests
// ========================================================================

/// find_match resolves through all 9 passes for progressively fuzzier input.
#[test]
fn fuzzy_match_passes_integration() {
    use opendev_tools_impl::edit_replacers::find_match;

    // Pass 1: Simple exact match
    let original = "fn main() {\n    println!(\"hello\");\n}";
    let result = find_match(original, "println!(\"hello\");").unwrap();
    assert_eq!(result.pass_name, "simple");

    // Pass 2: LineTrimmed - LLM provides without indentation
    let original = "fn foo() {\n    let x = 1;\n    let y = 2;\n}";
    let result = find_match(original, "let x = 1;\nlet y = 2;").unwrap();
    assert_eq!(result.pass_name, "line_trimmed");
    assert_eq!(result.actual, "    let x = 1;\n    let y = 2;");

    // Pass 4: WhitespaceNormalized - extra spaces
    let original = "let   x  =   1;";
    let result = find_match(original, "let x = 1;").unwrap();
    assert_eq!(result.pass_name, "whitespace_normalized");
    assert_eq!(result.actual, "let   x  =   1;");

    // Pass 6: EscapeNormalized - literal \n instead of newline
    let original = "let s = \"hello\nworld\";";
    let result = find_match(original, "let s = \"hello\\nworld\";").unwrap();
    assert_eq!(result.pass_name, "escape_normalized");

    // Pass 7: TrimmedBoundary
    let result = opendev_tools_impl::edit_replacers::find_match("abc xyz def", "  xyz  ").unwrap();
    assert!(result.actual.contains("xyz"));

    // No match at all
    let result = find_match("hello world", "completely different text that is nowhere");
    assert!(result.is_none());
}

/// unified_diff generates correct diff output for modifications.
#[test]
fn unified_diff_output_format() {
    use opendev_tools_impl::edit_replacers::unified_diff;

    let original = "line1\nline2\nline3\nline4\n";
    let modified = "line1\nline2_changed\nline3\nline4\nline5\n";

    let diff = unified_diff("test.rs", original, modified, 3);

    assert!(diff.contains("--- a/test.rs"));
    assert!(diff.contains("+++ b/test.rs"));
    assert!(diff.contains("-line2"));
    assert!(diff.contains("+line2_changed"));
    assert!(diff.contains("+line5"));
}

/// unified_diff returns empty string for identical inputs.
#[test]
fn unified_diff_no_changes_returns_empty() {
    use opendev_tools_impl::edit_replacers::unified_diff;

    let text = "same\ncontent\n";
    let diff = unified_diff("test.rs", text, text, 3);
    assert!(diff.is_empty());
}

/// find_occurrence_positions locates all instances of a pattern.
#[test]
fn find_occurrence_positions_multiple() {
    use opendev_tools_impl::edit_replacers::find_occurrence_positions;

    let content = "foo\nbar\nfoo\nbaz\nfoo";
    let positions = find_occurrence_positions(content, "foo");
    assert_eq!(positions, vec![1, 3, 5]);

    // No occurrences
    let positions = find_occurrence_positions(content, "xyz");
    assert!(positions.is_empty());
}

/// CRLF line endings are normalized before matching.
#[test]
fn fuzzy_match_normalizes_crlf() {
    use opendev_tools_impl::edit_replacers::find_match;

    let original = "line1\r\nline2\r\nline3";
    let result = find_match(original, "line2").unwrap();
    assert_eq!(result.pass_name, "simple");
    assert_eq!(result.actual, "line2");
}

// ========================================================================
// FileListTool integration tests
// ========================================================================

/// FileListTool lists files in a directory with correct structure.
#[tokio::test]
async fn file_list_shows_directory_contents() {
    use opendev_tools_impl::FileListTool;

    let tmp = TempDir::new().unwrap();
    std::fs::write(tmp.path().join("file1.rs"), "fn main() {}").unwrap();
    std::fs::write(tmp.path().join("file2.txt"), "hello").unwrap();
    std::fs::create_dir_all(tmp.path().join("subdir")).unwrap();
    std::fs::write(tmp.path().join("subdir").join("nested.rs"), "mod nested;").unwrap();

    let tool = FileListTool;
    let ctx = ToolContext::new(tmp.path());
    let args = make_args(&[("pattern", serde_json::json!("**/*"))]);

    let result = tool.execute(args, &ctx).await;
    assert!(
        result.success,
        "file_list should succeed: {:?}",
        result.error
    );
    let output = result.output.unwrap();
    assert!(output.contains("file1.rs"));
    assert!(output.contains("file2.txt"));
    assert!(output.contains("nested.rs"));
}