fallow-output 3.27.0

Output contract types for fallow reports
Documentation
//! Shared output contracts for duplication action arrays.
//!
//! The duplication report body is assembled by API/CLI layers while clone
//! contracts live in `fallow-types`. These envelope DTOs stay engine-neutral
//! and are shared by schema emission, JSON output, and programmatic consumers.

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};

/// Current schema version for `fallow dupes --format json`.
pub const DUPES_SCHEMA_VERSION: u32 = 10;

/// Current schema version for programmatic duplication JSON.
pub const DUPES_PROGRAMMATIC_SCHEMA_VERSION: u32 = 4;

/// Schema projection for the duplication envelope's CLI and programmatic
/// version lineages.
#[cfg(feature = "schema")]
#[allow(dead_code, reason = "schema-only type used by the field projection")]
#[derive(schemars::JsonSchema)]
#[schemars(extend(
    "enum" = [DUPES_PROGRAMMATIC_SCHEMA_VERSION, DUPES_SCHEMA_VERSION]
))]
struct DupesSchemaVersion(u32);

/// Envelope emitted by `fallow dupes --format json`.
///
/// `Report` and `Group` are generic so the envelope can live in
/// `fallow-output` while duplication report wrappers and grouped output
/// internals continue to migrate out of CLI/API-specific crates.
#[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> {
    /// Duplication output schema version.
    #[cfg_attr(feature = "schema", schemars(with = "DupesSchemaVersion"))]
    pub schema_version: SchemaVersion,
    /// Fallow CLI version that produced this output.
    pub version: ToolVersion,
    /// Wall-clock analysis duration in milliseconds.
    pub elapsed_ms: ElapsedMs,
    /// Duplication report body, flattened into the envelope root.
    #[serde(flatten)]
    pub report: Report,
    /// Number of clone groups carried in `clone_groups[]`.
    pub clone_groups_shown: usize,
    /// Number of scoped-corpus clone groups withheld from `clone_groups[]` by
    /// a presentation cap such as `--top`. `0` on an untruncated run, so
    /// `clone_groups_shown + clone_groups_omitted == stats.clone_groups`
    /// always holds and `stats` keeps describing the whole measured corpus.
    pub clone_groups_omitted: usize,
    /// Number of clone families carried in `clone_families[]`.
    pub clone_families_shown: usize,
    /// Number of scoped-corpus clone families withheld from `clone_families[]`
    /// by a presentation cap such as `--top`, which rebuilds the families from
    /// the groups that survived the cap. `0` on an untruncated run, so
    /// `clone_families_shown + clone_families_omitted == stats.clone_families`
    /// always holds and `stats` keeps describing the whole measured corpus.
    pub clone_families_omitted: usize,
    /// Grouping mode when `--group-by` was passed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub grouped_by: Option<GroupByMode>,
    /// Total finding count across all groups; present only in grouped output.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub total_issues: Option<usize>,
    /// Grouped findings; present only in grouped output.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub groups: Option<Vec<Group>>,
    /// This run's view of the loaded baseline, present only in baseline runs.
    /// Carries the staleness counts, the advisory verdict and `gate_trips`, the
    /// same boolean `--fail-on-stale-baseline` exits on, so a CI integration
    /// reads one field instead of restating the rule. Read `change_scoped`
    /// before dividing `matched_entries` by `baseline_entries`: a narrowed run
    /// can report `matched_entries: 0` on a healthy baseline.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub baseline_staleness: Option<crate::BaselineStaleness>,
    /// Every gate this run ARMED, keyed by name, absent when it armed none.
    /// Each entry is the same rule that decides the exit code, so a CI
    /// integration reads the verdict instead of guessing from a process status
    /// it usually cannot see. A gate fails the build when `status` is `fail`
    /// AND `enforced` is true. Armed, not evaluated: fallow's default severity
    /// rules fail a run with no flag at all, so an absent object means "no gate
    /// was asked for", never "nothing failed". See [`crate::GateOutcomes`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub gate_outcomes: Option<crate::GateOutcomes>,
    /// `_meta` block with metric / rule definitions, emitted when `--explain`
    /// is passed (always present in MCP responses).
    #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
    pub meta: Option<Meta>,
    /// Workspace-discovery and source-discovery diagnostics for the run
    /// (issue #473). See `CheckOutput::workspace_diagnostics` for the full
    /// contract; the same list is repeated on each top-level command's
    /// envelope so single-command consumers see it without having to look at
    /// a separate top-level field. A standalone `fallow dupes` run has no
    /// dead-code analyze pass, so the two analysis-stage kinds never appear
    /// here.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
    /// Read-only follow-up commands computed from this run's findings. See
    /// `CheckOutput::next_steps` for the contract.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub next_steps: Vec<NextStep>,
}

/// Inputs for constructing a [`DupesOutput`] without exposing envelope assembly
/// details to callers.
#[derive(Debug, Clone)]
pub struct DupesOutputInput<Report, Group> {
    /// Duplication output schema version.
    pub schema_version: u32,
    /// Fallow CLI version to report.
    pub version: String,
    /// Wall-clock analysis duration; serialized as whole milliseconds.
    pub elapsed: Duration,
    /// Duplication report body to flatten into the envelope root.
    pub report: Report,
    /// Number of clone groups carried in `clone_groups[]`.
    pub clone_groups_shown: usize,
    /// Number of scoped-corpus clone groups withheld by a presentation cap.
    pub clone_groups_omitted: usize,
    /// Number of clone families carried in `clone_families[]`.
    pub clone_families_shown: usize,
    /// Number of scoped-corpus clone families withheld by a presentation cap.
    pub clone_families_omitted: usize,
    /// Grouping mode when `--group-by` was passed.
    pub grouped_by: Option<GroupByMode>,
    /// Total finding count across all groups, for grouped output.
    pub total_issues: Option<usize>,
    /// Grouped findings, for grouped output.
    pub groups: Option<Vec<Group>>,
    /// This run's view of the loaded duplication baseline, for baseline runs.
    pub baseline_staleness: Option<crate::BaselineStaleness>,
    /// Every gate this run evaluated, absent when it evaluated none.
    pub gate_outcomes: Option<crate::GateOutcomes>,
    /// `_meta` block to attach when `--explain` was passed.
    pub meta: Option<Meta>,
    /// Workspace-discovery and source-discovery diagnostics. See
    /// `CheckOutput::workspace_diagnostics` for the contract.
    pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
    /// Read-only follow-up commands computed from this run's findings.
    pub next_steps: Vec<NextStep>,
}

/// Build a duplication JSON envelope from caller-owned report data.
#[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,
        clone_groups_shown: input.clone_groups_shown,
        clone_groups_omitted: input.clone_groups_omitted,
        clone_families_shown: input.clone_families_shown,
        clone_families_omitted: input.clone_families_omitted,
        grouped_by: input.grouped_by,
        total_issues: input.total_issues,
        groups: input.groups,
        baseline_staleness: input.baseline_staleness,
        gate_outcomes: input.gate_outcomes,
        meta: input.meta,
        workspace_diagnostics: input.workspace_diagnostics,
        next_steps: input.next_steps,
    }
}

/// Serialize `fallow dupes --format json`.
///
/// # Errors
///
/// Returns a serde error when the duplication output cannot be converted to
/// JSON.
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)
}

/// Inline suppression comment emitted for code duplication findings.
pub const DUPES_SUPPRESS_COMMENT: &str = "// fallow-ignore-next-line code-duplication";

/// Shared description for the suppression action emitted on duplication findings.
pub const DUPES_SUPPRESS_DESCRIPTION: &str =
    "Suppress with an inline comment above the duplicated code";

/// Per-action wire shape attached to each `CloneGroupFinding` and
/// `AttributedCloneGroupFinding` (see `crates/api/src/dupes_output.rs`):
/// `extract-shared` plus `suppress-line`. The typed wrappers replaced the
/// legacy JSON post-pass injection that used to live in the CLI report layer.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CloneGroupAction {
    /// Action type identifier.
    #[serde(rename = "type")]
    pub kind: CloneGroupActionType,
    /// Whether `fallow fix` can auto-apply this action. Both variants are
    /// manual today; the field is non-singleton so a future auto-applier
    /// does not need a schema change.
    pub auto_fixable: bool,
    /// Human-readable description of the action.
    pub description: String,
    /// The inline comment to insert (e.g.,
    /// `// fallow-ignore-next-line code-duplication`). Present on
    /// `suppress-line`; absent on `extract-shared`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub comment: Option<String>,
}

/// Discriminant for [`CloneGroupAction::kind`]. Mirrors the action types
/// emitted by the legacy `build_clone_group_actions` walker.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum CloneGroupActionType {
    /// Extract the duplicated code into a shared function.
    ExtractShared,
    /// Suppress the finding with an inline comment above the duplicated code.
    SuppressLine,
}

/// Per-action wire shape attached to each `CloneFamilyFinding`. Mirrors
/// the action types previously emitted by
/// `build_clone_family_actions`: `extract-shared`, one `apply-suggestion`
/// per `RefactoringSuggestion` on the family, and a trailing
/// `suppress-line`.
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CloneFamilyAction {
    /// Action type identifier.
    #[serde(rename = "type")]
    pub kind: CloneFamilyActionType,
    /// Whether `fallow fix` can auto-apply this action. All three variants
    /// are manual today.
    pub auto_fixable: bool,
    /// Human-readable description of the action.
    pub description: String,
    /// Additional context. Present on `extract-shared` (explaining that
    /// the family's clone groups share the same files); absent otherwise.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub note: Option<String>,
    /// The inline comment to insert (e.g.,
    /// `// fallow-ignore-next-line code-duplication`). Present on
    /// `suppress-line` only.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub comment: Option<String>,
}

/// Discriminant for [`CloneFamilyAction::kind`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum CloneFamilyActionType {
    /// Extract the duplicated code blocks into a shared module.
    ExtractShared,
    /// Apply one of the family's refactoring suggestions.
    ApplySuggestion,
    /// Suppress with an inline comment above the duplicated code.
    SuppressLine,
}

/// Build the stable action list for one clone group.
#[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()),
        },
    ]
}

/// Build the stable action list for a clone family.
#[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> {
            gate_outcomes: None,
            baseline_staleness: None,
            schema_version: 7,
            version: "0.0.0".to_string(),
            elapsed: Duration::from_millis(5),
            report: json!({"stats": {"clone_groups": 0}}),
            clone_groups_shown: 0,
            clone_groups_omitted: 0,
            clone_families_shown: 0,
            clone_families_omitted: 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);
    }
}