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                line.push_str(&format!(" [{}]", s.name));
210            }
211            if o.show_assignee
212                && let Some(u) = &i.assignee
213            {
214                line.push_str(&format!(" (by {})", u.name));
215            }
216            if o.show_priority {
217                line.push_str(&format!(" P{} ({})", i.priority, i.priority_label));
218            }
219            if o.show_team {
220                line.push_str(&format!(" [{}]", i.team.key));
221            }
222            if o.show_urls {
223                line.push_str(&format!(" {}", i.url));
224            }
225            if o.show_ids {
226                line.push_str(&format!(" #{}", i.id));
227            }
228            if o.show_dates {
229                line.push_str(&format!(" @{}", 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 self
283            .description
284            .as_ref()
285            .is_some_and(|d| !d.trim().is_empty())
286        {
287            let _ = writeln!(out, "\n{}", self.description.as_ref().unwrap());
288        }
289
290        out
291    }
292}
293
294impl TextFormat for CreateIssueResult {
295    fn fmt_text(&self, _opts: &TextOptions) -> String {
296        if !self.success {
297            return "Failed to create issue".into();
298        }
299        match &self.issue {
300            Some(i) => format!(
301                "Created issue: {} - {}\nURL: {}",
302                i.identifier, i.title, i.url
303            ),
304            None => "Issue created (no details returned)".into(),
305        }
306    }
307}
308
309impl TextFormat for IssueResult {
310    fn fmt_text(&self, _opts: &TextOptions) -> String {
311        format!(
312            "Updated issue: {} - {}\nURL: {}",
313            self.issue.identifier, self.issue.title, self.issue.url
314        )
315    }
316}
317
318impl TextFormat for CommentResult {
319    fn fmt_text(&self, _opts: &TextOptions) -> String {
320        if !self.success {
321            return "Failed to add comment".into();
322        }
323        match (&self.comment_id, &self.body) {
324            (Some(id), Some(body)) => {
325                // 80 total, reserve 3 for "..."
326                let preview = if body.chars().count() > 80 {
327                    format!("{}...", truncate_chars(body, 77))
328                } else {
329                    body.clone()
330                };
331                format!("Comment added ({}): {}", id, preview)
332            }
333            _ => "Comment added".into(),
334        }
335    }
336}
337
338impl TextFormat for CommentsResult {
339    fn fmt_text(&self, _opts: &TextOptions) -> String {
340        if self.comments.is_empty() && self.total_comments == 0 {
341            return format!("No comments on {}", self.issue_identifier);
342        }
343
344        let mut out = String::new();
345        let start = self.shown_comments.saturating_sub(self.comments.len()) + 1;
346        let _ = writeln!(
347            out,
348            "Comments for {} (showing {}-{} of {}):",
349            self.issue_identifier, start, self.shown_comments, self.total_comments
350        );
351
352        for c in &self.comments {
353            let author = c.author_name.as_deref().unwrap_or("Unknown");
354            let timestamp = if c.created_at.len() >= 16 {
355                &c.created_at[..16]
356            } else {
357                &c.created_at
358            };
359
360            // Indent replies with ↳
361            let prefix = if c.parent_id.is_some() {
362                "  ↳ "
363            } else {
364                "  "
365            };
366
367            let _ = writeln!(out, "{}[{}] {}:", prefix, timestamp, author);
368            // Indent body lines
369            for line in c.body.lines() {
370                let _ = writeln!(out, "{}  {}", prefix, line);
371            }
372            let _ = writeln!(out);
373        }
374
375        if self.has_more {
376            let _ = writeln!(
377                out,
378                "(more comments available - call linear_get_issue_comments again)"
379            );
380        } else if self.total_comments > 0 {
381            let _ = writeln!(out, "(complete - another call restarts from beginning)");
382        }
383
384        out
385    }
386}
387
388// ============================================================================
389// Archive + Metadata models
390// ============================================================================
391
392#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
393pub struct ArchiveIssueResult {
394    pub success: bool,
395}
396
397/// Result of a set_relation operation
398#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
399pub struct SetRelationResult {
400    pub success: bool,
401    /// Action taken: "created", "removed", or "no_change"
402    pub action: String,
403}
404
405impl TextFormat for ArchiveIssueResult {
406    fn fmt_text(&self, _opts: &TextOptions) -> String {
407        if self.success {
408            "Issue archived successfully".into()
409        } else {
410            "Failed to archive issue".into()
411        }
412    }
413}
414
415impl TextFormat for SetRelationResult {
416    fn fmt_text(&self, _opts: &TextOptions) -> String {
417        match (self.success, self.action.as_str()) {
418            (true, "created") => "Relation created successfully".into(),
419            (true, "removed") => "Relation removed successfully".into(),
420            (true, "no_change") => "No relation change needed".into(),
421            (false, _) => "Failed to modify relation".into(),
422            _ => format!("Relation operation: {}", self.action),
423        }
424    }
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
428#[serde(rename_all = "snake_case")]
429pub enum MetadataKind {
430    Users,
431    Teams,
432    Projects,
433    WorkflowStates,
434    Labels,
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
438pub struct MetadataItem {
439    pub id: String,
440    pub name: String,
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub key: Option<String>,
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub email: Option<String>,
445    #[serde(skip_serializing_if = "Option::is_none")]
446    pub state_type: Option<String>,
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub team_id: Option<String>,
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
452pub struct GetMetadataResult {
453    pub kind: MetadataKind,
454    pub items: Vec<MetadataItem>,
455    pub has_next_page: bool,
456    pub end_cursor: Option<String>,
457}
458
459impl TextFormat for GetMetadataResult {
460    fn fmt_text(&self, _opts: &TextOptions) -> String {
461        if self.items.is_empty() {
462            return format!("{:?}: <none>", self.kind);
463        }
464        let mut out = String::new();
465        for item in &self.items {
466            let mut line = format!("{} ({})", item.name, item.id);
467            if let Some(ref key) = item.key {
468                line = format!("{} [{}] ({})", item.name, key, item.id);
469            }
470            if let Some(ref email) = item.email {
471                line.push_str(&format!(" <{}>", email));
472            }
473            if let Some(ref st) = item.state_type {
474                line.push_str(&format!(" [{}]", st));
475            }
476            let _ = writeln!(out, "  {}", line);
477        }
478        if self.has_next_page
479            && let Some(ref cursor) = self.end_cursor
480        {
481            let _ = writeln!(out, "  (more results: after={})", cursor);
482        }
483        out
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn truncates_ascii_safely() {
493        let s = "abcdefghijklmnopqrstuvwxyz";
494        assert_eq!(truncate_chars(s, 5), "abcde");
495    }
496
497    #[test]
498    fn truncates_utf8_safely() {
499        let s = "hello 😀😃😄😁"; // multi-byte
500        let truncated = truncate_chars(s, 8);
501        assert_eq!(truncated.chars().count(), 8);
502        assert_eq!(truncated, "hello 😀😃");
503    }
504
505    #[test]
506    fn handles_short_strings() {
507        assert_eq!(truncate_chars("hi", 10), "hi");
508    }
509
510    #[test]
511    fn format_options_default_shows_state_assignee_priority() {
512        let opts = FormatOptions::default();
513        assert!(opts.show_state);
514        assert!(opts.show_assignee);
515        assert!(opts.show_priority);
516        assert!(!opts.show_ids);
517        assert!(!opts.show_urls);
518        assert!(!opts.show_dates);
519        assert!(!opts.show_team);
520    }
521
522    #[test]
523    fn format_options_csv_adds_to_defaults() {
524        let opts = FormatOptions::from_csv("id,url");
525        assert!(opts.show_ids);
526        assert!(opts.show_urls);
527        // defaults still true
528        assert!(opts.show_state);
529        assert!(opts.show_assignee);
530        assert!(opts.show_priority);
531    }
532
533    #[test]
534    fn create_issue_result_includes_url() {
535        let result = CreateIssueResult {
536            success: true,
537            issue: Some(IssueSummary {
538                id: "id".into(),
539                identifier: "ENG-123".into(),
540                title: "Test Issue".into(),
541                url: "https://linear.app/team/issue/ENG-123".into(),
542                team: TeamRef {
543                    id: "team-id".into(),
544                    key: "ENG".into(),
545                    name: "Engineering".into(),
546                },
547                state: None,
548                assignee: None,
549                creator: None,
550                project: None,
551                priority: 3,
552                priority_label: "Normal".into(),
553                label_ids: vec![],
554                due_date: None,
555                created_at: "2024-03-27T10:00:00Z".into(),
556                updated_at: "2024-03-27T10:00:00Z".into(),
557            }),
558        };
559        let text = result.fmt_text(&TextOptions::default());
560        assert!(text.contains("ENG-123"));
561        assert!(text.contains("URL: https://linear.app/team/issue/ENG-123"));
562    }
563
564    #[test]
565    fn issue_result_includes_url() {
566        let result = IssueResult {
567            issue: IssueSummary {
568                id: "id".into(),
569                identifier: "ENG-456".into(),
570                title: "Updated Issue".into(),
571                url: "https://linear.app/team/issue/ENG-456".into(),
572                team: TeamRef {
573                    id: "team-id".into(),
574                    key: "ENG".into(),
575                    name: "Engineering".into(),
576                },
577                state: None,
578                assignee: None,
579                creator: None,
580                project: None,
581                priority: 3,
582                priority_label: "Normal".into(),
583                label_ids: vec![],
584                due_date: None,
585                created_at: "2024-03-27T10:00:00Z".into(),
586                updated_at: "2024-03-27T10:00:00Z".into(),
587            },
588        };
589        let text = result.fmt_text(&TextOptions::default());
590        assert!(text.contains("ENG-456"));
591        assert!(text.contains("URL: https://linear.app/team/issue/ENG-456"));
592    }
593
594    #[test]
595    fn comments_result_formats_empty() {
596        let result = CommentsResult {
597            issue_identifier: "ENG-123".into(),
598            comments: vec![],
599            shown_comments: 0,
600            total_comments: 0,
601            has_more: false,
602        };
603        let text = result.fmt_text(&TextOptions::default());
604        assert!(text.contains("No comments on ENG-123"));
605    }
606
607    #[test]
608    fn comments_result_formats_with_reply_indent() {
609        let result = CommentsResult {
610            issue_identifier: "ENG-123".into(),
611            comments: vec![
612                CommentSummary {
613                    id: "c1".into(),
614                    body: "Parent comment".into(),
615                    url: "https://linear.app/...".into(),
616                    created_at: "2024-03-27T10:00:00Z".into(),
617                    updated_at: "2024-03-27T10:00:00Z".into(),
618                    parent_id: None,
619                    author_name: Some("Alice".into()),
620                    author_email: Some("alice@example.com".into()),
621                },
622                CommentSummary {
623                    id: "c2".into(),
624                    body: "Reply comment".into(),
625                    url: "https://linear.app/...".into(),
626                    created_at: "2024-03-27T10:15:00Z".into(),
627                    updated_at: "2024-03-27T10:15:00Z".into(),
628                    parent_id: Some("c1".into()),
629                    author_name: Some("Bob".into()),
630                    author_email: Some("bob@example.com".into()),
631                },
632            ],
633            shown_comments: 2,
634            total_comments: 2,
635            has_more: false,
636        };
637        let text = result.fmt_text(&TextOptions::default());
638        assert!(text.contains("Alice"));
639        assert!(text.contains("↳")); // Reply has indent
640        assert!(text.contains("Bob"));
641    }
642
643    #[test]
644    fn comments_result_shows_pagination_message() {
645        let result = CommentsResult {
646            issue_identifier: "ENG-123".into(),
647            comments: vec![CommentSummary {
648                id: "c1".into(),
649                body: "Test comment".into(),
650                url: "https://linear.app/...".into(),
651                created_at: "2024-03-27T10:00:00Z".into(),
652                updated_at: "2024-03-27T10:00:00Z".into(),
653                parent_id: None,
654                author_name: Some("Alice".into()),
655                author_email: None,
656            }],
657            shown_comments: 10,
658            total_comments: 15,
659            has_more: true,
660        };
661        let text = result.fmt_text(&TextOptions::default());
662        assert!(text.contains("more comments available"));
663    }
664}