1use agentic_tools_core::fmt::{TextFormat, TextOptions};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use std::fmt::Write as _;
5
6fn truncate_chars(s: &str, max: usize) -> String {
8 s.chars().take(max).collect()
9}
10
11#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
16pub struct UserRef {
17 pub id: String,
18 pub name: String,
19 pub email: String,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
23pub struct TeamRef {
24 pub id: String,
25 pub key: String,
26 pub name: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
30pub struct WorkflowStateRef {
31 pub id: String,
32 pub name: String,
33 pub state_type: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
37pub struct ProjectRef {
38 pub id: String,
39 pub name: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
43pub struct ParentIssueRef {
44 pub id: String,
45 pub identifier: String,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
53pub struct IssueSummary {
54 pub id: String,
55 pub identifier: String,
56 pub title: String,
57 pub url: String,
58
59 pub team: TeamRef,
60 pub state: Option<WorkflowStateRef>,
61 pub assignee: Option<UserRef>,
62 pub creator: Option<UserRef>,
63 pub project: Option<ProjectRef>,
64
65 pub priority: i32,
66 pub priority_label: String,
67
68 pub label_ids: Vec<String>,
69 pub due_date: Option<String>,
70
71 pub created_at: String,
72 pub updated_at: String,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
76pub struct SearchResult {
77 pub issues: Vec<IssueSummary>,
78 pub has_next_page: bool,
79 pub end_cursor: Option<String>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
83pub struct IssueDetails {
84 pub issue: IssueSummary,
85 pub description: Option<String>,
86
87 pub estimate: Option<f64>,
88 pub parent: Option<ParentIssueRef>,
89 pub started_at: Option<String>,
90 pub completed_at: Option<String>,
91 pub canceled_at: Option<String>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
95pub struct CreateIssueResult {
96 pub success: bool,
97 pub issue: Option<IssueSummary>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
101pub struct CommentResult {
102 pub success: bool,
103 pub comment_id: Option<String>,
104 pub body: Option<String>,
105 pub created_at: Option<String>,
106}
107
108#[derive(Debug, Clone)]
113pub struct FormatOptions {
114 pub show_ids: bool,
115 pub show_urls: bool,
116 pub show_dates: bool,
117 pub show_assignee: bool,
118 pub show_state: bool,
119 pub show_team: bool,
120 pub show_priority: bool,
121}
122
123impl Default for FormatOptions {
124 fn default() -> Self {
125 Self {
126 show_ids: false,
127 show_urls: false,
128 show_dates: false,
129 show_assignee: true,
130 show_state: true,
131 show_team: false,
132 show_priority: true,
133 }
134 }
135}
136
137impl FormatOptions {
138 pub fn from_env() -> Self {
139 Self::from_csv(&std::env::var("LINEAR_TOOLS_EXTRAS").unwrap_or_default())
140 }
141
142 pub fn from_csv(csv: &str) -> Self {
143 let mut o = Self::default();
144 for f in csv
145 .split(',')
146 .map(|s| s.trim().to_lowercase())
147 .filter(|s| !s.is_empty())
148 {
149 match f.as_str() {
150 "id" | "ids" => o.show_ids = true,
151 "url" | "urls" => o.show_urls = true,
152 "date" | "dates" => o.show_dates = true,
153 "assignee" | "assignees" => o.show_assignee = true,
154 "state" | "states" => o.show_state = true,
155 "team" | "teams" => o.show_team = true,
156 "priority" | "priorities" => o.show_priority = true,
157 _ => {}
158 }
159 }
160 o
161 }
162}
163
164impl TextFormat for SearchResult {
165 fn fmt_text(&self, _opts: &TextOptions) -> String {
166 if self.issues.is_empty() {
167 return "Issues: <none>".into();
168 }
169 let o = FormatOptions::from_env();
170 let mut out = String::new();
171 let _ = writeln!(out, "Issues:");
172 for i in &self.issues {
173 let mut line = format!("{} - {}", i.identifier, i.title);
174 if o.show_state
175 && let Some(s) = &i.state
176 {
177 line.push_str(&format!(" [{}]", s.name));
178 }
179 if o.show_assignee
180 && let Some(u) = &i.assignee
181 {
182 line.push_str(&format!(" (by {})", u.name));
183 }
184 if o.show_priority {
185 line.push_str(&format!(" P{} ({})", i.priority, i.priority_label));
186 }
187 if o.show_team {
188 line.push_str(&format!(" [{}]", i.team.key));
189 }
190 if o.show_urls {
191 line.push_str(&format!(" {}", i.url));
192 }
193 if o.show_ids {
194 line.push_str(&format!(" #{}", i.id));
195 }
196 if o.show_dates {
197 line.push_str(&format!(" @{}", i.updated_at));
198 }
199 let _ = writeln!(out, " {}", line);
200 }
201 if self.has_next_page
202 && let Some(cursor) = &self.end_cursor
203 {
204 let _ = writeln!(out, "\n[More results available, cursor: {}]", cursor);
205 }
206 out
207 }
208}
209
210impl TextFormat for IssueDetails {
211 fn fmt_text(&self, _opts: &TextOptions) -> String {
212 let o = FormatOptions::from_env();
213 let i = &self.issue;
214 let mut out = String::new();
215
216 let _ = writeln!(out, "{}: {}", i.identifier, i.title);
218
219 let mut meta = Vec::new();
221 if let Some(s) = &i.state {
222 meta.push(format!("Status: {}", s.name));
223 }
224 if o.show_priority {
225 meta.push(format!("Priority: P{} ({})", i.priority, i.priority_label));
226 }
227 if o.show_assignee
228 && let Some(u) = &i.assignee
229 {
230 meta.push(format!("Assignee: {}", u.name));
231 }
232 if o.show_team {
233 meta.push(format!("Team: {}", i.team.key));
234 }
235 if let Some(p) = &i.project {
236 meta.push(format!("Project: {}", p.name));
237 }
238 if !meta.is_empty() {
239 let _ = writeln!(out, "{}", meta.join(" | "));
240 }
241
242 if o.show_urls {
243 let _ = writeln!(out, "URL: {}", i.url);
244 }
245 if o.show_dates {
246 let _ = writeln!(out, "Created: {} | Updated: {}", i.created_at, i.updated_at);
247 }
248
249 if self
251 .description
252 .as_ref()
253 .is_some_and(|d| !d.trim().is_empty())
254 {
255 let _ = writeln!(out, "\n{}", self.description.as_ref().unwrap());
256 }
257
258 out
259 }
260}
261
262impl TextFormat for CreateIssueResult {
263 fn fmt_text(&self, _opts: &TextOptions) -> String {
264 if !self.success {
265 return "Failed to create issue".into();
266 }
267 match &self.issue {
268 Some(i) => format!("Created issue: {} - {}", i.identifier, i.title),
269 None => "Issue created (no details returned)".into(),
270 }
271 }
272}
273
274impl TextFormat for CommentResult {
275 fn fmt_text(&self, _opts: &TextOptions) -> String {
276 if !self.success {
277 return "Failed to add comment".into();
278 }
279 match (&self.comment_id, &self.body) {
280 (Some(id), Some(body)) => {
281 let preview = if body.chars().count() > 80 {
283 format!("{}...", truncate_chars(body, 77))
284 } else {
285 body.clone()
286 };
287 format!("Comment added ({}): {}", id, preview)
288 }
289 _ => "Comment added".into(),
290 }
291 }
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
299pub struct ArchiveIssueResult {
300 pub success: bool,
301}
302
303impl TextFormat for ArchiveIssueResult {
304 fn fmt_text(&self, _opts: &TextOptions) -> String {
305 if self.success {
306 "Issue archived successfully".into()
307 } else {
308 "Failed to archive issue".into()
309 }
310 }
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
314#[serde(rename_all = "snake_case")]
315pub enum MetadataKind {
316 Users,
317 Teams,
318 Projects,
319 WorkflowStates,
320 Labels,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
324pub struct MetadataItem {
325 pub id: String,
326 pub name: String,
327 #[serde(skip_serializing_if = "Option::is_none")]
328 pub key: Option<String>,
329 #[serde(skip_serializing_if = "Option::is_none")]
330 pub email: Option<String>,
331 #[serde(skip_serializing_if = "Option::is_none")]
332 pub state_type: Option<String>,
333 #[serde(skip_serializing_if = "Option::is_none")]
334 pub team_id: Option<String>,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
338pub struct GetMetadataResult {
339 pub kind: MetadataKind,
340 pub items: Vec<MetadataItem>,
341 pub has_next_page: bool,
342 pub end_cursor: Option<String>,
343}
344
345impl TextFormat for GetMetadataResult {
346 fn fmt_text(&self, _opts: &TextOptions) -> String {
347 if self.items.is_empty() {
348 return format!("{:?}: <none>", self.kind);
349 }
350 let mut out = String::new();
351 for item in &self.items {
352 let mut line = format!("{} ({})", item.name, item.id);
353 if let Some(ref key) = item.key {
354 line = format!("{} [{}] ({})", item.name, key, item.id);
355 }
356 if let Some(ref email) = item.email {
357 line.push_str(&format!(" <{}>", email));
358 }
359 if let Some(ref st) = item.state_type {
360 line.push_str(&format!(" [{}]", st));
361 }
362 let _ = writeln!(out, " {}", line);
363 }
364 if self.has_next_page
365 && let Some(ref cursor) = self.end_cursor
366 {
367 let _ = writeln!(out, " (more results: after={})", cursor);
368 }
369 out
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn truncates_ascii_safely() {
379 let s = "abcdefghijklmnopqrstuvwxyz";
380 assert_eq!(truncate_chars(s, 5), "abcde");
381 }
382
383 #[test]
384 fn truncates_utf8_safely() {
385 let s = "hello 😀😃😄😁"; let truncated = truncate_chars(s, 8);
387 assert_eq!(truncated.chars().count(), 8);
388 assert_eq!(truncated, "hello 😀😃");
389 }
390
391 #[test]
392 fn handles_short_strings() {
393 assert_eq!(truncate_chars("hi", 10), "hi");
394 }
395
396 #[test]
397 fn format_options_default_shows_state_assignee_priority() {
398 let opts = FormatOptions::default();
399 assert!(opts.show_state);
400 assert!(opts.show_assignee);
401 assert!(opts.show_priority);
402 assert!(!opts.show_ids);
403 assert!(!opts.show_urls);
404 assert!(!opts.show_dates);
405 assert!(!opts.show_team);
406 }
407
408 #[test]
409 fn format_options_csv_adds_to_defaults() {
410 let opts = FormatOptions::from_csv("id,url");
411 assert!(opts.show_ids);
412 assert!(opts.show_urls);
413 assert!(opts.show_state);
415 assert!(opts.show_assignee);
416 assert!(opts.show_priority);
417 }
418}