Skip to main content

scc_cli/
viewer.rs

1//! Web viewer, architecture diagram, and snap-bitmap recipe
2//! (SPEC-SCC-VIEWER): human-readable HTML over the live store, a
3//! deterministic Mermaid/SVG diagram from the L1 architecture layer, and
4//! the repo-map text plus pinned Pillow recipe behind `scc snap`.
5//!
6//! The viewer reuses `httpd.rs` routing and the loopback gate; the diagram
7//! reuses the `export_ccg` L1 filter; the snap recipe carries the same map
8//! text the CLI prints. One IR, three surfaces.
9
10
11/// Diagram model + Mermaid/SVG rendering live in the engine
12/// (`scc_engine::diagram`) so every transport renders the same bytes.
13/// This module re-exports the engine implementation; the CLI only parses
14/// args and prints.
15pub use scc_engine::diagram::{DiagramEdge, DiagramModel, DiagramNode};
16pub use scc_engine::diagram::{
17    MAX_DIAGRAM_NODES, MAX_EDGES_PER_NODE, build_diagram_model, render_mermaid, render_svg,
18};
19
20/// The repo-map text behind `scc snap`: overview line plus component
21/// lines plus surface symbol names. Same text the CLI prints; the PNG
22/// recipe renders exactly these bytes.
23// trace:v1 id=impl.crates-scc-cli-src-viewer.map-text work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
24pub fn map_text(store: &scc_store::Store, max_chars: usize) -> crate::Result<String> {
25    let repo = store.repository();
26    let mut lines = vec![format!("SCC REPO MAP: {} ({})", repo.name, repo.id)];
27    let mut comps = store.components()?;
28    comps.sort_by(|a, b| a.name.cmp(&b.name));
29    for c in &comps {
30        lines.push(format!("[{}] {}", c.kind, c.name));
31    }
32    let mut syms = store.all_entities()?;
33    syms.sort_by(|a, b| a.name.cmp(&b.name));
34    for e in syms.iter().filter(|e| e.kind == "symbol").take(400) {
35        lines.push(format!("  {} {}", e.kind, e.name));
36    }
37    let mut out = lines.join("\n");
38    out.truncate(max_chars);
39    Ok(out)
40}
41
42/// Pinned Pillow recipe rendering `map_text` bytes to PNG. Grid: 8px
43/// cols and 16px rows on a 1568px canvas (196 chars per row), height
44/// padded to a multiple of 28px (vision patch alignment).
45// trace:v1 id=impl.crates-scc-cli-src-viewer.snap-recipe work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
46pub fn snap_recipe() -> &'static str {
47    concat!(
48        "from PIL import Image, ImageDraw, ImageFont\n",
49        "import sys\n",
50        "COLS, ROW_H, WIDTH = 196, 16, 1568\n",
51        "FONTS = (\"DejaVuSansMono.ttf\", \"/System/Library/Fonts/Supplemental/Courier New.ttf\", \"/System/Library/Fonts/Supplemental/Menlo.ttf\")\n",
52        "def load_font(size=16):\n",
53        "    for spec in FONTS:\n",
54        "        try:\n",
55        "            return ImageFont.truetype(spec, size)\n",
56        "        except Exception:\n",
57        "            continue\n",
58        "    return ImageFont.load_default(size=size)\n",
59        "def snap(text, out):\n",
60        "    font = load_font()\n",
61        "    rows = text.splitlines()\n",
62        "    h = ((len(rows) * ROW_H + 27) // 28) * 28\n",
63        "    img = Image.new(\"RGB\", (WIDTH, h), \"white\")\n",
64        "    d = ImageDraw.Draw(img)\n",
65        "    for i, line in enumerate(rows):\n",
66        "        d.text((4, i * ROW_H), line[:COLS], font=font, fill=\"black\")\n",
67        "    img.save(out)\n",
68        "if __name__ == \"__main__\":\n",
69        "    snap(open(sys.argv[1]).read(), sys.argv[2])\n",
70    )
71}
72
73/// Honesty line: text tokens (chars divided by 4) versus PNG image
74/// tokens (Anthropic pixel formula at the snapped canvas size).
75// trace:v1 id=impl.crates-scc-cli-src-viewer.token-estimate work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
76pub fn token_estimate(map_chars: usize, rows: usize) -> (usize, usize) {
77    let text_tokens = map_chars / 4;
78    let h = (rows * 16).div_ceil(28) * 28;
79    let image_tokens = 1568 * h / 750;
80    (text_tokens, image_tokens)
81}
82
83// trace:v1 id=impl.crates-scc-cli-src-viewer.page-html-esc work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
84fn esc(s: &str) -> String {
85    s.replace('&', "&amp;")
86        .replace('<', "&lt;")
87        .replace('>', "&gt;")
88        .replace('"', "&quot;")
89}
90
91/// Minimal page shell: no framework, no build step, loopback only.
92// trace:v1 id=impl.crates-scc-cli-src-viewer.page-shell work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
93pub fn page(title: &str, body: &str) -> String {
94    format!(
95        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>{}</title><style>body{{font-family:sans-serif;max-width:1100px;margin:2em auto;padding:0 1em}}nav a{{margin-right:1em}}pre{{background:#f4f4f4;padding:1em;overflow:auto}}table{{border-collapse:collapse}}td,th{{border:1px solid #ccc;padding:.3em .6em;text-align:left}}</style></head><body><nav><a href=\"/\">overview</a><a href=\"/components\">components</a><a href=\"/flows\">flows</a><a href=\"/diagram\">diagram</a><form style=\"display:inline\" action=\"/search\"><input name=\"q\" placeholder=\"search\"></form></nav><h1>{}</h1>{}<hr><p><small>served by scc view (loopback only)</small></p></body></html>",
96        esc(title),
97        esc(title),
98        body
99    )
100}
101
102/// Overview page from live store reads.
103// trace:v1 id=impl.crates-scc-cli-src-viewer.overview-page work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
104pub fn overview_page(store: &scc_store::Store) -> crate::Result<String> {
105    let repo = store.repository();
106    let stats = store.stats().unwrap_or_default();
107    let mut keys: Vec<&String> = stats.keys().collect();
108    keys.sort();
109    let mut rows = String::new();
110    for k in keys {
111        rows.push_str(&format!("<tr><td>{}</td><td>{}</td></tr>", esc(k), stats[k]));
112    }
113    let stale = crate::stale_paths(store).unwrap_or_default();
114    let fresh = if stale.is_empty() {
115        "<p>freshness: CURRENT</p>".to_string()
116    } else {
117        format!("<p>freshness: STALE - {} file(s) changed</p>", stale.len())
118    };
119    let body = format!(
120        "<p>repo {} ({})</p>{fresh}<table>{rows}</table>",
121        esc(&repo.name),
122        esc(&repo.id)
123    );
124    Ok(page(&format!("SCC: {}", repo.name), &body))
125}
126
127/// Component list page.
128// trace:v1 id=impl.crates-scc-cli-src-viewer.components-page work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
129pub fn components_page(store: &scc_store::Store) -> crate::Result<String> {
130    let mut comps = store.components()?;
131    comps.sort_by(|a, b| a.name.cmp(&b.name));
132    let mut body = String::from("<ul>");
133    for c in comps {
134        body.push_str(&format!(
135            "<li><a href=\"/components/{}\">{} [{}]</a></li>",
136            esc(&c.id),
137            esc(&c.name),
138            esc(&c.kind)
139        ));
140    }
141    body.push_str("</ul>");
142    Ok(page("Components", &body))
143}
144
145/// Component detail page; None when the id is unknown.
146// trace:v1 id=impl.crates-scc-cli-src-viewer.component-detail work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
147pub fn component_detail_page(store: &scc_store::Store, id: &str) -> crate::Result<Option<String>> {
148    for c in store.components()? {
149        if c.id == id {
150            let body = format!(
151                "<p>[{}] {}</p><pre>{}</pre><h2>evidence</h2><pre>{}</pre>",
152                esc(&c.kind),
153                esc(&c.name),
154                esc(&serde_json::to_string_pretty(&c.attributes).unwrap_or_default()),
155                esc(&serde_json::to_string_pretty(&c.evidence).unwrap_or_default())
156            );
157            return Ok(Some(page(&c.name, &body)));
158        }
159    }
160    Ok(None)
161}
162
163/// Flow list page.
164// trace:v1 id=impl.crates-scc-cli-src-viewer.flow-pages work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
165pub fn flows_page(store: &scc_store::Store) -> crate::Result<String> {
166    let mut flows = store.flows()?;
167    flows.sort_by(|a, b| a.name.cmp(&b.name));
168    let mut body = String::from("<ul>");
169    for f in flows {
170        body.push_str(&format!(
171            "<li><a href=\"/flows/{}\">{} [{}]</a></li>",
172            esc(&f.id),
173            esc(&f.name),
174            esc(crate::flow_kind_str(&f.kind))
175        ));
176    }
177    body.push_str("</ul>");
178    Ok(page("Flows", &body))
179}
180
181/// Flow detail page; None when the id is unknown.
182// trace:v1 id=impl.crates-scc-cli-src-viewer.flow-detail work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
183pub fn flow_detail_page(store: &scc_store::Store, id: &str) -> crate::Result<Option<String>> {
184    for f in store.flows()? {
185        if f.id == id {
186            let mut steps = f.steps.clone();
187            steps.sort_by_key(|s| s.order);
188            let mut body = format!(
189                "<p>[{}] {}</p><ol>",
190                esc(crate::flow_kind_str(&f.kind)),
191                esc(&f.name)
192            );
193            for s in steps {
194                body.push_str(&format!("<li>{}: {}</li>", esc(&s.actor), esc(&s.operation)));
195            }
196            body.push_str("</ol>");
197            return Ok(Some(page(&f.name, &body)));
198        }
199    }
200    Ok(None)
201}
202
203/// Lexical search page reusing the store FTS path.
204// trace:v1 id=impl.crates-scc-cli-src-viewer.search-page work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
205pub fn search_page(store: &scc_store::Store, q: &str) -> crate::Result<String> {
206    let mut body = format!("<p>query: {}</p>", esc(q));
207    if !q.is_empty() {
208        let entities = store.search_entities(q, 20).unwrap_or_default();
209        body.push_str("<h2>entities</h2><ul>");
210        for e in entities {
211            body.push_str(&format!("<li>{} [{}]</li>", esc(&e.name), esc(&e.kind)));
212        }
213        body.push_str("</ul>");
214    }
215    Ok(page("Search", &body))
216}
217
218/// Diagram page: inline SVG plus copyable Mermaid source.
219// trace:v1 id=impl.crates-scc-cli-src-viewer.diagram-page work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
220pub fn diagram_page(store: &scc_store::Store) -> crate::Result<String> {
221    let model = build_diagram_model(store).map_err(|e| crate::CliError::Other(e.to_string()))?;
222    let svg = render_svg(&model);
223    let mermaid = render_mermaid(&model);
224    let body = format!(
225        "<p>{} nodes, {} edges, {} flows (caps: {}/{})</p>{}<h2>mermaid</h2><pre>{}</pre>",
226        model.nodes.len(),
227        model.edges.len(),
228        model.flows.len(),
229        MAX_DIAGRAM_NODES,
230        MAX_EDGES_PER_NODE,
231        svg,
232        esc(&mermaid)
233    );
234    Ok(page("Diagram", &body))
235}
236
237/// Viewer route predicate: exact pages plus `/components/<id>`,
238/// `/flows/<id>`, and `/search` with query string.
239// trace:v1 id=impl.crates-scc-cli-src-viewer.is-viewer-path work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
240pub fn is_viewer_path(url: &str) -> bool {
241    let path = url.split('?').next().unwrap_or(url);
242    path == "/"
243        || path == "/components"
244        || path == "/flows"
245        || path == "/diagram"
246        || path == "/search"
247        || path.starts_with("/components/")
248        || path.starts_with("/flows/")
249}
250
251/// Serve one viewer route: (status, HTML body). Query strings honored on
252/// `/search?q=`; unknown ids yield 404.
253// trace:v1 id=impl.crates-scc-cli-src-viewer.serve-viewer work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
254pub fn serve_viewer(store: &scc_store::Store, url: &str) -> (u16, String) {
255    let (path, query) = match url.split_once('?') {
256        Some((p, q)) => (p, q),
257        None => (url, ""),
258    };
259    let q = query
260        .split('&')
261        .find_map(|kv| kv.strip_prefix("q="))
262        .map(percent_decode)
263        .unwrap_or_default();
264    match path {
265        "/" => (200, overview_page(store).unwrap_or_else(|e| format!("error: {e}"))),
266        "/components" => (200, components_page(store).unwrap_or_else(|e| format!("error: {e}"))),
267        "/flows" => (200, flows_page(store).unwrap_or_else(|e| format!("error: {e}"))),
268        "/diagram" => (200, diagram_page(store).unwrap_or_else(|e| format!("error: {e}"))),
269        "/search" => (200, search_page(store, &q).unwrap_or_else(|e| format!("error: {e}"))),
270        p if p.starts_with("/components/") => {
271            let id = percent_decode(p.trim_start_matches("/components/"));
272            match component_detail_page(store, &id) {
273                Ok(Some(html)) => (200, html),
274                _ => (404, page("Not found", "<p>unknown component</p>")),
275            }
276        }
277        p if p.starts_with("/flows/") => {
278            let id = percent_decode(p.trim_start_matches("/flows/"));
279            match flow_detail_page(store, &id) {
280                Ok(Some(html)) => (200, html),
281                _ => (404, page("Not found", "<p>unknown flow</p>")),
282            }
283        }
284        _ => (404, page("Not found", "<p>no such viewer route</p>")),
285    }
286}
287
288/// Minimal percent-decoding for route ids and `q=` values.
289// trace:v1 id=impl.crates-scc-cli-src-viewer.percent-decode work=WORK-SCC-VIEWER satisfies=SPEC-SCC-VIEWER
290fn percent_decode(s: &str) -> String {
291    let mut out = String::with_capacity(s.len());
292    let mut bytes = s.as_bytes().iter();
293    while let Some(&b) = bytes.next() {
294        if b == b'%' {
295            let hi = bytes.next().copied().unwrap_or(b'0');
296            let lo = bytes.next().copied().unwrap_or(b'0');
297            let hex = |c: u8| (c as char).to_digit(16).unwrap_or(0) as u8;
298            out.push((hex(hi) * 16 + hex(lo)) as char);
299        } else if b == b'+' {
300            out.push(' ');
301        } else {
302            out.push(b as char);
303        }
304    }
305    out
306}
307
308#[cfg(test)]
309mod tests {
310// trace:exempt reason=unit-test
311    use super::*;
312
313    #[test]
314    // trace:exempt reason=unit-test
315    fn token_estimate_matches_pixel_formula() {
316        let (text, img) = token_estimate(40000, 250);
317        assert_eq!(text, 10000);
318        assert_eq!(img, 1568 * (250_usize * 16).div_ceil(28) * 28 / 750);
319    }
320
321    #[test]
322    // trace:exempt reason=unit-test
323    fn labels_truncate_without_panic() {
324        assert_eq!(scc_engine::diagram::truncate_label("abc", 10), "abc");
325        assert!(scc_engine::diagram::truncate_label("abcdefghij", 5).ends_with("..."));
326        assert_eq!(esc("<a>&"), "&lt;a&gt;&amp;");
327    }
328}