catenary-mcp 1.6.1

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: AGPL-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,
}

/// Server lifecycle state.
///
/// A single enum that tracks the server from spawn through shutdown.
/// Carries data where needed (`Busy` holds the in-flight progress count).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServerLifecycle {
    /// Spawned, init handshake not complete.
    Initializing,
    /// Init complete, server unproven. Diagnostics path blocks,
    /// tool requests proceed (self-testing).
    Probing,
    /// Proven healthy, idle, accepts requests.
    Healthy,
    /// Server declared active via progress tokens. Carries
    /// `in_progress_count` (always >= 1).
    Busy(u32),
    /// Health probe failed or init error. Shut down.
    Failed,
    /// Connection lost / process died.
    Dead,
}

impl ServerLifecycle {
    /// Returns whether the server is in a terminal state.
    #[must_use]
    pub const fn is_terminal(&self) -> bool {
        matches!(self, Self::Failed | Self::Dead)
    }

    /// Returns the display state string for TUI/CLI.
    #[must_use]
    pub const fn display_state(&self) -> &str {
        match self {
            Self::Initializing | Self::Probing => "initializing",
            Self::Healthy => "ready",
            Self::Busy(_) => "busy",
            Self::Failed | Self::Dead => "dead",
        }
    }
}

impl Serialize for ServerLifecycle {
    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        serializer.serialize_str(self.display_state())
    }
}

/// 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 lifecycle state.
    pub state: ServerLifecycle,
    /// 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 lifecycle_display_state() {
        assert_eq!(
            ServerLifecycle::Initializing.display_state(),
            "initializing"
        );
        assert_eq!(ServerLifecycle::Probing.display_state(), "initializing");
        assert_eq!(ServerLifecycle::Healthy.display_state(), "ready");
        assert_eq!(ServerLifecycle::Busy(1).display_state(), "busy");
        assert_eq!(ServerLifecycle::Busy(3).display_state(), "busy");
        assert_eq!(ServerLifecycle::Failed.display_state(), "dead");
        assert_eq!(ServerLifecycle::Dead.display_state(), "dead");
    }

    #[test]
    fn lifecycle_is_terminal() {
        assert!(!ServerLifecycle::Initializing.is_terminal());
        assert!(!ServerLifecycle::Probing.is_terminal());
        assert!(!ServerLifecycle::Healthy.is_terminal());
        assert!(!ServerLifecycle::Busy(1).is_terminal());
        assert!(ServerLifecycle::Failed.is_terminal());
        assert!(ServerLifecycle::Dead.is_terminal());
    }

    #[test]
    fn lifecycle_serializes_to_display_state() {
        let json = serde_json::to_string(&ServerLifecycle::Healthy).expect("serialize");
        assert_eq!(json, "\"ready\"");

        let json = serde_json::to_string(&ServerLifecycle::Busy(2)).expect("serialize");
        assert_eq!(json, "\"busy\"");

        let json = serde_json::to_string(&ServerLifecycle::Dead).expect("serialize");
        assert_eq!(json, "\"dead\"");
    }
}