Skip to main content

foundry_mcp/core/ops/
create_project.rs

1//! Core op for creating a project (tool-agnostic)
2
3use anyhow::{Context, Result};
4
5use crate::core::{foundry, validation};
6use crate::types::project::ProjectConfig;
7use crate::types::responses::{CreateProjectResponse, FoundryResponse};
8use crate::utils::response::{build_incomplete_response, build_success_response};
9
10#[derive(Debug, Clone)]
11pub struct Input {
12    pub project_name: String,
13    pub vision: String,
14    pub tech_stack: String,
15    pub summary: String,
16}
17
18pub async fn run(input: Input) -> Result<FoundryResponse<CreateProjectResponse>> {
19    let foundry = foundry::get_default_foundry()?;
20
21    validate_project_preconditions(&foundry, &input.project_name).await?;
22
23    let suggestions = process_content_validation(&input)?;
24
25    let project_config = build_project_config(input);
26    let created_project = foundry
27        .create_project(project_config)
28        .await
29        .context("Failed to create project structure")?;
30
31    Ok(build_response(created_project, suggestions))
32}
33
34async fn validate_project_preconditions(
35    foundry: &foundry::Foundry<crate::core::backends::filesystem::FilesystemBackend>,
36    project_name: &str,
37) -> Result<()> {
38    validate_project_name(project_name)?;
39
40    if foundry.project_exists(project_name).await? {
41        return Err(anyhow::anyhow!("Project '{}' already exists", project_name));
42    }
43
44    Ok(())
45}
46
47fn process_content_validation(input: &Input) -> Result<Vec<String>> {
48    let validation_results = validate_content(input)?;
49
50    let (validation_errors, suggestions): (Vec<String>, Vec<String>) =
51        validation_results.into_iter().fold(
52            (Vec::new(), Vec::new()),
53            |(mut errors, mut suggestions), (content_type, result)| {
54                if !result.is_valid {
55                    errors.extend(
56                        result
57                            .errors
58                            .into_iter()
59                            .map(|e| format!("{}: {}", content_type, e)),
60                    );
61                }
62                suggestions.extend(
63                    result
64                        .suggestions
65                        .into_iter()
66                        .map(|s| format!("{}: {}", content_type, s)),
67                );
68                (errors, suggestions)
69            },
70        );
71
72    if !validation_errors.is_empty() {
73        return Err(anyhow::anyhow!(
74            "Content validation failed:\n{}",
75            validation_errors.join("\n")
76        ));
77    }
78
79    Ok(suggestions)
80}
81
82fn build_project_config(input: Input) -> ProjectConfig {
83    ProjectConfig {
84        name: input.project_name,
85        vision: input.vision,
86        tech_stack: input.tech_stack,
87        summary: input.summary,
88    }
89}
90
91fn build_response(
92    created_project: crate::types::project::Project,
93    suggestions: Vec<String>,
94) -> FoundryResponse<CreateProjectResponse> {
95    let files_created = vec![
96        "vision.md".to_string(),
97        "tech-stack.md".to_string(),
98        "summary.md".to_string(),
99        "specs/".to_string(),
100    ];
101
102    let response_data = CreateProjectResponse {
103        project_name: created_project.name.clone(),
104        created_at: created_project.created_at,
105        project_path: created_project.path.to_string_lossy().to_string(),
106        files_created,
107    };
108
109    let next_steps = vec![
110        format!("Project '{}' created successfully", created_project.name),
111        "Project structure is ready for development".to_string(),
112        format!(
113            "Next → create a spec: {{\"name\": \"create_spec\", \"arguments\": {{\"project_name\": \"{}\", \"feature_name\": \"<feature>\", \"spec\": \"...\", \"tasks\": \"...\", \"notes\": \"...\"}}}}; load project: {{\"name\": \"load_project\", \"arguments\": {{\"project_name\": \"{}\"}}}}; list projects: {{\"name\": \"list_projects\", \"arguments\": {{}}}}",
114            created_project.name, created_project.name
115        ),
116    ];
117
118    let workflow_hints = if !suggestions.is_empty() {
119        let mut enhanced_suggestions = vec![
120            "📋 DOCUMENT PURPOSE: Your content serves as COMPLETE CONTEXT for future implementation".to_string(),
121            "🎯 CONTEXT TEST: Could someone with no prior knowledge implement this using only your documents?".to_string(),
122        ];
123        enhanced_suggestions.extend(suggestions.clone());
124        enhanced_suggestions
125    } else {
126        vec![
127            "📋 DOCUMENT PURPOSE: Your content serves as COMPLETE CONTEXT for future implementation".to_string(),
128            "🎯 CONTEXT TEST: Could someone with no prior knowledge implement this using only your documents?".to_string(),
129            "Consider what you want to work on next".to_string(),
130            // Guidance preserved from previous implementation
131            // Create spec / Load project / Help
132            // These strings are intentionally identical to avoid behavior drift
133            // during the refactor.
134            //
135            // clippy: allow identical strings — intentional UX
136            format!("Create a spec: {{\"name\": \"create_spec\", \"arguments\": {{\"project_name\": \"{}\", \"feature_name\": \"<feature>\", \"spec\": \"...\", \"tasks\": \"...\", \"notes\": \"...\"}}}}", created_project.name),
137            format!("Load project: {{\"name\": \"load_project\", \"arguments\": {{\"project_name\": \"{}\"}}}}", created_project.name),
138            "Tool selection guidance: {\"name\": \"get_foundry_help\", {\"topic\": \"decision-points\"}}".to_string(),
139        ]
140    };
141
142    if suggestions.is_empty() {
143        build_success_response(response_data, next_steps, workflow_hints)
144    } else {
145        build_incomplete_response(response_data, next_steps, workflow_hints)
146    }
147}
148
149fn validate_project_name(name: &str) -> Result<()> {
150    if name.is_empty() {
151        return Err(anyhow::anyhow!("Project name cannot be empty"));
152    }
153
154    if !name
155        .chars()
156        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
157    {
158        return Err(anyhow::anyhow!(
159            "Project name must be in kebab-case format (lowercase letters, numbers, and hyphens only)"
160        ));
161    }
162
163    if name.starts_with('-') || name.ends_with('-') {
164        return Err(anyhow::anyhow!(
165            "Project name cannot start or end with a hyphen"
166        ));
167    }
168
169    if name.contains("--") {
170        return Err(anyhow::anyhow!(
171            "Project name cannot contain consecutive hyphens"
172        ));
173    }
174
175    Ok(())
176}
177
178fn validate_content(input: &Input) -> Result<Vec<(&'static str, validation::ValidationResult)>> {
179    let validations = vec![
180        (
181            "Vision",
182            validation::validate_content(validation::ContentType::Vision, &input.vision),
183        ),
184        (
185            "Tech Stack",
186            validation::validate_content(validation::ContentType::TechStack, &input.tech_stack),
187        ),
188        (
189            "Summary",
190            validation::validate_content(validation::ContentType::Summary, &input.summary),
191        ),
192    ];
193
194    Ok(validations)
195}