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