Skip to main content

libbrat_grite/
types.rs

1use serde::{Deserialize, Serialize};
2
3// =============================================================================
4// Dependency Types (for grite issue dep commands)
5// =============================================================================
6
7/// Type of dependency relationship between issues/tasks.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum DependencyType {
11    /// This issue blocks the target issue.
12    Blocks,
13    /// This issue depends on the target issue.
14    DependsOn,
15    /// This issue is related to the target issue (non-directional).
16    RelatedTo,
17}
18
19impl DependencyType {
20    /// Convert to string representation.
21    pub fn as_str(&self) -> &'static str {
22        match self {
23            DependencyType::Blocks => "blocks",
24            DependencyType::DependsOn => "depends_on",
25            DependencyType::RelatedTo => "related_to",
26        }
27    }
28
29    /// Parse from string.
30    pub fn from_str(s: &str) -> Option<Self> {
31        match s {
32            "blocks" => Some(DependencyType::Blocks),
33            "depends_on" => Some(DependencyType::DependsOn),
34            "related_to" => Some(DependencyType::RelatedTo),
35            _ => None,
36        }
37    }
38}
39
40impl std::fmt::Display for DependencyType {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        write!(f, "{}", self.as_str())
43    }
44}
45
46/// A dependency relationship between tasks.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct TaskDependency {
49    /// The target task's grite issue ID.
50    pub issue_id: String,
51    /// The type of dependency.
52    pub dep_type: DependencyType,
53    /// The target task's title.
54    pub title: String,
55}
56
57// =============================================================================
58// Context Types (for grite context commands)
59// =============================================================================
60
61/// Result of context indexing operation.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ContextIndexResult {
64    /// Number of files successfully indexed.
65    pub indexed: u32,
66    /// Number of files skipped (binary, unchanged, etc.).
67    pub skipped: u32,
68    /// Total number of files processed.
69    pub total_files: u32,
70}
71
72/// A symbol match from context query.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SymbolMatch {
75    /// The symbol name.
76    pub symbol: String,
77    /// The file path containing the symbol.
78    pub path: String,
79}
80
81/// A symbol extracted from a file.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct Symbol {
84    /// Symbol name.
85    pub name: String,
86    /// Symbol kind (function, class, struct, etc.).
87    pub kind: String,
88    /// Starting line number.
89    pub line_start: u32,
90    /// Ending line number.
91    pub line_end: u32,
92}
93
94/// File context information.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct FileContext {
97    /// File path.
98    pub path: String,
99    /// Detected programming language.
100    pub language: String,
101    /// AI-generated summary of the file.
102    pub summary: String,
103    /// Content hash (SHA256 hex).
104    pub content_hash: String,
105    /// Extracted symbols.
106    pub symbols: Vec<Symbol>,
107}
108
109/// Project context key-value entry.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct ProjectContextEntry {
112    /// The key.
113    pub key: String,
114    /// The value.
115    pub value: String,
116}
117
118// =============================================================================
119// Grit Issue Types
120// =============================================================================
121
122/// A Grit issue as returned by `grite issue show --json`.
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct GriteIssue {
125    pub issue_id: String,
126    pub title: String,
127    #[serde(default)]
128    pub body: String,
129    #[serde(default)]
130    pub labels: Vec<String>,
131    #[serde(default)]
132    pub state: String,
133    #[serde(default)]
134    pub updated_ts: i64,
135}
136
137/// Summary of a Grit issue from list command.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct GriteIssueSummary {
140    pub issue_id: String,
141    pub title: String,
142    #[serde(default)]
143    pub state: String,
144    #[serde(default)]
145    pub labels: Vec<String>,
146    #[serde(default)]
147    pub updated_ts: i64,
148}
149
150/// Convoy status.
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
152#[serde(rename_all = "lowercase")]
153pub enum ConvoyStatus {
154    Active,
155    Paused,
156    Complete,
157    Failed,
158}
159
160impl ConvoyStatus {
161    /// Convert to label string.
162    pub fn as_label(&self) -> &'static str {
163        match self {
164            ConvoyStatus::Active => "status:active",
165            ConvoyStatus::Paused => "status:paused",
166            ConvoyStatus::Complete => "status:complete",
167            ConvoyStatus::Failed => "status:failed",
168        }
169    }
170
171    /// Parse from label string.
172    pub fn from_label(label: &str) -> Option<Self> {
173        match label {
174            "status:active" => Some(ConvoyStatus::Active),
175            "status:paused" => Some(ConvoyStatus::Paused),
176            "status:complete" => Some(ConvoyStatus::Complete),
177            "status:failed" => Some(ConvoyStatus::Failed),
178            _ => None,
179        }
180    }
181
182    /// All possible status labels.
183    pub fn all_labels() -> &'static [&'static str] {
184        &[
185            "status:active",
186            "status:paused",
187            "status:complete",
188            "status:failed",
189        ]
190    }
191}
192
193impl Default for ConvoyStatus {
194    fn default() -> Self {
195        ConvoyStatus::Active
196    }
197}
198
199/// Task status.
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
201#[serde(rename_all = "lowercase")]
202pub enum TaskStatus {
203    Queued,
204    Running,
205    Blocked,
206    NeedsReview,
207    Merged,
208    Dropped,
209}
210
211impl TaskStatus {
212    /// Convert to label string.
213    pub fn as_label(&self) -> &'static str {
214        match self {
215            TaskStatus::Queued => "status:queued",
216            TaskStatus::Running => "status:running",
217            TaskStatus::Blocked => "status:blocked",
218            TaskStatus::NeedsReview => "status:needs-review",
219            TaskStatus::Merged => "status:merged",
220            TaskStatus::Dropped => "status:dropped",
221        }
222    }
223
224    /// Parse from label string.
225    pub fn from_label(label: &str) -> Option<Self> {
226        match label {
227            "status:queued" => Some(TaskStatus::Queued),
228            "status:running" => Some(TaskStatus::Running),
229            "status:blocked" => Some(TaskStatus::Blocked),
230            "status:needs-review" => Some(TaskStatus::NeedsReview),
231            "status:merged" => Some(TaskStatus::Merged),
232            "status:dropped" => Some(TaskStatus::Dropped),
233            _ => None,
234        }
235    }
236
237    /// All possible status labels for tasks.
238    pub fn all_labels() -> &'static [&'static str] {
239        &[
240            "status:queued",
241            "status:running",
242            "status:blocked",
243            "status:needs-review",
244            "status:merged",
245            "status:dropped",
246        ]
247    }
248}
249
250impl Default for TaskStatus {
251    fn default() -> Self {
252        TaskStatus::Queued
253    }
254}
255
256/// Session lifecycle status.
257#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
258#[serde(rename_all = "lowercase")]
259pub enum SessionStatus {
260    /// Session created, not yet ready.
261    Spawned,
262    /// Engine healthy, initial prompt delivered.
263    Ready,
264    /// Actively executing task work.
265    Running,
266    /// Waiting for review or merge.
267    Handoff,
268    /// Session terminated (success or failure).
269    Exit,
270}
271
272impl SessionStatus {
273    /// Convert to label string.
274    pub fn as_label(&self) -> &'static str {
275        match self {
276            SessionStatus::Spawned => "session:spawned",
277            SessionStatus::Ready => "session:ready",
278            SessionStatus::Running => "session:running",
279            SessionStatus::Handoff => "session:handoff",
280            SessionStatus::Exit => "session:exit",
281        }
282    }
283
284    /// Parse from label string.
285    pub fn from_label(label: &str) -> Option<Self> {
286        match label {
287            "session:spawned" => Some(SessionStatus::Spawned),
288            "session:ready" => Some(SessionStatus::Ready),
289            "session:running" => Some(SessionStatus::Running),
290            "session:handoff" => Some(SessionStatus::Handoff),
291            "session:exit" => Some(SessionStatus::Exit),
292            _ => None,
293        }
294    }
295
296    /// All possible session status labels.
297    pub fn all_labels() -> &'static [&'static str] {
298        &[
299            "session:spawned",
300            "session:ready",
301            "session:running",
302            "session:handoff",
303            "session:exit",
304        ]
305    }
306}
307
308impl Default for SessionStatus {
309    fn default() -> Self {
310        SessionStatus::Spawned
311    }
312}
313
314/// Session type: polecat (isolated worktree) or crew (shared).
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
316#[serde(rename_all = "lowercase")]
317pub enum SessionType {
318    /// Isolated worktree session managed by the Witness role.
319    Polecat,
320    /// Shared session for user-driven work.
321    Crew,
322}
323
324impl SessionType {
325    /// Convert to label string.
326    pub fn as_label(&self) -> &'static str {
327        match self {
328            SessionType::Polecat => "session:polecat",
329            SessionType::Crew => "session:crew",
330        }
331    }
332
333    /// Parse from label string.
334    pub fn from_label(label: &str) -> Option<Self> {
335        match label {
336            "session:polecat" => Some(SessionType::Polecat),
337            "session:crew" => Some(SessionType::Crew),
338            _ => None,
339        }
340    }
341
342    /// Convert to string for comment format.
343    pub fn as_str(&self) -> &'static str {
344        match self {
345            SessionType::Polecat => "polecat",
346            SessionType::Crew => "crew",
347        }
348    }
349
350    /// Parse from string (for comment parsing).
351    pub fn from_str(s: &str) -> Option<Self> {
352        match s {
353            "polecat" => Some(SessionType::Polecat),
354            "crew" => Some(SessionType::Crew),
355            _ => None,
356        }
357    }
358}
359
360impl Default for SessionType {
361    fn default() -> Self {
362        SessionType::Polecat
363    }
364}
365
366impl std::fmt::Display for SessionType {
367    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368        write!(f, "{}", self.as_str())
369    }
370}
371
372/// Actor role for the session.
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
374#[serde(rename_all = "lowercase")]
375pub enum SessionRole {
376    /// Strategic planner (readonly).
377    Mayor,
378    /// Worker controller, spawns polecat sessions.
379    Witness,
380    /// Post-merge cleanup and polish.
381    Refinery,
382    /// Background janitor for reconciliation and cleanup.
383    Deacon,
384    /// Human user.
385    User,
386}
387
388impl SessionRole {
389    /// Convert to string.
390    pub fn as_str(&self) -> &'static str {
391        match self {
392            SessionRole::Mayor => "mayor",
393            SessionRole::Witness => "witness",
394            SessionRole::Refinery => "refinery",
395            SessionRole::Deacon => "deacon",
396            SessionRole::User => "user",
397        }
398    }
399
400    /// Parse from string.
401    pub fn from_str(s: &str) -> Option<Self> {
402        match s {
403            "mayor" => Some(SessionRole::Mayor),
404            "witness" => Some(SessionRole::Witness),
405            "refinery" => Some(SessionRole::Refinery),
406            "deacon" => Some(SessionRole::Deacon),
407            "user" => Some(SessionRole::User),
408            _ => None,
409        }
410    }
411}
412
413impl Default for SessionRole {
414    fn default() -> Self {
415        SessionRole::User
416    }
417}
418
419impl std::fmt::Display for SessionRole {
420    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
421        write!(f, "{}", self.as_str())
422    }
423}
424
425/// A parsed session from a task issue comment.
426///
427/// Sessions are stored as comments on task issues, not as separate issues.
428/// This struct represents the parsed session state from a `[session]...[/session]` block.
429#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct Session {
431    /// Brat session ID (e.g., "s-20250117-7b3d").
432    pub session_id: String,
433
434    /// Associated task ID.
435    pub task_id: String,
436
437    /// Grit issue ID of the parent task.
438    pub grite_issue_id: String,
439
440    /// Role executing the session.
441    pub role: SessionRole,
442
443    /// Session type (polecat/crew).
444    pub session_type: SessionType,
445
446    /// Engine name (e.g., "codex", "claude", "shell").
447    pub engine: String,
448
449    /// Path to worktree (for polecat sessions).
450    #[serde(default)]
451    pub worktree: String,
452
453    /// Process ID (if running).
454    pub pid: Option<u32>,
455
456    /// Session status.
457    pub status: SessionStatus,
458
459    /// Timestamp when session started (millis since epoch).
460    pub started_ts: i64,
461
462    /// Last heartbeat timestamp (millis since epoch).
463    pub last_heartbeat_ts: Option<i64>,
464
465    /// Exit code (if exited).
466    pub exit_code: Option<i32>,
467
468    /// Exit reason (signal, timeout, crash, user-stop, completed).
469    pub exit_reason: Option<String>,
470
471    /// Reference to last output (sha256:<hex>).
472    pub last_output_ref: Option<String>,
473}
474
475/// A parsed convoy from a Grit issue.
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct Convoy {
478    /// Brat convoy ID (e.g., "c-20250116-a2f9").
479    pub convoy_id: String,
480
481    /// Grit's internal issue ID.
482    pub grite_issue_id: String,
483
484    /// Convoy title.
485    pub title: String,
486
487    /// Convoy description/body.
488    #[serde(default)]
489    pub body: String,
490
491    /// Convoy status.
492    pub status: ConvoyStatus,
493}
494
495/// A parsed task from a Grit issue.
496#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct Task {
498    /// Brat task ID (e.g., "t-20250116-3a2c").
499    pub task_id: String,
500
501    /// Grit's internal issue ID.
502    pub grite_issue_id: String,
503
504    /// Parent convoy ID.
505    pub convoy_id: String,
506
507    /// Task title.
508    pub title: String,
509
510    /// Task description/body.
511    #[serde(default)]
512    pub body: String,
513
514    /// Task status.
515    pub status: TaskStatus,
516}
517
518impl Task {
519    /// Parse paths from task body.
520    ///
521    /// Looks for a "Paths:" line in the body and extracts comma-separated paths.
522    /// Example body line: `Paths: src/main.rs, src/lib.rs, tests/`
523    ///
524    /// Returns an empty Vec if no Paths line is found.
525    pub fn parse_paths(&self) -> Vec<String> {
526        for line in self.body.lines() {
527            let trimmed = line.trim();
528            if let Some(paths_str) = trimmed.strip_prefix("Paths:") {
529                return paths_str
530                    .split(',')
531                    .map(|s| s.trim().to_string())
532                    .filter(|s| !s.is_empty())
533                    .collect();
534            }
535        }
536        Vec::new()
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    #[test]
545    fn test_task_status_label_roundtrip() {
546        for status in [
547            TaskStatus::Queued,
548            TaskStatus::Running,
549            TaskStatus::Blocked,
550            TaskStatus::NeedsReview,
551            TaskStatus::Merged,
552            TaskStatus::Dropped,
553        ] {
554            let label = status.as_label();
555            let parsed = TaskStatus::from_label(label);
556            assert_eq!(parsed, Some(status));
557        }
558    }
559
560    #[test]
561    fn test_convoy_status_label_roundtrip() {
562        for status in [
563            ConvoyStatus::Active,
564            ConvoyStatus::Paused,
565            ConvoyStatus::Complete,
566            ConvoyStatus::Failed,
567        ] {
568            let label = status.as_label();
569            let parsed = ConvoyStatus::from_label(label);
570            assert_eq!(parsed, Some(status));
571        }
572    }
573
574    #[test]
575    fn test_invalid_label() {
576        assert_eq!(TaskStatus::from_label("invalid"), None);
577        assert_eq!(ConvoyStatus::from_label("status:unknown"), None);
578    }
579
580    #[test]
581    fn test_session_type_label_roundtrip() {
582        for session_type in [SessionType::Polecat, SessionType::Crew] {
583            let label = session_type.as_label();
584            let parsed = SessionType::from_label(label);
585            assert_eq!(parsed, Some(session_type));
586        }
587    }
588
589    #[test]
590    fn test_session_type_str_roundtrip() {
591        for session_type in [SessionType::Polecat, SessionType::Crew] {
592            let s = session_type.as_str();
593            let parsed = SessionType::from_str(s);
594            assert_eq!(parsed, Some(session_type));
595        }
596    }
597
598    #[test]
599    fn test_session_role_roundtrip() {
600        for role in [
601            SessionRole::Mayor,
602            SessionRole::Witness,
603            SessionRole::Refinery,
604            SessionRole::Deacon,
605            SessionRole::User,
606        ] {
607            let s = role.as_str();
608            let parsed = SessionRole::from_str(s);
609            assert_eq!(parsed, Some(role));
610        }
611    }
612
613    #[test]
614    fn test_session_status_label_roundtrip() {
615        for status in [
616            SessionStatus::Spawned,
617            SessionStatus::Ready,
618            SessionStatus::Running,
619            SessionStatus::Handoff,
620            SessionStatus::Exit,
621        ] {
622            let label = status.as_label();
623            let parsed = SessionStatus::from_label(label);
624            assert_eq!(parsed, Some(status));
625        }
626    }
627
628    #[test]
629    fn test_task_parse_paths() {
630        let task = Task {
631            task_id: "t-20250117-test".to_string(),
632            grite_issue_id: "issue-123".to_string(),
633            convoy_id: "c-20250117-test".to_string(),
634            title: "Test task".to_string(),
635            body: "Some description\n\nPaths: src/main.rs, src/lib.rs, tests/\n\nMore text".to_string(),
636            status: TaskStatus::Queued,
637        };
638
639        let paths = task.parse_paths();
640        assert_eq!(paths, vec!["src/main.rs", "src/lib.rs", "tests/"]);
641    }
642
643    #[test]
644    fn test_task_parse_paths_empty() {
645        let task = Task {
646            task_id: "t-20250117-test".to_string(),
647            grite_issue_id: "issue-123".to_string(),
648            convoy_id: "c-20250117-test".to_string(),
649            title: "Test task".to_string(),
650            body: "Some description without paths".to_string(),
651            status: TaskStatus::Queued,
652        };
653
654        let paths = task.parse_paths();
655        assert!(paths.is_empty());
656    }
657
658    #[test]
659    fn test_task_parse_paths_single() {
660        let task = Task {
661            task_id: "t-20250117-test".to_string(),
662            grite_issue_id: "issue-123".to_string(),
663            convoy_id: "c-20250117-test".to_string(),
664            title: "Test task".to_string(),
665            body: "Paths: src/single.rs".to_string(),
666            status: TaskStatus::Queued,
667        };
668
669        let paths = task.parse_paths();
670        assert_eq!(paths, vec!["src/single.rs"]);
671    }
672
673    #[test]
674    fn test_dependency_type_roundtrip() {
675        for dep_type in [
676            DependencyType::Blocks,
677            DependencyType::DependsOn,
678            DependencyType::RelatedTo,
679        ] {
680            let s = dep_type.as_str();
681            let parsed = DependencyType::from_str(s);
682            assert_eq!(parsed, Some(dep_type));
683        }
684    }
685
686    #[test]
687    fn test_dependency_type_invalid() {
688        assert_eq!(DependencyType::from_str("invalid"), None);
689        assert_eq!(DependencyType::from_str(""), None);
690    }
691}