Skip to main content

linear_tools/
models.rs

1use agentic_tools_core::fmt::TextFormat;
2use agentic_tools_core::fmt::TextOptions;
3use schemars::JsonSchema;
4use serde::Deserialize;
5use serde::Serialize;
6use std::fmt::Write as _;
7
8/// Truncate a string to at most `max` characters (UTF-8 safe).
9fn truncate_chars(s: &str, max: usize) -> String {
10    s.chars().take(max).collect()
11}
12
13// ============================================================================
14// Nested Ref types for structured JSON output
15// ============================================================================
16
17#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
18pub struct UserRef {
19    pub id: String,
20    pub name: String,
21    pub email: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
25pub struct TeamRef {
26    pub id: String,
27    pub key: String,
28    pub name: String,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
32pub struct WorkflowStateRef {
33    pub id: String,
34    pub name: String,
35    pub state_type: String,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
39pub struct ProjectRef {
40    pub id: String,
41    pub name: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
45pub struct ParentIssueRef {
46    pub id: String,
47    pub identifier: String,
48}
49
50// ============================================================================
51// Issue models
52// ============================================================================
53
54#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
55pub struct IssueSummary {
56    pub id: String,
57    pub identifier: String,
58    pub title: String,
59    pub url: String,
60
61    pub team: TeamRef,
62    pub state: Option<WorkflowStateRef>,
63    pub assignee: Option<UserRef>,
64    pub creator: Option<UserRef>,
65    pub project: Option<ProjectRef>,
66
67    pub priority: i32,
68    pub priority_label: String,
69
70    pub label_ids: Vec<String>,
71    pub due_date: Option<String>,
72
73    pub created_at: String,
74    pub updated_at: String,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
78pub struct SearchResult {
79    pub issues: Vec<IssueSummary>,
80    pub has_next_page: bool,
81    pub end_cursor: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
85pub struct IssueDetails {
86    pub issue: IssueSummary,
87    pub description: Option<String>,
88
89    pub estimate: Option<f64>,
90    pub parent: Option<ParentIssueRef>,
91    pub started_at: Option<String>,
92    pub completed_at: Option<String>,
93    pub canceled_at: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
97pub struct CreateIssueResult {
98    pub success: bool,
99    pub issue: Option<IssueSummary>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
103pub struct IssueResult {
104    pub issue: IssueSummary,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
108pub struct CommentResult {
109    pub success: bool,
110    pub comment_id: Option<String>,
111    pub body: Option<String>,
112    pub created_at: Option<String>,
113}
114
115// ============================================================================
116// Get issue comments models
117// ============================================================================
118
119#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
120pub struct CommentSummary {
121    pub id: String,
122    pub body: String,
123    pub url: String,
124    pub created_at: String,
125    pub updated_at: String,
126    pub parent_id: Option<String>,
127    pub author_name: Option<String>,
128    pub author_email: Option<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
132pub struct CommentsResult {
133    pub issue_identifier: String,
134    pub comments: Vec<CommentSummary>,
135    pub shown_comments: usize,
136    pub total_comments: usize,
137    pub has_more: bool,
138}
139
140// ============================================================================
141// Text formatting
142// ============================================================================
143
144#[derive(Debug, Clone)]
145pub struct FormatOptions {
146    pub show_ids: bool,
147    pub show_urls: bool,
148    pub show_dates: bool,
149    pub show_assignee: bool,
150    pub show_state: bool,
151    pub show_team: bool,
152    pub show_priority: bool,
153}
154
155impl Default for FormatOptions {
156    fn default() -> Self {
157        Self {
158            show_ids: false,
159            show_urls: false,
160            show_dates: false,
161            show_assignee: true,
162            show_state: true,
163            show_team: false,
164            show_priority: true,
165        }
166    }
167}
168
169impl FormatOptions {
170    pub fn from_env() -> Self {
171        Self::from_csv(&std::env::var("LINEAR_TOOLS_EXTRAS").unwrap_or_default())
172    }
173
174    pub fn from_csv(csv: &str) -> Self {
175        let mut o = Self::default();
176        for f in csv
177            .split(',')
178            .map(|s| s.trim().to_lowercase())
179            .filter(|s| !s.is_empty())
180        {
181            match f.as_str() {
182                "id" | "ids" => o.show_ids = true,
183                "url" | "urls" => o.show_urls = true,
184                "date" | "dates" => o.show_dates = true,
185                "assignee" | "assignees" => o.show_assignee = true,
186                "state" | "states" => o.show_state = true,
187                "team" | "teams" => o.show_team = true,
188                "priority" | "priorities" => o.show_priority = true,
189                _ => {}
190            }
191        }
192        o
193    }
194}
195
196impl TextFormat for SearchResult {
197    fn fmt_text(&self, _opts: &TextOptions) -> String {
198        if self.issues.is_empty() {
199            return "Issues: <none>".into();
200        }
201        let o = FormatOptions::from_env();
202        let mut out = String::new();
203        let _ = writeln!(out, "Issues:");
204        for i in &self.issues {
205            let mut line = format!("{} - {}", i.identifier, i.title);
206            if o.show_state
207                && let Some(s) = &i.state
208            {
209                let _ = write!(line, " [{}]", s.name);
210            }
211            if o.show_assignee
212                && let Some(u) = &i.assignee
213            {
214                let _ = write!(line, " (by {})", u.name);
215            }
216            if o.show_priority {
217                let _ = write!(line, " P{} ({})", i.priority, i.priority_label);
218            }
219            if o.show_team {
220                let _ = write!(line, " [{}]", i.team.key);
221            }
222            if o.show_urls {
223                let _ = write!(line, " {}", i.url);
224            }
225            if o.show_ids {
226                let _ = write!(line, " #{}", i.id);
227            }
228            if o.show_dates {
229                let _ = write!(line, " @{}", i.updated_at);
230            }
231            let _ = writeln!(out, "  {line}");
232        }
233        if self.has_next_page
234            && let Some(cursor) = &self.end_cursor
235        {
236            let _ = writeln!(out, "\n[More results available, cursor: {cursor}]");
237        }
238        out
239    }
240}
241
242impl TextFormat for IssueDetails {
243    fn fmt_text(&self, _opts: &TextOptions) -> String {
244        let o = FormatOptions::from_env();
245        let i = &self.issue;
246        let mut out = String::new();
247
248        // Header line
249        let _ = writeln!(out, "{}: {}", i.identifier, i.title);
250
251        // Metadata line
252        let mut meta = Vec::new();
253        if let Some(s) = &i.state {
254            meta.push(format!("Status: {}", s.name));
255        }
256        if o.show_priority {
257            meta.push(format!("Priority: P{} ({})", i.priority, i.priority_label));
258        }
259        if o.show_assignee
260            && let Some(u) = &i.assignee
261        {
262            meta.push(format!("Assignee: {}", u.name));
263        }
264        if o.show_team {
265            meta.push(format!("Team: {}", i.team.key));
266        }
267        if let Some(p) = &i.project {
268            meta.push(format!("Project: {}", p.name));
269        }
270        if !meta.is_empty() {
271            let _ = writeln!(out, "{}", meta.join(" | "));
272        }
273
274        if o.show_urls {
275            let _ = writeln!(out, "URL: {}", i.url);
276        }
277        if o.show_dates {
278            let _ = writeln!(out, "Created: {} | Updated: {}", i.created_at, i.updated_at);
279        }
280
281        // Description
282        if let Some(description) = self.description.as_ref().filter(|d| !d.trim().is_empty()) {
283            let _ = writeln!(out, "\n{description}");
284        }
285
286        out
287    }
288}
289
290impl TextFormat for CreateIssueResult {
291    fn fmt_text(&self, _opts: &TextOptions) -> String {
292        if !self.success {
293            return "Failed to create issue".into();
294        }
295        match &self.issue {
296            Some(i) => format!(
297                "Created issue: {} - {}\nURL: {}",
298                i.identifier, i.title, i.url
299            ),
300            None => "Issue created (no details returned)".into(),
301        }
302    }
303}
304
305impl TextFormat for IssueResult {
306    fn fmt_text(&self, _opts: &TextOptions) -> String {
307        format!(
308            "Updated issue: {} - {}\nURL: {}",
309            self.issue.identifier, self.issue.title, self.issue.url
310        )
311    }
312}
313
314impl TextFormat for CommentResult {
315    fn fmt_text(&self, _opts: &TextOptions) -> String {
316        if !self.success {
317            return "Failed to add comment".into();
318        }
319        match (&self.comment_id, &self.body) {
320            (Some(id), Some(body)) => {
321                // 80 total, reserve 3 for "..."
322                let preview = if body.chars().count() > 80 {
323                    format!("{}...", truncate_chars(body, 77))
324                } else {
325                    body.clone()
326                };
327                format!("Comment added ({id}): {preview}")
328            }
329            _ => "Comment added".into(),
330        }
331    }
332}
333
334impl TextFormat for CommentsResult {
335    fn fmt_text(&self, _opts: &TextOptions) -> String {
336        if self.comments.is_empty() && self.total_comments == 0 {
337            return format!("No comments on {}", self.issue_identifier);
338        }
339
340        let mut out = String::new();
341        let start = self.shown_comments.saturating_sub(self.comments.len()) + 1;
342        let _ = writeln!(
343            out,
344            "Comments for {} (showing {}-{} of {}):",
345            self.issue_identifier, start, self.shown_comments, self.total_comments
346        );
347
348        for c in &self.comments {
349            let author = c.author_name.as_deref().unwrap_or("Unknown");
350            let timestamp = if c.created_at.len() >= 16 {
351                &c.created_at[..16]
352            } else {
353                &c.created_at
354            };
355
356            // Indent replies with ↳
357            let prefix = if c.parent_id.is_some() {
358                "  ↳ "
359            } else {
360                "  "
361            };
362
363            let _ = writeln!(out, "{prefix}[{timestamp}] {author}:");
364            // Indent body lines
365            for line in c.body.lines() {
366                let _ = writeln!(out, "{prefix}  {line}");
367            }
368            let _ = writeln!(out);
369        }
370
371        if self.has_more {
372            let _ = writeln!(
373                out,
374                "(more comments available - call linear_get_issue_comments again)"
375            );
376        } else if self.total_comments > 0 {
377            let _ = writeln!(out, "(complete - another call restarts from beginning)");
378        }
379
380        out
381    }
382}
383
384// ============================================================================
385// Archive + Metadata models
386// ============================================================================
387
388#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
389pub struct ArchiveIssueResult {
390    pub success: bool,
391}
392
393/// Result of a `set_relation` operation
394#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
395pub struct SetRelationResult {
396    pub success: bool,
397    /// Action taken: `"created"`, `"removed"`, or `"no_change"`
398    pub action: String,
399}
400
401impl TextFormat for ArchiveIssueResult {
402    fn fmt_text(&self, _opts: &TextOptions) -> String {
403        if self.success {
404            "Issue archived successfully".into()
405        } else {
406            "Failed to archive issue".into()
407        }
408    }
409}
410
411impl TextFormat for SetRelationResult {
412    fn fmt_text(&self, _opts: &TextOptions) -> String {
413        match (self.success, self.action.as_str()) {
414            (true, "created") => "Relation created successfully".into(),
415            (true, "removed") => "Relation removed successfully".into(),
416            (true, "no_change") => "No relation change needed".into(),
417            (false, _) => "Failed to modify relation".into(),
418            _ => format!("Relation operation: {}", self.action),
419        }
420    }
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
424#[serde(rename_all = "snake_case")]
425pub enum MetadataKind {
426    Users,
427    Teams,
428    Projects,
429    WorkflowStates,
430    Labels,
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
434pub struct MetadataItem {
435    pub id: String,
436    pub name: String,
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub key: Option<String>,
439    #[serde(skip_serializing_if = "Option::is_none")]
440    pub email: Option<String>,
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub state_type: Option<String>,
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub team_id: Option<String>,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
448pub struct GetMetadataResult {
449    pub kind: MetadataKind,
450    pub items: Vec<MetadataItem>,
451    pub has_next_page: bool,
452    pub end_cursor: Option<String>,
453}
454
455impl TextFormat for GetMetadataResult {
456    fn fmt_text(&self, _opts: &TextOptions) -> String {
457        if self.items.is_empty() {
458            return format!("{:?}: <none>", self.kind);
459        }
460        let mut out = String::new();
461        for item in &self.items {
462            let mut line = if let Some(ref key) = item.key {
463                format!("{} [{}] ({})", item.name, key, item.id)
464            } else {
465                format!("{} ({})", item.name, item.id)
466            };
467            if let Some(ref email) = item.email {
468                let _ = write!(line, " <{email}>");
469            }
470            if let Some(ref st) = item.state_type {
471                let _ = write!(line, " [{st}]");
472            }
473            let _ = writeln!(out, "  {line}");
474        }
475        if self.has_next_page
476            && let Some(ref cursor) = self.end_cursor
477        {
478            let _ = writeln!(out, "  (more results: after={cursor})");
479        }
480        out
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn truncates_ascii_safely() {
490        let s = "abcdefghijklmnopqrstuvwxyz";
491        assert_eq!(truncate_chars(s, 5), "abcde");
492    }
493
494    #[test]
495    fn truncates_utf8_safely() {
496        let s = "hello 😀😃😄😁"; // multi-byte
497        let truncated = truncate_chars(s, 8);
498        assert_eq!(truncated.chars().count(), 8);
499        assert_eq!(truncated, "hello 😀😃");
500    }
501
502    #[test]
503    fn handles_short_strings() {
504        assert_eq!(truncate_chars("hi", 10), "hi");
505    }
506
507    #[test]
508    fn format_options_default_shows_state_assignee_priority() {
509        let opts = FormatOptions::default();
510        assert!(opts.show_state);
511        assert!(opts.show_assignee);
512        assert!(opts.show_priority);
513        assert!(!opts.show_ids);
514        assert!(!opts.show_urls);
515        assert!(!opts.show_dates);
516        assert!(!opts.show_team);
517    }
518
519    #[test]
520    fn format_options_csv_adds_to_defaults() {
521        let opts = FormatOptions::from_csv("id,url");
522        assert!(opts.show_ids);
523        assert!(opts.show_urls);
524        // defaults still true
525        assert!(opts.show_state);
526        assert!(opts.show_assignee);
527        assert!(opts.show_priority);
528    }
529
530    #[test]
531    fn create_issue_result_includes_url() {
532        let result = CreateIssueResult {
533            success: true,
534            issue: Some(IssueSummary {
535                id: "id".into(),
536                identifier: "ENG-123".into(),
537                title: "Test Issue".into(),
538                url: "https://linear.app/team/issue/ENG-123".into(),
539                team: TeamRef {
540                    id: "team-id".into(),
541                    key: "ENG".into(),
542                    name: "Engineering".into(),
543                },
544                state: None,
545                assignee: None,
546                creator: None,
547                project: None,
548                priority: 3,
549                priority_label: "Normal".into(),
550                label_ids: vec![],
551                due_date: None,
552                created_at: "2024-03-27T10:00:00Z".into(),
553                updated_at: "2024-03-27T10:00:00Z".into(),
554            }),
555        };
556        let text = result.fmt_text(&TextOptions::default());
557        assert!(text.contains("ENG-123"));
558        assert!(text.contains("URL: https://linear.app/team/issue/ENG-123"));
559    }
560
561    #[test]
562    fn issue_result_includes_url() {
563        let result = IssueResult {
564            issue: IssueSummary {
565                id: "id".into(),
566                identifier: "ENG-456".into(),
567                title: "Updated Issue".into(),
568                url: "https://linear.app/team/issue/ENG-456".into(),
569                team: TeamRef {
570                    id: "team-id".into(),
571                    key: "ENG".into(),
572                    name: "Engineering".into(),
573                },
574                state: None,
575                assignee: None,
576                creator: None,
577                project: None,
578                priority: 3,
579                priority_label: "Normal".into(),
580                label_ids: vec![],
581                due_date: None,
582                created_at: "2024-03-27T10:00:00Z".into(),
583                updated_at: "2024-03-27T10:00:00Z".into(),
584            },
585        };
586        let text = result.fmt_text(&TextOptions::default());
587        assert!(text.contains("ENG-456"));
588        assert!(text.contains("URL: https://linear.app/team/issue/ENG-456"));
589    }
590
591    #[test]
592    fn comments_result_formats_empty() {
593        let result = CommentsResult {
594            issue_identifier: "ENG-123".into(),
595            comments: vec![],
596            shown_comments: 0,
597            total_comments: 0,
598            has_more: false,
599        };
600        let text = result.fmt_text(&TextOptions::default());
601        assert!(text.contains("No comments on ENG-123"));
602    }
603
604    #[test]
605    fn comments_result_formats_with_reply_indent() {
606        let result = CommentsResult {
607            issue_identifier: "ENG-123".into(),
608            comments: vec![
609                CommentSummary {
610                    id: "c1".into(),
611                    body: "Parent comment".into(),
612                    url: "https://linear.app/...".into(),
613                    created_at: "2024-03-27T10:00:00Z".into(),
614                    updated_at: "2024-03-27T10:00:00Z".into(),
615                    parent_id: None,
616                    author_name: Some("Alice".into()),
617                    author_email: Some("alice@example.com".into()),
618                },
619                CommentSummary {
620                    id: "c2".into(),
621                    body: "Reply comment".into(),
622                    url: "https://linear.app/...".into(),
623                    created_at: "2024-03-27T10:15:00Z".into(),
624                    updated_at: "2024-03-27T10:15:00Z".into(),
625                    parent_id: Some("c1".into()),
626                    author_name: Some("Bob".into()),
627                    author_email: Some("bob@example.com".into()),
628                },
629            ],
630            shown_comments: 2,
631            total_comments: 2,
632            has_more: false,
633        };
634        let text = result.fmt_text(&TextOptions::default());
635        assert!(text.contains("Alice"));
636        assert!(text.contains("↳")); // Reply has indent
637        assert!(text.contains("Bob"));
638    }
639
640    #[test]
641    fn comments_result_shows_pagination_message() {
642        let result = CommentsResult {
643            issue_identifier: "ENG-123".into(),
644            comments: vec![CommentSummary {
645                id: "c1".into(),
646                body: "Test comment".into(),
647                url: "https://linear.app/...".into(),
648                created_at: "2024-03-27T10:00:00Z".into(),
649                updated_at: "2024-03-27T10:00:00Z".into(),
650                parent_id: None,
651                author_name: Some("Alice".into()),
652                author_email: None,
653            }],
654            shown_comments: 10,
655            total_comments: 15,
656            has_more: true,
657        };
658        let text = result.fmt_text(&TextOptions::default());
659        assert!(text.contains("more comments available"));
660    }
661}