Skip to main content

track/models/
mod.rs

1//! Data models for the track CLI application.
2//!
3//! This module defines the core data structures used throughout the application,
4//! including tasks, TODOs, links, scraps, and JJ-related items.
5
6use chrono::{DateTime, Utc};
7use serde::Serialize;
8
9mod jj;
10mod status;
11mod todo_action;
12mod vcs_mode;
13mod workflow;
14
15pub use jj::{jj_slug, sanitize_jj_slug};
16pub use status::{TaskStatus, TodoStatus};
17pub use todo_action::TodoAction;
18pub use vcs_mode::VcsMode;
19pub use workflow::{
20    build_git_context, build_jj_context, build_next_action, compute_workflow_phase,
21    oldest_pending_todo, workspace_lifecycle, AgentGuardrails, GitAgentContext, JjAgentContext,
22    NextAction, TodoAgentView, WorkflowContext, WorkflowPhase, WorkspaceAgentView,
23    WorkspaceLifecycle,
24};
25
26fn render_markdown_with_links(content: &str) -> String {
27    use pulldown_cmark::{html, Event, Parser, Tag, TagEnd};
28    use regex::Regex;
29    use std::collections::{HashMap, HashSet};
30
31    let url_regex =
32        Regex::new(r"(?P<pre>^|[\s\(])(?P<url>https?://[^\s\)<>]+?)(?P<post>[.,;!?]*(?:[\s\)]|$))")
33            .unwrap();
34
35    let linkified = url_regex.replace_all(content, |caps: &regex::Captures| {
36        let pre = &caps["pre"];
37        let url = &caps["url"];
38        let post = &caps["post"];
39
40        let cap_start = caps.get(0).unwrap().start();
41        if cap_start >= 2 {
42            let before = &content[..cap_start];
43            if before.ends_with("](") {
44                return caps.get(0).unwrap().as_str().to_string();
45            }
46        }
47
48        format!("{}<{}>{}", pre, url, post)
49    });
50
51    let parser = Parser::new(linkified.as_ref());
52    let sanitized = parser.map(|event| match event {
53        Event::Html(html) | Event::InlineHtml(html) => {
54            let escaped = html_escape::encode_safe(&html).into_owned();
55            Event::Text(escaped.into())
56        }
57        _ => event,
58    });
59
60    let parser_with_target = sanitized.map(|event| match event {
61        Event::Start(Tag::Link {
62            link_type: _,
63            dest_url,
64            title,
65            id: _,
66        }) => Event::Html(
67            format!(
68                r#"<a href="{}" target="_blank" rel="noopener noreferrer"{}>"#,
69                html_escape::encode_double_quoted_attribute(&dest_url),
70                if !title.is_empty() {
71                    format!(
72                        r#" title="{}""#,
73                        html_escape::encode_double_quoted_attribute(&title)
74                    )
75                } else {
76                    String::new()
77                }
78            )
79            .into(),
80        ),
81        Event::End(TagEnd::Link) => Event::Html("</a>".into()),
82        _ => event,
83    });
84
85    let mut html_output = String::new();
86    html::push_html(&mut html_output, parser_with_target);
87
88    let allowed_tags: HashSet<&'static str> = [
89        "a",
90        "p",
91        "ul",
92        "ol",
93        "li",
94        "strong",
95        "em",
96        "code",
97        "pre",
98        "blockquote",
99        "br",
100        "h1",
101        "h2",
102        "h3",
103        "h4",
104        "h5",
105        "h6",
106    ]
107    .into_iter()
108    .collect();
109    let allowed_attrs: HashSet<&'static str> = ["href", "title", "target"].into_iter().collect();
110    let mut allowed_tag_attrs = HashMap::new();
111    allowed_tag_attrs.insert("a", allowed_attrs);
112
113    ammonia::Builder::default()
114        .tags(allowed_tags)
115        .tag_attributes(allowed_tag_attrs)
116        .link_rel(Some("noopener noreferrer"))
117        .clean(&html_output)
118        .to_string()
119}
120
121/// Represents a development task.
122///
123/// A task is the primary organizational unit in track. Each task can have multiple TODOs,
124/// links, scraps, and associated JJ repositories.
125#[derive(Debug, Clone, Serialize)]
126pub struct Task {
127    pub id: i64,
128    pub name: String,
129    pub description: Option<String>,
130    pub status: TaskStatus,
131    pub ticket_id: Option<String>,
132    pub ticket_url: Option<String>,
133    pub alias: Option<String>,
134    pub is_today_task: bool,
135    pub created_at: DateTime<Utc>,
136}
137
138/// Represents a TODO item within a task.
139///
140/// TODOs are task-scoped action items. Each TODO has a task-specific index
141/// and can optionally request a JJ workspace for isolated development.
142#[derive(Debug, Clone, Serialize)]
143pub struct Todo {
144    #[serde(skip)]
145    pub id: i64,
146    #[serde(skip)]
147    #[allow(dead_code)]
148    pub task_id: i64,
149    /// Task-scoped sequential ID for this TODO
150    #[serde(rename = "todo_id")]
151    pub task_index: i64,
152    pub content: String,
153    pub status: TodoStatus,
154    #[serde(skip)]
155    pub worktree_requested: bool,
156    #[serde(skip)]
157    #[allow(dead_code)]
158    pub created_at: DateTime<Utc>,
159    pub completed_at: Option<DateTime<Utc>>,
160}
161
162impl Todo {
163    /// Converts the todo content from markdown to HTML.
164    ///
165    /// This method uses pulldown-cmark to parse the markdown content
166    /// and render it as HTML. Plain URLs are automatically converted to clickable links.
167    /// All links open in new tabs with target="_blank".
168    /// The output is safe for display in web pages.
169    pub fn content_html(&self) -> String {
170        render_markdown_with_links(&self.content)
171    }
172}
173
174/// Represents a link associated with a task.
175///
176/// Links are URLs with titles that provide context or reference material for a task.
177#[derive(Debug, Clone, Serialize)]
178pub struct Link {
179    #[serde(skip)]
180    #[allow(dead_code)]
181    pub id: i64,
182    #[serde(skip)]
183    #[allow(dead_code)]
184    pub task_id: i64,
185    /// Task-scoped sequential ID for this link
186    #[serde(rename = "link_id")]
187    pub task_index: i64,
188    pub url: String,
189    pub title: String,
190    #[serde(skip)]
191    #[allow(dead_code)]
192    pub created_at: DateTime<Utc>,
193}
194
195/// Represents a scrap (work note) for a task.
196///
197/// Scraps are chronological notes that capture progress, decisions, and findings
198/// during task execution. They help maintain context and flow of work.
199#[derive(Debug, Clone, Serialize)]
200pub struct Scrap {
201    #[serde(skip)]
202    #[allow(dead_code)]
203    pub id: i64,
204    #[serde(skip)]
205    #[allow(dead_code)]
206    pub task_id: i64,
207    /// Task-scoped sequential ID for this scrap
208    pub scrap_id: i64,
209    pub content: String,
210    pub created_at: DateTime<Utc>,
211    /// The task_index of the active (oldest pending) todo when this scrap was created
212    pub active_todo_id: Option<i64>,
213}
214
215impl Scrap {
216    /// Converts the scrap content from markdown to HTML.
217    ///
218    /// This method uses pulldown-cmark to parse the markdown content
219    /// and render it as HTML. Plain URLs are automatically converted to clickable links.
220    /// All links open in new tabs with target="_blank".
221    /// The output is safe for display in web pages.
222    pub fn content_html(&self) -> String {
223        render_markdown_with_links(&self.content)
224    }
225}
226
227/// Represents a JJ workspace associated with a task or TODO.
228///
229/// Worktrees track both base repositories and TODO-specific workspaces,
230/// including their paths, bookmarks, and relationships.
231#[derive(Debug, Clone, Serialize)]
232pub struct Worktree {
233    pub id: i64,
234    pub task_id: i64,
235    pub path: String,
236    pub branch: String,
237    pub base_repo: Option<String>,
238    #[allow(dead_code)]
239    pub status: String,
240    #[allow(dead_code)]
241    pub created_at: DateTime<Utc>,
242    #[allow(dead_code)]
243    pub todo_id: Option<i64>,
244    #[allow(dead_code)]
245    pub is_base: bool,
246}
247
248/// Represents a remote repository link for a worktree.
249///
250/// RepoLinks store URLs to remote repositories (e.g., GitHub, GitLab)
251/// and their types (e.g., "origin", "upstream").
252#[derive(Debug, Clone, Serialize)]
253pub struct RepoLink {
254    #[allow(dead_code)]
255    pub id: i64,
256    #[allow(dead_code)]
257    pub worktree_id: i64,
258    pub url: String,
259    pub kind: String,
260    #[allow(dead_code)]
261    pub created_at: DateTime<Utc>,
262}
263
264/// Represents a repository associated with a task.
265///
266/// TaskRepos link JJ repositories to tasks, allowing multiple repositories
267/// to be managed within a single task context.
268#[derive(Debug, Clone, Serialize)]
269pub struct TaskRepo {
270    #[serde(skip)]
271    pub id: i64,
272    #[serde(skip)]
273    #[allow(dead_code)]
274    pub task_id: i64,
275    /// Task-scoped sequential ID for this repository
276    #[serde(rename = "repo_id")]
277    pub task_index: i64,
278    pub repo_path: String,
279    pub base_branch: Option<String>,
280    pub base_commit_hash: Option<String>,
281    #[serde(skip)]
282    #[allow(dead_code)]
283    pub created_at: DateTime<Utc>,
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use chrono::Utc;
290
291    #[test]
292    fn test_scrap_content_html_plain_text() {
293        let scrap = Scrap {
294            id: 1,
295            task_id: 1,
296            scrap_id: 1,
297            content: "This is a plain text scrap.".to_string(),
298            created_at: Utc::now(),
299            active_todo_id: None,
300        };
301        let html = scrap.content_html();
302        assert!(html.contains("<p>This is a plain text scrap.</p>"));
303    }
304
305    #[test]
306    fn test_scrap_content_html_with_markdown() {
307        let scrap = Scrap {
308            id: 1,
309            task_id: 1,
310            scrap_id: 1,
311            content: "# Heading\n\nThis is **bold** and *italic*.".to_string(),
312            created_at: Utc::now(),
313            active_todo_id: None,
314        };
315        let html = scrap.content_html();
316        assert!(html.contains("<h1>Heading</h1>"));
317        assert!(html.contains("<strong>bold</strong>"));
318        assert!(html.contains("<em>italic</em>"));
319    }
320
321    #[test]
322    fn test_scrap_content_html_with_code() {
323        let scrap = Scrap {
324            id: 1,
325            task_id: 1,
326            scrap_id: 1,
327            content: "Inline `code` and:\n\n```rust\nfn main() {}\n```".to_string(),
328            created_at: Utc::now(),
329            active_todo_id: None,
330        };
331        let html = scrap.content_html();
332        assert!(html.contains("<code>code</code>"));
333        assert!(html.contains("<pre><code"));
334        assert!(html.contains("fn main() {}"));
335    }
336
337    #[test]
338    fn test_scrap_content_html_with_list() {
339        let scrap = Scrap {
340            id: 1,
341            task_id: 1,
342            scrap_id: 1,
343            content: "- Item 1\n- Item 2\n- Item 3".to_string(),
344            created_at: Utc::now(),
345            active_todo_id: None,
346        };
347        let html = scrap.content_html();
348        assert!(html.contains("<ul>"));
349        assert!(html.contains("<li>Item 1</li>"));
350        assert!(html.contains("<li>Item 2</li>"));
351        assert!(html.contains("<li>Item 3</li>"));
352        assert!(html.contains("</ul>"));
353    }
354
355    #[test]
356    fn test_scrap_content_html_with_link() {
357        let scrap = Scrap {
358            id: 1,
359            task_id: 1,
360            scrap_id: 1,
361            content: "[Example](https://example.com)".to_string(),
362            created_at: Utc::now(),
363            active_todo_id: None,
364        };
365        let html = scrap.content_html();
366        assert!(html.contains("href=\"https://example.com\""));
367        assert!(html.contains("target=\"_blank\""));
368        assert!(html.contains("rel=\"noopener noreferrer\""));
369    }
370
371    #[test]
372    fn test_scrap_content_html_sanitizes_html() {
373        let scrap = Scrap {
374            id: 1,
375            task_id: 1,
376            scrap_id: 1,
377            content: "<script>alert('x')</script><b>safe</b>".to_string(),
378            created_at: Utc::now(),
379            active_todo_id: None,
380        };
381        let html = scrap.content_html();
382        assert!(!html.contains("<script>"));
383        assert!(!html.contains("alert('x')"));
384        assert!(html.contains("safe"));
385    }
386
387    #[test]
388    fn test_scrap_content_html_auto_linkify_plain_url() {
389        let scrap = Scrap {
390            id: 1,
391            task_id: 1,
392            scrap_id: 1,
393            content: "Check out https://example.com for more info.".to_string(),
394            created_at: Utc::now(),
395            active_todo_id: None,
396        };
397        let html = scrap.content_html();
398        assert!(html.contains("target=\"_blank\""));
399        assert!(html.contains("https://example.com"));
400    }
401
402    #[test]
403    fn test_scrap_content_html_auto_linkify_multiple_urls() {
404        let scrap = Scrap {
405            id: 1,
406            task_id: 1,
407            scrap_id: 1,
408            content: "See https://example.com and http://test.org".to_string(),
409            created_at: Utc::now(),
410            active_todo_id: None,
411        };
412        let html = scrap.content_html();
413        assert!(html.contains("target=\"_blank\""));
414        assert!(html.contains("https://example.com"));
415        assert!(html.contains("http://test.org"));
416    }
417
418    #[test]
419    fn test_scrap_content_html_auto_linkify_url_with_punctuation() {
420        let scrap = Scrap {
421            id: 1,
422            task_id: 1,
423            scrap_id: 1,
424            content: "Visit https://example.com/path?query=1, it's great!".to_string(),
425            created_at: Utc::now(),
426            active_todo_id: None,
427        };
428        let html = scrap.content_html();
429        // The comma should not be part of the link
430        assert!(html.contains("target=\"_blank\""));
431        assert!(html.contains("https://example.com/path?query=1"));
432    }
433
434    #[test]
435    fn test_scrap_content_html_preserve_markdown_links() {
436        let scrap = Scrap {
437            id: 1,
438            task_id: 1,
439            scrap_id: 1,
440            content: "Check [my site](https://example.com) and also https://test.com".to_string(),
441            created_at: Utc::now(),
442            active_todo_id: None,
443        };
444        let html = scrap.content_html();
445        // Markdown link should work normally
446        assert!(html.contains("target=\"_blank\""));
447        assert!(html.contains("my site"));
448        // Plain URL should be auto-linkified
449        assert!(html.contains("https://test.com"));
450    }
451
452    #[test]
453    fn test_todo_content_html_plain_text() {
454        let todo = Todo {
455            id: 1,
456            task_id: 1,
457            task_index: 1,
458            content: "This is a plain text todo.".to_string(),
459            status: TodoStatus::Pending,
460            worktree_requested: false,
461            created_at: Utc::now(),
462            completed_at: None,
463        };
464        let html = todo.content_html();
465        assert!(html.contains("<p>This is a plain text todo.</p>"));
466    }
467
468    #[test]
469    fn test_todo_content_html_auto_linkify_url() {
470        let todo = Todo {
471            id: 1,
472            task_id: 1,
473            task_index: 1,
474            content: "Check https://example.com for details".to_string(),
475            status: TodoStatus::Pending,
476            worktree_requested: false,
477            created_at: Utc::now(),
478            completed_at: None,
479        };
480        let html = todo.content_html();
481        assert!(html.contains("target=\"_blank\""));
482        assert!(html.contains("https://example.com"));
483    }
484
485    #[test]
486    fn test_todo_content_html_with_markdown() {
487        let todo = Todo {
488            id: 1,
489            task_id: 1,
490            task_index: 1,
491            content: "# Heading\n\nThis is **bold** and *italic*.".to_string(),
492            status: TodoStatus::Pending,
493            created_at: Utc::now(),
494            completed_at: None,
495            worktree_requested: false,
496        };
497        let html = todo.content_html();
498        assert!(html.contains("<h1>Heading</h1>"));
499        assert!(html.contains("<strong>bold</strong>"));
500        assert!(html.contains("<em>italic</em>"));
501    }
502
503    #[test]
504    fn test_todo_content_html_sanitizes_html() {
505        let todo = Todo {
506            id: 1,
507            task_id: 1,
508            task_index: 1,
509            content: "<img src=x onerror=alert(1)><b>safe</b>".to_string(),
510            status: TodoStatus::Pending,
511            created_at: Utc::now(),
512            completed_at: None,
513            worktree_requested: false,
514        };
515        let html = todo.content_html();
516        assert!(!html.contains("<img"));
517        assert!(html.contains("safe"));
518    }
519}