1use super::fetch::Tick;
5use super::{Ready, Sources, tick};
6use crate::cli::output::Output;
7use crate::cli::repeat::{self, RunOutput, Schedule};
8use scope::error::{Result, ScopeError};
9use scope::metrics::Sample;
10use scope::watch::{Event, History, Snapshot, Watchlist, diff};
11use std::io::Write as _;
12use std::path::Path;
13
14pub fn csv_header() -> &'static str {
16 "ts,target,metric,value,op,threshold,status,source,error,detail\n"
17}
18
19pub fn csv_rows(at: &str, snapshot: &Snapshot) -> String {
22 let mut rows = String::new();
23 for t in &snapshot.targets {
24 for c in &t.cells {
25 let (metric, op, threshold, source) = match &c.rule {
26 Some(r) => (
27 r.metric.name().to_string(),
28 r.op.symbol().to_string(),
29 r.value.to_string(),
30 format!("{:?}", r.metric.group()).to_lowercase(),
31 ),
32 None => (
33 c.label.clone(),
34 String::new(),
35 String::new(),
36 "preset".into(),
37 ),
38 };
39 let value = c.value.map(|v| v.to_string()).unwrap_or_default();
40 let error = match &c.status {
41 scope::watch::RuleStatus::Error(e) => e.as_str(),
42 _ => "",
43 };
44 let fields = [
45 at,
46 &t.target,
47 &metric,
48 &value,
49 &op,
50 &threshold,
51 c.status.name(),
52 &source,
53 error,
54 c.detail.as_deref().unwrap_or(""),
57 ];
58 let cells: Vec<String> = fields.iter().map(|f| csv_field(f)).collect();
59 rows.push_str(&cells.join(","));
60 rows.push('\n');
61 }
62 }
63 rows
64}
65
66fn csv_field(s: &str) -> String {
68 if s.contains([',', '"', '\n', '\r']) {
69 format!("\"{}\"", s.replace('"', "\"\""))
70 } else {
71 s.to_string()
72 }
73}
74
75pub fn report_text(w: &Watchlist, last: &Tick) -> String {
78 let mut md = format!("# Watchlist Report: {}\n\n", w.name);
79 let rows = w
81 .targets
82 .iter()
83 .flat_map(|t| t.rows().into_iter().map(move |venue| (t, venue)));
84 for ((t, venue), summary) in rows.zip(&last.summaries) {
85 if let (Some(summary), Some(venue)) = (summary, venue) {
86 md.push_str(&crate::cli::market::market_summary_to_markdown(
87 summary, venue, &t.id,
88 ));
89 md.push('\n');
90 }
91 }
92 md.push_str(
93 "## Last values\n\n| Target | Check | Value | Status | Detail |\n|---|---|---|---|---|\n",
94 );
95 for t in &last.snapshot.targets {
96 for c in &t.cells {
97 let value = c.value.map(|v| v.to_string()).unwrap_or_else(|| "-".into());
98 let detail = c.status.message().unwrap_or("").replace('|', "\\|");
99 md.push_str(&format!(
100 "| {} | {} | {} | {} | {} |\n",
101 t.target,
102 c.label.replace('|', "\\|"),
103 value,
104 c.status.name(),
105 detail
106 ));
107 }
108 }
109 md
110}
111
112pub fn write_report(path: &Path, w: &Watchlist, last: &Tick) -> Result<()> {
114 std::fs::write(path, report_text(w, last))
115 .map_err(|e| ScopeError::io_context(e, format!("cannot write {}", path.display())))
116}
117
118pub fn schedule(w: &Watchlist) -> Result<Schedule> {
120 match w.duration {
121 Some(d) => Schedule::new(w.interval, d),
122 None => Schedule::until_cancelled(w.interval),
123 }
124}
125
126pub async fn run(
133 ready: &Ready,
134 src: Sources<'_>,
135 out: &Output,
136 mut on_tick: impl FnMut(u64, &Tick, &[Event]),
137) -> Result<Tick> {
138 let w = &ready.list;
139 if let Some(path) = &w.outputs.csv {
140 std::fs::write(path, csv_header())
141 .map_err(|e| ScopeError::io_context(e, format!("cannot write {}", path.display())))?;
142 }
143 let prev = std::cell::RefCell::new(Snapshot::default());
146 let history = std::cell::RefCell::new(History::default());
147 let on_tick = std::cell::RefCell::new(&mut on_tick);
148 let (prev, history, on_tick) = (&prev, &history, &on_tick);
149 let last = repeat::run(&schedule(w)?, &[], None, out, |n| async move {
150 let mut memory = history.take();
152 let t = tick(ready, src, out, &mut memory).await;
153 history.replace(memory);
154 let events = diff(&prev.borrow(), &t.snapshot);
155 if let Some(path) = &w.outputs.csv {
156 let at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
157 append(path, &csv_rows(&at, &t.snapshot))?;
158 }
159 (on_tick.borrow_mut())(n, &t, &events);
160 *prev.borrow_mut() = t.snapshot.clone();
161 let failed: Vec<String> = events
162 .iter()
163 .filter(|e| matches!(e, Event::BreachStarted { .. }))
164 .map(Event::banner)
165 .collect();
166 Ok(RunOutput {
167 sample: Sample {
168 values: Vec::new(),
169 healthy: t.snapshot.breaches() == 0,
170 failed_checks: failed,
171 },
172 snapshot: String::new(),
173 view: None,
174 value: t,
175 })
176 })
177 .await?
178 .unwrap_or_default();
179 if let Some(path) = &w.outputs.report {
180 write_report(path, w, &last)?;
181 out.note_saved("Report", path.display());
182 }
183 Ok(last)
184}
185
186fn append(path: &Path, rows: &str) -> Result<()> {
187 let context = || format!("cannot append to {}", path.display());
188 let mut file = std::fs::OpenOptions::new()
189 .append(true)
190 .open(path)
191 .map_err(|e| ScopeError::io_context(e, context()))?;
192 file.write_all(rows.as_bytes())
193 .map_err(|e| ScopeError::io_context(e, context()))
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199 use scope::chains::mocks::MockClientFactory;
200 use scope::watch::{Cell, Rule, RuleStatus, Target, TargetKind, TargetSnapshot};
201 use std::time::Duration;
202
203 #[test]
204 fn csv_rows_hold_every_cell_and_quote_commas() {
205 let snapshot = Snapshot {
206 targets: vec![TargetSnapshot::new(
207 "USDC@binance",
208 vec![
209 Cell {
210 label: "Spread".into(),
211 rule: None,
212 value: Some(3.4),
213 status: RuleStatus::Fail("Spread: 3.4%, max 3%".into()),
214 detail: Some("Spread: 3.4%, max 3%".into()),
215 },
216 Cell {
219 label: "Bid depth".into(),
220 rule: None,
221 value: Some(4200.0),
222 status: RuleStatus::Pass,
223 detail: Some("Bid depth: 4200 USDT (min 3000)".into()),
224 },
225 Cell {
226 label: "holders >= 10".into(),
227 rule: Some(Rule::parse("holders >= 10").unwrap()),
228 value: None,
229 status: RuleStatus::Error("timeout, retry".into()),
230 detail: None,
231 },
232 ],
233 )],
234 };
235 let rows = csv_rows("2026-10-16T12:00:00Z", &snapshot);
236 let lines: Vec<&str> = rows.lines().collect();
237 assert_eq!(lines.len(), 3);
238 assert_eq!(
239 lines[0],
240 "2026-10-16T12:00:00Z,USDC@binance,Spread,3.4,,,fail,preset,,\"Spread: 3.4%, max 3%\""
241 );
242 assert_eq!(
243 lines[1],
244 "2026-10-16T12:00:00Z,USDC@binance,Bid depth,4200,,,pass,preset,,Bid depth: 4200 USDT (min 3000)"
245 );
246 assert_eq!(
247 lines[2],
248 "2026-10-16T12:00:00Z,USDC@binance,holders,,>=,10,error,token,\"timeout, retry\","
249 );
250 assert_eq!(csv_header().split(',').count(), 10);
251 }
252
253 #[tokio::test]
254 async fn a_run_ticks_writes_the_csv_and_the_report() {
255 let dir = tempfile::tempdir().unwrap();
256 let csv = dir.path().join("w.csv");
257 let report = dir.path().join("w.md");
258 let mut t = Target::new(
259 TargetKind::Token,
260 "0x1234567890123456789012345678901234567890",
261 );
262 t.rules = vec![Rule::parse("price_usd > 0").unwrap()];
263 let mut w = Watchlist::new("test");
264 w.interval = Duration::from_millis(50);
268 w.duration = Some(Duration::from_millis(500));
269 w.outputs.csv = Some(csv.clone());
270 w.outputs.report = Some(report.clone());
271 w.targets = vec![t];
272 let ready = Ready {
273 list: w,
274 contracts: vec![None],
275 };
276 let factory = MockClientFactory::new();
277 let config = scope::config::Config::default();
278 let src = Sources {
279 config: &config,
280 clients: &factory,
281 };
282 let mut ticks = Vec::new();
283 let (out, _cap) = Output::capture();
284 let last = run(&ready, src, &out, |n, _, events| {
285 ticks.push((n, events.len()))
286 })
287 .await
288 .unwrap();
289 let runs: Vec<u64> = ticks.iter().map(|t| t.0).collect();
291 assert!(runs.len() >= 2, "{runs:?}");
292 assert_eq!(runs, (1..=runs.len() as u64).collect::<Vec<_>>());
293 assert!(ticks.iter().all(|t| t.1 == 0), "a pass starts no event");
294 assert_eq!(last.snapshot.targets.len(), 1);
295 let text = std::fs::read_to_string(&csv).unwrap();
296 assert_eq!(
297 text.lines().count(),
298 1 + runs.len(),
299 "header and one row per tick"
300 );
301 let md = std::fs::read_to_string(&report).unwrap();
302 assert!(md.contains("# Watchlist Report: test"), "{md}");
303 assert!(md.contains("| price_usd > 0 |"), "{md}");
304 }
305
306 #[test]
307 fn the_report_has_a_market_section_per_target_with_a_summary_and_a_venue() {
308 use scope::market::{BookSource, HealthThresholds, MarketSummary, OrderBook};
309 let level = |price, quantity| scope::market::OrderBookLevel { price, quantity };
310 let book = OrderBook {
311 pair: "USDC/USDT".into(),
312 source: BookSource::Exchange,
313 bids: vec![level(0.999, 5_000.0)],
314 asks: vec![level(1.001, 5_000.0)],
315 };
316 let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
317 let mut on_venue = Target::new(TargetKind::Pair, "USDC");
318 on_venue.venues = vec!["binance".into()];
319 let no_venue = Target::new(TargetKind::Pair, "DAI");
321 let mut w = Watchlist::new("pegs");
322 w.targets = vec![on_venue, no_venue];
323 let last = Tick {
324 snapshot: Snapshot {
325 targets: vec![TargetSnapshot::new(
326 "USDC@binance",
327 vec![Cell {
328 label: "a|b".into(),
329 rule: None,
330 value: None,
331 status: RuleStatus::Error("x|y".into()),
332 detail: None,
333 }],
334 )],
335 },
336 summaries: vec![Some(summary.clone()), Some(summary)],
337 };
338 let md = report_text(&w, &last);
339 assert!(md.starts_with("# Watchlist Report: pegs\n\n"), "{md}");
340 assert_eq!(md.matches("# Market Health Report:").count(), 1, "{md}");
341 let section = md.find("# Market Health Report: USDC").unwrap();
342 assert!(section < md.find("## Last values").unwrap(), "{md}");
343 assert!(!md.contains("Market Health Report: DAI"), "{md}");
344 assert!(
346 md.contains("| USDC@binance | a\\|b | - | error | x\\|y |"),
347 "{md}"
348 );
349 }
350
351 #[test]
354 fn two_venues_give_two_sections_and_two_labelled_row_sets() {
355 use scope::market::{BookSource, HealthThresholds, MarketSummary, OrderBook};
356 let level = |price, quantity| scope::market::OrderBookLevel { price, quantity };
357 let book = OrderBook {
358 pair: "USDN/USDT".into(),
359 source: BookSource::Exchange,
360 bids: vec![level(0.999, 5_000.0)],
361 asks: vec![level(1.001, 5_000.0)],
362 };
363 let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
364 let mut t = Target::new(TargetKind::Pair, "USDN");
365 t.venues = vec!["weex".into(), "binance".into()];
366 t.rules = vec![scope::watch::Rule::parse("spread_pct < 1").unwrap()];
367 let mut w = Watchlist::new("pegs");
368 w.targets = vec![t];
369 let row = |venue: &str| {
370 TargetSnapshot::new(
371 format!("USDN@{venue}"),
372 vec![Cell {
373 label: "spread_pct < 1".into(),
374 rule: scope::watch::Rule::parse("spread_pct < 1").ok(),
375 value: Some(0.2),
376 status: RuleStatus::Pass,
377 detail: None,
378 }],
379 )
380 .on_venue(Some(venue))
381 };
382 let last = Tick {
383 snapshot: Snapshot {
384 targets: vec![row("weex"), row("binance")],
385 },
386 summaries: vec![Some(summary.clone()), Some(summary)],
387 };
388 let md = report_text(&w, &last);
389 assert_eq!(
390 md.matches("# Market Health Report: USDN").count(),
391 2,
392 "{md}"
393 );
394 assert!(
395 md.contains("| USDN@weex |") && md.contains("| USDN@binance |"),
396 "{md}"
397 );
398 let csv = csv_rows("t0", &last.snapshot);
399 let targets: Vec<&str> = csv.lines().map(|l| l.split(',').nth(1).unwrap()).collect();
400 assert_eq!(targets, ["USDN@weex", "USDN@binance"]);
401 }
402
403 #[tokio::test]
404 async fn a_report_that_cannot_be_written_is_an_error() {
405 let dir = tempfile::tempdir().unwrap();
406 let mut w = Watchlist::new("test");
407 w.interval = Duration::from_millis(5);
408 w.duration = Some(Duration::from_millis(1));
409 w.outputs.report = Some(dir.path().to_path_buf());
411 let ready = Ready {
412 list: w,
413 contracts: vec![],
414 };
415 let factory = MockClientFactory::new();
416 let config = scope::config::Config::default();
417 let src = Sources {
418 config: &config,
419 clients: &factory,
420 };
421 let (out, _cap) = Output::capture();
422 let err = run(&ready, src, &out, |_, _, _| {}).await.unwrap_err();
423 assert!(err.to_string().contains("cannot write"), "{err}");
424 }
425}