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 && 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        // 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}