1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::fmt::Write as _;
4use universal_tool_core::mcp::McpFormatter;
5
6fn 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 let _ = writeln!(out, "{}: {}", i.identifier, i.title);
143
144 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 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 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 😀😃😄😁"; 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}