Skip to main content

forest/tool/subcommands/api_cmd/
report.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use super::ReportMode;
5use crate::prelude::*;
6use crate::rpc::{self, ApiPaths, FilterList, Permission};
7use crate::tool::subcommands::api_cmd::api_compare_tests::TestSummary;
8use ahash::{HashMap, HashSet};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use serde_with::{DisplayFromStr, DurationMilliSeconds, DurationSeconds, serde_as};
12use similar::{ChangeTag, TextDiff};
13use std::collections::BTreeSet;
14use std::path::Path;
15use std::time::{Duration, Instant};
16use tabled::{builder::Builder, settings::Style};
17
18/// Tracks the performance metrics for a single RPC method.
19#[serde_as]
20#[derive(Debug, Clone, Serialize, Deserialize)]
21struct PerformanceMetrics {
22    #[serde_as(as = "DurationMilliSeconds<u64>")]
23    total_duration_ms: Duration,
24
25    #[serde_as(as = "DurationMilliSeconds<u64>")]
26    average_duration_ms: Duration,
27
28    #[serde_as(as = "DurationMilliSeconds<u64>")]
29    min_duration_ms: Duration,
30
31    #[serde_as(as = "DurationMilliSeconds<u64>")]
32    max_duration_ms: Duration,
33    test_count: usize,
34}
35
36impl PerformanceMetrics {
37    pub fn from_durations(durations: &[Duration]) -> Option<Self> {
38        if durations.is_empty() {
39            return None;
40        }
41
42        let test_count = durations.len();
43        let total_duration_ms: Duration = durations.iter().sum();
44        let average_duration_ms = total_duration_ms / test_count as u32;
45
46        Some(Self {
47            total_duration_ms,
48            average_duration_ms,
49            min_duration_ms: *durations.iter().min().expect("durations is not empty"),
50            max_duration_ms: *durations.iter().max().expect("durations is not empty"),
51            test_count,
52        })
53    }
54}
55
56/// Details about a successful test instance
57#[serde_as]
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60struct SuccessfulTest {
61    /// The parameters used for this test
62    request_params: serde_json::Value,
63
64    /// Forest node response
65    forest_status: TestSummary,
66
67    /// Lotus node response
68    lotus_status: TestSummary,
69
70    /// Individual test execution duration in milliseconds
71    #[serde_as(as = "DurationMilliSeconds<u64>")]
72    execution_duration_ms: Duration,
73}
74
75/// Testing status for a method
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case", tag = "type")]
78enum MethodTestStatus {
79    /// Method was tested
80    Tested {
81        total_count: usize,
82        success_count: usize,
83        failure_count: usize,
84    },
85    /// Method was filtered out by configuration
86    Filtered,
87    /// Method exists but was not tested
88    NotTested,
89}
90
91/// Details about a failed test instance
92#[serde_as]
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95struct FailedTest {
96    /// The parameters used for this test
97    pub request_params: serde_json::Value,
98
99    /// Forest test result
100    pub forest_status: TestSummary,
101
102    /// Lotus node result
103    pub lotus_status: TestSummary,
104
105    /// Diff between Forest and Lotus responses
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub response_diff: Option<String>,
108
109    /// Individual test execution duration in milliseconds
110    #[serde_as(as = "DurationMilliSeconds<u64>")]
111    pub execution_duration_ms: Duration,
112}
113
114/// Detailed report for a single RPC method
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(rename_all = "snake_case")]
117struct MethodReport {
118    /// Full RPC method name
119    name: String,
120
121    /// Required permission level
122    permission: Permission,
123
124    /// Current testing status
125    status: MethodTestStatus,
126
127    /// API versions this method was exercised under
128    #[serde(skip_serializing_if = "BTreeSet::is_empty")]
129    api_versions: BTreeSet<ApiPaths>,
130
131    // Performance metrics (always included)
132    #[serde(skip_serializing_if = "Option::is_none")]
133    performance: Option<PerformanceMetrics>,
134
135    /// Details of successful test instances (only in full mode)
136    #[serde(skip_serializing_if = "Vec::is_empty")]
137    success_test_params: Vec<SuccessfulTest>,
138
139    /// Details of failed test instances
140    #[serde(skip_serializing_if = "Vec::is_empty")]
141    failed_test_params: Vec<FailedTest>,
142}
143
144/// Report of all API comparison test results
145#[serde_as]
146#[derive(Debug, Clone, Serialize, Deserialize)]
147#[serde(rename_all = "snake_case")]
148pub struct ApiTestReport {
149    /// timestamp of when the test execution started
150    #[serde_as(as = "DisplayFromStr")]
151    execution_datetime_utc: DateTime<Utc>,
152
153    /// Total duration of the test run in seconds
154    #[serde_as(as = "DurationSeconds<u64>")]
155    total_duration_secs: Duration,
156
157    /// Comprehensive report for each RPC method
158    methods: Vec<MethodReport>,
159}
160
161/// Report builder to encapsulate report generation logic
162pub struct ReportBuilder {
163    method_reports: HashMap<String, MethodReport>,
164    method_timings: HashMap<String, Vec<Duration>>,
165    report_mode: ReportMode,
166    start_time: Instant,
167    failed_test_dumps: Vec<super::api_compare_tests::TestDump>,
168}
169
170impl ReportBuilder {
171    pub fn new(filter_list: &FilterList, report_mode: ReportMode) -> Self {
172        let all_methods = rpc::collect_rpc_method_info();
173
174        let method_reports = all_methods
175            .into_iter()
176            .map(|(method_name, permission)| {
177                let report = MethodReport {
178                    name: method_name.to_string(),
179                    permission,
180                    status: if !filter_list.authorize(method_name) {
181                        MethodTestStatus::Filtered
182                    } else {
183                        MethodTestStatus::NotTested
184                    },
185                    api_versions: BTreeSet::new(),
186                    performance: None,
187                    success_test_params: vec![],
188                    failed_test_params: vec![],
189                };
190                (method_name.to_string(), report)
191            })
192            .collect();
193
194        Self {
195            method_reports,
196            method_timings: HashMap::new(),
197            report_mode,
198            start_time: Instant::now(),
199            failed_test_dumps: vec![],
200        }
201    }
202
203    pub fn track_test_result(
204        &mut self,
205        method_name: &str,
206        success: bool,
207        test_result: &super::api_compare_tests::TestResult,
208        test_params: &serde_json::Value,
209        api_path: ApiPaths,
210    ) {
211        if let Some(report) = self.method_reports.get_mut(method_name) {
212            report.api_versions.insert(api_path);
213
214            // Update test status
215            match &mut report.status {
216                MethodTestStatus::NotTested | MethodTestStatus::Filtered => {
217                    report.status = MethodTestStatus::Tested {
218                        total_count: 1,
219                        success_count: if success { 1 } else { 0 },
220                        failure_count: if success { 0 } else { 1 },
221                    };
222                }
223                MethodTestStatus::Tested {
224                    total_count,
225                    success_count,
226                    failure_count,
227                    ..
228                } => {
229                    *total_count += 1;
230                    if success {
231                        *success_count += 1;
232                    } else {
233                        *failure_count += 1;
234                    }
235                }
236            }
237
238            // Track timing
239            self.method_timings
240                .entry(method_name.to_string())
241                .or_default()
242                .push(test_result.duration);
243
244            // if there is no test result for the current method, we can skip this test
245            if test_result.test_dump.is_none() {
246                return;
247            }
248
249            let test_dump = test_result.test_dump.as_ref().unwrap();
250
251            if !success {
252                self.failed_test_dumps.push(test_dump.clone());
253            }
254
255            // Add test details based on mode and success
256            if success && matches!(self.report_mode, ReportMode::Full) {
257                if let (Ok(_), Ok(_)) = (&test_dump.forest_response, &test_dump.lotus_response) {
258                    report.success_test_params.push(SuccessfulTest {
259                        request_params: test_params.clone(),
260                        forest_status: test_result.forest_status.clone(),
261                        lotus_status: test_result.lotus_status.clone(),
262                        execution_duration_ms: test_result.duration,
263                    });
264                }
265            } else if !success
266                && matches!(self.report_mode, ReportMode::Full | ReportMode::FailureOnly)
267            {
268                let response_diff = match (&test_dump.forest_response, &test_dump.lotus_response) {
269                    (Ok(forest_json), Ok(lotus_json)) => {
270                        Some(generate_diff(forest_json, lotus_json))
271                    }
272                    _ => None,
273                };
274
275                report.failed_test_params.push(FailedTest {
276                    request_params: test_params.clone(),
277                    forest_status: test_result.forest_status.clone(),
278                    lotus_status: test_result.lotus_status.clone(),
279                    response_diff,
280                    execution_duration_ms: test_result.duration,
281                });
282            }
283        }
284    }
285
286    /// Check if there were any failures
287    pub fn has_failures(&self) -> bool {
288        self.method_reports.values().any(|report| {
289            matches!(
290                report.status,
291                MethodTestStatus::Tested { failure_count, .. } if failure_count > 0
292            )
293        })
294    }
295
296    /// Print a summary of test results
297    pub fn print_summary(&mut self) {
298        // Calculate performance metrics for each method before printing
299        for (method_name, timings) in &self.method_timings {
300            if let Some(report) = self.method_reports.get_mut(method_name) {
301                report.performance = PerformanceMetrics::from_durations(timings);
302            }
303        }
304
305        let mut builder = Builder::default();
306        builder.push_record(["RPC Method", "Forest", "Lotus", "API Versions", "Status"]);
307
308        let mut methods: Vec<&MethodReport> = self.method_reports.values().collect();
309        methods.sort_by(|a, b| a.name.cmp(&b.name));
310
311        for report in methods {
312            match &report.status {
313                MethodTestStatus::Tested {
314                    total_count,
315                    success_count,
316                    failure_count,
317                } => {
318                    let method_name = if *total_count > 1 {
319                        format!("{} ({})", report.name, total_count)
320                    } else {
321                        report.name.clone()
322                    };
323
324                    let status = if *failure_count == 0 {
325                        "āœ… All Passed".into()
326                    } else {
327                        let mut reasons = HashSet::new();
328                        for failure in &report.failed_test_params {
329                            if failure.forest_status != TestSummary::Valid {
330                                reasons.insert(failure.forest_status.to_string());
331                            }
332                            if failure.lotus_status != TestSummary::Valid {
333                                reasons.insert(failure.lotus_status.to_string());
334                            }
335                        }
336
337                        let reasons_str =
338                            reasons.iter().map(|s| s.as_str()).collect_vec().join(", ");
339
340                        if *success_count == 0 {
341                            format!("āŒ All Failed ({reasons_str})")
342                        } else {
343                            format!("āš ļø  Mixed Results ({reasons_str})")
344                        }
345                    };
346
347                    builder.push_record([
348                        method_name.as_str(),
349                        &format!("{success_count}/{total_count}"),
350                        &format!("{success_count}/{total_count}"),
351                        &report.api_versions.iter().join(", "),
352                        &status,
353                    ]);
354                }
355                MethodTestStatus::NotTested | MethodTestStatus::Filtered => {
356                    // Skip not tested and filtered methods in summary
357                }
358            }
359        }
360
361        let table = builder.build().with(Style::markdown()).to_string();
362        println!("\n{table}");
363
364        // Print overall summary
365        let total_methods = self.method_reports.len();
366        let tested_methods = self
367            .method_reports
368            .values()
369            .filter(|r| matches!(r.status, MethodTestStatus::Tested { .. }))
370            .count();
371        let failed_methods = self
372            .method_reports
373            .values()
374            .filter(|r| {
375                matches!(
376                    r.status,
377                    MethodTestStatus::Tested { failure_count, .. } if failure_count > 0
378                )
379            })
380            .count();
381
382        println!("\nšŸ“Š Test Summary:");
383        println!("  Total methods: {total_methods}");
384        println!("  Tested methods: {tested_methods}");
385        println!("  Failed methods: {failed_methods}");
386        println!("  Duration: {}s", self.start_time.elapsed().as_secs());
387    }
388
389    /// Finalize and save the report in the provided directory
390    pub fn finalize_and_save(mut self, report_dir: &Path) -> anyhow::Result<()> {
391        // Calculate performance metrics for each method
392        for (method_name, timings) in self.method_timings {
393            if let Some(report) = self.method_reports.get_mut(&method_name) {
394                report.performance = PerformanceMetrics::from_durations(&timings);
395            }
396        }
397
398        let mut methods: Vec<MethodReport> = self.method_reports.into_values().collect();
399        methods.sort_by(|a, b| a.name.cmp(&b.name));
400
401        let report = ApiTestReport {
402            execution_datetime_utc: Utc::now(),
403            total_duration_secs: self.start_time.elapsed(),
404            methods,
405        };
406
407        if !report_dir.is_dir() {
408            std::fs::create_dir_all(report_dir)?;
409        }
410
411        let file_name = match self.report_mode {
412            ReportMode::Full => "full_report.json",
413            ReportMode::FailureOnly => "failure_report.json",
414            ReportMode::Summary => "summary_report.json",
415        };
416
417        std::fs::write(
418            report_dir.join(file_name),
419            serde_json::to_string_pretty(&report)?,
420        )?;
421        Ok(())
422    }
423}
424
425/// Generate a diff between forest and lotus responses
426pub fn generate_diff(forest_json: &serde_json::Value, lotus_json: &serde_json::Value) -> String {
427    let forest_pretty = serde_json::to_string_pretty(forest_json).unwrap_or_default();
428    let lotus_pretty = serde_json::to_string_pretty(lotus_json).unwrap_or_default();
429    let diff = TextDiff::from_lines(&forest_pretty, &lotus_pretty);
430
431    let mut diff_text = String::new();
432    for change in diff.iter_all_changes() {
433        let sign = match change.tag() {
434            ChangeTag::Delete => "-",
435            ChangeTag::Insert => "+",
436            ChangeTag::Equal => " ",
437        };
438        diff_text.push_str(&format!("{sign}{change}"));
439    }
440    diff_text
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use std::time::Duration;
447
448    #[test]
449    fn test_performance_metrics_calculation() {
450        let durations = vec![
451            Duration::from_millis(100),
452            Duration::from_millis(200),
453            Duration::from_millis(300),
454            Duration::from_millis(400),
455            Duration::from_millis(500),
456        ];
457        let metrics = PerformanceMetrics::from_durations(&durations).unwrap();
458
459        assert_eq!(metrics.test_count, 5);
460        assert_eq!(metrics.total_duration_ms.as_millis(), 1500);
461        assert_eq!(metrics.average_duration_ms.as_millis(), 300);
462        assert_eq!(metrics.min_duration_ms.as_millis(), 100);
463        assert_eq!(metrics.max_duration_ms.as_millis(), 500);
464    }
465
466    #[test]
467    fn test_performance_metrics_empty() {
468        let durations: Vec<Duration> = vec![];
469        let metrics = PerformanceMetrics::from_durations(&durations);
470        assert!(metrics.is_none());
471    }
472
473    #[test]
474    fn test_api_versions_render() {
475        assert_eq!(BTreeSet::<ApiPaths>::new().iter().join(", "), "");
476        assert_eq!(BTreeSet::from([ApiPaths::V1]).iter().join(", "), "V1");
477        // Ordering is deterministic regardless of insertion order.
478        assert_eq!(
479            BTreeSet::from([ApiPaths::V2, ApiPaths::V0, ApiPaths::V1])
480                .iter()
481                .join(", "),
482            "V0, V1, V2"
483        );
484    }
485
486    #[test]
487    fn test_performance_metrics_single_value() {
488        let durations = vec![Duration::from_millis(150)];
489        let metrics = PerformanceMetrics::from_durations(&durations).unwrap();
490
491        assert_eq!(metrics.test_count, 1);
492        assert_eq!(metrics.total_duration_ms.as_millis(), 150);
493        assert_eq!(metrics.average_duration_ms.as_millis(), 150);
494        assert_eq!(metrics.min_duration_ms.as_millis(), 150);
495        assert_eq!(metrics.max_duration_ms.as_millis(), 150);
496    }
497}