linear_tools/
models.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::fmt::Write as _;
4use universal_tool_core::mcp::McpFormatter;
5
6/// Truncate a string to at most `max` characters (UTF-8 safe).
7fn truncate_chars(s: &str, max: usize) -> String {
8    s.chars().take(max).collect()
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
12pub struct IssueSummary {
13    pub id: String,
14    pub identifier: String,
15    pub title: String,
16    pub state: Option<String>,
17    pub assignee: Option<String>,
18    pub priority: Option<i32>,
19    pub url: Option<String>,
20    pub team_key: Option<String>,
21    pub updated_at: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
25pub struct SearchResult {
26    pub issues: Vec<IssueSummary>,
27    pub has_next_page: bool,
28    pub end_cursor: Option<String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
32pub struct IssueDetails {
33    pub issue: IssueSummary,
34    pub description: Option<String>,
35    pub project: Option<String>,
36    pub created_at: String,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
40pub struct CreateIssueResult {
41    pub success: bool,
42    pub issue: Option<IssueSummary>,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46pub struct CommentResult {
47    pub success: bool,
48    pub comment_id: Option<String>,
49    pub body: Option<String>,
50    pub created_at: Option<String>,
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct FormatOptions {
55    pub show_ids: bool,
56    pub show_urls: bool,
57    pub show_dates: bool,
58    pub show_assignee: bool,
59    pub show_state: bool,
60    pub show_team: bool,
61    pub show_priority: bool,
62}
63
64impl FormatOptions {
65    pub fn from_env() -> Self {
66        Self::from_csv(&std::env::var("LINEAR_TOOLS_EXTRAS").unwrap_or_default())
67    }
68
69    pub fn from_csv(csv: &str) -> Self {
70        let mut o = Self::default();
71        for f in csv
72            .split(',')
73            .map(|s| s.trim().to_lowercase())
74            .filter(|s| !s.is_empty())
75        {
76            match f.as_str() {
77                "id" | "ids" => o.show_ids = true,
78                "url" | "urls" => o.show_urls = true,
79                "date" | "dates" => o.show_dates = true,
80                "assignee" | "assignees" => o.show_assignee = true,
81                "state" | "states" => o.show_state = true,
82                "team" | "teams" => o.show_team = true,
83                "priority" | "priorities" => o.show_priority = true,
84                _ => {}
85            }
86        }
87        o
88    }
89}
90
91impl McpFormatter for SearchResult {
92    fn mcp_format_text(&self) -> String {
93        if self.issues.is_empty() {
94            return "Issues: <none>".into();
95        }
96        let o = FormatOptions::from_env();
97        let mut out = String::new();
98        let _ = writeln!(out, "Issues:");
99        for i in &self.issues {
100            let mut line = format!("{} - {}", i.identifier, i.title);
101            if o.show_state && i.state.is_some() {
102                line.push_str(&format!(" [{}]", i.state.as_ref().unwrap()));
103            }
104            if o.show_assignee && i.assignee.is_some() {
105                line.push_str(&format!(" (by {})", i.assignee.as_ref().unwrap()));
106            }
107            if o.show_priority && i.priority.is_some() {
108                line.push_str(&format!(" P{}", i.priority.unwrap()));
109            }
110            if o.show_team && i.team_key.is_some() {
111                line.push_str(&format!(" [{}]", i.team_key.as_ref().unwrap()));
112            }
113            if o.show_urls && i.url.is_some() {
114                line.push_str(&format!(" {}", i.url.as_ref().unwrap()));
115            }
116            if o.show_ids {
117                line.push_str(&format!(" #{}", i.id));
118            }
119            if o.show_dates {
120                line.push_str(&format!(" @{}", i.updated_at));
121            }
122            let _ = writeln!(out, "  {}", line);
123        }
124        if self.has_next_page && self.end_cursor.is_some() {
125            let _ = writeln!(
126                out,
127                "\n[More results available, cursor: {}]",
128                self.end_cursor.as_ref().unwrap()
129            );
130        }
131        out
132    }
133}
134
135impl McpFormatter for IssueDetails {
136    fn mcp_format_text(&self) -> String {
137        let o = FormatOptions::from_env();
138        let i = &self.issue;
139        let mut out = String::new();
140
141        // Header line
142        let _ = writeln!(out, "{}: {}", i.identifier, i.title);
143
144        // Metadata line
145        let mut meta = Vec::new();
146        if let Some(s) = &i.state {
147            meta.push(format!("Status: {}", s));
148        }
149        if o.show_priority && i.priority.is_some() {
150            meta.push(format!("Priority: P{}", i.priority.unwrap()));
151        }
152        if o.show_assignee && i.assignee.is_some() {
153            meta.push(format!("Assignee: {}", i.assignee.as_ref().unwrap()));
154        }
155        if o.show_team && i.team_key.is_some() {
156            meta.push(format!("Team: {}", i.team_key.as_ref().unwrap()));
157        }
158        if let Some(p) = &self.project {
159            meta.push(format!("Project: {}", p));
160        }
161        if !meta.is_empty() {
162            let _ = writeln!(out, "{}", meta.join(" | "));
163        }
164
165        if o.show_urls && i.url.is_some() {
166            let _ = writeln!(out, "URL: {}", i.url.as_ref().unwrap());
167        }
168        if o.show_dates {
169            let _ = writeln!(
170                out,
171                "Created: {} | Updated: {}",
172                self.created_at, i.updated_at
173            );
174        }
175
176        // Description
177        if self
178            .description
179            .as_ref()
180            .is_some_and(|d| !d.trim().is_empty())
181        {
182            let _ = writeln!(out, "\n{}", self.description.as_ref().unwrap());
183        }
184
185        out
186    }
187}
188
189impl McpFormatter for CreateIssueResult {
190    fn mcp_format_text(&self) -> String {
191        if !self.success {
192            return "Failed to create issue".into();
193        }
194        match &self.issue {
195            Some(i) => format!("Created issue: {} - {}", i.identifier, i.title),
196            None => "Issue created (no details returned)".into(),
197        }
198    }
199}
200
201impl McpFormatter for CommentResult {
202    fn mcp_format_text(&self) -> String {
203        if !self.success {
204            return "Failed to add comment".into();
205        }
206        match (&self.comment_id, &self.body) {
207            (Some(id), Some(body)) => {
208                // 80 total, reserve 3 for "..."
209                let preview = if body.chars().count() > 80 {
210                    format!("{}...", truncate_chars(body, 77))
211                } else {
212                    body.clone()
213                };
214                format!("Comment added ({}): {}", id, preview)
215            }
216            _ => "Comment added".into(),
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::truncate_chars;
224
225    #[test]
226    fn truncates_ascii_safely() {
227        let s = "abcdefghijklmnopqrstuvwxyz";
228        assert_eq!(truncate_chars(s, 5), "abcde");
229    }
230
231    #[test]
232    fn truncates_utf8_safely() {
233        let s = "hello 😀😃😄😁"; // multi-byte
234        let truncated = truncate_chars(s, 8);
235        assert_eq!(truncated.chars().count(), 8);
236        assert_eq!(truncated, "hello 😀😃");
237    }
238
239    #[test]
240    fn handles_short_strings() {
241        assert_eq!(truncate_chars("hi", 10), "hi");
242    }
243}