foundry_mcp/cli/commands/
create_project.rs

1//! Implementation of the create_project command
2
3use crate::cli::args::CreateProjectArgs;
4use crate::core::{project, validation};
5use crate::types::project::ProjectConfig;
6use crate::types::responses::{CreateProjectResponse, FoundryResponse};
7use crate::utils::response::{build_incomplete_response, build_success_response};
8use anyhow::{Context, Result};
9
10pub async fn execute(args: CreateProjectArgs) -> Result<FoundryResponse<CreateProjectResponse>> {
11    // Validate project preconditions
12    validate_project_preconditions(&args.project_name)?;
13
14    // Validate and process content
15    let suggestions = process_content_validation(&args)?;
16
17    // Create the project
18    let project_config = build_project_config(args);
19    let created_project =
20        project::create_project(project_config).context("Failed to create project structure")?;
21
22    // Build and return response
23    Ok(build_response(created_project, suggestions))
24}
25
26/// Validate project preconditions (name format and existence)
27fn validate_project_preconditions(project_name: &str) -> Result<()> {
28    validate_project_name(project_name)?;
29
30    if project::project_exists(project_name)? {
31        return Err(anyhow::anyhow!("Project '{}' already exists", project_name));
32    }
33
34    Ok(())
35}
36
37/// Process content validation and return suggestions
38fn process_content_validation(args: &CreateProjectArgs) -> Result<Vec<String>> {
39    let validation_results = validate_content(args)?;
40
41    let (validation_errors, suggestions): (Vec<String>, Vec<String>) =
42        validation_results.into_iter().fold(
43            (Vec::new(), Vec::new()),
44            |(mut errors, mut suggestions), (content_type, result)| {
45                if !result.is_valid {
46                    errors.extend(
47                        result
48                            .errors
49                            .into_iter()
50                            .map(|e| format!("{}: {}", content_type, e)),
51                    );
52                }
53                suggestions.extend(
54                    result
55                        .suggestions
56                        .into_iter()
57                        .map(|s| format!("{}: {}", content_type, s)),
58                );
59                (errors, suggestions)
60            },
61        );
62
63    // If there are validation errors, return them
64    if !validation_errors.is_empty() {
65        return Err(anyhow::anyhow!(
66            "Content validation failed:\n{}",
67            validation_errors.join("\n")
68        ));
69    }
70
71    Ok(suggestions)
72}
73
74/// Build project configuration from args
75fn build_project_config(args: CreateProjectArgs) -> ProjectConfig {
76    ProjectConfig {
77        name: args.project_name,
78        vision: args.vision,
79        tech_stack: args.tech_stack,
80        summary: args.summary,
81    }
82}
83
84/// Build the final response
85fn build_response(
86    created_project: crate::types::project::Project,
87    suggestions: Vec<String>,
88) -> FoundryResponse<CreateProjectResponse> {
89    let files_created = vec![
90        "vision.md".to_string(),
91        "tech-stack.md".to_string(),
92        "summary.md".to_string(),
93        "specs/".to_string(),
94    ];
95
96    let response_data = CreateProjectResponse {
97        project_name: created_project.name.clone(),
98        created_at: created_project.created_at,
99        project_path: created_project.path.to_string_lossy().to_string(),
100        files_created,
101    };
102
103    let next_steps = vec![
104        format!("Project '{}' created successfully", created_project.name),
105        "Project structure is ready for development".to_string(),
106        "Available next steps: foundry create_spec (if you have a specific feature), foundry load_project (to see context), or foundry list_projects (to explore other projects)".to_string(),
107    ];
108
109    let workflow_hints = if !suggestions.is_empty() {
110        let mut enhanced_suggestions = vec![
111            "📋 DOCUMENT PURPOSE: Your content serves as COMPLETE CONTEXT for future implementation".to_string(),
112            "🎯 CONTEXT TEST: Could someone with no prior knowledge implement this using only your documents?".to_string(),
113        ];
114        enhanced_suggestions.extend(suggestions.clone());
115        enhanced_suggestions
116    } else {
117        vec![
118            "📋 DOCUMENT PURPOSE: Your content serves as COMPLETE CONTEXT for future implementation".to_string(),
119            "🎯 CONTEXT TEST: Could someone with no prior knowledge implement this using only your documents?".to_string(),
120            "Consider what you want to work on next".to_string(),
121            "foundry create_spec: Use when you have a specific feature to implement".to_string(),
122            "foundry load_project: Use to see full project context and available specs".to_string(),
123            "foundry get_foundry_help decision-points: Use to understand tool selection"
124                .to_string(),
125        ]
126    };
127
128    if suggestions.is_empty() {
129        build_success_response(response_data, next_steps, workflow_hints)
130    } else {
131        build_incomplete_response(response_data, next_steps, workflow_hints)
132    }
133}
134
135/// Validate project name format (kebab-case)
136fn validate_project_name(name: &str) -> Result<()> {
137    if name.is_empty() {
138        return Err(anyhow::anyhow!("Project name cannot be empty"));
139    }
140
141    // Check for kebab-case format
142    if !name
143        .chars()
144        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
145    {
146        return Err(anyhow::anyhow!(
147            "Project name must be in kebab-case format (lowercase letters, numbers, and hyphens only)"
148        ));
149    }
150
151    // Can't start or end with hyphen
152    if name.starts_with('-') || name.ends_with('-') {
153        return Err(anyhow::anyhow!(
154            "Project name cannot start or end with a hyphen"
155        ));
156    }
157
158    // Can't have consecutive hyphens
159    if name.contains("--") {
160        return Err(anyhow::anyhow!(
161            "Project name cannot contain consecutive hyphens"
162        ));
163    }
164
165    Ok(())
166}
167
168/// Validate all content according to schema requirements
169fn validate_content(
170    args: &CreateProjectArgs,
171) -> Result<Vec<(&'static str, validation::ValidationResult)>> {
172    let validations = vec![
173        (
174            "Vision",
175            validation::validate_content(validation::ContentType::Vision, &args.vision),
176        ),
177        (
178            "Tech Stack",
179            validation::validate_content(validation::ContentType::TechStack, &args.tech_stack),
180        ),
181        (
182            "Summary",
183            validation::validate_content(validation::ContentType::Summary, &args.summary),
184        ),
185    ];
186
187    Ok(validations)
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    // Mock project args for testing
195    fn create_test_args() -> CreateProjectArgs {
196        CreateProjectArgs {
197            project_name: "test-project".to_string(),
198            vision: "This is a test vision that is long enough to meet the minimum requirements. It should contain at least 200 characters to pass validation. This includes multiple sentences and provides comprehensive coverage of what the project aims to achieve.".to_string(),
199            tech_stack: "This project uses Rust as the primary language with tokio for async runtime, serde for serialization, and clap for command line argument parsing. It follows functional programming principles and modern Rust 2024 practices.".to_string(),
200            summary: "A comprehensive CLI tool for deterministic project management and AI coding assistant integration with modern Rust patterns.".to_string(),
201        }
202    }
203
204    #[test]
205    fn test_validate_project_name_valid() {
206        let valid_names = vec!["my-project", "project123", "my-awesome-project", "test"];
207
208        for name in valid_names {
209            assert!(
210                validate_project_name(name).is_ok(),
211                "Name '{}' should be valid",
212                name
213            );
214        }
215    }
216
217    #[test]
218    fn test_validate_project_name_invalid() {
219        let invalid_names = vec![
220            "",            // empty
221            "-project",    // starts with hyphen
222            "project-",    // ends with hyphen
223            "my--project", // consecutive hyphens
224            "MyProject",   // uppercase
225            "my project",  // space
226            "my.project",  // invalid character
227        ];
228
229        for name in invalid_names {
230            assert!(
231                validate_project_name(name).is_err(),
232                "Name '{}' should be invalid",
233                name
234            );
235        }
236    }
237
238    #[test]
239    fn test_validate_content_structure() {
240        let args = create_test_args();
241        let validations = validate_content(&args).unwrap();
242
243        assert_eq!(validations.len(), 3);
244
245        // Check that all content types are present
246        let content_types: Vec<&str> = validations.iter().map(|(t, _)| *t).collect();
247        assert!(content_types.contains(&"Vision"));
248        assert!(content_types.contains(&"Tech Stack"));
249        assert!(content_types.contains(&"Summary"));
250    }
251}