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