Skip to main content

jigs_map/
html.rs

1//! Render the live `JigMeta` inventory as a single self-contained HTML page.
2
3use 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        // When multiple jigs share the same short name, prefer the one
22        // with the shallowest module path (fewest segments). This makes
23        // the top-level pipeline entry win over feature-module handlers.
24        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
49/// Render the pipeline rooted at the entry jig (the first jig returned by
50/// the iterator) as a complete HTML document. `title` is shown in the page
51/// header and `<title>` tag.
52///
53/// `editor` is an optional URL template containing `{line}` plus either
54/// `{path}` (absolute file path, for local IDE handlers) or `{rel_path}`
55/// (path relative to the workspace root, for repo URLs). When set, the
56/// sidebar's file location becomes a link using the resolved template;
57/// when `None`, it renders as plain text. Common templates:
58///
59/// - VS Code / Cursor / Windsurf: `vscode://file/{path}:{line}`
60/// - VSCodium: `vscodium://file/{path}:{line}`
61/// - JetBrains IDEs: `idea://open?file={path}&line={line}`
62/// - Sublime Text: `subl://{path}:{line}`
63/// - TextMate: `txmt://open/?url=file://{path}&line={line}`
64/// - GitHub: `https://github.com/OWNER/REPO/blob/main/{rel_path}#L{line}`
65pub 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('&', "&amp;")
206        .replace('<', "&lt;")
207        .replace('>', "&gt;")
208        .replace('"', "&quot;")
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}