1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3use std::path::{Path, PathBuf};
4
5#[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#[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#[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#[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#[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
155pub 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
169pub 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
197pub 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
212pub 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#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[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 #[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 #[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 #[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 #[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 #[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}