Skip to main content

tsk_core/
lib.rs

1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3use std::path::{Path, PathBuf};
4
5// ---------------------------------------------------------------------------
6// Priority
7// ---------------------------------------------------------------------------
8
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10pub enum Priority {
11    #[serde(rename = "BG")]
12    Background,
13    #[serde(rename = "PRIO")]
14    Priority,
15    #[serde(rename = "INC")]
16    Incident,
17}
18
19impl Priority {
20    pub fn full_name(&self) -> &'static str {
21        match self {
22            Priority::Background => "background",
23            Priority::Priority => "priority",
24            Priority::Incident => "incident",
25        }
26    }
27
28    pub fn abbrev(&self) -> &'static str {
29        match self {
30            Priority::Background => "BG",
31            Priority::Priority => "PRIO",
32            Priority::Incident => "INC",
33        }
34    }
35}
36
37impl std::fmt::Display for Priority {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        write!(f, "{}", self.abbrev())
40    }
41}
42
43impl std::str::FromStr for Priority {
44    type Err = String;
45
46    fn from_str(s: &str) -> Result<Self, Self::Err> {
47        match s {
48            "BG" => Ok(Priority::Background),
49            "PRIO" => Ok(Priority::Priority),
50            "INC" => Ok(Priority::Incident),
51            _ => Err(format!("Invalid priority '{}'. Use BG, PRIO, or INC", s)),
52        }
53    }
54}
55
56// ---------------------------------------------------------------------------
57// Thread state
58// ---------------------------------------------------------------------------
59
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum ThreadState {
63    Active,
64    Paused,
65}
66
67impl std::fmt::Display for ThreadState {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            ThreadState::Active => write!(f, "active"),
71            ThreadState::Paused => write!(f, "paused"),
72        }
73    }
74}
75
76// ---------------------------------------------------------------------------
77// Thread model
78// ---------------------------------------------------------------------------
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81pub struct Thread {
82    pub id: u32,
83    pub slug: String,
84    pub state: ThreadState,
85    pub priority: Priority,
86    pub description: String,
87}
88
89impl Thread {
90    /// Zero-padded 4-digit string representation of the id (e.g. "0001").
91    pub fn id_str(&self) -> String {
92        format!("{:04}", self.id)
93    }
94}
95
96// ---------------------------------------------------------------------------
97// Event types
98// ---------------------------------------------------------------------------
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ThreadCreatedEvent {
102    pub event: String,
103    pub id: u32,
104    pub slug: String,
105    pub priority: Priority,
106    pub description: String,
107    pub timestamp: u64,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ThreadSwitchedEvent {
112    pub event: String,
113    pub active_id: u32,
114    pub paused_ids: Vec<u32>,
115    pub timestamp: u64,
116}
117
118// ---------------------------------------------------------------------------
119// JSON-RPC 2.0 types
120// ---------------------------------------------------------------------------
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct JsonRpcRequest {
124    pub jsonrpc: String,
125    pub id: u64,
126    pub method: String,
127    pub params: serde_json::Value,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct JsonRpcResponse {
132    pub jsonrpc: String,
133    pub id: u64,
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub result: Option<serde_json::Value>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub error: Option<JsonRpcError>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct JsonRpcError {
142    pub code: i64,
143    pub message: String,
144}
145
146impl JsonRpcResponse {
147    pub fn success(id: u64, result: serde_json::Value) -> Self {
148        Self {
149            jsonrpc: "2.0".to_string(),
150            id,
151            result: Some(result),
152            error: None,
153        }
154    }
155
156    pub fn error(id: u64, code: i64, message: impl Into<String>) -> Self {
157        Self {
158            jsonrpc: "2.0".to_string(),
159            id,
160            result: None,
161            error: Some(JsonRpcError {
162                code,
163                message: message.into(),
164            }),
165        }
166    }
167}
168
169// ---------------------------------------------------------------------------
170// Storage paths
171// ---------------------------------------------------------------------------
172
173pub fn tsk_dir(project_root: &Path) -> PathBuf {
174    project_root.join("tsk")
175}
176
177pub fn event_log_dir(project_root: &Path) -> PathBuf {
178    tsk_dir(project_root).join("event-log")
179}
180
181pub fn event_log_path(project_root: &Path) -> PathBuf {
182    event_log_dir(project_root).join("events.ndjson")
183}
184
185pub fn threads_dir(project_root: &Path) -> PathBuf {
186    tsk_dir(project_root).join("threads")
187}
188
189pub fn index_path(project_root: &Path) -> PathBuf {
190    threads_dir(project_root).join("index.json")
191}
192
193/// Thread working directory: `tsk/threads/{id:04}-{slug}/`
194pub fn thread_dir(project_root: &Path, id: u32, slug: &str) -> PathBuf {
195    threads_dir(project_root).join(format!("{:04}-{}", id, slug))
196}
197
198// ---------------------------------------------------------------------------
199// Socket path
200// ---------------------------------------------------------------------------
201
202/// Derives a stable socket path from the project root directory.
203/// Uses first 8 chars of SHA-256 of the path string, so multiple projects
204/// can have daemons running simultaneously.
205pub fn socket_path(project_root: &Path) -> PathBuf {
206    let mut hasher = Sha256::new();
207    hasher.update(project_root.to_string_lossy().as_bytes());
208    let result = hasher.finalize();
209    let hash: String = result.iter().map(|b| format!("{:02x}", b)).collect();
210    PathBuf::from(format!("/tmp/tsk-{}.sock", &hash[..8]))
211}
212
213// ---------------------------------------------------------------------------
214// Client helper
215// ---------------------------------------------------------------------------
216
217/// Connect to the daemon socket, send a JSON-RPC request, return the result.
218pub fn send_request(
219    socket: &Path,
220    method: &str,
221    params: serde_json::Value,
222) -> Result<serde_json::Value, String> {
223    use std::io::{BufRead, BufReader, Write};
224    use std::os::unix::net::UnixStream;
225
226    let mut stream = UnixStream::connect(socket).map_err(|_| {
227        "tskd is not running. Start it with: tskd".to_string()
228    })?;
229
230    stream
231        .set_read_timeout(Some(std::time::Duration::from_secs(5)))
232        .map_err(|e| format!("Failed to set timeout: {}", e))?;
233
234    let request = JsonRpcRequest {
235        jsonrpc: "2.0".to_string(),
236        id: 1,
237        method: method.to_string(),
238        params,
239    };
240
241    let mut line =
242        serde_json::to_string(&request).map_err(|e| format!("Serialisation error: {}", e))?;
243    line.push('\n');
244
245    stream
246        .write_all(line.as_bytes())
247        .map_err(|e| format!("Write error: {}", e))?;
248
249    let reader = BufReader::new(&stream);
250    let response_line = reader
251        .lines()
252        .next()
253        .ok_or("No response from daemon")?
254        .map_err(|e| format!("Read error: {}", e))?;
255
256    let response: JsonRpcResponse = serde_json::from_str(&response_line)
257        .map_err(|e| format!("Parse error: {}", e))?;
258
259    if let Some(err) = response.error {
260        return Err(err.message);
261    }
262
263    response.result.ok_or_else(|| "Empty response".to_string())
264}
265
266// ---------------------------------------------------------------------------
267// Unit tests
268// ---------------------------------------------------------------------------
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    // --- Thread id_str ---
275
276    #[test]
277    fn thread_id_str_zero_pads_to_four_digits() {
278        let t = Thread {
279            id: 1,
280            slug: "fix-login".to_string(),
281            state: ThreadState::Active,
282            priority: Priority::Priority,
283            description: "Fix it".to_string(),
284        };
285        assert_eq!(t.id_str(), "0001");
286    }
287
288    #[test]
289    fn thread_id_str_handles_larger_numbers() {
290        let t = Thread {
291            id: 42,
292            slug: "foo".to_string(),
293            state: ThreadState::Paused,
294            priority: Priority::Background,
295            description: "".to_string(),
296        };
297        assert_eq!(t.id_str(), "0042");
298    }
299
300    // --- Priority ---
301
302    #[test]
303    fn priority_serialises_to_abbreviations() {
304        assert_eq!(
305            serde_json::to_string(&Priority::Background).unwrap(),
306            "\"BG\""
307        );
308        assert_eq!(
309            serde_json::to_string(&Priority::Priority).unwrap(),
310            "\"PRIO\""
311        );
312        assert_eq!(
313            serde_json::to_string(&Priority::Incident).unwrap(),
314            "\"INC\""
315        );
316    }
317
318    #[test]
319    fn priority_deserialises_from_abbreviations() {
320        assert_eq!(
321            serde_json::from_str::<Priority>("\"BG\"").unwrap(),
322            Priority::Background
323        );
324        assert_eq!(
325            serde_json::from_str::<Priority>("\"PRIO\"").unwrap(),
326            Priority::Priority
327        );
328        assert_eq!(
329            serde_json::from_str::<Priority>("\"INC\"").unwrap(),
330            Priority::Incident
331        );
332    }
333
334    #[test]
335    fn priority_from_str_parses_abbreviations() {
336        assert_eq!("BG".parse::<Priority>().unwrap(), Priority::Background);
337        assert_eq!("PRIO".parse::<Priority>().unwrap(), Priority::Priority);
338        assert_eq!("INC".parse::<Priority>().unwrap(), Priority::Incident);
339        assert!("unknown".parse::<Priority>().is_err());
340    }
341
342    #[test]
343    fn priority_expands_to_full_names() {
344        assert_eq!(Priority::Background.full_name(), "background");
345        assert_eq!(Priority::Priority.full_name(), "priority");
346        assert_eq!(Priority::Incident.full_name(), "incident");
347    }
348
349    #[test]
350    fn priority_display_shows_abbreviation() {
351        assert_eq!(format!("{}", Priority::Background), "BG");
352        assert_eq!(format!("{}", Priority::Priority), "PRIO");
353        assert_eq!(format!("{}", Priority::Incident), "INC");
354    }
355
356    // --- JSON-RPC ---
357
358    #[test]
359    fn jsonrpc_request_serialises_correctly() {
360        let req = JsonRpcRequest {
361            jsonrpc: "2.0".to_string(),
362            id: 1,
363            method: "thread.create".to_string(),
364            params: serde_json::json!({"slug": "fix-login"}),
365        };
366        let s = serde_json::to_string(&req).unwrap();
367        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
368        assert_eq!(v["jsonrpc"], "2.0");
369        assert_eq!(v["id"], 1);
370        assert_eq!(v["method"], "thread.create");
371        assert_eq!(v["params"]["slug"], "fix-login");
372    }
373
374    #[test]
375    fn jsonrpc_success_response_has_result_no_error() {
376        let resp = JsonRpcResponse::success(1, serde_json::json!({"ok": true}));
377        let s = serde_json::to_string(&resp).unwrap();
378        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
379        assert_eq!(v["result"]["ok"], true);
380        assert!(v.get("error").is_none() || v["error"].is_null());
381    }
382
383    #[test]
384    fn jsonrpc_error_response_has_error_no_result() {
385        let resp = JsonRpcResponse::error(1, -32600, "slug already exists");
386        let s = serde_json::to_string(&resp).unwrap();
387        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
388        assert_eq!(v["error"]["code"], -32600);
389        assert_eq!(v["error"]["message"], "slug already exists");
390        assert!(v.get("result").is_none() || v["result"].is_null());
391    }
392
393    // --- ThreadCreatedEvent ---
394
395    #[test]
396    fn thread_created_event_roundtrips() {
397        let event = ThreadCreatedEvent {
398            event: "ThreadCreated".to_string(),
399            id: 1,
400            slug: "fix-login".to_string(),
401            priority: Priority::Priority,
402            description: "Fix the login bug".to_string(),
403            timestamp: 1234567890,
404        };
405        let s = serde_json::to_string(&event).unwrap();
406        let back: ThreadCreatedEvent = serde_json::from_str(&s).unwrap();
407        assert_eq!(back.id, 1);
408        assert_eq!(back.slug, "fix-login");
409        assert_eq!(back.priority, Priority::Priority);
410    }
411
412    // --- Thread model ---
413
414    #[test]
415    fn thread_serialises_with_id_field() {
416        let thread = Thread {
417            id: 1,
418            slug: "fix-login".to_string(),
419            state: ThreadState::Active,
420            priority: Priority::Priority,
421            description: "Fix it".to_string(),
422        };
423        let s = serde_json::to_string(&thread).unwrap();
424        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
425        assert_eq!(v["id"], 1);
426        assert_eq!(v["slug"], "fix-login");
427        assert_eq!(v["state"], "active");
428        assert_eq!(v["priority"], "PRIO");
429    }
430
431    // --- socket_path ---
432
433    #[test]
434    fn socket_path_is_under_tmp() {
435        let path = socket_path(std::path::Path::new("/some/project"));
436        assert!(path.starts_with("/tmp/"));
437        let name = path.file_name().unwrap().to_str().unwrap();
438        assert!(name.starts_with("tsk-"));
439        assert!(name.ends_with(".sock"));
440    }
441
442    #[test]
443    fn socket_path_is_deterministic_for_same_root() {
444        let p1 = socket_path(std::path::Path::new("/some/project"));
445        let p2 = socket_path(std::path::Path::new("/some/project"));
446        assert_eq!(p1, p2);
447    }
448
449    #[test]
450    fn socket_path_differs_for_different_roots() {
451        let p1 = socket_path(std::path::Path::new("/project/a"));
452        let p2 = socket_path(std::path::Path::new("/project/b"));
453        assert_ne!(p1, p2);
454    }
455
456    // --- thread_dir ---
457
458    #[test]
459    fn thread_dir_uses_zero_padded_id() {
460        let dir = thread_dir(std::path::Path::new("/proj"), 1, "fix-login");
461        assert!(dir.to_str().unwrap().contains("0001-fix-login"));
462    }
463}