1use 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: ®ex::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#[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#[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 #[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 #[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 pub fn content_html(&self) -> String {
176 render_markdown_with_links(&self.content)
177 }
178}
179
180#[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 #[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#[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 pub scrap_id: i64,
215 pub content: String,
216 pub created_at: DateTime<Utc>,
217 pub active_todo_id: Option<i64>,
219}
220
221impl Scrap {
222 pub fn content_html(&self) -> String {
229 render_markdown_with_links(&self.content)
230 }
231}
232
233#[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#[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#[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 #[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 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 assert!(html.contains("target=\"_blank\""));
453 assert!(html.contains("my site"));
454 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}