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() -> 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        // 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 `entry` (or the alphabetically first jig if
50/// `None`) as a complete HTML document. `title` is shown in the page header
51/// 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(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('&', "&amp;")
201        .replace('<', "&lt;")
202        .replace('>', "&gt;")
203        .replace('"', "&quot;")
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}