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