roder-roadmap 0.1.0

Agentic software development tools and SDKs for Roder.
Documentation
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::{
    Diagnostic, Document, DocumentSummary, ListOptions, RoadmapStateStore, Task, ThreadAttachment,
    list_documents, parse_document, validate_document,
};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoadmapControlSnapshot {
    pub documents: Vec<DocumentSummary>,
    pub selected: Option<RoadmapDocumentControl>,
    pub total_checked_tasks: usize,
    pub total_unchecked_tasks: usize,
    pub next_action: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoadmapDocumentControl {
    pub path: PathBuf,
    pub title: String,
    pub goal: String,
    pub checked_tasks: usize,
    pub unchecked_tasks: usize,
    pub focused_task_id: Option<String>,
    pub selected_thread_id: Option<String>,
    pub tasks: Vec<RoadmapTaskControl>,
    pub threads: Vec<ThreadAttachment>,
    pub diagnostics: Vec<Diagnostic>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoadmapTaskControl {
    pub id: String,
    pub heading: String,
    pub checked: bool,
    pub line: usize,
    pub level: usize,
    pub status: RoadmapTaskStatus,
    pub paths: Vec<String>,
    pub run_blocks: Vec<String>,
    pub threads: Vec<ThreadAttachment>,
    pub recommended_action: String,
    pub dispatch_prompt: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RoadmapTaskStatus {
    Done,
    Ready,
    Assigned,
    Pending,
}

pub fn build_control_snapshot(
    workspace: impl AsRef<Path>,
    data_dir: impl AsRef<Path>,
    selected_plan: Option<&str>,
) -> anyhow::Result<RoadmapControlSnapshot> {
    let workspace = workspace.as_ref();
    let documents = list_documents(workspace, ListOptions::default())?;
    let total_checked_tasks = documents
        .iter()
        .map(|document| document.checked_tasks)
        .sum();
    let total_unchecked_tasks = documents
        .iter()
        .map(|document| document.unchecked_tasks)
        .sum();
    let selected_path = selected_plan
        .map(|plan| resolve_plan_path(workspace, plan))
        .transpose()?
        .or_else(|| documents.first().map(|document| document.path.clone()));
    let selected = selected_path
        .as_deref()
        .map(|path| build_document_control(workspace, data_dir.as_ref(), path))
        .transpose()?;
    let next_action = next_action(selected.as_ref(), total_unchecked_tasks);

    Ok(RoadmapControlSnapshot {
        documents,
        selected,
        total_checked_tasks,
        total_unchecked_tasks,
        next_action,
    })
}

pub fn dispatch_prompt(document: &Document, task: &Task) -> String {
    dispatch_prompt_with_path(document, task, &document.path.display().to_string())
}

fn dispatch_prompt_with_path(document: &Document, task: &Task, path_label: &str) -> String {
    let mut prompt = format!(
        "You are a Roder roadmap worker.\n\
         Execute the focused roadmap task and keep the roadmap Markdown file as source of truth.\n\n\
         Roadmap: {}\n\
         Title: {}\n\
         Goal: {}\n\n\
         Task: {}\n\
         Task ID: {}\n",
        path_label, document.title, document.goal, task.heading, task.id
    );

    if !task.paths.is_empty() {
        prompt.push_str("\nOwned or task-local paths:\n");
        for path in &task.paths {
            prompt.push_str(&format!("- {path}\n"));
        }
    }
    if !task.run_blocks.is_empty() {
        prompt.push_str("\nRun commands:\n");
        for run in &task.run_blocks {
            prompt.push_str("```sh\n");
            prompt.push_str(run);
            prompt.push_str("\n```\n");
        }
    }
    prompt.push_str(
        "\nCompletion rule: only mark the task done after the stated acceptance criteria and run commands are satisfied, then record evidence.\n\
         Report back: end with a concise summary of what changed, run command outcomes, and acceptance evidence so the orchestrator can verify without redoing the work.",
    );
    prompt
}

fn build_document_control(
    workspace: &Path,
    data_dir: &Path,
    path: &Path,
) -> anyhow::Result<RoadmapDocumentControl> {
    let content = fs::read_to_string(path)?;
    let document = parse_document(path, &content);
    let state = RoadmapStateStore::new(data_dir)
        .load()?
        .filter(|state| state.path == path);
    let threads = state
        .as_ref()
        .map(|state| state.threads.clone())
        .unwrap_or_default();
    let focused_task_id = state
        .as_ref()
        .and_then(|state| state.focused_task_id.clone())
        .or_else(|| {
            document
                .tasks
                .iter()
                .find(|task| !task.checked)
                .or_else(|| document.tasks.first())
                .map(|task| task.id.clone())
        });
    let selected_thread_id = state
        .as_ref()
        .and_then(|state| state.attached_thread_id.clone());
    let checked_tasks = document.tasks.iter().filter(|task| task.checked).count();
    let unchecked_tasks = document.tasks.len().saturating_sub(checked_tasks);
    let diagnostics = validate_document(&document).diagnostics;
    let threads_by_task = threads_by_task(&threads);
    let tasks = document
        .tasks
        .iter()
        .map(|task| {
            let task_threads = threads_by_task.get(&task.id).cloned().unwrap_or_default();
            let status = task_status(task, &task_threads, focused_task_id.as_deref());
            RoadmapTaskControl {
                id: task.id.clone(),
                heading: task.heading.clone(),
                checked: task.checked,
                line: task.line,
                level: task.level,
                status,
                paths: task.paths.clone(),
                run_blocks: task.run_blocks.clone(),
                threads: task_threads,
                recommended_action: recommended_task_action(status),
                dispatch_prompt: dispatch_prompt_with_path(
                    &document,
                    task,
                    &rel(workspace, &document.path),
                ),
            }
        })
        .collect();

    Ok(RoadmapDocumentControl {
        path: rel(workspace, &document.path).into(),
        title: document.title,
        goal: document.goal,
        checked_tasks,
        unchecked_tasks,
        focused_task_id,
        selected_thread_id,
        tasks,
        threads,
        diagnostics,
    })
}

fn threads_by_task(threads: &[ThreadAttachment]) -> HashMap<String, Vec<ThreadAttachment>> {
    let mut by_task: HashMap<String, Vec<ThreadAttachment>> = HashMap::new();
    for thread in threads {
        if let Some(task_id) = thread.task_id.as_ref() {
            by_task
                .entry(task_id.clone())
                .or_default()
                .push(thread.clone());
        }
    }
    by_task
}

fn task_status(
    task: &Task,
    threads: &[ThreadAttachment],
    focused_task_id: Option<&str>,
) -> RoadmapTaskStatus {
    if task.checked {
        RoadmapTaskStatus::Done
    } else if !threads.is_empty() {
        RoadmapTaskStatus::Assigned
    } else if focused_task_id == Some(task.id.as_str()) {
        RoadmapTaskStatus::Ready
    } else {
        RoadmapTaskStatus::Pending
    }
}

fn recommended_task_action(status: RoadmapTaskStatus) -> String {
    match status {
        RoadmapTaskStatus::Done => "review evidence or reopen if acceptance changed",
        RoadmapTaskStatus::Ready => "dispatch a worker or continue the focused thread",
        RoadmapTaskStatus::Assigned => "inspect attached worker progress and steer if blocked",
        RoadmapTaskStatus::Pending => "wait for earlier tasks or focus this task explicitly",
    }
    .to_string()
}

fn next_action(selected: Option<&RoadmapDocumentControl>, total_unchecked_tasks: usize) -> String {
    let Some(selected) = selected else {
        return "create or select a roadmap document".to_string();
    };
    if !selected.diagnostics.is_empty() {
        return "fix roadmap validation diagnostics before dispatching workers".to_string();
    }
    if let Some(task) = selected
        .tasks
        .iter()
        .find(|task| task.status == RoadmapTaskStatus::Ready)
    {
        return format!("dispatch or continue {}", task.id);
    }
    if total_unchecked_tasks == 0 {
        "all roadmap tasks are complete".to_string()
    } else {
        "select the next unchecked roadmap task".to_string()
    }
}

fn resolve_plan_path(workspace: &Path, path: &str) -> anyhow::Result<PathBuf> {
    let path = if path.starts_with("roadmap/") {
        workspace.join(path)
    } else if path.ends_with(".md") {
        workspace.join("roadmap").join(path)
    } else {
        workspace.join("roadmap").join(format!("{path}.md"))
    };
    if path.parent() == Some(&workspace.join("roadmap"))
        && path.extension().and_then(|ext| ext.to_str()) == Some("md")
    {
        Ok(path)
    } else {
        anyhow::bail!("plan must resolve under roadmap/*.md")
    }
}

fn rel(workspace: &Path, path: &Path) -> String {
    path.strip_prefix(workspace)
        .unwrap_or(path)
        .display()
        .to_string()
        .replace('\\', "/")
}