Skip to main content

tvscreener/mcp/
format.rs

1// Copyright 2026 tvscreener-rs contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Text-formatting helpers for MCP tool responses.
5
6use crate::beautify::DEFAULT_TABLE_MAX_COLUMNS;
7use crate::error::Result;
8use crate::field::{
9    all_countries, all_exchanges, all_index_symbols, all_industries, all_markets, all_ratings,
10    all_sectors, get_preset, list_presets, resolve_field, search_fields, to_symbolset_wire, Asset,
11    NamedValue,
12};
13use crate::util::format_value;
14use crate::ScreenerRow;
15use std::collections::BTreeSet;
16use std::fmt::Write as _;
17
18fn format_const_wire_list(title: &str, items: &[NamedValue], footer: Option<&str>) -> String {
19    let mut out = format!("{title}\n");
20    for item in items {
21        let _ = writeln!(out, "  - **{}**: `{}`", item.const_name, item.value);
22    }
23    if let Some(footer) = footer {
24        out.push('\n');
25        out.push_str(footer);
26        if !footer.ends_with('\n') {
27            out.push('\n');
28        }
29    }
30    out
31}
32
33/// Formats `discover_fields` search hits as text.
34#[must_use]
35pub fn format_discover_fields(asset: Asset, search_term: &str, limit: usize) -> String {
36    let limit = limit.clamp(1, 100);
37    let hits = search_fields(asset, search_term);
38    let mut lines = Vec::new();
39    for def in hits.into_iter().take(limit) {
40        let Some((const_name, resolved)) = resolve_field(asset, &def.field_name) else {
41            continue;
42        };
43        let marker = if resolved.interval {
44            " [Technical]"
45        } else {
46            ""
47        };
48        lines.push(format!("- **{const_name}**: {}{marker}", resolved.label));
49    }
50    if lines.is_empty() {
51        format!("No fields found matching '{search_term}'")
52    } else {
53        format!(
54            "Found {} fields matching '{search_term}':\n\n{}\n",
55            lines.len(),
56            lines.join("\n")
57        )
58    }
59}
60
61/// Lists sample field categories for an asset (keyword buckets).
62#[must_use]
63pub fn format_field_types(asset: Asset) -> String {
64    const KEYWORDS: &[&str] = &[
65        "price",
66        "volume",
67        "moving_average",
68        "rsi",
69        "macd",
70        "bollinger",
71        "earnings",
72        "dividend",
73        "market_cap",
74        "sector",
75        "recommend",
76    ];
77    let mut out = format!("Field categories for {}:\n\n", asset.as_str());
78    for keyword in KEYWORDS {
79        let names: Vec<String> = search_fields(asset, keyword)
80            .into_iter()
81            .take(5)
82            .filter_map(|def| resolve_field(asset, &def.field_name).map(|(n, _)| n))
83            .collect();
84        if names.is_empty() {
85            continue;
86        }
87        let _ = writeln!(out, "**{keyword}**:");
88        for name in names {
89            let _ = writeln!(out, "  - {name}");
90        }
91        out.push('\n');
92    }
93    out.push_str("Use discover_fields('keyword') to find more fields in each category.\n");
94    out
95}
96
97/// Formats preset names as text.
98#[must_use]
99pub fn format_list_presets() -> String {
100    let names = list_presets();
101    let mut out = String::from("Available presets:\n");
102    for name in names {
103        let _ = writeln!(out, "  - {name}");
104    }
105    out
106}
107
108/// Lists stock sectors (`sector::*` const → wire value).
109#[must_use]
110pub fn format_list_sectors() -> String {
111    format_const_wire_list(
112        "Available sectors (const → wire):",
113        all_sectors(),
114        Some(
115            "Pass const names (e.g. `TECHNOLOGY_SERVICES`) or wire values to `search_stocks` `sectors` CSV.\n",
116        ),
117    )
118}
119
120/// Lists countries (`country::*` const → wire value).
121#[must_use]
122pub fn format_list_countries() -> String {
123    format_const_wire_list("Available countries (const → wire):", all_countries(), None)
124}
125
126/// Lists industries (`industry::*` const → wire value).
127#[must_use]
128pub fn format_list_industries() -> String {
129    format_const_wire_list(
130        "Available industries (const → wire):",
131        all_industries(),
132        None,
133    )
134}
135
136/// Lists exchanges (`exchange::*` const → wire value).
137#[must_use]
138pub fn format_list_exchanges() -> String {
139    format_const_wire_list("Available exchanges (const → wire):", all_exchanges(), None)
140}
141
142/// Lists recommendation rating bands.
143#[must_use]
144pub fn format_list_ratings() -> String {
145    let mut out = String::from("Available recommendation ratings (const → [min, max] → label):\n");
146    for r in all_ratings() {
147        let range = r
148            .range()
149            .map_or_else(|| "n/a".into(), |[lo, hi]| format!("[{lo}, {hi}]"));
150        let _ = writeln!(out, "  - **{}**: {} - {}", r.const_name, range, r.label);
151    }
152    out
153}
154
155/// Documents filter operators accepted by `custom_query`.
156#[must_use]
157pub fn format_list_filter_operators() -> String {
158    const OPS: &[(&str, &str)] = &[
159        (">=", "Greater than or equal"),
160        (">", "Greater than"),
161        ("<=", "Less than or equal"),
162        ("<", "Less than"),
163        ("==", "Equal to"),
164        ("!=", "Not equal to"),
165        ("match", "Text contains (for text fields like SECTOR)"),
166        (
167            "in_range",
168            "Value between [min, max] (e.g. RSI between 30-70)",
169        ),
170        ("not_in_range", "Value outside [min, max]"),
171        ("crosses", "Value crosses another"),
172        ("crosses_up", "Value crosses above another"),
173        ("crosses_down", "Value crosses below another"),
174    ];
175    let mut out = String::from("Available filter operators for custom_query:\n\n");
176    for (op, desc) in OPS {
177        let _ = writeln!(out, "- **{op}**: {desc}");
178    }
179    out.push_str(
180        "\nExample filters JSON:\n\
181         ```json\n\
182         [\n\
183           {\"field\": \"PRICE\", \"op\": \">=\", \"value\": 100},\n\
184           {\"field\": \"RSI\", \"op\": \"in_range\", \"value\": [30, 70]},\n\
185           {\"field\": \"SECTOR\", \"op\": \"match\", \"value\": \"Technology Services\"}\n\
186         ]\n\
187         ```\n",
188    );
189    out
190}
191
192/// Lists stock markets (`Market` const → wire value).
193#[must_use]
194pub fn format_list_markets() -> String {
195    let mut out = String::from("Available markets (const → wire):\n");
196    for m in all_markets() {
197        let _ = writeln!(out, "  - **{}**: `{}`", m.const_name, m.value);
198    }
199    out.push_str(
200        "\nPass const names or wire values to `build_payload` / `search_by_index` via `markets` CSV.\n",
201    );
202    out
203}
204
205/// Lists index symbolsets for stock `set_index`.
206#[must_use]
207pub fn format_list_index_symbols() -> String {
208    let mut out = String::from("Available index symbols (const → catalog → payload symbolset):\n");
209    for idx in all_index_symbols() {
210        let wire = to_symbolset_wire(&idx.value);
211        let _ = writeln!(
212            out,
213            "  - **{}**: `{}` → `{}` ({})",
214            idx.const_name, idx.value, wire, idx.label
215        );
216    }
217    out.push_str(
218        "\nPass const names (e.g. `SP500`) or catalog values (e.g. `SP;SPX`) to `indices` CSV.\n",
219    );
220    out
221}
222
223/// Formats one preset's fields as text.
224///
225/// # Errors
226///
227/// Propagates unknown-preset errors from [`get_preset`].
228pub fn format_get_preset(name: &str) -> Result<String> {
229    let fields = get_preset(name)?;
230    let mut out = format!("Preset `{name}` ({} fields):\n\n", fields.len());
231    for field in fields {
232        let _ = writeln!(out, "- {} (`{}`)", field.label, field.field_name);
233    }
234    Ok(out)
235}
236
237/// Renders rows as a Markdown table (symbol + selected labels).
238#[must_use]
239pub fn format_rows_markdown(rows: &[ScreenerRow], max_rows: usize) -> String {
240    if rows.is_empty() {
241        return "No results found matching the criteria.".into();
242    }
243    let rows: Vec<&ScreenerRow> = rows.iter().take(max_rows).collect();
244    let mut labels: Vec<String> = rows
245        .iter()
246        .flat_map(|r| r.data.keys().cloned())
247        .collect::<BTreeSet<_>>()
248        .into_iter()
249        .collect();
250    // Prefer a stable short set when many columns
251    if labels.len() > DEFAULT_TABLE_MAX_COLUMNS {
252        labels.truncate(DEFAULT_TABLE_MAX_COLUMNS);
253    }
254
255    let mut header = String::from("| Symbol |");
256    let mut sep = String::from("| --- |");
257    for label in &labels {
258        let _ = write!(header, " {label} |");
259        sep.push_str(" --- |");
260    }
261    header.push('\n');
262    sep.push('\n');
263
264    let mut body = String::new();
265    for row in rows {
266        let _ = write!(body, "| {} |", row.symbol);
267        for label in &labels {
268            let cell = row
269                .data
270                .get(label)
271                .map_or_else(|| "--".into(), format_value);
272            let _ = write!(body, " {cell} |");
273        }
274        body.push('\n');
275    }
276    format!("{header}{sep}{body}")
277}