use super::{MigrationWarning, string_or_array};
const JSCPD_UNMAPPABLE_FIELDS: &[(&str, &str, Option<&str>)] = &[
("maxLines", "No maximum line count limit in fallow", None),
("maxSize", "No maximum file size limit in fallow", None),
(
"ignorePattern",
"Content-based ignore patterns are not supported",
Some("use inline suppression: // fallow-ignore-next-line code-duplication"),
),
(
"reporters",
"Reporters are not configurable in fallow",
Some("use --format flag instead (human/json/sarif/compact)"),
),
(
"output",
"fallow writes to stdout",
Some("redirect output with shell: fallow dupes > report.json"),
),
(
"blame",
"Git blame integration is not supported in fallow",
None,
),
("absolute", "fallow always shows relative paths", None),
(
"noSymlinks",
"Symlink handling is not configurable in fallow",
None,
),
(
"ignoreCase",
"Case-insensitive matching is not supported in fallow",
None,
),
("format", "fallow auto-detects JS/TS files", None),
(
"formatsExts",
"Custom file extensions are not configurable in fallow",
None,
),
("store", "Store backend is not configurable in fallow", None),
(
"tokensToSkip",
"Token skipping is not configurable in fallow",
None,
),
(
"exitCode",
"Exit codes are not configurable in fallow",
Some("use the rules system to control which issues cause CI failure"),
),
(
"pattern",
"Pattern filtering is not supported in fallow",
None,
),
(
"path",
"Source path configuration is not supported",
Some("run fallow from the project root directory"),
),
];
pub(super) fn migrate_jscpd(
jscpd: &serde_json::Value,
config: &mut serde_json::Map<String, serde_json::Value>,
warnings: &mut Vec<MigrationWarning>,
) {
let Some(obj) = jscpd.as_object() else {
warnings.push(MigrationWarning {
source: "jscpd",
field: "(root)".to_string(),
message: "expected an object, got something else".to_string(),
suggestion: None,
});
return;
};
let mut dupes = serde_json::Map::new();
if let Some(min_tokens) = obj.get("minTokens").and_then(serde_json::Value::as_u64) {
dupes.insert(
"minTokens".to_string(),
serde_json::Value::Number(min_tokens.into()),
);
}
if let Some(min_lines) = obj.get("minLines").and_then(serde_json::Value::as_u64) {
dupes.insert(
"minLines".to_string(),
serde_json::Value::Number(min_lines.into()),
);
}
if let Some(threshold) = obj.get("threshold").and_then(serde_json::Value::as_f64)
&& let Some(n) = serde_json::Number::from_f64(threshold)
{
dupes.insert("threshold".to_string(), serde_json::Value::Number(n));
}
if let Some(mode_str) = obj.get("mode").and_then(|v| v.as_str()) {
let fallow_mode = match mode_str {
"strict" => Some("strict"),
"mild" => Some("mild"),
"weak" => {
warnings.push(MigrationWarning {
source: "jscpd",
field: "mode".to_string(),
message: "jscpd's \"weak\" mode may differ semantically from fallow's \"weak\" \
mode. jscpd uses lexer-based tokens while fallow uses AST-based tokens."
.to_string(),
suggestion: Some(
"test with both \"weak\" and \"mild\" to find the best match".to_string(),
),
});
Some("weak")
}
other => {
warnings.push(MigrationWarning {
source: "jscpd",
field: "mode".to_string(),
message: format!("unknown mode `{other}`, defaulting to \"mild\""),
suggestion: None,
});
None
}
};
if let Some(mode) = fallow_mode {
dupes.insert(
"mode".to_string(),
serde_json::Value::String(mode.to_string()),
);
}
}
if let Some(skip_local) = obj.get("skipLocal").and_then(serde_json::Value::as_bool) {
dupes.insert("skipLocal".to_string(), serde_json::Value::Bool(skip_local));
}
if let Some(ignore_val) = obj.get("ignore") {
let ignores = string_or_array(ignore_val);
if !ignores.is_empty() {
dupes.insert(
"ignore".to_string(),
serde_json::Value::Array(
ignores.into_iter().map(serde_json::Value::String).collect(),
),
);
}
}
if !dupes.is_empty() {
config.insert("duplicates".to_string(), serde_json::Value::Object(dupes));
}
for (field, message, suggestion) in JSCPD_UNMAPPABLE_FIELDS {
if obj.contains_key(*field) {
warnings.push(MigrationWarning {
source: "jscpd",
field: (*field).to_string(),
message: (*message).to_string(),
suggestion: suggestion.map(std::string::ToString::to_string),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn empty_config() -> serde_json::Map<String, serde_json::Value> {
serde_json::Map::new()
}
#[test]
fn migrate_jscpd_basic() {
let jscpd: serde_json::Value =
serde_json::from_str(r#"{"minTokens": 100, "minLines": 10, "threshold": 5.0}"#)
.unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let dupes = config.get("duplicates").unwrap().as_object().unwrap();
assert_eq!(dupes.get("minTokens").unwrap(), 100);
assert_eq!(dupes.get("minLines").unwrap(), 10);
assert_eq!(dupes.get("threshold").unwrap(), 5.0);
assert!(warnings.is_empty());
}
#[test]
fn migrate_jscpd_mode_weak_warns() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"mode": "weak"}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let dupes = config.get("duplicates").unwrap().as_object().unwrap();
assert_eq!(dupes.get("mode").unwrap(), "weak");
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("differ semantically"));
}
#[test]
fn migrate_jscpd_skip_local() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"skipLocal": true}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let dupes = config.get("duplicates").unwrap().as_object().unwrap();
assert_eq!(dupes.get("skipLocal").unwrap(), true);
}
#[test]
fn migrate_jscpd_ignore_patterns() {
let jscpd: serde_json::Value =
serde_json::from_str(r#"{"ignore": ["**/*.test.ts", "dist/**"]}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let dupes = config.get("duplicates").unwrap().as_object().unwrap();
assert_eq!(
dupes.get("ignore").unwrap(),
&serde_json::json!(["**/*.test.ts", "dist/**"])
);
}
#[test]
fn migrate_jscpd_unmappable_fields_generate_warnings() {
let jscpd: serde_json::Value = serde_json::from_str(
r#"{"minTokens": 50, "maxLines": 1000, "reporters": ["console"], "blame": true}"#,
)
.unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert_eq!(warnings.len(), 3);
let fields: Vec<&str> = warnings.iter().map(|w| w.field.as_str()).collect();
assert!(fields.contains(&"maxLines"));
assert!(fields.contains(&"reporters"));
assert!(fields.contains(&"blame"));
}
#[test]
fn migrate_jscpd_non_object_root_warns() {
let jscpd: serde_json::Value = serde_json::json!("not an object");
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].field, "(root)");
assert!(warnings[0].message.contains("expected an object"));
assert!(config.is_empty());
}
#[test]
fn migrate_jscpd_mode_strict() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"mode": "strict"}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let dupes = config.get("duplicates").unwrap().as_object().unwrap();
assert_eq!(dupes.get("mode").unwrap(), "strict");
assert!(warnings.is_empty());
}
#[test]
fn migrate_jscpd_mode_mild() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"mode": "mild"}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let dupes = config.get("duplicates").unwrap().as_object().unwrap();
assert_eq!(dupes.get("mode").unwrap(), "mild");
assert!(warnings.is_empty());
}
#[test]
fn migrate_jscpd_mode_unknown() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"mode": "experimental"}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let dupes = config.get("duplicates");
if let Some(dupes) = dupes {
assert!(dupes.get("mode").is_none());
}
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("unknown mode"));
assert!(warnings[0].message.contains("experimental"));
}
#[test]
fn migrate_jscpd_skip_local_false() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"skipLocal": false}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let dupes = config.get("duplicates").unwrap().as_object().unwrap();
assert_eq!(dupes.get("skipLocal").unwrap(), false);
}
#[test]
fn migrate_jscpd_ignore_single_string() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"ignore": "dist/**"}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let dupes = config.get("duplicates").unwrap().as_object().unwrap();
assert_eq!(
dupes.get("ignore").unwrap(),
&serde_json::json!(["dist/**"])
);
}
#[test]
fn migrate_jscpd_empty_ignore_array() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"ignore": []}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
if let Some(dupes) = config.get("duplicates") {
assert!(dupes.get("ignore").is_none());
}
}
#[test]
fn migrate_jscpd_threshold_integer() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"threshold": 10}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let dupes = config.get("duplicates").unwrap().as_object().unwrap();
assert_eq!(dupes.get("threshold").unwrap(), 10.0);
}
#[test]
fn migrate_jscpd_empty_object() {
let jscpd: serde_json::Value = serde_json::from_str(r"{}").unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert!(!config.contains_key("duplicates"));
assert!(warnings.is_empty());
}
#[test]
fn migrate_jscpd_all_unmappable_fields() {
let jscpd: serde_json::Value = serde_json::from_str(
r#"{
"minTokens": 50,
"maxLines": 1000,
"maxSize": "100kb",
"ignorePattern": ["foo"],
"reporters": ["console"],
"output": "./reports",
"blame": true,
"absolute": true,
"noSymlinks": true,
"ignoreCase": true,
"format": ["javascript"],
"formatsExts": {"js": ["mjs"]},
"store": "redis",
"tokensToSkip": ["if"],
"exitCode": 1,
"pattern": "*.ts",
"path": ["src/"]
}"#,
)
.unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert_eq!(warnings.len(), JSCPD_UNMAPPABLE_FIELDS.len());
let warning_fields: Vec<&str> = warnings.iter().map(|w| w.field.as_str()).collect();
for expected in [
"maxLines",
"maxSize",
"ignorePattern",
"reporters",
"output",
"blame",
"absolute",
"noSymlinks",
"ignoreCase",
"format",
"formatsExts",
"store",
"tokensToSkip",
"exitCode",
"pattern",
"path",
] {
assert!(
warning_fields.contains(&expected),
"missing warning for `{expected}`"
);
}
for w in &warnings {
assert_eq!(w.source, "jscpd");
}
let by_field = |f: &str| warnings.iter().find(|w| w.field == f).unwrap();
assert_eq!(
by_field("maxLines").message,
"No maximum line count limit in fallow"
);
assert_eq!(
by_field("reporters").message,
"Reporters are not configurable in fallow"
);
}
#[test]
fn migrate_jscpd_unmappable_with_suggestions() {
let jscpd: serde_json::Value = serde_json::from_str(
r#"{"ignorePattern": ["foo"], "reporters": ["console"], "output": "out", "exitCode": 1, "path": ["src"]}"#,
)
.unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert_eq!(warnings.len(), 5);
let by_field = |f: &str| warnings.iter().find(|w| w.field == f).unwrap();
let w = by_field("ignorePattern");
assert_eq!(
w.suggestion.as_deref().unwrap(),
"use inline suppression: // fallow-ignore-next-line code-duplication"
);
let w = by_field("reporters");
assert_eq!(
w.suggestion.as_deref().unwrap(),
"use --format flag instead (human/json/sarif/compact)"
);
let w = by_field("output");
assert_eq!(
w.suggestion.as_deref().unwrap(),
"redirect output with shell: fallow dupes > report.json"
);
let w = by_field("exitCode");
assert_eq!(
w.suggestion.as_deref().unwrap(),
"use the rules system to control which issues cause CI failure"
);
let w = by_field("path");
assert_eq!(
w.suggestion.as_deref().unwrap(),
"run fallow from the project root directory"
);
}
#[test]
fn migrate_jscpd_unmappable_without_suggestions() {
let jscpd: serde_json::Value = serde_json::from_str(
r#"{"maxLines": 1000, "maxSize": "50kb", "blame": true, "absolute": true, "noSymlinks": false, "ignoreCase": true, "format": ["js"], "formatsExts": {}, "store": "redis", "tokensToSkip": ["x"], "pattern": "*.ts"}"#,
)
.unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let expected_count = JSCPD_UNMAPPABLE_FIELDS
.iter()
.filter(|f| f.2.is_none())
.count();
assert_eq!(warnings.len(), expected_count);
let by_field = |f: &str| warnings.iter().find(|w| w.field == f).unwrap();
assert!(by_field("maxLines").suggestion.is_none());
assert_eq!(
by_field("maxLines").message,
"No maximum line count limit in fallow"
);
assert!(by_field("maxSize").suggestion.is_none());
assert_eq!(
by_field("maxSize").message,
"No maximum file size limit in fallow"
);
assert!(by_field("blame").suggestion.is_none());
assert_eq!(
by_field("blame").message,
"Git blame integration is not supported in fallow"
);
assert!(by_field("absolute").suggestion.is_none());
assert!(by_field("noSymlinks").suggestion.is_none());
assert!(by_field("ignoreCase").suggestion.is_none());
assert!(by_field("format").suggestion.is_none());
assert!(by_field("formatsExts").suggestion.is_none());
assert!(by_field("store").suggestion.is_none());
assert!(by_field("tokensToSkip").suggestion.is_none());
assert!(by_field("pattern").suggestion.is_none());
}
#[test]
fn migrate_jscpd_complex_full_config() {
let jscpd: serde_json::Value = serde_json::from_str(
r#"{
"minTokens": 75,
"minLines": 8,
"threshold": 3.5,
"mode": "weak",
"skipLocal": true,
"ignore": ["**/vendor/**", "dist/**"],
"maxLines": 5000,
"reporters": ["json"],
"blame": false
}"#,
)
.unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let dupes = config.get("duplicates").unwrap().as_object().unwrap();
assert_eq!(dupes.get("minTokens").unwrap(), 75);
assert_eq!(dupes.get("minLines").unwrap(), 8);
assert_eq!(dupes.get("threshold").unwrap(), 3.5);
assert_eq!(dupes.get("mode").unwrap(), "weak");
assert_eq!(dupes.get("skipLocal").unwrap(), true);
assert_eq!(
dupes.get("ignore").unwrap(),
&serde_json::json!(["**/vendor/**", "dist/**"])
);
assert_eq!(warnings.len(), 4);
let warning_fields: Vec<&str> = warnings.iter().map(|w| w.field.as_str()).collect();
assert!(warning_fields.contains(&"mode")); assert!(warning_fields.contains(&"maxLines"));
assert!(warning_fields.contains(&"reporters"));
assert!(warning_fields.contains(&"blame"));
}
#[test]
fn migrate_jscpd_non_numeric_min_tokens_ignored() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"minTokens": "fifty"}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert!(!config.contains_key("duplicates"));
}
#[test]
fn migrate_jscpd_non_numeric_min_lines_ignored() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"minLines": "ten"}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert!(!config.contains_key("duplicates"));
}
#[test]
fn migrate_jscpd_mode_non_string_ignored() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"mode": 42}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert!(!config.contains_key("duplicates"));
assert!(warnings.is_empty());
}
#[test]
fn migrate_jscpd_threshold_non_numeric_ignored() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"threshold": "high"}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert!(!config.contains_key("duplicates"));
}
#[test]
fn migrate_jscpd_skip_local_non_bool_ignored() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"skipLocal": "yes"}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert!(!config.contains_key("duplicates"));
}
#[test]
fn migrate_jscpd_min_tokens_float_ignored() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"minTokens": 50.5}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert!(!config.contains_key("duplicates"));
}
#[test]
fn migrate_jscpd_threshold_zero() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"threshold": 0}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let dupes = config.get("duplicates").unwrap().as_object().unwrap();
assert_eq!(dupes.get("threshold").unwrap(), 0.0);
}
#[test]
fn migrate_jscpd_ignore_mixed_types() {
let jscpd: serde_json::Value =
serde_json::from_str(r#"{"ignore": ["dist/**", 42, true]}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let dupes = config.get("duplicates").unwrap().as_object().unwrap();
assert_eq!(
dupes.get("ignore").unwrap(),
&serde_json::json!(["dist/**"])
);
}
#[test]
fn migrate_jscpd_ignore_non_value() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"ignore": 42}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert!(!config.contains_key("duplicates"));
}
#[test]
fn migrate_jscpd_all_warnings_have_jscpd_source() {
let jscpd: serde_json::Value =
serde_json::from_str(r#"{"mode": "unknown_mode", "maxLines": 100, "blame": true}"#)
.unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert!(!warnings.is_empty());
for w in &warnings {
assert_eq!(
w.source, "jscpd",
"warning for `{}` should have source \"jscpd\"",
w.field
);
}
}
#[test]
fn migrate_jscpd_only_unmappable_fields_no_duplicates_key() {
let jscpd: serde_json::Value =
serde_json::from_str(r#"{"maxLines": 1000, "blame": true, "reporters": ["json"]}"#)
.unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert!(!config.contains_key("duplicates"));
assert_eq!(warnings.len(), 3);
}
#[test]
fn migrate_jscpd_min_lines_zero() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"minLines": 0}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let dupes = config.get("duplicates").unwrap().as_object().unwrap();
assert_eq!(dupes.get("minLines").unwrap(), 0);
}
#[test]
fn migrate_jscpd_large_min_tokens() {
let jscpd: serde_json::Value = serde_json::from_str(r#"{"minTokens": 999999}"#).unwrap();
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
let dupes = config.get("duplicates").unwrap().as_object().unwrap();
assert_eq!(dupes.get("minTokens").unwrap(), 999_999);
}
#[test]
fn migrate_jscpd_null_root() {
let jscpd: serde_json::Value = serde_json::json!(null);
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert_eq!(warnings.len(), 1);
assert!(config.is_empty());
}
#[test]
fn migrate_jscpd_array_root() {
let jscpd: serde_json::Value = serde_json::json!([1, 2, 3]);
let mut config = empty_config();
let mut warnings = Vec::new();
migrate_jscpd(&jscpd, &mut config, &mut warnings);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].field, "(root)");
assert!(config.is_empty());
}
}