1use 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: ®ex::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#[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#[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 #[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 pub fn content_html(&self) -> String {
170 render_markdown_with_links(&self.content)
171 }
172}
173
174#[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 #[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#[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 pub scrap_id: i64,
209 pub content: String,
210 pub created_at: DateTime<Utc>,
211 pub active_todo_id: Option<i64>,
213}
214
215impl Scrap {
216 pub fn content_html(&self) -> String {
223 render_markdown_with_links(&self.content)
224 }
225}
226
227#[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#[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#[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 #[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 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 assert!(html.contains("target=\"_blank\""));
447 assert!(html.contains("my site"));
448 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}