use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use nyx_agent_types::product::{
LaunchEnvRef, LaunchHealthCheck, LaunchStep, ProjectLaunchProfileInput,
};
use nyx_agent_types::project::ProjectRuntimeProfile;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read config at {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to parse config at {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("failed to serialise config: {0}")]
Serialise(#[from] toml::ser::Error),
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub general: GeneralConfig,
pub performance: PerformanceConfig,
pub sandbox: SandboxConfig,
pub ai: AiConfig,
pub ui: UiConfig,
pub triggers: TriggersConfig,
pub nyx: NyxConfig,
pub run: RunConfig,
pub env: EnvConfig,
#[serde(rename = "project", default)]
pub projects: Vec<ProjectConfig>,
#[serde(rename = "schedule", default)]
pub schedules: Vec<ScheduleConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct GeneralConfig {
pub log_level: String,
pub state_dir: Option<PathBuf>,
}
impl Default for GeneralConfig {
fn default() -> Self {
Self { log_level: "info".to_string(), state_dir: None }
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct PerformanceConfig {
pub max_parallel_scans: u32,
pub scan_timeout_secs: u64,
#[serde(default)]
pub static_concurrency: Option<usize>,
#[serde(default)]
pub per_repo_timeout_secs: Option<u64>,
#[serde(default)]
pub scheduler_tick_secs: Option<u64>,
#[serde(default)]
pub chain_lane_concurrency: Option<usize>,
#[serde(default)]
pub fast_lane_concurrency: Option<usize>,
}
impl Default for PerformanceConfig {
fn default() -> Self {
Self {
max_parallel_scans: 4,
scan_timeout_secs: 600,
static_concurrency: None,
per_repo_timeout_secs: None,
scheduler_tick_secs: None,
chain_lane_concurrency: None,
fast_lane_concurrency: None,
}
}
}
impl PerformanceConfig {
pub fn per_repo_timeout(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.per_repo_timeout_secs.unwrap_or(30 * 60))
}
pub fn static_concurrency_override(&self) -> Option<usize> {
self.static_concurrency.map(|n| n.max(1))
}
pub fn scheduler_tick(&self) -> std::time::Duration {
let secs = self.scheduler_tick_secs.unwrap_or(60).max(1);
std::time::Duration::from_secs(secs)
}
pub const DEFAULT_CHAIN_LANE_CONCURRENCY: usize = 2;
pub const DEFAULT_FAST_LANE_CONCURRENCY: usize = 8;
pub fn chain_lane_concurrency_resolved(&self) -> usize {
self.chain_lane_concurrency
.map(|n| n.max(1))
.unwrap_or(Self::DEFAULT_CHAIN_LANE_CONCURRENCY)
}
pub fn fast_lane_concurrency_resolved(&self) -> usize {
self.fast_lane_concurrency.map(|n| n.max(1)).unwrap_or(Self::DEFAULT_FAST_LANE_CONCURRENCY)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SandboxConfig {
pub enabled: bool,
pub allow_network: bool,
#[serde(default)]
pub backend: SandboxBackend,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self { enabled: true, allow_network: false, backend: SandboxBackend::default() }
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SandboxBackend {
#[default]
Auto,
Process,
Birdcage,
Libkrun,
Firecracker,
Docker,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AiConfig {
pub provider: Option<String>,
pub model: Option<String>,
pub api_base: Option<String>,
#[serde(default)]
pub runtime: AiRuntime,
#[serde(default = "default_max_concurrent_one_shot")]
pub max_concurrent_one_shot: u32,
#[serde(default)]
pub default_run_budget_usd_micros: Option<i64>,
#[serde(default)]
pub pricing: HashMap<String, AiPricingOverride>,
#[serde(default)]
pub payload_synthesis_per_call_cap_usd_micros: Option<i64>,
#[serde(default)]
pub spec_derivation_per_call_cap_usd_micros: Option<i64>,
#[serde(default)]
pub chain_reasoning_per_call_cap_usd_micros: Option<i64>,
#[serde(default)]
pub novel_discovery_per_call_cap_usd_micros: Option<i64>,
#[serde(default)]
pub exploration_soft_cap_usd_micros: Option<i64>,
#[serde(default)]
pub exploration_run_cap_usd_micros: Option<i64>,
}
impl Default for AiConfig {
fn default() -> Self {
Self {
provider: None,
model: None,
api_base: None,
runtime: AiRuntime::default(),
max_concurrent_one_shot: default_max_concurrent_one_shot(),
default_run_budget_usd_micros: None,
pricing: HashMap::new(),
payload_synthesis_per_call_cap_usd_micros: None,
spec_derivation_per_call_cap_usd_micros: None,
chain_reasoning_per_call_cap_usd_micros: None,
novel_discovery_per_call_cap_usd_micros: None,
exploration_soft_cap_usd_micros: None,
exploration_run_cap_usd_micros: None,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AiPricingOverride {
pub input_per_mtok_usd: i64,
pub output_per_mtok_usd: i64,
pub cache_write_per_mtok_usd: i64,
pub cache_read_per_mtok_usd: i64,
}
fn default_max_concurrent_one_shot() -> u32 {
4
}
impl AiConfig {
pub const DEFAULT_RUN_BUDGET_USD_MICROS: i64 = i64::MAX;
pub fn max_concurrent_one_shot_resolved(&self) -> usize {
self.max_concurrent_one_shot.max(1) as usize
}
pub fn default_run_budget_usd_micros_resolved(&self) -> i64 {
match self.default_run_budget_usd_micros {
Some(v) if v > 0 => v,
_ => Self::DEFAULT_RUN_BUDGET_USD_MICROS,
}
}
pub fn payload_synthesis_per_call_cap_usd_micros_resolved(&self) -> i64 {
match self.payload_synthesis_per_call_cap_usd_micros {
Some(v) if v > 0 => v,
_ => Self::DEFAULT_RUN_BUDGET_USD_MICROS,
}
}
pub fn spec_derivation_per_call_cap_usd_micros_resolved(&self) -> i64 {
match self.spec_derivation_per_call_cap_usd_micros {
Some(v) if v > 0 => v,
_ => Self::DEFAULT_RUN_BUDGET_USD_MICROS,
}
}
pub fn chain_reasoning_per_call_cap_usd_micros_resolved(&self) -> i64 {
match self.chain_reasoning_per_call_cap_usd_micros {
Some(v) if v > 0 => v,
_ => Self::DEFAULT_RUN_BUDGET_USD_MICROS,
}
}
pub fn novel_discovery_per_call_cap_usd_micros_resolved(&self) -> i64 {
match self.novel_discovery_per_call_cap_usd_micros {
Some(v) if v > 0 => v,
_ => Self::DEFAULT_RUN_BUDGET_USD_MICROS,
}
}
pub fn exploration_soft_cap_usd_micros_resolved(&self, default: i64) -> i64 {
match self.exploration_soft_cap_usd_micros {
Some(v) if v > 0 => v,
_ => default,
}
}
pub fn exploration_run_cap_usd_micros_resolved(&self, default: i64) -> i64 {
match self.exploration_run_cap_usd_micros {
Some(v) if v > 0 => v,
_ => default,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AiRuntime {
#[default]
None,
Anthropic,
LocalLlm,
ClaudeCode,
Codex,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct UiConfig {
pub listen_addr: String,
pub open_browser: bool,
}
impl Default for UiConfig {
fn default() -> Self {
Self { listen_addr: "127.0.0.1:8765".to_string(), open_browser: true }
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct NyxConfig {
pub binary_path: Option<PathBuf>,
pub min_version: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct EnvConfig {
pub pull_policy: Option<EnvPullPolicy>,
}
impl EnvConfig {
pub fn pull_policy_resolved(&self) -> EnvPullPolicy {
self.pull_policy.unwrap_or_default()
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EnvPullPolicy {
#[default]
Missing,
Always,
Never,
}
fn default_optional_scanner_enabled() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RunConfig {
pub replay_stable_check: bool,
#[serde(default)]
pub allow_state_changing_live_probes: bool,
#[serde(default)]
pub browser_checks_enabled: bool,
#[serde(default)]
pub exploit_mode_enabled: bool,
#[serde(default)]
pub exploit_dry_run: bool,
#[serde(default = "default_true")]
pub business_logic_templates_enabled: bool,
#[serde(default)]
pub research_mode_enabled: bool,
#[serde(default)]
pub unsafe_attack_agent_enabled: bool,
#[serde(default)]
pub business_logic_template_ids: Vec<String>,
#[serde(default)]
pub exploit_request_cap: Option<u32>,
#[serde(default)]
pub exploit_requests_per_second: Option<u32>,
#[serde(default = "default_true")]
pub exploit_reset_after_state_changing: bool,
#[serde(default = "default_optional_scanner_enabled")]
pub enable_zap_baseline: bool,
#[serde(default = "default_optional_scanner_enabled")]
pub enable_nuclei: bool,
#[serde(default = "default_optional_scanner_enabled")]
pub enable_trivy: bool,
#[serde(default = "default_optional_scanner_enabled")]
pub enable_osv_scanner: bool,
#[serde(default = "default_optional_scanner_enabled")]
pub enable_secret_scanning: bool,
#[serde(default = "default_optional_scanner_enabled")]
pub enable_katana: bool,
#[serde(default = "default_optional_scanner_enabled")]
pub enable_httpx: bool,
#[serde(default)]
pub enable_aggressive_sqlmap: bool,
}
impl Default for RunConfig {
fn default() -> Self {
Self {
replay_stable_check: false,
allow_state_changing_live_probes: false,
browser_checks_enabled: false,
exploit_mode_enabled: false,
exploit_dry_run: false,
business_logic_templates_enabled: true,
research_mode_enabled: false,
unsafe_attack_agent_enabled: false,
business_logic_template_ids: Vec::new(),
exploit_request_cap: None,
exploit_requests_per_second: None,
exploit_reset_after_state_changing: true,
enable_zap_baseline: true,
enable_nuclei: true,
enable_trivy: true,
enable_osv_scanner: true,
enable_secret_scanning: true,
enable_katana: true,
enable_httpx: true,
enable_aggressive_sqlmap: false,
}
}
}
impl RunConfig {
pub const DEFAULT_EXPLOIT_REQUEST_CAP: u32 = 10;
pub const DEFAULT_EXPLOIT_REQUESTS_PER_SECOND: u32 = 5;
pub fn exploit_request_cap_resolved(&self) -> u32 {
self.exploit_request_cap.unwrap_or(Self::DEFAULT_EXPLOIT_REQUEST_CAP).max(1)
}
pub fn exploit_requests_per_second_resolved(&self) -> u32 {
self.exploit_requests_per_second.unwrap_or(Self::DEFAULT_EXPLOIT_REQUESTS_PER_SECOND).max(1)
}
pub fn state_changing_live_probes_allowed(&self) -> bool {
self.exploit_mode_enabled && self.allow_state_changing_live_probes
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct TriggersConfig {
pub on_push: bool,
pub on_pr: bool,
pub schedule_cron: Option<String>,
#[serde(default)]
pub webhook_secret_ref: Option<String>,
#[serde(default)]
pub webhook_branch: Option<String>,
#[serde(default)]
pub webhook_provider: Option<String>,
#[serde(default)]
pub webhook_max_concurrent: Option<usize>,
#[serde(default)]
pub webhook_rate_limit_per_minute: Option<u32>,
}
impl TriggersConfig {
pub fn webhook_max_concurrent_resolved(&self, default: usize) -> usize {
match self.webhook_max_concurrent {
Some(n) if n > 0 => n,
_ => default,
}
}
pub fn webhook_rate_limit_per_minute_resolved(&self, default: u32) -> u32 {
match self.webhook_rate_limit_per_minute {
Some(n) if n > 0 => n,
_ => default,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScheduleConfig {
pub cron: String,
#[serde(default)]
pub repo: Option<String>,
#[serde(default = "default_schedule_label")]
pub label: String,
}
fn default_schedule_label() -> String {
"scheduled".to_string()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectConfig {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub target_base_url: Option<String>,
#[serde(default)]
pub env_config: Option<toml::Value>,
#[serde(default)]
pub launch: Option<ProjectLaunchConfig>,
#[serde(default)]
pub runtime_profile: Option<ProjectRuntimeProfile>,
#[serde(rename = "repo", default)]
pub repos: Vec<RepoConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProjectLaunchConfig {
pub name: Option<String>,
pub mode: Option<String>,
pub target_urls: Vec<String>,
#[serde(default)]
pub build: Vec<ProjectLaunchCommandConfig>,
#[serde(default)]
pub start: Vec<ProjectLaunchCommandConfig>,
#[serde(default)]
pub seed: Vec<ProjectLaunchCommandConfig>,
#[serde(default)]
pub reset: Vec<ProjectLaunchCommandConfig>,
#[serde(default)]
pub login: Vec<ProjectLaunchCommandConfig>,
#[serde(default)]
pub stop: Vec<ProjectLaunchCommandConfig>,
#[serde(default)]
pub health: Vec<ProjectLaunchHealthConfig>,
#[serde(default)]
pub env_files: Vec<String>,
#[serde(default)]
pub env_vars: Vec<ProjectLaunchEnvVarConfig>,
}
impl ProjectLaunchConfig {
pub fn to_profile_input(&self, fallback_target_url: Option<&str>) -> ProjectLaunchProfileInput {
let mut target_urls = self.target_urls.clone();
if target_urls.is_empty() {
if let Some(target) = fallback_target_url.filter(|target| !target.trim().is_empty()) {
target_urls.push(target.to_string());
}
}
let env_refs = self
.env_files
.iter()
.filter(|path| !path.trim().is_empty())
.map(|path| LaunchEnvRef {
kind: "env-file".to_string(),
value: path.trim().to_string(),
secret: true,
})
.chain(self.env_vars.iter().filter_map(ProjectLaunchEnvVarConfig::to_env_ref))
.collect();
ProjectLaunchProfileInput {
name: self.name.clone(),
mode: self.mode.clone(),
build_steps: self.build.iter().map(ProjectLaunchCommandConfig::to_step).collect(),
start_steps: self.start.iter().map(ProjectLaunchCommandConfig::to_step).collect(),
seed_steps: self.seed.iter().map(ProjectLaunchCommandConfig::to_step).collect(),
reset_steps: self.reset.iter().map(ProjectLaunchCommandConfig::to_step).collect(),
login_steps: self.login.iter().map(ProjectLaunchCommandConfig::to_step).collect(),
stop_steps: self.stop.iter().map(ProjectLaunchCommandConfig::to_step).collect(),
health_checks: self.health.iter().map(ProjectLaunchHealthConfig::to_check).collect(),
target_urls,
env_refs,
working_dirs: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectLaunchCommandConfig {
pub command: String,
#[serde(default, alias = "repo")]
pub repo_name: Option<String>,
#[serde(default)]
pub working_directory: Option<String>,
#[serde(default, alias = "timeout_secs")]
pub timeout_seconds: Option<u64>,
#[serde(default, alias = "input")]
pub stdin: Option<String>,
}
impl ProjectLaunchCommandConfig {
pub fn to_step(&self) -> LaunchStep {
LaunchStep {
command: self.command.clone(),
repo_id: None,
repo_name: self.repo_name.clone(),
working_directory: self.working_directory.clone(),
timeout_seconds: self.timeout_seconds,
stdin: self.stdin.clone(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProjectLaunchHealthConfig {
pub kind: Option<String>,
pub url: Option<String>,
pub host: Option<String>,
pub port: Option<u16>,
pub command: Option<ProjectLaunchCommandConfig>,
#[serde(alias = "timeout_secs")]
pub timeout_seconds: Option<u64>,
}
impl ProjectLaunchHealthConfig {
pub fn to_check(&self) -> LaunchHealthCheck {
LaunchHealthCheck {
kind: self.kind.clone().unwrap_or_else(|| {
if self.command.is_some() {
"command".to_string()
} else {
"http".to_string()
}
}),
url: self.url.clone(),
host: self.host.clone(),
port: self.port,
command: self.command.as_ref().map(ProjectLaunchCommandConfig::to_step),
timeout_seconds: self.timeout_seconds,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProjectLaunchEnvVarConfig {
pub name: String,
pub secret: bool,
}
impl Default for ProjectLaunchEnvVarConfig {
fn default() -> Self {
Self { name: String::new(), secret: true }
}
}
impl ProjectLaunchEnvVarConfig {
fn to_env_ref(&self) -> Option<LaunchEnvRef> {
let name = self.name.trim();
(!name.is_empty()).then(|| LaunchEnvRef {
kind: "env-var".to_string(),
value: name.to_string(),
secret: self.secret,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RepoConfig {
pub name: String,
#[serde(default)]
pub i_own_this: bool,
pub source: RepoSourceConfig,
#[serde(default = "default_true")]
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
pub enum RepoSourceConfig {
Git {
url: String,
#[serde(default)]
branch: Option<String>,
#[serde(default)]
auth: Option<String>,
},
LocalPath { path: PathBuf },
}
fn default_true() -> bool {
true
}
impl Config {
#[tracing::instrument(skip_all, fields(path = %path.display()))]
pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
let raw = std::fs::read_to_string(path)
.map_err(|source| ConfigError::Read { path: path.to_path_buf(), source })?;
Self::parse(&raw, path)
}
#[tracing::instrument(skip_all, fields(path = %path.display()))]
pub fn load_or_default(path: &Path) -> Result<Self, ConfigError> {
match std::fs::read_to_string(path) {
Ok(raw) => Self::parse(&raw, path),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(source) => Err(ConfigError::Read { path: path.to_path_buf(), source }),
}
}
pub fn parse(raw: &str, path: &Path) -> Result<Self, ConfigError> {
toml::from_str(raw)
.map_err(|source| ConfigError::Parse { path: path.to_path_buf(), source })
}
pub fn to_toml_string(&self) -> Result<String, ConfigError> {
Ok(toml::to_string_pretty(self)?)
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[test]
fn defaults_roundtrip_through_toml() {
let cfg = Config::default();
let rendered = cfg.to_toml_string().expect("serialise defaults");
let parsed = Config::parse(&rendered, &PathBuf::from("<test>")).expect("parse defaults");
assert_eq!(parsed, cfg);
}
#[test]
fn run_defaults_enable_recommended_optional_scanners() {
let cfg = Config::parse("[run]\n", &PathBuf::from("<test>")).expect("parse run defaults");
assert!(cfg.run.enable_zap_baseline);
assert!(cfg.run.enable_nuclei);
assert!(cfg.run.enable_trivy);
assert!(cfg.run.enable_osv_scanner);
assert!(cfg.run.enable_secret_scanning);
assert!(cfg.run.enable_katana);
assert!(cfg.run.enable_httpx);
assert!(!cfg.run.enable_aggressive_sqlmap);
assert!(!cfg.run.exploit_mode_enabled);
assert!(!cfg.run.exploit_dry_run);
assert!(cfg.run.business_logic_templates_enabled);
assert!(cfg.run.business_logic_template_ids.is_empty());
assert_eq!(cfg.run.exploit_request_cap_resolved(), RunConfig::DEFAULT_EXPLOIT_REQUEST_CAP);
assert_eq!(
cfg.run.exploit_requests_per_second_resolved(),
RunConfig::DEFAULT_EXPLOIT_REQUESTS_PER_SECOND
);
assert!(cfg.run.exploit_reset_after_state_changing);
assert!(!cfg.run.state_changing_live_probes_allowed());
}
#[test]
fn exploit_policy_config_parses_and_resolves() {
let raw = r#"
[run]
exploit_mode_enabled = true
allow_state_changing_live_probes = true
exploit_dry_run = true
business_logic_templates_enabled = false
business_logic_template_ids = ["tenant_object_isolation", "webhook_callback_trust_boundary"]
exploit_request_cap = 0
exploit_requests_per_second = 0
exploit_reset_after_state_changing = false
"#;
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse exploit policy");
assert!(cfg.run.exploit_mode_enabled);
assert!(cfg.run.allow_state_changing_live_probes);
assert!(cfg.run.state_changing_live_probes_allowed());
assert!(cfg.run.exploit_dry_run);
assert!(!cfg.run.business_logic_templates_enabled);
assert_eq!(
cfg.run.business_logic_template_ids,
vec![
"tenant_object_isolation".to_string(),
"webhook_callback_trust_boundary".to_string()
]
);
assert_eq!(cfg.run.exploit_request_cap_resolved(), 1);
assert_eq!(cfg.run.exploit_requests_per_second_resolved(), 1);
assert!(!cfg.run.exploit_reset_after_state_changing);
}
#[test]
fn populated_config_roundtrips() {
let cfg = Config {
general: GeneralConfig {
log_level: "debug".to_string(),
state_dir: Some(PathBuf::from("/tmp/nyx")),
},
performance: PerformanceConfig {
max_parallel_scans: 8,
scan_timeout_secs: 1200,
static_concurrency: Some(2),
per_repo_timeout_secs: Some(45),
scheduler_tick_secs: Some(10),
chain_lane_concurrency: Some(3),
fast_lane_concurrency: Some(12),
},
sandbox: SandboxConfig {
enabled: false,
allow_network: true,
backend: SandboxBackend::Birdcage,
},
ai: AiConfig {
provider: Some("anthropic".to_string()),
model: Some("claude-opus-4-7".to_string()),
api_base: None,
runtime: AiRuntime::Anthropic,
max_concurrent_one_shot: 2,
default_run_budget_usd_micros: None,
pricing: {
let mut m = HashMap::new();
m.insert(
"claude-opus-4-7-20260101".to_string(),
AiPricingOverride {
input_per_mtok_usd: 12,
output_per_mtok_usd: 60,
cache_write_per_mtok_usd: 15,
cache_read_per_mtok_usd: 1,
},
);
m
},
payload_synthesis_per_call_cap_usd_micros: Some(2_500_000),
spec_derivation_per_call_cap_usd_micros: Some(1_500_000),
chain_reasoning_per_call_cap_usd_micros: Some(3_000_000),
novel_discovery_per_call_cap_usd_micros: Some(4_000_000),
exploration_soft_cap_usd_micros: Some(3_500_000),
exploration_run_cap_usd_micros: Some(8_000_000),
},
ui: UiConfig { listen_addr: "0.0.0.0:9999".to_string(), open_browser: true },
triggers: TriggersConfig {
on_push: true,
on_pr: true,
schedule_cron: Some("0 * * * *".to_string()),
webhook_secret_ref: Some("env:NYX_WEBHOOK_SECRET".to_string()),
webhook_branch: Some("main".to_string()),
webhook_provider: Some("github".to_string()),
webhook_max_concurrent: Some(4),
webhook_rate_limit_per_minute: Some(60),
},
nyx: NyxConfig {
binary_path: Some(PathBuf::from("/opt/nyx/bin/nyx")),
min_version: Some("0.2.0".to_string()),
},
run: RunConfig {
replay_stable_check: true,
allow_state_changing_live_probes: true,
browser_checks_enabled: true,
exploit_mode_enabled: true,
exploit_dry_run: true,
exploit_request_cap: Some(3),
exploit_requests_per_second: Some(2),
exploit_reset_after_state_changing: false,
..RunConfig::default()
},
env: EnvConfig { pull_policy: Some(EnvPullPolicy::Never) },
projects: vec![ProjectConfig {
name: "acme-app".to_string(),
description: Some("Acme web product".to_string()),
target_base_url: Some("http://localhost:3000".to_string()),
env_config: None,
launch: None,
runtime_profile: None,
repos: vec![
RepoConfig {
name: "acme-backend".to_string(),
i_own_this: true,
source: RepoSourceConfig::Git {
url: "git@github.com:acme/acme-backend.git".to_string(),
branch: Some("main".to_string()),
auth: Some("ssh-key:~/.ssh/work_ed25519".to_string()),
},
enabled: true,
},
RepoConfig {
name: "monolith".to_string(),
i_own_this: true,
source: RepoSourceConfig::LocalPath {
path: PathBuf::from("/Users/eli/code/monolith"),
},
enabled: true,
},
],
}],
schedules: vec![ScheduleConfig {
cron: "0 3 * * 1".to_string(),
repo: Some("acme-backend".to_string()),
label: "weekly-monday-3am".to_string(),
}],
};
let rendered = cfg.to_toml_string().expect("serialise");
let parsed = Config::parse(&rendered, &PathBuf::from("<test>")).expect("parse");
assert_eq!(parsed, cfg);
}
#[test]
fn missing_file_returns_default() {
let path = PathBuf::from("/definitely/does/not/exist/nyx-agent.toml");
let cfg = Config::load_or_default(&path).expect("missing file -> default");
assert_eq!(cfg, Config::default());
}
#[test]
fn empty_string_parses_to_default() {
let cfg = Config::parse("", &PathBuf::from("<test>")).expect("empty parses");
assert_eq!(cfg, Config::default());
}
#[test]
fn repo_enabled_defaults_to_true_when_omitted() {
let raw = "[[project]]\nname = \"p\"\n\n[[project.repo]]\nname = \"acme-backend\"\n\
i_own_this = true\n\
source = { kind = \"local-path\", path = \"/srv/repos/acme-backend\" }\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
assert_eq!(cfg.projects.len(), 1);
assert_eq!(cfg.projects[0].repos.len(), 1);
assert!(
cfg.projects[0].repos[0].enabled,
"declared repo without explicit enabled must default to true"
);
}
#[test]
fn repo_source_git_parses_with_inline_table() {
let raw = "[[project]]\nname = \"p\"\n\n[[project.repo]]\nname = \"billing\"\n\
i_own_this = true\n\
source = { kind = \"git\", url = \"git@github.com:org/billing.git\", \
branch = \"main\", auth = \"ssh-key:~/.ssh/work_ed25519\" }\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
match &cfg.projects[0].repos[0].source {
RepoSourceConfig::Git { url, branch, auth } => {
assert_eq!(url, "git@github.com:org/billing.git");
assert_eq!(branch.as_deref(), Some("main"));
assert_eq!(auth.as_deref(), Some("ssh-key:~/.ssh/work_ed25519"));
}
other => panic!("expected git source, got {other:?}"),
}
}
#[test]
fn repo_source_local_path_parses() {
let raw = "[[project]]\nname = \"p\"\n\n[[project.repo]]\nname = \"monolith\"\n\
i_own_this = true\n\
source = { kind = \"local-path\", path = \"/home/eli/code/monolith\" }\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
match &cfg.projects[0].repos[0].source {
RepoSourceConfig::LocalPath { path } => {
assert_eq!(path, &PathBuf::from("/home/eli/code/monolith"));
}
other => panic!("expected local-path source, got {other:?}"),
}
}
#[test]
fn repo_source_unknown_kind_rejected() {
let raw = "[[project]]\nname = \"p\"\n\n[[project.repo]]\nname = \"x\"\n\
i_own_this = true\n\
source = { kind = \"hg\", path = \"/srv/x\" }\n";
let err = Config::parse(raw, &PathBuf::from("<test>")).expect_err("must reject");
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn repo_i_own_this_defaults_to_false_when_omitted() {
let raw = "[[project]]\nname = \"p\"\n\n[[project.repo]]\nname = \"x\"\n\
source = { kind = \"local-path\", path = \"/srv/x\" }\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
assert!(
!cfg.projects[0].repos[0].i_own_this,
"i_own_this must default to false so the daemon refuses unattested repos"
);
}
#[test]
fn top_level_repo_block_rejected() {
let raw = "[[repo]]\nname = \"x\"\ni_own_this = true\n\
source = { kind = \"local-path\", path = \"/srv/x\" }\n";
let err = Config::parse(raw, &PathBuf::from("<test>")).expect_err("must reject");
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn project_groups_multiple_repos() {
let raw = "[[project]]\nname = \"acme\"\ndescription = \"Acme product\"\n\
target_base_url = \"http://localhost:3000\"\n\n\
[[project.repo]]\nname = \"acme-backend\"\ni_own_this = true\nenabled = true\n\
source = { kind = \"local-path\", path = \"/p/backend\" }\n\n\
[[project.repo]]\nname = \"acme-frontend\"\ni_own_this = true\nenabled = true\n\
source = { kind = \"local-path\", path = \"/p/frontend\" }\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
assert_eq!(cfg.projects.len(), 1);
let p = &cfg.projects[0];
assert_eq!(p.name, "acme");
assert_eq!(p.description.as_deref(), Some("Acme product"));
assert_eq!(p.target_base_url.as_deref(), Some("http://localhost:3000"));
assert_eq!(p.repos.len(), 2);
assert_eq!(p.repos[0].name, "acme-backend");
assert_eq!(p.repos[1].name, "acme-frontend");
}
#[test]
fn project_launch_profile_parses_lifecycle_hooks() {
let raw = r#"
[[project]]
name = "acme"
target_base_url = "http://localhost:3000"
[project.launch]
mode = "custom-commands"
env_files = [".env.test"]
[[project.launch.build]]
command = "npm ci"
repo = "web"
timeout_secs = 120
[[project.launch.start]]
command = "npm run dev"
repo_name = "web"
[[project.launch.seed]]
command = "npm run seed:test"
[[project.launch.reset]]
command = "npm run db:reset"
[[project.launch.login]]
command = "npm run session:nyx-agent"
[[project.launch.stop]]
command = "npm run stop"
[[project.launch.health]]
url = "http://localhost:3000/health"
timeout_seconds = 15
[[project.launch.env_vars]]
name = "NYX_AGENT_TEST_TOKEN"
secret = true
"#;
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
let launch = cfg.projects[0].launch.as_ref().expect("launch config");
let input = launch.to_profile_input(cfg.projects[0].target_base_url.as_deref());
assert_eq!(input.mode.as_deref(), Some("custom-commands"));
assert_eq!(input.target_urls, vec!["http://localhost:3000"]);
assert_eq!(input.build_steps[0].repo_name.as_deref(), Some("web"));
assert_eq!(input.build_steps[0].timeout_seconds, Some(120));
assert_eq!(input.seed_steps[0].command, "npm run seed:test");
assert_eq!(input.reset_steps[0].command, "npm run db:reset");
assert_eq!(input.login_steps[0].command, "npm run session:nyx-agent");
assert_eq!(input.stop_steps[0].command, "npm run stop");
assert_eq!(input.health_checks[0].url.as_deref(), Some("http://localhost:3000/health"));
assert_eq!(input.env_refs.len(), 2);
}
#[test]
fn unknown_field_rejected() {
let raw = "garbage_field = true\n";
let err = Config::parse(raw, &PathBuf::from("<test>")).expect_err("must reject");
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn performance_static_concurrency_and_per_repo_timeout_round_trip() {
let raw = "[performance]\nstatic_concurrency = 3\nper_repo_timeout_secs = 5\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
assert_eq!(cfg.performance.static_concurrency, Some(3));
assert_eq!(cfg.performance.per_repo_timeout_secs, Some(5));
assert_eq!(cfg.performance.per_repo_timeout(), std::time::Duration::from_secs(5));
assert_eq!(cfg.performance.static_concurrency_override(), Some(3));
}
#[test]
fn performance_omitted_overrides_fall_back() {
let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
assert!(cfg.performance.static_concurrency.is_none());
assert!(cfg.performance.per_repo_timeout_secs.is_none());
assert_eq!(cfg.performance.per_repo_timeout(), std::time::Duration::from_secs(30 * 60));
assert!(cfg.performance.static_concurrency_override().is_none());
}
#[test]
fn ai_max_concurrent_one_shot_default_is_four() {
let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
assert_eq!(cfg.ai.max_concurrent_one_shot, 4);
assert_eq!(cfg.ai.max_concurrent_one_shot_resolved(), 4);
}
#[test]
fn ai_max_concurrent_one_shot_zero_floors_to_one() {
let raw = "[ai]\nmax_concurrent_one_shot = 0\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
assert_eq!(cfg.ai.max_concurrent_one_shot, 0);
assert_eq!(cfg.ai.max_concurrent_one_shot_resolved(), 1);
}
#[test]
fn ai_max_concurrent_one_shot_roundtrips_through_toml() {
let raw = "[ai]\nmax_concurrent_one_shot = 8\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
assert_eq!(cfg.ai.max_concurrent_one_shot, 8);
let rendered = cfg.to_toml_string().expect("ser");
let back = Config::parse(&rendered, &PathBuf::from("<test>")).expect("roundtrip");
assert_eq!(back.ai.max_concurrent_one_shot, 8);
}
#[test]
fn performance_static_concurrency_zero_floors_to_one() {
let cfg = Config {
performance: PerformanceConfig {
static_concurrency: Some(0),
..PerformanceConfig::default()
},
..Config::default()
};
assert_eq!(cfg.performance.static_concurrency_override(), Some(1));
}
#[test]
fn performance_scheduler_tick_defaults_to_sixty_seconds() {
let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
assert!(cfg.performance.scheduler_tick_secs.is_none());
assert_eq!(cfg.performance.scheduler_tick(), std::time::Duration::from_secs(60));
}
#[test]
fn performance_scheduler_tick_roundtrips_through_toml() {
let raw = "[performance]\nscheduler_tick_secs = 5\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
assert_eq!(cfg.performance.scheduler_tick_secs, Some(5));
assert_eq!(cfg.performance.scheduler_tick(), std::time::Duration::from_secs(5));
}
#[test]
fn performance_scheduler_tick_zero_floors_to_one_second() {
let cfg = Config {
performance: PerformanceConfig {
scheduler_tick_secs: Some(0),
..PerformanceConfig::default()
},
..Config::default()
};
assert_eq!(cfg.performance.scheduler_tick(), std::time::Duration::from_secs(1));
}
#[test]
fn performance_lane_concurrency_defaults_match_sandbox_constants() {
let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
assert!(cfg.performance.chain_lane_concurrency.is_none());
assert!(cfg.performance.fast_lane_concurrency.is_none());
assert_eq!(cfg.performance.chain_lane_concurrency_resolved(), 2);
assert_eq!(cfg.performance.fast_lane_concurrency_resolved(), 8);
}
#[test]
fn performance_lane_concurrency_roundtrips_through_toml() {
let raw = "[performance]\nchain_lane_concurrency = 4\nfast_lane_concurrency = 16\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
assert_eq!(cfg.performance.chain_lane_concurrency, Some(4));
assert_eq!(cfg.performance.fast_lane_concurrency, Some(16));
assert_eq!(cfg.performance.chain_lane_concurrency_resolved(), 4);
assert_eq!(cfg.performance.fast_lane_concurrency_resolved(), 16);
}
#[test]
fn ai_pricing_override_defaults_empty() {
let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
assert!(cfg.ai.pricing.is_empty());
}
#[test]
fn ai_pricing_override_parses_per_model_block() {
let raw = "[ai.pricing.\"claude-opus-4-7\"]\n\
input_per_mtok_usd = 12\n\
output_per_mtok_usd = 60\n\
cache_write_per_mtok_usd = 15\n\
cache_read_per_mtok_usd = 1\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
let entry =
cfg.ai.pricing.get("claude-opus-4-7").expect("override for claude-opus-4-7 must parse");
assert_eq!(entry.input_per_mtok_usd, 12);
assert_eq!(entry.output_per_mtok_usd, 60);
assert_eq!(entry.cache_write_per_mtok_usd, 15);
assert_eq!(entry.cache_read_per_mtok_usd, 1);
}
#[test]
fn ai_pricing_override_omitted_cache_fields_default_to_zero() {
let raw = "[ai.pricing.\"claude-haiku-4-5\"]\n\
input_per_mtok_usd = 1\n\
output_per_mtok_usd = 5\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
let entry = cfg.ai.pricing.get("claude-haiku-4-5").expect("override must parse");
assert_eq!(entry.input_per_mtok_usd, 1);
assert_eq!(entry.output_per_mtok_usd, 5);
assert_eq!(entry.cache_write_per_mtok_usd, 0);
assert_eq!(entry.cache_read_per_mtok_usd, 0);
}
#[test]
fn ai_pricing_override_unknown_field_rejected() {
let raw = "[ai.pricing.\"claude-haiku-4-5\"]\n\
input_per_mtok_usd = 1\n\
garbage = true\n";
let err = Config::parse(raw, &PathBuf::from("<test>")).expect_err("must reject");
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn ai_run_budget_defaults_to_uncapped_sentinel() {
let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
assert!(cfg.ai.default_run_budget_usd_micros.is_none());
assert_eq!(cfg.ai.default_run_budget_usd_micros_resolved(), i64::MAX);
}
#[test]
fn ai_per_call_caps_default_to_run_budget_constant() {
let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
assert!(cfg.ai.payload_synthesis_per_call_cap_usd_micros.is_none());
assert!(cfg.ai.spec_derivation_per_call_cap_usd_micros.is_none());
assert!(cfg.ai.chain_reasoning_per_call_cap_usd_micros.is_none());
assert!(cfg.ai.novel_discovery_per_call_cap_usd_micros.is_none());
let fallback = AiConfig::DEFAULT_RUN_BUDGET_USD_MICROS;
assert_eq!(cfg.ai.payload_synthesis_per_call_cap_usd_micros_resolved(), fallback);
assert_eq!(cfg.ai.spec_derivation_per_call_cap_usd_micros_resolved(), fallback);
assert_eq!(cfg.ai.chain_reasoning_per_call_cap_usd_micros_resolved(), fallback);
assert_eq!(cfg.ai.novel_discovery_per_call_cap_usd_micros_resolved(), fallback);
}
#[test]
fn ai_per_call_caps_parse_per_task_overrides() {
let raw = "[ai]\n\
payload_synthesis_per_call_cap_usd_micros = 2500000\n\
spec_derivation_per_call_cap_usd_micros = 1500000\n\
chain_reasoning_per_call_cap_usd_micros = 3000000\n\
novel_discovery_per_call_cap_usd_micros = 4000000\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
assert_eq!(cfg.ai.payload_synthesis_per_call_cap_usd_micros_resolved(), 2_500_000);
assert_eq!(cfg.ai.spec_derivation_per_call_cap_usd_micros_resolved(), 1_500_000);
assert_eq!(cfg.ai.chain_reasoning_per_call_cap_usd_micros_resolved(), 3_000_000);
assert_eq!(cfg.ai.novel_discovery_per_call_cap_usd_micros_resolved(), 4_000_000);
}
#[test]
fn ai_per_call_caps_non_positive_overrides_fall_back_to_default() {
let raw = "[ai]\n\
payload_synthesis_per_call_cap_usd_micros = 0\n\
spec_derivation_per_call_cap_usd_micros = -1\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
let fallback = AiConfig::DEFAULT_RUN_BUDGET_USD_MICROS;
assert_eq!(cfg.ai.payload_synthesis_per_call_cap_usd_micros_resolved(), fallback);
assert_eq!(cfg.ai.spec_derivation_per_call_cap_usd_micros_resolved(), fallback);
}
#[test]
fn ai_exploration_caps_default_to_caller_default() {
let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
assert!(cfg.ai.exploration_soft_cap_usd_micros.is_none());
assert!(cfg.ai.exploration_run_cap_usd_micros.is_none());
assert_eq!(cfg.ai.exploration_soft_cap_usd_micros_resolved(5_000_000), 5_000_000);
assert_eq!(cfg.ai.exploration_run_cap_usd_micros_resolved(10_000_000), 10_000_000);
}
#[test]
fn ai_exploration_caps_parse_operator_overrides() {
let raw = "[ai]\n\
exploration_soft_cap_usd_micros = 3500000\n\
exploration_run_cap_usd_micros = 8000000\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
assert_eq!(cfg.ai.exploration_soft_cap_usd_micros_resolved(5_000_000), 3_500_000);
assert_eq!(cfg.ai.exploration_run_cap_usd_micros_resolved(10_000_000), 8_000_000);
}
#[test]
fn ai_exploration_caps_non_positive_overrides_fall_back_to_caller_default() {
let raw = "[ai]\n\
exploration_soft_cap_usd_micros = 0\n\
exploration_run_cap_usd_micros = -1\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
assert_eq!(cfg.ai.exploration_soft_cap_usd_micros_resolved(5_000_000), 5_000_000);
assert_eq!(cfg.ai.exploration_run_cap_usd_micros_resolved(10_000_000), 10_000_000);
}
#[test]
fn env_pull_policy_defaults_to_missing() {
let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
assert!(cfg.env.pull_policy.is_none());
assert_eq!(cfg.env.pull_policy_resolved(), EnvPullPolicy::Missing);
}
#[test]
fn env_pull_policy_parses_kebab_case_variants() {
for (raw, expected) in [
("[env]\npull_policy = \"missing\"\n", EnvPullPolicy::Missing),
("[env]\npull_policy = \"always\"\n", EnvPullPolicy::Always),
("[env]\npull_policy = \"never\"\n", EnvPullPolicy::Never),
] {
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
assert_eq!(cfg.env.pull_policy, Some(expected));
assert_eq!(cfg.env.pull_policy_resolved(), expected);
}
}
#[test]
fn env_pull_policy_unknown_value_rejected() {
let raw = "[env]\npull_policy = \"sometimes\"\n";
let err = Config::parse(raw, &PathBuf::from("<test>")).expect_err("must reject");
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn env_pull_policy_unknown_field_rejected() {
let raw = "[env]\nmystery = true\n";
let err = Config::parse(raw, &PathBuf::from("<test>")).expect_err("must reject");
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn webhook_limit_knobs_default_to_none_and_fall_back_to_caller_default() {
let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
assert!(cfg.triggers.webhook_max_concurrent.is_none());
assert!(cfg.triggers.webhook_rate_limit_per_minute.is_none());
assert_eq!(cfg.triggers.webhook_max_concurrent_resolved(8), 8);
assert_eq!(cfg.triggers.webhook_rate_limit_per_minute_resolved(30), 30);
}
#[test]
fn webhook_limit_knobs_parse_operator_overrides() {
let raw = "[triggers]\n\
webhook_max_concurrent = 16\n\
webhook_rate_limit_per_minute = 120\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
assert_eq!(cfg.triggers.webhook_max_concurrent, Some(16));
assert_eq!(cfg.triggers.webhook_rate_limit_per_minute, Some(120));
assert_eq!(cfg.triggers.webhook_max_concurrent_resolved(8), 16);
assert_eq!(cfg.triggers.webhook_rate_limit_per_minute_resolved(30), 120);
}
#[test]
fn webhook_limit_knobs_non_positive_overrides_fall_back_to_default() {
let raw = "[triggers]\n\
webhook_max_concurrent = 0\n\
webhook_rate_limit_per_minute = 0\n";
let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
assert_eq!(cfg.triggers.webhook_max_concurrent_resolved(8), 8);
assert_eq!(cfg.triggers.webhook_rate_limit_per_minute_resolved(30), 30);
}
#[test]
fn performance_lane_concurrency_zero_floors_to_one() {
let cfg = Config {
performance: PerformanceConfig {
chain_lane_concurrency: Some(0),
fast_lane_concurrency: Some(0),
..PerformanceConfig::default()
},
..Config::default()
};
assert_eq!(cfg.performance.chain_lane_concurrency_resolved(), 1);
assert_eq!(cfg.performance.fast_lane_concurrency_resolved(), 1);
}
}