Skip to main content

track_core/
api_notify.rs

1use std::time::Duration;
2
3use crate::types::ApiRuntimeConfig;
4
5const NOTIFY_TIMEOUT: Duration = Duration::from_millis(250);
6
7// =============================================================================
8// Local API Notification
9// =============================================================================
10//
11// The CLI owns task creation, but the browser UI should still refresh when a
12// new task lands on disk. We keep that integration deliberately lightweight:
13// the CLI performs a best-effort POST to the local API, and the caller decides
14// whether failures matter. For this project they do not block task capture.
15//
16// The route stays tiny and local-only, but a dedicated HTTP client is still
17// the better tool for the job. That keeps the integration reliable without
18// forcing this crate to maintain its own miniature HTTP implementation.
19pub fn notify_task_changed(api: &ApiRuntimeConfig) -> Result<(), ureq::Error> {
20    let url = format!("http://127.0.0.1:{}/api/events/tasks-changed", api.port);
21    let agent: ureq::Agent = ureq::Agent::config_builder()
22        .timeout_global(Some(NOTIFY_TIMEOUT))
23        .build()
24        .into();
25
26    agent.post(&url).send_empty()?;
27    Ok(())
28}
29
30#[cfg(test)]
31mod tests {
32    use std::io::{Read, Write};
33    use std::net::TcpListener;
34    use std::sync::mpsc;
35    use std::thread;
36    use std::time::Duration;
37
38    use crate::types::ApiRuntimeConfig;
39
40    use super::notify_task_changed;
41
42    #[test]
43    fn posts_task_change_notification_to_the_local_api() {
44        let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
45        let port = listener
46            .local_addr()
47            .expect("listener should expose local address")
48            .port();
49        let (sender, receiver) = mpsc::channel();
50
51        let server = thread::spawn(move || {
52            let (mut stream, _) = listener.accept().expect("listener should accept");
53            let mut buffer = [0u8; 1024];
54            let bytes_read = stream
55                .read(&mut buffer)
56                .expect("request should be readable");
57            stream
58                .write_all(
59                    b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
60                )
61                .expect("response should be writable");
62            sender
63                .send(String::from_utf8_lossy(&buffer[..bytes_read]).into_owned())
64                .expect("request should reach the test thread");
65        });
66
67        notify_task_changed(&ApiRuntimeConfig { port }).expect("notification should succeed");
68
69        let request = receiver
70            .recv_timeout(Duration::from_secs(1))
71            .expect("notification should be received");
72
73        // The observable contract we care about is the route and verb. Header
74        // framing belongs to the HTTP client implementation and can vary
75        // across versions without changing the integration behavior we rely on.
76        assert!(request.starts_with("POST /api/events/tasks-changed HTTP/1.1"));
77        server.join().expect("server thread should exit cleanly");
78    }
79}