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
8fn truncate_chars(s: &str, max: usize) -> String {
10 s.chars().take(max).collect()
11}
12
13#[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#[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#[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#[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 let _ = writeln!(out, "{}: {}", i.identifier, i.title);
250
251 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 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 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 let prefix = if c.parent_id.is_some() {
362 " ↳ "
363 } else {
364 " "
365 };
366
367 let _ = writeln!(out, "{}[{}] {}:", prefix, timestamp, author);
368 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
393pub struct ArchiveIssueResult {
394 pub success: bool,
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
399pub struct SetRelationResult {
400 pub success: bool,
401 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 😀😃😄😁"; 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 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("↳")); 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}