1use std::path::{Path, PathBuf};
2
3use time::OffsetDateTime;
4
5use crate::{
6 Document, DocumentSummary, ListOptions, RoadmapState, RoadmapStateStore, ThreadAttachment,
7 ValidationResult, list_documents, parse_document, set_task_checked, validate_document,
8};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum RoadmapEventKind {
12 Opened,
13 Updated,
14 TaskFocused,
15 TaskChecked,
16 ThreadAttached,
17 ThreadSpawned,
18 Validated,
19 ModeChanged,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct RoadmapEvent {
24 pub kind: RoadmapEventKind,
25 pub path: PathBuf,
26 pub task_id: Option<String>,
27 pub thread_id: Option<String>,
28 pub timestamp: OffsetDateTime,
29}
30
31#[derive(Debug, Clone)]
32pub struct RoadmapRuntime {
33 workspace: PathBuf,
34 store: RoadmapStateStore,
35 events: Vec<RoadmapEvent>,
36}
37
38impl RoadmapRuntime {
39 pub fn new(workspace: impl Into<PathBuf>, data_dir: impl Into<PathBuf>) -> Self {
40 Self {
41 workspace: workspace.into(),
42 store: RoadmapStateStore::new(data_dir),
43 events: Vec::new(),
44 }
45 }
46
47 pub fn events(&self) -> &[RoadmapEvent] {
48 &self.events
49 }
50
51 pub fn list_roadmaps(&self) -> anyhow::Result<Vec<DocumentSummary>> {
52 list_documents(&self.workspace, ListOptions::default())
53 }
54
55 pub fn open_roadmap(&mut self, path: impl AsRef<Path>) -> anyhow::Result<Document> {
56 let path = self.resolve_roadmap_path(path.as_ref())?;
57 let document = self.read_document(&path)?;
58 let now = OffsetDateTime::now_utc();
59 let mut state = self.state_for(&document)?;
60 state.document_id = document.id.clone();
61 state.path = document.path.clone();
62 if state.focused_task_id.is_none() {
63 state.focused_task_id = document.tasks.first().map(|task| task.id.clone());
64 }
65 state.updated_at = now;
66 self.save_state(state)?;
67 self.emit(RoadmapEventKind::Opened, &document.path, None, None);
68 Ok(document)
69 }
70
71 pub fn focus_roadmap_task(
72 &mut self,
73 path: impl AsRef<Path>,
74 task_id: &str,
75 ) -> anyhow::Result<()> {
76 let path = self.resolve_roadmap_path(path.as_ref())?;
77 let document = self.read_document(&path)?;
78 ensure_task(&document, task_id)?;
79 let mut state = self.state_for(&document)?;
80 state.focused_task_id = Some(task_id.to_string());
81 state.updated_at = OffsetDateTime::now_utc();
82 self.save_state(state)?;
83 self.emit(
84 RoadmapEventKind::TaskFocused,
85 &document.path,
86 Some(task_id.to_string()),
87 None,
88 );
89 Ok(())
90 }
91
92 pub fn set_roadmap_task(
93 &mut self,
94 path: impl AsRef<Path>,
95 task_id: &str,
96 checked: bool,
97 evidence: &str,
98 ) -> anyhow::Result<()> {
99 let path = self.resolve_roadmap_path(path.as_ref())?;
100 set_task_checked(&path, task_id, checked, evidence)?;
101 let document = self.read_document(&path)?;
102 let mut state = self.state_for(&document)?;
103 state.focused_task_id = Some(task_id.to_string());
104 state.updated_at = OffsetDateTime::now_utc();
105 self.save_state(state)?;
106 self.emit(
107 if checked {
108 RoadmapEventKind::TaskChecked
109 } else {
110 RoadmapEventKind::Updated
111 },
112 &document.path,
113 Some(task_id.to_string()),
114 None,
115 );
116 Ok(())
117 }
118
119 pub fn validate_roadmap(&mut self, path: impl AsRef<Path>) -> anyhow::Result<ValidationResult> {
120 let path = self.resolve_roadmap_path(path.as_ref())?;
121 let document = self.read_document(&path)?;
122 let result = validate_document(&document);
123 let mut state = self.state_for(&document)?;
124 state.last_validation = Some(OffsetDateTime::now_utc());
125 state.last_diagnostics = result.diagnostics.clone();
126 state.updated_at = OffsetDateTime::now_utc();
127 self.save_state(state)?;
128 self.emit(RoadmapEventKind::Validated, &document.path, None, None);
129 Ok(result)
130 }
131
132 pub fn list_roadmap_threads(
133 &self,
134 path: impl AsRef<Path>,
135 ) -> anyhow::Result<Vec<ThreadAttachment>> {
136 let path = self.resolve_roadmap_path(path.as_ref())?;
137 let state = self.store.load()?.filter(|state| state.path == path);
138 Ok(state.map(|state| state.threads).unwrap_or_default())
139 }
140
141 pub fn record_mode_changed(&mut self, path: impl AsRef<Path>) -> anyhow::Result<()> {
142 let path = self.resolve_roadmap_path(path.as_ref())?;
143 let document = self.read_document(&path)?;
144 let mut state = self.state_for(&document)?;
145 state.updated_at = OffsetDateTime::now_utc();
146 self.save_state(state)?;
147 self.emit(RoadmapEventKind::ModeChanged, &document.path, None, None);
148 Ok(())
149 }
150
151 pub fn spawn_roadmap_thread(
152 &mut self,
153 path: impl AsRef<Path>,
154 task_id: &str,
155 ) -> anyhow::Result<ThreadAttachment> {
156 let thread_id = format!("thread-{}", uuid::Uuid::new_v4());
157 self.attach_roadmap_thread(
158 path,
159 task_id,
160 &thread_id,
161 Some("Roadmap worker".to_string()),
162 )?;
163 let attachment = self
164 .store
165 .load()?
166 .and_then(|state| {
167 state
168 .threads
169 .into_iter()
170 .find(|thread| thread.thread_id == thread_id)
171 })
172 .ok_or_else(|| anyhow::anyhow!("spawned thread attachment not found"))?;
173 self.emit(
174 RoadmapEventKind::ThreadSpawned,
175 &attachment_path(&self.store)?,
176 Some(task_id.to_string()),
177 Some(thread_id),
178 );
179 Ok(attachment)
180 }
181
182 pub fn attach_roadmap_thread(
183 &mut self,
184 path: impl AsRef<Path>,
185 task_id: &str,
186 thread_id: &str,
187 title: Option<String>,
188 ) -> anyhow::Result<ThreadAttachment> {
189 let path = self.resolve_roadmap_path(path.as_ref())?;
190 let document = self.read_document(&path)?;
191 ensure_task(&document, task_id)?;
192 let mut state = self.state_for(&document)?;
193 let now = OffsetDateTime::now_utc();
194 let attachment = ThreadAttachment {
195 thread_id: thread_id.to_string(),
196 task_id: Some(task_id.to_string()),
197 title,
198 status: Some("attached".to_string()),
199 created_at: now,
200 updated_at: now,
201 };
202 state.attached_thread_id = Some(thread_id.to_string());
203 state.threads.push(attachment.clone());
204 state.updated_at = now;
205 self.save_state(state)?;
206 self.emit(
207 RoadmapEventKind::ThreadAttached,
208 &document.path,
209 Some(task_id.to_string()),
210 Some(thread_id.to_string()),
211 );
212 Ok(attachment)
213 }
214
215 fn read_document(&self, path: &Path) -> anyhow::Result<Document> {
216 let content = std::fs::read_to_string(path)?;
217 Ok(parse_document(path, &content))
218 }
219
220 fn state_for(&self, document: &Document) -> anyhow::Result<RoadmapState> {
221 if let Some(state) = self.store.load()?
222 && state.path == document.path
223 {
224 return Ok(state);
225 }
226 Ok(RoadmapState {
227 document_id: document.id.clone(),
228 path: document.path.clone(),
229 focused_task_id: None,
230 primary_thread_id: None,
231 attached_thread_id: None,
232 threads: Vec::new(),
233 last_validation: None,
234 last_diagnostics: Vec::new(),
235 updated_at: OffsetDateTime::now_utc(),
236 })
237 }
238
239 fn save_state(&self, state: RoadmapState) -> anyhow::Result<()> {
240 self.store.save(&state)
241 }
242
243 fn resolve_roadmap_path(&self, path: &Path) -> anyhow::Result<PathBuf> {
244 let candidate = if path.is_absolute() {
245 path.to_path_buf()
246 } else {
247 self.workspace.join(path)
248 };
249 let roadmap_dir = self.workspace.join("roadmap");
250 if candidate
251 .parent()
252 .map(|parent| parent == roadmap_dir)
253 .unwrap_or(false)
254 && candidate.extension().and_then(|ext| ext.to_str()) == Some("md")
255 {
256 Ok(candidate)
257 } else {
258 anyhow::bail!("roadmap path must be under {}", roadmap_dir.display())
259 }
260 }
261
262 fn emit(
263 &mut self,
264 kind: RoadmapEventKind,
265 path: &Path,
266 task_id: Option<String>,
267 thread_id: Option<String>,
268 ) {
269 self.events.push(RoadmapEvent {
270 kind,
271 path: path.to_path_buf(),
272 task_id,
273 thread_id,
274 timestamp: OffsetDateTime::now_utc(),
275 });
276 }
277}
278
279fn ensure_task(document: &Document, task_id: &str) -> anyhow::Result<()> {
280 if document.tasks.iter().any(|task| task.id == task_id) {
281 Ok(())
282 } else {
283 anyhow::bail!("task not found: {task_id}")
284 }
285}
286
287fn attachment_path(store: &RoadmapStateStore) -> anyhow::Result<PathBuf> {
288 store
289 .load()?
290 .map(|state| state.path)
291 .ok_or_else(|| anyhow::anyhow!("roadmap state missing"))
292}