1use serde::{Deserialize, Serialize};
3
4use crate::{
5 provider_capabilities::ProviderCapabilitiesV1,
6 render_plan::{RenderPlan, RenderTier, RenderWarning},
7};
8
9#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
11pub struct PlannerPolicy;
12
13pub fn planner_policy() -> PlannerPolicy {
15 PlannerPolicy
16}
17
18#[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
35pub 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 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 if caps.supports_adaptive_cards {
161 let unsupported = has_unsupported_elements(card, caps, warnings);
162 if !unsupported {
163 return RenderTier::TierA;
164 }
165 return RenderTier::TierB;
167 }
168
169 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}