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    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// ---------------------------------------------------------------------------
79// Thread model
80// ---------------------------------------------------------------------------
81
82#[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    /// Zero-padded 4-digit string representation of the id (e.g. "0001").
93    pub fn id_str(&self) -> String {
94        format!("{:04}", self.id)
95    }
96}
97
98// ---------------------------------------------------------------------------
99// Task state
100// ---------------------------------------------------------------------------
101
102#[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// ---------------------------------------------------------------------------
125// Task model
126// ---------------------------------------------------------------------------
127
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct Task {
130    /// Human-readable id: `TSK-{thread_id:04}-{seq:04}` e.g. `TSK-0001-0001`
131    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    /// Sequence number for manual ordering (1-based, not displayed)
137    pub seq: u32,
138    /// Only meaningful when state is Blocked
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub blocked_reason: Option<String>,
141}
142
143impl Task {
144    /// Format a task id from thread id and sequence number.
145    pub fn make_id(thread_id: u32, seq: u32) -> String {
146        format!("TSK-{:04}-{:04}", thread_id, seq)
147    }
148}
149
150// ---------------------------------------------------------------------------
151// Storage paths (tasks)
152// ---------------------------------------------------------------------------
153
154/// Path to the tasks file for a thread: `tsk/threads/{id}-{slug}/tasks.json`
155pub 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// ---------------------------------------------------------------------------
160// Event types
161// ---------------------------------------------------------------------------
162
163#[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// ---------------------------------------------------------------------------
208// JSON-RPC 2.0 types
209// ---------------------------------------------------------------------------
210
211#[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
258// ---------------------------------------------------------------------------
259// Storage paths
260// ---------------------------------------------------------------------------
261
262pub 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
282/// Thread working directory: `tsk/threads/{id:04}-{slug}/`
283pub fn thread_dir(project_root: &Path, id: u32, slug: &str) -> PathBuf {
284    threads_dir(project_root).join(format!("{:04}-{}", id, slug))
285}
286
287// ---------------------------------------------------------------------------
288// Socket path
289// ---------------------------------------------------------------------------
290
291/// Derives a stable socket path from the project root directory.
292/// Uses first 8 chars of SHA-256 of the path string, so multiple projects
293/// can have daemons running simultaneously.
294pub 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
302// ---------------------------------------------------------------------------
303// Client helper
304// ---------------------------------------------------------------------------
305
306/// Connect to the daemon socket, send a JSON-RPC request, return the result.
307pub 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// ---------------------------------------------------------------------------
356// Unit tests
357// ---------------------------------------------------------------------------
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    // --- Thread id_str ---
364
365    #[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    // --- Priority ---
390
391    #[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    // --- JSON-RPC ---
446
447    #[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    // --- ThreadCreatedEvent ---
483
484    #[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    // --- Thread model ---
502
503    #[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    // --- ThreadState::Waiting ---
521
522    #[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    // --- socket_path ---
566
567    #[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    // --- thread_dir ---
591
592    #[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    // --- Task ---
599
600    #[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}