Skip to main content

task_mcp/
template.rs

1use std::fmt;
2use std::str::FromStr;
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7// =============================================================================
8// Project type
9// =============================================================================
10
11/// Supported project types for justfile template generation.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
13#[serde(rename_all = "kebab-case")]
14pub enum ProjectType {
15    #[default]
16    Rust,
17    ViteReact,
18}
19
20impl FromStr for ProjectType {
21    type Err = String;
22
23    fn from_str(s: &str) -> Result<Self, Self::Err> {
24        match s.trim().to_lowercase().replace('_', "-").as_str() {
25            "rust" => Ok(Self::Rust),
26            "vite-react" | "vite" | "react" => Ok(Self::ViteReact),
27            other => Err(format!(
28                "unknown project type: {other:?}. Valid: rust, vite-react"
29            )),
30        }
31    }
32}
33
34impl fmt::Display for ProjectType {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            Self::Rust => write!(f, "rust"),
38            Self::ViteReact => write!(f, "vite-react"),
39        }
40    }
41}
42
43// =============================================================================
44// Built-in templates
45// =============================================================================
46
47const RUST_TEMPLATE: &str = r#"# task-mcp justfile (rust)
48
49# Pre-commit quality check (fmt → clippy → test)
50[group('allow-agent')]
51check:
52    cargo fmt
53    cargo clippy -- -D warnings
54    cargo test
55
56# Build only
57[group('allow-agent')]
58build:
59    cargo build
60
61# Run tests only
62[group('allow-agent')]
63test:
64    cargo test
65
66# Format and lint
67[group('allow-agent')]
68lint:
69    cargo fmt
70    cargo clippy -- -D warnings
71"#;
72
73const VITE_REACT_TEMPLATE: &str = r#"# task-mcp justfile (vite-react)
74
75# Pre-commit quality check (lint → typecheck → test)
76[group('allow-agent')]
77check:
78    npm run lint
79    npm run typecheck
80    npm run test -- --run
81
82# Build only
83[group('allow-agent')]
84build:
85    npm run build
86
87# Run tests only
88[group('allow-agent')]
89test:
90    npm run test -- --run
91
92# Lint and format
93[group('allow-agent')]
94lint:
95    npm run lint
96    npm run format
97"#;
98
99// =============================================================================
100// Public API
101// =============================================================================
102
103/// Resolve the justfile template content.
104///
105/// Priority: `template_file` argument > `env_template_file` (from Config) > built-in for `project_type`.
106pub async fn resolve_template(
107    project_type: ProjectType,
108    template_file: Option<&str>,
109    env_template_file: Option<&str>,
110) -> Result<String, TemplateError> {
111    // 1. Explicit template_file argument
112    if let Some(path) = template_file {
113        return read_template_file(path).await;
114    }
115    // 2. ENV-based template file
116    if let Some(path) = env_template_file {
117        return read_template_file(path).await;
118    }
119    // 3. Built-in
120    Ok(builtin_template(project_type).to_string())
121}
122
123/// Return the built-in template for a given project type.
124pub fn builtin_template(project_type: ProjectType) -> &'static str {
125    match project_type {
126        ProjectType::Rust => RUST_TEMPLATE,
127        ProjectType::ViteReact => VITE_REACT_TEMPLATE,
128    }
129}
130
131async fn read_template_file(path: &str) -> Result<String, TemplateError> {
132    tokio::fs::read_to_string(path).await.map_err(|e| {
133        if e.kind() == std::io::ErrorKind::NotFound {
134            TemplateError::FileNotFound(path.to_string())
135        } else {
136            TemplateError::Io(path.to_string(), e.to_string())
137        }
138    })
139}
140
141// =============================================================================
142// Error
143// =============================================================================
144
145#[derive(Debug, thiserror::Error)]
146pub enum TemplateError {
147    #[error("template file not found: {0}")]
148    FileNotFound(String),
149    #[error("failed to read template file {0}: {1}")]
150    Io(String, String),
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn project_type_from_str() {
159        assert_eq!("rust".parse::<ProjectType>().unwrap(), ProjectType::Rust);
160        assert_eq!("RUST".parse::<ProjectType>().unwrap(), ProjectType::Rust);
161        assert_eq!(
162            "vite-react".parse::<ProjectType>().unwrap(),
163            ProjectType::ViteReact
164        );
165        assert_eq!(
166            "vite".parse::<ProjectType>().unwrap(),
167            ProjectType::ViteReact
168        );
169        assert_eq!(
170            "react".parse::<ProjectType>().unwrap(),
171            ProjectType::ViteReact
172        );
173        assert!("unknown".parse::<ProjectType>().is_err());
174    }
175
176    #[test]
177    fn project_type_display() {
178        assert_eq!(ProjectType::Rust.to_string(), "rust");
179        assert_eq!(ProjectType::ViteReact.to_string(), "vite-react");
180    }
181
182    #[test]
183    fn builtin_template_rust_contains_cargo() {
184        let t = builtin_template(ProjectType::Rust);
185        assert!(t.contains("cargo"));
186        assert!(t.contains("[group('allow-agent')]"));
187    }
188
189    #[test]
190    fn builtin_template_vite_react_contains_npm() {
191        let t = builtin_template(ProjectType::ViteReact);
192        assert!(t.contains("npm"));
193        assert!(t.contains("[group('allow-agent')]"));
194    }
195
196    #[tokio::test]
197    async fn resolve_template_builtin_fallback() {
198        let result = resolve_template(ProjectType::Rust, None, None).await;
199        assert!(result.is_ok());
200        assert!(result.unwrap().contains("cargo"));
201    }
202
203    #[tokio::test]
204    async fn resolve_template_file_not_found() {
205        let result =
206            resolve_template(ProjectType::Rust, Some("/nonexistent/template.just"), None).await;
207        assert!(result.is_err());
208    }
209}