Skip to main content

gammaloop_api/commands/
display.rs

1use clap::{Args, Subcommand, ValueEnum};
2use colored::Colorize;
3use itertools::Itertools;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use serde_json::Value as JsonValue;
7use std::{
8    ffi::OsStr,
9    fs,
10    path::{Path, PathBuf},
11};
12use tabled::{
13    builder::Builder,
14    settings::{style::HorizontalLine, themes::Theme, Style},
15    Tabled,
16};
17use tracing::info;
18use walkdir::WalkDir;
19
20use color_eyre::Result;
21use eyre::{eyre, Context};
22use gammalooprs::{
23    processes::{Amplitude, CrossSection, Process, ProcessCollection},
24    settings::RuntimeSettings,
25};
26
27use crate::{
28    commands::generate::{render_generation_summary, ProcessArgs},
29    commands::process_settings::{
30        observable_kind, quantity_kind, selector_kind, serialize_runtime_named_settings,
31        summarize_observable, summarize_quantity, summarize_selector, NamedProcessSettingKind,
32    },
33    commands::CliArgumentMetadataExt,
34    completion::CompletionArgExt,
35    integrand_info::{
36        IntegrandCutThresholdInfo, IntegrandEsurfaceClassification, IntegrandGraphGroupInfo,
37        IntegrandInfo, IntegrandKind,
38    },
39    session::display_command,
40    settings_tree::{serialize_settings_with_defaults, value_at_path},
41    state::{CommandsBlock, IntegrandGenerationSummary, ProcessRef, RunHistory, State},
42    CLISettings,
43};
44
45#[derive(Subcommand, Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
46pub enum Display {
47    /// Show model particles, parameters, couplings, and vertices for the selected context.
48    Model {
49        /// Include the model's coupling-order definitions and coupling values.
50        #[arg(short = 'c', long = "show-couplings", default_value_t = false)]
51        show_couplings: bool,
52        /// Include interaction vertices from the active model.
53        #[arg(short = 'v', long = "show-vertices", default_value_t = false)]
54        show_vertices: bool,
55        /// Include external and derived model parameters.
56        #[arg(short = 'r', long = "show-parameters", default_value_t = false)]
57        show_parameters: bool,
58        /// Include the model's particle definitions.
59        #[arg(long = "show-particles", default_value_t = false)]
60        show_particles: bool,
61        /// Include particles, parameters, couplings, and vertices in one report.
62        #[arg(short = 'a', long = "show-all", default_value_t = false)]
63        show_all: bool,
64        /// Process reference: `#<id>`, `name:<name>`, or `<id>/<name>`
65        #[arg(
66            short = 'p',
67            long = "process",
68            value_name = "PROCESS",
69            cli_requires("integrand_name"),
70            completion_process_selector(crate::completion::SelectorKind::Any)
71        )]
72        process: Option<ProcessRef>,
73        /// Integrand name inside the selected process
74        #[arg(
75            short = 'i',
76            long = "integrand-name",
77            value_name = "NAME",
78            cli_requires("process"),
79            completion_integrand_selector(crate::completion::SelectorKind::Any)
80        )]
81        integrand_name: Option<String>,
82    },
83    /// List processes currently stored in the active state.
84    Processes,
85    /// Show generated integrands, graph groups, resources, and selected detail tables.
86    #[command(name = "integrand")]
87    Integrands {
88        /// Process reference: `#<id>`, `name:<name>`, or `<id>/<name>`
89        #[arg(
90            short = 'p',
91            long = "process",
92            value_name = "PROCESS",
93            completion_process_selector(crate::completion::SelectorKind::Any)
94        )]
95        process: Option<ProcessRef>,
96        /// Integrand name inside the selected process
97        #[arg(
98            short = 'i',
99            long = "integrand-name",
100            value_name = "NAME",
101            cli_requires("process"),
102            completion_integrand_selector(crate::completion::SelectorKind::Any)
103        )]
104        integrand_name: Option<String>,
105        /// Restrict the detailed view to the selected master graph names
106        #[arg(
107            short = 'g',
108            long = "graph",
109            value_name = "MASTER_GRAPH",
110            num_args = 1..,
111            cli_requires("integrand_name"),
112            completion_selected_master_graph()
113        )]
114        graphs: Vec<String>,
115        /// Restrict the detailed view to the selected category tables
116        #[arg(
117            long = "category",
118            value_name = "CATEGORY",
119            num_args = 1..,
120            cli_requires("integrand_name"),
121            completion_selected_integrand_category()
122        )]
123        categories: Vec<IntegrandDisplayCategory>,
124        /// Hide generated threshold CTs that cannot exist for the active model and kinematics
125        #[arg(
126            long = "hide-non-existing-thresholds",
127            default_value_t = false,
128            cli_requires("integrand_name")
129        )]
130        #[serde(default)]
131        hide_non_existing_thresholds: bool,
132    },
133    /// List configured named quantities, or inspect one quantity in a selected process.
134    Quantities {
135        #[command(flatten)]
136        target: DisplayProcessNamedSettingsArgs,
137    },
138    /// List configured observables, or inspect one observable in a selected process.
139    Observables {
140        #[command(flatten)]
141        target: DisplayProcessNamedSettingsArgs,
142    },
143    /// List configured event selectors, or inspect one selector in a selected process.
144    Selectors {
145        #[command(flatten)]
146        target: DisplayProcessNamedSettingsArgs,
147    },
148    /// Show one stored command block, or list every block when no name is supplied.
149    #[command(name = "command_block")]
150    CommandBlock {
151        /// Stored command-block name; omit it to list all available blocks.
152        #[arg(value_name = "NAME")]
153        name: Option<String>,
154    },
155    /// Show effective global, default-runtime, or process-specific settings.
156    Settings {
157        #[command(subcommand)]
158        target: DisplaySettingsTarget,
159    },
160}
161
162#[derive(Args, Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
163pub struct DisplayProcessNamedSettingsArgs {
164    #[command(flatten)]
165    process: ProcessArgs,
166    /// Named quantity, observable, or selector; omit it to list the selected category.
167    #[arg(value_name = "NAME")]
168    name: Option<String>,
169}
170
171#[derive(Subcommand, Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
172pub enum DisplaySettingsTarget {
173    /// Show effective global generation, logging, and parallelization settings.
174    Global {
175        /// Optional dotted path to a specific setting
176        #[arg(value_name = "PATH")]
177        path: Option<String>,
178    },
179    /// Show the default runtime settings inherited by newly generated integrands.
180    #[command(alias = "defaults")]
181    DefaultRuntime {
182        /// Optional dotted path to a specific setting
183        #[arg(value_name = "PATH")]
184        path: Option<String>,
185    },
186    /// Show effective settings for one generated process or integrand.
187    Process {
188        #[command(flatten)]
189        process: ProcessArgs,
190        /// Optional dotted path to a specific setting
191        #[arg(value_name = "PATH")]
192        path: Option<String>,
193    },
194}
195
196impl Display {
197    pub fn run(
198        &self,
199        state: &State,
200        global_settings: &CLISettings,
201        default_runtime_settings: &RuntimeSettings,
202        run_history: &RunHistory,
203    ) -> Result<()> {
204        match self {
205            Display::Integrands {
206                process,
207                integrand_name,
208                graphs,
209                categories,
210                hide_non_existing_thresholds,
211            } => {
212                let process_id = process
213                    .as_ref()
214                    .map(|process_ref| state.resolve_process_ref(Some(process_ref)))
215                    .transpose()?;
216                if let Some(integrand_name) = integrand_name.as_deref() {
217                    render_integrand_detail(
218                        state,
219                        &global_settings.state.folder,
220                        process_id.expect("clap requires --process when --integrand-name is set"),
221                        integrand_name,
222                        graphs,
223                        categories,
224                        *hide_non_existing_thresholds,
225                    )?;
226                } else {
227                    render_integrands_table(state, &global_settings.state.folder, process_id)?;
228                }
229            }
230            Display::Processes => {
231                render_processes_table(state, &global_settings.state.folder)?;
232            }
233            Display::Quantities { target } => {
234                render_named_process_settings(state, target, NamedProcessSettingKind::Quantity)?;
235            }
236            Display::Observables { target } => {
237                render_named_process_settings(state, target, NamedProcessSettingKind::Observable)?;
238            }
239            Display::Selectors { target } => {
240                render_named_process_settings(state, target, NamedProcessSettingKind::Selector)?;
241            }
242            Display::CommandBlock { name } => {
243                render_command_blocks(run_history, name.as_deref())?;
244            }
245            Display::Model {
246                show_couplings,
247                show_vertices,
248                show_parameters,
249                show_particles,
250                show_all,
251                process,
252                integrand_name,
253            } => {
254                let model = if let (Some(process), Some(integrand_name)) =
255                    (process.as_ref(), integrand_name.as_ref())
256                {
257                    let process_id = state.resolve_process_ref(Some(process))?;
258                    state.resolve_model_for_integrand(process_id, integrand_name)?
259                } else {
260                    state.model.clone()
261                };
262                info!(
263                    "\n{}",
264                    model.get_description(
265                        *show_particles || *show_all,
266                        *show_parameters || *show_all,
267                        *show_vertices || *show_all,
268                        *show_couplings || *show_all,
269                    )
270                )
271            }
272            Display::Settings { target } => {
273                target.run(state, global_settings, default_runtime_settings)?;
274            }
275        }
276        Ok(())
277    }
278}
279
280#[derive(Clone, Debug)]
281struct IntegrandMetrics {
282    name: String,
283    graphs: usize,
284    graph_groups: usize,
285    bin_disk_size_bytes: Option<u64>,
286    so_disk_size_bytes: Option<u64>,
287}
288
289#[derive(Clone, Debug, Default)]
290struct IntegrandArtifactSizes {
291    bin_disk_size_bytes: Option<u64>,
292    so_disk_size_bytes: Option<u64>,
293}
294
295#[derive(
296    Debug, Clone, Copy, Serialize, Deserialize, ValueEnum, JsonSchema, PartialEq, Eq, Hash,
297)]
298#[serde(rename_all = "snake_case")]
299pub enum IntegrandDisplayCategory {
300    #[value(name = "generation")]
301    Generation,
302    #[value(name = "orientation")]
303    Orientation,
304    #[value(name = "loop_momentum_basis")]
305    LoopMomentumBasis,
306    #[value(name = "cuts")]
307    Cuts,
308}
309
310impl IntegrandDisplayCategory {
311    fn title(self) -> &'static str {
312        match self {
313            Self::Generation => "Generation",
314            Self::Orientation => "Orientations",
315            Self::LoopMomentumBasis => "Loop momentum bases",
316            Self::Cuts => "Cuts",
317        }
318    }
319
320    fn value_header(self) -> &'static str {
321        match self {
322            Self::Generation => "generation",
323            Self::Orientation => "orientation",
324            Self::LoopMomentumBasis => "loop momentum basis",
325            Self::Cuts => "cut",
326        }
327    }
328}
329
330#[derive(Tabled)]
331struct DetailSummaryRow {
332    field: String,
333    value: String,
334}
335
336fn orientation_column_widths(edge_ids: &[usize], fallback_count: usize) -> Vec<usize> {
337    if edge_ids.is_empty() {
338        vec![1; fallback_count]
339    } else {
340        edge_ids
341            .iter()
342            .map(|edge_id| edge_id.to_string().len().max(1))
343            .collect()
344    }
345}
346
347fn render_orientation_edge_ids(edge_ids: &[usize]) -> String {
348    if edge_ids.is_empty() {
349        return String::new();
350    }
351
352    let widths = orientation_column_widths(edge_ids, edge_ids.len());
353    edge_ids
354        .iter()
355        .zip(widths.iter())
356        .map(|(edge_id, width)| {
357            format!("{edge_id:<width$}", width = *width)
358                .magenta()
359                .to_string()
360        })
361        .join(" ")
362}
363
364fn render_orientation_signature(signature: &[i8], edge_ids: &[usize]) -> String {
365    let widths = orientation_column_widths(edge_ids, signature.len());
366    signature
367        .iter()
368        .zip(widths.iter())
369        .map(|(sign, width)| {
370            let padded = format!(
371                "{:<width$}",
372                match sign {
373                    1 => "+",
374                    -1 => "-",
375                    0 => "0",
376                    _ => "?",
377                },
378                width = *width
379            );
380            match sign {
381                1 => padded.green().bold().to_string(),
382                -1 => padded.red().bold().to_string(),
383                0 => padded.dimmed().to_string(),
384                _ => padded.to_string(),
385            }
386        })
387        .join(" ")
388}
389
390fn render_edge_ids_with_highlight(edge_ids: &[usize], highlight: bool) -> String {
391    if edge_ids.is_empty() {
392        return String::new();
393    }
394
395    let rendered = format!(
396        "({})",
397        edge_ids.iter().map(|edge_id| edge_id.to_string()).join(",")
398    );
399    if highlight {
400        rendered.green().bold().to_string()
401    } else {
402        rendered.magenta().to_string()
403    }
404}
405
406fn render_edge_ids(edge_ids: &[usize]) -> String {
407    render_edge_ids_with_highlight(edge_ids, false)
408}
409
410fn render_threshold_counterterms(
411    thresholds: &[IntegrandCutThresholdInfo],
412    hide_non_existing_thresholds: bool,
413) -> String {
414    let visible = thresholds
415        .iter()
416        .filter(|threshold| !hide_non_existing_thresholds || threshold.status.is_currently_viable())
417        .collect_vec();
418    if visible.is_empty() {
419        return "none".dimmed().to_string();
420    }
421    format!(
422        "[{}]",
423        visible
424            .iter()
425            .map(|threshold| {
426                let id = if threshold.status.can_become_pinched() {
427                    format!("{}*", threshold.esurface_id)
428                } else {
429                    threshold.esurface_id.to_string()
430                };
431                if threshold.status.is_currently_viable() {
432                    id.cyan().to_string()
433                } else {
434                    id.dimmed().to_string()
435                }
436            })
437            .join(",")
438    )
439}
440
441fn render_threshold_esurface(edge_ids: &[usize]) -> String {
442    render_edge_ids(edge_ids)
443}
444
445fn render_cut(edge_ids: &[usize], raising_power: usize) -> String {
446    let edge_ids = render_edge_ids(edge_ids);
447    if edge_ids.is_empty() || raising_power <= 1 {
448        edge_ids
449    } else {
450        format!("{edge_ids}^{raising_power}").magenta().to_string()
451    }
452}
453
454#[derive(Clone)]
455struct CategoryRow {
456    primary_id: String,
457    secondary_id: String,
458    tertiary_id: String,
459    value: String,
460}
461
462fn render_category_id(id: usize, highlight: bool) -> String {
463    let rendered = format!("#{id}");
464    if highlight {
465        rendered.green().bold().to_string()
466    } else {
467        rendered.yellow().to_string()
468    }
469}
470
471fn render_optional_category_id(id: Option<usize>, highlight: bool) -> String {
472    id.map(|id| render_category_id(id, highlight))
473        .unwrap_or_default()
474}
475
476fn master_graph(
477    group: &IntegrandGraphGroupInfo,
478) -> Result<&crate::integrand_info::IntegrandGraphInfo> {
479    group
480        .graphs
481        .iter()
482        .find(|graph| graph.is_master)
483        .ok_or_else(|| eyre!("Graph group #{} has no master graph", group.group_id))
484}
485
486fn available_integrand_categories(detail: &IntegrandInfo) -> Vec<IntegrandDisplayCategory> {
487    match detail.kind {
488        IntegrandKind::Amplitude => vec![
489            IntegrandDisplayCategory::Generation,
490            IntegrandDisplayCategory::Orientation,
491            IntegrandDisplayCategory::LoopMomentumBasis,
492            IntegrandDisplayCategory::Cuts,
493        ],
494        IntegrandKind::CrossSection => vec![
495            IntegrandDisplayCategory::Generation,
496            IntegrandDisplayCategory::Orientation,
497            IntegrandDisplayCategory::LoopMomentumBasis,
498            IntegrandDisplayCategory::Cuts,
499        ],
500    }
501}
502
503fn selected_integrand_categories(
504    detail: &IntegrandInfo,
505    requested: &[IntegrandDisplayCategory],
506) -> Result<Vec<IntegrandDisplayCategory>> {
507    let available = available_integrand_categories(detail);
508    if requested.is_empty() {
509        return Ok(available);
510    }
511
512    let mut selected = Vec::new();
513    for category in requested.iter().copied() {
514        if !available.contains(&category) {
515            return Err(eyre!(
516                "Category '{}' is not available for {} integrands.",
517                category.to_possible_value().expect("value enum").get_name(),
518                detail.kind
519            ));
520        }
521        if !selected.contains(&category) {
522            selected.push(category);
523        }
524    }
525    Ok(selected)
526}
527
528fn filtered_graph_groups<'a>(
529    detail: &'a IntegrandInfo,
530    requested_graphs: &[String],
531) -> Result<Vec<&'a IntegrandGraphGroupInfo>> {
532    if requested_graphs.is_empty() {
533        return Ok(detail.graph_groups.iter().collect());
534    }
535
536    let available_master_names = detail
537        .graph_groups
538        .iter()
539        .map(master_graph)
540        .collect::<Result<Vec<_>>>()?
541        .into_iter()
542        .map(|graph| graph.name.clone())
543        .collect::<Vec<_>>();
544    let missing = requested_graphs
545        .iter()
546        .filter(|graph_name| {
547            !available_master_names
548                .iter()
549                .any(|name| name == *graph_name)
550        })
551        .cloned()
552        .collect::<Vec<_>>();
553    if !missing.is_empty() {
554        return Err(eyre!(
555            "Unknown master graph filter(s): {}. Available master graphs: {}",
556            missing.join(", "),
557            available_master_names.join(", ")
558        ));
559    }
560
561    Ok(detail
562        .graph_groups
563        .iter()
564        .filter(|group| {
565            master_graph(group)
566                .map(|graph| requested_graphs.iter().any(|name| name == &graph.name))
567                .unwrap_or(false)
568        })
569        .collect())
570}
571
572fn render_integrand_detail(
573    state: &State,
574    state_folder: &Path,
575    process_id: usize,
576    integrand_name: &str,
577    requested_graphs: &[String],
578    requested_categories: &[IntegrandDisplayCategory],
579    hide_non_existing_thresholds: bool,
580) -> Result<()> {
581    let detail = state.get_integrand_info(
582        Some(&ProcessRef::Id(process_id)),
583        Some(&integrand_name.to_string()),
584    )?;
585    let process = &state.process_list.processes[process_id];
586    let artifact_sizes =
587        collect_integrand_artifact_sizes(state_folder, process, &detail.integrand_name)?;
588    let generation_summary = state.generation_summary(process_id, &detail.integrand_name);
589    render_integrand_detail_from_info(
590        &detail,
591        &artifact_sizes,
592        generation_summary,
593        requested_graphs,
594        requested_categories,
595        hide_non_existing_thresholds,
596    )
597}
598
599fn category_rows(
600    group: &IntegrandGraphGroupInfo,
601    category: IntegrandDisplayCategory,
602    hide_non_existing_thresholds: bool,
603) -> Vec<CategoryRow> {
604    match category {
605        IntegrandDisplayCategory::Generation => Vec::new(),
606        IntegrandDisplayCategory::Orientation => group
607            .orientations
608            .iter()
609            .map(|orientation| CategoryRow {
610                primary_id: render_category_id(orientation.orientation_id, false),
611                secondary_id: String::new(),
612                tertiary_id: String::new(),
613                value: render_orientation_signature(
614                    &orientation.signature,
615                    &group.orientation_edge_ids,
616                ),
617            })
618            .collect(),
619        IntegrandDisplayCategory::LoopMomentumBasis => group
620            .loop_momentum_bases
621            .iter()
622            .map(|basis| CategoryRow {
623                primary_id: render_category_id(basis.basis_id, basis.matches_generation_basis),
624                secondary_id: render_optional_category_id(
625                    basis.channel_id,
626                    basis.matches_generation_basis,
627                ),
628                tertiary_id: String::new(),
629                value: render_edge_ids_with_highlight(
630                    &basis.edge_ids,
631                    basis.matches_generation_basis,
632                ),
633            })
634            .collect(),
635        IntegrandDisplayCategory::Cuts => group
636            .cuts
637            .iter()
638            .map(|cut| CategoryRow {
639                primary_id: render_category_id(cut.cut_id, false),
640                secondary_id: render_threshold_counterterms(
641                    &cut.left_thresholds,
642                    hide_non_existing_thresholds,
643                ),
644                tertiary_id: render_threshold_counterterms(
645                    &cut.right_thresholds,
646                    hide_non_existing_thresholds,
647                ),
648                value: render_cut(&cut.edge_ids, cut.raising_power),
649            })
650            .collect(),
651    }
652}
653
654fn category_group_header(
655    group: &IntegrandGraphGroupInfo,
656    category: IntegrandDisplayCategory,
657) -> String {
658    match category {
659        IntegrandDisplayCategory::Orientation => {
660            render_orientation_edge_ids(&group.orientation_edge_ids)
661        }
662        IntegrandDisplayCategory::Generation
663        | IntegrandDisplayCategory::LoopMomentumBasis
664        | IntegrandDisplayCategory::Cuts => String::new(),
665    }
666}
667
668fn render_integrand_thresholds_table(
669    groups: &[&IntegrandGraphGroupInfo],
670    integrand_kind: IntegrandKind,
671) -> Result<Option<String>> {
672    let status_header = match integrand_kind {
673        IntegrandKind::Amplitude => "Classification",
674        IntegrandKind::CrossSection => "Active in cuts",
675    };
676    let mut builder = Builder::new();
677    builder.push_record([
678        "Group #".bold().blue().to_string(),
679        "Graph".bold().blue().to_string(),
680        "Esurface".bold().blue().to_string(),
681        "edges".bold().blue().to_string(),
682        status_header.bold().blue().to_string(),
683    ]);
684
685    let mut inserted_blocks = 0usize;
686    let mut separator_rows = vec![1usize];
687    let mut next_row = 1usize;
688
689    for group in groups {
690        if group.threshold_esurfaces.is_empty() {
691            continue;
692        }
693
694        let master = master_graph(group)?;
695        inserted_blocks += 1;
696        builder.push_record([
697            format!("#{}", group.group_id).blue().to_string(),
698            format!("#{} : {}", master.graph_id, master.name)
699                .green()
700                .bold()
701                .to_string(),
702            String::new(),
703            String::new(),
704            String::new(),
705        ]);
706        next_row += 1;
707        separator_rows.push(next_row);
708
709        for threshold in &group.threshold_esurfaces {
710            let representative_graph = group
711                .graphs
712                .iter()
713                .find(|graph| graph.graph_id == threshold.representative_graph_id)
714                .ok_or_else(|| {
715                    eyre!(
716                        "Threshold E-surface #{} in graph group #{} references missing representative graph #{}",
717                        threshold.esurface_id,
718                        group.group_id,
719                        threshold.representative_graph_id,
720                    )
721                })?;
722            let graph_label = if representative_graph.is_master {
723                String::new()
724            } else {
725                format!(
726                    "#{} : {}",
727                    representative_graph.graph_id, representative_graph.name
728                )
729                .yellow()
730                .to_string()
731            };
732            let status = match integrand_kind {
733                IntegrandKind::Amplitude => match threshold.classification.ok_or_else(|| {
734                    eyre!(
735                        "Amplitude threshold E-surface #{} in graph group #{} has no classification",
736                        threshold.esurface_id,
737                        group.group_id,
738                    )
739                })? {
740                    IntegrandEsurfaceClassification::Existing => {
741                        "existing".cyan().to_string()
742                    }
743                    IntegrandEsurfaceClassification::Pinched => {
744                        "pinched".yellow().to_string()
745                    }
746                    IntegrandEsurfaceClassification::NonExisting => {
747                        "non-existing".dimmed().to_string()
748                    }
749                },
750                IntegrandKind::CrossSection => {
751                    if threshold.active_cuts.is_empty() {
752                        "none".dimmed().to_string()
753                    } else {
754                        threshold
755                            .active_cuts
756                            .iter()
757                            .map(|cut| {
758                                if cut.can_become_pinched {
759                                    format!("#{}*", cut.cut_id)
760                                } else {
761                                    format!("#{}", cut.cut_id)
762                                }
763                            })
764                            .join(",")
765                            .cyan()
766                            .to_string()
767                    }
768                }
769            };
770            builder.push_record([
771                String::new(),
772                graph_label,
773                format!("#{}", threshold.esurface_id).yellow().to_string(),
774                render_threshold_esurface(&threshold.edge_ids),
775                status,
776            ]);
777            next_row += 1;
778        }
779        separator_rows.push(next_row);
780    }
781
782    if inserted_blocks == 0 {
783        return Ok(None);
784    }
785
786    let mut table = builder.build();
787    let mut style = Theme::from_style(Style::rounded().remove_horizontals());
788    let mut unique_separator_rows = separator_rows.into_iter().unique().collect_vec();
789    if let Some(last_row) = unique_separator_rows.pop() {
790        for row in unique_separator_rows {
791            style.insert_horizontal_line(
792                row,
793                HorizontalLine::new('─')
794                    .intersection('┼')
795                    .left('├')
796                    .right('┤'),
797            );
798        }
799        style.insert_horizontal_line(
800            last_row,
801            HorizontalLine::new('─')
802                .intersection('┴')
803                .left('╰')
804                .right('╯'),
805        );
806    }
807    table.with(style);
808    Ok(Some(table.to_string()))
809}
810
811fn render_integrand_category_table(
812    groups: &[&IntegrandGraphGroupInfo],
813    category: IntegrandDisplayCategory,
814    hide_non_existing_thresholds: bool,
815) -> Result<Option<String>> {
816    let mut builder = Builder::new();
817    match category {
818        IntegrandDisplayCategory::LoopMomentumBasis => {
819            builder.push_record([
820                "Group #".bold().blue().to_string(),
821                "Graph".bold().blue().to_string(),
822                "basis ID".bold().blue().to_string(),
823                "channel ID".bold().blue().to_string(),
824                category.value_header().bold().blue().to_string(),
825            ]);
826        }
827        IntegrandDisplayCategory::Cuts => {
828            builder.push_record([
829                "Group #".bold().blue().to_string(),
830                "Graph".bold().blue().to_string(),
831                "ID".bold().blue().to_string(),
832                category.value_header().bold().blue().to_string(),
833                "left thresholds".bold().blue().to_string(),
834                "right thresholds".bold().blue().to_string(),
835            ]);
836        }
837        _ => {
838            builder.push_record([
839                "Group #".bold().blue().to_string(),
840                "Graph".bold().blue().to_string(),
841                "ID".bold().blue().to_string(),
842                category.value_header().bold().blue().to_string(),
843            ]);
844        }
845    }
846
847    let mut inserted_blocks = 0usize;
848    let mut separator_rows = vec![1usize];
849    let mut next_row = 1usize;
850
851    for group in groups {
852        let rows = category_rows(group, category, hide_non_existing_thresholds);
853        if rows.is_empty() {
854            continue;
855        }
856
857        let master = master_graph(group)?;
858        inserted_blocks += 1;
859        match category {
860            IntegrandDisplayCategory::LoopMomentumBasis => {
861                builder.push_record([
862                    format!("#{}", group.group_id).blue().to_string(),
863                    format!("#{} : {}", master.graph_id, master.name)
864                        .green()
865                        .bold()
866                        .to_string(),
867                    String::new(),
868                    String::new(),
869                    category_group_header(group, category),
870                ]);
871            }
872            IntegrandDisplayCategory::Cuts => {
873                builder.push_record([
874                    format!("#{}", group.group_id).blue().to_string(),
875                    format!("#{} : {}", master.graph_id, master.name)
876                        .green()
877                        .bold()
878                        .to_string(),
879                    String::new(),
880                    category_group_header(group, category),
881                    String::new(),
882                    String::new(),
883                ]);
884            }
885            _ => {
886                builder.push_record([
887                    format!("#{}", group.group_id).blue().to_string(),
888                    format!("#{} : {}", master.graph_id, master.name)
889                        .green()
890                        .bold()
891                        .to_string(),
892                    String::new(),
893                    category_group_header(group, category),
894                ]);
895            }
896        }
897        next_row += 1;
898        separator_rows.push(next_row);
899
900        for row in rows {
901            match category {
902                IntegrandDisplayCategory::LoopMomentumBasis => {
903                    builder.push_record([
904                        String::new(),
905                        String::new(),
906                        row.primary_id,
907                        row.secondary_id,
908                        row.value,
909                    ]);
910                }
911                IntegrandDisplayCategory::Cuts => {
912                    builder.push_record([
913                        String::new(),
914                        String::new(),
915                        row.primary_id,
916                        row.value,
917                        row.secondary_id,
918                        row.tertiary_id,
919                    ]);
920                }
921                _ => {
922                    builder.push_record([String::new(), String::new(), row.primary_id, row.value]);
923                }
924            }
925            next_row += 1;
926        }
927        separator_rows.push(next_row);
928    }
929
930    if inserted_blocks == 0 {
931        return Ok(None);
932    }
933
934    let mut table = builder.build();
935    let mut style = Theme::from_style(Style::rounded().remove_horizontals());
936    let mut unique_separator_rows = separator_rows.into_iter().unique().collect_vec();
937    if let Some(last_row) = unique_separator_rows.pop() {
938        for row in unique_separator_rows {
939            style.insert_horizontal_line(
940                row,
941                HorizontalLine::new('─')
942                    .intersection('┼')
943                    .left('├')
944                    .right('┤'),
945            );
946        }
947        style.insert_horizontal_line(
948            last_row,
949            HorizontalLine::new('─')
950                .intersection('┴')
951                .left('╰')
952                .right('╯'),
953        );
954    }
955    table.with(style);
956    Ok(Some(table.to_string()))
957}
958
959fn render_generation_category(summary: Option<&IntegrandGenerationSummary>) -> String {
960    summary
961        .and_then(|summary| {
962            render_generation_summary(&summary.reports, summary.peak_ram_bytes, None, None)
963        })
964        .unwrap_or_else(|| {
965            "No generation summary has been recorded for this integrand."
966                .yellow()
967                .to_string()
968        })
969}
970
971fn render_integrand_detail_from_info(
972    detail: &IntegrandInfo,
973    artifact_sizes: &IntegrandArtifactSizes,
974    generation_summary: Option<&IntegrandGenerationSummary>,
975    requested_graphs: &[String],
976    requested_categories: &[IntegrandDisplayCategory],
977    hide_non_existing_thresholds: bool,
978) -> Result<()> {
979    info!(
980        "{}",
981        format!(
982            "Integrand '{}' for process #{} ({})",
983            detail.integrand_name, detail.process_id, detail.process_name
984        )
985        .bold()
986        .blue()
987    );
988
989    let summary_rows = vec![
990        DetailSummaryRow {
991            field: "kind".to_string(),
992            value: detail.kind.to_string().yellow().to_string(),
993        },
994        DetailSummaryRow {
995            field: "compile enabled".to_string(),
996            value: detail
997                .generation_compilation
998                .compile_enabled()
999                .to_string()
1000                .yellow()
1001                .to_string(),
1002        },
1003        DetailSummaryRow {
1004            field: "generation backend".to_string(),
1005            value: detail
1006                .generation_compilation
1007                .active_backend_name()
1008                .yellow()
1009                .to_string(),
1010        },
1011        DetailSummaryRow {
1012            field: "generation compile options".to_string(),
1013            value: detail
1014                .generation_compilation
1015                .external_options()
1016                .map(ToString::to_string)
1017                .unwrap_or_else(|| "(none)".to_string())
1018                .magenta()
1019                .to_string(),
1020        },
1021        DetailSummaryRow {
1022            field: "active f64 backend".to_string(),
1023            value: detail.active_f64_backend.to_string().yellow().to_string(),
1024        },
1025        DetailSummaryRow {
1026            field: "graphs".to_string(),
1027            value: detail.graph_count.to_string().yellow().to_string(),
1028        },
1029        DetailSummaryRow {
1030            field: "graph groups".to_string(),
1031            value: detail.graph_group_count.to_string().yellow().to_string(),
1032        },
1033        DetailSummaryRow {
1034            field: ".bin size".to_string(),
1035            value: format_artifact_size(artifact_sizes.bin_disk_size_bytes),
1036        },
1037        DetailSummaryRow {
1038            field: ".so size".to_string(),
1039            value: format_artifact_size(artifact_sizes.so_disk_size_bytes),
1040        },
1041    ];
1042    let mut summary_table = tabled::Table::new(summary_rows);
1043    summary_table.with(Style::rounded());
1044    info!(
1045        "
1046{summary_table}"
1047    );
1048
1049    let groups = filtered_graph_groups(detail, requested_graphs)?;
1050    let categories = selected_integrand_categories(detail, requested_categories)?;
1051    for category in categories {
1052        if category == IntegrandDisplayCategory::Generation {
1053            let table = render_generation_category(generation_summary);
1054            info!(
1055                "
1056{}
1057{table}",
1058                category.title().bold().blue()
1059            );
1060            continue;
1061        }
1062
1063        if let Some(table) =
1064            render_integrand_category_table(&groups, category, hide_non_existing_thresholds)?
1065        {
1066            info!(
1067                "
1068{}
1069{table}",
1070                category.title().bold().blue()
1071            );
1072        }
1073
1074        if category == IntegrandDisplayCategory::Cuts {
1075            if let Some(table) = render_integrand_thresholds_table(&groups, detail.kind)? {
1076                info!(
1077                    "
1078{}
1079{table}",
1080                    "Threshold esurfaces".bold().blue()
1081                );
1082            }
1083        }
1084    }
1085
1086    Ok(())
1087}
1088
1089fn render_processes_table(state: &State, state_folder: &Path) -> Result<()> {
1090    if state.process_list.processes.is_empty() {
1091        info!("{}", "No processes generated yet.".yellow());
1092        return Ok(());
1093    }
1094
1095    let mut builder = Builder::new();
1096    builder.push_record([
1097        "process #".bold().blue().to_string(),
1098        "process".bold().blue().to_string(),
1099        "# integrands".bold().blue().to_string(),
1100        "integrand names".bold().blue().to_string(),
1101        "graphs / integrand".bold().blue().to_string(),
1102        "graph groups / integrand".bold().blue().to_string(),
1103        ".bin size / integrand".bold().blue().to_string(),
1104        ".so size / integrand".bold().blue().to_string(),
1105    ]);
1106
1107    for process in &state.process_list.processes {
1108        let metrics = collect_integrand_metrics(state_folder, process)?;
1109        builder.push_record([
1110            format!("#{}", process.definition.process_id)
1111                .blue()
1112                .to_string(),
1113            process
1114                .definition
1115                .folder_name
1116                .clone()
1117                .green()
1118                .bold()
1119                .to_string(),
1120            metrics.len().to_string().yellow().to_string(),
1121            format_integrand_names(&metrics),
1122            format_metrics_column(&metrics, |metric| metric.graphs.to_string()),
1123            format_metrics_column(&metrics, |metric| metric.graph_groups.to_string()),
1124            format_metrics_column(&metrics, |metric| {
1125                format_artifact_size(metric.bin_disk_size_bytes)
1126            }),
1127            format_metrics_column(&metrics, |metric| {
1128                format_artifact_size(metric.so_disk_size_bytes)
1129            }),
1130        ]);
1131    }
1132
1133    let mut table = builder.build();
1134    table.with(Style::rounded());
1135    info!("{}", "Processes".bold().blue());
1136    info!("\n{table}");
1137    Ok(())
1138}
1139
1140fn render_integrands_table(
1141    state: &State,
1142    state_folder: &Path,
1143    process_filter: Option<usize>,
1144) -> Result<()> {
1145    if state.process_list.processes.is_empty() {
1146        info!("{}", "No processes generated yet.".yellow());
1147        return Ok(());
1148    }
1149
1150    let mut builder = Builder::new();
1151    builder.push_record([
1152        "process #".bold().blue().to_string(),
1153        "process".bold().blue().to_string(),
1154        "integrand".bold().blue().to_string(),
1155        "graphs".bold().blue().to_string(),
1156        "graph groups".bold().blue().to_string(),
1157        ".bin size".bold().blue().to_string(),
1158        ".so size".bold().blue().to_string(),
1159    ]);
1160
1161    let mut num_rows = 0usize;
1162    for (process_index, process) in state.process_list.processes.iter().enumerate() {
1163        if process_filter.is_some_and(|selected| selected != process_index) {
1164            continue;
1165        }
1166        let metrics = collect_integrand_metrics(state_folder, process)?;
1167        for metric in metrics {
1168            num_rows += 1;
1169            builder.push_record([
1170                format!("#{}", process.definition.process_id)
1171                    .blue()
1172                    .to_string(),
1173                process
1174                    .definition
1175                    .folder_name
1176                    .clone()
1177                    .green()
1178                    .bold()
1179                    .to_string(),
1180                metric.name.cyan().to_string(),
1181                metric.graphs.to_string().yellow().to_string(),
1182                metric.graph_groups.to_string().yellow().to_string(),
1183                format_artifact_size(metric.bin_disk_size_bytes),
1184                format_artifact_size(metric.so_disk_size_bytes),
1185            ]);
1186        }
1187    }
1188
1189    if num_rows == 0 {
1190        if let Some(process_id) = process_filter {
1191            let process = &state.process_list.processes[process_id];
1192            info!(
1193                "{}",
1194                format!(
1195                    "No integrands found for process #{} ({})",
1196                    process.definition.process_id, process.definition.folder_name
1197                )
1198                .yellow()
1199            );
1200        } else {
1201            info!("{}", "No integrands found.".yellow());
1202        }
1203        return Ok(());
1204    }
1205
1206    let mut table = builder.build();
1207    table.with(Style::rounded());
1208
1209    if let Some(process_id) = process_filter {
1210        let process = &state.process_list.processes[process_id];
1211        info!(
1212            "{}",
1213            format!(
1214                "Integrands for process #{} ({})",
1215                process.definition.process_id, process.definition.folder_name
1216            )
1217            .bold()
1218            .blue()
1219        );
1220    } else {
1221        info!("{}", "Integrands".bold().blue());
1222    }
1223    info!("\n{table}");
1224    Ok(())
1225}
1226
1227fn collect_integrand_metrics(
1228    state_folder: &Path,
1229    process: &Process,
1230) -> Result<Vec<IntegrandMetrics>> {
1231    match &process.collection {
1232        ProcessCollection::Amplitudes(amplitudes) => amplitudes
1233            .values()
1234            .map(|amplitude| integrand_metrics_from_amplitude(state_folder, process, amplitude))
1235            .collect(),
1236        ProcessCollection::CrossSections(cross_sections) => cross_sections
1237            .values()
1238            .map(|cross_section| {
1239                integrand_metrics_from_cross_section(state_folder, process, cross_section)
1240            })
1241            .collect(),
1242    }
1243}
1244
1245fn integrand_metrics_from_amplitude(
1246    state_folder: &Path,
1247    process: &Process,
1248    amplitude: &Amplitude,
1249) -> Result<IntegrandMetrics> {
1250    let artifact_sizes = collect_integrand_artifact_sizes(state_folder, process, &amplitude.name)?;
1251    Ok(IntegrandMetrics {
1252        name: amplitude.name.clone(),
1253        graphs: amplitude.graphs.len(),
1254        graph_groups: amplitude.graph_group_structure.len(),
1255        bin_disk_size_bytes: artifact_sizes.bin_disk_size_bytes,
1256        so_disk_size_bytes: artifact_sizes.so_disk_size_bytes,
1257    })
1258}
1259
1260fn integrand_metrics_from_cross_section(
1261    state_folder: &Path,
1262    process: &Process,
1263    cross_section: &CrossSection,
1264) -> Result<IntegrandMetrics> {
1265    let artifact_sizes =
1266        collect_integrand_artifact_sizes(state_folder, process, &cross_section.name)?;
1267    Ok(IntegrandMetrics {
1268        name: cross_section.name.clone(),
1269        graphs: cross_section.supergraphs.len(),
1270        graph_groups: cross_section.graph_group_structure.len(),
1271        bin_disk_size_bytes: artifact_sizes.bin_disk_size_bytes,
1272        so_disk_size_bytes: artifact_sizes.so_disk_size_bytes,
1273    })
1274}
1275
1276fn collect_integrand_artifact_sizes(
1277    state_folder: &Path,
1278    process: &Process,
1279    integrand_name: &str,
1280) -> Result<IntegrandArtifactSizes> {
1281    let artifact_root = integrand_artifact_root(state_folder, process, integrand_name);
1282    Ok(IntegrandArtifactSizes {
1283        bin_disk_size_bytes: disk_usage_for_extension(&artifact_root, "bin")?,
1284        so_disk_size_bytes: disk_usage_for_extension(&artifact_root, "so")?,
1285    })
1286}
1287
1288fn integrand_artifact_root(
1289    state_folder: &Path,
1290    process: &Process,
1291    integrand_name: &str,
1292) -> PathBuf {
1293    let collection_dir = match &process.collection {
1294        ProcessCollection::Amplitudes(_) => "amplitudes",
1295        ProcessCollection::CrossSections(_) => "cross_sections",
1296    };
1297    state_folder
1298        .join("processes")
1299        .join(collection_dir)
1300        .join(&process.definition.folder_name)
1301        .join(integrand_name)
1302}
1303
1304fn disk_usage_for_extension(root: &Path, extension: &str) -> Result<Option<u64>> {
1305    if !root.try_exists().with_context(|| {
1306        format!(
1307            "While checking saved integrand artifacts under {}",
1308            root.display()
1309        )
1310    })? {
1311        return Ok(None);
1312    }
1313
1314    let mut total = 0u64;
1315    for entry in WalkDir::new(root) {
1316        let entry = entry.with_context(|| {
1317            format!(
1318                "While walking saved integrand artifacts under {}",
1319                root.display()
1320            )
1321        })?;
1322        if !entry.file_type().is_file() || entry.path().extension() != Some(OsStr::new(extension)) {
1323            continue;
1324        }
1325
1326        let metadata = entry
1327            .metadata()
1328            .with_context(|| format!("While reading metadata for {}", entry.path().display()))?;
1329        total = total
1330            .checked_add(file_disk_usage_bytes(&metadata))
1331            .ok_or_else(|| {
1332                eyre!(
1333                    "Artifact size overflow while summing .{} files under {}",
1334                    extension,
1335                    root.display()
1336                )
1337            })?;
1338    }
1339    Ok(Some(total))
1340}
1341
1342fn file_disk_usage_bytes(metadata: &fs::Metadata) -> u64 {
1343    #[cfg(unix)]
1344    {
1345        use std::os::unix::fs::MetadataExt;
1346        metadata.blocks().saturating_mul(512)
1347    }
1348
1349    #[cfg(not(unix))]
1350    {
1351        metadata.len()
1352    }
1353}
1354
1355fn format_integrand_names(metrics: &[IntegrandMetrics]) -> String {
1356    if metrics.is_empty() {
1357        return "(none)".dimmed().to_string();
1358    }
1359    metrics
1360        .iter()
1361        .map(|metric| metric.name.clone().cyan().to_string())
1362        .collect::<Vec<_>>()
1363        .join("\n")
1364}
1365
1366fn format_metrics_column(
1367    metrics: &[IntegrandMetrics],
1368    format_value: impl Fn(&IntegrandMetrics) -> String,
1369) -> String {
1370    if metrics.is_empty() {
1371        return "(none)".dimmed().to_string();
1372    }
1373    metrics
1374        .iter()
1375        .map(|metric| format!("{}: {}", metric.name.cyan(), format_value(metric)))
1376        .collect::<Vec<_>>()
1377        .join("\n")
1378}
1379
1380fn format_artifact_size(size: Option<u64>) -> String {
1381    size.map(|size| format_bytes(size).magenta().to_string())
1382        .unwrap_or_else(|| "(not saved)".dimmed().to_string())
1383}
1384
1385fn format_bytes(size: u64) -> String {
1386    const KIB: u64 = 1024;
1387    const MIB: u64 = KIB * 1024;
1388    const GIB: u64 = MIB * 1024;
1389
1390    if size < KIB {
1391        format!("{size} B")
1392    } else if size < MIB {
1393        format!("{:.2} KiB", size as f64 / KIB as f64)
1394    } else if size < GIB {
1395        format!("{:.2} MiB", size as f64 / MIB as f64)
1396    } else {
1397        format!("{:.2} GiB", size as f64 / GIB as f64)
1398    }
1399}
1400
1401fn command_block_contents(block: &CommandsBlock) -> String {
1402    if block.commands.is_empty() {
1403        return "(empty)".to_string();
1404    }
1405
1406    block
1407        .commands
1408        .iter()
1409        .map(display_command)
1410        .collect::<Vec<_>>()
1411        .join("\n")
1412}
1413
1414fn render_command_blocks(run_history: &RunHistory, selected_name: Option<&str>) -> Result<()> {
1415    if let Some(name) = selected_name {
1416        let block = run_history.command_block(name).ok_or_else(|| {
1417            eyre!(
1418                "Unknown command block '{}'. Available command blocks: {}",
1419                name,
1420                run_history
1421                    .command_blocks
1422                    .iter()
1423                    .map(|block| block.name.as_str())
1424                    .collect::<Vec<_>>()
1425                    .join(", ")
1426            )
1427        })?;
1428        info!("\n{}", command_block_contents(block));
1429        return Ok(());
1430    }
1431
1432    if run_history.command_blocks.is_empty() {
1433        info!("{}", "No active command blocks.".yellow());
1434        return Ok(());
1435    }
1436
1437    info!("{}", "Command blocks".bold().blue());
1438    info!("\n{}", render_command_blocks_table(run_history));
1439    Ok(())
1440}
1441
1442fn render_command_blocks_table(run_history: &RunHistory) -> String {
1443    let mut builder = Builder::new();
1444    builder.push_record([
1445        "name".bold().blue().to_string(),
1446        "commands".bold().blue().to_string(),
1447    ]);
1448
1449    for block in &run_history.command_blocks {
1450        builder.push_record([
1451            block.name.green().bold().to_string(),
1452            command_block_contents(block),
1453        ]);
1454    }
1455
1456    let mut table = builder.build();
1457    let mut style = Theme::from_style(Style::rounded().remove_horizontals());
1458    for row in 1..=run_history.command_blocks.len() {
1459        style.insert_horizontal_line(
1460            row,
1461            HorizontalLine::new('─')
1462                .intersection('┼')
1463                .left('├')
1464                .right('┤'),
1465        );
1466    }
1467    table.with(style);
1468    table.to_string()
1469}
1470
1471fn render_named_process_settings(
1472    state: &State,
1473    target: &DisplayProcessNamedSettingsArgs,
1474    setting_kind: NamedProcessSettingKind,
1475) -> Result<()> {
1476    let process_id = state.resolve_process_ref(target.process.process.as_ref())?;
1477    let process = &state.process_list.processes[process_id];
1478    let integrand_names = selected_integrand_names(process, target.process.integrand_name.as_ref());
1479
1480    if let Some(name) = target.name.as_deref() {
1481        return render_named_process_setting_detail(
1482            state,
1483            process_id,
1484            &process.definition.folder_name,
1485            process.definition.process_id,
1486            &integrand_names,
1487            setting_kind,
1488            name,
1489        );
1490    }
1491
1492    let mut builder = Builder::new();
1493    let show_integrand = integrand_names.len() > 1;
1494    let mut header = Vec::new();
1495    if show_integrand {
1496        header.push("integrand".bold().blue().to_string());
1497    }
1498    header.push("name".bold().blue().to_string());
1499    header.push("kind".bold().blue().to_string());
1500    header.push("details".bold().blue().to_string());
1501    builder.push_record(header);
1502
1503    let mut num_rows = 0usize;
1504    for integrand_name in &integrand_names {
1505        let integrand = state
1506            .process_list
1507            .get_integrand(process_id, integrand_name)?
1508            .require_generated()?;
1509        match setting_kind {
1510            NamedProcessSettingKind::Quantity => {
1511                for (name, settings) in &integrand.get_settings().quantities {
1512                    num_rows += 1;
1513                    let mut record = Vec::new();
1514                    if show_integrand {
1515                        record.push(integrand_name.clone().cyan().to_string());
1516                    }
1517                    record.push(name.clone().green().to_string());
1518                    record.push(quantity_kind(settings).yellow().to_string());
1519                    record.push(summarize_quantity(settings));
1520                    builder.push_record(record);
1521                }
1522            }
1523            NamedProcessSettingKind::Observable => {
1524                for (name, settings) in &integrand.get_settings().observables {
1525                    num_rows += 1;
1526                    let mut record = Vec::new();
1527                    if show_integrand {
1528                        record.push(integrand_name.clone().cyan().to_string());
1529                    }
1530                    record.push(name.clone().green().to_string());
1531                    record.push(observable_kind(settings).yellow().to_string());
1532                    record.push(summarize_observable(settings));
1533                    builder.push_record(record);
1534                }
1535            }
1536            NamedProcessSettingKind::Selector => {
1537                for (name, settings) in &integrand.get_settings().selectors {
1538                    num_rows += 1;
1539                    let mut record = Vec::new();
1540                    if show_integrand {
1541                        record.push(integrand_name.clone().cyan().to_string());
1542                    }
1543                    record.push(name.clone().green().to_string());
1544                    record.push(selector_kind(settings).yellow().to_string());
1545                    record.push(summarize_selector(settings));
1546                    builder.push_record(record);
1547                }
1548            }
1549        }
1550    }
1551
1552    if num_rows == 0 {
1553        info!(
1554            "{}",
1555            format!(
1556                "No {} configured for process #{} ({})",
1557                setting_kind.plural(),
1558                process.definition.process_id,
1559                process.definition.folder_name
1560            )
1561            .yellow()
1562        );
1563        return Ok(());
1564    }
1565
1566    let mut table = builder.build();
1567    table.with(Style::rounded());
1568    let title = if show_integrand {
1569        format!(
1570            "{} for process #{} ({})",
1571            setting_kind.plural(),
1572            process.definition.process_id,
1573            process.definition.folder_name
1574        )
1575    } else {
1576        format!(
1577            "{} for process #{} ({}) integrand '{}'",
1578            setting_kind.plural(),
1579            process.definition.process_id,
1580            process.definition.folder_name,
1581            integrand_names[0]
1582        )
1583    };
1584    info!("{}", title.bold().blue());
1585    info!("\n{table}");
1586    Ok(())
1587}
1588
1589fn render_named_process_setting_detail(
1590    state: &State,
1591    process_id: usize,
1592    process_name: &str,
1593    process_display_id: usize,
1594    integrand_names: &[String],
1595    setting_kind: NamedProcessSettingKind,
1596    name: &str,
1597) -> Result<()> {
1598    let mut selected_roots = Vec::new();
1599    let mut missing_integrands = Vec::new();
1600
1601    for integrand_name in integrand_names {
1602        let integrand = state
1603            .process_list
1604            .get_integrand(process_id, integrand_name)?
1605            .require_generated()?;
1606        let serialized = serialize_runtime_named_settings(integrand.get_settings())?;
1607        let map = match setting_kind {
1608            NamedProcessSettingKind::Quantity => &serialized.quantities,
1609            NamedProcessSettingKind::Observable => &serialized.observables,
1610            NamedProcessSettingKind::Selector => &serialized.selectors,
1611        };
1612        if let Some(root) = map.get(name) {
1613            selected_roots.push((integrand_name.clone(), root.clone()));
1614        } else {
1615            missing_integrands.push(integrand_name.clone());
1616        }
1617    }
1618
1619    if selected_roots.is_empty() {
1620        return Err(eyre!(
1621            "No {} named '{}' found for process #{} ({})",
1622            setting_kind.singular(),
1623            name,
1624            process_display_id,
1625            process_name
1626        ));
1627    }
1628
1629    if !missing_integrands.is_empty() {
1630        return Err(eyre!(
1631            "{} '{}' is missing from integrand(s): {}",
1632            setting_kind.singular(),
1633            name,
1634            missing_integrands.join(", ")
1635        ));
1636    }
1637
1638    for (integrand_name, root) in selected_roots {
1639        render_settings_table(
1640            &format!(
1641                "{} '{}' for process #{} ({}) integrand '{}'",
1642                setting_kind.singular(),
1643                name,
1644                process_display_id,
1645                process_name,
1646                integrand_name
1647            ),
1648            &root,
1649            None,
1650        )?;
1651    }
1652
1653    Ok(())
1654}
1655
1656fn selected_integrand_names(process: &Process, requested: Option<&String>) -> Vec<String> {
1657    if let Some(integrand_name) = requested {
1658        vec![integrand_name.clone()]
1659    } else {
1660        process
1661            .get_integrand_names()
1662            .into_iter()
1663            .map(str::to_string)
1664            .collect()
1665    }
1666}
1667
1668impl DisplaySettingsTarget {
1669    fn run(
1670        &self,
1671        state: &State,
1672        global_settings: &CLISettings,
1673        default_runtime_settings: &RuntimeSettings,
1674    ) -> Result<()> {
1675        match self {
1676            DisplaySettingsTarget::Global { path } => {
1677                let root = serialize_settings_with_defaults(
1678                    global_settings,
1679                    "global settings for display",
1680                )?;
1681                render_settings_table("global settings", &root, path.as_deref())?;
1682            }
1683            DisplaySettingsTarget::DefaultRuntime { path } => {
1684                let root = serialize_settings_with_defaults(
1685                    default_runtime_settings,
1686                    "default runtime settings for display",
1687                )?;
1688                render_settings_table("default runtime settings", &root, path.as_deref())?;
1689            }
1690            DisplaySettingsTarget::Process { process, path } => {
1691                let process_id = state.resolve_process_ref(process.process.as_ref())?;
1692                let process_ref = &state.process_list.processes[process_id];
1693                if let Some(name) = &process.integrand_name {
1694                    let integrand = state
1695                        .process_list
1696                        .get_integrand(process_id, name)?
1697                        .require_generated()?;
1698                    let settings = serialize_settings_with_defaults(
1699                        integrand.get_settings(),
1700                        "process runtime settings for display",
1701                    )?;
1702                    render_settings_table(
1703                        &format!(
1704                            "process settings for #{} ({}) integrand '{}'",
1705                            process_ref.definition.process_id,
1706                            process_ref.definition.folder_name,
1707                            name
1708                        ),
1709                        &settings,
1710                        path.as_deref(),
1711                    )?;
1712                } else {
1713                    for integrand_name in process_ref.get_integrand_names() {
1714                        let integrand = state
1715                            .process_list
1716                            .get_integrand(process_id, integrand_name)?
1717                            .require_generated()?;
1718                        let settings = serialize_settings_with_defaults(
1719                            integrand.get_settings(),
1720                            "process runtime settings for display",
1721                        )?;
1722                        render_settings_table(
1723                            &format!(
1724                                "process settings for #{} ({}) integrand '{}'",
1725                                process_ref.definition.process_id,
1726                                process_ref.definition.folder_name,
1727                                integrand_name
1728                            ),
1729                            &settings,
1730                            path.as_deref(),
1731                        )?;
1732                    }
1733                }
1734            }
1735        }
1736        Ok(())
1737    }
1738}
1739
1740fn render_settings_table(title: &str, root: &JsonValue, key_path: Option<&str>) -> Result<()> {
1741    let key_path = key_path.map(str::trim).filter(|path| !path.is_empty());
1742    let selected = if let Some(path) = key_path {
1743        value_at_path(root, path)?
1744    } else {
1745        root
1746    };
1747    let mut builder = Builder::new();
1748    builder.push_record(["key", "value"]);
1749
1750    match selected {
1751        JsonValue::Object(map) => {
1752            let mut keys: Vec<_> = map.keys().cloned().collect();
1753            keys.sort();
1754            for key in keys {
1755                let value = map
1756                    .get(&key)
1757                    .expect("sorted keys were derived from the same map");
1758                builder.push_record([key, format_json_value(value)]);
1759            }
1760        }
1761        JsonValue::Array(array) => {
1762            if array.is_empty() {
1763                builder.push_record(["(empty)".to_string(), "[]".to_string()]);
1764            } else {
1765                for (index, value) in array.iter().enumerate() {
1766                    builder.push_record([index.to_string(), format_json_value(value)]);
1767                }
1768            }
1769        }
1770        primitive => {
1771            builder.push_record(["value".to_string(), format_json_value(primitive)]);
1772        }
1773    }
1774
1775    let mut table = builder.build();
1776    table.with(Style::rounded());
1777    if let Some(path) = key_path {
1778        info!("{title} (path: {path}):");
1779    } else {
1780        info!("{title}:");
1781    }
1782    info!("\n{table}");
1783    Ok(())
1784}
1785
1786fn format_json_value(value: &JsonValue) -> String {
1787    match value {
1788        JsonValue::Null => "null".to_string(),
1789        JsonValue::Bool(_) | JsonValue::Number(_) => value.to_string(),
1790        JsonValue::String(s) => s.clone(),
1791        _ => serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()),
1792    }
1793}
1794
1795#[cfg(test)]
1796mod test {
1797    use clap::Parser;
1798    use serde_json::json;
1799
1800    use crate::{
1801        commands::Commands,
1802        integrand_info::{
1803            IntegrandActiveThresholdCutInfo, IntegrandCutThresholdInfo,
1804            IntegrandEsurfaceClassification, IntegrandGraphGroupInfo, IntegrandGraphInfo,
1805            IntegrandKind, IntegrandThresholdEsurfaceInfo, IntegrandThresholdStatus,
1806        },
1807        state::{CommandHistory, CommandsBlock, ProcessRef, RunHistory},
1808        CLISettings, Repl,
1809    };
1810    use gammalooprs::settings::RuntimeSettings;
1811
1812    use super::{
1813        command_block_contents, format_bytes, render_command_blocks_table,
1814        render_integrand_thresholds_table, render_threshold_counterterms,
1815        serialize_settings_with_defaults, value_at_path, Display, DisplaySettingsTarget,
1816        IntegrandDisplayCategory,
1817    };
1818
1819    #[test]
1820    fn threshold_rendering_dims_or_hides_non_existing_associations() {
1821        let thresholds = vec![
1822            IntegrandCutThresholdInfo {
1823                esurface_id: 71,
1824                status: IntegrandThresholdStatus::CanBecomePinched,
1825                cut_boundary_edge_ids: vec![1, 2],
1826                threshold_boundary_edge_ids: vec![3, 4],
1827                invariant_bound_is_applicable: true,
1828            },
1829            IntegrandCutThresholdInfo {
1830                esurface_id: 83,
1831                status: IntegrandThresholdStatus::ProvenNonExisting,
1832                cut_boundary_edge_ids: vec![1, 2],
1833                threshold_boundary_edge_ids: vec![5],
1834                invariant_bound_is_applicable: true,
1835            },
1836        ];
1837
1838        let shown = render_threshold_counterterms(&thresholds, false);
1839        assert!(shown.contains("71*"));
1840        assert!(shown.contains("83"));
1841
1842        let hidden = render_threshold_counterterms(&thresholds, true);
1843        assert!(hidden.contains("71*"));
1844        assert!(!hidden.contains("83"));
1845    }
1846
1847    #[test]
1848    fn threshold_inventory_lists_active_cut_and_pinch_marker() {
1849        let group = IntegrandGraphGroupInfo {
1850            group_id: 0,
1851            graphs: vec![IntegrandGraphInfo {
1852                graph_id: 0,
1853                name: "generic".to_string(),
1854                is_master: true,
1855            }],
1856            orientation_edge_ids: Vec::new(),
1857            orientations: Vec::new(),
1858            loop_momentum_bases: Vec::new(),
1859            threshold_esurface_ids: vec![5],
1860            threshold_esurfaces: vec![IntegrandThresholdEsurfaceInfo {
1861                esurface_id: 5,
1862                representative_graph_id: 0,
1863                edge_ids: vec![1, 2],
1864                classification: None,
1865                active_cuts: vec![IntegrandActiveThresholdCutInfo {
1866                    cut_id: 7,
1867                    can_become_pinched: true,
1868                }],
1869            }],
1870            cuts: Vec::new(),
1871        };
1872
1873        let rendered = render_integrand_thresholds_table(&[&group], IntegrandKind::CrossSection)
1874            .unwrap()
1875            .unwrap();
1876        assert!(rendered.contains("Active in cuts"));
1877        assert!(rendered.contains("#7*"));
1878    }
1879
1880    #[test]
1881    fn amplitude_threshold_inventory_reports_source_graph_and_classification() {
1882        let group = IntegrandGraphGroupInfo {
1883            group_id: 0,
1884            graphs: vec![
1885                IntegrandGraphInfo {
1886                    graph_id: 0,
1887                    name: "master".to_string(),
1888                    is_master: true,
1889                },
1890                IntegrandGraphInfo {
1891                    graph_id: 1,
1892                    name: "member".to_string(),
1893                    is_master: false,
1894                },
1895            ],
1896            orientation_edge_ids: Vec::new(),
1897            orientations: Vec::new(),
1898            loop_momentum_bases: Vec::new(),
1899            threshold_esurface_ids: vec![5],
1900            threshold_esurfaces: vec![IntegrandThresholdEsurfaceInfo {
1901                esurface_id: 5,
1902                representative_graph_id: 1,
1903                edge_ids: vec![1, 2],
1904                classification: Some(IntegrandEsurfaceClassification::Pinched),
1905                active_cuts: Vec::new(),
1906            }],
1907            cuts: Vec::new(),
1908        };
1909
1910        let rendered = render_integrand_thresholds_table(&[&group], IntegrandKind::Amplitude)
1911            .unwrap()
1912            .unwrap();
1913        assert!(rendered.contains("Classification"));
1914        assert!(!rendered.contains("Active in cuts"));
1915        assert!(rendered.contains("#1 : member"));
1916        assert!(rendered.contains("pinched"));
1917    }
1918
1919    #[test]
1920    fn parse_display_settings_process_with_path() {
1921        let repl = Repl::try_parse_from([
1922            "gammaloop",
1923            "display",
1924            "settings",
1925            "process",
1926            "-p",
1927            "epem_a_tth",
1928            "-i",
1929            "LO",
1930            "integrator.n_max",
1931        ])
1932        .unwrap();
1933
1934        match repl.command {
1935            Commands::Display(Display::Settings { target }) => match target {
1936                DisplaySettingsTarget::Process { process, path } => {
1937                    assert_eq!(
1938                        process.process,
1939                        Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
1940                    );
1941                    assert_eq!(process.integrand_name, Some("LO".to_string()));
1942                    assert_eq!(path.as_deref(), Some("integrator.n_max"));
1943                }
1944                other => panic!("Expected process target, got {other:?}"),
1945            },
1946            other => panic!("Expected display settings command, got {other:?}"),
1947        }
1948    }
1949
1950    #[test]
1951    fn parse_display_settings_process_without_path() {
1952        let repl = Repl::try_parse_from([
1953            "gammaloop",
1954            "display",
1955            "settings",
1956            "process",
1957            "-p",
1958            "epem_a_tth",
1959            "-i",
1960            "LO",
1961        ])
1962        .unwrap();
1963
1964        match repl.command {
1965            Commands::Display(Display::Settings { target }) => match target {
1966                DisplaySettingsTarget::Process { path, .. } => {
1967                    assert_eq!(path, None);
1968                }
1969                other => panic!("Expected process target, got {other:?}"),
1970            },
1971            other => panic!("Expected display settings command, got {other:?}"),
1972        }
1973    }
1974
1975    #[test]
1976    fn parse_display_model_target() {
1977        let repl = Repl::try_parse_from([
1978            "gammaloop",
1979            "display",
1980            "model",
1981            "-p",
1982            "epem_a_tth",
1983            "-i",
1984            "LO",
1985            "--show-particles",
1986        ])
1987        .unwrap();
1988
1989        match repl.command {
1990            Commands::Display(Display::Model {
1991                process,
1992                integrand_name,
1993                show_particles,
1994                ..
1995            }) => {
1996                assert_eq!(
1997                    process,
1998                    Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
1999                );
2000                assert_eq!(integrand_name, Some("LO".to_string()));
2001                assert!(show_particles);
2002            }
2003            other => panic!("Expected display model command, got {other:?}"),
2004        }
2005    }
2006
2007    #[test]
2008    fn display_model_requires_process_and_integrand_together() {
2009        let err = Repl::try_parse_from(["gammaloop", "display", "model", "-p", "epem_a_tth"])
2010            .unwrap_err();
2011        assert!(err.to_string().contains("--integrand-name"));
2012    }
2013
2014    #[test]
2015    fn parse_display_quantities_without_name() {
2016        let repl = Repl::try_parse_from([
2017            "gammaloop",
2018            "display",
2019            "quantities",
2020            "-p",
2021            "epem_a_tth",
2022            "-i",
2023            "LO",
2024        ])
2025        .unwrap();
2026
2027        match repl.command {
2028            Commands::Display(Display::Quantities { target }) => {
2029                assert_eq!(
2030                    target.process.process,
2031                    Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
2032                );
2033                assert_eq!(target.process.integrand_name, Some("LO".to_string()));
2034                assert_eq!(target.name, None);
2035            }
2036            other => panic!("Expected display quantities command, got {other:?}"),
2037        }
2038    }
2039
2040    #[test]
2041    fn parse_display_selector_with_name() {
2042        let repl = Repl::try_parse_from([
2043            "gammaloop",
2044            "display",
2045            "selectors",
2046            "-p",
2047            "epem_a_tth",
2048            "top_cut",
2049        ])
2050        .unwrap();
2051
2052        match repl.command {
2053            Commands::Display(Display::Selectors { target }) => {
2054                assert_eq!(
2055                    target.process.process,
2056                    Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
2057                );
2058                assert_eq!(target.process.integrand_name, None);
2059                assert_eq!(target.name.as_deref(), Some("top_cut"));
2060            }
2061            other => panic!("Expected display selectors command, got {other:?}"),
2062        }
2063    }
2064
2065    #[test]
2066    fn parse_display_command_block_without_name() {
2067        let repl = Repl::try_parse_from(["gammaloop", "display", "command_block"]).unwrap();
2068
2069        match repl.command {
2070            Commands::Display(Display::CommandBlock { name }) => {
2071                assert_eq!(name, None);
2072            }
2073            other => panic!("Expected display command_block command, got {other:?}"),
2074        }
2075    }
2076
2077    #[test]
2078    fn parse_display_command_block_with_name() {
2079        let repl =
2080            Repl::try_parse_from(["gammaloop", "display", "command_block", "alpha"]).unwrap();
2081
2082        match repl.command {
2083            Commands::Display(Display::CommandBlock { name }) => {
2084                assert_eq!(name.as_deref(), Some("alpha"));
2085            }
2086            other => panic!("Expected display command_block command, got {other:?}"),
2087        }
2088    }
2089
2090    #[test]
2091    fn parse_display_command_block_rejects_hyphenated_name() {
2092        assert!(Repl::try_parse_from(["gammaloop", "display", "command-block"]).is_err());
2093    }
2094
2095    #[test]
2096    fn parse_display_settings_global_without_query() {
2097        let repl = Repl::try_parse_from(["gammaloop", "display", "settings", "global"]).unwrap();
2098
2099        match repl.command {
2100            Commands::Display(Display::Settings { target }) => match target {
2101                DisplaySettingsTarget::Global { path } => {
2102                    assert_eq!(path, None);
2103                }
2104                other => panic!("Expected global target, got {other:?}"),
2105            },
2106            other => panic!("Expected display settings command, got {other:?}"),
2107        }
2108    }
2109
2110    #[test]
2111    fn parse_display_settings_defaults_alias() {
2112        let repl = Repl::try_parse_from([
2113            "gammaloop",
2114            "display",
2115            "settings",
2116            "defaults",
2117            "integrator.n_start",
2118        ])
2119        .unwrap();
2120
2121        match repl.command {
2122            Commands::Display(Display::Settings { target }) => match target {
2123                DisplaySettingsTarget::DefaultRuntime { path } => {
2124                    assert_eq!(path.as_deref(), Some("integrator.n_start"));
2125                }
2126                other => panic!("Expected default runtime target, got {other:?}"),
2127            },
2128            other => panic!("Expected display settings command, got {other:?}"),
2129        }
2130    }
2131
2132    #[test]
2133    fn value_lookup_supports_nested_paths() {
2134        let value = json!({
2135            "integrator": {
2136                "n_max": 42
2137            }
2138        });
2139        let found = value_at_path(&value, "integrator.n_max").unwrap();
2140        assert_eq!(found, &json!(42));
2141    }
2142
2143    #[test]
2144    fn serialized_settings_include_default_values() {
2145        let global = CLISettings::default();
2146        let global_json = serialize_settings_with_defaults(&global, "global settings").unwrap();
2147        assert!(value_at_path(&global_json, "global.n_cores.generate").is_ok());
2148
2149        let runtime = RuntimeSettings::default();
2150        let runtime_json =
2151            serialize_settings_with_defaults(&runtime, "default runtime settings").unwrap();
2152        assert!(value_at_path(&runtime_json, "integrator.n_start").is_ok());
2153    }
2154
2155    #[test]
2156    fn parse_display_integrand_without_process() {
2157        let repl = Repl::try_parse_from(["gammaloop", "display", "integrand"]).unwrap();
2158
2159        match repl.command {
2160            Commands::Display(Display::Integrands {
2161                process,
2162                integrand_name,
2163                graphs,
2164                categories,
2165                hide_non_existing_thresholds,
2166            }) => {
2167                assert_eq!(process, None);
2168                assert_eq!(integrand_name, None);
2169                assert!(graphs.is_empty());
2170                assert!(categories.is_empty());
2171                assert!(!hide_non_existing_thresholds);
2172            }
2173            other => panic!("Expected display integrand command, got {other:?}"),
2174        }
2175    }
2176
2177    #[test]
2178    fn structured_display_integrand_defaults_missing_threshold_filter() {
2179        let mut value = serde_json::to_value(Display::Integrands {
2180            process: None,
2181            integrand_name: None,
2182            graphs: Vec::new(),
2183            categories: Vec::new(),
2184            hide_non_existing_thresholds: false,
2185        })
2186        .unwrap();
2187        value
2188            .get_mut("Integrands")
2189            .and_then(serde_json::Value::as_object_mut)
2190            .unwrap()
2191            .remove("hide_non_existing_thresholds");
2192
2193        let display: Display = serde_json::from_value(value).unwrap();
2194        assert!(matches!(
2195            display,
2196            Display::Integrands {
2197                hide_non_existing_thresholds: false,
2198                ..
2199            }
2200        ));
2201    }
2202
2203    #[test]
2204    fn parse_display_integrand_detail() {
2205        let repl = Repl::try_parse_from([
2206            "gammaloop",
2207            "display",
2208            "integrand",
2209            "-p",
2210            "epem_a_tth",
2211            "-i",
2212            "LO",
2213        ])
2214        .unwrap();
2215
2216        match repl.command {
2217            Commands::Display(Display::Integrands {
2218                process,
2219                integrand_name,
2220                graphs,
2221                categories,
2222                hide_non_existing_thresholds,
2223            }) => {
2224                assert_eq!(
2225                    process,
2226                    Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
2227                );
2228                assert_eq!(integrand_name.as_deref(), Some("LO"));
2229                assert!(graphs.is_empty());
2230                assert!(categories.is_empty());
2231                assert!(!hide_non_existing_thresholds);
2232            }
2233            other => panic!("Expected display integrand command, got {other:?}"),
2234        }
2235    }
2236
2237    #[test]
2238    fn parse_display_integrand_detail_filters() {
2239        let repl = Repl::try_parse_from([
2240            "gammaloop",
2241            "display",
2242            "integrand",
2243            "-p",
2244            "epem_a_tth",
2245            "-i",
2246            "LO",
2247            "-g",
2248            "GL0",
2249            "GL2",
2250            "--category",
2251            "generation",
2252            "orientation",
2253            "cuts",
2254            "--hide-non-existing-thresholds",
2255        ])
2256        .unwrap();
2257
2258        match repl.command {
2259            Commands::Display(Display::Integrands {
2260                process,
2261                integrand_name,
2262                graphs,
2263                categories,
2264                hide_non_existing_thresholds,
2265            }) => {
2266                assert_eq!(
2267                    process,
2268                    Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
2269                );
2270                assert!(hide_non_existing_thresholds);
2271                assert_eq!(integrand_name.as_deref(), Some("LO"));
2272                assert_eq!(graphs, vec!["GL0".to_string(), "GL2".to_string()]);
2273                assert_eq!(
2274                    categories,
2275                    vec![
2276                        IntegrandDisplayCategory::Generation,
2277                        IntegrandDisplayCategory::Orientation,
2278                        IntegrandDisplayCategory::Cuts,
2279                    ]
2280                );
2281            }
2282            other => panic!("Expected display integrand command, got {other:?}"),
2283        }
2284    }
2285
2286    #[test]
2287    fn format_bytes_uses_binary_units() {
2288        assert_eq!(format_bytes(999), "999 B");
2289        assert_eq!(format_bytes(1024), "1.00 KiB");
2290        assert_eq!(format_bytes(1024 * 1024), "1.00 MiB");
2291    }
2292
2293    #[test]
2294    fn command_block_contents_preserve_raw_commands() {
2295        let block = CommandsBlock {
2296            name: "alpha".to_string(),
2297            commands: vec![
2298                CommandHistory::from_raw_string("display processes").unwrap(),
2299                CommandHistory::from_raw_string("set process -p triangle -i LO defaults").unwrap(),
2300            ],
2301        };
2302
2303        assert_eq!(
2304            command_block_contents(&block),
2305            "display processes\nset process -p triangle -i LO defaults"
2306        );
2307    }
2308
2309    #[test]
2310    fn command_block_contents_marks_empty_blocks() {
2311        let run_history = RunHistory {
2312            command_blocks: vec![CommandsBlock {
2313                name: "empty".to_string(),
2314                commands: Vec::new(),
2315            }],
2316            ..RunHistory::default()
2317        };
2318
2319        assert_eq!(
2320            command_block_contents(&run_history.command_blocks[0]),
2321            "(empty)"
2322        );
2323    }
2324
2325    #[test]
2326    fn command_blocks_table_separates_each_block() {
2327        let run_history = RunHistory {
2328            command_blocks: vec![
2329                CommandsBlock {
2330                    name: "alpha".to_string(),
2331                    commands: vec![CommandHistory::from_raw_string("display processes").unwrap()],
2332                },
2333                CommandsBlock {
2334                    name: "beta".to_string(),
2335                    commands: vec![CommandHistory::from_raw_string("display model").unwrap()],
2336                },
2337            ],
2338            ..RunHistory::default()
2339        };
2340
2341        let rendered = render_command_blocks_table(&run_history);
2342
2343        assert!(rendered.contains("alpha"), "{rendered}");
2344        assert!(rendered.contains("beta"), "{rendered}");
2345        assert_eq!(
2346            rendered
2347                .lines()
2348                .filter(|line| line.starts_with('├') && line.ends_with('┤'))
2349                .count(),
2350            2,
2351            "{rendered}"
2352        );
2353    }
2354}