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    io::{self, Write},
11    path::{Path, PathBuf},
12};
13use tabled::{
14    builder::Builder,
15    settings::{style::HorizontalLine, themes::Theme, Style},
16    Tabled,
17};
18use tracing::info;
19use walkdir::WalkDir;
20
21use color_eyre::Result;
22use eyre::{eyre, Context};
23use gammalooprs::{
24    processes::{Amplitude, CrossSection, Process, ProcessCollection, ThresholdCountertermSide},
25    settings::RuntimeSettings,
26};
27
28use crate::{
29    commands::generate::{render_generation_summary, ProcessArgs},
30    commands::process_settings::{
31        observable_kind, quantity_kind, selector_kind, serialize_runtime_named_settings,
32        summarize_observable, summarize_quantity, summarize_selector, NamedProcessSettingKind,
33    },
34    commands::CliArgumentMetadataExt,
35    completion::CompletionArgExt,
36    integrand_info::{
37        IntegrandCutThresholdInfo, IntegrandEsurfaceClassification, IntegrandGraphGroupInfo,
38        IntegrandInfo, IntegrandKind,
39    },
40    session::display_command,
41    settings_tree::{serialize_settings_with_defaults, value_at_path},
42    state::{CommandsBlock, IntegrandGenerationSummary, ProcessRef, RunHistory, State},
43    CLISettings,
44};
45
46mod sampling_metadata;
47mod threshold_metadata;
48
49/// Output representation for a dedicated graph metadata view.
50#[derive(Debug, Clone, Copy, Serialize, Deserialize, ValueEnum, JsonSchema, PartialEq, Eq)]
51#[serde(rename_all = "snake_case")]
52pub enum MetadataDisplayFormat {
53    Pretty,
54    Toml,
55}
56
57#[derive(Subcommand, Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
58pub enum Display {
59    /// Show model particles, parameters, couplings, and vertices for the selected context.
60    Model {
61        /// Include the model's coupling-order definitions and coupling values.
62        #[arg(short = 'c', long = "show-couplings", default_value_t = false)]
63        show_couplings: bool,
64        /// Include interaction vertices from the active model.
65        #[arg(short = 'v', long = "show-vertices", default_value_t = false)]
66        show_vertices: bool,
67        /// Include external and derived model parameters.
68        #[arg(short = 'r', long = "show-parameters", default_value_t = false)]
69        show_parameters: bool,
70        /// Include the model's particle definitions.
71        #[arg(long = "show-particles", default_value_t = false)]
72        show_particles: bool,
73        /// Include particles, parameters, couplings, and vertices in one report.
74        #[arg(short = 'a', long = "show-all", default_value_t = false)]
75        show_all: bool,
76        /// Process reference: `#<id>`, `name:<name>`, or `<id>/<name>`
77        #[arg(
78            short = 'p',
79            long = "process",
80            value_name = "PROCESS",
81            cli_requires("integrand_name"),
82            completion_process_selector(crate::completion::SelectorKind::Any)
83        )]
84        process: Option<ProcessRef>,
85        /// Integrand name inside the selected process
86        #[arg(
87            short = 'i',
88            long = "integrand-name",
89            value_name = "NAME",
90            cli_requires("process"),
91            completion_integrand_selector(crate::completion::SelectorKind::Any)
92        )]
93        integrand_name: Option<String>,
94    },
95    /// List processes currently stored in the active state.
96    Processes,
97    /// Show generated integrands, graph groups, resources, and selected detail tables.
98    #[command(name = "integrand", visible_alias = "integrands")]
99    Integrands {
100        /// Process reference: `#<id>`, `name:<name>`, or `<id>/<name>`
101        #[arg(
102            short = 'p',
103            long = "process",
104            value_name = "PROCESS",
105            completion_process_selector(crate::completion::SelectorKind::Any)
106        )]
107        process: Option<ProcessRef>,
108        /// Integrand name inside the selected process
109        #[arg(
110            short = 'i',
111            long = "integrand-name",
112            value_name = "NAME",
113            cli_requires("process"),
114            completion_integrand_selector(crate::completion::SelectorKind::Any)
115        )]
116        integrand_name: Option<String>,
117        /// Restrict the detailed view to the selected master graph names
118        #[arg(
119            short = 'g',
120            long = "graph",
121            value_name = "MASTER_GRAPH",
122            num_args = 1..,
123            cli_requires("integrand_name"),
124            completion_selected_master_graph()
125        )]
126        graphs: Vec<String>,
127        /// Restrict the detailed view to the selected category tables
128        #[arg(
129            long = "category",
130            value_name = "CATEGORY",
131            num_args = 1..,
132            cli_requires("integrand_name"),
133            completion_selected_integrand_category()
134        )]
135        categories: Vec<IntegrandDisplayCategory>,
136        /// Hide generated threshold CTs that cannot exist for the active model and kinematics
137        #[arg(
138            long = "hide-non-existing-thresholds",
139            default_value_t = false,
140            cli_requires("integrand_name")
141        )]
142        #[serde(default)]
143        hide_non_existing_thresholds: bool,
144        /// Show full threshold-counterterm multiplier expressions and flags
145        #[arg(
146            long = "show-threshold-functions",
147            default_value_t = false,
148            requires = "integrand_name"
149        )]
150        #[serde(default)]
151        show_threshold_functions: bool,
152        /// Show only resolved sampling channels; omit --graph to include all master graphs.
153        #[arg(
154            long = "show_sampling",
155            visible_alias = "show-sampling",
156            value_name = "MODE",
157            cli_requires("integrand_name"),
158            cli_requires("process"),
159            conflicts_with = "show_threshold_subtraction"
160        )]
161        #[serde(default, skip_serializing_if = "Option::is_none")]
162        show_sampling: Option<MetadataDisplayFormat>,
163        /// Show only generation-time threshold metadata; TOML requires one master graph.
164        #[arg(
165            long = "show_threshold_subtraction",
166            visible_alias = "show-threshold-subtraction",
167            value_name = "MODE",
168            cli_requires("integrand_name"),
169            cli_requires("process"),
170            conflicts_with = "show_sampling"
171        )]
172        #[serde(default, skip_serializing_if = "Option::is_none")]
173        show_threshold_subtraction: Option<MetadataDisplayFormat>,
174    },
175    /// List configured named quantities, or inspect one quantity in a selected process.
176    Quantities {
177        #[command(flatten)]
178        target: DisplayProcessNamedSettingsArgs,
179    },
180    /// List configured observables, or inspect one observable in a selected process.
181    Observables {
182        #[command(flatten)]
183        target: DisplayProcessNamedSettingsArgs,
184    },
185    /// List configured event selectors, or inspect one selector in a selected process.
186    Selectors {
187        #[command(flatten)]
188        target: DisplayProcessNamedSettingsArgs,
189    },
190    /// Show one stored command block, or list every block when no name is supplied.
191    #[command(name = "command_block")]
192    CommandBlock {
193        /// Stored command-block name; omit it to list all available blocks.
194        #[arg(value_name = "NAME")]
195        name: Option<String>,
196    },
197    /// Show effective global, default-runtime, or process-specific settings.
198    Settings {
199        #[command(subcommand)]
200        target: DisplaySettingsTarget,
201    },
202}
203
204#[derive(Args, Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
205pub struct DisplayProcessNamedSettingsArgs {
206    #[command(flatten)]
207    process: ProcessArgs,
208    /// Named quantity, observable, or selector; omit it to list the selected category.
209    #[arg(value_name = "NAME")]
210    name: Option<String>,
211}
212
213#[derive(Subcommand, Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
214pub enum DisplaySettingsTarget {
215    /// Show effective global generation, logging, and parallelization settings.
216    Global {
217        /// Optional dotted path to a specific setting
218        #[arg(value_name = "PATH")]
219        path: Option<String>,
220    },
221    /// Show the default runtime settings inherited by newly generated integrands.
222    #[command(alias = "defaults")]
223    DefaultRuntime {
224        /// Optional dotted path to a specific setting
225        #[arg(value_name = "PATH")]
226        path: Option<String>,
227    },
228    /// Show effective settings for one generated process or integrand.
229    Process {
230        #[command(flatten)]
231        process: ProcessArgs,
232        /// Optional dotted path to a specific setting
233        #[arg(value_name = "PATH")]
234        path: Option<String>,
235    },
236}
237
238impl Display {
239    pub fn run(
240        &self,
241        state: &State,
242        global_settings: &CLISettings,
243        default_runtime_settings: &RuntimeSettings,
244        run_history: &RunHistory,
245    ) -> Result<()> {
246        match self {
247            Display::Integrands {
248                process,
249                integrand_name,
250                graphs,
251                categories,
252                hide_non_existing_thresholds,
253                show_threshold_functions,
254                show_sampling,
255                show_threshold_subtraction,
256            } => {
257                if show_sampling.is_some() && show_threshold_subtraction.is_some() {
258                    return Err(eyre!(
259                        "--show_sampling and --show_threshold_subtraction are mutually exclusive"
260                    ));
261                }
262                if let Some(format) = show_sampling.or(*show_threshold_subtraction) {
263                    let process_ref = process.as_ref().ok_or_else(|| {
264                        eyre!(
265                            "Metadata display requires an explicit --process and --integrand-name"
266                        )
267                    })?;
268                    let integrand_name = integrand_name.as_deref().ok_or_else(|| {
269                        eyre!(
270                            "Metadata display requires an explicit --process and --integrand-name"
271                        )
272                    })?;
273                    if *show_threshold_subtraction == Some(MetadataDisplayFormat::Toml)
274                        && graphs.len() != 1
275                    {
276                        return Err(eyre!(
277                            "Threshold TOML display requires exactly one --graph master name"
278                        ));
279                    }
280                    let process_id = state.resolve_process_ref(Some(process_ref))?;
281                    let render = if show_sampling.is_some() {
282                        sampling_metadata::render
283                    } else {
284                        threshold_metadata::render
285                    };
286                    let output = render(state, process_id, integrand_name, graphs, format)?;
287                    match format {
288                        MetadataDisplayFormat::Pretty => info!("\n{output}"),
289                        MetadataDisplayFormat::Toml => {
290                            let mut stdout = io::stdout().lock();
291                            stdout.write_all(output.as_bytes())?;
292                            stdout.flush()?;
293                        }
294                    }
295                    return Ok(());
296                }
297                let process_id = process
298                    .as_ref()
299                    .map(|process_ref| state.resolve_process_ref(Some(process_ref)))
300                    .transpose()?;
301                if let Some(integrand_name) = integrand_name.as_deref() {
302                    let process_id = process_id
303                        .ok_or_else(|| eyre!("--integrand-name requires an explicit --process"))?;
304                    let detail = state.get_integrand_info(
305                        Some(&ProcessRef::Id(process_id)),
306                        Some(&integrand_name.to_string()),
307                    )?;
308                    let process = &state.process_list.processes[process_id];
309                    let artifact_sizes = collect_integrand_artifact_sizes(
310                        &global_settings.state.folder,
311                        process,
312                        &detail.integrand_name,
313                    )?;
314                    render_integrand_detail_from_info(
315                        &detail,
316                        &artifact_sizes,
317                        state.generation_summary(process_id, &detail.integrand_name),
318                        graphs,
319                        categories,
320                        *hide_non_existing_thresholds,
321                        *show_threshold_functions,
322                    )?;
323                } else {
324                    render_integrands_table(state, &global_settings.state.folder, process_id)?;
325                }
326            }
327            Display::Processes => {
328                render_processes_table(state, &global_settings.state.folder)?;
329            }
330            Display::Quantities { target } => {
331                render_named_process_settings(state, target, NamedProcessSettingKind::Quantity)?;
332            }
333            Display::Observables { target } => {
334                render_named_process_settings(state, target, NamedProcessSettingKind::Observable)?;
335            }
336            Display::Selectors { target } => {
337                render_named_process_settings(state, target, NamedProcessSettingKind::Selector)?;
338            }
339            Display::CommandBlock { name } => {
340                render_command_blocks(run_history, name.as_deref())?;
341            }
342            Display::Model {
343                show_couplings,
344                show_vertices,
345                show_parameters,
346                show_particles,
347                show_all,
348                process,
349                integrand_name,
350            } => {
351                let model = if let (Some(process), Some(integrand_name)) =
352                    (process.as_ref(), integrand_name.as_ref())
353                {
354                    let process_id = state.resolve_process_ref(Some(process))?;
355                    state.resolve_model_for_integrand(process_id, integrand_name)?
356                } else {
357                    state.model.clone()
358                };
359                info!(
360                    "\n{}",
361                    model.get_description(
362                        *show_particles || *show_all,
363                        *show_parameters || *show_all,
364                        *show_vertices || *show_all,
365                        *show_couplings || *show_all,
366                    )
367                )
368            }
369            Display::Settings { target } => {
370                target.run(state, global_settings, default_runtime_settings)?;
371            }
372        }
373        Ok(())
374    }
375}
376
377#[derive(Clone, Debug)]
378struct IntegrandMetrics {
379    name: String,
380    graphs: usize,
381    graph_groups: usize,
382    bin_disk_size_bytes: Option<u64>,
383    so_disk_size_bytes: Option<u64>,
384}
385
386#[derive(Clone, Debug, Default)]
387struct IntegrandArtifactSizes {
388    bin_disk_size_bytes: Option<u64>,
389    so_disk_size_bytes: Option<u64>,
390}
391
392#[derive(
393    Debug, Clone, Copy, Serialize, Deserialize, ValueEnum, JsonSchema, PartialEq, Eq, Hash,
394)]
395#[serde(rename_all = "snake_case")]
396pub enum IntegrandDisplayCategory {
397    #[value(name = "generation")]
398    Generation,
399    #[value(name = "orientation")]
400    Orientation,
401    #[value(name = "loop_momentum_basis")]
402    LoopMomentumBasis,
403    #[value(name = "cuts")]
404    Cuts,
405}
406
407impl IntegrandDisplayCategory {
408    fn title(self) -> &'static str {
409        match self {
410            Self::Generation => "Generation",
411            Self::Orientation => "Orientations",
412            Self::LoopMomentumBasis => "Loop momentum bases",
413            Self::Cuts => "Cuts",
414        }
415    }
416
417    fn value_header(self) -> &'static str {
418        match self {
419            Self::Generation => "generation",
420            Self::Orientation => "orientation",
421            Self::LoopMomentumBasis => "loop momentum basis",
422            Self::Cuts => "cut",
423        }
424    }
425}
426
427#[derive(Tabled)]
428struct DetailSummaryRow {
429    field: String,
430    value: String,
431}
432
433fn orientation_column_widths(edge_ids: &[usize], fallback_count: usize) -> Vec<usize> {
434    if edge_ids.is_empty() {
435        vec![1; fallback_count]
436    } else {
437        edge_ids
438            .iter()
439            .map(|edge_id| edge_id.to_string().len().max(1))
440            .collect()
441    }
442}
443
444fn render_orientation_edge_ids(edge_ids: &[usize]) -> String {
445    if edge_ids.is_empty() {
446        return String::new();
447    }
448
449    let widths = orientation_column_widths(edge_ids, edge_ids.len());
450    edge_ids
451        .iter()
452        .zip(widths.iter())
453        .map(|(edge_id, width)| {
454            format!("{edge_id:<width$}", width = *width)
455                .magenta()
456                .to_string()
457        })
458        .join(" ")
459}
460
461fn render_orientation_signature(signature: &[i8], edge_ids: &[usize]) -> String {
462    let widths = orientation_column_widths(edge_ids, signature.len());
463    signature
464        .iter()
465        .zip(widths.iter())
466        .map(|(sign, width)| {
467            let padded = format!(
468                "{:<width$}",
469                match sign {
470                    1 => "+",
471                    -1 => "-",
472                    0 => "0",
473                    _ => "?",
474                },
475                width = *width
476            );
477            match sign {
478                1 => padded.green().bold().to_string(),
479                -1 => padded.red().bold().to_string(),
480                0 => padded.dimmed().to_string(),
481                _ => padded.to_string(),
482            }
483        })
484        .join(" ")
485}
486
487fn render_edge_ids_with_highlight(edge_ids: &[usize], highlight: bool) -> String {
488    if edge_ids.is_empty() {
489        return String::new();
490    }
491
492    let rendered = format!(
493        "({})",
494        edge_ids.iter().map(|edge_id| edge_id.to_string()).join(",")
495    );
496    if highlight {
497        rendered.green().bold().to_string()
498    } else {
499        rendered.magenta().to_string()
500    }
501}
502
503fn render_edge_ids(edge_ids: &[usize]) -> String {
504    render_edge_ids_with_highlight(edge_ids, false)
505}
506
507fn render_threshold_counterterms(
508    thresholds: &[IntegrandCutThresholdInfo],
509    hide_non_existing_thresholds: bool,
510) -> String {
511    let visible = thresholds
512        .iter()
513        .filter(|threshold| !hide_non_existing_thresholds || threshold.status.is_currently_viable())
514        .collect_vec();
515    if visible.is_empty() {
516        return "none".dimmed().to_string();
517    }
518    format!(
519        "[{}]",
520        visible
521            .iter()
522            .map(|threshold| {
523                let id = if threshold.status.can_become_pinched() {
524                    format!("{}*", threshold.esurface_id)
525                } else {
526                    threshold.esurface_id.to_string()
527                };
528                if threshold.status.is_currently_viable() {
529                    id.cyan().to_string()
530                } else {
531                    id.dimmed().to_string()
532                }
533            })
534            .join(",")
535    )
536}
537
538fn render_threshold_esurface(edge_ids: &[usize]) -> String {
539    render_edge_ids(edge_ids)
540}
541
542fn render_cut(edge_ids: &[usize], raising_power: usize) -> String {
543    let edge_ids = render_edge_ids(edge_ids);
544    if edge_ids.is_empty() || raising_power <= 1 {
545        edge_ids
546    } else {
547        format!("{edge_ids}^{raising_power}").magenta().to_string()
548    }
549}
550
551#[derive(Clone)]
552struct CategoryRow {
553    primary_id: String,
554    secondary_id: String,
555    tertiary_id: String,
556    value: String,
557}
558
559fn render_category_id(id: usize, highlight: bool) -> String {
560    let rendered = format!("#{id}");
561    if highlight {
562        rendered.green().bold().to_string()
563    } else {
564        rendered.yellow().to_string()
565    }
566}
567
568fn render_optional_category_id(id: Option<usize>, highlight: bool) -> String {
569    id.map(|id| render_category_id(id, highlight))
570        .unwrap_or_default()
571}
572
573fn master_graph(
574    group: &IntegrandGraphGroupInfo,
575) -> Result<&crate::integrand_info::IntegrandGraphInfo> {
576    group
577        .graphs
578        .iter()
579        .find(|graph| graph.is_master)
580        .ok_or_else(|| eyre!("Graph group #{} has no master graph", group.group_id))
581}
582
583fn available_integrand_categories(detail: &IntegrandInfo) -> Vec<IntegrandDisplayCategory> {
584    match detail.kind {
585        IntegrandKind::Amplitude => vec![
586            IntegrandDisplayCategory::Generation,
587            IntegrandDisplayCategory::Orientation,
588            IntegrandDisplayCategory::LoopMomentumBasis,
589            IntegrandDisplayCategory::Cuts,
590        ],
591        IntegrandKind::CrossSection => vec![
592            IntegrandDisplayCategory::Generation,
593            IntegrandDisplayCategory::Orientation,
594            IntegrandDisplayCategory::LoopMomentumBasis,
595            IntegrandDisplayCategory::Cuts,
596        ],
597    }
598}
599
600fn selected_integrand_categories(
601    detail: &IntegrandInfo,
602    requested: &[IntegrandDisplayCategory],
603) -> Result<Vec<IntegrandDisplayCategory>> {
604    let available = available_integrand_categories(detail);
605    if requested.is_empty() {
606        return Ok(available);
607    }
608
609    let mut selected = Vec::new();
610    for category in requested.iter().copied() {
611        if !available.contains(&category) {
612            return Err(eyre!(
613                "Category '{}' is not available for {} integrands.",
614                category.to_possible_value().expect("value enum").get_name(),
615                detail.kind
616            ));
617        }
618        if !selected.contains(&category) {
619            selected.push(category);
620        }
621    }
622    Ok(selected)
623}
624
625fn filtered_graph_groups<'a>(
626    detail: &'a IntegrandInfo,
627    requested_graphs: &[String],
628) -> Result<Vec<&'a IntegrandGraphGroupInfo>> {
629    if requested_graphs.is_empty() {
630        return Ok(detail.graph_groups.iter().collect());
631    }
632
633    let available_master_names = detail
634        .graph_groups
635        .iter()
636        .map(master_graph)
637        .collect::<Result<Vec<_>>>()?
638        .into_iter()
639        .map(|graph| graph.name.clone())
640        .collect::<Vec<_>>();
641    let missing = requested_graphs
642        .iter()
643        .filter(|graph_name| {
644            !available_master_names
645                .iter()
646                .any(|name| name == *graph_name)
647        })
648        .cloned()
649        .collect::<Vec<_>>();
650    if !missing.is_empty() {
651        return Err(eyre!(
652            "Unknown master graph filter(s): {}. Available master graphs: {}",
653            missing.join(", "),
654            available_master_names.join(", ")
655        ));
656    }
657
658    Ok(detail
659        .graph_groups
660        .iter()
661        .filter(|group| {
662            master_graph(group)
663                .map(|graph| requested_graphs.iter().any(|name| name == &graph.name))
664                .unwrap_or(false)
665        })
666        .collect())
667}
668
669fn category_rows(
670    group: &IntegrandGraphGroupInfo,
671    category: IntegrandDisplayCategory,
672    hide_non_existing_thresholds: bool,
673) -> Vec<CategoryRow> {
674    match category {
675        IntegrandDisplayCategory::Generation => Vec::new(),
676        IntegrandDisplayCategory::Orientation => group
677            .orientations
678            .iter()
679            .map(|orientation| CategoryRow {
680                primary_id: render_category_id(orientation.orientation_id, false),
681                secondary_id: String::new(),
682                tertiary_id: String::new(),
683                value: render_orientation_signature(
684                    &orientation.signature,
685                    &group.orientation_edge_ids,
686                ),
687            })
688            .collect(),
689        IntegrandDisplayCategory::LoopMomentumBasis => group
690            .loop_momentum_bases
691            .iter()
692            .map(|basis| CategoryRow {
693                primary_id: render_category_id(basis.basis_id, basis.matches_generation_basis),
694                secondary_id: render_optional_category_id(
695                    basis.channel_id,
696                    basis.matches_generation_basis,
697                ),
698                tertiary_id: String::new(),
699                value: render_edge_ids_with_highlight(
700                    &basis.edge_ids,
701                    basis.matches_generation_basis,
702                ),
703            })
704            .collect(),
705        IntegrandDisplayCategory::Cuts => group
706            .cuts
707            .iter()
708            .map(|cut| CategoryRow {
709                primary_id: render_category_id(cut.cut_id, false),
710                secondary_id: render_threshold_counterterms(
711                    &cut.left_thresholds,
712                    hide_non_existing_thresholds,
713                ),
714                tertiary_id: render_threshold_counterterms(
715                    &cut.right_thresholds,
716                    hide_non_existing_thresholds,
717                ),
718                value: render_cut(&cut.edge_ids, cut.raising_power),
719            })
720            .collect(),
721    }
722}
723
724fn category_group_header(
725    group: &IntegrandGraphGroupInfo,
726    category: IntegrandDisplayCategory,
727) -> String {
728    match category {
729        IntegrandDisplayCategory::Orientation => {
730            render_orientation_edge_ids(&group.orientation_edge_ids)
731        }
732        IntegrandDisplayCategory::Generation
733        | IntegrandDisplayCategory::LoopMomentumBasis
734        | IntegrandDisplayCategory::Cuts => String::new(),
735    }
736}
737
738fn render_integrand_thresholds_table(
739    groups: &[&IntegrandGraphGroupInfo],
740    integrand_kind: IntegrandKind,
741) -> Result<Option<String>> {
742    let status_header = match integrand_kind {
743        IntegrandKind::Amplitude => "Classification",
744        IntegrandKind::CrossSection => "Active in cuts",
745    };
746    let mut builder = Builder::new();
747    builder.push_record([
748        "Group #".bold().blue().to_string(),
749        "Graph".bold().blue().to_string(),
750        "Esurface".bold().blue().to_string(),
751        "edges".bold().blue().to_string(),
752        status_header.bold().blue().to_string(),
753    ]);
754
755    let mut inserted_blocks = 0usize;
756    let mut separator_rows = vec![1usize];
757    let mut next_row = 1usize;
758
759    for group in groups {
760        if group.threshold_esurfaces.is_empty() {
761            continue;
762        }
763
764        let master = master_graph(group)?;
765        inserted_blocks += 1;
766        builder.push_record([
767            format!("#{}", group.group_id).blue().to_string(),
768            format!("#{} : {}", master.graph_id, master.name)
769                .green()
770                .bold()
771                .to_string(),
772            String::new(),
773            String::new(),
774            String::new(),
775        ]);
776        next_row += 1;
777        separator_rows.push(next_row);
778
779        for threshold in &group.threshold_esurfaces {
780            let representative_graph = group
781                .graphs
782                .iter()
783                .find(|graph| graph.graph_id == threshold.representative_graph_id)
784                .ok_or_else(|| {
785                    eyre!(
786                        "Threshold E-surface #{} in graph group #{} references missing representative graph #{}",
787                        threshold.esurface_id,
788                        group.group_id,
789                        threshold.representative_graph_id,
790                    )
791                })?;
792            let graph_label = if representative_graph.is_master {
793                String::new()
794            } else {
795                format!(
796                    "#{} : {}",
797                    representative_graph.graph_id, representative_graph.name
798                )
799                .yellow()
800                .to_string()
801            };
802            let status = match integrand_kind {
803                IntegrandKind::Amplitude => match threshold.classification.ok_or_else(|| {
804                    eyre!(
805                        "Amplitude threshold E-surface #{} in graph group #{} has no classification",
806                        threshold.esurface_id,
807                        group.group_id,
808                    )
809                })? {
810                    IntegrandEsurfaceClassification::Existing => {
811                        "existing".cyan().to_string()
812                    }
813                    IntegrandEsurfaceClassification::Pinched => {
814                        "pinched".yellow().to_string()
815                    }
816                    IntegrandEsurfaceClassification::NonExisting => {
817                        "non-existing".dimmed().to_string()
818                    }
819                },
820                IntegrandKind::CrossSection => {
821                    if threshold.active_cuts.is_empty() {
822                        "none".dimmed().to_string()
823                    } else {
824                        threshold
825                            .active_cuts
826                            .iter()
827                            .map(|cut| {
828                                if cut.can_become_pinched {
829                                    format!("#{}*", cut.cut_id)
830                                } else {
831                                    format!("#{}", cut.cut_id)
832                                }
833                            })
834                            .join(",")
835                            .cyan()
836                            .to_string()
837                    }
838                }
839            };
840            builder.push_record([
841                String::new(),
842                graph_label,
843                format!("#{}", threshold.esurface_id).yellow().to_string(),
844                render_threshold_esurface(&threshold.edge_ids),
845                status,
846            ]);
847            next_row += 1;
848        }
849        separator_rows.push(next_row);
850    }
851
852    if inserted_blocks == 0 {
853        return Ok(None);
854    }
855
856    let mut table = builder.build();
857    let mut style = Theme::from_style(Style::rounded().remove_horizontals());
858    let mut unique_separator_rows = separator_rows.into_iter().unique().collect_vec();
859    if let Some(last_row) = unique_separator_rows.pop() {
860        for row in unique_separator_rows {
861            style.insert_horizontal_line(
862                row,
863                HorizontalLine::new('─')
864                    .intersection('┼')
865                    .left('├')
866                    .right('┤'),
867            );
868        }
869        style.insert_horizontal_line(
870            last_row,
871            HorizontalLine::new('─')
872                .intersection('┴')
873                .left('╰')
874                .right('╯'),
875        );
876    }
877    table.with(style);
878    Ok(Some(table.to_string()))
879}
880
881fn render_threshold_counterterm_directives(
882    groups: &[&IntegrandGraphGroupInfo],
883    show_functions: bool,
884) -> Option<String> {
885    let graphs = groups
886        .iter()
887        .flat_map(|group| &group.graphs)
888        .filter(|graph| !graph.threshold_counterterm_directives.is_empty())
889        .collect_vec();
890    if graphs.is_empty() {
891        return None;
892    }
893
894    let mut builder = Builder::new();
895    builder.push_record([
896        "G".bold().blue().to_string(),
897        "C".bold().blue().to_string(),
898        "T".bold().blue().to_string(),
899        "V".bold().blue().to_string(),
900        "↔".bold().blue().to_string(),
901        "S".bold().blue().to_string(),
902        "P".bold().blue().to_string(),
903        "f".bold().blue().to_string(),
904    ]);
905
906    for graph in graphs {
907        let mut previous_cut: Option<&[usize]> = None;
908        let mut previous_threshold: Option<&[usize]> = None;
909        for (directive_index, directive) in
910            graph.threshold_counterterm_directives.iter().enumerate()
911        {
912            let resolved = graph.threshold_counterterms.as_ref().and_then(|registry| {
913                registry.variants.iter().find(|variant| {
914                    variant.name == directive.name
915                        && variant.associations.iter().any(|association| {
916                            association.cut_edges == directive.cut_edge_ids
917                                && association.threshold_edges == directive.threshold_edge_ids
918                        })
919                })
920            });
921            let suffix = if directive.implicit_default { "*" } else { "" };
922            let variant = if directive.disabled {
923                format!("× {}{suffix}", directive.name).red().to_string()
924            } else if resolved.is_some_and(|variant| variant.active) {
925                format!("● {}{suffix}", directive.name).green().to_string()
926            } else {
927                format!("○ {}{suffix}", directive.name).yellow().to_string()
928            };
929            let side = resolved
930                .map(|variant| match variant.side {
931                    ThresholdCountertermSide::Amplitude => "A".cyan().to_string(),
932                    ThresholdCountertermSide::Left => "L".green().to_string(),
933                    ThresholdCountertermSide::Right => "R".yellow().to_string(),
934                })
935                .unwrap_or_else(|| "?".dimmed().to_string());
936            let subspace = directive
937                .requested_subspace
938                .as_deref()
939                .map(render_edge_ids)
940                .unwrap_or_else(|| "max".dimmed().to_string());
941            let parent = directive
942                .requested_parent_lmb
943                .as_deref()
944                .map(render_edge_ids)
945                .unwrap_or_else(|| "auto".dimmed().to_string());
946            let multiplier = directive
947                .multiplier
948                .as_ref()
949                .map(|multiplier| {
950                    if show_functions {
951                        let definitions = multiplier
952                            .function_map
953                            .iter()
954                            .map(|(name, expression)| format!("\n{name} = {expression}"))
955                            .collect::<String>();
956                        format!(
957                            "{} [s={},o={}]{}",
958                            multiplier.expression,
959                            u8::from(multiplier.symmetrize),
960                            u8::from(multiplier.opaque_derivatives),
961                            definitions,
962                        )
963                        .magenta()
964                        .to_string()
965                    } else {
966                        "ƒ".magenta().bold().to_string()
967                    }
968                })
969                .unwrap_or_else(|| "1".dimmed().to_string());
970            let graph_label = if directive_index == 0 {
971                format!("#{} {}", graph.graph_id, graph.name)
972                    .blue()
973                    .to_string()
974            } else {
975                "·".dimmed().to_string()
976            };
977            let cut = if previous_cut == Some(directive.cut_edge_ids.as_slice()) {
978                "·".dimmed().to_string()
979            } else if directive.cut_edge_ids.is_empty() {
980                "∅".cyan().to_string()
981            } else {
982                render_edge_ids(&directive.cut_edge_ids)
983            };
984            let threshold = if previous_threshold == Some(directive.threshold_edge_ids.as_slice())
985                && previous_cut == Some(directive.cut_edge_ids.as_slice())
986            {
987                "·".dimmed().to_string()
988            } else {
989                format!(
990                    "({})",
991                    directive
992                        .threshold_edge_ids
993                        .iter()
994                        .map(ToString::to_string)
995                        .join(",")
996                )
997                .yellow()
998                .to_string()
999            };
1000            builder.push_record([
1001                graph_label,
1002                cut,
1003                threshold,
1004                variant,
1005                side,
1006                subspace,
1007                parent,
1008                multiplier,
1009            ]);
1010            previous_cut = Some(directive.cut_edge_ids.as_slice());
1011            previous_threshold = Some(directive.threshold_edge_ids.as_slice());
1012        }
1013    }
1014
1015    let mut table = builder.build();
1016    table.with(Style::rounded().remove_horizontals());
1017    Some(format!(
1018        "{table}\n{}",
1019        "● active  ○ inactive/dormant  × disabled  · repeated  * shorthand default; ↔ = A/L/R, S = subspace, P = parent LMB"
1020            .dimmed()
1021    ))
1022}
1023
1024fn render_integrand_category_table(
1025    groups: &[&IntegrandGraphGroupInfo],
1026    category: IntegrandDisplayCategory,
1027    hide_non_existing_thresholds: bool,
1028) -> Result<Option<String>> {
1029    let mut builder = Builder::new();
1030    match category {
1031        IntegrandDisplayCategory::LoopMomentumBasis => {
1032            builder.push_record([
1033                "Group #".bold().blue().to_string(),
1034                "Graph".bold().blue().to_string(),
1035                "basis ID".bold().blue().to_string(),
1036                "channel ID".bold().blue().to_string(),
1037                category.value_header().bold().blue().to_string(),
1038            ]);
1039        }
1040        IntegrandDisplayCategory::Cuts => {
1041            builder.push_record([
1042                "Group #".bold().blue().to_string(),
1043                "Graph".bold().blue().to_string(),
1044                "ID".bold().blue().to_string(),
1045                category.value_header().bold().blue().to_string(),
1046                "left thresholds".bold().blue().to_string(),
1047                "right thresholds".bold().blue().to_string(),
1048            ]);
1049        }
1050        _ => {
1051            builder.push_record([
1052                "Group #".bold().blue().to_string(),
1053                "Graph".bold().blue().to_string(),
1054                "ID".bold().blue().to_string(),
1055                category.value_header().bold().blue().to_string(),
1056            ]);
1057        }
1058    }
1059
1060    let mut inserted_blocks = 0usize;
1061    let mut separator_rows = vec![1usize];
1062    let mut next_row = 1usize;
1063
1064    for group in groups {
1065        let rows = category_rows(group, category, hide_non_existing_thresholds);
1066        if rows.is_empty() {
1067            continue;
1068        }
1069
1070        let master = master_graph(group)?;
1071        inserted_blocks += 1;
1072        match category {
1073            IntegrandDisplayCategory::LoopMomentumBasis => {
1074                builder.push_record([
1075                    format!("#{}", group.group_id).blue().to_string(),
1076                    format!("#{} : {}", master.graph_id, master.name)
1077                        .green()
1078                        .bold()
1079                        .to_string(),
1080                    String::new(),
1081                    String::new(),
1082                    category_group_header(group, category),
1083                ]);
1084            }
1085            IntegrandDisplayCategory::Cuts => {
1086                builder.push_record([
1087                    format!("#{}", group.group_id).blue().to_string(),
1088                    format!("#{} : {}", master.graph_id, master.name)
1089                        .green()
1090                        .bold()
1091                        .to_string(),
1092                    String::new(),
1093                    category_group_header(group, category),
1094                    String::new(),
1095                    String::new(),
1096                ]);
1097            }
1098            _ => {
1099                builder.push_record([
1100                    format!("#{}", group.group_id).blue().to_string(),
1101                    format!("#{} : {}", master.graph_id, master.name)
1102                        .green()
1103                        .bold()
1104                        .to_string(),
1105                    String::new(),
1106                    category_group_header(group, category),
1107                ]);
1108            }
1109        }
1110        next_row += 1;
1111        separator_rows.push(next_row);
1112
1113        for row in rows {
1114            match category {
1115                IntegrandDisplayCategory::LoopMomentumBasis => {
1116                    builder.push_record([
1117                        String::new(),
1118                        String::new(),
1119                        row.primary_id,
1120                        row.secondary_id,
1121                        row.value,
1122                    ]);
1123                }
1124                IntegrandDisplayCategory::Cuts => {
1125                    builder.push_record([
1126                        String::new(),
1127                        String::new(),
1128                        row.primary_id,
1129                        row.value,
1130                        row.secondary_id,
1131                        row.tertiary_id,
1132                    ]);
1133                }
1134                _ => {
1135                    builder.push_record([String::new(), String::new(), row.primary_id, row.value]);
1136                }
1137            }
1138            next_row += 1;
1139        }
1140        separator_rows.push(next_row);
1141    }
1142
1143    if inserted_blocks == 0 {
1144        return Ok(None);
1145    }
1146
1147    let mut table = builder.build();
1148    let mut style = Theme::from_style(Style::rounded().remove_horizontals());
1149    let mut unique_separator_rows = separator_rows.into_iter().unique().collect_vec();
1150    if let Some(last_row) = unique_separator_rows.pop() {
1151        for row in unique_separator_rows {
1152            style.insert_horizontal_line(
1153                row,
1154                HorizontalLine::new('─')
1155                    .intersection('┼')
1156                    .left('├')
1157                    .right('┤'),
1158            );
1159        }
1160        style.insert_horizontal_line(
1161            last_row,
1162            HorizontalLine::new('─')
1163                .intersection('┴')
1164                .left('╰')
1165                .right('╯'),
1166        );
1167    }
1168    table.with(style);
1169    Ok(Some(table.to_string()))
1170}
1171
1172fn render_generation_category(summary: Option<&IntegrandGenerationSummary>) -> String {
1173    summary
1174        .and_then(|summary| {
1175            render_generation_summary(&summary.reports, summary.peak_ram_bytes, None, None)
1176        })
1177        .unwrap_or_else(|| {
1178            "No generation summary has been recorded for this integrand."
1179                .yellow()
1180                .to_string()
1181        })
1182}
1183
1184fn render_integrand_detail_from_info(
1185    detail: &IntegrandInfo,
1186    artifact_sizes: &IntegrandArtifactSizes,
1187    generation_summary: Option<&IntegrandGenerationSummary>,
1188    requested_graphs: &[String],
1189    requested_categories: &[IntegrandDisplayCategory],
1190    hide_non_existing_thresholds: bool,
1191    show_threshold_functions: bool,
1192) -> Result<()> {
1193    info!(
1194        "{}",
1195        format!(
1196            "Integrand '{}' for process #{} ({})",
1197            detail.integrand_name, detail.process_id, detail.process_name
1198        )
1199        .bold()
1200        .blue()
1201    );
1202
1203    let summary_rows = vec![
1204        DetailSummaryRow {
1205            field: "kind".to_string(),
1206            value: detail.kind.to_string().yellow().to_string(),
1207        },
1208        DetailSummaryRow {
1209            field: "compile enabled".to_string(),
1210            value: detail
1211                .generation_compilation
1212                .compile_enabled()
1213                .to_string()
1214                .yellow()
1215                .to_string(),
1216        },
1217        DetailSummaryRow {
1218            field: "generation backend".to_string(),
1219            value: detail
1220                .generation_compilation
1221                .active_backend_name()
1222                .yellow()
1223                .to_string(),
1224        },
1225        DetailSummaryRow {
1226            field: "generation compile options".to_string(),
1227            value: detail
1228                .generation_compilation
1229                .external_options()
1230                .map(ToString::to_string)
1231                .unwrap_or_else(|| "(none)".to_string())
1232                .magenta()
1233                .to_string(),
1234        },
1235        DetailSummaryRow {
1236            field: "active f64 backend".to_string(),
1237            value: detail.active_f64_backend.to_string().yellow().to_string(),
1238        },
1239        DetailSummaryRow {
1240            field: "graphs".to_string(),
1241            value: detail.graph_count.to_string().yellow().to_string(),
1242        },
1243        DetailSummaryRow {
1244            field: "graph groups".to_string(),
1245            value: detail.graph_group_count.to_string().yellow().to_string(),
1246        },
1247        DetailSummaryRow {
1248            field: ".bin size".to_string(),
1249            value: format_artifact_size(artifact_sizes.bin_disk_size_bytes),
1250        },
1251        DetailSummaryRow {
1252            field: ".so size".to_string(),
1253            value: format_artifact_size(artifact_sizes.so_disk_size_bytes),
1254        },
1255    ];
1256    let mut summary_table = tabled::Table::new(summary_rows);
1257    summary_table.with(Style::rounded());
1258    info!(
1259        "
1260{summary_table}"
1261    );
1262
1263    let groups = filtered_graph_groups(detail, requested_graphs)?;
1264    let categories = selected_integrand_categories(detail, requested_categories)?;
1265    for category in categories {
1266        if category == IntegrandDisplayCategory::Generation {
1267            let table = render_generation_category(generation_summary);
1268            info!(
1269                "
1270{}
1271{table}",
1272                category.title().bold().blue()
1273            );
1274            continue;
1275        }
1276
1277        if let Some(table) =
1278            render_integrand_category_table(&groups, category, hide_non_existing_thresholds)?
1279        {
1280            info!(
1281                "
1282{}
1283{table}",
1284                category.title().bold().blue()
1285            );
1286        }
1287
1288        if category == IntegrandDisplayCategory::Cuts {
1289            if let Some(table) = render_integrand_thresholds_table(&groups, detail.kind)? {
1290                info!(
1291                    "
1292{}
1293{table}",
1294                    "Threshold esurfaces".bold().blue()
1295                );
1296            }
1297            if let Some(table) =
1298                render_threshold_counterterm_directives(&groups, show_threshold_functions)
1299            {
1300                info!(
1301                    "
1302{}
1303{table}",
1304                    "Threshold counterterm directives".bold().blue()
1305                );
1306            }
1307        }
1308    }
1309
1310    Ok(())
1311}
1312
1313fn render_processes_table(state: &State, state_folder: &Path) -> Result<()> {
1314    if state.process_list.processes.is_empty() {
1315        info!("{}", "No processes generated yet.".yellow());
1316        return Ok(());
1317    }
1318
1319    let mut builder = Builder::new();
1320    builder.push_record([
1321        "process #".bold().blue().to_string(),
1322        "process".bold().blue().to_string(),
1323        "# integrands".bold().blue().to_string(),
1324        "integrand names".bold().blue().to_string(),
1325        "graphs / integrand".bold().blue().to_string(),
1326        "graph groups / integrand".bold().blue().to_string(),
1327        ".bin size / integrand".bold().blue().to_string(),
1328        ".so size / integrand".bold().blue().to_string(),
1329    ]);
1330
1331    for process in &state.process_list.processes {
1332        let metrics = collect_integrand_metrics(state_folder, process)?;
1333        builder.push_record([
1334            format!("#{}", process.definition.process_id)
1335                .blue()
1336                .to_string(),
1337            process
1338                .definition
1339                .folder_name
1340                .clone()
1341                .green()
1342                .bold()
1343                .to_string(),
1344            metrics.len().to_string().yellow().to_string(),
1345            format_integrand_names(&metrics),
1346            format_metrics_column(&metrics, |metric| metric.graphs.to_string()),
1347            format_metrics_column(&metrics, |metric| metric.graph_groups.to_string()),
1348            format_metrics_column(&metrics, |metric| {
1349                format_artifact_size(metric.bin_disk_size_bytes)
1350            }),
1351            format_metrics_column(&metrics, |metric| {
1352                format_artifact_size(metric.so_disk_size_bytes)
1353            }),
1354        ]);
1355    }
1356
1357    let mut table = builder.build();
1358    table.with(Style::rounded());
1359    info!("{}", "Processes".bold().blue());
1360    info!("\n{table}");
1361    Ok(())
1362}
1363
1364fn render_integrands_table(
1365    state: &State,
1366    state_folder: &Path,
1367    process_filter: Option<usize>,
1368) -> Result<()> {
1369    if state.process_list.processes.is_empty() {
1370        info!("{}", "No processes generated yet.".yellow());
1371        return Ok(());
1372    }
1373
1374    let mut builder = Builder::new();
1375    builder.push_record([
1376        "process #".bold().blue().to_string(),
1377        "process".bold().blue().to_string(),
1378        "integrand".bold().blue().to_string(),
1379        "graphs".bold().blue().to_string(),
1380        "graph groups".bold().blue().to_string(),
1381        ".bin size".bold().blue().to_string(),
1382        ".so size".bold().blue().to_string(),
1383    ]);
1384
1385    let mut num_rows = 0usize;
1386    for (process_index, process) in state.process_list.processes.iter().enumerate() {
1387        if process_filter.is_some_and(|selected| selected != process_index) {
1388            continue;
1389        }
1390        let metrics = collect_integrand_metrics(state_folder, process)?;
1391        for metric in metrics {
1392            num_rows += 1;
1393            builder.push_record([
1394                format!("#{}", process.definition.process_id)
1395                    .blue()
1396                    .to_string(),
1397                process
1398                    .definition
1399                    .folder_name
1400                    .clone()
1401                    .green()
1402                    .bold()
1403                    .to_string(),
1404                metric.name.cyan().to_string(),
1405                metric.graphs.to_string().yellow().to_string(),
1406                metric.graph_groups.to_string().yellow().to_string(),
1407                format_artifact_size(metric.bin_disk_size_bytes),
1408                format_artifact_size(metric.so_disk_size_bytes),
1409            ]);
1410        }
1411    }
1412
1413    if num_rows == 0 {
1414        if let Some(process_id) = process_filter {
1415            let process = &state.process_list.processes[process_id];
1416            info!(
1417                "{}",
1418                format!(
1419                    "No integrands found for process #{} ({})",
1420                    process.definition.process_id, process.definition.folder_name
1421                )
1422                .yellow()
1423            );
1424        } else {
1425            info!("{}", "No integrands found.".yellow());
1426        }
1427        return Ok(());
1428    }
1429
1430    let mut table = builder.build();
1431    table.with(Style::rounded());
1432
1433    if let Some(process_id) = process_filter {
1434        let process = &state.process_list.processes[process_id];
1435        info!(
1436            "{}",
1437            format!(
1438                "Integrands for process #{} ({})",
1439                process.definition.process_id, process.definition.folder_name
1440            )
1441            .bold()
1442            .blue()
1443        );
1444    } else {
1445        info!("{}", "Integrands".bold().blue());
1446    }
1447    info!("\n{table}");
1448    Ok(())
1449}
1450
1451fn collect_integrand_metrics(
1452    state_folder: &Path,
1453    process: &Process,
1454) -> Result<Vec<IntegrandMetrics>> {
1455    match &process.collection {
1456        ProcessCollection::Amplitudes(amplitudes) => amplitudes
1457            .values()
1458            .map(|amplitude| integrand_metrics_from_amplitude(state_folder, process, amplitude))
1459            .collect(),
1460        ProcessCollection::CrossSections(cross_sections) => cross_sections
1461            .values()
1462            .map(|cross_section| {
1463                integrand_metrics_from_cross_section(state_folder, process, cross_section)
1464            })
1465            .collect(),
1466    }
1467}
1468
1469fn integrand_metrics_from_amplitude(
1470    state_folder: &Path,
1471    process: &Process,
1472    amplitude: &Amplitude,
1473) -> Result<IntegrandMetrics> {
1474    let artifact_sizes = collect_integrand_artifact_sizes(state_folder, process, &amplitude.name)?;
1475    Ok(IntegrandMetrics {
1476        name: amplitude.name.clone(),
1477        graphs: amplitude.graphs.len(),
1478        graph_groups: amplitude.graph_group_structure.len(),
1479        bin_disk_size_bytes: artifact_sizes.bin_disk_size_bytes,
1480        so_disk_size_bytes: artifact_sizes.so_disk_size_bytes,
1481    })
1482}
1483
1484fn integrand_metrics_from_cross_section(
1485    state_folder: &Path,
1486    process: &Process,
1487    cross_section: &CrossSection,
1488) -> Result<IntegrandMetrics> {
1489    let artifact_sizes =
1490        collect_integrand_artifact_sizes(state_folder, process, &cross_section.name)?;
1491    Ok(IntegrandMetrics {
1492        name: cross_section.name.clone(),
1493        graphs: cross_section.supergraphs.len(),
1494        graph_groups: cross_section.graph_group_structure.len(),
1495        bin_disk_size_bytes: artifact_sizes.bin_disk_size_bytes,
1496        so_disk_size_bytes: artifact_sizes.so_disk_size_bytes,
1497    })
1498}
1499
1500fn collect_integrand_artifact_sizes(
1501    state_folder: &Path,
1502    process: &Process,
1503    integrand_name: &str,
1504) -> Result<IntegrandArtifactSizes> {
1505    let artifact_root = integrand_artifact_root(state_folder, process, integrand_name);
1506    Ok(IntegrandArtifactSizes {
1507        bin_disk_size_bytes: disk_usage_for_extension(&artifact_root, "bin")?,
1508        so_disk_size_bytes: disk_usage_for_extension(&artifact_root, "so")?,
1509    })
1510}
1511
1512fn integrand_artifact_root(
1513    state_folder: &Path,
1514    process: &Process,
1515    integrand_name: &str,
1516) -> PathBuf {
1517    let collection_dir = match &process.collection {
1518        ProcessCollection::Amplitudes(_) => "amplitudes",
1519        ProcessCollection::CrossSections(_) => "cross_sections",
1520    };
1521    state_folder
1522        .join("processes")
1523        .join(collection_dir)
1524        .join(&process.definition.folder_name)
1525        .join(integrand_name)
1526}
1527
1528fn disk_usage_for_extension(root: &Path, extension: &str) -> Result<Option<u64>> {
1529    if !root.try_exists().with_context(|| {
1530        format!(
1531            "While checking saved integrand artifacts under {}",
1532            root.display()
1533        )
1534    })? {
1535        return Ok(None);
1536    }
1537
1538    let mut total = 0u64;
1539    for entry in WalkDir::new(root) {
1540        let entry = entry.with_context(|| {
1541            format!(
1542                "While walking saved integrand artifacts under {}",
1543                root.display()
1544            )
1545        })?;
1546        if !entry.file_type().is_file() || entry.path().extension() != Some(OsStr::new(extension)) {
1547            continue;
1548        }
1549
1550        let metadata = entry
1551            .metadata()
1552            .with_context(|| format!("While reading metadata for {}", entry.path().display()))?;
1553        total = total
1554            .checked_add(file_disk_usage_bytes(&metadata))
1555            .ok_or_else(|| {
1556                eyre!(
1557                    "Artifact size overflow while summing .{} files under {}",
1558                    extension,
1559                    root.display()
1560                )
1561            })?;
1562    }
1563    Ok(Some(total))
1564}
1565
1566fn file_disk_usage_bytes(metadata: &fs::Metadata) -> u64 {
1567    #[cfg(unix)]
1568    {
1569        use std::os::unix::fs::MetadataExt;
1570        metadata.blocks().saturating_mul(512)
1571    }
1572
1573    #[cfg(not(unix))]
1574    {
1575        metadata.len()
1576    }
1577}
1578
1579fn format_integrand_names(metrics: &[IntegrandMetrics]) -> String {
1580    if metrics.is_empty() {
1581        return "(none)".dimmed().to_string();
1582    }
1583    metrics
1584        .iter()
1585        .map(|metric| metric.name.clone().cyan().to_string())
1586        .collect::<Vec<_>>()
1587        .join("\n")
1588}
1589
1590fn format_metrics_column(
1591    metrics: &[IntegrandMetrics],
1592    format_value: impl Fn(&IntegrandMetrics) -> String,
1593) -> String {
1594    if metrics.is_empty() {
1595        return "(none)".dimmed().to_string();
1596    }
1597    metrics
1598        .iter()
1599        .map(|metric| format!("{}: {}", metric.name.cyan(), format_value(metric)))
1600        .collect::<Vec<_>>()
1601        .join("\n")
1602}
1603
1604fn format_artifact_size(size: Option<u64>) -> String {
1605    size.map(|size| format_bytes(size).magenta().to_string())
1606        .unwrap_or_else(|| "(not saved)".dimmed().to_string())
1607}
1608
1609fn format_bytes(size: u64) -> String {
1610    const KIB: u64 = 1024;
1611    const MIB: u64 = KIB * 1024;
1612    const GIB: u64 = MIB * 1024;
1613
1614    if size < KIB {
1615        format!("{size} B")
1616    } else if size < MIB {
1617        format!("{:.2} KiB", size as f64 / KIB as f64)
1618    } else if size < GIB {
1619        format!("{:.2} MiB", size as f64 / MIB as f64)
1620    } else {
1621        format!("{:.2} GiB", size as f64 / GIB as f64)
1622    }
1623}
1624
1625fn command_block_contents(block: &CommandsBlock) -> String {
1626    if block.commands.is_empty() {
1627        return "(empty)".to_string();
1628    }
1629
1630    block
1631        .commands
1632        .iter()
1633        .map(display_command)
1634        .collect::<Vec<_>>()
1635        .join("\n")
1636}
1637
1638fn render_command_blocks(run_history: &RunHistory, selected_name: Option<&str>) -> Result<()> {
1639    if let Some(name) = selected_name {
1640        let block = run_history.command_block(name).ok_or_else(|| {
1641            eyre!(
1642                "Unknown command block '{}'. Available command blocks: {}",
1643                name,
1644                run_history
1645                    .command_blocks
1646                    .iter()
1647                    .map(|block| block.name.as_str())
1648                    .collect::<Vec<_>>()
1649                    .join(", ")
1650            )
1651        })?;
1652        info!("\n{}", command_block_contents(block));
1653        return Ok(());
1654    }
1655
1656    if run_history.command_blocks.is_empty() {
1657        info!("{}", "No active command blocks.".yellow());
1658        return Ok(());
1659    }
1660
1661    info!("{}", "Command blocks".bold().blue());
1662    info!("\n{}", render_command_blocks_table(run_history));
1663    Ok(())
1664}
1665
1666fn render_command_blocks_table(run_history: &RunHistory) -> String {
1667    let mut builder = Builder::new();
1668    builder.push_record([
1669        "name".bold().blue().to_string(),
1670        "placeholders".bold().blue().to_string(),
1671        "commands".bold().blue().to_string(),
1672    ]);
1673
1674    for block in &run_history.command_blocks {
1675        let placeholders = run_history
1676            .command_block_placeholder_specs(&block.name)
1677            .into_iter()
1678            .map(|spec| spec.render())
1679            .collect::<Vec<_>>()
1680            .join("\n");
1681        builder.push_record([
1682            block.name.green().bold().to_string(),
1683            if placeholders.is_empty() {
1684                "-".to_string()
1685            } else {
1686                placeholders
1687            },
1688            command_block_contents(block),
1689        ]);
1690    }
1691
1692    let mut table = builder.build();
1693    let mut style = Theme::from_style(Style::rounded().remove_horizontals());
1694    for row in 1..=run_history.command_blocks.len() {
1695        style.insert_horizontal_line(
1696            row,
1697            HorizontalLine::new('─')
1698                .intersection('┼')
1699                .left('├')
1700                .right('┤'),
1701        );
1702    }
1703    table.with(style);
1704    table.to_string()
1705}
1706
1707fn render_named_process_settings(
1708    state: &State,
1709    target: &DisplayProcessNamedSettingsArgs,
1710    setting_kind: NamedProcessSettingKind,
1711) -> Result<()> {
1712    let process_id = state.resolve_process_ref(target.process.process.as_ref())?;
1713    let process = &state.process_list.processes[process_id];
1714    let integrand_names = selected_integrand_names(process, target.process.integrand_name.as_ref());
1715
1716    if let Some(name) = target.name.as_deref() {
1717        return render_named_process_setting_detail(
1718            state,
1719            process_id,
1720            &process.definition.folder_name,
1721            process.definition.process_id,
1722            &integrand_names,
1723            setting_kind,
1724            name,
1725        );
1726    }
1727
1728    let mut builder = Builder::new();
1729    let show_integrand = integrand_names.len() > 1;
1730    let mut header = Vec::new();
1731    if show_integrand {
1732        header.push("integrand".bold().blue().to_string());
1733    }
1734    header.push("name".bold().blue().to_string());
1735    header.push("kind".bold().blue().to_string());
1736    header.push("details".bold().blue().to_string());
1737    builder.push_record(header);
1738
1739    let mut num_rows = 0usize;
1740    for integrand_name in &integrand_names {
1741        let integrand = state
1742            .process_list
1743            .get_integrand(process_id, integrand_name)?
1744            .require_generated()?;
1745        match setting_kind {
1746            NamedProcessSettingKind::Quantity => {
1747                for (name, settings) in &integrand.get_settings().quantities {
1748                    num_rows += 1;
1749                    let mut record = Vec::new();
1750                    if show_integrand {
1751                        record.push(integrand_name.clone().cyan().to_string());
1752                    }
1753                    record.push(name.clone().green().to_string());
1754                    record.push(quantity_kind(settings).yellow().to_string());
1755                    record.push(summarize_quantity(settings));
1756                    builder.push_record(record);
1757                }
1758            }
1759            NamedProcessSettingKind::Observable => {
1760                for (name, settings) in &integrand.get_settings().observables {
1761                    num_rows += 1;
1762                    let mut record = Vec::new();
1763                    if show_integrand {
1764                        record.push(integrand_name.clone().cyan().to_string());
1765                    }
1766                    record.push(name.clone().green().to_string());
1767                    record.push(observable_kind(settings).yellow().to_string());
1768                    record.push(summarize_observable(settings));
1769                    builder.push_record(record);
1770                }
1771            }
1772            NamedProcessSettingKind::Selector => {
1773                for (name, settings) in &integrand.get_settings().selectors {
1774                    num_rows += 1;
1775                    let mut record = Vec::new();
1776                    if show_integrand {
1777                        record.push(integrand_name.clone().cyan().to_string());
1778                    }
1779                    record.push(name.clone().green().to_string());
1780                    record.push(selector_kind(settings).yellow().to_string());
1781                    record.push(summarize_selector(settings));
1782                    builder.push_record(record);
1783                }
1784            }
1785        }
1786    }
1787
1788    if num_rows == 0 {
1789        info!(
1790            "{}",
1791            format!(
1792                "No {} configured for process #{} ({})",
1793                setting_kind.plural(),
1794                process.definition.process_id,
1795                process.definition.folder_name
1796            )
1797            .yellow()
1798        );
1799        return Ok(());
1800    }
1801
1802    let mut table = builder.build();
1803    table.with(Style::rounded());
1804    let title = if show_integrand {
1805        format!(
1806            "{} for process #{} ({})",
1807            setting_kind.plural(),
1808            process.definition.process_id,
1809            process.definition.folder_name
1810        )
1811    } else {
1812        format!(
1813            "{} for process #{} ({}) integrand '{}'",
1814            setting_kind.plural(),
1815            process.definition.process_id,
1816            process.definition.folder_name,
1817            integrand_names[0]
1818        )
1819    };
1820    info!("{}", title.bold().blue());
1821    info!("\n{table}");
1822    Ok(())
1823}
1824
1825fn render_named_process_setting_detail(
1826    state: &State,
1827    process_id: usize,
1828    process_name: &str,
1829    process_display_id: usize,
1830    integrand_names: &[String],
1831    setting_kind: NamedProcessSettingKind,
1832    name: &str,
1833) -> Result<()> {
1834    let mut selected_roots = Vec::new();
1835    let mut missing_integrands = Vec::new();
1836
1837    for integrand_name in integrand_names {
1838        let integrand = state
1839            .process_list
1840            .get_integrand(process_id, integrand_name)?
1841            .require_generated()?;
1842        let serialized = serialize_runtime_named_settings(integrand.get_settings())?;
1843        let map = match setting_kind {
1844            NamedProcessSettingKind::Quantity => &serialized.quantities,
1845            NamedProcessSettingKind::Observable => &serialized.observables,
1846            NamedProcessSettingKind::Selector => &serialized.selectors,
1847        };
1848        if let Some(root) = map.get(name) {
1849            selected_roots.push((integrand_name.clone(), root.clone()));
1850        } else {
1851            missing_integrands.push(integrand_name.clone());
1852        }
1853    }
1854
1855    if selected_roots.is_empty() {
1856        return Err(eyre!(
1857            "No {} named '{}' found for process #{} ({})",
1858            setting_kind.singular(),
1859            name,
1860            process_display_id,
1861            process_name
1862        ));
1863    }
1864
1865    if !missing_integrands.is_empty() {
1866        return Err(eyre!(
1867            "{} '{}' is missing from integrand(s): {}",
1868            setting_kind.singular(),
1869            name,
1870            missing_integrands.join(", ")
1871        ));
1872    }
1873
1874    for (integrand_name, root) in selected_roots {
1875        render_settings_table(
1876            &format!(
1877                "{} '{}' for process #{} ({}) integrand '{}'",
1878                setting_kind.singular(),
1879                name,
1880                process_display_id,
1881                process_name,
1882                integrand_name
1883            ),
1884            &root,
1885            None,
1886        )?;
1887    }
1888
1889    Ok(())
1890}
1891
1892fn selected_integrand_names(process: &Process, requested: Option<&String>) -> Vec<String> {
1893    if let Some(integrand_name) = requested {
1894        vec![integrand_name.clone()]
1895    } else {
1896        process
1897            .get_integrand_names()
1898            .into_iter()
1899            .map(str::to_string)
1900            .collect()
1901    }
1902}
1903
1904impl DisplaySettingsTarget {
1905    fn run(
1906        &self,
1907        state: &State,
1908        global_settings: &CLISettings,
1909        default_runtime_settings: &RuntimeSettings,
1910    ) -> Result<()> {
1911        match self {
1912            DisplaySettingsTarget::Global { path } => {
1913                let root = serialize_settings_with_defaults(
1914                    global_settings,
1915                    "global settings for display",
1916                )?;
1917                render_settings_table("global settings", &root, path.as_deref())?;
1918            }
1919            DisplaySettingsTarget::DefaultRuntime { path } => {
1920                let root = serialize_settings_with_defaults(
1921                    default_runtime_settings,
1922                    "default runtime settings for display",
1923                )?;
1924                render_settings_table("default runtime settings", &root, path.as_deref())?;
1925            }
1926            DisplaySettingsTarget::Process { process, path } => {
1927                let process_id = state.resolve_process_ref(process.process.as_ref())?;
1928                let process_ref = &state.process_list.processes[process_id];
1929                if let Some(name) = &process.integrand_name {
1930                    let integrand = state
1931                        .process_list
1932                        .get_integrand(process_id, name)?
1933                        .require_generated()?;
1934                    let settings = serialize_settings_with_defaults(
1935                        integrand.get_settings(),
1936                        "process runtime settings for display",
1937                    )?;
1938                    render_settings_table(
1939                        &format!(
1940                            "process settings for #{} ({}) integrand '{}'",
1941                            process_ref.definition.process_id,
1942                            process_ref.definition.folder_name,
1943                            name
1944                        ),
1945                        &settings,
1946                        path.as_deref(),
1947                    )?;
1948                } else {
1949                    for integrand_name in process_ref.get_integrand_names() {
1950                        let integrand = state
1951                            .process_list
1952                            .get_integrand(process_id, integrand_name)?
1953                            .require_generated()?;
1954                        let settings = serialize_settings_with_defaults(
1955                            integrand.get_settings(),
1956                            "process runtime settings for display",
1957                        )?;
1958                        render_settings_table(
1959                            &format!(
1960                                "process settings for #{} ({}) integrand '{}'",
1961                                process_ref.definition.process_id,
1962                                process_ref.definition.folder_name,
1963                                integrand_name
1964                            ),
1965                            &settings,
1966                            path.as_deref(),
1967                        )?;
1968                    }
1969                }
1970            }
1971        }
1972        Ok(())
1973    }
1974}
1975
1976fn render_settings_table(title: &str, root: &JsonValue, key_path: Option<&str>) -> Result<()> {
1977    let key_path = key_path.map(str::trim).filter(|path| !path.is_empty());
1978    let selected = if let Some(path) = key_path {
1979        value_at_path(root, path)?
1980    } else {
1981        root
1982    };
1983    let mut builder = Builder::new();
1984    builder.push_record(["key", "value"]);
1985
1986    match selected {
1987        JsonValue::Object(map) => {
1988            let mut keys: Vec<_> = map.keys().cloned().collect();
1989            keys.sort();
1990            for key in keys {
1991                let value = map
1992                    .get(&key)
1993                    .expect("sorted keys were derived from the same map");
1994                builder.push_record([key, format_json_value(value)]);
1995            }
1996        }
1997        JsonValue::Array(array) => {
1998            if array.is_empty() {
1999                builder.push_record(["(empty)".to_string(), "[]".to_string()]);
2000            } else {
2001                for (index, value) in array.iter().enumerate() {
2002                    builder.push_record([index.to_string(), format_json_value(value)]);
2003                }
2004            }
2005        }
2006        primitive => {
2007            builder.push_record(["value".to_string(), format_json_value(primitive)]);
2008        }
2009    }
2010
2011    let mut table = builder.build();
2012    table.with(Style::rounded());
2013    if let Some(path) = key_path {
2014        info!("{title} (path: {path}):");
2015    } else {
2016        info!("{title}:");
2017    }
2018    info!("\n{table}");
2019    Ok(())
2020}
2021
2022fn format_json_value(value: &JsonValue) -> String {
2023    match value {
2024        JsonValue::Null => "null".to_string(),
2025        JsonValue::Bool(_) | JsonValue::Number(_) => value.to_string(),
2026        JsonValue::String(s) => s.clone(),
2027        _ => serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string()),
2028    }
2029}
2030
2031#[cfg(test)]
2032mod test {
2033    use clap::Parser;
2034    use serde_json::json;
2035
2036    use crate::{
2037        commands::Commands,
2038        integrand_info::{
2039            IntegrandActiveThresholdCutInfo, IntegrandCutThresholdInfo,
2040            IntegrandEsurfaceClassification, IntegrandGraphGroupInfo, IntegrandGraphInfo,
2041            IntegrandKind, IntegrandThresholdCountertermDirectiveInfo,
2042            IntegrandThresholdEsurfaceInfo, IntegrandThresholdStatus,
2043        },
2044        state::{CommandHistory, CommandsBlock, ProcessRef, RunHistory, State},
2045        CLISettings, Repl,
2046    };
2047    use gammalooprs::{
2048        processes::{
2049            ThresholdCountertermAssociationMetadata, ThresholdCountertermMetadataRegistry,
2050            ThresholdCountertermMultiplierMetadata, ThresholdCountertermOrigin,
2051            ThresholdCountertermSide, ThresholdCountertermVariantMetadata,
2052        },
2053        settings::RuntimeSettings,
2054    };
2055
2056    use super::{
2057        command_block_contents, format_bytes, render_command_blocks_table,
2058        render_integrand_thresholds_table, render_threshold_counterterm_directives,
2059        render_threshold_counterterms, serialize_settings_with_defaults, value_at_path, Display,
2060        DisplaySettingsTarget, IntegrandDisplayCategory, MetadataDisplayFormat,
2061    };
2062
2063    #[test]
2064    fn threshold_rendering_dims_or_hides_non_existing_associations() {
2065        let thresholds = vec![
2066            IntegrandCutThresholdInfo {
2067                esurface_id: 71,
2068                status: IntegrandThresholdStatus::CanBecomePinched,
2069                cut_boundary_edge_ids: vec![1, 2],
2070                threshold_boundary_edge_ids: vec![3, 4],
2071                invariant_bound_is_applicable: true,
2072            },
2073            IntegrandCutThresholdInfo {
2074                esurface_id: 83,
2075                status: IntegrandThresholdStatus::ProvenNonExisting,
2076                cut_boundary_edge_ids: vec![1, 2],
2077                threshold_boundary_edge_ids: vec![5],
2078                invariant_bound_is_applicable: true,
2079            },
2080        ];
2081
2082        let shown = render_threshold_counterterms(&thresholds, false);
2083        assert!(shown.contains("71*"));
2084        assert!(shown.contains("83"));
2085
2086        let hidden = render_threshold_counterterms(&thresholds, true);
2087        assert!(hidden.contains("71*"));
2088        assert!(!hidden.contains("83"));
2089    }
2090
2091    #[test]
2092    fn threshold_inventory_lists_active_cut_and_pinch_marker() {
2093        let group = IntegrandGraphGroupInfo {
2094            group_id: 0,
2095            graphs: vec![IntegrandGraphInfo {
2096                graph_id: 0,
2097                name: "generic".to_string(),
2098                is_master: true,
2099                threshold_counterterm_directives: Vec::new(),
2100                threshold_counterterms: None,
2101            }],
2102            orientation_edge_ids: Vec::new(),
2103            orientations: Vec::new(),
2104            loop_momentum_bases: Vec::new(),
2105            threshold_esurface_ids: vec![5],
2106            threshold_esurfaces: vec![IntegrandThresholdEsurfaceInfo {
2107                esurface_id: 5,
2108                representative_graph_id: 0,
2109                edge_ids: vec![1, 2],
2110                classification: None,
2111                active_cuts: vec![IntegrandActiveThresholdCutInfo {
2112                    cut_id: 7,
2113                    can_become_pinched: true,
2114                }],
2115            }],
2116            cuts: Vec::new(),
2117        };
2118
2119        let rendered = render_integrand_thresholds_table(&[&group], IntegrandKind::CrossSection)
2120            .unwrap()
2121            .unwrap();
2122        assert!(rendered.contains("Active in cuts"));
2123        assert!(rendered.contains("#7*"));
2124    }
2125
2126    #[test]
2127    fn amplitude_threshold_inventory_reports_source_graph_and_classification() {
2128        let group = IntegrandGraphGroupInfo {
2129            group_id: 0,
2130            graphs: vec![
2131                IntegrandGraphInfo {
2132                    graph_id: 0,
2133                    name: "master".to_string(),
2134                    is_master: true,
2135                    threshold_counterterm_directives: Vec::new(),
2136                    threshold_counterterms: None,
2137                },
2138                IntegrandGraphInfo {
2139                    graph_id: 1,
2140                    name: "member".to_string(),
2141                    is_master: false,
2142                    threshold_counterterm_directives: Vec::new(),
2143                    threshold_counterterms: None,
2144                },
2145            ],
2146            orientation_edge_ids: Vec::new(),
2147            orientations: Vec::new(),
2148            loop_momentum_bases: Vec::new(),
2149            threshold_esurface_ids: vec![5],
2150            threshold_esurfaces: vec![IntegrandThresholdEsurfaceInfo {
2151                esurface_id: 5,
2152                representative_graph_id: 1,
2153                edge_ids: vec![1, 2],
2154                classification: Some(IntegrandEsurfaceClassification::Pinched),
2155                active_cuts: Vec::new(),
2156            }],
2157            cuts: Vec::new(),
2158        };
2159
2160        let rendered = render_integrand_thresholds_table(&[&group], IntegrandKind::Amplitude)
2161            .unwrap()
2162            .unwrap();
2163        assert!(rendered.contains("Classification"));
2164        assert!(!rendered.contains("Active in cuts"));
2165        assert!(rendered.contains("#1 : member"));
2166        assert!(rendered.contains("pinched"));
2167    }
2168
2169    #[test]
2170    fn threshold_counterterm_directive_display_is_compact_and_expression_optional() {
2171        let amplitude_registry = ThresholdCountertermMetadataRegistry {
2172            graph_name: "amp".to_string(),
2173            variants: vec![ThresholdCountertermVariantMetadata {
2174                variant_id: 0,
2175                name: "forced".to_string(),
2176                group_id: None,
2177                cut_group_id: None,
2178                associations: vec![ThresholdCountertermAssociationMetadata {
2179                    cut_id: None,
2180                    cut_edges: Vec::new(),
2181                    threshold_edges: vec![3, 4],
2182                    esurface_id: 2,
2183                    eligible: true,
2184                    origin: ThresholdCountertermOrigin::Explicit,
2185                }],
2186                side: ThresholdCountertermSide::Amplitude,
2187                threshold_esurface_ids: vec![2],
2188                requested_subspace: Some(vec![3]),
2189                resolved_subspace: vec![3],
2190                requested_parent_lmb: None,
2191                resolved_parent_lmb: vec![3],
2192                subspace_loop_count: 1,
2193                multiplier: Some(ThresholdCountertermMultiplierMetadata {
2194                    expression: "eta(effective, eset(3, 4))^2".to_string(),
2195                    function_map: Default::default(),
2196                    symmetrize: false,
2197                    opaque_derivatives: true,
2198                }),
2199                generated: true,
2200                active: true,
2201            }],
2202            evaluators: Vec::new(),
2203            components: Vec::new(),
2204        };
2205        let cross_registry = ThresholdCountertermMetadataRegistry {
2206            graph_name: "cross".to_string(),
2207            variants: vec![ThresholdCountertermVariantMetadata {
2208                variant_id: 0,
2209                name: "intrinsic".to_string(),
2210                group_id: None,
2211                cut_group_id: Some(0),
2212                associations: vec![ThresholdCountertermAssociationMetadata {
2213                    cut_id: Some(0),
2214                    cut_edges: vec![2, 4, 12],
2215                    threshold_edges: vec![7, 8],
2216                    esurface_id: 9,
2217                    eligible: true,
2218                    origin: ThresholdCountertermOrigin::Explicit,
2219                }],
2220                side: ThresholdCountertermSide::Left,
2221                threshold_esurface_ids: vec![9],
2222                requested_subspace: Some(vec![7]),
2223                resolved_subspace: vec![7],
2224                requested_parent_lmb: Some(vec![3, 4, 7, 10]),
2225                resolved_parent_lmb: vec![3, 4, 7, 10],
2226                subspace_loop_count: 1,
2227                multiplier: None,
2228                generated: true,
2229                active: true,
2230            }],
2231            evaluators: Vec::new(),
2232            components: Vec::new(),
2233        };
2234        let group = IntegrandGraphGroupInfo {
2235            group_id: 0,
2236            graphs: vec![
2237                IntegrandGraphInfo {
2238                    graph_id: 0,
2239                    name: "amp".to_string(),
2240                    is_master: true,
2241                    threshold_counterterm_directives: vec![
2242                        IntegrandThresholdCountertermDirectiveInfo {
2243                            cut_edge_ids: Vec::new(),
2244                            threshold_edge_ids: vec![3, 4],
2245                            name: "forced".to_string(),
2246                            implicit_default: false,
2247                            requested_subspace: Some(vec![3]),
2248                            requested_parent_lmb: None,
2249                            disabled: false,
2250                            multiplier: Some(ThresholdCountertermMultiplierMetadata {
2251                                expression: "eta(effective, eset(3, 4))^2".to_string(),
2252                                function_map: Default::default(),
2253                                symmetrize: false,
2254                                opaque_derivatives: true,
2255                            }),
2256                        },
2257                    ],
2258                    threshold_counterterms: Some(amplitude_registry),
2259                },
2260                IntegrandGraphInfo {
2261                    graph_id: 1,
2262                    name: "cross".to_string(),
2263                    is_master: false,
2264                    threshold_counterterm_directives: vec![
2265                        IntegrandThresholdCountertermDirectiveInfo {
2266                            cut_edge_ids: vec![2, 4, 12],
2267                            threshold_edge_ids: vec![7, 8],
2268                            name: "intrinsic".to_string(),
2269                            implicit_default: false,
2270                            requested_subspace: Some(vec![7]),
2271                            requested_parent_lmb: Some(vec![3, 4, 7, 10]),
2272                            disabled: false,
2273                            multiplier: None,
2274                        },
2275                        IntegrandThresholdCountertermDirectiveInfo {
2276                            cut_edge_ids: vec![2, 4, 12],
2277                            threshold_edge_ids: vec![7, 8],
2278                            name: "off".to_string(),
2279                            implicit_default: false,
2280                            requested_subspace: None,
2281                            requested_parent_lmb: None,
2282                            disabled: true,
2283                            multiplier: None,
2284                        },
2285                    ],
2286                    threshold_counterterms: Some(cross_registry),
2287                },
2288            ],
2289            orientation_edge_ids: Vec::new(),
2290            orientations: Vec::new(),
2291            loop_momentum_bases: Vec::new(),
2292            threshold_esurface_ids: Vec::new(),
2293            threshold_esurfaces: Vec::new(),
2294            cuts: Vec::new(),
2295        };
2296
2297        let compact = render_threshold_counterterm_directives(&[&group], false).unwrap();
2298        assert!(compact.contains("#0 amp"));
2299        assert!(compact.contains("#1 cross"));
2300        assert!(compact.contains('∅'));
2301        assert!(compact.contains("● forced"));
2302        assert!(compact.contains('ƒ'));
2303        assert!(compact.contains("× off"));
2304        assert!(!compact.contains("eta(effective"));
2305        assert!(
2306            !compact.contains("\u{1b}["),
2307            "colour-disabled output: {compact}"
2308        );
2309
2310        let expanded = render_threshold_counterterm_directives(&[&group], true).unwrap();
2311        assert!(expanded.contains("eta(effective, eset(3, 4))^2 [s=0,o=1]"));
2312    }
2313
2314    #[test]
2315    fn threshold_counterterm_directive_display_omits_empty_specifications() {
2316        let group = IntegrandGraphGroupInfo {
2317            group_id: 0,
2318            graphs: vec![IntegrandGraphInfo {
2319                graph_id: 0,
2320                name: "legacy".to_string(),
2321                is_master: true,
2322                threshold_counterterm_directives: Vec::new(),
2323                threshold_counterterms: Some(ThresholdCountertermMetadataRegistry {
2324                    graph_name: "legacy".to_string(),
2325                    variants: Vec::new(),
2326                    evaluators: Vec::new(),
2327                    components: Vec::new(),
2328                }),
2329            }],
2330            orientation_edge_ids: Vec::new(),
2331            orientations: Vec::new(),
2332            loop_momentum_bases: Vec::new(),
2333            threshold_esurface_ids: Vec::new(),
2334            threshold_esurfaces: Vec::new(),
2335            cuts: Vec::new(),
2336        };
2337
2338        assert!(render_threshold_counterterm_directives(&[&group], false).is_none());
2339    }
2340
2341    #[test]
2342    fn parse_display_settings_process_with_path() {
2343        let repl = Repl::try_parse_from([
2344            "gammaloop",
2345            "display",
2346            "settings",
2347            "process",
2348            "-p",
2349            "epem_a_tth",
2350            "-i",
2351            "LO",
2352            "integrator.n_max",
2353        ])
2354        .unwrap();
2355
2356        match repl.command {
2357            Commands::Display(Display::Settings { target }) => match target {
2358                DisplaySettingsTarget::Process { process, path } => {
2359                    assert_eq!(
2360                        process.process,
2361                        Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
2362                    );
2363                    assert_eq!(process.integrand_name, Some("LO".to_string()));
2364                    assert_eq!(path.as_deref(), Some("integrator.n_max"));
2365                }
2366                other => panic!("Expected process target, got {other:?}"),
2367            },
2368            other => panic!("Expected display settings command, got {other:?}"),
2369        }
2370    }
2371
2372    #[test]
2373    fn parse_display_settings_process_without_path() {
2374        let repl = Repl::try_parse_from([
2375            "gammaloop",
2376            "display",
2377            "settings",
2378            "process",
2379            "-p",
2380            "epem_a_tth",
2381            "-i",
2382            "LO",
2383        ])
2384        .unwrap();
2385
2386        match repl.command {
2387            Commands::Display(Display::Settings { target }) => match target {
2388                DisplaySettingsTarget::Process { path, .. } => {
2389                    assert_eq!(path, None);
2390                }
2391                other => panic!("Expected process target, got {other:?}"),
2392            },
2393            other => panic!("Expected display settings command, got {other:?}"),
2394        }
2395    }
2396
2397    #[test]
2398    fn parse_display_model_target() {
2399        let repl = Repl::try_parse_from([
2400            "gammaloop",
2401            "display",
2402            "model",
2403            "-p",
2404            "epem_a_tth",
2405            "-i",
2406            "LO",
2407            "--show-particles",
2408        ])
2409        .unwrap();
2410
2411        match repl.command {
2412            Commands::Display(Display::Model {
2413                process,
2414                integrand_name,
2415                show_particles,
2416                ..
2417            }) => {
2418                assert_eq!(
2419                    process,
2420                    Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
2421                );
2422                assert_eq!(integrand_name, Some("LO".to_string()));
2423                assert!(show_particles);
2424            }
2425            other => panic!("Expected display model command, got {other:?}"),
2426        }
2427    }
2428
2429    #[test]
2430    fn display_model_requires_process_and_integrand_together() {
2431        let err = Repl::try_parse_from(["gammaloop", "display", "model", "-p", "epem_a_tth"])
2432            .unwrap_err();
2433        assert!(err.to_string().contains("--integrand-name"));
2434    }
2435
2436    #[test]
2437    fn parse_display_quantities_without_name() {
2438        let repl = Repl::try_parse_from([
2439            "gammaloop",
2440            "display",
2441            "quantities",
2442            "-p",
2443            "epem_a_tth",
2444            "-i",
2445            "LO",
2446        ])
2447        .unwrap();
2448
2449        match repl.command {
2450            Commands::Display(Display::Quantities { target }) => {
2451                assert_eq!(
2452                    target.process.process,
2453                    Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
2454                );
2455                assert_eq!(target.process.integrand_name, Some("LO".to_string()));
2456                assert_eq!(target.name, None);
2457            }
2458            other => panic!("Expected display quantities command, got {other:?}"),
2459        }
2460    }
2461
2462    #[test]
2463    fn parse_display_selector_with_name() {
2464        let repl = Repl::try_parse_from([
2465            "gammaloop",
2466            "display",
2467            "selectors",
2468            "-p",
2469            "epem_a_tth",
2470            "top_cut",
2471        ])
2472        .unwrap();
2473
2474        match repl.command {
2475            Commands::Display(Display::Selectors { target }) => {
2476                assert_eq!(
2477                    target.process.process,
2478                    Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
2479                );
2480                assert_eq!(target.process.integrand_name, None);
2481                assert_eq!(target.name.as_deref(), Some("top_cut"));
2482            }
2483            other => panic!("Expected display selectors command, got {other:?}"),
2484        }
2485    }
2486
2487    #[test]
2488    fn parse_display_command_block_without_name() {
2489        let repl = Repl::try_parse_from(["gammaloop", "display", "command_block"]).unwrap();
2490
2491        match repl.command {
2492            Commands::Display(Display::CommandBlock { name }) => {
2493                assert_eq!(name, None);
2494            }
2495            other => panic!("Expected display command_block command, got {other:?}"),
2496        }
2497    }
2498
2499    #[test]
2500    fn parse_display_command_block_with_name() {
2501        let repl =
2502            Repl::try_parse_from(["gammaloop", "display", "command_block", "alpha"]).unwrap();
2503
2504        match repl.command {
2505            Commands::Display(Display::CommandBlock { name }) => {
2506                assert_eq!(name.as_deref(), Some("alpha"));
2507            }
2508            other => panic!("Expected display command_block command, got {other:?}"),
2509        }
2510    }
2511
2512    #[test]
2513    fn parse_display_command_block_rejects_hyphenated_name() {
2514        assert!(Repl::try_parse_from(["gammaloop", "display", "command-block"]).is_err());
2515    }
2516
2517    #[test]
2518    fn parse_display_settings_global_without_query() {
2519        let repl = Repl::try_parse_from(["gammaloop", "display", "settings", "global"]).unwrap();
2520
2521        match repl.command {
2522            Commands::Display(Display::Settings { target }) => match target {
2523                DisplaySettingsTarget::Global { path } => {
2524                    assert_eq!(path, None);
2525                }
2526                other => panic!("Expected global target, got {other:?}"),
2527            },
2528            other => panic!("Expected display settings command, got {other:?}"),
2529        }
2530    }
2531
2532    #[test]
2533    fn parse_display_settings_defaults_alias() {
2534        let repl = Repl::try_parse_from([
2535            "gammaloop",
2536            "display",
2537            "settings",
2538            "defaults",
2539            "integrator.n_start",
2540        ])
2541        .unwrap();
2542
2543        match repl.command {
2544            Commands::Display(Display::Settings { target }) => match target {
2545                DisplaySettingsTarget::DefaultRuntime { path } => {
2546                    assert_eq!(path.as_deref(), Some("integrator.n_start"));
2547                }
2548                other => panic!("Expected default runtime target, got {other:?}"),
2549            },
2550            other => panic!("Expected display settings command, got {other:?}"),
2551        }
2552    }
2553
2554    #[test]
2555    fn value_lookup_supports_nested_paths() {
2556        let value = json!({
2557            "integrator": {
2558                "n_max": 42
2559            }
2560        });
2561        let found = value_at_path(&value, "integrator.n_max").unwrap();
2562        assert_eq!(found, &json!(42));
2563    }
2564
2565    #[test]
2566    fn serialized_settings_include_default_values() {
2567        let global = CLISettings::default();
2568        let global_json = serialize_settings_with_defaults(&global, "global settings").unwrap();
2569        assert!(value_at_path(&global_json, "global.n_cores.generate").is_ok());
2570
2571        let runtime = RuntimeSettings::default();
2572        let runtime_json =
2573            serialize_settings_with_defaults(&runtime, "default runtime settings").unwrap();
2574        assert!(value_at_path(&runtime_json, "integrator.n_start").is_ok());
2575    }
2576
2577    #[test]
2578    fn parse_display_integrand_without_process() {
2579        let repl = Repl::try_parse_from(["gammaloop", "display", "integrand"]).unwrap();
2580
2581        match repl.command {
2582            Commands::Display(Display::Integrands {
2583                process,
2584                integrand_name,
2585                graphs,
2586                categories,
2587                hide_non_existing_thresholds,
2588                show_threshold_functions,
2589                show_sampling,
2590                show_threshold_subtraction,
2591            }) => {
2592                assert_eq!(process, None);
2593                assert_eq!(integrand_name, None);
2594                assert!(graphs.is_empty());
2595                assert!(categories.is_empty());
2596                assert!(!hide_non_existing_thresholds);
2597                assert!(!show_threshold_functions);
2598                assert_eq!(show_sampling, None);
2599                assert_eq!(show_threshold_subtraction, None);
2600            }
2601            other => panic!("Expected display integrand command, got {other:?}"),
2602        }
2603    }
2604
2605    #[test]
2606    fn structured_display_integrand_defaults_missing_threshold_options() {
2607        let mut value = serde_json::to_value(Display::Integrands {
2608            process: None,
2609            integrand_name: None,
2610            graphs: Vec::new(),
2611            categories: Vec::new(),
2612            hide_non_existing_thresholds: false,
2613            show_threshold_functions: false,
2614            show_sampling: None,
2615            show_threshold_subtraction: None,
2616        })
2617        .unwrap();
2618        value
2619            .get_mut("Integrands")
2620            .and_then(serde_json::Value::as_object_mut)
2621            .unwrap()
2622            .remove("hide_non_existing_thresholds");
2623        value
2624            .get_mut("Integrands")
2625            .and_then(serde_json::Value::as_object_mut)
2626            .unwrap()
2627            .remove("show_threshold_functions");
2628
2629        let display: Display = serde_json::from_value(value).unwrap();
2630        assert!(matches!(
2631            display,
2632            Display::Integrands {
2633                hide_non_existing_thresholds: false,
2634                show_threshold_functions: false,
2635                show_sampling: None,
2636                show_threshold_subtraction: None,
2637                ..
2638            }
2639        ));
2640    }
2641
2642    #[test]
2643    fn parse_display_integrand_detail() {
2644        let repl = Repl::try_parse_from([
2645            "gammaloop",
2646            "display",
2647            "integrand",
2648            "-p",
2649            "epem_a_tth",
2650            "-i",
2651            "LO",
2652        ])
2653        .unwrap();
2654
2655        match repl.command {
2656            Commands::Display(Display::Integrands {
2657                process,
2658                integrand_name,
2659                graphs,
2660                categories,
2661                hide_non_existing_thresholds,
2662                show_threshold_functions,
2663                show_sampling,
2664                show_threshold_subtraction,
2665            }) => {
2666                assert_eq!(
2667                    process,
2668                    Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
2669                );
2670                assert_eq!(integrand_name.as_deref(), Some("LO"));
2671                assert!(graphs.is_empty());
2672                assert!(categories.is_empty());
2673                assert!(!hide_non_existing_thresholds);
2674                assert!(!show_threshold_functions);
2675                assert_eq!(show_sampling, None);
2676                assert_eq!(show_threshold_subtraction, None);
2677            }
2678            other => panic!("Expected display integrand command, got {other:?}"),
2679        }
2680    }
2681
2682    #[test]
2683    fn parse_display_integrand_detail_filters() {
2684        let repl = Repl::try_parse_from([
2685            "gammaloop",
2686            "display",
2687            "integrand",
2688            "-p",
2689            "epem_a_tth",
2690            "-i",
2691            "LO",
2692            "-g",
2693            "GL0",
2694            "GL2",
2695            "--category",
2696            "generation",
2697            "orientation",
2698            "cuts",
2699            "--hide-non-existing-thresholds",
2700            "--show-threshold-functions",
2701        ])
2702        .unwrap();
2703
2704        match repl.command {
2705            Commands::Display(Display::Integrands {
2706                process,
2707                integrand_name,
2708                graphs,
2709                categories,
2710                hide_non_existing_thresholds,
2711                show_threshold_functions,
2712                show_sampling,
2713                show_threshold_subtraction,
2714            }) => {
2715                assert_eq!(
2716                    process,
2717                    Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
2718                );
2719                assert!(hide_non_existing_thresholds);
2720                assert!(show_threshold_functions);
2721                assert_eq!(show_sampling, None);
2722                assert_eq!(show_threshold_subtraction, None);
2723                assert_eq!(integrand_name.as_deref(), Some("LO"));
2724                assert_eq!(graphs, vec!["GL0".to_string(), "GL2".to_string()]);
2725                assert_eq!(
2726                    categories,
2727                    vec![
2728                        IntegrandDisplayCategory::Generation,
2729                        IntegrandDisplayCategory::Orientation,
2730                        IntegrandDisplayCategory::Cuts,
2731                    ]
2732                );
2733            }
2734            other => panic!("Expected display integrand command, got {other:?}"),
2735        }
2736    }
2737
2738    #[test]
2739    fn metadata_display_parses_formats_aliases_and_graph_lists() {
2740        for command in ["integrand", "integrands"] {
2741            for (flag, sampling) in [
2742                ("--show_sampling", true),
2743                ("--show-sampling", true),
2744                ("--show_threshold_subtraction", false),
2745                ("--show-threshold-subtraction", false),
2746            ] {
2747                for (value, format) in [
2748                    ("pretty", MetadataDisplayFormat::Pretty),
2749                    ("toml", MetadataDisplayFormat::Toml),
2750                ] {
2751                    let mut args = vec![
2752                        "gammaloop",
2753                        "display",
2754                        command,
2755                        "-p",
2756                        "ttH",
2757                        "-i",
2758                        "NNLO",
2759                        flag,
2760                        value,
2761                    ];
2762                    if sampling || format == MetadataDisplayFormat::Pretty {
2763                        args.extend(["--graph", "GL297", "GL638"]);
2764                    } else {
2765                        args.extend(["--graph", "GL638"]);
2766                    }
2767                    let Commands::Display(display) = Repl::try_parse_from(args).unwrap().command
2768                    else {
2769                        panic!("expected display");
2770                    };
2771                    let Display::Integrands {
2772                        show_sampling,
2773                        show_threshold_subtraction,
2774                        ..
2775                    } = &display
2776                    else {
2777                        panic!("expected integrand display");
2778                    };
2779                    assert_eq!(*show_sampling, sampling.then_some(format));
2780                    assert_eq!(*show_threshold_subtraction, (!sampling).then_some(format));
2781                    let serialized = toml::to_string(&display).unwrap();
2782                    assert_eq!(toml::from_str::<Display>(&serialized).unwrap(), display);
2783                }
2784            }
2785        }
2786    }
2787
2788    #[test]
2789    fn metadata_display_rejects_invalid_cli_options() {
2790        for tail in [
2791            vec!["--show_sampling"],
2792            vec!["--show_sampling", "json"],
2793            vec!["--show_threshold_subtraction", "json"],
2794            vec![
2795                "--show_sampling",
2796                "pretty",
2797                "--show_threshold_subtraction",
2798                "toml",
2799            ],
2800        ] {
2801            let mut args = vec![
2802                "gammaloop",
2803                "display",
2804                "integrands",
2805                "-p",
2806                "ttH",
2807                "-i",
2808                "NNLO",
2809            ];
2810            args.extend(tail);
2811            assert!(Repl::try_parse_from(args).is_err());
2812        }
2813        for flag in ["--show_sampling", "--show_threshold_subtraction"] {
2814            for context in [vec![], vec!["-p", "ttH"], vec!["-i", "NNLO"]] {
2815                let mut args = vec!["gammaloop", "display", "integrands", flag, "pretty"];
2816                args.extend(context);
2817                assert!(Repl::try_parse_from(args).is_err());
2818            }
2819        }
2820    }
2821
2822    #[test]
2823    fn metadata_display_validates_structured_requests_before_reading_state() {
2824        let state = State::new_test();
2825        for (process, integrand, graphs, sampling, thresholds, expected) in [
2826            (
2827                None,
2828                Some("NNLO"),
2829                vec![],
2830                Some(MetadataDisplayFormat::Pretty),
2831                None,
2832                "explicit --process and --integrand-name",
2833            ),
2834            (
2835                Some(ProcessRef::Id(0)),
2836                None,
2837                vec![],
2838                None,
2839                Some(MetadataDisplayFormat::Pretty),
2840                "explicit --process and --integrand-name",
2841            ),
2842            (
2843                Some(ProcessRef::Id(0)),
2844                Some("NNLO"),
2845                vec![],
2846                Some(MetadataDisplayFormat::Pretty),
2847                Some(MetadataDisplayFormat::Toml),
2848                "mutually exclusive",
2849            ),
2850            (
2851                Some(ProcessRef::Id(0)),
2852                Some("NNLO"),
2853                vec![],
2854                None,
2855                Some(MetadataDisplayFormat::Toml),
2856                "exactly one --graph",
2857            ),
2858            (
2859                Some(ProcessRef::Id(0)),
2860                Some("NNLO"),
2861                vec!["GL297".to_string(), "GL638".to_string()],
2862                None,
2863                Some(MetadataDisplayFormat::Toml),
2864                "exactly one --graph",
2865            ),
2866        ] {
2867            let command = Display::Integrands {
2868                process,
2869                integrand_name: integrand.map(str::to_string),
2870                graphs,
2871                categories: vec![IntegrandDisplayCategory::Cuts],
2872                hide_non_existing_thresholds: true,
2873                show_threshold_functions: true,
2874                show_sampling: sampling,
2875                show_threshold_subtraction: thresholds,
2876            };
2877            let command =
2878                serde_json::from_value::<Display>(serde_json::to_value(command).unwrap()).unwrap();
2879            let error = command
2880                .run(
2881                    &state,
2882                    &CLISettings::default(),
2883                    &RuntimeSettings::default(),
2884                    &RunHistory::default(),
2885                )
2886                .unwrap_err();
2887            assert!(error.to_string().contains(expected), "{error:?}");
2888        }
2889    }
2890
2891    #[test]
2892    fn format_bytes_uses_binary_units() {
2893        assert_eq!(format_bytes(999), "999 B");
2894        assert_eq!(format_bytes(1024), "1.00 KiB");
2895        assert_eq!(format_bytes(1024 * 1024), "1.00 MiB");
2896    }
2897
2898    #[test]
2899    fn command_block_contents_preserve_raw_commands() {
2900        let block = CommandsBlock {
2901            name: "alpha".to_string(),
2902            commands: vec![
2903                CommandHistory::from_raw_string("display processes").unwrap(),
2904                CommandHistory::from_raw_string("set process -p triangle -i LO defaults").unwrap(),
2905            ],
2906        };
2907
2908        assert_eq!(
2909            command_block_contents(&block),
2910            "display processes\nset process -p triangle -i LO defaults"
2911        );
2912    }
2913
2914    #[test]
2915    fn command_block_contents_marks_empty_blocks() {
2916        let run_history = RunHistory {
2917            command_blocks: vec![CommandsBlock {
2918                name: "empty".to_string(),
2919                commands: Vec::new(),
2920            }],
2921            ..RunHistory::default()
2922        };
2923
2924        assert_eq!(
2925            command_block_contents(&run_history.command_blocks[0]),
2926            "(empty)"
2927        );
2928    }
2929
2930    #[test]
2931    fn command_blocks_table_separates_each_block() {
2932        let run_history = RunHistory {
2933            command_blocks: vec![
2934                CommandsBlock {
2935                    name: "alpha".to_string(),
2936                    commands: vec![CommandHistory::from_raw_string("display processes").unwrap()],
2937                },
2938                CommandsBlock {
2939                    name: "beta".to_string(),
2940                    commands: vec![CommandHistory::from_raw_string("display model").unwrap()],
2941                },
2942            ],
2943            ..RunHistory::default()
2944        };
2945
2946        let rendered = render_command_blocks_table(&run_history);
2947
2948        assert!(rendered.contains("alpha"), "{rendered}");
2949        assert!(rendered.contains("beta"), "{rendered}");
2950        assert_eq!(
2951            rendered
2952                .lines()
2953                .filter(|line| line.starts_with('├') && line.ends_with('┤'))
2954                .count(),
2955            2,
2956            "{rendered}"
2957        );
2958    }
2959
2960    #[test]
2961    fn command_blocks_table_lists_command_block_placeholders() {
2962        let run_history = RunHistory {
2963            command_blocks: vec![
2964                CommandsBlock {
2965                    name: "inner".to_string(),
2966                    commands: vec![CommandHistory::from_raw_string(
2967                        "set global kv global.display_directive=$(level:warn)",
2968                    )
2969                    .unwrap()],
2970                },
2971                CommandsBlock {
2972                    name: "outer".to_string(),
2973                    commands: vec![CommandHistory::from_raw_string(
2974                        "run inner -D level=$(outer_level)",
2975                    )
2976                    .unwrap()],
2977                },
2978            ],
2979            ..RunHistory::default()
2980        };
2981
2982        let rendered = render_command_blocks_table(&run_history);
2983
2984        assert!(rendered.contains("placeholders"), "{rendered}");
2985        assert!(rendered.contains("$(level:warn)"), "{rendered}");
2986        assert!(rendered.contains("$(outer_level)"), "{rendered}");
2987        assert_eq!(
2988            run_history
2989                .command_block_placeholder_names("outer")
2990                .into_iter()
2991                .collect::<Vec<_>>(),
2992            vec!["outer_level".to_string()]
2993        );
2994    }
2995}