1pub 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
20pub 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
42pub 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
73pub 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
83fn esc(s: &str) -> String {
85 s.replace('&', "&")
86 .replace('<', "<")
87 .replace('>', ">")
88 .replace('"', """)
89}
90
91pub 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
102pub 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
127pub 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
145pub 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
163pub 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
181pub 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
203pub 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
218pub 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
237pub 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
251pub 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
288fn 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 {
310use super::*;
312
313 #[test]
314 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 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>&"), "<a>&");
327 }
328}