use std::time::Duration;
use fallow_types::envelope::{ElapsedMs, Meta, SchemaVersion, ToolVersion};
use fallow_types::output::NextStep;
use fallow_types::workspace::WorkspaceDiagnostic;
use serde::Serialize;
use crate::GroupByMode;
use crate::root_envelopes::{RootEnvelopeMode, attach_telemetry_meta, serialize_named_json_output};
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "schema", schemars(title = "fallow dupes --format json"))]
pub struct DupesOutput<Report, Group> {
pub schema_version: SchemaVersion,
pub version: ToolVersion,
pub elapsed_ms: ElapsedMs,
#[serde(flatten)]
pub report: Report,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grouped_by: Option<GroupByMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_issues: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub groups: Option<Vec<Group>>,
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub next_steps: Vec<NextStep>,
}
#[derive(Debug, Clone)]
pub struct DupesOutputInput<Report, Group> {
pub schema_version: u32,
pub version: String,
pub elapsed: Duration,
pub report: Report,
pub grouped_by: Option<GroupByMode>,
pub total_issues: Option<usize>,
pub groups: Option<Vec<Group>>,
pub meta: Option<Meta>,
pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
pub next_steps: Vec<NextStep>,
}
#[must_use]
pub fn build_dupes_output<Report, Group>(
input: DupesOutputInput<Report, Group>,
) -> DupesOutput<Report, Group> {
DupesOutput {
schema_version: SchemaVersion(input.schema_version),
version: ToolVersion(input.version),
elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
report: input.report,
grouped_by: input.grouped_by,
total_issues: input.total_issues,
groups: input.groups,
meta: input.meta,
workspace_diagnostics: input.workspace_diagnostics,
next_steps: input.next_steps,
}
}
pub fn serialize_dupes_json_output<Report, Group>(
output: DupesOutput<Report, Group>,
mode: RootEnvelopeMode,
analysis_run_id: Option<&str>,
) -> Result<serde_json::Value, serde_json::Error>
where
Report: Serialize,
Group: Serialize,
{
let mut value = serialize_named_json_output(output, "dupes", mode)?;
attach_telemetry_meta(&mut value, analysis_run_id);
Ok(value)
}
pub const DUPES_SUPPRESS_COMMENT: &str = "// fallow-ignore-next-line code-duplication";
pub const DUPES_SUPPRESS_DESCRIPTION: &str =
"Suppress with an inline comment above the duplicated code";
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CloneGroupAction {
#[serde(rename = "type")]
pub kind: CloneGroupActionType,
pub auto_fixable: bool,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum CloneGroupActionType {
ExtractShared,
SuppressLine,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CloneFamilyAction {
#[serde(rename = "type")]
pub kind: CloneFamilyActionType,
pub auto_fixable: bool,
pub description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub comment: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum CloneFamilyActionType {
ExtractShared,
ApplySuggestion,
SuppressLine,
}
#[must_use]
pub fn clone_group_actions(line_count: usize, instance_count: usize) -> Vec<CloneGroupAction> {
vec![
CloneGroupAction {
kind: CloneGroupActionType::ExtractShared,
auto_fixable: false,
description: format!(
"Extract duplicated code ({line_count} lines, {instance_count} instance{}) into a shared function",
if instance_count == 1 { "" } else { "s" },
),
comment: None,
},
CloneGroupAction {
kind: CloneGroupActionType::SuppressLine,
auto_fixable: false,
description: DUPES_SUPPRESS_DESCRIPTION.to_string(),
comment: Some(DUPES_SUPPRESS_COMMENT.to_string()),
},
]
}
#[must_use]
pub fn clone_family_actions<'a>(
group_count: usize,
total_duplicated_lines: usize,
suggestion_descriptions: impl IntoIterator<Item = &'a str>,
) -> Vec<CloneFamilyAction> {
let suggestions = suggestion_descriptions.into_iter();
let (lower, _) = suggestions.size_hint();
let mut actions = Vec::with_capacity(2 + lower);
actions.push(CloneFamilyAction {
kind: CloneFamilyActionType::ExtractShared,
auto_fixable: false,
description: format!(
"Extract {group_count} duplicated code block{} ({total_duplicated_lines} lines) into a shared module",
if group_count == 1 { "" } else { "s" },
),
note: Some(
"These clone groups share the same files, indicating a structural relationship; refactor together"
.to_string(),
),
comment: None,
});
for description in suggestions {
actions.push(CloneFamilyAction {
kind: CloneFamilyActionType::ApplySuggestion,
auto_fixable: false,
description: description.to_string(),
note: None,
comment: None,
});
}
actions.push(CloneFamilyAction {
kind: CloneFamilyActionType::SuppressLine,
auto_fixable: false,
description: DUPES_SUPPRESS_DESCRIPTION.to_string(),
note: None,
comment: Some(DUPES_SUPPRESS_COMMENT.to_string()),
});
actions
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn dupes_json_output_uses_output_owned_root_contract() {
let output = build_dupes_output(DupesOutputInput::<_, serde_json::Value> {
schema_version: 7,
version: "0.0.0".to_string(),
elapsed: Duration::from_millis(5),
report: json!({"stats": {"clone_groups": 0}}),
grouped_by: None,
total_issues: None,
groups: None,
meta: None,
workspace_diagnostics: Vec::new(),
next_steps: Vec::new(),
});
let value =
serialize_dupes_json_output(output, RootEnvelopeMode::Tagged, Some("run-dupes"))
.expect("dupes output should serialize");
assert_eq!(value["kind"], "dupes");
assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-dupes");
}
#[test]
fn clone_group_actions_keep_primary_then_suppression_order() {
let actions = clone_group_actions(20, 2);
assert_eq!(actions[0].kind, CloneGroupActionType::ExtractShared);
assert_eq!(actions[1].kind, CloneGroupActionType::SuppressLine);
assert_eq!(actions[1].comment.as_deref(), Some(DUPES_SUPPRESS_COMMENT));
}
#[test]
fn clone_family_actions_insert_suggestions_between_primary_and_suppression() {
let actions = clone_family_actions(2, 40, ["Move to shared parser"]);
assert_eq!(actions[0].kind, CloneFamilyActionType::ExtractShared);
assert_eq!(actions[1].kind, CloneFamilyActionType::ApplySuggestion);
assert_eq!(actions[1].description, "Move to shared parser");
assert_eq!(actions[2].kind, CloneFamilyActionType::SuppressLine);
}
}