use std::ffi::OsString;
use std::path::PathBuf;
use anyhow::{Result, bail};
use clap::Parser;
use jscpd_rs::{Cli, Options};
#[tokio::main]
async fn main() {
if let Err(error) = run().await {
eprintln!("{}", server_error_message(&error.to_string()));
std::process::exit(1);
}
}
async fn run() -> Result<()> {
let server_args = ServerArgs::from_env()?;
if server_args.help {
print_server_help();
return Ok(());
}
if let Some(option) = server_args.unknown_option {
eprintln!("error: unknown option '{option}'");
std::process::exit(1);
}
let cli = Cli::parse_from(server_args.jscpd_args);
if cli.version {
println!("{}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let working_directory = server_cli_working_directory(&cli);
let options = Options::from_cli(cli)?;
jscpd_rs::server::serve_with_working_directory(
options,
working_directory,
&server_args.host,
server_args.port,
)
.await
}
#[derive(Debug)]
struct ServerArgs {
host: String,
port: u16,
jscpd_args: Vec<OsString>,
help: bool,
unknown_option: Option<String>,
}
impl ServerArgs {
fn from_env() -> Result<Self> {
Self::parse(std::env::args_os())
}
fn parse<I>(args: I) -> Result<Self>
where
I: IntoIterator<Item = OsString>,
{
let mut args = args.into_iter().collect::<Vec<_>>();
let program = args
.first()
.cloned()
.unwrap_or_else(|| OsString::from("jscpd-server"));
if !args.is_empty() {
args.remove(0);
}
let mut args = args.into_iter().peekable();
let mut host = "0.0.0.0".to_string();
let mut port = 3000u16;
let mut help = false;
let mut unknown_option = None;
let mut jscpd_args = vec![program];
while let Some(arg) = args.next() {
if arg == "--help" {
help = true;
} else if arg == "--host" || arg == "-H" {
host = next_optional_value(&mut args).unwrap_or_else(|| "true".to_string());
} else if arg == "--port" || arg == "-p" {
let value = next_optional_value(&mut args).unwrap_or_else(|| "true".to_string());
port = parse_port(&value)?;
} else if let Some(value) = prefixed_value(&arg, "--host=") {
host = value;
} else if let Some(value) = prefixed_value(&arg, "--port=") {
port = parse_port(&value)?;
} else if is_supported_jscpd_server_option(&arg) || !is_option_like(&arg) {
jscpd_args.push(arg);
} else {
unknown_option = arg.to_str().map(str::to_string);
break;
}
}
Ok(Self {
host,
port,
jscpd_args,
help,
unknown_option,
})
}
}
fn next_optional_value<I>(args: &mut std::iter::Peekable<I>) -> Option<String>
where
I: Iterator<Item = OsString>,
{
let next = args.peek()?;
if next.to_str().is_some_and(|value| value.starts_with('-')) {
return None;
}
args.next().and_then(|value| value.into_string().ok())
}
fn print_server_help() {
println!("{}", server_help());
}
fn is_option_like(arg: &OsString) -> bool {
arg.to_str().is_some_and(|value| value.starts_with('-'))
}
fn is_supported_jscpd_server_option(arg: &OsString) -> bool {
let Some(value) = arg.to_str() else {
return false;
};
let option = value
.split_once('=')
.map_or(value, |(option, _value)| option);
matches!(
option,
"-V" | "--version"
| "-c"
| "--config"
| "-f"
| "--format"
| "-i"
| "--ignore"
| "--ignore-pattern"
| "-l"
| "--min-lines"
| "-k"
| "--min-tokens"
| "-x"
| "--max-lines"
| "-z"
| "--max-size"
| "-m"
| "--mode"
| "--store"
| "--store-path"
| "-a"
| "--absolute"
| "-n"
| "--noSymlinks"
| "--ignoreCase"
| "-g"
| "--gitignore"
| "--skipLocal"
)
}
fn server_help() -> &'static str {
r#"Usage: jscpd-server [options] <path>
Start jscpd as a server
Options:
-V, --version output the version number
-p, --port [number] port to run the server on (Default is 3000)
-H, --host [string] host to bind the server to (Default is 0.0.0.0)
-c, --config [string] path to config file (Default is .jscpd.json in
<path>)
-f, --format [string] format or formats separated by comma
-i, --ignore [string] glob pattern for files to exclude
--ignore-pattern [string] ignore code blocks matching regexp patterns
-l, --min-lines [number] min size of duplication in code lines (Default is
5)
-k, --min-tokens [number] min size of duplication in code tokens (Default is
50)
-x, --max-lines [number] max size of source in lines (Default is 1000)
-z, --max-size [string] max size of source in bytes, examples: 1kb, 1mb,
120kb (Default is 100kb)
-m, --mode [string] mode of quality of search, can be "strict", "mild" and "weak" (Default is "function mild(token) {
return strict(token) && token.type !== "empty" && token.type !== "new_line";
}")
--store [string] use for define custom store (e.g. --store leveldb
used for big codebase)
--store-path [string] directory to use for store cache (e.g.
--store-path /tmp/jscpd-cache, useful when running
multiple instances in parallel)
-a, --absolute use absolute path in reports
-n, --noSymlinks dont use symlinks for detection
--ignoreCase ignore case of symbols in code (experimental)
-g, --gitignore ignore all files from .gitignore file
--skipLocal skip duplicates in local folders
--help display help for command"#
}
fn prefixed_value(arg: &OsString, prefix: &str) -> Option<String> {
arg.to_str()
.and_then(|value| value.strip_prefix(prefix))
.map(str::to_string)
}
fn parse_port(value: &str) -> Result<u16> {
let Ok(port) = value.parse::<u16>() else {
bail!("Invalid port number: {value}");
};
if port == 0 {
bail!("Invalid port number: {value}");
}
Ok(port)
}
fn server_cli_working_directory(cli: &Cli) -> PathBuf {
cli.paths
.first()
.cloned()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
}
fn server_error_message(message: &str) -> String {
match message {
"TypeError: mode is not a function" => {
format!("Failed to start server: {message}")
}
message
if message.starts_with("TypeError [ERR_INVALID_ARG_TYPE]")
|| message.starts_with("TypeError:")
|| message.starts_with("SyntaxError:") =>
{
message.to_string()
}
message => format!("Failed to start server: Error: {message}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> ServerArgs {
ServerArgs::parse(args.iter().map(OsString::from)).expect("parse server args")
}
#[test]
fn extracts_server_host_and_port() {
let args = parse(&[
"jscpd-server",
".",
"--host",
"127.0.0.1",
"--port",
"4567",
"--format",
"javascript",
]);
assert_eq!(args.host, "127.0.0.1");
assert_eq!(args.port, 4567);
assert_eq!(
args.jscpd_args,
vec![
OsString::from("jscpd-server"),
OsString::from("."),
OsString::from("--format"),
OsString::from("javascript"),
]
);
assert_eq!(args.unknown_option, None);
}
#[test]
fn supports_equals_server_flags() {
let args = parse(&["jscpd-server", "--host=localhost", "--port=3001", "src"]);
assert_eq!(args.host, "localhost");
assert_eq!(args.port, 3001);
assert_eq!(
args.jscpd_args,
vec![OsString::from("jscpd-server"), OsString::from("src")]
);
assert_eq!(args.unknown_option, None);
}
#[test]
fn server_working_directory_uses_cli_arg_before_config_paths() {
let cli = Cli::parse_from(["jscpd-server", "--config", ".jscpd.json"]);
assert_eq!(
server_cli_working_directory(&cli),
std::env::current_dir().unwrap()
);
let cli = Cli::parse_from(["jscpd-server", "--config", ".jscpd.json", "src"]);
assert_eq!(server_cli_working_directory(&cli), PathBuf::from("src"));
}
#[test]
fn detects_server_help_without_forwarding_to_jscpd_cli() {
let args = parse(&["jscpd-server", "--help"]);
assert!(args.help);
assert_eq!(args.unknown_option, None);
let help = server_help();
assert!(help.contains("Usage: jscpd-server [options] <path>"));
assert!(help.contains("Start jscpd as a server"));
assert!(help.contains("-p, --port [number]"));
assert!(help.contains("-H, --host [string]"));
assert!(
help.contains("function mild(token)"),
"server help should preserve upstream default mode text"
);
assert!(!help.contains("detector of copy/paste in files"));
}
#[test]
fn bare_or_invalid_server_port_matches_upstream_error() {
let error = ServerArgs::parse(["jscpd-server", "--port"].into_iter().map(OsString::from))
.expect_err("bare port should fail");
assert_eq!(error.to_string(), "Invalid port number: true");
let error = ServerArgs::parse(
["jscpd-server", "--port", "abc"]
.into_iter()
.map(OsString::from),
)
.expect_err("invalid port should fail");
assert_eq!(error.to_string(), "Invalid port number: abc");
}
#[test]
fn formats_server_start_errors_like_upstream() {
assert_eq!(
server_error_message("Invalid port number: true"),
"Failed to start server: Error: Invalid port number: true"
);
assert_eq!(
server_error_message("TypeError: cli.format.split is not a function"),
"TypeError: cli.format.split is not a function"
);
assert_eq!(
server_error_message("TypeError: mode is not a function"),
"Failed to start server: TypeError: mode is not a function"
);
}
#[test]
fn rejects_options_not_supported_by_upstream_server() {
for option in [
"--list",
"-h",
"--reporters",
"--output",
"--debug",
"--verbose",
"--exitCode",
"--noTips",
"--skipComments",
"--formats-exts",
"--formats-names",
"--pattern",
"--blame",
"--silent",
"--threshold",
"--no-gitignore",
] {
let args = parse(&["jscpd-server", option]);
assert_eq!(args.unknown_option, Some(option.to_string()));
}
}
#[test]
fn forwards_only_upstream_server_common_options() {
let input = [
"jscpd-server",
"src",
"-V",
"--version",
"-c",
".jscpd.json",
"--config=custom.json",
"-f",
"javascript",
"--format=typescript",
"-i",
"**/*.min.js",
"--ignore=dist/**",
"--ignore-pattern",
"generated",
"-l",
"5",
"--min-lines=6",
"-k",
"50",
"--min-tokens=60",
"-x",
"1000",
"--max-lines=2000",
"-z",
"1mb",
"--max-size=2mb",
"-m",
"strict",
"--mode=weak",
"--store",
"memory",
"--store-path",
".cache",
"-a",
"--absolute",
"-n",
"--noSymlinks",
"--ignoreCase",
"-g",
"--gitignore",
"--skipLocal",
];
let args = parse(&input);
assert_eq!(args.unknown_option, None);
assert_eq!(
args.jscpd_args,
input.iter().map(OsString::from).collect::<Vec<_>>()
);
}
}