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