Skip to main content

spikard_cli/init/
php.rs

1//! PHP Project Scaffolder
2//!
3//! Generates a minimal PHP project structure with Spikard integration.
4//! Follows PSR-4 autoloading conventions and modern PHP 8.2+ standards.
5
6use super::scaffolder::{ProjectScaffolder, ScaffoldedFile};
7use anyhow::Result;
8use std::path::{Path, PathBuf};
9
10/// PHP project scaffolder
11pub struct PhpScaffolder;
12
13impl ProjectScaffolder for PhpScaffolder {
14    #[allow(clippy::vec_init_then_push)]
15    fn scaffold(&self, _project_dir: &Path, project_name: &str) -> Result<Vec<ScaffoldedFile>> {
16        let mut files = Vec::new();
17
18        // Create composer.json
19        files.push(ScaffoldedFile::new(
20            PathBuf::from("composer.json"),
21            self.generate_composer_json(project_name),
22        ));
23
24        // Create phpstan.neon
25        files.push(ScaffoldedFile::new(
26            PathBuf::from("phpstan.neon"),
27            self.generate_phpstan_neon(),
28        ));
29
30        // Create phpunit.xml
31        files.push(ScaffoldedFile::new(
32            PathBuf::from("phpunit.xml"),
33            self.generate_phpunit_xml(),
34        ));
35
36        // Create src/AppController.php
37        files.push(ScaffoldedFile::new(
38            PathBuf::from("src/AppController.php"),
39            self.generate_app_php(),
40        ));
41
42        // Create bin/server.php
43        files.push(ScaffoldedFile::new(
44            PathBuf::from("bin/server.php"),
45            self.generate_server_php(),
46        ));
47
48        // Create tests/AppTest.php
49        files.push(ScaffoldedFile::new(
50            PathBuf::from("tests/AppTest.php"),
51            self.generate_app_test_php(),
52        ));
53
54        // Create .gitignore
55        files.push(ScaffoldedFile::new(
56            PathBuf::from(".gitignore"),
57            self.generate_gitignore(),
58        ));
59
60        // Create README.md
61        files.push(ScaffoldedFile::new(
62            PathBuf::from("README.md"),
63            self.generate_readme(project_name),
64        ));
65
66        Ok(files)
67    }
68
69    fn next_steps(&self, project_name: &str) -> Vec<String> {
70        vec![
71            format!("cd {}", project_name),
72            "composer install".to_string(),
73            "php bin/server.php".to_string(),
74        ]
75    }
76}
77
78impl PhpScaffolder {
79    fn generate_composer_json(&self, project_name: &str) -> String {
80        let version = env!("CARGO_PKG_VERSION");
81        let package_name = project_name.replace('_', "-").to_lowercase();
82        format!(
83            r#"{{
84  "name": "your-vendor/{package_name}",
85  "description": "Spikard PHP application",
86  "type": "project",
87  "require": {{
88    "php": "^8.2",
89    "spikard/spikard": "^{version}"
90  }},
91  "require-dev": {{
92    "phpunit/phpunit": "^11.0",
93    "phpstan/phpstan": "^1.10"
94  }},
95  "autoload": {{
96    "psr-4": {{
97      "App\\": "src/"
98    }}
99  }},
100  "autoload-dev": {{
101    "psr-4": {{
102      "App\\Tests\\": "tests/"
103    }}
104  }},
105  "authors": [
106    {{
107      "name": "Your Name",
108      "email": "you@example.com"
109    }}
110  ],
111  "license": "MIT",
112  "scripts": {{
113    "serve": "php bin/server.php",
114    "test": "vendor/bin/phpunit --configuration phpunit.xml",
115    "phpstan": "vendor/bin/phpstan analyse --configuration phpstan.neon"
116  }}
117}}
118"#
119        )
120    }
121
122    fn generate_phpstan_neon(&self) -> String {
123        r"parameters:
124  level: max
125  paths:
126    - src
127    - tests
128  excludePaths:
129    - */vendor/*
130  treatPhpDocTypesAsCertain: false
131  checkMissingIterableValueType: false
132"
133        .to_string()
134    }
135
136    fn generate_phpunit_xml(&self) -> String {
137        r#"<?xml version="1.0" encoding="UTF-8"?>
138<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
139         xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
140         bootstrap="vendor/autoload.php"
141         cacheDirectory=".phpunit.cache"
142         colors="true"
143         verbose="true">
144  <testsuites>
145    <testsuite name="Unit Tests">
146      <directory>tests</directory>
147    </testsuite>
148  </testsuites>
149
150  <coverage processUncoveredFiles="true">
151    <include>
152      <directory suffix=".php">src</directory>
153    </include>
154    <exclude>
155      <directory>tests</directory>
156    </exclude>
157  </coverage>
158</phpunit>
159"#
160        .to_string()
161    }
162
163    fn generate_app_php(&self) -> String {
164        r"<?php
165
166declare(strict_types=1);
167
168namespace App;
169
170use Spikard\Attributes\Get;
171use Spikard\Http\Response;
172
173/**
174 * Main application controller
175 *
176 * Demonstrates a simple Spikard application with a health check endpoint.
177 */
178final class AppController
179{
180    #[Get('/health')]
181    public function health(): Response
182    {
183        return Response::json(['status' => 'healthy', 'message' => 'Server is running']);
184    }
185
186    #[Get('/')]
187    public function index(): Response
188    {
189        return Response::text('Welcome to Spikard PHP');
190    }
191}
192"
193        .to_string()
194    }
195
196    fn generate_server_php(&self) -> String {
197        r#"<?php
198
199declare(strict_types=1);
200
201require_once __DIR__ . '/../vendor/autoload.php';
202
203use App\AppController;
204use Spikard\App;
205use Spikard\Config\ServerConfig;
206
207$config = new ServerConfig(port: 8000);
208$app = (new App($config))->registerController(new AppController());
209
210echo "Starting server on http://127.0.0.1:8000\n";
211echo "Press Ctrl+C to stop\n\n";
212
213$app->run();
214"#
215        .to_string()
216    }
217
218    fn generate_app_test_php(&self) -> String {
219        r"<?php
220
221declare(strict_types=1);
222
223namespace App\Tests;
224
225use App\AppController;
226use PHPUnit\Framework\TestCase;
227use Spikard\Http\Response;
228
229/**
230 * Tests for the main application
231 */
232final class AppTest extends TestCase
233{
234    public function testControllerCreatesResponses(): void
235    {
236        $controller = new AppController();
237
238        $this->assertInstanceOf(Response::class, $controller->health());
239        $this->assertInstanceOf(Response::class, $controller->index());
240    }
241}
242"
243        .to_string()
244    }
245
246    fn generate_gitignore(&self) -> String {
247        r"# Dependencies
248/vendor/
249
250# IDE
251.vscode/
252.idea/
253*.swp
254*.swo
255*~
256
257# PHP
258.php-version
259
260# Testing
261.phpunit.cache/
262coverage/
263
264# Environment
265.env
266.env.local
267.env.*.local
268
269# OS
270.DS_Store
271Thumbs.db
272"
273        .to_string()
274    }
275
276    fn generate_readme(&self, project_name: &str) -> String {
277        format!(
278            r"# {project_name}
279
280A Spikard PHP application.
281
282## Requirements
283
284- PHP 8.2+
285- Composer
286
287## Installation
288
289```bash
290composer install
291```
292
293## Running the Application
294
295```bash
296php bin/server.php
297```
298
299The server will start on `http://127.0.0.1:8000`.
300
301## Testing
302
303```bash
304composer test
305```
306
307## Static Analysis
308
309```bash
310composer phpstan
311```
312
313## Next Steps
314
3151. Install dependencies: `composer install`
3162. Run the server: `php bin/server.php`
3173. Make requests to `http://localhost:8000/health` to verify
318
319## Documentation
320
321- [Spikard Documentation](https://spikard.dev)
322- [PHP PSR Standards](https://www.php-fig.org/)
323"
324        )
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn test_php_scaffolder_generates_composer_json() {
334        let scaffolder = PhpScaffolder;
335        let content = scaffolder.generate_composer_json("test-app");
336
337        assert!(content.contains("\"your-vendor/test-app\""));
338        assert!(content.contains("\"php\": \"^8.2\""));
339        assert!(content.contains("\"spikard/spikard\": \"^"));
340        assert!(content.contains("\"psr-4\""));
341    }
342
343    #[test]
344    fn test_php_scaffolder_generates_phpstan_config() {
345        let scaffolder = PhpScaffolder;
346        let content = scaffolder.generate_phpstan_neon();
347
348        assert!(content.contains("level: max"));
349        assert!(content.contains("- src"));
350        assert!(content.contains("- tests"));
351    }
352
353    #[test]
354    fn test_php_scaffolder_generates_php_files_with_strict_types() {
355        let scaffolder = PhpScaffolder;
356        let app_content = scaffolder.generate_app_php();
357
358        assert!(app_content.starts_with("<?php"));
359        assert!(app_content.contains("declare(strict_types=1);"));
360        assert!(app_content.contains("namespace App;"));
361
362        let test_content = scaffolder.generate_app_test_php();
363        assert!(test_content.starts_with("<?php"));
364        assert!(test_content.contains("declare(strict_types=1);"));
365        assert!(test_content.contains("namespace App\\Tests;"));
366    }
367
368    #[test]
369    fn test_php_scaffolder_next_steps() {
370        let scaffolder = PhpScaffolder;
371        let steps = scaffolder.next_steps("my-project");
372
373        assert_eq!(steps.len(), 3);
374        assert!(steps[0].contains("cd my-project"));
375        assert_eq!(steps[1], "composer install");
376        assert_eq!(steps[2], "php bin/server.php");
377    }
378}