task-mcp 0.5.0

MCP server for task runner integration — Agent-safe harness for defined tasks
Documentation
use std::fmt;
use std::str::FromStr;

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

// =============================================================================
// Project type
// =============================================================================

/// Supported project types for justfile template generation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ProjectType {
    #[default]
    Rust,
    ViteReact,
}

impl FromStr for ProjectType {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.trim().to_lowercase().replace('_', "-").as_str() {
            "rust" => Ok(Self::Rust),
            "vite-react" | "vite" | "react" => Ok(Self::ViteReact),
            other => Err(format!(
                "unknown project type: {other:?}. Valid: rust, vite-react"
            )),
        }
    }
}

impl fmt::Display for ProjectType {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Rust => write!(f, "rust"),
            Self::ViteReact => write!(f, "vite-react"),
        }
    }
}

// =============================================================================
// Built-in templates
// =============================================================================

const RUST_TEMPLATE: &str = r#"# task-mcp justfile (rust)

# Pre-commit quality check (fmt → clippy → test)
[group('allow-agent')]
check:
    cargo fmt
    cargo clippy -- -D warnings
    cargo test

# Build only
[group('allow-agent')]
build:
    cargo build

# Run tests only
[group('allow-agent')]
test:
    cargo test

# Format and lint
[group('allow-agent')]
lint:
    cargo fmt
    cargo clippy -- -D warnings
"#;

const VITE_REACT_TEMPLATE: &str = r#"# task-mcp justfile (vite-react)

# Pre-commit quality check (lint → typecheck → test)
[group('allow-agent')]
check:
    npm run lint
    npm run typecheck
    npm run test -- --run

# Build only
[group('allow-agent')]
build:
    npm run build

# Run tests only
[group('allow-agent')]
test:
    npm run test -- --run

# Lint and format
[group('allow-agent')]
lint:
    npm run lint
    npm run format
"#;

// =============================================================================
// Public API
// =============================================================================

/// Resolve the justfile template content.
///
/// Priority: `template_file` argument > `env_template_file` (from Config) > built-in for `project_type`.
pub async fn resolve_template(
    project_type: ProjectType,
    template_file: Option<&str>,
    env_template_file: Option<&str>,
) -> Result<String, TemplateError> {
    // 1. Explicit template_file argument
    if let Some(path) = template_file {
        return read_template_file(path).await;
    }
    // 2. ENV-based template file
    if let Some(path) = env_template_file {
        return read_template_file(path).await;
    }
    // 3. Built-in
    Ok(builtin_template(project_type).to_string())
}

/// Return the built-in template for a given project type.
pub fn builtin_template(project_type: ProjectType) -> &'static str {
    match project_type {
        ProjectType::Rust => RUST_TEMPLATE,
        ProjectType::ViteReact => VITE_REACT_TEMPLATE,
    }
}

async fn read_template_file(path: &str) -> Result<String, TemplateError> {
    tokio::fs::read_to_string(path).await.map_err(|e| {
        if e.kind() == std::io::ErrorKind::NotFound {
            TemplateError::FileNotFound(path.to_string())
        } else {
            TemplateError::Io(path.to_string(), e.to_string())
        }
    })
}

// =============================================================================
// Error
// =============================================================================

#[derive(Debug, thiserror::Error)]
pub enum TemplateError {
    #[error("template file not found: {0}")]
    FileNotFound(String),
    #[error("failed to read template file {0}: {1}")]
    Io(String, String),
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn project_type_from_str() {
        assert_eq!("rust".parse::<ProjectType>().unwrap(), ProjectType::Rust);
        assert_eq!("RUST".parse::<ProjectType>().unwrap(), ProjectType::Rust);
        assert_eq!(
            "vite-react".parse::<ProjectType>().unwrap(),
            ProjectType::ViteReact
        );
        assert_eq!(
            "vite".parse::<ProjectType>().unwrap(),
            ProjectType::ViteReact
        );
        assert_eq!(
            "react".parse::<ProjectType>().unwrap(),
            ProjectType::ViteReact
        );
        assert!("unknown".parse::<ProjectType>().is_err());
    }

    #[test]
    fn project_type_display() {
        assert_eq!(ProjectType::Rust.to_string(), "rust");
        assert_eq!(ProjectType::ViteReact.to_string(), "vite-react");
    }

    #[test]
    fn builtin_template_rust_contains_cargo() {
        let t = builtin_template(ProjectType::Rust);
        assert!(t.contains("cargo"));
        assert!(t.contains("[group('allow-agent')]"));
    }

    #[test]
    fn builtin_template_vite_react_contains_npm() {
        let t = builtin_template(ProjectType::ViteReact);
        assert!(t.contains("npm"));
        assert!(t.contains("[group('allow-agent')]"));
    }

    #[tokio::test]
    async fn resolve_template_builtin_fallback() {
        let result = resolve_template(ProjectType::Rust, None, None).await;
        assert!(result.is_ok());
        assert!(result.unwrap().contains("cargo"));
    }

    #[tokio::test]
    async fn resolve_template_file_not_found() {
        let result =
            resolve_template(ProjectType::Rust, Some("/nonexistent/template.just"), None).await;
        assert!(result.is_err());
    }
}