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