Skip to main content

mcpls_core/bridge/
signals.rs

1//! Route-level health signals attached to tool results.
2//!
3//! One definition shared by every tool that reports them, so two tools can
4//! never name the same signal differently or sample it differently.
5
6use schemars::JsonSchema;
7use serde::Serialize;
8
9use super::{IndexingState, NotificationCache};
10use crate::config::ServerId;
11use crate::redaction::{Redactions, ServerText};
12
13/// Whether the routed server was still indexing its workspace.
14///
15/// Attached to the results of tools whose answer depends on the workspace
16/// index, so an empty result with the flag set is read as "possibly
17/// incomplete" rather than "nothing here".
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
19pub struct IndexingSignal {
20    /// `true` if the routed language server reported its initial workspace indexing as
21    /// in progress during this read; the result may reflect a partial index.
22    pub indexing_in_progress: bool,
23}
24
25impl IndexingSignal {
26    /// Samples the indexing state of `route_id`'s server.
27    ///
28    /// Keyed on the routing identity, not cache ownership, which a respawn
29    /// clears (#359).
30    #[must_use]
31    pub fn sample(cache: &NotificationCache, route_id: Option<&ServerId>) -> Self {
32        Self {
33            indexing_in_progress: route_id
34                .is_some_and(|id| cache.indexing_state(id) == IndexingState::Loading),
35        }
36    }
37
38    /// Combines two samples taken at different times: the signal is set if it
39    /// was set in either.
40    #[must_use]
41    pub const fn union(self, later: Self) -> Self {
42        Self {
43            indexing_in_progress: self.indexing_in_progress || later.indexing_in_progress,
44        }
45    }
46}
47
48/// A tool result with the [`IndexingSignal`] flattened beside it.
49#[derive(Debug, Clone, Serialize, JsonSchema)]
50pub struct Indexed<T> {
51    /// The tool's own result.
52    #[serde(flatten)]
53    pub result: T,
54    /// Whether the routed server was still indexing.
55    #[serde(flatten)]
56    pub indexing: IndexingSignal,
57}
58
59impl<T> Indexed<T> {
60    /// Pairs `result` with `indexing`.
61    #[must_use]
62    pub const fn new(result: T, indexing: IndexingSignal) -> Self {
63        Self { result, indexing }
64    }
65}
66
67impl<T: ServerText> ServerText for Indexed<T> {
68    fn redact_server_text(&mut self, redactions: &Redactions) {
69        let Self {
70            result,
71            indexing: _,
72        } = self;
73        result.redact_server_text(redactions);
74    }
75}
76
77/// Route-level health signals shared by every diagnostics reader
78/// (`get_diagnostics`, `get_cached_diagnostics`, and the diagnostics
79/// resource), so the three can never report different keys or semantics.
80#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, JsonSchema)]
81pub struct RouteSignals {
82    /// `true` if the language server publishing this file's diagnostics crashed and was
83    /// restarted during this mcpls session. Diagnostics it delivers only by push (e.g.
84    /// rust-analyzer's flycheck/clippy) are no longer received, so the returned
85    /// diagnostics may be incomplete until mcpls restarts.
86    pub push_notifications_degraded: bool,
87    /// The indexing half, shared with the tools that only report it.
88    #[serde(flatten)]
89    pub indexing: IndexingSignal,
90}
91
92impl RouteSignals {
93    /// Samples both signals of `route_id`'s server.
94    #[must_use]
95    pub fn sample(cache: &NotificationCache, route_id: Option<&ServerId>) -> Self {
96        Self {
97            push_notifications_degraded: route_id.is_some_and(|id| cache.is_push_degraded(id)),
98            indexing: IndexingSignal::sample(cache, route_id),
99        }
100    }
101
102    /// Combines two samples taken at different times: a signal is set if it was set in
103    /// either.
104    #[must_use]
105    pub const fn union(self, later: Self) -> Self {
106        Self {
107            push_notifications_degraded: self.push_notifications_degraded
108                || later.push_notifications_degraded,
109            indexing: self.indexing.union(later.indexing),
110        }
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn test_unknown_route_samples_no_signal() {
120        let cache = NotificationCache::new();
121        assert_eq!(RouteSignals::sample(&cache, None), RouteSignals::default());
122        let id = ServerId::from_static("rust");
123        assert_eq!(
124            RouteSignals::sample(&cache, Some(&id)),
125            RouteSignals::default()
126        );
127    }
128
129    #[test]
130    fn test_union_keeps_a_signal_set_in_either_sample() {
131        let degraded = RouteSignals {
132            push_notifications_degraded: true,
133            ..RouteSignals::default()
134        };
135        let loading = RouteSignals {
136            indexing: IndexingSignal {
137                indexing_in_progress: true,
138            },
139            ..RouteSignals::default()
140        };
141        let both = degraded.union(loading);
142        assert!(both.push_notifications_degraded);
143        assert!(both.indexing.indexing_in_progress);
144        assert_eq!(loading.union(degraded), both);
145    }
146
147    #[test]
148    fn test_indexed_flattens_the_signal_beside_the_result() {
149        #[derive(Serialize)]
150        struct Items {
151            items: Vec<u8>,
152        }
153        let indexed = Indexed::new(
154            Items { items: vec![1] },
155            IndexingSignal {
156                indexing_in_progress: true,
157            },
158        );
159        assert_eq!(
160            serde_json::to_string(&indexed).unwrap(),
161            r#"{"items":[1],"indexing_in_progress":true}"#
162        );
163    }
164
165    #[test]
166    fn test_indexing_half_flattens_into_one_object() {
167        let signals = RouteSignals {
168            push_notifications_degraded: true,
169            indexing: IndexingSignal {
170                indexing_in_progress: true,
171            },
172        };
173        assert_eq!(
174            serde_json::to_string(&signals).unwrap(),
175            r#"{"push_notifications_degraded":true,"indexing_in_progress":true}"#
176        );
177        assert_eq!(
178            serde_json::to_string(&IndexingSignal::default()).unwrap(),
179            r#"{"indexing_in_progress":false}"#
180        );
181    }
182}