Skip to main content

vent_mcp/
config.rs

1//! Configuration loading, defaults, and validation for vent-mcp.
2//!
3//! Configuration is the main policy boundary for the server. It decides which
4//! channels are valid, where events may be delivered, how JSONL output is placed,
5//! and how webhook payloads can be shaped. This module deliberately validates
6//! those choices before the server starts or the CLI sends a message, so callers
7//! cannot route feedback to undeclared channels, malformed webhook destinations,
8//! or ambiguous provider mappings.
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::env;
12use std::ffi::OsString;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19use crate::provider::ProviderTemplate;
20
21const DEFAULT_CONFIG_FILE_NAME: &str = "config.toml";
22const DEFAULT_CONFIG_DIR_NAME: &str = "vent-mcp";
23pub(crate) const DEFAULT_LOG_SINK_NAME: &str = "log";
24const ENV_CONFIG: &str = "VENT_MCP_CONFIG";
25#[cfg(feature = "webhook")]
26const DEFAULT_WEBHOOK_TIMEOUT_MS: u64 = 10_000;
27
28/// Validated configuration loaded from disk with its resolved file path.
29#[derive(Debug, Clone, PartialEq)]
30pub struct LoadedConfig {
31    path: PathBuf,
32    config: RuntimeConfig,
33}
34
35impl LoadedConfig {
36    /// Resolves, creates when appropriate, reads, and validates the active config.
37    ///
38    /// Environment-specified configs must already exist, while default user
39    /// locations can be bootstrapped with the built-in safe defaults.
40    pub fn load() -> Result<Self, ConfigError> {
41        let resolved = resolve_config_path()?;
42        Self::load_from_resolved_path(resolved)
43    }
44
45    /// Loads and validates a config from an explicit path.
46    ///
47    /// This bypasses automatic default creation and is used by tests and callers
48    /// that already know which config file should be authoritative.
49    pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
50        let path = path.as_ref().to_path_buf();
51        let config = AppConfig::load_from_path(&path)?;
52        Self::from_app_config(path, config)
53    }
54
55    /// Loads a config from a previously resolved path, creating defaults when safe.
56    ///
57    /// Default creation happens only for implicit XDG or home-directory paths.
58    /// An explicit environment override is treated as intentional and therefore
59    /// fails when the file is missing.
60    fn load_from_resolved_path(resolved: ResolvedConfigPath) -> Result<Self, ConfigError> {
61        if !resolved.path.exists() {
62            if resolved.source == ConfigPathSource::Env {
63                return Err(ConfigError::NotFound {
64                    path: resolved.path,
65                });
66            }
67            write_default_config(&resolved.path)?;
68        }
69
70        Self::load_from_path(resolved.path)
71    }
72
73    fn from_app_config(path: PathBuf, config: AppConfig) -> Result<Self, ConfigError> {
74        let config_dir = config_dir_for_path(&path);
75        let config = RuntimeConfig::from_app_config(config, config_dir)?;
76        Ok(Self { path, config })
77    }
78
79    /// Returns the normalized runtime configuration.
80    #[must_use]
81    pub fn config(&self) -> &RuntimeConfig {
82        &self.config
83    }
84
85    /// Consumes the loaded config and returns its normalized runtime form.
86    #[must_use]
87    pub fn into_config(self) -> RuntimeConfig {
88        self.config
89    }
90
91    /// Returns the directory that contains the active configuration file.
92    ///
93    /// Sinks use this as the base for default relative storage decisions.
94    #[must_use]
95    pub fn config_dir(&self) -> PathBuf {
96        config_dir_for_path(&self.path)
97    }
98}
99
100/// Normalized runtime policy derived from validated TOML configuration.
101#[derive(Debug, Clone, PartialEq)]
102pub struct RuntimeConfig {
103    default_channel: String,
104    logging: LoggingConfig,
105    channels: Vec<ChannelConfig>,
106    #[cfg(feature = "webhook")]
107    providers: BTreeMap<String, ProviderTemplate>,
108    sinks: Vec<SinkConfig>,
109    sinks_by_name: BTreeMap<String, usize>,
110    config_dir: PathBuf,
111    jsonl_dir: PathBuf,
112}
113
114impl RuntimeConfig {
115    pub(crate) fn from_app_config(
116        config: AppConfig,
117        config_dir: PathBuf,
118    ) -> Result<Self, ConfigValidationError> {
119        config.validate()?;
120        let AppConfig {
121            default_channel,
122            logging,
123            channels,
124            providers: raw_providers,
125            sinks,
126        } = config;
127
128        #[cfg(feature = "webhook")]
129        let providers = compile_webhook_providers(&raw_providers)?;
130        #[cfg(not(feature = "webhook"))]
131        let _ = raw_providers;
132
133        let sinks_by_name = sinks
134            .iter()
135            .enumerate()
136            .map(|(index, sink)| (sink.name().to_string(), index))
137            .collect();
138        let jsonl_dir = resolve_jsonl_dir(&logging, &config_dir)?;
139
140        Ok(Self {
141            default_channel,
142            logging,
143            channels,
144            #[cfg(feature = "webhook")]
145            providers,
146            sinks,
147            sinks_by_name,
148            config_dir,
149            jsonl_dir,
150        })
151    }
152
153    /// Returns the channel used when callers omit an explicit channel.
154    #[must_use]
155    pub fn default_channel(&self) -> &str {
156        &self.default_channel
157    }
158
159    /// Reports whether a channel name is configured.
160    #[must_use]
161    pub fn has_channel(&self, name: &str) -> bool {
162        self.channels.iter().any(|channel| channel.name == name)
163    }
164
165    /// True when only the default channel exists and channel choice can be hidden.
166    #[must_use]
167    pub fn has_only_default_channel(&self) -> bool {
168        self.channels.len() == 1 && self.channels[0].name == self.default_channel
169    }
170
171    /// Builds the channel catalog exposed by the `list_channels` MCP tool.
172    #[must_use]
173    pub fn channel_list(&self) -> crate::types::ListChannelsOutput {
174        crate::types::ListChannelsOutput {
175            default_channel: self.default_channel.clone(),
176            channels: self
177                .channels
178                .iter()
179                .map(|channel| crate::types::ChannelInfo {
180                    name: channel.name.clone(),
181                    description: channel.description.clone(),
182                })
183                .collect(),
184        }
185    }
186
187    pub(crate) fn sinks_for_channel(&self, channel_name: &str) -> Option<Vec<&SinkConfig>> {
188        let channel = self
189            .channels
190            .iter()
191            .find(|channel| channel.name == channel_name)?;
192        Some(
193            channel
194                .sinks
195                .iter()
196                .map(|sink| {
197                    let index = self
198                        .sinks_by_name
199                        .get(sink)
200                        .expect("validated channel sink reference");
201                    &self.sinks[*index]
202                })
203                .collect(),
204        )
205    }
206
207    pub(crate) fn jsonl_dir(&self) -> &Path {
208        &self.jsonl_dir
209    }
210
211    #[cfg(feature = "webhook")]
212    pub(crate) fn webhook_provider(&self, name: &str) -> Option<&ProviderTemplate> {
213        self.providers.get(name)
214    }
215
216    #[allow(dead_code)]
217    pub(crate) fn config_dir(&self) -> &Path {
218        &self.config_dir
219    }
220
221    #[allow(dead_code)]
222    pub(crate) fn logging(&self) -> &LoggingConfig {
223        &self.logging
224    }
225}
226
227/// Top-level TOML configuration shape for channels, sinks, providers, and logging.
228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
229#[serde(default, deny_unknown_fields)]
230pub struct AppConfig {
231    pub default_channel: String,
232    pub logging: LoggingConfig,
233    pub channels: Vec<ChannelConfig>,
234    pub providers: BTreeMap<String, WebhookProviderConfig>,
235    pub sinks: Vec<SinkConfig>,
236}
237
238impl Default for AppConfig {
239    /// Builds the conservative default config with one channel and JSONL logging.
240    ///
241    /// The default creates a local, non-network sink so a first run has somewhere
242    /// to record vents without requiring webhook credentials or provider setup.
243    fn default() -> Self {
244        Self {
245            default_channel: "feedback".to_string(),
246            logging: LoggingConfig::default(),
247            channels: vec![ChannelConfig {
248                name: "feedback".to_string(),
249                description: "Blocked work, repeated failures, or confusing workflows. Avoid routine progress updates.".to_string(),
250                sinks: vec![DEFAULT_LOG_SINK_NAME.to_string()],
251            }],
252            providers: default_webhook_providers(),
253            sinks: vec![default_log_sink()],
254        }
255    }
256}
257
258impl AppConfig {
259    /// Reads, parses, and validates TOML configuration from disk.
260    ///
261    /// The parsed value is never returned before validation succeeds, keeping
262    /// downstream server and sink code free from partial-config assumptions.
263    pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
264        let path = path.as_ref();
265        if !path.exists() {
266            return Err(ConfigError::NotFound {
267                path: path.to_path_buf(),
268            });
269        }
270
271        let raw = fs::read_to_string(path).map_err(|source| ConfigError::Read {
272            path: path.to_path_buf(),
273            source,
274        })?;
275
276        let config: Self = toml::from_str(&raw).map_err(|source| ConfigError::ParseTomlAtPath {
277            path: path.to_path_buf(),
278            source,
279        })?;
280        config.validate()?;
281        Ok(config)
282    }
283
284    /// Parses and validates TOML configuration from a string.
285    ///
286    /// This is used by config tests without exposing raw parsing as binary API.
287    #[cfg(test)]
288    pub fn from_toml_str(raw: &str) -> Result<Self, ConfigError> {
289        let config: Self =
290            toml::from_str(raw).map_err(|source| ConfigError::ParseToml { source })?;
291        config.validate()?;
292        Ok(config)
293    }
294
295    /// Validates channel, provider, and sink settings as one coherent policy.
296    ///
297    /// The checks reject empty lists, duplicate or malformed channel names,
298    /// default channels that do not exist, invalid provider maps, and sink
299    /// settings that would fail later in less predictable ways.
300    pub fn validate(&self) -> Result<(), ConfigValidationError> {
301        validate_channel_name(&self.default_channel, "default_channel")?;
302
303        if self.channels.is_empty() {
304            return Err(ConfigValidationError::ChannelsMustNotBeEmpty);
305        }
306        if self.sinks.is_empty() {
307            return Err(ConfigValidationError::SinksMustNotBeEmpty);
308        }
309
310        let mut channel_names = BTreeSet::new();
311        for channel in &self.channels {
312            validate_channel_name(&channel.name, "channels.name")?;
313            if channel.description.trim().is_empty() {
314                return Err(ConfigValidationError::ChannelDescriptionMustNotBeEmpty {
315                    channel: channel.name.clone(),
316                });
317            }
318            if channel.sinks.is_empty() {
319                return Err(ConfigValidationError::ChannelSinksMustNotBeEmpty {
320                    channel: channel.name.clone(),
321                });
322            }
323            let mut channel_sinks = BTreeSet::new();
324            for sink in &channel.sinks {
325                validate_provider_name(sink, "channels.sinks")?;
326                if !channel_sinks.insert(sink.as_str()) {
327                    return Err(ConfigValidationError::DuplicateChannelSink {
328                        channel: channel.name.clone(),
329                        sink: sink.clone(),
330                    });
331                }
332            }
333            if !channel_names.insert(channel.name.as_str()) {
334                return Err(ConfigValidationError::DuplicateChannel {
335                    channel: channel.name.clone(),
336                });
337            }
338        }
339
340        if !channel_names.contains(self.default_channel.as_str()) {
341            return Err(ConfigValidationError::DefaultChannelMustExist {
342                channel: self.default_channel.clone(),
343            });
344        }
345
346        for (provider_name, provider) in &self.providers {
347            validate_provider_name(provider_name, "providers")?;
348            provider.validate(provider_name)?;
349        }
350
351        let mut sink_names = BTreeSet::new();
352        for sink in &self.sinks {
353            sink.validate(&self.providers)?;
354            let name = sink.name();
355            validate_provider_name(name, "sinks.name")?;
356            if !sink_names.insert(name) {
357                return Err(ConfigValidationError::DuplicateSinkName {
358                    sink: name.to_string(),
359                });
360            }
361        }
362
363        // All jsonl sinks share one vents.jsonl file, so a channel may reference at most one.
364        let jsonl_sink_names: BTreeSet<&str> = self
365            .sinks
366            .iter()
367            .filter(|sink| matches!(sink, SinkConfig::Jsonl(_)))
368            .map(SinkConfig::name)
369            .collect();
370
371        for channel in &self.channels {
372            let mut jsonl_in_channel = 0;
373            for sink in &channel.sinks {
374                if !sink_names.contains(sink.as_str()) {
375                    return Err(ConfigValidationError::UnknownChannelSink {
376                        channel: channel.name.clone(),
377                        sink: sink.clone(),
378                    });
379                }
380                if jsonl_sink_names.contains(sink.as_str()) {
381                    jsonl_in_channel += 1;
382                }
383            }
384            if jsonl_in_channel > 1 {
385                return Err(ConfigValidationError::MultipleJsonlSinksInChannel {
386                    channel: channel.name.clone(),
387                });
388            }
389        }
390
391        Ok(())
392    }
393}
394
395/// JSONL storage location overrides for persisted vent records.
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
397#[serde(default, deny_unknown_fields)]
398pub struct LoggingConfig {
399    pub jsonl_dir: Option<String>,
400}
401
402/// One named vent channel and the sinks that receive its events.
403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
404#[serde(deny_unknown_fields)]
405pub struct ChannelConfig {
406    pub name: String,
407    pub description: String,
408    #[serde(default)]
409    pub sinks: Vec<String>,
410}
411
412/// Concrete delivery destination referenced by channel routes.
413#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
414#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
415pub enum SinkConfig {
416    Jsonl(JsonlSinkConfig),
417    #[cfg(feature = "webhook")]
418    Webhook(WebhookSinkConfig),
419}
420
421impl SinkConfig {
422    /// Validates the concrete sink variant against the provider registry.
423    ///
424    /// JSONL has no extra settings, while webhook sinks must reference only
425    /// defined providers and pass URL, timeout, and header checks.
426    fn validate(
427        &self,
428        _providers: &BTreeMap<String, WebhookProviderConfig>,
429    ) -> Result<(), ConfigValidationError> {
430        match self {
431            SinkConfig::Jsonl(_) => Ok(()),
432            #[cfg(feature = "webhook")]
433            SinkConfig::Webhook(config) => config.validate(_providers),
434        }
435    }
436
437    #[must_use]
438    pub fn name(&self) -> &str {
439        match self {
440            SinkConfig::Jsonl(config) => &config.name,
441            #[cfg(feature = "webhook")]
442            SinkConfig::Webhook(config) => &config.name,
443        }
444    }
445}
446
447/// JSONL sink definition that appends vent records to the shared log file.
448#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
449#[serde(default, deny_unknown_fields)]
450pub struct JsonlSinkConfig {
451    pub name: String,
452}
453
454/// HTTP webhook sink with optional provider shaping and env-backed headers.
455#[cfg(feature = "webhook")]
456#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
457#[serde(default, deny_unknown_fields)]
458pub struct WebhookSinkConfig {
459    pub name: String,
460    pub url: String,
461    pub provider: Option<String>,
462    pub headers: Vec<WebhookHeaderConfig>,
463    pub timeout_ms: u64,
464}
465
466#[cfg(feature = "webhook")]
467impl Default for WebhookSinkConfig {
468    /// Builds a webhook sink with empty destination and default timeout.
469    ///
470    /// The URL remains intentionally empty so deserialization can fill it and
471    /// validation can reject configs that omit a real endpoint.
472    fn default() -> Self {
473        Self {
474            name: String::new(),
475            url: String::new(),
476            provider: None,
477            headers: Vec::new(),
478            timeout_ms: DEFAULT_WEBHOOK_TIMEOUT_MS,
479        }
480    }
481}
482
483#[cfg(feature = "webhook")]
484impl WebhookSinkConfig {
485    /// Validates one webhook sink and any provider reference it contains.
486    ///
487    /// Webhooks must target HTTP(S), have a positive timeout, use non-empty
488    /// environment-backed headers, and either request raw event JSON or a known
489    /// provider mapping.
490    fn validate(
491        &self,
492        providers: &BTreeMap<String, WebhookProviderConfig>,
493    ) -> Result<(), ConfigValidationError> {
494        if self.url.trim().is_empty() {
495            return Err(ConfigValidationError::WebhookUrlMustNotBeEmpty);
496        }
497
498        let parsed = url::Url::parse(&self.url).map_err(|_| {
499            ConfigValidationError::WebhookUrlMustBeHttp {
500                url: self.url.clone(),
501            }
502        })?;
503        if !matches!(parsed.scheme(), "http" | "https") {
504            return Err(ConfigValidationError::WebhookUrlMustBeHttp {
505                url: self.url.clone(),
506            });
507        }
508        if self.timeout_ms == 0 {
509            return Err(ConfigValidationError::WebhookTimeoutMsMustBePositive);
510        }
511        if let Some(provider) = self.provider.as_deref().map(str::trim) {
512            if provider.is_empty() {
513                return Err(ConfigValidationError::WebhookProviderNameMustNotBeEmpty);
514            }
515            validate_provider_name(provider, "sinks.provider")?;
516            if provider != "raw" && !providers.contains_key(provider) {
517                return Err(ConfigValidationError::UnknownWebhookProvider {
518                    provider: provider.to_string(),
519                });
520            }
521        }
522
523        let mut header_names = BTreeSet::new();
524        for header in &self.headers {
525            if header.name.trim().is_empty() {
526                return Err(ConfigValidationError::WebhookHeaderNameMustNotBeEmpty);
527            }
528            if header.env.trim().is_empty() {
529                return Err(ConfigValidationError::WebhookHeaderEnvMustNotBeEmpty {
530                    header: header.name.clone(),
531                });
532            }
533            let normalized = header.name.trim().to_ascii_lowercase();
534            if !header_names.insert(normalized) {
535                return Err(ConfigValidationError::DuplicateWebhookHeader {
536                    header: header.name.clone(),
537                });
538            }
539        }
540
541        Ok(())
542    }
543}
544
545/// Maps vent event fields onto dotted webhook JSON output paths.
546#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
547#[serde(default)]
548pub struct WebhookProviderConfig {
549    pub field_label_key: Option<String>,
550    #[serde(flatten)]
551    pub fields: BTreeMap<String, String>,
552}
553
554impl WebhookProviderConfig {
555    /// Validates a provider's event-field-to-output-path mapping.
556    ///
557    /// Providers are constrained to known event fields and unique dotted output
558    /// paths so rendered webhook payloads cannot collide or silently drop fields.
559    fn validate(&self, provider_name: &str) -> Result<(), ConfigValidationError> {
560        ProviderTemplate::compile(provider_name, self).map(|_| ())
561    }
562}
563
564/// Webhook header populated from a named environment variable at send time.
565#[cfg(feature = "webhook")]
566#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
567#[serde(deny_unknown_fields)]
568pub struct WebhookHeaderConfig {
569    pub name: String,
570    pub env: String,
571}
572
573/// Resolved config file path and the lookup rule that selected it.
574#[derive(Debug, Clone, PartialEq, Eq)]
575pub struct ResolvedConfigPath {
576    pub path: PathBuf,
577    pub source: ConfigPathSource,
578}
579
580/// Why a config path was chosen during environment-based resolution.
581#[derive(Debug, Clone, Copy, PartialEq, Eq)]
582pub enum ConfigPathSource {
583    Env,
584    Xdg,
585    Home,
586}
587
588/// Errors while resolving, reading, parsing, or bootstrapping configuration files.
589#[derive(Debug, Error)]
590pub enum ConfigError {
591    #[error("home directory is not set")]
592    HomeDirectoryNotSet,
593    #[error("config file not found at {path}")]
594    NotFound { path: PathBuf },
595    #[error("failed to read config file at {path}: {source}")]
596    Read {
597        path: PathBuf,
598        source: std::io::Error,
599    },
600    #[error("failed to parse config file at {path}: {source}")]
601    ParseTomlAtPath {
602        path: PathBuf,
603        source: toml::de::Error,
604    },
605    #[error("failed to parse config: {source}")]
606    ParseToml { source: toml::de::Error },
607    #[error("{0}")]
608    Validation(#[from] ConfigValidationError),
609    #[error("failed to create config directory at {path}: {source}")]
610    CreateConfigDir {
611        path: PathBuf,
612        source: std::io::Error,
613    },
614    #[error("failed to serialize default config: {source}")]
615    SerializeDefaultConfig { source: toml::ser::Error },
616    #[error("failed to write default config at {path}: {source}")]
617    WriteDefaultConfig {
618        path: PathBuf,
619        source: std::io::Error,
620    },
621}
622
623/// Policy violations detected while validating TOML configuration.
624#[derive(Debug, Error, PartialEq, Eq)]
625pub enum ConfigValidationError {
626    #[error("channels must contain at least one channel")]
627    ChannelsMustNotBeEmpty,
628    #[error("sinks must contain at least one sink")]
629    SinksMustNotBeEmpty,
630    #[error("invalid channel name in {field}: {name}")]
631    InvalidChannelName { field: String, name: String },
632    #[error("duplicate sink name: {sink}")]
633    DuplicateSinkName { sink: String },
634    #[error("duplicate channel: {channel}")]
635    DuplicateChannel { channel: String },
636    #[error("channel must reference at least one sink: {channel}")]
637    ChannelSinksMustNotBeEmpty { channel: String },
638    #[error("duplicate sink reference in channel {channel}: {sink}")]
639    DuplicateChannelSink { channel: String, sink: String },
640    #[error("unknown sink reference in channel {channel}: {sink}")]
641    UnknownChannelSink { channel: String, sink: String },
642    #[error("default channel does not exist: {channel}")]
643    DefaultChannelMustExist { channel: String },
644    #[error("channel description must not be empty: {channel}")]
645    ChannelDescriptionMustNotBeEmpty { channel: String },
646    #[error("webhook url must not be empty")]
647    WebhookUrlMustNotBeEmpty,
648    #[error("webhook url must be an http or https URL: {url}")]
649    WebhookUrlMustBeHttp { url: String },
650    #[cfg(feature = "webhook")]
651    #[error("webhook timeout_ms must be positive")]
652    WebhookTimeoutMsMustBePositive,
653    #[cfg(feature = "webhook")]
654    #[error("webhook header name must not be empty")]
655    WebhookHeaderNameMustNotBeEmpty,
656    #[cfg(feature = "webhook")]
657    #[error("webhook header env must not be empty for header {header}")]
658    WebhookHeaderEnvMustNotBeEmpty { header: String },
659    #[cfg(feature = "webhook")]
660    #[error("duplicate webhook header: {header}")]
661    DuplicateWebhookHeader { header: String },
662    #[error("webhook provider name must not be empty")]
663    WebhookProviderNameMustNotBeEmpty,
664    #[error("unknown webhook provider: {provider}")]
665    UnknownWebhookProvider { provider: String },
666    #[error("webhook provider map must not be empty: {provider}")]
667    WebhookProviderMapMustNotBeEmpty { provider: String },
668    #[error("invalid webhook provider label key in {provider}: {label_key}")]
669    InvalidWebhookProviderLabelKey { provider: String, label_key: String },
670    #[error("unknown webhook provider field in {provider}: {field}")]
671    UnknownWebhookProviderField { provider: String, field: String },
672    #[error("invalid webhook provider path in {provider} for {field}: {path}")]
673    InvalidWebhookProviderPath {
674        provider: String,
675        field: String,
676        path: String,
677    },
678    #[error("duplicate webhook provider output path in {provider}: {path}")]
679    DuplicateWebhookProviderPath { provider: String, path: String },
680    #[error(
681        "webhook provider output path in {provider} collides with another mapped path: {path}"
682    )]
683    CollidingWebhookProviderPath { provider: String, path: String },
684    #[error("cannot expand home-relative jsonl_dir because HOME is not set: {path}")]
685    JsonlDirHomeNotSet { path: String },
686    #[error(
687        "channel {channel} references multiple jsonl sinks, which would write duplicate records to the shared log file"
688    )]
689    MultipleJsonlSinksInChannel { channel: String },
690}
691
692/// Resolves the active configuration path from process environment.
693///
694/// The lookup order is explicit config path, XDG config home, then the user's
695/// home directory. Empty environment values are ignored rather than treated as
696/// real paths.
697pub fn resolve_config_path() -> Result<ResolvedConfigPath, ConfigError> {
698    resolve_config_path_with(
699        |key| env::var_os(key),
700        env::var_os("HOME").map(PathBuf::from),
701    )
702}
703
704/// Resolves a config path using injectable environment and home-directory inputs.
705///
706/// This helper keeps path precedence testable without mutating global process
707/// environment. The returned source records why the path was selected so loading
708/// can decide whether default creation is allowed.
709pub fn resolve_config_path_with<F>(
710    lookup_var: F,
711    home_dir: Option<PathBuf>,
712) -> Result<ResolvedConfigPath, ConfigError>
713where
714    F: Fn(&str) -> Option<OsString>,
715{
716    if let Some(path) = lookup_var(ENV_CONFIG).and_then(non_empty_os_string) {
717        return Ok(ResolvedConfigPath {
718            path: PathBuf::from(path),
719            source: ConfigPathSource::Env,
720        });
721    }
722
723    if let Some(xdg_config_home) = lookup_var("XDG_CONFIG_HOME").and_then(non_empty_os_string) {
724        return Ok(ResolvedConfigPath {
725            path: PathBuf::from(xdg_config_home)
726                .join(DEFAULT_CONFIG_DIR_NAME)
727                .join(DEFAULT_CONFIG_FILE_NAME),
728            source: ConfigPathSource::Xdg,
729        });
730    }
731
732    let home = home_dir.ok_or(ConfigError::HomeDirectoryNotSet)?;
733    Ok(ResolvedConfigPath {
734        path: home
735            .join(".config")
736            .join(DEFAULT_CONFIG_DIR_NAME)
737            .join(DEFAULT_CONFIG_FILE_NAME),
738        source: ConfigPathSource::Home,
739    })
740}
741
742/// Treats empty OS strings as unset environment values.
743fn non_empty_os_string(value: OsString) -> Option<OsString> {
744    if value.is_empty() {
745        None
746    } else {
747        Some(value)
748    }
749}
750
751fn config_dir_for_path(path: &Path) -> PathBuf {
752    path.parent()
753        .map(Path::to_path_buf)
754        .unwrap_or_else(|| PathBuf::from("."))
755}
756
757fn resolve_jsonl_dir(
758    logging: &LoggingConfig,
759    config_dir: &Path,
760) -> Result<PathBuf, ConfigValidationError> {
761    // Empty or whitespace jsonl_dir falls back to the config directory, not CWD.
762    let Some(value) = logging
763        .jsonl_dir
764        .as_deref()
765        .map(str::trim)
766        .filter(|value| !value.is_empty())
767    else {
768        return Ok(config_dir.to_path_buf());
769    };
770
771    expand_tilde(value).map_err(|()| ConfigValidationError::JsonlDirHomeNotSet {
772        path: value.to_string(),
773    })
774}
775
776fn expand_tilde(value: &str) -> Result<PathBuf, ()> {
777    expand_tilde_with(value, home_dir())
778}
779
780fn expand_tilde_with(value: &str, home: Option<PathBuf>) -> Result<PathBuf, ()> {
781    // Home-relative paths error when HOME is unset instead of writing under literal ~/… .
782    if value == "~" {
783        return home.ok_or(());
784    }
785
786    if let Some(rest) = value.strip_prefix("~/") {
787        return home.map(|home| home.join(rest)).ok_or(());
788    }
789
790    Ok(Path::new(value).to_path_buf())
791}
792
793fn home_dir() -> Option<PathBuf> {
794    env::var_os("HOME")
795        .filter(|home| !home.is_empty())
796        .map(PathBuf::from)
797}
798
799#[cfg(feature = "webhook")]
800fn compile_webhook_providers(
801    providers: &BTreeMap<String, WebhookProviderConfig>,
802) -> Result<BTreeMap<String, ProviderTemplate>, ConfigValidationError> {
803    providers
804        .iter()
805        .map(|(name, provider)| {
806            ProviderTemplate::compile(name, provider).map(|template| (name.clone(), template))
807        })
808        .collect()
809}
810
811/// Validates channel-like names used by configuration and sink selection.
812///
813/// Names are limited to lowercase ASCII letters, digits, underscores, and dashes
814/// so they remain stable in CLI input, MCP schemas, status labels, and webhook
815/// provider references.
816fn validate_channel_name(name: &str, field: &str) -> Result<(), ConfigValidationError> {
817    let valid = !name.is_empty()
818        && name.len() <= 64
819        && name
820            .bytes()
821            .all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'_' | b'-'));
822
823    if valid {
824        Ok(())
825    } else {
826        Err(ConfigValidationError::InvalidChannelName {
827            field: field.to_string(),
828            name: name.to_string(),
829        })
830    }
831}
832
833/// Validates provider names with the same rules used for channels.
834fn validate_provider_name(name: &str, field: &str) -> Result<(), ConfigValidationError> {
835    validate_channel_name(name, field)
836}
837
838#[must_use]
839pub(crate) fn default_log_sink() -> SinkConfig {
840    SinkConfig::Jsonl(JsonlSinkConfig {
841        name: DEFAULT_LOG_SINK_NAME.to_string(),
842    })
843}
844
845/// Supplies built-in webhook provider mappings for common receivers.
846///
847/// These defaults let users send useful payloads to raw automation endpoints,
848/// text-only chat endpoints, and rich chat endpoints without writing the mapping
849/// from scratch.
850fn default_webhook_providers() -> BTreeMap<String, WebhookProviderConfig> {
851    [
852        ("zapier", raw_event_provider()),
853        ("make", raw_event_provider()),
854        ("n8n", raw_event_provider()),
855        ("pipedream", raw_event_provider()),
856        ("workato", raw_event_provider()),
857        ("ifttt", ifttt_provider()),
858        ("slack", slack_like_provider()),
859        ("mattermost", slack_like_provider()),
860        (
861            "discord",
862            labeled_context_provider("name", "content", "embeds.0.fields.0.value"),
863        ),
864        ("microsoft_teams", text_provider("text")),
865        ("google_chat", text_provider("text")),
866        ("webex", text_provider("markdown")),
867    ]
868    .into_iter()
869    .map(|(name, provider)| (name.to_string(), provider))
870    .collect()
871}
872
873/// Maps the canonical vent event shape unchanged into a receiver-specific body.
874fn raw_event_provider() -> WebhookProviderConfig {
875    WebhookProviderConfig {
876        field_label_key: None,
877        fields: BTreeMap::from([
878            ("id".to_string(), "id".to_string()),
879            ("timestamp".to_string(), "timestamp".to_string()),
880            ("channel".to_string(), "channel".to_string()),
881            ("message".to_string(), "message".to_string()),
882            ("project".to_string(), "project".to_string()),
883        ]),
884    }
885}
886
887/// Maps only the vent message into a provider's plain text field.
888fn text_provider(message_path: &str) -> WebhookProviderConfig {
889    WebhookProviderConfig {
890        field_label_key: None,
891        fields: BTreeMap::from([("message".to_string(), message_path.to_string())]),
892    }
893}
894
895/// Maps message and project into rich fields with generated labels.
896fn labeled_context_provider(
897    label_key: &str,
898    message_path: &str,
899    project_path: &str,
900) -> WebhookProviderConfig {
901    WebhookProviderConfig {
902        field_label_key: Some(label_key.to_string()),
903        fields: BTreeMap::from([
904            ("message".to_string(), message_path.to_string()),
905            ("project".to_string(), project_path.to_string()),
906        ]),
907    }
908}
909
910/// Maps into Slack-compatible attachment fields.
911fn slack_like_provider() -> WebhookProviderConfig {
912    labeled_context_provider("title", "text", "attachments.0.fields.0.value")
913}
914
915/// Maps the three Maker Webhooks values IFTTT exposes to applets.
916fn ifttt_provider() -> WebhookProviderConfig {
917    WebhookProviderConfig {
918        field_label_key: None,
919        fields: BTreeMap::from([
920            ("message".to_string(), "value1".to_string()),
921            ("channel".to_string(), "value2".to_string()),
922            ("project".to_string(), "value3".to_string()),
923        ]),
924    }
925}
926
927/// Writes a default config file at an implicit config path.
928///
929/// Parent directories are created as needed, and serialization errors remain
930/// explicit so startup failures explain whether creation or rendering failed.
931fn write_default_config(path: &Path) -> Result<(), ConfigError> {
932    if let Some(parent) = path.parent() {
933        fs::create_dir_all(parent).map_err(|source| ConfigError::CreateConfigDir {
934            path: parent.to_path_buf(),
935            source,
936        })?;
937    }
938
939    let rendered = toml::to_string_pretty(&AppConfig::default())
940        .map_err(|source| ConfigError::SerializeDefaultConfig { source })?;
941    fs::write(path, rendered).map_err(|source| ConfigError::WriteDefaultConfig {
942        path: path.to_path_buf(),
943        source,
944    })?;
945
946    Ok(())
947}
948
949#[cfg(test)]
950mod tests {
951    use std::ffi::OsString;
952    use std::path::PathBuf;
953
954    use tempfile::tempdir;
955
956    #[cfg(feature = "webhook")]
957    use super::SinkConfig;
958    use super::{
959        resolve_config_path_with, AppConfig, ConfigPathSource, ConfigValidationError, LoadedConfig,
960    };
961
962    /// Verifies config path lookup precedence without touching real environment variables.
963    #[test]
964    fn resolve_config_path_uses_expected_precedence() {
965        let from_env = resolve_config_path_with(
966            |name| match name {
967                "VENT_MCP_CONFIG" => Some(OsString::from("/tmp/override.toml")),
968                "XDG_CONFIG_HOME" => Some(OsString::from("/xdg")),
969                _ => None,
970            },
971            Some(PathBuf::from("/Users/alice")),
972        )
973        .expect("env override should resolve");
974        assert_eq!(from_env.path, PathBuf::from("/tmp/override.toml"));
975        assert_eq!(from_env.source, ConfigPathSource::Env);
976
977        let from_xdg = resolve_config_path_with(
978            |name| match name {
979                "XDG_CONFIG_HOME" => Some(OsString::from("/xdg")),
980                _ => None,
981            },
982            Some(PathBuf::from("/Users/alice")),
983        )
984        .expect("xdg should resolve");
985        assert_eq!(from_xdg.path, PathBuf::from("/xdg/vent-mcp/config.toml"));
986        assert_eq!(from_xdg.source, ConfigPathSource::Xdg);
987
988        let from_home = resolve_config_path_with(|_| None, Some(PathBuf::from("/Users/alice")))
989            .expect("home should resolve");
990        assert_eq!(
991            from_home.path,
992            PathBuf::from("/Users/alice/.config/vent-mcp/config.toml")
993        );
994        assert_eq!(from_home.source, ConfigPathSource::Home);
995    }
996
997    /// Verifies validation requires the default channel to be declared.
998    #[test]
999    fn config_validation_rejects_missing_default_channel() {
1000        let raw = r#"
1001default_channel = "missing"
1002
1003[[channels]]
1004name = "general"
1005description = "General feedback."
1006sinks = ["log"]
1007
1008[[sinks]]
1009type = "jsonl"
1010name = "log"
1011"#;
1012
1013        let error = AppConfig::from_toml_str(raw).expect_err("config should fail");
1014        assert!(matches!(
1015            error.into_validation(),
1016            Some(ConfigValidationError::DefaultChannelMustExist { .. })
1017        ));
1018    }
1019
1020    /// Verifies duplicate channel names are rejected.
1021    #[test]
1022    fn config_validation_rejects_duplicate_channels() {
1023        let raw = r#"
1024default_channel = "general"
1025
1026[[channels]]
1027name = "general"
1028description = "General feedback."
1029sinks = ["log"]
1030
1031[[channels]]
1032name = "general"
1033description = "Duplicate feedback."
1034sinks = ["log"]
1035
1036[[sinks]]
1037type = "jsonl"
1038name = "log"
1039"#;
1040
1041        let error = AppConfig::from_toml_str(raw).expect_err("config should fail");
1042        assert!(matches!(
1043            error.into_validation(),
1044            Some(ConfigValidationError::DuplicateChannel { .. })
1045        ));
1046    }
1047
1048    /// Verifies configs must define at least one sink.
1049    #[test]
1050    fn config_validation_rejects_empty_sinks() {
1051        let raw = r#"
1052default_channel = "general"
1053sinks = []
1054
1055[[channels]]
1056name = "general"
1057description = "General feedback."
1058sinks = ["log"]
1059"#;
1060
1061        let error = AppConfig::from_toml_str(raw).expect_err("config should fail");
1062        assert!(matches!(
1063            error.into_validation(),
1064            Some(ConfigValidationError::SinksMustNotBeEmpty)
1065        ));
1066    }
1067
1068    /// Verifies every channel must explicitly route to at least one sink.
1069    #[test]
1070    fn config_validation_rejects_channel_without_sinks() {
1071        let raw = r#"
1072default_channel = "general"
1073
1074[[channels]]
1075name = "general"
1076description = "General feedback."
1077
1078[[sinks]]
1079type = "jsonl"
1080name = "log"
1081"#;
1082
1083        let error = AppConfig::from_toml_str(raw).expect_err("config should fail");
1084        assert!(matches!(
1085            error.into_validation(),
1086            Some(ConfigValidationError::ChannelSinksMustNotBeEmpty { .. })
1087        ));
1088    }
1089
1090    /// Verifies channel routes must reference known sink definitions.
1091    #[test]
1092    fn config_validation_rejects_unknown_channel_sink() {
1093        let raw = r#"
1094default_channel = "general"
1095
1096[[channels]]
1097name = "general"
1098description = "General feedback."
1099sinks = ["missing"]
1100
1101[[sinks]]
1102type = "jsonl"
1103name = "log"
1104"#;
1105
1106        let error = AppConfig::from_toml_str(raw).expect_err("config should fail");
1107        assert!(matches!(
1108            error.into_validation(),
1109            Some(ConfigValidationError::UnknownChannelSink { .. })
1110        ));
1111    }
1112
1113    /// Verifies named sinks are validated and cannot collide.
1114    #[test]
1115    fn config_validation_rejects_duplicate_sink_names() {
1116        let raw = r#"
1117default_channel = "general"
1118
1119[[channels]]
1120name = "general"
1121description = "General feedback."
1122sinks = ["log"]
1123
1124[[sinks]]
1125type = "jsonl"
1126name = "log"
1127
1128[[sinks]]
1129type = "jsonl"
1130name = "log"
1131"#;
1132
1133        let error = AppConfig::from_toml_str(raw).expect_err("config should fail");
1134        assert!(matches!(
1135            error.into_validation(),
1136            Some(ConfigValidationError::DuplicateSinkName { .. })
1137        ));
1138    }
1139
1140    /// Verifies sink definitions must have explicit names for channel routing.
1141    #[test]
1142    fn config_validation_rejects_unnamed_sinks() {
1143        let raw = r#"
1144default_channel = "general"
1145
1146[[channels]]
1147name = "general"
1148description = "General feedback."
1149sinks = ["log"]
1150
1151[[sinks]]
1152type = "jsonl"
1153"#;
1154
1155        let error = AppConfig::from_toml_str(raw).expect_err("config should fail");
1156        assert!(matches!(
1157            error.into_validation(),
1158            Some(ConfigValidationError::InvalidChannelName { field, .. })
1159                if field == "sinks.name"
1160        ));
1161    }
1162
1163    /// Verifies webhook TOML parses headers, provider references, and timeout values.
1164    #[test]
1165    #[cfg(feature = "webhook")]
1166    fn config_parses_webhook_with_env_headers() {
1167        let raw = r#"
1168default_channel = "general"
1169
1170[[channels]]
1171name = "general"
1172description = "General feedback."
1173sinks = ["slack"]
1174
1175[[sinks]]
1176type = "webhook"
1177name = "slack"
1178url = "https://example.com/vent"
1179provider = "slack"
1180timeout_ms = 2500
1181headers = [
1182  { name = "Authorization", env = "VENT_MCP_WEBHOOK_AUTHORIZATION" },
1183]
1184"#;
1185
1186        let config = AppConfig::from_toml_str(raw).expect("webhook config");
1187        assert_eq!(config.sinks.len(), 1);
1188        let SinkConfig::Webhook(webhook) = &config.sinks[0] else {
1189            panic!("expected webhook sink");
1190        };
1191        assert_eq!(webhook.provider.as_deref(), Some("slack"));
1192        assert_eq!(webhook.timeout_ms, 2500);
1193    }
1194
1195    /// Verifies webhook timeouts default to a positive value and reject zero.
1196    #[test]
1197    #[cfg(feature = "webhook")]
1198    fn webhook_timeout_defaults_and_must_be_positive() {
1199        let raw = r#"
1200default_channel = "general"
1201
1202[[channels]]
1203name = "general"
1204description = "General feedback."
1205sinks = ["webhook"]
1206
1207[[sinks]]
1208type = "webhook"
1209name = "webhook"
1210url = "https://example.com/vent"
1211"#;
1212
1213        let config = AppConfig::from_toml_str(raw).expect("webhook config");
1214        let SinkConfig::Webhook(webhook) = &config.sinks[0] else {
1215            panic!("expected webhook sink");
1216        };
1217        assert_eq!(webhook.timeout_ms, super::DEFAULT_WEBHOOK_TIMEOUT_MS);
1218
1219        let invalid = r#"
1220default_channel = "general"
1221
1222[[channels]]
1223name = "general"
1224description = "General feedback."
1225sinks = ["webhook"]
1226
1227[[sinks]]
1228type = "webhook"
1229name = "webhook"
1230url = "https://example.com/vent"
1231timeout_ms = 0
1232"#;
1233        let error = AppConfig::from_toml_str(invalid).expect_err("timeout should fail");
1234        assert!(matches!(
1235            error.into_validation(),
1236            Some(ConfigValidationError::WebhookTimeoutMsMustBePositive)
1237        ));
1238    }
1239
1240    /// Verifies provider mappings must reference known fields, paths, and providers.
1241    #[test]
1242    #[cfg(feature = "webhook")]
1243    fn webhook_provider_config_validates_sink_references_and_paths() {
1244        let raw = r#"
1245default_channel = "general"
1246
1247[[channels]]
1248name = "general"
1249description = "General feedback."
1250sinks = ["webhook"]
1251
1252[providers.custom]
1253message = "data.content.message"
1254channel = "data.content.channel"
1255
1256[[sinks]]
1257type = "webhook"
1258name = "webhook"
1259url = "https://example.com/vent"
1260provider = "custom"
1261"#;
1262
1263        let config = AppConfig::from_toml_str(raw).expect("provider config");
1264        assert!(config.providers.contains_key("custom"));
1265
1266        let missing = r#"
1267default_channel = "general"
1268
1269[[channels]]
1270name = "general"
1271description = "General feedback."
1272sinks = ["webhook"]
1273
1274[[sinks]]
1275type = "webhook"
1276name = "webhook"
1277url = "https://example.com/vent"
1278provider = "missing"
1279"#;
1280        let error = AppConfig::from_toml_str(missing).expect_err("provider should fail");
1281        assert!(matches!(
1282            error.into_validation(),
1283            Some(ConfigValidationError::UnknownWebhookProvider { .. })
1284        ));
1285
1286        let invalid_field = r#"
1287default_channel = "general"
1288
1289[[channels]]
1290name = "general"
1291description = "General feedback."
1292sinks = ["log"]
1293
1294[providers.custom]
1295unknown = "data.content"
1296
1297[[sinks]]
1298type = "jsonl"
1299name = "log"
1300"#;
1301        let error = AppConfig::from_toml_str(invalid_field).expect_err("field should fail");
1302        assert!(matches!(
1303            error.into_validation(),
1304            Some(ConfigValidationError::UnknownWebhookProviderField { .. })
1305        ));
1306
1307        let invalid_path = r#"
1308default_channel = "general"
1309
1310[[channels]]
1311name = "general"
1312description = "General feedback."
1313sinks = ["log"]
1314
1315[providers.custom]
1316message = "data..content"
1317
1318[[sinks]]
1319type = "jsonl"
1320name = "log"
1321"#;
1322        let error = AppConfig::from_toml_str(invalid_path).expect_err("path should fail");
1323        assert!(matches!(
1324            error.into_validation(),
1325            Some(ConfigValidationError::InvalidWebhookProviderPath { .. })
1326        ));
1327
1328        let colliding_path = r#"
1329default_channel = "general"
1330
1331[[channels]]
1332name = "general"
1333description = "General feedback."
1334sinks = ["log"]
1335
1336[providers.bad]
1337message = "payload.body"
1338project = "payload"
1339
1340[[sinks]]
1341type = "jsonl"
1342name = "log"
1343"#;
1344        let error =
1345            AppConfig::from_toml_str(colliding_path).expect_err("colliding path should fail");
1346        assert!(matches!(
1347            error.into_validation(),
1348            Some(ConfigValidationError::CollidingWebhookProviderPath { .. })
1349        ));
1350
1351        let oversized_index = r#"
1352default_channel = "general"
1353
1354[[channels]]
1355name = "general"
1356description = "General feedback."
1357sinks = ["log"]
1358
1359[providers.bad]
1360message = "items.5000000"
1361
1362[[sinks]]
1363type = "jsonl"
1364name = "log"
1365"#;
1366        let error =
1367            AppConfig::from_toml_str(oversized_index).expect_err("oversized index should fail");
1368        assert!(matches!(
1369            error.into_validation(),
1370            Some(ConfigValidationError::InvalidWebhookProviderPath { .. })
1371        ));
1372
1373        let overflowing_index = r#"
1374default_channel = "general"
1375
1376[[channels]]
1377name = "general"
1378description = "General feedback."
1379sinks = ["log"]
1380
1381[providers.bad]
1382message = "items.999999999999999999999999999999999999999999"
1383
1384[[sinks]]
1385type = "jsonl"
1386name = "log"
1387"#;
1388        let error =
1389            AppConfig::from_toml_str(overflowing_index).expect_err("overflowing index should fail");
1390        assert!(matches!(
1391            error.into_validation(),
1392            Some(ConfigValidationError::InvalidWebhookProviderPath { .. })
1393        ));
1394
1395        let overflowing_root_index = r#"
1396default_channel = "general"
1397
1398[[channels]]
1399name = "general"
1400description = "General feedback."
1401sinks = ["log"]
1402
1403[providers.bad]
1404message = "999999999999999999999999999999999999999999"
1405
1406[[sinks]]
1407type = "jsonl"
1408name = "log"
1409"#;
1410        let error = AppConfig::from_toml_str(overflowing_root_index)
1411            .expect_err("overflowing root index should fail");
1412        assert!(matches!(
1413            error.into_validation(),
1414            Some(ConfigValidationError::InvalidWebhookProviderPath { .. })
1415        ));
1416    }
1417
1418    /// Rejects channels that would double-write the shared JSONL log file.
1419    #[test]
1420    fn channel_with_multiple_jsonl_sinks_is_rejected() {
1421        let config = r#"
1422default_channel = "feedback"
1423
1424[[channels]]
1425name = "feedback"
1426description = "Feedback."
1427sinks = ["log", "audit"]
1428
1429[[sinks]]
1430type = "jsonl"
1431name = "log"
1432
1433[[sinks]]
1434type = "jsonl"
1435name = "audit"
1436"#;
1437        let error =
1438            AppConfig::from_toml_str(config).expect_err("duplicate jsonl sinks should fail");
1439        assert!(matches!(
1440            error.into_validation(),
1441            Some(ConfigValidationError::MultipleJsonlSinksInChannel { .. })
1442        ));
1443    }
1444
1445    /// Empty or omitted jsonl_dir anchors JSONL output beside the config file.
1446    #[test]
1447    fn empty_jsonl_dir_falls_back_to_config_dir() {
1448        let config_dir = PathBuf::from("/home/user/.config/vent-mcp");
1449
1450        for value in [None, Some(String::new()), Some("   ".to_string())] {
1451            let logging = super::LoggingConfig { jsonl_dir: value };
1452            assert_eq!(
1453                super::resolve_jsonl_dir(&logging, &config_dir).expect("should resolve"),
1454                config_dir,
1455                "empty/omitted jsonl_dir should anchor to the config directory"
1456            );
1457        }
1458
1459        let explicit = super::LoggingConfig {
1460            jsonl_dir: Some("/var/log/vent".to_string()),
1461        };
1462        assert_eq!(
1463            super::resolve_jsonl_dir(&explicit, &config_dir).expect("should resolve"),
1464            PathBuf::from("/var/log/vent")
1465        );
1466    }
1467
1468    /// Tilde jsonl_dir paths fail closed when HOME cannot be resolved.
1469    #[test]
1470    fn tilde_expansion_without_home_errors() {
1471        let home = Some(PathBuf::from("/home/user"));
1472        assert_eq!(
1473            super::expand_tilde_with("~/.local/state/vent-mcp", home.clone()).expect("has home"),
1474            PathBuf::from("/home/user/.local/state/vent-mcp")
1475        );
1476        assert_eq!(
1477            super::expand_tilde_with("~", home).expect("has home"),
1478            PathBuf::from("/home/user")
1479        );
1480
1481        assert!(super::expand_tilde_with("~/.local/state/vent-mcp", None).is_err());
1482        assert!(super::expand_tilde_with("~", None).is_err());
1483        assert_eq!(
1484            super::expand_tilde_with("/var/log/vent", None).expect("absolute"),
1485            PathBuf::from("/var/log/vent")
1486        );
1487    }
1488
1489    /// Verifies implicit default config paths are created on first load.
1490    #[test]
1491    fn load_from_default_path_creates_default_config() {
1492        let dir = tempdir().expect("temp dir");
1493        let path = dir.path().join("vent-mcp").join("config.toml");
1494
1495        let loaded = LoadedConfig::load_from_resolved_path(super::ResolvedConfigPath {
1496            path: path.clone(),
1497            source: ConfigPathSource::Home,
1498        })
1499        .expect("default config should be created");
1500
1501        assert!(path.exists());
1502        assert_eq!(loaded.config().default_channel(), "feedback");
1503    }
1504
1505    /// Verifies the default config ships the broad webhook provider set.
1506    #[test]
1507    fn default_config_includes_common_webhook_provider_maps() {
1508        let config = AppConfig::default();
1509        let expected = [
1510            "zapier",
1511            "make",
1512            "n8n",
1513            "pipedream",
1514            "workato",
1515            "ifttt",
1516            "slack",
1517            "mattermost",
1518            "discord",
1519            "microsoft_teams",
1520            "google_chat",
1521            "webex",
1522        ];
1523
1524        for provider in expected {
1525            assert!(
1526                config.providers.contains_key(provider),
1527                "missing default provider {provider}"
1528            );
1529        }
1530        assert_eq!(
1531            config.providers["microsoft_teams"].fields["message"],
1532            "text"
1533        );
1534        assert_eq!(config.providers["ifttt"].fields["message"], "value1");
1535        assert_eq!(config.channels[0].sinks, [super::DEFAULT_LOG_SINK_NAME]);
1536        assert_eq!(config.sinks[0].name(), super::DEFAULT_LOG_SINK_NAME);
1537        assert!(!config.providers["discord"].fields.contains_key("channel"));
1538        assert!(!config.providers["slack"].fields.contains_key("channel"));
1539    }
1540
1541    trait ConfigErrorExt {
1542        /// Extracts validation errors from the top-level config error wrapper.
1543        fn into_validation(self) -> Option<ConfigValidationError>;
1544    }
1545
1546    impl ConfigErrorExt for super::ConfigError {
1547        /// Returns the inner validation error when this is a validation failure.
1548        fn into_validation(self) -> Option<ConfigValidationError> {
1549            match self {
1550                super::ConfigError::Validation(error) => Some(error),
1551                _ => None,
1552            }
1553        }
1554    }
1555}