返回 CodeWhale
dev_server_readiness.rs
根目录 / crates / tui / src / tools / dev_server_readiness.rs
1 //! Local dev-server readiness tool.
2 //!
3 //! This intentionally covers only the narrow "is my localhost dev server ready
4 //! yet?" primitive. It is not process supervision and it rejects non-loopback
5 //! targets so agents do not turn it into a general network probe.
6
7 use super::spec::{
8 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
9 optional_str, optional_u64, required_u64,
10 };
11 use async_trait::async_trait;
12 use serde::Serialize;
13 use serde_json::{Value, json};
14 use std::future::Future;
15 use std::net::IpAddr;
16 use std::time::Duration;
17 use tokio::net::TcpStream;
18 use tokio::time::{Instant, sleep, timeout};
19
20 const DEFAULT_HOST: &str = "127.0.0.1";
21 const DEFAULT_TIMEOUT_MS: u64 = 30_000;
22 const HARD_MAX_TIMEOUT_MS: u64 = 120_000;
23 const DEFAULT_POLL_INTERVAL_MS: u64 = 250;
24 const MIN_POLL_INTERVAL_MS: u64 = 10;
25 const MAX_POLL_INTERVAL_MS: u64 = 5_000;
26 const TCP_CONNECT_ATTEMPT_TIMEOUT_MS: u64 = 2_000;
27 const HTTP_HEALTHCHECK_ATTEMPT_TIMEOUT_MS: u64 = 10_000;
28
29 pub struct WaitForDevServerTool;
30
31 #[derive(Debug, Clone)]
32 struct ReadinessRequest {
33 host: String,
34 port: u16,
35 url: Option<reqwest::Url>,
36 timeout: Duration,
37 poll_interval: Duration,
38 }
39
40 #[derive(Debug, Serialize)]
41 struct ReadinessOutput {
42 ready: bool,
43 phase: &'static str,
44 target: String,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 url: Option<String>,
47 elapsed_ms: u64,
48 timed_out: bool,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 last_error: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 last_status: Option<u16>,
53 }
54
55 #[async_trait]
56 impl ToolSpec for WaitForDevServerTool {
57 fn name(&self) -> &'static str {
58 "wait_for_dev_server"
59 }
60
61 fn model_visible(&self) -> bool {
62 false
63 }
64
65 fn description(&self) -> &'static str {
66 "Wait for a local dev server to become ready. Polls a loopback TCP port, optionally then an HTTP(S) health URL on the same port, with bounded timeout and structured success/failure output."
67 }
68
69 fn input_schema(&self) -> Value {
70 json!({
71 "type": "object",
72 "properties": {
73 "host": {
74 "type": "string",
75 "description": "Loopback host to poll (default 127.0.0.1). Allowed: localhost, 127.0.0.1, ::1, or another loopback IP."
76 },
77 "port": {
78 "type": "integer",
79 "minimum": 1,
80 "maximum": 65535,
81 "description": "TCP port to wait for."
82 },
83 "url": {
84 "type": "string",
85 "description": "Optional HTTP/HTTPS loopback healthcheck URL on the same port. 2xx and 3xx statuses count as ready."
86 },
87 "timeout_ms": {
88 "type": "integer",
89 "description": "Maximum time to wait in milliseconds (default 30000; hard max 120000)."
90 },
91 "poll_interval_ms": {
92 "type": "integer",
93 "description": "Delay between probes in milliseconds (default 250; clamped to 10..5000)."
94 }
95 },
96 "required": ["port"],
97 "additionalProperties": false
98 })
99 }
100
101 fn capabilities(&self) -> Vec<ToolCapability> {
102 vec![ToolCapability::ReadOnly, ToolCapability::Network]
103 }
104
105 fn approval_requirement(&self) -> ApprovalRequirement {
106 ApprovalRequirement::Auto
107 }
108
109 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
110 let request = parse_request(&input)?;
111 let output = wait_for_readiness(request, context).await?;
112 readiness_result(output)
113 }
114 }
115
116 fn parse_request(input: &Value) -> Result<ReadinessRequest, ToolError> {
117 let host = normalize_loopback_host(optional_str(input, "host")?.unwrap_or(DEFAULT_HOST))?;
118 let port = parse_port(input)?;
119 let url = parse_healthcheck_url(input, port)?;
120 let timeout = Duration::from_millis(
121 optional_u64(input, "timeout_ms", DEFAULT_TIMEOUT_MS)?.min(HARD_MAX_TIMEOUT_MS),
122 );
123 let poll_interval = Duration::from_millis(
124 optional_u64(input, "poll_interval_ms", DEFAULT_POLL_INTERVAL_MS)?
125 .clamp(MIN_POLL_INTERVAL_MS, MAX_POLL_INTERVAL_MS),
126 );
127
128 Ok(ReadinessRequest {
129 host,
130 port,
131 url,
132 timeout,
133 poll_interval,
134 })
135 }
136
137 fn parse_port(input: &Value) -> Result<u16, ToolError> {
138 let raw = required_u64(input, "port")?;
139 if raw == 0 || raw > u16::MAX as u64 {
140 return Err(ToolError::invalid_input(
141 "`port` must be between 1 and 65535",
142 ));
143 }
144 Ok(raw as u16)
145 }
146
147 fn normalize_loopback_host(host: &str) -> Result<String, ToolError> {
148 let trimmed = host.trim();
149 if trimmed.is_empty() {
150 return Err(ToolError::invalid_input("`host` cannot be empty"));
151 }
152 let unbracketed = trimmed
153 .strip_prefix('[')
154 .and_then(|value| value.strip_suffix(']'))
155 .unwrap_or(trimmed);
156 let lowered = unbracketed.to_ascii_lowercase();
157 if lowered == "localhost" {
158 return Ok(DEFAULT_HOST.to_string());
159 }
160 let ip = lowered.parse::<IpAddr>().map_err(|_| {
161 ToolError::invalid_input("`host` must be localhost or a loopback IP address")
162 })?;
163 if !ip.is_loopback() {
164 return Err(ToolError::invalid_input(
165 "`host` must be localhost or a loopback IP address",
166 ));
167 }
168 Ok(ip.to_string())
169 }
170
171 fn parse_healthcheck_url(input: &Value, port: u16) -> Result<Option<reqwest::Url>, ToolError> {
172 let Some(url) = optional_str(input, "url")?
173 .map(str::trim)
174 .filter(|url| !url.is_empty())
175 else {
176 return Ok(None);
177 };
178 let mut parsed = reqwest::Url::parse(url)
179 .map_err(|err| ToolError::invalid_input(format!("invalid `url`: {err}")))?;
180 if parsed.scheme() != "http" && parsed.scheme() != "https" {
181 return Err(ToolError::invalid_input(
182 "`url` must use http:// or https://",
183 ));
184 }
185 if !parsed.username().is_empty() || parsed.password().is_some() {
186 return Err(ToolError::invalid_input(
187 "`url` must not include credentials",
188 ));
189 }
190 let host = parsed
191 .host_str()
192 .ok_or_else(|| ToolError::invalid_input("`url` must include a host"))?;
193 let normalized_host = normalize_loopback_host(host).map_err(|_| {
194 ToolError::invalid_input("`url` host must be localhost or a loopback IP address")
195 })?;
196 let url_port = parsed
197 .port_or_known_default()
198 .ok_or_else(|| ToolError::invalid_input("`url` must include or imply a port"))?;
199 if url_port != port {
200 return Err(ToolError::invalid_input(
201 "`url` port must match the `port` readiness target",
202 ));
203 }
204 parsed
205 .set_host(Some(&normalized_host))
206 .map_err(|_| ToolError::invalid_input("`url` host must be a valid loopback target"))?;
207 Ok(Some(parsed))
208 }
209
210 async fn wait_for_readiness(
211 request: ReadinessRequest,
212 context: &ToolContext,
213 ) -> Result<ReadinessOutput, ToolError> {
214 let started = Instant::now();
215 let deadline = started + request.timeout;
216 let target = target_label(&request.host, request.port);
217
218 if let Some(timeout) = wait_for_tcp(&request, &target, started, deadline, context).await? {
219 return Ok(timeout);
220 }
221
222 let Some(url) = request.url.clone() else {
223 return Ok(ReadinessOutput {
224 ready: true,
225 phase: "ready",
226 target,
227 url: None,
228 elapsed_ms: elapsed_ms(started),
229 timed_out: false,
230 last_error: None,
231 last_status: None,
232 });
233 };
234
235 wait_for_http(&request, url, &target, started, deadline, context).await
236 }
237
238 async fn wait_for_tcp(
239 request: &ReadinessRequest,
240 target: &str,
241 started: Instant,
242 deadline: Instant,
243 context: &ToolContext,
244 ) -> Result<Option<ReadinessOutput>, ToolError> {
245 let mut last_error = None;
246
247 loop {
248 check_cancelled(context)?;
249 match run_until_deadline(
250 deadline,
251 Duration::from_millis(TCP_CONNECT_ATTEMPT_TIMEOUT_MS),
252 TcpStream::connect((request.host.as_str(), request.port)),
253 )
254 .await
255 {
256 Ok(Ok(_stream)) => break,
257 Ok(Err(err)) => last_error = Some(err.to_string()),
258 Err(()) if last_error.is_none() => {
259 last_error = Some("connection attempt timed out".to_string());
260 }
261 Err(()) => {}
262 }
263
264 if Instant::now() >= deadline {
265 return Ok(Some(ReadinessOutput {
266 ready: false,
267 phase: "tcp",
268 target: target.to_string(),
269 url: request.url.as_ref().map(ToString::to_string),
270 elapsed_ms: elapsed_ms(started),
271 timed_out: true,
272 last_error,
273 last_status: None,
274 }));
275 }
276
277 sleep_until_next_poll(deadline, request.poll_interval, context).await?;
278 }
279
280 Ok(None)
281 }
282
283 async fn wait_for_http(
284 request: &ReadinessRequest,
285 url: reqwest::Url,
286 target: &str,
287 started: Instant,
288 deadline: Instant,
289 context: &ToolContext,
290 ) -> Result<ReadinessOutput, ToolError> {
291 let client = crate::tls::reqwest_client_builder()
292 .timeout(request.timeout)
293 .redirect(reqwest::redirect::Policy::none())
294 .no_proxy()
295 .build()
296 .map_err(|err| {
297 ToolError::execution_failed(format!("failed to build HTTP client: {err}"))
298 })?;
299 let mut last_status = None;
300 let mut last_error = None;
301
302 loop {
303 check_cancelled(context)?;
304 match run_until_deadline(
305 deadline,
306 Duration::from_millis(HTTP_HEALTHCHECK_ATTEMPT_TIMEOUT_MS),
307 client.get(url.clone()).send(),
308 )
309 .await
310 {
311 Ok(Ok(response)) => {
312 let status = response.status();
313 last_status = Some(status.as_u16());
314 last_error = None;
315 if status.is_success() || status.is_redirection() {
316 return Ok(ReadinessOutput {
317 ready: true,
318 phase: "ready",
319 target: target.to_string(),
320 url: Some(url.to_string()),
321 elapsed_ms: elapsed_ms(started),
322 timed_out: false,
323 last_error: None,
324 last_status,
325 });
326 }
327 }
328 Ok(Err(err)) => {
329 last_error = Some(if err.is_timeout() {
330 "healthcheck request timed out".to_string()
331 } else {
332 err.to_string()
333 });
334 }
335 Err(()) if last_error.is_none() && last_status.is_none() => {
336 last_error = Some("healthcheck request timed out".to_string());
337 }
338 Err(()) => {}
339 }
340
341 if Instant::now() >= deadline {
342 return Ok(ReadinessOutput {
343 ready: false,
344 phase: "http",
345 target: target.to_string(),
346 url: Some(url.to_string()),
347 elapsed_ms: elapsed_ms(started),
348 timed_out: true,
349 last_error,
350 last_status,
351 });
352 }
353
354 sleep_until_next_poll(deadline, request.poll_interval, context).await?;
355 }
356 }
357
358 async fn run_until_deadline<T, F>(
359 deadline: Instant,
360 attempt_timeout: Duration,
361 future: F,
362 ) -> Result<T, ()>
363 where
364 F: Future<Output = T>,
365 {
366 let remaining = deadline.saturating_duration_since(Instant::now());
367 if remaining.is_zero() {
368 return Err(());
369 }
370 timeout(remaining.min(attempt_timeout), future)
371 .await
372 .map_err(|_| ())
373 }
374
375 async fn sleep_until_next_poll(
376 deadline: Instant,
377 poll_interval: Duration,
378 context: &ToolContext,
379 ) -> Result<(), ToolError> {
380 let remaining = deadline.saturating_duration_since(Instant::now());
381 if remaining.is_zero() {
382 return Ok(());
383 }
384 let delay = remaining.min(poll_interval);
385 if let Some(token) = context.cancel_token.as_ref() {
386 tokio::select! {
387 () = token.cancelled() => Err(ToolError::cancelled("wait_for_dev_server cancelled")),
388 () = sleep(delay) => Ok(()),
389 }
390 } else {
391 sleep(delay).await;
392 Ok(())
393 }
394 }
395
396 fn check_cancelled(context: &ToolContext) -> Result<(), ToolError> {
397 if context
398 .cancel_token
399 .as_ref()
400 .is_some_and(tokio_util::sync::CancellationToken::is_cancelled)
401 {
402 return Err(ToolError::cancelled("wait_for_dev_server cancelled"));
403 }
404 Ok(())
405 }
406
407 fn target_label(host: &str, port: u16) -> String {
408 if host.contains(':') {
409 format!("[{host}]:{port}")
410 } else {
411 format!("{host}:{port}")
412 }
413 }
414
415 fn elapsed_ms(started: Instant) -> u64 {
416 started.elapsed().as_millis().try_into().unwrap_or(u64::MAX)
417 }
418
419 fn readiness_result(output: ReadinessOutput) -> Result<ToolResult, ToolError> {
420 let success = output.ready;
421 let metadata = json!({
422 "ready": output.ready,
423 "phase": output.phase,
424 "target": output.target,
425 "url": output.url,
426 "elapsed_ms": output.elapsed_ms,
427 "timed_out": output.timed_out,
428 "last_error": output.last_error,
429 "last_status": output.last_status,
430 });
431 let content = serde_json::to_string_pretty(&output).map_err(|err| {
432 ToolError::execution_failed(format!("failed to serialize readiness result: {err}"))
433 })?;
434 Ok(ToolResult {
435 content,
436 success,
437 metadata: Some(metadata),
438 })
439 }
440
441 #[cfg(test)]
442 mod tests {
443 use super::*;
444 use crate::tools::spec::{ToolContext, ToolResult, ToolSpec};
445 use serde_json::{Value, json};
446 use std::path::PathBuf;
447 use tokio::io::{AsyncReadExt, AsyncWriteExt};
448 use tokio::net::{TcpListener, TcpSocket};
449 use tokio::task::JoinHandle;
450
451 fn ctx() -> ToolContext {
452 ToolContext::new(PathBuf::from("."))
453 }
454
455 async fn run_tool(input: Value) -> (ToolResult, Value) {
456 let tool = WaitForDevServerTool;
457 let result = tool.execute(input, &ctx()).await.expect("tool result");
458 let payload = serde_json::from_str(&result.content).expect("json result");
459 (result, payload)
460 }
461
462 async fn bind_tcp_listener() -> (TcpListener, u16) {
463 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
464 let port = listener.local_addr().unwrap().port();
465 (listener, port)
466 }
467
468 fn reserve_unlistened_tcp_port() -> (TcpSocket, u16) {
469 let socket = TcpSocket::new_v4().unwrap();
470 socket.bind("127.0.0.1:0".parse().unwrap()).unwrap();
471 let port = socket.local_addr().unwrap().port();
472 (socket, port)
473 }
474
475 fn spawn_http_server(status: &'static str) -> (u16, JoinHandle<()>) {
476 let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
477 let port = std_listener.local_addr().unwrap().port();
478 std_listener.set_nonblocking(true).unwrap();
479 let listener = TcpListener::from_std(std_listener).unwrap();
480 let handle = tokio::spawn(async move {
481 loop {
482 let Ok((mut stream, _addr)) = listener.accept().await else {
483 continue;
484 };
485 tokio::spawn(async move {
486 let mut buf = [0_u8; 512];
487 let _ = stream.read(&mut buf).await;
488 let response = format!(
489 "HTTP/1.1 {status}\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok"
490 );
491 let _ = stream.write_all(response.as_bytes()).await;
492 });
493 }
494 });
495 (port, handle)
496 }
497
498 fn spawn_hanging_http_server() -> (u16, JoinHandle<()>) {
499 let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
500 let port = std_listener.local_addr().unwrap().port();
501 std_listener.set_nonblocking(true).unwrap();
502 let listener = TcpListener::from_std(std_listener).unwrap();
503 let handle = tokio::spawn(async move {
504 loop {
505 let Ok((stream, _addr)) = listener.accept().await else {
506 continue;
507 };
508 tokio::spawn(async move {
509 let _stream = stream;
510 sleep(Duration::from_secs(60)).await;
511 });
512 }
513 });
514 (port, handle)
515 }
516
517 fn spawn_delayed_http_server(delay: Duration) -> (u16, JoinHandle<()>) {
518 let std_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
519 let port = std_listener.local_addr().unwrap().port();
520 std_listener.set_nonblocking(true).unwrap();
521 let listener = TcpListener::from_std(std_listener).unwrap();
522 let handle = tokio::spawn(async move {
523 loop {
524 let Ok((mut stream, _addr)) = listener.accept().await else {
525 continue;
526 };
527 tokio::spawn(async move {
528 let mut buf = [0_u8; 512];
529 let _ = stream.read(&mut buf).await;
530 sleep(delay).await;
531 let response =
532 "HTTP/1.1 204 No Content\r\ncontent-length: 0\r\nconnection: close\r\n\r\n";
533 let _ = stream.write_all(response.as_bytes()).await;
534 });
535 }
536 });
537 (port, handle)
538 }
539
540 #[tokio::test]
541 async fn waits_until_tcp_port_accepts_connections() {
542 let (listener, port) = bind_tcp_listener().await;
543 let accept = tokio::spawn(async move {
544 let _ = listener.accept().await;
545 });
546
547 let (result, payload) = run_tool(json!({
548 "host": "127.0.0.1",
549 "port": port,
550 "timeout_ms": 1_000,
551 "poll_interval_ms": 10
552 }))
553 .await;
554
555 assert!(result.success);
556 assert_eq!(payload["ready"], true);
557 assert_eq!(payload["phase"], "ready");
558 assert_eq!(payload["target"], format!("127.0.0.1:{port}"));
559 assert!(payload["elapsed_ms"].as_u64().is_some());
560 let _ = accept.await;
561 }
562
563 #[tokio::test]
564 async fn reports_timeout_for_refused_tcp_port() {
565 // Keep the port reserved without listening. Dropping a listener first
566 // lets a parallel test or process claim the port before this probe.
567 let (_reservation, port) = reserve_unlistened_tcp_port();
568
569 let (result, payload) = run_tool(json!({
570 "host": "127.0.0.1",
571 "port": port,
572 "timeout_ms": 80,
573 "poll_interval_ms": 10
574 }))
575 .await;
576
577 assert!(!result.success);
578 assert_eq!(payload["ready"], false);
579 assert_eq!(payload["phase"], "tcp");
580 assert_eq!(payload["timed_out"], true);
581 assert_eq!(payload["target"], format!("127.0.0.1:{port}"));
582 assert!(payload["elapsed_ms"].as_u64().is_some());
583 assert!(
584 payload["last_error"]
585 .as_str()
586 .is_some_and(|message| !message.is_empty())
587 );
588 }
589
590 #[tokio::test]
591 async fn waits_for_http_success_status_after_tcp_ready() {
592 let (port, server) = spawn_http_server("204 No Content");
593
594 let (result, payload) = run_tool(json!({
595 "host": "127.0.0.1",
596 "port": port,
597 "url": format!("http://127.0.0.1:{port}/health"),
598 "timeout_ms": 1_000,
599 "poll_interval_ms": 10
600 }))
601 .await;
602
603 assert!(result.success);
604 assert_eq!(payload["ready"], true);
605 assert_eq!(payload["phase"], "ready");
606 assert_eq!(payload["last_status"], 204);
607 server.abort();
608 }
609
610 #[tokio::test]
611 async fn reports_last_http_status_when_healthcheck_never_succeeds() {
612 let (port, server) = spawn_http_server("503 Service Unavailable");
613
614 let (result, payload) = run_tool(json!({
615 "host": "127.0.0.1",
616 "port": port,
617 "url": format!("http://127.0.0.1:{port}/health"),
618 "timeout_ms": 120,
619 "poll_interval_ms": 10
620 }))
621 .await;
622
623 assert!(!result.success);
624 assert_eq!(payload["ready"], false);
625 assert_eq!(payload["phase"], "http");
626 assert_eq!(payload["timed_out"], true);
627 assert_eq!(payload["last_status"], 503);
628 server.abort();
629 }
630
631 #[tokio::test]
632 async fn reports_http_timeout_when_healthcheck_hangs() {
633 let (port, server) = spawn_hanging_http_server();
634
635 let (result, payload) = run_tool(json!({
636 "host": "127.0.0.1",
637 "port": port,
638 "url": format!("http://127.0.0.1:{port}/health"),
639 "timeout_ms": 80,
640 "poll_interval_ms": 10
641 }))
642 .await;
643
644 assert!(!result.success);
645 assert_eq!(payload["ready"], false);
646 assert_eq!(payload["phase"], "http");
647 assert_eq!(payload["timed_out"], true);
648 assert!(payload["last_status"].is_null());
649 assert_eq!(
650 payload["last_error"].as_str(),
651 Some("healthcheck request timed out")
652 );
653 server.abort();
654 }
655
656 #[tokio::test]
657 async fn slow_healthcheck_can_complete_across_short_poll_intervals() {
658 let (port, server) = spawn_delayed_http_server(Duration::from_millis(600));
659
660 let (result, payload) = run_tool(json!({
661 "host": "127.0.0.1",
662 "port": port,
663 "url": format!("http://127.0.0.1:{port}/health"),
664 "timeout_ms": 2_000,
665 "poll_interval_ms": 50
666 }))
667 .await;
668
669 assert!(result.success);
670 assert_eq!(payload["ready"], true);
671 assert_eq!(payload["phase"], "ready");
672 assert_eq!(payload["last_status"], 204);
673 server.abort();
674 }
675
676 #[test]
677 fn canonicalizes_localhost_to_loopback_literals() {
678 let request = parse_request(&json!({
679 "host": "localhost",
680 "port": 8080,
681 "url": "http://localhost:8080/health"
682 }))
683 .unwrap();
684
685 assert_eq!(request.host, "127.0.0.1");
686 let url = request.url.unwrap();
687 assert_eq!(url.host_str(), Some("127.0.0.1"));
688 assert_eq!(url.as_str(), "http://127.0.0.1:8080/health");
689 }
690
691 #[tokio::test]
692 async fn rejects_non_loopback_targets() {
693 let tool = WaitForDevServerTool;
694
695 let err = tool
696 .execute(
697 json!({
698 "host": "example.com",
699 "port": 80,
700 "timeout_ms": 10
701 }),
702 &ctx(),
703 )
704 .await
705 .unwrap_err();
706 assert!(format!("{err}").contains("loopback"));
707
708 let err = tool
709 .execute(
710 json!({
711 "host": "127.0.0.1",
712 "port": 8080,
713 "url": "https://example.com/health",
714 "timeout_ms": 10
715 }),
716 &ctx(),
717 )
718 .await
719 .unwrap_err();
720 assert!(format!("{err}").contains("loopback"));
721 }
722
723 #[tokio::test]
724 async fn rejects_healthcheck_url_credentials() {
725 let tool = WaitForDevServerTool;
726
727 let err = tool
728 .execute(
729 json!({
730 "host": "127.0.0.1",
731 "port": 8080,
732 "url": "http://user:secret@127.0.0.1:8080/health",
733 "timeout_ms": 10
734 }),
735 &ctx(),
736 )
737 .await
738 .unwrap_err();
739 assert!(format!("{err}").contains("credentials"));
740 }
741 }
742
742 lines RUST