codescout 0.12.1

High-performance coding agent toolkit MCP server
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
//! Group flat tool results by `file` field. Used by `symbols(search)`,
//! `references`, and `grep` for both LLM-facing text rendering and (for refs
//! and grep) the JSON output shape.
//!
//! File-only grouping by design. If a future tool needs `group_by_kind` or
//! `group_by_directory`, introduce a new abstraction then; do not parameterize
//! this one. (See 2026-05-15-grouped-tool-output-design.md.)

use serde_json::Value;

/// A group of items sharing the same `file` field.
///
/// Holds borrowed references into the input slice — callers keep ownership.
pub struct FileGroup<'a> {
    pub file: &'a str,
    pub items: Vec<&'a Value>,
}

/// Group items by their `file` field. Sort: group size desc, ties by path asc.
/// Within a group, original order is preserved (stable partition).
///
/// Items lacking a `file` field are dropped silently; callers should not pass
/// such items to this function.
pub fn group_by_file(items: &[Value]) -> Vec<FileGroup<'_>> {
    use std::collections::BTreeMap;
    let mut by_file: BTreeMap<&str, Vec<&Value>> = BTreeMap::new();
    for item in items {
        if let Some(file) = item.get("file").and_then(|v| v.as_str()) {
            by_file.entry(file).or_default().push(item);
        }
    }
    let mut groups: Vec<FileGroup<'_>> = by_file
        .into_iter()
        .map(|(file, items)| FileGroup { file, items })
        .collect();
    // Stable: BTreeMap iteration is path-asc; sort_by with reverse on count
    // preserves alphabetical order among ties.
    groups.sort_by(|a, b| b.items.len().cmp(&a.items.len()));
    groups
}

/// Truncate a flat item list to fit `budget`, preserving file diversity.
///
/// Policy: Round-robin across files, prioritizing hotter (more frequent) files.
/// Each pass, offer one item to each file in count-desc order (most frequent first)
/// until budget is exhausted or all files are depleted.
///
/// Returns the visible items, plus the un-truncated `total` and `files` counts
/// (callers anchor "N hits in M files" headers to these).
pub fn cap_grouped(items: Vec<Value>, budget: usize) -> (Vec<Value>, usize, usize) {
    let total = items.len();
    if total == 0 {
        return (items, 0, 0);
    }

    use std::collections::BTreeMap;
    let mut by_file: BTreeMap<String, Vec<usize>> = BTreeMap::new();
    for (idx, item) in items.iter().enumerate() {
        if let Some(file) = item.get("file").and_then(|v| v.as_str()) {
            by_file.entry(file.to_string()).or_default().push(idx);
        }
    }
    let files = by_file.len();

    if budget >= total {
        return (items, total, files);
    }

    let mut buckets: Vec<(String, Vec<usize>)> = by_file.into_iter().collect();
    buckets.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)));

    let mut picked: Vec<usize> = Vec::with_capacity(budget);
    let mut cursors: Vec<usize> = vec![0; buckets.len()];

    // Round-robin: each pass, offer one item to each file (in count-desc order)
    // until budget exhausted or all files depleted.
    loop {
        let mut picked_any = false;
        for i in 0..buckets.len() {
            if picked.len() >= budget {
                break;
            }
            if cursors[i] < buckets[i].1.len() {
                picked.push(buckets[i].1[cursors[i]]);
                cursors[i] += 1;
                picked_any = true;
            }
        }
        if !picked_any || picked.len() >= budget {
            break;
        }
    }

    picked.sort();
    let picked_set: std::collections::HashSet<usize> = picked.into_iter().collect();
    let visible: Vec<Value> = items
        .into_iter()
        .enumerate()
        .filter_map(|(idx, item)| {
            if picked_set.contains(&idx) {
                Some(item)
            } else {
                None
            }
        })
        .collect();

    (visible, total, files)
}

/// Render groups to compact text.
///
/// Output shape:
/// ```text
/// <total> <noun> in <files> files
///
/// path/to/a.rs (3)
///   <render_item(item0)>
///   <render_item(item1)>
/// path/to/b.rs (1)
///   <render_item(item0)>
/// ```
///
/// Single-file results (`files <= 1`) suppress the global header — the file
/// header is signal enough.
///
/// `noun` is the plural form ("hits", "references", "matches"). The function
/// does not pluralize for callers; pass the right word.
pub fn render_grouped(
    groups: &[FileGroup<'_>],
    total: usize,
    files: usize,
    noun: &str,
    render_item: impl Fn(&Value) -> String,
) -> String {
    if groups.is_empty() {
        return format!("0 {noun}");
    }

    let mut out = String::new();
    if files > 1 {
        out.push_str(&format!("{total} {noun} in {files} files\n\n"));
    }

    for (gi, group) in groups.iter().enumerate() {
        if gi > 0 {
            out.push('\n');
        }
        out.push_str(&format!("{} ({})\n", group.file, group.items.len()));
        for item in &group.items {
            out.push_str(&render_item(item));
            out.push('\n');
        }
    }
    if out.ends_with('\n') {
        out.pop();
    }
    out
}

/// Build the JSON `file_groups[]` shape used by `references` and `grep`.
///
/// Each group becomes `{ file, count, items }` where `items` is the group's
/// items with the per-item `file` field stripped.
pub fn groups_to_json(groups: &[FileGroup<'_>]) -> Value {
    use serde_json::json;
    let arr: Vec<Value> = groups
        .iter()
        .map(|g| {
            let items: Vec<Value> = g
                .items
                .iter()
                .map(|item| {
                    let mut clone = (*item).clone();
                    if let Some(obj) = clone.as_object_mut() {
                        obj.remove("file");
                    }
                    clone
                })
                .collect();
            json!({
                "file": g.file,
                "count": g.items.len(),
                "items": items,
            })
        })
        .collect();
    Value::Array(arr)
}
/// Reconstruct `FileGroup`s from a previously-serialized `file_groups[]` JSON
/// array (the shape produced by `groups_to_json`). Use this in `format_compact`
/// paths to avoid re-flattening + re-grouping when the JSON is already grouped.
///
/// Groups missing `file` or `items` are silently skipped — callers should treat
/// missing data as "nothing to render here", not an error.
pub fn groups_from_json(file_groups: &[Value]) -> Vec<FileGroup<'_>> {
    file_groups
        .iter()
        .filter_map(|group| {
            let file = group.get("file")?.as_str()?;
            let items = group.get("items")?.as_array()?;
            let items: Vec<&Value> = items.iter().collect();
            Some(FileGroup { file, items })
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn item(file: &str) -> Value {
        json!({ "file": file })
    }

    #[test]
    fn groups_sorted_by_count_desc() {
        let items = vec![
            item("a.rs"),
            item("b.rs"),
            item("b.rs"),
            item("c.rs"),
            item("c.rs"),
            item("c.rs"),
        ];
        let groups = group_by_file(&items);
        assert_eq!(groups.len(), 3);
        assert_eq!(groups[0].file, "c.rs");
        assert_eq!(groups[0].items.len(), 3);
        assert_eq!(groups[1].file, "b.rs");
        assert_eq!(groups[1].items.len(), 2);
        assert_eq!(groups[2].file, "a.rs");
        assert_eq!(groups[2].items.len(), 1);
    }

    #[test]
    fn groups_tie_break_by_path_asc() {
        let items = vec![item("z.rs"), item("a.rs"), item("m.rs")];
        let groups = group_by_file(&items);
        assert_eq!(groups[0].file, "a.rs");
        assert_eq!(groups[1].file, "m.rs");
        assert_eq!(groups[2].file, "z.rs");
    }

    #[test]
    fn drops_items_without_file_field() {
        let items = vec![item("a.rs"), json!({ "no_file": true })];
        let groups = group_by_file(&items);
        assert_eq!(groups.len(), 1);
        assert_eq!(groups[0].file, "a.rs");
    }

    #[test]
    fn cap_grouped_round_robin_first() {
        // 4 files with counts {3, 2, 1, 1}, budget=3 → top 3 files each get 1 hit.
        let items: Vec<Value> = ["a.rs", "a.rs", "a.rs", "b.rs", "b.rs", "c.rs", "d.rs"]
            .iter()
            .map(|f| item(f))
            .collect();
        let (visible, total, files) = cap_grouped(items, 3);
        assert_eq!(total, 7);
        assert_eq!(files, 4);
        assert_eq!(visible.len(), 3);
        let visible_files: Vec<&str> = visible
            .iter()
            .map(|v| v["file"].as_str().unwrap())
            .collect();
        assert_eq!(visible_files, vec!["a.rs", "b.rs", "c.rs"]);
    }

    #[test]
    fn cap_grouped_fills_hot_after_breadth() {
        // {6, 3, 1}, budget=8 → round-robin (hot-first) picks: a, b, c, a, b, a, b, a
        let mut items: Vec<Value> = vec![];
        for _ in 0..6 {
            items.push(item("a.rs"));
        }
        for _ in 0..3 {
            items.push(item("b.rs"));
        }
        items.push(item("c.rs"));
        let (visible, total, files) = cap_grouped(items, 8);
        assert_eq!(total, 10);
        assert_eq!(files, 3);
        assert_eq!(visible.len(), 8);
        let counts: std::collections::HashMap<&str, usize> =
            visible
                .iter()
                .fold(std::collections::HashMap::new(), |mut acc, v| {
                    let f = v["file"].as_str().unwrap();
                    *acc.entry(f).or_insert(0) += 1;
                    acc
                });
        assert_eq!(counts["a.rs"], 4);
        assert_eq!(counts["b.rs"], 3);
        assert_eq!(counts["c.rs"], 1);
    }

    #[test]
    fn cap_grouped_budget_exceeds_total() {
        let items: Vec<Value> = vec![item("a.rs"), item("b.rs")];
        let (visible, total, files) = cap_grouped(items, 100);
        assert_eq!(visible.len(), 2);
        assert_eq!(total, 2);
        assert_eq!(files, 2);
    }

    #[test]
    fn render_multi_file_header_and_groups() {
        let items = vec![
            json!({ "file": "a.rs", "marker": "x" }),
            json!({ "file": "a.rs", "marker": "y" }),
            json!({ "file": "b.rs", "marker": "z" }),
        ];
        let groups = group_by_file(&items);
        let out = render_grouped(&groups, 3, 2, "matches", |v| {
            format!("  m={}", v["marker"].as_str().unwrap())
        });
        assert!(out.starts_with("3 matches in 2 files\n"), "got:\n{out}");
        assert!(out.contains("a.rs (2)"));
        assert!(out.contains("b.rs (1)"));
        assert!(out.contains("  m=x"));
        assert!(out.contains("  m=z"));
        let a_pos = out.find("a.rs").unwrap();
        let b_pos = out.find("b.rs").unwrap();
        assert!(a_pos < b_pos, "hotter file should appear first");
    }

    #[test]
    fn render_single_file_omits_header_line() {
        let items = vec![
            json!({ "file": "a.rs", "marker": "x" }),
            json!({ "file": "a.rs", "marker": "y" }),
        ];
        let groups = group_by_file(&items);
        let out = render_grouped(&groups, 2, 1, "matches", |v| {
            format!("  m={}", v["marker"].as_str().unwrap())
        });
        assert!(!out.contains(" in 1 files"), "got:\n{out}");
        assert!(out.starts_with("a.rs (2)\n"), "got:\n{out}");
    }

    #[test]
    fn render_empty_groups() {
        let groups: Vec<FileGroup<'_>> = vec![];
        let out = render_grouped(&groups, 0, 0, "matches", |_| String::new());
        assert_eq!(out, "0 matches");
    }

    #[test]
    fn render_singular_noun_when_total_one() {
        let items = vec![json!({ "file": "a.rs" })];
        let groups = group_by_file(&items);
        let out = render_grouped(&groups, 1, 1, "match", |_| "  x".to_string());
        assert!(out.starts_with("a.rs (1)"));
    }

    #[test]
    fn cap_grouped_zero_budget() {
        let items: Vec<Value> = vec![item("a.rs"), item("b.rs")];
        let (visible, total, files) = cap_grouped(items, 0);
        assert_eq!(visible.len(), 0);
        assert_eq!(total, 2);
        assert_eq!(files, 2);
    }

    #[test]
    fn groups_to_json_shape() {
        let items = vec![
            json!({ "file": "a.rs", "line": 1 }),
            json!({ "file": "a.rs", "line": 2 }),
            json!({ "file": "b.rs", "line": 5 }),
        ];
        let groups = group_by_file(&items);
        let value = groups_to_json(&groups);
        let arr = value.as_array().unwrap();
        assert_eq!(arr.len(), 2);
        assert_eq!(arr[0]["file"], "a.rs");
        assert_eq!(arr[0]["count"], 2);
        let items_a = arr[0]["items"].as_array().unwrap();
        assert_eq!(items_a.len(), 2);
        assert!(items_a[0].get("file").is_none());
        assert_eq!(items_a[0]["line"], 1);
        assert_eq!(arr[1]["file"], "b.rs");
        assert_eq!(arr[1]["count"], 1);
    }

    #[test]
    fn groups_from_json_reconstructs_filegroups() {
        let json = json!([
            { "file": "a.rs", "count": 2, "items": [{ "line": 1 }, { "line": 2 }] },
            { "file": "b.rs", "count": 1, "items": [{ "line": 5 }] },
        ]);
        let arr = json.as_array().unwrap();
        let groups = groups_from_json(arr);
        assert_eq!(groups.len(), 2);
        assert_eq!(groups[0].file, "a.rs");
        assert_eq!(groups[0].items.len(), 2);
        assert_eq!(groups[0].items[0]["line"], 1);
        assert_eq!(groups[1].file, "b.rs");
        assert_eq!(groups[1].items.len(), 1);
    }

    #[test]
    fn groups_from_json_skips_malformed_groups() {
        let json = json!([
            { "file": "a.rs", "items": [{ "line": 1 }] },
            { "no_file": true, "items": [] },
            { "file": "b.rs", "no_items": true },
        ]);
        let arr = json.as_array().unwrap();
        let groups = groups_from_json(arr);
        assert_eq!(groups.len(), 1);
        assert_eq!(groups[0].file, "a.rs");
    }
}