catenary-mcp 1.5.0

A high-performance multiplexing bridge between MCP (Model Context Protocol) and LSP (Language Server Protocol). Enables LLMs to access IDE-grade code intelligence across multiple languages simultaneously with smart routing and UTF-8 accuracy.
Documentation
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 Mark Wells <contact@markwells.dev>

//! Server state and progress tracking types.

use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use std::time::Instant;
use tracing::warn;

/// Token type for progress tracking.
pub type ProgressToken = String;

/// State of an active progress operation.
#[derive(Debug, Clone)]
pub struct ProgressState {
    /// The title of the progress operation.
    pub title: String,
    /// The optional progress message.
    pub message: Option<String>,
    /// The optional progress percentage (0-100).
    pub percentage: Option<u32>,
    /// When the operation started.
    pub started: Instant,
}

/// Overall server readiness state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ServerState {
    /// Server just spawned, may be initializing.
    Initializing,
    /// Server is busy (processing progress, workspace changes, etc.).
    Busy,
    /// Server ready to handle requests.
    Ready,
    /// Server connection lost.
    Dead,
    /// Server exhausted patience threshold but process is still alive.
    Stuck,
}

impl ServerState {
    /// Create from atomic u8 value.
    #[must_use]
    pub const fn from_u8(value: u8) -> Self {
        match value {
            0 => Self::Initializing,
            1 => Self::Busy,
            2 => Self::Ready,
            4 => Self::Stuck,
            _ => Self::Dead,
        }
    }

    /// Convert to atomic u8 value.
    #[must_use]
    pub const fn as_u8(self) -> u8 {
        match self {
            Self::Initializing => 0,
            Self::Busy => 1,
            Self::Ready => 2,
            Self::Dead => 3,
            Self::Stuck => 4,
        }
    }
}

/// Detailed status for a single LSP server.
#[derive(Debug, Clone, Serialize)]
pub struct ServerStatus {
    /// The language ID this server handles.
    pub language: String,
    /// Current server readiness state.
    pub state: ServerState,
    /// Active progress title, if any.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub progress_title: Option<String>,
    /// Active progress message, if any.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub progress_message: Option<String>,
    /// Active progress percentage, if any.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub progress_percentage: Option<u32>,
    /// Seconds since spawn.
    pub uptime_secs: u64,
}

/// Manages progress state for a single LSP client.
#[derive(Debug, Default)]
pub struct ProgressTracker {
    active_progress: HashMap<ProgressToken, ProgressState>,
    /// Last broadcast title (used to deduplicate monitor output).
    last_broadcast_title: Option<String>,
    /// Last broadcast percentage (used to deduplicate monitor output).
    last_broadcast_percentage: Option<u32>,
}

impl ProgressTracker {
    /// Creates a new `ProgressTracker`.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Update state from a progress notification.
    ///
    /// `token` is the canonicalized progress token (string form).
    /// `value` is the raw `WorkDoneProgress` payload from `$/progress`.
    pub fn update(&mut self, token: &str, value: &Value) {
        match value.get("kind").and_then(Value::as_str) {
            Some("begin") => {
                self.active_progress.insert(
                    token.to_string(),
                    ProgressState {
                        title: value
                            .get("title")
                            .and_then(Value::as_str)
                            .unwrap_or("")
                            .to_string(),
                        message: value
                            .get("message")
                            .and_then(Value::as_str)
                            .map(str::to_string),
                        percentage: value
                            .get("percentage")
                            .and_then(Value::as_u64)
                            .and_then(|n| u32::try_from(n).ok()),
                        started: Instant::now(),
                    },
                );
            }
            Some("report") => {
                if let Some(state) = self.active_progress.get_mut(token) {
                    if let Some(msg) = value.get("message").and_then(Value::as_str) {
                        state.message = Some(msg.to_string());
                    }
                    if let Some(pct) = value
                        .get("percentage")
                        .and_then(Value::as_u64)
                        .and_then(|n| u32::try_from(n).ok())
                    {
                        state.percentage = Some(pct);
                    }
                }
            }
            Some("end") => {
                self.active_progress.remove(token);
            }
            other => {
                warn!("Unknown progress kind: {:?}", other);
            }
        }
    }

    /// Returns true if server is busy with any progress operations.
    #[must_use]
    pub fn is_busy(&self) -> bool {
        !self.active_progress.is_empty()
    }

    /// Returns the most significant active progress (longest running or lowest percentage).
    #[must_use]
    pub fn primary_progress(&self) -> Option<&ProgressState> {
        self.active_progress
            .values()
            .min_by_key(|p| p.percentage.unwrap_or(0))
    }

    /// Returns `true` if the primary progress has changed since the last broadcast.
    ///
    /// Compares title and percentage only — per-file message changes are not
    /// considered meaningful for monitor output, since LSP servers like
    /// rust-analyzer send a notification for every individual file scanned.
    /// Updates the cached state when returning `true`.
    pub fn broadcast_changed(&mut self) -> bool {
        let (title, pct) = self
            .primary_progress()
            .map_or((None, None), |p| (Some(p.title.clone()), p.percentage));
        if title == self.last_broadcast_title && pct == self.last_broadcast_percentage {
            return false;
        }
        self.last_broadcast_title = title;
        self.last_broadcast_percentage = pct;
        true
    }

    /// Clear all progress (e.g., on reconnect).
    pub fn clear(&mut self) {
        self.active_progress.clear();
    }
}

#[cfg(test)]
#[allow(
    clippy::expect_used,
    reason = "tests use expect for readable assertions"
)]
mod tests {
    use super::*;
    use serde_json::json;

    #[test]
    fn test_progress_begin_end() {
        let mut tracker = ProgressTracker::new();
        assert!(!tracker.is_busy());

        // Begin progress
        let begin = json!({"kind": "begin", "title": "Indexing", "message": "src/main.rs", "percentage": 0});
        tracker.update("indexing", &begin);

        assert!(tracker.is_busy());
        let primary = tracker.primary_progress().expect("active progress");
        assert_eq!(primary.title, "Indexing");
        assert_eq!(primary.message, Some("src/main.rs".to_string()));
        assert_eq!(primary.percentage, Some(0));

        // End progress
        let end = json!({"kind": "end"});
        tracker.update("indexing", &end);

        assert!(!tracker.is_busy());
    }

    #[test]
    fn test_progress_report() {
        let mut tracker = ProgressTracker::new();

        // Begin
        let begin = json!({"kind": "begin", "title": "Indexing", "percentage": 0});
        tracker.update("indexing", &begin);

        // Report progress
        let report = json!({"kind": "report", "message": "50% done", "percentage": 50});
        tracker.update("indexing", &report);

        let primary = tracker.primary_progress().expect("active progress");
        assert_eq!(primary.percentage, Some(50));
        assert_eq!(primary.message, Some("50% done".to_string()));
    }

    #[test]
    fn test_multiple_progress_tokens() {
        let mut tracker = ProgressTracker::new();

        // Begin two progress operations
        let begin1 = json!({"kind": "begin", "title": "Indexing", "percentage": 50});
        let begin2 = json!({"kind": "begin", "title": "Analyzing", "percentage": 10});
        tracker.update("indexing", &begin1);
        tracker.update("analyzing", &begin2);

        assert!(tracker.is_busy());

        // Primary should be the one with lower percentage
        let primary = tracker.primary_progress().expect("active progress");
        assert_eq!(primary.title, "Analyzing");
        assert_eq!(primary.percentage, Some(10));

        // End one
        let end1 = json!({"kind": "end"});
        tracker.update("indexing", &end1);

        assert!(tracker.is_busy());
        let primary = tracker.primary_progress().expect("active progress");
        assert_eq!(primary.title, "Analyzing");
    }

    #[test]
    fn test_server_state_conversion() {
        assert_eq!(ServerState::from_u8(0), ServerState::Initializing);
        assert_eq!(ServerState::from_u8(1), ServerState::Busy);
        assert_eq!(ServerState::from_u8(2), ServerState::Ready);
        assert_eq!(ServerState::from_u8(3), ServerState::Dead);
        assert_eq!(ServerState::from_u8(4), ServerState::Stuck);
        assert_eq!(ServerState::from_u8(99), ServerState::Dead);

        assert_eq!(ServerState::Initializing.as_u8(), 0);
        assert_eq!(ServerState::Busy.as_u8(), 1);
        assert_eq!(ServerState::Ready.as_u8(), 2);
        assert_eq!(ServerState::Dead.as_u8(), 3);
        assert_eq!(ServerState::Stuck.as_u8(), 4);
    }
}