clnrm_core/cli/commands/
record.rs

1//! Baseline recording command
2//!
3//! Records test execution as a baseline for future comparisons.
4//! This is the MVP implementation focusing on the 80/20 principle:
5//! - Records test execution traces
6//! - Saves to `.clnrm/baseline.json`
7//! - Computes SHA-256 digest
8//!
9//! Deferred to v0.7.1:
10//! - `repro` command (replay with same seed/clock)
11//! - `redgreen` command (compare two runs)
12//! - Baseline versioning and metadata
13
14use crate::cli::commands::run::run_tests_sequential_with_results;
15use crate::cli::types::{CliConfig, OutputFormat};
16use crate::cli::utils::discover_test_files;
17use crate::error::{CleanroomError, Result};
18use serde::{Deserialize, Serialize};
19use std::path::PathBuf;
20use tracing::{info, warn};
21
22/// Baseline recording data structure
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct BaselineRecord {
25    /// Timestamp of recording
26    pub timestamp: String,
27    /// Framework version
28    pub version: String,
29    /// Test results
30    pub test_results: Vec<BaselineTestResult>,
31    /// SHA-256 digest of the baseline data
32    pub digest: String,
33}
34
35/// Individual test result in baseline
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct BaselineTestResult {
38    /// Test name
39    pub name: String,
40    /// Whether test passed
41    pub passed: bool,
42    /// Duration in milliseconds
43    pub duration_ms: u64,
44    /// Test file path
45    pub file_path: String,
46}
47
48/// Run baseline recording command
49///
50/// # Arguments
51/// * `paths` - Optional test paths to record (default: discover all)
52/// * `output` - Optional output path (default: `.clnrm/baseline.json`)
53///
54/// # Returns
55/// * `Result<()>` - Success or error
56///
57/// # Errors
58/// * Returns error if test execution fails
59/// * Returns error if file writing fails
60/// * Returns error if digest computation fails
61pub async fn run_record(paths: Option<Vec<PathBuf>>, output: Option<PathBuf>) -> Result<()> {
62    // Arrange - Setup configuration and paths
63    info!("Starting baseline recording");
64
65    let output_path = output.unwrap_or_else(|| PathBuf::from(".clnrm/baseline.json"));
66    let digest_path = output_path.with_extension("sha256");
67
68    // Create .clnrm directory if it doesn't exist
69    if let Some(parent) = output_path.parent() {
70        std::fs::create_dir_all(parent).map_err(|e| {
71            CleanroomError::io_error(format!(
72                "Failed to create directory '{}': {}",
73                parent.display(),
74                e
75            ))
76        })?;
77    }
78
79    // Discover test files
80    let test_paths = if let Some(paths) = paths {
81        paths
82    } else {
83        vec![PathBuf::from(".")]
84    };
85
86    let mut all_test_files = Vec::new();
87    for path in &test_paths {
88        let discovered = discover_test_files(path)?;
89        all_test_files.extend(discovered);
90    }
91
92    if all_test_files.is_empty() {
93        return Err(CleanroomError::validation_error(
94            "No test files found to record as baseline",
95        ));
96    }
97
98    info!("Found {} test file(s) to record", all_test_files.len());
99    println!(
100        "📹 Recording baseline from {} test file(s)...",
101        all_test_files.len()
102    );
103
104    // Act - Run tests and collect results
105    let config = CliConfig {
106        parallel: false, // Sequential for deterministic recording
107        jobs: 1,
108        format: OutputFormat::Auto,
109        fail_fast: false,
110        watch: false,
111        verbose: 0,
112        force: true,     // Force run all tests for baseline
113        digest: true,    // Generate digest for baseline
114        validate: false, // No validation for baseline recording
115    };
116
117    let results = run_tests_sequential_with_results(&all_test_files, &config).await?;
118
119    // Convert to baseline format
120    let baseline_results: Vec<BaselineTestResult> = results
121        .iter()
122        .map(|r| BaselineTestResult {
123            name: r.name.clone(),
124            passed: r.passed,
125            duration_ms: r.duration_ms,
126            file_path: extract_file_path(&r.name),
127        })
128        .collect();
129
130    // Create baseline record
131    let timestamp = chrono::Utc::now().to_rfc3339();
132    let version = env!("CARGO_PKG_VERSION").to_string();
133
134    // Compute digest (before including it in the record)
135    let baseline_data_for_digest = serde_json::json!({
136        "timestamp": timestamp,
137        "version": version,
138        "test_results": baseline_results,
139    });
140
141    let digest = compute_sha256(&baseline_data_for_digest)?;
142
143    let baseline = BaselineRecord {
144        timestamp,
145        version,
146        test_results: baseline_results,
147        digest: digest.clone(),
148    };
149
150    // Assert - Write baseline to file
151    let baseline_json = serde_json::to_string_pretty(&baseline).map_err(|e| {
152        CleanroomError::internal_error(format!("Failed to serialize baseline: {}", e))
153    })?;
154
155    std::fs::write(&output_path, &baseline_json).map_err(|e| {
156        CleanroomError::io_error(format!(
157            "Failed to write baseline to '{}': {}",
158            output_path.display(),
159            e
160        ))
161    })?;
162
163    // Write digest to separate file
164    std::fs::write(&digest_path, &digest).map_err(|e| {
165        CleanroomError::io_error(format!(
166            "Failed to write digest to '{}': {}",
167            digest_path.display(),
168            e
169        ))
170    })?;
171
172    // Print summary
173    let passed = baseline.test_results.iter().filter(|t| t.passed).count();
174    let failed = baseline.test_results.iter().filter(|t| !t.passed).count();
175
176    println!();
177    println!("✅ Baseline recorded successfully");
178    println!("   Tests: {} passed, {} failed", passed, failed);
179    println!("   Output: {}", output_path.display());
180    println!("   Digest: {}", digest_path.display());
181    println!("   SHA-256: {}", digest);
182
183    info!(
184        "Baseline recording completed: {} tests recorded",
185        baseline.test_results.len()
186    );
187
188    if failed > 0 {
189        warn!("Baseline contains {} failed test(s)", failed);
190        println!();
191        println!("⚠️  Warning: Baseline includes {} failed test(s)", failed);
192        println!("   Consider fixing failures before using this as a baseline.");
193    }
194
195    Ok(())
196}
197
198/// Compute SHA-256 digest of JSON data
199///
200/// # Arguments
201/// * `data` - JSON value to hash
202///
203/// # Returns
204/// * `Result<String>` - Hex-encoded SHA-256 digest
205///
206/// # Errors
207/// * Returns error if serialization fails
208fn compute_sha256(data: &serde_json::Value) -> Result<String> {
209    use sha2::{Digest, Sha256};
210
211    let json_bytes = serde_json::to_vec(data).map_err(|e| {
212        CleanroomError::internal_error(format!("Failed to serialize data for hashing: {}", e))
213    })?;
214
215    let mut hasher = Sha256::new();
216    hasher.update(&json_bytes);
217    let result = hasher.finalize();
218
219    Ok(format!("{:x}", result))
220}
221
222/// Extract file path from test name
223///
224/// Test names typically include the file path. This extracts it for baseline recording.
225///
226/// # Arguments
227/// * `test_name` - Test name that may include file path
228///
229/// # Returns
230/// * `String` - Extracted file path or test name if no path found
231fn extract_file_path(test_name: &str) -> String {
232    // Test names from run_tests_sequential include the file path
233    // Format: "path/to/test.toml" or just "test_name"
234    // For MVP, we just return the test name as-is
235    test_name.to_string()
236}