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