use chrono::{DateTime, Utc};
use serde::Serialize;
mod jj;
mod status;
mod todo_action;
mod todo_add_options;
mod vcs_mode;
mod workflow;
pub use jj::{jj_slug, sanitize_jj_slug};
pub use status::{TaskStatus, TodoStatus};
pub use todo_action::TodoAction;
pub use todo_add_options::TodoAddOptions;
pub use vcs_mode::VcsMode;
pub use workflow::{
build_git_context, build_jj_context, build_next_action, build_workflow_checklist,
build_workflow_context, compute_workflow_phase, legacy_worktree_pending,
legacy_worktree_sync_needed, oldest_pending_todo, workspace_lifecycle, AgentGuardrails,
GitAgentContext, JjAgentContext, NextAction, NextActionKind, TodoAgentView, WorkflowContext,
WorkflowPhase, WorkflowStep, WorkspaceAgentView, WorkspaceLifecycle,
};
fn render_markdown_with_links(content: &str) -> String {
use pulldown_cmark::{html, Event, Parser, Tag, TagEnd};
use regex::Regex;
use std::collections::{HashMap, HashSet};
let url_regex =
Regex::new(r"(?P<pre>^|[\s\(])(?P<url>https?://[^\s\)<>]+?)(?P<post>[.,;!?]*(?:[\s\)]|$))")
.unwrap();
let linkified = url_regex.replace_all(content, |caps: ®ex::Captures| {
let pre = &caps["pre"];
let url = &caps["url"];
let post = &caps["post"];
let cap_start = caps.get(0).unwrap().start();
if cap_start >= 2 {
let before = &content[..cap_start];
if before.ends_with("](") {
return caps.get(0).unwrap().as_str().to_string();
}
}
format!("{}<{}>{}", pre, url, post)
});
let parser = Parser::new(linkified.as_ref());
let sanitized = parser.map(|event| match event {
Event::Html(html) | Event::InlineHtml(html) => {
let escaped = html_escape::encode_safe(&html).into_owned();
Event::Text(escaped.into())
}
_ => event,
});
let parser_with_target = sanitized.map(|event| match event {
Event::Start(Tag::Link {
link_type: _,
dest_url,
title,
id: _,
}) => Event::Html(
format!(
r#"<a href="{}" target="_blank" rel="noopener noreferrer"{}>"#,
html_escape::encode_double_quoted_attribute(&dest_url),
if !title.is_empty() {
format!(
r#" title="{}""#,
html_escape::encode_double_quoted_attribute(&title)
)
} else {
String::new()
}
)
.into(),
),
Event::End(TagEnd::Link) => Event::Html("</a>".into()),
_ => event,
});
let mut html_output = String::new();
html::push_html(&mut html_output, parser_with_target);
let allowed_tags: HashSet<&'static str> = [
"a",
"p",
"ul",
"ol",
"li",
"strong",
"em",
"code",
"pre",
"blockquote",
"br",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
]
.into_iter()
.collect();
let allowed_attrs: HashSet<&'static str> = ["href", "title", "target"].into_iter().collect();
let mut allowed_tag_attrs = HashMap::new();
allowed_tag_attrs.insert("a", allowed_attrs);
ammonia::Builder::default()
.tags(allowed_tags)
.tag_attributes(allowed_tag_attrs)
.link_rel(Some("noopener noreferrer"))
.clean(&html_output)
.to_string()
}
#[derive(Debug, Clone, Serialize)]
pub struct Task {
pub id: i64,
pub name: String,
pub description: Option<String>,
pub status: TaskStatus,
pub ticket_id: Option<String>,
pub ticket_url: Option<String>,
pub alias: Option<String>,
pub is_today_task: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Todo {
#[serde(skip)]
pub id: i64,
#[serde(skip)]
#[allow(dead_code)]
pub task_id: i64,
#[serde(rename = "todo_id")]
pub task_index: i64,
pub content: String,
pub status: TodoStatus,
#[serde(skip)]
pub worktree_requested: bool,
#[serde(skip)]
pub requires_workspace: bool,
#[serde(skip)]
#[allow(dead_code)]
pub created_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
}
impl Todo {
pub fn content_html(&self) -> String {
render_markdown_with_links(&self.content)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Link {
#[serde(skip)]
#[allow(dead_code)]
pub id: i64,
#[serde(skip)]
#[allow(dead_code)]
pub task_id: i64,
#[serde(rename = "link_id")]
pub task_index: i64,
pub url: String,
pub title: String,
#[serde(skip)]
#[allow(dead_code)]
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Scrap {
#[serde(skip)]
#[allow(dead_code)]
pub id: i64,
#[serde(skip)]
#[allow(dead_code)]
pub task_id: i64,
pub scrap_id: i64,
pub content: String,
pub created_at: DateTime<Utc>,
pub active_todo_id: Option<i64>,
}
impl Scrap {
pub fn content_html(&self) -> String {
render_markdown_with_links(&self.content)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct Worktree {
pub id: i64,
pub task_id: i64,
pub path: String,
pub branch: String,
pub base_repo: Option<String>,
#[allow(dead_code)]
pub status: String,
#[allow(dead_code)]
pub created_at: DateTime<Utc>,
#[allow(dead_code)]
pub todo_id: Option<i64>,
#[allow(dead_code)]
pub is_base: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct RepoLink {
#[allow(dead_code)]
pub id: i64,
#[allow(dead_code)]
pub worktree_id: i64,
pub url: String,
pub kind: String,
#[allow(dead_code)]
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TaskRepo {
#[serde(skip)]
pub id: i64,
#[serde(skip)]
#[allow(dead_code)]
pub task_id: i64,
#[serde(rename = "repo_id")]
pub task_index: i64,
pub repo_path: String,
pub base_branch: Option<String>,
pub base_commit_hash: Option<String>,
#[serde(skip)]
#[allow(dead_code)]
pub created_at: DateTime<Utc>,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
#[test]
fn test_scrap_content_html_plain_text() {
let scrap = Scrap {
id: 1,
task_id: 1,
scrap_id: 1,
content: "This is a plain text scrap.".to_string(),
created_at: Utc::now(),
active_todo_id: None,
};
let html = scrap.content_html();
assert!(html.contains("<p>This is a plain text scrap.</p>"));
}
#[test]
fn test_scrap_content_html_with_markdown() {
let scrap = Scrap {
id: 1,
task_id: 1,
scrap_id: 1,
content: "# Heading\n\nThis is **bold** and *italic*.".to_string(),
created_at: Utc::now(),
active_todo_id: None,
};
let html = scrap.content_html();
assert!(html.contains("<h1>Heading</h1>"));
assert!(html.contains("<strong>bold</strong>"));
assert!(html.contains("<em>italic</em>"));
}
#[test]
fn test_scrap_content_html_with_code() {
let scrap = Scrap {
id: 1,
task_id: 1,
scrap_id: 1,
content: "Inline `code` and:\n\n```rust\nfn main() {}\n```".to_string(),
created_at: Utc::now(),
active_todo_id: None,
};
let html = scrap.content_html();
assert!(html.contains("<code>code</code>"));
assert!(html.contains("<pre><code"));
assert!(html.contains("fn main() {}"));
}
#[test]
fn test_scrap_content_html_with_list() {
let scrap = Scrap {
id: 1,
task_id: 1,
scrap_id: 1,
content: "- Item 1\n- Item 2\n- Item 3".to_string(),
created_at: Utc::now(),
active_todo_id: None,
};
let html = scrap.content_html();
assert!(html.contains("<ul>"));
assert!(html.contains("<li>Item 1</li>"));
assert!(html.contains("<li>Item 2</li>"));
assert!(html.contains("<li>Item 3</li>"));
assert!(html.contains("</ul>"));
}
#[test]
fn test_scrap_content_html_with_link() {
let scrap = Scrap {
id: 1,
task_id: 1,
scrap_id: 1,
content: "[Example](https://example.com)".to_string(),
created_at: Utc::now(),
active_todo_id: None,
};
let html = scrap.content_html();
assert!(html.contains("href=\"https://example.com\""));
assert!(html.contains("target=\"_blank\""));
assert!(html.contains("rel=\"noopener noreferrer\""));
}
#[test]
fn test_scrap_content_html_sanitizes_html() {
let scrap = Scrap {
id: 1,
task_id: 1,
scrap_id: 1,
content: "<script>alert('x')</script><b>safe</b>".to_string(),
created_at: Utc::now(),
active_todo_id: None,
};
let html = scrap.content_html();
assert!(!html.contains("<script>"));
assert!(!html.contains("alert('x')"));
assert!(html.contains("safe"));
}
#[test]
fn test_scrap_content_html_auto_linkify_plain_url() {
let scrap = Scrap {
id: 1,
task_id: 1,
scrap_id: 1,
content: "Check out https://example.com for more info.".to_string(),
created_at: Utc::now(),
active_todo_id: None,
};
let html = scrap.content_html();
assert!(html.contains("target=\"_blank\""));
assert!(html.contains("https://example.com"));
}
#[test]
fn test_scrap_content_html_auto_linkify_multiple_urls() {
let scrap = Scrap {
id: 1,
task_id: 1,
scrap_id: 1,
content: "See https://example.com and http://test.org".to_string(),
created_at: Utc::now(),
active_todo_id: None,
};
let html = scrap.content_html();
assert!(html.contains("target=\"_blank\""));
assert!(html.contains("https://example.com"));
assert!(html.contains("http://test.org"));
}
#[test]
fn test_scrap_content_html_auto_linkify_url_with_punctuation() {
let scrap = Scrap {
id: 1,
task_id: 1,
scrap_id: 1,
content: "Visit https://example.com/path?query=1, it's great!".to_string(),
created_at: Utc::now(),
active_todo_id: None,
};
let html = scrap.content_html();
assert!(html.contains("target=\"_blank\""));
assert!(html.contains("https://example.com/path?query=1"));
}
#[test]
fn test_scrap_content_html_preserve_markdown_links() {
let scrap = Scrap {
id: 1,
task_id: 1,
scrap_id: 1,
content: "Check [my site](https://example.com) and also https://test.com".to_string(),
created_at: Utc::now(),
active_todo_id: None,
};
let html = scrap.content_html();
assert!(html.contains("target=\"_blank\""));
assert!(html.contains("my site"));
assert!(html.contains("https://test.com"));
}
#[test]
fn test_todo_content_html_plain_text() {
let todo = Todo {
id: 1,
task_id: 1,
task_index: 1,
content: "This is a plain text todo.".to_string(),
status: TodoStatus::Pending,
worktree_requested: false,
requires_workspace: true,
created_at: Utc::now(),
completed_at: None,
};
let html = todo.content_html();
assert!(html.contains("<p>This is a plain text todo.</p>"));
}
#[test]
fn test_todo_content_html_auto_linkify_url() {
let todo = Todo {
id: 1,
task_id: 1,
task_index: 1,
content: "Check https://example.com for details".to_string(),
status: TodoStatus::Pending,
worktree_requested: false,
requires_workspace: true,
created_at: Utc::now(),
completed_at: None,
};
let html = todo.content_html();
assert!(html.contains("target=\"_blank\""));
assert!(html.contains("https://example.com"));
}
#[test]
fn test_todo_content_html_with_markdown() {
let todo = Todo {
id: 1,
task_id: 1,
task_index: 1,
content: "# Heading\n\nThis is **bold** and *italic*.".to_string(),
status: TodoStatus::Pending,
created_at: Utc::now(),
completed_at: None,
worktree_requested: false,
requires_workspace: true,
};
let html = todo.content_html();
assert!(html.contains("<h1>Heading</h1>"));
assert!(html.contains("<strong>bold</strong>"));
assert!(html.contains("<em>italic</em>"));
}
#[test]
fn test_todo_content_html_sanitizes_html() {
let todo = Todo {
id: 1,
task_id: 1,
task_index: 1,
content: "<img src=x onerror=alert(1)><b>safe</b>".to_string(),
status: TodoStatus::Pending,
created_at: Utc::now(),
completed_at: None,
worktree_requested: false,
requires_workspace: true,
};
let html = todo.content_html();
assert!(!html.contains("<img"));
assert!(html.contains("safe"));
}
}