use crate::framework::Framework;
use serde::Deserialize;
use std::path::{Path, PathBuf};
pub const KEYS: &[&str] = &["deadCode", "dead-code", "basta"];
const CONFIG_FILES: &[&str] = &[".jscpd.json", ".config/jscpd.json", ".config/.jscpd.json"];
const MANIFEST: &str = "package.json";
const MANIFEST_KEY: &str = "jscpd";
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Section {
pub enabled: Option<bool>,
pub categories: Option<Vec<String>>,
#[serde(alias = "min-confidence")]
pub min_confidence: Option<u8>,
#[serde(alias = "min-lines")]
pub min_lines: Option<u32>,
pub entry: Option<Vec<String>>,
pub ignore: Option<Vec<String>>,
#[serde(alias = "include-tests")]
pub include_tests: Option<bool>,
#[serde(alias = "include-entry-exports")]
pub include_entry_exports: Option<bool>,
pub threshold: Option<f64>,
pub frameworks: Option<Vec<Framework>>,
#[serde(alias = "frameworks-config")]
pub frameworks_config: Option<PathBuf>,
pub framework: Option<Vec<String>>,
#[serde(alias = "no-frameworks")]
pub no_frameworks: Option<bool>,
#[serde(alias = "rust-diagnostics")]
pub rust_diagnostics: Option<PathBuf>,
}
impl Section {
pub fn from_value(value: &serde_json::Value) -> Result<Self, String> {
serde_json::from_value(value.clone()).map_err(|error| error.to_string())
}
pub fn of_config(config: &serde_json::Value) -> Result<Option<Self>, String> {
let Some((key, value)) = KEYS.iter().find_map(|key| Some((key, config.get(key)?))) else {
return Ok(None);
};
match value {
serde_json::Value::Bool(_) | serde_json::Value::Null => Ok(None),
value => Self::from_value(value)
.map(Some)
.map_err(|error| format!("\"{key}\": {error}")),
}
}
pub fn load(path: &Path) -> Result<Option<Self>, String> {
let text = std::fs::read_to_string(path)
.map_err(|error| format!("{}: {error}", path.display()))?;
let config: serde_json::Value =
serde_json::from_str(&text).map_err(|error| format!("{}: {error}", path.display()))?;
Self::of_config(&config).map_err(|error| format!("{}: {error}", path.display()))
}
pub fn discover(directory: &Path) -> Result<Option<(PathBuf, Self)>, String> {
for name in CONFIG_FILES {
let path = directory.join(name);
if path.is_file() {
return Ok(Self::load(&path)?.map(|section| (path, section)));
}
}
let path = directory.join(MANIFEST);
let Ok(text) = std::fs::read_to_string(&path) else {
return Ok(None);
};
let Some(config) = serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|manifest| manifest.get(MANIFEST_KEY).cloned())
else {
return Ok(None);
};
Self::of_config(&config)
.map(|section| section.map(|section| (path.clone(), section)))
.map_err(|error| format!("{}: {error}", path.display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_scan::TempTree;
#[test]
fn the_section_goes_by_any_of_its_three_names() {
for key in KEYS {
let config = serde_json::json!({ *key: { "minConfidence": 80 } });
let section = Section::of_config(&config).unwrap().expect(key);
assert_eq!(section.min_confidence, Some(80), "{key}");
}
assert_eq!(
Section::of_config(&serde_json::json!({ "threshold": 5 })).unwrap(),
None
);
}
#[test]
fn the_boolean_form_of_the_key_is_a_switch_not_a_section() {
for config in [
serde_json::json!({ "deadCode": true }),
serde_json::json!({ "basta": false }),
] {
assert_eq!(Section::of_config(&config).unwrap(), None);
}
}
#[test]
fn every_setting_has_a_key_and_kebab_case_is_accepted() {
let section = Section::from_value(&serde_json::json!({
"enabled": true,
"categories": ["unused-file", "exports"],
"min-confidence": 70,
"minLines": 3,
"entry": ["src/handlers/**"],
"ignore": ["**/generated/**"],
"include-tests": true,
"includeEntryExports": true,
"threshold": 2.5,
"frameworks": [{ "name": "house", "detect": { "dependencies": ["house"] }, "globals": ["onBoot"] }],
"frameworksConfig": "tools/frameworks.yaml",
"framework": ["next"],
"no-frameworks": false,
"rustDiagnostics": "target/check.json"
}))
.unwrap();
assert_eq!(
section.rust_diagnostics.as_deref(),
Some(Path::new("target/check.json"))
);
assert_eq!(section.enabled, Some(true));
assert_eq!(section.min_confidence, Some(70));
assert_eq!(section.min_lines, Some(3));
assert_eq!(section.include_tests, Some(true));
assert_eq!(section.threshold, Some(2.5));
assert_eq!(section.frameworks.as_ref().unwrap()[0].name, "house");
assert_eq!(
section.frameworks_config.as_deref(),
Some(Path::new("tools/frameworks.yaml"))
);
assert_eq!(section.no_frameworks, Some(false));
}
#[test]
fn a_misspelled_key_is_refused_by_name() {
let error = Section::of_config(&serde_json::json!({ "deadCode": { "minConfidense": 80 } }))
.unwrap_err();
assert!(error.contains("\"deadCode\""), "{error}");
assert!(error.contains("minConfidense"), "{error}");
}
#[test]
fn the_config_is_found_where_jscpd_finds_it() {
let tree = TempTree::new("section-discover");
assert_eq!(Section::discover(tree.path()).unwrap(), None);
tree.write(
"package.json",
r#"{ "name": "app", "jscpd": { "dead-code": { "minConfidence": 90 } } }"#,
);
let (path, section) = Section::discover(tree.path()).unwrap().unwrap();
assert_eq!(path, tree.path().join("package.json"));
assert_eq!(section.min_confidence, Some(90));
tree.write(
".config/jscpd.json",
r#"{ "basta": { "minConfidence": 70 } }"#,
);
let (_, section) = Section::discover(tree.path()).unwrap().unwrap();
assert_eq!(section.min_confidence, Some(70));
tree.write(".jscpd.json", r#"{ "threshold": 5 }"#);
assert_eq!(
Section::discover(tree.path()).unwrap(),
None,
"the first file found is the config, section or no section"
);
}
#[test]
fn a_file_that_cannot_be_read_says_which_one() {
let tree = TempTree::new("section-broken");
tree.write(".jscpd.json", "{ not json");
let error = Section::discover(tree.path()).unwrap_err();
assert!(error.contains(".jscpd.json"), "{error}");
let missing = Section::load(&tree.path().join("nope.json")).unwrap_err();
assert!(missing.contains("nope.json"), "{missing}");
}
}