1use 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#[derive(Debug, Clone, PartialEq)]
30pub struct LoadedConfig {
31 path: PathBuf,
32 config: RuntimeConfig,
33}
34
35impl LoadedConfig {
36 pub fn load() -> Result<Self, ConfigError> {
41 let resolved = resolve_config_path()?;
42 Self::load_from_resolved_path(resolved)
43 }
44
45 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 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 #[must_use]
81 pub fn config(&self) -> &RuntimeConfig {
82 &self.config
83 }
84
85 #[must_use]
87 pub fn into_config(self) -> RuntimeConfig {
88 self.config
89 }
90
91 #[must_use]
95 pub fn config_dir(&self) -> PathBuf {
96 config_dir_for_path(&self.path)
97 }
98}
99
100#[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 #[must_use]
155 pub fn default_channel(&self) -> &str {
156 &self.default_channel
157 }
158
159 #[must_use]
161 pub fn has_channel(&self, name: &str) -> bool {
162 self.channels.iter().any(|channel| channel.name == name)
163 }
164
165 #[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 #[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#[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 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 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 #[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 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 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#[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#[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#[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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
449#[serde(default, deny_unknown_fields)]
450pub struct JsonlSinkConfig {
451 pub name: String,
452}
453
454#[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 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 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#[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 fn validate(&self, provider_name: &str) -> Result<(), ConfigValidationError> {
560 ProviderTemplate::compile(provider_name, self).map(|_| ())
561 }
562}
563
564#[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#[derive(Debug, Clone, PartialEq, Eq)]
575pub struct ResolvedConfigPath {
576 pub path: PathBuf,
577 pub source: ConfigPathSource,
578}
579
580#[derive(Debug, Clone, Copy, PartialEq, Eq)]
582pub enum ConfigPathSource {
583 Env,
584 Xdg,
585 Home,
586}
587
588#[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#[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
692pub 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
704pub 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
742fn 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 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 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
811fn 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
833fn 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
845fn 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
873fn 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
887fn 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
895fn 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
910fn slack_like_provider() -> WebhookProviderConfig {
912 labeled_context_provider("title", "text", "attachments.0.fields.0.value")
913}
914
915fn 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
927fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 fn into_validation(self) -> Option<ConfigValidationError>;
1544 }
1545
1546 impl ConfigErrorExt for super::ConfigError {
1547 fn into_validation(self) -> Option<ConfigValidationError> {
1549 match self {
1550 super::ConfigError::Validation(error) => Some(error),
1551 _ => None,
1552 }
1553 }
1554 }
1555}