1use crate::db::Database;
4use crate::models::TodoStatus;
5use crate::models::{
6 AgentGuardrails, GitAgentContext, JjAgentContext, Scrap, Todo, TodoAgentView, VcsMode,
7 WorkflowContext, Worktree,
8};
9use crate::services::agent_context::{build_agent_extensions, AgentStatusExtensions};
10use crate::services::WorktreeService;
11use crate::use_cases::{GetTaskInfoUseCase, TaskInfoSnapshot};
12use crate::utils::{Result, TrackError};
13use serde::Serialize;
14
15#[derive(Serialize)]
17pub struct StatusResponse {
18 pub task: Option<serde_json::Value>,
19 pub todos: Vec<serde_json::Value>,
20 pub links: Vec<serde_json::Value>,
21 pub scraps: Vec<serde_json::Value>,
22 pub worktrees: Vec<serde_json::Value>,
23 pub repos: Vec<serde_json::Value>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub workflow: Option<WorkflowContext>,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 pub vcs_mode: Option<VcsMode>,
28 #[serde(skip_serializing_if = "Option::is_none")]
29 pub jj: Option<JjAgentContext>,
30 #[serde(skip_serializing_if = "Option::is_none")]
31 pub git: Option<GitAgentContext>,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub todos_agent: Option<Vec<TodoAgentView>>,
34 #[serde(skip_serializing_if = "Option::is_none")]
35 pub guardrails: Option<AgentGuardrails>,
36}
37
38impl StatusResponse {
39 pub fn empty() -> Self {
40 Self {
41 task: None,
42 todos: vec![],
43 links: vec![],
44 scraps: vec![],
45 worktrees: vec![],
46 repos: vec![],
47 workflow: None,
48 vcs_mode: None,
49 jj: None,
50 git: None,
51 todos_agent: None,
52 guardrails: None,
53 }
54 }
55}
56
57pub fn build_template_context(db: &Database, task_id: i64) -> Result<serde_json::Value> {
59 let info = GetTaskInfoUseCase::new(db);
60 let snapshot = info.load(task_id)?;
61 let calendar_id = db.get_app_state("calendar_id").ok().flatten();
62 to_template_context(db, &snapshot, calendar_id)
63}
64
65pub fn build_api_status(db: &Database, snapshot: &TaskInfoSnapshot) -> Result<StatusResponse> {
67 let agent = agent_extensions(db, snapshot)?;
68 let todos = format_todos(&snapshot.todos, &snapshot.worktrees, &snapshot.scraps)?;
69
70 Ok(StatusResponse {
71 task: Some(to_json(&snapshot.task)?),
72 todos,
73 links: snapshot
74 .links
75 .iter()
76 .map(to_json)
77 .collect::<Result<Vec<_>>>()?,
78 scraps: snapshot
79 .scraps
80 .iter()
81 .map(to_json)
82 .collect::<Result<Vec<_>>>()?,
83 worktrees: snapshot
84 .worktrees
85 .iter()
86 .map(to_json)
87 .collect::<Result<Vec<_>>>()?,
88 repos: snapshot
89 .repos
90 .iter()
91 .map(to_json)
92 .collect::<Result<Vec<_>>>()?,
93 workflow: Some(agent.workflow),
94 vcs_mode: Some(agent.vcs_mode),
95 jj: agent.jj,
96 git: agent.git,
97 todos_agent: Some(agent.todos_agent),
98 guardrails: Some(agent.guardrails),
99 })
100}
101
102fn to_template_context(
103 db: &Database,
104 snapshot: &TaskInfoSnapshot,
105 calendar_id: Option<String>,
106) -> Result<serde_json::Value> {
107 let agent = agent_extensions(db, snapshot)?;
108
109 Ok(serde_json::json!({
110 "task": snapshot.task,
111 "todos": format_todos(&snapshot.todos, &snapshot.worktrees, &snapshot.scraps)?,
112 "links": snapshot.links,
113 "scraps": format_scraps(&snapshot.scraps),
114 "worktrees": snapshot.worktrees,
115 "repos": snapshot.repos,
116 "base_branch": GetTaskInfoUseCase::base_bookmark(snapshot),
117 "calendar_id": calendar_id,
118 "workflow": agent.workflow,
119 "vcs_mode": agent.vcs_mode,
120 "jj": agent.jj,
121 "git": agent.git,
122 "guardrails": agent.guardrails,
123 }))
124}
125
126fn agent_extensions(db: &Database, snapshot: &TaskInfoSnapshot) -> Result<AgentStatusExtensions> {
127 let worktree_service = WorktreeService::new(db);
128 Ok(build_agent_extensions(
129 snapshot.vcs_mode,
130 &snapshot.task,
131 &snapshot.todos,
132 &snapshot.worktrees,
133 &snapshot.repos,
134 &worktree_service,
135 ))
136}
137
138fn to_json<T: serde::Serialize>(value: &T) -> Result<serde_json::Value> {
139 serde_json::to_value(value).map_err(|err| TrackError::SerializationFailed(err.to_string()))
140}
141
142pub fn format_todos(
144 todos: &[Todo],
145 worktrees: &[Worktree],
146 scraps: &[Scrap],
147) -> Result<Vec<serde_json::Value>> {
148 let oldest_pending_id = todos
149 .iter()
150 .filter(|todo| todo.status == TodoStatus::Pending)
151 .min_by_key(|todo| todo.task_index)
152 .map(|todo| todo.id);
153
154 todos
155 .iter()
156 .map(|todo| {
157 let todo_worktrees: Vec<String> = worktrees
158 .iter()
159 .filter(|worktree| worktree.todo_id == Some(todo.id))
160 .map(|worktree| worktree.path.clone())
161 .collect();
162
163 let scrap_count = scraps
164 .iter()
165 .filter(|scrap| scrap.active_todo_id == Some(todo.task_index))
166 .count();
167
168 let is_in_progress = oldest_pending_id == Some(todo.id);
169 let mut value = to_json(todo)?;
170
171 if let Some(obj) = value.as_object_mut() {
172 obj.insert(
173 "worktree_requested".to_string(),
174 serde_json::Value::Bool(todo.worktree_requested),
175 );
176 obj.insert(
177 "requires_workspace".to_string(),
178 serde_json::Value::Bool(todo.requires_workspace),
179 );
180 obj.insert(
181 "worktree_paths".to_string(),
182 serde_json::json!(todo_worktrees),
183 );
184 obj.insert(
185 "content_html".to_string(),
186 serde_json::Value::String(todo.content_html()),
187 );
188 obj.insert(
189 "has_scraps".to_string(),
190 serde_json::Value::Bool(scrap_count > 0),
191 );
192 obj.insert(
193 "is_in_progress".to_string(),
194 serde_json::Value::Bool(is_in_progress),
195 );
196 }
197
198 Ok(value)
199 })
200 .collect()
201}
202
203pub fn format_scraps(scraps: &[Scrap]) -> Vec<serde_json::Value> {
205 use chrono::Local;
206
207 scraps
208 .iter()
209 .map(|scrap| {
210 let formatted_time = scrap
211 .created_at
212 .with_timezone(&Local)
213 .format("%Y-%m-%d %H:%M:%S")
214 .to_string();
215
216 serde_json::json!({
217 "scrap_id": scrap.scrap_id,
218 "content": scrap.content,
219 "content_html": scrap.content_html(),
220 "created_at": formatted_time,
221 "active_todo_id": scrap.active_todo_id,
222 })
223 })
224 .collect()
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use crate::services::{TaskService, TodoService};
231
232 #[test]
233 fn template_context_includes_formatted_todos() {
234 let db = Database::new_in_memory().unwrap();
235 let task = TaskService::new(&db)
236 .create_task("Web", None, None, None)
237 .unwrap();
238 TodoService::new(&db)
239 .add_todo(task.id, "Item", false)
240 .unwrap();
241
242 let context = build_template_context(&db, task.id).unwrap();
243 assert_eq!(context["task"]["name"], "Web");
244 assert_eq!(
245 context["todos"].as_array().map(|items| items.len()),
246 Some(1)
247 );
248 assert!(context["todos"][0]["content_html"].is_string());
249 }
250}