1use std::collections::HashMap;
40
41use serde_json::Value;
42
43const TEXT_HEAD_BYTES: usize = 400;
45const TEXT_TAIL_BYTES: usize = 200;
46const SEQ_HEAD_ITEMS: usize = 3;
48const SEQ_TAIL_ITEMS: usize = 2;
49const ELEMENT_BUDGET: usize = 120;
51
52#[derive(Default, Debug)]
58pub struct SessionValues {
59 values: HashMap<String, Value>,
60 next: usize,
61}
62
63impl SessionValues {
64 pub fn new() -> Self {
65 Self::default()
66 }
67
68 pub fn put(&mut self, value: Value) -> String {
70 self.next += 1;
71 let handle = format!("r{}", self.next);
72 self.values.insert(handle.clone(), value);
73 handle
74 }
75
76 pub fn get(&self, handle: &str) -> Option<&Value> {
77 self.values.get(handle)
78 }
79
80 pub fn len(&self) -> usize {
81 self.values.len()
82 }
83
84 pub fn is_empty(&self) -> bool {
85 self.values.is_empty()
86 }
87
88 pub fn resolve_refs(&self, params: &mut Value) -> Vec<String> {
107 let mut resolved = Vec::new();
108 self.resolve_into(params, &mut resolved);
109 resolved
110 }
111
112 fn lookup(&self, reference: &str) -> Option<&Value> {
123 let mut parts = reference.split('.');
124 let mut cursor = self.get(parts.next()?)?;
125 for part in parts {
126 cursor = match cursor {
127 Value::Object(map) => map.get(part)?,
128 Value::Array(items) => items.get(part.parse::<usize>().ok()?)?,
131 _ => return None,
132 };
133 }
134 Some(cursor)
135 }
136
137 fn resolve_into(&self, node: &mut Value, resolved: &mut Vec<String>) {
138 match node {
139 Value::String(s) => {
140 if let Some(reference) = s.strip_prefix('$') {
141 if let Some(value) = self.lookup(reference) {
142 resolved.push(reference.to_string());
143 *node = value.clone();
144 }
145 }
146 }
147 Value::Array(items) => {
148 for item in items {
149 self.resolve_into(item, resolved);
150 }
151 }
152 Value::Object(map) => {
153 for (_, v) in map.iter_mut() {
154 self.resolve_into(v, resolved);
155 }
156 }
157 _ => {}
158 }
159 }
160}
161
162pub fn render_preview(handle: &str, value: &Value) -> String {
175 match value {
176 Value::Array(items) => render_array(handle, items),
177 Value::Object(map) => render_object(handle, map),
178 Value::String(s) => render_text(handle, s),
179 other => format!("{handle} = {other}"),
181 }
182}
183
184fn render_array(handle: &str, items: &[Value]) -> String {
185 let len = items.len();
186 if len <= SEQ_HEAD_ITEMS + SEQ_TAIL_ITEMS {
187 let all: Vec<String> = items.iter().map(summarize_element).collect();
188 return format!("{handle} = array(len={len}, [{}])", all.join(", "));
189 }
190 let head: Vec<String> = items
191 .iter()
192 .take(SEQ_HEAD_ITEMS)
193 .map(summarize_element)
194 .collect();
195 let tail: Vec<String> = items[len - SEQ_TAIL_ITEMS..]
196 .iter()
197 .map(summarize_element)
198 .collect();
199 format!(
200 "{handle} = array(len={len}, [:{}]=[{}], [-{}:]=[{}])",
201 SEQ_HEAD_ITEMS,
202 head.join(", "),
203 SEQ_TAIL_ITEMS,
204 tail.join(", ")
205 )
206}
207
208fn render_object(handle: &str, map: &serde_json::Map<String, Value>) -> String {
209 let bytes = Value::Object(map.clone()).to_string().len();
210 if map.len() > 24 {
218 let keys: Vec<&str> = map.keys().take(8).map(String::as_str).collect();
219 return format!(
220 "{handle} = json(keys={} total, first=[{}, …], bytes={bytes})",
221 map.len(),
222 keys.join(", ")
223 );
224 }
225 let fields: Vec<String> = map
226 .iter()
227 .map(|(k, v)| format!("{k}: {}", describe_field(v)))
228 .collect();
229 format!("{handle} = json({}, bytes={bytes})", fields.join(", "))
230}
231
232fn describe_field(value: &Value) -> String {
235 match value {
236 Value::String(s) if s.len() > ELEMENT_BUDGET => {
237 format!("text(len={}, lines={})", human(s.len()), s.lines().count())
238 }
239 Value::String(s) => format!("{s:?}"),
240 Value::Array(items) => format!("array(len={})", items.len()),
241 Value::Object(inner) => format!("json(keys={})", inner.len()),
242 other => other.to_string(),
243 }
244}
245
246fn render_text(handle: &str, s: &str) -> String {
247 let bytes = s.len();
248 let lines = s.lines().count();
249 if bytes <= TEXT_HEAD_BYTES + TEXT_TAIL_BYTES {
250 return format!("{handle} = text(len={}, lines={lines}) {s:?}", human(bytes));
251 }
252 let head = clip(s, TEXT_HEAD_BYTES, true);
253 let tail = clip(s, TEXT_TAIL_BYTES, false);
254 format!(
255 "{handle} = text(len={}, lines={lines}, head={head:?}, tail={tail:?})",
256 human(bytes)
257 )
258}
259
260fn summarize_element(value: &Value) -> String {
262 match value {
263 Value::Object(map) => {
264 let inner: Vec<String> = map
265 .iter()
266 .take(2)
267 .map(|(k, v)| format!("{k}:{}", clip_str(&scalar_or_type(v), 24)))
268 .collect();
269 let more = if map.len() > 2 { ", …" } else { "" };
270 format!("{{{}{more}}}", inner.join(","))
271 }
272 Value::Array(items) => format!("array(len={})", items.len()),
273 Value::String(s) => format!("{:?}", clip_str(s, ELEMENT_BUDGET)),
274 other => clip_str(&other.to_string(), ELEMENT_BUDGET),
275 }
276}
277
278fn scalar_or_type(value: &Value) -> String {
279 match value {
280 Value::Object(_) => "{…}".to_string(),
281 Value::Array(items) => format!("array({})", items.len()),
282 Value::String(s) => s.clone(),
283 other => other.to_string(),
284 }
285}
286
287pub(crate) fn clip_str(s: &str, budget: usize) -> String {
293 if s.len() <= budget {
294 return s.to_string();
295 }
296 let mut end = budget;
297 while !s.is_char_boundary(end) {
298 end -= 1;
299 }
300 format!("{}…", &s[..end])
301}
302
303fn clip(s: &str, budget: usize, from_head: bool) -> String {
305 if s.len() <= budget {
306 return s.to_string();
307 }
308 if from_head {
309 let mut end = budget;
310 while !s.is_char_boundary(end) {
311 end -= 1;
312 }
313 s[..end].to_string()
314 } else {
315 let mut start = s.len() - budget;
316 while !s.is_char_boundary(start) {
317 start += 1;
318 }
319 s[start..].to_string()
320 }
321}
322
323fn human(bytes: usize) -> String {
326 const KB: usize = 1024;
327 const MB: usize = KB * 1024;
328 if bytes >= MB {
329 format!("{:.1}MB", bytes as f64 / MB as f64)
330 } else if bytes >= KB {
331 format!("{:.1}KB", bytes as f64 / KB as f64)
332 } else {
333 format!("{bytes}B")
334 }
335}
336
337pub fn reference_hint(handle: &str) -> String {
343 format!(
344 "\n[full value retained this run — pass \"${handle}\" as a tool argument \
345 to operate on all of it, or \"${handle}.<field>\" for one field]"
346 )
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use serde_json::json;
353
354 #[test]
355 fn handles_are_short_and_sequential() {
356 let mut store = SessionValues::new();
357 assert_eq!(store.put(json!(1)), "r1");
358 assert_eq!(store.put(json!(2)), "r2");
359 assert_eq!(store.get("r1"), Some(&json!(1)));
360 assert_eq!(store.get("nope"), None);
361 assert_eq!(store.len(), 2);
362 }
363
364 #[test]
366 fn the_elided_data_is_recoverable_through_the_handle() {
367 let mut store = SessionValues::new();
368 let rows: Vec<Value> = (0..100).map(|i| json!({"id": i})).collect();
369 let handle = store.put(json!(rows));
370
371 let preview = render_preview(&handle, store.get(&handle).unwrap());
372 assert!(
373 preview.contains("len=100"),
374 "true length must survive: {preview}"
375 );
376
377 let recovered = store.get(&handle).unwrap().as_array().unwrap();
379 assert_eq!(recovered.len(), 100);
380 assert_eq!(recovered[57], json!({"id": 57}));
381 }
382
383 #[test]
384 fn array_preview_carries_type_length_and_both_ends() {
385 let items: Vec<Value> = (0..100).map(|i| json!(i)).collect();
386 let p = render_preview("r1", &json!(items));
387 assert!(p.starts_with("r1 = array(len=100"), "{p}");
388 assert!(p.contains("[:3]=[0, 1, 2]"), "head sample missing: {p}");
389 assert!(p.contains("[-2:]=[98, 99]"), "tail sample missing: {p}");
390 }
391
392 #[test]
395 fn short_arrays_render_whole() {
396 let p = render_preview("r1", &json!([1, 2, 3]));
397 assert_eq!(p, "r1 = array(len=3, [1, 2, 3])");
398 }
399
400 #[test]
401 fn object_preview_describes_fields_not_just_key_names() {
402 let p = render_preview("r2", &json!({"name": "car", "version": "0.46.1"}));
403 assert!(
404 p.contains(r#"name: "car""#),
405 "small values shown literally: {p}"
406 );
407 assert!(p.contains("bytes="), "true size must be stated: {p}");
408 }
409
410 #[test]
415 fn a_large_field_inside_an_envelope_announces_itself() {
416 let body = format!("HEAD\n{}\nTAIL", "z".repeat(50_000));
417 let value = json!({
418 "content": body,
419 "path": "./big.txt",
420 "size_bytes": 50_010,
421 "total_lines": 3,
422 });
423 let p = render_preview("r1", &value);
424 assert!(
425 p.contains("content: text(len=") && p.contains("lines=3"),
426 "the big field must state its size and shape: {p}"
427 );
428 assert!(
429 p.contains(r#"path: "./big.txt""#),
430 "small fields stay literal: {p}"
431 );
432 assert!(
433 p.len() < 400,
434 "preview must stay bounded: {} bytes",
435 p.len()
436 );
437 }
438
439 #[test]
442 fn dotted_paths_address_a_field_of_a_retained_value() {
443 let mut store = SessionValues::new();
444 let h = store.put(json!({"content": "the payload", "path": "./f.txt"}));
445
446 let mut params = json!({"content": format!("${h}.content")});
447 let resolved = store.resolve_refs(&mut params);
448 assert_eq!(resolved, vec![format!("{h}.content")]);
449 assert_eq!(params["content"], json!("the payload"));
450
451 let rows = store.put(json!([{"id": 1}, {"id": 2}, {"id": 3}]));
454 let mut p2 = json!({"row": format!("${rows}.1.id")});
455 store.resolve_refs(&mut p2);
456 assert_eq!(p2["row"], json!(2));
457
458 let mut p3 = json!({"x": format!("${h}.nope")});
461 assert!(store.resolve_refs(&mut p3).is_empty());
462 assert_eq!(p3["x"], json!(format!("${h}.nope")));
463 }
464
465 #[test]
466 fn text_preview_reports_size_lines_and_both_ends() {
467 let body = format!("FIRST LINE\n{}\nLAST LINE", "x".repeat(5_000));
468 let p = render_preview("r3", &json!(body));
469 assert!(p.contains("text(len="), "{p}");
470 assert!(p.contains("lines=3"), "line count missing: {p}");
471 assert!(p.contains("FIRST LINE"), "head missing: {p}");
472 assert!(p.contains("LAST LINE"), "tail missing: {p}");
473 assert!(p.len() < 1_200, "preview not bounded: {} bytes", p.len());
475 }
476
477 #[test]
480 fn multibyte_text_does_not_panic() {
481 let body = "€".repeat(5_000);
482 let p = render_preview("r1", &json!(body));
483 assert!(p.contains("text(len="), "{p}");
484 }
485
486 #[test]
487 fn whole_string_references_resolve() {
488 let mut store = SessionValues::new();
489 let handle = store.put(json!([1, 2, 3]));
490 let mut params = json!({"rows": format!("${handle}"), "limit": 10});
491 let resolved = store.resolve_refs(&mut params);
492 assert_eq!(resolved, vec![handle]);
493 assert_eq!(params["rows"], json!([1, 2, 3]));
494 assert_eq!(params["limit"], json!(10));
495 }
496
497 #[test]
500 fn nested_references_resolve() {
501 let mut store = SessionValues::new();
502 let h = store.put(json!("hello"));
503 let mut params = json!({"outer": {"inner": [format!("${h}")]}});
504 store.resolve_refs(&mut params);
505 assert_eq!(params["outer"]["inner"][0], json!("hello"));
506 }
507
508 #[test]
512 fn partial_matches_are_left_alone() {
513 let mut store = SessionValues::new();
514 let h = store.put(json!("VALUE"));
515 let mut params = json!({"cmd": format!("echo ${h} > out.txt"), "re": "^\\$r1$"});
516 let resolved = store.resolve_refs(&mut params);
517 assert!(
518 resolved.is_empty(),
519 "a substring must not trigger substitution: {params}"
520 );
521 assert_eq!(params["cmd"], json!(format!("echo ${h} > out.txt")));
522 }
523
524 #[test]
528 fn unknown_handles_are_left_untouched() {
529 let store = SessionValues::new();
530 let mut params = json!({"rows": "$r99"});
531 let resolved = store.resolve_refs(&mut params);
532 assert!(resolved.is_empty());
533 assert_eq!(params["rows"], json!("$r99"));
534 }
535
536 #[test]
537 fn reference_hint_names_the_typed_form() {
538 let hint = reference_hint("r7");
539 assert!(
540 hint.contains("\"$r7\""),
541 "the model must see the exact form: {hint}"
542 );
543 }
544}