Skip to main content

gammaloop_api/
session.rs

1//! Borrowed command-session access to a loaded GammaLoop state.
2//!
3//! [`CliSession`] ties together the domain state, replay history, persistent settings, runtime
4//! defaults, and transient command-block state owned by [`crate::LoadedState`]. It does not own or
5//! automatically persist any of them.
6
7use std::{
8    collections::BTreeMap,
9    io::{self, IsTerminal, Write},
10    ops::ControlFlow,
11};
12
13use color_eyre::{eyre::eyre, Result};
14use colored::Colorize;
15use gammalooprs::integrands::process::ProcessIntegrand;
16use gammalooprs::model::Model;
17use gammalooprs::processes::{
18    GraphSelectionSignatureInventory, ProcessCollection, ProcessDefinition,
19    RaisedCutSignatureInventory,
20};
21use gammalooprs::settings::{global::GenerationSettings, RuntimeSettings};
22use tracing::{info, warn};
23
24use crate::{
25    command_template::contains_placeholder,
26    commands::{
27        run::{
28            prepare_command_histories_with_context, PreparedCommand, PreparedRun, MAX_RUN_DEPTH,
29        },
30        save::SaveState,
31        CommandExecution, Commands, StartCommandsBlock,
32    },
33    completion::{
34        IntegrandDetailCompletionEntry, IrProfileCompletionEntry, ModelVertexCompletionEntry,
35        ProcessCompletionEntry, ProcessKind,
36    },
37    integrand_info::IntegrandKind,
38    render_smart_toml,
39    state::{CommandHistory, CommandsBlock, ProcessRef, RunHistory, State},
40    CLISettings, ReadOnlyStateOrigin,
41};
42
43#[cfg(feature = "cli")]
44use crate::commands::process_settings::{
45    serialize_runtime_named_settings, ProcessSettingsCompletionEntry,
46};
47
48const MAX_DISPLAY_COMMAND_LINES: usize = 5;
49
50fn format_command_block_conflict_message(conflicting_blocks: &[String]) -> String {
51    match conflicting_blocks {
52        [] => String::new(),
53        [single] => format!(
54            "Run card command block '{}' redefines an existing block with different commands",
55            single
56        ),
57        many => format!(
58            "Run card command blocks {} redefine existing blocks with different commands",
59            many.iter()
60                .map(|name| format!("'{}'", name))
61                .collect::<Vec<_>>()
62                .join(", ")
63        ),
64    }
65}
66
67fn prompt_command_block_conflict_override(conflicting_blocks: &[String]) -> Result<bool> {
68    let prompt = format!(
69        "{}. Proceed anyway? (it may break reproducibility of the state from run.toml) [y/n] > ",
70        format_command_block_conflict_message(conflicting_blocks)
71    );
72    let mut stderr = io::stderr();
73    let mut input = String::new();
74    loop {
75        eprint!("{prompt}");
76        stderr.flush()?;
77        input.clear();
78        io::stdin().read_line(&mut input)?;
79        match input.trim().to_ascii_lowercase().as_str() {
80            "y" | "yes" => return Ok(true),
81            "n" | "no" => return Ok(false),
82            _ => {
83                eprintln!("Please answer 'y' or 'n'.");
84            }
85        }
86    }
87}
88
89/// Transient state used while defining a reusable command block.
90///
91/// This is process/session state rather than persisted run history. Most callers should retain the
92/// value inside [`crate::LoadedState`] and access it through [`CliSession`].
93#[derive(Debug, Clone, Default)]
94pub struct CliSessionState {
95    pending_commands_block: Option<PendingCommandsBlock>,
96}
97
98#[derive(Debug, Clone)]
99struct PendingCommandsBlock {
100    name: String,
101    commands: Vec<CommandHistory>,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105enum HistoryMode {
106    Record,
107    Suppress,
108}
109
110/// Mutably borrowed view used to execute GammaLoop commands.
111///
112/// A session updates its caller-owned [`State`], [`RunHistory`], and effective settings in place.
113/// It may also write files according to the selected command. Read-only mode prevents
114/// GammaLoop-managed writes into the active state tree, but does not constrain explicit exports or
115/// external processes such as the `!` shell command.
116pub struct CliSession<'a> {
117    state: &'a mut State,
118    run_history: &'a mut RunHistory,
119    cli_settings: &'a mut CLISettings,
120    default_runtime_settings: &'a mut RuntimeSettings,
121    session_state: &'a mut CliSessionState,
122}
123
124impl<'a> CliSession<'a> {
125    /// Combine the mutable state and settings that participate in command execution.
126    ///
127    /// This constructor does not initialize GammaLoop, load files, replay history, or execute boot
128    /// commands. Prefer [`crate::StateLoadOption::load`] followed by
129    /// [`crate::LoadedState::cli_session`] unless the caller already owns a consistent bundle.
130    pub fn new(
131        state: &'a mut State,
132        run_history: &'a mut RunHistory,
133        cli_settings: &'a mut CLISettings,
134        default_runtime_settings: &'a mut RuntimeSettings,
135        session_state: &'a mut CliSessionState,
136    ) -> Self {
137        Self {
138            state,
139            run_history,
140            cli_settings,
141            default_runtime_settings,
142            session_state,
143        }
144    }
145
146    /// Prepare and execute one command against the borrowed state.
147    ///
148    /// Ordinary successful commands are normalized and appended to the replayable run history;
149    /// command-block definition controls have specialized recording behavior. A command can mutate
150    /// the state and settings or write its requested outputs before returning. Inspect both
151    /// [`CommandExecution::flow`] and [`CommandExecution::output`]: save/quit requests use
152    /// [`ControlFlow::Break`], while only evaluation and integration currently return structured
153    /// output.
154    ///
155    /// Errors include parse/preparation failures, invalid state or settings, unavailable backends,
156    /// numerical command failures, and forbidden writes into a read-only state tree. An error does
157    /// not imply that every command-specific external effect was rolled back.
158    pub fn execute_command(&mut self, command: CommandHistory) -> Result<CommandExecution> {
159        let prepared = PreparedCommand::prepare(command, self.run_history, 1)?;
160        self.execute_prepared(prepared, HistoryMode::Record)
161    }
162
163    /// Reapply the stored session settings and commands without appending duplicate history.
164    ///
165    /// Replay mutates the current state and can perform the same command-specific effects as normal
166    /// execution. Callers must honor a returned [`ControlFlow::Break`] save/quit request.
167    pub fn replay_run_history(&mut self) -> Result<ControlFlow<SaveState>> {
168        self.run_history
169            .apply_session_settings(self.cli_settings, self.default_runtime_settings)?;
170        let prepared = prepare_command_histories_with_context(
171            &self.run_history.commands.clone(),
172            self.run_history,
173            1,
174            "run history",
175        )?;
176        self.execute_prepared_commands(prepared, HistoryMode::Suppress)
177    }
178
179    pub fn apply_boot_run_history(
180        &mut self,
181        boot_run_history: &RunHistory,
182        effective_boot_run_history: &RunHistory,
183        booted_existing_state: bool,
184    ) -> Result<ControlFlow<SaveState>> {
185        let interactive_prompt_available = io::stdin().is_terminal() && io::stdout().is_terminal();
186        self.apply_boot_run_history_with_conflict_resolver(
187            boot_run_history,
188            effective_boot_run_history,
189            booted_existing_state,
190            |conflicting_blocks| {
191                if interactive_prompt_available {
192                    return prompt_command_block_conflict_override(conflicting_blocks);
193                }
194
195                Err(eyre!(
196                    "{}. Cannot continue non-interactively; rerun in an interactive terminal or align the command block definitions.",
197                    format_command_block_conflict_message(conflicting_blocks)
198                ))
199            },
200        )
201    }
202
203    pub fn apply_boot_run_history_with_conflict_resolver<F>(
204        &mut self,
205        boot_run_history: &RunHistory,
206        effective_boot_run_history: &RunHistory,
207        booted_existing_state: bool,
208        mut resolve_conflicts: F,
209    ) -> Result<ControlFlow<SaveState>>
210    where
211        F: FnMut(&[String]) -> Result<bool>,
212    {
213        boot_run_history.validate()?;
214
215        if booted_existing_state
216            && !self
217                .run_history
218                .frozen_boot_settings_match(boot_run_history)
219        {
220            let warning = format!(
221                "Boot card settings differ from the frozen settings stored in {}. This session is forced into --read-only-state. Line up cli_settings.global and default_runtime_settings in the boot card with the frozen settings in run.toml if you want to boot this state without read-only mode.",
222                self.cli_settings.state.folder.join("run.toml").display()
223            );
224            self.cli_settings
225                .session
226                .force_read_only_state(ReadOnlyStateOrigin::BootSettingsMismatch);
227            self.cli_settings
228                .session
229                .startup_warnings
230                .push(warning.clone());
231            warn!("{warning}");
232        }
233        if !booted_existing_state {
234            self.run_history.freeze_boot_settings_from(boot_run_history);
235        }
236
237        let mut merged_history = self.run_history.clone();
238        let conflicting_blocks = merged_history
239            .conflicting_command_block_names(&effective_boot_run_history.command_blocks);
240        let overwrite_conflicting_blocks = if conflicting_blocks.is_empty() {
241            false
242        } else if self.cli_settings.session.read_only_state {
243            if self
244                .cli_settings
245                .session
246                .is_read_only_due_to_boot_settings_mismatch()
247            {
248                let warning = format!(
249                    "{}. The boot card settings differ from the frozen settings stored in {}; this session remains in --read-only-state for reproducibility, but will use the boot card command block definitions for this process only.",
250                    format_command_block_conflict_message(&conflicting_blocks),
251                    self.cli_settings.state.folder.join("run.toml").display()
252                );
253                self.cli_settings
254                    .session
255                    .startup_warnings
256                    .push(warning.clone());
257                warn!("{warning}");
258                true
259            } else {
260                return Err(eyre!(
261                    "{}. Cannot proceed while --read-only-state is enabled.",
262                    format_command_block_conflict_message(&conflicting_blocks)
263                ));
264            }
265        } else if resolve_conflicts(&conflicting_blocks)? {
266            true
267        } else {
268            info!(
269                "{}",
270                "Boot run card application cancelled by user.".yellow()
271            );
272            return Ok(ControlFlow::Break(SaveState::default()));
273        };
274        merged_history.merge_command_blocks_with_overwrite(
275            &effective_boot_run_history.command_blocks,
276            overwrite_conflicting_blocks,
277        )?;
278
279        for block in &effective_boot_run_history.command_blocks {
280            // Reusable blocks may inherit variables through nested runs. Validate
281            // names and recursion without expanding those variables; expansion
282            // uses the actual invocation environment before any command executes.
283            let mut pending = vec![(
284                format!("command block '{}'", block.name),
285                block.commands.clone(),
286                vec![block.name.clone()],
287                2,
288            )];
289            while let Some((context, commands, active_blocks, depth)) = pending.pop() {
290                for (index, command) in commands.into_iter().enumerate() {
291                    let Commands::Run(run) = command.command else {
292                        continue;
293                    };
294                    if depth > MAX_RUN_DEPTH {
295                        return Err(eyre!(
296                            "Maximum nested run depth of {} reached while validating {}",
297                            MAX_RUN_DEPTH,
298                            context
299                        ));
300                    }
301                    let names = run
302                        .selected_block_names()
303                        .iter()
304                        .filter(|name| !contains_placeholder(name))
305                        .cloned()
306                        .collect::<Vec<_>>();
307                    let selected = merged_history
308                        .select_command_blocks(&names)
309                        .map_err(|err| {
310                            eyre!(
311                                "Failed to validate {} command #{}: {}",
312                                context,
313                                index + 1,
314                                err
315                            )
316                        })?;
317                    for nested in selected.into_iter().rev() {
318                        if let Some(start) =
319                            active_blocks.iter().position(|name| *name == nested.name)
320                        {
321                            let mut cycle = active_blocks[start..].to_vec();
322                            cycle.push(nested.name.clone());
323                            return Err(eyre!(
324                                "Command block recursion detected: {}",
325                                cycle.join(" -> ")
326                            ));
327                        }
328                        let mut active = active_blocks.clone();
329                        active.push(nested.name.clone());
330                        pending.push((
331                            format!("command block '{}'", nested.name),
332                            nested.commands,
333                            active,
334                            depth + 1,
335                        ));
336                    }
337                    let inline = run.parse_inline_commands()?;
338                    if !inline.is_empty() {
339                        pending.push((
340                            format!("{} run -c", context),
341                            inline,
342                            active_blocks.clone(),
343                            depth + 1,
344                        ));
345                    }
346                }
347            }
348        }
349
350        let prepared = prepare_command_histories_with_context(
351            &effective_boot_run_history.commands,
352            &merged_history,
353            1,
354            "boot",
355        )?;
356
357        self.run_history.merge_command_blocks_with_overwrite(
358            &effective_boot_run_history.command_blocks,
359            overwrite_conflicting_blocks,
360        )?;
361        effective_boot_run_history
362            .apply_session_settings(self.cli_settings, self.default_runtime_settings)?;
363        self.execute_prepared_commands(prepared, HistoryMode::Record)
364    }
365
366    pub fn has_pending_commands_block(&self) -> bool {
367        self.session_state.pending_commands_block.is_some()
368    }
369
370    pub fn pending_commands_block_name(&self) -> Option<String> {
371        self.session_state
372            .pending_commands_block
373            .as_ref()
374            .map(|pending| pending.name.clone())
375    }
376
377    pub fn prompt_state_label(&self) -> String {
378        self.cli_settings.state.prompt_label()
379    }
380
381    pub fn current_commands_block_names(&self) -> Vec<String> {
382        self.run_history
383            .command_blocks
384            .iter()
385            .map(|block| block.name.clone())
386            .collect()
387    }
388
389    pub fn current_command_block_placeholders(
390        &self,
391    ) -> BTreeMap<String, Vec<crate::command_template::PlaceholderSpec>> {
392        self.run_history
393            .command_blocks
394            .iter()
395            .map(|block| {
396                (
397                    block.name.clone(),
398                    self.run_history
399                        .command_block_placeholder_specs(&block.name)
400                        .into_iter()
401                        .collect(),
402                )
403            })
404            .collect()
405    }
406
407    pub fn current_process_entries(&self) -> Vec<ProcessCompletionEntry> {
408        self.state
409            .process_list
410            .processes
411            .iter()
412            .enumerate()
413            .map(|(id, process)| ProcessCompletionEntry {
414                id,
415                name: process.definition.folder_name.clone(),
416                kind: match &process.collection {
417                    ProcessCollection::Amplitudes(_) => ProcessKind::Amplitude,
418                    ProcessCollection::CrossSections(_) => ProcessKind::CrossSection,
419                },
420                integrand_names: process
421                    .collection
422                    .get_integrand_names()
423                    .into_iter()
424                    .map(str::to_string)
425                    .collect(),
426            })
427            .collect()
428    }
429
430    pub fn current_integrand_detail_entries(&self) -> Vec<IntegrandDetailCompletionEntry> {
431        self.state
432            .process_list
433            .processes
434            .iter()
435            .enumerate()
436            .flat_map(|(process_id, process)| {
437                process
438                    .collection
439                    .get_integrand_names()
440                    .into_iter()
441                    .filter_map(move |integrand_name| {
442                        let integrand_name = integrand_name.to_string();
443                        let info = self
444                            .state
445                            .get_integrand_info(
446                                Some(&ProcessRef::Id(process_id)),
447                                Some(&integrand_name),
448                            )
449                            .ok()?;
450                        let mut master_graph_names = info
451                            .graph_groups
452                            .iter()
453                            .filter_map(|group| {
454                                group
455                                    .graphs
456                                    .iter()
457                                    .find(|graph| graph.is_master)
458                                    .map(|graph| graph.name.clone())
459                            })
460                            .collect::<Vec<_>>();
461                        master_graph_names.sort();
462                        master_graph_names.dedup();
463
464                        let mut categories = vec![
465                            "generation".to_string(),
466                            "orientation".to_string(),
467                            "loop_momentum_basis".to_string(),
468                        ];
469                        if info.kind == IntegrandKind::CrossSection {
470                            categories.push("cuts".to_string());
471                        }
472                        let signature_inventory =
473                            integrand_signature_inventory(&process.collection, &integrand_name);
474                        let amplitude_signature_inventory = amplitude_graph_signature_inventory(
475                            &process.collection,
476                            &integrand_name,
477                            &self.state.model,
478                            &process.definition,
479                            &self.cli_settings.global.generation,
480                        );
481                        let raised_cut_signature_inventory = raised_cut_signature_inventory(
482                            &process.collection,
483                            &integrand_name,
484                            &self.state.model,
485                            &process.definition,
486                            &self.cli_settings.global.generation,
487                        );
488
489                        Some(IntegrandDetailCompletionEntry {
490                            process_name: process.definition.folder_name.clone(),
491                            integrand_name,
492                            kind: info.kind,
493                            master_graph_names,
494                            raised_all_signatures: signature_inventory.raised_all,
495                            raised_massive_signatures: signature_inventory.raised_massive,
496                            raised_massless_signatures: signature_inventory.raised_massless,
497                            cycle_signatures: signature_inventory.cycles,
498                            amplitude_raised_all_signatures: amplitude_signature_inventory
499                                .raised_all,
500                            amplitude_raised_massive_signatures: amplitude_signature_inventory
501                                .raised_massive,
502                            amplitude_raised_massless_signatures: amplitude_signature_inventory
503                                .raised_massless,
504                            amplitude_cycle_signatures: amplitude_signature_inventory.cycles,
505                            raised_cut_all_signatures: raised_cut_signature_inventory.all,
506                            raised_cut_massive_signatures: raised_cut_signature_inventory.massive,
507                            raised_cut_massless_signatures: raised_cut_signature_inventory.massless,
508                            categories,
509                        })
510                    })
511                    .collect::<Vec<_>>()
512            })
513            .collect()
514    }
515
516    pub fn current_ir_profile_entries(&self) -> Vec<IrProfileCompletionEntry> {
517        self.state
518            .process_list
519            .processes
520            .iter()
521            .filter_map(|process| {
522                let ProcessCollection::CrossSections(cross_sections) = &process.collection else {
523                    return None;
524                };
525
526                Some(
527                    cross_sections
528                        .iter()
529                        .filter_map(|(integrand_name, cross_section)| {
530                            let ProcessIntegrand::CrossSection(integrand) =
531                                cross_section.integrand.as_ref()?
532                            else {
533                                return None;
534                            };
535                            let completion_entries = integrand.ir_profile_completion_entries();
536                            let mut graph_names = completion_entries
537                                .iter()
538                                .map(|(graph_name, _)| graph_name.clone())
539                                .collect::<Vec<_>>();
540                            graph_names.sort();
541                            graph_names.dedup();
542
543                            let mut graph_limit_entries = completion_entries
544                                .into_iter()
545                                .flat_map(|(graph_name, limits)| {
546                                    limits
547                                        .into_iter()
548                                        .map(move |limit| format!("{graph_name} {limit}"))
549                                })
550                                .collect::<Vec<_>>();
551                            graph_limit_entries.sort();
552                            graph_limit_entries.dedup();
553
554                            Some(IrProfileCompletionEntry {
555                                process_name: process.definition.folder_name.clone(),
556                                integrand_name: integrand_name.clone(),
557                                graph_names,
558                                graph_limit_entries,
559                            })
560                        })
561                        .collect::<Vec<_>>(),
562                )
563            })
564            .flatten()
565            .collect()
566    }
567
568    #[cfg(feature = "cli")]
569    pub(crate) fn current_process_settings_entries(&self) -> Vec<ProcessSettingsCompletionEntry> {
570        let mut entries = Vec::new();
571
572        for (process_id, process) in self.state.process_list.processes.iter().enumerate() {
573            for integrand_name in process.collection.get_integrand_names() {
574                let Ok(resolved) = process.get_integrand(integrand_name) else {
575                    continue;
576                };
577                let Some(integrand) = resolved.integrand else {
578                    continue;
579                };
580                let Ok(serialized) = serialize_runtime_named_settings(integrand.get_settings())
581                else {
582                    continue;
583                };
584
585                entries.push(ProcessSettingsCompletionEntry {
586                    process_id,
587                    process_name: process.definition.folder_name.clone(),
588                    integrand_name: integrand_name.to_string(),
589                    quantities: serialized.quantities,
590                    observables: serialized.observables,
591                    selectors: serialized.selectors,
592                });
593            }
594        }
595
596        entries.sort_by(|left, right| {
597            left.process_id
598                .cmp(&right.process_id)
599                .then_with(|| left.integrand_name.cmp(&right.integrand_name))
600        });
601        entries
602    }
603
604    pub fn current_model_parameter_entries(
605        &self,
606    ) -> Vec<crate::completion::ModelParameterCompletionEntry> {
607        let mut entries = self
608            .state
609            .model_parameters
610            .keys()
611            .filter_map(|name| {
612                self.state
613                    .model
614                    .get_parameter_opt(name.to_string())
615                    .map(
616                        |parameter| crate::completion::ModelParameterCompletionEntry {
617                            name: name.to_string(),
618                            parameter_type: parameter.parameter_type.clone(),
619                        },
620                    )
621            })
622            .collect::<Vec<_>>();
623        entries.sort_by(|left, right| left.name.cmp(&right.name));
624        entries
625    }
626
627    pub fn current_model_particle_names(&self) -> Vec<String> {
628        let mut names = self
629            .state
630            .model
631            .particles
632            .iter()
633            .flat_map(|particle| [particle.name.to_string(), particle.antiname.to_string()])
634            .collect::<Vec<_>>();
635        names.sort();
636        names.dedup();
637        names
638    }
639
640    pub fn current_model_select_particle_names(&self) -> Vec<String> {
641        let mut names = self
642            .state
643            .model
644            .particles
645            .iter()
646            .filter(|particle| particle.pdg_code > 0)
647            .map(|particle| particle.name.to_string())
648            .collect::<Vec<_>>();
649        names.sort();
650        names.dedup();
651        names
652    }
653
654    pub fn current_model_coupling_names(&self) -> Vec<String> {
655        let mut names = self
656            .state
657            .model
658            .orders
659            .iter()
660            .map(|order| order.name.to_string())
661            .collect::<Vec<_>>();
662        names.sort();
663        names.dedup();
664        names
665    }
666
667    pub fn current_model_vertices(&self) -> Vec<ModelVertexCompletionEntry> {
668        let mut entries = self
669            .state
670            .model
671            .vertex_rules
672            .iter()
673            .map(|vertex_rule| ModelVertexCompletionEntry {
674                name: vertex_rule.0.name.to_string(),
675                particles: vertex_rule
676                    .0
677                    .particles
678                    .iter()
679                    .map(|particle| particle.name.to_string())
680                    .collect(),
681            })
682            .collect::<Vec<_>>();
683        entries.sort_by(|left, right| left.name.cmp(&right.name));
684        entries.dedup_by(|left, right| left.name == right.name);
685        entries
686    }
687
688    pub fn run_history_toml(&self) -> Result<String> {
689        self.run_history
690            .to_toml_string(self.cli_settings.try_strings)
691    }
692
693    pub fn global_settings_toml(&self) -> Result<String> {
694        render_smart_toml(self.cli_settings)
695    }
696
697    pub fn active_command_blocks(&self) -> BTreeMap<String, Vec<String>> {
698        self.run_history
699            .command_blocks
700            .iter()
701            .map(|block| {
702                (
703                    block.name.clone(),
704                    block.commands.iter().map(display_command).collect(),
705                )
706            })
707            .collect()
708    }
709
710    pub fn dismiss_pending_commands_block(&mut self, trigger: &str) -> bool {
711        let Some(pending) = self.session_state.pending_commands_block.take() else {
712            return false;
713        };
714
715        info!(
716            "{} {} {} {}",
717            "Dismissing command block definition".blue(),
718            pending.name.green(),
719            "after".blue(),
720            trigger.green()
721        );
722        true
723    }
724
725    fn execute_prepared_commands(
726        &mut self,
727        commands: Vec<PreparedCommand>,
728        history_mode: HistoryMode,
729    ) -> Result<ControlFlow<SaveState>> {
730        for command in commands {
731            let execution = self.execute_prepared(command, history_mode)?;
732            if let ControlFlow::Break(save_state) = execution.flow {
733                return Ok(ControlFlow::Break(save_state));
734            }
735        }
736        Ok(ControlFlow::Continue(()))
737    }
738
739    fn execute_prepared(
740        &mut self,
741        command: PreparedCommand,
742        history_mode: HistoryMode,
743    ) -> Result<CommandExecution> {
744        match command {
745            PreparedCommand::Plain(command) => self.execute_plain(command, history_mode),
746            PreparedCommand::Run { command, plan } => self.execute_run(command, plan, history_mode),
747        }
748    }
749
750    fn execute_plain(
751        &mut self,
752        command: CommandHistory,
753        history_mode: HistoryMode,
754    ) -> Result<CommandExecution> {
755        if self.session_state.pending_commands_block.is_some() {
756            return self.handle_recording_mode(command);
757        }
758
759        let display_text = display_command(&command);
760        info!("{} {}", "Running command".blue(), display_text.green());
761
762        let record = match &command.command {
763            Commands::StartCommandsBlock(start) => {
764                self.start_commands_block(start.clone())?;
765                false
766            }
767            Commands::FinishCommandsBlock => {
768                self.finish_commands_block()?;
769                false
770            }
771            _ => {
772                let execution = command.command.clone().run(
773                    self.state,
774                    self.run_history,
775                    self.cli_settings,
776                    self.default_runtime_settings,
777                )?;
778                if let ControlFlow::Break(save_state) = execution.flow {
779                    if history_mode == HistoryMode::Record {
780                        self.record_command(&command);
781                    }
782                    return Ok(CommandExecution::break_with(save_state));
783                }
784                let output = execution.output;
785                if history_mode == HistoryMode::Record {
786                    self.record_command(&command);
787                }
788                return Ok(CommandExecution::continue_with(output));
789            }
790        };
791
792        if record {
793            self.record_command(&command);
794        }
795
796        Ok(CommandExecution::continue_without_output())
797    }
798
799    fn execute_run(
800        &mut self,
801        command: CommandHistory,
802        plan: PreparedRun,
803        history_mode: HistoryMode,
804    ) -> Result<CommandExecution> {
805        if self.session_state.pending_commands_block.is_some() {
806            return self.handle_recording_mode(command);
807        }
808
809        if plan.is_empty() {
810            return Ok(CommandExecution::continue_without_output());
811        }
812
813        let display_text = display_command(&command);
814        info!("{} {}", "Running command".blue(), display_text.green());
815
816        for block in plan.blocks {
817            info!("{} {}", "Starting command block".blue(), block.name.green());
818            if let ControlFlow::Break(save_state) =
819                self.execute_prepared_commands(block.commands, HistoryMode::Suppress)?
820            {
821                if history_mode == HistoryMode::Record {
822                    self.record_command(&command);
823                }
824                return Ok(CommandExecution::break_with(save_state));
825            }
826        }
827
828        if !plan.commands.is_empty() {
829            info!(
830                "{} {}",
831                "Starting commands supplied with".blue(),
832                "--commands/-c".green()
833            );
834        }
835
836        if let ControlFlow::Break(save_state) =
837            self.execute_prepared_commands(plan.commands, HistoryMode::Suppress)?
838        {
839            if history_mode == HistoryMode::Record {
840                self.record_command(&command);
841            }
842            return Ok(CommandExecution::break_with(save_state));
843        }
844
845        if history_mode == HistoryMode::Record {
846            self.record_command(&command);
847        }
848
849        Ok(CommandExecution::continue_without_output())
850    }
851
852    fn handle_recording_mode(&mut self, command: CommandHistory) -> Result<CommandExecution> {
853        match command.command.clone() {
854            Commands::StartCommandsBlock(_) => Err(eyre!(
855                "Cannot start a new command block definition before finishing the current one"
856            )),
857            Commands::FinishCommandsBlock => {
858                self.finish_commands_block()?;
859                Ok(CommandExecution::continue_without_output())
860            }
861            Commands::Quit(_) => {
862                self.dismiss_pending_commands_block("quit");
863                Ok(CommandExecution::continue_without_output())
864            }
865            _ => {
866                let stored = normalize_command_history(&command);
867                let pending = self
868                    .session_state
869                    .pending_commands_block
870                    .as_mut()
871                    .expect("recording mode requires a pending block");
872                pending.commands.push(stored);
873                Ok(CommandExecution::continue_without_output())
874            }
875        }
876    }
877
878    fn start_commands_block(&mut self, start: StartCommandsBlock) -> Result<()> {
879        if self.session_state.pending_commands_block.is_some() {
880            return Err(eyre!(
881                "Cannot start a new command block definition before finishing the current one"
882            ));
883        }
884
885        if self.run_history.command_block(&start.name).is_some() {
886            info!(
887                "{} {}",
888                "Overwriting existing command block definition".blue(),
889                start.name.green()
890            );
891        } else {
892            info!(
893                "{} {}",
894                "Defining new command block definition".blue(),
895                start.name.green()
896            );
897        }
898
899        self.session_state.pending_commands_block = Some(PendingCommandsBlock {
900            name: start.name,
901            commands: Vec::new(),
902        });
903        Ok(())
904    }
905
906    fn finish_commands_block(&mut self) -> Result<()> {
907        let Some(pending) = self.session_state.pending_commands_block.take() else {
908            return Err(eyre!(
909                "No command block definition is currently being recorded"
910            ));
911        };
912
913        let block = CommandsBlock {
914            name: pending.name.clone(),
915            commands: pending.commands,
916        };
917
918        if let Some(existing_index) = self
919            .run_history
920            .command_blocks
921            .iter()
922            .position(|existing| existing.name == pending.name)
923        {
924            self.run_history.command_blocks[existing_index] = block;
925        } else {
926            self.run_history.command_blocks.push(block);
927        }
928
929        self.run_history.validate()?;
930        Ok(())
931    }
932
933    fn record_command(&mut self, command: &CommandHistory) {
934        if let Some(normalized) = normalize_persisted_command_history(command) {
935            self.run_history
936                .push_with_raw(normalized.command, normalized.raw_string);
937        }
938    }
939}
940
941fn integrand_signature_inventory(
942    collection: &ProcessCollection,
943    integrand_name: &str,
944) -> GraphSelectionSignatureInventory {
945    match collection {
946        ProcessCollection::Amplitudes(amplitudes) => amplitudes
947            .get(integrand_name)
948            .map(|amplitude| {
949                GraphSelectionSignatureInventory::from_master_graphs(
950                    amplitude.graph_group_structure.iter().map(|group| {
951                        let master_graph_id = group
952                            .into_iter()
953                            .next()
954                            .expect("graph group should contain a master graph");
955                        &amplitude.graphs[master_graph_id].graph
956                    }),
957                )
958            })
959            .unwrap_or_else(GraphSelectionSignatureInventory::empty),
960        ProcessCollection::CrossSections(cross_sections) => cross_sections
961            .get(integrand_name)
962            .map(|cross_section| {
963                GraphSelectionSignatureInventory::from_master_graphs(
964                    cross_section.graph_group_structure.iter().map(|group| {
965                        let master_graph_id = group
966                            .into_iter()
967                            .next()
968                            .expect("graph group should contain a master graph");
969                        &cross_section.supergraphs[master_graph_id].graph
970                    }),
971                )
972            })
973            .unwrap_or_else(GraphSelectionSignatureInventory::empty),
974    }
975}
976
977fn amplitude_graph_signature_inventory(
978    collection: &ProcessCollection,
979    integrand_name: &str,
980    model: &Model,
981    process_definition: &ProcessDefinition,
982    generation_settings: &GenerationSettings,
983) -> GraphSelectionSignatureInventory {
984    let ProcessCollection::CrossSections(cross_sections) = collection else {
985        return GraphSelectionSignatureInventory::empty();
986    };
987    cross_sections
988        .get(integrand_name)
989        .map(|cross_section| {
990            cross_section
991                .amplitude_graph_signature_inventory(model, process_definition, generation_settings)
992                .unwrap_or_else(|_| GraphSelectionSignatureInventory::empty())
993        })
994        .unwrap_or_else(GraphSelectionSignatureInventory::empty)
995}
996
997fn raised_cut_signature_inventory(
998    collection: &ProcessCollection,
999    integrand_name: &str,
1000    model: &Model,
1001    process_definition: &ProcessDefinition,
1002    generation_settings: &GenerationSettings,
1003) -> RaisedCutSignatureInventory {
1004    let ProcessCollection::CrossSections(cross_sections) = collection else {
1005        return RaisedCutSignatureInventory::empty();
1006    };
1007    cross_sections
1008        .get(integrand_name)
1009        .map(|cross_section| {
1010            cross_section
1011                .raised_cut_signature_inventory(model, process_definition, generation_settings)
1012                .unwrap_or_else(|_| RaisedCutSignatureInventory::empty())
1013        })
1014        .unwrap_or_else(RaisedCutSignatureInventory::empty)
1015}
1016
1017fn normalize_persisted_command_history(command: &CommandHistory) -> Option<CommandHistory> {
1018    match &command.command {
1019        Commands::Quit(_) | Commands::StartCommandsBlock(_) | Commands::FinishCommandsBlock => None,
1020        Commands::Run(run) => normalize_persisted_run_history(command, run),
1021        _ => Some(normalize_command_history(command)),
1022    }
1023}
1024
1025fn normalize_persisted_run_history(
1026    command: &CommandHistory,
1027    run: &crate::commands::Run,
1028) -> Option<CommandHistory> {
1029    let inline_commands = run
1030        .parse_inline_commands()
1031        .expect("persisted run commands should always have parseable inline commands");
1032    let persisted_inline_commands = inline_commands
1033        .iter()
1034        .filter_map(normalize_persisted_command_history)
1035        .collect::<Vec<_>>();
1036
1037    let persisted_run = crate::commands::Run {
1038        block_names: run.block_names.clone(),
1039        defines: run.defines.clone(),
1040        commands: (!persisted_inline_commands.is_empty()).then(|| {
1041            persisted_inline_commands
1042                .iter()
1043                .map(persisted_command_raw_string)
1044                .collect::<Vec<_>>()
1045                .join("; ")
1046        }),
1047    };
1048
1049    if persisted_run.is_noop() {
1050        return None;
1051    }
1052
1053    if persisted_run == *run {
1054        return Some(normalize_command_history(command));
1055    }
1056
1057    let raw_string = persisted_run.canonical_raw_string();
1058    Some(CommandHistory::new_with_raw(
1059        Commands::Run(persisted_run),
1060        raw_string,
1061    ))
1062}
1063
1064fn persisted_command_raw_string(command: &CommandHistory) -> String {
1065    normalize_command_history(command)
1066        .raw_string()
1067        .expect("persisted inline commands should remain representable as raw strings")
1068        .to_string()
1069}
1070
1071fn normalize_command_history(command: &CommandHistory) -> CommandHistory {
1072    let raw_string = command
1073        .raw_string()
1074        .filter(|raw| raw_round_trips(raw, &command.command))
1075        .map(str::to_string)
1076        .or_else(|| match &command.command {
1077            Commands::Run(run) => Some(run.canonical_raw_string()),
1078            _ => None,
1079        });
1080
1081    CommandHistory {
1082        command: command.command.clone(),
1083        raw_string,
1084    }
1085}
1086
1087fn raw_round_trips(raw: &str, command: &Commands) -> bool {
1088    CommandHistory::from_raw_string(raw)
1089        .map(|parsed| parsed.command == *command)
1090        .unwrap_or(false)
1091}
1092
1093pub(crate) fn display_command(command: &CommandHistory) -> String {
1094    let display = if let Some(raw_string) = normalize_command_history(command).raw_string {
1095        raw_string
1096    } else {
1097        format!("{:?}", command.command)
1098    };
1099
1100    truncate_multiline_display_command(&display)
1101}
1102
1103fn truncate_multiline_display_command(display: &str) -> String {
1104    let lines = display.lines().collect::<Vec<_>>();
1105    if lines.len() <= MAX_DISPLAY_COMMAND_LINES {
1106        return display.to_string();
1107    }
1108
1109    let mut truncated = lines[..MAX_DISPLAY_COMMAND_LINES].join("\n");
1110    truncated.push_str("\n[...]");
1111    truncated
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116    use super::{display_command, MAX_DISPLAY_COMMAND_LINES};
1117    use crate::{commands::run::Run, commands::Commands, state::CommandHistory};
1118
1119    #[test]
1120    fn display_command_preserves_multiline_commands_up_to_line_limit() {
1121        let commands = (1..=MAX_DISPLAY_COMMAND_LINES)
1122            .map(|i| format!("cmd{i}"))
1123            .collect::<Vec<_>>()
1124            .join("\n");
1125        let command = CommandHistory::new(Commands::Run(Run {
1126            block_names: Vec::new(),
1127            defines: Vec::new(),
1128            commands: Some(commands.clone()),
1129        }));
1130
1131        let displayed = display_command(&command);
1132
1133        assert_eq!(displayed.lines().count(), MAX_DISPLAY_COMMAND_LINES);
1134        assert!(displayed.contains(&commands));
1135        assert!(!displayed.contains("[...]"));
1136    }
1137
1138    #[test]
1139    fn display_command_truncates_multiline_commands_beyond_line_limit() {
1140        let command = CommandHistory::new(Commands::Run(Run {
1141            block_names: Vec::new(),
1142            defines: Vec::new(),
1143            commands: Some("cmd1\ncmd2\ncmd3\ncmd4\ncmd5\ncmd6".to_string()),
1144        }));
1145
1146        let displayed = display_command(&command);
1147        let displayed_lines = displayed.lines().collect::<Vec<_>>();
1148
1149        assert_eq!(
1150            displayed_lines,
1151            vec!["run -c 'cmd1", "cmd2", "cmd3", "cmd4", "cmd5", "[...]"]
1152        );
1153    }
1154}