Skip to main content

linear_tools/
models.rs

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
8/// Truncate a string to at most `max` characters (UTF-8 safe).
9fn truncate_chars(s: &str, max: usize) -> String {
10    s.chars().take(max).collect()
11}
12
13// ============================================================================
14// Nested Ref types for structured JSON output
15// ============================================================================
16
17#[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// ============================================================================
51// Issue models
52// ============================================================================
53
54#[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// ============================================================================
111// Text formatting
112// ============================================================================
113
114#[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        // Header line
219        let _ = writeln!(out, "{}: {}", i.identifier, i.title);
220
221        // Metadata line
222        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        // Description
252        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                // 80 total, reserve 3 for "..."
284                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// ============================================================================
297// Archive + Metadata models
298// ============================================================================
299
300#[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 😀😃😄😁"; // multi-byte
388        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        // defaults still true
416        assert!(opts.show_state);
417        assert!(opts.show_assignee);
418        assert!(opts.show_priority);
419    }
420}