1use jigs_core::JigMeta;
4use std::collections::BTreeMap;
5use std::path::{Path, PathBuf};
6
7const TEMPLATE: &str = include_str!("template.html");
8
9pub fn to_html(entry: Option<&str>, title: &str, editor: Option<&str>) -> String {
24 let all: BTreeMap<&'static str, &'static JigMeta> =
25 jigs_core::all_jigs().map(|m| (m.name, m)).collect();
26 let entry = entry
27 .map(str::to_string)
28 .or_else(|| all.keys().next().map(|s| s.to_string()))
29 .unwrap_or_default();
30 let visible = reachable(&all, &entry);
31 let data = encode(&visible, &entry, title, editor);
32 TEMPLATE
33 .replace("__TITLE__", &esc_attr(title))
34 .replace("__DATA__", &data)
35}
36
37fn reachable(
38 all: &BTreeMap<&'static str, &'static JigMeta>,
39 entry: &str,
40) -> BTreeMap<String, &'static JigMeta> {
41 let mut out = BTreeMap::new();
42 let mut stack = vec![entry.to_string()];
43 while let Some(name) = stack.pop() {
44 if out.contains_key(&name) {
45 continue;
46 }
47 if let Some(m) = all.get(name.as_str()) {
48 for c in m.chain {
49 stack.push((*c).to_string());
50 }
51 out.insert(name, *m);
52 }
53 }
54 out
55}
56
57fn encode(
58 visible: &BTreeMap<String, &'static JigMeta>,
59 entry: &str,
60 title: &str,
61 editor: Option<&str>,
62) -> String {
63 let mut s = String::new();
64 s.push_str("{\"entry\":");
65 push_json_str(&mut s, entry);
66 s.push_str(",\"title\":");
67 push_json_str(&mut s, title);
68 s.push_str(",\"editor\":");
69 match editor {
70 Some(t) => push_json_str(&mut s, t),
71 None => s.push_str("null"),
72 }
73 s.push_str(",\"nodes\":{");
74 for (i, m) in visible.values().enumerate() {
75 if i > 0 {
76 s.push(',');
77 }
78 push_json_str(&mut s, m.name);
79 s.push_str(":{\"file\":");
80 push_json_str(&mut s, m.file);
81 s.push_str(",\"line\":");
82 s.push_str(&m.line.to_string());
83 s.push_str(",\"kind\":");
84 push_json_str(&mut s, m.kind);
85 s.push_str(",\"input\":");
86 push_json_str(&mut s, m.input);
87 s.push_str(",\"async\":");
88 s.push_str(if m.is_async { "true" } else { "false" });
89 s.push_str(",\"file_abs\":");
90 push_json_str(&mut s, &absolutize(m.file));
91 s.push_str(",\"basename\":");
92 push_json_str(&mut s, basename(m.file));
93 s.push_str(",\"children\":[");
94 for (j, c) in m.chain.iter().enumerate() {
95 if j > 0 {
96 s.push(',');
97 }
98 push_json_str(&mut s, c);
99 }
100 s.push_str("]}");
101 }
102 s.push_str("}}");
103 s
104}
105
106fn push_json_str(out: &mut String, s: &str) {
107 out.push('"');
108 for ch in s.chars() {
109 match ch {
110 '"' => out.push_str("\\\""),
111 '\\' => out.push_str("\\\\"),
112 '\n' => out.push_str("\\n"),
113 '\r' => out.push_str("\\r"),
114 '\t' => out.push_str("\\t"),
115 '<' => out.push_str("\\u003c"),
116 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
117 c => out.push(c),
118 }
119 }
120 out.push('"');
121}
122
123fn basename(file: &str) -> &str {
124 Path::new(file)
125 .file_name()
126 .and_then(|n| n.to_str())
127 .unwrap_or(file)
128}
129
130fn absolutize(file: &str) -> String {
131 let p = Path::new(file);
132 if p.is_absolute() {
133 return file.to_string();
134 }
135 match std::env::current_dir() {
136 Ok(cwd) => {
137 let joined: PathBuf = cwd.join(p);
138 joined.to_string_lossy().into_owned()
139 }
140 Err(_) => file.to_string(),
141 }
142}
143
144fn esc_attr(s: &str) -> String {
145 s.replace('&', "&")
146 .replace('<', "<")
147 .replace('>', ">")
148 .replace('"', """)
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 fn meta(name: &'static str, kind: &'static str, chain: &'static [&'static str]) -> JigMeta {
156 JigMeta {
157 name,
158 file: "test.rs",
159 line: 1,
160 kind,
161 input: "Request",
162 is_async: false,
163 chain,
164 }
165 }
166
167 fn fake(items: Vec<JigMeta>) -> BTreeMap<&'static str, &'static JigMeta> {
168 let leaked: Vec<&'static JigMeta> = items
169 .into_iter()
170 .map(|m| Box::leak(Box::new(m)) as &'static _)
171 .collect();
172 leaked.into_iter().map(|m| (m.name, m)).collect()
173 }
174
175 #[test]
176 fn reachable_filters_to_entry_subgraph() {
177 let all = fake(vec![
178 meta("root", "Response", &["a", "b"]),
179 meta("a", "Request", &[]),
180 meta("b", "Branch", &[]),
181 meta("orphan", "Other", &[]),
182 ]);
183 let r = reachable(&all, "root");
184 assert!(r.contains_key("root"));
185 assert!(r.contains_key("a"));
186 assert!(r.contains_key("b"));
187 assert!(!r.contains_key("orphan"));
188 }
189
190 #[test]
191 fn reachable_handles_cycles() {
192 let all = fake(vec![meta("a", "Other", &["b"]), meta("b", "Other", &["a"])]);
193 let r = reachable(&all, "a");
194 assert_eq!(r.len(), 2);
195 }
196
197 #[test]
198 fn encode_emits_structure() {
199 let all = fake(vec![
200 meta("root", "Response", &["a"]),
201 meta("a", "Request", &[]),
202 ]);
203 let visible = reachable(&all, "root");
204 let json = encode(&visible, "root", "demo", None);
205 assert!(json.contains("\"entry\":\"root\""));
206 assert!(json.contains("\"root\":{"));
207 assert!(json.contains("\"children\":[\"a\"]"));
208 assert!(json.contains("\"editor\":null"));
209 }
210
211 #[test]
212 fn editor_template_is_embedded_when_set() {
213 let all = fake(vec![meta("root", "Response", &[])]);
214 let visible = reachable(&all, "root");
215 let tmpl = "vscodium://file/{path}:{line}";
216 let json = encode(&visible, "root", "demo", Some(tmpl));
217 assert!(json.contains("\"editor\":\"vscodium://file/{path}:{line}\""));
218 }
219
220 #[test]
221 fn json_escapes_script_close() {
222 let all = fake(vec![meta("</script>", "Other", &[])]);
223 let visible = reachable(&all, "</script>");
224 let json = encode(&visible, "</script>", "t", None);
225 assert!(!json.contains("</script>"));
226 assert!(json.contains("\\u003c/script"));
227 }
228}