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 Waiting { reason: Option<String> },
66}
67
68impl std::fmt::Display for ThreadState {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 match self {
71 ThreadState::Active => write!(f, "active"),
72 ThreadState::Paused => write!(f, "paused"),
73 ThreadState::Waiting { .. } => write!(f, "waiting"),
74 }
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct Thread {
84 pub id: u32,
85 pub slug: String,
86 pub state: ThreadState,
87 pub priority: Priority,
88 pub description: String,
89}
90
91impl Thread {
92 pub fn id_str(&self) -> String {
94 format!("{:04}", self.id)
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
103#[serde(rename_all = "kebab-case")]
104pub enum TaskState {
105 NotStarted,
106 InProgress,
107 Blocked,
108 Done,
109 Cancelled,
110}
111
112impl std::fmt::Display for TaskState {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 TaskState::NotStarted => write!(f, "not-started"),
116 TaskState::InProgress => write!(f, "in-progress"),
117 TaskState::Blocked => write!(f, "blocked"),
118 TaskState::Done => write!(f, "done"),
119 TaskState::Cancelled => write!(f, "cancelled"),
120 }
121 }
122}
123
124#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct Task {
130 pub id: String,
132 pub description: String,
133 pub state: TaskState,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub due_by: Option<String>,
136 pub seq: u32,
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub blocked_reason: Option<String>,
141}
142
143impl Task {
144 pub fn make_id(thread_id: u32, seq: u32) -> String {
146 format!("TSK-{:04}-{:04}", thread_id, seq)
147 }
148}
149
150pub fn tasks_path(project_root: &Path, thread_id: u32, slug: &str) -> PathBuf {
156 thread_dir(project_root, thread_id, slug).join("tasks.json")
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct ThreadCreatedEvent {
165 pub event: String,
166 pub id: u32,
167 pub slug: String,
168 pub priority: Priority,
169 pub description: String,
170 pub timestamp: u64,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct ThreadSwitchedEvent {
175 pub event: String,
176 pub active_id: u32,
177 pub paused_ids: Vec<u32>,
178 pub timestamp: u64,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct ThreadWaitedEvent {
183 pub event: String,
184 pub id: u32,
185 pub reason: Option<String>,
186 pub timestamp: u64,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct ThreadResumedEvent {
191 pub event: String,
192 pub id: u32,
193 pub note: Option<String>,
194 pub timestamp: u64,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct ThreadUpdatedEvent {
199 pub event: String,
200 pub id: u32,
201 pub slug: String,
202 pub priority: Priority,
203 pub description: String,
204 pub timestamp: u64,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct JsonRpcRequest {
213 pub jsonrpc: String,
214 pub id: u64,
215 pub method: String,
216 pub params: serde_json::Value,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct JsonRpcResponse {
221 pub jsonrpc: String,
222 pub id: u64,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub result: Option<serde_json::Value>,
225 #[serde(skip_serializing_if = "Option::is_none")]
226 pub error: Option<JsonRpcError>,
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct JsonRpcError {
231 pub code: i64,
232 pub message: String,
233}
234
235impl JsonRpcResponse {
236 pub fn success(id: u64, result: serde_json::Value) -> Self {
237 Self {
238 jsonrpc: "2.0".to_string(),
239 id,
240 result: Some(result),
241 error: None,
242 }
243 }
244
245 pub fn error(id: u64, code: i64, message: impl Into<String>) -> Self {
246 Self {
247 jsonrpc: "2.0".to_string(),
248 id,
249 result: None,
250 error: Some(JsonRpcError {
251 code,
252 message: message.into(),
253 }),
254 }
255 }
256}
257
258pub fn tsk_dir(project_root: &Path) -> PathBuf {
263 project_root.join("tsk")
264}
265
266pub fn event_log_dir(project_root: &Path) -> PathBuf {
267 tsk_dir(project_root).join("event-log")
268}
269
270pub fn event_log_path(project_root: &Path) -> PathBuf {
271 event_log_dir(project_root).join("events.ndjson")
272}
273
274pub fn threads_dir(project_root: &Path) -> PathBuf {
275 tsk_dir(project_root).join("threads")
276}
277
278pub fn index_path(project_root: &Path) -> PathBuf {
279 threads_dir(project_root).join("index.json")
280}
281
282pub fn thread_dir(project_root: &Path, id: u32, slug: &str) -> PathBuf {
284 threads_dir(project_root).join(format!("{:04}-{}", id, slug))
285}
286
287pub fn socket_path(project_root: &Path) -> PathBuf {
295 let mut hasher = Sha256::new();
296 hasher.update(project_root.to_string_lossy().as_bytes());
297 let result = hasher.finalize();
298 let hash: String = result.iter().map(|b| format!("{:02x}", b)).collect();
299 PathBuf::from(format!("/tmp/tsk-{}.sock", &hash[..8]))
300}
301
302pub fn send_request(
308 socket: &Path,
309 method: &str,
310 params: serde_json::Value,
311) -> Result<serde_json::Value, String> {
312 use std::io::{BufRead, BufReader, Write};
313 use std::os::unix::net::UnixStream;
314
315 let mut stream = UnixStream::connect(socket).map_err(|_| {
316 "tskd is not running. Start it with: tskd".to_string()
317 })?;
318
319 stream
320 .set_read_timeout(Some(std::time::Duration::from_secs(5)))
321 .map_err(|e| format!("Failed to set timeout: {}", e))?;
322
323 let request = JsonRpcRequest {
324 jsonrpc: "2.0".to_string(),
325 id: 1,
326 method: method.to_string(),
327 params,
328 };
329
330 let mut line =
331 serde_json::to_string(&request).map_err(|e| format!("Serialisation error: {}", e))?;
332 line.push('\n');
333
334 stream
335 .write_all(line.as_bytes())
336 .map_err(|e| format!("Write error: {}", e))?;
337
338 let reader = BufReader::new(&stream);
339 let response_line = reader
340 .lines()
341 .next()
342 .ok_or("No response from daemon")?
343 .map_err(|e| format!("Read error: {}", e))?;
344
345 let response: JsonRpcResponse = serde_json::from_str(&response_line)
346 .map_err(|e| format!("Parse error: {}", e))?;
347
348 if let Some(err) = response.error {
349 return Err(err.message);
350 }
351
352 response.result.ok_or_else(|| "Empty response".to_string())
353}
354
355#[cfg(test)]
360mod tests {
361 use super::*;
362
363 #[test]
366 fn thread_id_str_zero_pads_to_four_digits() {
367 let t = Thread {
368 id: 1,
369 slug: "fix-login".to_string(),
370 state: ThreadState::Active,
371 priority: Priority::Priority,
372 description: "Fix it".to_string(),
373 };
374 assert_eq!(t.id_str(), "0001");
375 }
376
377 #[test]
378 fn thread_id_str_handles_larger_numbers() {
379 let t = Thread {
380 id: 42,
381 slug: "foo".to_string(),
382 state: ThreadState::Paused,
383 priority: Priority::Background,
384 description: "".to_string(),
385 };
386 assert_eq!(t.id_str(), "0042");
387 }
388
389 #[test]
392 fn priority_serialises_to_abbreviations() {
393 assert_eq!(
394 serde_json::to_string(&Priority::Background).unwrap(),
395 "\"BG\""
396 );
397 assert_eq!(
398 serde_json::to_string(&Priority::Priority).unwrap(),
399 "\"PRIO\""
400 );
401 assert_eq!(
402 serde_json::to_string(&Priority::Incident).unwrap(),
403 "\"INC\""
404 );
405 }
406
407 #[test]
408 fn priority_deserialises_from_abbreviations() {
409 assert_eq!(
410 serde_json::from_str::<Priority>("\"BG\"").unwrap(),
411 Priority::Background
412 );
413 assert_eq!(
414 serde_json::from_str::<Priority>("\"PRIO\"").unwrap(),
415 Priority::Priority
416 );
417 assert_eq!(
418 serde_json::from_str::<Priority>("\"INC\"").unwrap(),
419 Priority::Incident
420 );
421 }
422
423 #[test]
424 fn priority_from_str_parses_abbreviations() {
425 assert_eq!("BG".parse::<Priority>().unwrap(), Priority::Background);
426 assert_eq!("PRIO".parse::<Priority>().unwrap(), Priority::Priority);
427 assert_eq!("INC".parse::<Priority>().unwrap(), Priority::Incident);
428 assert!("unknown".parse::<Priority>().is_err());
429 }
430
431 #[test]
432 fn priority_expands_to_full_names() {
433 assert_eq!(Priority::Background.full_name(), "background");
434 assert_eq!(Priority::Priority.full_name(), "priority");
435 assert_eq!(Priority::Incident.full_name(), "incident");
436 }
437
438 #[test]
439 fn priority_display_shows_abbreviation() {
440 assert_eq!(format!("{}", Priority::Background), "BG");
441 assert_eq!(format!("{}", Priority::Priority), "PRIO");
442 assert_eq!(format!("{}", Priority::Incident), "INC");
443 }
444
445 #[test]
448 fn jsonrpc_request_serialises_correctly() {
449 let req = JsonRpcRequest {
450 jsonrpc: "2.0".to_string(),
451 id: 1,
452 method: "thread.create".to_string(),
453 params: serde_json::json!({"slug": "fix-login"}),
454 };
455 let s = serde_json::to_string(&req).unwrap();
456 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
457 assert_eq!(v["jsonrpc"], "2.0");
458 assert_eq!(v["id"], 1);
459 assert_eq!(v["method"], "thread.create");
460 assert_eq!(v["params"]["slug"], "fix-login");
461 }
462
463 #[test]
464 fn jsonrpc_success_response_has_result_no_error() {
465 let resp = JsonRpcResponse::success(1, serde_json::json!({"ok": true}));
466 let s = serde_json::to_string(&resp).unwrap();
467 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
468 assert_eq!(v["result"]["ok"], true);
469 assert!(v.get("error").is_none() || v["error"].is_null());
470 }
471
472 #[test]
473 fn jsonrpc_error_response_has_error_no_result() {
474 let resp = JsonRpcResponse::error(1, -32600, "slug already exists");
475 let s = serde_json::to_string(&resp).unwrap();
476 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
477 assert_eq!(v["error"]["code"], -32600);
478 assert_eq!(v["error"]["message"], "slug already exists");
479 assert!(v.get("result").is_none() || v["result"].is_null());
480 }
481
482 #[test]
485 fn thread_created_event_roundtrips() {
486 let event = ThreadCreatedEvent {
487 event: "ThreadCreated".to_string(),
488 id: 1,
489 slug: "fix-login".to_string(),
490 priority: Priority::Priority,
491 description: "Fix the login bug".to_string(),
492 timestamp: 1234567890,
493 };
494 let s = serde_json::to_string(&event).unwrap();
495 let back: ThreadCreatedEvent = serde_json::from_str(&s).unwrap();
496 assert_eq!(back.id, 1);
497 assert_eq!(back.slug, "fix-login");
498 assert_eq!(back.priority, Priority::Priority);
499 }
500
501 #[test]
504 fn thread_serialises_with_id_field() {
505 let thread = Thread {
506 id: 1,
507 slug: "fix-login".to_string(),
508 state: ThreadState::Active,
509 priority: Priority::Priority,
510 description: "Fix it".to_string(),
511 };
512 let s = serde_json::to_string(&thread).unwrap();
513 let v: serde_json::Value = serde_json::from_str(&s).unwrap();
514 assert_eq!(v["id"], 1);
515 assert_eq!(v["slug"], "fix-login");
516 assert_eq!(v["state"], "active");
517 assert_eq!(v["priority"], "PRIO");
518 }
519
520 #[test]
523 fn waiting_state_serialises_as_nested_object() {
524 let state = ThreadState::Waiting { reason: Some("waiting for PR review".to_string()) };
525 let v: serde_json::Value = serde_json::to_value(&state).unwrap();
526 assert_eq!(v["waiting"]["reason"], "waiting for PR review");
527 }
528
529 #[test]
530 fn waiting_state_with_no_reason_serialises_correctly() {
531 let state = ThreadState::Waiting { reason: None };
532 let v: serde_json::Value = serde_json::to_value(&state).unwrap();
533 assert!(v["waiting"].is_object());
534 assert!(v["waiting"]["reason"].is_null());
535 }
536
537 #[test]
538 fn waiting_state_roundtrips_via_json() {
539 let state = ThreadState::Waiting { reason: Some("blocked on deploy".to_string()) };
540 let s = serde_json::to_string(&state).unwrap();
541 let back: ThreadState = serde_json::from_str(&s).unwrap();
542 assert_eq!(back, state);
543 }
544
545 #[test]
546 fn waiting_state_display_shows_waiting() {
547 let state = ThreadState::Waiting { reason: Some("blocked".to_string()) };
548 assert_eq!(format!("{}", state), "waiting");
549 }
550
551 #[test]
552 fn thread_with_waiting_state_roundtrips_via_json() {
553 let thread = Thread {
554 id: 1,
555 slug: "fix-login".to_string(),
556 state: ThreadState::Waiting { reason: Some("waiting for review".to_string()) },
557 priority: Priority::Priority,
558 description: "Fix it".to_string(),
559 };
560 let s = serde_json::to_string(&thread).unwrap();
561 let back: Thread = serde_json::from_str(&s).unwrap();
562 assert_eq!(back, thread);
563 }
564
565 #[test]
568 fn socket_path_is_under_tmp() {
569 let path = socket_path(std::path::Path::new("/some/project"));
570 assert!(path.starts_with("/tmp/"));
571 let name = path.file_name().unwrap().to_str().unwrap();
572 assert!(name.starts_with("tsk-"));
573 assert!(name.ends_with(".sock"));
574 }
575
576 #[test]
577 fn socket_path_is_deterministic_for_same_root() {
578 let p1 = socket_path(std::path::Path::new("/some/project"));
579 let p2 = socket_path(std::path::Path::new("/some/project"));
580 assert_eq!(p1, p2);
581 }
582
583 #[test]
584 fn socket_path_differs_for_different_roots() {
585 let p1 = socket_path(std::path::Path::new("/project/a"));
586 let p2 = socket_path(std::path::Path::new("/project/b"));
587 assert_ne!(p1, p2);
588 }
589
590 #[test]
593 fn thread_dir_uses_zero_padded_id() {
594 let dir = thread_dir(std::path::Path::new("/proj"), 1, "fix-login");
595 assert!(dir.to_str().unwrap().contains("0001-fix-login"));
596 }
597
598 #[test]
601 fn task_make_id_formats_correctly() {
602 assert_eq!(Task::make_id(1, 1), "TSK-0001-0001");
603 assert_eq!(Task::make_id(42, 100), "TSK-0042-0100");
604 }
605
606 #[test]
607 fn task_state_serialises_with_kebab_case() {
608 assert_eq!(serde_json::to_string(&TaskState::NotStarted).unwrap(), "\"not-started\"");
609 assert_eq!(serde_json::to_string(&TaskState::InProgress).unwrap(), "\"in-progress\"");
610 assert_eq!(serde_json::to_string(&TaskState::Blocked).unwrap(), "\"blocked\"");
611 assert_eq!(serde_json::to_string(&TaskState::Done).unwrap(), "\"done\"");
612 assert_eq!(serde_json::to_string(&TaskState::Cancelled).unwrap(), "\"cancelled\"");
613 }
614
615 #[test]
616 fn task_state_deserialises_from_kebab_case() {
617 assert_eq!(serde_json::from_str::<TaskState>("\"not-started\"").unwrap(), TaskState::NotStarted);
618 assert_eq!(serde_json::from_str::<TaskState>("\"in-progress\"").unwrap(), TaskState::InProgress);
619 assert_eq!(serde_json::from_str::<TaskState>("\"blocked\"").unwrap(), TaskState::Blocked);
620 assert_eq!(serde_json::from_str::<TaskState>("\"done\"").unwrap(), TaskState::Done);
621 assert_eq!(serde_json::from_str::<TaskState>("\"cancelled\"").unwrap(), TaskState::Cancelled);
622 }
623
624 #[test]
625 fn task_state_display() {
626 assert_eq!(format!("{}", TaskState::NotStarted), "not-started");
627 assert_eq!(format!("{}", TaskState::InProgress), "in-progress");
628 assert_eq!(format!("{}", TaskState::Blocked), "blocked");
629 assert_eq!(format!("{}", TaskState::Done), "done");
630 assert_eq!(format!("{}", TaskState::Cancelled), "cancelled");
631 }
632
633 #[test]
634 fn task_roundtrips_via_json() {
635 let task = Task {
636 id: Task::make_id(1, 1),
637 description: "Write unit tests".to_string(),
638 state: TaskState::InProgress,
639 due_by: Some("2026-03-31".to_string()),
640 seq: 1,
641 blocked_reason: None,
642 };
643 let s = serde_json::to_string(&task).unwrap();
644 let back: Task = serde_json::from_str(&s).unwrap();
645 assert_eq!(back, task);
646 }
647
648 #[test]
649 fn task_due_by_is_omitted_when_none() {
650 let task = Task {
651 id: Task::make_id(1, 1),
652 description: "Test".to_string(),
653 state: TaskState::NotStarted,
654 due_by: None,
655 seq: 1,
656 blocked_reason: None,
657 };
658 let v: serde_json::Value = serde_json::to_value(&task).unwrap();
659 assert!(v.get("due_by").is_none(), "due_by should be omitted when None");
660 }
661
662 #[test]
663 fn task_blocked_reason_is_omitted_when_none() {
664 let task = Task {
665 id: Task::make_id(1, 1),
666 description: "Test".to_string(),
667 state: TaskState::NotStarted,
668 due_by: None,
669 seq: 1,
670 blocked_reason: None,
671 };
672 let v: serde_json::Value = serde_json::to_value(&task).unwrap();
673 assert!(v.get("blocked_reason").is_none(), "blocked_reason should be omitted when None");
674 }
675
676 #[test]
677 fn tasks_path_is_inside_thread_dir() {
678 let path = tasks_path(std::path::Path::new("/proj"), 1, "fix-login");
679 assert!(path.to_str().unwrap().contains("0001-fix-login"));
680 assert!(path.to_str().unwrap().ends_with("tasks.json"));
681 }
682}