Skip to main content

phoenix_cli/
release.rs

1#![allow(
2    clippy::missing_errors_doc,
3    clippy::needless_pass_by_value,
4    clippy::too_many_lines,
5    clippy::map_unwrap_or
6)]
7
8use std::{
9    env,
10    ffi::OsString,
11    fs,
12    path::{Path, PathBuf},
13    process::Command,
14};
15
16use phoenix_release::{
17    DeployStatus, InstallOptions, InstallSource, PackOptions, ReleaseError, RollbackOptions,
18    StagingSources, create_tarball, extract_tarball, install, rollback, status, write_staging,
19};
20
21use crate::ProjectGenerator;
22
23pub fn release_build(args: Vec<String>) -> Result<(), String> {
24    let options = parse_build_args(args)?;
25    let generator = ProjectGenerator::discover(env::current_dir().map_err(|e| e.to_string())?)
26        .map_err(|e| e.to_string())?;
27    let root = generator.root();
28
29    let package = read_cargo_package(root)?;
30    let version = options
31        .version
32        .clone()
33        .unwrap_or_else(|| package.version.clone());
34    let binary_name = options
35        .bin
36        .clone()
37        .unwrap_or_else(|| package.default_run.unwrap_or(package.name.clone()));
38    let target_triple = options.target.clone().unwrap_or_else(host_triple);
39    let output = options
40        .output
41        .clone()
42        .unwrap_or_else(|| root.join("dist/releases").join(&version));
43    let staging_dir = output.join("staging");
44
45    if !options.skip_types && root.join("node_modules").is_dir() {
46        run_command("npm", &["run", "types"], root, "npm run types")?;
47    }
48    if !options.skip_npm && root.join("node_modules").is_dir() {
49        run_command("npm", &["run", "build"], root, "npm run build")?;
50    }
51
52    let mut cargo_args = vec!["build".to_owned(), "--release".to_owned()];
53    if let Some(target) = &options.target {
54        cargo_args.push("--target".to_owned());
55        cargo_args.push(target.clone());
56    }
57    cargo_args.push("--bin".to_owned());
58    cargo_args.push(binary_name.clone());
59    cargo_args.push("--bin".to_owned());
60    cargo_args.push("phoenix-manage".to_owned());
61
62    let cargo = env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"));
63    run_command_strings(
64        &cargo.to_string_lossy(),
65        &cargo_args,
66        root,
67        "cargo build --release",
68    )?;
69
70    let release_dir = release_profile_dir(root, options.target.as_deref());
71    let binary_path = release_dir.join(&binary_name);
72    if !binary_path.is_file() {
73        return Err(format!(
74            "release binary missing at {}",
75            binary_path.display()
76        ));
77    }
78    let manage_path = release_dir.join("phoenix-manage");
79    if !manage_path.is_file() {
80        return Err(format!(
81            "phoenix-manage binary missing at {}",
82            manage_path.display()
83        ));
84    }
85
86    let public_assets = ensure_dir(root.join("public/assets"))?;
87    let public_ssr = ensure_dir(root.join("public/ssr"))?;
88    let config = ensure_dir(root.join("config"))?;
89    let migrations = ensure_dir(root.join("database/migrations"))?;
90
91    let client_manifest = root.join("public/assets/phoenix-manifest.json");
92    let ssr_manifest = root.join("public/ssr/phoenix-manifest.json");
93    let contract_hash = read_json_str_field(&client_manifest, "contract_hash")
94        .or_else(|| read_json_str_field(&ssr_manifest, "contract_hash"));
95
96    let manifest = write_staging(
97        &PackOptions {
98            version: version.clone(),
99            app_name: package.name.clone(),
100            binary_name: binary_name.clone(),
101            target_triple: target_triple.clone(),
102            staging_dir: staging_dir.clone(),
103            git_revision: git_revision(root),
104            client_manifest: read_json_str_field(&client_manifest, "version")
105                .map(|_| "public/assets/phoenix-manifest.json".to_owned()),
106            ssr_manifest: read_json_str_field(&ssr_manifest, "version")
107                .map(|_| "public/ssr/phoenix-manifest.json".to_owned()),
108            contract_hash,
109            rustc_version: rustc_version(),
110            profile: Some("release".to_owned()),
111            npm_build: if options.skip_npm {
112                None
113            } else {
114                Some("npm run build".to_owned())
115            },
116        },
117        &StagingSources {
118            binary: binary_path,
119            phoenix_manage: manage_path,
120            public_assets,
121            public_ssr,
122            config,
123            migrations,
124        },
125    )
126    .map_err(release_err)?;
127
128    println!("STAGING {}", staging_dir.display());
129    println!("MANIFEST {}", staging_dir.join("manifest.toml").display());
130    println!("VERSION {}", manifest.version);
131
132    if options.tarball {
133        fs::create_dir_all(&output).map_err(|e| e.to_string())?;
134        let tarball = output.join(format!("{}-{}.tar.gz", package.name, version));
135        let digest = create_tarball(&staging_dir, &tarball).map_err(release_err)?;
136        println!("TARBALL {}", tarball.display());
137        println!("SHA256 {digest}");
138    }
139
140    Ok(())
141}
142
143pub fn release_install(args: Vec<String>) -> Result<(), String> {
144    let options = parse_install_args(args)?;
145    let deploy_root = resolve_deploy_root(options.deploy_root)?;
146    let version = resolve_install_version(options.version.as_deref(), &options.source)?;
147    let source = match options.source {
148        InstallSourceKind::Tarball(path) => InstallSource::Tarball(path),
149        InstallSourceKind::Path(path) => InstallSource::Path(path),
150    };
151
152    let report = install(InstallOptions {
153        deploy_root: deploy_root.clone(),
154        version: version.clone(),
155        source,
156        skip_migrate: options.skip_migrate,
157        no_switch: options.no_switch,
158        restart_cmd: options.restart_cmd.clone(),
159        dry_run: options.dry_run,
160    })
161    .map_err(release_err)?;
162
163    if options.dry_run {
164        println!("DRY RUN install {version} into {}", deploy_root.display());
165    }
166    println!("INSTALLED {}", report.version);
167    println!("RELEASE_DIR {}", report.release_dir.display());
168    if let Some(previous) = report.previous_version {
169        println!("PREVIOUS {previous}");
170    }
171    println!(
172        "SWITCHED={} MIGRATED={} RESTARTED={}",
173        report.switched, report.migrated, report.restarted
174    );
175    Ok(())
176}
177
178pub fn release_rollback(args: Vec<String>) -> Result<(), String> {
179    let options = parse_rollback_args(args)?;
180    let deploy_root = resolve_deploy_root(options.deploy_root)?;
181
182    let report = rollback(RollbackOptions {
183        deploy_root: deploy_root.clone(),
184        to: options.to,
185        steps: options.steps,
186        restart_cmd: options.restart_cmd,
187        skip_restart: options.skip_restart,
188        dry_run: options.dry_run,
189    })
190    .map_err(release_err)?;
191
192    if options.dry_run {
193        println!("DRY RUN rollback on {}", deploy_root.display());
194    }
195    if let Some(from) = report.from {
196        println!("FROM {from}");
197    }
198    println!("TO {}", report.to);
199    println!("RESTARTED={}", report.restarted);
200    Ok(())
201}
202
203pub fn release_status(args: Vec<String>) -> Result<(), String> {
204    let options = parse_status_args(args)?;
205    let deploy_root = resolve_deploy_root(options.deploy_root)?;
206    let snapshot = status(&deploy_root).map_err(release_err)?;
207
208    if options.json {
209        print_status_json(&snapshot);
210    } else {
211        print_status_human(&deploy_root, &snapshot);
212    }
213    Ok(())
214}
215
216struct CargoPackage {
217    name: String,
218    version: String,
219    default_run: Option<String>,
220}
221
222struct BuildOptions {
223    version: Option<String>,
224    output: Option<PathBuf>,
225    tarball: bool,
226    bin: Option<String>,
227    skip_npm: bool,
228    skip_types: bool,
229    target: Option<String>,
230}
231
232enum InstallSourceKind {
233    Tarball(PathBuf),
234    Path(PathBuf),
235}
236
237struct InstallOptionsParsed {
238    deploy_root: Option<PathBuf>,
239    source: InstallSourceKind,
240    version: Option<String>,
241    skip_migrate: bool,
242    no_switch: bool,
243    restart_cmd: Option<String>,
244    dry_run: bool,
245}
246
247struct RollbackOptionsParsed {
248    deploy_root: Option<PathBuf>,
249    to: Option<String>,
250    steps: usize,
251    restart_cmd: Option<String>,
252    skip_restart: bool,
253    dry_run: bool,
254}
255
256struct StatusOptions {
257    deploy_root: Option<PathBuf>,
258    json: bool,
259}
260
261fn parse_build_args(args: Vec<String>) -> Result<BuildOptions, String> {
262    let mut options = BuildOptions {
263        version: None,
264        output: None,
265        tarball: false,
266        bin: None,
267        skip_npm: false,
268        skip_types: false,
269        target: None,
270    };
271    let mut index = 0;
272    while index < args.len() {
273        match args[index].as_str() {
274            "--version" => {
275                index += 1;
276                options.version = Some(next_value(&args, index, "--version")?);
277            }
278            "--output" => {
279                index += 1;
280                options.output = Some(PathBuf::from(next_value(&args, index, "--output")?));
281            }
282            "--tarball" => options.tarball = true,
283            "--bin" => {
284                index += 1;
285                options.bin = Some(next_value(&args, index, "--bin")?);
286            }
287            "--skip-npm" => options.skip_npm = true,
288            "--skip-types" => options.skip_types = true,
289            "--target" => {
290                index += 1;
291                options.target = Some(next_value(&args, index, "--target")?);
292            }
293            flag if flag.starts_with("--version=") => {
294                options.version = Some(flag["--version=".len()..].to_owned());
295            }
296            flag if flag.starts_with("--output=") => {
297                options.output = Some(PathBuf::from(flag["--output=".len()..].to_owned()));
298            }
299            flag if flag.starts_with("--bin=") => {
300                options.bin = Some(flag["--bin=".len()..].to_owned());
301            }
302            flag if flag.starts_with("--target=") => {
303                options.target = Some(flag["--target=".len()..].to_owned());
304            }
305            flag => return Err(format!("unknown release option `{flag}`")),
306        }
307        index += 1;
308    }
309    Ok(options)
310}
311
312fn parse_install_args(args: Vec<String>) -> Result<InstallOptionsParsed, String> {
313    let mut options = InstallOptionsParsed {
314        deploy_root: None,
315        source: InstallSourceKind::Path(PathBuf::from(".")),
316        version: None,
317        skip_migrate: false,
318        no_switch: false,
319        restart_cmd: None,
320        dry_run: false,
321    };
322    let mut has_source = false;
323    let mut index = 0;
324    while index < args.len() {
325        match args[index].as_str() {
326            "--deploy-root" => {
327                index += 1;
328                options.deploy_root =
329                    Some(PathBuf::from(next_value(&args, index, "--deploy-root")?));
330            }
331            "--tarball" => {
332                index += 1;
333                let path = PathBuf::from(next_value(&args, index, "--tarball")?);
334                options.source = InstallSourceKind::Tarball(path);
335                has_source = true;
336            }
337            "--path" => {
338                index += 1;
339                let path = PathBuf::from(next_value(&args, index, "--path")?);
340                options.source = InstallSourceKind::Path(path);
341                has_source = true;
342            }
343            "--version" => {
344                index += 1;
345                options.version = Some(next_value(&args, index, "--version")?);
346            }
347            "--skip-migrate" => options.skip_migrate = true,
348            "--no-switch" => options.no_switch = true,
349            "--restart-cmd" => {
350                index += 1;
351                options.restart_cmd = Some(next_value(&args, index, "--restart-cmd")?);
352            }
353            "--dry-run" => options.dry_run = true,
354            flag if flag.starts_with("--deploy-root=") => {
355                options.deploy_root =
356                    Some(PathBuf::from(flag["--deploy-root=".len()..].to_owned()));
357            }
358            flag if flag.starts_with("--tarball=") => {
359                options.source = InstallSourceKind::Tarball(PathBuf::from(
360                    flag["--tarball=".len()..].to_owned(),
361                ));
362                has_source = true;
363            }
364            flag if flag.starts_with("--path=") => {
365                options.source =
366                    InstallSourceKind::Path(PathBuf::from(flag["--path=".len()..].to_owned()));
367                has_source = true;
368            }
369            flag if flag.starts_with("--version=") => {
370                options.version = Some(flag["--version=".len()..].to_owned());
371            }
372            flag if flag.starts_with("--restart-cmd=") => {
373                options.restart_cmd = Some(flag["--restart-cmd=".len()..].to_owned());
374            }
375            flag => return Err(format!("unknown release:install option `{flag}`")),
376        }
377        index += 1;
378    }
379    if !has_source {
380        return Err("release:install requires --tarball <path> or --path <dir>".to_owned());
381    }
382    Ok(options)
383}
384
385fn parse_rollback_args(args: Vec<String>) -> Result<RollbackOptionsParsed, String> {
386    let mut options = RollbackOptionsParsed {
387        deploy_root: None,
388        to: None,
389        steps: 1,
390        restart_cmd: None,
391        skip_restart: false,
392        dry_run: false,
393    };
394    let mut index = 0;
395    while index < args.len() {
396        match args[index].as_str() {
397            "--deploy-root" => {
398                index += 1;
399                options.deploy_root =
400                    Some(PathBuf::from(next_value(&args, index, "--deploy-root")?));
401            }
402            "--to" => {
403                index += 1;
404                options.to = Some(next_value(&args, index, "--to")?);
405            }
406            "--steps" => {
407                index += 1;
408                options.steps = parse_steps(&next_value(&args, index, "--steps")?)?;
409            }
410            "--restart-cmd" => {
411                index += 1;
412                options.restart_cmd = Some(next_value(&args, index, "--restart-cmd")?);
413            }
414            "--skip-restart" => options.skip_restart = true,
415            "--dry-run" => options.dry_run = true,
416            flag if flag.starts_with("--deploy-root=") => {
417                options.deploy_root =
418                    Some(PathBuf::from(flag["--deploy-root=".len()..].to_owned()));
419            }
420            flag if flag.starts_with("--to=") => {
421                options.to = Some(flag["--to=".len()..].to_owned());
422            }
423            flag if flag.starts_with("--steps=") => {
424                options.steps = parse_steps(&flag["--steps=".len()..])?;
425            }
426            flag if flag.starts_with("--restart-cmd=") => {
427                options.restart_cmd = Some(flag["--restart-cmd=".len()..].to_owned());
428            }
429            flag => return Err(format!("unknown release:rollback option `{flag}`")),
430        }
431        index += 1;
432    }
433    Ok(options)
434}
435
436fn parse_status_args(args: Vec<String>) -> Result<StatusOptions, String> {
437    let mut options = StatusOptions {
438        deploy_root: None,
439        json: false,
440    };
441    let mut index = 0;
442    while index < args.len() {
443        match args[index].as_str() {
444            "--deploy-root" => {
445                index += 1;
446                options.deploy_root =
447                    Some(PathBuf::from(next_value(&args, index, "--deploy-root")?));
448            }
449            "--json" => options.json = true,
450            flag if flag.starts_with("--deploy-root=") => {
451                options.deploy_root =
452                    Some(PathBuf::from(flag["--deploy-root=".len()..].to_owned()));
453            }
454            flag => return Err(format!("unknown release:status option `{flag}`")),
455        }
456        index += 1;
457    }
458    Ok(options)
459}
460
461fn next_value(args: &[String], index: usize, flag: &str) -> Result<String, String> {
462    args.get(index)
463        .cloned()
464        .ok_or_else(|| format!("{flag} requires a value"))
465}
466
467fn parse_steps(value: &str) -> Result<usize, String> {
468    value
469        .parse::<usize>()
470        .ok()
471        .filter(|steps| *steps > 0)
472        .ok_or_else(|| "rollback steps must be a positive integer".to_owned())
473}
474
475fn resolve_deploy_root(explicit: Option<PathBuf>) -> Result<PathBuf, String> {
476    if let Some(path) = explicit {
477        return Ok(path);
478    }
479    env::var("PHOENIX_DEPLOY_ROOT")
480        .map(PathBuf::from)
481        .map_err(|_| {
482            "deploy root is required; pass --deploy-root or set PHOENIX_DEPLOY_ROOT".to_owned()
483        })
484}
485
486fn resolve_install_version(
487    explicit: Option<&str>,
488    source: &InstallSourceKind,
489) -> Result<String, String> {
490    if let Some(version) = explicit {
491        return Ok(version.to_owned());
492    }
493    match source {
494        InstallSourceKind::Path(path) => manifest_version_at(path),
495        InstallSourceKind::Tarball(path) => {
496            let temp = env::temp_dir().join(format!(
497                "px-release-install-{}-{}",
498                std::process::id(),
499                path.file_name()
500                    .and_then(|name| name.to_str())
501                    .unwrap_or("tarball")
502            ));
503            if temp.exists() {
504                fs::remove_dir_all(&temp).map_err(|e| e.to_string())?;
505            }
506            extract_tarball(path, &temp).map_err(release_err)?;
507            let version = manifest_version_at(&temp)?;
508            let _ = fs::remove_dir_all(&temp);
509            Ok(version)
510        }
511    }
512}
513
514fn manifest_version_at(path: &Path) -> Result<String, String> {
515    let manifest_path = path.join("manifest.toml");
516    if !manifest_path.is_file() {
517        return Err(format!(
518            "manifest missing at {}; pass --version explicitly",
519            manifest_path.display()
520        ));
521    }
522    let text = fs::read_to_string(&manifest_path).map_err(|e| e.to_string())?;
523    for line in text.lines() {
524        let line = line.trim();
525        if line.starts_with("version = ") {
526            return parse_toml_string_value(line.strip_prefix("version = ").unwrap_or(line));
527        }
528    }
529    Err(format!(
530        "could not read version from {}; pass --version explicitly",
531        manifest_path.display()
532    ))
533}
534
535fn read_cargo_package(root: &Path) -> Result<CargoPackage, String> {
536    let cargo_toml = root.join("Cargo.toml");
537    let text = fs::read_to_string(&cargo_toml)
538        .map_err(|error| format!("failed to read {}: {error}", cargo_toml.display()))?;
539    let mut in_package = false;
540    let mut name = None;
541    let mut version = None;
542    let mut default_run = None;
543    for line in text.lines() {
544        let trimmed = line.trim();
545        if trimmed.starts_with('[') && trimmed.ends_with(']') {
546            in_package = trimmed == "[package]";
547            continue;
548        }
549        if !in_package {
550            continue;
551        }
552        if let Some(value) = trimmed.strip_prefix("name = ") {
553            name = Some(parse_toml_string_value(value)?);
554        } else if let Some(value) = trimmed.strip_prefix("version = ") {
555            version = Some(parse_toml_string_value(value)?);
556        } else if let Some(value) = trimmed.strip_prefix("default-run = ") {
557            default_run = Some(parse_toml_string_value(value)?);
558        }
559    }
560    Ok(CargoPackage {
561        name: name.ok_or("Cargo.toml [package] name is missing")?,
562        version: version.ok_or("Cargo.toml [package] version is missing")?,
563        default_run,
564    })
565}
566
567fn parse_toml_string_value(raw: &str) -> Result<String, String> {
568    let raw = raw.trim();
569    if raw.starts_with('"') && raw.ends_with('"') && raw.len() >= 2 {
570        Ok(raw[1..raw.len() - 1].to_owned())
571    } else {
572        Err(format!("expected quoted TOML string, got `{raw}`"))
573    }
574}
575
576fn release_profile_dir(root: &Path, target: Option<&str>) -> PathBuf {
577    match target {
578        Some(triple) => root.join("target").join(triple).join("release"),
579        None => root.join("target/release"),
580    }
581}
582
583fn ensure_dir(path: PathBuf) -> Result<PathBuf, String> {
584    if !path.exists() {
585        fs::create_dir_all(&path)
586            .map_err(|error| format!("failed to create directory {}: {error}", path.display()))?;
587    }
588    Ok(path)
589}
590
591fn run_command(program: &str, args: &[&str], cwd: &Path, label: &str) -> Result<(), String> {
592    let status = Command::new(program)
593        .args(args)
594        .current_dir(cwd)
595        .status()
596        .map_err(|error| format!("failed to start `{label}`: {error}"))?;
597    if status.success() {
598        Ok(())
599    } else {
600        Err(format!("`{label}` exited with {status}"))
601    }
602}
603
604fn run_command_strings(
605    program: &str,
606    args: &[String],
607    cwd: &Path,
608    label: &str,
609) -> Result<(), String> {
610    let status = Command::new(program)
611        .args(args)
612        .current_dir(cwd)
613        .status()
614        .map_err(|error| format!("failed to start `{label}`: {error}"))?;
615    if status.success() {
616        Ok(())
617    } else {
618        Err(format!("`{label}` exited with {status}"))
619    }
620}
621
622fn host_triple() -> String {
623    rustc_metadata()
624        .and_then(|text| {
625            text.lines()
626                .find(|line| line.starts_with("host: "))
627                .map(|line| line["host: ".len()..].trim().to_owned())
628        })
629        .unwrap_or_else(|| "unknown".to_owned())
630}
631
632fn rustc_version() -> Option<String> {
633    rustc_metadata().and_then(|text| {
634        text.lines()
635            .find(|line| line.starts_with("release: "))
636            .map(|line| line["release: ".len()..].trim().to_owned())
637    })
638}
639
640fn rustc_metadata() -> Option<String> {
641    Command::new("rustc")
642        .arg("-vV")
643        .output()
644        .ok()
645        .filter(|output| output.status.success())
646        .and_then(|output| String::from_utf8(output.stdout).ok())
647}
648
649fn git_revision(root: &Path) -> Option<String> {
650    Command::new("git")
651        .args(["rev-parse", "HEAD"])
652        .current_dir(root)
653        .output()
654        .ok()
655        .filter(|output| output.status.success())
656        .and_then(|output| String::from_utf8(output.stdout).ok())
657        .map(|text| text.trim().to_owned())
658        .filter(|text| !text.is_empty())
659}
660
661fn read_json_str_field(path: &Path, field: &str) -> Option<String> {
662    if !path.is_file() {
663        return None;
664    }
665    let text = fs::read_to_string(path).ok()?;
666    let value: serde_json::Value = serde_json::from_str(&text).ok()?;
667    value
668        .get(field)
669        .and_then(|entry| entry.as_str())
670        .map(ToOwned::to_owned)
671}
672
673fn print_status_human(deploy_root: &Path, snapshot: &DeployStatus) {
674    println!("DEPLOY_ROOT {}", deploy_root.display());
675    println!(
676        "CURRENT {}",
677        snapshot.current_version.as_deref().unwrap_or("(none)")
678    );
679    println!(
680        "PREVIOUS {}",
681        snapshot.previous_version.as_deref().unwrap_or("(none)")
682    );
683    println!("LOCKED={}", snapshot.locked);
684    if snapshot.releases.is_empty() {
685        println!("RELEASES (none)");
686        return;
687    }
688    println!("RELEASES");
689    for release in &snapshot.releases {
690        println!(
691            "  {} application={} migrations={} created_at={}",
692            release.version,
693            release.application.as_deref().unwrap_or("-"),
694            release
695                .migration_count
696                .map(|count| count.to_string())
697                .unwrap_or_else(|| "-".to_owned()),
698            release.created_at.as_deref().unwrap_or("-"),
699        );
700    }
701}
702
703fn print_status_json(snapshot: &DeployStatus) {
704    let releases: Vec<serde_json::Value> = snapshot
705        .releases
706        .iter()
707        .map(|release| {
708            serde_json::json!({
709                "version": release.version,
710                "application": release.application,
711                "migration_count": release.migration_count,
712                "created_at": release.created_at,
713            })
714        })
715        .collect();
716    let value = serde_json::json!({
717        "current_version": snapshot.current_version,
718        "previous_version": snapshot.previous_version,
719        "locked": snapshot.locked,
720        "releases": releases,
721    });
722    println!(
723        "{}",
724        serde_json::to_string_pretty(&value).unwrap_or_default()
725    );
726}
727
728fn release_err(error: ReleaseError) -> String {
729    error.to_string()
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    #[test]
737    fn parse_build_flags() {
738        let options = parse_build_args(vec![
739            "--version".into(),
740            "1.2.3".into(),
741            "--tarball".into(),
742            "--skip-npm".into(),
743        ])
744        .unwrap();
745        assert_eq!(options.version.as_deref(), Some("1.2.3"));
746        assert!(options.tarball);
747        assert!(options.skip_npm);
748    }
749
750    #[test]
751    fn parse_install_requires_source() {
752        assert!(parse_install_args(vec![]).is_err());
753    }
754
755    #[test]
756    fn parse_toml_string() {
757        assert_eq!(parse_toml_string_value("\"demo\"").unwrap(), "demo");
758    }
759}