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 id: u32,
83 pub slug: String,
84 pub state: ThreadState,
85 pub priority: Priority,
86 pub description: String,
87}
88
89impl Thread {
90 pub fn id_str(&self) -> String {
92 format!("{:04}", self.id)
93 }
94}
95
96#[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#[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
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, id: u32, slug: &str) -> PathBuf {
195 threads_dir(project_root).join(format!("{:04}-{}", id, slug))
196}
197
198pub 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
213pub 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#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[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 #[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 #[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 #[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 #[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 #[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 #[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}