1use jigs_core::JigMeta;
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7const TEMPLATE: &str = include_str!("template.html");
8
9type Index = BTreeMap<&'static str, Vec<&'static JigMeta>>;
10
11fn build_index(jigs: impl Iterator<Item = &'static JigMeta>) -> Index {
12 let mut map: Index = BTreeMap::new();
13 for m in jigs {
14 map.entry(m.name).or_default().push(m);
15 }
16 map
17}
18
19fn resolve(name: &str, all: &Index) -> Option<&'static JigMeta> {
20 if let Some(v) = all.get(name) {
21 return v
25 .iter()
26 .min_by_key(|m| m.module.split("::").count())
27 .copied();
28 }
29 if let Some(pos) = name.rfind("::") {
30 let target_name = &name[pos + 2..];
31 let prefix = name[..pos].strip_prefix("crate::").unwrap_or(&name[..pos]);
32 if let Some(candidates) = all.get(target_name) {
33 for m in candidates {
34 if m.module.ends_with(prefix) || m.module.contains(&format!("::{}", prefix)) {
35 return Some(m);
36 }
37 }
38 for m in candidates {
39 if m.file.contains(prefix) {
40 return Some(m);
41 }
42 }
43 return candidates.first().copied();
44 }
45 }
46 None
47}
48
49pub fn to_html(
66 jigs: impl Iterator<Item = &'static JigMeta>,
67 title: &str,
68 editor: Option<&str>,
69) -> String {
70 let mut peekable = jigs.peekable();
71 let entry = peekable
72 .peek()
73 .map(|m| m.name.to_string())
74 .unwrap_or_default();
75 let all = build_index(peekable);
76 let visible = reachable(&all, &entry);
77 let data = encode(&visible, &entry, title, editor);
78 TEMPLATE
79 .replace("__TITLE__", &esc_attr(title))
80 .replace("__DATA__", &data)
81}
82
83fn reachable(all: &Index, entry: &str) -> BTreeMap<String, &'static JigMeta> {
84 let mut out = BTreeMap::new();
85 let mut stack = vec![entry.to_string()];
86 while let Some(name) = stack.pop() {
87 if out.contains_key(&name) {
88 continue;
89 }
90 if let Some(m) = resolve(name.as_str(), all) {
91 for c in m.chain {
92 stack.push(c.name.to_string());
93 }
94 out.insert(name, m);
95 }
96 }
97 out
98}
99
100fn encode(
101 visible: &BTreeMap<String, &'static JigMeta>,
102 entry: &str,
103 title: &str,
104 editor: Option<&str>,
105) -> String {
106 let mut s = String::new();
107 s.push_str("{\"entry\":");
108 push_json_str(&mut s, entry);
109 s.push_str(",\"title\":");
110 push_json_str(&mut s, title);
111 s.push_str(",\"editor\":");
112 match editor {
113 Some(t) => push_json_str(&mut s, t),
114 None => s.push_str("null"),
115 }
116 s.push_str(",\"nodes\":{");
117 for (i, (key, m)) in visible.iter().enumerate() {
118 if i > 0 {
119 s.push(',');
120 }
121 push_json_str(&mut s, key);
122 s.push_str(":{\"file\":");
123 push_json_str(&mut s, m.file);
124 s.push_str(",\"line\":");
125 s.push_str(&m.line.to_string());
126 s.push_str(",\"kind\":");
127 push_json_str(&mut s, m.kind);
128 s.push_str(",\"input\":");
129 push_json_str(&mut s, m.input);
130 s.push_str(",\"input_type\":");
131 push_json_str(&mut s, m.input_type);
132 s.push_str(",\"output_type\":");
133 push_json_str(&mut s, m.output_type);
134 s.push_str(",\"async\":");
135 s.push_str(if m.is_async { "true" } else { "false" });
136 s.push_str(",\"file_abs\":");
137 push_json_str(&mut s, &absolutize(m.file));
138 s.push_str(",\"basename\":");
139 push_json_str(&mut s, basename(m.file));
140 s.push_str(",\"module\":");
141 push_json_str(&mut s, m.module);
142 s.push_str(",\"children\":[");
143 for (j, c) in m.chain.iter().enumerate() {
144 if j > 0 {
145 s.push(',');
146 }
147 push_json_str(&mut s, c.name);
148 }
149 s.push_str("],\"child_kinds\":[");
150 for (j, c) in m.chain.iter().enumerate() {
151 if j > 0 {
152 s.push(',');
153 }
154 let k = match c.kind {
155 jigs_core::ChainKind::Then => "then",
156 jigs_core::ChainKind::Fork => "fork",
157 };
158 push_json_str(&mut s, k);
159 }
160 s.push_str("]}");
161 }
162 s.push_str("}}");
163 s
164}
165
166fn push_json_str(out: &mut String, s: &str) {
167 out.push('"');
168 for ch in s.chars() {
169 match ch {
170 '"' => out.push_str("\\\""),
171 '\\' => out.push_str("\\\\"),
172 '\n' => out.push_str("\\n"),
173 '\r' => out.push_str("\\r"),
174 '\t' => out.push_str("\\t"),
175 '<' => out.push_str("\\u003c"),
176 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
177 c => out.push(c),
178 }
179 }
180 out.push('"');
181}
182
183fn basename(file: &str) -> &str {
184 Path::new(file)
185 .file_name()
186 .and_then(|n| n.to_str())
187 .unwrap_or(file)
188}
189
190fn absolutize(file: &str) -> String {
191 let p = Path::new(file);
192 if p.is_absolute() {
193 return file.to_string();
194 }
195 match std::env::current_dir() {
196 Ok(cwd) => {
197 let joined: PathBuf = cwd.join(p);
198 joined.to_string_lossy().into_owned()
199 }
200 Err(_) => file.to_string(),
201 }
202}
203
204fn esc_attr(s: &str) -> String {
205 s.replace('&', "&")
206 .replace('<', "<")
207 .replace('>', ">")
208 .replace('"', """)
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 fn meta(name: &'static str, kind: &'static str, chain: &[&'static str]) -> JigMeta {
216 let v: Vec<jigs_core::ChainStep> = chain
217 .iter()
218 .map(|n| jigs_core::ChainStep {
219 name: n,
220 kind: jigs_core::ChainKind::Then,
221 })
222 .collect();
223 let leaked: &'static [jigs_core::ChainStep] = Box::leak(v.into_boxed_slice());
224 JigMeta {
225 name,
226 file: "test.rs",
227 line: 1,
228 kind,
229 input: "Request",
230 input_type: "",
231 output_type: "",
232 is_async: false,
233 module: "crate",
234 chain: leaked,
235 }
236 }
237
238 fn fake(items: Vec<JigMeta>) -> Index {
239 let mut map: Index = BTreeMap::new();
240 for m in items {
241 let leaked: &'static JigMeta = Box::leak(Box::new(m));
242 map.entry(leaked.name).or_default().push(leaked);
243 }
244 map
245 }
246
247 #[test]
248 fn reachable_filters_to_entry_subgraph() {
249 let all = fake(vec![
250 meta("root", "Response", &["a", "b"]),
251 meta("a", "Request", &[]),
252 meta("b", "Branch", &[]),
253 meta("orphan", "Other", &[]),
254 ]);
255 let r = reachable(&all, "root");
256 assert!(r.contains_key("root"));
257 assert!(r.contains_key("a"));
258 assert!(r.contains_key("b"));
259 assert!(!r.contains_key("orphan"));
260 }
261
262 #[test]
263 fn reachable_handles_cycles() {
264 let all = fake(vec![meta("a", "Other", &["b"]), meta("b", "Other", &["a"])]);
265 let r = reachable(&all, "a");
266 assert_eq!(r.len(), 2);
267 }
268
269 #[test]
270 fn encode_emits_structure() {
271 let all = fake(vec![
272 meta("root", "Response", &["a"]),
273 meta("a", "Request", &[]),
274 ]);
275 let visible = reachable(&all, "root");
276 let json = encode(&visible, "root", "demo", None);
277 assert!(json.contains("\"entry\":\"root\""));
278 assert!(json.contains("\"root\":{"));
279 assert!(json.contains("\"children\":[\"a\"]"));
280 assert!(json.contains("\"editor\":null"));
281 }
282
283 #[test]
284 fn editor_template_is_embedded_when_set() {
285 let all = fake(vec![meta("root", "Response", &[])]);
286 let visible = reachable(&all, "root");
287 let tmpl = "vscodium://file/{path}:{line}";
288 let json = encode(&visible, "root", "demo", Some(tmpl));
289 assert!(json.contains("\"editor\":\"vscodium://file/{path}:{line}\""));
290 }
291
292 #[test]
293 fn json_escapes_script_close() {
294 let all = fake(vec![meta("</script>", "Other", &[])]);
295 let visible = reachable(&all, "</script>");
296 let json = encode(&visible, "</script>", "t", None);
297 assert!(!json.contains("</script>"));
298 assert!(json.contains("\\u003c/script"));
299 }
300}