Skip to main content

tideway_cli/commands/
add.rs

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