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