Skip to main content

roder_roadmap/
tools.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use roder_api::extension::ToolProviderId;
6use roder_api::tools::{
7    ToolCall, ToolContributor, ToolExecutionContext, ToolExecutor, ToolRegistry, ToolResult,
8    ToolSpec,
9};
10use serde::Deserialize;
11use serde_json::json;
12
13use crate::{
14    Document, ListOptions, RoadmapRuntime, Task, list_documents, parse_document, validate_document,
15};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum RoadmapToolActivation {
19    Inactive,
20    RoadmappingMode,
21    ExplicitRequest,
22}
23
24impl RoadmapToolActivation {
25    fn enabled(self) -> bool {
26        matches!(
27            self,
28            RoadmapToolActivation::RoadmappingMode | RoadmapToolActivation::ExplicitRequest
29        )
30    }
31}
32
33#[derive(Debug, Clone)]
34pub struct RoadmapToolContributor {
35    workspace: PathBuf,
36    data_dir: PathBuf,
37    activation: RoadmapToolActivation,
38}
39
40impl RoadmapToolContributor {
41    pub fn new(
42        workspace: impl Into<PathBuf>,
43        data_dir: impl Into<PathBuf>,
44        activation: RoadmapToolActivation,
45    ) -> Self {
46        Self {
47            workspace: workspace.into(),
48            data_dir: data_dir.into(),
49            activation,
50        }
51    }
52}
53
54impl ToolContributor for RoadmapToolContributor {
55    fn id(&self) -> ToolProviderId {
56        "roadmap-tools".to_string()
57    }
58
59    fn contribute(&self, registry: &mut ToolRegistry) -> anyhow::Result<()> {
60        if !self.activation.enabled() {
61            return Ok(());
62        }
63        let config = RoadmapToolConfig {
64            workspace: self.workspace.clone(),
65            data_dir: self.data_dir.clone(),
66        };
67        registry.register(Arc::new(RoadmapListTool(config.clone())))?;
68        registry.register(Arc::new(RoadmapReadTool(config.clone())))?;
69        registry.register(Arc::new(RoadmapCreateTool(config.clone())))?;
70        registry.register(Arc::new(RoadmapPatchTool(config.clone())))?;
71        registry.register(Arc::new(RoadmapSetTaskStateTool(config.clone())))?;
72        registry.register(Arc::new(RoadmapValidateTool(config.clone())))?;
73        registry.register(Arc::new(RoadmapThreadListTool(config.clone())))?;
74        registry.register(Arc::new(RoadmapThreadSpawnTool(config.clone())))?;
75        registry.register(Arc::new(RoadmapThreadAttachTool(config)))?;
76        Ok(())
77    }
78}
79
80#[derive(Debug, Clone)]
81struct RoadmapToolConfig {
82    workspace: PathBuf,
83    data_dir: PathBuf,
84}
85
86struct RoadmapListTool(RoadmapToolConfig);
87struct RoadmapReadTool(RoadmapToolConfig);
88struct RoadmapCreateTool(RoadmapToolConfig);
89struct RoadmapPatchTool(RoadmapToolConfig);
90struct RoadmapSetTaskStateTool(RoadmapToolConfig);
91struct RoadmapValidateTool(RoadmapToolConfig);
92struct RoadmapThreadListTool(RoadmapToolConfig);
93struct RoadmapThreadSpawnTool(RoadmapToolConfig);
94struct RoadmapThreadAttachTool(RoadmapToolConfig);
95
96#[async_trait::async_trait]
97impl ToolExecutor for RoadmapListTool {
98    fn spec(&self) -> ToolSpec {
99        spec(
100            "roadmap_list",
101            "List roadmap Markdown documents in the workspace roadmap directory.",
102            json!({
103                "type": "object",
104                "properties": {
105                    "include_index": { "type": "boolean", "default": false }
106                },
107                "additionalProperties": false
108            }),
109        )
110    }
111
112    async fn execute(
113        &self,
114        ctx: ToolExecutionContext,
115        call: ToolCall,
116    ) -> anyhow::Result<ToolResult> {
117        require_workspace(&ctx)?;
118        let args = parse::<RoadmapListArgs>(&call)?;
119        let documents = list_documents(
120            &self.0.workspace,
121            ListOptions {
122                include_index: args.include_index.unwrap_or(false),
123            },
124        )?;
125        Ok(result(
126            call,
127            format!("found {} roadmap documents", documents.len()),
128            json!({ "documents": documents }),
129            false,
130        ))
131    }
132}
133
134#[async_trait::async_trait]
135impl ToolExecutor for RoadmapReadTool {
136    fn spec(&self) -> ToolSpec {
137        spec(
138            "roadmap_read",
139            "Read and parse one roadmap Markdown document.",
140            json!({
141                "type": "object",
142                "properties": { "path": { "type": "string" } },
143                "required": ["path"],
144                "additionalProperties": false
145            }),
146        )
147    }
148
149    async fn execute(
150        &self,
151        ctx: ToolExecutionContext,
152        call: ToolCall,
153    ) -> anyhow::Result<ToolResult> {
154        require_workspace(&ctx)?;
155        let args = parse::<RoadmapPathArgs>(&call)?;
156        let path = resolve_roadmap_path(&self.0.workspace, &args.path)?;
157        let document = read_document(&path)?;
158        Ok(document_result(call, "read roadmap", &document, json!({})))
159    }
160}
161
162#[async_trait::async_trait]
163impl ToolExecutor for RoadmapCreateTool {
164    fn spec(&self) -> ToolSpec {
165        spec(
166            "roadmap_create",
167            "Create a new roadmap Markdown document using the next phase number.",
168            json!({
169                "type": "object",
170                "properties": {
171                    "slug": { "type": "string" },
172                    "title": { "type": "string" },
173                    "goal": { "type": "string" }
174                },
175                "required": ["slug", "title"],
176                "additionalProperties": false
177            }),
178        )
179    }
180
181    async fn execute(
182        &self,
183        ctx: ToolExecutionContext,
184        call: ToolCall,
185    ) -> anyhow::Result<ToolResult> {
186        require_workspace(&ctx)?;
187        let args = parse::<RoadmapCreateArgs>(&call)?;
188        let slug = sanitize_slug(&args.slug)?;
189        let roadmap_dir = self.0.workspace.join("roadmap");
190        fs::create_dir_all(&roadmap_dir)?;
191        let phase = next_phase_number(&roadmap_dir)?;
192        let path = roadmap_dir.join(format!("{phase:02}-{slug}.md"));
193        if path.exists() {
194            anyhow::bail!("roadmap already exists: {}", path.display());
195        }
196        let title = args.title.trim();
197        if title.is_empty() {
198            anyhow::bail!("title must not be empty");
199        }
200        let goal = args
201            .goal
202            .as_deref()
203            .filter(|goal| !goal.trim().is_empty())
204            .unwrap_or("Describe the intended outcome.");
205        let content = format!(
206            "# {title} Implementation Plan\n\n**Goal:** {goal}\n**Architecture:** Document the architecture before implementation.\n**Tech Stack:** Rust.\n\n## Owned Paths\n\n- Create: `roadmap/{phase:02}-{slug}.md`\n\n## Tasks\n\n- [ ] Draft the implementation plan\n\nRun:\n\n```sh\ncargo test -p roder-roadmap\n```\n\nAcceptance:\n- The roadmap is actionable and validated.\n\n## Phase Acceptance\n\n- [ ] Plan is complete.\n"
207        );
208        fs::write(&path, content)?;
209        let document = read_document(&path)?;
210        Ok(document_result(
211            call,
212            "created roadmap",
213            &document,
214            json!({ "changed_path": document.path }),
215        ))
216    }
217}
218
219#[async_trait::async_trait]
220impl ToolExecutor for RoadmapPatchTool {
221    fn spec(&self) -> ToolSpec {
222        spec(
223            "roadmap_patch",
224            "Replace exact text in a roadmap document or the repo-local roadmap-planning skill.",
225            json!({
226                "type": "object",
227                "properties": {
228                    "path": { "type": "string" },
229                    "old_string": { "type": "string" },
230                    "new_string": { "type": "string" }
231                },
232                "required": ["path", "old_string", "new_string"],
233                "additionalProperties": false
234            }),
235        )
236    }
237
238    async fn execute(
239        &self,
240        ctx: ToolExecutionContext,
241        call: ToolCall,
242    ) -> anyhow::Result<ToolResult> {
243        require_workspace(&ctx)?;
244        let args = parse::<RoadmapPatchArgs>(&call)?;
245        if args.old_string.is_empty() {
246            anyhow::bail!("old_string must not be empty");
247        }
248        let path = resolve_allowed_write_path(&self.0.workspace, &args.path)?;
249        let content = fs::read_to_string(&path)?;
250        let replacements = content.matches(&args.old_string).count();
251        if replacements == 0 {
252            return Ok(result(
253                call,
254                "old_string does not match file".to_string(),
255                json!({ "changed_path": path, "replacements": 0 }),
256                true,
257            ));
258        }
259        fs::write(&path, content.replace(&args.old_string, &args.new_string))?;
260        let document = if is_roadmap_file(&self.0.workspace, &path) {
261            Some(read_document(&path)?)
262        } else {
263            None
264        };
265        Ok(tool_result_for_optional_document(
266            call,
267            "patched roadmap text",
268            document.as_ref(),
269            json!({ "changed_path": path, "replacements": replacements }),
270            false,
271        ))
272    }
273}
274
275#[async_trait::async_trait]
276impl ToolExecutor for RoadmapSetTaskStateTool {
277    fn spec(&self) -> ToolSpec {
278        spec(
279            "roadmap_set_task_state",
280            "Mark a roadmap task done or open. Marking done requires non-empty evidence.",
281            json!({
282                "type": "object",
283                "properties": {
284                    "path": { "type": "string" },
285                    "task_id": { "type": "string" },
286                    "checked": { "type": "boolean" },
287                    "evidence": { "type": "string" }
288                },
289                "required": ["path", "task_id", "checked"],
290                "additionalProperties": false
291            }),
292        )
293    }
294
295    async fn execute(
296        &self,
297        ctx: ToolExecutionContext,
298        call: ToolCall,
299    ) -> anyhow::Result<ToolResult> {
300        require_workspace(&ctx)?;
301        let args = parse::<RoadmapSetTaskStateArgs>(&call)?;
302        let evidence = args.evidence.unwrap_or_default();
303        if args.checked && evidence.trim().is_empty() {
304            anyhow::bail!("evidence is required when marking a roadmap task done");
305        }
306        let mut runtime = runtime(&self.0);
307        runtime.set_roadmap_task(&args.path, &args.task_id, args.checked, &evidence)?;
308        let path = resolve_roadmap_path(&self.0.workspace, &args.path)?;
309        let document = read_document(&path)?;
310        Ok(document_result(
311            call,
312            "updated roadmap task state",
313            &document,
314            json!({ "changed_path": document.path, "task_id": args.task_id }),
315        ))
316    }
317}
318
319#[async_trait::async_trait]
320impl ToolExecutor for RoadmapValidateTool {
321    fn spec(&self) -> ToolSpec {
322        spec(
323            "roadmap_validate",
324            "Validate one roadmap document and return diagnostics.",
325            json!({
326                "type": "object",
327                "properties": { "path": { "type": "string" } },
328                "required": ["path"],
329                "additionalProperties": false
330            }),
331        )
332    }
333
334    async fn execute(
335        &self,
336        ctx: ToolExecutionContext,
337        call: ToolCall,
338    ) -> anyhow::Result<ToolResult> {
339        require_workspace(&ctx)?;
340        let args = parse::<RoadmapPathArgs>(&call)?;
341        let path = resolve_roadmap_path(&self.0.workspace, &args.path)?;
342        let document = read_document(&path)?;
343        let validation = validate_document(&document);
344        Ok(result(
345            call,
346            format!("validation diagnostics: {}", validation.diagnostics.len()),
347            json!({
348                "path": document.path,
349                "document_id": document.id,
350                "diagnostics": validation.diagnostics,
351                "next_unchecked_task": next_unchecked_task(&document),
352            }),
353            false,
354        ))
355    }
356}
357
358#[async_trait::async_trait]
359impl ToolExecutor for RoadmapThreadListTool {
360    fn spec(&self) -> ToolSpec {
361        spec(
362            "roadmap_thread_list",
363            "List thread attachments for a roadmap document.",
364            json!({
365                "type": "object",
366                "properties": { "path": { "type": "string" } },
367                "required": ["path"],
368                "additionalProperties": false
369            }),
370        )
371    }
372
373    async fn execute(
374        &self,
375        ctx: ToolExecutionContext,
376        call: ToolCall,
377    ) -> anyhow::Result<ToolResult> {
378        require_workspace(&ctx)?;
379        let args = parse::<RoadmapPathArgs>(&call)?;
380        let runtime = runtime(&self.0);
381        let threads = runtime.list_roadmap_threads(&args.path)?;
382        Ok(result(
383            call,
384            format!("found {} roadmap thread attachments", threads.len()),
385            json!({ "threads": threads }),
386            false,
387        ))
388    }
389}
390
391#[async_trait::async_trait]
392impl ToolExecutor for RoadmapThreadSpawnTool {
393    fn spec(&self) -> ToolSpec {
394        spec(
395            "roadmap_thread_spawn",
396            "Create a new thread attachment for a roadmap task without mutating transcript history.",
397            json!({
398                "type": "object",
399                "properties": {
400                    "path": { "type": "string" },
401                    "task_id": { "type": "string" }
402                },
403                "required": ["path", "task_id"],
404                "additionalProperties": false
405            }),
406        )
407    }
408
409    async fn execute(
410        &self,
411        ctx: ToolExecutionContext,
412        call: ToolCall,
413    ) -> anyhow::Result<ToolResult> {
414        require_workspace(&ctx)?;
415        let args = parse::<RoadmapThreadTaskArgs>(&call)?;
416        let mut runtime = runtime(&self.0);
417        let attachment = runtime.spawn_roadmap_thread(&args.path, &args.task_id)?;
418        Ok(result(
419            call,
420            "spawned roadmap thread attachment".to_string(),
421            json!({ "thread": attachment, "task_id": args.task_id }),
422            false,
423        ))
424    }
425}
426
427#[async_trait::async_trait]
428impl ToolExecutor for RoadmapThreadAttachTool {
429    fn spec(&self) -> ToolSpec {
430        spec(
431            "roadmap_thread_attach",
432            "Attach an existing thread id to a roadmap task without mutating transcript history.",
433            json!({
434                "type": "object",
435                "properties": {
436                    "path": { "type": "string" },
437                    "task_id": { "type": "string" },
438                    "thread_id": { "type": "string" },
439                    "title": { "type": "string" }
440                },
441                "required": ["path", "task_id", "thread_id"],
442                "additionalProperties": false
443            }),
444        )
445    }
446
447    async fn execute(
448        &self,
449        ctx: ToolExecutionContext,
450        call: ToolCall,
451    ) -> anyhow::Result<ToolResult> {
452        require_workspace(&ctx)?;
453        let args = parse::<RoadmapThreadAttachArgs>(&call)?;
454        let mut runtime = runtime(&self.0);
455        let attachment = runtime.attach_roadmap_thread(
456            &args.path,
457            &args.task_id,
458            &args.thread_id,
459            args.title,
460        )?;
461        Ok(result(
462            call,
463            "attached roadmap thread".to_string(),
464            json!({ "thread": attachment, "task_id": args.task_id }),
465            false,
466        ))
467    }
468}
469
470#[derive(Debug, Deserialize)]
471struct RoadmapListArgs {
472    include_index: Option<bool>,
473}
474
475#[derive(Debug, Deserialize)]
476struct RoadmapPathArgs {
477    path: String,
478}
479
480#[derive(Debug, Deserialize)]
481struct RoadmapCreateArgs {
482    slug: String,
483    title: String,
484    goal: Option<String>,
485}
486
487#[derive(Debug, Deserialize)]
488struct RoadmapPatchArgs {
489    path: String,
490    old_string: String,
491    new_string: String,
492}
493
494#[derive(Debug, Deserialize)]
495struct RoadmapSetTaskStateArgs {
496    path: String,
497    task_id: String,
498    checked: bool,
499    evidence: Option<String>,
500}
501
502#[derive(Debug, Deserialize)]
503struct RoadmapThreadTaskArgs {
504    path: String,
505    task_id: String,
506}
507
508#[derive(Debug, Deserialize)]
509struct RoadmapThreadAttachArgs {
510    path: String,
511    task_id: String,
512    thread_id: String,
513    title: Option<String>,
514}
515
516fn runtime(config: &RoadmapToolConfig) -> RoadmapRuntime {
517    RoadmapRuntime::new(&config.workspace, &config.data_dir)
518}
519
520fn parse<T: for<'de> Deserialize<'de>>(call: &ToolCall) -> anyhow::Result<T> {
521    serde_json::from_value(call.arguments.clone())
522        .map_err(|err| anyhow::anyhow!("invalid {} arguments: {err}", call.name))
523}
524
525fn spec(name: &str, description: &str, parameters: serde_json::Value) -> ToolSpec {
526    ToolSpec {
527        name: name.to_string(),
528        description: description.to_string(),
529        parameters,
530    }
531}
532
533fn result(call: ToolCall, text: String, data: serde_json::Value, is_error: bool) -> ToolResult {
534    ToolResult {
535        id: call.id,
536        name: call.name,
537        text,
538        data,
539        is_error,
540    }
541}
542
543fn document_result(
544    call: ToolCall,
545    text: &str,
546    document: &Document,
547    extra: serde_json::Value,
548) -> ToolResult {
549    tool_result_for_optional_document(call, text, Some(document), extra, false)
550}
551
552fn tool_result_for_optional_document(
553    call: ToolCall,
554    text: &str,
555    document: Option<&Document>,
556    extra: serde_json::Value,
557    is_error: bool,
558) -> ToolResult {
559    let mut data = match extra {
560        serde_json::Value::Object(map) => map,
561        _ => serde_json::Map::new(),
562    };
563    if let Some(document) = document {
564        data.insert("document".to_string(), json!(document));
565        data.insert("path".to_string(), json!(document.path));
566        data.insert("document_id".to_string(), json!(document.id));
567        data.insert(
568            "diagnostics".to_string(),
569            json!(validate_document(document).diagnostics),
570        );
571        data.insert(
572            "next_unchecked_task".to_string(),
573            json!(next_unchecked_task(document)),
574        );
575    }
576    result(
577        call,
578        text.to_string(),
579        serde_json::Value::Object(data),
580        is_error,
581    )
582}
583
584fn read_document(path: &Path) -> anyhow::Result<Document> {
585    let content = fs::read_to_string(path)?;
586    Ok(parse_document(path, &content))
587}
588
589fn resolve_roadmap_path(workspace: &Path, path: &str) -> anyhow::Result<PathBuf> {
590    let workspace = workspace.canonicalize()?;
591    let candidate = if Path::new(path).is_absolute() {
592        PathBuf::from(path)
593    } else {
594        workspace.join(path)
595    };
596    let candidate = candidate.canonicalize()?;
597    if is_roadmap_file(&workspace, &candidate) {
598        Ok(candidate)
599    } else {
600        anyhow::bail!(
601            "roadmap path must be under {}",
602            workspace.join("roadmap").display()
603        )
604    }
605}
606
607fn resolve_allowed_write_path(workspace: &Path, path: &str) -> anyhow::Result<PathBuf> {
608    let workspace = workspace.canonicalize()?;
609    let candidate = if Path::new(path).is_absolute() {
610        PathBuf::from(path)
611    } else {
612        workspace.join(path)
613    };
614    let candidate = candidate.canonicalize()?;
615    if is_roadmap_file(&workspace, &candidate)
616        || candidate.starts_with(workspace.join(".agents/skills/roadmap-planning"))
617    {
618        Ok(candidate)
619    } else {
620        anyhow::bail!(
621            "roadmap write tools are limited to roadmap/*.md and .agents/skills/roadmap-planning"
622        )
623    }
624}
625
626fn is_roadmap_file(workspace: &Path, path: &Path) -> bool {
627    path.parent() == Some(&workspace.join("roadmap"))
628        && path.extension().and_then(|ext| ext.to_str()) == Some("md")
629}
630
631fn require_workspace(ctx: &ToolExecutionContext) -> anyhow::Result<()> {
632    ctx.require_workspace().map(|_| ())
633}
634
635fn sanitize_slug(slug: &str) -> anyhow::Result<String> {
636    let slug = slug.trim();
637    if slug.is_empty() {
638        anyhow::bail!("slug must not be empty");
639    }
640    if !slug
641        .chars()
642        .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-')
643    {
644        anyhow::bail!("slug must contain only lowercase letters, digits, and hyphens");
645    }
646    Ok(slug.to_string())
647}
648
649fn next_phase_number(roadmap_dir: &Path) -> anyhow::Result<u32> {
650    let mut max_phase = 0;
651    for entry in fs::read_dir(roadmap_dir)? {
652        let entry = entry?;
653        let Some(name) = entry.file_name().to_str().map(str::to_string) else {
654            continue;
655        };
656        let Some(prefix) = name.split_once('-').map(|(prefix, _)| prefix) else {
657            continue;
658        };
659        if let Ok(phase) = prefix.parse::<u32>() {
660            max_phase = max_phase.max(phase);
661        }
662    }
663    Ok(max_phase + 1)
664}
665
666fn next_unchecked_task(document: &Document) -> Option<&Task> {
667    document.tasks.iter().find(|task| !task.checked)
668}