1use ::tracing::info;
47use ::tracing::level_filters::LevelFilter;
48use ::tracing::warn;
49use clap::parser::ValueSource;
50use clap::{CommandFactory, FromArgMatches, Parser};
51use commands::save::SaveState;
52use commands::Commands;
53
54use gammalooprs::utils::serde_utils::{SerdeFileError, SHOWDEFAULTS};
55use session::{CliSession, CliSessionState};
56use tracing::{
57 configure_file_log_boot_mode, set_file_log_filter, set_file_log_filter_override,
58 set_log_format_override, set_log_style, set_stderr_log_filter, set_stderr_log_filter_override,
59};
60
61use color_eyre::Result;
67use colored::Colorize;
68use eyre::{eyre, Context};
69use gammaloop_tracing_filter::LogFormat;
70use gammalooprs::{
71 initialisation::initialise,
72 processes::{ProcessCollection, ProcessLoadIntegrandSelector, ProcessLoadSelection},
73 settings::{GlobalSettings, RuntimeSettings},
74 utils::serde_utils::IsDefault,
75 utils::{
76 serde_utils::{get_schema_folder, is_false, is_true, SmartSerde},
77 tracing::LogLevel,
78 },
79};
80use schemars::{schema_for, JsonSchema};
81use serde::{Deserialize, Deserializer, Serialize, Serializer};
82
83use state::{classify_state_folder, RunHistory, State, StateFolderKind, SyncSettings};
84use std::{ffi::OsString, path::Path, path::PathBuf, sync::atomic::Ordering};
85use std::{fs, fs::File, ops::ControlFlow, time::Duration, time::Instant};
86use walkdir::WalkDir;
87
88#[cfg(feature = "cli")]
89use {
90 ::tracing::debug,
91 clap::ValueEnum,
92 clap_complete::shells::{Bash, Elvish, Fish, PowerShell, Zsh},
93 clap_complete_nushell::Nushell,
94 console::{measure_text_width, style},
95 dirs::home_dir,
96 gammalooprs::utils::GIT_VERSION,
97 reedline::{FileBackedHistory, Prompt, PromptEditMode, PromptHistorySearch},
98 repl::{ClapEditor, ReadCommandOutput},
99 state::CommandHistory,
100 std::{borrow::Cow, io::IsTerminal},
101 tracing::get_stderr_log_filter_label,
102};
103
104mod command_parser;
106pub(crate) mod command_template;
107pub mod completion;
108pub mod integrand_info;
109pub(crate) mod model_parameters;
110#[cfg(feature = "python")]
111pub mod python;
112#[cfg(feature = "cli")]
113pub mod repl;
114pub mod session;
115pub(crate) mod settings_tree;
116
117pub mod state;
118pub mod templates;
119pub mod tracing;
120
121#[cfg(test)]
122pub(crate) static LOG_TEST_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
123
124#[cfg(test)]
125fn is_test_workspace_root(path: &Path) -> bool {
126 path.join("Cargo.toml").is_file()
127 && path.join("tests/resources").is_dir()
128 && path.join("examples/cli").is_dir()
129}
130
131#[cfg(test)]
132pub(crate) fn test_workspace_root() -> &'static Path {
133 static WORKSPACE_ROOT: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
134
135 WORKSPACE_ROOT
136 .get_or_init(|| {
137 let current_dir = std::env::current_dir().ok();
138 let current_exe = std::env::current_exe().ok();
139 let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
140
141 let mut candidates = Vec::new();
142 if let Some(dir) = current_dir.clone() {
143 candidates.extend(dir.ancestors().map(Path::to_path_buf));
144 }
145 if let Some(exe) = current_exe.clone() {
146 candidates.extend(exe.ancestors().skip(1).map(Path::to_path_buf));
147 }
148 candidates.extend(manifest_dir.ancestors().map(Path::to_path_buf));
149
150 candidates
151 .into_iter()
152 .find(|path| is_test_workspace_root(path))
153 .unwrap_or_else(|| {
154 panic!(
155 "Failed to locate workspace root from current dir '{:?}', current exe '{:?}', or manifest dir '{}'",
156 current_dir,
157 current_exe,
158 manifest_dir.display()
159 )
160 })
161 })
162 .as_path()
163}
164
165pub(crate) const GLOBAL_SETTINGS_FILENAME: &str = "global_settings.toml";
166pub(crate) const DEFAULT_RUNTIME_SETTINGS_FILENAME: &str = "default_runtime_settings.toml";
167#[cfg(feature = "cli")]
168const BANNER_ART: &str = r" ██ ▄████████▄ ▄████████▄ ██████████▄
169 ██ ▀▀ ▀▀ ▀▀ ▀▀ ▀▀ ▀▀ ██
170 ██ ▄██████████████████████████████████████████████████▀
171 ▀██▀ ▄▄ ▄▄ ▄▄ ▄▄ ▄▄ ▄▄
172 ██ ██ █████████ ▀████████▀ ▀████████▀ ██
173 ██ ██
174 ██ ██
175";
176
177pub(crate) fn render_smart_toml<T: SmartSerde>(value: &T) -> Result<String> {
178 let mut toml_string = if let Some(schema_path) = value.has_schema_path(true) {
179 let schema_path = schema_path?;
180 format!("#:schema {}\n", schema_path.display())
181 } else {
182 String::new()
183 };
184 toml_string.push_str(&toml::to_string_pretty(value)?);
185 Ok(toml_string)
186}
187
188fn lexically_normalized_absolute_path(path: &Path) -> Result<PathBuf> {
189 let absolute_path = if path.is_absolute() {
190 path.to_path_buf()
191 } else {
192 std::env::current_dir()?.join(path)
193 };
194
195 let mut normalized = PathBuf::new();
196 for component in absolute_path.components() {
197 match component {
198 std::path::Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
199 std::path::Component::RootDir => normalized.push(component.as_os_str()),
200 std::path::Component::CurDir => {}
201 std::path::Component::ParentDir => {
202 normalized.pop();
203 }
204 std::path::Component::Normal(part) => normalized.push(part),
205 }
206 }
207
208 Ok(normalized)
209}
210
211fn path_lies_within(base: &Path, candidate: &Path) -> Result<bool> {
212 let normalized_base = lexically_normalized_absolute_path(base)?;
213 let normalized_candidate = lexically_normalized_absolute_path(candidate)?;
214 Ok(normalized_candidate == normalized_base
215 || normalized_candidate.strip_prefix(&normalized_base).is_ok())
216}
217
218#[derive(Parser, Debug)]
219#[command(name = "gammaLoop", version, about)]
220#[command(next_line_help = true)]
221pub struct Repl {
222 #[command(subcommand)]
223 pub command: Commands,
224}
225
226#[derive(Parser, Debug)]
227#[command(
228 name = "gammaLoop",
229 version,
230 about,
231 subcommand_precedence_over_arg = true
232)]
233#[command(next_line_help = true)]
234pub struct OneShot {
235 #[arg(long, default_value_t = false)]
237 pub clean_state: bool,
238
239 #[arg(long, num_args = 1.., value_name = "PROCESS")]
242 pub processes: Option<Vec<String>>,
243
244 #[arg(long, num_args = 1.., value_name = "INTEGRAND|PROCESS@INTEGRAND")]
247 pub integrands: Option<Vec<String>>,
248
249 #[arg(value_hint = clap::ValueHint::FilePath)]
251 pub boot_commands_path: Option<PathBuf>,
252
253 #[arg(short = 's', long, default_value = "./gammaloop_state", value_hint = clap::ValueHint::DirPath)]
255 pub state_folder: PathBuf,
256
257 #[arg(skip = false)]
259 state_folder_explicitly_set: bool,
260
261 #[arg(short = 'm', long, value_hint = clap::ValueHint::FilePath)]
263 pub model_file: Option<PathBuf>,
264
265 #[arg(short = 'n', long, default_value_t = false, group = "saving")]
267 no_save_state: bool,
268
269 #[arg(short = 'o', long, default_value_t = false, group = "saving")]
271 override_state: bool,
272
273 #[arg(short = 't', long = "trace-logs-filename")]
275 trace_logs_filename: Option<String>,
276
277 #[arg(short = 'l')]
279 level: Option<LogLevel>,
280
281 #[arg(short = 'L', long = "logfile-level")]
283 logfile_level: Option<LogLevel>,
284
285 #[arg(short = 'p', long = "logging-prefix")]
287 logging_prefix: Option<LogFormat>,
288
289 #[arg(long, default_value_t = false)]
291 read_only_state: bool,
292
293 #[arg(short = 'g', long = "settings-global", value_hint = clap::ValueHint::FilePath)]
295 settings_global_path: Option<PathBuf>,
296
297 #[arg(
299 short = 'r',
300 long = "settings-runtime-defaults",
301 value_hint = clap::ValueHint::FilePath
302 )]
303 settings_runtime_defaults_path: Option<PathBuf>,
304
305 #[arg(long)]
307 no_try_strings: bool,
308
309 #[cfg(feature = "cli")]
310 #[arg(long = "completions", value_enum)]
312 completions: Option<CompletionShell>,
313
314 #[command(subcommand)]
319 pub command: Option<Commands>,
320}
321
322#[cfg(feature = "cli")]
323#[derive(Debug, Copy, Clone, PartialEq, Eq, ValueEnum)]
324enum CompletionShell {
325 Bash,
326 Elvish,
327 Fish,
328 PowerShell,
329 Zsh,
330 Nushell,
331}
332
333#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
334#[serde(default, deny_unknown_fields)]
335pub struct CLISettings {
336 #[serde(skip_serializing_if = "is_true")]
338 pub try_strings: bool,
339 #[serde(skip_serializing_if = "is_false")]
341 pub override_state: bool,
342 #[serde(skip_serializing_if = "IsDefault::is_default")]
344 pub state: StateSettings,
345 #[serde(skip_serializing_if = "IsDefault::is_default")]
347 pub global: GlobalSettings,
348 #[serde(skip)]
350 #[schemars(skip)]
351 pub session: SessionSettings,
352}
353
354#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
355#[serde(default, deny_unknown_fields)]
356pub struct StateSettings {
357 pub folder: PathBuf,
359 #[serde(
361 default,
362 skip_serializing_if = "skip_optional_nonempty_string",
363 serialize_with = "serialize_optional_nonempty_string",
364 deserialize_with = "deserialize_optional_nonempty_string"
365 )]
366 pub name: Option<String>,
367}
368
369#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
370#[serde(default, deny_unknown_fields)]
371pub struct SessionSettings {
372 #[serde(skip)]
373 #[schemars(skip)]
374 pub read_only_state: bool,
375 #[serde(skip)]
376 #[schemars(skip)]
377 pub(crate) read_only_state_origin: Option<ReadOnlyStateOrigin>,
378 #[serde(skip)]
379 #[schemars(skip)]
380 pub startup_warnings: Vec<String>,
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
384pub enum StateAccessMode {
385 ReadWrite,
386 ReadOnly,
387}
388
389#[derive(Debug, Clone, Copy, PartialEq, Eq)]
390pub(crate) enum ReadOnlyStateOrigin {
391 UserRequested,
392 BootSettingsMismatch,
393}
394
395impl SessionSettings {
396 pub(crate) fn set_user_requested_read_only_state(&mut self, read_only_state: bool) {
397 self.read_only_state = read_only_state;
398 self.read_only_state_origin = read_only_state.then_some(ReadOnlyStateOrigin::UserRequested);
399 }
400
401 pub(crate) fn force_read_only_state(&mut self, origin: ReadOnlyStateOrigin) {
402 if !self.read_only_state {
403 self.read_only_state_origin = Some(origin);
404 }
405 self.read_only_state = true;
406 }
407
408 pub(crate) fn is_read_only_due_to_boot_settings_mismatch(&self) -> bool {
409 self.read_only_state
410 && self.read_only_state_origin == Some(ReadOnlyStateOrigin::BootSettingsMismatch)
411 }
412
413 pub fn state_access_mode(&self) -> StateAccessMode {
414 if self.read_only_state {
415 StateAccessMode::ReadOnly
416 } else {
417 StateAccessMode::ReadWrite
418 }
419 }
420}
421
422impl Default for StateSettings {
423 fn default() -> Self {
424 Self {
425 folder: "./gammaloop_state".into(),
426 name: None,
427 }
428 }
429}
430
431impl StateSettings {
432 pub fn prompt_label(&self) -> String {
433 self.name
434 .as_deref()
435 .map(str::trim)
436 .filter(|name| !name.is_empty())
437 .map(str::to_string)
438 .unwrap_or_else(|| self.folder.display().to_string())
439 }
440}
441
442fn deserialize_optional_nonempty_string<'de, D>(
443 deserializer: D,
444) -> std::result::Result<Option<String>, D::Error>
445where
446 D: Deserializer<'de>,
447{
448 let value = Option::<String>::deserialize(deserializer)?;
449 Ok(value.and_then(|name| {
450 let trimmed = name.trim();
451 (!trimmed.is_empty()).then(|| trimmed.to_string())
452 }))
453}
454
455fn skip_optional_nonempty_string(value: &Option<String>) -> bool {
456 value.is_none() && !SHOWDEFAULTS.load(Ordering::Relaxed)
457}
458
459fn serialize_optional_nonempty_string<S>(
460 value: &Option<String>,
461 serializer: S,
462) -> std::result::Result<S::Ok, S::Error>
463where
464 S: Serializer,
465{
466 match value {
467 Some(name) => serializer.serialize_str(name),
468 None if SHOWDEFAULTS.load(Ordering::Relaxed) => serializer.serialize_str(""),
469 None => serializer.serialize_none(),
470 }
471}
472
473impl Default for CLISettings {
474 fn default() -> Self {
475 CLISettings {
476 try_strings: true,
477 override_state: false,
478 state: StateSettings::default(),
479 global: GlobalSettings::default(),
480 session: SessionSettings::default(),
481 }
482 }
483}
484
485impl PartialEq for CLISettings {
486 fn eq(&self, other: &Self) -> bool {
487 self.try_strings == other.try_strings
488 && self.override_state == other.override_state
489 && self.state == other.state
490 && self.global == other.global
491 }
492}
493
494impl CLISettings {
495 pub fn override_with(&mut self, cli: &OneShot) {
496 self.try_strings = !cli.no_try_strings;
497
498 self.override_state = cli.override_state;
499
500 self.state.folder = cli.state_folder.clone();
501 self.session
502 .set_user_requested_read_only_state(cli.read_only_state);
503 }
504
505 pub(crate) fn ensure_write_target_outside_active_state(
506 &self,
507 target: &Path,
508 operation: &str,
509 ) -> Result<()> {
510 if !self.session.read_only_state {
511 return Ok(());
512 }
513
514 if !self.write_target_lies_within_active_state(target)? {
515 return Ok(());
516 }
517
518 Err(eyre!(
519 "Cannot {operation} at '{}' because this session was started with --read-only-state and the target lies inside the active state folder '{}'. Choose a path outside the active state folder or restart without --read-only-state.",
520 target.display(),
521 self.state.folder.display()
522 ))
523 }
524
525 pub(crate) fn write_target_lies_within_active_state(&self, target: &Path) -> Result<bool> {
526 path_lies_within(&self.state.folder, target)
527 }
528
529 pub(crate) fn cwd_output_path_with_state_name(&self, base_name: &str) -> PathBuf {
530 let mut name = base_name.to_string();
531 if let Some(state_name) = self
532 .state
533 .name
534 .as_deref()
535 .map(str::trim)
536 .filter(|state_name| !state_name.is_empty())
537 .map(path_safe_suffix)
538 {
539 name.push('_');
540 name.push_str(&state_name);
541 }
542 PathBuf::from(".").join(name)
543 }
544}
545
546impl SmartSerde for CLISettings {}
547
548fn path_safe_suffix(value: &str) -> String {
549 let suffix = value
550 .chars()
551 .map(|ch| {
552 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
553 ch
554 } else {
555 '_'
556 }
557 })
558 .collect::<String>();
559 if suffix.is_empty() {
560 "state".to_string()
561 } else {
562 suffix
563 }
564}
565
566#[derive(Debug, Clone, PartialEq, Default)]
578pub struct StateLoadOption {
579 pub clean_state: bool,
581 pub boot_commands_path: Option<PathBuf>,
583 pub state_folder: Option<PathBuf>,
585 pub model_file: Option<PathBuf>,
587 pub trace_logs_filename: Option<String>,
589 pub level: Option<LogLevel>,
591 pub logfile_level: Option<LogLevel>,
593 pub logging_prefix: Option<LogFormat>,
595 pub read_only_state: bool,
597 pub settings_global_path: Option<PathBuf>,
599 pub settings_runtime_defaults_path: Option<PathBuf>,
601 pub processes: Option<Vec<String>>,
603 pub integrands: Option<Vec<String>>,
605}
606
607pub struct LoadedState {
615 pub state: State,
617 pub run_history: RunHistory,
619 pub cli_settings: CLISettings,
621 pub default_runtime_settings: RuntimeSettings,
623 pub session_state: CliSessionState,
625 pub state_load_summary: Option<StateLoadSummary>,
627}
628
629impl LoadedState {
630 pub fn cli_session(&mut self) -> CliSession<'_> {
636 CliSession::new(
637 &mut self.state,
638 &mut self.run_history,
639 &mut self.cli_settings,
640 &mut self.default_runtime_settings,
641 &mut self.session_state,
642 )
643 }
644
645 pub fn state_access_mode(&self) -> StateAccessMode {
646 self.cli_settings.session.state_access_mode()
647 }
648
649 pub fn is_read_only_state(&self) -> bool {
650 self.state_access_mode() == StateAccessMode::ReadOnly
651 }
652
653 pub fn active_state_folder(&self) -> &Path {
654 &self.cli_settings.state.folder
655 }
656
657 pub fn ensure_write_target_allowed(&self, target: &Path, operation: &str) -> Result<()> {
658 self.cli_settings
659 .ensure_write_target_outside_active_state(target, operation)
660 }
661}
662
663pub struct Parsed {
664 pub cli: OneShot,
665 pub input_string: String,
666 pub matches: clap::ArgMatches,
667}
668
669impl StateLoadOption {
670 pub fn read_only(state_folder: impl Into<PathBuf>) -> Self {
671 Self {
672 state_folder: Some(state_folder.into()),
673 read_only_state: true,
674 ..Self::default()
675 }
676 }
677
678 pub fn with_read_only_state(mut self, read_only_state: bool) -> Self {
679 self.read_only_state = read_only_state;
680 self
681 }
682
683 fn into_oneshot(self) -> OneShot {
684 let state_folder_explicitly_set = self.state_folder.is_some();
685 OneShot {
686 clean_state: self.clean_state,
687 processes: self.processes,
688 integrands: self.integrands,
689 boot_commands_path: self.boot_commands_path,
690 state_folder: self
691 .state_folder
692 .unwrap_or_else(|| PathBuf::from("./gammaloop_state")),
693 state_folder_explicitly_set,
694 model_file: self.model_file,
695 no_save_state: true,
696 override_state: false,
697 trace_logs_filename: self.trace_logs_filename,
698 level: self.level,
699 logfile_level: self.logfile_level,
700 logging_prefix: self.logging_prefix,
701 read_only_state: self.read_only_state,
702 settings_global_path: self.settings_global_path,
703 settings_runtime_defaults_path: self.settings_runtime_defaults_path,
704 no_try_strings: false,
705 #[cfg(feature = "cli")]
706 completions: None,
707 command: None,
708 }
709 }
710
711 pub fn load(self) -> Result<LoadedState> {
725 initialise()?;
726 let mut one_shot = self.into_oneshot();
727 let (loaded_state, boot_exit) = one_shot.bootstrap_session()?;
728 if boot_exit.is_some() {
729 return Err(eyre::eyre!(
730 "Boot run history requested to exit, which is not supported by the API state-load entry point"
731 ));
732 }
733 Ok(loaded_state)
734 }
735}
736
737#[derive(Debug, Clone, Default, PartialEq)]
738struct SettingsFileOverrides {
739 global: Option<GlobalSettings>,
740 default_runtime_settings: Option<RuntimeSettings>,
741}
742
743impl SettingsFileOverrides {
744 fn apply(
745 &self,
746 cli_settings: &mut CLISettings,
747 default_runtime_settings: &mut RuntimeSettings,
748 ) -> Result<()> {
749 if let Some(global) = &self.global {
750 cli_settings.global = global.clone();
751 cli_settings.sync_settings()?;
752 }
753 if let Some(runtime_settings) = &self.default_runtime_settings {
754 *default_runtime_settings = runtime_settings.clone();
755 }
756 Ok(())
757 }
758
759 fn apply_to_run_history(&self, run_history: &RunHistory) -> RunHistory {
760 let mut overridden = run_history.clone();
761 if let Some(global) = &self.global {
762 overridden.cli_settings.global = global.clone();
763 }
764 if let Some(runtime_settings) = &self.default_runtime_settings {
765 overridden.default_runtime_settings = runtime_settings.clone();
766 }
767 overridden
768 }
769}
770
771#[derive(Debug, Clone)]
773pub struct StateLoadSummary {
774 pub elapsed: Duration,
776 pub serialized_size_bytes: Option<u64>,
778 pub total_graphs: usize,
780}
781
782#[cfg(feature = "cli")]
783#[derive(Clone)]
784struct SessionPrompt {
785 left_prompt: String,
786}
787
788#[cfg(feature = "cli")]
789impl Prompt for SessionPrompt {
790 fn render_prompt_left(&self) -> Cow<'_, str> {
791 Cow::Borrowed(&self.left_prompt)
792 }
793
794 fn render_prompt_right(&self) -> Cow<'_, str> {
795 Cow::Borrowed("")
796 }
797
798 fn render_prompt_indicator(&self, _prompt_mode: PromptEditMode) -> Cow<'_, str> {
799 Cow::Borrowed("> ")
800 }
801
802 fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
803 Cow::Borrowed("::: ")
804 }
805
806 fn render_prompt_history_search_indicator(
807 &self,
808 history_search: PromptHistorySearch,
809 ) -> Cow<'_, str> {
810 Cow::Owned(format!("(reverse-search: {}) ", history_search.term))
811 }
812}
813
814#[cfg(feature = "cli")]
815fn make_repl_prompt(state_label: &str, pending_block_name: Option<&str>) -> Box<dyn Prompt> {
816 let left_prompt = match pending_block_name {
817 Some(name) => format!(
818 "{} | γloop {} ",
819 state_label,
820 format!("[defining: {name}]").blue()
821 ),
822 None => format!("{} | γloop ", state_label),
823 };
824 Box::new(SessionPrompt { left_prompt })
825}
826
827#[cfg(feature = "cli")]
828fn format_duration_human(duration: Duration) -> String {
829 if duration.as_secs() >= 60 {
830 let minutes = duration.as_secs() / 60;
831 let seconds = duration.as_secs_f64() - (minutes * 60) as f64;
832 format!("{minutes}m {seconds:.1}s")
833 } else if duration.as_secs_f64() >= 1.0 {
834 format!("{:.2}s", duration.as_secs_f64())
835 } else {
836 format!("{}ms", duration.as_millis())
837 }
838}
839
840#[cfg(feature = "cli")]
841fn format_size_human(bytes: u64) -> String {
842 const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
843
844 let mut value = bytes as f64;
845 let mut unit = 0usize;
846 while value >= 1024.0 && unit < UNITS.len() - 1 {
847 value /= 1024.0;
848 unit += 1;
849 }
850
851 if unit == 0 {
852 format!("{bytes} {}", UNITS[unit])
853 } else {
854 format!("{value:.2} {}", UNITS[unit])
855 }
856}
857
858fn serialized_size_of_path(path: &Path) -> Option<u64> {
859 let mut total = 0u64;
860 for entry in WalkDir::new(path) {
861 let entry = entry.ok()?;
862 if entry.file_type().is_file() {
863 total = total.checked_add(entry.metadata().ok()?.len())?;
864 }
865 }
866 Some(total)
867}
868
869fn total_graph_count(state: &State) -> usize {
870 state
871 .process_list
872 .processes
873 .iter()
874 .map(|process| match &process.collection {
875 ProcessCollection::Amplitudes(amplitudes) => amplitudes
876 .values()
877 .map(|amplitude| amplitude.graphs.len())
878 .sum::<usize>(),
879 ProcessCollection::CrossSections(cross_sections) => cross_sections
880 .values()
881 .map(|cross_section| cross_section.supergraphs.len())
882 .sum::<usize>(),
883 })
884 .sum::<usize>()
885}
886
887#[cfg(feature = "cli")]
888fn banner_footer_line(spec: &str) -> String {
889 format!(
890 r#" ▀██████▀ version:{:<15} log level:{} "#,
891 GIT_VERSION, spec,
892 )
893}
894
895#[cfg(feature = "cli")]
896fn banner_width(spec: &str) -> usize {
897 BANNER_ART
898 .lines()
899 .map(measure_text_width)
900 .chain(std::iter::once(measure_text_width(&banner_footer_line(
901 spec,
902 ))))
903 .max()
904 .unwrap_or_default()
905}
906
907#[cfg(feature = "cli")]
908fn print_state_load_summary(summary: &StateLoadSummary) {
909 let spec_label = get_stderr_log_filter_label();
910 let banner_width = banner_width(&spec_label);
911 let plain_summary = format!(
912 "State: load {} | disk {} | #graphs {}",
913 format_duration_human(summary.elapsed),
914 summary
915 .serialized_size_bytes
916 .map(format_size_human)
917 .unwrap_or_else(|| "unknown".to_string()),
918 summary.total_graphs
919 );
920 let left_padding =
921 " ".repeat(banner_width.saturating_sub(measure_text_width(&plain_summary)) / 2);
922 let state_label = "State:".blue();
923 let load_label = "load".blue();
924 let disk_label = "disk".blue();
925 let graphs_label = "#graphs".blue();
926 let load_value = format_duration_human(summary.elapsed).green();
927 let disk_value = summary
928 .serialized_size_bytes
929 .map(format_size_human)
930 .unwrap_or_else(|| "unknown".to_string())
931 .green();
932 let graph_value = summary.total_graphs.to_string().green();
933
934 println!(
935 "{}{} {} {} | {} {} | {} {}\n",
936 left_padding,
937 state_label,
938 load_label,
939 load_value,
940 disk_label,
941 disk_value,
942 graphs_label,
943 graph_value
944 );
945}
946
947impl OneShot {
948 fn has_process_selection(&self) -> bool {
949 self.processes.as_ref().is_some_and(|v| !v.is_empty())
950 || self.integrands.as_ref().is_some_and(|v| !v.is_empty())
951 }
952
953 fn process_load_selection(&self) -> Result<Option<ProcessLoadSelection>> {
954 let process_names = self
955 .processes
956 .clone()
957 .unwrap_or_default()
958 .into_iter()
959 .map(|name| {
960 let name = name.trim().to_string();
961 if name.is_empty() {
962 Err(eyre!("--processes cannot contain an empty process name"))
963 } else {
964 Ok(name)
965 }
966 })
967 .collect::<Result<std::collections::BTreeSet<_>>>()?;
968
969 let integrand_selectors = self
970 .integrands
971 .clone()
972 .unwrap_or_default()
973 .into_iter()
974 .map(|selector| {
975 let selector = selector.trim();
976 if selector.is_empty() {
977 return Err(eyre!("--integrands cannot contain an empty selector"));
978 }
979 if let Some((process_name, integrand_name)) = selector.split_once('@') {
980 let process_name = process_name.trim();
981 let integrand_name = integrand_name.trim();
982 if process_name.is_empty()
983 || integrand_name.is_empty()
984 || integrand_name.contains('@')
985 {
986 return Err(eyre!(
987 "Invalid integrand selector '{}': expected PROCESS@INTEGRAND",
988 selector
989 ));
990 }
991 Ok(ProcessLoadIntegrandSelector::Qualified {
992 process_name: process_name.to_string(),
993 integrand_name: integrand_name.to_string(),
994 })
995 } else {
996 Ok(ProcessLoadIntegrandSelector::Name(selector.to_string()))
997 }
998 })
999 .collect::<Result<Vec<_>>>()?;
1000
1001 if process_names.is_empty() && integrand_selectors.is_empty() {
1002 Ok(None)
1003 } else {
1004 Ok(Some(ProcessLoadSelection {
1005 process_names,
1006 integrand_selectors,
1007 }))
1008 }
1009 }
1010
1011 pub fn new_cli_settings(&self, global: GlobalSettings) -> CLISettings {
1012 let mut session = SessionSettings::default();
1013 session.set_user_requested_read_only_state(self.read_only_state);
1014 CLISettings {
1015 try_strings: !self.no_try_strings,
1016 override_state: self.override_state,
1017 state: StateSettings {
1018 folder: self.state_folder.clone(),
1019 ..StateSettings::default()
1020 },
1021 global,
1022 session,
1023 }
1024 }
1025
1026 pub fn new_test(state_folder: PathBuf) -> Self {
1027 OneShot {
1028 state_folder,
1029 state_folder_explicitly_set: false,
1030 boot_commands_path: None,
1031 model_file: None,
1032 no_save_state: true,
1033 override_state: false,
1034 command: None,
1035 level: None,
1036 logfile_level: None,
1037 logging_prefix: None,
1038 read_only_state: false,
1039 settings_global_path: None,
1040 settings_runtime_defaults_path: None,
1041 no_try_strings: false,
1042 #[cfg(feature = "cli")]
1043 completions: None,
1044 clean_state: false,
1045 processes: None,
1046 integrands: None,
1047 trace_logs_filename: None,
1048 }
1049 }
1050
1051 fn current_state_folder_kind(&self) -> Result<StateFolderKind> {
1052 classify_state_folder(&self.state_folder)
1053 }
1054
1055 fn clean_resolved_state_folder(&self) -> Result<()> {
1056 if !self.clean_state {
1057 return Ok(());
1058 }
1059
1060 if self.has_process_selection() {
1061 return Err(eyre!(
1062 "--clean-state cannot be combined with --processes or --integrands because the selected state is loaded from disk"
1063 ));
1064 }
1065
1066 if self.read_only_state {
1067 return Err(eyre!(
1068 "Cannot remove the active state folder '{}' because this session was started with --read-only-state. Restart without --read-only-state to use --clean-state.",
1069 self.state_folder.display()
1070 ));
1071 }
1072
1073 let metadata = match fs::symlink_metadata(&self.state_folder) {
1074 Ok(metadata) => metadata,
1075 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()),
1076 Err(err) => {
1077 return Err(err).wrap_err_with(|| {
1078 format!(
1079 "Failed to inspect state path '{}' before cleaning",
1080 self.state_folder.display()
1081 )
1082 })
1083 }
1084 };
1085
1086 if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() {
1087 fs::remove_dir_all(&self.state_folder).wrap_err_with(|| {
1088 format!(
1089 "Failed to remove state folder '{}' before startup",
1090 self.state_folder.display()
1091 )
1092 })?;
1093 } else {
1094 fs::remove_file(&self.state_folder).wrap_err_with(|| {
1095 format!(
1096 "Failed to remove state path '{}' before startup",
1097 self.state_folder.display()
1098 )
1099 })?;
1100 }
1101
1102 Ok(())
1103 }
1104
1105 fn initial_cli_settings_for_startup(
1106 &self,
1107 state_folder_kind: &StateFolderKind,
1108 boot_run_history: Option<&RunHistory>,
1109 settings_file_overrides: &SettingsFileOverrides,
1110 ) -> Result<CLISettings> {
1111 let mut cli_settings = match state_folder_kind {
1112 StateFolderKind::Saved => Self::load_global_settings_file(&self.state_folder)?,
1113 StateFolderKind::Missing | StateFolderKind::Scratch | StateFolderKind::Unmanifested => {
1114 boot_run_history
1115 .map(|run_history| run_history.cli_settings.clone())
1116 .unwrap_or_else(|| self.new_cli_settings(GlobalSettings::default()))
1117 }
1118 StateFolderKind::Invalid(reason) => {
1119 return Err(eyre::eyre!(reason.clone()));
1120 }
1121 };
1122 cli_settings.override_with(self);
1123 if let Some(global) = &settings_file_overrides.global {
1124 cli_settings.global = global.clone();
1125 }
1126 Ok(cli_settings)
1127 }
1128
1129 fn configure_startup_tracing(&self, cli_settings: &CLISettings) -> Result<()> {
1130 if self.read_only_state && !matches!(self.logfile_level, None | Some(LogLevel::Off)) {
1131 return Err(eyre::eyre!(
1132 "--read-only-state is incompatible with enabling logfile output"
1133 ));
1134 }
1135
1136 set_file_log_filter(&cli_settings.global.logfile_directive)?;
1137 set_stderr_log_filter(&cli_settings.global.display_directive)?;
1138 set_log_style(cli_settings.global.log_style.to_runtime());
1139 set_log_format_override(self.logging_prefix);
1140 set_stderr_log_filter_override(
1141 self.level
1142 .map(|level| level.to_cli_display_directive_spec().to_string()),
1143 )?;
1144
1145 let (file_override, hard_disable_file_logs, hard_disable_reason) = if self.read_only_state {
1146 (
1147 Some(LogLevel::Off.to_cli_logfile_directive_spec().to_string()),
1148 true,
1149 Some("--read-only-state"),
1150 )
1151 } else if matches!(self.logfile_level, Some(LogLevel::Off)) {
1152 (
1153 Some(LogLevel::Off.to_cli_logfile_directive_spec().to_string()),
1154 true,
1155 Some("--logfile-level off"),
1156 )
1157 } else {
1158 (
1159 self.logfile_level
1160 .map(|level| level.to_cli_logfile_directive_spec().to_string()),
1161 false,
1162 None,
1163 )
1164 };
1165
1166 set_file_log_filter_override(file_override)?;
1167 configure_file_log_boot_mode(hard_disable_file_logs, hard_disable_reason)?;
1168 Ok(())
1169 }
1170
1171 fn load_boot_run_history(&self) -> Result<Option<RunHistory>> {
1172 self.boot_commands_path
1173 .as_ref()
1174 .map(RunHistory::load)
1175 .transpose()
1176 }
1177
1178 fn load_settings_file_overrides(&self) -> Result<SettingsFileOverrides> {
1179 let global = self
1180 .settings_global_path
1181 .as_ref()
1182 .map(|path| CLISettings::from_file_typed(path).map(|settings| settings.global))
1183 .transpose()?;
1184 let default_runtime_settings = self
1185 .settings_runtime_defaults_path
1186 .as_ref()
1187 .map(RuntimeSettings::from_file_typed)
1188 .transpose()?;
1189
1190 Ok(SettingsFileOverrides {
1191 global,
1192 default_runtime_settings,
1193 })
1194 }
1195
1196 fn load_global_settings_file(state_folder: &std::path::Path) -> Result<CLISettings> {
1197 let global_settings_path = state_folder.join(GLOBAL_SETTINGS_FILENAME);
1198 match CLISettings::from_file_typed(&global_settings_path) {
1199 Ok(settings) => Ok(settings),
1200 Err(SerdeFileError::FileError(_)) => Ok(CLISettings::default()),
1201 Err(err) => Err(err.into()),
1202 }
1203 }
1204
1205 fn subcmd_input_string(
1206 argv: &[OsString],
1207 cmd: &clap::Command,
1208 matches: &clap::ArgMatches,
1209 ) -> Option<String> {
1210 let (sc_name, _) = matches.subcommand()?; let sc = cmd.get_subcommands().find(|c| c.get_name() == sc_name)?;
1214
1215 let mut names: Vec<&str> = vec![sc_name];
1217 names.extend(sc.get_all_aliases());
1218
1219 let idx = argv.iter().position(|t| {
1221 let s = t.to_string_lossy();
1222 names.iter().any(|&n| s == n)
1223 })?;
1224
1225 Some(
1227 argv[idx..]
1228 .iter()
1229 .map(|s| s.to_string_lossy().into_owned())
1230 .collect::<Vec<_>>()
1231 .join(" "),
1232 )
1233 }
1234
1235 fn is_inline_run_command_flag(arg: &OsString) -> bool {
1236 let arg = arg.to_string_lossy();
1237 arg == "-c" || arg == "--commands" || arg.starts_with("--commands=")
1238 }
1239
1240 fn legacy_post_card_inline_run_argv(argv: &[OsString]) -> Option<Vec<OsString>> {
1241 let inline_command_index = argv.iter().position(Self::is_inline_run_command_flag)?;
1242 let prefix = &argv[..inline_command_index];
1243 if prefix.len() < 2 {
1244 return None;
1245 }
1246
1247 let prefix_cli = OneShot::try_parse_from(prefix.to_vec()).ok()?;
1248 if prefix_cli.boot_commands_path.is_none() || prefix_cli.command.is_some() {
1249 return None;
1250 }
1251
1252 let mut expanded = Vec::with_capacity(argv.len() + 1);
1253 expanded.extend(prefix.iter().cloned());
1254 expanded.push(OsString::from("run"));
1255 expanded.extend(argv[inline_command_index..].iter().cloned());
1256 Some(expanded)
1257 }
1258
1259 fn parse_args_with_capture<I, T>(args: I) -> Result<Parsed, clap::Error>
1260 where
1261 I: IntoIterator<Item = T>,
1262 T: Into<OsString>,
1263 {
1264 let argv: Vec<OsString> = command_parser::normalize_clap_args(
1265 args.into_iter()
1266 .map(Into::into)
1267 .map(|arg| arg.to_string_lossy().into_owned())
1268 .collect(),
1269 )
1270 .into_iter()
1271 .map(OsString::from)
1272 .collect();
1273
1274 let mut cmd = <OneShot as CommandFactory>::command();
1276 let (matches, argv) = match cmd.clone().try_get_matches_from(&argv) {
1277 Ok(matches) => (matches, argv),
1278 Err(err) => {
1279 let Some(expanded_argv) = Self::legacy_post_card_inline_run_argv(&argv) else {
1280 return Err(err);
1281 };
1282 match cmd.clone().try_get_matches_from(&expanded_argv) {
1283 Ok(matches) => (matches, expanded_argv),
1284 Err(_) => return Err(err),
1285 }
1286 }
1287 };
1288
1289 let cli = <OneShot as FromArgMatches>::from_arg_matches(&matches)
1290 .map_err(|e| e.format(&mut cmd))?;
1291 let mut cli = cli;
1292 cli.state_folder_explicitly_set =
1293 matches.value_source("state_folder") == Some(ValueSource::CommandLine);
1294
1295 let input_string = OneShot::subcmd_input_string(&argv, &cmd, &matches).unwrap_or_default();
1296 Ok(Parsed {
1297 input_string,
1298 matches,
1299 cli,
1300 })
1301 }
1302
1303 pub fn parse_env_with_capture() -> Result<Parsed, clap::Error> {
1305 Self::parse_args_with_capture(std::env::args_os())
1306 }
1307
1308 fn resolve_initial_state_folder(
1309 &self,
1310 boot_run_history: Option<&RunHistory>,
1311 ) -> Result<PathBuf> {
1312 if self.state_folder_explicitly_set {
1313 return Ok(self.state_folder.clone());
1314 }
1315
1316 if let Some(run_history) = boot_run_history {
1317 return Ok(run_history.cli_settings.state.folder.clone());
1318 }
1319
1320 Ok(self.state_folder.clone())
1321 }
1322
1323 fn load_with_boot_context(
1324 &mut self,
1325 state_folder_kind: StateFolderKind,
1326 boot_run_history: Option<&RunHistory>,
1327 settings_file_overrides: &SettingsFileOverrides,
1328 ) -> Result<(
1329 State,
1330 RunHistory,
1331 CLISettings,
1332 RuntimeSettings,
1333 Option<StateLoadSummary>,
1334 )> {
1335 let process_selection = self.process_load_selection()?;
1336 if process_selection.is_some() {
1337 if !matches!(state_folder_kind, StateFolderKind::Saved) {
1338 return Err(eyre!(
1339 "--processes/--integrands require an existing saved state"
1340 ));
1341 }
1342 self.no_save_state = true;
1345 self.read_only_state = true;
1346 }
1347 let startup_cli_settings = self.initial_cli_settings_for_startup(
1348 &state_folder_kind,
1349 boot_run_history,
1350 settings_file_overrides,
1351 )?;
1352 self.configure_startup_tracing(&startup_cli_settings)?;
1353
1354 let (state, run_history, cli_settings, default_runtime_settings, load_summary) =
1355 match state_folder_kind {
1356 StateFolderKind::Saved => {
1357 let load_started = Instant::now();
1358 let mut state = State::load_with_selection(
1359 self.state_folder.clone(),
1360 self.model_file.clone(),
1361 self.trace_logs_filename.clone(),
1362 process_selection.as_ref(),
1363 )
1364 .wrap_err_with(|| {
1365 format!(
1366 "Failed to load existing state from {}",
1367 self.state_folder.display()
1368 )
1369 })?;
1370 let allow_symjit_fallback =
1371 startup_cli_settings.global.generation.evaluator.compile
1372 && matches!(
1373 startup_cli_settings
1374 .global
1375 .generation
1376 .compile
1377 .compilation_mode,
1378 gammalooprs::settings::global::CompilationMode::Symjit
1379 );
1380 state.activate_loaded_integrand_backends(allow_symjit_fallback)?;
1381
1382 let default_runtime = match RuntimeSettings::from_file_typed(
1383 self.state_folder.join(DEFAULT_RUNTIME_SETTINGS_FILENAME),
1384 ) {
1385 Ok(a) => a,
1386 Err(SerdeFileError::FileError(_)) => RuntimeSettings::default(),
1387 Err(e) => return Err(e.into()),
1388 };
1389 let run_path = self.state_folder.join("run.toml");
1390 let run_history = if run_path.exists() {
1391 RunHistory::load(run_path)?
1392 } else {
1393 RunHistory::default()
1394 };
1395
1396 let load_summary = StateLoadSummary {
1397 elapsed: load_started.elapsed(),
1398 serialized_size_bytes: serialized_size_of_path(&self.state_folder),
1399 total_graphs: total_graph_count(&state),
1400 };
1401
1402 (
1403 state,
1404 run_history,
1405 startup_cli_settings,
1406 default_runtime,
1407 Some(load_summary),
1408 )
1409 }
1410 StateFolderKind::Missing | StateFolderKind::Scratch => {
1411 info!(
1412 "{} {}",
1413 "Initializing new state in".blue(),
1414 self.state_folder.display().to_string().green()
1415 );
1416
1417 let mut run_history = RunHistory::default();
1418 if let Some(boot_run_history) = boot_run_history {
1419 run_history.freeze_boot_settings_from(boot_run_history);
1420 }
1421
1422 (
1423 State::new(self.state_folder.clone(), self.trace_logs_filename.clone()),
1424 run_history,
1425 startup_cli_settings,
1426 RuntimeSettings::default(),
1427 None,
1428 )
1429 }
1430 StateFolderKind::Unmanifested => {
1431 warn!(
1432 "State folder '{}' has no {}; treating leftover contents as blank scratch state.",
1433 self.state_folder.display(),
1434 "state_manifest.toml",
1435 );
1436 info!(
1437 "{} {}",
1438 "Initializing new state in".blue(),
1439 self.state_folder.display().to_string().green()
1440 );
1441
1442 let mut run_history = RunHistory::default();
1443 if let Some(boot_run_history) = boot_run_history {
1444 run_history.freeze_boot_settings_from(boot_run_history);
1445 }
1446
1447 (
1448 State::new(self.state_folder.clone(), self.trace_logs_filename.clone()),
1449 run_history,
1450 startup_cli_settings,
1451 RuntimeSettings::default(),
1452 None,
1453 )
1454 }
1455 StateFolderKind::Invalid(_) => unreachable!(),
1456 };
1457
1458 cli_settings.sync_settings()?;
1459
1460 Ok((
1461 state,
1462 run_history,
1463 cli_settings,
1464 default_runtime_settings,
1465 load_summary,
1466 ))
1467 }
1468
1469 pub fn load(
1470 &mut self,
1471 ) -> Result<(
1472 State,
1473 RunHistory,
1474 CLISettings,
1475 RuntimeSettings,
1476 Option<StateLoadSummary>,
1477 )> {
1478 let boot_run_history = self.load_boot_run_history()?;
1479 let settings_file_overrides = self.load_settings_file_overrides()?;
1480 self.state_folder = self.resolve_initial_state_folder(boot_run_history.as_ref())?;
1481 self.clean_resolved_state_folder()?;
1482 let state_folder_kind = self.current_state_folder_kind()?;
1483 if let StateFolderKind::Invalid(reason) = &state_folder_kind {
1484 return Err(eyre::eyre!(reason.clone()));
1485 }
1486 self.load_with_boot_context(
1487 state_folder_kind,
1488 boot_run_history.as_ref(),
1489 &settings_file_overrides,
1490 )
1491 }
1492
1493 fn bootstrap_session(&mut self) -> Result<(LoadedState, Option<SaveState>)> {
1494 let boot_run_history = self.load_boot_run_history()?;
1495 let settings_file_overrides = self.load_settings_file_overrides()?;
1496 self.state_folder = self.resolve_initial_state_folder(boot_run_history.as_ref())?;
1497 self.clean_resolved_state_folder()?;
1498 let state_folder_kind = self.current_state_folder_kind()?;
1499 if let StateFolderKind::Invalid(reason) = &state_folder_kind {
1500 return Err(eyre::eyre!(reason.clone()));
1501 }
1502 let booted_existing_state = matches!(state_folder_kind, StateFolderKind::Saved);
1503
1504 let (
1505 mut state,
1506 mut run_history,
1507 mut cli_settings,
1508 mut default_runtime_settings,
1509 state_load_summary,
1510 ) = self.load_with_boot_context(
1511 state_folder_kind,
1512 boot_run_history.as_ref(),
1513 &settings_file_overrides,
1514 )?;
1515 settings_file_overrides.apply(&mut cli_settings, &mut default_runtime_settings)?;
1516
1517 let mut session_state = CliSessionState::default();
1518 let mut session = CliSession::new(
1519 &mut state,
1520 &mut run_history,
1521 &mut cli_settings,
1522 &mut default_runtime_settings,
1523 &mut session_state,
1524 );
1525
1526 let mut boot_exit = None;
1527 if let Some(boot_run_history) = boot_run_history.as_ref() {
1528 let effective_boot_run_history =
1529 settings_file_overrides.apply_to_run_history(boot_run_history);
1530 if let ControlFlow::Break(save_state) = session.apply_boot_run_history(
1531 boot_run_history,
1532 &effective_boot_run_history,
1533 booted_existing_state,
1534 )? {
1535 boot_exit = Some(save_state);
1536 }
1537 }
1538
1539 Ok((
1540 LoadedState {
1541 state,
1542 run_history,
1543 cli_settings,
1544 default_runtime_settings,
1545 session_state,
1546 state_load_summary,
1547 },
1548 boot_exit,
1549 ))
1550 }
1551
1552 #[cfg(feature = "cli")]
1553 pub fn run(mut self, raw: String) -> Result<()> {
1554 if let Some(shell) = self.completions {
1555 print!("{}", generate_completion_script(shell));
1556 return Ok(());
1557 }
1558
1559 initialise()?;
1560
1561 let (
1562 LoadedState {
1563 mut state,
1564 mut run_history,
1565 mut cli_settings,
1566 mut default_runtime_settings,
1567 mut session_state,
1568 state_load_summary,
1569 ..
1570 },
1571 boot_exit,
1572 ) = self.bootstrap_session()?;
1573
1574 let boot_requested_exit = boot_exit.is_some();
1575 let mut save_state = boot_exit.unwrap_or_default();
1576 let mut session = CliSession::new(
1577 &mut state,
1578 &mut run_history,
1579 &mut cli_settings,
1580 &mut default_runtime_settings,
1581 &mut session_state,
1582 );
1583
1584 if !boot_requested_exit {
1585 let had_initial_command = self.command.is_some();
1586 let mut enter_repl = !had_initial_command;
1587
1588 if let Some(a) = self.command.take() {
1589 let command = if raw.trim().is_empty() {
1590 CommandHistory::new(a)
1591 } else {
1592 CommandHistory::new_with_raw(a, raw)
1593 };
1594 match session.execute_command(command)?.flow {
1595 ControlFlow::Break(a) => save_state = a,
1596 ControlFlow::Continue(()) => {
1597 enter_repl =
1598 std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
1599 }
1600 }
1601 }
1602
1603 if enter_repl {
1604 if !had_initial_command {
1605 print_banner();
1606 if let Some(summary) = state_load_summary.as_ref() {
1607 print_state_load_summary(summary);
1608 }
1609 }
1610 run_repl_session(&mut session, &mut save_state);
1611 }
1612 }
1613
1614 let implicit_read_only_exit =
1615 cli_settings.session.read_only_state && save_state == SaveState::default();
1616 let requested_active_state_save_after_auto_read_only = !self.no_save_state
1617 && !implicit_read_only_exit
1618 && !save_state.no_save_state
1619 && cli_settings
1620 .session
1621 .is_read_only_due_to_boot_settings_mismatch()
1622 && path_lies_within(
1623 &cli_settings.state.folder,
1624 &save_state
1625 .path
1626 .clone()
1627 .unwrap_or_else(|| cli_settings.state.folder.clone()),
1628 )?;
1629 if requested_active_state_save_after_auto_read_only {
1630 warn!(
1631 "Skipping save state to {} because boot card settings differ from the frozen settings stored in {} and this session was forced into --read-only-state. The active state was left unchanged; save to a path outside the active state folder if you want a separate snapshot.",
1632 save_state
1633 .path
1634 .as_deref()
1635 .unwrap_or(&cli_settings.state.folder)
1636 .display(),
1637 cli_settings.state.folder.join("run.toml").display()
1638 );
1639 }
1640 if !self.no_save_state
1641 && !implicit_read_only_exit
1642 && !requested_active_state_save_after_auto_read_only
1643 {
1644 debug!("Saving State, override: {}", self.override_state);
1645 save_state.save(
1646 &mut state,
1647 &run_history,
1648 &default_runtime_settings,
1649 &cli_settings,
1650 )?
1651 }
1652 Ok(())
1653 }
1654
1655 }
1657
1658#[cfg(feature = "cli")]
1659fn run_repl_session(session: &mut CliSession<'_>, save_state: &mut SaveState) {
1660 let completion_state = repl::SharedCompletionState::new();
1661 completion_state.update_from_session(session);
1662 let mut repl = ClapEditor::<Repl>::builder()
1663 .with_prompt(make_repl_prompt(&session.prompt_state_label(), None))
1664 .with_completion_state(completion_state.clone());
1665
1666 if let Some(home) = home_dir() {
1667 repl = repl.with_editor_hook(move |reed| {
1668 reed.with_history(Box::new(
1669 FileBackedHistory::with_file(10000, home.join(".gammaLoop_history")).unwrap(),
1670 ))
1671 })
1672 }
1673 let mut editor = repl.build();
1674 let refresh_repl_state = |editor: &mut repl::ClapEditor<Repl>,
1675 session: &session::CliSession<'_>| {
1676 completion_state.update_from_session(session);
1677 let prompt_state_label = session.prompt_state_label();
1678 let pending_block_name = session.pending_commands_block_name();
1679 editor.set_prompt(make_repl_prompt(
1680 &prompt_state_label,
1681 pending_block_name.as_deref(),
1682 ));
1683 };
1684
1685 loop {
1686 match editor.read_command() {
1687 ReadCommandOutput::Command(command, raw_input) => {
1688 match session
1689 .execute_command(CommandHistory::new_with_raw(command.command, raw_input))
1690 {
1691 Err(e) => {
1692 eprintln!("{e:?}");
1693 }
1694 Ok(execution) => match execution.flow {
1695 ControlFlow::Break(a) => {
1696 refresh_repl_state(&mut editor, session);
1697 *save_state = a;
1698 break;
1699 }
1700 ControlFlow::Continue(()) => {
1701 refresh_repl_state(&mut editor, session);
1702 }
1703 },
1704 }
1705 }
1706 ReadCommandOutput::EmptyLine => (),
1707 ReadCommandOutput::ClapError(e) => {
1708 e.print().unwrap();
1709 }
1710 ReadCommandOutput::ShlexError => {
1711 println!(
1712 "{} input was not valid and could not be processed",
1713 style("Error:").red().bold()
1714 );
1715 }
1716 ReadCommandOutput::ReedlineError(e) => {
1717 panic!("{e}");
1718 }
1719 ReadCommandOutput::CtrlC => {
1720 if session.dismiss_pending_commands_block("Ctrl-C") {
1721 editor.set_prompt(make_repl_prompt(&session.prompt_state_label(), None));
1722 continue;
1723 }
1724 continue;
1725 }
1726 ReadCommandOutput::CtrlD => {
1727 if session.dismiss_pending_commands_block("Ctrl-D") {
1728 editor.set_prompt(make_repl_prompt(&session.prompt_state_label(), None));
1729 continue;
1730 }
1731 break;
1732 }
1733 }
1734 }
1735}
1736
1737#[cfg(feature = "cli")]
1738fn generate_completion_script(shell: CompletionShell) -> String {
1739 let mut command = OneShot::command();
1740 let mut output = Vec::new();
1741 match shell {
1742 CompletionShell::Bash => {
1743 clap_complete::generate(Bash, &mut command, "gammaloop", &mut output)
1744 }
1745 CompletionShell::Elvish => {
1746 clap_complete::generate(Elvish, &mut command, "gammaloop", &mut output)
1747 }
1748 CompletionShell::Fish => {
1749 clap_complete::generate(Fish, &mut command, "gammaloop", &mut output)
1750 }
1751 CompletionShell::PowerShell => {
1752 clap_complete::generate(PowerShell, &mut command, "gammaloop", &mut output)
1753 }
1754 CompletionShell::Zsh => {
1755 clap_complete::generate(Zsh, &mut command, "gammaloop", &mut output)
1756 }
1757 CompletionShell::Nushell => {
1758 clap_complete::generate(Nushell, &mut command, "gammaloop", &mut output)
1759 }
1760 }
1761 let script = String::from_utf8(output).expect("clap completion script must be valid UTF-8");
1762 match shell {
1763 CompletionShell::Bash => patch_bash_completion_for_repo_wrapper(script),
1764 CompletionShell::Fish => {
1765 patch_fish_completion_for_repo_wrapper(normalize_fish_completion(script))
1766 }
1767 _ => script,
1768 }
1769}
1770
1771#[cfg(feature = "cli")]
1772fn patch_bash_completion_for_repo_wrapper(mut script: String) -> String {
1773 script.push_str(
1774 "\n# Support the repository wrapper script path as well.\n\
1775if [[ $(type -t _gammaloop) == function ]]; then\n\
1776 complete -F _gammaloop -o bashdefault -o default ./gammaloop\n\
1777fi\n",
1778 );
1779 script
1780}
1781
1782#[cfg(feature = "cli")]
1783fn patch_fish_completion_for_repo_wrapper(script: String) -> String {
1784 let mut wrapper_lines = Vec::new();
1785 for line in script.lines() {
1786 if line.starts_with("complete -c gammaloop") {
1787 wrapper_lines.push(line.replacen(
1788 "complete -c gammaloop",
1789 "complete -c './gammaloop'",
1790 1,
1791 ));
1792 }
1793 }
1794 if wrapper_lines.is_empty() {
1795 script
1796 } else {
1797 format!(
1798 "{script}\n# Support the repository wrapper script path as well.\n{}\n",
1799 wrapper_lines.join("\n")
1800 )
1801 }
1802}
1803
1804#[cfg(feature = "cli")]
1805fn normalize_fish_completion(script: String) -> String {
1806 let mut normalized = String::with_capacity(script.len());
1807 let bytes = script.as_bytes();
1808 let mut i = 0;
1809 while i < bytes.len() {
1810 if bytes[i..].starts_with(b"-a \"") {
1811 normalized.push_str("-a \"");
1812 i += 4;
1813 while i < bytes.len() {
1814 let b = bytes[i];
1815 if b == b'"' {
1816 normalized.push('"');
1817 i += 1;
1818 break;
1819 }
1820 if b == b'\n' {
1821 normalized.push(' ');
1822 } else {
1823 normalized.push(b as char);
1824 }
1825 i += 1;
1826 }
1827 } else {
1828 normalized.push(bytes[i] as char);
1829 i += 1;
1830 }
1831 }
1832 normalized
1833}
1834
1835pub mod commands;
1836
1837pub enum Log {
1838 Level(LevelFilter),
1839 Format(LogFormat),
1840}
1841
1842#[cfg(feature = "cli")]
1856pub(crate) fn print_banner() {
1857 let spec = get_stderr_log_filter_label();
1858 println!(
1859 "\n{}{}\n",
1860 BANNER_ART.to_string().bold().blue(),
1861 banner_footer_line(&spec)
1862 .replace(GIT_VERSION, &GIT_VERSION.green().to_string())
1863 .replace(&spec, &spec.green().to_string())
1864 .bold()
1865 .blue(),
1866 );
1867}
1868
1869pub fn write_schemas() -> Result<()> {
1870 let global_schema = schema_for!(GlobalSettings);
1871 let runtime_schema = schema_for!(RuntimeSettings);
1872 let runhistory_schema = schema_for!(RunHistory);
1873 let folder = get_schema_folder(false)?;
1874
1875 let mut global_file = File::create(folder.join("global.json"))?;
1876 let mut runtime_file = File::create(folder.join("runtime.json"))?;
1877 let mut runhistory_file = File::create(folder.join("runhistory.json"))?;
1878
1879 serde_json::to_writer_pretty(&mut global_file, &global_schema)
1880 .wrap_err("Could not write global schema")?;
1881 serde_json::to_writer_pretty(&mut runtime_file, &runtime_schema)
1882 .wrap_err("Could not write runtime schema")?;
1883 serde_json::to_writer_pretty(&mut runhistory_file, &runhistory_schema)
1884 .wrap_err("Could not write runhistory schema")?;
1885
1886 Ok(())
1887}
1888
1889#[cfg(test)]
1890mod tests {
1891 use std::{ffi::OsString, fs, path::PathBuf};
1892
1893 use clap::Parser;
1894 use serde_json::Value as JsonValue;
1895 use tempfile::tempdir;
1896
1897 use gammalooprs::{
1898 settings::{GlobalSettings, RuntimeSettings},
1899 utils::serde_utils::{ShowDefaultsGuard, SmartSerde},
1900 };
1901
1902 use crate::commands::{Duplicate, Remove};
1903 use crate::settings_tree::serialize_settings_with_defaults;
1904 use crate::state::ProcessRef;
1905 use crate::tracing::{
1906 configure_file_log_boot_mode, get_file_log_filter, get_stderr_log_filter,
1907 set_file_log_filter, set_file_log_filter_override, set_log_format_override, set_log_style,
1908 set_stderr_log_filter, set_stderr_log_filter_override,
1909 };
1910
1911 use super::{
1912 CLISettings, Commands, LogFormat, LogLevel, OneShot, Repl, RunHistory, StateSettings,
1913 GLOBAL_SETTINGS_FILENAME, LOG_TEST_MUTEX,
1914 };
1915 #[cfg(feature = "cli")]
1916 use {
1917 super::{generate_completion_script, CompletionShell, State},
1918 crate::{
1919 commands::save::SaveState,
1920 state::{CommandHistory, CommandsBlock},
1921 },
1922 };
1923
1924 const ENV_FILE_LOG_FILTER: &str = "GL_LOGFILE_FILTER";
1925 const ENV_DISPLAY_LOG_FILTER: &str = "GL_DISPLAY_FILTER";
1926 const ENV_ALL_LOG_FILTER: &str = "GL_ALL_LOG_FILTER";
1927
1928 struct LogEnvGuard {
1929 file: Option<OsString>,
1930 display: Option<OsString>,
1931 all: Option<OsString>,
1932 }
1933
1934 impl LogEnvGuard {
1935 fn capture() -> Self {
1936 Self {
1937 file: std::env::var_os(ENV_FILE_LOG_FILTER),
1938 display: std::env::var_os(ENV_DISPLAY_LOG_FILTER),
1939 all: std::env::var_os(ENV_ALL_LOG_FILTER),
1940 }
1941 }
1942
1943 fn clear() {
1944 unsafe {
1945 std::env::remove_var(ENV_FILE_LOG_FILTER);
1946 std::env::remove_var(ENV_DISPLAY_LOG_FILTER);
1947 std::env::remove_var(ENV_ALL_LOG_FILTER);
1948 }
1949 }
1950
1951 fn set(var: &str, value: &str) {
1952 unsafe {
1953 std::env::set_var(var, value);
1954 }
1955 }
1956 }
1957
1958 impl Drop for LogEnvGuard {
1959 fn drop(&mut self) {
1960 fn restore_var(name: &str, value: &Option<OsString>) {
1961 unsafe {
1962 if let Some(value) = value {
1963 std::env::set_var(name, value);
1964 } else {
1965 std::env::remove_var(name);
1966 }
1967 }
1968 }
1969
1970 restore_var(ENV_FILE_LOG_FILTER, &self.file);
1971 restore_var(ENV_DISPLAY_LOG_FILTER, &self.display);
1972 restore_var(ENV_ALL_LOG_FILTER, &self.all);
1973 }
1974 }
1975
1976 fn reset_tracing_state() {
1977 LogEnvGuard::clear();
1978 configure_file_log_boot_mode(false, None).unwrap();
1979 set_stderr_log_filter_override(None).unwrap();
1980 set_file_log_filter_override(None).unwrap();
1981 set_stderr_log_filter("info").unwrap();
1982 set_file_log_filter("off").unwrap();
1983 set_log_style(Default::default());
1984 set_log_format_override(None);
1985 }
1986
1987 fn write_run_card(path: &PathBuf, state_folder: &str) {
1988 let run_history = RunHistory {
1989 cli_settings: CLISettings {
1990 state: StateSettings {
1991 folder: state_folder.into(),
1992 ..StateSettings::default()
1993 },
1994 ..CLISettings::default()
1995 },
1996 ..RunHistory::default()
1997 };
1998 fs::write(path, toml::to_string_pretty(&run_history).unwrap()).unwrap();
1999 }
2000
2001 #[test]
2002 fn resolve_initial_state_folder_prefers_explicit_cli_value() {
2003 let temp = tempdir().unwrap();
2004 let run_path = temp.path().join("run.toml");
2005 write_run_card(&run_path, "./from_run_card");
2006
2007 let mut one_shot =
2008 OneShot::try_parse_from(["gammaloop", run_path.to_string_lossy().as_ref()]).unwrap();
2009 one_shot.state_folder = "./from_cli".into();
2010 one_shot.state_folder_explicitly_set = true;
2011
2012 assert_eq!(
2013 one_shot.resolve_initial_state_folder(None).unwrap(),
2014 PathBuf::from("./from_cli")
2015 );
2016 }
2017
2018 #[test]
2019 fn resolve_initial_state_folder_uses_run_card_when_cli_not_explicit() {
2020 let temp = tempdir().unwrap();
2021 let run_path = temp.path().join("run.toml");
2022 write_run_card(&run_path, "./from_run_card");
2023
2024 let one_shot =
2025 OneShot::try_parse_from(["gammaloop", run_path.to_string_lossy().as_ref()]).unwrap();
2026 let boot_run_history = one_shot.load_boot_run_history().unwrap();
2027
2028 assert_eq!(
2029 one_shot
2030 .resolve_initial_state_folder(boot_run_history.as_ref())
2031 .unwrap(),
2032 PathBuf::from("./from_run_card")
2033 );
2034 }
2035
2036 #[test]
2037 fn oneshot_accepts_clean_state_flag() {
2038 let parsed = OneShot::try_parse_from(["gammaloop", "--clean-state"]).unwrap();
2039 assert!(parsed.clean_state);
2040 }
2041
2042 #[test]
2043 fn oneshot_parses_process_and_integrand_load_selectors() {
2044 let parsed = OneShot::try_parse_from([
2045 "gammaloop",
2046 "--processes",
2047 "process_a",
2048 "process_b",
2049 "--integrands",
2050 "NLO",
2051 "process_c@NNLO",
2052 ])
2053 .unwrap();
2054
2055 let selection = parsed.process_load_selection().unwrap().unwrap();
2056 assert_eq!(
2057 selection.process_names.into_iter().collect::<Vec<_>>(),
2058 ["process_a", "process_b"]
2059 );
2060 assert_eq!(selection.integrand_selectors.len(), 2);
2061 assert!(matches!(
2062 &selection.integrand_selectors[0],
2063 gammalooprs::processes::ProcessLoadIntegrandSelector::Name(name) if name == "NLO"
2064 ));
2065 assert!(matches!(
2066 &selection.integrand_selectors[1],
2067 gammalooprs::processes::ProcessLoadIntegrandSelector::Qualified { process_name, integrand_name }
2068 if process_name == "process_c" && integrand_name == "NNLO"
2069 ));
2070 }
2071
2072 #[test]
2073 fn process_selection_rejects_clean_state() {
2074 let parsed =
2075 OneShot::try_parse_from(["gammaloop", "--clean-state", "--processes", "process_a"])
2076 .unwrap();
2077 let error = parsed.clean_resolved_state_folder().unwrap_err();
2078 assert!(error.to_string().contains("cannot be combined"));
2079 }
2080
2081 #[test]
2082 fn integrand_selector_rejects_malformed_qualified_name() {
2083 let parsed =
2084 OneShot::try_parse_from(["gammaloop", "--integrands", "process@integrand@extra"])
2085 .unwrap();
2086 let error = parsed.process_load_selection().unwrap_err();
2087 assert!(error.to_string().contains("expected PROCESS@INTEGRAND"));
2088 }
2089
2090 #[test]
2091 fn clean_state_removes_resolved_run_card_state_before_validation() {
2092 let temp = tempdir().unwrap();
2093 let state_path = temp.path().join("from_run_card");
2094 let run_path = temp.path().join("run.toml");
2095 write_run_card(&run_path, state_path.to_string_lossy().as_ref());
2096
2097 fs::create_dir_all(&state_path).unwrap();
2098 fs::write(state_path.join("stale.txt"), "stale").unwrap();
2099
2100 let mut one_shot = OneShot::try_parse_from([
2101 "gammaloop",
2102 "--clean-state",
2103 run_path.to_string_lossy().as_ref(),
2104 ])
2105 .unwrap();
2106
2107 let (_state, run_history, cli_settings, runtime_settings, summary) =
2108 one_shot.load().unwrap();
2109
2110 assert!(summary.is_none());
2111 assert!(run_history.commands.is_empty());
2112 assert_eq!(cli_settings.state.folder, state_path);
2113 assert_eq!(runtime_settings, RuntimeSettings::default());
2114 assert!(!cli_settings.state.folder.exists());
2115 }
2116
2117 #[test]
2118 fn clean_state_rejects_read_only_state_mode() {
2119 let temp = tempdir().unwrap();
2120 let state_path = temp.path().join("read_only_state");
2121 fs::create_dir_all(&state_path).unwrap();
2122 fs::write(state_path.join("stale.txt"), "stale").unwrap();
2123
2124 let mut one_shot = OneShot::try_parse_from([
2125 "gammaloop",
2126 "--clean-state",
2127 "--read-only-state",
2128 "-s",
2129 state_path.to_string_lossy().as_ref(),
2130 ])
2131 .unwrap();
2132
2133 let err = match one_shot.load() {
2134 Ok(_) => panic!("read-only clean-state startup should fail"),
2135 Err(err) => err,
2136 };
2137 assert!(format!("{err:?}").contains("--read-only-state"));
2138 assert!(state_path.join("stale.txt").exists());
2139 }
2140
2141 #[cfg(feature = "cli")]
2142 #[test]
2143 fn one_shot_run_history_persists_all_executed_commands() {
2144 let temp = tempdir().unwrap();
2145 let state_path = temp.path().join("state");
2146 let run_path = temp.path().join("boot.toml");
2147 let run_history = RunHistory {
2148 cli_settings: CLISettings {
2149 state: StateSettings {
2150 folder: state_path.clone(),
2151 ..StateSettings::default()
2152 },
2153 ..CLISettings::default()
2154 },
2155 command_blocks: vec![
2156 CommandsBlock {
2157 name: "cmdBlockA".to_string(),
2158 commands: vec![CommandHistory::from_raw_string(
2159 "set global kv global.logfile_directive=error",
2160 )
2161 .unwrap()],
2162 },
2163 CommandsBlock {
2164 name: "cmdBlockB".to_string(),
2165 commands: vec![CommandHistory::from_raw_string(
2166 "set default-runtime kv general.mu_r=12.0",
2167 )
2168 .unwrap()],
2169 },
2170 ],
2171 commands: vec![CommandHistory::from_raw_string(
2172 "set global kv global.display_directive=warn",
2173 )
2174 .unwrap()],
2175 ..RunHistory::default()
2176 };
2177 fs::write(&run_path, run_history.to_toml_string(true).unwrap()).unwrap();
2178
2179 let one_shot = OneShot::try_parse_from([
2180 "gammaloop",
2181 run_path.to_string_lossy().as_ref(),
2182 "run",
2183 "cmdBlockA",
2184 "cmdBlockB",
2185 "-c",
2186 "set default-runtime kv general.m_uv=7.0; quit -o",
2187 ])
2188 .unwrap();
2189
2190 one_shot
2191 .run(
2192 "run cmdBlockA cmdBlockB -c \"set default-runtime kv general.m_uv=7.0; quit -o\""
2193 .to_string(),
2194 )
2195 .unwrap();
2196
2197 let persisted = RunHistory::load(state_path.join("run.toml")).unwrap();
2198 let commands = persisted
2199 .commands
2200 .iter()
2201 .map(crate::session::display_command)
2202 .collect::<Vec<_>>();
2203 assert_eq!(
2204 commands,
2205 vec![
2206 "set global kv global.display_directive=warn",
2207 "run cmdBlockA cmdBlockB -c 'set default-runtime kv general.m_uv=7.0'",
2208 ]
2209 );
2210 }
2211
2212 #[cfg(feature = "cli")]
2213 #[test]
2214 fn auto_read_only_boot_settings_mismatch_ignores_quit_override_for_active_state() {
2215 let temp = tempdir().unwrap();
2216 let state_path = temp.path().join("state");
2217 let boot_path = temp.path().join("boot.toml");
2218
2219 let mut state = State::new_test();
2220 let mut cli_settings = CLISettings::default();
2221 cli_settings.state.folder = state_path.clone();
2222 let mut saved_run_history = RunHistory::default();
2223 saved_run_history.default_runtime_settings.general.mu_r = 11.0;
2224 SaveState {
2225 override_state: Some(true),
2226 ..Default::default()
2227 }
2228 .save(
2229 &mut state,
2230 &saved_run_history,
2231 &RuntimeSettings::default(),
2232 &cli_settings,
2233 )
2234 .unwrap();
2235 let saved_run_toml = fs::read_to_string(state_path.join("run.toml")).unwrap();
2236
2237 let mut boot_runtime = RuntimeSettings::default();
2238 boot_runtime.general.mu_r = 29.0;
2239 let boot_run_history = RunHistory {
2240 default_runtime_settings: boot_runtime,
2241 ..RunHistory::default()
2242 };
2243 fs::write(
2244 &boot_path,
2245 boot_run_history
2246 .to_toml_string(cli_settings.try_strings)
2247 .unwrap(),
2248 )
2249 .unwrap();
2250
2251 let mut one_shot = OneShot::try_parse_from([
2252 "gammaloop",
2253 "-s",
2254 state_path.to_string_lossy().as_ref(),
2255 boot_path.to_string_lossy().as_ref(),
2256 "quit",
2257 "-o",
2258 ])
2259 .unwrap();
2260 one_shot.state_folder_explicitly_set = true;
2261
2262 one_shot.run("quit -o".to_string()).unwrap();
2263
2264 assert_eq!(
2265 fs::read_to_string(state_path.join("run.toml")).unwrap(),
2266 saved_run_toml
2267 );
2268 }
2269
2270 #[test]
2271 fn oneshot_rejects_removed_fresh_state_flag() {
2272 assert!(OneShot::try_parse_from(["gammaloop", "--fresh-state"]).is_err());
2273 assert!(OneShot::try_parse_from(["gammaloop", "-f"]).is_err());
2274 }
2275
2276 #[test]
2277 fn oneshot_parses_run_subcommand_block_names() {
2278 let parsed =
2279 OneShot::try_parse_from(["gammaloop", "run", "generation", "integration"]).unwrap();
2280 let Some(Commands::Run(run)) = parsed.command else {
2281 panic!("expected run command");
2282 };
2283 assert_eq!(
2284 run.selected_block_names(),
2285 &["generation".to_string(), "integration".to_string()][..]
2286 );
2287 }
2288
2289 #[test]
2290 fn oneshot_treats_subcommand_names_as_run_block_names() {
2291 let parsed = OneShot::try_parse_from([
2292 "gammaloop",
2293 "run",
2294 "generate",
2295 "integrate_euclidean",
2296 "quit",
2297 ])
2298 .unwrap();
2299 let Some(Commands::Run(run)) = parsed.command else {
2300 panic!("expected run command");
2301 };
2302 assert_eq!(
2303 run.selected_block_names(),
2304 &[
2305 "generate".to_string(),
2306 "integrate_euclidean".to_string(),
2307 "quit".to_string()
2308 ][..]
2309 );
2310 }
2311
2312 #[test]
2313 fn oneshot_requires_explicit_generate_mode() {
2314 assert!(OneShot::try_parse_from(["gammaloop", "generate", "g", "g", ">", "h"]).is_err());
2315 }
2316
2317 #[test]
2318 fn oneshot_parses_subcommand_without_run_card() {
2319 let parsed = OneShot::try_parse_from(["gammaloop", "display", "integrand"]).unwrap();
2320 assert!(matches!(parsed.command, Some(Commands::Display(_))));
2321 }
2322
2323 #[test]
2324 fn oneshot_accepts_boot_card_positional() {
2325 let parsed = OneShot::try_parse_from(["gammaloop", "card.toml"]).unwrap();
2326 assert_eq!(parsed.boot_commands_path, Some(PathBuf::from("card.toml")));
2327 }
2328
2329 #[test]
2330 fn oneshot_allows_boot_card_positional_with_run_subcommand() {
2331 let parsed = OneShot::try_parse_from([
2332 "gammaloop",
2333 "card.toml",
2334 "-s",
2335 "./GL_OUTPUT/triangle",
2336 "run",
2337 "-c",
2338 "quit -o",
2339 ])
2340 .unwrap();
2341
2342 assert_eq!(parsed.boot_commands_path, Some(PathBuf::from("card.toml")));
2343 assert_eq!(parsed.state_folder, PathBuf::from("./GL_OUTPUT/triangle"));
2344 assert!(matches!(parsed.command, Some(Commands::Run(_))));
2345 }
2346
2347 #[test]
2348 fn oneshot_allows_post_card_inline_run_command_shortcut() {
2349 let parsed =
2350 OneShot::parse_args_with_capture(["gammaloop", "card.toml", "-c", "quit -n"]).unwrap();
2351
2352 assert_eq!(
2353 parsed.cli.boot_commands_path,
2354 Some(PathBuf::from("card.toml"))
2355 );
2356 let Some(Commands::Run(run)) = parsed.cli.command else {
2357 panic!("expected run command");
2358 };
2359 assert_eq!(run.commands.as_deref(), Some("quit -n"));
2360 }
2361
2362 #[test]
2363 fn oneshot_rejects_removed_boot_card_flag() {
2364 assert!(OneShot::try_parse_from(["gammaloop", "-c", "card.toml"]).is_err());
2365 }
2366
2367 #[test]
2368 fn oneshot_accepts_trace_logs_short_flag() {
2369 let parsed =
2370 OneShot::try_parse_from(["gammaloop", "-t", "trace.log", "card.toml"]).unwrap();
2371 assert_eq!(parsed.trace_logs_filename.as_deref(), Some("trace.log"));
2372 assert_eq!(parsed.boot_commands_path, Some(PathBuf::from("card.toml")));
2373 }
2374
2375 #[test]
2376 fn oneshot_leaves_logging_prefix_unspecified_by_default() {
2377 let parsed = OneShot::try_parse_from(["gammaloop"]).unwrap();
2378 assert_eq!(parsed.logging_prefix, None);
2379 }
2380
2381 #[test]
2382 fn oneshot_accepts_logging_prefix_long_flag_override() {
2383 let parsed = OneShot::try_parse_from(["gammaloop", "--logging-prefix", "long"]).unwrap();
2384 assert_eq!(parsed.logging_prefix, Some(LogFormat::Long));
2385 }
2386
2387 #[test]
2388 fn oneshot_accepts_logging_prefix_full_flag_override() {
2389 let parsed = OneShot::try_parse_from(["gammaloop", "--logging-prefix", "full"]).unwrap();
2390 assert_eq!(parsed.logging_prefix, Some(LogFormat::Full));
2391 }
2392
2393 #[test]
2394 fn oneshot_rejects_removed_logging_prefix_underscore_flag() {
2395 assert!(OneShot::try_parse_from(["gammaloop", "--logging_prefix", "long"]).is_err());
2396 }
2397
2398 #[test]
2399 fn oneshot_accepts_settings_global_short_flag() {
2400 let parsed = OneShot::try_parse_from(["gammaloop", "-g", "global.toml"]).unwrap();
2401 assert_eq!(
2402 parsed.settings_global_path,
2403 Some(PathBuf::from("global.toml"))
2404 );
2405 }
2406
2407 #[test]
2408 fn oneshot_accepts_settings_runtime_defaults_short_flag() {
2409 let parsed = OneShot::try_parse_from(["gammaloop", "-r", "runtime.toml"]).unwrap();
2410 assert_eq!(
2411 parsed.settings_runtime_defaults_path,
2412 Some(PathBuf::from("runtime.toml"))
2413 );
2414 }
2415
2416 #[test]
2417 fn oneshot_accepts_logfile_level_and_read_only_state_flags() {
2418 let parsed =
2419 OneShot::try_parse_from(["gammaloop", "--read-only-state", "--logfile-level", "off"])
2420 .unwrap();
2421 assert!(parsed.read_only_state);
2422 assert_eq!(parsed.logfile_level, Some(LogLevel::Off));
2423 }
2424
2425 #[test]
2426 fn configure_startup_tracing_uses_global_defaults_without_overrides() {
2427 let _guard = LOG_TEST_MUTEX.lock().unwrap_or_else(|err| err.into_inner());
2428 let _env = LogEnvGuard::capture();
2429 reset_tracing_state();
2430
2431 let cli = OneShot::try_parse_from(["gammaloop"]).unwrap();
2432 let cli_settings = CLISettings::default();
2433
2434 cli.configure_startup_tracing(&cli_settings).unwrap();
2435
2436 assert_eq!(get_stderr_log_filter(), "info");
2437 assert_eq!(get_file_log_filter(), "off");
2438 }
2439
2440 #[test]
2441 fn configure_startup_tracing_prefers_all_env_override_over_specific_envs() {
2442 let _guard = LOG_TEST_MUTEX.lock().unwrap_or_else(|err| err.into_inner());
2443 let _env = LogEnvGuard::capture();
2444 reset_tracing_state();
2445
2446 LogEnvGuard::set(ENV_ALL_LOG_FILTER, "trace");
2447 LogEnvGuard::set(ENV_DISPLAY_LOG_FILTER, "warn");
2448 LogEnvGuard::set(ENV_FILE_LOG_FILTER, "error");
2449
2450 let cli = OneShot::try_parse_from(["gammaloop"]).unwrap();
2451 let mut cli_settings = CLISettings::default();
2452 cli_settings.global.display_directive = "info".into();
2453 cli_settings.global.logfile_directive = "off".into();
2454
2455 cli.configure_startup_tracing(&cli_settings).unwrap();
2456
2457 assert_eq!(get_stderr_log_filter(), "trace");
2458 assert_eq!(get_file_log_filter(), "trace");
2459 }
2460
2461 #[test]
2462 fn configure_startup_tracing_cli_overrides_supersede_settings_and_env() {
2463 let _guard = LOG_TEST_MUTEX.lock().unwrap_or_else(|err| err.into_inner());
2464 let _env = LogEnvGuard::capture();
2465 reset_tracing_state();
2466
2467 LogEnvGuard::set(ENV_DISPLAY_LOG_FILTER, "warn");
2468 LogEnvGuard::set(ENV_FILE_LOG_FILTER, "error");
2469
2470 let cli = OneShot::try_parse_from(["gammaloop", "-l", "debug", "--logfile-level", "trace"])
2471 .unwrap();
2472 let mut cli_settings = CLISettings::default();
2473 cli_settings.global.display_directive = "info".into();
2474 cli_settings.global.logfile_directive = "off".into();
2475
2476 cli.configure_startup_tracing(&cli_settings).unwrap();
2477
2478 assert_eq!(
2479 get_stderr_log_filter(),
2480 "gammaloop_api=debug,gammalooprs=debug"
2481 );
2482 assert_eq!(
2483 get_file_log_filter(),
2484 "gammaloop_api=trace,gammalooprs=trace"
2485 );
2486 }
2487
2488 #[test]
2489 fn configure_startup_tracing_read_only_state_forces_logfile_off() {
2490 let _guard = LOG_TEST_MUTEX.lock().unwrap_or_else(|err| err.into_inner());
2491 let _env = LogEnvGuard::capture();
2492 reset_tracing_state();
2493
2494 let cli = OneShot::try_parse_from(["gammaloop", "--read-only-state"]).unwrap();
2495 let mut cli_settings = CLISettings::default();
2496 cli_settings.global.logfile_directive = "debug".into();
2497
2498 cli.configure_startup_tracing(&cli_settings).unwrap();
2499
2500 assert_eq!(get_stderr_log_filter(), "info");
2501 assert_eq!(get_file_log_filter(), "gammaloop_api=off,gammalooprs=off");
2502 }
2503
2504 #[cfg(feature = "cli")]
2505 #[test]
2506 fn oneshot_accepts_completions_flag() {
2507 let parsed = OneShot::try_parse_from(["gammaloop", "--completions", "bash"]).unwrap();
2508 assert_eq!(parsed.completions, Some(CompletionShell::Bash));
2509 }
2510
2511 #[cfg(feature = "cli")]
2512 #[test]
2513 fn bash_completion_script_binds_repo_wrapper_path() {
2514 let script = generate_completion_script(CompletionShell::Bash);
2515 assert!(script.contains("complete -F _gammaloop -o bashdefault -o default gammaloop"));
2516 assert!(script.contains("complete -F _gammaloop -o bashdefault -o default ./gammaloop"));
2517 }
2518
2519 #[cfg(feature = "cli")]
2520 #[test]
2521 fn fish_completion_script_binds_repo_wrapper_path() {
2522 let script = generate_completion_script(CompletionShell::Fish);
2523 assert!(script.contains("complete -c gammaloop"));
2524 assert!(script.contains("complete -c './gammaloop'"));
2525 assert!(!script.contains("-a \"true\\t''\nfalse\\t''\""));
2526 }
2527
2528 #[cfg(feature = "cli")]
2529 #[test]
2530 fn oneshot_accepts_nushell_completions_flag() {
2531 let parsed = OneShot::try_parse_from(["gammaloop", "--completions", "nushell"]).unwrap();
2532 assert_eq!(parsed.completions, Some(CompletionShell::Nushell));
2533 }
2534
2535 #[cfg(feature = "cli")]
2536 #[test]
2537 fn nushell_completion_script_is_exportable_module() {
2538 let script = generate_completion_script(CompletionShell::Nushell);
2539 assert!(script.contains("module completions"));
2540 assert!(script.contains("export use completions *"));
2541 }
2542
2543 #[test]
2544 fn repl_parses_normalized_integrand_selector_flags() {
2545 let parsed =
2546 Repl::try_parse_from(["gammaloop", "integrate", "-p", "triangle", "-i", "LO"]).unwrap();
2547
2548 let Commands::Integrate(integrate) = parsed.command else {
2549 panic!("expected integrate command");
2550 };
2551 assert_eq!(
2552 integrate.process,
2553 vec![ProcessRef::Unqualified("triangle".to_string())]
2554 );
2555 assert_eq!(integrate.integrand_name, vec!["LO".to_string()]);
2556 }
2557
2558 #[test]
2559 fn repl_parses_duplicate_integrand_output_selectors() {
2560 let parsed = Repl::try_parse_from([
2561 "gammaloop",
2562 "duplicate",
2563 "integrand",
2564 "-p",
2565 "box",
2566 "-i",
2567 "scalar_box",
2568 "--output_process_name",
2569 "box_copy",
2570 "--output_integrand_name",
2571 "scalar_box_copy",
2572 ])
2573 .unwrap();
2574
2575 let Commands::Duplicate(Duplicate::Integrand(command)) = parsed.command else {
2576 panic!("expected duplicate integrand command");
2577 };
2578 assert_eq!(
2579 command.process,
2580 Some(ProcessRef::Unqualified("box".to_string()))
2581 );
2582 assert_eq!(command.integrand_name, Some("scalar_box".to_string()));
2583 assert_eq!(command.output_process_name, "box_copy");
2584 assert_eq!(command.output_integrand_name, "scalar_box_copy");
2585 }
2586
2587 #[test]
2588 fn repl_parses_inspect_process_and_point_with_normalized_flags() {
2589 let parsed = Repl::try_parse_from([
2590 "gammaloop",
2591 "inspect",
2592 "-p",
2593 "triangle",
2594 "-i",
2595 "LO",
2596 "-x",
2597 "0.1",
2598 "0.2",
2599 "0.3",
2600 ])
2601 .unwrap();
2602
2603 let Commands::Inspect(inspect) = parsed.command else {
2604 panic!("expected inspect command");
2605 };
2606 assert_eq!(
2607 inspect.process,
2608 Some(ProcessRef::Unqualified("triangle".to_string()))
2609 );
2610 assert_eq!(inspect.integrand_name, Some("LO".to_string()));
2611 assert_eq!(inspect.point, vec![0.1, 0.2, 0.3]);
2612 }
2613
2614 #[test]
2615 fn repl_parses_inspect_discrete_dims_from_one_flag_occurrence() {
2616 let parsed = Repl::try_parse_from([
2617 "gammaloop",
2618 "inspect",
2619 "-p",
2620 "triangle",
2621 "-i",
2622 "LO",
2623 "-m",
2624 "-x",
2625 "0.1",
2626 "0.2",
2627 "0.3",
2628 "-d",
2629 "0",
2630 "0",
2631 ])
2632 .unwrap();
2633
2634 let Commands::Inspect(inspect) = parsed.command else {
2635 panic!("expected inspect command");
2636 };
2637 assert_eq!(inspect.discrete_dim, vec![0, 0]);
2638 }
2639
2640 #[test]
2641 fn repl_parses_inspect_discrete_dims_from_repeated_flags() {
2642 let parsed = Repl::try_parse_from([
2643 "gammaloop",
2644 "inspect",
2645 "-p",
2646 "triangle",
2647 "-i",
2648 "LO",
2649 "-m",
2650 "-x",
2651 "0.1",
2652 "0.2",
2653 "0.3",
2654 "-d",
2655 "0",
2656 "-d",
2657 "0",
2658 ])
2659 .unwrap();
2660
2661 let Commands::Inspect(inspect) = parsed.command else {
2662 panic!("expected inspect command");
2663 };
2664 assert_eq!(inspect.discrete_dim, vec![0, 0]);
2665 }
2666
2667 #[test]
2668 fn repl_parses_inspect_graph_and_orientation_ids() {
2669 let parsed = Repl::try_parse_from([
2670 "gammaloop",
2671 "inspect",
2672 "-p",
2673 "triangle",
2674 "-i",
2675 "LO",
2676 "-m",
2677 "-x",
2678 "0.1",
2679 "0.2",
2680 "0.3",
2681 "--graph-id",
2682 "2",
2683 "--orientation-id",
2684 "1",
2685 ])
2686 .unwrap();
2687
2688 let Commands::Inspect(inspect) = parsed.command else {
2689 panic!("expected inspect command");
2690 };
2691 assert_eq!(inspect.graph_id, Some(2));
2692 assert_eq!(inspect.orientation_id, Some(1));
2693 assert!(inspect.discrete_dim.is_empty());
2694 }
2695
2696 #[test]
2697 fn repl_parses_remove_processes_with_normalized_selectors() {
2698 let parsed = Repl::try_parse_from([
2699 "gammaloop",
2700 "remove",
2701 "processes",
2702 "-p",
2703 "triangle",
2704 "-i",
2705 "LO",
2706 ])
2707 .unwrap();
2708
2709 let Commands::Remove(Remove::Processes {
2710 process,
2711 integrand_name,
2712 }) = parsed.command
2713 else {
2714 panic!("expected remove processes command");
2715 };
2716 assert_eq!(
2717 process,
2718 Some(ProcessRef::Unqualified("triangle".to_string()))
2719 );
2720 assert_eq!(integrand_name, Some("LO".to_string()));
2721 }
2722
2723 #[test]
2724 fn settings_file_overrides_apply_without_touching_state_folder() {
2725 let temp = tempdir().unwrap();
2726 let global_path = temp.path().join("global.toml");
2727 let runtime_path = temp.path().join("runtime.toml");
2728
2729 let mut global_file_settings = CLISettings::default();
2730 global_file_settings.state.folder = "./ignored".into();
2731 global_file_settings.global.display_directive = "warn".into();
2732 fs::write(
2733 &global_path,
2734 toml::to_string_pretty(&global_file_settings).unwrap(),
2735 )
2736 .unwrap();
2737
2738 let mut runtime_file_settings = RuntimeSettings::default();
2739 runtime_file_settings.general.mu_r = 37.0;
2740 fs::write(
2741 &runtime_path,
2742 toml::to_string_pretty(&runtime_file_settings).unwrap(),
2743 )
2744 .unwrap();
2745
2746 let cli = OneShot::try_parse_from([
2747 "gammaloop",
2748 "--settings-global",
2749 global_path.to_string_lossy().as_ref(),
2750 "--settings-runtime-defaults",
2751 runtime_path.to_string_lossy().as_ref(),
2752 ])
2753 .unwrap();
2754
2755 let overrides = cli.load_settings_file_overrides().unwrap();
2756 let mut cli_settings = CLISettings::default();
2757 cli_settings.state.folder = "./keep".into();
2758 cli_settings.global = GlobalSettings::default();
2759 let mut runtime_settings = RuntimeSettings::default();
2760
2761 overrides
2762 .apply(&mut cli_settings, &mut runtime_settings)
2763 .unwrap();
2764
2765 assert_eq!(cli_settings.state.folder, PathBuf::from("./keep"));
2766 assert_eq!(cli_settings.global.display_directive, "warn");
2767 assert_eq!(runtime_settings.general.mu_r, 37.0);
2768 }
2769
2770 #[test]
2771 fn state_name_empty_string_deserializes_to_none() {
2772 let settings: CLISettings = toml::from_str(
2773 r#"
2774 [state]
2775 folder = "./named_state"
2776 name = ""
2777 "#,
2778 )
2779 .unwrap();
2780
2781 assert_eq!(settings.state.folder, PathBuf::from("./named_state"));
2782 assert_eq!(settings.state.name, None);
2783 }
2784
2785 #[test]
2786 fn state_prompt_label_prefers_name_and_falls_back_to_folder() {
2787 let named = StateSettings {
2788 folder: "./named_state".into(),
2789 name: Some("demo".into()),
2790 };
2791 assert_eq!(named.prompt_label(), "demo");
2792
2793 let unnamed = StateSettings {
2794 folder: "./named_state".into(),
2795 name: Some(" ".into()),
2796 };
2797 assert_eq!(unnamed.prompt_label(), "./named_state");
2798 }
2799
2800 #[test]
2801 fn state_name_is_present_in_completion_serialization() {
2802 let serialized =
2803 serialize_settings_with_defaults(&CLISettings::default(), "CLI settings").unwrap();
2804 let state = serialized
2805 .get("state")
2806 .and_then(JsonValue::as_object)
2807 .expect("state object must be present");
2808
2809 assert_eq!(
2810 state.get("name").and_then(JsonValue::as_str),
2811 Some(""),
2812 "state.name should stay visible to settings completion even when unset"
2813 );
2814 }
2815
2816 #[test]
2817 fn global_settings_defaults_stay_visible_in_completion_serialization() {
2818 let serialized =
2819 serialize_settings_with_defaults(&CLISettings::default(), "CLI settings").unwrap();
2820 let global = serialized
2821 .get("global")
2822 .and_then(JsonValue::as_object)
2823 .expect("global object must be present");
2824
2825 assert_eq!(
2826 global.get("display_directive").and_then(JsonValue::as_str),
2827 Some("info")
2828 );
2829 assert_eq!(
2830 global.get("logfile_directive").and_then(JsonValue::as_str),
2831 Some("off")
2832 );
2833 assert_eq!(
2834 global
2835 .get("generation")
2836 .and_then(JsonValue::as_object)
2837 .and_then(|generation| generation.get("compile"))
2838 .and_then(JsonValue::as_object)
2839 .and_then(|compile| compile.get("compiler"))
2840 .and_then(JsonValue::as_str),
2841 Some(gammalooprs::settings::global::default_external_compiler())
2842 );
2843 }
2844
2845 #[test]
2846 fn runtime_settings_custom_defaults_stay_visible_in_completion_serialization() {
2847 let serialized = serialize_settings_with_defaults(
2848 &RuntimeSettings::default(),
2849 "default runtime settings",
2850 )
2851 .unwrap();
2852 let serialized_text = serde_json::to_string(&serialized).unwrap();
2853 assert!(serialized_text.contains("large_deformation_check"));
2854 }
2855
2856 #[test]
2857 fn saved_global_settings_file_keeps_default_directives_visible() {
2858 let temp = tempdir().unwrap();
2859 let _show_defaults_guard = ShowDefaultsGuard::new(true);
2860 CLISettings::default()
2861 .to_file(temp.path().join(GLOBAL_SETTINGS_FILENAME), true)
2862 .unwrap();
2863 let saved = fs::read_to_string(temp.path().join(GLOBAL_SETTINGS_FILENAME)).unwrap();
2864
2865 assert!(saved.contains("display_directive = \"info\""), "{saved}");
2866 assert!(saved.contains("logfile_directive = \"off\""), "{saved}");
2867 assert!(saved.contains("log_format = \"Long\""), "{saved}");
2868 assert!(
2869 saved.contains(&format!(
2870 "compiler = \"{}\"",
2871 gammalooprs::settings::global::default_external_compiler()
2872 )),
2873 "{saved}"
2874 );
2875 }
2876
2877 #[test]
2878 fn settings_file_overrides_replace_boot_card_settings() {
2879 let mut run_history = RunHistory::default();
2880 run_history.cli_settings.global.display_directive = "error".into();
2881 run_history.default_runtime_settings.general.mu_r = 11.0;
2882
2883 let overridden_global = GlobalSettings {
2884 display_directive: "warn".into(),
2885 ..Default::default()
2886 };
2887 let mut overridden_runtime = RuntimeSettings::default();
2888 overridden_runtime.general.mu_r = 29.0;
2889
2890 let overrides = super::SettingsFileOverrides {
2891 global: Some(overridden_global),
2892 default_runtime_settings: Some(overridden_runtime),
2893 };
2894
2895 let overridden = overrides.apply_to_run_history(&run_history);
2896
2897 assert_eq!(overridden.cli_settings.global.display_directive, "warn");
2898 assert_eq!(overridden.default_runtime_settings.general.mu_r, 29.0);
2899 assert_eq!(run_history.cli_settings.global.display_directive, "error");
2900 assert_eq!(run_history.default_runtime_settings.general.mu_r, 11.0);
2901 }
2902
2903 #[test]
2904 fn load_global_settings_file_uses_default_when_missing() {
2905 let temp = tempdir().unwrap();
2906
2907 let mut new_settings = CLISettings::default();
2908 new_settings.global.display_directive = "warn".into();
2909 fs::write(
2910 temp.path().join(GLOBAL_SETTINGS_FILENAME),
2911 toml::to_string_pretty(&new_settings).unwrap(),
2912 )
2913 .unwrap();
2914
2915 let loaded = OneShot::load_global_settings_file(temp.path()).unwrap();
2916 assert_eq!(loaded.global.display_directive, "warn");
2917
2918 fs::remove_file(temp.path().join(GLOBAL_SETTINGS_FILENAME)).unwrap();
2919 let loaded = OneShot::load_global_settings_file(temp.path()).unwrap();
2920 assert_eq!(loaded, CLISettings::default());
2921 assert_eq!(
2922 loaded.global.log_style.log_format,
2923 gammalooprs::utils::tracing::LogFormat::Long
2924 );
2925 }
2926
2927 #[test]
2928 fn load_treats_logs_only_folder_as_blank_state() {
2929 let temp = tempdir().unwrap();
2930 std::fs::create_dir_all(temp.path().join("logs")).unwrap();
2931 std::fs::write(temp.path().join("logs").join("gammalog.jsonl"), "").unwrap();
2932
2933 let mut cli = OneShot::new_test(temp.path().to_path_buf());
2934 let (_state, run_history, cli_settings, runtime_settings, summary) = cli.load().unwrap();
2935
2936 assert!(summary.is_none());
2937 assert!(run_history.commands.is_empty());
2938 assert_eq!(cli_settings.state.folder, temp.path().to_path_buf());
2939 assert_eq!(runtime_settings, RuntimeSettings::default());
2940 }
2941
2942 #[test]
2943 fn load_treats_unmanifested_state_folder_as_blank_state() {
2944 let temp = tempdir().unwrap();
2945 std::fs::create_dir_all(temp.path().join("processes").join("amplitudes")).unwrap();
2946
2947 let mut cli = OneShot::new_test(temp.path().to_path_buf());
2948 let (_state, run_history, cli_settings, runtime_settings, summary) = cli.load().unwrap();
2949
2950 assert!(summary.is_none());
2951 assert!(run_history.commands.is_empty());
2952 assert_eq!(cli_settings.state.folder, temp.path().to_path_buf());
2953 assert_eq!(runtime_settings, RuntimeSettings::default());
2954 }
2955}