diff --git a/README.md b/README.md index acf893b..400a19a 100644 --- a/README.md +++ b/README.md @@ -744,7 +744,25 @@ the CLI only loads inputs and presents them. The builder follows the `WSLCommand` conventions from WSLPlugins-rs: mutable setters, consuming `with_*` variants, getters, and `prepare()` / `execute()`. `PreparedCalculation` retains the validated molecule and basis and can be -executed repeatedly through the shared `CalculationExecution` trait. +executed repeatedly through the shared `CalculationExecution` trait. It also +exposes a normalized `CalculationRequest` with requested coordinates, units, +molecular state, and effective scientific options, without frontend source +spans or geometry paths. In text mode, `run` prints the original TOML and XYZ +sources verbatim so they can be copied back into files, preserving comments, +paths, and coordinate precision. Relative geometry paths require the same file +layout when reused. It then renders the normalized request as canonical TOML +with scientific defaults and XYZ in the requested units, followed by the +resolved configuration and XYZ in Bohr. These canonical pairs can each be +copied into `calculation.toml` and `molecule.xyz` to recreate the corresponding +semantic input. Canonical XYZ retains enough coordinate digits for an exact +floating-point round trip. Canonical TOML uses typed CLI adapters and +`toml-spanner`, without cache or terminal options. The requested basis label is +distinct from the resolved basis name and AO contents; only the resolved +scientific state determines artifact compatibility. Source provenance is +optional and never needed to render either semantic view. TOML and XYZ are CLI +presentation adapters; future input frontends should adapt into the same +scientific configuration and preparation path rather than introduce their +syntax into `rustiq-core`. `run_hf()` returns `HfOutcome::Converged(HfSolution)` or `HfOutcome::Unconverged(HfSolution)`. Both retain the HF summary, orbitals and integrals; only the converged type exposes `mp2()`. Cloning a diff --git a/crates/rustiq-core/src/calculation.rs b/crates/rustiq-core/src/calculation.rs index 080e9d4..e1b76ba 100644 --- a/crates/rustiq-core/src/calculation.rs +++ b/crates/rustiq-core/src/calculation.rs @@ -81,6 +81,7 @@ pub use solution::{CalculationExecutionError, Converged, HfOutcome, HfSolution, mod builder; mod execution; mod prepared_calculation; +mod request; pub use crate::basis::{Basis, BasisError}; pub use crate::eri::EriError; use crate::hf::{scf::ScfSetupError, uhf::UhfSetupError}; @@ -104,6 +105,7 @@ pub use execution::{ HfCalculationResult, Mp2MemoryPlan, }; pub use prepared_calculation::PreparedCalculation; +pub use request::CalculationRequest; use crate::{ config::{ diff --git a/crates/rustiq-core/src/calculation/builder.rs b/crates/rustiq-core/src/calculation/builder.rs index 0fa19af..383d277 100644 --- a/crates/rustiq-core/src/calculation/builder.rs +++ b/crates/rustiq-core/src/calculation/builder.rs @@ -8,7 +8,7 @@ use std::time::Instant; use super::{ CalculationError, CalculationEvent, CalculationExecution, CalculationExecutionError, - CalculationResult, PreparedCalculation, + CalculationRequest, CalculationResult, PreparedCalculation, }; /// Configure a calculation from explicitly loaded inputs. @@ -40,6 +40,7 @@ use super::{ pub struct CalculationBuilder<'a> { geometry: &'a Geometry, basis_file: &'a BasisFile, + basis_label: Option, molecule_config: MoleculeConfig, hf: HfConfig, mp2: Option, @@ -51,6 +52,7 @@ impl<'a> CalculationBuilder<'a> { Self { geometry, basis_file, + basis_label: None, molecule_config: MoleculeConfig::default(), hf: HfConfig::default(), mp2: None, @@ -64,6 +66,18 @@ impl<'a> CalculationBuilder<'a> { pub fn get_basis_file(&self) -> &BasisFile { self.basis_file } + + /// Set a portable requested basis label; the loaded basis remains authoritative. + pub fn basis_label(&mut self, label: impl Into) -> &mut Self { + self.basis_label = Some(label.into()); + self + } + + #[must_use] + pub fn with_basis_label(mut self, label: impl Into) -> Self { + self.basis_label(label); + self + } pub fn get_molecule_config(&self) -> &MoleculeConfig { &self.molecule_config } @@ -141,9 +155,15 @@ impl<'a> CalculationBuilder<'a> { &self, mut events: impl FnMut(CalculationEvent<'_>), ) -> Result { - let mut molecule = self.molecule_config.build(self.geometry.clone())?; + let request = self.normalized_request(); + let mut requested_geometry = self.geometry.clone(); + requested_geometry.comment.clear(); + let mut molecule = self.molecule_config.build(requested_geometry.clone())?; molecule.convert_to(Units::Bohr); - let hf = (self.hf.clone(), self.hf.resolve_method(&molecule)?); + let method = self.hf.resolve_method(&molecule)?; + let mut execution_hf = self.hf.clone(); + resolve_random_seeds(&mut execution_hf); + let hf = (execution_hf, method); events(CalculationEvent::BasisStarted); let start = Instant::now(); let basis = Basis::try_load(self.basis_file, &molecule)?; @@ -152,13 +172,72 @@ impl<'a> CalculationBuilder<'a> { elapsed: start.elapsed(), }); Ok(PreparedCalculation { + request, molecule, basis, + basis_name: self.basis_file.name().to_owned(), hf, mp2: self.mp2, eri_cache: self.eri_cache.clone(), }) } + + fn normalized_request(&self) -> CalculationRequest { + let mut geometry = self.geometry.clone(); + geometry.comment.clear(); + CalculationRequest { + geometry, + molecule: MoleculeConfig { + units: self.molecule_config.units, + charge: self.molecule_config.charge.value.into(), + multiplicity: self.molecule_config.multiplicity.value.into(), + }, + basis_name: self + .basis_label + .clone() + .unwrap_or_else(|| self.basis_file.name().to_owned()), + hf: normalized_hf_config(&self.hf), + mp2: self.mp2.map(|config| Mp2Config { + frozen_orbitals: config.frozen_orbitals.value.into(), + memory_limit: config.memory_limit.value.into(), + }), + } + } +} + +fn resolve_random_seeds(config: &mut HfConfig) { + let guess = &mut config.guess.value; + match guess { + crate::config::DensityGuessConfig::Random { config } => { + resolve_seed(&mut config.random.seed); + } + crate::config::DensityGuessConfig::CoreHamiltonian { perturbation } + | crate::config::DensityGuessConfig::OneElectron { perturbation } => { + if let Some(perturbation) = perturbation { + resolve_seed(&mut perturbation.random.seed); + } + } + crate::config::DensityGuessConfig::Zero => {} + } +} + +fn resolve_seed(seed: &mut Option) { + if seed.is_none() { + *seed = Some(rand::random()); + } +} + +pub(super) fn normalized_hf_config(config: &HfConfig) -> HfConfig { + HfConfig { + method: config.method.value.into(), + max_iterations: config.max_iterations, + convergence_threshold: config.convergence_threshold, + linear_dependency_threshold: config.linear_dependency_threshold.value.into(), + eri_schwarz_threshold: config.eri_schwarz_threshold, + guess: config.guess.value.into(), + diis: config.diis, + diis_size: config.diis_size, + } } impl CalculationExecution for CalculationBuilder<'_> { diff --git a/crates/rustiq-core/src/calculation/prepared_calculation.rs b/crates/rustiq-core/src/calculation/prepared_calculation.rs index 6dee6ce..590796f 100644 --- a/crates/rustiq-core/src/calculation/prepared_calculation.rs +++ b/crates/rustiq-core/src/calculation/prepared_calculation.rs @@ -11,19 +11,28 @@ use crate::{ }; use std::cell::RefCell; +use super::CalculationRequest; + /// A validated molecule in Bohr and its basis, prepared together by the builder. /// /// Each execution starts fresh HF state and uses the same immutable inputs. /// This avoids self-referential SCF storage and allows reuse of the basis. pub struct PreparedCalculation { + pub(super) request: CalculationRequest, pub(super) molecule: Molecule, pub(super) basis: Basis, + pub(super) basis_name: String, pub(super) hf: (HfConfig, ResolvedHfMethod), pub(super) mp2: Option, pub(super) eri_cache: Option, } impl PreparedCalculation { + /// Returns the normalized inputs as requested before scientific resolution. + pub fn request(&self) -> &CalculationRequest { + &self.request + } + pub fn get_molecule(&self) -> &Molecule { &self.molecule } @@ -31,6 +40,33 @@ impl PreparedCalculation { pub fn get_basis(&self) -> &Basis { &self.basis } + + pub fn hf_method(&self) -> ResolvedHfMethod { + self.hf.1 + } + + /// Human-readable label of the loaded basis; this is not its scientific identity. + /// The resolved basis contents exposed by `get_basis()` are authoritative. Replaying + /// canonical TOML that uses this label assumes a compatible basis store. + pub fn basis_name(&self) -> &str { + &self.basis_name + } + + /// Resolved HF presentation options with an explicit method and no frontend source spans. + /// Random seeds resolved during preparation are retained here. + pub fn hf_config(&self) -> HfConfig { + let mut config = super::builder::normalized_hf_config(&self.hf.0); + config.method.value = match self.hf.1 { + ResolvedHfMethod::Rhf => crate::config::HfMethod::Rhf, + ResolvedHfMethod::Uhf => crate::config::HfMethod::Uhf, + }; + config + } + + /// MP2 options without frontend source spans; automatic memory resolves at execution. + pub fn mp2_config(&self) -> Option<&Mp2Config> { + self.request.mp2() + } } impl PreparedCalculation { diff --git a/crates/rustiq-core/src/calculation/request.rs b/crates/rustiq-core/src/calculation/request.rs new file mode 100644 index 0000000..4d09a7f --- /dev/null +++ b/crates/rustiq-core/src/calculation/request.rs @@ -0,0 +1,42 @@ +use crate::{ + config::{HfConfig, MoleculeConfig, Mp2Config}, + molecules::geometry::Geometry, +}; + +/// Frontend-independent, normalized inputs used to prepare a calculation. +/// +/// This view keeps requested coordinates and units alongside effective +/// scientific options. It deliberately contains no source text, source spans, +/// frontend syntax, or filesystem paths. +#[derive(Debug, Clone)] +pub struct CalculationRequest { + pub(crate) geometry: Geometry, + pub(crate) molecule: MoleculeConfig, + pub(crate) basis_name: String, + pub(crate) hf: HfConfig, + pub(crate) mp2: Option, +} + +impl CalculationRequest { + pub fn geometry(&self) -> &Geometry { + &self.geometry + } + + pub fn molecule(&self) -> &MoleculeConfig { + &self.molecule + } + + /// Requested portable basis label, or the loaded basis name for direct API use. + /// This label is not the scientific identity of the resolved basis. + pub fn basis_name(&self) -> &str { + &self.basis_name + } + + pub fn hf(&self) -> &HfConfig { + &self.hf + } + + pub fn mp2(&self) -> Option<&Mp2Config> { + self.mp2.as_ref() + } +} diff --git a/crates/rustiq-core/src/molecules/molecule.rs b/crates/rustiq-core/src/molecules/molecule.rs index cefcc8d..b025410 100644 --- a/crates/rustiq-core/src/molecules/molecule.rs +++ b/crates/rustiq-core/src/molecules/molecule.rs @@ -94,12 +94,10 @@ impl Molecule { &self.geometry } - #[allow(dead_code)] pub fn unit(&self) -> Units { self.unit } - #[allow(dead_code)] pub fn charge(&self) -> i32 { self.charge } diff --git a/crates/rustiq-core/tests/public_api.rs b/crates/rustiq-core/tests/public_api.rs index 9b4d94d..21a2f6d 100644 --- a/crates/rustiq-core/tests/public_api.rs +++ b/crates/rustiq-core/tests/public_api.rs @@ -183,6 +183,123 @@ fn calculation_builder_normalizes_units_and_orchestrates_both_hf_methods_and_mp2 } } +#[test] +fn prepared_calculation_resolves_missing_random_seeds_and_preserves_auto_event_config() { + let geometry = geometry(); + let file = basis_file(); + let mut config = HfConfig { + guess: DensityGuessConfig::Random { + config: RandomGuessConfig { + random: RandomConfig { + distribution: DistributionConfig::Uniform { + config: UniformDistributionConfig { + min: -1.0, + max: 1.0, + }, + }, + seed: None, + }, + }, + } + .into(), + ..Default::default() + }; + config.method.value = HfMethod::Auto; + let prepared = CalculationBuilder::new(&geometry, &file) + .with_hf(config) + .prepare() + .unwrap(); + + let request_guess = prepared.request().hf().guess.value; + let resolved_guess = prepared.hf_config().guess.value; + let seed = |guess| match guess { + DensityGuessConfig::Random { config } => config.random.seed, + _ => panic!("expected random guess"), + }; + assert_eq!(seed(request_guess), None); + let resolved_seed = seed(resolved_guess).expect("preparation resolves the missing seed"); + assert_eq!(seed(prepared.hf_config().guess.value), Some(resolved_seed)); + assert_eq!(prepared.hf_config().method.value, HfMethod::Rhf); + + for _ in 0..2 { + let mut observed_auto = false; + let mut event_seed = None; + prepared + .run_hf_with_events(|event| { + if let CalculationEvent::HfStarted { method, config } = event { + observed_auto = + method == ResolvedHfMethod::Rhf && config.method.value == HfMethod::Auto; + event_seed = Some(seed(config.guess.value)); + } + }) + .unwrap(); + assert!(observed_auto); + assert_eq!(event_seed, Some(Some(resolved_seed))); + } +} + +#[test] +fn preparation_resolves_missing_perturbation_seeds_but_keeps_explicit_seeds() { + let geometry = geometry(); + let file = basis_file(); + for (guess, expected) in [ + ( + DensityGuessConfig::CoreHamiltonian { + perturbation: Some(Default::default()), + }, + None, + ), + ( + DensityGuessConfig::OneElectron { + perturbation: Some(Default::default()), + }, + None, + ), + ] { + let prepared = CalculationBuilder::new(&geometry, &file) + .with_hf(HfConfig { + guess: guess.into(), + ..Default::default() + }) + .prepare() + .unwrap(); + let seed = |guess| match guess { + DensityGuessConfig::CoreHamiltonian { perturbation } + | DensityGuessConfig::OneElectron { perturbation } => perturbation.unwrap().random.seed, + _ => panic!("expected perturbed guess"), + }; + assert_eq!(seed(prepared.request().hf().guess.value), expected); + assert!(seed(prepared.hf_config().guess.value).is_some()); + let resolved_seed = seed(prepared.hf_config().guess.value); + assert!(resolved_seed.is_some()); + assert_eq!(seed(prepared.hf_config().guess.value), resolved_seed); + } + + let explicit = DensityGuessConfig::CoreHamiltonian { + perturbation: Some(rustiq_core::config::GuessPerturbationConfig { + random: RandomConfig { + distribution: DistributionConfig::Uniform { + config: UniformDistributionConfig { min: 0.0, max: 1.0 }, + }, + seed: Some(8675309), + }, + }), + }; + let prepared = CalculationBuilder::new(&geometry, &file) + .with_hf(HfConfig { + guess: explicit.into(), + ..Default::default() + }) + .prepare() + .unwrap(); + match prepared.hf_config().guess.value { + DensityGuessConfig::CoreHamiltonian { perturbation } => { + assert_eq!(perturbation.unwrap().random.seed, Some(8675309)); + } + _ => panic!("expected core Hamiltonian guess"), + } +} + #[test] fn builder_mutable_setters_keep_hf_mandatory_and_allow_disabling_mp2() { let geometry = geometry(); diff --git a/src/cli/commands/init_command.rs b/src/cli/commands/init_command.rs index 50cb227..61be487 100644 --- a/src/cli/commands/init_command.rs +++ b/src/cli/commands/init_command.rs @@ -225,8 +225,8 @@ mod tests { command(&input, &output, &["--mp2"]).run().unwrap(); let content = fs::read_to_string(&output).unwrap(); - // Use the same parser and formatted TOML that RunCommand passes to bat. let parsed = parse_runfile("calculation.toml", &content).unwrap(); + let expanded = toml_spanner::to_string(&parsed.runfile.output(Defaults::Include)).unwrap(); for field in [ "charge", "multiplicity", @@ -238,8 +238,8 @@ mod tests { ] { assert!(!content.contains(field), "init must omit {field}"); assert!( - parsed.formatted_toml.contains(field), - "run display must include {field}" + expanded.contains(field), + "expanded runfile must include {field}" ); } let molecule = &parsed.runfile.global.molecule; diff --git a/src/cli/commands/run_command.rs b/src/cli/commands/run_command.rs index 33da7f4..cf02f24 100644 --- a/src/cli/commands/run_command.rs +++ b/src/cli/commands/run_command.rs @@ -6,11 +6,16 @@ use std::{ }; use clap::{ArgAction, ValueEnum}; -use miette::{miette, IntoDiagnostic, NamedSource}; +use miette::{miette, Diagnostic, IntoDiagnostic, NamedSource, Report}; use crate::cli::{ self, - ux::{bat, calculation_report::CalculationReporter, json_output::CalculationOutput}, + ux::{ + bat, + calculation_presentation::{calculation_summary, requested_calculation, SourceProvenance}, + calculation_report::CalculationReporter, + json_output::CalculationOutput, + }, }; use crate::runfile::{hf::HfOutputFormat, parser::parse_runfile}; use rustiq_core::{ @@ -122,18 +127,20 @@ impl Runnable for RunCommand { ("".to_string(), content) }; let parsed = parse_runfile(source_name.clone(), &toml_content)?; - let source_code = NamedSource::new(source_name, toml_content); - let scientific_error = - |error| miette::Report::new(error).with_source_code(source_code.clone()); let run = parsed.runfile; - if !json_output { - bat::print_toml(&parsed.formatted_toml); - } let molecule_path = &run.global.molecule.geometry; - let geom = Geometry::from_path(molecule_path).into_diagnostic()?; - if !json_output { - bat::print_xyz(&geom.to_string()); - } + let xyz_content = fs::read_to_string(molecule_path).into_diagnostic()?; + let source = SourceProvenance::new( + source_name, + toml_content, + molecule_path.clone(), + xyz_content, + ); + let source_code = + NamedSource::new(source.calculation_name.clone(), source.calculation.clone()); + let geom = + Geometry::from_source(source.geometry_path.display().to_string(), &source.geometry) + .into_diagnostic()?; if !json_output { println!("Loading basis set..."); } @@ -151,6 +158,7 @@ impl Runnable for RunCommand { .as_ref() .is_none_or(|hf| hf.format != HfOutputFormat::Nope); let calculation = CalculationBuilder::new(&geom, &basis_file) + .with_basis_label(&run.global.basis) .with_molecule_config(parsed.molecule_config) .with_mp2(parsed.mp2_config); let calculation = if run.cache.enabled { @@ -167,14 +175,34 @@ impl Runnable for RunCommand { } else { calculation }; + let prepared = { + let stdout = io::stdout(); + let mut reporter = CalculationReporter::new(stdout.lock(), !json_output, show_scf); + let prepared = calculation.prepare_with_events(|event| reporter.on_event(event)); + if let Some(error) = reporter.take_error() { + return Err(miette!("failed to write calculation report: {error}")); + } + prepared.map_err(|error| with_source(error, &source_code))? + }; + if !json_output { + let requested = requested_calculation(prepared.request()).into_diagnostic()?; + println!("\nRequested calculation (canonical TOML)"); + bat::print_toml(&requested.toml); + println!("\nRequested geometry (canonical XYZ, {})", requested.units); + bat::print_xyz(&requested.xyz); + println!( + "\n{}", + calculation_summary(&prepared, &source.geometry_path) + ); + } let result = { let stdout = io::stdout(); let mut reporter = CalculationReporter::new(stdout.lock(), !json_output, show_scf); - let outcome = calculation.execute_with_events(|event| reporter.on_event(event)); + let outcome = prepared.execute_with_events(|event| reporter.on_event(event)); if let Some(error) = reporter.take_error() { return Err(miette!("failed to write calculation report: {error}")); } - outcome.map_err(&scientific_error)? + outcome.map_err(|error| with_source(error, &source_code))? }; if json_output { let stdout = io::stdout(); @@ -193,6 +221,13 @@ impl Runnable for RunCommand { } } +fn with_source(error: E, source: &NamedSource) -> Report +where + E: Diagnostic + Send + Sync + 'static, +{ + Report::new(error).with_source_code(source.clone()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/cli/ux/bat/mod.rs b/src/cli/ux/bat/mod.rs index 4595327..8e411cb 100644 --- a/src/cli/ux/bat/mod.rs +++ b/src/cli/ux/bat/mod.rs @@ -14,7 +14,7 @@ const BAT_THEMES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/bat-assets/t static BAT_ASSET_CACHE_DIR: OnceLock = OnceLock::new(); -pub fn print_toml(content: &str) { +pub(crate) fn print_toml(content: &str) { if PrettyPrinter::new() .input_from_bytes(content.as_bytes()) .paging_mode(::bat::PagingMode::Never) @@ -26,7 +26,7 @@ pub fn print_toml(content: &str) { } } -pub fn print_xyz(content: &str) { +pub(crate) fn print_xyz(content: &str) { if !io::stdout().is_terminal() { print!("{content}"); return; @@ -47,9 +47,7 @@ fn print_highlighted_xyz(content: &str) -> Result<(), Box ..Default::default() }; let controller = Controller::new(&config, &assets); - controller.run(vec![Input::from_reader(content.as_bytes()).into()], None)?; - Ok(()) } @@ -64,7 +62,6 @@ fn bat_asset_cache_dir() -> io::Result<&'static Path> { fs::create_dir_all(dir)?; write_asset_if_needed(&dir.join("syntaxes.bin"), BAT_SYNTAXES)?; write_asset_if_needed(&dir.join("themes.bin"), BAT_THEMES)?; - Ok(dir.as_path()) } @@ -72,6 +69,5 @@ fn write_asset_if_needed(path: &Path, contents: &[u8]) -> io::Result<()> { if fs::read(path).is_ok_and(|existing| existing == contents) { return Ok(()); } - fs::write(path, contents) } diff --git a/src/cli/ux/calculation_presentation.rs b/src/cli/ux/calculation_presentation.rs new file mode 100644 index 0000000..e800623 --- /dev/null +++ b/src/cli/ux/calculation_presentation.rs @@ -0,0 +1,521 @@ +// Full canonical views are lazy capabilities for inspection and artifact reuse; +// the normal `run` path uses only the concise summary below. +#![allow(dead_code)] + +use std::{ + fmt::Write, + num::NonZeroUsize, + path::{Path, PathBuf}, +}; + +use rustiq_core::{ + calculation::{CalculationRequest, PreparedCalculation}, + config::{ + self, + validated::{DiisSize, NonNegativeFiniteF64, PositiveFiniteF64}, + }, + molecules::{geometry::Geometry, units::Units}, +}; +use toml_spanner::{ToTomlError, Toml}; + +use crate::runfile::{ + global::{molecule_config::MoleculeConfig, Global}, + hf::{DensityGuessConfig, HfMethod}, + mp2::Mp2Config, +}; + +/// CLI rendering schema, not a scientific persistence schema. The generated +/// geometry filename belongs to this TOML/XYZ adapter, never to the core views. +/// Resolved TOML includes the loaded basis label for CLI replay; replay assumes +/// a compatible basis store, while `PreparedCalculation::get_basis()` is the +/// authoritative resolved basis content. +#[derive(Toml)] +#[toml(ToToml)] +struct CalculationToml { + global: Global, + hf: HfSettings, + mp2: Option, +} + +/// Scientific HF settings using the existing runfile spelling and serializers. +/// Terminal/reporting options are not part of the semantic presentation. +#[derive(Toml)] +#[toml(ToToml)] +struct HfSettings { + method: HfMethod, + #[toml(with = crate::runfile::validated::non_zero_usize)] + max_iterations: NonZeroUsize, + #[toml(with = crate::runfile::validated::positive_finite_f64)] + convergence_threshold: PositiveFiniteF64, + #[toml(with = crate::runfile::validated::non_negative_finite_f64)] + linear_dependency_threshold: NonNegativeFiniteF64, + // A disabled threshold must be explicit: omitting it would restore screening. + eri_schwarz_threshold: f64, + guess: DensityGuessConfig, + diis: bool, + #[toml(with = crate::runfile::validated::diis_size)] + diis_size: DiisSize, +} + +impl From<&config::HfConfig> for HfSettings { + fn from(value: &config::HfConfig) -> Self { + Self { + method: value.method.value.into(), + max_iterations: value.max_iterations, + convergence_threshold: value.convergence_threshold, + linear_dependency_threshold: value.linear_dependency_threshold.value, + eri_schwarz_threshold: value + .eri_schwarz_threshold + .map_or(0.0, PositiveFiniteF64::into_inner), + guess: value.guess.value.into(), + diis: value.diis, + diis_size: value.diis_size, + } + } +} + +impl CalculationToml { + fn requested(request: &CalculationRequest) -> Self { + Self { + global: Global { + basis: request.basis_name().into(), + molecule: MoleculeConfig { + geometry: "molecule.xyz".into(), + charge: request.molecule().charge.value, + multiplicity: request.molecule().multiplicity.value, + molecule_unit: request.molecule().units, + }, + }, + hf: request.hf().into(), + mp2: request.mp2().map(Into::into), + } + } + + fn resolved(prepared: &PreparedCalculation) -> Self { + let molecule = prepared.get_molecule(); + Self { + global: Global { + basis: prepared.basis_name().into(), + molecule: MoleculeConfig { + geometry: "molecule.xyz".into(), + charge: molecule.charge(), + multiplicity: molecule.multiplicity(), + molecule_unit: molecule.unit(), + }, + }, + hf: (&prepared.hf_config()).into(), + mp2: prepared.mp2_config().map(Into::into), + } + } +} + +pub(crate) struct CanonicalPair { + pub(crate) toml: String, + pub(crate) xyz: String, + pub(crate) units: &'static str, +} + +/// Exact frontend input retained at the CLI boundary for diagnostics or an +/// explicit source view. It is provenance only and never enters core state. +pub(crate) struct SourceProvenance { + pub(crate) calculation_name: String, + pub(crate) calculation: String, + pub(crate) geometry_path: PathBuf, + pub(crate) geometry: String, +} + +impl SourceProvenance { + pub(crate) fn new( + calculation_name: String, + calculation: String, + geometry_path: PathBuf, + geometry: String, + ) -> Self { + Self { + calculation_name, + calculation, + geometry_path, + geometry, + } + } +} + +impl CanonicalPair { + #[cfg(test)] + fn combined(&self) -> String { + format!( + "Requested calculation (canonical TOML)\n{}\nRequested geometry (canonical XYZ, {})\n{}", + self.toml, self.units, self.xyz + ) + } +} + +pub(crate) fn requested_calculation( + request: &CalculationRequest, +) -> Result { + Ok(CanonicalPair { + toml: toml_spanner::to_string(&CalculationToml::requested(request))?, + xyz: geometry_xyz(request.geometry(), "Requested geometry"), + units: unit_name(request.molecule().units), + }) +} + +pub(crate) struct ResolvedCalculation { + pub(crate) summary: String, + pub(crate) configuration: String, + pub(crate) geometry: String, +} + +/// Concise text shown by the normal `run` command. Full canonical renderings +/// remain available through the requested/resolved helpers above and below. +pub(crate) fn calculation_summary(prepared: &PreparedCalculation, geometry_path: &Path) -> String { + let request = prepared.request(); + let molecule = prepared.get_molecule(); + let requested_method = match request.hf().method.value { + config::HfMethod::Auto => "Auto", + config::HfMethod::Rhf => "RHF", + config::HfMethod::Uhf => "UHF", + }; + let method = prepared.hf_method().to_string(); + let method = if requested_method == "Auto" { + format!("{method} (requested: {requested_method})") + } else { + method + }; + let coordinates = match request.molecule().units { + Units::Angstrom => "Bohr (input: Angstrom)", + Units::Bohr => "Bohr", + }; + + format!( + "Calculation\n Geometry {}\n Atoms {}\n Charge {}\n Multiplicity {}\n Coordinates {coordinates}\n HF {method}\n Basis {} ({} functions)", + geometry_path.display(), + molecule.geometry().atoms.len(), + molecule.charge(), + molecule.multiplicity().get(), + prepared.basis_name(), + prepared.get_basis().nbasis(), + ) +} + +impl ResolvedCalculation { + #[cfg(test)] + fn combined(&self) -> String { + format!( + "{}\n\nResolved configuration (canonical TOML)\n{}\nResolved geometry (canonical XYZ, Bohr)\n{}", + self.summary, self.configuration, self.geometry + ) + } +} + +pub(crate) fn resolved_calculation( + prepared: &PreparedCalculation, +) -> Result { + let molecule = prepared.get_molecule(); + Ok(ResolvedCalculation { + summary: format!( + "Resolved calculation\n Coordinates Bohr\n Charge {}\n Multiplicity {}\n HF method {}\n Basis {} ({} functions)", + molecule.charge(), + molecule.multiplicity().get(), + prepared.hf_method(), + prepared.basis_name(), + prepared.get_basis().nbasis(), + ), + configuration: toml_spanner::to_string(&CalculationToml::resolved(prepared))?, + geometry: geometry_xyz(molecule.geometry(), "Resolved geometry"), + }) +} + +fn unit_name(unit: Units) -> &'static str { + match unit { + Units::Bohr => "Bohr", + Units::Angstrom => "Angstrom", + } +} + +fn geometry_xyz(geometry: &Geometry, comment: &str) -> String { + let mut output = format!("{}\n{comment}\n", geometry.atoms.len()); + for atom in &geometry.atoms { + // f64 Display retains enough digits to recover each coordinate exactly. + writeln!( + output, + "{} {} {} {}", + atom.element.symbol, atom.position.x, atom.position.y, atom.position.z, + ) + .expect("writing to a String cannot fail"); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runfile::parser::parse_runfile; + use approx::assert_abs_diff_eq; + use rustiq_core::{ + basis::BasisFile, + calculation::{CalculationBuilder, CalculationExecution}, + }; + + fn basis() -> BasisFile { + BasisFile::from_reader(&include_bytes!("../../../tests/data/sto-3g.json")[..]).unwrap() + } + + fn prepare(source: &str, geometry: &Geometry) -> PreparedCalculation { + let parsed = parse_runfile("input.toml", source).unwrap(); + let basis = basis(); + CalculationBuilder::new(geometry, &basis) + .with_basis_label(parsed.runfile.global.basis) + .with_molecule_config(parsed.molecule_config) + .with_hf(parsed.hf_config.unwrap_or_default()) + .with_mp2(parsed.mp2_config) + .prepare() + .unwrap() + } + + #[test] + fn source_provenance_keeps_toml_and_xyz_text_exact() { + let toml = "# Original comment\n[global]\nbasis = 'sto-3g' # Original quote and spacing\n"; + let xyz = + "2\nGeometry comment\nH 0 0 -0.370000000123456789\nH 0 0 0.370000000123456789\n"; + let source = SourceProvenance::new( + "calculation.toml".into(), + toml.into(), + "../molecule.xyz".into(), + xyz.into(), + ); + + assert_eq!(source.calculation, toml); + assert_eq!(source.geometry, xyz); + assert_eq!(source.calculation_name, "calculation.toml"); + assert_eq!(source.geometry_path, Path::new("../molecule.xyz")); + } + + #[test] + fn summary_shows_effective_values_and_only_meaningful_requested_differences() { + let geometry = + Geometry::from_source("molecule.xyz", "2\nH2\nH 0 0 -0.37\nH 0 0 0.37\n").unwrap(); + let prepared = prepare("[global]\nbasis = 'sto-3g'\n", &geometry); + let summary = calculation_summary(&prepared, Path::new("../molecule.xyz")); + + assert!(summary.contains("Geometry ../molecule.xyz")); + assert!(summary.contains("Atoms 2")); + assert!(summary.contains("Charge 0")); + assert!(summary.contains("Multiplicity 1")); + assert!(summary.contains("Coordinates Bohr (input: Angstrom)")); + assert!(summary.contains("HF RHF (requested: Auto)")); + assert!(summary.contains("Basis STO-3G (2 functions)")); + assert!(!summary.contains("requested: 0")); + assert!(!summary.contains("requested: sto-3g")); + assert!(!summary.contains("H 0 0")); + } + + #[test] + fn copied_canonical_requested_and_resolved_pairs_run_equivalently() { + let geometry = + Geometry::from_source("input.xyz", "2\nH2\nH 0 0 -0.37\nH 0 0 0.37\n").unwrap(); + let prepared = prepare("[global]\nbasis = 'sto-3g'\n", &geometry); + let requested = requested_calculation(prepared.request()).unwrap(); + let resolved = resolved_calculation(&prepared).unwrap(); + let temp = tempfile::tempdir().unwrap(); + + let original_energy = prepared.execute().unwrap().hf.summary().scf.total_energy; + for (name, toml, xyz) in [ + ("requested", requested.toml, requested.xyz), + ("resolved", resolved.configuration, resolved.geometry), + ] { + let directory = temp.path().join(name); + std::fs::create_dir(&directory).unwrap(); + let toml_path = directory.join("calculation.toml"); + let xyz_path = directory.join("molecule.xyz"); + std::fs::write(&toml_path, toml).unwrap(); + std::fs::write(&xyz_path, xyz).unwrap(); + + let input = std::fs::read_to_string(&toml_path).unwrap(); + let parsed = parse_runfile(toml_path.display().to_string(), &input).unwrap(); + let replay_geometry = Geometry::from_path(&xyz_path).unwrap(); + let basis = basis(); + let replay = CalculationBuilder::new(&replay_geometry, &basis) + .with_basis_label(parsed.runfile.global.basis) + .with_molecule_config(parsed.molecule_config) + .with_hf(parsed.hf_config.unwrap_or_default()) + .with_mp2(parsed.mp2_config) + .prepare() + .unwrap(); + let replay_energy = replay.execute().unwrap().hf.summary().scf.total_energy; + assert_abs_diff_eq!(replay_energy, original_energy, epsilon = 1e-10); + } + } + + #[test] + fn canonical_views_render_without_source_files_and_preserve_coordinate_precision() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("molecule.xyz"); + std::fs::write( + &path, + "2\nOriginal comment\nH 0 0 -0.370000000123456789\nH 0 0 0.370000000123456789\n", + ) + .unwrap(); + let geometry = Geometry::from_path(&path).unwrap(); + let prepared = prepare("[global]\nbasis = 'sto-3g'\n", &geometry); + std::fs::remove_file(path).unwrap(); + drop(geometry); + + assert_eq!(prepared.request().molecule().units, Units::Angstrom); + assert_eq!(prepared.get_molecule().unit(), Units::Bohr); + assert_eq!(prepared.request().hf().method.value, config::HfMethod::Auto); + assert_eq!(prepared.hf_config().method.value, config::HfMethod::Rhf); + assert_eq!(prepared.request().basis_name(), "sto-3g"); + assert_eq!(prepared.basis_name(), "STO-3G"); + for (rendered, expected, method) in [ + ( + CalculationToml::requested(prepared.request()), + prepared.request().geometry(), + config::HfMethod::Auto, + ), + ( + CalculationToml::resolved(&prepared), + prepared.get_molecule().geometry(), + config::HfMethod::Rhf, + ), + ] { + let toml = toml_spanner::to_string(&rendered).unwrap(); + let restored = parse_runfile("rendered.toml", &toml).unwrap(); + assert_eq!(restored.hf_config.unwrap().method.value, method); + assert!(!toml.contains("format =")); + assert!(!toml.contains("cache")); + let xyz = geometry_xyz(expected, "Canonical geometry"); + let restored = Geometry::from_source("rendered.xyz", &xyz).unwrap(); + for (actual, expected) in restored.atoms.iter().zip(&expected.atoms) { + assert_eq!(actual.position, expected.position); + assert_eq!(actual.element.symbol, expected.element.symbol); + } + } + assert!(requested_calculation(prepared.request()) + .unwrap() + .toml + .contains("method = \"Auto\"")); + assert!(resolved_calculation(&prepared) + .unwrap() + .configuration + .contains("method = \"Rhf\"")); + } + + #[test] + fn equivalent_defaults_and_source_provenance_render_identically() { + let original = Geometry::from_source( + "original.xyz", + "2\nOriginal comment\nH 0 0 -0.37\nH 0 0 0.37\n", + ) + .unwrap(); + let equivalent = Geometry::from_source( + "different.xyz", + "2\nDifferent comment\n1 0.0 0.0 -0.370000\nH 0.0 0.0 0.370000\n", + ) + .unwrap(); + for post_hf in ["", "\n[mp2]\n"] { + let implicit = prepare( + &format!("# omitted defaults\n[global]\nbasis = 'sto-3g'\n{post_hf}"), + &original, + ); + let explicit_source = + toml_spanner::to_string(&CalculationToml::requested(implicit.request())).unwrap(); + let explicit_source = format!( + "# explicitly written defaults\n{}\n[cache]\nenabled = true\n", + explicit_source.replace("molecule.xyz", "../different.xyz") + ); + let explicit = prepare(&explicit_source, &equivalent); + assert_eq!( + requested_calculation(implicit.request()) + .unwrap() + .combined(), + requested_calculation(explicit.request()) + .unwrap() + .combined() + ); + assert_eq!( + resolved_calculation(&implicit).unwrap().combined(), + resolved_calculation(&explicit).unwrap().combined() + ); + assert!(explicit.request().geometry().comment.is_empty()); + assert!(explicit.get_molecule().geometry().comment.is_empty()); + assert!(explicit.request().molecule().charge.span.is_none()); + assert!(explicit.request().molecule().multiplicity.span.is_none()); + for hf in [explicit.request().hf().clone(), explicit.hf_config()] { + assert!(hf.method.span.is_none()); + assert!(hf.guess.span.is_none()); + assert!(hf.linear_dependency_threshold.span.is_none()); + } + if let Some(mp2) = explicit.mp2_config() { + assert!(mp2.frozen_orbitals.span.is_none()); + assert!(mp2.memory_limit.span.is_none()); + } + } + } + + #[test] + fn canonical_request_round_trips_non_default_hf_and_mp2_options() { + let geometry = Geometry::from_source("input.xyz", "2\nH2\nH 0 0 0\nH 0 0 0.74\n").unwrap(); + for guess in [ + "[hf.guess]\ntype = 'Zero'\n", + "[hf.guess]\ntype = 'CoreHamiltonian'\n[hf.guess.perturbation]\nseed = 41\n", + "[hf.guess]\ntype = 'OneElectron'\n[hf.guess.perturbation]\ndistribution = 'Uniform'\nmin = -0.001\nmax = 0.001\nseed = 42\n", + "[hf.guess]\ntype = 'Random'\ndistribution = 'Normal'\nmean = 0.25\nstd_dev = 0.01\nseed = 43\n", + ] { + let source = format!("[global]\nbasis = 'sto-3g'\n[hf]\nmethod = 'Uhf'\nmax_iterations = 42\nconvergence_threshold = 1e-7\nlinear_dependency_threshold = 0.0\neri_schwarz_threshold = 0.0\ndiis = true\ndiis_size = 8\nformat = 'Nope'\n{guess}\n[mp2]\nfrozen_orbitals = 1\nmemory_limit = '9007199254740993 B'\n"); + let prepared = prepare(&source, &geometry); + let rendered = toml_spanner::to_string(&CalculationToml::requested(prepared.request())).unwrap(); + let original = parse_runfile("original.toml", &source).unwrap(); + let reparsed = parse_runfile("rendered.toml", &rendered).unwrap(); + assert_eq!( + toml_spanner::to_string(&original.runfile.hf.unwrap().guess).unwrap(), + toml_spanner::to_string(&reparsed.runfile.hf.unwrap().guess).unwrap(), + ); + let restored = prepare(&rendered, &geometry); + assert_eq!( + requested_calculation(prepared.request()).unwrap().combined(), + requested_calculation(restored.request()).unwrap().combined() + ); + assert_eq!(prepared.request().hf().max_iterations.get(), 42); + assert!(prepared.request().hf().eri_schwarz_threshold.is_none()); + assert_eq!(prepared.request().mp2().unwrap().frozen_orbitals.value, 1); + assert_eq!(prepared.request().mp2().unwrap().memory_limit.value, config::MemoryLimit::Fixed(bytesize::ByteSize::b(9_007_199_254_740_993))); + assert!(!rendered.contains("format =")); + } + } + + #[test] + fn resolved_rendered_toml_includes_prepared_random_seed() { + let geometry = Geometry::from_source("input.xyz", "2\nH2\nH 0 0 0\nH 0 0 0.74\n").unwrap(); + let prepared = prepare( + "[global]\nbasis = 'sto-3g'\n[hf.guess]\ntype = 'Random'\ndistribution = 'Uniform'\nmin = -1.0\nmax = 1.0\n", + &geometry, + ); + let seed = match prepared.hf_config().guess.value { + config::DensityGuessConfig::Random { config } => { + config.random.seed.expect("preparation resolves the seed") + } + _ => panic!("expected random guess"), + }; + let requested = requested_calculation(prepared.request()).unwrap().toml; + let resolved = resolved_calculation(&prepared).unwrap().configuration; + assert!(!requested.contains("seed =")); + assert!(resolved.contains(&format!("seed = {seed}"))); + } + + #[test] + fn auto_remains_requested_and_resolves_to_uhf_for_a_doublet() { + let geometry = Geometry::from_source("input.xyz", "2\nH2+\nH 0 0 0\nH 0 0 1.4\n").unwrap(); + let prepared = prepare("[global]\nbasis = 'sto-3g'\n[global.molecule]\ncharge = 1\nmultiplicity = 2\nmolecule_unit = 'Bohr'\n", &geometry); + assert_eq!(prepared.request().hf().method.value, config::HfMethod::Auto); + assert_eq!(prepared.hf_config().method.value, config::HfMethod::Uhf); + assert!(requested_calculation(prepared.request()) + .unwrap() + .toml + .contains("method = \"Auto\"")); + assert!(resolved_calculation(&prepared) + .unwrap() + .configuration + .contains("method = \"Uhf\"")); + } +} diff --git a/src/cli/ux/mod.rs b/src/cli/ux/mod.rs index 5d44ace..e3039c3 100644 --- a/src/cli/ux/mod.rs +++ b/src/cli/ux/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod banner; pub mod basis_table; pub(crate) mod bat; +pub(crate) mod calculation_presentation; pub(crate) mod calculation_report; pub(crate) mod json_output; pub(crate) mod mp2_report; diff --git a/src/runfile/adapter.rs b/src/runfile/adapter.rs index 9104b41..480aecc 100644 --- a/src/runfile/adapter.rs +++ b/src/runfile/adapter.rs @@ -1,4 +1,4 @@ -//! Explicit conversion from the TOML schema to scientific options. +//! Explicit conversions between CLI TOML representations and scientific options. use super::{hf, mp2, random_config}; use rustiq_core::config as core; @@ -110,6 +110,81 @@ impl From for core::random_config::RandomConfig { } } +impl From for hf::HfMethod { + fn from(value: core::HfMethod) -> Self { + match value { + core::HfMethod::Auto => Self::Auto, + core::HfMethod::Rhf => Self::Rhf, + core::HfMethod::Uhf => Self::Uhf, + } + } +} + +impl From for hf::DensityGuessConfig { + fn from(value: core::DensityGuessConfig) -> Self { + match value { + core::DensityGuessConfig::CoreHamiltonian { perturbation } => Self::CoreHamiltonian { + perturbation: perturbation.map(|value| hf::GuessPerturbationConfig { + random: value.random.into(), + }), + }, + core::DensityGuessConfig::OneElectron { perturbation } => Self::OneElectron { + perturbation: perturbation.map(|value| hf::GuessPerturbationConfig { + random: value.random.into(), + }), + }, + core::DensityGuessConfig::Random { config } => Self::Random { + config: hf::RandomGuessConfig { + random: config.random.into(), + }, + }, + core::DensityGuessConfig::Zero => Self::Zero, + } + } +} + +impl From for random_config::RandomConfig { + fn from(value: core::random_config::RandomConfig) -> Self { + use random_config::distribution_config::{ + NormalDistributionConfig, UniformDistributionConfig, + }; + let distribution = match value.distribution { + core::random_config::DistributionConfig::Normal { config } => { + random_config::DistributionConfig::Normal { + config: NormalDistributionConfig { + mean: config.mean, + std_dev: config.std_dev, + }, + } + } + core::random_config::DistributionConfig::Uniform { config } => { + random_config::DistributionConfig::Uniform { + config: UniformDistributionConfig { + min: config.min, + max: config.max, + }, + } + } + }; + Self { + distribution, + seed: value.seed, + } + } +} + +impl From<&core::Mp2Config> for mp2::Mp2Config { + fn from(value: &core::Mp2Config) -> Self { + Self { + frozen_orbitals: value.frozen_orbitals.value, + memory_limit: match value.memory_limit.value { + core::MemoryLimit::Auto => mp2::MemoryLimit::Auto, + core::MemoryLimit::Fixed(size) => mp2::MemoryLimit::Fixed(size), + }, + } + } +} + #[cfg(test)] mod tests { use rustiq_core::config::{DensityGuessConfig, RandomGuessConfig}; diff --git a/src/runfile/output.rs b/src/runfile/output.rs index ec3ea6e..d5c8919 100644 --- a/src/runfile/output.rs +++ b/src/runfile/output.rs @@ -155,10 +155,6 @@ mod tests { "frozen_orbitals", ] { assert!(full.contains(field), "missing {field}"); - assert!( - parsed.formatted_toml.contains(field), - "display missing {field}" - ); assert!(!compact.contains(field), "unexpected {field}"); } assert!(compact.contains("[hf]")); diff --git a/src/runfile/parser.rs b/src/runfile/parser.rs index a54c3ff..fa09813 100644 --- a/src/runfile/parser.rs +++ b/src/runfile/parser.rs @@ -1,5 +1,3 @@ -use miette::IntoDiagnostic; - use crate::runfile::RunFile; use super::diagnostics::FromTomlErrorMietteExt; @@ -7,7 +5,6 @@ use super::diagnostics::FromTomlErrorMietteExt; #[derive(Debug)] pub struct ParsedRunFile { pub runfile: RunFile, - pub formatted_toml: String, /// Scientific options with locations in the original input, not the formatted output. pub hf_config: Option, pub mp2_config: Option, @@ -26,10 +23,6 @@ pub fn parse_runfile( let runfile = document .to::() .map_err(|error| error.into_miette_diagnostic(source_name, toml_content))?; - let formatted_toml = toml_spanner::Formatting::preserved_from(&document) - .format(&runfile.output(super::output::Defaults::Include)) - .into_diagnostic()?; - let mut hf_config = runfile.hf.as_ref().map(rustiq_core::config::HfConfig::from); let mut molecule_config = rustiq_core::config::MoleculeConfig::from(&runfile.global.molecule); let mut mp2_config = runfile @@ -63,7 +56,6 @@ pub fn parse_runfile( Ok(ParsedRunFile { runfile, - formatted_toml, hf_config, mp2_config, molecule_config, diff --git a/tests/cli_samples.rs b/tests/cli_samples.rs index 89cd8a7..348d13c 100644 --- a/tests/cli_samples.rs +++ b/tests/cli_samples.rs @@ -73,6 +73,27 @@ fn assert_error(output: &Output) { ); } +fn strip_ansi(input: &str) -> String { + let bytes = input.as_bytes(); + let mut visible = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index..].starts_with(b"\x1b[") { + index += 2; + while index < bytes.len() && !(0x40..=0x7e).contains(&bytes[index]) { + index += 1; + } + index += usize::from(index < bytes.len()); + } else { + let character = input[index..].chars().next().unwrap(); + let end = index + character.len_utf8(); + visible.extend_from_slice(&bytes[index..end]); + index = end; + } + } + String::from_utf8(visible).unwrap() +} + #[test] #[cfg(feature = "online")] fn test_online_basis_commands_are_available_with_default_features() { @@ -131,10 +152,65 @@ fn test_cli_h2_sample_converges_and_prints_reference_energy() { assert_success(&output); - let stdout = String::from_utf8_lossy(&output.stdout); + let stdout = strip_ansi(&String::from_utf8_lossy(&output.stdout)); assert!(stdout.contains("SCF converged after 2 iterations.")); assert!(stdout.contains("Total Energy (including nuclear repulsion): -1.116759 Hartree")); assert!(stdout.contains("Overlap effective rank: 2/2 (0 discarded")); + assert!(stdout.contains("Calculation\n Geometry ../molecule.xyz")); + assert!(stdout.contains(" Atoms 2")); + assert!(stdout.contains(" Charge 0")); + assert!(stdout.contains(" Multiplicity 1")); + assert!(stdout.contains(" Coordinates Bohr (input: Angstrom)")); + assert!(stdout.contains(" HF RHF (requested: Auto)")); + assert!(stdout.contains(" Basis STO-3G (2 functions)")); + assert!(stdout.contains("Requested calculation (canonical TOML)")); + assert!(stdout.contains("method = \"Auto\"")); + assert!(stdout.contains("max_iterations = 100")); + assert!(stdout.contains("Requested geometry (canonical XYZ, Angstrom)")); + assert_eq!(stdout.matches("H 0 0 -0.37").count(), 1); + assert_eq!(stdout.matches("H 0 0 0.37").count(), 1); + for hidden_section in [ + "Original calculation (TOML source)", + "Original geometry (XYZ source", + "Resolved configuration (canonical TOML)", + "Resolved geometry (canonical XYZ", + ] { + assert!( + !stdout.contains(hidden_section), + "unexpected {hidden_section}" + ); + } +} + +#[test] +fn test_cli_shows_canonical_request_without_original_source_dump() { + let temp_root = temp_root("cli-original-input"); + prepare_basis_store(&temp_root); + let input_dir = temp_root.join("input"); + fs::create_dir_all(&input_dir).unwrap(); + let toml = "# Keep the original path and formatting\n[global]\nbasis = 'sto-3g'\n\n[global.molecule]\ngeometry = 'molecule.xyz' # relative to this file\n"; + let xyz = "2\nHydrogen molecule -- original comment\nH 0 0 -0.370000000123456789\nH 0 0 0.370000000123456789\n"; + let calculation_path = input_dir.join("calculation.toml"); + fs::write(&calculation_path, toml).unwrap(); + fs::write(input_dir.join("molecule.xyz"), xyz).unwrap(); + + let output = + run_rustiq_with_data_home(&["run", calculation_path.to_str().unwrap()], &temp_root); + assert_success(&output); + + let stdout = strip_ansi(&String::from_utf8(output.stdout).unwrap()); + assert!(stdout.contains("Requested calculation (canonical TOML)")); + assert!(stdout.contains("basis = \"sto-3g\"")); + assert!(stdout.contains("geometry = \"molecule.xyz\"")); + assert!(stdout.contains("Requested geometry (canonical XYZ, Angstrom)")); + assert!(stdout.contains("H 0 0 -0.3700000001234568")); + assert!(stdout.contains("Calculation\n")); + assert!(stdout.contains("Geometry molecule.xyz")); + assert!(!stdout.contains("Keep the original path and formatting")); + assert!(!stdout.contains("Hydrogen molecule -- original comment")); + assert!(!stdout.contains("relative to this file")); + assert!(!stdout.contains("Resolved configuration (canonical TOML)")); + assert!(!stdout.contains("Resolved geometry (canonical XYZ")); } #[test]