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 && self.end_cursor.is_some() {
202 let _ = writeln!(
203 out,
204 "\n[More results available, cursor: {}]",
205 self.end_cursor.as_ref().unwrap()
206 );
207 }
208 out
209 }
210}
211
212impl TextFormat for IssueDetails {
213 fn fmt_text(&self, _opts: &TextOptions) -> String {
214 let o = FormatOptions::from_env();
215 let i = &self.issue;
216 let mut out = String::new();
217
218 let _ = writeln!(out, "{}: {}", i.identifier, i.title);
220
221 let mut meta = Vec::new();
223 if let Some(s) = &i.state {
224 meta.push(format!("Status: {}", s.name));
225 }
226 if o.show_priority {
227 meta.push(format!("Priority: P{} ({})", i.priority, i.priority_label));
228 }
229 if o.show_assignee
230 && let Some(u) = &i.assignee
231 {
232 meta.push(format!("Assignee: {}", u.name));
233 }
234 if o.show_team {
235 meta.push(format!("Team: {}", i.team.key));
236 }
237 if let Some(p) = &i.project {
238 meta.push(format!("Project: {}", p.name));
239 }
240 if !meta.is_empty() {
241 let _ = writeln!(out, "{}", meta.join(" | "));
242 }
243
244 if o.show_urls {
245 let _ = writeln!(out, "URL: {}", i.url);
246 }
247 if o.show_dates {
248 let _ = writeln!(out, "Created: {} | Updated: {}", i.created_at, i.updated_at);
249 }
250
251 if self
253 .description
254 .as_ref()
255 .is_some_and(|d| !d.trim().is_empty())
256 {
257 let _ = writeln!(out, "\n{}", self.description.as_ref().unwrap());
258 }
259
260 out
261 }
262}
263
264impl TextFormat for CreateIssueResult {
265 fn fmt_text(&self, _opts: &TextOptions) -> String {
266 if !self.success {
267 return "Failed to create issue".into();
268 }
269 match &self.issue {
270 Some(i) => format!("Created issue: {} - {}", i.identifier, i.title),
271 None => "Issue created (no details returned)".into(),
272 }
273 }
274}
275
276impl TextFormat for CommentResult {
277 fn fmt_text(&self, _opts: &TextOptions) -> String {
278 if !self.success {
279 return "Failed to add comment".into();
280 }
281 match (&self.comment_id, &self.body) {
282 (Some(id), Some(body)) => {
283 let preview = if body.chars().count() > 80 {
285 format!("{}...", truncate_chars(body, 77))
286 } else {
287 body.clone()
288 };
289 format!("Comment added ({}): {}", id, preview)
290 }
291 _ => "Comment added".into(),
292 }
293 }
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
301pub struct ArchiveIssueResult {
302 pub success: bool,
303}
304
305impl TextFormat for ArchiveIssueResult {
306 fn fmt_text(&self, _opts: &TextOptions) -> String {
307 if self.success {
308 "Issue archived successfully".into()
309 } else {
310 "Failed to archive issue".into()
311 }
312 }
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
316#[serde(rename_all = "snake_case")]
317pub enum MetadataKind {
318 Users,
319 Teams,
320 Projects,
321 WorkflowStates,
322 Labels,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
326pub struct MetadataItem {
327 pub id: String,
328 pub name: String,
329 #[serde(skip_serializing_if = "Option::is_none")]
330 pub key: Option<String>,
331 #[serde(skip_serializing_if = "Option::is_none")]
332 pub email: Option<String>,
333 #[serde(skip_serializing_if = "Option::is_none")]
334 pub state_type: Option<String>,
335 #[serde(skip_serializing_if = "Option::is_none")]
336 pub team_id: Option<String>,
337}
338
339#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
340pub struct GetMetadataResult {
341 pub kind: MetadataKind,
342 pub items: Vec<MetadataItem>,
343 pub has_next_page: bool,
344 pub end_cursor: Option<String>,
345}
346
347impl TextFormat for GetMetadataResult {
348 fn fmt_text(&self, _opts: &TextOptions) -> String {
349 if self.items.is_empty() {
350 return format!("{:?}: <none>", self.kind);
351 }
352 let mut out = String::new();
353 for item in &self.items {
354 let mut line = format!("{} ({})", item.name, item.id);
355 if let Some(ref key) = item.key {
356 line = format!("{} [{}] ({})", item.name, key, item.id);
357 }
358 if let Some(ref email) = item.email {
359 line.push_str(&format!(" <{}>", email));
360 }
361 if let Some(ref st) = item.state_type {
362 line.push_str(&format!(" [{}]", st));
363 }
364 let _ = writeln!(out, " {}", line);
365 }
366 if self.has_next_page
367 && let Some(ref cursor) = self.end_cursor
368 {
369 let _ = writeln!(out, " (more results: after={})", cursor);
370 }
371 out
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378
379 #[test]
380 fn truncates_ascii_safely() {
381 let s = "abcdefghijklmnopqrstuvwxyz";
382 assert_eq!(truncate_chars(s, 5), "abcde");
383 }
384
385 #[test]
386 fn truncates_utf8_safely() {
387 let s = "hello 😀😃😄😁"; let truncated = truncate_chars(s, 8);
389 assert_eq!(truncated.chars().count(), 8);
390 assert_eq!(truncated, "hello 😀😃");
391 }
392
393 #[test]
394 fn handles_short_strings() {
395 assert_eq!(truncate_chars("hi", 10), "hi");
396 }
397
398 #[test]
399 fn format_options_default_shows_state_assignee_priority() {
400 let opts = FormatOptions::default();
401 assert!(opts.show_state);
402 assert!(opts.show_assignee);
403 assert!(opts.show_priority);
404 assert!(!opts.show_ids);
405 assert!(!opts.show_urls);
406 assert!(!opts.show_dates);
407 assert!(!opts.show_team);
408 }
409
410 #[test]
411 fn format_options_csv_adds_to_defaults() {
412 let opts = FormatOptions::from_csv("id,url");
413 assert!(opts.show_ids);
414 assert!(opts.show_urls);
415 assert!(opts.show_state);
417 assert!(opts.show_assignee);
418 assert!(opts.show_priority);
419 }
420}