1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{anyhow, bail, Context, Result};
5use ratatui::backend::TestBackend;
6use ratatui::Terminal;
7use serde::Deserialize;
8
9use crate::model::candle::Candle;
10use crate::ui::{self, AppState, GridTab};
11
12const DEFAULT_SCENARIO_DIR: &str = "docs/ui/scenarios";
13const DEFAULT_INDEX_PATH: &str = "docs/ui/INDEX.md";
14const DEFAULT_README_PATH: &str = "README.md";
15const DEFAULT_SYMBOLS: [&str; 5] = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT"];
16
17#[derive(Debug, Clone, Deserialize)]
18pub struct Scenario {
19 pub id: String,
20 pub title: String,
21 #[serde(default = "default_width")]
22 pub width: u16,
23 #[serde(default = "default_height")]
24 pub height: u16,
25 #[serde(default)]
26 pub profiles: Vec<String>,
27 #[serde(default, alias = "step")]
28 pub steps: Vec<Step>,
29}
30
31#[derive(Debug, Clone, Deserialize)]
32#[serde(tag = "type", rename_all = "snake_case")]
33pub enum Step {
34 Key { value: String },
35 Wait { ms: u64 },
36 AssertText { value: String },
37 Snapshot { path: String },
38}
39
40#[derive(Debug, Clone)]
41pub struct RenderedScenario {
42 pub id: String,
43 pub title: String,
44 pub snapshot_paths: Vec<SnapshotArtifact>,
45}
46
47#[derive(Debug, Clone)]
48pub struct SnapshotArtifact {
49 pub raw_path: String,
50 pub image_path: Option<String>,
51}
52
53fn default_width() -> u16 {
54 180
55}
56
57fn default_height() -> u16 {
58 50
59}
60
61pub fn run_cli(args: &[String]) -> Result<()> {
62 if args.is_empty() {
63 return run_mode("full");
64 }
65 match args[0].as_str() {
66 "smoke" => run_mode("smoke"),
67 "full" => run_mode("full"),
68 "scenario" => {
69 let id = args
70 .get(1)
71 .ok_or_else(|| anyhow!("`scenario` requires an id argument"))?;
72 run_single_scenario(id)
73 }
74 "readme-only" => {
75 let rendered = collect_existing_rendered(DEFAULT_INDEX_PATH)?;
76 update_readme(DEFAULT_README_PATH, &rendered)
77 }
78 "help" | "--help" | "-h" => {
79 print_usage();
80 Ok(())
81 }
82 other => bail!(
83 "unknown subcommand `{}`. expected one of: smoke|full|scenario|readme-only",
84 other
85 ),
86 }
87}
88
89fn run_mode(profile: &str) -> Result<()> {
90 let scenarios = load_scenarios_from_dir(DEFAULT_SCENARIO_DIR)?;
91 let filtered: Vec<Scenario> = if profile == "full" {
92 scenarios
93 } else {
94 scenarios
95 .into_iter()
96 .filter(|s| s.profiles.iter().any(|p| p == profile))
97 .collect()
98 };
99 if filtered.is_empty() {
100 bail!("no scenarios found for profile `{}`", profile);
101 }
102 run_scenarios_and_write(&filtered, DEFAULT_INDEX_PATH, DEFAULT_README_PATH)?;
103 Ok(())
104}
105
106fn run_single_scenario(id: &str) -> Result<()> {
107 let scenarios = load_scenarios_from_dir(DEFAULT_SCENARIO_DIR)?;
108 let scenario = scenarios
109 .into_iter()
110 .find(|s| s.id == id)
111 .ok_or_else(|| anyhow!("scenario `{}` not found", id))?;
112 run_scenarios_and_write(&[scenario], DEFAULT_INDEX_PATH, DEFAULT_README_PATH)?;
113 Ok(())
114}
115
116pub fn run_scenarios_and_write<P: AsRef<Path>, R: AsRef<Path>>(
117 scenarios: &[Scenario],
118 index_path: P,
119 readme_path: R,
120) -> Result<Vec<RenderedScenario>> {
121 let rendered = run_scenarios(scenarios)?;
122 write_index(index_path, &rendered)?;
123 update_readme(readme_path, &rendered)?;
124 Ok(rendered)
125}
126
127pub fn load_scenarios_from_dir<P: AsRef<Path>>(dir: P) -> Result<Vec<Scenario>> {
128 let mut paths: Vec<PathBuf> = fs::read_dir(dir.as_ref())
129 .with_context(|| format!("failed to read {}", dir.as_ref().display()))?
130 .filter_map(|entry| entry.ok().map(|e| e.path()))
131 .filter(|path| path.extension().map(|ext| ext == "toml").unwrap_or(false))
132 .collect();
133 paths.sort();
134
135 let mut scenarios = Vec::with_capacity(paths.len());
136 for path in paths {
137 let raw = fs::read_to_string(&path)
138 .with_context(|| format!("failed to read scenario {}", path.display()))?;
139 let scenario: Scenario = toml::from_str(&raw)
140 .with_context(|| format!("failed to parse scenario {}", path.display()))?;
141 scenarios.push(scenario);
142 }
143 Ok(scenarios)
144}
145
146fn run_scenarios(scenarios: &[Scenario]) -> Result<Vec<RenderedScenario>> {
147 scenarios.iter().map(run_scenario).collect()
148}
149
150fn run_scenario(s: &Scenario) -> Result<RenderedScenario> {
151 let mut state = seed_state();
152 let mut snapshots = Vec::new();
153
154 for step in &s.steps {
155 match step {
156 Step::Key { value } => apply_key_action(&mut state, value)?,
157 Step::Wait { ms } => {
158 let _ = ms;
159 }
160 Step::AssertText { value } => {
161 let text = render_to_text(&state, s.width, s.height)?;
162 if !text.contains(value) {
163 bail!(
164 "scenario `{}` assert_text failed: missing `{}`",
165 s.id,
166 value
167 );
168 }
169 }
170 Step::Snapshot { path } => {
171 let text = render_to_text(&state, s.width, s.height)?;
172 let snapshot_path = PathBuf::from(path);
173 if let Some(parent) = snapshot_path.parent() {
174 fs::create_dir_all(parent).with_context(|| {
175 format!("failed to create snapshot dir {}", parent.display())
176 })?;
177 }
178 fs::write(&snapshot_path, text).with_context(|| {
179 format!("failed to write snapshot {}", snapshot_path.display())
180 })?;
181 let image_path = write_svg_preview(&snapshot_path)?;
182 snapshots.push(SnapshotArtifact {
183 raw_path: snapshot_path.to_string_lossy().to_string(),
184 image_path,
185 });
186 }
187 }
188 }
189
190 if snapshots.is_empty() {
191 let default_path = format!("docs/ui/screenshots/{}.txt", s.id);
192 let text = render_to_text(&state, s.width, s.height)?;
193 let default_path_buf = PathBuf::from(&default_path);
194 if let Some(parent) = default_path_buf.parent() {
195 fs::create_dir_all(parent)
196 .with_context(|| format!("failed to create {}", parent.display()))?;
197 }
198 fs::write(&default_path_buf, text)
199 .with_context(|| format!("failed to write {}", default_path_buf.display()))?;
200 let image_path = write_svg_preview(&default_path_buf)?;
201 snapshots.push(SnapshotArtifact {
202 raw_path: default_path,
203 image_path,
204 });
205 }
206
207 Ok(RenderedScenario {
208 id: s.id.clone(),
209 title: s.title.clone(),
210 snapshot_paths: snapshots,
211 })
212}
213
214pub fn render_to_text(state: &AppState, width: u16, height: u16) -> Result<String> {
215 let backend = TestBackend::new(width, height);
216 let mut terminal = Terminal::new(backend).context("failed to init test terminal")?;
217 terminal
218 .draw(|frame| ui::render(frame, state))
219 .context("failed to render frame")?;
220 let buf = terminal.backend().buffer();
221 let area = buf.area;
222 let mut out = String::new();
223 for y in 0..area.height {
224 for x in 0..area.width {
225 out.push_str(buf[(x, y)].symbol());
226 }
227 out.push('\n');
228 }
229 Ok(out)
230}
231
232pub fn seed_state() -> AppState {
233 let mut state = AppState::new("BTCUSDT", "MA(Config)", 120, 60_000, "1m");
234 let now_ms = chrono::Utc::now().timestamp_millis() as u64;
235 state.ws_connected = true;
236 state.current_equity_usdt = Some(10_000.0);
237 state.initial_equity_usdt = Some(9_800.0);
238 state.candles = seed_candles(now_ms, state.candle_interval_ms, 100, 67_000.0);
239 state.last_price_update_ms = Some(now_ms);
240 state.last_price_event_ms = Some(now_ms.saturating_sub(180));
241 state.last_price_latency_ms = Some(180);
242 state.last_order_history_update_ms = Some(now_ms.saturating_sub(1_100));
243 state.last_order_history_event_ms = Some(now_ms.saturating_sub(1_950));
244 state.last_order_history_latency_ms = Some(850);
245 state.symbol_items = DEFAULT_SYMBOLS.iter().map(|v| v.to_string()).collect();
246 state.strategy_item_symbols = vec![
247 "BTCUSDT".to_string(),
248 "ETHUSDT".to_string(),
249 "SOLUSDT".to_string(),
250 ];
251 state.strategy_item_active = vec![true, false, true];
252 state.strategy_item_total_running_ms = vec![3_600_000, 0, 7_200_000];
253 state.network_reconnect_count = 1;
254 state.network_tick_drop_count = 2;
255 state.network_tick_latencies_ms = vec![120, 160, 170, 210, 300];
256 state.network_fill_latencies_ms = vec![400, 600, 1200];
257 state.network_order_sync_latencies_ms = vec![100, 130, 170];
258 state.network_tick_in_timestamps_ms = vec![
259 now_ms.saturating_sub(200),
260 now_ms.saturating_sub(450),
261 now_ms.saturating_sub(920),
262 now_ms.saturating_sub(1_800),
263 now_ms.saturating_sub(8_000),
264 ];
265 state.network_tick_drop_timestamps_ms = vec![
266 now_ms.saturating_sub(600),
267 now_ms.saturating_sub(9_500),
268 ];
269 state.network_reconnect_timestamps_ms = vec![now_ms.saturating_sub(16_000)];
270 state.network_disconnect_timestamps_ms = vec![now_ms.saturating_sub(15_500)];
271 state.network_last_fill_ms = Some(now_ms.saturating_sub(4_500));
272 state.fast_sma = state.candles.last().map(|c| c.close * 0.9992);
273 state.slow_sma = state.candles.last().map(|c| c.close * 0.9985);
274 state
275}
276
277fn seed_candles(now_ms: u64, interval_ms: u64, count: usize, base_price: f64) -> Vec<Candle> {
278 let count = count.max(8);
279 let bucket_close = now_ms - (now_ms % interval_ms);
280 let mut candles = Vec::with_capacity(count);
281 for i in 0..count {
282 let remaining = (count - i) as u64;
283 let open_time = bucket_close.saturating_sub(remaining * interval_ms);
284 let close_time = open_time.saturating_add(interval_ms);
285 let drift = (i as f64) * 2.1;
286 let wave = ((i as f64) * 0.24).sin() * 18.0;
287 let open = base_price + drift + wave;
288 let close = open + (((i % 6) as f64) - 2.0) * 1.7;
289 let high = open.max(close) + 6.5;
290 let low = open.min(close) - 6.0;
291 candles.push(Candle {
292 open,
293 high,
294 low,
295 close,
296 open_time,
297 close_time,
298 });
299 }
300 candles
301}
302
303fn apply_key_action(state: &mut AppState, key: &str) -> Result<()> {
304 match key.to_ascii_lowercase().as_str() {
305 "g" => {
306 state.grid_open = !state.grid_open;
307 if !state.grid_open {
308 state.strategy_editor_open = false;
309 }
310 }
311 "1" => {
312 if state.grid_open {
313 state.grid_tab = GridTab::Assets;
314 }
315 }
316 "2" => {
317 if state.grid_open {
318 state.grid_tab = GridTab::Strategies;
319 }
320 }
321 "3" => {
322 if state.grid_open {
323 state.grid_tab = GridTab::Risk;
324 }
325 }
326 "4" => {
327 if state.grid_open {
328 state.grid_tab = GridTab::Network;
329 }
330 }
331 "5" => {
332 if state.grid_open {
333 state.grid_tab = GridTab::SystemLog;
334 }
335 }
336 "tab" => {
337 if state.grid_open && state.grid_tab == GridTab::Strategies {
338 state.grid_select_on_panel = !state.grid_select_on_panel;
339 }
340 }
341 "c" => {
342 if state.grid_open && state.grid_tab == GridTab::Strategies {
343 state.strategy_editor_open = true;
344 }
345 }
346 "esc" => {
347 if state.strategy_editor_open {
348 state.strategy_editor_open = false;
349 } else if state.grid_open {
350 state.grid_open = false;
351 } else if state.symbol_selector_open {
352 state.symbol_selector_open = false;
353 } else if state.strategy_selector_open {
354 state.strategy_selector_open = false;
355 } else if state.account_popup_open {
356 state.account_popup_open = false;
357 } else if state.history_popup_open {
358 state.history_popup_open = false;
359 }
360 }
361 "t" => {
362 if !state.grid_open {
363 state.symbol_selector_open = true;
364 }
365 }
366 "y" => {
367 if !state.grid_open {
368 state.strategy_selector_open = true;
369 }
370 }
371 "a" => {
372 if !state.grid_open {
373 state.account_popup_open = true;
374 }
375 }
376 "i" => {
377 if !state.grid_open {
378 state.history_popup_open = true;
379 }
380 }
381 other => bail!("unsupported key action `{}`", other),
382 }
383 Ok(())
384}
385
386fn write_index<P: AsRef<Path>>(path: P, rendered: &[RenderedScenario]) -> Result<()> {
387 if let Some(parent) = path.as_ref().parent() {
388 fs::create_dir_all(parent)
389 .with_context(|| format!("failed to create {}", parent.display()))?;
390 }
391 let mut out = String::new();
392 out.push_str("# UI Snapshot Index\n\n");
393 out.push_str("Generated by `cargo run --bin ui_docs -- <mode>`.\n\n");
394 for item in rendered {
395 out.push_str(&format!("## {} (`{}`)\n\n", item.title, item.id));
396 for snapshot in &item.snapshot_paths {
397 if let Some(image_path) = &snapshot.image_path {
398 let rel_image = image_path
399 .strip_prefix("docs/ui/")
400 .unwrap_or(image_path.as_str());
401 out.push_str(&format!("\n\n", item.id, xml_escape(rel_image)));
402 }
403 out.push_str(&format!("- raw: `{}`\n", snapshot.raw_path));
404 }
405 out.push('\n');
406 }
407 fs::write(path.as_ref(), out)
408 .with_context(|| format!("failed to write {}", path.as_ref().display()))?;
409 Ok(())
410}
411
412fn collect_existing_rendered<P: AsRef<Path>>(index_path: P) -> Result<Vec<RenderedScenario>> {
413 let raw = fs::read_to_string(index_path.as_ref())
414 .with_context(|| format!("failed to read {}", index_path.as_ref().display()))?;
415 let mut rendered = Vec::new();
416 let mut current: Option<RenderedScenario> = None;
417
418 for line in raw.lines() {
419 if let Some(rest) = line.strip_prefix("## ") {
420 if let Some(prev) = current.take() {
421 rendered.push(prev);
422 }
423 let (title, id) = if let Some((lhs, rhs)) = rest.rsplit_once(" (`") {
424 let id = rhs.trim_end_matches("`)");
425 (lhs.trim().to_string(), id.to_string())
426 } else {
427 (rest.to_string(), "unknown".to_string())
428 };
429 current = Some(RenderedScenario {
430 id,
431 title,
432 snapshot_paths: Vec::new(),
433 });
434 } else if let Some(path) = line
435 .trim()
436 .strip_prefix("- raw: `")
437 .and_then(|v| v.strip_suffix('`'))
438 {
439 if let Some(curr) = current.as_mut() {
440 let image_path = infer_svg_path(Path::new(path));
441 curr.snapshot_paths.push(SnapshotArtifact {
442 raw_path: path.to_string(),
443 image_path,
444 });
445 }
446 }
447 }
448 if let Some(prev) = current.take() {
449 rendered.push(prev);
450 }
451 Ok(rendered)
452}
453
454pub fn update_readme<P: AsRef<Path>>(readme_path: P, rendered: &[RenderedScenario]) -> Result<()> {
455 let start_marker = "<!-- UI_DOCS:START -->";
456 let end_marker = "<!-- UI_DOCS:END -->";
457 let raw = fs::read_to_string(readme_path.as_ref())
458 .with_context(|| format!("failed to read {}", readme_path.as_ref().display()))?;
459 let start = raw
460 .find(start_marker)
461 .ok_or_else(|| anyhow!("README start marker not found"))?;
462 let end = raw
463 .find(end_marker)
464 .ok_or_else(|| anyhow!("README end marker not found"))?;
465 if start >= end {
466 bail!("README marker order invalid");
467 }
468 let mut block = String::new();
469 block.push_str(start_marker);
470 block.push('\n');
471 block.push_str("### UI Docs (Auto)\n\n");
472 block.push_str("- Generated by `cargo run --bin ui_docs -- smoke|full`\n");
473 block.push_str("- Full index: `docs/ui/INDEX.md`\n\n");
474 for item in rendered.iter().take(4) {
475 if let Some(snapshot) = item.snapshot_paths.first() {
476 if let Some(image_path) = &snapshot.image_path {
477 block.push_str(&format!(
478 "\n\n",
479 item.title,
480 xml_escape(image_path)
481 ));
482 }
483 block.push_str(&format!("- {} raw: `{}`\n", item.title, snapshot.raw_path));
484 }
485 }
486 block.push('\n');
487 block.push_str(end_marker);
488 let next = format!(
489 "{}{}{}",
490 &raw[..start],
491 block,
492 &raw[end + end_marker.len()..]
493 );
494 fs::write(readme_path.as_ref(), next)
495 .with_context(|| format!("failed to write {}", readme_path.as_ref().display()))?;
496 Ok(())
497}
498
499fn print_usage() {
500 eprintln!("usage:");
501 eprintln!(" cargo run --bin ui-docs");
502 eprintln!(" cargo run --bin ui_docs -- smoke");
503 eprintln!(" cargo run --bin ui_docs -- full");
504 eprintln!(" cargo run --bin ui_docs -- scenario <id>");
505 eprintln!(" cargo run --bin ui_docs -- readme-only");
506}
507
508fn write_svg_preview(raw_snapshot_path: &Path) -> Result<Option<String>> {
509 let raw = fs::read_to_string(raw_snapshot_path)
510 .with_context(|| format!("failed to read {}", raw_snapshot_path.display()))?;
511 let svg_path = raw_snapshot_path.with_extension("svg");
512 let lines: Vec<&str> = raw.lines().collect();
513 let width_chars = lines
514 .iter()
515 .map(|line| line.chars().count())
516 .max()
517 .unwrap_or(0);
518 let height_chars = lines.len();
519 if width_chars == 0 || height_chars == 0 {
520 return Ok(None);
521 }
522 let cell_w = 9usize;
523 let cell_h = 18usize;
524 let px_w = (width_chars * cell_w + 24) as u32;
525 let px_h = (height_chars * cell_h + 24) as u32;
526
527 let mut svg = String::new();
528 svg.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
529 svg.push('\n');
530 svg.push_str(&format!(
531 r#"<svg xmlns="http://www.w3.org/2000/svg" width="{}" height="{}" viewBox="0 0 {} {}">"#,
532 px_w, px_h, px_w, px_h
533 ));
534 svg.push('\n');
535 svg.push_str(&format!(
536 r##"<rect x="0" y="0" width="{}" height="{}" fill="#0f111a"/>"##,
537 px_w, px_h
538 ));
539 svg.push('\n');
540 svg.push_str(r##"<g font-family="Menlo, Monaco, 'Courier New', monospace" font-size="14" fill="#d8dee9">"##);
541 svg.push('\n');
542
543 for (i, line) in lines.iter().enumerate() {
544 let y = 18 + (i as u32) * (cell_h as u32);
545 svg.push_str(&format!(
546 r#"<text x="12" y="{}" xml:space="preserve">{}</text>"#,
547 y,
548 xml_escape(line)
549 ));
550 svg.push('\n');
551 }
552 svg.push_str("</g>\n</svg>\n");
553
554 fs::write(&svg_path, svg).with_context(|| format!("failed to write {}", svg_path.display()))?;
555 Ok(Some(svg_path.to_string_lossy().to_string()))
556}
557
558fn infer_svg_path(raw_path: &Path) -> Option<String> {
559 let svg = raw_path.with_extension("svg");
560 if svg.exists() {
561 Some(svg.to_string_lossy().to_string())
562 } else {
563 None
564 }
565}
566
567fn xml_escape(input: &str) -> String {
568 input
569 .replace('&', "&")
570 .replace('<', "<")
571 .replace('>', ">")
572 .replace('"', """)
573 .replace('\'', "'")
574}