Skip to main content

gammaloop_api/
lib.rs

1//! Rust facade for loading a GammaLoop state and driving its command session.
2//!
3//! This crate owns GammaLoop's persistent state, run-card, command, CLI, and Python-facing
4//! lifecycle. The numerical integrand and event contracts live in [`gammalooprs`]. Embedded Rust
5//! callers normally configure [`StateLoadOption`], call [`StateLoadOption::load`], and borrow a
6//! [`session::CliSession`] from the resulting [`LoadedState`].
7//!
8//! Loading is not a pure deserialization step: it initializes process-global services, configures
9//! tracing, can apply a boot card, and can remove the resolved state path when `clean_state` is
10//! selected. Dropping [`LoadedState`] does not save it. Commands decide whether to mutate the
11//! in-memory state, write an explicitly requested export, or return a save/quit request.
12//!
13//! # Embedded lifecycle
14//!
15//! The following example is compile-checked but not run as a doctest because GammaLoop startup
16//! can require process-global Symbolica initialization and licensed features.
17//!
18//! ```no_run
19//! use std::ops::ControlFlow;
20//!
21//! use gammaloop_api::{
22//!     commands::CommandOutput, state::CommandHistory, StateLoadOption,
23//! };
24//!
25//! # fn main() -> color_eyre::Result<()> {
26//! let temporary = tempfile::tempdir()?;
27//! let mut loaded = StateLoadOption {
28//!     state_folder: Some(temporary.path().join("state")),
29//!     read_only_state: true,
30//!     ..StateLoadOption::default()
31//! }
32//! .load()?;
33//! assert!(loaded.state_load_summary.is_none());
34//!
35//! let execution = {
36//!     let mut session = loaded.cli_session();
37//!     let command = CommandHistory::from_raw_string("display settings global")?;
38//!     session.execute_command(command)?
39//! };
40//! assert!(matches!(execution.flow, ControlFlow::Continue(())));
41//! assert!(matches!(execution.output, CommandOutput::None));
42//! # Ok(())
43//! # }
44//! ```
45
46use ::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
61// use clap_repl::{
62//     reedline::{DefaultPrompt, DefaultPromptSegment, FileBackedHistory},
63//     ClapEditor, ReadCommandOutput,
64// };
65
66use 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
104// use tracing::LogLevel;
105mod 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    /// Remove the resolved state folder before startup so the session starts from a blank state
236    #[arg(long, default_value_t = false)]
237    pub clean_state: bool,
238
239    /// Load only the named processes (including all of their integrands).
240    /// Combined with --integrands by union; implies read-only state and no saving.
241    #[arg(long, num_args = 1.., value_name = "PROCESS")]
242    pub processes: Option<Vec<String>>,
243
244    /// Load integrands by name, or as PROCESS@INTEGRAND pairs.
245    /// Bare names match every process; implies read-only state and no saving.
246    #[arg(long, num_args = 1.., value_name = "INTEGRAND|PROCESS@INTEGRAND")]
247    pub integrands: Option<Vec<String>>,
248
249    /// Optional TOML card to load at boot time
250    #[arg(value_hint = clap::ValueHint::FilePath)]
251    pub boot_commands_path: Option<PathBuf>,
252
253    /// Path to the state folder
254    #[arg(short = 's', long, default_value = "./gammaloop_state", value_hint = clap::ValueHint::DirPath)]
255    pub state_folder: PathBuf,
256
257    /// Internal flag indicating whether `state_folder` was explicitly set on CLI.
258    #[arg(skip = false)]
259    state_folder_explicitly_set: bool,
260
261    /// Path to the model file
262    #[arg(short = 'm', long, value_hint = clap::ValueHint::FilePath)]
263    pub model_file: Option<PathBuf>,
264
265    /// Skip saving state on exit
266    #[arg(short = 'n', long, default_value_t = false, group = "saving")]
267    no_save_state: bool,
268
269    /// Save state to file after each call
270    #[arg(short = 'o', long, default_value_t = false, group = "saving")]
271    override_state: bool,
272
273    /// Set the name of the file containing all traces from gammaloop (logs) for current session
274    #[arg(short = 't', long = "trace-logs-filename")]
275    trace_logs_filename: Option<String>,
276
277    /// Set log level for current session
278    #[arg(short = 'l')]
279    level: Option<LogLevel>,
280
281    /// Set logfile log level for current session
282    #[arg(short = 'L', long = "logfile-level")]
283    logfile_level: Option<LogLevel>,
284
285    /// Type of prefix for the logging format
286    #[arg(short = 'p', long = "logging-prefix")]
287    logging_prefix: Option<LogFormat>,
288
289    /// Prevent writes into the state folder for the lifetime of this session
290    #[arg(long, default_value_t = false)]
291    read_only_state: bool,
292
293    /// Path to a global settings TOML file.
294    #[arg(short = 'g', long = "settings-global", value_hint = clap::ValueHint::FilePath)]
295    settings_global_path: Option<PathBuf>,
296
297    /// Path to a default runtime settings TOML file.
298    #[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    /// Try to serialize using strings when saving run history
306    #[arg(long)]
307    no_try_strings: bool,
308
309    #[cfg(feature = "cli")]
310    /// Generate a shell completion script for the gammaloop executable
311    #[arg(long = "completions", value_enum)]
312    completions: Option<CompletionShell>,
313
314    // /// Debug level
315    // #[arg(short = 'd', long, value_enum, default_value_t = LogLevel::Info)]
316    // debug_level: LogLevel,
317    /// Optional sub‑command
318    #[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    /// Serialize replayable commands as compact strings when their structured form permits it.
337    #[serde(skip_serializing_if = "is_true")]
338    pub try_strings: bool,
339    /// Permit generated artifacts to replace compatible files in the active state folder.
340    #[serde(skip_serializing_if = "is_false")]
341    pub override_state: bool,
342    /// Location and optional display name of the persistent GammaLoop state.
343    #[serde(skip_serializing_if = "IsDefault::is_default")]
344    pub state: StateSettings,
345    /// Generation, logging, and parallelization settings shared by the CLI session.
346    #[serde(skip_serializing_if = "IsDefault::is_default")]
347    pub global: GlobalSettings,
348    /// Transient read-only controls and startup warnings excluded from serialized settings.
349    #[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    /// Folder containing persisted settings, models, processes, and run history.
358    pub folder: PathBuf,
359    /// Optional human-readable state name stored with the serialized state.
360    #[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/// Programmatic startup options for an embedded GammaLoop session.
567///
568/// The resolved state folder is the explicit `state_folder`, otherwise the folder named by the
569/// boot card, otherwise `./gammaloop_state`. A saved, manifested folder is deserialized; a missing,
570/// empty, or unmanifested folder starts a blank in-memory state. Loading a blank state does not
571/// create or save the folder by itself.
572///
573/// `read_only_state` prevents GammaLoop-managed writes to the active state tree, not arbitrary
574/// filesystem writes: explicit exports elsewhere and external processes such as the `!` shell
575/// command remain outside that boundary. `clean_state` removes the resolved path and therefore
576/// cannot be combined with read-only mode.
577#[derive(Debug, Clone, PartialEq, Default)]
578pub struct StateLoadOption {
579    /// Remove the resolved state folder before loading so the session starts from a blank state.
580    pub clean_state: bool,
581    /// Optional run card whose settings and commands are applied during boot.
582    pub boot_commands_path: Option<PathBuf>,
583    /// State folder to load, defaulting to `./gammaloop_state` when omitted.
584    pub state_folder: Option<PathBuf>,
585    /// Optional model file that replaces the saved model when loading an existing state.
586    pub model_file: Option<PathBuf>,
587    /// Optional trace-log filename for the loaded session.
588    pub trace_logs_filename: Option<String>,
589    /// Terminal log-level override for the loaded session.
590    pub level: Option<LogLevel>,
591    /// File log-level override for the loaded session.
592    pub logfile_level: Option<LogLevel>,
593    /// Prefix format used for emitted log records.
594    pub logging_prefix: Option<LogFormat>,
595    /// Prevent writes inside the active state folder for the lifetime of the session.
596    pub read_only_state: bool,
597    /// Optional TOML file whose global settings override the state or boot card.
598    pub settings_global_path: Option<PathBuf>,
599    /// Optional TOML file supplying the session's default runtime settings.
600    pub settings_runtime_defaults_path: Option<PathBuf>,
601    /// Restrict loading to these process names (all integrands in each process).
602    pub processes: Option<Vec<String>>,
603    /// Restrict loading to these integrand names or PROCESS@INTEGRAND pairs.
604    pub integrands: Option<Vec<String>>,
605}
606
607/// Owned state and settings produced by [`StateLoadOption::load`].
608///
609/// This value is the persistence boundary for an embedded session. It owns the mutable state and
610/// replay history, but neither dropping it nor dropping a [`CliSession`]
611/// automatically saves them. Inspect `cli_settings.session.startup_warnings` after loading: a boot
612/// card that disagrees with a saved state's frozen settings can force the session into read-only
613/// mode to protect reproducibility.
614pub struct LoadedState {
615    /// In-memory model, processes, integrands, and generation metadata.
616    pub state: State,
617    /// Persisted or boot-provided settings and replayable commands.
618    pub run_history: RunHistory,
619    /// Effective state, global, and transient session settings after startup overrides.
620    pub cli_settings: CLISettings,
621    /// Runtime settings used as defaults for commands that do not supply their own.
622    pub default_runtime_settings: RuntimeSettings,
623    /// Transient command-block state retained between session operations.
624    pub session_state: CliSessionState,
625    /// Load timing, serialized size, and graph count for an existing saved state.
626    pub state_load_summary: Option<StateLoadSummary>,
627}
628
629impl LoadedState {
630    /// Borrow the state, history, and effective settings as one command session.
631    ///
632    /// The returned session holds mutable borrows of this bundle. End its lexical scope (or drop
633    /// it) before reading the fields of `LoadedState` directly. Commands update these owned values;
634    /// they do not create a second state or save on drop.
635    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    /// Resolve, initialize, and load an embedded GammaLoop session.
712    ///
713    /// For an existing manifested state this restores the saved model, processes, settings, run
714    /// history, and integrand backends. `model_file` is an override only on that saved-state path.
715    /// For a missing or unmanifested path the method creates a blank state in memory instead.
716    /// When supplied, the boot run history and settings overrides are applied before this method
717    /// returns, so loading can mutate the domain state and can perform command-specific external
718    /// writes.
719    ///
720    /// This method initializes process-global GammaLoop/Symbolica services and tracing. It never
721    /// automatically persists the returned state. It fails when the state or settings cannot be
722    /// read or validated, startup initialization fails, a boot command fails or requests exit, or
723    /// the requested clean/read-only combination is inconsistent.
724    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/// Diagnostic measurements available only when an existing manifested state was loaded.
772#[derive(Debug, Clone)]
773pub struct StateLoadSummary {
774    /// Wall-clock time spent restoring the saved state and activating its backends.
775    pub elapsed: Duration,
776    /// Total bytes in regular files below the state directory when metadata was available.
777    pub serialized_size_bytes: Option<u64>,
778    /// Total number of amplitude or cross-section graphs restored across all processes.
779    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()?; // no subcommand -> None
1211
1212        // Find the Command that corresponds to the canonical subcommand name
1213        let sc = cmd.get_subcommands().find(|c| c.get_name() == sc_name)?;
1214
1215        // Tokens that could have been used to invoke it: canonical + all aliases
1216        let mut names: Vec<&str> = vec![sc_name];
1217        names.extend(sc.get_all_aliases());
1218
1219        // Locate the index in argv where the subcommand token appears
1220        let idx = argv.iter().position(|t| {
1221            let s = t.to_string_lossy();
1222            names.iter().any(|&n| s == n)
1223        })?;
1224
1225        // Join from the subcommand onward
1226        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        // Build a Command (same as derive(Parser)) and get matches
1275        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    /// Parse from env args *and* capture ArgMatches (explicit vs defaults).
1304    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            // A filtered state is a transient view. Never allow the normal CLI
1343            // exit path to overwrite the complete on-disk state with it.
1344            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    // pub fn initialize(&self) {}
1656}
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// impl FromStr for LogFormat {
1843//     type Err = Report;
1844//     fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
1845//         Ok(match s {
1846//             "long" => LogFormat::Long,
1847//             "short" => LogFormat::Short,
1848//             "min" => LogFormat::Min,
1849//             "none" => LogFormat::None,
1850//             _ => Err(eyre!("Invalid log format"))?,
1851//         })
1852//     }
1853// }
1854
1855#[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}