use crate::tools::todo::{SharedTodoList, TodoItem, TodoListSnapshot, TodoStatus};
use crate::work_graph::SharedWorkRuntime;
pub const MAX_ITEM_LINES: usize = 24;
pub const MAX_BODY_CHARS: usize = 2_000;
pub const MAX_ITEM_CONTENT_CHARS: usize = 160;
const OMISSION_MARKER: char = '…';
const ESCAPED_CLOSE_PREFIX: &str = "<\\/codewhale:";
const CLOSE_PREFIX: &str = "</codewhale:";
#[must_use]
pub fn todo_snapshot_body(snapshot: &TodoListSnapshot) -> Option<String> {
if snapshot.items.is_empty() {
return None;
}
let header = format!("To-do ({}% settled)", snapshot.completion_pct);
let lines: Vec<String> = snapshot.items.iter().map(item_line).collect();
let priority = priority_order(snapshot);
let mut selected: Vec<usize> = Vec::new();
let mut used = header.chars().count();
for idx in priority {
if selected.len() >= MAX_ITEM_LINES {
break;
}
let cost = 1 + lines[idx].chars().count();
if used + cost > MAX_BODY_CHARS {
break;
}
used += cost;
selected.push(idx);
}
let mut omitted = lines.len() - selected.len();
if omitted > 0 {
loop {
let cost = 1 + omission_line(omitted).chars().count();
if used + cost <= MAX_BODY_CHARS || selected.len() <= 1 {
break;
}
if let Some(dropped) = selected.pop() {
used -= 1 + lines[dropped].chars().count();
omitted += 1;
}
}
}
selected.sort_unstable();
let mut body = header;
for idx in selected {
body.push('\n');
body.push_str(&lines[idx]);
}
if omitted > 0 {
body.push('\n');
body.push_str(&omission_line(omitted));
}
debug_assert!(body.chars().count() <= MAX_BODY_CHARS);
Some(body)
}
#[derive(Clone)]
pub struct TodoSource {
work: Option<SharedWorkRuntime>,
todos: SharedTodoList,
}
impl std::fmt::Debug for TodoSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TodoSource")
.field("graph_backed", &self.is_graph_backed())
.finish()
}
}
impl TodoSource {
#[must_use]
pub fn new(work: Option<SharedWorkRuntime>, todos: SharedTodoList) -> Self {
Self { work, todos }
}
#[must_use]
pub fn is_graph_backed(&self) -> bool {
self.work
.as_ref()
.is_some_and(|work| work.matches_todos(&self.todos))
}
pub async fn snapshot(&self) -> TodoListSnapshot {
if let Some(work) = self.work.as_ref().filter(|_| self.is_graph_backed()) {
match work.current_todos().await {
Ok(snapshot) => return snapshot,
Err(err) => tracing::warn!(
target: "todo_snapshot",
error = %err,
"work graph projection unavailable; falling back to the legacy To-do view"
),
}
}
self.todos.lock().await.snapshot()
}
pub async fn body(&self) -> Option<String> {
todo_snapshot_body(&self.snapshot().await)
}
}
pub const MAX_CARD_ITEM_LINES: usize = 3;
pub const MAX_CARD_ITEM_CONTENT_CHARS: usize = 72;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TodoCardProjection {
pub header: String,
pub items: Vec<String>,
pub omitted: usize,
}
#[must_use]
pub fn card_todo_projection(snapshot: &TodoListSnapshot) -> Option<TodoCardProjection> {
if snapshot.items.is_empty() {
return None;
}
let total = snapshot.items.len();
let settled = snapshot
.items
.iter()
.filter(|item| item.status.is_settled())
.count();
let header = format!(
"To-do {settled}/{total} · {}% settled",
snapshot.completion_pct
);
let mut selected: Vec<usize> = priority_order(snapshot)
.into_iter()
.take(MAX_CARD_ITEM_LINES)
.collect();
selected.sort_unstable();
let items: Vec<String> = selected
.iter()
.map(|idx| card_item_line(&snapshot.items[*idx]))
.collect();
Some(TodoCardProjection {
omitted: total - items.len(),
header,
items,
})
}
fn card_item_line(item: &TodoItem) -> String {
format!(
"{} #{} {}",
status_marker(item.status),
item.id,
sanitize_to(&item.content, MAX_CARD_ITEM_CONTENT_CHARS)
)
}
#[must_use]
pub fn card_omission_line(count: usize) -> String {
format!("{OMISSION_MARKER} +{count} more")
}
pub const FORK_TODO_SECTION_HEADING: &str = "### To-do";
#[must_use]
pub fn fork_state_todo_section(body: &str) -> String {
format!("{FORK_TODO_SECTION_HEADING}\n\n{body}\n")
}
fn priority_order(snapshot: &TodoListSnapshot) -> Vec<usize> {
let active = active_index(snapshot);
let mut priority: Vec<usize> = Vec::with_capacity(snapshot.items.len());
if let Some(active) = active {
priority.push(active);
}
priority.extend((0..snapshot.items.len()).filter(|idx| Some(*idx) != active));
priority
}
fn active_index(snapshot: &TodoListSnapshot) -> Option<usize> {
snapshot
.in_progress_id
.and_then(|id| snapshot.items.iter().position(|item| item.id == id))
.or_else(|| {
snapshot
.items
.iter()
.position(|item| item.status == TodoStatus::InProgress)
})
}
fn status_marker(status: TodoStatus) -> &'static str {
match status {
TodoStatus::Pending => "[ ]",
TodoStatus::InProgress => "[~]",
TodoStatus::Completed => "[x]",
TodoStatus::Cancelled => "[-]",
}
}
fn item_line(item: &TodoItem) -> String {
format!(
"- {} #{} {}",
status_marker(item.status),
item.id,
sanitize(&item.content)
)
}
fn omission_line(count: usize) -> String {
format!("- {OMISSION_MARKER} +{count} more To-do items omitted")
}
fn sanitize(content: &str) -> String {
sanitize_to(content, MAX_ITEM_CONTENT_CHARS)
}
fn sanitize_to(content: &str, max_chars: usize) -> String {
let flattened: String = content
.chars()
.map(|ch| if ch.is_control() { ' ' } else { ch })
.collect();
let escaped = escape_wrapper(&flattened);
truncate_chars(escaped.trim(), max_chars)
}
fn escape_wrapper(content: &str) -> String {
if !content.to_ascii_lowercase().contains(CLOSE_PREFIX) {
return content.to_string();
}
let lower = content.to_ascii_lowercase();
let mut out = String::with_capacity(content.len() + 8);
let mut cursor = 0usize;
while let Some(found) = lower[cursor..].find(CLOSE_PREFIX) {
let at = cursor + found;
out.push_str(&content[cursor..at]);
out.push_str(ESCAPED_CLOSE_PREFIX);
cursor = at + CLOSE_PREFIX.len();
}
out.push_str(&content[cursor..]);
out
}
fn truncate_chars(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
return text.to_string();
}
let keep = max_chars.saturating_sub(1);
let mut out: String = text.chars().take(keep).collect();
out.push(OMISSION_MARKER);
out
}
#[cfg(test)]
mod tests;