Skip to main content

garudust_core/
config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::OnceLock;
4
5use serde::{Deserialize, Serialize};
6
7use crate::types::ReasoningEffort;
8
9static DOTENV_VARS: OnceLock<HashMap<String, String>> = OnceLock::new();
10
11/// Load ~/.garudust/.env once per process into an in-memory map.
12/// Never writes to process environment, so secrets are not visible to subprocesses.
13fn load_dotenv_once(path: &Path) -> &'static HashMap<String, String> {
14    DOTENV_VARS.get_or_init(|| {
15        let mut map = HashMap::new();
16        let Ok(content) = std::fs::read_to_string(path) else {
17            return map;
18        };
19        for line in content.lines() {
20            let line = line.trim();
21            if line.is_empty() || line.starts_with('#') {
22                continue;
23            }
24            if let Some((k, v)) = line.split_once('=') {
25                let k = k.trim().to_string();
26                let v = v.trim().trim_matches('"').trim_matches('\'').to_string();
27                map.insert(k, v);
28            }
29        }
30        map
31    })
32}
33
34/// Read an env var: real environment takes priority, dotenv map is fallback.
35fn env_or_dotenv(key: &str, dotenv: &HashMap<String, String>) -> Option<String> {
36    std::env::var(key)
37        .ok()
38        .filter(|v| !v.is_empty())
39        .or_else(|| dotenv.get(key).filter(|v| !v.is_empty()).cloned())
40}
41
42/// Read a secret from real env or ~/.garudust/.env (whichever is set first).
43/// Useful for Rust tools that don't go through script.rs env forwarding.
44pub fn get_secret(key: &str) -> Option<String> {
45    std::env::var(key)
46        .ok()
47        .filter(|v| !v.is_empty())
48        .or_else(|| {
49            DOTENV_VARS
50                .get()?
51                .get(key)
52                .filter(|v| !v.is_empty())
53                .cloned()
54        })
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct AgentConfig {
59    #[serde(skip)]
60    pub home_dir: PathBuf,
61    #[serde(default = "default_model")]
62    pub model: String,
63    #[serde(default = "default_max_iterations")]
64    pub max_iterations: u32,
65    #[serde(default)]
66    pub tool_delay_ms: u64,
67    #[serde(default = "default_provider")]
68    pub provider: String,
69    pub base_url: Option<String>,
70    #[serde(skip)]
71    pub api_key: Option<String>,
72    #[serde(default)]
73    pub compression: CompressionConfig,
74    #[serde(default)]
75    pub network: NetworkConfig,
76    #[serde(default)]
77    pub mcp_servers: Vec<McpServerConfig>,
78    #[serde(default)]
79    pub max_concurrent_requests: Option<usize>,
80    #[serde(default)]
81    pub security: SecurityConfig,
82    #[serde(default)]
83    pub memory_expiry: MemoryExpiryConfig,
84    /// Inject a memory-save reminder every N tool-use iterations within a task.
85    /// 0 = disabled. Default: 5.
86    #[serde(default = "default_nudge_interval")]
87    pub nudge_interval: u32,
88    /// Max retry attempts on transient LLM API errors (429, 5xx, network). 0 = disabled.
89    #[serde(default = "default_llm_max_retries")]
90    pub llm_max_retries: u32,
91    /// Base delay in milliseconds for exponential backoff between retries.
92    #[serde(default = "default_llm_retry_base_ms")]
93    pub llm_retry_base_ms: u64,
94    /// Platform-level access controls (whitelist, mention gate, session isolation).
95    #[serde(default)]
96    pub platform: PlatformConfig,
97    /// Minimum tool-use iterations that trigger an automatic skill-reflection pass after a task.
98    /// The agent reviews the conversation and calls write_skill if the workflow is reusable.
99    /// Set to 0 to disable. Default: 5.
100    #[serde(default = "default_auto_skill_threshold")]
101    pub auto_skill_threshold: u32,
102    /// Timeout in seconds for a single LLM API call (chat or stream). 0 = no timeout. Default: 120.
103    #[serde(default = "default_llm_timeout_secs")]
104    pub llm_timeout_secs: u64,
105    /// Timeout in seconds applied to every non-terminal tool dispatch. 0 = no timeout. Default: 60.
106    #[serde(default = "default_tool_timeout_secs")]
107    pub tool_timeout_secs: u64,
108    /// Drain window in seconds for graceful shutdown — server waits this long for in-flight
109    /// requests to complete before forcing exit. Default: 30.
110    #[serde(default = "default_shutdown_timeout_secs")]
111    pub shutdown_timeout_secs: u64,
112    /// Hard cap on total tokens (input + output) consumed by a single task.
113    /// When exceeded the agent stops and returns what it has with a budget notice.
114    /// `None` means no limit.
115    #[serde(default)]
116    pub max_tokens_per_task: Option<u32>,
117    /// Maximum output tokens per LLM request. Default: 8192.
118    /// Lower this for models with small context windows (e.g. 4096 for a 27k-ctx model).
119    #[serde(default)]
120    pub max_output_tokens: Option<u32>,
121    /// Reasoning effort for supported models (Claude extended thinking, OpenAI o1/o3/o4).
122    /// Set via config.yaml: `reasoning_effort: medium`
123    #[serde(default)]
124    pub reasoning_effort: Option<ReasoningEffort>,
125    /// Maximum context window of the model in tokens.
126    /// Used by the context compressor to decide when to summarise history.
127    /// Defaults to 128 000. Set this to the actual limit for small-context models
128    /// (e.g. `context_window: 27168` for Qwen3-14B-AWQ on vLLM).
129    #[serde(default)]
130    pub context_window: Option<usize>,
131    /// Toolsets to disable. Removes all tools in the named toolset from every
132    /// request, reducing context usage for small-context models.
133    /// Available toolsets: web, files, terminal, memory, skills, agent,
134    ///   browser, git, notes, json, mcp
135    /// Providers: anthropic, openrouter, vllm, ollama, bedrock, codex, thaillm
136    /// Example: `disabled_toolsets: [browser, git, notes, json, agent]`
137    #[serde(default)]
138    pub disabled_toolsets: Vec<String>,
139    /// Individual tools to disable by exact name. Useful when only some tools
140    /// in a toolset need to be removed (e.g. disable `image_read` without
141    /// removing the entire `files` toolset).
142    /// Example: `disabled_tools: [image_read, pdf_read, session_search]`
143    #[serde(default)]
144    pub disabled_tools: Vec<String>,
145    /// Append a usage footer (`[N iter | Xin Yout Ztok @ model]`) to every
146    /// agent response. Useful for debugging; usually unwanted on chat platforms
147    /// where end users see the output. Default: false.
148    #[serde(default)]
149    pub show_usage_footer: bool,
150    /// Per-platform webhook server settings (LINE, WhatsApp, generic webhook).
151    /// Each entry sets enabled flag, listening port, and HTTP path. Tokens and
152    /// secrets continue to be read from `~/.garudust/.env` — never from yaml.
153    #[serde(default)]
154    pub platforms: WebhookPlatformsConfig,
155    /// HTTP gateway server settings (port, …). Overridden by `--port` and
156    /// `GARUDUST_PORT` env var.
157    #[serde(default)]
158    pub server: ServerConfig,
159    /// Cron scheduler — recurring agent tasks plus the memory consolidation /
160    /// expiry sweeps. CLI flags (`--cron-jobs`, `--memory-cron`,
161    /// `--memory-expiry-cron`) and the corresponding env vars take precedence.
162    #[serde(default)]
163    pub cron: CronConfig,
164}
165
166fn default_model() -> String {
167    "anthropic/claude-sonnet-4-6".into()
168}
169fn default_provider() -> String {
170    "openrouter".into()
171}
172fn default_max_iterations() -> u32 {
173    90
174}
175fn default_nudge_interval() -> u32 {
176    5
177}
178fn default_auto_skill_threshold() -> u32 {
179    5
180}
181fn default_llm_max_retries() -> u32 {
182    3
183}
184fn default_llm_retry_base_ms() -> u64 {
185    1000
186}
187fn default_llm_timeout_secs() -> u64 {
188    120
189}
190fn default_tool_timeout_secs() -> u64 {
191    60
192}
193fn default_shutdown_timeout_secs() -> u64 {
194    30
195}
196
197/// Per-category retention policy for memory entries.
198/// `None` means the category never expires.
199/// `preference` and `skill` default to `None` — they represent durable knowledge.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct MemoryExpiryConfig {
202    /// Max age in days for `fact` entries. Default: 90.
203    #[serde(default = "default_fact_days")]
204    pub fact_days: Option<u32>,
205    /// Max age in days for `project` entries. Default: 30.
206    #[serde(default = "default_project_days")]
207    pub project_days: Option<u32>,
208    /// Max age in days for `other` entries. Default: 60.
209    #[serde(default = "default_other_days")]
210    pub other_days: Option<u32>,
211    /// `preference` entries never expire by default.
212    #[serde(default)]
213    pub preference_days: Option<u32>,
214    /// `skill` entries never expire by default.
215    #[serde(default)]
216    pub skill_days: Option<u32>,
217}
218
219#[allow(clippy::unnecessary_wraps)]
220fn default_fact_days() -> Option<u32> {
221    Some(90)
222}
223#[allow(clippy::unnecessary_wraps)]
224fn default_project_days() -> Option<u32> {
225    Some(30)
226}
227#[allow(clippy::unnecessary_wraps)]
228fn default_other_days() -> Option<u32> {
229    Some(60)
230}
231
232impl Default for MemoryExpiryConfig {
233    fn default() -> Self {
234        Self {
235            fact_days: default_fact_days(),
236            project_days: default_project_days(),
237            other_days: default_other_days(),
238            preference_days: None,
239            skill_days: None,
240        }
241    }
242}
243
244/// Terminal execution sandbox mode.
245#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
246#[serde(rename_all = "lowercase")]
247pub enum TerminalSandbox {
248    /// Direct host execution (default). Hardline blocks still apply.
249    #[default]
250    None,
251    /// Wrap every command in `docker run --rm` with hardened flags.
252    Docker,
253}
254
255/// Security-related settings grouped together (mirrors CompressionConfig / NetworkConfig pattern).
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct SecurityConfig {
258    /// Bearer token required on /chat* endpoints. None = open (warn at startup).
259    #[serde(skip)]
260    pub gateway_api_key: Option<String>,
261
262    /// Allowed root paths for read_file tool. Defaults to cwd + home.
263    #[serde(default)]
264    pub allowed_read_paths: Vec<PathBuf>,
265
266    /// Allowed root paths for write_file tool. Defaults to cwd only.
267    #[serde(default)]
268    pub allowed_write_paths: Vec<PathBuf>,
269
270    /// Command approval mode: "auto" | "smart" | "deny". Default "smart".
271    #[serde(default = "default_approval_mode")]
272    pub approval_mode: String,
273
274    /// Per-IP rate limit in requests/minute. None = disabled.
275    #[serde(default)]
276    pub rate_limit_rpm: Option<u32>,
277
278    /// Terminal execution sandbox. Default "none" (direct host execution).
279    #[serde(default)]
280    pub terminal_sandbox: TerminalSandbox,
281
282    /// Docker image used when `terminal_sandbox = docker`. Default "ubuntu:24.04".
283    #[serde(default = "default_sandbox_image")]
284    pub terminal_sandbox_image: String,
285
286    /// Extra `docker run` flags appended after the hardened defaults.
287    /// Example: `["--network=none", "--memory=512m", "--cpus=0.5"]`
288    #[serde(default)]
289    pub terminal_sandbox_opts: Vec<String>,
290}
291
292fn default_approval_mode() -> String {
293    "smart".to_string()
294}
295
296fn default_sandbox_image() -> String {
297    "ubuntu:24.04".to_string()
298}
299
300impl Default for SecurityConfig {
301    fn default() -> Self {
302        Self {
303            gateway_api_key: None,
304            allowed_read_paths: Vec::new(),
305            allowed_write_paths: Vec::new(),
306            approval_mode: default_approval_mode(),
307            rate_limit_rpm: None,
308            terminal_sandbox: TerminalSandbox::None,
309            terminal_sandbox_image: default_sandbox_image(),
310            terminal_sandbox_opts: Vec::new(),
311        }
312    }
313}
314
315/// Platform-level access and behaviour controls (whitelist, mention gate, session isolation).
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct PlatformConfig {
318    /// User IDs allowed to send messages to the agent.
319    /// Empty list means everyone is allowed.
320    #[serde(default)]
321    pub allowed_user_ids: Vec<String>,
322
323    /// Only respond in group chats when the bot is @mentioned.
324    /// Private / DM chats always get a response regardless of this flag.
325    #[serde(default)]
326    pub require_mention: bool,
327
328    /// Bot username used for @mention detection (without the @).
329    /// Example: set to "mybot" so @mybot triggers a response.
330    #[serde(default)]
331    pub bot_username: String,
332
333    /// Give each user their own conversation session (default: true).
334    /// Set to false only when you want all users in a channel to share one session.
335    /// Not applied to the webhook platform — webhook callers control session routing via payload.
336    #[serde(default = "default_true")]
337    pub session_per_user: bool,
338}
339
340fn default_true() -> bool {
341    true
342}
343
344impl Default for PlatformConfig {
345    fn default() -> Self {
346        Self {
347            allowed_user_ids: Vec::new(),
348            require_mention: false,
349            bot_username: String::new(),
350            session_per_user: true,
351        }
352    }
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct McpServerConfig {
357    pub name: String,
358    pub command: String,
359    #[serde(default)]
360    pub args: Vec<String>,
361}
362
363/// Per-platform webhook server settings. A `WebhookPlatformConfig` with
364/// `enabled = false` means the adapter is not started even if its secret is
365/// present in the environment.
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct WebhookPlatformConfig {
368    /// Whether to start this adapter at boot.
369    #[serde(default)]
370    pub enabled: bool,
371    /// TCP port to bind on `0.0.0.0`.
372    pub port: u16,
373    /// HTTP path the adapter listens on (e.g. `/webhooks/line`).
374    pub webhook_path: String,
375}
376
377/// Container for all webhook-based platform settings.
378#[derive(Debug, Clone, Serialize, Deserialize, Default)]
379pub struct WebhookPlatformsConfig {
380    #[serde(default)]
381    pub line: Option<WebhookPlatformConfig>,
382    #[serde(default)]
383    pub whatsapp: Option<WebhookPlatformConfig>,
384    #[serde(default)]
385    pub webhook: Option<WebhookPlatformConfig>,
386}
387
388/// HTTP gateway server settings.
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct ServerConfig {
391    /// TCP port for the HTTP gateway. Default `3000`.
392    #[serde(default = "default_server_port")]
393    pub port: u16,
394}
395
396fn default_server_port() -> u16 {
397    3000
398}
399
400impl Default for ServerConfig {
401    fn default() -> Self {
402        Self {
403            port: default_server_port(),
404        }
405    }
406}
407
408/// A single scheduled agent task — cron expression plus the prompt to run.
409#[derive(Debug, Clone, Serialize, Deserialize)]
410pub struct CronJob {
411    /// Standard 5-field cron expression (e.g. `0 9 * * *`).
412    pub schedule: String,
413    /// The task prompt handed to the agent when the cron fires.
414    pub task: String,
415}
416
417/// Cron scheduler configuration.
418#[derive(Debug, Clone, Serialize, Deserialize, Default)]
419pub struct CronConfig {
420    /// Recurring agent tasks.
421    #[serde(default)]
422    pub jobs: Vec<CronJob>,
423    /// Cron expression for automatic memory consolidation. `None` = disabled.
424    #[serde(default)]
425    pub memory_consolidation: Option<String>,
426    /// Cron expression for automatic memory expiry sweeps. `None` = disabled.
427    #[serde(default)]
428    pub memory_expiry: Option<String>,
429}
430
431impl WebhookPlatformConfig {
432    /// Defaults for the generic webhook adapter. Used when no explicit
433    /// `platforms.webhook` block is present so existing setups keep working.
434    pub fn default_webhook() -> Self {
435        Self {
436            enabled: true,
437            port: 3001,
438            webhook_path: "/webhook".to_string(),
439        }
440    }
441
442    /// Defaults for LINE. Constructed by the setup wizard when the user opts
443    /// in, so `enabled = true`; for manual yaml authors, `enabled` itself
444    /// defaults to `false` via serde, keeping the adapter opt-in.
445    pub fn default_line() -> Self {
446        Self {
447            enabled: true,
448            port: 3002,
449            webhook_path: "/line".to_string(),
450        }
451    }
452
453    /// Defaults for WhatsApp — same semantics as `default_line`.
454    pub fn default_whatsapp() -> Self {
455        Self {
456            enabled: true,
457            port: 3003,
458            webhook_path: "/whatsapp".to_string(),
459        }
460    }
461}
462
463impl Default for AgentConfig {
464    fn default() -> Self {
465        let cwd = std::env::current_dir().unwrap_or_default();
466        let home = dirs::home_dir().unwrap_or_default();
467        Self {
468            home_dir: Self::garudust_dir(),
469            model: "anthropic/claude-sonnet-4-6".into(),
470            max_iterations: 90,
471            tool_delay_ms: 0,
472            provider: "openrouter".into(),
473            base_url: None,
474            api_key: None,
475            compression: CompressionConfig::default(),
476            network: NetworkConfig::default(),
477            mcp_servers: Vec::new(),
478            max_concurrent_requests: None,
479            security: SecurityConfig {
480                gateway_api_key: None,
481                allowed_read_paths: vec![cwd.clone(), home],
482                allowed_write_paths: vec![cwd],
483                approval_mode: default_approval_mode(),
484                rate_limit_rpm: None,
485                terminal_sandbox: TerminalSandbox::None,
486                terminal_sandbox_image: default_sandbox_image(),
487                terminal_sandbox_opts: Vec::new(),
488            },
489            memory_expiry: MemoryExpiryConfig::default(),
490            nudge_interval: default_nudge_interval(),
491            llm_max_retries: default_llm_max_retries(),
492            llm_retry_base_ms: default_llm_retry_base_ms(),
493            platform: PlatformConfig::default(),
494            auto_skill_threshold: default_auto_skill_threshold(),
495            llm_timeout_secs: default_llm_timeout_secs(),
496            tool_timeout_secs: default_tool_timeout_secs(),
497            shutdown_timeout_secs: default_shutdown_timeout_secs(),
498            max_tokens_per_task: None,
499            max_output_tokens: None,
500            reasoning_effort: None,
501            context_window: None,
502            disabled_toolsets: Vec::new(),
503            disabled_tools: Vec::new(),
504            show_usage_footer: false,
505            platforms: WebhookPlatformsConfig {
506                webhook: Some(WebhookPlatformConfig::default_webhook()),
507                line: None,
508                whatsapp: None,
509            },
510            server: ServerConfig::default(),
511            cron: CronConfig::default(),
512        }
513    }
514}
515
516impl AgentConfig {
517    /// Canonical ~/.garudust directory.
518    pub fn garudust_dir() -> PathBuf {
519        dirs::home_dir()
520            .unwrap_or_else(|| PathBuf::from("/tmp"))
521            .join(".garudust")
522    }
523
524    /// Load config from ~/.garudust/config.yaml + ~/.garudust/.env + environment.
525    ///
526    /// Priority (highest first):
527    ///   1. Environment variables already set in the shell
528    ///   2. ~/.garudust/.env  (set if not already present in env)
529    ///   3. ~/.garudust/config.yaml
530    ///   4. Built-in defaults
531    pub fn load() -> Self {
532        let home_dir = Self::garudust_dir();
533
534        // Load dotenv values into memory (never calls set_var — secrets stay out of process env)
535        let env_file = home_dir.join(".env");
536        let dotenv = load_dotenv_once(&env_file);
537
538        // Load config.yaml (non-secret settings)
539        let yaml_path = home_dir.join("config.yaml");
540        let mut config: AgentConfig = if yaml_path.exists() {
541            let src = std::fs::read_to_string(&yaml_path).unwrap_or_default();
542            serde_yaml::from_str(&src).unwrap_or_default()
543        } else {
544            AgentConfig::default()
545        };
546
547        config.home_dir = home_dir;
548
549        // Populate default security paths if they came back empty from YAML
550        if config.security.allowed_read_paths.is_empty() {
551            let cwd = std::env::current_dir().unwrap_or_default();
552            let home = dirs::home_dir().unwrap_or_default();
553            config.security.allowed_read_paths = vec![cwd.clone(), home];
554            config.security.allowed_write_paths = vec![cwd];
555        }
556
557        // Apply env/dotenv overrides (real env takes priority over dotenv and config.yaml).
558        // Provider-specific keys override whatever was set in config.yaml.
559        if let Some(k) = env_or_dotenv("ANTHROPIC_API_KEY", dotenv) {
560            config.api_key = Some(k);
561            config.provider = "anthropic".into();
562        } else if let Some(k) = env_or_dotenv("OPENROUTER_API_KEY", dotenv) {
563            config.api_key = Some(k);
564        } else if let Some(url) = env_or_dotenv("OLLAMA_BASE_URL", dotenv) {
565            config.provider = "ollama".into();
566            config.base_url = Some(url);
567        } else if let Some(url) = env_or_dotenv("VLLM_BASE_URL", dotenv) {
568            config.provider = "vllm".into();
569            config.base_url = Some(url);
570        } else if let Some(k) = env_or_dotenv("THAILLM_API_KEY", dotenv) {
571            config.api_key = Some(k);
572            config.provider = "thaillm".into();
573        }
574        // Load api_key for the resolved provider when not already set above.
575        // This handles the case where provider/base_url come from config.yaml.
576        if config.api_key.is_none() {
577            config.api_key = match config.provider.as_str() {
578                "vllm" => env_or_dotenv("VLLM_API_KEY", dotenv),
579                "anthropic" => env_or_dotenv("ANTHROPIC_API_KEY", dotenv),
580                "thaillm" => env_or_dotenv("THAILLM_API_KEY", dotenv),
581                _ => env_or_dotenv("OPENROUTER_API_KEY", dotenv),
582            };
583        }
584        if let Some(m) = env_or_dotenv("GARUDUST_MODEL", dotenv) {
585            config.model = m;
586        }
587        if let Some(u) = env_or_dotenv("GARUDUST_BASE_URL", dotenv) {
588            config.base_url = Some(u);
589        }
590        if let Some(k) = env_or_dotenv("GARUDUST_API_KEY", dotenv) {
591            config.security.gateway_api_key = Some(k);
592        }
593        if let Some(v) = env_or_dotenv("GARUDUST_RATE_LIMIT", dotenv) {
594            if let Ok(n) = v.parse::<u32>() {
595                config.security.rate_limit_rpm = Some(n);
596            }
597        }
598        if let Some(mode) = env_or_dotenv("GARUDUST_APPROVAL_MODE", dotenv) {
599            config.security.approval_mode = mode;
600        }
601        if let Some(sandbox) = env_or_dotenv("GARUDUST_TERMINAL_SANDBOX", dotenv) {
602            config.security.terminal_sandbox = match sandbox.to_lowercase().as_str() {
603                "docker" => TerminalSandbox::Docker,
604                _ => TerminalSandbox::None,
605            };
606        }
607        if let Some(image) = env_or_dotenv("GARUDUST_SANDBOX_IMAGE", dotenv) {
608            config.security.terminal_sandbox_image = image;
609        }
610
611        config
612    }
613
614    /// Save non-secret settings to ~/.garudust/config.yaml.
615    pub fn save_yaml(&self) -> std::io::Result<()> {
616        std::fs::create_dir_all(&self.home_dir)?;
617        let yaml = serde_yaml::to_string(self).map_err(std::io::Error::other)?;
618        std::fs::write(self.home_dir.join("config.yaml"), yaml)
619    }
620
621    /// Write or update a KEY=VALUE line in ~/.garudust/.env.
622    pub fn set_env_var(home_dir: &Path, key: &str, value: &str) -> std::io::Result<()> {
623        std::fs::create_dir_all(home_dir)?;
624        let env_path = home_dir.join(".env");
625        let existing = if env_path.exists() {
626            std::fs::read_to_string(&env_path)?
627        } else {
628            String::new()
629        };
630
631        let prefix = format!("{key}=");
632        let mut lines: Vec<String> = existing
633            .lines()
634            .filter(|l| !l.starts_with(&prefix))
635            .map(String::from)
636            .collect();
637        lines.push(format!("{key}={value}"));
638
639        std::fs::write(&env_path, lines.join("\n") + "\n")
640    }
641}
642
643// ── Sub-configs ──────────────────────────────────────────────────────────────
644
645#[derive(Debug, Clone, Serialize, Deserialize)]
646pub struct CompressionConfig {
647    pub enabled: bool,
648    pub threshold_fraction: f32,
649    pub model: Option<String>,
650}
651
652impl Default for CompressionConfig {
653    fn default() -> Self {
654        Self {
655            enabled: true,
656            threshold_fraction: 0.8,
657            model: None,
658        }
659    }
660}
661
662#[derive(Debug, Clone, Serialize, Deserialize, Default)]
663pub struct NetworkConfig {
664    pub force_ipv4: bool,
665    pub proxy: Option<String>,
666}