Skip to main content

wowlab_sentinel/mcp/tools/
rotations.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use sqlx::PgPool;
4
5const DEFAULT_LIMIT: i64 = 20;
6const MAX_LIMIT: i64 = 50;
7
8/// Input for fetching a single rotation by ID.
9#[derive(Debug, Default, Deserialize, JsonSchema)]
10#[schemars(description = "Fetch a rotation script by ID, optionally at a specific version")]
11pub(super) struct GetRotationInput {
12    #[schemars(description = "Rotation UUID (from `list_rotations`).")]
13    pub id: String,
14
15    #[serde(default)]
16    #[schemars(description = "Version number to fetch. Omit for the current/latest version.")]
17    pub version: Option<i32>,
18}
19
20/// Input for listing/searching public rotations.
21#[derive(Debug, Default, Deserialize, JsonSchema)]
22#[schemars(description = "List accessible rotation scripts with optional filters")]
23pub(super) struct ListRotationsInput {
24    #[serde(default)]
25    #[schemars(description = "Filter by spec ID (e.g. 262 for Enhancement Shaman).")]
26    pub spec_id: Option<i32>,
27
28    #[serde(default)]
29    #[schemars(description = "Search rotation names (case-insensitive substring match).")]
30    pub name: Option<String>,
31
32    #[serde(default)]
33    #[schemars(description = "Max results to return. Default: 20, max: 50.")]
34    pub limit: Option<i64>,
35
36    #[serde(default)]
37    #[schemars(description = "Offset for pagination.")]
38    pub offset: Option<i64>,
39}
40
41#[derive(Debug, JsonSchema, Serialize, sqlx::FromRow)]
42pub(super) struct RotationRow {
43    #[schemars(with = "String")]
44    id: uuid::Uuid,
45    slug: String,
46    name: String,
47    script: serde_json::Value,
48    description: Option<String>,
49    spec_id: i32,
50    current_version: i32,
51    #[schemars(with = "Option<String>")]
52    forked_from_id: Option<uuid::Uuid>,
53    created_at: chrono::DateTime<chrono::Utc>,
54    updated_at: chrono::DateTime<chrono::Utc>,
55}
56
57#[derive(Debug, JsonSchema, Serialize, sqlx::FromRow)]
58pub(super) struct RotationListRow {
59    #[schemars(with = "String")]
60    id: uuid::Uuid,
61    slug: String,
62    name: String,
63    description: Option<String>,
64    spec_id: i32,
65    spec_name: Option<String>,
66    class_name: Option<String>,
67    current_version: i32,
68    created_at: chrono::DateTime<chrono::Utc>,
69    updated_at: chrono::DateTime<chrono::Utc>,
70}
71
72#[derive(Debug, JsonSchema, Serialize, sqlx::FromRow)]
73pub(super) struct RotationVersionRow {
74    #[schemars(with = "String")]
75    id: uuid::Uuid,
76    slug: String,
77    name: String,
78    script: serde_json::Value,
79    description: Option<String>,
80    spec_id: i32,
81    version: i32,
82    message: Option<String>,
83    created_at: chrono::DateTime<chrono::Utc>,
84}
85
86pub(super) async fn handle_get(
87    db: &PgPool,
88    params: GetRotationInput,
89) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
90    Ok(crate::mcp::tool_result(
91        crate::telemetry::McpTool::GetRotation,
92        execute_get(db, params).await,
93    ))
94}
95
96pub(super) async fn handle_list(
97    db: &PgPool,
98    params: ListRotationsInput,
99) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
100    Ok(crate::mcp::tool_result(
101        crate::telemetry::McpTool::ListRotations,
102        execute_list(db, params).await,
103    ))
104}
105
106#[derive(Debug, JsonSchema, Serialize)]
107#[serde(untagged)]
108pub(super) enum GetRotationOutput {
109    Current(RotationRow),
110    Version(RotationVersionRow),
111}
112
113async fn execute_get(
114    db: &PgPool,
115    input: GetRotationInput,
116) -> Result<GetRotationOutput, super::ToolError> {
117    let id = input.id.parse::<uuid::Uuid>().map_err(|error| {
118        super::ToolError::invalid_params(format!("Invalid UUID for `id`: {error}"))
119    })?;
120    let user_id = crate::mcp::current_user_id();
121
122    if let Some(version) = input.version {
123        let row = sqlx::query_file_as!(
124            RotationVersionRow,
125            "queries/mcp_get_rotation_version_by_id.sql",
126            id,
127            version,
128            user_id
129        )
130        .fetch_optional(db)
131        .await
132        .map_err(|error| super::ToolError::database("Failed to fetch rotation version", error))?;
133
134        match row {
135            Some(row) => Ok(GetRotationOutput::Version(row)),
136            None => Err(super::ToolError::invalid_params("Rotation not found")),
137        }
138    } else {
139        let row = sqlx::query_file_as!(
140            RotationRow,
141            "queries/mcp_get_rotation_by_id.sql",
142            id,
143            user_id
144        )
145        .fetch_optional(db)
146        .await
147        .map_err(|error| super::ToolError::database("Failed to fetch rotation", error))?;
148
149        match row {
150            Some(row) => Ok(GetRotationOutput::Current(row)),
151            None => Err(super::ToolError::invalid_params("Rotation not found")),
152        }
153    }
154}
155
156async fn execute_list(
157    db: &PgPool,
158    input: ListRotationsInput,
159) -> Result<Vec<RotationListRow>, super::ToolError> {
160    let user_id = crate::mcp::current_user_id();
161    let limit = input.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
162    let offset = input.offset.unwrap_or(0).max(0);
163
164    let rows = sqlx::query_file_as!(
165        RotationListRow,
166        "queries/mcp_list_rotations.sql",
167        input.spec_id,
168        input.name.as_deref(),
169        limit,
170        offset,
171        user_id
172    )
173    .fetch_all(db)
174    .await
175    .map_err(|error| super::ToolError::database("Failed to list rotations", error))?;
176
177    Ok(rows)
178}