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