frust_devtools_protocol/
discovery.rs1pub const DISCOVERY_PREFIX: &str = "frust-devtools listening on ";
24
25pub const FAILURE_PREFIX: &str = "frust-devtools: service did not start: ";
35
36const TOKEN_MARKER: &str = " token ";
40
41#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Discovery {
49 pub port: u16,
50 pub token: Option<String>,
51}
52
53pub fn format_discovery_line(port: u16, token: Option<&str>) -> String {
56 match token {
57 Some(token) => format!("{DISCOVERY_PREFIX}{port}{TOKEN_MARKER}{token}"),
58 None => format!("{DISCOVERY_PREFIX}{port}"),
59 }
60}
61
62pub fn parse_discovery_line(line: &str) -> Option<Discovery> {
76 let idx = line.find(DISCOVERY_PREFIX)?;
77 let rest = &line[idx + DISCOVERY_PREFIX.len()..];
78 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
79 if digits.is_empty() {
80 return None;
81 }
82 let port: u16 = digits.parse().ok()?;
83 let token = rest[digits.len()..]
84 .strip_prefix(TOKEN_MARKER)
85 .map(|after| {
86 after
87 .chars()
88 .take_while(|c| !c.is_whitespace())
89 .collect::<String>()
90 })
91 .filter(|token| !token.is_empty());
92 Some(Discovery { port, token })
93}
94
95pub fn format_failure_line(reason: &str) -> String {
98 format!("{FAILURE_PREFIX}{reason}")
99}
100
101pub fn parse_failure_line(line: &str) -> Option<&str> {
106 let idx = line.find(FAILURE_PREFIX)?;
107 let reason = line[idx + FAILURE_PREFIX.len()..].trim_end();
108 (!reason.is_empty()).then_some(reason)
109}
110
111pub fn redact_discovery_token(line: &str) -> std::borrow::Cow<'_, str> {
141 use std::borrow::Cow;
142
143 let prefix_idx = match line.find(DISCOVERY_PREFIX) {
145 Some(idx) => idx,
146 None => return Cow::Borrowed(line),
147 };
148
149 let rest = &line[prefix_idx + DISCOVERY_PREFIX.len()..];
150
151 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
153 if digits.is_empty() {
154 return Cow::Borrowed(line);
155 }
156
157 let after_port = &rest[digits.len()..];
158
159 if !after_port.starts_with(TOKEN_MARKER) {
161 return Cow::Borrowed(line);
162 }
163
164 let after_marker = &after_port[TOKEN_MARKER.len()..];
166 let token_end = after_marker
167 .chars()
168 .position(|c| c.is_whitespace())
169 .unwrap_or(after_marker.len());
170
171 if token_end == 0 {
173 return Cow::Borrowed(line);
174 }
175
176 let before_token =
181 &line[..prefix_idx + DISCOVERY_PREFIX.len() + digits.len() + TOKEN_MARKER.len()];
182 let after_token = &after_marker[token_end..];
183
184 Cow::Owned(format!("{}{}{}", before_token, "<redacted>", after_token))
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 fn port_of(line: &str) -> Option<u16> {
192 parse_discovery_line(line).map(|d| d.port)
193 }
194
195 #[test]
196 fn failure_line_round_trips() {
197 let line = format_failure_line("Connection refused (os error 111)");
198 assert_eq!(
199 parse_failure_line(&line),
200 Some("Connection refused (os error 111)")
201 );
202 }
203
204 #[test]
205 fn parses_failure_line_with_logcat_prefix() {
206 let line = "08-10 10:22:16.394 27868 27868 W frust : frust_shell_common::devtools: \
207 frust-devtools: service did not start: Connection refused (os error 111)";
208 assert_eq!(
209 parse_failure_line(line),
210 Some("Connection refused (os error 111)")
211 );
212 }
213
214 #[test]
215 fn failure_line_absent_or_empty_is_none() {
216 assert_eq!(parse_failure_line("some unrelated log line"), None);
217 assert_eq!(parse_failure_line(FAILURE_PREFIX), None);
218 }
219
220 #[test]
221 fn parses_bare_line() {
222 assert_eq!(
223 parse_discovery_line("frust-devtools listening on 54321"),
224 Some(Discovery {
225 port: 54321,
226 token: None
227 })
228 );
229 }
230
231 #[test]
232 fn parses_with_trailing_text() {
233 assert_eq!(port_of("frust-devtools listening on 54321\n"), Some(54321));
234 assert_eq!(
235 port_of("frust-devtools listening on 54321 (waiting for client)"),
236 Some(54321)
237 );
238 }
239
240 #[test]
241 fn parses_with_logcat_style_prefix() {
242 let line = "08-10 12:00:00.123 1234 5678 I Frust : frust-devtools listening on 8123";
243 assert_eq!(port_of(line), Some(8123));
244 }
245
246 #[test]
247 fn parses_with_generic_timestamp_and_tag_prefix() {
248 let line = "[2026-08-10T12:00:00Z] app: frust-devtools listening on 65000";
249 assert_eq!(port_of(line), Some(65000));
250 }
251
252 #[test]
253 fn returns_none_when_prefix_absent() {
254 assert_eq!(parse_discovery_line("some unrelated log line"), None);
255 }
256
257 #[test]
258 fn returns_none_when_no_digits_follow_prefix() {
259 assert_eq!(
260 parse_discovery_line("frust-devtools listening on not-a-port"),
261 None
262 );
263 }
264
265 #[test]
266 fn returns_none_on_empty_line() {
267 assert_eq!(parse_discovery_line(""), None);
268 }
269
270 #[test]
271 fn format_and_parse_round_trip_with_a_token() {
272 let line = format_discovery_line(54321, Some("0123456789abcdef0123456789abcdef"));
273 assert_eq!(
274 parse_discovery_line(&line),
275 Some(Discovery {
276 port: 54321,
277 token: Some("0123456789abcdef0123456789abcdef".to_string()),
278 })
279 );
280 }
281
282 #[test]
283 fn format_and_parse_round_trip_without_a_token() {
284 let line = format_discovery_line(1234, None);
285 assert_eq!(
286 parse_discovery_line(&line),
287 Some(Discovery {
288 port: 1234,
289 token: None
290 })
291 );
292 }
293
294 #[test]
295 fn a_tokened_line_survives_a_logger_prefix_and_trailing_text() {
296 let line = format!(
297 "08-10 12:00:00.123 1234 5678 I Frust : {} (waiting)",
298 format_discovery_line(8123, Some("deadbeef"))
299 );
300 assert_eq!(
301 parse_discovery_line(&line),
302 Some(Discovery {
303 port: 8123,
304 token: Some("deadbeef".to_string()),
305 })
306 );
307 }
308
309 #[test]
310 fn a_truncated_token_marker_still_yields_the_port() {
311 assert_eq!(
314 parse_discovery_line("frust-devtools listening on 9000 token "),
315 Some(Discovery {
316 port: 9000,
317 token: None
318 })
319 );
320 }
321
322 #[test]
323 fn trailing_text_that_is_not_the_token_marker_is_not_a_token() {
324 assert_eq!(
325 parse_discovery_line("frust-devtools listening on 9000 tokenish stuff"),
326 Some(Discovery {
327 port: 9000,
328 token: None
329 })
330 );
331 }
332
333 #[test]
334 fn redact_token_basic() {
335 let line = "frust-devtools listening on 54321 token abc123def456";
336 let redacted = redact_discovery_token(line);
337 assert_eq!(
338 redacted.as_ref(),
339 "frust-devtools listening on 54321 token <redacted>"
340 );
341 }
342
343 #[test]
344 fn redact_token_returns_borrowed_when_no_token() {
345 use std::borrow::Cow;
346 let line = "frust-devtools listening on 54321";
347 let result = redact_discovery_token(line);
348 assert!(matches!(result, Cow::Borrowed(_)));
349 assert_eq!(result.as_ref(), line);
350 }
351
352 #[test]
353 fn redact_token_returns_borrowed_for_non_discovery_line() {
354 use std::borrow::Cow;
355 let line = "some unrelated log line";
356 let result = redact_discovery_token(line);
357 assert!(matches!(result, Cow::Borrowed(_)));
358 assert_eq!(result.as_ref(), line);
359 }
360
361 #[test]
362 fn redact_token_with_uppercase_token() {
363 let line = "frust-devtools listening on 8000 token ABCDEF0123456789";
364 let redacted = redact_discovery_token(line);
365 assert_eq!(
366 redacted.as_ref(),
367 "frust-devtools listening on 8000 token <redacted>"
368 );
369 }
370
371 #[test]
372 fn redact_token_with_lowercase_token() {
373 let line = "frust-devtools listening on 8000 token abcdef0123456789";
374 let redacted = redact_discovery_token(line);
375 assert_eq!(
376 redacted.as_ref(),
377 "frust-devtools listening on 8000 token <redacted>"
378 );
379 }
380
381 #[test]
382 fn redact_token_with_logcat_prefix() {
383 let line = "08-10 12:00:00.123 1234 5678 I Frust : frust-devtools listening on 8123 token deadbeef";
384 let redacted = redact_discovery_token(line);
385 assert_eq!(
386 redacted.as_ref(),
387 "08-10 12:00:00.123 1234 5678 I Frust : frust-devtools listening on 8123 token <redacted>"
388 );
389 }
390
391 #[test]
392 fn redact_token_with_generic_timestamp_prefix() {
393 let line =
394 "[2026-08-10T12:00:00Z] app: frust-devtools listening on 65000 token secret123abc";
395 let redacted = redact_discovery_token(line);
396 assert_eq!(
397 redacted.as_ref(),
398 "[2026-08-10T12:00:00Z] app: frust-devtools listening on 65000 token <redacted>"
399 );
400 }
401
402 #[test]
403 fn redact_token_with_trailing_text() {
404 let line = "frust-devtools listening on 54321 token abc123 (waiting for client)";
405 let redacted = redact_discovery_token(line);
406 assert_eq!(
407 redacted.as_ref(),
408 "frust-devtools listening on 54321 token <redacted> (waiting for client)"
409 );
410 }
411
412 #[test]
413 fn redact_token_with_newline() {
414 let line = "frust-devtools listening on 54321 token abc123\n";
415 let redacted = redact_discovery_token(line);
416 assert_eq!(
417 redacted.as_ref(),
418 "frust-devtools listening on 54321 token <redacted>\n"
419 );
420 }
421
422 #[test]
423 fn redact_token_marker_without_value_is_unchanged() {
424 use std::borrow::Cow;
425 let line = "frust-devtools listening on 9000 token ";
426 let result = redact_discovery_token(line);
427 assert!(matches!(result, Cow::Borrowed(_)));
429 }
430
431 #[test]
432 fn parse_discovery_still_extracts_real_token() {
433 let line = "frust-devtools listening on 54321 token abc123def456";
434 let discovery = parse_discovery_line(line).expect("should parse");
435 assert_eq!(discovery.port, 54321);
436 assert_eq!(discovery.token, Some("abc123def456".to_string()));
437 }
438
439 #[test]
440 fn redact_token_format_discovery_line_round_trip() {
441 let line = format_discovery_line(54321, Some("0123456789abcdef0123456789abcdef"));
443 let redacted = redact_discovery_token(&line);
444
445 assert!(redacted.contains("<redacted>"));
447 assert!(!redacted.contains("0123456789abcdef0123456789abcdef"));
448
449 let discovery = parse_discovery_line(&line).expect("should parse");
451 assert_eq!(discovery.port, 54321);
452 assert_eq!(
453 discovery.token,
454 Some("0123456789abcdef0123456789abcdef".to_string())
455 );
456 }
457}