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 hash: String,
83    pub short_hash: String,
84    pub slug: String,
85    pub state: ThreadState,
86    pub priority: Priority,
87    pub description: String,
88}
89
90// ---------------------------------------------------------------------------
91// Event types
92// ---------------------------------------------------------------------------
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct ThreadStartedEvent {
96    pub event: String,
97    pub hash: String,
98    pub slug: String,
99    pub priority: Priority,
100    pub description: String,
101    pub timestamp: u64,
102}
103
104// ---------------------------------------------------------------------------
105// JSON-RPC 2.0 types
106// ---------------------------------------------------------------------------
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct JsonRpcRequest {
110    pub jsonrpc: String,
111    pub id: u64,
112    pub method: String,
113    pub params: serde_json::Value,
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct JsonRpcResponse {
118    pub jsonrpc: String,
119    pub id: u64,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub result: Option<serde_json::Value>,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub error: Option<JsonRpcError>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct JsonRpcError {
128    pub code: i64,
129    pub message: String,
130}
131
132impl JsonRpcResponse {
133    pub fn success(id: u64, result: serde_json::Value) -> Self {
134        Self {
135            jsonrpc: "2.0".to_string(),
136            id,
137            result: Some(result),
138            error: None,
139        }
140    }
141
142    pub fn error(id: u64, code: i64, message: impl Into<String>) -> Self {
143        Self {
144            jsonrpc: "2.0".to_string(),
145            id,
146            result: None,
147            error: Some(JsonRpcError {
148                code,
149                message: message.into(),
150            }),
151        }
152    }
153}
154
155// ---------------------------------------------------------------------------
156// Thread ID generation
157// ---------------------------------------------------------------------------
158
159/// Returns (full_sha256_hex, short_7_char_hash) for the given slug.
160pub fn thread_hash(slug: &str) -> (String, String) {
161    let mut hasher = Sha256::new();
162    hasher.update(slug.as_bytes());
163    let result = hasher.finalize();
164    let full: String = result.iter().map(|b| format!("{:02x}", b)).collect();
165    let short = full[..7].to_string();
166    (full, short)
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
193pub fn thread_dir(project_root: &Path, short_hash: &str, slug: &str) -> PathBuf {
194    threads_dir(project_root).join(format!("{}-{}", short_hash, slug))
195}
196
197// ---------------------------------------------------------------------------
198// Socket path
199// ---------------------------------------------------------------------------
200
201/// Derives a stable socket path from the project root directory.
202/// Uses first 8 chars of SHA-256 of the path string, so multiple projects
203/// can have daemons running simultaneously.
204pub fn socket_path(project_root: &Path) -> PathBuf {
205    let mut hasher = Sha256::new();
206    hasher.update(project_root.to_string_lossy().as_bytes());
207    let result = hasher.finalize();
208    let hash: String = result.iter().map(|b| format!("{:02x}", b)).collect();
209    PathBuf::from(format!("/tmp/tsk-{}.sock", &hash[..8]))
210}
211
212// ---------------------------------------------------------------------------
213// Client helper
214// ---------------------------------------------------------------------------
215
216/// Connect to the daemon socket, send a JSON-RPC request, return the result.
217pub fn send_request(
218    socket: &Path,
219    method: &str,
220    params: serde_json::Value,
221) -> Result<serde_json::Value, String> {
222    use std::io::{BufRead, BufReader, Write};
223    use std::os::unix::net::UnixStream;
224
225    let mut stream = UnixStream::connect(socket).map_err(|_| {
226        "tskd is not running. Start it with: tskd".to_string()
227    })?;
228
229    stream
230        .set_read_timeout(Some(std::time::Duration::from_secs(5)))
231        .map_err(|e| format!("Failed to set timeout: {}", e))?;
232
233    let request = JsonRpcRequest {
234        jsonrpc: "2.0".to_string(),
235        id: 1,
236        method: method.to_string(),
237        params,
238    };
239
240    let mut line =
241        serde_json::to_string(&request).map_err(|e| format!("Serialisation error: {}", e))?;
242    line.push('\n');
243
244    stream
245        .write_all(line.as_bytes())
246        .map_err(|e| format!("Write error: {}", e))?;
247
248    let reader = BufReader::new(&stream);
249    let response_line = reader
250        .lines()
251        .next()
252        .ok_or("No response from daemon")?
253        .map_err(|e| format!("Read error: {}", e))?;
254
255    let response: JsonRpcResponse = serde_json::from_str(&response_line)
256        .map_err(|e| format!("Parse error: {}", e))?;
257
258    if let Some(err) = response.error {
259        return Err(err.message);
260    }
261
262    response.result.ok_or_else(|| "Empty response".to_string())
263}
264
265// ---------------------------------------------------------------------------
266// Unit tests — written first, drive the implementation above
267// ---------------------------------------------------------------------------
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    // --- thread_hash ---
274
275    #[test]
276    fn thread_hash_returns_64_char_full_and_7_char_short() {
277        let (full, short) = thread_hash("fix-login");
278        assert_eq!(full.len(), 64, "full hash should be 64 hex chars");
279        assert_eq!(short.len(), 7, "short hash should be 7 chars");
280        assert!(full.starts_with(&short), "short hash should be prefix of full");
281    }
282
283    #[test]
284    fn thread_hash_is_deterministic() {
285        let (full1, short1) = thread_hash("fix-login");
286        let (full2, short2) = thread_hash("fix-login");
287        assert_eq!(full1, full2);
288        assert_eq!(short1, short2);
289    }
290
291    #[test]
292    fn thread_hash_differs_for_different_slugs() {
293        let (full1, _) = thread_hash("fix-login");
294        let (full2, _) = thread_hash("update-deps");
295        assert_ne!(full1, full2);
296    }
297
298    // --- Priority ---
299
300    #[test]
301    fn priority_serialises_to_abbreviations() {
302        assert_eq!(
303            serde_json::to_string(&Priority::Background).unwrap(),
304            "\"BG\""
305        );
306        assert_eq!(
307            serde_json::to_string(&Priority::Priority).unwrap(),
308            "\"PRIO\""
309        );
310        assert_eq!(
311            serde_json::to_string(&Priority::Incident).unwrap(),
312            "\"INC\""
313        );
314    }
315
316    #[test]
317    fn priority_deserialises_from_abbreviations() {
318        assert_eq!(
319            serde_json::from_str::<Priority>("\"BG\"").unwrap(),
320            Priority::Background
321        );
322        assert_eq!(
323            serde_json::from_str::<Priority>("\"PRIO\"").unwrap(),
324            Priority::Priority
325        );
326        assert_eq!(
327            serde_json::from_str::<Priority>("\"INC\"").unwrap(),
328            Priority::Incident
329        );
330    }
331
332    #[test]
333    fn priority_from_str_parses_abbreviations() {
334        assert_eq!("BG".parse::<Priority>().unwrap(), Priority::Background);
335        assert_eq!("PRIO".parse::<Priority>().unwrap(), Priority::Priority);
336        assert_eq!("INC".parse::<Priority>().unwrap(), Priority::Incident);
337        assert!("unknown".parse::<Priority>().is_err());
338    }
339
340    #[test]
341    fn priority_expands_to_full_names() {
342        assert_eq!(Priority::Background.full_name(), "background");
343        assert_eq!(Priority::Priority.full_name(), "priority");
344        assert_eq!(Priority::Incident.full_name(), "incident");
345    }
346
347    #[test]
348    fn priority_display_shows_abbreviation() {
349        assert_eq!(format!("{}", Priority::Background), "BG");
350        assert_eq!(format!("{}", Priority::Priority), "PRIO");
351        assert_eq!(format!("{}", Priority::Incident), "INC");
352    }
353
354    // --- JSON-RPC ---
355
356    #[test]
357    fn jsonrpc_request_serialises_correctly() {
358        let req = JsonRpcRequest {
359            jsonrpc: "2.0".to_string(),
360            id: 1,
361            method: "thread.start".to_string(),
362            params: serde_json::json!({"slug": "fix-login"}),
363        };
364        let s = serde_json::to_string(&req).unwrap();
365        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
366        assert_eq!(v["jsonrpc"], "2.0");
367        assert_eq!(v["id"], 1);
368        assert_eq!(v["method"], "thread.start");
369        assert_eq!(v["params"]["slug"], "fix-login");
370    }
371
372    #[test]
373    fn jsonrpc_success_response_has_result_no_error() {
374        let resp = JsonRpcResponse::success(1, serde_json::json!({"ok": true}));
375        let s = serde_json::to_string(&resp).unwrap();
376        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
377        assert_eq!(v["result"]["ok"], true);
378        assert!(v.get("error").is_none() || v["error"].is_null());
379    }
380
381    #[test]
382    fn jsonrpc_error_response_has_error_no_result() {
383        let resp = JsonRpcResponse::error(1, -32600, "slug already exists");
384        let s = serde_json::to_string(&resp).unwrap();
385        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
386        assert_eq!(v["error"]["code"], -32600);
387        assert_eq!(v["error"]["message"], "slug already exists");
388        assert!(v.get("result").is_none() || v["result"].is_null());
389    }
390
391    // --- ThreadStartedEvent ---
392
393    #[test]
394    fn thread_started_event_roundtrips() {
395        let (hash, _) = thread_hash("fix-login");
396        let event = ThreadStartedEvent {
397            event: "ThreadStarted".to_string(),
398            hash: hash.clone(),
399            slug: "fix-login".to_string(),
400            priority: Priority::Priority,
401            description: "Fix the login bug".to_string(),
402            timestamp: 1234567890,
403        };
404        let s = serde_json::to_string(&event).unwrap();
405        let back: ThreadStartedEvent = serde_json::from_str(&s).unwrap();
406        assert_eq!(back.slug, "fix-login");
407        assert_eq!(back.priority, Priority::Priority);
408        assert_eq!(back.hash, hash);
409    }
410
411    // --- Thread model ---
412
413    #[test]
414    fn thread_serialises_with_hash_fields() {
415        let (hash, short_hash) = thread_hash("fix-login");
416        let thread = Thread {
417            hash: hash.clone(),
418            short_hash: short_hash.clone(),
419            slug: "fix-login".to_string(),
420            state: ThreadState::Active,
421            priority: Priority::Priority,
422            description: "Fix it".to_string(),
423        };
424        let s = serde_json::to_string(&thread).unwrap();
425        let v: serde_json::Value = serde_json::from_str(&s).unwrap();
426        assert_eq!(v["hash"], hash);
427        assert_eq!(v["short_hash"], short_hash);
428        assert_eq!(v["slug"], "fix-login");
429        assert_eq!(v["state"], "active");
430        assert_eq!(v["priority"], "PRIO");
431    }
432
433    // --- socket_path ---
434
435    #[test]
436    fn socket_path_is_under_tmp() {
437        let path = socket_path(std::path::Path::new("/some/project"));
438        assert!(path.starts_with("/tmp/"));
439        let name = path.file_name().unwrap().to_str().unwrap();
440        assert!(name.starts_with("tsk-"));
441        assert!(name.ends_with(".sock"));
442    }
443
444    #[test]
445    fn socket_path_is_deterministic_for_same_root() {
446        let p1 = socket_path(std::path::Path::new("/some/project"));
447        let p2 = socket_path(std::path::Path::new("/some/project"));
448        assert_eq!(p1, p2);
449    }
450
451    #[test]
452    fn socket_path_differs_for_different_roots() {
453        let p1 = socket_path(std::path::Path::new("/project/a"));
454        let p2 = socket_path(std::path::Path::new("/project/b"));
455        assert_ne!(p1, p2);
456    }
457}