logicaffeine_web/ui/pages/
heal.rs1use std::ops::Range;
12
13use serde::{Deserialize, Serialize};
14
15pub const ENDPOINT: &str = match option_env!("LOGOS_HEAL_URL") {
18 Some(url) => url,
19 None => "https://api.logicaffeine.com/heal",
20};
21
22pub const FAIRY: &str = "/assets/heal-fairy.png";
24
25pub const EMPTY_EDITOR: &str = "Nothing to heal yet. Write some English in the editor first.";
27
28pub const NOTHING_NEW: &str = "Nothing new to heal. Change some lines first, then press Heal.";
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum Mode {
35 Code,
36 Logic,
37}
38
39#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
43pub struct Request {
44 pub mode: Mode,
45 pub text: String,
46 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
47 pub fragment: bool,
48}
49
50#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52pub struct Answer {
53 pub ok: bool,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub healed: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub model: Option<String>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub error: Option<String>,
60}
61
62pub fn outcome(status: u16, body: &str) -> Result<String, String> {
64 let answer: Answer = match serde_json::from_str(body) {
65 Ok(answer) => answer,
66 Err(_) if status == 429 => return Err(reason("rate_limited")),
67 Err(_) => return Err(reason("not_json")),
68 };
69 match (status, answer.ok, answer.healed) {
70 (200, true, Some(healed)) if !healed.trim().is_empty() => Ok(healed),
71 (200, true, _) => Err(reason("empty_answer")),
72 (429, _, _) => Err(reason("rate_limited")),
73 _ => Err(reason(answer.error.as_deref().unwrap_or("failed"))),
74 }
75}
76
77pub fn reason(error: &str) -> String {
79 match error {
80 "text_too_long" => "That is more than one heal takes (8 KB). Heal a part of it.".to_string(),
81 "text_missing" => EMPTY_EDITOR.to_string(),
82 "rate_limited" => "Too many heals in a minute. Try again shortly.".to_string(),
83 "upstream_failed" => "The model did not answer. Try again.".to_string(),
84 "empty_answer" | "no_text" => "The model answered with no text. Try again.".to_string(),
85 "origin_not_allowed" => "This page may not heal from here.".to_string(),
86 "unreachable" => "The heal service did not answer. Check the connection and try again.".to_string(),
87 other => format!("Heal failed ({other})."),
88 }
89}
90
91pub fn changed_lines(before: &str, after: &str) -> Vec<Range<usize>> {
95 line_diff(before, after).after
96}
97
98#[derive(Clone, Debug, Default, PartialEq, Eq)]
102pub struct LineDiff {
103 pub before: Vec<Range<usize>>,
104 pub after: Vec<Range<usize>>,
105}
106
107pub fn line_diff(before: &str, after: &str) -> LineDiff {
108 let a: Vec<&str> = before.lines().map(str::trim_end).collect();
109 let b: Vec<&str> = after.lines().map(str::trim_end).collect();
110 let (n, m) = (a.len(), b.len());
112 let mut table = vec![vec![0usize; m + 1]; n + 1];
113 for i in (0..n).rev() {
114 for j in (0..m).rev() {
115 table[i][j] = if a[i] == b[j] { table[i + 1][j + 1] + 1 } else { table[i + 1][j].max(table[i][j + 1]) };
116 }
117 }
118 fn push(ranges: &mut Vec<Range<usize>>, range: Range<usize>) {
119 match ranges.last_mut() {
120 Some(last) if last.end == range.start => last.end = range.end,
121 _ => ranges.push(range),
122 }
123 }
124 let mut diff = LineDiff::default();
125 let (mut i, mut j) = (0, 0);
126 while i < n || j < m {
127 if i < n && j < m && a[i] == b[j] {
128 i += 1;
129 j += 1;
130 } else if j < m && (i >= n || table[i][j + 1] >= table[i + 1][j]) {
131 push(&mut diff.after, j..j + 1);
132 push(&mut diff.before, i..i);
133 j += 1;
134 } else {
135 push(&mut diff.after, j..j);
136 push(&mut diff.before, i..i + 1);
137 i += 1;
138 }
139 }
140 diff
141}
142
143pub fn edited_regions(baseline: &str, current: &str) -> Vec<Range<usize>> {
149 let mut regions: Vec<Range<usize>> = Vec::new();
150 for range in line_diff(baseline, current).after {
151 if range.is_empty() {
152 continue;
153 }
154 match regions.last_mut() {
155 Some(last) if range.start <= last.end + 1 => last.end = last.end.max(range.end),
156 _ => regions.push(range),
157 }
158 }
159 regions
160}
161
162pub fn region_text(text: &str, region: &Range<usize>) -> String {
164 text.lines().skip(region.start).take(region.len()).collect::<Vec<_>>().join("\n")
165}
166
167pub fn reindent(region: &str, mended: &str) -> String {
171 let indent_of = |line: &str| line.len() - line.trim_start().len();
172 let base = region.lines().next().map_or(0, indent_of);
173 let prefix = " ".repeat(base);
174 mended
175 .lines()
176 .map(|line| {
177 if line.trim().is_empty() || indent_of(line) >= base {
178 line.to_string()
179 } else {
180 format!("{prefix}{}", line.trim_start())
181 }
182 })
183 .collect::<Vec<_>>()
184 .join("\n")
185}
186
187pub fn splice(text: &str, regions: &[Range<usize>], mended: &[String]) -> String {
190 let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
191 for (region, replacement) in regions.iter().zip(mended).rev() {
192 let new_lines: Vec<String> = replacement.lines().map(str::to_string).collect();
193 lines.splice(region.clone(), new_lines);
194 }
195 let mut out = lines.join("\n");
196 if text.ends_with('\n') {
197 out.push('\n');
198 }
199 out
200}
201
202pub fn same_text(before: &str, after: &str) -> bool {
205 let norm = |t: &str| t.lines().map(str::trim_end).collect::<Vec<_>>().join("\n").trim_end().to_string();
206 norm(before) == norm(after)
207}
208
209pub fn word_marks<'a>(line: &'a str, other: &str) -> Vec<(&'a str, bool)> {
213 let a: Vec<&str> = line.split_inclusive(' ').collect();
214 let b: Vec<&str> = other.split_inclusive(' ').collect();
215 let prefix = a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count();
216 let suffix = a[prefix..].iter().rev().zip(b[prefix..].iter().rev()).take_while(|(x, y)| x == y).count();
217 let mut out = Vec::new();
218 let mut piece = |range: Range<usize>, marked: bool| {
219 if range.start < range.end {
220 let start = a[..range.start].iter().map(|s| s.len()).sum::<usize>();
221 let end = a[..range.end].iter().map(|s| s.len()).sum::<usize>();
222 out.push((&line[start..end], marked));
223 }
224 };
225 piece(0..prefix, false);
226 piece(prefix..a.len() - suffix, true);
227 piece(a.len() - suffix..a.len(), false);
228 out
229}
230
231#[derive(Clone, Debug, PartialEq, Eq)]
233pub enum Flight {
234 Idle,
236 Flying { to: usize },
238 Mending,
240 Review { before: String, after: String, lines: Vec<Range<usize>>, diff: LineDiff },
242 Failed(String),
244}
245
246impl Flight {
247 pub fn busy(&self) -> bool {
249 matches!(self, Flight::Flying { .. } | Flight::Mending)
250 }
251
252 pub fn begin(text: &str) -> Flight {
254 if text.trim().is_empty() {
255 Flight::Failed(EMPTY_EDITOR.to_string())
256 } else {
257 Flight::Flying { to: 0 }
258 }
259 }
260
261 pub fn arrived(self) -> Flight {
263 match self {
264 Flight::Flying { .. } => Flight::Mending,
265 other => other,
266 }
267 }
268
269 pub fn answered(before: String, outcome: Result<String, String>) -> Flight {
272 match outcome {
273 Ok(after) if same_text(&before, &after) => Flight::Failed("Nothing to mend. The text already reads well.".to_string()),
274 Ok(after) => {
275 let diff = line_diff(&before, &after);
276 let lines = diff.after.clone();
277 Flight::Review { before, after, lines, diff }
278 }
279 Err(reason) => Flight::Failed(reason),
280 }
281 }
282
283 pub fn keep(self) -> (Flight, Option<String>) {
285 match self {
286 Flight::Review { after, .. } => (Flight::Idle, Some(after)),
287 _ => (Flight::Idle, None),
288 }
289 }
290
291 pub fn undo(self) -> Flight {
293 Flight::Idle
294 }
295
296 pub fn line(&self) -> usize {
298 match self {
299 Flight::Flying { to } => *to,
300 Flight::Review { lines, .. } => lines.first().map_or(0, |r| r.start),
301 _ => 0,
302 }
303 }
304}
305
306pub fn narration(flight: &Flight) -> Option<String> {
308 match flight {
309 Flight::Idle => None,
310 Flight::Flying { .. } | Flight::Mending => Some("Healing\u{2026}".to_string()),
311 Flight::Review { lines, .. } => {
312 let n: usize = lines.iter().map(|r| r.len().max(1)).sum();
313 Some(format!("Healed {n} line{}. Keep or Undo.", if n == 1 { "" } else { "s" }))
314 }
315 Flight::Failed(reason) => Some(reason.clone()),
316 }
317}
318
319pub fn pixel_steps(len: usize, seed: u64) -> Vec<usize> {
322 let mut order: Vec<usize> = (0..len).collect();
323 let mut state = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15).max(1);
324 for i in (1..len).rev() {
325 state ^= state << 13;
326 state ^= state >> 7;
327 state ^= state << 17;
328 let j = (state % (i as u64 + 1)) as usize;
329 order.swap(i, j);
330 }
331 order
332}
333
334pub async fn send(mode: Mode, text: &str, fragment: bool) -> Result<String, String> {
337 #[cfg(target_arch = "wasm32")]
338 {
339 let body = Request { mode, text: text.to_string(), fragment };
340 let Ok(request) = gloo_net::http::Request::post(ENDPOINT).json(&body) else {
341 return Err(reason("unreachable"));
342 };
343 match request.send().await {
344 Ok(response) => {
345 let status = response.status();
346 let body = response.text().await.unwrap_or_default();
347 outcome(status, &body)
348 }
349 Err(_) => Err(reason("unreachable")),
350 }
351 }
352 #[cfg(not(target_arch = "wasm32"))]
353 {
354 let _ = (mode, text, fragment);
355 Err(reason("unreachable"))
356 }
357}
358
359#[cfg(test)]
360#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/pages/heal/tests.rs"]
361mod tests;