gglib_core/domain/agent/
tool_display.rs

1//! Shared tool display formatting — single source of truth for all surfaces.
2//!
3//! These pure functions convert raw tool names and arguments into
4//! human-readable display strings.  The agentic loop populates
5//! [`AgentEvent`] payloads with pre-formatted fields computed here,
6//! so CLI, `WebUI` (Axum SSE), and GUI (Tauri) all render identical labels
7//! without duplicating formatting logic.
8//!
9//! [`AgentEvent`]: super::events::AgentEvent
10
11/// Strip the routing prefix from a qualified tool name.
12///
13/// Tool names carry a `"builtin:"` or `"{server_id}:"` prefix for
14/// O(1) dispatch routing in `CombinedToolExecutor`.  This function
15/// removes that prefix for display purposes only.
16///
17/// ```
18/// use gglib_core::domain::agent::tool_display::strip_tool_prefix;
19///
20/// assert_eq!(strip_tool_prefix("builtin:read_file"), "read_file");
21/// assert_eq!(strip_tool_prefix("3:some_tool"), "some_tool");
22/// assert_eq!(strip_tool_prefix("plain_name"), "plain_name");
23/// ```
24pub fn strip_tool_prefix(name: &str) -> &str {
25    name.find(':').map_or(name, |pos| &name[pos + 1..])
26}
27
28/// Convert a raw tool name into a human-readable "Title Case" label.
29///
30/// Splits on hyphens, underscores, dots, and whitespace, then title-cases
31/// each word.  This replaces the frontend `formatToolDisplayName` function
32/// and is the single Rust source of truth used by all surfaces.
33///
34/// ```
35/// use gglib_core::domain::agent::tool_display::format_tool_display_name;
36///
37/// assert_eq!(format_tool_display_name("read_file"), "Read File");
38/// assert_eq!(format_tool_display_name("get-weather"), "Get Weather");
39/// assert_eq!(format_tool_display_name("file.read"), "File Read");
40/// assert_eq!(format_tool_display_name("get_current_time"), "Get Current Time");
41/// assert_eq!(format_tool_display_name("Already Good"), "Already Good");
42/// ```
43pub fn format_tool_display_name(raw: &str) -> String {
44    raw.split(|c: char| c == '-' || c == '_' || c == '.' || c.is_whitespace())
45        .filter(|w| !w.is_empty())
46        .map(title_case_word)
47        .collect::<Vec<_>>()
48        .join(" ")
49}
50
51/// Extract a one-line argument summary from a tool call's `arguments` JSON.
52///
53/// Known builtins get tool-specific summaries (e.g. `read_file → path`).
54/// Unknown tools show the first string-valued key, truncated to 60 chars.
55/// Returns `None` when no meaningful summary can be extracted.
56///
57/// ```
58/// use gglib_core::domain::agent::tool_display::format_tool_args_summary;
59/// use serde_json::json;
60///
61/// let args = json!({"path": "/src/main.rs", "line_range": [1, 50]});
62/// assert_eq!(
63///     format_tool_args_summary("read_file", &args),
64///     Some("/src/main.rs".to_string()),
65/// );
66///
67/// let args = json!({"pattern": "TODO", "path": "/src"});
68/// assert_eq!(
69///     format_tool_args_summary("grep_search", &args),
70///     Some("\"TODO\" in /src".to_string()),
71/// );
72/// ```
73pub fn format_tool_args_summary(bare_name: &str, arguments: &serde_json::Value) -> Option<String> {
74    let obj = arguments.as_object()?;
75
76    match bare_name {
77        "read_file" => obj
78            .get("path")
79            .and_then(|v| v.as_str())
80            .map(|s| truncate(s, 60).to_string()),
81
82        "list_directory" => obj
83            .get("path")
84            .and_then(|v| v.as_str())
85            .map(|s| truncate(s, 60).to_string()),
86
87        "grep_search" => {
88            let pattern = obj.get("pattern").and_then(|v| v.as_str())?;
89            let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or(".");
90            Some(format!(
91                "\"{}\" in {}",
92                truncate(pattern, 30),
93                truncate(path, 30)
94            ))
95        }
96
97        "get_current_time" => obj
98            .get("timezone")
99            .and_then(|v| v.as_str())
100            .map(std::string::ToString::to_string),
101
102        // Generic fallback: show the first string-valued argument.
103        _ => obj
104            .values()
105            .find_map(|v| v.as_str())
106            .map(|s| truncate(s, 60).to_string()),
107    }
108}
109
110// =============================================================================
111// Helpers
112// =============================================================================
113
114/// Title-case a single word (first char uppercase, rest lowercase).
115fn title_case_word(word: &str) -> String {
116    let mut chars = word.chars();
117    chars.next().map_or_else(String::new, |c| {
118        let upper: String = c.to_uppercase().collect();
119        upper + chars.as_str()
120    })
121}
122
123/// Truncate a string to `max_len` characters, appending `…` if truncated.
124fn truncate(s: &str, max_len: usize) -> &str {
125    if s.len() <= max_len {
126        s
127    } else {
128        // Find a valid char boundary at or before max_len.
129        let end = s.floor_char_boundary(max_len);
130        &s[..end]
131    }
132}
133
134// =============================================================================
135// Tests
136// =============================================================================
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use serde_json::json;
142
143    // ── strip_tool_prefix ────────────────────────────────────────────
144
145    #[test]
146    fn strip_builtin_prefix() {
147        assert_eq!(strip_tool_prefix("builtin:read_file"), "read_file");
148    }
149
150    #[test]
151    fn strip_numeric_server_prefix() {
152        assert_eq!(strip_tool_prefix("3:some_tool"), "some_tool");
153    }
154
155    #[test]
156    fn strip_no_prefix() {
157        assert_eq!(strip_tool_prefix("plain_name"), "plain_name");
158    }
159
160    // ── format_tool_display_name ─────────────────────────────────────
161
162    #[test]
163    fn display_name_underscore() {
164        assert_eq!(format_tool_display_name("read_file"), "Read File");
165    }
166
167    #[test]
168    fn display_name_hyphen() {
169        assert_eq!(format_tool_display_name("get-weather"), "Get Weather");
170    }
171
172    #[test]
173    fn display_name_dot() {
174        assert_eq!(format_tool_display_name("file.read"), "File Read");
175    }
176
177    #[test]
178    fn display_name_mixed_separators() {
179        assert_eq!(
180            format_tool_display_name("my-tool_name.here"),
181            "My Tool Name Here"
182        );
183    }
184
185    #[test]
186    fn display_name_consecutive_separators() {
187        assert_eq!(format_tool_display_name("a..b"), "A B");
188        assert_eq!(format_tool_display_name("a--b"), "A B");
189    }
190
191    #[test]
192    fn display_name_single_word() {
193        assert_eq!(format_tool_display_name("weather"), "Weather");
194    }
195
196    #[test]
197    fn display_name_already_title_case() {
198        assert_eq!(format_tool_display_name("Get Weather"), "Get Weather");
199    }
200
201    // ── format_tool_args_summary ─────────────────────────────────────
202
203    #[test]
204    fn args_summary_read_file() {
205        let args = json!({"path": "/src/main.rs"});
206        assert_eq!(
207            format_tool_args_summary("read_file", &args),
208            Some("/src/main.rs".into())
209        );
210    }
211
212    #[test]
213    fn args_summary_grep_search() {
214        let args = json!({"pattern": "TODO", "path": "/src"});
215        assert_eq!(
216            format_tool_args_summary("grep_search", &args),
217            Some("\"TODO\" in /src".into())
218        );
219    }
220
221    #[test]
222    fn args_summary_grep_search_no_path() {
223        let args = json!({"pattern": "TODO"});
224        assert_eq!(
225            format_tool_args_summary("grep_search", &args),
226            Some("\"TODO\" in .".into())
227        );
228    }
229
230    #[test]
231    fn args_summary_list_directory() {
232        let args = json!({"path": "/src"});
233        assert_eq!(
234            format_tool_args_summary("list_directory", &args),
235            Some("/src".into())
236        );
237    }
238
239    #[test]
240    fn args_summary_get_current_time() {
241        let args = json!({"timezone": "UTC"});
242        assert_eq!(
243            format_tool_args_summary("get_current_time", &args),
244            Some("UTC".into())
245        );
246    }
247
248    #[test]
249    fn args_summary_generic_fallback() {
250        let args = json!({"query": "rust async"});
251        assert_eq!(
252            format_tool_args_summary("custom_search", &args),
253            Some("rust async".into())
254        );
255    }
256
257    #[test]
258    fn args_summary_no_string_values() {
259        let args = json!({"count": 5});
260        assert_eq!(format_tool_args_summary("some_tool", &args), None);
261    }
262
263    #[test]
264    fn args_summary_null_args() {
265        assert_eq!(
266            format_tool_args_summary("some_tool", &serde_json::Value::Null),
267            None
268        );
269    }
270}