Skip to main content

cargo_nextrs/
lib.rs

1use std::env;
2use std::ffi::{OsStr, OsString};
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::process::{Command, ExitCode};
6
7use serde_json::Value;
8
9const DEFAULT_CLIENT_DIR: &str = ".nextrs/client";
10
11/// Run a nextrs CLI launcher and translate its result into a process exit code.
12///
13/// `command_name` only controls the error prefix, allowing `cargo nextrs` and
14/// `nextrs` to share the exact same command implementation.
15pub fn main_with_args(command_name: &str, args: impl IntoIterator<Item = OsString>) -> ExitCode {
16    match run_with_args(args) {
17        Ok(()) => ExitCode::SUCCESS,
18        Err(error) => {
19            eprintln!("{command_name}: {error}");
20            ExitCode::FAILURE
21        }
22    }
23}
24
25/// Dispatch a nextrs CLI argument list.
26pub fn run_with_args(args: impl IntoIterator<Item = OsString>) -> Result<(), String> {
27    let command = CommandLine::parse(args)?;
28    match command {
29        CommandLine::Help => {
30            print_help();
31            Ok(())
32        }
33        CommandLine::New(args) => create_app(args),
34        CommandLine::Dev(args) => {
35            prepare_generated_client_for_dev()?;
36            cargo_nextrs_dev::run_with_args(args).map_err(io_error)
37        }
38        CommandLine::ClientGenerate(options) => generate_client(options),
39    }
40}
41
42#[derive(Debug, PartialEq, Eq)]
43enum CommandLine {
44    Help,
45    New(Vec<OsString>),
46    Dev(Vec<OsString>),
47    ClientGenerate(GenerateOptions),
48}
49
50#[derive(Debug, PartialEq, Eq)]
51struct GenerateOptions {
52    root: PathBuf,
53    client_dir: PathBuf,
54    config: Option<PathBuf>,
55}
56
57impl CommandLine {
58    fn parse(args: impl IntoIterator<Item = OsString>) -> Result<Self, String> {
59        let mut args = args.into_iter().peekable();
60        if matches!(args.peek().map(OsString::as_os_str), Some(arg) if arg == "nextrs") {
61            args.next();
62        }
63
64        let Some(first) = args.next() else {
65            return Ok(Self::Help);
66        };
67        if matches!(first.to_str(), Some("-h" | "--help" | "help")) {
68            return Ok(Self::Help);
69        }
70        if first == "new" {
71            return Ok(Self::New(args.collect()));
72        }
73        if first == "dev" {
74            return Ok(Self::Dev(args.collect()));
75        }
76        if first != "client" {
77            return Err(format!("unknown command `{}`", first.to_string_lossy()));
78        }
79
80        let Some(action) = args.next() else {
81            return Err("missing client command; expected `generate`".into());
82        };
83        if action != "generate" {
84            return Err(format!(
85                "unknown client command `{}`; expected `generate`",
86                action.to_string_lossy()
87            ));
88        }
89
90        let mut root = PathBuf::from(".");
91        let mut client_dir = PathBuf::from(DEFAULT_CLIENT_DIR);
92        let mut config = None;
93        while let Some(arg) = args.next() {
94            match arg.to_str() {
95                Some("--root") => root = required_path(&mut args, "--root")?,
96                Some("--client-dir") => client_dir = required_path(&mut args, "--client-dir")?,
97                Some("--config") => config = Some(required_path(&mut args, "--config")?),
98                Some("-h" | "--help") => return Ok(Self::Help),
99                Some(flag) if flag.starts_with('-') => {
100                    return Err(format!("unknown option `{flag}`"));
101                }
102                _ => return Err(format!("unexpected argument `{}`", arg.to_string_lossy())),
103            }
104        }
105
106        Ok(Self::ClientGenerate(GenerateOptions {
107            root,
108            client_dir,
109            config,
110        }))
111    }
112}
113
114fn create_app(args: Vec<OsString>) -> Result<(), String> {
115    let args = args
116        .into_iter()
117        .map(|arg| {
118            arg.into_string()
119                .map_err(|arg| format!("new arguments must be valid UTF-8: {arg:?}"))
120        })
121        .collect::<Result<Vec<_>, _>>()?;
122    create_nextrs_app::run_with_args_named("nextrs new", args).map_err(io_error)
123}
124
125fn required_path(args: &mut impl Iterator<Item = OsString>, flag: &str) -> Result<PathBuf, String> {
126    args.next()
127        .map(PathBuf::from)
128        .ok_or_else(|| format!("{flag} requires a path"))
129}
130
131fn generate_client(options: GenerateOptions) -> Result<(), String> {
132    let root = absolutize(&env::current_dir().map_err(io_error)?, &options.root);
133    let root_package_json = root.join("package.json");
134    if !root_package_json.is_file() {
135        return Err(format!(
136            "{} does not exist; run this from a nextrs app root or pass --root",
137            root_package_json.display()
138        ));
139    }
140
141    let custom_client_dir = options.client_dir != Path::new(DEFAULT_CLIENT_DIR);
142    let requested_client_dir = absolutize(&root, &options.client_dir);
143    if !custom_client_dir
144        && !requested_client_dir.join("package.json").is_file()
145        && root_declares_generated_client(&root_package_json)?
146    {
147        eprintln!("nextrs: materializing the ignored generated-client package");
148        execute(&root, "npm", &["run", "client:ensure"], None)?;
149    }
150    let legacy_client_dir = root.join("client");
151    let client_dir = if !custom_client_dir
152        && !requested_client_dir.join("package.json").is_file()
153        && legacy_client_dir.join("package.json").is_file()
154    {
155        eprintln!(
156            "nextrs: using legacy client directory {}; regenerate the app scaffold to move it to {}",
157            legacy_client_dir.display(),
158            requested_client_dir.display()
159        );
160        legacy_client_dir
161    } else {
162        requested_client_dir
163    };
164    let package_json = client_dir.join("package.json");
165    if !package_json.is_file() {
166        return Err(format!(
167            "{} does not exist after client materialization; restore `.nextrs/ensure-client.mjs` and `.nextrs/template/client` from a fresh `nextrs new` app, or pass --client-dir for a legacy client",
168            package_json.display(),
169        ));
170    }
171
172    let modern_package = if !custom_client_dir && client_dir == root.join(DEFAULT_CLIENT_DIR) {
173        let package = read_client_package(&package_json)?;
174        validate_root_client_contract(&root_package_json, &package.name)?;
175        warn_on_mixed_package_managers(&root);
176        ensure_root_client_install(&root, &client_dir, &package)?;
177        Some(package)
178    } else {
179        if !root.join("node_modules").is_dir() {
180            eprintln!("nextrs: installing application dependencies at the app root");
181            execute(&root, "npm", &["install"], None)?;
182        }
183        None
184    };
185
186    let default_config = client_dir.join("nextrs.client.json");
187    let config = options
188        .config
189        .map(|path| absolutize(&root, &path))
190        .or_else(|| default_config.is_file().then_some(default_config));
191
192    if let Some(config) = config {
193        if !config.is_file() {
194            return Err(format!(
195                "external client config not found: {}",
196                config.display()
197            ));
198        }
199        eprintln!(
200            "nextrs: generating internal client and publishing external client from {}",
201            config.display()
202        );
203        let config_arg = config.as_os_str();
204        execute(
205            &client_dir,
206            "npm",
207            &[
208                OsStr::new("run"),
209                OsStr::new("generate:external"),
210                OsStr::new("--"),
211                config_arg,
212            ],
213            None,
214        )?;
215    } else {
216        eprintln!("nextrs: generating client from the current Rust contract");
217        let (cwd, script) = normal_generation_target(&root, &client_dir, custom_client_dir);
218        execute(cwd, "npm", &["run", script], None)?;
219    }
220    if let Some(package) = modern_package {
221        validate_generated_client(&root, &client_dir, &package)?;
222        eprintln!(
223            "nextrs: verified {} through the root workspace link (JavaScript + declarations)",
224            package.name
225        );
226    }
227    Ok(())
228}
229
230fn prepare_generated_client_for_dev() -> Result<(), String> {
231    let root = env::current_dir().map_err(io_error)?;
232    let client_package = root.join(DEFAULT_CLIENT_DIR).join("package.json");
233    if client_package.is_file() {
234        eprintln!("nextrs: refreshing the generated client before starting dev");
235        generate_client(GenerateOptions {
236            root: PathBuf::from("."),
237            client_dir: PathBuf::from(DEFAULT_CLIENT_DIR),
238            config: None,
239        })?;
240    } else if root_declares_generated_client(&root.join("package.json"))? {
241        eprintln!("nextrs: materializing and refreshing the generated client before starting dev");
242        generate_client(GenerateOptions {
243            root: PathBuf::from("."),
244            client_dir: PathBuf::from(DEFAULT_CLIENT_DIR),
245            config: None,
246        })?;
247    }
248    Ok(())
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
252struct ClientPackage {
253    name: String,
254    exports: Vec<ClientExport>,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
258struct ClientExport {
259    subpath: &'static str,
260    types: PathBuf,
261    import: PathBuf,
262}
263
264fn read_client_package(path: &Path) -> Result<ClientPackage, String> {
265    let json = read_json(path)?;
266    let name = json
267        .get("name")
268        .and_then(Value::as_str)
269        .filter(|name| !name.is_empty())
270        .ok_or_else(|| format!("{} must contain a non-empty package name", path.display()))?
271        .to_string();
272    package_install_path(Path::new("node_modules"), &name)?;
273
274    let exports = [".", "./react-query"]
275        .into_iter()
276        .map(|subpath| {
277            let entry = json
278                .get("exports")
279                .and_then(|exports| exports.get(subpath))
280                .ok_or_else(|| {
281                    format!(
282                        "{} is missing the `{subpath}` package export",
283                        path.display()
284                    )
285                })?;
286            Ok(ClientExport {
287                subpath,
288                types: safe_export_path(path, subpath, entry, "types")?,
289                import: safe_export_path(path, subpath, entry, "import")?,
290            })
291        })
292        .collect::<Result<Vec<_>, String>>()?;
293
294    Ok(ClientPackage { name, exports })
295}
296
297fn safe_export_path(
298    package_json: &Path,
299    subpath: &str,
300    entry: &Value,
301    condition: &str,
302) -> Result<PathBuf, String> {
303    let value = entry
304        .get(condition)
305        .and_then(Value::as_str)
306        .ok_or_else(|| {
307            format!(
308                "{} export `{subpath}` must declare a `{condition}` target",
309                package_json.display()
310            )
311        })?;
312    let relative = value.strip_prefix("./").ok_or_else(|| {
313        format!(
314            "{} export `{subpath}` has unsafe `{condition}` target `{value}`",
315            package_json.display()
316        )
317    })?;
318    let path = PathBuf::from(relative);
319    if path.components().any(|component| {
320        matches!(
321            component,
322            std::path::Component::ParentDir
323                | std::path::Component::RootDir
324                | std::path::Component::Prefix(_)
325        )
326    }) {
327        return Err(format!(
328            "{} export `{subpath}` has unsafe `{condition}` target `{value}`",
329            package_json.display()
330        ));
331    }
332    Ok(path)
333}
334
335fn validate_root_client_contract(
336    root_package_json: &Path,
337    client_name: &str,
338) -> Result<(), String> {
339    let root = read_json(root_package_json)?;
340    let has_workspace = workspace_paths(&root)
341        .iter()
342        .any(|path| normalized_package_path(path) == DEFAULT_CLIENT_DIR);
343    if !has_workspace {
344        return Err(format!(
345            "{} must list `{DEFAULT_CLIENT_DIR}` in `workspaces` so editors and Node resolve the generated client",
346            root_package_json.display()
347        ));
348    }
349
350    let dependency = ["dependencies", "devDependencies", "optionalDependencies"]
351        .into_iter()
352        .find_map(|section| {
353            root.get(section)
354                .and_then(|dependencies| dependencies.get(client_name))
355                .and_then(Value::as_str)
356        });
357    let valid_dependency = dependency
358        .and_then(|value| value.strip_prefix("file:"))
359        .is_some_and(|path| normalized_package_path(path) == DEFAULT_CLIENT_DIR);
360    if !valid_dependency {
361        return Err(format!(
362            "{} must depend on `{client_name}` via `file:./{DEFAULT_CLIENT_DIR}`",
363            root_package_json.display()
364        ));
365    }
366    Ok(())
367}
368
369fn root_declares_generated_client(root_package_json: &Path) -> Result<bool, String> {
370    if !root_package_json.is_file() {
371        return Ok(false);
372    }
373    let root = read_json(root_package_json)?;
374    if workspace_paths(&root)
375        .iter()
376        .any(|path| normalized_package_path(path) == DEFAULT_CLIENT_DIR)
377    {
378        return Ok(true);
379    }
380    Ok(["dependencies", "devDependencies", "optionalDependencies"]
381        .into_iter()
382        .filter_map(|section| root.get(section).and_then(Value::as_object))
383        .flat_map(|dependencies| dependencies.values())
384        .filter_map(Value::as_str)
385        .filter_map(|value| value.strip_prefix("file:"))
386        .any(|path| normalized_package_path(path) == DEFAULT_CLIENT_DIR))
387}
388
389fn workspace_paths(root: &Value) -> Vec<&str> {
390    let Some(workspaces) = root.get("workspaces") else {
391        return Vec::new();
392    };
393    let packages = workspaces
394        .as_array()
395        .or_else(|| workspaces.get("packages").and_then(Value::as_array));
396    packages
397        .into_iter()
398        .flatten()
399        .filter_map(Value::as_str)
400        .collect()
401}
402
403fn normalized_package_path(path: &str) -> &str {
404    path.trim().trim_start_matches("./").trim_end_matches('/')
405}
406
407fn warn_on_mixed_package_managers(root: &Path) {
408    if !root.join("package-lock.json").is_file() {
409        return;
410    }
411    let alternatives = ["pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"]
412        .into_iter()
413        .filter(|lock| root.join(lock).is_file())
414        .collect::<Vec<_>>();
415    if !alternatives.is_empty() {
416        eprintln!(
417            "nextrs: warning: found package-lock.json and {}; generated apps use npm, so keep one package manager and remove stale lockfiles",
418            alternatives.join(", ")
419        );
420    }
421}
422
423fn ensure_root_client_install(
424    root: &Path,
425    client_dir: &Path,
426    package: &ClientPackage,
427) -> Result<(), String> {
428    ensure_root_client_install_with(root, client_dir, package, || {
429        execute(root, "npm", &["install"], None)
430    })
431}
432
433fn ensure_root_client_install_with(
434    root: &Path,
435    client_dir: &Path,
436    package: &ClientPackage,
437    install: impl FnOnce() -> Result<(), String>,
438) -> Result<(), String> {
439    if client_install_is_valid(root, client_dir, package)? {
440        return Ok(());
441    }
442
443    eprintln!(
444        "nextrs: generated client link is missing or stale; repairing it with a root npm install"
445    );
446    install()?;
447    if client_install_is_valid(root, client_dir, package)? {
448        Ok(())
449    } else {
450        let install_dir = package_install_path(&root.join("node_modules"), &package.name)?;
451        Err(format!(
452            "npm install completed, but {} is still missing, dangling, or stale. Regenerate `{DEFAULT_CLIENT_DIR}` from the app root and do not install inside it",
453            install_dir.display()
454        ))
455    }
456}
457
458fn client_install_is_valid(
459    root: &Path,
460    client_dir: &Path,
461    package: &ClientPackage,
462) -> Result<bool, String> {
463    let installed_dir = package_install_path(&root.join("node_modules"), &package.name)?;
464    let installed_package_json = installed_dir.join("package.json");
465    if !installed_package_json.is_file() {
466        return Ok(false);
467    }
468    let source_root = fs::canonicalize(client_dir).map_err(|error| {
469        format!(
470            "failed to resolve generated client directory {}: {error}",
471            client_dir.display()
472        )
473    })?;
474    let Ok(installed_root) = fs::canonicalize(&installed_dir) else {
475        return Ok(false);
476    };
477    if source_root != installed_root {
478        return Ok(false);
479    }
480    let installed = read_client_package(&installed_package_json)?;
481    if installed.name != package.name || installed.exports != package.exports {
482        return Ok(false);
483    }
484
485    // Require the actual workspace link, rather than accepting a copied or
486    // cached package with identical metadata that can go stale after codegen.
487    let source_manifest = fs::read(client_dir.join("package.json")).map_err(io_error)?;
488    let installed_manifest = fs::read(installed_package_json).map_err(io_error)?;
489    Ok(source_manifest == installed_manifest)
490}
491
492fn validate_generated_client(
493    root: &Path,
494    client_dir: &Path,
495    package: &ClientPackage,
496) -> Result<(), String> {
497    let installed_dir = package_install_path(&root.join("node_modules"), &package.name)?;
498    for export in &package.exports {
499        for (condition, relative) in [("types", &export.types), ("import", &export.import)] {
500            let source = client_dir.join(relative);
501            if !source.is_file() {
502                return Err(format!(
503                    "generated client export `{}` is missing its {condition} output: {}",
504                    export.subpath,
505                    source.display()
506                ));
507            }
508            let installed = installed_dir.join(relative);
509            if !installed.is_file() {
510                return Err(format!(
511                    "root package link does not expose the generated {condition} output for `{}`: {}",
512                    export.subpath,
513                    installed.display()
514                ));
515            }
516        }
517    }
518
519    let package_name = serde_json::to_string(&package.name).map_err(|error| error.to_string())?;
520    let script =
521        format!("await import({package_name}); await import({package_name} + '/react-query')");
522    execute(
523        root,
524        "node",
525        &[
526            OsStr::new("--input-type=module"),
527            OsStr::new("--eval"),
528            OsStr::new(&script),
529        ],
530        None,
531    )
532    .map_err(|error| {
533        format!(
534            "generated client files were built, but the consuming app cannot import `{}`: {error}",
535            package.name
536        )
537    })
538}
539
540fn package_install_path(node_modules: &Path, package_name: &str) -> Result<PathBuf, String> {
541    let parts = package_name.split('/').collect::<Vec<_>>();
542    let valid_part =
543        |part: &str| !part.is_empty() && part != "." && part != ".." && !part.contains('\\');
544    let valid = match parts.as_slice() {
545        [name] => !name.starts_with('@') && valid_part(name),
546        [scope, name] => {
547            scope.starts_with('@') && scope.len() > 1 && valid_part(scope) && valid_part(name)
548        }
549        _ => false,
550    };
551    if !valid {
552        return Err(format!(
553            "invalid generated client package name `{package_name}`"
554        ));
555    }
556    Ok(parts
557        .into_iter()
558        .fold(node_modules.to_path_buf(), |path, part| path.join(part)))
559}
560
561fn read_json(path: &Path) -> Result<Value, String> {
562    let contents = fs::read_to_string(path)
563        .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
564    serde_json::from_str(&contents)
565        .map_err(|error| format!("failed to parse {}: {error}", path.display()))
566}
567
568fn normal_generation_target<'a>(
569    root: &'a Path,
570    client_dir: &'a Path,
571    custom_client_dir: bool,
572) -> (&'a Path, &'static str) {
573    if custom_client_dir {
574        // Compatibility for applications that explicitly keep a separate
575        // generated-client package outside nextrs's hidden default.
576        (client_dir, "gen")
577    } else {
578        (root, "client:generate")
579    }
580}
581
582fn execute<S: AsRef<OsStr>>(
583    cwd: &Path,
584    program: &str,
585    args: &[S],
586    envs: Option<&[(&str, &str)]>,
587) -> Result<(), String> {
588    let mut command = Command::new(program);
589    command.current_dir(cwd).args(args);
590    if let Some(envs) = envs {
591        command.envs(envs.iter().copied());
592    }
593    let status = command
594        .status()
595        .map_err(|error| format!("failed to run `{program}` in {}: {error}", cwd.display()))?;
596    if status.success() {
597        Ok(())
598    } else {
599        Err(format!("`{program}` exited with {status}"))
600    }
601}
602
603fn absolutize(base: &Path, path: &Path) -> PathBuf {
604    if path.is_absolute() {
605        path.to_path_buf()
606    } else {
607        base.join(path)
608    }
609}
610
611fn io_error(error: std::io::Error) -> String {
612    error.to_string()
613}
614
615fn print_help() {
616    println!(
617        "nextrs\n\nUSAGE:\n    nextrs new <PATH> [OPTIONS]\n    nextrs dev [--bin <NAME>] [-- <APP_ARGS>]\n    nextrs client generate [OPTIONS]\n\nRun the same commands as `cargo nextrs ...` or `nextrs ...`.\n\nCLIENT OPTIONS:\n    --root <PATH>        nextrs application root (default: current directory)\n    --client-dir <PATH>  generated package relative to the app root (default: .nextrs/client)\n    --config <PATH>      external-client config; defaults to .nextrs/client/nextrs.client.json when present\n    -h, --help           Print help\n\nClient dependencies are installed once at the application root; never run\n`npm install` inside the generated client directory. Generation validates and\nrepairs the root workspace link, then verifies both JS and declaration exports.\n\nOne `cargo install cargo-nextrs` provides both launchers, the dev server,\nthe legacy `cargo-nextrs-dev` compatibility binary, and client generation."
618    );
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    const CLIENT_PACKAGE_JSON: &str = r#"{
626      "name": "@demo/client",
627      "exports": {
628        ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
629        "./react-query": {
630          "types": "./dist/react-query.d.ts",
631          "import": "./dist/react-query.js"
632        }
633      }
634    }"#;
635
636    fn test_dir(name: &str) -> PathBuf {
637        let path = std::env::temp_dir().join(format!("cargo-nextrs-{name}-{}", std::process::id()));
638        let _ = fs::remove_dir_all(&path);
639        fs::create_dir_all(&path).unwrap();
640        path
641    }
642
643    fn write_modern_package(root: &Path) -> (PathBuf, ClientPackage) {
644        let client_dir = root.join(DEFAULT_CLIENT_DIR);
645        fs::create_dir_all(&client_dir).unwrap();
646        let package_json = client_dir.join("package.json");
647        fs::write(&package_json, CLIENT_PACKAGE_JSON).unwrap();
648        let package = read_client_package(&package_json).unwrap();
649        (client_dir, package)
650    }
651
652    fn parse(args: &[&str]) -> Result<CommandLine, String> {
653        CommandLine::parse(args.iter().map(OsString::from))
654    }
655
656    #[test]
657    fn parses_cargo_subcommand_prefix() {
658        assert_eq!(
659            parse(&["nextrs", "client", "generate"]).unwrap(),
660            CommandLine::ClientGenerate(GenerateOptions {
661                root: PathBuf::from("."),
662                client_dir: PathBuf::from(DEFAULT_CLIENT_DIR),
663                config: None,
664            })
665        );
666    }
667
668    #[test]
669    fn parses_new_from_both_launchers() {
670        let expected = CommandLine::New(vec![
671            OsString::from("demo"),
672            OsString::from("--nextrs-path"),
673            OsString::from("../nextrs"),
674        ]);
675        assert_eq!(
676            parse(&["new", "demo", "--nextrs-path", "../nextrs"]).unwrap(),
677            expected
678        );
679        assert_eq!(
680            parse(&["nextrs", "new", "demo", "--nextrs-path", "../nextrs"]).unwrap(),
681            expected
682        );
683    }
684
685    #[test]
686    fn parses_generation_paths() {
687        assert_eq!(
688            parse(&[
689                "client",
690                "generate",
691                "--root",
692                "server",
693                "--client-dir",
694                "web-client",
695                "--config",
696                "publish.json",
697            ])
698            .unwrap(),
699            CommandLine::ClientGenerate(GenerateOptions {
700                root: PathBuf::from("server"),
701                client_dir: PathBuf::from("web-client"),
702                config: Some(PathBuf::from("publish.json")),
703            })
704        );
705    }
706
707    #[test]
708    fn normal_generation_uses_the_root_script() {
709        let root = Path::new("/app");
710        let generated_client = root.join(DEFAULT_CLIENT_DIR);
711        assert_eq!(
712            normal_generation_target(root, &generated_client, false),
713            (root, "client:generate")
714        );
715        assert_eq!(
716            normal_generation_target(root, Path::new("/custom-client"), true),
717            (Path::new("/custom-client"), "gen")
718        );
719    }
720
721    #[test]
722    fn passes_dev_arguments_to_the_dev_runner() {
723        assert_eq!(
724            parse(&["nextrs", "dev", "--bin", "demo"]).unwrap(),
725            CommandLine::Dev(vec![OsString::from("--bin"), OsString::from("demo")])
726        );
727    }
728
729    #[test]
730    fn rejects_unknown_commands() {
731        assert!(parse(&["client", "wat"]).is_err());
732        assert!(parse(&["wat"]).is_err());
733    }
734
735    #[test]
736    fn validates_the_root_workspace_and_file_dependency() {
737        let root = test_dir("root-contract");
738        let package_json = root.join("package.json");
739        fs::write(
740            &package_json,
741            r#"{
742              "workspaces": [".nextrs/client"],
743              "dependencies": { "@demo/client": "file:./.nextrs/client" }
744            }"#,
745        )
746        .unwrap();
747        validate_root_client_contract(&package_json, "@demo/client").unwrap();
748
749        fs::write(
750            &package_json,
751            r#"{ "dependencies": { "@demo/client": "file:./.nextrs/client" } }"#,
752        )
753        .unwrap();
754        assert!(
755            validate_root_client_contract(&package_json, "@demo/client")
756                .unwrap_err()
757                .contains("workspaces")
758        );
759        fs::remove_dir_all(root).unwrap();
760    }
761
762    #[cfg(unix)]
763    #[test]
764    fn repairs_a_missing_root_client_install_even_when_node_modules_exists() {
765        let root = test_dir("repair-link");
766        let (client_dir, package) = write_modern_package(&root);
767        fs::create_dir_all(root.join("node_modules/unrelated-package")).unwrap();
768
769        let mut installed = false;
770        ensure_root_client_install_with(&root, &client_dir, &package, || {
771            installed = true;
772            let install_dir = package_install_path(&root.join("node_modules"), &package.name)?;
773            fs::create_dir_all(install_dir.parent().unwrap()).map_err(io_error)?;
774            std::os::unix::fs::symlink(&client_dir, &install_dir).map_err(io_error)?;
775            Ok(())
776        })
777        .unwrap();
778
779        assert!(installed, "the missing client link was not repaired");
780        assert!(client_install_is_valid(&root, &client_dir, &package).unwrap());
781        fs::remove_dir_all(root).unwrap();
782    }
783
784    #[test]
785    fn detects_a_declared_client_whose_package_skeleton_is_missing() {
786        let root = test_dir("missing-skeleton");
787        let package_json = root.join("package.json");
788        fs::write(
789            &package_json,
790            r#"{
791              "workspaces": [".nextrs/client"],
792              "dependencies": { "@demo/client": "file:./.nextrs/client" }
793            }"#,
794        )
795        .unwrap();
796        assert!(root_declares_generated_client(&package_json).unwrap());
797        fs::remove_dir_all(root).unwrap();
798    }
799
800    #[test]
801    fn maps_scoped_package_names_under_root_node_modules() {
802        assert_eq!(
803            package_install_path(Path::new("/app/node_modules"), "@demo/client").unwrap(),
804            Path::new("/app/node_modules/@demo/client")
805        );
806        assert!(package_install_path(Path::new("node_modules"), "@demo/../client").is_err());
807    }
808}