Coverage Report

Created: 2026-04-05 07:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
crates/flotilla-protocol/src/query.rs
Line
Count
Source
1
use std::{collections::HashMap, path::PathBuf};
2
3
use serde::{Deserialize, Serialize};
4
5
use crate::{
6
    snapshot::{ProviderError, WorkItem},
7
    EnvironmentInfo, HostName, HostSummary, PeerConnectionState,
8
};
9
10
/// Provider health across categories. Outer key: category (e.g. "vcs",
11
/// "change_request"). Inner key: provider name. Value: healthy.
12
pub type ProviderHealthMap = HashMap<String, HashMap<String, bool>>;
13
14
// --- status ---
15
16
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17
pub struct StatusResponse {
18
    pub repos: Vec<RepoSummary>,
19
}
20
21
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22
pub struct RepoSummary {
23
    pub path: PathBuf,
24
    pub slug: Option<String>,
25
    pub provider_health: ProviderHealthMap,
26
    pub work_item_count: usize,
27
    pub error_count: usize,
28
}
29
30
// --- repo detail ---
31
32
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33
pub struct RepoDetailResponse {
34
    pub path: PathBuf,
35
    pub slug: Option<String>,
36
    pub provider_health: ProviderHealthMap,
37
    pub work_items: Vec<WorkItem>,
38
    pub errors: Vec<ProviderError>,
39
}
40
41
// --- repo providers ---
42
43
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44
pub struct RepoProvidersResponse {
45
    pub path: PathBuf,
46
    pub slug: Option<String>,
47
    pub host_discovery: Vec<DiscoveryEntry>,
48
    pub repo_discovery: Vec<DiscoveryEntry>,
49
    pub providers: Vec<ProviderInfo>,
50
    pub unmet_requirements: Vec<UnmetRequirementInfo>,
51
}
52
53
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54
pub struct DiscoveryEntry {
55
    pub kind: String,
56
    pub detail: HashMap<String, String>,
57
}
58
59
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60
pub struct ProviderInfo {
61
    pub category: String,
62
    pub name: String,
63
    pub healthy: bool,
64
}
65
66
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67
pub struct UnmetRequirementInfo {
68
    pub factory: String,
69
    pub kind: String,
70
    #[serde(skip_serializing_if = "Option::is_none")]
71
    pub value: Option<String>,
72
}
73
74
// --- repo work ---
75
76
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77
pub struct RepoWorkResponse {
78
    pub path: PathBuf,
79
    pub slug: Option<String>,
80
    pub work_items: Vec<WorkItem>,
81
}
82
83
// --- host / topology ---
84
85
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86
pub struct HostListResponse {
87
    pub hosts: Vec<HostListEntry>,
88
}
89
90
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91
pub struct HostListEntry {
92
    pub host: HostName,
93
    pub is_local: bool,
94
    /// `true` only for non-local hosts that appear in `hosts.toml`.
95
    pub configured: bool,
96
    pub connection_status: PeerConnectionState,
97
    /// Indicates whether `get_host_status` would be able to return a
98
    /// non-`None` summary for this host.
99
    pub has_summary: bool,
100
    pub repo_count: usize,
101
    pub work_item_count: usize,
102
}
103
104
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105
pub struct HostStatusResponse {
106
    pub host: HostName,
107
    pub is_local: bool,
108
    /// `true` only for non-local hosts that appear in `hosts.toml`.
109
    pub configured: bool,
110
    pub connection_status: PeerConnectionState,
111
    #[serde(skip_serializing_if = "Option::is_none")]
112
    pub summary: Option<HostSummary>,
113
    #[serde(default)]
114
    pub visible_environments: Vec<EnvironmentInfo>,
115
    pub repo_count: usize,
116
    pub work_item_count: usize,
117
}
118
119
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120
pub struct HostProvidersResponse {
121
    pub host: HostName,
122
    pub is_local: bool,
123
    /// `true` only for non-local hosts that appear in `hosts.toml`.
124
    pub configured: bool,
125
    pub connection_status: PeerConnectionState,
126
    pub summary: HostSummary,
127
    #[serde(default)]
128
    pub visible_environments: Vec<EnvironmentInfo>,
129
}
130
131
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132
pub struct TopologyResponse {
133
    pub local_host: HostName,
134
    pub routes: Vec<TopologyRoute>,
135
}
136
137
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138
pub struct TopologyRoute {
139
    pub target: HostName,
140
    pub next_hop: HostName,
141
    pub direct: bool,
142
    pub connected: bool,
143
    #[serde(default)]
144
    pub fallbacks: Vec<HostName>,
145
}
146
147
#[cfg(test)]
148
mod tests {
149
    use serde_json::json;
150
151
    use super::{
152
        HostListEntry, HostListResponse, HostProvidersResponse, HostStatusResponse, TopologyResponse, TopologyRoute, UnmetRequirementInfo,
153
    };
154
    use crate::{
155
        test_helpers::assert_roundtrip, EnvironmentId, EnvironmentInfo, EnvironmentStatus, HostEnvironment, HostName, HostProviderStatus,
156
        HostSummary, ImageId, PeerConnectionState, SystemInfo, ToolInventory,
157
    };
158
159
    #[test]
160
1
    fn unmet_requirement_info_omits_none_value_when_serialized() {
161
1
        let without_value = UnmetRequirementInfo { factory: "git".into(), kind: "no_vcs_checkout".into(), value: None };
162
1
        let with_value = UnmetRequirementInfo { factory: "github".into(), kind: "missing_binary".into(), value: Some("gh".into()) };
163
164
1
        assert_eq!(
165
1
            serde_json::to_value(&without_value).expect("serialize without value"),
166
1
            json!({
167
1
                "factory": "git",
168
1
                "kind": "no_vcs_checkout"
169
            })
170
        );
171
1
        assert_eq!(
172
1
            serde_json::to_value(&with_value).expect("serialize with value"),
173
1
            json!({
174
1
                "factory": "github",
175
1
                "kind": "missing_binary",
176
1
                "value": "gh"
177
            })
178
        );
179
1
    }
180
181
3
    fn sample_host_summary() -> HostSummary {
182
3
        HostSummary {
183
3
            host_name: HostName::new("desktop"),
184
3
            system: SystemInfo {
185
3
                home_dir: Some("/home/dev".into()),
186
3
                os: Some("linux".into()),
187
3
                arch: Some("aarch64".into()),
188
3
                cpu_count: Some(8),
189
3
                memory_total_mb: Some(16384),
190
3
                environment: HostEnvironment::Container,
191
3
            },
192
3
            inventory: ToolInventory::default(),
193
3
            providers: vec![HostProviderStatus { category: "vcs".into(), name: "Git".into(), implementation: "git".into(), healthy: true }],
194
3
            environments: vec![],
195
3
        }
196
3
    }
197
198
2
    fn sample_visible_environments() -> Vec<EnvironmentInfo> {
199
2
        vec![
200
2
            EnvironmentInfo::Direct {
201
2
                id: EnvironmentId::new("direct-env"),
202
2
                display_name: Some("direct".into()),
203
2
                host_id: None,
204
2
                status: EnvironmentStatus::Running,
205
2
            },
206
2
            EnvironmentInfo::Provisioned {
207
2
                id: EnvironmentId::new("provisioned-env"),
208
2
                display_name: Some("provisioned".into()),
209
2
                image: ImageId::new("mock:image"),
210
2
                status: EnvironmentStatus::Running,
211
2
            },
212
        ]
213
2
    }
214
215
    #[test]
216
1
    fn host_list_response_roundtrips_without_summary_data() {
217
1
        let response = HostListResponse {
218
1
            hosts: vec![HostListEntry {
219
1
                host: HostName::new("remote"),
220
1
                is_local: false,
221
1
                configured: true,
222
1
                connection_status: PeerConnectionState::Disconnected,
223
1
                has_summary: false,
224
1
                repo_count: 0,
225
1
                work_item_count: 0,
226
1
            }],
227
1
        };
228
229
1
        assert_roundtrip(&response);
230
1
    }
231
232
    #[test]
233
1
    fn host_status_response_roundtrips_with_summary() {
234
1
        let response = HostStatusResponse {
235
1
            host: HostName::new("desktop"),
236
1
            is_local: true,
237
1
            configured: true,
238
1
            connection_status: PeerConnectionState::Connected,
239
1
            summary: Some(sample_host_summary()),
240
1
            visible_environments: sample_visible_environments(),
241
1
            repo_count: 2,
242
1
            work_item_count: 5,
243
1
        };
244
245
1
        assert_roundtrip(&response);
246
1
    }
247
248
    #[test]
249
1
    fn host_providers_response_roundtrips_summary() {
250
1
        let response = HostProvidersResponse {
251
1
            host: HostName::new("desktop"),
252
1
            is_local: true,
253
1
            configured: true,
254
1
            connection_status: PeerConnectionState::Connected,
255
1
            summary: sample_host_summary(),
256
1
            visible_environments: sample_visible_environments(),
257
1
        };
258
259
1
        assert_roundtrip(&response);
260
1
    }
261
262
    #[test]
263
1
    fn host_status_response_defaults_missing_visible_environments() {
264
1
        let mut value = serde_json::to_value(HostStatusResponse {
265
1
            host: HostName::new("desktop"),
266
1
            is_local: true,
267
1
            configured: true,
268
1
            connection_status: PeerConnectionState::Connected,
269
1
            summary: Some(sample_host_summary()),
270
1
            visible_environments: vec![],
271
1
            repo_count: 2,
272
1
            work_item_count: 5,
273
1
        })
274
1
        .expect("serialize host status");
275
1
        value.as_object_mut().expect("object").remove("visible_environments");
276
277
1
        let decoded: HostStatusResponse = serde_json::from_value(value).expect("deserialize without visible environments");
278
1
        assert!(decoded.visible_environments.is_empty());
279
1
    }
280
281
    #[test]
282
1
    fn topology_response_roundtrips_fallbacks() {
283
1
        let response = TopologyResponse {
284
1
            local_host: HostName::new("desktop"),
285
1
            routes: vec![TopologyRoute {
286
1
                target: HostName::new("worker"),
287
1
                next_hop: HostName::new("relay"),
288
1
                direct: false,
289
1
                connected: true,
290
1
                fallbacks: vec![HostName::new("backup-relay")],
291
1
            }],
292
1
        };
293
294
1
        assert_roundtrip(&response);
295
1
    }
296
}