Skip to main content

enact_config/
config.rs

1//! Configuration Types - Core configuration structure
2//!
3//! This module defines the configuration structure that matches the TypeScript schema.
4//! It's kept in sync with packages/enact-schemas/src/config.schemas.ts
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use std::path::Path;
10use std::str::FromStr;
11
12/// Runtime mode
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
14pub enum RuntimeMode {
15    #[default]
16    #[serde(rename = "local")]
17    Local,
18    #[serde(rename = "airgapped")]
19    AirGapped,
20    #[serde(rename = "cloud")]
21    Cloud,
22}
23
24impl FromStr for RuntimeMode {
25    type Err = std::convert::Infallible;
26
27    fn from_str(value: &str) -> Result<Self, Self::Err> {
28        Ok(match value.to_ascii_lowercase().as_str() {
29            "airgapped" => RuntimeMode::AirGapped,
30            "cloud" => RuntimeMode::Cloud,
31            _ => RuntimeMode::Local,
32        })
33    }
34}
35
36impl fmt::Display for RuntimeMode {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            RuntimeMode::Local => write!(f, "local"),
40            RuntimeMode::AirGapped => write!(f, "airgapped"),
41            RuntimeMode::Cloud => write!(f, "cloud"),
42        }
43    }
44}
45
46/// Provider configuration
47#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
48pub struct Providers {
49    pub azure: Option<AzureProvider>,
50    pub anthropic: Option<AnthropicProvider>,
51    pub openai: Option<OpenAIProvider>,
52    pub ollama: Option<OllamaProvider>,
53    pub google: Option<GoogleProvider>,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct AzureProvider {
58    pub endpoint: Option<String>,
59    pub api_key: Option<String>,
60    pub deployment_name: Option<String>,
61    #[serde(default = "default_api_version")]
62    pub api_version: String,
63}
64
65fn default_api_version() -> String {
66    "2024-02-15-preview".to_string()
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
70pub struct AnthropicProvider {
71    pub api_key: Option<String>,
72    #[serde(default = "default_anthropic_base_url")]
73    pub base_url: String,
74}
75
76fn default_anthropic_base_url() -> String {
77    "https://api.anthropic.com".to_string()
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81pub struct OpenAIProvider {
82    pub api_key: Option<String>,
83    #[serde(default = "default_openai_base_url")]
84    pub base_url: String,
85    pub organization: Option<String>,
86}
87
88fn default_openai_base_url() -> String {
89    "https://api.openai.com/v1".to_string()
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93pub struct OllamaProvider {
94    #[serde(default = "default_ollama_base_url")]
95    pub base_url: String,
96}
97
98fn default_ollama_base_url() -> String {
99    "http://localhost:11434".to_string()
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
103pub struct GoogleProvider {
104    pub api_key: Option<String>,
105    #[serde(default = "default_google_base_url")]
106    pub base_url: String,
107}
108
109fn default_google_base_url() -> String {
110    "https://generativelanguage.googleapis.com/v1".to_string()
111}
112
113/// Runtime configuration
114#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
115pub struct Runtime {
116    #[serde(default)]
117    pub mode: RuntimeMode,
118    #[serde(default = "default_max_concurrent")]
119    pub max_concurrent_executions: u32,
120    #[serde(default = "default_timeout")]
121    pub default_timeout: u64,
122    #[serde(default = "default_true")]
123    pub enable_telemetry: bool,
124    #[serde(default = "default_true")]
125    pub allow_network: bool,
126}
127
128fn default_max_concurrent() -> u32 {
129    10
130}
131
132fn default_timeout() -> u64 {
133    30000
134}
135
136fn default_true() -> bool {
137    true
138}
139
140impl Default for Runtime {
141    fn default() -> Self {
142        Self {
143            mode: RuntimeMode::Local,
144            max_concurrent_executions: 10,
145            default_timeout: 30000,
146            enable_telemetry: true,
147            allow_network: true,
148        }
149    }
150}
151
152/// Storage configuration
153#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
154pub struct Storage {
155    #[serde(default = "default_event_store")]
156    pub event_store: EventStore,
157    #[serde(default = "default_state_store")]
158    pub state_store: StateStore,
159    #[serde(default = "default_filesystem_store")]
160    pub artifact_store: ArtifactStore,
161    #[serde(default = "default_sqlite_vector_store")]
162    pub vector_store: VectorStore,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
166pub struct EventStore {
167    #[serde(default = "default_sqlite")]
168    pub r#type: String,
169    pub path: Option<String>,
170    pub dsn: Option<String>,
171}
172
173fn default_sqlite() -> String {
174    "sqlite".to_string()
175}
176
177impl Default for EventStore {
178    fn default() -> Self {
179        Self {
180            r#type: "jsonl".to_string(),
181            path: Some("events".to_string()),
182            dsn: None,
183        }
184    }
185}
186
187fn default_event_store() -> EventStore {
188    EventStore::default()
189}
190
191impl Default for StateStore {
192    fn default() -> Self {
193        Self {
194            r#type: "jsonl".to_string(),
195            path: Some("state".to_string()),
196            dsn: None,
197        }
198    }
199}
200
201fn default_state_store() -> StateStore {
202    StateStore::default()
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
206pub struct StateStore {
207    #[serde(default = "default_sqlite")]
208    pub r#type: String,
209    pub path: Option<String>,
210    pub dsn: Option<String>,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
214pub struct ArtifactStore {
215    #[serde(default = "default_filesystem")]
216    pub r#type: String,
217    pub path: Option<String>,
218    #[serde(default = "default_zstd")]
219    pub compression: String,
220}
221
222fn default_filesystem() -> String {
223    "filesystem".to_string()
224}
225
226fn default_zstd() -> String {
227    "zstd".to_string()
228}
229
230impl Default for ArtifactStore {
231    fn default() -> Self {
232        Self {
233            r#type: "filesystem".to_string(),
234            path: None,
235            compression: "zstd".to_string(),
236        }
237    }
238}
239
240fn default_filesystem_store() -> ArtifactStore {
241    ArtifactStore::default()
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
245pub struct VectorStore {
246    #[serde(default = "default_sqlite")]
247    pub r#type: String,
248    pub url: Option<String>,
249    pub collection: Option<String>,
250    pub path: Option<String>,
251    pub dsn: Option<String>,
252}
253
254impl Default for VectorStore {
255    fn default() -> Self {
256        Self {
257            r#type: "sqlite".to_string(),
258            url: None,
259            collection: None,
260            path: None,
261            dsn: None,
262        }
263    }
264}
265
266fn default_sqlite_vector_store() -> VectorStore {
267    VectorStore::default()
268}
269
270/// Tools configuration
271#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
272pub struct Tools {
273    #[serde(default)]
274    pub ingestion: IngestionTools,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
278pub struct IngestionTools {
279    #[serde(default = "default_pdf")]
280    pub pdf: PdfIngestion,
281    #[serde(default = "default_ocr")]
282    pub ocr: OcrIngestion,
283    #[serde(default = "default_embeddings")]
284    pub embeddings: EmbeddingsIngestion,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
288pub struct PdfIngestion {
289    #[serde(default = "default_pdfium")]
290    pub engine: String,
291}
292
293impl Default for PdfIngestion {
294    fn default() -> Self {
295        Self {
296            engine: "pdfium".to_string(),
297        }
298    }
299}
300
301fn default_pdfium() -> String {
302    "pdfium".to_string()
303}
304
305fn default_pdf() -> PdfIngestion {
306    PdfIngestion {
307        engine: "pdfium".to_string(),
308    }
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
312pub struct OcrIngestion {
313    #[serde(default = "default_tesseract")]
314    pub engine: String,
315    #[serde(default = "default_languages")]
316    pub languages: Vec<String>,
317}
318
319impl Default for OcrIngestion {
320    fn default() -> Self {
321        Self {
322            engine: "tesseract".to_string(),
323            languages: vec!["eng".to_string()],
324        }
325    }
326}
327
328fn default_tesseract() -> String {
329    "tesseract".to_string()
330}
331
332fn default_languages() -> Vec<String> {
333    vec!["eng".to_string()]
334}
335
336fn default_ocr() -> OcrIngestion {
337    OcrIngestion {
338        engine: "tesseract".to_string(),
339        languages: vec!["eng".to_string()],
340    }
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
344pub struct EmbeddingsIngestion {
345    #[serde(default = "default_fastembed")]
346    pub engine: String,
347    pub model: Option<String>,
348}
349
350impl Default for EmbeddingsIngestion {
351    fn default() -> Self {
352        Self {
353            engine: "fastembed".to_string(),
354            model: None,
355        }
356    }
357}
358
359fn default_fastembed() -> String {
360    "fastembed".to_string()
361}
362
363fn default_embeddings() -> EmbeddingsIngestion {
364    EmbeddingsIngestion {
365        engine: "fastembed".to_string(),
366        model: None,
367    }
368}
369
370/// Cloud configuration
371#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
372pub struct Cloud {
373    pub api_url: Option<String>,
374    pub tenant_id: Option<String>,
375    #[serde(default)]
376    pub auto_sync: bool,
377}
378
379/// Human-in-the-loop approval configuration
380///
381/// Configures when and how tool calls require human approval before execution.
382///
383/// ## Policies
384///
385/// - `always_approve` - Auto-approve all tool calls (default)
386/// - `always_deny` - Block all tool calls
387/// - `ask` or `always_require` - Prompt user for every tool call
388/// - `ask_once` - Prompt once per tool, remember decisions for session
389/// - `pattern` - Only prompt for tools matching `require_patterns` regexes
390///
391/// ## Example YAML
392///
393/// ```yaml
394/// approval:
395///   enabled: true
396///   policy: pattern
397///   require_patterns:
398///     - "Edit|Write|Bash"   # Prompt for file-modifying tools
399///     - "mcp__.*"           # Prompt for all MCP tools
400///   timeout_seconds: 300
401///   tool_overrides:
402///     Read: always_approve  # Auto-approve Read tool
403///     Glob: always_approve  # Auto-approve Glob tool
404/// ```
405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
406pub struct ApprovalConfig {
407    /// Whether approval is enabled
408    #[serde(default)]
409    pub enabled: bool,
410
411    /// Policy type: "always_approve", "always_deny", "ask", "ask_once", "pattern"
412    #[serde(default = "default_approval_policy")]
413    pub policy: String,
414
415    /// Maximum steps before requiring approval (for threshold policy)
416    pub max_steps: Option<usize>,
417
418    /// Patterns that require approval (for pattern policy)
419    /// e.g., ["Edit|Write|Bash", "mcp__.*"]
420    pub require_patterns: Option<Vec<String>>,
421
422    /// Timeout for waiting for approval (in seconds)
423    #[serde(default = "default_approval_timeout")]
424    pub timeout_seconds: u64,
425
426    /// Per-tool policy overrides (tool_name -> policy)
427    /// e.g., {"Read": "always_approve", "Bash": "ask"}
428    #[serde(default)]
429    pub tool_overrides: Option<std::collections::HashMap<String, String>>,
430}
431
432fn default_approval_policy() -> String {
433    "always_approve".to_string()
434}
435
436fn default_approval_timeout() -> u64 {
437    300 // 5 minutes
438}
439
440impl Default for ApprovalConfig {
441    fn default() -> Self {
442        Self {
443            enabled: false,
444            policy: "always_approve".to_string(),
445            max_steps: None,
446            require_patterns: None,
447            timeout_seconds: 300,
448            tool_overrides: None,
449        }
450    }
451}
452
453/// Episodic memory configuration
454///
455/// Configures daily logs, session snapshots, and consolidation rules
456/// for episodic (short-term) memory storage.
457#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
458pub struct MemoryConfig {
459    /// Memory backend: "sqlite", "markdown", "none"
460    #[serde(default = "default_memory_backend")]
461    pub backend: String,
462
463    /// Directory for daily log files (relative to workspace)
464    #[serde(default = "default_daily_logs_dir")]
465    pub daily_logs_dir: String,
466
467    /// Maximum entries per daily log before rolling
468    #[serde(default = "default_max_daily_entries")]
469    pub max_daily_entries: usize,
470
471    /// Whether to automatically consolidate to semantic memory
472    #[serde(default)]
473    pub auto_consolidate: bool,
474
475    /// Time of day to run consolidation (HH:MM format)
476    pub consolidation_time: Option<String>,
477
478    /// Number of days to retain episodic logs (None = forever)
479    pub retention_days: Option<u32>,
480
481    /// Whether to include timestamps in entries
482    #[serde(default = "default_true")]
483    pub include_timestamps: bool,
484}
485
486fn default_memory_backend() -> String {
487    "markdown".to_string()
488}
489
490fn default_daily_logs_dir() -> String {
491    "memory".to_string()
492}
493
494fn default_max_daily_entries() -> usize {
495    100
496}
497
498impl Default for MemoryConfig {
499    fn default() -> Self {
500        Self {
501            backend: "markdown".to_string(),
502            daily_logs_dir: "memory".to_string(),
503            max_daily_entries: 100,
504            auto_consolidate: true,
505            consolidation_time: Some("03:00".to_string()),
506            retention_days: Some(30),
507            include_timestamps: true,
508        }
509    }
510}
511
512/// Session/conversation management configuration
513///
514/// Controls context rotation, idle timeouts, and cleanup behavior.
515/// Can be overridden at agent or channel level using `SessionConfigOverride`.
516#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
517pub struct SessionConfig {
518    /// Maximum conversation turns before context rotation.
519    #[serde(default = "default_max_turns")]
520    pub max_turns: usize,
521
522    /// Rotate context when estimated token usage exceeds this percentage of model max.
523    #[serde(default = "default_rotation_threshold_pct")]
524    pub rotation_threshold_pct: u8,
525
526    /// Rotate context after this many seconds of inactivity.
527    #[serde(default = "default_idle_timeout_secs")]
528    pub idle_timeout_secs: u64,
529
530    /// Interval for cleanup task to remove stale conversations (seconds).
531    #[serde(default = "default_cleanup_interval_secs")]
532    pub cleanup_interval_secs: u64,
533
534    /// Remove conversations idle longer than this (seconds).
535    #[serde(default = "default_cleanup_idle_threshold_secs")]
536    pub cleanup_idle_threshold_secs: u64,
537}
538
539fn default_max_turns() -> usize {
540    20
541}
542
543fn default_rotation_threshold_pct() -> u8 {
544    80
545}
546
547fn default_idle_timeout_secs() -> u64 {
548    1800 // 30 minutes
549}
550
551fn default_cleanup_interval_secs() -> u64 {
552    300 // 5 minutes
553}
554
555fn default_cleanup_idle_threshold_secs() -> u64 {
556    3600 // 1 hour
557}
558
559impl Default for SessionConfig {
560    fn default() -> Self {
561        Self {
562            max_turns: default_max_turns(),
563            rotation_threshold_pct: default_rotation_threshold_pct(),
564            idle_timeout_secs: default_idle_timeout_secs(),
565            cleanup_interval_secs: default_cleanup_interval_secs(),
566            cleanup_idle_threshold_secs: default_cleanup_idle_threshold_secs(),
567        }
568    }
569}
570
571/// Optional session config overrides for agent.yaml or channels.yaml.
572///
573/// Fields set to `None` inherit from the parent configuration level.
574/// Override hierarchy: Channel > Agent > Global > Defaults
575#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
576pub struct SessionConfigOverride {
577    pub max_turns: Option<usize>,
578    pub rotation_threshold_pct: Option<u8>,
579    pub idle_timeout_secs: Option<u64>,
580    pub cleanup_interval_secs: Option<u64>,
581    pub cleanup_idle_threshold_secs: Option<u64>,
582}
583
584impl SessionConfigOverride {
585    /// Apply this override onto a base SessionConfig.
586    /// Fields that are `None` inherit from the base.
587    pub fn apply_to(&self, base: &SessionConfig) -> SessionConfig {
588        SessionConfig {
589            max_turns: self.max_turns.unwrap_or(base.max_turns),
590            rotation_threshold_pct: self
591                .rotation_threshold_pct
592                .unwrap_or(base.rotation_threshold_pct),
593            idle_timeout_secs: self.idle_timeout_secs.unwrap_or(base.idle_timeout_secs),
594            cleanup_interval_secs: self
595                .cleanup_interval_secs
596                .unwrap_or(base.cleanup_interval_secs),
597            cleanup_idle_threshold_secs: self
598                .cleanup_idle_threshold_secs
599                .unwrap_or(base.cleanup_idle_threshold_secs),
600        }
601    }
602}
603
604/// HTTP/gRPC server configuration
605#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
606pub struct ServerConfig {
607    /// Port for the HTTP gateway (default: 8080)
608    #[serde(default = "default_server_port")]
609    pub port: u16,
610    /// Host address to bind to (default: "0.0.0.0")
611    #[serde(default = "default_server_host")]
612    pub host: String,
613    /// gRPC port (default: 50051)
614    #[serde(default = "default_grpc_port")]
615    pub grpc_port: u16,
616    /// Documentation server port (default: 1111)
617    #[serde(default = "default_docs_port")]
618    pub docs_port: u16,
619    /// Base URL for the docs server (e.g. "http://127.0.0.1:1111"). When unset, derived from docs_port.
620    /// Used to rewrite asset URLs in HTML so CSS/JS load correctly when serving pre-built docs locally.
621    #[serde(default)]
622    pub docs_base_url: Option<String>,
623}
624
625fn default_server_port() -> u16 {
626    8080
627}
628
629fn default_server_host() -> String {
630    "0.0.0.0".to_string()
631}
632
633fn default_grpc_port() -> u16 {
634    50051
635}
636
637fn default_docs_port() -> u16 {
638    1111
639}
640
641impl Default for ServerConfig {
642    fn default() -> Self {
643        Self {
644            port: default_server_port(),
645            host: default_server_host(),
646            grpc_port: default_grpc_port(),
647            docs_port: default_docs_port(),
648            docs_base_url: None,
649        }
650    }
651}
652
653/// Logging paths (relative to ENACT_HOME)
654#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
655pub struct LoggingConfig {
656    /// Path relative to ENACT_HOME for daemon log file
657    #[serde(default = "default_daemon_log")]
658    pub daemon_log: String,
659    /// Path relative to ENACT_HOME for serve log file
660    #[serde(default = "default_serve_log")]
661    pub serve_log: String,
662}
663
664/// Observability configuration
665///
666/// Controls tracing, logging, and metrics for LLM calls, token usage,
667/// memory access, guardrails, and context windows.
668#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
669pub struct ObservabilityConfig {
670    /// Enable LLM call tracing (start/end/failed events)
671    #[serde(default = "default_true")]
672    pub trace_llm_calls: bool,
673
674    /// Log full prompts (expensive - use for debugging only)
675    #[serde(default)]
676    pub log_full_prompts: bool,
677
678    /// Log full responses (expensive - use for debugging only)
679    #[serde(default)]
680    pub log_full_responses: bool,
681
682    /// Track token usage and emit token.usage events
683    #[serde(default = "default_true")]
684    pub track_token_usage: bool,
685
686    /// Trace memory access (recall/store events)
687    #[serde(default = "default_true")]
688    pub trace_memory_access: bool,
689
690    /// Enable context window snapshots
691    #[serde(default)]
692    pub enable_context_snapshots: bool,
693
694    /// Enable reasoning trace capture
695    #[serde(default)]
696    pub capture_reasoning_traces: bool,
697
698    /// Maximum content length for logged prompts/responses (truncation limit)
699    #[serde(default = "default_max_content_length")]
700    pub max_content_length: usize,
701}
702
703fn default_max_content_length() -> usize {
704    1000
705}
706
707impl Default for ObservabilityConfig {
708    fn default() -> Self {
709        Self {
710            trace_llm_calls: true,
711            log_full_prompts: false,
712            log_full_responses: false,
713            track_token_usage: true,
714            trace_memory_access: true,
715            enable_context_snapshots: false,
716            capture_reasoning_traces: false,
717            max_content_length: 1000,
718        }
719    }
720}
721
722fn default_daemon_log() -> String {
723    "logs/daemon.log".to_string()
724}
725
726fn default_serve_log() -> String {
727    "logs/serve.log".to_string()
728}
729
730impl Default for LoggingConfig {
731    fn default() -> Self {
732        Self {
733            daemon_log: default_daemon_log(),
734            serve_log: default_serve_log(),
735        }
736    }
737}
738
739/// Main configuration structure
740#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
741#[serde(deny_unknown_fields)]
742pub struct Config {
743    /// Default model id for run/serve (e.g. azure_foundry:text:gpt-4-1-mini). Read from ~/.enact/config.yaml.
744    #[serde(default)]
745    pub default_model_id: Option<String>,
746    #[serde(default)]
747    pub providers: Providers,
748    #[serde(default)]
749    pub runtime: Runtime,
750    #[serde(default)]
751    pub storage: Storage,
752    #[serde(default)]
753    pub tools: Tools,
754    pub cloud: Option<Cloud>,
755    /// HTTP/gRPC server configuration
756    #[serde(default)]
757    pub server: ServerConfig,
758    /// Human-in-the-loop approval configuration
759    #[serde(default)]
760    pub approval: ApprovalConfig,
761    /// Memory configuration
762    #[serde(default)]
763    pub memory: MemoryConfig,
764    /// Session/conversation management configuration
765    #[serde(default)]
766    pub session: SessionConfig,
767    /// Log file paths (relative to ENACT_HOME)
768    #[serde(default)]
769    pub logging: LoggingConfig,
770    /// Observability configuration (LLM tracing, token usage, memory access, etc.)
771    #[serde(default)]
772    pub observability: ObservabilityConfig,
773}
774
775impl Config {
776    /// Load global config from ENACT_HOME/config.yaml.
777    /// If the file does not exist, returns default config.
778    pub fn load_from_home() -> Result<Self> {
779        let path = crate::home::enact_home().join("config.yaml");
780        Self::load_from_yaml_path(&path)
781    }
782
783    /// Write default config to `path` if the file does not exist.
784    /// Used by install/doctor/serve to ensure config.yaml is present.
785    pub fn ensure_default_at(path: &Path) -> Result<()> {
786        if path.exists() {
787            return Ok(());
788        }
789        if let Some(parent) = path.parent() {
790            std::fs::create_dir_all(parent).context("Failed to create config directory")?;
791        }
792        Self::default().save_to_yaml_path(path)
793    }
794
795    /// Save global config to ENACT_HOME/config.yaml.
796    /// Creates a rolling backup of all config files before writing.
797    pub fn save_to_home(&self) -> Result<()> {
798        crate::home::create_config_backup()?;
799        let path = crate::home::enact_home().join("config.yaml");
800        self.save_to_yaml_path(&path)
801    }
802
803    /// Load config from a YAML file path.
804    pub fn load_from_yaml_path(path: &Path) -> Result<Self> {
805        if !path.exists() {
806            return Ok(Self::default());
807        }
808        let s = std::fs::read_to_string(path).context("Failed to read config file")?;
809        let config: Config = serde_yaml::from_str(&s).context("Failed to parse config YAML")?;
810        Ok(config)
811    }
812
813    /// Save config to a YAML file path.
814    pub fn save_to_yaml_path(&self, path: &Path) -> Result<()> {
815        if let Some(parent) = path.parent() {
816            std::fs::create_dir_all(parent).context("Failed to create config directory")?;
817        }
818        let s = serde_yaml::to_string(self).context("Failed to serialize config to YAML")?;
819        std::fs::write(path, s).context("Failed to write config file")?;
820        Ok(())
821    }
822}