1pub const PLOT_PREFIX: &str = "@PLOT:";
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum PlotKind {
10 Bar,
11 Line,
12 Scatter,
13}
14
15impl PlotKind {
16 pub fn as_tag(self) -> &'static str {
17 match self {
18 PlotKind::Bar => "BAR",
19 PlotKind::Line => "LINE",
20 PlotKind::Scatter => "SCATTER",
21 }
22 }
23
24 pub fn from_tag(tag: &str) -> Option<Self> {
25 match tag {
26 "BAR" => Some(PlotKind::Bar),
27 "LINE" => Some(PlotKind::Line),
28 "SCATTER" => Some(PlotKind::Scatter),
29 _ => None,
30 }
31 }
32}
33
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct PlotSpec {
36 pub kind: PlotKind,
37 pub r1: usize,
38 pub c1: usize,
39 pub r2: usize,
40 pub c2: usize,
41
42 pub title: Option<String>,
43 pub x_label: Option<String>,
44 pub y_label: Option<String>,
45}
46
47fn percent_encode(s: &str) -> String {
48 let mut out = String::new();
49 for b in s.as_bytes() {
50 match *b {
51 b'%' | b'|' | b':' | b'\n' | b'\r' => {
52 out.push('%');
53 out.push_str(&format!("{:02X}", b));
54 }
55 _ => out.push(*b as char),
56 }
57 }
58 out
59}
60
61fn from_hex_digit(b: u8) -> Option<u8> {
62 match b {
63 b'0'..=b'9' => Some(b - b'0'),
64 b'a'..=b'f' => Some(b - b'a' + 10),
65 b'A'..=b'F' => Some(b - b'A' + 10),
66 _ => None,
67 }
68}
69
70fn percent_decode(s: &str) -> Option<String> {
71 let bytes = s.as_bytes();
72 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
73 let mut i = 0;
74 while i < bytes.len() {
75 if bytes[i] == b'%' {
76 if i + 2 >= bytes.len() {
77 return None;
78 }
79 let hi = from_hex_digit(bytes[i + 1])?;
80 let lo = from_hex_digit(bytes[i + 2])?;
81 out.push((hi << 4) | lo);
82 i += 3;
83 } else {
84 out.push(bytes[i]);
85 i += 1;
86 }
87 }
88 String::from_utf8(out).ok()
89}
90
91pub fn format_plot_spec(spec: &PlotSpec) -> String {
92 let base = format!(
93 "{}{}:{},{},{},{}",
94 PLOT_PREFIX,
95 spec.kind.as_tag(),
96 spec.r1,
97 spec.c1,
98 spec.r2,
99 spec.c2
100 );
101
102 let title = spec.title.as_deref().unwrap_or("");
103 let x = spec.x_label.as_deref().unwrap_or("");
104 let y = spec.y_label.as_deref().unwrap_or("");
105 if title.is_empty() && x.is_empty() && y.is_empty() {
106 return base;
107 }
108
109 format!(
110 "{}|{}|{}|{}",
111 base,
112 percent_encode(title),
113 percent_encode(x),
114 percent_encode(y)
115 )
116}
117
118pub fn parse_plot_spec(s: &str) -> Option<PlotSpec> {
119 let s = s.trim();
120 let rest = s.strip_prefix(PLOT_PREFIX)?;
121 let (kind_tag, rest) = rest.split_once(':')?;
122 let kind = PlotKind::from_tag(kind_tag)?;
123
124 let (coords, meta) = rest
125 .split_once('|')
126 .map_or((rest, None), |(a, b)| (a, Some(b)));
127
128 let mut it = coords.split(',');
129 let r1 = it.next()?.parse::<usize>().ok()?;
130 let c1 = it.next()?.parse::<usize>().ok()?;
131 let r2 = it.next()?.parse::<usize>().ok()?;
132 let c2 = it.next()?.parse::<usize>().ok()?;
133 if it.next().is_some() {
134 return None;
135 }
136
137 let mut title: Option<String> = None;
138 let mut x_label: Option<String> = None;
139 let mut y_label: Option<String> = None;
140 if let Some(meta) = meta {
141 let parts: Vec<&str> = meta.split('|').collect();
142 if let Some(p) = parts.first()
143 && !p.is_empty()
144 {
145 title = percent_decode(p);
146 }
147 if let Some(p) = parts.get(1)
148 && !p.is_empty()
149 {
150 x_label = percent_decode(p);
151 }
152 if let Some(p) = parts.get(2)
153 && !p.is_empty()
154 {
155 y_label = percent_decode(p);
156 }
157 }
158
159 Some(PlotSpec {
160 kind,
161 r1,
162 c1,
163 r2,
164 c2,
165 title,
166 x_label,
167 y_label,
168 })
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn test_plot_spec_round_trip() {
177 let spec = PlotSpec {
178 kind: PlotKind::Bar,
179 r1: 0,
180 c1: 1,
181 r2: 9,
182 c2: 1,
183 title: Some("My Plot".to_string()),
184 x_label: Some("X".to_string()),
185 y_label: Some("Y".to_string()),
186 };
187 let s = format_plot_spec(&spec);
188 assert_eq!(parse_plot_spec(&s), Some(spec));
189 }
190}