1use anyhow::{Context, Result};
4use std::collections::BTreeSet;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::cli::{AddArgs, AddFeature};
9use crate::templates::{BackendTemplateContext, BackendTemplateEngine};
10use crate::{TIDEWAY_VERSION, ensure_dir, print_info, print_success, print_warning, write_file};
11
12const APP_BUILDER_START_MARKER: &str = "tideway:app-builder:start";
13const APP_BUILDER_END_MARKER: &str = "tideway:app-builder:end";
14
15pub fn run(args: AddArgs) -> Result<()> {
16 let project_dir = PathBuf::from(&args.path);
17 let cargo_path = project_dir.join("Cargo.toml");
18
19 if !cargo_path.exists() {
20 return Err(anyhow::anyhow!(
21 "Cargo.toml not found in {}",
22 project_dir.display()
23 ));
24 }
25
26 let cargo_contents = fs::read_to_string(&cargo_path)
27 .with_context(|| format!("Failed to read {}", cargo_path.display()))?;
28
29 let project_name = project_name_from_cargo(&cargo_contents, &project_dir);
30 let project_name_pascal = to_pascal_case(&project_name);
31
32 update_cargo_toml(&cargo_path, &cargo_contents, args.feature)?;
33 update_env_example(&project_dir, args.feature, &project_name)?;
34
35 if args.feature == AddFeature::Auth {
36 scaffold_auth(
37 &project_dir,
38 &project_name,
39 &project_name_pascal,
40 args.force,
41 )?;
42 print_info("Auth scaffold created in src/auth/");
43 if args.wire {
44 wire_auth_in_main(&project_dir, &project_name)?;
45 } else {
46 print_info("Next steps: wire AuthModule + SimpleAuthProvider in main.rs");
47 }
48 }
49
50 if args.feature == AddFeature::Database && args.wire {
51 wire_database_in_main(&project_dir)?;
52 }
53
54 if args.feature == AddFeature::Openapi {
55 ensure_openapi_docs_file(&project_dir)?;
56 if args.wire {
57 wire_openapi_in_main(&project_dir)?;
58 } else {
59 print_info("Next steps: wire OpenAPI in main.rs");
60 }
61 }
62
63 print_success(&format!("Added {}", args.feature));
64 Ok(())
65}
66
67fn update_cargo_toml(path: &Path, contents: &str, feature: AddFeature) -> Result<()> {
68 let mut doc = contents.parse::<toml_edit::DocumentMut>()?;
69
70 let deps = doc["dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
71
72 let tideway_item = deps
73 .as_table_mut()
74 .expect("dependencies should be a table")
75 .entry("tideway");
76
77 let feature_name = feature.to_string();
78
79 match tideway_item {
80 toml_edit::Entry::Vacant(entry) => {
81 let mut table = toml_edit::InlineTable::new();
82 table.get_or_insert("version", TIDEWAY_VERSION);
83 table.get_or_insert("features", array_value(&[feature_name.as_str()]));
84 entry.insert(toml_edit::Item::Value(toml_edit::Value::InlineTable(table)));
85 }
86 toml_edit::Entry::Occupied(mut entry) => {
87 if entry.get().is_str() {
88 let version = entry.get().as_str().unwrap_or(TIDEWAY_VERSION).to_string();
89 let mut table = toml_edit::InlineTable::new();
90 table.get_or_insert("version", version);
91 table.get_or_insert("features", array_value(&[feature_name.as_str()]));
92 entry.insert(toml_edit::Item::Value(toml_edit::Value::InlineTable(table)));
93 } else {
94 let item = entry.get_mut();
95 let features = item["features"]
96 .or_insert(toml_edit::Item::Value(toml_edit::Value::Array(
97 toml_edit::Array::new(),
98 )))
99 .as_array_mut()
100 .expect("features should be an array");
101
102 if !features.iter().any(|v| v.as_str() == Some(&feature_name)) {
103 features.push(feature_name);
104 }
105 }
106 }
107 }
108
109 if feature == AddFeature::Database {
110 let deps_table = deps.as_table_mut().expect("dependencies should be a table");
111 deps_table
112 .entry("sea-orm")
113 .or_insert(toml_edit::Item::Value(toml_edit::Value::InlineTable({
114 let mut table = toml_edit::InlineTable::new();
115 table.get_or_insert("version", "1.1");
116 table.get_or_insert(
117 "features",
118 array_value(&["sqlx-postgres", "runtime-tokio-rustls"]),
119 );
120 table
121 })));
122 }
123
124 if feature == AddFeature::Auth {
125 let deps_table = deps.as_table_mut().expect("dependencies should be a table");
126 deps_table
127 .entry("async-trait")
128 .or_insert(toml_edit::value("0.1"));
129 deps_table
130 .entry("serde")
131 .or_insert(toml_edit::Item::Value(toml_edit::Value::InlineTable({
132 let mut table = toml_edit::InlineTable::new();
133 table.get_or_insert("version", "1.0");
134 table.get_or_insert("features", array_value(&["derive"]));
135 table
136 })));
137 deps_table
138 .entry("serde_json")
139 .or_insert(toml_edit::value("1.0"));
140 }
141
142 write_file(path, &doc.to_string())
143 .with_context(|| format!("Failed to write {}", path.display()))?;
144 Ok(())
145}
146
147fn update_env_example(project_dir: &Path, feature: AddFeature, project_name: &str) -> Result<()> {
148 let env_path = project_dir.join(".env.example");
149 let mut lines = if env_path.exists() {
150 fs::read_to_string(&env_path)
151 .with_context(|| format!("Failed to read {}", env_path.display()))?
152 .lines()
153 .map(|line| line.to_string())
154 .collect::<Vec<_>>()
155 } else {
156 vec![
157 "# Server".to_string(),
158 "TIDEWAY_HOST=0.0.0.0".to_string(),
159 "TIDEWAY_PORT=8000".to_string(),
160 String::new(),
161 ]
162 };
163
164 let mut existing = BTreeSet::new();
165 for line in &lines {
166 if let Some((key, _)) = line.split_once('=') {
167 existing.insert(key.trim().to_string());
168 }
169 }
170
171 match feature {
172 AddFeature::Database => {
173 if !existing.contains("DATABASE_URL") {
174 lines.push("# Database".to_string());
175 lines.push(format!(
176 "DATABASE_URL=postgres://postgres:postgres@localhost:5432/{}",
177 project_name
178 ));
179 lines.push(String::new());
180 }
181 }
182 AddFeature::Auth => {
183 if !existing.contains("JWT_SECRET") {
184 lines.push("# Auth".to_string());
185 lines.push("JWT_SECRET=your-super-secret-jwt-key-change-in-production".to_string());
186 lines.push(String::new());
187 }
188 }
189 _ => {}
190 }
191
192 write_file(&env_path, &lines.join("\n"))
193 .with_context(|| format!("Failed to write {}", env_path.display()))?;
194 Ok(())
195}
196
197fn scaffold_auth(
198 project_dir: &Path,
199 project_name: &str,
200 project_name_pascal: &str,
201 force: bool,
202) -> Result<()> {
203 let context = BackendTemplateContext {
204 project_name: project_name.to_string(),
205 project_name_pascal: project_name_pascal.to_string(),
206 has_organizations: false,
207 database: "postgres".to_string(),
208 tideway_version: TIDEWAY_VERSION.to_string(),
209 tideway_features: vec!["auth".to_string()],
210 has_tideway_features: true,
211 has_auth_feature: true,
212 has_database_feature: false,
213 has_openapi_feature: false,
214 needs_arc: true,
215 has_config: false,
216 };
217
218 let engine = BackendTemplateEngine::new(context)?;
219 let auth_dir = project_dir.join("src").join("auth");
220
221 write_file_with_force(
222 &auth_dir.join("mod.rs"),
223 &engine.render("starter/src/auth/mod.rs")?,
224 force,
225 )?;
226 write_file_with_force(
227 &auth_dir.join("provider.rs"),
228 &engine.render("starter/src/auth/provider.rs")?,
229 force,
230 )?;
231 write_file_with_force(
232 &auth_dir.join("routes.rs"),
233 &engine.render("starter/src/auth/routes.rs")?,
234 force,
235 )?;
236
237 Ok(())
238}
239
240fn wire_auth_in_main(project_dir: &Path, project_name: &str) -> Result<()> {
241 let main_path = project_dir.join("src").join("main.rs");
242 if !main_path.exists() {
243 print_warning("src/main.rs not found; skipping auto-wiring");
244 return Ok(());
245 }
246
247 let mut contents = fs::read_to_string(&main_path)
248 .with_context(|| format!("Failed to read {}", main_path.display()))?;
249
250 if !contents.contains("mod auth;") {
251 if contents.contains("mod routes;") {
252 contents = contents.replace("mod routes;\n", "mod routes;\nmod auth;\n");
253 } else {
254 contents = format!("mod auth;\n{}", contents);
255 }
256 }
257
258 contents = ensure_use_line(contents, "use axum::Extension;", "use tideway::auth");
259 contents = ensure_use_line(
260 contents,
261 "use crate::auth::{AuthModule, SimpleAuthProvider};",
262 "use tideway::auth",
263 );
264 contents = ensure_use_line(contents, "use std::sync::Arc;", "use tideway::");
265 contents = ensure_use_line(
266 contents,
267 "use tideway::auth::{JwtIssuer, JwtIssuerConfig};",
268 "use tideway::auth",
269 );
270
271 let has_jwt_secret = contents.contains("let jwt_secret");
272 let has_jwt_issuer = contents.contains("let jwt_issuer");
273 let has_auth_provider = contents.contains("auth_provider");
274 let has_auth_module = contents.contains("auth_module");
275
276 if has_jwt_secret && has_jwt_issuer {
277 if !has_auth_provider || !has_auth_module {
278 if let Some(insert_at) = contents.find("let jwt_issuer") {
279 let after = contents[insert_at..]
280 .find(";\n")
281 .map(|idx| insert_at + idx + 2)
282 .unwrap_or(insert_at);
283 let insert = format!(
284 " let auth_provider = SimpleAuthProvider::from_secret(&jwt_secret);\n let auth_module = AuthModule::new(jwt_issuer.clone());\n"
285 );
286 contents.insert_str(after, &insert);
287 }
288 }
289 } else {
290 let block = format!(
291 " let jwt_secret = std::env::var(\"JWT_SECRET\").expect(\"JWT_SECRET is not set\");\n let jwt_issuer = Arc::new(JwtIssuer::new(JwtIssuerConfig::with_secret(\n &jwt_secret,\n \"{}\",\n )).expect(\"Failed to create JWT issuer\"));\n let auth_provider = SimpleAuthProvider::from_secret(&jwt_secret);\n let auth_module = AuthModule::new(jwt_issuer.clone());\n\n",
292 project_name
293 );
294 contents = insert_before_app_builder(contents, &block)?;
295 }
296
297 contents = insert_auth_into_app_builder(contents)?;
298
299 write_file(&main_path, &contents)
300 .with_context(|| format!("Failed to write {}", main_path.display()))?;
301 print_success("Wired auth into src/main.rs");
302 Ok(())
303}
304
305pub fn wire_database_in_main(project_dir: &Path) -> Result<()> {
306 let main_path = project_dir.join("src").join("main.rs");
307 if !main_path.exists() {
308 print_warning("src/main.rs not found; skipping auto-wiring");
309 return Ok(());
310 }
311
312 let mut contents = fs::read_to_string(&main_path)
313 .with_context(|| format!("Failed to read {}", main_path.display()))?;
314
315 if !contents.contains("async fn main") {
316 print_warning("main.rs is not async; skipping database wiring");
317 return Ok(());
318 }
319
320 contents = ensure_use_line(
321 contents,
322 "use tideway::{AppContext, SeaOrmPool};",
323 "use tideway::",
324 );
325 contents = ensure_use_line(contents, "use std::sync::Arc;", "use tideway::");
326
327 let has_database_block = contents.contains("DATABASE_URL")
328 || contents.contains("sea_orm::Database::connect")
329 || contents.contains("with_database");
330
331 if !has_database_block {
332 let block = " let database_url = std::env::var(\"DATABASE_URL\").expect(\"DATABASE_URL is not set\");\n let db = sea_orm::Database::connect(&database_url)\n .await\n .expect(\"Failed to connect to database\");\n\n";
333 contents = insert_before_app_builder(contents, block)?;
334 }
335
336 if !contents.contains(".with_database(") {
337 contents = insert_database_into_app_builder(contents)?;
338 }
339
340 write_file(&main_path, &contents)
341 .with_context(|| format!("Failed to write {}", main_path.display()))?;
342 print_success("Wired database into src/main.rs");
343 Ok(())
344}
345
346fn ensure_use_line(mut contents: String, line: &str, anchor: &str) -> String {
347 if contents.contains(line) {
348 return contents;
349 }
350
351 if let Some(pos) = contents.find(anchor) {
352 if let Some(line_end) = contents[pos..].find('\n') {
353 let insert_at = pos + line_end + 1;
354 contents.insert_str(insert_at, &format!("{}\n", line));
355 return contents;
356 }
357 }
358
359 contents = format!("{}\n{}", line, contents);
360 contents
361}
362
363fn insert_before_app_builder(mut contents: String, block: &str) -> Result<String> {
364 if let Some(pos) = find_app_builder_start(&contents) {
365 contents.insert_str(pos, block);
366 Ok(contents)
367 } else {
368 print_warning("Could not find app builder; skipping auth wiring");
369 Ok(contents)
370 }
371}
372
373fn insert_auth_into_app_builder(mut contents: String) -> Result<String> {
374 if contents.contains("register_module(auth_module)") {
375 return Ok(contents);
376 }
377
378 if let Some(pos) = find_app_builder_start(&contents) {
379 let line_end = contents[pos..]
380 .find('\n')
381 .map(|idx| pos + idx)
382 .unwrap_or(contents.len());
383 let indent = contents[pos..]
384 .chars()
385 .take_while(|c| c.is_whitespace())
386 .collect::<String>();
387 let insert = format!(
388 "{} .with_global_layer(Extension(auth_provider))\n{} .register_module(auth_module)\n",
389 indent, indent
390 );
391 contents.insert_str(line_end + 1, &insert);
392 Ok(contents)
393 } else {
394 print_warning("Could not find app builder; skipping auth module registration");
395 Ok(contents)
396 }
397}
398
399fn insert_database_into_app_builder(mut contents: String) -> Result<String> {
400 if let Some(pos) = find_app_builder_start(&contents) {
401 let line_end = contents[pos..]
402 .find('\n')
403 .map(|idx| pos + idx)
404 .unwrap_or(contents.len());
405 let indent = contents[pos..]
406 .chars()
407 .take_while(|c| c.is_whitespace())
408 .collect::<String>();
409 let insert = format!(
410 "{} .with_context(\n{} AppContext::builder()\n{} .with_database(Arc::new(SeaOrmPool::new(db, database_url)))\n{} .build()\n{} )\n",
411 indent, indent, indent, indent, indent
412 );
413 contents.insert_str(line_end + 1, &insert);
414 Ok(contents)
415 } else {
416 print_warning("Could not find app builder; skipping database wiring");
417 Ok(contents)
418 }
419}
420
421fn wire_openapi_in_main(project_dir: &Path) -> Result<()> {
422 let main_path = project_dir.join("src").join("main.rs");
423 if !main_path.exists() {
424 print_warning("src/main.rs not found; skipping auto-wiring");
425 return Ok(());
426 }
427
428 let mut contents = fs::read_to_string(&main_path)
429 .with_context(|| format!("Failed to read {}", main_path.display()))?;
430
431 if contents.contains("openapi::create_openapi_router")
432 || contents.contains("openapi_merge_module")
433 {
434 print_info("OpenAPI already appears wired in main.rs");
435 return Ok(());
436 }
437
438 contents = ensure_use_line(contents, "use tideway::ConfigBuilder;", "use tideway::");
439 if contents.contains("mod config;") {
440 contents = ensure_use_line(contents, "use crate::config::AppConfig;", "use tideway::");
441 }
442 contents = ensure_use_line(contents, "use tideway::openapi;", "use tideway::");
443
444 if !contents.contains("mod openapi_docs;") {
445 if contents.contains("mod routes;") {
446 contents = contents.replace("mod routes;\n", "mod routes;\nmod openapi_docs;\n");
447 } else {
448 contents = format!("mod openapi_docs;\n{}", contents);
449 }
450 }
451
452 let has_config_var = contents.contains("let config = ConfigBuilder::new()")
453 || contents.contains("let config = AppConfig::from_env()");
454 let config_available =
455 contents.contains("ConfigBuilder::new()") || contents.contains("AppConfig::from_env()");
456
457 if !has_config_var && config_available {
458 let config_block = " let config = ConfigBuilder::new()\n .from_env()\n .build()\n .expect(\"Invalid TIDEWAY_* config\");\n\n";
459 contents = insert_before_app_builder(contents, config_block)?;
460 }
461
462 if contents.contains("let config = AppConfig::from_env()") {
463 contents = insert_openapi_into_app_builder(contents, "config.tideway")?;
464 } else {
465 contents = insert_openapi_into_app_builder(contents, "config")?;
466 }
467
468 write_file(&main_path, &contents)
469 .with_context(|| format!("Failed to write {}", main_path.display()))?;
470 print_success("Wired OpenAPI into src/main.rs");
471 Ok(())
472}
473
474fn insert_openapi_into_app_builder(mut contents: String, config_ref: &str) -> Result<String> {
475 if contents.contains("create_openapi_router") {
476 return Ok(contents);
477 }
478
479 if let Some(pos) = find_app_builder_start(&contents) {
480 let app_var =
481 find_app_builder_var_name(&contents, pos).unwrap_or_else(|| "app".to_string());
482 if let Some(insert_at) = find_app_builder_end_insert_at(&contents, pos) {
484 let block = format!(
485 "\n #[cfg(feature = \"openapi\")]\n if {config_ref}.openapi.enabled {{\n let openapi = tideway::openapi_merge_module!(openapi_docs, ApiDoc);\n let openapi_router = tideway::openapi::create_openapi_router(openapi, &{config_ref}.openapi);\n {app_var} = {app_var}.merge_router(openapi_router);\n }}\n"
486 );
487 contents.insert_str(insert_at, &block);
488 } else {
489 print_warning("Could not find app builder termination; skipping OpenAPI wiring");
490 }
491 Ok(contents)
492 } else {
493 print_warning("Could not find app builder; skipping OpenAPI wiring");
494 Ok(contents)
495 }
496}
497
498fn find_app_builder_start(contents: &str) -> Option<usize> {
499 if let Some(marker_pos) = contents.find(APP_BUILDER_START_MARKER) {
500 if let Some(line_end) = contents[marker_pos..].find('\n') {
501 return Some(marker_pos + line_end + 1);
502 }
503 }
504 let mut search_from = 0;
505 while let Some(rel_pos) = contents[search_from..].find(" = App::") {
506 let abs_pos = search_from + rel_pos;
507 let line_start = contents[..abs_pos]
508 .rfind('\n')
509 .map(|idx| idx + 1)
510 .unwrap_or(0);
511 if find_app_builder_var_name(contents, line_start).is_some() {
512 return Some(line_start);
513 }
514 search_from = abs_pos + 1;
515 }
516 None
517}
518
519fn find_app_builder_var_name(contents: &str, start_pos: usize) -> Option<String> {
520 let line_end = contents[start_pos..]
521 .find('\n')
522 .map(|idx| start_pos + idx)
523 .unwrap_or(contents.len());
524 let line = contents[start_pos..line_end].trim();
525
526 if !line.starts_with("let ") || !line.contains("= App::") {
527 return None;
528 }
529
530 let after_let = line.trim_start_matches("let ").trim();
531 let before_eq = after_let.split('=').next()?.trim();
532 let var = before_eq.strip_prefix("mut ").unwrap_or(before_eq).trim();
533 if var.is_empty() {
534 None
535 } else {
536 Some(var.to_string())
537 }
538}
539
540fn find_app_builder_end_insert_at(contents: &str, start_pos: usize) -> Option<usize> {
541 if let Some(marker_pos) = contents.find(APP_BUILDER_END_MARKER) {
542 if marker_pos >= start_pos {
543 let marker_line_start = contents[..marker_pos]
544 .rfind('\n')
545 .map(|idx| idx + 1)
546 .unwrap_or(0);
547 if let Some(marker_line_end_rel) = contents[marker_line_start..].find('\n') {
548 return Some(marker_line_start + marker_line_end_rel + 1);
549 }
550 return Some(contents.len());
551 }
552 }
553 find_statement_terminator(contents, start_pos).map(|idx| idx + 1)
554}
555
556fn find_statement_terminator(contents: &str, start_pos: usize) -> Option<usize> {
557 let bytes = contents.as_bytes();
558 let mut i = start_pos;
559 let mut paren_depth = 0usize;
560 let mut brace_depth = 0usize;
561 let mut bracket_depth = 0usize;
562 let mut in_single_quote = false;
563 let mut in_double_quote = false;
564 let mut escape = false;
565
566 while i < bytes.len() {
567 let b = bytes[i];
568
569 if !in_single_quote
571 && !in_double_quote
572 && i + 1 < bytes.len()
573 && bytes[i] == b'/'
574 && bytes[i + 1] == b'/'
575 {
576 while i < bytes.len() && bytes[i] != b'\n' {
577 i += 1;
578 }
579 continue;
580 }
581
582 if escape {
583 escape = false;
584 i += 1;
585 continue;
586 }
587
588 if in_single_quote {
589 if b == b'\\' {
590 escape = true;
591 } else if b == b'\'' {
592 in_single_quote = false;
593 }
594 i += 1;
595 continue;
596 }
597
598 if in_double_quote {
599 if b == b'\\' {
600 escape = true;
601 } else if b == b'"' {
602 in_double_quote = false;
603 }
604 i += 1;
605 continue;
606 }
607
608 match b {
609 b'\'' => in_single_quote = true,
610 b'"' => in_double_quote = true,
611 b'(' => paren_depth += 1,
612 b')' => paren_depth = paren_depth.saturating_sub(1),
613 b'{' => brace_depth += 1,
614 b'}' => brace_depth = brace_depth.saturating_sub(1),
615 b'[' => bracket_depth += 1,
616 b']' => bracket_depth = bracket_depth.saturating_sub(1),
617 b';' if paren_depth == 0 && brace_depth == 0 && bracket_depth == 0 => return Some(i),
618 _ => {}
619 }
620
621 i += 1;
622 }
623 None
624}
625
626fn ensure_openapi_docs_file(project_dir: &Path) -> Result<()> {
627 let docs_path = project_dir.join("src").join("openapi_docs.rs");
628 if docs_path.exists() {
629 return Ok(());
630 }
631
632 let contents = r#"#[cfg(feature = "openapi")]
633tideway::openapi_doc!(pub(crate) ApiDoc, paths());
634"#;
635
636 if let Some(parent) = docs_path.parent() {
637 ensure_dir(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
638 }
639
640 write_file(&docs_path, &contents)
641 .with_context(|| format!("Failed to write {}", docs_path.display()))?;
642 print_success("Created src/openapi_docs.rs");
643 Ok(())
644}
645
646fn write_file_with_force(path: &Path, contents: &str, force: bool) -> Result<()> {
647 if path.exists() && !force {
648 print_warning(&format!(
649 "Skipping {} (use --force to overwrite)",
650 path.display()
651 ));
652 return Ok(());
653 }
654
655 if let Some(parent) = path.parent() {
656 ensure_dir(parent).with_context(|| format!("Failed to create {}", parent.display()))?;
657 }
658
659 write_file(path, contents).with_context(|| format!("Failed to write {}", path.display()))?;
660 Ok(())
661}
662
663fn project_name_from_cargo(contents: &str, project_dir: &Path) -> String {
664 let doc = contents
665 .parse::<toml_edit::DocumentMut>()
666 .ok()
667 .and_then(|doc| doc["package"]["name"].as_str().map(|s| s.to_string()));
668
669 doc.unwrap_or_else(|| {
670 project_dir
671 .file_name()
672 .and_then(|n| n.to_str())
673 .unwrap_or("my_app")
674 .to_string()
675 })
676 .replace('-', "_")
677}
678
679fn to_pascal_case(s: &str) -> String {
680 s.split('_')
681 .filter(|part| !part.is_empty())
682 .map(|word| {
683 let mut chars = word.chars();
684 match chars.next() {
685 None => String::new(),
686 Some(first) => first.to_uppercase().chain(chars).collect(),
687 }
688 })
689 .collect()
690}
691
692pub fn array_value(values: &[&str]) -> toml_edit::Value {
693 let mut array = toml_edit::Array::new();
694 for value in values {
695 array.push(*value);
696 }
697 toml_edit::Value::Array(array)
698}