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 CommentResult {
104 pub success: bool,
105 pub comment_id: Option<String>,
106 pub body: Option<String>,
107 pub created_at: Option<String>,
108}
109
110#[derive(Debug, Clone)]
115pub struct FormatOptions {
116 pub show_ids: bool,
117 pub show_urls: bool,
118 pub show_dates: bool,
119 pub show_assignee: bool,
120 pub show_state: bool,
121 pub show_team: bool,
122 pub show_priority: bool,
123}
124
125impl Default for FormatOptions {
126 fn default() -> Self {
127 Self {
128 show_ids: false,
129 show_urls: false,
130 show_dates: false,
131 show_assignee: true,
132 show_state: true,
133 show_team: false,
134 show_priority: true,
135 }
136 }
137}
138
139impl FormatOptions {
140 pub fn from_env() -> Self {
141 Self::from_csv(&std::env::var("LINEAR_TOOLS_EXTRAS").unwrap_or_default())
142 }
143
144 pub fn from_csv(csv: &str) -> Self {
145 let mut o = Self::default();
146 for f in csv
147 .split(',')
148 .map(|s| s.trim().to_lowercase())
149 .filter(|s| !s.is_empty())
150 {
151 match f.as_str() {
152 "id" | "ids" => o.show_ids = true,
153 "url" | "urls" => o.show_urls = true,
154 "date" | "dates" => o.show_dates = true,
155 "assignee" | "assignees" => o.show_assignee = true,
156 "state" | "states" => o.show_state = true,
157 "team" | "teams" => o.show_team = true,
158 "priority" | "priorities" => o.show_priority = true,
159 _ => {}
160 }
161 }
162 o
163 }
164}
165
166impl TextFormat for SearchResult {
167 fn fmt_text(&self, _opts: &TextOptions) -> String {
168 if self.issues.is_empty() {
169 return "Issues: <none>".into();
170 }
171 let o = FormatOptions::from_env();
172 let mut out = String::new();
173 let _ = writeln!(out, "Issues:");
174 for i in &self.issues {
175 let mut line = format!("{} - {}", i.identifier, i.title);
176 if o.show_state
177 && let Some(s) = &i.state
178 {
179 line.push_str(&format!(" [{}]", s.name));
180 }
181 if o.show_assignee
182 && let Some(u) = &i.assignee
183 {
184 line.push_str(&format!(" (by {})", u.name));
185 }
186 if o.show_priority {
187 line.push_str(&format!(" P{} ({})", i.priority, i.priority_label));
188 }
189 if o.show_team {
190 line.push_str(&format!(" [{}]", i.team.key));
191 }
192 if o.show_urls {
193 line.push_str(&format!(" {}", i.url));
194 }
195 if o.show_ids {
196 line.push_str(&format!(" #{}", i.id));
197 }
198 if o.show_dates {
199 line.push_str(&format!(" @{}", i.updated_at));
200 }
201 let _ = writeln!(out, " {}", line);
202 }
203 if self.has_next_page
204 && let Some(cursor) = &self.end_cursor
205 {
206 let _ = writeln!(out, "\n[More results available, cursor: {}]", cursor);
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}