use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::{HostProxy, NOTIFICATION_TOOLS_PROGRESS, NOTIFICATION_WIDGETS_UPDATED};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ProgressContent {
#[serde(rename = "text")]
Text { text: String },
#[serde(rename = "markdown")]
Markdown { text: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolProgressParams {
pub progress_token: String,
pub progress: u64,
#[serde(default)]
pub total: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<Vec<ProgressContent>>,
}
#[derive(Clone)]
pub struct ProgressReporter {
host: HostProxy,
progress_token: String,
cancelled: Arc<AtomicBool>,
}
impl ProgressReporter {
pub fn new(host: HostProxy, progress_token: String) -> Self {
Self {
host,
progress_token,
cancelled: Arc::new(AtomicBool::new(false)),
}
}
pub async fn report(
&self,
progress: u64,
total: u64,
phase: Option<&str>,
content: Option<Vec<ProgressContent>>,
) -> crate::Result<()> {
let params = ToolProgressParams {
progress_token: self.progress_token.clone(),
progress,
total,
phase: phase.map(String::from),
content,
};
self.host
.notify(NOTIFICATION_TOOLS_PROGRESS, serde_json::to_value(¶ms)?)
.await
}
pub async fn report_text(
&self,
progress: u64,
total: u64,
phase: &str,
text: &str,
) -> crate::Result<()> {
self.report(
progress,
total,
Some(phase),
Some(vec![ProgressContent::Text {
text: text.to_string(),
}]),
)
.await
}
pub async fn update_widget(&self, widget_id: &str, data: Value) -> crate::Result<()> {
self.host
.notify(
NOTIFICATION_WIDGETS_UPDATED,
serde_json::json!({
"widget_id": widget_id,
"data": data,
}),
)
.await
}
pub fn is_cancelled(&self) -> bool {
self.cancelled.load(Ordering::Relaxed)
}
#[allow(dead_code, reason = "wired when cancellation routing is implemented")]
pub(crate) fn mark_cancelled(&self) {
self.cancelled.store(true, Ordering::Relaxed);
}
pub fn progress_token(&self) -> &str {
&self.progress_token
}
#[allow(dead_code, reason = "wired when cancellation routing is implemented")]
pub(crate) fn cancelled_flag(&self) -> Arc<AtomicBool> {
self.cancelled.clone()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CancelledParams {
pub request_id: Value,
#[serde(default = "default_reason")]
pub reason: String,
}
fn default_reason() -> String {
"user_cancelled".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tokio::sync::{Mutex, mpsc};
fn make_host_proxy() -> (HostProxy, mpsc::Receiver<String>) {
let (tx, rx) = mpsc::channel(16);
let pending = Arc::new(Mutex::new(HashMap::new()));
let proxy = HostProxy::new(tx, pending);
(proxy, rx)
}
#[test]
fn test_progress_params_roundtrip() {
let params = ToolProgressParams {
progress_token: "p-42".to_string(),
progress: 30,
total: 100,
phase: Some("scanning files".to_string()),
content: Some(vec![ProgressContent::Text {
text: "Found 247 Rust files...".to_string(),
}]),
};
let json = serde_json::to_string(¶ms).unwrap();
let parsed: ToolProgressParams = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.progress_token, "p-42");
assert_eq!(parsed.progress, 30);
assert_eq!(parsed.total, 100);
assert_eq!(parsed.phase.as_deref(), Some("scanning files"));
assert!(parsed.content.is_some());
}
#[test]
fn test_progress_params_minimal() {
let json = r#"{"progress_token":"p-1","progress":50}"#;
let parsed: ToolProgressParams = serde_json::from_str(json).unwrap();
assert_eq!(parsed.progress, 50);
assert_eq!(parsed.total, 0);
assert!(parsed.phase.is_none());
assert!(parsed.content.is_none());
}
#[test]
fn test_progress_content_text() {
let content = ProgressContent::Text {
text: "Processing...".to_string(),
};
let json = serde_json::to_string(&content).unwrap();
assert!(json.contains(r#""type":"text"#));
}
#[test]
fn test_progress_content_markdown() {
let content = ProgressContent::Markdown {
text: "# Results\n- Item 1".to_string(),
};
let json = serde_json::to_string(&content).unwrap();
assert!(json.contains(r#""type":"markdown"#));
}
#[test]
fn test_cancelled_params_roundtrip() {
let params = CancelledParams {
request_id: serde_json::json!(42),
reason: "user_cancelled".to_string(),
};
let json = serde_json::to_string(¶ms).unwrap();
let parsed: CancelledParams = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.request_id, 42);
assert_eq!(parsed.reason, "user_cancelled");
}
#[test]
fn test_cancellation_flag() {
let (proxy, _rx) = make_host_proxy();
let reporter = ProgressReporter::new(proxy, "p-1".to_string());
assert!(!reporter.is_cancelled());
reporter.mark_cancelled();
assert!(reporter.is_cancelled());
}
#[test]
fn test_cancellation_flag_shared() {
let (proxy, _rx) = make_host_proxy();
let reporter = ProgressReporter::new(proxy, "p-1".to_string());
let flag = reporter.cancelled_flag();
assert!(!reporter.is_cancelled());
flag.store(true, Ordering::Relaxed);
assert!(reporter.is_cancelled());
}
#[tokio::test]
async fn test_progress_reporter_report() {
let (proxy, mut rx) = make_host_proxy();
let reporter = ProgressReporter::new(proxy, "p-42".to_string());
reporter
.report(
10,
100,
Some("scanning"),
Some(vec![ProgressContent::Text {
text: "Found 10 files".to_string(),
}]),
)
.await
.unwrap();
let msg = rx.recv().await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
assert_eq!(parsed["method"], "notifications/tools/progress");
assert_eq!(parsed["params"]["progress_token"], "p-42");
assert_eq!(parsed["params"]["progress"], 10);
assert_eq!(parsed["params"]["total"], 100);
assert_eq!(parsed["params"]["phase"], "scanning");
}
#[tokio::test]
async fn test_progress_reporter_report_text() {
let (proxy, mut rx) = make_host_proxy();
let reporter = ProgressReporter::new(proxy, "p-1".to_string());
reporter
.report_text(50, 100, "processing", "Half done")
.await
.unwrap();
let msg = rx.recv().await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
assert_eq!(parsed["params"]["progress"], 50);
assert_eq!(parsed["params"]["content"][0]["type"], "text");
assert_eq!(parsed["params"]["content"][0]["text"], "Half done");
}
#[tokio::test]
async fn test_progress_reporter_update_widget() {
let (proxy, mut rx) = make_host_proxy();
let reporter = ProgressReporter::new(proxy, "p-1".to_string());
reporter
.update_widget("analysis", serde_json::json!({"status": "running"}))
.await
.unwrap();
let msg = rx.recv().await.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&msg).unwrap();
assert_eq!(parsed["method"], "notifications/widgets/updated");
assert_eq!(parsed["params"]["widget_id"], "analysis");
assert_eq!(parsed["params"]["data"]["status"], "running");
}
}