Skip to main content

simular/cli/
commands.rs

1//! CLI command handlers.
2//!
3//! This module contains the execution logic for each CLI command.
4//! Extracted to enable comprehensive testing of command behavior.
5
6use crate::edd::v2::{validate_emc_yaml, validate_experiment_yaml, SchemaValidationError};
7use crate::edd::{ExperimentRunner, RunnerConfig};
8use std::path::Path;
9use std::process::ExitCode;
10
11use super::args::RenderFormat;
12use super::output::{
13    print_emc_report, print_emc_validation_results, print_experiment_result, print_help,
14    print_version,
15};
16use super::schema::validate_emc_schema;
17use super::{Args, Command};
18
19/// Main CLI entry point.
20///
21/// Dispatches to the appropriate command handler based on parsed arguments.
22#[must_use]
23pub fn run_cli(args: Args) -> ExitCode {
24    match args.command {
25        Command::Run {
26            experiment_path,
27            seed_override,
28            verbose,
29        } => run_experiment(&experiment_path, seed_override, verbose),
30        Command::Render {
31            domain,
32            format,
33            output,
34            fps,
35            duration,
36            seed,
37        } => render_svg(&domain, &format, &output, fps, duration, seed),
38        Command::Validate { experiment_path } => validate_experiment(&experiment_path),
39        Command::Verify {
40            experiment_path,
41            runs,
42        } => verify_reproducibility(&experiment_path, runs),
43        Command::EmcCheck { experiment_path } => emc_check(&experiment_path),
44        Command::EmcValidate { emc_path } => emc_validate(&emc_path),
45        Command::ListEmc => list_emc(),
46        Command::Help => {
47            print_help();
48            ExitCode::SUCCESS
49        }
50        Command::Version => {
51            print_version();
52            ExitCode::SUCCESS
53        }
54        Command::Error(msg) => {
55            eprintln!("Error: {msg}");
56            print_help();
57            ExitCode::FAILURE
58        }
59    }
60}
61
62/// Run an experiment from a YAML file.
63///
64/// # Arguments
65///
66/// * `path` - Path to the experiment YAML file
67/// * `seed_override` - Optional seed to override the experiment's configured seed
68/// * `verbose` - Whether to enable verbose output
69#[must_use]
70pub fn run_experiment(path: &Path, seed_override: Option<u64>, verbose: bool) -> ExitCode {
71    println!("╔═══════════════════════════════════════════════════════════════╗");
72    println!("║           simular - EDD Experiment Runner                     ║");
73    println!("╚═══════════════════════════════════════════════════════════════╝\n");
74
75    let config = RunnerConfig {
76        seed_override,
77        verbose,
78        ..RunnerConfig::default()
79    };
80
81    let mut runner = ExperimentRunner::with_config(config);
82
83    // Initialize EMC registry
84    match runner.initialize() {
85        Ok(count) => {
86            if verbose {
87                println!("Loaded {count} EMCs from library");
88            }
89        }
90        Err(e) => {
91            eprintln!("Warning: Failed to scan EMC library: {e}");
92        }
93    }
94
95    println!("Running experiment: {}\n", path.display());
96
97    match runner.run(path) {
98        Ok(result) => {
99            print_experiment_result(&result, verbose);
100            if result.passed {
101                ExitCode::SUCCESS
102            } else {
103                ExitCode::from(1)
104            }
105        }
106        Err(e) => {
107            eprintln!("Error: {e}");
108            ExitCode::from(1)
109        }
110    }
111}
112
113/// Validate an experiment YAML file against the EDD v2 schema.
114///
115/// # Arguments
116///
117/// * `path` - Path to the experiment YAML file
118#[must_use]
119pub fn validate_experiment(path: &Path) -> ExitCode {
120    println!("╔═══════════════════════════════════════════════════════════════╗");
121    println!("║       simular - EDD v2 Experiment Schema Validation           ║");
122    println!("╚═══════════════════════════════════════════════════════════════╝\n");
123
124    println!("Validating: {}\n", path.display());
125
126    // Read file
127    let contents = match std::fs::read_to_string(path) {
128        Ok(c) => c,
129        Err(e) => {
130            eprintln!("✗ Error reading file: {e}");
131            return ExitCode::from(1);
132        }
133    };
134
135    // Validate against EDD v2 schema
136    match validate_experiment_yaml(&contents) {
137        Ok(()) => {
138            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
139            println!("✓ Schema validation PASSED");
140            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
141            println!("EDD v2 Compliance:");
142            println!("  ✓ Required fields present (id, seed, emc_ref, simulation, falsification)");
143            println!("  ✓ Falsification criteria defined");
144            println!("  ✓ No prohibited custom code fields");
145            println!("\nNext steps:");
146            println!("  • Run: simular run {}", path.display());
147            println!("  • Verify: simular verify {}", path.display());
148            ExitCode::SUCCESS
149        }
150        Err(e) => {
151            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
152            println!("✗ Schema validation FAILED");
153            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
154            match e {
155                SchemaValidationError::ValidationFailed(errors) => {
156                    println!("Validation errors:");
157                    for (i, err) in errors.iter().enumerate() {
158                        println!("  {}. {err}", i + 1);
159                    }
160                }
161                other => {
162                    println!("Error: {other}");
163                }
164            }
165            println!("\nSee: docs/specifications/EDD-spec-unified.md for schema requirements");
166            ExitCode::from(1)
167        }
168    }
169}
170
171/// Verify reproducibility of an experiment across multiple runs.
172///
173/// # Arguments
174///
175/// * `path` - Path to the experiment YAML file
176/// * `runs` - Number of verification runs to perform
177#[must_use]
178pub fn verify_reproducibility(path: &Path, runs: usize) -> ExitCode {
179    println!("╔═══════════════════════════════════════════════════════════════╗");
180    println!("║        simular - Reproducibility Verification                 ║");
181    println!("╚═══════════════════════════════════════════════════════════════╝\n");
182
183    let config = RunnerConfig {
184        verify_reproducibility: true,
185        reproducibility_runs: runs,
186        ..RunnerConfig::default()
187    };
188
189    let mut runner = ExperimentRunner::with_config(config);
190
191    if let Err(e) = runner.initialize() {
192        eprintln!("Warning: Failed to scan EMC library: {e}");
193    }
194
195    println!("Verifying reproducibility: {}", path.display());
196    println!("Runs: {runs}\n");
197
198    match runner.verify(path) {
199        Ok(summary) => {
200            let status = if summary.passed { "PASSED" } else { "FAILED" };
201            let sym = if summary.passed { "✓" } else { "✗" };
202
203            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
204            println!("Reproducibility Check");
205            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
206
207            println!("  Runs:      {}", summary.runs);
208            println!("  Identical: {}", summary.identical);
209            println!("  Platform:  {}", summary.platform);
210            println!("\n  Reference Hash: {}", summary.reference_hash);
211
212            if summary.run_hashes.len() > 1 {
213                println!("\n  Run Hashes:");
214                for (i, hash) in summary.run_hashes.iter().enumerate() {
215                    let match_sym = if hash == &summary.reference_hash {
216                        "="
217                    } else {
218                        "!"
219                    };
220                    println!("    Run {}: {} {}", i + 1, hash, match_sym);
221                }
222            }
223
224            println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
225            println!("{sym} Result: {status}");
226            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
227
228            if summary.passed {
229                ExitCode::SUCCESS
230            } else {
231                ExitCode::from(1)
232            }
233        }
234        Err(e) => {
235            eprintln!("Error: {e}");
236            ExitCode::from(1)
237        }
238    }
239}
240
241/// Check EMC compliance for an experiment.
242///
243/// # Arguments
244///
245/// * `path` - Path to the experiment YAML file
246#[must_use]
247pub fn emc_check(path: &Path) -> ExitCode {
248    println!("╔═══════════════════════════════════════════════════════════════╗");
249    println!("║          simular - EMC Compliance Check                       ║");
250    println!("╚═══════════════════════════════════════════════════════════════╝\n");
251
252    let mut runner = ExperimentRunner::new();
253
254    if let Err(e) = runner.initialize() {
255        eprintln!("Warning: Failed to scan EMC library: {e}");
256    }
257
258    println!("Checking EMC compliance: {}\n", path.display());
259
260    match runner.emc_check(path) {
261        Ok(report) => {
262            print_emc_report(&report);
263            if report.passed {
264                ExitCode::SUCCESS
265            } else {
266                ExitCode::from(1)
267            }
268        }
269        Err(e) => {
270            eprintln!("Error: {e}");
271            ExitCode::from(1)
272        }
273    }
274}
275
276/// List all EMCs available in the library.
277#[must_use]
278pub fn list_emc() -> ExitCode {
279    println!("╔═══════════════════════════════════════════════════════════════╗");
280    println!("║             simular - EMC Library                             ║");
281    println!("╚═══════════════════════════════════════════════════════════════╝\n");
282
283    let mut runner = ExperimentRunner::new();
284
285    match runner.initialize() {
286        Ok(count) => {
287            println!("Found {count} EMCs in library:\n");
288
289            let references = runner.registry_mut().list_references();
290            let mut sorted: Vec<_> = references.into_iter().collect();
291            sorted.sort_unstable();
292
293            let mut current_domain = String::new();
294
295            for reference in &sorted {
296                let parts: Vec<&str> = reference.split('/').collect();
297                if parts.len() >= 2 {
298                    let domain = parts[0];
299                    if domain != current_domain {
300                        if !current_domain.is_empty() {
301                            println!();
302                        }
303                        println!("{}:", domain.to_uppercase());
304                        current_domain = domain.to_string();
305                    }
306                }
307                println!("  - {reference}");
308            }
309
310            println!("\nUsage: simular run <experiment.yaml>");
311            println!(
312                "Reference EMCs using: equation_model_card.emc_ref: \"{}\"",
313                if sorted.is_empty() {
314                    "domain/name"
315                } else {
316                    sorted[0]
317                }
318            );
319
320            ExitCode::SUCCESS
321        }
322        Err(e) => {
323            eprintln!("Error scanning EMC library: {e}");
324            ExitCode::from(1)
325        }
326    }
327}
328
329/// Validate an EMC YAML file against the EDD v2 schema.
330///
331/// # Arguments
332///
333/// * `path` - Path to the EMC file
334#[must_use]
335pub fn emc_validate(path: &Path) -> ExitCode {
336    println!("╔═══════════════════════════════════════════════════════════════╗");
337    println!("║        simular - EDD v2 EMC Schema Validation                 ║");
338    println!("╚═══════════════════════════════════════════════════════════════╝\n");
339    println!("Validating EMC: {}\n", path.display());
340
341    let contents = match std::fs::read_to_string(path) {
342        Ok(c) => c,
343        Err(e) => {
344            eprintln!("✗ Error reading file: {e}");
345            return ExitCode::from(1);
346        }
347    };
348
349    // First, validate against EDD v2 JSON schema
350    match validate_emc_yaml(&contents) {
351        Ok(()) => {
352            println!("✓ EDD v2 JSON Schema validation PASSED\n");
353        }
354        Err(e) => {
355            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
356            println!("✗ EDD v2 JSON Schema validation FAILED");
357            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
358            match e {
359                SchemaValidationError::ValidationFailed(errors) => {
360                    println!("Validation errors:");
361                    for (i, err) in errors.iter().enumerate() {
362                        println!("  {}. {err}", i + 1);
363                    }
364                }
365                other => {
366                    println!("Error: {other}");
367                }
368            }
369            return ExitCode::from(1);
370        }
371    }
372
373    // Then run the semantic validation
374    let yaml: serde_yaml::Value = match serde_yaml::from_str(&contents) {
375        Ok(y) => y,
376        Err(e) => {
377            eprintln!("YAML Syntax Error: {e}");
378            return ExitCode::from(1);
379        }
380    };
381
382    let (errors, warnings) = validate_emc_schema(&yaml);
383    print_emc_validation_results(&yaml, &errors, &warnings);
384
385    if errors.is_empty() {
386        println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
387        println!("✓ All EMC validations PASSED");
388        println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
389        ExitCode::SUCCESS
390    } else {
391        ExitCode::from(1)
392    }
393}
394
395/// Shared mutable state for frame rendering.
396struct RenderCtx<'a> {
397    format: &'a RenderFormat,
398    output: &'a Path,
399    svg: &'a mut crate::renderers::svg::SvgRenderer,
400    keyframes: &'a mut crate::renderers::keyframes::KeyframeRecorder,
401}
402
403/// Write a single SVG frame or record keyframe data.
404fn write_frame(
405    frame: usize,
406    commands: &[crate::orbit::render::RenderCommand],
407    ctx: &mut RenderCtx<'_>,
408) -> Result<(), String> {
409    match ctx.format {
410        RenderFormat::SvgFrames => {
411            let svg = ctx.svg.render(commands);
412            let path = ctx.output.join(format!("frame_{frame:04}.svg"));
413            std::fs::write(&path, &svg).map_err(|e| format!("Error writing frame {frame}: {e}"))
414        }
415        RenderFormat::SvgKeyframes => {
416            ctx.keyframes.record_frame(commands);
417            if frame == 0 {
418                let svg = ctx.svg.render(commands);
419                let path = ctx.output.join("scene.svg");
420                std::fs::write(&path, &svg).map_err(|e| format!("Error writing template SVG: {e}"))
421            } else {
422                Ok(())
423            }
424        }
425    }
426}
427
428/// Render a simulation to SVG frames or SVG + keyframes JSON.
429pub fn render_svg(
430    domain: &str,
431    format: &RenderFormat,
432    output: &Path,
433    fps: u32,
434    duration: f64,
435    seed: u64,
436) -> ExitCode {
437    use crate::renderers::keyframes::KeyframeRecorder;
438    use crate::renderers::svg::SvgRenderer;
439
440    println!("╔═══════════════════════════════════════════════════════════════╗");
441    println!("║           simular - SVG Render Pipeline                      ║");
442    println!("╚═══════════════════════════════════════════════════════════════╝\n");
443
444    if let Err(e) = std::fs::create_dir_all(output) {
445        eprintln!("Error creating output directory: {e}");
446        return ExitCode::from(1);
447    }
448
449    let format_name = match format {
450        RenderFormat::SvgFrames => "svg-frames",
451        RenderFormat::SvgKeyframes => "svg-keyframes",
452    };
453    println!("Domain:   {domain}");
454    println!("Format:   {format_name}");
455    println!("Output:   {}", output.display());
456    println!("FPS:      {fps}");
457    println!("Duration: {duration}s");
458    println!("Seed:     {seed}\n");
459
460    let total_frames = (duration * f64::from(fps)) as usize;
461    let mut svg_renderer = SvgRenderer::new();
462    let mut keyframe_recorder = KeyframeRecorder::new(fps, seed, domain);
463    let mut ctx = RenderCtx {
464        format,
465        output,
466        svg: &mut svg_renderer,
467        keyframes: &mut keyframe_recorder,
468    };
469
470    println!("Rendering {total_frames} frames...");
471
472    let result = match domain {
473        "orbit" => render_orbit(fps, total_frames, &mut ctx),
474        "bouncing_balls" => render_bouncing_balls(fps, seed, total_frames, &mut ctx),
475        _ => {
476            eprintln!("Error: unsupported domain '{domain}'. Supported: orbit, bouncing_balls");
477            return ExitCode::from(1);
478        }
479    };
480
481    if let Err(e) = result {
482        eprintln!("{e}");
483        return ExitCode::from(1);
484    }
485
486    if *format == RenderFormat::SvgKeyframes {
487        let json = ctx.keyframes.to_json();
488        let path = output.join("keyframes.json");
489        if let Err(e) = std::fs::write(&path, &json) {
490            eprintln!("Error writing keyframes: {e}");
491            return ExitCode::from(1);
492        }
493        println!("\r  Frames:     {total_frames}");
494        println!("  Elements:   {}", ctx.keyframes.element_count());
495        println!("  Template:   {}", output.join("scene.svg").display());
496        println!("  Keyframes:  {}", path.display());
497    } else {
498        println!("\r  Frames:     {total_frames}");
499        println!("  Output:     {}/frame_NNNN.svg", output.display());
500    }
501
502    println!("\n✓ Render complete");
503    ExitCode::SUCCESS
504}
505
506/// Render orbit domain.
507fn render_orbit(fps: u32, total_frames: usize, ctx: &mut RenderCtx<'_>) -> Result<(), String> {
508    use crate::orbit::physics::YoshidaIntegrator;
509    use crate::orbit::render::{render_state, OrbitTrail, RenderConfig};
510    use crate::orbit::scenarios::KeplerConfig;
511    use crate::orbit::units::OrbitTime;
512
513    let config = KeplerConfig::default();
514    let mut state = config.build(1e6);
515    let integrator = YoshidaIntegrator::new();
516    let dt_seconds = 3600.0;
517    let sim_dt_per_frame = 86400.0 / f64::from(fps);
518    let steps_per_frame = (sim_dt_per_frame / dt_seconds).max(1.0) as usize;
519    let render_config = RenderConfig::default();
520    let mut trails = vec![OrbitTrail::new(0), OrbitTrail::new(500)];
521
522    for frame in 0..total_frames {
523        for _ in 0..steps_per_frame {
524            let dt = OrbitTime::from_seconds(dt_seconds);
525            integrator
526                .step(&mut state, dt)
527                .map_err(|_| format!("Physics error at frame {frame}"))?;
528            for (i, body) in state.bodies.iter().enumerate() {
529                if i < trails.len() {
530                    let (x, y, _) = body.position.as_meters();
531                    trails[i].push(x, y);
532                }
533            }
534        }
535        let commands = render_state(&state, &render_config, &trails, None, None);
536        write_frame(frame, &commands, ctx)?;
537        if frame % 60 == 0 {
538            print!("\r  Frame {frame}/{total_frames}");
539        }
540    }
541    Ok(())
542}
543
544/// Render bouncing balls domain.
545fn render_bouncing_balls(
546    fps: u32,
547    seed: u64,
548    total_frames: usize,
549    ctx: &mut RenderCtx<'_>,
550) -> Result<(), String> {
551    use crate::scenarios::bouncing_balls::{BouncingBallsConfig, BouncingBallsState};
552
553    let config = BouncingBallsConfig::default();
554    let mut state = BouncingBallsState::new(config, seed);
555    let dt = 1.0 / f64::from(fps);
556
557    for frame in 0..total_frames {
558        state.step(dt);
559        let commands = state.render();
560        write_frame(frame, &commands, ctx)?;
561        if frame % 60 == 0 {
562            print!("\r  Frame {frame}/{total_frames}");
563        }
564    }
565    Ok(())
566}