Skip to main content

max / alloy

13.8 KB · 426 lines History Blame Raw
1 //! Text in, [`model`](super::model) out.
2 //!
3 //! One function crosses the boundary: [`parse`] merges the four reads a refresh
4 //! makes into one [`SyncState`]. The eight serde shapes above it are Syncthing's
5 //! JSON and stay private to this file, so a field the tool renames is a change
6 //! here and nowhere else.
7 //!
8 //! Names the model, serde and std only. A parser that logged would need
9 //! `crate::cli::CommandLog`, and that is [`backend`](super::backend)'s business.
10
11 use std::collections::HashMap;
12
13 use anyhow::Result;
14 use serde::Deserialize;
15
16 use super::model::{Device, Folder, PendingDevice, SyncState};
17
18 #[derive(Deserialize)]
19 struct StConfig {
20 #[serde(default)]
21 folders: Vec<StFolder>,
22 #[serde(default)]
23 devices: Vec<StDevice>,
24 }
25
26 #[derive(Deserialize)]
27 #[serde(rename_all = "camelCase")]
28 struct StFolder {
29 id: String,
30 #[serde(default)]
31 label: String,
32 #[serde(default)]
33 path: String,
34 #[serde(rename = "type", default)]
35 kind: String,
36 #[serde(default)]
37 paused: bool,
38 #[serde(default)]
39 devices: Vec<StFolderDevice>,
40 }
41
42 #[derive(Deserialize)]
43 struct StFolderDevice {
44 /// Spelled `deviceID` like [`StDevice`]'s, and named outright for the same
45 /// reason: camelCase renaming does not reach it.
46 #[serde(rename = "deviceID", default)]
47 device_id: String,
48 }
49
50 #[derive(Deserialize)]
51 struct StDevice {
52 // Syncthing spells it `deviceID`, not `deviceId`, so camelCase renaming
53 // does not reach it and the field has to be named outright. Caught by a
54 // fixture taken from real output rather than by reading the code.
55 #[serde(rename = "deviceID")]
56 device_id: String,
57 #[serde(default)]
58 name: String,
59 #[serde(default)]
60 paused: bool,
61 }
62
63 #[derive(Deserialize)]
64 struct StConnections {
65 #[serde(default)]
66 connections: HashMap<String, StConnection>,
67 }
68
69 #[derive(Deserialize)]
70 struct StConnection {
71 #[serde(default)]
72 connected: bool,
73 }
74
75 /// One entry of `show pending devices`, which is a map keyed by device id.
76 ///
77 /// `address` is a single string, not the `addresses` array the REST reference
78 /// might lead you to expect. Taken from a real pending entry produced by
79 /// pointing a second instance at a first, because guessing this shape is
80 /// exactly how the `deviceID` bug got written.
81 #[derive(Deserialize)]
82 struct StPending {
83 #[serde(default)]
84 address: String,
85 #[serde(default)]
86 name: String,
87 #[serde(default)]
88 time: String,
89 }
90
91 #[derive(Deserialize)]
92 struct StSystem {
93 /// `myID`, with the same capitalisation quirk as `deviceID`.
94 #[serde(rename = "myID", default)]
95 my_id: String,
96 }
97
98 /// Merge the four reads into one screen's worth of state.
99 pub(super) fn parse(
100 config: &str,
101 connections: &str,
102 system: &str,
103 pending: &str,
104 ) -> Result<SyncState> {
105 let config: StConfig = serde_json::from_str(config)?;
106 // Liveness and identity are best-effort on purpose. A folder list that
107 // renders without connection state is worth more than an error, and the
108 // only cost of losing either is a row reading "disconnected" or no row
109 // being marked as this machine.
110 let connections: StConnections = serde_json::from_str(connections).unwrap_or(StConnections {
111 connections: HashMap::new(),
112 });
113 let my_id = serde_json::from_str::<StSystem>(system)
114 .map(|system| system.my_id)
115 .unwrap_or_default();
116
117 let mut folders: Vec<Folder> = config
118 .folders
119 .into_iter()
120 .map(|folder| Folder {
121 label: if folder.label.is_empty() {
122 folder.id.clone()
123 } else {
124 folder.label
125 },
126 id: folder.id,
127 path: folder.path,
128 kind: folder.kind,
129 paused: folder.paused,
130 devices: folder
131 .devices
132 .into_iter()
133 .map(|device| device.device_id)
134 .collect(),
135 })
136 .collect();
137 // Syncthing returns folders in config order, which is insertion order.
138 // Sorting by label keeps a list stable across a refresh that reordered
139 // nothing the user can see, and keeps the cursor over the same row.
140 folders.sort_by(|a, b| a.label.cmp(&b.label));
141
142 let mut devices: Vec<Device> = config
143 .devices
144 .into_iter()
145 .map(|device| {
146 let is_self = !my_id.is_empty() && device.device_id == my_id;
147 Device {
148 connected: connections
149 .connections
150 .get(&device.device_id)
151 .is_some_and(|connection| connection.connected),
152 name: if device.name.is_empty() {
153 device.device_id.split('-').next().unwrap_or("").to_string()
154 } else {
155 device.name
156 },
157 id: device.device_id,
158 paused: device.paused,
159 is_self,
160 }
161 })
162 .collect();
163 // This machine first, then connected peers, then the rest by name. Same
164 // rule as the peer list in `mesh`, and for the same reason: a map-backed
165 // source reshuffling under the cursor is the bug it prevents.
166 devices.sort_by(|a, b| {
167 b.is_self
168 .cmp(&a.is_self)
169 .then(b.connected.cmp(&a.connected))
170 .then(a.name.cmp(&b.name))
171 });
172
173 // Best-effort like the other two decorations: a daemon too old to know the
174 // endpoint, or a malformed answer, costs the pending tab and nothing else.
175 let mut pending: Vec<PendingDevice> =
176 serde_json::from_str::<HashMap<String, StPending>>(pending)
177 .unwrap_or_default()
178 .into_iter()
179 .map(|(id, entry)| PendingDevice {
180 name: if entry.name.is_empty() {
181 id.split('-').next().unwrap_or("").to_string()
182 } else {
183 entry.name
184 },
185 id,
186 address: entry.address,
187 time: entry.time,
188 })
189 .collect();
190 // Newest first: the one that just tried to connect is the one being waited
191 // on. Ties break by name so a map's iteration order never shows through.
192 pending.sort_by(|a, b| b.time.cmp(&a.time).then(a.name.cmp(&b.name)));
193
194 Ok(SyncState {
195 folders,
196 devices,
197 pending,
198 })
199 }
200
201 #[cfg(test)]
202 mod tests {
203 use super::*;
204
205 // Shaped from a real `syncthing cli config dump-json` on syncthing
206 // v1.30.0, trimmed to the fields the parser reads. The awkward parts are
207 // real: a folder with an empty label, and the device list carrying this
208 // machine alongside its peers with no flag saying which is which.
209 const CONFIG: &str = r#"{
210 "folders": [
211 {
212 "id": "photos", "label": "Pictures", "path": "/home/max/Pictures",
213 "type": "sendonly", "paused": true,
214 "devices": [{"deviceID": "SELF"}, {"deviceID": "PEER"}]
215 },
216 {
217 "id": "docs", "label": "", "path": "/home/max/Documents",
218 "type": "sendreceive", "paused": false,
219 "devices": [{"deviceID": "SELF"}]
220 }
221 ],
222 "devices": [
223 {"deviceID": "PEER", "name": "astra", "paused": false},
224 {"deviceID": "SELF", "name": "fw13", "paused": false},
225 {"deviceID": "OTHER", "name": "mbp", "paused": true}
226 ]
227 }"#;
228
229 const CONNECTIONS: &str = r#"{
230 "connections": {
231 "PEER": {"connected": true},
232 "OTHER": {"connected": false}
233 }
234 }"#;
235
236 const SYSTEM: &str = r#"{"myID": "SELF"}"#;
237
238 // Captured from a real pending entry, produced by pointing a second
239 // syncthing instance at a first. Note `address` is a single string: the
240 // REST reference describes an `addresses` array elsewhere, and assuming
241 // that here would have been the deviceID bug a second time.
242 const PENDING: &str = r#"{
243 "FAIUVWX-EABLCHR-JVEHT3G-ZO4VDFQ-25RQW5K-5AEOTK4-4JZ27KO-YKVNWAD": {
244 "address": "127.0.0.1:47984",
245 "name": "laptop",
246 "time": "2026-07-25T19:59:54Z"
247 },
248 "GGGGGGG-HHHHHHH-IIIIIII-JJJJJJJ-KKKKKKK-LLLLLLL-MMMMMMM-NNNNNNN": {
249 "address": "192.168.1.9:22000",
250 "name": "",
251 "time": "2026-07-24T08:00:00Z"
252 }
253 }"#;
254
255 fn state() -> SyncState {
256 parse(CONFIG, CONNECTIONS, SYSTEM, PENDING).unwrap()
257 }
258
259 #[test]
260 fn reads_folders_and_devices() {
261 let state = state();
262 assert_eq!(state.folders.len(), 2);
263 assert_eq!(state.devices.len(), 3);
264 }
265
266 // An empty label is legal in Syncthing and shows as the id in its own UI.
267 // Rendering a blank row would leave a folder the user cannot name.
268 #[test]
269 fn a_folder_without_a_label_falls_back_to_its_id() {
270 let state = state();
271 let docs = state
272 .folders
273 .iter()
274 .find(|folder| folder.id == "docs")
275 .unwrap();
276 assert_eq!(docs.label, "docs");
277 }
278
279 // Config order is insertion order, so without this the list reshuffles
280 // under the cursor whenever a folder is added.
281 #[test]
282 fn folders_are_ordered_by_label() {
283 let state = state();
284 let labels: Vec<&str> = state
285 .folders
286 .iter()
287 .map(|folder| folder.label.as_str())
288 .collect();
289 assert_eq!(labels, ["Pictures", "docs"]);
290 }
291
292 // Self first, then connected, then by name. Same rule as the mesh peer
293 // list, and the reason the device list is sorted at all.
294 #[test]
295 fn devices_are_ordered_self_then_connected_then_by_name() {
296 let state = state();
297 let names: Vec<&str> = state
298 .devices
299 .iter()
300 .map(|device| device.name.as_str())
301 .collect();
302 assert_eq!(names, ["fw13", "astra", "mbp"]);
303 assert!(state.devices[0].is_self);
304 }
305
306 // This machine has no connection to itself, so the connections map never
307 // mentions it. Labelling that "disconnected" would be alarming and wrong.
308 #[test]
309 fn this_machine_is_not_reported_as_disconnected() {
310 let state = state();
311 let me = &state.devices[0];
312 assert!(!me.connected);
313 assert_eq!(me.state_label(), "this machine");
314 }
315
316 #[test]
317 fn connection_state_comes_from_the_connections_read() {
318 let state = state();
319 let astra = state
320 .devices
321 .iter()
322 .find(|device| device.name == "astra")
323 .unwrap();
324 assert!(astra.connected);
325 assert_eq!(astra.state_label(), "connected");
326 }
327
328 // A paused device outranks its connection state in the label: paused is
329 // something the user did and can undo, disconnected is weather.
330 #[test]
331 fn paused_is_reported_ahead_of_disconnected() {
332 let state = state();
333 let mbp = state
334 .devices
335 .iter()
336 .find(|device| device.name == "mbp")
337 .unwrap();
338 assert!(mbp.paused);
339 assert_eq!(mbp.state_label(), "paused");
340 }
341
342 #[test]
343 fn folder_paused_state_is_read() {
344 let state = state();
345 let photos = state
346 .folders
347 .iter()
348 .find(|folder| folder.id == "photos")
349 .unwrap();
350 assert!(photos.paused);
351 assert_eq!(photos.state_label(), "paused");
352 assert_eq!(photos.shared_with(), 2);
353 }
354
355 // Losing liveness or identity must not lose the folder list: the config
356 // read is the one that matters, and the other two decorate it.
357 #[test]
358 fn a_missing_connections_read_still_yields_folders() {
359 let state = parse(CONFIG, "not json", SYSTEM, PENDING).unwrap();
360 assert_eq!(state.folders.len(), 2);
361 assert!(state.devices.iter().all(|device| !device.connected));
362 }
363
364 #[test]
365 fn a_missing_system_read_leaves_no_device_marked_self() {
366 let state = parse(CONFIG, CONNECTIONS, "not json", PENDING).unwrap();
367 assert!(state.devices.iter().all(|device| !device.is_self));
368 }
369
370 // The reason `parse` returns a Result at all: a config that is not JSON is
371 // the one failure worth surfacing, because there is no screen without it.
372 #[test]
373 fn an_unreadable_config_is_an_error() {
374 assert!(parse("not json", CONNECTIONS, SYSTEM, PENDING).is_err());
375 }
376
377 // -------------------------------------------------------------------
378 // Pending devices
379 // -------------------------------------------------------------------
380
381 #[test]
382 fn reads_pending_devices() {
383 let state = state();
384 assert_eq!(state.pending.len(), 2);
385 let first = &state.pending[0];
386 assert_eq!(first.name, "laptop");
387 assert_eq!(first.address, "127.0.0.1:47984");
388 }
389
390 // Newest first: the device that just knocked is the one being waited on,
391 // and a map's iteration order must not show through.
392 #[test]
393 fn pending_devices_are_newest_first() {
394 let state = state();
395 let names: Vec<&str> = state
396 .pending
397 .iter()
398 .map(|entry| entry.name.as_str())
399 .collect();
400 assert_eq!(names, ["laptop", "GGGGGGG"]);
401 }
402
403 // Syncthing sends an empty name for a device that has not set one. A blank
404 // row would leave the user with nothing to identify it by.
405 #[test]
406 fn a_pending_device_without_a_name_falls_back_to_its_id() {
407 let state = state();
408 assert_eq!(state.pending[1].name, "GGGGGGG");
409 }
410
411 // A daemon too old for the endpoint, or any malformed answer, costs the
412 // pending tab and must not cost the folder list.
413 #[test]
414 fn an_unreadable_pending_read_still_yields_folders() {
415 let state = parse(CONFIG, CONNECTIONS, SYSTEM, "not json").unwrap();
416 assert_eq!(state.folders.len(), 2);
417 assert!(state.pending.is_empty());
418 }
419
420 #[test]
421 fn no_pending_devices_is_an_empty_list_not_an_error() {
422 let state = parse(CONFIG, CONNECTIONS, SYSTEM, "{}").unwrap();
423 assert!(state.pending.is_empty());
424 }
425 }
426