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