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}