Skip to main content

gammaloop_api/commands/
save.rs

1use std::{fs, path::PathBuf};
2
3use clap::{Args, Subcommand};
4use color_eyre::{
5    eyre::{eyre, Context},
6    owo_colors::OwoColorize,
7    Result,
8};
9use gammalooprs::{
10    processes::{
11        DotExportSettings, StandaloneDataFormat, StandaloneExportMode, StandaloneExportSettings,
12        StandaloneNumericTarget,
13    },
14    settings::RuntimeSettings,
15    utils::serde_utils::{ShowDefaultsGuard, SmartSerde},
16    uv::export::UVForestExportSettings,
17};
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20use tracing::{info, warn};
21
22use crate::{
23    commands::generate::ProcessArgs,
24    commands::CliArgumentMetadataExt,
25    state::{
26        classify_state_folder, RunHistory, SerializeCommandsAsStringsGuard, State, StateFolderKind,
27    },
28    templates::Assets,
29    write_schemas, CLISettings, DEFAULT_RUNTIME_SETTINGS_FILENAME, GLOBAL_SETTINGS_FILENAME,
30};
31
32#[derive(Subcommand, Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
33pub enum Save {
34    /// Export generated graphs as DOT files and drawing templates.
35    Dot {
36        /// Destination directory; defaults to the active state folder.
37        #[arg(value_hint = clap::ValueHint::FilePath)]
38        path: Option<PathBuf>,
39        /// Combine diagrams in one DOT output instead of writing one file per graph.
40        #[arg(short = 'c', default_value_t = false)]
41        combine_diagrams: bool,
42        /// Include ultraviolet counterterm graphs in the exported drawing data.
43        #[arg(short = 'n', long,num_args(0..=1), cli_default_missing_value("true"),
44               value_parser = clap::builder::BoolishValueParser::new(),)]
45        with_uv: Option<bool>,
46        /// Export the full numerator expression rather than the compact display form.
47        #[arg(short = 'u', long,num_args(0..=1), cli_default_missing_value("true"),
48               value_parser = clap::builder::BoolishValueParser::new(),)]
49        output_full_numerator: Option<bool>,
50        /// Simplify gamma-matrix algebra before formatting graph numerators.
51        #[arg(short = 'g', long,num_args(0..=1), cli_default_missing_value("true"),
52               value_parser = clap::builder::BoolishValueParser::new(),)]
53        do_gamma_algebra: Option<bool>,
54        /// Simplify color algebra before formatting graph numerators.
55        #[arg(long,num_args(0..=1), cli_default_missing_value("true"),
56               value_parser = clap::builder::BoolishValueParser::new(),)]
57        do_color_algebra: Option<bool>,
58        /// Write separate cross-section drawings for distinct initial-state assignments.
59        #[arg(long, num_args(0..=1), cli_default_missing_value("true"),
60               value_parser = clap::builder::BoolishValueParser::new(),)]
61        split_xs_by_initial_states: Option<bool>,
62        /// Include fields generated internally rather than supplied by the source graph.
63        #[arg(long, num_args(0..=1), cli_default_missing_value("true"),
64               value_parser = clap::builder::BoolishValueParser::new(),)]
65        include_autogenerated_fields: Option<bool>,
66    },
67    /// Export the ultraviolet forest for selected generated graphs.
68    UvForest {
69        /// Destination directory; defaults to the active state folder.
70        #[arg(value_hint = clap::ValueHint::DirPath)]
71        path: Option<PathBuf>,
72        #[command(flatten)]
73        process: ProcessArgs,
74        /// Master graph names to export; repeat the option to select multiple graphs.
75        #[arg(short = 'g', long = "graph", value_name = "GRAPH")]
76        graph: Vec<String>,
77        /// Export computed counterterm expressions in addition to the forest structure.
78        #[arg(long, default_value_t = false)]
79        computed: bool,
80    },
81    /// Export an integrand as a standalone evaluator or serialized data file.
82    Standalone {
83        /// Destination path for the exported project or data file.
84        #[arg(value_hint = clap::ValueHint::FilePath)]
85        path: Option<PathBuf>,
86        /// Generate a standalone Python evaluator project.
87        #[arg(long, default_value_t = false, conflicts_with = "rust")]
88        python: bool,
89        /// Generate a standalone Rust evaluator project.
90        #[arg(long, default_value_t = false, conflicts_with = "python")]
91        rust: bool,
92        /// Serialize evaluator data in the compact binary format.
93        #[arg(long, default_value_t = false, conflicts_with = "json")]
94        binary: bool,
95        /// Serialize evaluator data as human-readable JSON.
96        #[arg(long, default_value_t = false, conflicts_with = "binary")]
97        json: bool,
98        /// Include quad-precision evaluator support in generated source projects.
99        #[arg(long, default_value_t = false, conflicts_with = "arbprec")]
100        quadprec: bool,
101        /// Include arbitrary-precision evaluator support in generated source projects.
102        #[arg(long, default_value_t = false, conflicts_with = "quadprec")]
103        arbprec: bool,
104    },
105    /// Persist the active state, settings, and replayable run history.
106    State(SaveState),
107    /// Regenerate the JSON schema files for CLI and runtime configuration.
108    Schema {},
109}
110
111impl Save {
112    pub fn run(
113        self,
114        state: &mut State,
115        run_history: &RunHistory,
116        default_runtime_settings: &RuntimeSettings,
117        global_settings: &CLISettings,
118    ) -> Result<()> {
119        match self {
120            Save::Dot {
121                path,
122                combine_diagrams,
123                output_full_numerator,
124                do_color_algebra,
125                do_gamma_algebra,
126                split_xs_by_initial_states,
127                with_uv,
128                include_autogenerated_fields,
129            } => {
130                // Use original default location (state folder) or custom path if provided
131                let target_dir = path.unwrap_or(global_settings.state.folder.clone());
132                global_settings
133                    .ensure_write_target_outside_active_state(&target_dir, "save dot files")?;
134                info!("Saving dot files to {}", target_dir.display());
135
136                // Extract embedded templates to drawings/templates relative to target directory
137                if let Err(e) = Assets::extract_templates(&target_dir) {
138                    warn!(
139                        "Warning: Could not extract templates to drawings/templates: {}",
140                        e
141                    );
142                }
143
144                // Generate dynamic edge styles based on the model
145                let template_path = target_dir.join("drawings/templates/edge-style.typ");
146                if let Err(e) = state.model.generate_edge_style_template(&template_path) {
147                    warn!("Warning: Could not generate dynamic edge styles: {}", e);
148                }
149
150                let settings = DotExportSettings {
151                    do_color_algebra: do_color_algebra.unwrap_or(false),
152                    do_gamma_algebra: do_gamma_algebra.unwrap_or(false),
153                    output_full_numerator: output_full_numerator.unwrap_or(false),
154                    split_xs_by_initial_states: split_xs_by_initial_states.unwrap_or(true),
155                    with_uv: with_uv.unwrap_or(false),
156                    combine_diagrams,
157                    include_autogenerated_fields: include_autogenerated_fields.unwrap_or(false),
158                };
159
160                // Export dot files to original location
161                state.export_dots(&target_dir, &settings)?;
162
163                // Create Justfile with draw recipe from embedded template
164                if let Err(e) = Assets::extract_justfile(&target_dir) {
165                    warn!(
166                        "Warning: Could not create justfile at {}: {}",
167                        target_dir.join("justfile").display(),
168                        e
169                    );
170                }
171
172                Ok(())
173            }
174            Save::UvForest {
175                path,
176                process,
177                graph,
178                computed,
179            } => {
180                let target_dir = path.unwrap_or(global_settings.state.folder.clone());
181                global_settings
182                    .ensure_write_target_outside_active_state(&target_dir, "save uv forest")?;
183                let (process_id, integrand_name) = state.find_integrand_ref(
184                    process.process.as_ref(),
185                    process.integrand_name.as_ref(),
186                )?;
187                let graph_ids =
188                    resolve_uv_forest_graph_ids(state, process_id, &integrand_name, &graph)?;
189                let settings = UVForestExportSettings { computed };
190                state.process_list.export_uv_forests(
191                    &target_dir,
192                    process_id,
193                    &integrand_name,
194                    &graph_ids,
195                    &settings,
196                )
197            }
198            Save::State(s) => s.save(
199                state,
200                run_history,
201                default_runtime_settings,
202                global_settings,
203            ),
204            Save::Standalone {
205                path,
206                python,
207                rust,
208                json,
209                binary,
210                quadprec,
211                arbprec,
212            } => {
213                let target_dir = path.unwrap_or(global_settings.state.folder.clone());
214                global_settings.ensure_write_target_outside_active_state(
215                    &target_dir,
216                    "export standalone files",
217                )?;
218                let mode = match (python, rust) {
219                    (true, false) => StandaloneExportMode::Python,
220                    (false, true) | (false, false) => StandaloneExportMode::Rust,
221                    (true, true) => unreachable!("clap enforces mutual exclusivity"),
222                };
223
224                let format = match (json, binary) {
225                    (true, false) => StandaloneDataFormat::Json,
226                    (false, true) | (false, false) => StandaloneDataFormat::Binary,
227                    (true, true) => unreachable!("clap enforces mutual exclusivity"),
228                };
229                let precision = match (quadprec, arbprec) {
230                    (true, false) => StandaloneNumericTarget::Quad,
231                    (false, true) => StandaloneNumericTarget::Arb,
232                    (false, false) => StandaloneNumericTarget::Double,
233                    (true, true) => unreachable!("clap enforces mutual exclusivity"),
234                };
235                let settings = StandaloneExportSettings {
236                    mode,
237                    format,
238                    precision,
239                };
240                state.process_list.export_standalone(&target_dir, &settings)
241            }
242
243            Save::Schema {} => write_schemas(),
244        }
245    }
246}
247
248fn resolve_uv_forest_graph_ids(
249    state: &State,
250    process_id: usize,
251    integrand_name: &str,
252    graph_selectors: &[String],
253) -> Result<Vec<usize>> {
254    let resolved = state
255        .process_list
256        .get_integrand(process_id, integrand_name)
257        .with_context(|| {
258            format!("while resolving integrand {integrand_name} in process id {process_id}")
259        })?;
260    let integrand = resolved.require_generated()?;
261
262    if graph_selectors.is_empty() {
263        return Ok((0..integrand.graph_count()).collect());
264    }
265
266    graph_selectors
267        .iter()
268        .map(|selector| {
269            let numeric_selector = selector.strip_prefix('#').unwrap_or(selector);
270            if let Ok(graph_id) = numeric_selector.parse::<usize>() {
271                if graph_id < integrand.graph_count() {
272                    return Ok(graph_id);
273                }
274            }
275
276            if let Some(graph_id) = integrand.find_graph_id_by_name(selector) {
277                return Ok(graph_id);
278            }
279
280            let available = (0..integrand.graph_count())
281                .filter_map(|graph_id| {
282                    integrand
283                        .graph_name_by_id(graph_id)
284                        .map(|name| format!("{graph_id}:{name}"))
285                })
286                .collect::<Vec<_>>()
287                .join(", ");
288            Err(eyre!(
289                "No graph '{}' in integrand {}. Available graphs: {}",
290                selector,
291                integrand_name,
292                available
293            ))
294        })
295        .collect()
296}
297
298#[derive(Args, Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Default)]
299pub struct SaveState {
300    /// Path to save the state to, by default is the current state folder
301    #[arg(short = 'p', long, value_hint = clap::ValueHint::FilePath)]
302    pub path: Option<PathBuf>,
303
304    /// Save state to file after each call
305    #[arg(short = 'o', long,num_args(0..=1), cli_default_missing_value("true"),
306           value_parser = clap::builder::BoolishValueParser::new(),)]
307    pub override_state: Option<bool>,
308
309    /// Exit without writing state data, even when state saving is otherwise enabled.
310    #[arg(short = 'n', long, default_value_t = false)]
311    pub no_save_state: bool,
312
313    /// Try to serialize using strings when saving run history
314    #[arg(long, num_args(0..=1),cli_default_missing_value("true"),
315           value_parser = clap::builder::BoolishValueParser::new(),)]
316    pub try_strings: Option<bool>,
317
318    /// Treat state serialization warnings as errors and refuse a partial save.
319    #[arg(long, num_args(0..=1),cli_default_missing_value("true"),
320           value_parser = clap::builder::BoolishValueParser::new())]
321    pub strict: Option<bool>,
322}
323
324impl SaveState {
325    pub fn save(
326        &self,
327        state: &mut State,
328        run_history: &RunHistory,
329        default_runtime_settings: &RuntimeSettings,
330        global_settings: &CLISettings,
331    ) -> Result<()> {
332        if self.no_save_state {
333            // info!("Skipping saving state as per user request");
334            return Ok(());
335        }
336        // println!(
337        //     "Saving state to {}..",
338        //     global_settings.state_folder.display()
339        // );
340        // let root_folder = root_folder.join("gammaloop_state");
341
342        // check if the export root exists, if not create it, if it does return error
343        let mut selected_root_folder = self
344            .path
345            .clone()
346            .unwrap_or(global_settings.state.folder.clone());
347        global_settings
348            .ensure_write_target_outside_active_state(&selected_root_folder, "save state")?;
349        let state_folder_kind = classify_state_folder(&selected_root_folder)?;
350        if matches!(state_folder_kind, StateFolderKind::Missing) {
351            fs::create_dir_all(&selected_root_folder)?;
352        } else {
353            if self.strict.unwrap_or(false) {
354                return Err(eyre!(
355                    "Export root already exists, please choose a different path or remove the existing directory",
356                ));
357            }
358
359            if !matches!(state_folder_kind, StateFolderKind::Scratch)
360                && !self
361                    .override_state
362                    .unwrap_or(global_settings.override_state)
363            {
364                while selected_root_folder.clone().exists() {
365                    eprint!(
366                        "Gammaloop export root {} already exists. Specify '{}' for overwriting, '{}' for not saving, or '{}' to specify where to save current state to:\n > ",
367                        selected_root_folder.display().to_string().green(),
368                        "o".red().bold(),
369                        "n".blue().bold(),
370                        "<NEW_PATH>".green().bold()
371                    );
372                    let mut user_input = String::new();
373                    std::io::stdin()
374                        .read_line(&mut user_input)
375                        .expect("Could not read user-specified gammaloop state export destination");
376                    //user_input = user_input.trim().into();
377                    match user_input.trim() {
378                        "o" => {
379                            info!(
380                                "Overwriting existing gammaloop state at {}",
381                                selected_root_folder.display().to_string().green()
382                            );
383                            break;
384                        }
385                        "n" => {
386                            return Ok(());
387                        }
388                        new_path => {
389                            selected_root_folder = new_path.into();
390                            continue;
391                        }
392                    }
393                }
394            }
395        }
396
397        global_settings
398            .ensure_write_target_outside_active_state(&selected_root_folder, "save state")?;
399
400        state.save(&selected_root_folder, true, false)?;
401
402        let _serialize_commands_guard = SerializeCommandsAsStringsGuard::new(
403            self.try_strings.unwrap_or(global_settings.try_strings),
404        );
405        run_history.save_toml(&selected_root_folder, true, false)?;
406
407        let _show_defaults_guard = ShowDefaultsGuard::new(true);
408        default_runtime_settings.to_file(
409            selected_root_folder.join(DEFAULT_RUNTIME_SETTINGS_FILENAME),
410            true,
411        )?;
412        global_settings.to_file(selected_root_folder.join(GLOBAL_SETTINGS_FILENAME), true)?;
413
414        Ok(())
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::{Save, SaveState};
421    use crate::{
422        commands::generate::ProcessArgs,
423        state::{RunHistory, State},
424        CLISettings,
425    };
426    use gammalooprs::settings::RuntimeSettings;
427    use std::path::PathBuf;
428
429    #[test]
430    fn save_state_rejects_default_state_path_in_read_only_mode() {
431        let mut state = crate::state::State::new_test();
432        let mut cli_settings = CLISettings::default();
433        cli_settings.state.folder = PathBuf::from("/tmp/read_only_state");
434        cli_settings.session.read_only_state = true;
435
436        let err = SaveState::default()
437            .save(
438                &mut state,
439                &RunHistory::default(),
440                &RuntimeSettings::default(),
441                &cli_settings,
442            )
443            .unwrap_err();
444
445        assert!(format!("{err:?}").contains("--read-only-state"));
446    }
447
448    #[test]
449    fn save_state_rejects_paths_inside_active_state_in_read_only_mode() {
450        let mut state = State::new_test();
451        let mut cli_settings = CLISettings::default();
452        cli_settings.state.folder = PathBuf::from("/tmp/read_only_state");
453        cli_settings.session.read_only_state = true;
454
455        let err = SaveState {
456            path: Some(PathBuf::from("/tmp/read_only_state/nested/export")),
457            ..SaveState::default()
458        }
459        .save(
460            &mut state,
461            &RunHistory::default(),
462            &RuntimeSettings::default(),
463            &cli_settings,
464        )
465        .unwrap_err();
466
467        assert!(format!("{err:?}").contains("--read-only-state"));
468    }
469
470    #[test]
471    fn save_dot_rejects_default_target_in_read_only_mode() {
472        let mut state = State::new_test();
473        let mut cli_settings = CLISettings::default();
474        cli_settings.state.folder = PathBuf::from("/tmp/read_only_state");
475        cli_settings.session.read_only_state = true;
476
477        let err = Save::Dot {
478            path: None,
479            combine_diagrams: false,
480            with_uv: None,
481            output_full_numerator: None,
482            do_gamma_algebra: None,
483            do_color_algebra: None,
484            split_xs_by_initial_states: None,
485            include_autogenerated_fields: None,
486        }
487        .run(
488            &mut state,
489            &RunHistory::default(),
490            &RuntimeSettings::default(),
491            &cli_settings,
492        )
493        .unwrap_err();
494
495        assert!(format!("{err:?}").contains("--read-only-state"));
496    }
497
498    #[test]
499    fn save_uv_forest_rejects_default_target_in_read_only_mode() {
500        let mut state = State::new_test();
501        let mut cli_settings = CLISettings::default();
502        cli_settings.state.folder = PathBuf::from("/tmp/read_only_state");
503        cli_settings.session.read_only_state = true;
504
505        let err = Save::UvForest {
506            path: None,
507            process: ProcessArgs {
508                process: None,
509                integrand_name: None,
510            },
511            graph: Vec::new(),
512            computed: false,
513        }
514        .run(
515            &mut state,
516            &RunHistory::default(),
517            &RuntimeSettings::default(),
518            &cli_settings,
519        )
520        .unwrap_err();
521
522        assert!(format!("{err:?}").contains("--read-only-state"));
523    }
524
525    #[test]
526    fn save_standalone_rejects_paths_inside_active_state_in_read_only_mode() {
527        let mut state = State::new_test();
528        let mut cli_settings = CLISettings::default();
529        cli_settings.state.folder = PathBuf::from("/tmp/read_only_state");
530        cli_settings.session.read_only_state = true;
531
532        let err = Save::Standalone {
533            path: Some(PathBuf::from("/tmp/read_only_state/standalone")),
534            python: false,
535            rust: true,
536            binary: false,
537            json: false,
538            quadprec: false,
539            arbprec: false,
540        }
541        .run(
542            &mut state,
543            &RunHistory::default(),
544            &RuntimeSettings::default(),
545            &cli_settings,
546        )
547        .unwrap_err();
548
549        assert!(format!("{err:?}").contains("--read-only-state"));
550    }
551}