1use std::fmt;
2use std::str::FromStr;
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7#[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
43const RUST_TEMPLATE: &str = r#"# task-mcp justfile (rust)
48
49# [allow-agent]
50# Pre-commit quality check (fmt → clippy → test)
51check:
52 cargo fmt
53 cargo clippy -- -D warnings
54 cargo test
55
56# [allow-agent]
57# Build only
58build:
59 cargo build
60
61# [allow-agent]
62# Run tests only
63test:
64 cargo test
65
66# [allow-agent]
67# Format and lint
68lint:
69 cargo fmt
70 cargo clippy -- -D warnings
71"#;
72
73const VITE_REACT_TEMPLATE: &str = r#"# task-mcp justfile (vite-react)
74
75# [allow-agent]
76# Pre-commit quality check (lint → typecheck → test)
77check:
78 npm run lint
79 npm run typecheck
80 npm run test -- --run
81
82# [allow-agent]
83# Build only
84build:
85 npm run build
86
87# [allow-agent]
88# Run tests only
89test:
90 npm run test -- --run
91
92# [allow-agent]
93# Lint and format
94lint:
95 npm run lint
96 npm run format
97"#;
98
99pub async fn resolve_template(
107 project_type: ProjectType,
108 template_file: Option<&str>,
109 env_template_file: Option<&str>,
110) -> Result<String, TemplateError> {
111 if let Some(path) = template_file {
113 return read_template_file(path).await;
114 }
115 if let Some(path) = env_template_file {
117 return read_template_file(path).await;
118 }
119 Ok(builtin_template(project_type).to_string())
121}
122
123pub 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#[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("[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("[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}