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 Dot {
36 #[arg(value_hint = clap::ValueHint::FilePath)]
38 path: Option<PathBuf>,
39 #[arg(short = 'c', default_value_t = false)]
41 combine_diagrams: bool,
42 #[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 #[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 #[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 #[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 #[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 #[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 UvForest {
69 #[arg(value_hint = clap::ValueHint::DirPath)]
71 path: Option<PathBuf>,
72 #[command(flatten)]
73 process: ProcessArgs,
74 #[arg(short = 'g', long = "graph", value_name = "GRAPH")]
76 graph: Vec<String>,
77 #[arg(long, default_value_t = false)]
79 computed: bool,
80 },
81 Standalone {
83 #[arg(value_hint = clap::ValueHint::FilePath)]
85 path: Option<PathBuf>,
86 #[arg(long, default_value_t = false, conflicts_with = "rust")]
88 python: bool,
89 #[arg(long, default_value_t = false, conflicts_with = "python")]
91 rust: bool,
92 #[arg(long, default_value_t = false, conflicts_with = "json")]
94 binary: bool,
95 #[arg(long, default_value_t = false, conflicts_with = "binary")]
97 json: bool,
98 #[arg(long, default_value_t = false, conflicts_with = "arbprec")]
100 quadprec: bool,
101 #[arg(long, default_value_t = false, conflicts_with = "quadprec")]
103 arbprec: bool,
104 },
105 State(SaveState),
107 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 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 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 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 state.export_dots(&target_dir, &settings)?;
162
163 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 #[arg(short = 'p', long, value_hint = clap::ValueHint::FilePath)]
302 pub path: Option<PathBuf>,
303
304 #[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 #[arg(short = 'n', long, default_value_t = false)]
311 pub no_save_state: bool,
312
313 #[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 #[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 return Ok(());
335 }
336 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 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}