Skip to main content

gsm_core/
render_planner.rs

1//! Deterministic render planner prototype (capability-driven tiers).
2use serde::{Deserialize, Serialize};
3
4use crate::{
5    provider_capabilities::ProviderCapabilitiesV1,
6    render_plan::{RenderPlan, RenderTier, RenderWarning},
7};
8
9/// Minimal policy placeholder for future use.
10#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
11pub struct PlannerPolicy;
12
13/// Convenience constructor for a default policy.
14pub fn planner_policy() -> PlannerPolicy {
15    PlannerPolicy
16}
17
18/// Simplified card input for planning tests.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct PlannerCard {
21    pub title: Option<String>,
22    pub text: Option<String>,
23    #[serde(default)]
24    pub actions: Vec<PlannerAction>,
25    #[serde(default)]
26    pub images: Vec<String>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct PlannerAction {
31    pub title: String,
32    pub url: Option<String>,
33}
34
35/// Pure deterministic planner that picks a tier based on capabilities.
36pub fn plan_render(
37    card: &PlannerCard,
38    caps: &ProviderCapabilitiesV1,
39    _policy: &PlannerPolicy,
40) -> RenderPlan {
41    let mut warnings = Vec::<RenderWarning>::new();
42    let mut summary_sanitized = false;
43    let mut actions_sanitized = false;
44    let mut lines = Vec::new();
45    if let Some(title) = card.title.as_deref() {
46        let (clean, stripped) = sanitize_text(title, caps);
47        summary_sanitized |= stripped;
48        lines.push(clean);
49    }
50    if let Some(text) = card.text.as_deref()
51        && !text.is_empty()
52    {
53        let (clean, stripped) = sanitize_text(text, caps);
54        summary_sanitized |= stripped;
55        lines.push(clean);
56    }
57
58    let mut action_titles = Vec::new();
59    let mut action_links = Vec::new();
60    for action in &card.actions {
61        let (title, stripped) = sanitize_text(&action.title, caps);
62        actions_sanitized |= stripped;
63        action_titles.push(title.clone());
64        if let Some(url) = &action.url {
65            action_links.push(format!("{} ({})", title, url));
66        } else {
67            action_links.push(title);
68        }
69    }
70
71    if !action_links.is_empty() {
72        lines.push(format!("Actions: {}", action_links.join(", ")));
73    }
74
75    let mut summary = if lines.is_empty() {
76        None
77    } else {
78        Some(lines.join("\n"))
79    };
80
81    if summary_sanitized {
82        push_warning(
83            &mut warnings,
84            "formatting_stripped",
85            Some("markdown/html stripped".into()),
86            Some("/summary_text".into()),
87        );
88    }
89
90    if actions_sanitized {
91        push_warning(
92            &mut warnings,
93            "formatting_stripped",
94            Some("markdown/html stripped".into()),
95            Some("/actions".into()),
96        );
97    }
98
99    // Truncate text fields based on capabilities.
100    if let Some(max) = effective_max_text_len(caps)
101        && let Some(text) = &summary
102    {
103        let (truncated, did_truncate) = truncate_chars(text, max);
104        if did_truncate {
105            push_warning(
106                &mut warnings,
107                "text_truncated",
108                Some(format!("summary trimmed to {} chars", max)),
109                Some("/summary_text".to_string()),
110            );
111        }
112        summary = Some(truncated);
113    }
114
115    if let Some(max_bytes) = effective_max_payload_bytes(caps)
116        && let Some(text) = &summary
117    {
118        let (trimmed, did_trim) = truncate_bytes(text, max_bytes);
119        if did_trim {
120            push_warning(
121                &mut warnings,
122                "payload_trimmed",
123                Some(format!("summary trimmed to {} bytes", max_bytes)),
124                Some("/summary_text".to_string()),
125            );
126        }
127        summary = Some(trimmed);
128    }
129
130    let tier = select_tier(card, caps, &mut warnings);
131
132    RenderPlan {
133        tier,
134        summary_text: summary,
135        actions: action_titles,
136        attachments: card.images.clone(),
137        warnings,
138        debug: Some(serde_json::json!({
139            "planner_version": 1,
140            "tier": tier_label(tier),
141        })),
142    }
143}
144
145fn tier_label(tier: RenderTier) -> &'static str {
146    match tier {
147        RenderTier::TierA => "a",
148        RenderTier::TierB => "b",
149        RenderTier::TierC => "c",
150        RenderTier::TierD => "d",
151    }
152}
153
154fn select_tier(
155    card: &PlannerCard,
156    caps: &ProviderCapabilitiesV1,
157    warnings: &mut Vec<RenderWarning>,
158) -> RenderTier {
159    // Tier A path: adaptive cards supported and no unsupported elements.
160    if caps.supports_adaptive_cards {
161        let unsupported = has_unsupported_elements(card, caps, warnings);
162        if !unsupported {
163            return RenderTier::TierA;
164        }
165        // Adaptive is supported but we need to drop/alter elements.
166        return RenderTier::TierB;
167    }
168
169    // Default to Tier D for everything else.
170    let _ = has_unsupported_elements(card, caps, warnings);
171    warnings.push(RenderWarning {
172        code: "adaptive_cards_not_supported".into(),
173        message: None,
174        path: None,
175    });
176    RenderTier::TierD
177}
178
179fn has_unsupported_elements(
180    card: &PlannerCard,
181    caps: &ProviderCapabilitiesV1,
182    warnings: &mut Vec<RenderWarning>,
183) -> bool {
184    let mut unsupported = false;
185
186    if !caps.supports_buttons && !card.actions.is_empty() {
187        unsupported = true;
188        warnings.push(RenderWarning {
189            code: "unsupported_element".into(),
190            message: Some("buttons/actions not supported".into()),
191            path: Some("/actions".into()),
192        });
193    }
194
195    if !caps.supports_images && !card.images.is_empty() {
196        unsupported = true;
197        warnings.push(RenderWarning {
198            code: "images_not_supported".into(),
199            message: Some("images not supported".into()),
200            path: Some("/images".into()),
201        });
202    }
203
204    unsupported
205}
206
207fn truncate_chars(text: &str, max: usize) -> (String, bool) {
208    let mut out = String::new();
209    for (idx, ch) in text.chars().enumerate() {
210        if idx == max {
211            return (out, true);
212        }
213        out.push(ch);
214    }
215    (out, false)
216}
217
218fn truncate_bytes(text: &str, max: usize) -> (String, bool) {
219    if text.len() <= max {
220        return (text.to_string(), false);
221    }
222    let mut out = String::new();
223    let mut bytes = 0;
224    for ch in text.chars() {
225        let len = ch.len_utf8();
226        if bytes + len > max {
227            break;
228        }
229        out.push(ch);
230        bytes += len;
231    }
232    (out, true)
233}
234
235fn sanitize_text(text: &str, caps: &ProviderCapabilitiesV1) -> (String, bool) {
236    let mut sanitized = text.to_string();
237    let mut stripped = false;
238
239    if !caps.supports_html {
240        let mut out = String::with_capacity(sanitized.len());
241        let mut in_tag = false;
242        for ch in sanitized.chars() {
243            match ch {
244                '<' => {
245                    in_tag = true;
246                }
247                '>' => {
248                    in_tag = false;
249                    continue;
250                }
251                _ => {
252                    if !in_tag {
253                        out.push(ch);
254                    }
255                }
256            }
257        }
258        if out != sanitized {
259            stripped = true;
260            sanitized = out;
261        }
262    }
263
264    if !caps.supports_markdown {
265        let replaced = sanitized.replace(['*', '_', '`'], "");
266        if replaced != sanitized {
267            stripped = true;
268            sanitized = replaced;
269        }
270    }
271
272    (sanitized, stripped)
273}
274
275fn push_warning(
276    warnings: &mut Vec<RenderWarning>,
277    code: &str,
278    message: Option<String>,
279    path: Option<String>,
280) {
281    if warnings.iter().any(|w| w.code == code && w.path == path) {
282        return;
283    }
284    warnings.push(RenderWarning {
285        code: code.to_string(),
286        message,
287        path,
288    });
289}
290
291fn effective_max_text_len(caps: &ProviderCapabilitiesV1) -> Option<usize> {
292    caps.limits
293        .max_text_len
294        .or(caps.max_text_len)
295        .map(|v| v as usize)
296}
297
298fn effective_max_payload_bytes(caps: &ProviderCapabilitiesV1) -> Option<usize> {
299    caps.limits
300        .max_payload_bytes
301        .or(caps.max_payload_bytes)
302        .map(|v| v as usize)
303}