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::{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        // Insert after app builder block to keep code readable.
483        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        // Skip line comments.
570        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}