use crate::errors::Result; use crate::object::{self, StableId}; use crate::workspace::{CLOSED_ON_KEY, Id, LogCommit, Workspace}; use crate::{namespace, queue}; use clap::{Args, Parser}; use git2::{Oid, Patch, Repository}; use pulldown_cmark::{CowStr, Event, Options, Parser as MarkdownParser, html}; use std::collections::{BTreeMap, BTreeSet}; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::PathBuf; use std::str::FromStr as _; use url::{Url, form_urlencoded}; const PAGE_SIZE: usize = 25; #[derive(Args, Clone)] pub struct ServeArgs { /// Address to bind. #[arg(long, default_value = "127.0.0.1")] host: String, /// Port to bind. #[arg(short, long, default_value_t = 7935)] port: u16, } #[derive(Parser)] #[command(version, about = "Host a read-only HTTP browser for tsk")] struct ServeCli { /// Override the tsk root directory. #[arg(short = 'C', env = "TSK_ROOT", value_name = "DIR")] dir: Option, #[command(flatten)] args: ServeArgs, } pub fn run() -> Result<()> { let cli = ServeCli::parse(); let dir = cli.dir.unwrap_or(std::env::current_dir()?); serve(dir, cli.args) } pub fn serve(dir: PathBuf, args: ServeArgs) -> Result<()> { let ws = Workspace::from_path(dir)?; let bind = format!("{}:{}", args.host, args.port); let listener = TcpListener::bind(&bind)?; eprintln!("Serving tsk read-only browser at http://{bind}/"); for stream in listener.incoming() { match stream { Ok(stream) => { if let Err(err) = handle_connection(&ws, stream) { eprintln!("request failed: {err}"); } } Err(err) => eprintln!("connection failed: {err}"), } } Ok(()) } fn handle_connection(ws: &Workspace, mut stream: TcpStream) -> Result<()> { let mut buffer = [0_u8; 8192]; let n = stream.read(&mut buffer)?; if n == 0 { return Ok(()); } let request = String::from_utf8_lossy(&buffer[..n]); let Some(line) = request.lines().next() else { return Ok(()); }; let mut parts = line.split_whitespace(); let method = parts.next().unwrap_or(""); let target = parts.next().unwrap_or("/"); if method != "GET" && method != "HEAD" { return write_response( &mut stream, 405, "Method Not Allowed", "text/plain; charset=utf-8", "read-only server supports GET and HEAD\n", method == "HEAD", ); } let body = match render_path(ws, target) { Ok(Rendered::Html(html)) => { return write_response( &mut stream, 200, "OK", "text/html; charset=utf-8", &html, method == "HEAD", ); } Ok(Rendered::Redirect(location)) => { return write_redirect(&mut stream, &location, method == "HEAD"); } Ok(Rendered::NotFound(message)) => message, Err(err) => format!("{}\n", err), }; write_response( &mut stream, 404, "Not Found", "text/plain; charset=utf-8", &body, method == "HEAD", ) } enum Rendered { Html(String), Redirect(String), NotFound(String), } fn render_path(ws: &Workspace, target: &str) -> Result { let page = page_from_target(target); let path = target.split('?').next().unwrap_or("/"); let path = path.trim_end_matches('/').trim_start_matches('/'); if path.is_empty() { return Ok(Rendered::Redirect(format!("/queues/{}", ws.queue()?))); } let parts: Vec<&str> = path.split('/').collect(); match parts.as_slice() { ["queues"] => render_queues(ws).map(Rendered::Html), ["queues", name, "log"] => render_queue_log(ws, name, page).map(Rendered::Html), ["queues", name] => render_queue(ws, name, page).map(Rendered::Html), ["namespaces"] => render_namespaces(ws).map(Rendered::Html), ["namespaces", name, "log"] => render_namespace_log(ws, name, page).map(Rendered::Html), ["namespaces", name] => render_namespace(ws, name, page).map(Rendered::Html), ["commits", oid] => render_commit(ws, oid).map(Rendered::Html), ["properties"] => render_properties(ws, page).map(Rendered::Html), ["properties", key] => render_property(ws, key, page).map(Rendered::Html), ["tasks", stable, "log"] => render_task_log(ws, stable, page).map(Rendered::Html), ["tasks", stable] => render_task(ws, stable).map(Rendered::Html), _ => Ok(Rendered::NotFound("not found\n".to_string())), } } fn render_queues(ws: &Workspace) -> Result { let mut names = ws.list_queues()?; let active_queue = ws.queue()?; include_current(&mut names, &active_queue); let mut items = String::new(); for name in names { let marker = if name == active_queue { " active" } else { "" }; items.push_str(&format!( "
  • {0}{1}
  • ", h(&name), marker )); } page(ws, "Queues", &format!("

    Queues

      {items}
    ")) } fn render_queue(ws: &Workspace, name: &str, page_num: usize) -> Result { queue::validate_name(name)?; let repo = repo(ws)?; let q = queue::read(&repo, name)?; let bindings = all_bindings(ws, &repo)?; let page_slice = paginate(&q.index, page_num); let mut rows = String::new(); for (offset, stable) in page_slice.items.iter().enumerate() { let title = title_for(&repo, stable)?; rows.push_str(&table_row([ (page_slice.start + offset + 1).to_string(), binding_links(bindings.get(stable)), task_anchor(stable, h(&title)), render_task_commit(&repo, stable), ])); } if rows.is_empty() { rows.push_str(&empty_table_row(4, "No tasks")); } let inbox = if q.inbox.is_empty() { "

    Inbox empty

    ".to_string() } else { let mut items = String::new(); for key in q.inbox_order { let Some(stable) = q.inbox.get(&key) else { continue; }; items.push_str(&format!( "
  • {}: {}
  • ", h(&key), task_anchor(stable, h(&title_for(&repo, stable)?)) )); } format!("
      {items}
    ") }; let pagination = pagination_nav(&format!("/queues/{}", h(name)), &page_slice); page( ws, &format!("Queue {name}"), &format!( "

    Queue {}

    Log

    \

    can-pull: {}

    \ {pagination}\ {rows}
    #BindingTitleStable
    \ {pagination}\

    Inbox

    {inbox}", h(name), h(name), q.can_pull ), ) } fn render_queue_log(ws: &Workspace, name: &str, page: usize) -> Result { queue::validate_name(name)?; render_log_page( ws, &format!("Queue {name} Log"), &format!("Queue {} Log", h(name)), &format!("/queues/{}", h(name)), &format!("Queue {}", h(name)), &format!("/queues/{}/log", h(name)), ws.log_queue(name)?, page, ) } fn render_namespaces(ws: &Workspace) -> Result { let mut names = ws.list_namespaces()?; let active_namespace = ws.namespace()?; include_current(&mut names, &active_namespace); let mut items = String::new(); for name in names { let marker = if name == active_namespace { " active" } else { "" }; items.push_str(&format!( "
  • {0}{1}
  • ", h(&name), marker )); } page( ws, "Namespaces", &format!("

    Namespaces

      {items}
    "), ) } fn render_namespace(ws: &Workspace, name: &str, page_num: usize) -> Result { namespace::validate_name(name)?; let repo = repo(ws)?; let ns = namespace::read(&repo, name)?; let mut mapping: Vec<_> = ns.mapping.into_iter().collect(); mapping.reverse(); let page_slice = paginate(&mapping, page_num); let mut rows = String::new(); for (human, stable) in page_slice.items { rows.push_str(&table_row([ format!("{}-{} ", h(name), human), task_anchor(&stable, h(&title_for(&repo, &stable)?)), render_task_commit(&repo, &stable), ])); } if rows.is_empty() { rows.push_str(&empty_table_row(3, "No tasks")); } let pagination = pagination_nav(&format!("/namespaces/{}", h(name)), &page_slice); page( ws, &format!("Namespace {name}"), &format!( "

    Namespace {}

    Log

    \ {pagination}\ {rows}
    IDTitleStable
    \ {pagination}", h(name), h(name) ), ) } fn render_namespace_log(ws: &Workspace, name: &str, page: usize) -> Result { namespace::validate_name(name)?; render_log_page( ws, &format!("Namespace {name} Log"), &format!("Namespace {} Log", h(name)), &format!("/namespaces/{}", h(name)), &format!("Namespace {}", h(name)), &format!("/namespaces/{}/log", h(name)), ws.log_namespace(name)?, page, ) } struct PropertyRow { id: Id, stable: StableId, title: String, } fn render_properties(ws: &Workspace, page_num: usize) -> Result { let repo = repo(ws)?; let namespace_name = ws.namespace()?; let ns = namespace::read(&repo, &namespace_name)?; let mut keys = BTreeSet::new(); for (_human, stable) in ns.mapping { let Some(task) = object::read(&repo, &stable)? else { continue; }; for key in task.properties.into_keys() { keys.insert(key); } } let entries: Vec<_> = keys.into_iter().collect(); let page_slice = paginate(&entries, page_num); let mut items = String::new(); for key in page_slice.items { items.push_str(&format!( "
  • {}
  • ", property_path_segment(key), h(key) )); } if items.is_empty() { items.push_str("
  • No properties
  • "); } let pagination = pagination_nav("/properties", &page_slice); page( ws, "Properties", &format!( "

    Properties

    Namespace {}

    \ {pagination}\
      {items}
    \ {pagination}", h(&namespace_name) ), ) } fn render_property(ws: &Workspace, key: &str, page_num: usize) -> Result { let key = decode_path_segment(key); let repo = repo(ws)?; let namespace_name = ws.namespace()?; let ns = namespace::read(&repo, &namespace_name)?; let mut entries = Vec::new(); for (human, stable) in ns.mapping { let Some(task) = object::read(&repo, &stable)? else { continue; }; if task.properties.contains_key(&key) { entries.push(PropertyRow { id: Id(human), stable: stable.clone(), title: task.title().to_string(), }); } } entries.sort_by(|a, b| a.id.cmp(&b.id)); let page_slice = paginate(&entries, page_num); let mut items = String::new(); for row in page_slice.items { items.push_str(&format!( "
  • {} {}
  • ", task_anchor(&row.stable, row.id.to_string()), h(&row.title) )); } if items.is_empty() { items.push_str("
  • No tasks
  • "); } let href = format!("/properties/{}", property_path_segment(&key)); let pagination = pagination_nav(&href, &page_slice); page( ws, &format!("Property {key}"), &format!( "

    Property {}

    \

    Back to Properties

    \

    Namespace {}

    \ {pagination}\
      {items}
    \ {pagination}", h(&key), h(&namespace_name) ), ) } fn render_task(ws: &Workspace, stable: &str) -> Result { let stable = StableId(stable.to_string()); let repo = repo(ws)?; let Some(task) = object::read(&repo, &stable)? else { return page( ws, "Task not found", &format!( "

    Task not found

    {}

    ", h(&stable.0) ), ); }; let bindings = all_bindings(ws, &repo)?; let mut props = String::new(); for (key, values) in &task.properties { let rendered_values = values .iter() .map(|v| render_property_value(ws, &repo, key, v)) .collect::>>()? .join(", "); props.push_str(&table_row([h(key), rendered_values])); } if props.is_empty() { props.push_str(&empty_table_row(2, "No properties")); } let (content_class, rendered_content) = render_task_content(ws, &repo, &task.content)?; let body = format!( "

    {}

    \

    Stable {}

    \

    Log

    \

    Bindings: {}

    \

    Content

    {rendered_content}
    \

    Properties

    \ {props}
    KeyValues
    ", h(task.title()), render_task_commit(&repo, &stable), h(&stable.0), binding_links(bindings.get(&stable)) ); page(ws, task.title(), &body) } fn render_task_log(ws: &Workspace, stable: &str, page: usize) -> Result { let stable = StableId(stable.to_string()); let repo = repo(ws)?; let title = title_for(&repo, &stable)?; render_log_page( ws, &format!("{title} Log"), &format!("Task Log: {}", h(&title)), &format!("/tasks/{}", h(&stable.0)), &title, &format!("/tasks/{}/log", h(&stable.0)), ws.log_ref(&stable.refname())?, page, ) } fn render_commit(ws: &Workspace, oid: &str) -> Result { let repo = repo(ws)?; let Ok(oid) = Oid::from_str(oid) else { return page( ws, "Commit not found", &format!("

    Commit not found

    {}

    ", h(oid)), ); }; let Ok(commit) = repo.find_commit(oid) else { return page( ws, "Commit not found", &format!( "

    Commit not found

    {}

    ", h(&oid.to_string()) ), ); }; let author = commit.author(); let author_name = author.name().unwrap_or("unknown"); let author_email = author.email().unwrap_or(""); let summary = commit.summary().ok().flatten().unwrap_or(""); let message = commit.message().unwrap_or(""); let mut parents = String::new(); for parent in commit.parents() { let parent_oid = parent.id().to_string(); parents.push_str(&format!( "
  • {1}
  • ", h(&parent_oid), h(&parent_oid[..parent_oid.len().min(12)]) )); } if parents.is_empty() { parents.push_str("
  • none
  • "); } let diff = render_commit_diff(&repo, &commit)?; page( ws, &format!("Commit {}", &oid.to_string()[..12]), &format!( "

    Commit {}

    \

    {} <{}> ({})

    \

    {}

    \
    {}
    \

    Parents

      {parents}
    \

    Changes

    {diff}", h(&oid.to_string()), h(author_name), h(author_email), h(&format_unix(commit.time().seconds())), h(summary), h(message) ), ) } fn render_commit_diff(repo: &Repository, commit: &git2::Commit<'_>) -> Result { let new_tree = commit.tree()?; let old_tree = commit .parent(0) .ok() .map(|parent| parent.tree()) .transpose()?; let diff = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None)?; if diff.deltas().len() == 0 { return Ok("

    No file changes.

    ".to_string()); } let mut out = String::from("
    "); for index in 0..diff.deltas().len() { let delta = diff.get_delta(index).expect("diff delta index"); let old_path = delta.old_file().path().map(|p| p.to_string_lossy()); let new_path = delta.new_file().path().map(|p| p.to_string_lossy()); let display_path = new_path .as_deref() .or(old_path.as_deref()) .unwrap_or("unknown"); let patch = Patch::from_diff(&diff, index)?; let stats = patch.as_ref().map(Patch::line_stats).transpose()?; let stat = stats .map(|(_, additions, deletions)| { format!( "+{additions} -{deletions}" ) }) .unwrap_or_default(); out.push_str(&format!( "
    {}{stat}
    ", h(display_path) )); let Some(patch) = patch else { out.push_str("

    Binary file changed

    "); continue; }; out.push_str("
    "); for hunk_index in 0..patch.num_hunks() { let (hunk, line_count) = patch.hunk(hunk_index)?; out.push_str(&format!( "", h(&String::from_utf8_lossy(hunk.header()).trim_end()) )); for line_index in 0..line_count { let line = patch.line_in_hunk(hunk_index, line_index)?; let (class, marker) = match line.origin() { '+' => ("diff-add", "+"), '-' => ("diff-del", "-"), '\\' => ("diff-note", "\\"), _ => ("diff-context", " "), }; let content = String::from_utf8_lossy(line.content()); let content = content.strip_suffix('\n').unwrap_or(&content); let content = content.strip_suffix('\r').unwrap_or(content); out.push_str(&format!( "\ ", line.old_lineno().map(|n| n.to_string()).unwrap_or_default(), line.new_lineno().map(|n| n.to_string()).unwrap_or_default(), h(content) )); } } out.push_str("
    {}
    {}{}{marker}{}
    "); } out.push_str("
    "); Ok(out) } fn render_log_page( ws: &Workspace, title: &str, heading: &str, back_href: &str, back_label: &str, page_href: &str, commits: Vec, page_num: usize, ) -> Result { let page_slice = paginate(&commits, page_num); let mut rows = String::new(); for commit in page_slice.items { let short = &commit.oid[..commit.oid.len().min(8)]; rows.push_str(&table_row([ commit_anchor(&commit.oid, format!("{}", h(short))), h(&commit.summary), h(&commit.author), h(&format_unix(commit.timestamp)), ])); } if rows.is_empty() { rows.push_str(&empty_table_row(4, "No commits")); } let pagination = pagination_nav(page_href, &page_slice); page( ws, title, &format!( "

    {heading}

    Back to {}

    \ {pagination}\ {rows}
    CommitSummaryAuthorWhen
    \ {pagination}", h(back_href), h(back_label) ), ) } fn table_row(cells: [String; N]) -> String { format!( "{}", cells .into_iter() .map(|cell| format!("{cell}")) .collect::() ) } fn empty_table_row(colspan: usize, label: &str) -> String { format!( "{}", h(label) ) } fn task_anchor(stable: &StableId, label_html: String) -> String { anchor(&format!("/tasks/{}", stable.0), label_html) } fn commit_anchor(oid: &str, label_html: String) -> String { anchor(&format!("/commits/{oid}"), label_html) } fn anchor(href: &str, label_html: String) -> String { format!("{label_html}", h(href)) } fn render_task_content( ws: &Workspace, repo: &Repository, input: &str, ) -> Result<(&'static str, String)> { if looks_like_markdown(input) { Ok(( "task-content task-content-markdown", render_markdown(ws, repo, input)?, )) } else { Ok(( "task-content task-content-plain", render_tsk_markup(ws, repo, input)?, )) } } fn looks_like_markdown(input: &str) -> bool { input.lines().any(|line| { let trimmed = line.trim_start(); trimmed.starts_with("# ") || trimmed.starts_with("## ") || trimmed.starts_with("### ") || trimmed.starts_with("```") || trimmed.starts_with("- ") || trimmed.starts_with("* ") || is_ordered_list_item(trimmed) }) || input.contains("**") } fn render_markdown(ws: &Workspace, repo: &Repository, input: &str) -> Result { let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS | Options::ENABLE_HEADING_ATTRIBUTES; let parser = MarkdownParser::new_ext(input, options); let mut events = Vec::new(); for event in parser { match event { Event::Text(text) => push_markdown_text_events(ws, repo, &text, &mut events)?, Event::Html(raw) | Event::InlineHtml(raw) => { events.push(Event::Text(CowStr::Boxed(raw.to_string().into_boxed_str()))); } other => events.push(other), } } let mut out = String::new(); html::push_html(&mut out, events.into_iter()); Ok(out) } fn push_markdown_text_events<'a>( ws: &Workspace, repo: &Repository, text: &str, out: &mut Vec>, ) -> Result<()> { let mut i = 0; while i < text.len() { let rest = &text[i..]; if let Some((html, consumed)) = render_internal_link(ws, repo, rest)? { out.push(Event::Html(CowStr::Boxed(html.into_boxed_str()))); i += consumed; continue; } let next_link = rest.find("[[").unwrap_or(rest.len()); if next_link > 0 { out.push(Event::Text(CowStr::Boxed( rest[..next_link].to_string().into_boxed_str(), ))); i += next_link; } else { let Some(ch) = rest.chars().next() else { break; }; out.push(Event::Text(CowStr::Boxed(ch.to_string().into_boxed_str()))); i += ch.len_utf8(); } } Ok(()) } fn is_ordered_list_item(input: &str) -> bool { let Some(dot) = input.find(". ") else { return false; }; dot > 0 && input[..dot].chars().all(|c| c.is_ascii_digit()) } fn render_tsk_markup(ws: &Workspace, repo: &Repository, input: &str) -> Result { let mut out = String::new(); let mut i = 0; while i < input.len() { let rest = &input[i..]; if let Some((html, consumed)) = render_internal_link(ws, repo, rest)? { out.push_str(&html); i += consumed; continue; } if let Some((html, consumed)) = render_markdown_link(rest) { out.push_str(&html); i += consumed; continue; } if let Some((html, consumed)) = render_raw_link(rest) { out.push_str(&html); i += consumed; continue; } if let Some((html, consumed)) = render_delimited(rest, '!', "strong") { out.push_str(&html); i += consumed; continue; } if let Some((html, consumed)) = render_delimited(rest, '*', "em") { out.push_str(&html); i += consumed; continue; } if let Some((html, consumed)) = render_delimited(rest, '_', "u") { out.push_str(&html); i += consumed; continue; } if let Some((html, consumed)) = render_delimited(rest, '~', "s") { out.push_str(&html); i += consumed; continue; } if let Some((html, consumed)) = render_delimited(rest, '=', "mark") { out.push_str(&html); i += consumed; continue; } if let Some((html, consumed)) = render_delimited(rest, '`', "code") { out.push_str(&html); i += consumed; continue; } let Some(ch) = rest.chars().next() else { break; }; out.push_str(&h(&ch.to_string())); i += ch.len_utf8(); } Ok(out) } fn render_property_value( ws: &Workspace, repo: &Repository, key: &str, value: &str, ) -> Result { if key == CLOSED_ON_KEY && let Some(html) = render_git_commit(repo, value) { return Ok(html); } render_tsk_markup(ws, repo, value) } fn render_git_commit(repo: &Repository, value: &str) -> Option { let oid = Oid::from_str(value).ok()?; repo.find_commit(oid).ok()?; Some(commit_link(&oid.to_string(), value)) } fn render_task_commit(repo: &Repository, stable: &StableId) -> String { let stable_html = oid_code(&stable.0); let Some(commit_oid) = repo .find_reference(&stable.refname()) .ok() .and_then(|reference| reference.target()) else { return stable_html; }; if repo.find_commit(commit_oid).is_err() { return stable_html; } commit_link(&commit_oid.to_string(), &stable.0) } fn commit_link(commit_oid: &str, label_oid: &str) -> String { format!( "{}", h(commit_oid), oid_code(label_oid) ) } fn oid_code(label_oid: &str) -> String { let short = &label_oid[..label_oid.len().min(12)]; format!("{}", h(label_oid), h(short)) } fn render_internal_link( ws: &Workspace, repo: &Repository, input: &str, ) -> Result> { if !input.starts_with("[[") { return Ok(None); } let Some(end) = input[2..].find("]]").map(|idx| idx + 2) else { return Ok(None); }; let target = &input[2..end]; let consumed = end + 2; let valid_ident = |s: &str| { !s.is_empty() && s.chars() .all(|c| c.is_alphanumeric() || c == '_' || c == '-') }; let html = if let Ok(id) = Id::from_str(target) { task_id_link(repo, &ws.namespace()?, id, target)? } else if let Some((ns, rest)) = target.split_once('/') && valid_ident(ns) && let Ok(id) = Id::from_str(rest) { task_id_link(repo, ns, id, target)? } else if let Some((prefix, id)) = target.split_once('-') && valid_ident(prefix) && id.parse::().is_ok() { format!("{}", h(target)) } else { h(&input[..consumed]) }; Ok(Some((html, consumed))) } fn task_id_link(repo: &Repository, namespace_name: &str, id: Id, label: &str) -> Result { Ok(match namespace::lookup(repo, namespace_name, id.0)? { Some(stable) => format!( "{}", h(&stable.0), h(label) ), None => format!("{}", h(label)), }) } fn render_markdown_link(input: &str) -> Option<(String, usize)> { if !input.starts_with('[') || input.starts_with("[[") { return None; } let text_end = input.find("](")?; let url_start = text_end + 2; let url_end = input[url_start..].find(')')? + url_start; let text = &input[1..text_end]; let url = &input[url_start..url_end]; if Url::parse(url).is_err() { return None; } Some(( format!( "{}", h(url), render_inline_plain(text) ), url_end + 1, )) } fn render_raw_link(input: &str) -> Option<(String, usize)> { if !input.starts_with('<') { return None; } let end = input.find('>')?; let url = &input[1..end]; if Url::parse(url).is_err() { return None; } Some(( format!("{}", h(url), h(url)), end + 1, )) } fn render_delimited(input: &str, delimiter: char, tag: &str) -> Option<(String, usize)> { if !input.starts_with(delimiter) { return None; } let content_start = delimiter.len_utf8(); let end = input[content_start..].find(delimiter)? + content_start; if end == content_start { return None; } let content = render_inline_plain(&input[content_start..end]); Some(( format!("<{tag}>{content}"), end + delimiter.len_utf8(), )) } fn render_inline_plain(input: &str) -> String { h(input) } fn all_bindings( ws: &Workspace, repo: &Repository, ) -> Result>> { let mut names = ws.list_namespaces()?; include_current(&mut names, &ws.namespace()?); let mut out: BTreeMap> = BTreeMap::new(); for name in names { for (human, stable) in namespace::read(repo, &name)?.mapping { out.entry(stable).or_default().push((name.clone(), human)); } } Ok(out) } fn binding_links(bindings: Option<&Vec<(String, u32)>>) -> String { let Some(bindings) = bindings else { return "unbound".to_string(); }; bindings .iter() .map(|(ns, human)| format!("{0}-{1}", h(ns), human)) .collect::>() .join(", ") } fn title_for(repo: &Repository, stable: &StableId) -> Result { Ok(object::read(repo, stable)? .map(|task| task.title().to_string()) .unwrap_or_else(|| "".to_string())) } fn repo(ws: &Workspace) -> Result { Ok(Repository::open(&ws.git_dir)?) } fn include_current(names: &mut Vec, current: &str) { if !names.iter().any(|name| name == current) { names.push(current.to_string()); } let set: BTreeSet = names.drain(..).collect(); names.extend(set); } fn page_from_target(target: &str) -> usize { let Some(query) = target.split_once('?').map(|(_, query)| query) else { return 1; }; form_urlencoded::parse(query.as_bytes()) .find(|(key, _)| key == "page") .and_then(|(_, value)| value.parse::().ok()) .filter(|page| *page > 0) .unwrap_or(1) } struct PageSlice<'a, T> { items: &'a [T], page: usize, total_pages: usize, total: usize, start: usize, } fn paginate(items: &[T], requested_page: usize) -> PageSlice<'_, T> { let total = items.len(); let total_pages = total.div_ceil(PAGE_SIZE).max(1); let page = requested_page.clamp(1, total_pages); let start = ((page - 1) * PAGE_SIZE).min(total); let end = (start + PAGE_SIZE).min(total); PageSlice { items: &items[start..end], page, total_pages, total, start, } } fn pagination_nav(base_href: &str, page: &PageSlice<'_, T>) -> String { if page.total <= PAGE_SIZE { return String::new(); } let prev = if page.page > 1 { format!( "Previous", h(base_href), page.page - 1 ) } else { "Previous".to_string() }; let next = if page.page < page.total_pages { format!( "Next", h(base_href), page.page + 1 ) } else { "Next".to_string() }; format!( "", page.page, page.total_pages, page.total ) } fn page(ws: &Workspace, title: &str, body: &str) -> Result { let active_queue = ws.queue()?; let active_namespace = ws.namespace()?; Ok(format!( "\ \ \ \ {}{}\ \
    {}
    ", PICO_CSS_URL, h(title), STYLE, h(&active_queue), h(&active_queue), h(&active_namespace), h(&active_namespace), body )) } const PICO_CSS_URL: &str = "https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"; const STYLE: &str = ""; fn format_unix(ts: i64) -> String { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); let delta = now - ts; if delta < 0 { return "in the future".to_string(); } relative_time(delta as u64) } fn relative_time(secs: u64) -> String { const M: u64 = 60; const H: u64 = 60 * M; const D: u64 = 24 * H; if secs < M { format!("{secs}s ago") } else if secs < H { format!("{}m ago", secs / M) } else if secs < D { format!("{}h ago", secs / H) } else if secs < 30 * D { format!("{}d ago", secs / D) } else if secs < 365 * D { format!("{}mo ago", secs / (30 * D)) } else { format!("{}y ago", secs / (365 * D)) } } fn h(input: &str) -> String { let mut out = String::with_capacity(input.len()); for ch in input.chars() { match ch { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(ch), } } out } fn property_path_segment(key: &str) -> String { let mut out = String::with_capacity(key.len()); for byte in key.bytes() { match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { out.push(byte as char) } _ => out.push_str(&format!("%{byte:02X}")), } } out } fn decode_path_segment(segment: &str) -> String { let bytes = segment.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' && i + 2 < bytes.len() && let (Some(high), Some(low)) = (hex_value(bytes[i + 1]), hex_value(bytes[i + 2])) { out.push(high << 4 | low); i += 3; } else { out.push(bytes[i]); i += 1; } } String::from_utf8_lossy(&out).into_owned() } fn hex_value(byte: u8) -> Option { match byte { b'0'..=b'9' => Some(byte - b'0'), b'a'..=b'f' => Some(byte - b'a' + 10), b'A'..=b'F' => Some(byte - b'A' + 10), _ => None, } } #[cfg(test)] mod tests { use super::*; fn run_git_init(p: &std::path::Path) { let repo = Repository::init(p).expect("git init"); let mut config = repo.config().expect("git config"); for (key, value) in [("user.name", "Test"), ("user.email", "t@e")] { config.set_str(key, value).expect("set git config"); } } fn fresh_workspace() -> (tempfile::TempDir, Workspace) { let dir = tempfile::tempdir().unwrap(); run_git_init(dir.path()); Workspace::init(dir.path().to_path_buf()).unwrap(); let ws = Workspace::from_path(dir.path().to_path_buf()).unwrap(); (dir, ws) } fn create_git_commit(dir: &std::path::Path, message: &str) -> String { let repo = Repository::open(dir).unwrap(); let blob = repo.blob(message.as_bytes()).unwrap(); let mut builder = repo.treebuilder(None).unwrap(); builder.insert("file.txt", blob, 0o100644).unwrap(); let tree_id = builder.write().unwrap(); let tree = repo.find_tree(tree_id).unwrap(); let sig = git2::Signature::now("Test", "t@e").unwrap(); let parent = repo.head().ok().and_then(|head| head.peel_to_commit().ok()); let parents: Vec<_> = parent.iter().collect(); repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents) .unwrap() .to_string() } #[test] fn page_shell_includes_mobile_layout_rules() { let (_dir, ws) = fresh_workspace(); let html = render_queues(&ws).unwrap(); assert!( html.contains( "" ), "page should use the device viewport: {html}" ); assert!( html.contains("class=\"container-fluid site-nav\""), "top nav should expose responsive styling hook: {html}" ); assert!( html.contains("class=\"active-context\""), "active queue/namespace nav should expose responsive styling hook: {html}" ); assert!( html.contains("table{display:block;max-width:100%;overflow-x:auto"), "wide tables should scroll within the viewport: {html}" ); assert!( html.contains("@media (max-width:700px)"), "page shell should include narrow viewport rules: {html}" ); } #[test] fn task_page_renders_links_in_property_values() { let (_dir, ws) = fresh_workspace(); let task = ws.new_task("linked props".into(), "".into()).unwrap(); let id = task.id; let stable = task.stable.clone(); ws.push_task(task).unwrap(); ws.add_property_value(id.into(), "url", "[site](https://example.com/path?q=1&v=2)") .unwrap(); ws.add_property_value(id.into(), "task", "[[tsk-1]]") .unwrap(); ws.add_property_value(id.into(), "raw", "not html") .unwrap(); let html = render_task(&ws, &stable.0).unwrap(); assert!( html.contains( "site" ), "external link should render in property value: {html}" ); assert!( html.contains(&format!( "tsk-1", h(&stable.0) )), "task link should render in property value: {html}" ); assert!( html.contains("<b>not html</b>"), "plain html-looking property value should stay escaped: {html}" ); } #[test] fn closed_on_property_renders_git_commit() { let (dir, ws) = fresh_workspace(); let closed_on = create_git_commit(dir.path(), "implement feature"); let short = &closed_on[..12]; let task = ws.new_task("closed task".into(), "".into()).unwrap(); let id = task.id; let stable = task.stable.clone(); ws.push_task(task).unwrap(); ws.drop_with_closed_on(id.into(), Some(closed_on.clone())) .unwrap(); let task_html = render_task(&ws, &stable.0).unwrap(); assert!( task_html.contains(&format!( "{short}" )), "task page should render closed-on as a commit page link: {task_html}" ); assert!( !task_html.contains("implement feature") && !task_html.contains("Test ("), "task page should not render commit details inline: {task_html}" ); let properties_html = render_properties(&ws, 1).unwrap(); assert!( properties_html.contains(&format!( "{CLOSED_ON_KEY}" )), "properties page should link closed-on key: {properties_html}" ); assert!( !properties_html.contains(&closed_on) && !properties_html.contains("implement feature"), "properties page should not render property values or commit details inline: {properties_html}" ); let commit_html = render_commit(&ws, &closed_on).unwrap(); assert!( commit_html.contains("

    implement feature

    "), "commit page should render commit details: {commit_html}" ); } #[test] fn commit_page_renders_syntax_highlighted_diff() { let (dir, ws) = fresh_workspace(); create_git_commit(dir.path(), "old & line\n"); let commit = create_git_commit(dir.path(), "new \n"); let html = render_commit(&ws, &commit).unwrap(); assert!( html.contains("
    ") && html.contains("file.txt"), "commit page should render a file diff: {html}" ); assert!( html.contains("class=\"diff-del\"") && html.contains("-old & line") && html.contains("class=\"diff-add\"") && html.contains("+new <line>"), "changed lines should be escaped and syntax highlighted: {html}" ); assert!( html.contains("class=\"diff-hunk\"") && html.contains("@@ -1 +1 @@"), "diff should include highlighted hunk headers: {html}" ); assert!( html.contains(".diff-add td{background:#dafbe1") && html.contains(".diff-del td{background:#ffebe9") && html.contains(".diff-hunk td{background:#ddf4ff"), "semantic diff colors should apply to table cells: {html}" ); } #[test] fn properties_page_lists_active_namespace_property_names() { let (_dir, ws) = fresh_workspace(); let first = ws.new_task("first task".into(), "".into()).unwrap(); let first_id = first.id; ws.push_task(first).unwrap(); ws.add_property_value(first_id.into(), "priority", "high") .unwrap(); ws.add_property_value(first_id.into(), "link", "[[tsk-1]]") .unwrap(); ws.switch_namespace("alpha").unwrap(); let alpha = ws.new_task("alpha task".into(), "".into()).unwrap(); let alpha_id = alpha.id; ws.push_task(alpha).unwrap(); ws.add_property_value(alpha_id.into(), "owner", "alpha") .unwrap(); ws.switch_namespace("tsk").unwrap(); let html = render_properties(&ws, 1).unwrap(); assert!( html.contains("

    Properties

    "), "properties page should render heading: {html}" ); assert!( html.contains("priority"), "properties page should link priority key: {html}" ); assert!( html.contains("link"), "properties page should link link key: {html}" ); assert!( !html.contains("high") && !html.contains("first task"), "properties page should not list property values or task titles: {html}" ); assert!( !html.contains("owner") && !html.contains("alpha task"), "properties page should only show active namespace tasks: {html}" ); } #[test] fn property_page_lists_active_namespace_tasks() { let (_dir, ws) = fresh_workspace(); let first = ws.new_task("first task".into(), "".into()).unwrap(); let first_id = first.id; let first_stable = first.stable.clone(); ws.push_task(first).unwrap(); ws.add_property_value(first_id.into(), "priority", "high") .unwrap(); ws.add_property_value(first_id.into(), "link", "[[tsk-1]]") .unwrap(); ws.switch_namespace("alpha").unwrap(); let alpha = ws.new_task("alpha task".into(), "".into()).unwrap(); let alpha_id = alpha.id; ws.push_task(alpha).unwrap(); ws.add_property_value(alpha_id.into(), "priority", "alpha") .unwrap(); ws.switch_namespace("tsk").unwrap(); let html = render_property(&ws, "priority", 1).unwrap(); assert!( html.contains("

    Property priority

    "), "property page should render heading: {html}" ); assert!( html.contains(&format!( "
  • tsk-1 first task
  • ", h(&first_stable.0) )), "property page should link matching task: {html}" ); assert!( !html.contains("high") && !html.contains("alpha task"), "property page should only list active namespace tasks, not values: {html}" ); } #[test] fn properties_route_renders_page() { let (_dir, ws) = fresh_workspace(); let task = ws.new_task("route props".into(), "".into()).unwrap(); let id = task.id; ws.push_task(task).unwrap(); ws.set_property(id.into(), "status-note", vec!["ready".into()]) .unwrap(); let Rendered::Html(html) = render_path(&ws, "/properties").unwrap() else { panic!("properties route should render html"); }; assert!( html.contains("

    Properties

    "), "route should render properties page: {html}" ); assert!( html.contains("status-note"), "route should include property key: {html}" ); let Rendered::Html(html) = render_path(&ws, "/properties/status-note").unwrap() else { panic!("property route should render html"); }; assert!( html.contains("

    Property status-note

    "), "property route should render property page: {html}" ); assert!( html.contains("route props"), "property route should include matching task: {html}" ); } #[test] fn task_page_renders_markdown_body_without_tsk_inline_false_positives() { let (_dir, ws) = fresh_workspace(); let task = ws .new_task( "markdown review".into(), r#"## Review The `tamper_detected_via_stable_id_check` test and `if idx == 0` should not become tsk underline or highlight markup. ```rust if idx == 0 { if content_oid.to_string() != stable_hex { return Err(...); } } ``` - `tsk import` of an mbox you got from someone is its own attacker-controlled channel. 1. Decide what stable id means across wrapped lines. "# .into(), ) .unwrap(); let stable = task.stable.clone(); ws.push_task(task).unwrap(); let html = render_task(&ws, &stable.0).unwrap(); assert!(html.contains("

    Review

    "), "heading rendered: {html}"); assert!( html.contains("tamper_detected_via_stable_id_check"), "inline code should protect underscores: {html}" ); assert!( html.contains("if idx == 0"), "inline code should protect ==: {html}" ); assert!( html.contains("
    "),
                "fenced code should render as a block: {html}"
            );
            assert!(
                html.contains("if idx == 0 {\n"),
                "fenced code content should be preserved: {html}"
            );
            assert!(
                !html.contains("") && !html.contains(""),
                "markdown code should not trigger tsk inline tags: {html}"
            );
            assert!(
                html.contains(
                    "
  • tsk import of an mbox you got from someone is its own\nattacker-controlled channel.
  • " ), "wrapped unordered list item should stay in one item: {html}" ); assert!( html.contains("
  • Decide what stable id means\nacross wrapped lines.
  • "), "wrapped ordered list item should stay in one item: {html}" ); } #[test] fn queue_page_links_to_log_and_log_renders_commits() { let (_dir, ws) = fresh_workspace(); let task = ws.new_task("queued work".into(), "".into()).unwrap(); let id = task.id; let stable = task.stable.clone(); ws.push_task(task).unwrap(); ws.drop(id.into()).unwrap(); let queue_html = render_queue(&ws, "tsk", 1).unwrap(); assert!( queue_html.contains("Log"), "queue page should link to queue log: {queue_html}" ); let log_html = render_queue_log(&ws, "tsk", 1).unwrap(); assert!( log_html.contains("Queue tsk Log"), "queue log should have a heading: {log_html}" ); assert!( log_html.contains(&format!("drop tsk-{} {}", id.0, stable)), "queue log should include drop summary with both ids: {log_html}" ); assert!( log_html.contains(&format!("push tsk-{} {}", id.0, stable)), "queue log should include push summary with both ids: {log_html}" ); } #[test] fn namespace_page_links_to_log_and_log_renders_commits() { let (_dir, ws) = fresh_workspace(); let task = ws.new_task("namespaced work".into(), "".into()).unwrap(); ws.push_task(task).unwrap(); let namespace_html = render_namespace(&ws, "tsk", 1).unwrap(); assert!( namespace_html.contains("Log"), "namespace page should link to namespace log: {namespace_html}" ); let log_html = render_namespace_log(&ws, "tsk", 1).unwrap(); assert!( log_html.contains("Namespace tsk Log"), "namespace log should have a heading: {log_html}" ); assert!( log_html.contains("assign-id tsk-1"), "namespace log should include assignment summary: {log_html}" ); } #[test] fn task_page_links_to_log_and_log_renders_commits() { let (_dir, ws) = fresh_workspace(); let task = ws.new_task("logged task".into(), "".into()).unwrap(); let stable = task.stable.clone(); ws.push_task(task).unwrap(); let task_html = render_task(&ws, &stable.0).unwrap(); let repo = repo(&ws).unwrap(); let task_commit = repo .find_reference(&stable.refname()) .unwrap() .target() .unwrap() .to_string(); assert!( task_html.contains(&format!("Log", h(&stable.0))), "task page should link to task log: {task_html}" ); assert!( task_html.contains(&format!( "{}", stable.0, stable.short() )), "task page should render the stable id as its task commit link: {task_html}" ); let log_html = render_task_log(&ws, &stable.0, 1).unwrap(); assert!( log_html.contains("Task Log: logged task"), "task log should have a heading: {log_html}" ); assert!( log_html.contains("create"), "task log should include create summary: {log_html}" ); assert!( log_html.contains(&format!("")), "task log should link commit hashes to commit pages: {log_html}" ); } #[test] fn queue_tasks_paginate_when_over_threshold() { let (_dir, ws) = fresh_workspace(); for n in 0..(PAGE_SIZE + 2) { let task = ws .new_task(format!("queue-task-{n:02}"), "".into()) .unwrap(); ws.push_task(task).unwrap(); } let first = render_queue(&ws, "tsk", 1).unwrap(); assert!( first.contains("Page 1 of 2"), "first page should show pagination: {first}" ); assert!( first.contains("queue-task-26"), "newest task should be on first page: {first}" ); assert!( !first.contains("queue-task-00"), "oldest task should not be on first page: {first}" ); assert!( first.contains("Next"), "first page should link to second page: {first}" ); let second = render_queue(&ws, "tsk", 2).unwrap(); assert!( second.contains("Page 2 of 2"), "second page should show pagination: {second}" ); assert!( second.contains("queue-task-00"), "oldest task should be on second page: {second}" ); assert!( !second.contains("queue-task-26"), "newest task should not be on second page: {second}" ); assert!( second.contains("Previous"), "second page should link back to first page: {second}" ); } #[test] fn render_path_uses_page_query_parameter() { let (_dir, ws) = fresh_workspace(); for n in 0..(PAGE_SIZE + 2) { let task = ws .new_task(format!("route-task-{n:02}"), "".into()) .unwrap(); ws.push_task(task).unwrap(); } let Rendered::Html(html) = render_path(&ws, "/queues/tsk?page=2").unwrap() else { panic!("queue route should render html"); }; assert!( html.contains("Page 2 of 2"), "route should pass query page to renderer: {html}" ); assert!( html.contains("route-task-00"), "second page should contain oldest task: {html}" ); } #[test] fn namespace_tasks_paginate_when_over_threshold() { let (_dir, ws) = fresh_workspace(); for n in 0..(PAGE_SIZE + 2) { let task = ws .new_task(format!("namespace-task-{n:02}"), "".into()) .unwrap(); ws.push_task(task).unwrap(); } let first = render_namespace(&ws, "tsk", 1).unwrap(); assert!( first.contains("Page 1 of 2"), "first page should show pagination: {first}" ); assert!( first.contains("namespace-task-26"), "last namespace binding should be on first page: {first}" ); assert!( !first.contains("namespace-task-00"), "first namespace binding should not be on first page: {first}" ); let second = render_namespace(&ws, "tsk", 2).unwrap(); assert!( second.contains("Page 2 of 2"), "second page should show pagination: {second}" ); assert!( second.contains("namespace-task-00"), "first namespace binding should be on second page: {second}" ); assert!( second.contains("Previous"), "second page should link back to first page: {second}" ); } #[test] fn logs_paginate_when_over_threshold() { let (_dir, ws) = fresh_workspace(); let task = ws.new_task("many log entries".into(), "".into()).unwrap(); let id = task.id; let stable = task.stable.clone(); ws.push_task(task).unwrap(); for n in 0..(PAGE_SIZE + 2) { let mut task = ws.task(id.into()).unwrap(); task.body = format!("revision {n:02}"); ws.save_task(&task).unwrap(); } let first = render_task_log(&ws, &stable.0, 1).unwrap(); assert!( first.contains("Page 1 of 2"), "first log page should show pagination: {first}" ); assert!( first.matches("edit").count() >= PAGE_SIZE, "first log page should contain page-size edit entries: {first}" ); assert!( !first.contains("create tsk-1"), "oldest create entry should not be on first log page: {first}" ); let second = render_task_log(&ws, &stable.0, 2).unwrap(); assert!( second.contains("Page 2 of 2"), "second log page should show pagination: {second}" ); assert!( second.contains("create tsk-1"), "oldest create entry should be on second log page: {second}" ); assert!( second.contains(&format!( "Previous", h(&stable.0) )), "second log page should link back to first page: {second}" ); } } fn write_redirect(stream: &mut TcpStream, location: &str, head: bool) -> Result<()> { let body = format!("redirecting to {location}\n"); let response = format!( "HTTP/1.1 302 Found\r\nLocation: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", location, if head { 0 } else { body.len() } ); stream.write_all(response.as_bytes())?; if !head { stream.write_all(body.as_bytes())?; } Ok(()) } fn write_response( stream: &mut TcpStream, status: u16, reason: &str, content_type: &str, body: &str, head: bool, ) -> Result<()> { let response = format!( "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", if head { 0 } else { body.len() } ); stream.write_all(response.as_bytes())?; if !head { stream.write_all(body.as_bytes())?; } Ok(()) }