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