use std::fmt;
use std::str::FromStr;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[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"),
}
}
}
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
"#;
pub async fn resolve_template(
project_type: ProjectType,
template_file: Option<&str>,
env_template_file: Option<&str>,
) -> Result<String, TemplateError> {
if let Some(path) = template_file {
return read_template_file(path).await;
}
if let Some(path) = env_template_file {
return read_template_file(path).await;
}
Ok(builtin_template(project_type).to_string())
}
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())
}
})
}
#[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());
}
}