Skip to main content

turbomcp_cli/
new.rs

1//! New project command implementation.
2//!
3//! Creates new MCP server projects from templates with proper configuration
4//! for various deployment targets including Cloudflare Workers.
5
6use crate::cli::{NewArgs, ProjectTemplate};
7use crate::error::{CliError, CliResult};
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12/// Execute the new project command.
13pub fn execute(args: &NewArgs) -> CliResult<()> {
14    // Determine output directory
15    let output_dir = args
16        .output
17        .clone()
18        .unwrap_or_else(|| PathBuf::from(&args.name));
19
20    // Check if directory already exists
21    if output_dir.exists() {
22        return Err(CliError::Other(format!(
23            "Directory '{}' already exists",
24            output_dir.display()
25        )));
26    }
27
28    println!("Creating new MCP server project '{}'...", args.name);
29    println!("  Template: {}", args.template);
30
31    // Create project directory
32    fs::create_dir_all(&output_dir)
33        .map_err(|e| CliError::Other(format!("Failed to create directory: {}", e)))?;
34
35    // Generate project files based on template
36    match args.template {
37        ProjectTemplate::Minimal => generate_minimal(args, &output_dir)?,
38        ProjectTemplate::Full => generate_full(args, &output_dir)?,
39        ProjectTemplate::CloudflareWorkers => generate_cloudflare_workers(args, &output_dir)?,
40        ProjectTemplate::CloudflareWorkersOauth => {
41            generate_cloudflare_workers_oauth(args, &output_dir)?
42        }
43        ProjectTemplate::CloudflareWorkersDurableObjects => {
44            generate_cloudflare_workers_do(args, &output_dir)?
45        }
46    }
47
48    // Initialize git repository if requested
49    if args.git {
50        init_git(&output_dir)?;
51    }
52
53    println!("\nProject created successfully!");
54    println!("\nNext steps:");
55    println!("  cd {}", args.name);
56
57    match args.template {
58        ProjectTemplate::CloudflareWorkers
59        | ProjectTemplate::CloudflareWorkersOauth
60        | ProjectTemplate::CloudflareWorkersDurableObjects => {
61            println!("  npx wrangler dev    # Start local development");
62            println!("  npx wrangler deploy # Deploy to Cloudflare");
63        }
64        _ => {
65            println!("  cargo build         # Build the server");
66            println!("  cargo run           # Run the server");
67        }
68    }
69
70    Ok(())
71}
72
73/// Generate a minimal MCP server project.
74fn generate_minimal(args: &NewArgs, output_dir: &Path) -> CliResult<()> {
75    let description = args
76        .description
77        .as_deref()
78        .unwrap_or("A minimal MCP server");
79
80    // Cargo.toml
81    let cargo_toml = format!(
82        r#"[package]
83name = "{name}"
84version = "0.1.0"
85edition = "2024"
86description = "{description}"
87{author}
88
89[dependencies]
90turbomcp = "3.0"
91tokio = {{ version = "1", features = ["full"] }}
92serde = {{ version = "1", features = ["derive"] }}
93schemars = "0.8"
94"#,
95        name = args.name,
96        description = description,
97        author = args
98            .author
99            .as_ref()
100            .map(|a| format!("authors = [\"{}\"]", a))
101            .unwrap_or_default(),
102    );
103
104    // src/main.rs
105    let main_rs = format!(
106        r#"//! {description}
107
108use turbomcp::prelude::*;
109use serde::Deserialize;
110
111#[derive(Clone)]
112struct {struct_name};
113
114#[derive(Deserialize, schemars::JsonSchema)]
115struct HelloArgs {{
116    /// Name to greet
117    name: String,
118}}
119
120#[server(name = "{name}", version = "0.1.0")]
121impl {struct_name} {{
122    /// Say hello to someone
123    #[tool("Say hello to someone")]
124    async fn hello(&self, args: HelloArgs) -> String {{
125        format!("Hello, {{}}!", args.name)
126    }}
127}}
128
129#[tokio::main]
130async fn main() -> Result<(), Box<dyn std::error::Error>> {{
131    let server = {struct_name};
132    server.serve_stdio().await?;
133    Ok(())
134}}
135"#,
136        description = description,
137        struct_name = to_struct_name(&args.name),
138        name = args.name,
139    );
140
141    // Write files
142    write_file(output_dir, "Cargo.toml", &cargo_toml)?;
143    fs::create_dir_all(output_dir.join("src"))?;
144    write_file(&output_dir.join("src"), "main.rs", &main_rs)?;
145
146    // .gitignore
147    write_file(output_dir, ".gitignore", "/target\n")?;
148
149    Ok(())
150}
151
152/// Generate a full-featured MCP server project.
153fn generate_full(args: &NewArgs, output_dir: &Path) -> CliResult<()> {
154    let description = args
155        .description
156        .as_deref()
157        .unwrap_or("A full-featured MCP server");
158
159    // Cargo.toml
160    let cargo_toml = format!(
161        r#"[package]
162name = "{name}"
163version = "0.1.0"
164edition = "2024"
165description = "{description}"
166{author}
167
168[dependencies]
169turbomcp = {{ version = "3.0", features = ["http", "auth"] }}
170tokio = {{ version = "1", features = ["full"] }}
171serde = {{ version = "1", features = ["derive"] }}
172serde_json = "1"
173schemars = "0.8"
174tracing = "0.1"
175tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
176"#,
177        name = args.name,
178        description = description,
179        author = args
180            .author
181            .as_ref()
182            .map(|a| format!("authors = [\"{}\"]", a))
183            .unwrap_or_default(),
184    );
185
186    // src/main.rs
187    let main_rs = format!(
188        r#"//! {description}
189
190use turbomcp::prelude::*;
191use serde::{{Deserialize, Serialize}};
192use tracing_subscriber::{{layer::SubscriberExt, util::SubscriberInitExt}};
193
194#[derive(Clone)]
195struct {struct_name} {{
196    config: ServerConfig,
197}}
198
199#[derive(Clone, Debug, Serialize, Deserialize)]
200struct ServerConfig {{
201    name: String,
202}}
203
204// Tool argument types
205#[derive(Deserialize, schemars::JsonSchema)]
206struct HelloArgs {{
207    /// Name to greet
208    name: String,
209}}
210
211#[derive(Deserialize, schemars::JsonSchema)]
212struct CalculateArgs {{
213    /// First number
214    a: f64,
215    /// Second number
216    b: f64,
217    /// Operation to perform
218    operation: String,
219}}
220
221// Prompt argument types
222#[derive(Deserialize, schemars::JsonSchema)]
223struct GreetingArgs {{
224    /// User's name
225    name: String,
226    /// Tone of greeting
227    tone: Option<String>,
228}}
229
230#[server(
231    name = "{name}",
232    version = "0.1.0",
233    description = "{description}"
234)]
235impl {struct_name} {{
236    /// Say hello to someone
237    #[tool("Say hello to someone")]
238    async fn hello(&self, args: HelloArgs) -> String {{
239        format!("Hello, {{}}!", args.name)
240    }}
241
242    /// Perform a calculation
243    #[tool("Perform basic arithmetic")]
244    async fn calculate(&self, args: CalculateArgs) -> Result<String, ToolError> {{
245        let result = match args.operation.as_str() {{
246            "add" => args.a + args.b,
247            "subtract" => args.a - args.b,
248            "multiply" => args.a * args.b,
249            "divide" => {{
250                if args.b == 0.0 {{
251                    return Err(ToolError::new("Cannot divide by zero"));
252                }}
253                args.a / args.b
254            }}
255            _ => return Err(ToolError::new(format!(
256                "Unknown operation: {{}}. Use: add, subtract, multiply, divide",
257                args.operation
258            ))),
259        }};
260        Ok(format!("{{}} {{}} {{}} = {{}}", args.a, args.operation, args.b, result))
261    }}
262
263    /// Server configuration
264    #[resource("config://server")]
265    async fn config(&self, _uri: String) -> ResourceResult {{
266        ResourceResult::json(
267            "config://server",
268            &self.config,
269        ).map_err(|e| ResourceError::new(e.to_string()))
270         .unwrap_or_else(|e| ResourceResult::text("config://server", &format!("Error: {{}}", e)))
271    }}
272
273    /// Greeting prompt
274    #[prompt("Generate a greeting")]
275    async fn greeting(&self, args: GreetingArgs) -> PromptResult {{
276        let tone = args.tone.as_deref().unwrap_or("friendly");
277        PromptResult::user(format!(
278            "Generate a {{}} greeting for {{}}.",
279            tone,
280            args.name
281        ))
282    }}
283}}
284
285#[tokio::main]
286async fn main() -> Result<(), Box<dyn std::error::Error>> {{
287    // Initialize tracing
288    tracing_subscriber::registry()
289        .with(tracing_subscriber::EnvFilter::new(
290            std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
291        ))
292        .with(tracing_subscriber::fmt::layer())
293        .init();
294
295    let server = {struct_name} {{
296        config: ServerConfig {{
297            name: "{name}".to_string(),
298        }},
299    }};
300
301    // Choose transport based on environment
302    if std::env::var("MCP_HTTP").is_ok() {{
303        tracing::info!("Starting HTTP server on port 8080...");
304        server.serve_http(8080).await?;
305    }} else {{
306        tracing::info!("Starting STDIO server...");
307        server.serve_stdio().await?;
308    }}
309
310    Ok(())
311}}
312"#,
313        description = description,
314        struct_name = to_struct_name(&args.name),
315        name = args.name,
316    );
317
318    // Write files
319    write_file(output_dir, "Cargo.toml", &cargo_toml)?;
320    fs::create_dir_all(output_dir.join("src"))?;
321    write_file(&output_dir.join("src"), "main.rs", &main_rs)?;
322
323    // .gitignore
324    write_file(output_dir, ".gitignore", "/target\n")?;
325
326    Ok(())
327}
328
329/// Generate a Cloudflare Workers MCP server project.
330fn generate_cloudflare_workers(args: &NewArgs, output_dir: &Path) -> CliResult<()> {
331    let description = args
332        .description
333        .as_deref()
334        .unwrap_or("An MCP server for Cloudflare Workers");
335
336    // Cargo.toml
337    let cargo_toml = format!(
338        r#"[package]
339name = "{name}"
340version = "0.1.0"
341edition = "2024"
342description = "{description}"
343{author}
344
345[lib]
346crate-type = ["cdylib"]
347
348[dependencies]
349turbomcp-wasm = {{ version = "3.0", features = ["macros", "streamable"] }}
350worker = "0.5"
351serde = {{ version = "1", features = ["derive"] }}
352serde_json = "1"
353schemars = "0.8"
354wasm-bindgen = "0.2"
355wasm-bindgen-futures = "0.4"
356
357[profile.release]
358opt-level = "s"
359lto = true
360"#,
361        name = args.name,
362        description = description,
363        author = args
364            .author
365            .as_ref()
366            .map(|a| format!("authors = [\"{}\"]", a))
367            .unwrap_or_default(),
368    );
369
370    // wrangler.toml
371    let wrangler_toml = format!(
372        r#"name = "{name}"
373main = "build/worker/shim.mjs"
374compatibility_date = "2024-01-01"
375
376[build]
377command = "cargo install -q worker-build && worker-build --release"
378
379# Uncomment for KV storage
380# [[kv_namespaces]]
381# binding = "MY_KV"
382# id = "your-kv-namespace-id"
383
384# Uncomment for Durable Objects
385# [[durable_objects.bindings]]
386# name = "MCP_STATE"
387# class_name = "McpState"
388"#,
389        name = args.name,
390    );
391
392    // src/lib.rs
393    let lib_rs = format!(
394        r#"//! {description}
395
396use turbomcp_wasm::prelude::*;
397use serde::Deserialize;
398use worker::*;
399
400#[derive(Clone)]
401struct {struct_name};
402
403#[derive(Deserialize, schemars::JsonSchema)]
404struct HelloArgs {{
405    /// Name to greet
406    name: String,
407}}
408
409#[server(name = "{name}", version = "0.1.0")]
410impl {struct_name} {{
411    /// Say hello to someone
412    #[tool("Say hello to someone")]
413    async fn hello(&self, args: HelloArgs) -> String {{
414        format!("Hello, {{}}!", args.name)
415    }}
416
417    /// Get server status
418    #[tool("Check server health")]
419    async fn status(&self) -> String {{
420        "OK".to_string()
421    }}
422}}
423
424#[event(fetch)]
425async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {{
426    console_error_panic_hook::set_once();
427
428    let server = {struct_name};
429    let mcp = server.into_mcp_server();
430
431    mcp.handle(req).await
432}}
433"#,
434        description = description,
435        struct_name = to_struct_name(&args.name),
436        name = args.name,
437    );
438
439    // Write files
440    write_file(output_dir, "Cargo.toml", &cargo_toml)?;
441    write_file(output_dir, "wrangler.toml", &wrangler_toml)?;
442    fs::create_dir_all(output_dir.join("src"))?;
443    write_file(&output_dir.join("src"), "lib.rs", &lib_rs)?;
444
445    // .gitignore
446    write_file(output_dir, ".gitignore", "/target\n/build\n/node_modules\n")?;
447
448    Ok(())
449}
450
451/// Generate a Cloudflare Workers MCP server with OAuth 2.1.
452fn generate_cloudflare_workers_oauth(args: &NewArgs, output_dir: &Path) -> CliResult<()> {
453    let description = args
454        .description
455        .as_deref()
456        .unwrap_or("An MCP server for Cloudflare Workers with OAuth 2.1");
457
458    // Cargo.toml
459    let cargo_toml = format!(
460        r#"[package]
461name = "{name}"
462version = "0.1.0"
463edition = "2024"
464description = "{description}"
465{author}
466
467[lib]
468crate-type = ["cdylib"]
469
470[dependencies]
471turbomcp-wasm = {{ version = "3.0", features = ["macros", "streamable", "auth"] }}
472worker = "0.5"
473serde = {{ version = "1", features = ["derive"] }}
474serde_json = "1"
475schemars = "0.8"
476wasm-bindgen = "0.2"
477wasm-bindgen-futures = "0.4"
478
479[profile.release]
480opt-level = "s"
481lto = true
482"#,
483        name = args.name,
484        description = description,
485        author = args
486            .author
487            .as_ref()
488            .map(|a| format!("authors = [\"{}\"]", a))
489            .unwrap_or_default(),
490    );
491
492    // wrangler.toml
493    let wrangler_toml = format!(
494        r#"name = "{name}"
495main = "build/worker/shim.mjs"
496compatibility_date = "2024-01-01"
497
498[build]
499command = "cargo install -q worker-build && worker-build --release"
500
501# OAuth token storage
502[[kv_namespaces]]
503binding = "OAUTH_TOKENS"
504id = "your-kv-namespace-id"
505
506# Secrets (set via wrangler secret put)
507# JWT_SECRET - Secret for signing JWT tokens
508# OAUTH_CLIENT_SECRET - OAuth client secret
509"#,
510        name = args.name,
511    );
512
513    // src/lib.rs
514    let lib_rs = format!(
515        r#"//! {description}
516
517use turbomcp_wasm::prelude::*;
518use turbomcp_wasm::wasm_server::{{WithAuth, AuthExt}};
519use serde::Deserialize;
520use worker::*;
521use std::sync::Arc;
522
523#[derive(Clone)]
524struct {struct_name};
525
526#[derive(Deserialize, schemars::JsonSchema)]
527struct HelloArgs {{
528    /// Name to greet
529    name: String,
530}}
531
532#[server(name = "{name}", version = "0.1.0")]
533impl {struct_name} {{
534    /// Say hello to someone (requires authentication)
535    #[tool("Say hello to someone")]
536    async fn hello(&self, ctx: Arc<RequestContext>, args: HelloArgs) -> Result<String, ToolError> {{
537        // Check authentication
538        if !ctx.is_authenticated() {{
539            return Err(ToolError::new("Authentication required"));
540        }}
541
542        let user = ctx.user_id().unwrap_or("unknown");
543        Ok(format!("Hello, {{}}! (authenticated as {{}})", args.name, user))
544    }}
545
546    /// Get server status (public)
547    #[tool("Check server health")]
548    async fn status(&self) -> String {{
549        "OK".to_string()
550    }}
551}}
552
553#[event(fetch)]
554async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {{
555    console_error_panic_hook::set_once();
556
557    let server = {struct_name};
558    let mcp = server
559        .into_mcp_server()
560        .with_jwt_auth(env.secret("JWT_SECRET")?.to_string());
561
562    mcp.handle(req).await
563}}
564"#,
565        description = description,
566        struct_name = to_struct_name(&args.name),
567        name = args.name,
568    );
569
570    // Write files
571    write_file(output_dir, "Cargo.toml", &cargo_toml)?;
572    write_file(output_dir, "wrangler.toml", &wrangler_toml)?;
573    fs::create_dir_all(output_dir.join("src"))?;
574    write_file(&output_dir.join("src"), "lib.rs", &lib_rs)?;
575
576    // .gitignore
577    write_file(output_dir, ".gitignore", "/target\n/build\n/node_modules\n")?;
578
579    Ok(())
580}
581
582/// Generate a Cloudflare Workers MCP server with Durable Objects.
583fn generate_cloudflare_workers_do(args: &NewArgs, output_dir: &Path) -> CliResult<()> {
584    let description = args
585        .description
586        .as_deref()
587        .unwrap_or("An MCP server for Cloudflare Workers with Durable Objects");
588
589    // Cargo.toml
590    let cargo_toml = format!(
591        r#"[package]
592name = "{name}"
593version = "0.1.0"
594edition = "2024"
595description = "{description}"
596{author}
597
598[lib]
599crate-type = ["cdylib"]
600
601[dependencies]
602turbomcp-wasm = {{ version = "3.0", features = ["macros", "streamable"] }}
603worker = "0.5"
604serde = {{ version = "1", features = ["derive"] }}
605serde_json = "1"
606schemars = "0.8"
607wasm-bindgen = "0.2"
608wasm-bindgen-futures = "0.4"
609
610[profile.release]
611opt-level = "s"
612lto = true
613"#,
614        name = args.name,
615        description = description,
616        author = args
617            .author
618            .as_ref()
619            .map(|a| format!("authors = [\"{}\"]", a))
620            .unwrap_or_default(),
621    );
622
623    // wrangler.toml
624    let wrangler_toml = format!(
625        r#"name = "{name}"
626main = "build/worker/shim.mjs"
627compatibility_date = "2024-01-01"
628
629[build]
630command = "cargo install -q worker-build && worker-build --release"
631
632# Durable Objects for session and state management
633[[durable_objects.bindings]]
634name = "MCP_SESSIONS"
635class_name = "McpSession"
636
637[[durable_objects.bindings]]
638name = "MCP_STATE"
639class_name = "McpState"
640
641[[durable_objects.bindings]]
642name = "MCP_RATE_LIMIT"
643class_name = "McpRateLimit"
644
645[[migrations]]
646tag = "v1"
647new_classes = ["McpSession", "McpState", "McpRateLimit"]
648"#,
649        name = args.name,
650    );
651
652    // src/lib.rs
653    let lib_rs = format!(
654        r#"//! {description}
655
656use turbomcp_wasm::prelude::*;
657use turbomcp_wasm::wasm_server::{{
658    DurableObjectSessionStore,
659    DurableObjectStateStore,
660    DurableObjectRateLimiter,
661    RateLimitConfig,
662    StreamableHandler,
663}};
664use serde::{{Deserialize, Serialize}};
665use worker::*;
666use std::sync::Arc;
667
668#[derive(Clone)]
669struct {struct_name} {{
670    state_store: DurableObjectStateStore,
671}}
672
673#[derive(Deserialize, schemars::JsonSchema)]
674struct HelloArgs {{
675    /// Name to greet
676    name: String,
677}}
678
679#[derive(Deserialize, schemars::JsonSchema)]
680struct SaveArgs {{
681    /// Key to save under
682    key: String,
683    /// Value to save
684    value: String,
685}}
686
687#[derive(Deserialize, schemars::JsonSchema)]
688struct LoadArgs {{
689    /// Key to load
690    key: String,
691}}
692
693#[server(name = "{name}", version = "0.1.0")]
694impl {struct_name} {{
695    /// Say hello to someone
696    #[tool("Say hello to someone")]
697    async fn hello(&self, args: HelloArgs) -> String {{
698        format!("Hello, {{}}!", args.name)
699    }}
700
701    /// Save a value to persistent state
702    #[tool("Save a value to persistent storage")]
703    async fn save(&self, ctx: Arc<RequestContext>, args: SaveArgs) -> Result<String, ToolError> {{
704        let session_id = ctx.session_id().unwrap_or("default");
705
706        self.state_store
707            .set(session_id, &args.key, &args.value)
708            .await
709            .map_err(|e| ToolError::new(format!("Failed to save: {{}}", e)))?;
710
711        Ok(format!("Saved '{{}}' = '{{}}'", args.key, args.value))
712    }}
713
714    /// Load a value from persistent state
715    #[tool("Load a value from persistent storage")]
716    async fn load(&self, ctx: Arc<RequestContext>, args: LoadArgs) -> Result<String, ToolError> {{
717        let session_id = ctx.session_id().unwrap_or("default");
718
719        let value: Option<String> = self.state_store
720            .get(session_id, &args.key)
721            .await
722            .map_err(|e| ToolError::new(format!("Failed to load: {{}}", e)))?;
723
724        match value {{
725            Some(v) => Ok(v),
726            None => Ok(format!("Key '{{}}' not found", args.key)),
727        }}
728    }}
729}}
730
731#[event(fetch)]
732async fn fetch(req: Request, env: Env, _ctx: Context) -> Result<Response> {{
733    console_error_panic_hook::set_once();
734
735    // Initialize Durable Object stores
736    let session_store = DurableObjectSessionStore::from_env(&env, "MCP_SESSIONS")?;
737    let state_store = DurableObjectStateStore::from_env(&env, "MCP_STATE")?;
738    let rate_limiter = DurableObjectRateLimiter::from_env(&env, "MCP_RATE_LIMIT")?
739        .with_config(RateLimitConfig::per_minute(100));
740
741    let server = {struct_name} {{ state_store }};
742    let mcp = server.into_mcp_server();
743
744    // Use Streamable HTTP with session persistence
745    let handler = StreamableHandler::new(mcp)
746        .with_session_store(session_store);
747
748    handler.handle(req).await
749}}
750
751// Durable Object implementations (minimal stubs - expand as needed)
752// See turbomcp_wasm::wasm_server::durable_objects for protocol documentation
753
754#[durable_object]
755pub struct McpSession {{
756    state: State,
757    #[allow(dead_code)]
758    env: Env,
759}}
760
761#[durable_object]
762impl DurableObject for McpSession {{
763    fn new(state: State, env: Env) -> Self {{
764        Self {{ state, env }}
765    }}
766
767    async fn fetch(&mut self, req: Request) -> Result<Response> {{
768        // Handle session storage requests
769        // See DurableObjectSessionStore protocol documentation
770        Response::ok("{{}}")
771    }}
772}}
773
774#[durable_object]
775pub struct McpState {{
776    state: State,
777    #[allow(dead_code)]
778    env: Env,
779}}
780
781#[durable_object]
782impl DurableObject for McpState {{
783    fn new(state: State, env: Env) -> Self {{
784        Self {{ state, env }}
785    }}
786
787    async fn fetch(&mut self, req: Request) -> Result<Response> {{
788        // Handle state storage requests
789        // See DurableObjectStateStore protocol documentation
790        Response::ok("{{}}")
791    }}
792}}
793
794#[durable_object]
795pub struct McpRateLimit {{
796    state: State,
797    #[allow(dead_code)]
798    env: Env,
799}}
800
801#[durable_object]
802impl DurableObject for McpRateLimit {{
803    fn new(state: State, env: Env) -> Self {{
804        Self {{ state, env }}
805    }}
806
807    async fn fetch(&mut self, req: Request) -> Result<Response> {{
808        // Handle rate limiting requests
809        // See DurableObjectRateLimiter protocol documentation
810        Response::ok("{{}}")
811    }}
812}}
813"#,
814        description = description,
815        struct_name = to_struct_name(&args.name),
816        name = args.name,
817    );
818
819    // Write files
820    write_file(output_dir, "Cargo.toml", &cargo_toml)?;
821    write_file(output_dir, "wrangler.toml", &wrangler_toml)?;
822    fs::create_dir_all(output_dir.join("src"))?;
823    write_file(&output_dir.join("src"), "lib.rs", &lib_rs)?;
824
825    // .gitignore
826    write_file(output_dir, ".gitignore", "/target\n/build\n/node_modules\n")?;
827
828    Ok(())
829}
830
831/// Initialize a git repository in the project directory.
832fn init_git(output_dir: &Path) -> CliResult<()> {
833    let status = Command::new("git")
834        .arg("init")
835        .current_dir(output_dir)
836        .stdout(std::process::Stdio::null())
837        .stderr(std::process::Stdio::null())
838        .status();
839
840    match status {
841        Ok(s) if s.success() => {
842            println!("  Initialized git repository");
843            Ok(())
844        }
845        _ => {
846            println!("  Warning: Failed to initialize git repository");
847            Ok(()) // Don't fail the whole operation
848        }
849    }
850}
851
852/// Write a file to the specified directory.
853fn write_file(dir: &Path, name: &str, content: &str) -> CliResult<()> {
854    let path = dir.join(name);
855    fs::write(&path, content)
856        .map_err(|e| CliError::Other(format!("Failed to write {}: {}", path.display(), e)))
857}
858
859/// Convert a project name to a valid Rust struct name.
860fn to_struct_name(name: &str) -> String {
861    let name = name
862        .chars()
863        .map(|c| if c.is_alphanumeric() { c } else { '_' })
864        .collect::<String>();
865
866    // Convert to PascalCase
867    let mut result = String::new();
868    let mut capitalize_next = true;
869
870    for c in name.chars() {
871        if c == '_' {
872            capitalize_next = true;
873        } else if capitalize_next {
874            result.push(c.to_ascii_uppercase());
875            capitalize_next = false;
876        } else {
877            result.push(c);
878        }
879    }
880
881    // Ensure it starts with a letter
882    if result.chars().next().is_none_or(|c| c.is_ascii_digit()) {
883        result = format!("Server{}", result);
884    }
885
886    result
887}
888
889#[cfg(test)]
890mod tests {
891    use super::*;
892
893    #[test]
894    fn test_to_struct_name() {
895        assert_eq!(to_struct_name("my-server"), "MyServer");
896        assert_eq!(to_struct_name("hello_world"), "HelloWorld");
897        assert_eq!(to_struct_name("123test"), "Server123test");
898        assert_eq!(to_struct_name("simple"), "Simple");
899    }
900
901    #[test]
902    fn test_template_display() {
903        assert_eq!(ProjectTemplate::Minimal.to_string(), "minimal");
904        assert_eq!(
905            ProjectTemplate::CloudflareWorkers.to_string(),
906            "cloudflare-workers"
907        );
908    }
909}