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    // Database-less scaffolds omit `src/bin/phoenix-manage.rs`; only build it when present.
53    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-manifest.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-manifest.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    // manage_path is already Option<PathBuf>
137    .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}