Skip to main content

tuitbot_core/toolkit/
mod.rs

1//! Stateless X API utility toolkit.
2//!
3//! Provides pure, stateless functions for all X API operations.
4//! Each function takes `&dyn XApiClient` and operation-specific parameters,
5//! performs input validation, and delegates to the client trait.
6//!
7//! No policy enforcement, audit logging, rate limiting, or DB access here.
8//! Those concerns belong in the workflow layer (AD-04, AD-12).
9
10pub mod engage;
11pub mod media;
12pub mod read;
13pub mod write;
14
15#[cfg(test)]
16mod e2e_tests;
17
18use crate::error::XApiError;
19
20/// Maximum tweet length enforced by the X API.
21pub const MAX_TWEET_LENGTH: usize = 280;
22
23/// Errors from toolkit operations.
24///
25/// Maps to existing `ErrorCode` variants in MCP responses (AD-10).
26/// Stateless checks live here; stateful checks in the workflow layer (AD-12).
27#[derive(Debug, thiserror::Error)]
28pub enum ToolkitError {
29    /// Underlying X API error (passthrough).
30    #[error(transparent)]
31    XApi(#[from] XApiError),
32
33    /// Invalid input parameter.
34    #[error("invalid input: {message}")]
35    InvalidInput { message: String },
36
37    /// Tweet text exceeds the maximum length.
38    #[error("tweet too long: {length} characters (max {max})")]
39    TweetTooLong { length: usize, max: usize },
40
41    /// File extension does not map to a supported media type.
42    #[error("unsupported media type for file: {path}")]
43    UnsupportedMediaType { path: String },
44
45    /// Media data exceeds the size limit for its type.
46    #[error("media too large: {size} bytes (max {max} for {media_type})")]
47    MediaTooLarge {
48        size: u64,
49        max: u64,
50        media_type: String,
51    },
52
53    /// Thread posting failed partway through.
54    #[error("thread failed at tweet {failed_index}: posted {posted}/{total} tweets")]
55    ThreadPartialFailure {
56        /// IDs of tweets successfully posted before the failure.
57        posted_ids: Vec<String>,
58        /// Zero-based index of the tweet that failed.
59        failed_index: usize,
60        /// Count of successfully posted tweets.
61        posted: usize,
62        /// Total tweets in the thread.
63        total: usize,
64        /// The underlying X API error.
65        #[source]
66        source: Box<XApiError>,
67    },
68}
69
70/// Validate tweet text length (stateless check).
71pub fn validate_tweet_length(text: &str) -> Result<(), ToolkitError> {
72    if text.len() > MAX_TWEET_LENGTH {
73        return Err(ToolkitError::TweetTooLong {
74            length: text.len(),
75            max: MAX_TWEET_LENGTH,
76        });
77    }
78    Ok(())
79}
80
81/// Validate that a string ID parameter is non-empty.
82fn validate_id(id: &str, name: &str) -> Result<(), ToolkitError> {
83    if id.is_empty() {
84        return Err(ToolkitError::InvalidInput {
85            message: format!("{name} must not be empty"),
86        });
87    }
88    Ok(())
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn validate_tweet_length_ok() {
97        assert!(validate_tweet_length("Hello world").is_ok());
98    }
99
100    #[test]
101    fn validate_tweet_length_exactly_280() {
102        let text = "a".repeat(280);
103        assert!(validate_tweet_length(&text).is_ok());
104    }
105
106    #[test]
107    fn validate_tweet_length_too_long() {
108        let text = "a".repeat(281);
109        let err = validate_tweet_length(&text).unwrap_err();
110        assert!(matches!(
111            err,
112            ToolkitError::TweetTooLong {
113                length: 281,
114                max: 280
115            }
116        ));
117    }
118
119    #[test]
120    fn validate_id_ok() {
121        assert!(validate_id("123", "tweet_id").is_ok());
122    }
123
124    #[test]
125    fn validate_id_empty() {
126        let err = validate_id("", "tweet_id").unwrap_err();
127        assert!(matches!(err, ToolkitError::InvalidInput { .. }));
128    }
129
130    #[test]
131    fn toolkit_error_display() {
132        let err = ToolkitError::TweetTooLong {
133            length: 300,
134            max: 280,
135        };
136        assert_eq!(err.to_string(), "tweet too long: 300 characters (max 280)");
137    }
138
139    #[test]
140    fn toolkit_error_from_x_api() {
141        let xe = XApiError::ApiError {
142            status: 404,
143            message: "Not found".to_string(),
144        };
145        let te: ToolkitError = xe.into();
146        assert!(matches!(te, ToolkitError::XApi(_)));
147    }
148}