projectatlas_core/
toon.rs

1//! Purpose: Render `ProjectAtlas` responses with the TOON standard encoder.
2
3use 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/// Render a repository overview as standard TOON.
12#[must_use]
13pub fn render_overview(overview: &Overview) -> String {
14    encode_agent_payload(&json!({ "overview": overview }))
15}
16
17/// Build the agent-facing folder/file row projection used by TOON and JSON.
18#[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/// Build the agent-facing ranked row projection with bounded reasons.
60#[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/// Render indexed nodes as standard TOON.
89#[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/// Render ranked indexed nodes as standard TOON.
96#[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/// Render an outline as standard TOON.
103#[must_use]
104pub fn render_outline(outline: &FileOutline) -> String {
105    encode_agent_payload(&json!({ "outline": outline }))
106}
107
108/// Render health findings as standard TOON.
109#[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/// Render token savings overview as standard TOON.
129#[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/// Render token savings trends as standard TOON.
202#[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/// Render symbols as standard TOON.
257#[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/// Render symbol relations as standard TOON.
280#[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/// Encode a serializable payload through the standard TOON Rust implementation.
300#[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/// Encode one string using TOON by wrapping it in an object and extracting text.
315#[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
325/// Render a severity enum as a stable TOON value.
326fn render_severity(severity: Severity) -> &'static str {
327    severity.as_str()
328}
329
330/// Format an optional savings rate as a stable display label.
331fn 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
338/// Return a conservative quoted fallback for rare encoder failures.
339fn 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}