use super::fetch::Tick;
use super::{Ready, Sources, tick};
use crate::cli::output::Output;
use crate::cli::repeat::{self, RunOutput, Schedule};
use scope::error::{Result, ScopeError};
use scope::metrics::Sample;
use scope::watch::{Event, History, Snapshot, Watchlist, diff};
use std::io::Write as _;
use std::path::Path;
pub fn csv_header() -> &'static str {
"ts,target,metric,value,op,threshold,status,source,error,detail\n"
}
pub fn csv_rows(at: &str, snapshot: &Snapshot) -> String {
let mut rows = String::new();
for t in &snapshot.targets {
for c in &t.cells {
let (metric, op, threshold, source) = match &c.rule {
Some(r) => (
r.metric.name().to_string(),
r.op.symbol().to_string(),
r.value.to_string(),
format!("{:?}", r.metric.group()).to_lowercase(),
),
None => (
c.label.clone(),
String::new(),
String::new(),
"preset".into(),
),
};
let value = c.value.map(|v| v.to_string()).unwrap_or_default();
let error = match &c.status {
scope::watch::RuleStatus::Error(e) => e.as_str(),
_ => "",
};
let fields = [
at,
&t.target,
&metric,
&value,
&op,
&threshold,
c.status.name(),
&source,
error,
c.detail.as_deref().unwrap_or(""),
];
let cells: Vec<String> = fields.iter().map(|f| csv_field(f)).collect();
rows.push_str(&cells.join(","));
rows.push('\n');
}
}
rows
}
fn csv_field(s: &str) -> String {
if s.contains([',', '"', '\n', '\r']) {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
pub fn report_text(w: &Watchlist, last: &Tick) -> String {
let mut md = format!("# Watchlist Report: {}\n\n", w.name);
let rows = w
.targets
.iter()
.flat_map(|t| t.rows().into_iter().map(move |venue| (t, venue)));
for ((t, venue), summary) in rows.zip(&last.summaries) {
if let (Some(summary), Some(venue)) = (summary, venue) {
md.push_str(&crate::cli::market::market_summary_to_markdown(
summary, venue, &t.id,
));
md.push('\n');
}
}
md.push_str(
"## Last values\n\n| Target | Check | Value | Status | Detail |\n|---|---|---|---|---|\n",
);
for t in &last.snapshot.targets {
for c in &t.cells {
let value = c.value.map(|v| v.to_string()).unwrap_or_else(|| "-".into());
let detail = c.status.message().unwrap_or("").replace('|', "\\|");
md.push_str(&format!(
"| {} | {} | {} | {} | {} |\n",
t.target,
c.label.replace('|', "\\|"),
value,
c.status.name(),
detail
));
}
}
md
}
pub fn write_report(path: &Path, w: &Watchlist, last: &Tick) -> Result<()> {
std::fs::write(path, report_text(w, last))
.map_err(|e| ScopeError::io_context(e, format!("cannot write {}", path.display())))
}
pub fn schedule(w: &Watchlist) -> Result<Schedule> {
match w.duration {
Some(d) => Schedule::new(w.interval, d),
None => Schedule::until_cancelled(w.interval),
}
}
pub async fn run(
ready: &Ready,
src: Sources<'_>,
out: &Output,
mut on_tick: impl FnMut(u64, &Tick, &[Event]),
) -> Result<Tick> {
let w = &ready.list;
if let Some(path) = &w.outputs.csv {
std::fs::write(path, csv_header())
.map_err(|e| ScopeError::io_context(e, format!("cannot write {}", path.display())))?;
}
let prev = std::cell::RefCell::new(Snapshot::default());
let history = std::cell::RefCell::new(History::default());
let on_tick = std::cell::RefCell::new(&mut on_tick);
let (prev, history, on_tick) = (&prev, &history, &on_tick);
let last = repeat::run(&schedule(w)?, &[], None, out, |n| async move {
let mut memory = history.take();
let t = tick(ready, src, out, &mut memory).await;
history.replace(memory);
let events = diff(&prev.borrow(), &t.snapshot);
if let Some(path) = &w.outputs.csv {
let at = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
append(path, &csv_rows(&at, &t.snapshot))?;
}
(on_tick.borrow_mut())(n, &t, &events);
*prev.borrow_mut() = t.snapshot.clone();
let failed: Vec<String> = events
.iter()
.filter(|e| matches!(e, Event::BreachStarted { .. }))
.map(Event::banner)
.collect();
Ok(RunOutput {
sample: Sample {
values: Vec::new(),
healthy: t.snapshot.breaches() == 0,
failed_checks: failed,
},
snapshot: String::new(),
view: None,
value: t,
})
})
.await?
.unwrap_or_default();
if let Some(path) = &w.outputs.report {
write_report(path, w, &last)?;
out.note_saved("Report", path.display());
}
Ok(last)
}
fn append(path: &Path, rows: &str) -> Result<()> {
let context = || format!("cannot append to {}", path.display());
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(path)
.map_err(|e| ScopeError::io_context(e, context()))?;
file.write_all(rows.as_bytes())
.map_err(|e| ScopeError::io_context(e, context()))
}
#[cfg(test)]
mod tests {
use super::*;
use scope::chains::mocks::MockClientFactory;
use scope::watch::{Cell, Rule, RuleStatus, Target, TargetKind, TargetSnapshot};
use std::time::Duration;
#[test]
fn csv_rows_hold_every_cell_and_quote_commas() {
let snapshot = Snapshot {
targets: vec![TargetSnapshot::new(
"USDC@binance",
vec![
Cell {
label: "Spread".into(),
rule: None,
value: Some(3.4),
status: RuleStatus::Fail("Spread: 3.4%, max 3%".into()),
detail: Some("Spread: 3.4%, max 3%".into()),
},
Cell {
label: "Bid depth".into(),
rule: None,
value: Some(4200.0),
status: RuleStatus::Pass,
detail: Some("Bid depth: 4200 USDT (min 3000)".into()),
},
Cell {
label: "holders >= 10".into(),
rule: Some(Rule::parse("holders >= 10").unwrap()),
value: None,
status: RuleStatus::Error("timeout, retry".into()),
detail: None,
},
],
)],
};
let rows = csv_rows("2026-10-16T12:00:00Z", &snapshot);
let lines: Vec<&str> = rows.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(
lines[0],
"2026-10-16T12:00:00Z,USDC@binance,Spread,3.4,,,fail,preset,,\"Spread: 3.4%, max 3%\""
);
assert_eq!(
lines[1],
"2026-10-16T12:00:00Z,USDC@binance,Bid depth,4200,,,pass,preset,,Bid depth: 4200 USDT (min 3000)"
);
assert_eq!(
lines[2],
"2026-10-16T12:00:00Z,USDC@binance,holders,,>=,10,error,token,\"timeout, retry\","
);
assert_eq!(csv_header().split(',').count(), 10);
}
#[tokio::test]
async fn a_run_ticks_writes_the_csv_and_the_report() {
let dir = tempfile::tempdir().unwrap();
let csv = dir.path().join("w.csv");
let report = dir.path().join("w.md");
let mut t = Target::new(
TargetKind::Token,
"0x1234567890123456789012345678901234567890",
);
t.rules = vec![Rule::parse("price_usd > 0").unwrap()];
let mut w = Watchlist::new("test");
w.interval = Duration::from_millis(50);
w.duration = Some(Duration::from_millis(500));
w.outputs.csv = Some(csv.clone());
w.outputs.report = Some(report.clone());
w.targets = vec![t];
let ready = Ready {
list: w,
contracts: vec![None],
};
let factory = MockClientFactory::new();
let config = scope::config::Config::default();
let src = Sources {
config: &config,
clients: &factory,
};
let mut ticks = Vec::new();
let (out, _cap) = Output::capture();
let last = run(&ready, src, &out, |n, _, events| {
ticks.push((n, events.len()))
})
.await
.unwrap();
let runs: Vec<u64> = ticks.iter().map(|t| t.0).collect();
assert!(runs.len() >= 2, "{runs:?}");
assert_eq!(runs, (1..=runs.len() as u64).collect::<Vec<_>>());
assert!(ticks.iter().all(|t| t.1 == 0), "a pass starts no event");
assert_eq!(last.snapshot.targets.len(), 1);
let text = std::fs::read_to_string(&csv).unwrap();
assert_eq!(
text.lines().count(),
1 + runs.len(),
"header and one row per tick"
);
let md = std::fs::read_to_string(&report).unwrap();
assert!(md.contains("# Watchlist Report: test"), "{md}");
assert!(md.contains("| price_usd > 0 |"), "{md}");
}
#[test]
fn the_report_has_a_market_section_per_target_with_a_summary_and_a_venue() {
use scope::market::{BookSource, HealthThresholds, MarketSummary, OrderBook};
let level = |price, quantity| scope::market::OrderBookLevel { price, quantity };
let book = OrderBook {
pair: "USDC/USDT".into(),
source: BookSource::Exchange,
bids: vec![level(0.999, 5_000.0)],
asks: vec![level(1.001, 5_000.0)],
};
let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
let mut on_venue = Target::new(TargetKind::Pair, "USDC");
on_venue.venues = vec!["binance".into()];
let no_venue = Target::new(TargetKind::Pair, "DAI");
let mut w = Watchlist::new("pegs");
w.targets = vec![on_venue, no_venue];
let last = Tick {
snapshot: Snapshot {
targets: vec![TargetSnapshot::new(
"USDC@binance",
vec![Cell {
label: "a|b".into(),
rule: None,
value: None,
status: RuleStatus::Error("x|y".into()),
detail: None,
}],
)],
},
summaries: vec![Some(summary.clone()), Some(summary)],
};
let md = report_text(&w, &last);
assert!(md.starts_with("# Watchlist Report: pegs\n\n"), "{md}");
assert_eq!(md.matches("# Market Health Report:").count(), 1, "{md}");
let section = md.find("# Market Health Report: USDC").unwrap();
assert!(section < md.find("## Last values").unwrap(), "{md}");
assert!(!md.contains("Market Health Report: DAI"), "{md}");
assert!(
md.contains("| USDC@binance | a\\|b | - | error | x\\|y |"),
"{md}"
);
}
#[test]
fn two_venues_give_two_sections_and_two_labelled_row_sets() {
use scope::market::{BookSource, HealthThresholds, MarketSummary, OrderBook};
let level = |price, quantity| scope::market::OrderBookLevel { price, quantity };
let book = OrderBook {
pair: "USDN/USDT".into(),
source: BookSource::Exchange,
bids: vec![level(0.999, 5_000.0)],
asks: vec![level(1.001, 5_000.0)],
};
let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
let mut t = Target::new(TargetKind::Pair, "USDN");
t.venues = vec!["weex".into(), "binance".into()];
t.rules = vec![scope::watch::Rule::parse("spread_pct < 1").unwrap()];
let mut w = Watchlist::new("pegs");
w.targets = vec![t];
let row = |venue: &str| {
TargetSnapshot::new(
format!("USDN@{venue}"),
vec![Cell {
label: "spread_pct < 1".into(),
rule: scope::watch::Rule::parse("spread_pct < 1").ok(),
value: Some(0.2),
status: RuleStatus::Pass,
detail: None,
}],
)
.on_venue(Some(venue))
};
let last = Tick {
snapshot: Snapshot {
targets: vec![row("weex"), row("binance")],
},
summaries: vec![Some(summary.clone()), Some(summary)],
};
let md = report_text(&w, &last);
assert_eq!(
md.matches("# Market Health Report: USDN").count(),
2,
"{md}"
);
assert!(
md.contains("| USDN@weex |") && md.contains("| USDN@binance |"),
"{md}"
);
let csv = csv_rows("t0", &last.snapshot);
let targets: Vec<&str> = csv.lines().map(|l| l.split(',').nth(1).unwrap()).collect();
assert_eq!(targets, ["USDN@weex", "USDN@binance"]);
}
#[tokio::test]
async fn a_report_that_cannot_be_written_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let mut w = Watchlist::new("test");
w.interval = Duration::from_millis(5);
w.duration = Some(Duration::from_millis(1));
w.outputs.report = Some(dir.path().to_path_buf());
let ready = Ready {
list: w,
contracts: vec![],
};
let factory = MockClientFactory::new();
let config = scope::config::Config::default();
let src = Sources {
config: &config,
clients: &factory,
};
let (out, _cap) = Output::capture();
let err = run(&ready, src, &out, |_, _, _| {}).await.unwrap_err();
assert!(err.to_string().contains("cannot write"), "{err}");
}
}