1use std::env;
11
12use chrono::{DateTime, Utc};
13use rmcp::schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use uuid::Uuid;
16
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
19pub struct ChannelInfo {
20 pub name: String,
21 pub description: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
26#[serde(rename_all = "camelCase")]
27pub struct ListChannelsOutput {
28 pub default_channel: String,
29 pub channels: Vec<ChannelInfo>,
30}
31
32#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)]
34pub struct VentInput {
35 pub message: String,
36 pub channel: Option<String>,
37}
38
39#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)]
41pub struct VentDefaultChannelInput {
42 pub message: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
47#[serde(rename_all = "camelCase")]
48pub struct VentOutput {
49 pub ok: bool,
50 pub event_id: String,
52 pub channel: String,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub error: Option<String>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
59pub struct SinkDeliveryStatus {
60 pub sink: String,
61 pub ok: bool,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub message: Option<String>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
68#[serde(transparent)]
69pub struct EventId(String);
70
71impl EventId {
72 #[must_use]
73 fn new_random() -> Self {
74 Self(short_event_id())
75 }
76
77 #[cfg(test)]
78 #[must_use]
79 fn as_str(&self) -> &str {
80 &self.0
81 }
82}
83
84impl std::fmt::Display for EventId {
85 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 formatter.write_str(&self.0)
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
92pub struct VentEvent {
93 pub id: EventId,
94 pub timestamp: DateTime<Utc>,
95 pub channel: String,
96 pub message: String,
97 pub project: String,
98}
99
100impl VentEvent {
101 #[must_use]
107 pub fn new(channel: String, message: String, project: String) -> Self {
108 Self {
109 id: EventId::new_random(),
110 timestamp: Utc::now(),
111 channel,
112 message,
113 project,
114 }
115 }
116}
117
118#[must_use]
120pub fn first_delivery_error(statuses: &[SinkDeliveryStatus]) -> Option<String> {
121 statuses.iter().find(|status| !status.ok).map(|status| {
122 status
123 .message
124 .clone()
125 .unwrap_or_else(|| format!("{} failed", status.sink))
126 })
127}
128
129fn short_event_id() -> String {
130 const ALPHABET: &[u8; 62] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
131 const ID_LEN: usize = 8;
132
133 let mut value = Uuid::new_v4().as_u128();
134 let mut id = String::with_capacity(ID_LEN);
135 for _ in 0..ID_LEN {
136 let index = (value % ALPHABET.len() as u128) as usize;
137 id.push(ALPHABET[index] as char);
138 value /= ALPHABET.len() as u128;
139 }
140 id
141}
142
143#[must_use]
149pub fn project_name_from_current_dir() -> String {
150 env::current_dir()
151 .ok()
152 .and_then(|path| {
153 path.file_name()
154 .map(|name| name.to_string_lossy().to_string())
155 })
156 .filter(|name| !name.trim().is_empty())
157 .unwrap_or_else(|| "unknown".to_string())
158}
159
160#[cfg(test)]
161mod tests {
162 use super::VentEvent;
163
164 #[test]
165 fn event_ids_are_short_base62_strings() {
166 let event = VentEvent::new(
167 "general".to_string(),
168 "Something happened.".to_string(),
169 "vent-mcp".to_string(),
170 );
171
172 assert_eq!(event.id.as_str().len(), 8);
173 assert!(event
174 .id
175 .as_str()
176 .chars()
177 .all(|character| character.is_ascii_alphanumeric()));
178 }
179}