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