1use crate::health::{HealthFinding, Severity};
4use crate::outline::FileOutline;
5use crate::symbols::{CodeSymbol, SymbolRelation};
6use crate::telemetry::{TokenOverview, TokenTrendReport};
7use crate::{IndexedNode, Overview, RankedNode};
8use serde::Serialize;
9use serde_json::json;
10
11#[must_use]
13pub fn render_overview(overview: &Overview) -> String {
14 encode_agent_payload(&json!({ "overview": overview }))
15}
16
17#[must_use]
19pub fn render_node_rows(label: &str, nodes: &[IndexedNode]) -> Vec<serde_json::Value> {
20 nodes
21 .iter()
22 .map(|node| {
23 let purpose = node.purpose.purpose.as_deref().unwrap_or("");
24 let content_summary = node.summary.as_deref().unwrap_or("");
25 match label {
26 "folders" => json!({
27 "path": node.node.path,
28 "kind": node.node.kind.to_string(),
29 "folder_purpose": purpose,
30 "content_summary": content_summary,
31 "status": node.purpose.status.to_string(),
32 "purpose_source": node.purpose.source,
33 "purpose_agent_reviewed": node.purpose.agent_reviewed(),
34 }),
35 "files" => json!({
36 "path": node.node.path,
37 "kind": node.node.kind.to_string(),
38 "language": node.node.language.as_deref().unwrap_or(""),
39 "file_purpose": purpose,
40 "content_summary": content_summary,
41 "status": node.purpose.status.to_string(),
42 "purpose_source": node.purpose.source,
43 "purpose_agent_reviewed": node.purpose.agent_reviewed(),
44 }),
45 _ => json!({
46 "path": node.node.path,
47 "kind": node.node.kind.to_string(),
48 "purpose": purpose,
49 "content_summary": content_summary,
50 "status": node.purpose.status.to_string(),
51 "purpose_source": node.purpose.source,
52 "purpose_agent_reviewed": node.purpose.agent_reviewed(),
53 }),
54 }
55 })
56 .collect()
57}
58
59#[must_use]
61pub fn render_ranked_node_rows(label: &str, nodes: &[RankedNode]) -> Vec<serde_json::Value> {
62 nodes
63 .iter()
64 .map(|ranked| {
65 let mut row = render_node_rows(label, std::slice::from_ref(&ranked.node))
66 .into_iter()
67 .next()
68 .unwrap_or_else(|| json!({}));
69 if let Some(object) = row.as_object_mut() {
70 object.insert("reasons".to_string(), json!(ranked.reasons));
71 object.insert("reason_codes".to_string(), json!(ranked.reason_codes));
72 object.insert(
73 "connection_counts".to_string(),
74 json!(ranked.connection_counts),
75 );
76 object.insert("connections".to_string(), json!(ranked.connections));
77 object.insert(
78 "connections_truncated".to_string(),
79 json!(ranked.connections_truncated),
80 );
81 object.insert("next_call".to_string(), json!(ranked.next_call));
82 }
83 row
84 })
85 .collect()
86}
87
88#[must_use]
90pub fn render_nodes(label: &str, nodes: &[IndexedNode]) -> String {
91 let rows = render_node_rows(label, nodes);
92 encode_agent_payload(&json!({ label: rows }))
93}
94
95#[must_use]
97pub fn render_ranked_nodes(label: &str, nodes: &[RankedNode]) -> String {
98 let rows = render_ranked_node_rows(label, nodes);
99 encode_agent_payload(&json!({ label: rows }))
100}
101
102#[must_use]
104pub fn render_outline(outline: &FileOutline) -> String {
105 encode_agent_payload(&json!({ "outline": outline }))
106}
107
108#[must_use]
110pub fn render_health(findings: &[HealthFinding]) -> String {
111 let rows = findings
112 .iter()
113 .map(|finding| {
114 json!({
115 "severity": render_severity(finding.severity),
116 "id": finding.id,
117 "category": finding.category,
118 "path": finding.path,
119 "related_path": finding.related_path.as_deref().unwrap_or(""),
120 "message": finding.message,
121 "recommendation": finding.recommendation,
122 })
123 })
124 .collect::<Vec<_>>();
125 encode_agent_payload(&json!({ "health_findings": rows }))
126}
127
128#[must_use]
130pub fn render_token_overview(overview: &TokenOverview) -> String {
131 let savings_rate = percentage_label(overview.savings_rate);
132 let buckets = overview
133 .buckets
134 .iter()
135 .map(|bucket| {
136 json!({
137 "token_savings_bucket": bucket.token_savings_bucket,
138 "provider": bucket.provider,
139 "model": bucket.model,
140 "tokenizer_backend": bucket.tokenizer_backend,
141 "accuracy": bucket.accuracy,
142 "baseline_kind": bucket.baseline_kind,
143 "confidence": bucket.confidence,
144 "accounting_layer": bucket.accounting_layer,
145 "estimate_method": bucket.estimate_method,
146 "denominator_kind": bucket.denominator_kind,
147 "dedupe_scope": bucket.dedupe_scope,
148 "calls": bucket.calls,
149 "baseline_tokens": bucket.estimated_without_projectatlas,
150 "emitted_tokens": bucket.estimated_with_projectatlas,
151 "saved_tokens": bucket.estimated_saved,
152 "savings_rate": percentage_label(bucket.savings_rate),
153 })
154 })
155 .collect::<Vec<_>>();
156 encode_agent_payload(&json!({
157 "token_savings": {
158 "estimate_kind": overview.estimate_kind,
159 "estimator": overview.estimator,
160 "estimate_scope": overview.estimate_scope,
161 "detail_availability": overview.detail_availability,
162 "calls": overview.calls,
163 "estimated_without_projectatlas": overview.estimated_without_projectatlas,
164 "estimated_with_projectatlas": overview.estimated_with_projectatlas,
165 "estimated_saved": overview.estimated_saved,
166 "legacy_gross_estimated_saved": overview.legacy_gross_estimated_saved,
167 "measured_tokens_saved": overview.measured_tokens_saved,
168 "gross_modeled_tokens_avoided": overview.gross_modeled_tokens_avoided,
169 "deduped_modeled_tokens_avoided": overview.deduped_modeled_tokens_avoided,
170 "tokens_avoided": overview.tokens_avoided,
171 "repeated_baselines_deduped": overview.repeated_baselines_deduped,
172 "likely_file_reads_avoided": overview.likely_file_reads_avoided,
173 "read_avoidance": {
174 "likely_file_reads_avoided": overview.likely_file_reads_avoided,
175 "observed_file_read_replacements": overview.observed_file_read_replacements,
176 "modeled_file_reads_avoided": overview.modeled_file_reads_avoided,
177 "scope": overview.read_avoidance_scope,
178 "confidence": overview.read_avoidance_confidence,
179 "plain_language": "ProjectAtlas summaries, search results, and slices were used instead of opening likely whole files.",
180 },
181 "agent_efficiency": overview.agent_efficiency,
182 "calibration": overview.calibration,
183 "savings_rate": savings_rate,
184 "totals": {
185 "baseline_tokens": overview.estimated_without_projectatlas,
186 "emitted_tokens": overview.estimated_with_projectatlas,
187 "saved_tokens": overview.estimated_saved,
188 "legacy_gross_saved_tokens": overview.legacy_gross_estimated_saved,
189 "measured_saved_tokens": overview.measured_tokens_saved,
190 "gross_modeled_avoided_tokens": overview.gross_modeled_tokens_avoided,
191 "deduped_modeled_avoided_tokens": overview.deduped_modeled_tokens_avoided,
192 "tokens_avoided": overview.tokens_avoided,
193 "likely_file_reads_avoided": overview.likely_file_reads_avoided,
194 "savings_rate": savings_rate,
195 },
196 "buckets": buckets,
197 }
198 }))
199}
200
201#[must_use]
203pub fn render_token_trends(report: &TokenTrendReport) -> String {
204 let periods = report
205 .periods
206 .iter()
207 .map(|period| {
208 let buckets = period
209 .buckets
210 .iter()
211 .map(|bucket| {
212 json!({
213 "token_savings_bucket": bucket.token_savings_bucket,
214 "provider": bucket.provider,
215 "model": bucket.model,
216 "tokenizer_backend": bucket.tokenizer_backend,
217 "accuracy": bucket.accuracy,
218 "baseline_kind": bucket.baseline_kind,
219 "confidence": bucket.confidence,
220 "accounting_layer": bucket.accounting_layer,
221 "estimate_method": bucket.estimate_method,
222 "denominator_kind": bucket.denominator_kind,
223 "dedupe_scope": bucket.dedupe_scope,
224 "calls": bucket.calls,
225 "baseline_tokens": bucket.estimated_without_projectatlas,
226 "emitted_tokens": bucket.estimated_with_projectatlas,
227 "saved_tokens": bucket.estimated_saved,
228 "savings_rate": percentage_label(bucket.savings_rate),
229 })
230 })
231 .collect::<Vec<_>>();
232 json!({
233 "period": period.period,
234 "calls": period.calls,
235 "baseline_tokens": period.estimated_without_projectatlas,
236 "emitted_tokens": period.estimated_with_projectatlas,
237 "saved_tokens": period.estimated_saved,
238 "savings_rate": percentage_label(period.savings_rate),
239 "buckets": buckets,
240 })
241 })
242 .collect::<Vec<_>>();
243 encode_agent_payload(&json!({
244 "token_trends": {
245 "estimate_kind": report.estimate_kind,
246 "estimator": report.estimator,
247 "estimate_scope": report.estimate_scope,
248 "session": report.session.as_deref().unwrap_or("all sessions"),
249 "window": report.window,
250 "detail_availability": report.detail_availability,
251 "periods": periods,
252 }
253 }))
254}
255
256#[must_use]
258pub fn render_symbols(symbols: &[CodeSymbol]) -> String {
259 let rows = symbols
260 .iter()
261 .map(|symbol| {
262 json!({
263 "path": symbol.path,
264 "kind": symbol.kind.to_string(),
265 "name": symbol.name,
266 "start": symbol.line_start,
267 "end": symbol.line_end,
268 "parent": symbol.parent.as_deref().unwrap_or(""),
269 "parser": symbol.parser.to_string(),
270 "signature": symbol.signature,
271 "exported": symbol.exported,
272 "documentation": symbol.documentation.as_deref().unwrap_or(""),
273 })
274 })
275 .collect::<Vec<_>>();
276 encode_agent_payload(&json!({ "symbols": rows }))
277}
278
279#[must_use]
281pub fn render_symbol_relations(relations: &[SymbolRelation]) -> String {
282 let rows = relations
283 .iter()
284 .map(|relation| {
285 json!({
286 "path": relation.path,
287 "kind": relation.kind.to_string(),
288 "source": relation.source_name,
289 "target": relation.target_name,
290 "line": relation.line,
291 "parser": relation.parser.to_string(),
292 "context": relation.context,
293 })
294 })
295 .collect::<Vec<_>>();
296 encode_agent_payload(&json!({ "symbol_relations": rows }))
297}
298
299#[must_use]
301pub fn encode_agent_payload<T>(payload: &T) -> String
302where
303 T: Serialize,
304{
305 match toon_format::encode_default(payload) {
306 Ok(mut encoded) => {
307 encoded.push('\n');
308 encoded
309 }
310 Err(error) => format!("toon_error: {}\n", encode_error_text(&error.to_string())),
311 }
312}
313
314#[must_use]
316pub fn encode_error_text(value: &str) -> String {
317 match toon_format::encode_default(&json!({ "value": value })) {
318 Ok(encoded) => encoded
319 .strip_prefix("value: ")
320 .map_or_else(|| quoted_fallback(value), ToString::to_string),
321 Err(_) => quoted_fallback(value),
322 }
323}
324
325fn render_severity(severity: Severity) -> &'static str {
327 severity.as_str()
328}
329
330fn percentage_label(rate: Option<f64>) -> String {
332 rate.map_or_else(
333 || "unknown".to_string(),
334 |value| format!("{:.1}%", value * 100.0),
335 )
336}
337
338fn quoted_fallback(value: &str) -> String {
340 let escaped = value
341 .replace('\\', "\\\\")
342 .replace('"', "\\\"")
343 .replace('\n', "\\n")
344 .replace('\r', "\\r")
345 .replace('\t', "\\t");
346 format!("\"{escaped}\"")
347}
348
349#[cfg(test)]
350mod tests {
351 use super::{encode_agent_payload, render_symbols, render_token_overview, render_token_trends};
352 use crate::symbols::{CodeSymbol, ParserKind, SymbolKind};
353 use crate::telemetry::{
354 TokenOverview, TokenTrendReport, TokenTrendWindow, UsageDetailAvailability,
355 usage_from_estimates, usage_from_text,
356 };
357 use serde_json::{Value, json};
358
359 #[test]
360 fn renders_round_trippable_toon_with_standard_decoder() -> Result<(), Box<dyn std::error::Error>>
361 {
362 let toon = encode_agent_payload(&serde_json::json!({
363 "items": [
364 {"path": "src/lib.rs", "text": "alpha,beta"},
365 {"path": "src/main.rs", "text": "line\nbreak"}
366 ]
367 }));
368 let decoded: Value = toon_format::decode_default(&toon)?;
369 if decoded["items"][0]["text"] != "alpha,beta" {
370 return Err("first decoded item did not round-trip".into());
371 }
372 if decoded["items"][1]["text"] != "line\nbreak" {
373 return Err("second decoded item did not round-trip".into());
374 }
375 Ok(())
376 }
377
378 #[test]
379 fn renders_symbols_as_tabular_toon() {
380 let toon = render_symbols(&[CodeSymbol {
381 path: "src/lib.rs".to_string(),
382 language: Some("rust".to_string()),
383 name: "scan".to_string(),
384 kind: SymbolKind::Function,
385 signature: "fn scan()".to_string(),
386 exported: false,
387 documentation: None,
388 line_start: 1,
389 line_end: 3,
390 parent: None,
391 parser: ParserKind::TreeSitter,
392 detail: Some("function_item".to_string()),
393 }]);
394 assert!(toon.contains(
395 "symbols[1]{path,kind,name,start,end,parent,parser,signature,exported,documentation}:"
396 ));
397 }
398
399 #[test]
400 fn renders_token_overview_with_read_avoidance_section() -> Result<(), Box<dyn std::error::Error>>
401 {
402 let overview = TokenOverview::from_events(&[
403 usage_from_text("s", "summary", None, None, "abcdefghijkl", "abcd"),
404 usage_from_estimates("s", "search", None, None, 100, 20),
405 ]);
406 let toon = render_token_overview(&overview);
407 let decoded: Value = toon_format::decode_default(&toon)?;
408 let token_savings = &decoded["token_savings"];
409
410 require_json_eq(
411 &token_savings["likely_file_reads_avoided"],
412 &json!(2),
413 "top-level read avoidance",
414 )?;
415 require_json_eq(
416 &token_savings["read_avoidance"]["likely_file_reads_avoided"],
417 &json!(2),
418 "section read avoidance",
419 )?;
420 require_json_eq(
421 &token_savings["read_avoidance"]["observed_file_read_replacements"],
422 &json!(1),
423 "observed read replacements",
424 )?;
425 require_json_eq(
426 &token_savings["read_avoidance"]["modeled_file_reads_avoided"],
427 &json!(1),
428 "modeled read avoidance",
429 )?;
430 require_json_eq(
431 &token_savings["totals"]["likely_file_reads_avoided"],
432 &json!(2),
433 "total read avoidance",
434 )?;
435 require_json_eq(
436 &token_savings["read_avoidance"]["plain_language"],
437 &json!(
438 "ProjectAtlas summaries, search results, and slices were used instead of opening likely whole files."
439 ),
440 "plain language read avoidance",
441 )?;
442 Ok(())
443 }
444
445 #[test]
446 fn token_toon_reports_retained_detail_truth_for_overview_and_trends()
447 -> Result<(), Box<dyn std::error::Error>> {
448 let mut overview = TokenOverview::from_events(&[]);
449 overview.set_detail_availability(UsageDetailAvailability::Partial);
450 let overview_toon = render_token_overview(&overview);
451 let decoded_overview: Value = toon_format::decode_default(&overview_toon)?;
452 require_json_eq(
453 &decoded_overview["token_savings"]["detail_availability"],
454 &json!("partial"),
455 "overview detail availability",
456 )?;
457
458 let mut trends = TokenTrendReport::new(None, TokenTrendWindow::Month, Vec::new());
459 trends.set_detail_availability(UsageDetailAvailability::Expired);
460 let trends_toon = render_token_trends(&trends);
461 let decoded_trends: Value = toon_format::decode_default(&trends_toon)?;
462 require_json_eq(
463 &decoded_trends["token_trends"]["detail_availability"],
464 &json!("expired"),
465 "trend detail availability",
466 )?;
467 Ok(())
468 }
469
470 fn require_json_eq(
471 actual: &Value,
472 expected: &Value,
473 label: &str,
474 ) -> Result<(), Box<dyn std::error::Error>> {
475 if actual == expected {
476 Ok(())
477 } else {
478 Err(format!("{label}: expected {expected}, got {actual}").into())
479 }
480 }
481}