Skip to main content

vent_mcp/
delivery.rs

1//! Shared delivery service used by MCP tools and CLI mode.
2//!
3//! This module owns the request policy that used to be split across adapters:
4//! trim input, choose the default channel, reject unknown channels, construct one
5//! event, dispatch it, and reduce sink statuses to the terse acknowledgement the
6//! caller receives.
7
8use std::sync::Arc;
9
10use crate::config::RuntimeConfig;
11use crate::sinks::SinkDispatcher;
12use crate::types::{first_delivery_error, ListChannelsOutput, VentEvent, VentOutput};
13
14/// Trimmed vent message and optional channel override for delivery.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct VentRequest {
17    pub message: String,
18    pub channel: Option<String>,
19}
20
21/// Validates vent input and dispatches one event through configured channel sinks.
22#[derive(Clone)]
23pub struct VentService {
24    config: Arc<RuntimeConfig>,
25    dispatcher: SinkDispatcher,
26    project: Arc<String>,
27}
28
29impl VentService {
30    /// Builds a service from validated runtime config and a project directory label.
31    #[must_use]
32    pub fn new(config: RuntimeConfig, project: String) -> Self {
33        let dispatcher = SinkDispatcher::new(Arc::new(config));
34        let config = dispatcher.config();
35        Self {
36            config,
37            dispatcher,
38            project: Arc::new(project),
39        }
40    }
41
42    #[must_use]
43    pub(crate) fn config(&self) -> &RuntimeConfig {
44        &self.config
45    }
46
47    /// Returns configured channels and the default channel name.
48    #[must_use]
49    pub fn list_channels(&self) -> ListChannelsOutput {
50        self.config.channel_list()
51    }
52
53    /// Trims input, enforces channel policy, dispatches one event, and reports the first sink failure.
54    pub async fn send(&self, request: VentRequest) -> VentOutput {
55        let message = request.message.trim();
56        if message.is_empty() {
57            return failure_output(
58                self.config.default_channel().to_string(),
59                "message must not be empty".to_string(),
60            );
61        }
62
63        let channel = request
64            .channel
65            .as_deref()
66            .map(str::trim)
67            .filter(|channel| !channel.is_empty())
68            .unwrap_or_else(|| self.config.default_channel());
69
70        if !self.config.has_channel(channel) {
71            return failure_output(channel.to_string(), format!("unknown channel: {channel}"));
72        }
73
74        let event = VentEvent::new(
75            channel.to_string(),
76            message.to_string(),
77            self.project.as_ref().clone(),
78        );
79        let statuses = self.dispatcher.dispatch(&event).await;
80        let ok = statuses.iter().all(|status| status.ok);
81
82        VentOutput {
83            ok,
84            event_id: event.id.to_string(),
85            channel: event.channel,
86            error: first_delivery_error(&statuses),
87        }
88    }
89}
90
91fn failure_output(channel: String, message: String) -> VentOutput {
92    VentOutput {
93        ok: false,
94        event_id: String::new(),
95        channel,
96        error: Some(message),
97    }
98}