Skip to main content

linear_tools/
models.rs

1use agentic_tools_core::fmt::{TextFormat, TextOptions};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use std::fmt::Write as _;
5
6/// Truncate a string to at most `max` characters (UTF-8 safe).
7fn truncate_chars(s: &str, max: usize) -> String {
8    s.chars().take(max).collect()
9}
10
11// ============================================================================
12// Nested Ref types for structured JSON output
13// ============================================================================
14
15#[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// ============================================================================
49// Issue models
50// ============================================================================
51
52#[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// ============================================================================
109// Text formatting
110// ============================================================================
111
112#[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        // Header line
217        let _ = writeln!(out, "{}: {}", i.identifier, i.title);
218
219        // Metadata line
220        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        // Description
250        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                // 80 total, reserve 3 for "..."
282                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// ============================================================================
295// Archive + Metadata models
296// ============================================================================
297
298#[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 😀😃😄😁"; // multi-byte
386        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        // defaults still true
414        assert!(opts.show_state);
415        assert!(opts.show_assignee);
416        assert!(opts.show_priority);
417    }
418}