Skip to main content

gammaloop_api/commands/
mod.rs

1//! Typed command definitions and command-execution results.
2//!
3//! Commands share the CLI's stateful behavior even when executed through
4//! [`crate::session::CliSession`]. Most communicate through state/settings mutations, logs, or
5//! explicitly requested files. [`CommandOutput`] is reserved for results that the embedded Rust
6//! caller should consume directly.
7
8use std::{ops::ControlFlow, path::PathBuf, str::FromStr};
9
10use clap::{builder::ArgExt, Arg, Subcommand};
11use color_eyre::Report;
12use gammalooprs::settings::RuntimeSettings;
13use save::SaveState;
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17use crate::{
18    state::{CommandHistory, RunHistory, State},
19    CLISettings,
20};
21use symbolica::atom::Atom;
22pub mod approach;
23pub use approach::Approach;
24pub mod bench;
25pub use bench::Bench;
26pub mod commands_block;
27pub use commands_block::StartCommandsBlock;
28pub mod display;
29pub use display::Display;
30pub mod duplicate;
31pub use duplicate::Duplicate;
32pub mod generate;
33pub use generate::Generate;
34pub mod import;
35pub use import::Import;
36pub mod inspect;
37pub use inspect::Inspect;
38pub mod integrate;
39pub use integrate::{Integrate, IntegrationOutput};
40pub mod remove;
41pub use remove::Remove;
42pub mod save;
43pub use save::Save;
44pub mod select;
45pub use select::Select;
46pub mod set;
47pub mod shell;
48pub use set::Set;
49pub use shell::Shell;
50pub mod threedreps;
51pub use threedreps::ThreeDRep;
52pub mod run;
53pub use run::Run;
54pub mod evaluate;
55pub mod evaluate_samples;
56pub use evaluate::Evaluate;
57pub mod renormalize;
58pub use renormalize::Renormalize;
59pub mod profile;
60pub use profile::Profile;
61pub(crate) mod process_settings;
62
63/// Export metadata for Clap settings without public introspection APIs.
64#[doc(hidden)]
65#[derive(Clone, Debug, Default)]
66pub struct CliArgumentMetadata {
67    pub requires: Vec<&'static str>,
68    pub default_missing_values: Vec<&'static str>,
69}
70
71impl ArgExt for CliArgumentMetadata {}
72
73pub(crate) trait CliArgumentMetadataExt {
74    fn cli_requires(self, id: &'static str) -> Self;
75    fn cli_default_missing_value(self, value: &'static str) -> Self;
76}
77
78impl CliArgumentMetadataExt for Arg {
79    fn cli_requires(self, id: &'static str) -> Self {
80        let mut metadata = self
81            .get::<CliArgumentMetadata>()
82            .cloned()
83            .unwrap_or_default();
84        metadata.requires.push(id);
85        self.requires(id).add(metadata)
86    }
87
88    fn cli_default_missing_value(self, value: &'static str) -> Self {
89        let mut metadata = self
90            .get::<CliArgumentMetadata>()
91            .cloned()
92            .unwrap_or_default();
93        metadata.default_missing_values.push(value);
94        self.default_missing_value(value).add(metadata)
95    }
96}
97
98/// Structured value returned by a command, when that command has an embedded result.
99///
100/// Most commands return [`None`](CommandOutput::None); this does not mean that they had no effect.
101/// They may have changed the in-memory state or settings, emitted diagnostics, or written an
102/// explicitly requested file.
103#[derive(Debug, Clone, Default)]
104pub enum CommandOutput {
105    /// No direct Rust value; inspect the mutated state, settings, logs, or requested output file.
106    #[default]
107    None,
108    /// Symbolic result produced by an [`Evaluate`](Commands::Evaluate) command.
109    Evaluate(Atom),
110    /// Estimate, uncertainty, and integration metadata produced by an
111    /// [`Integrate`](Commands::Integrate) command.
112    Integrate(IntegrationOutput),
113}
114
115/// Control-flow request and optional structured value produced by one command.
116///
117/// Callers must inspect `flow` even when they do not need `output`. A break carries the
118/// [`SaveState`] policy requested by save/quit handling; it does not persist the state merely by
119/// being dropped.
120#[derive(Debug, Clone)]
121pub struct CommandExecution {
122    /// Continue the session or return the requested save/quit policy to the embedding caller.
123    pub flow: ControlFlow<SaveState>,
124    /// Embedded result for evaluation/integration, otherwise [`CommandOutput::None`].
125    pub output: CommandOutput,
126}
127
128impl CommandExecution {
129    pub fn continue_with(output: CommandOutput) -> Self {
130        Self {
131            flow: ControlFlow::Continue(()),
132            output,
133        }
134    }
135
136    pub fn continue_without_output() -> Self {
137        Self::continue_with(CommandOutput::None)
138    }
139
140    pub fn break_with(save_state: SaveState) -> Self {
141        Self {
142            flow: ControlFlow::Break(save_state),
143            output: CommandOutput::None,
144        }
145    }
146}
147
148#[allow(clippy::large_enum_variant)]
149#[derive(Subcommand, Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
150pub enum Commands {
151    /// Inspect models, processes, integrands, settings, and named session data.
152    #[clap(subcommand)]
153    Display(Display),
154    /// Copy generated data to a new process or integrand name.
155    #[clap(subcommand)]
156    Duplicate(Duplicate),
157    /// Change global, runtime, model, or process settings.
158    #[clap(subcommand)]
159    Set(Set),
160    /// Import models or previously generated graphs into the active state.
161    #[clap(subcommand)]
162    Import(Import),
163    /// Export graph data, standalone evaluators, schemas, or the current state.
164    #[clap(subcommand)]
165    Save(Save),
166
167    /// Execute a stored command block or an inline command list.
168    Run(Run),
169    /// Begin recording subsequent commands under a reusable block name.
170    #[command(name = "start_commands_block")]
171    StartCommandsBlock(StartCommandsBlock),
172    /// Stop recording commands and store the active command block.
173    #[command(name = "finish_commands_block")]
174    FinishCommandsBlock,
175
176    /// Remove generated processes from the active state.
177    #[command(name = "remove")]
178    #[clap(subcommand)]
179    Remove(Remove),
180
181    /// Integrate one or more generated process integrands.
182    Integrate(Integrate),
183
184    Generate(Generate),
185
186    /// Select graph groups and write the selection to a new process or integrand.
187    Select(Select),
188
189    /// End the interactive GammaLoop session without executing further commands.
190    Quit(SaveState),
191    /// Inspect integrand contributions at one phase-space point or momentum configuration.
192    // #[clap(subcommand)]
193    Inspect(Inspect),
194
195    /// Sample an integrand while approaching a phase-space point along selected scaling axes.
196    Approach(Approach),
197
198    /// Evaluate a vacuum amplitude analytically or numerically with Vakint.
199    Evaluate(Evaluate),
200
201    /// Compute and export ultraviolet renormalization contributions for an amplitude.
202    Renormalize(Renormalize),
203
204    /// Measure evaluation throughput at a fixed point over a target duration.
205    Bench(Bench),
206    /// Profile ultraviolet or infrared scaling limits of a generated integrand.
207    #[clap(subcommand)]
208    Profile(Profile),
209
210    /// Validate a graph or build its diagnostic three-dimensional energy representation.
211    #[command(name = "3Drep")]
212    #[clap(subcommand)]
213    ThreeDRep(ThreeDRep),
214
215    /// Evaluate a process-definition file against an HPC batch input and write named results.
216    Batch {
217        /// Process-definition file used to construct the batch evaluator.
218        #[arg(value_name = "PROCESS_FILE", value_hint = clap::ValueHint::FilePath)]
219        process_file: PathBuf,
220        /// Batch input file containing the evaluation points or jobs to execute.
221        #[arg(value_name = "BATCH_INPUT_FILE", value_hint = clap::ValueHint::FilePath)]
222        batch_input_file: PathBuf,
223        /// Logical batch name used in generated output metadata.
224        #[arg(short = 'n', long, value_name = "NAME")]
225        name: String,
226        /// Base name assigned to the batch result output.
227        #[arg(value_name = "NAME")]
228        output_name: String,
229    },
230    /// Run an operating-system shell command from the interactive GammaLoop session.
231    #[command(name = "!")]
232    Shell(Shell),
233
234    #[doc(hidden)]
235    #[command(skip)]
236    CommandTemplate(String),
237}
238
239impl FromStr for Commands {
240    type Err = Report;
241    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
242        Ok(CommandHistory::from_raw_string(s)?.command)
243    }
244}
245
246impl Commands {
247    pub fn run(
248        self,
249        state: &mut State,
250        run_history: &mut RunHistory,
251        global_cli_settings: &mut CLISettings,
252        default_runtime_settings: &mut RuntimeSettings,
253    ) -> Result<CommandExecution, Report> {
254        match self {
255            Commands::Profile(mut p) => {
256                if let profile::ProfileResult::UltraViolet(analysis) =
257                    p.run(state, global_cli_settings)?
258                {
259                    let verdict = analysis.pass_fail(gammalooprs::uv::profile::UV_PROFILE_MAX_DOD);
260                    if verdict.failed > 0 {
261                        let Profile::UltraViolet(options) = &mut p else {
262                            unreachable!()
263                        };
264                        let (process_id, integrand_name) = state.find_integrand_ref(
265                            options.process.as_ref(),
266                            options.integrand_name.as_ref(),
267                        )?;
268                        options.process = Some(crate::state::ProcessRef::Id(process_id));
269                        options.integrand_name = Some(integrand_name.clone());
270                        options.seed = Some(options.seed.unwrap_or(42));
271                        if !options.uv_ray_directions.is_empty() && options.uv_ray_norms.is_empty()
272                        {
273                            options.uv_ray_norms.push(
274                                state
275                                    .process_list
276                                    .get_integrand_mut(process_id, &integrand_name)?
277                                    .get_settings()
278                                    .kinematics
279                                    .e_cm,
280                            );
281                        }
282                        let scope = if analysis.stopped_early {
283                            "Stopped after the first failing limit; only completed limits are reported."
284                        } else {
285                            "All selected limits were profiled."
286                        };
287                        // Reuse the command serialization accepted by run cards.
288                        // Resolve defaults that otherwise depend on session selection.
289                        let reproduction =
290                            toml::to_string_pretty(&std::collections::BTreeMap::from([(
291                                "commands",
292                                vec![Commands::Profile(p)],
293                            )]))?;
294                        return Err(eyre::eyre!(
295                            "{verdict}\n{scope}\nReproduce in the same generated state with this run-card fragment:\n{reproduction}"
296                        ));
297                    }
298                }
299            }
300            Commands::ThreeDRep(command) => {
301                command.run(state, global_cli_settings)?;
302            }
303            Commands::Quit(s) => {
304                return Ok(CommandExecution::break_with(s));
305            }
306            Commands::Inspect(inspect) => {
307                if let Some(path) = &inspect.json_output {
308                    global_cli_settings.ensure_write_target_outside_active_state(
309                        path,
310                        "write inspect JSON output",
311                    )?;
312                }
313                let _ = inspect.run(state)?;
314            }
315            Commands::Approach(approach) => {
316                let _ = approach.run(state, global_cli_settings)?;
317            }
318            Commands::Bench(bench) => {
319                if let Some(path) = &bench.json_output {
320                    global_cli_settings.ensure_write_target_outside_active_state(
321                        path,
322                        "write benchmark JSON output",
323                    )?;
324                }
325                bench.run(state)?;
326            }
327            Commands::Import(s) => s.run(state, global_cli_settings)?,
328            Commands::Save(s) => s.run(
329                state,
330                run_history,
331                default_runtime_settings,
332                global_cli_settings,
333            )?,
334            Commands::Set(s) => s.run(state, global_cli_settings, default_runtime_settings)?,
335            Commands::Generate(g) => {
336                let would_compile_into_active_state = global_cli_settings.session.read_only_state
337                    && global_cli_settings.global.generation.evaluator.compile
338                    && matches!(
339                        g.mode.as_ref(),
340                        None | Some(generate::GenerateCmd::Existing(_))
341                    );
342                if would_compile_into_active_state {
343                    return Err(Report::msg(format!(
344                        "Cannot compile generated integrands into '{}' because this session was started with --read-only-state. Disable `global.generation.evaluator.compile`, restart without --read-only-state, or save the state elsewhere first.",
345                        global_cli_settings.state.folder.display()
346                    )));
347                }
348
349                g.run(
350                    state,
351                    &global_cli_settings.state.folder,
352                    global_cli_settings.override_state,
353                    &global_cli_settings.global,
354                    default_runtime_settings,
355                )?
356            }
357            Commands::Select(s) => {
358                s.run(state, global_cli_settings)?;
359            }
360            Commands::Integrate(g) => {
361                return Ok(CommandExecution::continue_with(CommandOutput::Integrate(
362                    g.run(state, global_cli_settings)?,
363                )));
364            }
365            Commands::Evaluate(g) => {
366                return Ok(CommandExecution::continue_with(CommandOutput::Evaluate(
367                    g.run(state, global_cli_settings, default_runtime_settings)?,
368                )));
369            }
370            Commands::Renormalize(r) => {
371                _ = r.run(state, global_cli_settings)?;
372            }
373            Commands::Display(l) => {
374                l.run(
375                    state,
376                    global_cli_settings,
377                    default_runtime_settings,
378                    run_history,
379                )?;
380            }
381            Commands::Duplicate(command) => {
382                command.run(state)?;
383            }
384            Commands::Run(r) => {
385                return r.run(
386                    state,
387                    global_cli_settings,
388                    default_runtime_settings,
389                    run_history,
390                );
391            }
392            Commands::StartCommandsBlock(_) | Commands::FinishCommandsBlock => {
393                return Err(Report::msg(
394                    "Command block definition commands must be handled by the CLI session",
395                ));
396            }
397            Commands::Remove(r) => r.run(state)?,
398            Commands::Batch {
399                process_file: _process_file,
400                batch_input_file: _batch_input_file,
401                name: _name,
402                output_name: _output_name,
403            } => {
404                todo!("Batch command not implemented yet");
405            }
406            Commands::Shell(s) => {
407                s.run()?;
408            }
409            Commands::CommandTemplate(_) => {
410                return Err(Report::msg(
411                    "Command templates can only be executed through `run` with `-D/--define` variables",
412                ));
413            }
414        }
415        Ok(CommandExecution::continue_without_output())
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use std::path::PathBuf;
422
423    use super::{Approach, Bench, Commands, Inspect, Renormalize};
424    use crate::{
425        commands::generate::{Generate, GenerateCmd, ProcessArgs},
426        state::{ProcessRef, RunHistory, State},
427        CLISettings,
428    };
429    use gammalooprs::settings::RuntimeSettings;
430
431    #[test]
432    fn select_command_parses_graph_names() {
433        let command: Commands =
434            "select -p #0 -i default --with-only-graph-names GL02 GL03 --with-graph-names GL04 GL05 --without-graph-names GL06"
435                .parse()
436                .unwrap();
437        match command {
438            Commands::Select(select) => {
439                assert_eq!(select.process, Some(ProcessRef::Id(0)));
440                assert_eq!(select.integrand_name.as_deref(), Some("default"));
441                assert_eq!(
442                    select.with_graph_names,
443                    vec!["GL04".to_string(), "GL05".to_string()]
444                );
445                assert_eq!(
446                    select.with_only_graph_names,
447                    vec!["GL02".to_string(), "GL03".to_string()]
448                );
449                assert_eq!(select.without_graph_names, vec!["GL06".to_string()]);
450            }
451            other => panic!("expected select command, got {other:?}"),
452        }
453    }
454
455    #[test]
456    fn select_command_requires_a_with_only_graph_name() {
457        let error = "select -p #0 -i default --with-only-graph-names"
458            .parse::<Commands>()
459            .unwrap_err();
460        assert!(error.to_string().contains("--with-only-graph-names"));
461    }
462
463    #[test]
464    fn select_command_parses_filter_options() {
465        let command: Commands = "select -p #0 -i default --amplitude-graphs --with-raised-propagator-signatures '[2]' --without-massive-raised-propagator-signatures '[]' --with-cycle-signatures '[(3,21)]' --without-cycle-signatures '[(ghost)]' --with-vertices '[V_6,V_9]' --without-vertices '[V_36]' --with-particles '[t,b]' '[g]' --without-particles '(e+,e-)'"
466            .parse()
467            .unwrap();
468        match command {
469            Commands::Select(select) => {
470                assert!(select.amplitude_graphs);
471                assert_eq!(
472                    select.with_raised_propagator_signatures,
473                    vec!["[2]".to_string()]
474                );
475                assert_eq!(
476                    select.without_massive_raised_propagator_signatures,
477                    vec!["[]".to_string()]
478                );
479                assert_eq!(select.with_cycle_signatures, vec!["[(3,21)]".to_string()]);
480                assert_eq!(
481                    select.without_cycle_signatures,
482                    vec!["[(ghost)]".to_string()]
483                );
484                assert_eq!(select.with_vertices, vec!["[V_6,V_9]".to_string()]);
485                assert_eq!(select.without_vertices, vec!["[V_36]".to_string()]);
486                assert_eq!(
487                    select.with_particles,
488                    vec!["[t,b]".to_string(), "[g]".to_string()]
489                );
490                assert_eq!(select.without_particles, vec!["(e+,e-)".to_string()]);
491            }
492            other => panic!("expected select command, got {other:?}"),
493        }
494    }
495
496    #[test]
497    fn select_command_parses_output_targets() {
498        let command: Commands = "select -p #0 -i default --with-graph-names GL04 --output_process selected_proc --output_integrand selected_itg --clear-existing-processes"
499            .parse()
500            .unwrap();
501        match command {
502            Commands::Select(select) => {
503                assert_eq!(select.process, Some(ProcessRef::Id(0)));
504                assert_eq!(select.integrand_name.as_deref(), Some("default"));
505                assert_eq!(select.output_process.as_deref(), Some("selected_proc"));
506                assert_eq!(select.output_integrand.as_deref(), Some("selected_itg"));
507                assert!(select.clear_existing_processes);
508            }
509            other => panic!("expected select command, got {other:?}"),
510        }
511    }
512
513    #[test]
514    fn approach_command_parses_axes_spacing_and_output() {
515        let command: Commands = "approach -p #0 -i default -x 0.5 0.25 --approach-axis 1.0,0.0 --axis-label soft --approach-axis 0.0,1.0 --axis-label threshold --n-points 3 --skip-midpoint --logarithmic --min-abs-t 1e-6 --max-abs-t 1e-2 --parameter-name lambda --n-cores 2 --output-results approach.json"
516            .parse()
517            .unwrap();
518        match command {
519            Commands::Approach(approach) => {
520                assert_eq!(approach.process, Some(ProcessRef::Id(0)));
521                assert_eq!(approach.integrand_name.as_deref(), Some("default"));
522                assert_eq!(approach.point, vec![0.5, 0.25]);
523                assert_eq!(
524                    approach.approach_axes,
525                    vec!["1.0,0.0".to_string(), "0.0,1.0".to_string()]
526                );
527                assert_eq!(approach.axis_labels, vec!["soft", "threshold"]);
528                assert_eq!(approach.n_points, 3);
529                assert!(approach.skip_midpoint);
530                assert!(approach.logarithmic);
531                assert!(!approach.linear);
532                assert_eq!(approach.min_abs_t, 1.0e-6);
533                assert_eq!(approach.max_abs_t, 1.0e-2);
534                assert_eq!(approach.parameter_name, "lambda");
535                assert_eq!(approach.n_cores, Some(2));
536                assert_eq!(
537                    approach.output_results.as_deref(),
538                    Some("approach.json".as_ref())
539                );
540            }
541            other => panic!("expected approach command, got {other:?}"),
542        }
543    }
544
545    #[test]
546    fn approach_command_parses_momentum_space_selectors() {
547        let command: Commands = "approach -p #0 -i default -x 1.0,-7.0e-2,0.0,-0.2,0.3,0.4 --approach-axis=-1.0e-3,0.0,0.0,0.0,0.0,0.0 --n-points 1 --linear --n-cores 1 --momentum-space --graph-id 2 --orientation-id 1"
548            .parse()
549            .unwrap();
550        match command {
551            Commands::Approach(approach) => {
552                assert_eq!(approach.point, vec![1.0, -7.0e-2, 0.0, -0.2, 0.3, 0.4]);
553                assert_eq!(approach.approach_axes, vec!["-1.0e-3,0.0,0.0,0.0,0.0,0.0"]);
554                assert!(approach.momentum_space);
555                assert!(approach.linear);
556                assert_eq!(approach.graph_id, Some(2));
557                assert_eq!(approach.orientation_id, Some(1));
558                assert_eq!(approach.n_cores, Some(1));
559            }
560            other => panic!("expected approach command, got {other:?}"),
561        }
562    }
563
564    #[test]
565    fn generate_rejects_compilation_into_active_state_in_read_only_mode() {
566        let mut state = State::new_test();
567        let mut run_history = RunHistory::default();
568        let mut cli_settings = CLISettings::default();
569        let mut runtime_settings = RuntimeSettings::default();
570        cli_settings.session.read_only_state = true;
571        cli_settings.global.generation.evaluator.compile = true;
572
573        let err = Commands::Generate(Generate {
574            keep_sources: false,
575            mode: Some(GenerateCmd::Existing(ProcessArgs {
576                process: None,
577                integrand_name: None,
578            })),
579        })
580        .run(
581            &mut state,
582            &mut run_history,
583            &mut cli_settings,
584            &mut runtime_settings,
585        )
586        .unwrap_err();
587
588        assert!(format!("{err:?}").contains("--read-only-state"));
589    }
590
591    #[test]
592    fn inspect_rejects_json_output_inside_read_only_state_before_integrand_lookup() {
593        let mut state = State::new_test();
594        let mut run_history = RunHistory::default();
595        let mut cli_settings = CLISettings::default();
596        let mut runtime_settings = RuntimeSettings::default();
597        cli_settings.state.folder = PathBuf::from("/tmp/read_only_state");
598        cli_settings.session.read_only_state = true;
599
600        let err = Commands::Inspect(Inspect {
601            point: vec![0.1, 0.2],
602            json_output: Some(PathBuf::from("/tmp/read_only_state/inspect.json")),
603            ..Inspect::default()
604        })
605        .run(
606            &mut state,
607            &mut run_history,
608            &mut cli_settings,
609            &mut runtime_settings,
610        )
611        .unwrap_err();
612
613        let rendered = format!("{err:?}");
614        assert!(
615            rendered.contains("Cannot write inspect JSON output"),
616            "{rendered}"
617        );
618        assert!(rendered.contains("--read-only-state"), "{rendered}");
619    }
620
621    #[test]
622    fn bench_rejects_json_output_inside_read_only_state_before_integrand_lookup() {
623        let mut state = State::new_test();
624        let mut run_history = RunHistory::default();
625        let mut cli_settings = CLISettings::default();
626        let mut runtime_settings = RuntimeSettings::default();
627        cli_settings.state.folder = PathBuf::from("/tmp/read_only_state");
628        cli_settings.session.read_only_state = true;
629
630        let err = Commands::Bench(Bench {
631            point: vec![0.1, 0.2],
632            duration: "1ms".to_string(),
633            json_output: Some(PathBuf::from("/tmp/read_only_state/bench.json")),
634            ..Bench::default()
635        })
636        .run(
637            &mut state,
638            &mut run_history,
639            &mut cli_settings,
640            &mut runtime_settings,
641        )
642        .unwrap_err();
643
644        let rendered = format!("{err:?}");
645        assert!(
646            rendered.contains("Cannot write benchmark JSON output"),
647            "{rendered}"
648        );
649        assert!(rendered.contains("--read-only-state"), "{rendered}");
650    }
651
652    #[test]
653    fn approach_rejects_output_inside_read_only_state_before_integrand_lookup() {
654        let mut state = State::new_test();
655        let mut run_history = RunHistory::default();
656        let mut cli_settings = CLISettings::default();
657        let mut runtime_settings = RuntimeSettings::default();
658        cli_settings.state.folder = PathBuf::from("/tmp/read_only_state");
659        cli_settings.session.read_only_state = true;
660
661        let err = Commands::Approach(Approach {
662            point: vec![0.1, 0.2],
663            output_results: Some(PathBuf::from("/tmp/read_only_state/approach.json")),
664            ..Approach::default()
665        })
666        .run(
667            &mut state,
668            &mut run_history,
669            &mut cli_settings,
670            &mut runtime_settings,
671        )
672        .unwrap_err();
673
674        let rendered = format!("{err:?}");
675        assert!(
676            rendered.contains("Cannot write approach results"),
677            "{rendered}"
678        );
679        assert!(rendered.contains("--read-only-state"), "{rendered}");
680    }
681
682    #[test]
683    fn renormalize_rejects_output_inside_read_only_state_before_integrand_lookup() {
684        let mut state = State::new_test();
685        let mut run_history = RunHistory::default();
686        let mut cli_settings = CLISettings::default();
687        let mut runtime_settings = RuntimeSettings::default();
688        cli_settings.state.folder = PathBuf::from("/tmp/read_only_state");
689        cli_settings.session.read_only_state = true;
690
691        let err = Commands::Renormalize(Renormalize {
692            result_path: Some(PathBuf::from("/tmp/read_only_state/renormalization")),
693            ..Renormalize::default()
694        })
695        .run(
696            &mut state,
697            &mut run_history,
698            &mut cli_settings,
699            &mut runtime_settings,
700        )
701        .unwrap_err();
702
703        let rendered = format!("{err:?}");
704        assert!(
705            rendered.contains("Cannot write renormalization results"),
706            "{rendered}"
707        );
708        assert!(rendered.contains("--read-only-state"), "{rendered}");
709    }
710}