1use std::collections::HashMap;
5
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Value};
8
9use crate::types::SlopNode;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct LlmTool {
14 #[serde(rename = "type")]
15 pub tool_type: String,
16 pub function: LlmFunction,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct LlmFunction {
22 pub name: String,
23 pub description: String,
24 pub parameters: Value,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ToolResolution {
30 pub path: String,
31 pub action: String,
32}
33
34#[derive(Debug, Clone)]
36pub struct ToolSet {
37 pub tools: Vec<LlmTool>,
38 resolve_map: HashMap<String, ToolResolution>,
39}
40
41impl ToolSet {
42 pub fn resolve(&self, tool_name: &str) -> Option<&ToolResolution> {
44 self.resolve_map.get(tool_name)
45 }
46}
47
48fn sanitize(s: &str) -> String {
49 s.chars()
50 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
51 .collect()
52}
53
54struct Entry {
55 short_name: String,
56 path: String,
57 action: String,
58 ancestors: Vec<String>,
59 label: Option<String>,
60 description: Option<String>,
61 dangerous: bool,
62 params: Option<Value>,
63}
64
65pub fn affordances_to_tools(node: &SlopNode, path: &str) -> ToolSet {
70 let mut entries = Vec::new();
71 collect(node, path, &[], &mut entries);
72
73 let name_map = disambiguate(&entries);
74
75 let mut tools = Vec::new();
76 let mut resolve_map = HashMap::new();
77
78 for (i, entry) in entries.iter().enumerate() {
79 let tool_name = &name_map[i];
80 let p = if entry.path.is_empty() { "/" } else { &entry.path };
81
82 resolve_map.insert(
83 tool_name.clone(),
84 ToolResolution { path: p.to_string(), action: entry.action.clone() },
85 );
86
87 let label = entry.label.as_deref().unwrap_or(&entry.action);
88 let mut desc = match &entry.description {
89 Some(d) => format!("{label}: {d}"),
90 None => label.to_string(),
91 };
92 desc.push_str(&format!(" (on {p})"));
93 if entry.dangerous {
94 desc.push_str(" [DANGEROUS - confirm first]");
95 }
96
97 let parameters = entry.params.clone()
98 .unwrap_or_else(|| json!({"type": "object", "properties": {}}));
99
100 tools.push(LlmTool {
101 tool_type: "function".into(),
102 function: LlmFunction {
103 name: tool_name.clone(),
104 description: desc,
105 parameters,
106 },
107 });
108 }
109
110 ToolSet { tools, resolve_map }
111}
112
113fn collect(node: &SlopNode, path: &str, ancestors: &[String], out: &mut Vec<Entry>) {
114 let safe_id = sanitize(&node.id);
115 if let Some(affs) = &node.affordances {
116 for aff in affs {
117 let safe_action = sanitize(&aff.action);
118 let p = if path.is_empty() { "/".to_string() } else { path.to_string() };
119 out.push(Entry {
120 short_name: format!("{safe_id}__{safe_action}"),
121 path: p,
122 action: aff.action.clone(),
123 ancestors: ancestors.iter().map(|a| sanitize(a)).collect(),
124 label: aff.label.clone(),
125 description: aff.description.clone(),
126 dangerous: aff.dangerous,
127 params: aff.params.clone(),
128 });
129 }
130 }
131 if let Some(children) = &node.children {
132 let mut new_ancestors = ancestors.to_vec();
133 new_ancestors.push(node.id.clone());
134 for child in children {
135 let child_path = format!("{}/{}", path, child.id);
136 collect(child, &child_path, &new_ancestors, out);
137 }
138 }
139}
140
141fn disambiguate(entries: &[Entry]) -> Vec<String> {
142 let mut result = vec![String::new(); entries.len()];
143
144 let mut groups: HashMap<&str, Vec<usize>> = HashMap::new();
146 for (i, e) in entries.iter().enumerate() {
147 groups.entry(&e.short_name).or_default().push(i);
148 }
149
150 for (short_name, indices) in &groups {
151 if indices.len() == 1 {
152 result[indices[0]] = short_name.to_string();
153 continue;
154 }
155 for &idx in indices {
157 let entry = &entries[idx];
158 let mut name = short_name.to_string();
159 for i in (0..entry.ancestors.len()).rev() {
160 name = format!("{}__{name}", entry.ancestors[i]);
161 let mut unique = true;
162 let depth = entry.ancestors.len() - 1 - i;
163 for &other in indices {
164 if other == idx { continue; }
165 let oe = &entries[other];
166 let mut o_name = short_name.to_string();
167 for j in (0..oe.ancestors.len()).rev().take(depth + 1) {
168 o_name = format!("{}__{o_name}", oe.ancestors[j]);
169 }
170 if o_name == name {
171 unique = false;
172 break;
173 }
174 }
175 if unique { break; }
176 }
177 result[idx] = name;
178 }
179 }
180
181 result
182}
183
184pub fn format_tree(node: &SlopNode, indent: usize) -> String {
186 let mut out = String::new();
187 write_node(node, indent, &mut out);
188 out
189}
190
191fn write_node(node: &SlopNode, indent: usize, out: &mut String) {
192 let pad = " ".repeat(indent);
193
194 let display_name = node.properties.as_ref().and_then(|p| {
196 p.get("label")
197 .or_else(|| p.get("title"))
198 .and_then(|v| v.as_str())
199 });
200 let header = match display_name {
201 Some(name) if name != node.id => format!("{}: {}", node.id, name),
202 _ => node.id.clone(),
203 };
204 out.push_str(&format!("{pad}[{}] {header}", node.node_type));
205
206 if let Some(props) = &node.properties {
208 let pairs: Vec<String> = props
209 .iter()
210 .filter(|(k, _)| k.as_str() != "label" && k.as_str() != "title")
211 .map(|(k, v)| format!("{k}={v}"))
212 .collect();
213 if !pairs.is_empty() {
214 out.push_str(&format!(" ({})", pairs.join(", ")));
215 }
216 }
217
218 if let Some(meta) = &node.meta {
220 let mut flags = Vec::new();
221 if meta.pinned == Some(true) {
222 flags.push("pinned");
223 }
224 if meta.focus == Some(true) {
225 flags.push("focus");
226 }
227 if meta.changed == Some(true) {
228 flags.push("changed");
229 }
230 if let Some(ref u) = meta.urgency {
231 flags.push(match u {
232 crate::types::Urgency::Critical => "CRITICAL",
233 crate::types::Urgency::High => "HIGH",
234 crate::types::Urgency::Medium => "medium",
235 crate::types::Urgency::Low => "low",
236 crate::types::Urgency::None => "",
237 });
238 }
239 let flags: Vec<&str> = flags.into_iter().filter(|f| !f.is_empty()).collect();
240 if !flags.is_empty() {
241 out.push_str(&format!(" [{}]", flags.join(", ")));
242 }
243 if let Some(ref summary) = meta.summary {
244 out.push_str(&format!(" \u{2014} \"{summary}\""));
245 }
246 if let Some(salience) = meta.salience {
247 out.push_str(&format!(" salience={}", (salience * 100.0).round() / 100.0));
248 }
249 }
250
251 if let Some(affs) = &node.affordances {
253 if !affs.is_empty() {
254 let acts: Vec<String> = affs
255 .iter()
256 .map(|aff| {
257 let mut s = aff.action.clone();
258 if let Some(ref params) = aff.params {
259 if let Some(props) = params.get("properties").and_then(|p| p.as_object())
260 {
261 let param_strs: Vec<String> = props
262 .iter()
263 .map(|(k, v)| {
264 let typ =
265 v.get("type").and_then(|t| t.as_str()).unwrap_or("?");
266 format!("{k}: {typ}")
267 })
268 .collect();
269 if !param_strs.is_empty() {
270 s.push_str(&format!("({})", param_strs.join(", ")));
271 }
272 }
273 }
274 s
275 })
276 .collect();
277 out.push_str(&format!(" actions: {{{}}}", acts.join(", ")));
278 }
279 }
280
281 out.push('\n');
282
283 if let Some(meta) = &node.meta {
285 let child_count = node.children.as_ref().map_or(0, |c| c.len());
286 if let Some(total) = meta.total_children {
287 if total > child_count {
288 if meta.window.is_some() {
289 out.push_str(&format!(
290 "{pad} (showing {} of {})\n",
291 child_count, total
292 ));
293 } else if child_count == 0 {
294 let noun = if total == 1 { "child" } else { "children" };
295 out.push_str(&format!("{pad} ({} {} not loaded)\n", total, noun));
296 }
297 }
298 }
299 }
300
301 if let Some(children) = &node.children {
302 for child in children {
303 write_node(child, indent + 1, out);
304 }
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::types::{NodeMeta, SlopNode, Urgency};
312 use serde_json::json;
313
314 fn sample_tree() -> SlopNode {
315 serde_json::from_value(json!({
316 "id": "app",
317 "type": "root",
318 "properties": {"label": "My App"},
319 "children": [
320 {
321 "id": "counter",
322 "type": "status",
323 "properties": {"count": 5},
324 "affordances": [
325 {"action": "increment", "label": "Add one", "description": "Increment the counter"},
326 {"action": "reset", "dangerous": true}
327 ]
328 }
329 ]
330 }))
331 .unwrap()
332 }
333
334 fn canonical_tree() -> SlopNode {
336 serde_json::from_value(json!({
337 "id": "store",
338 "type": "root",
339 "properties": {"label": "Pet Store"},
340 "meta": {"salience": 0.9},
341 "affordances": [
342 {"action": "search", "params": {"type": "object", "properties": {"query": {"type": "string"}}}}
343 ],
344 "children": [
345 {
346 "id": "catalog",
347 "type": "collection",
348 "properties": {"label": "Catalog", "count": 142},
349 "meta": {"total_children": 142, "window": [0, 25], "summary": "142 products, 12 on sale"},
350 "children": [
351 {
352 "id": "prod-1",
353 "type": "item",
354 "properties": {"label": "Rubber Duck", "price": 4.99, "in_stock": true},
355 "affordances": [
356 {"action": "add_to_cart", "params": {"type": "object", "properties": {"quantity": {"type": "number"}}}},
357 {"action": "view"}
358 ]
359 }
360 ]
361 },
362 {
363 "id": "cart",
364 "type": "collection",
365 "properties": {"label": "Cart"},
366 "meta": {"total_children": 3, "summary": "3 items, $24.97"}
367 }
368 ]
369 }))
370 .unwrap()
371 }
372
373 #[test]
374 fn test_short_tool_names() {
375 let tree = sample_tree();
376 let ts = affordances_to_tools(&tree, "/app");
377 assert_eq!(ts.tools.len(), 2);
378 assert_eq!(ts.tools[0].tool_type, "function");
379 assert_eq!(ts.tools[0].function.name, "counter__increment");
380 assert_eq!(ts.tools[1].function.name, "counter__reset");
381 }
382
383 #[test]
384 fn test_resolve() {
385 let tree = sample_tree();
386 let ts = affordances_to_tools(&tree, "/app");
387 let r = ts.resolve("counter__increment").unwrap();
388 assert_eq!(r.path, "/app/counter");
389 assert_eq!(r.action, "increment");
390 }
391
392 #[test]
393 fn test_disambiguate_collisions() {
394 let tree: SlopNode = serde_json::from_value(json!({
395 "id": "root", "type": "root",
396 "children": [
397 { "id": "board-1", "type": "view", "children": [
398 { "id": "backlog", "type": "collection", "affordances": [{"action": "reorder"}] }
399 ]},
400 { "id": "board-2", "type": "view", "children": [
401 { "id": "backlog", "type": "collection", "affordances": [{"action": "reorder"}] }
402 ]}
403 ]
404 })).unwrap();
405 let ts = affordances_to_tools(&tree, "");
406 assert_eq!(ts.tools.len(), 2);
407 let names: Vec<&str> = ts.tools.iter().map(|t| t.function.name.as_str()).collect();
408 assert!(names.contains(&"board_1__backlog__reorder"));
409 assert!(names.contains(&"board_2__backlog__reorder"));
410
411 let r1 = ts.resolve("board_1__backlog__reorder").unwrap();
412 assert_eq!(r1.path, "/board-1/backlog");
413 let r2 = ts.resolve("board_2__backlog__reorder").unwrap();
414 assert_eq!(r2.path, "/board-2/backlog");
415 }
416
417 #[test]
418 fn test_format_tree_header_id_and_label() {
419 let text = format_tree(&canonical_tree(), 0);
420 assert!(text.contains("[root] store: Pet Store"), "missing root header:\n{text}");
421 assert!(text.contains("[collection] catalog: Catalog"), "missing catalog header:\n{text}");
422 assert!(text.contains("[item] prod-1: Rubber Duck"), "missing prod header:\n{text}");
423 }
424
425 #[test]
426 fn test_format_tree_header_id_only_when_no_label() {
427 let node = SlopNode::new("status", "status");
428 let text = format_tree(&node, 0);
429 assert!(text.contains("[status] status"), "missing id-only header:\n{text}");
430 }
431
432 #[test]
433 fn test_format_tree_extra_props_exclude_label() {
434 let text = format_tree(&canonical_tree(), 0);
435 assert!(text.contains("count=142"), "missing count prop:\n{text}");
436 assert!(!text.contains("label="), "label= should be excluded:\n{text}");
437 }
438
439 #[test]
440 fn test_format_tree_meta_summary_quoted() {
441 let text = format_tree(&canonical_tree(), 0);
442 assert!(text.contains("\"142 products, 12 on sale\""), "missing catalog summary:\n{text}");
443 assert!(text.contains("\"3 items, $24.97\""), "missing cart summary:\n{text}");
444 }
445
446 #[test]
447 fn test_format_tree_meta_salience() {
448 let text = format_tree(&canonical_tree(), 0);
449 assert!(text.contains("salience=0.9"), "missing salience:\n{text}");
450 }
451
452 #[test]
453 fn test_format_tree_affordances_inline_with_params() {
454 let text = format_tree(&canonical_tree(), 0);
455 assert!(text.contains("actions: {search(query: string)}"), "missing search:\n{text}");
456 assert!(text.contains("add_to_cart(quantity: number)"), "missing add_to_cart:\n{text}");
457 assert!(text.contains("view}"), "missing view:\n{text}");
458 }
459
460 #[test]
461 fn test_format_tree_windowed_collection() {
462 let text = format_tree(&canonical_tree(), 0);
463 assert!(text.contains("(showing 1 of 142)"), "missing windowed indicator:\n{text}");
464 }
465
466 #[test]
467 fn test_format_tree_lazy_collection() {
468 let text = format_tree(&canonical_tree(), 0);
469 assert!(text.contains("(3 children not loaded)"), "missing lazy indicator:\n{text}");
470 }
471
472 #[test]
473 fn test_format_tree_with_meta_flags() {
474 let mut tree = sample_tree();
475 tree.meta = Some(NodeMeta {
476 summary: Some("Root node".into()),
477 focus: Some(true),
478 urgency: Some(Urgency::High),
479 ..NodeMeta::default()
480 });
481 let text = format_tree(&tree, 0);
482 assert!(text.contains("[focus, HIGH]"), "missing flags:\n{text}");
483 assert!(text.contains("\"Root node\""), "missing summary:\n{text}");
484 }
485
486 #[test]
487 fn test_format_tree_indentation() {
488 let text = format_tree(&canonical_tree(), 0);
489 let lines: Vec<&str> = text.lines().collect();
490 assert!(lines[0].starts_with("[root]"), "root should be at indent 0");
491 let catalog = lines.iter().find(|l| l.contains("catalog")).unwrap();
492 assert!(catalog.starts_with(" [collection]"), "catalog should be at indent 1");
493 let prod = lines.iter().find(|l| l.contains("prod-1")).unwrap();
494 assert!(prod.starts_with(" [item]"), "prod-1 should be at indent 2");
495 }
496
497 #[test]
498 fn test_no_affordances() {
499 let tree = SlopNode::new("empty", "group");
500 let ts = affordances_to_tools(&tree, "/empty");
501 assert!(ts.tools.is_empty());
502 }
503}