Skip to main content

roder_roadmap/
runtime.rs

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}