use crate::priority;
#[cfg(test)]
use crate::priority::UnifiedAnalysisUtils;
use anyhow::Result;
use std::cmp::Ordering;
use std::collections::HashSet;
use std::fs;
use std::io::Write;
use std::path::PathBuf;
pub fn output_json(
analysis: &priority::UnifiedAnalysis,
output_file: Option<PathBuf>,
) -> Result<()> {
output_json_with_filters(analysis, None, None, output_file)
}
pub fn output_json_with_filters(
analysis: &priority::UnifiedAnalysis,
top: Option<usize>,
tail: Option<usize>,
output_file: Option<PathBuf>,
) -> Result<()> {
output_json_with_format(analysis, top, tail, output_file, false)
}
pub fn output_json_with_format(
analysis: &priority::UnifiedAnalysis,
top: Option<usize>,
tail: Option<usize>,
output_file: Option<PathBuf>,
include_scoring_details: bool,
) -> Result<()> {
let unified_output =
crate::output::unified::convert_to_unified_format(analysis, include_scoring_details);
let filtered = apply_filters_to_unified_output(unified_output, top, tail);
let json = serde_json::to_string_pretty(&filtered)?;
if let Some(path) = output_file {
if let Some(parent) = path.parent() {
crate::io::ensure_dir(parent)?;
}
let mut file = fs::File::create(path)?;
file.write_all(json.as_bytes())?;
} else {
println!("{json}");
}
Ok(())
}
fn apply_filters_to_unified_output(
mut output: crate::output::unified::UnifiedOutput,
top: Option<usize>,
tail: Option<usize>,
) -> crate::output::unified::UnifiedOutput {
if top.is_some() || tail.is_some() {
output.items = filter_items_by_location_groups(output.items, top, tail);
}
output.summary.total_items = output.items.len();
output
}
#[derive(Debug, Clone)]
struct LocationGroupSelection {
key: (String, Option<String>, Option<usize>),
combined_score: f64,
first_index: usize,
}
fn filter_items_by_location_groups(
items: Vec<crate::output::unified::UnifiedDebtItemOutput>,
top: Option<usize>,
tail: Option<usize>,
) -> Vec<crate::output::unified::UnifiedDebtItemOutput> {
let groups = sorted_function_location_groups(&items);
let selected_keys = select_location_group_keys(groups, top, tail);
items
.into_iter()
.filter(|item| selected_keys.contains(&item_location_key(item)))
.collect()
}
fn sorted_function_location_groups(
items: &[crate::output::unified::UnifiedDebtItemOutput],
) -> Vec<LocationGroupSelection> {
let mut groups = Vec::<LocationGroupSelection>::new();
for (index, item) in items.iter().enumerate() {
let Some(key) = function_location_key(item) else {
continue;
};
if let Some(group) = groups.iter_mut().find(|group| group.key == key) {
group.combined_score += item.score();
} else {
groups.push(LocationGroupSelection {
key,
combined_score: item.score(),
first_index: index,
});
}
}
groups.sort_by(compare_location_groups);
groups
}
fn select_location_group_keys(
groups: Vec<LocationGroupSelection>,
top: Option<usize>,
tail: Option<usize>,
) -> HashSet<(String, Option<String>, Option<usize>)> {
let selected = if let Some(n) = top {
groups.into_iter().take(n).collect()
} else if let Some(n) = tail {
let skip = groups.len().saturating_sub(n);
groups.into_iter().skip(skip).collect()
} else {
groups
};
selected.into_iter().map(|group| group.key).collect()
}
fn compare_location_groups(a: &LocationGroupSelection, b: &LocationGroupSelection) -> Ordering {
b.combined_score
.partial_cmp(&a.combined_score)
.unwrap_or(Ordering::Equal)
.then_with(|| a.first_index.cmp(&b.first_index))
}
fn item_location_key(
item: &crate::output::unified::UnifiedDebtItemOutput,
) -> (String, Option<String>, Option<usize>) {
match item {
crate::output::unified::UnifiedDebtItemOutput::Function(function) => (
function.location.file.clone(),
function.location.function.clone(),
function.location.line,
),
crate::output::unified::UnifiedDebtItemOutput::File(file) => {
(file.location.file.clone(), None, None)
}
}
}
fn function_location_key(
item: &crate::output::unified::UnifiedDebtItemOutput,
) -> Option<(String, Option<String>, Option<usize>)> {
match item {
crate::output::unified::UnifiedDebtItemOutput::Function(function) => Some((
function.location.file.clone(),
function.location.function.clone(),
function.location.line,
)),
crate::output::unified::UnifiedDebtItemOutput::File(_) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::unified::{
DebtSummary, OutputMetadata, ScoreDistribution, TypeBreakdown, UnifiedDebtItemOutput,
UnifiedOutput,
};
use crate::priority::{
call_graph::CallGraph, ActionableRecommendation, DebtType, FunctionRole, ImpactMetrics,
Location, UnifiedDebtItem, UnifiedScore,
};
use std::path::PathBuf;
use tempfile::TempDir;
fn create_test_item(name: &str, score: f64) -> UnifiedDebtItem {
UnifiedDebtItem {
location: Location {
file: PathBuf::from("test.rs"),
line: 10,
function: name.to_string(),
},
debt_type: DebtType::ComplexityHotspot {
cyclomatic: 15,
cognitive: 25,
},
unified_score: UnifiedScore {
complexity_factor: 50.0,
coverage_factor: 80.0,
dependency_factor: 50.0,
role_multiplier: 2.0,
final_score: score.max(0.0),
base_score: None,
exponential_factor: None,
risk_boost: None,
pre_adjustment_score: None,
adjustment_applied: None,
purity_factor: None,
refactorability_factor: None,
pattern_factor: None,
debt_adjustment: None,
pre_normalization_score: None,
structural_multiplier: Some(1.0),
has_coverage_data: false,
contextual_risk_multiplier: None,
pre_contextual_score: None,
debt_type_multiplier: None,
},
function_role: FunctionRole::PureLogic,
recommendation: ActionableRecommendation {
primary_action: "Fix issue".to_string(),
rationale: "Test reason".to_string(),
implementation_steps: vec![],
related_items: vec![],
steps: None,
estimated_effort_hours: None,
},
expected_impact: ImpactMetrics {
complexity_reduction: 100.0,
risk_reduction: 10.0,
coverage_improvement: 100.0,
lines_reduction: 500,
},
transitive_coverage: None,
file_context: None,
upstream_dependencies: 10,
downstream_dependencies: 20,
upstream_callers: vec![],
downstream_callees: vec![],
upstream_production_callers: vec![],
upstream_test_callers: vec![],
production_blast_radius: 0,
nesting_depth: 5,
function_length: 200,
cyclomatic_complexity: 25,
cognitive_complexity: 40,
is_pure: Some(false),
purity_confidence: Some(0.8),
purity_level: None,
god_object_indicators: None,
tier: None,
function_context: None,
context_confidence: None,
contextual_recommendation: None,
pattern_analysis: None,
context_multiplier: None,
context_type: None,
language_specific: None, detected_pattern: None,
contextual_risk: None, file_line_count: None,
responsibility_category: None,
error_swallowing_count: None,
error_swallowing_patterns: None,
entropy_analysis: None,
context_suggestion: None,
}
}
fn create_test_analysis_with_items(count: usize) -> priority::UnifiedAnalysis {
let call_graph = CallGraph::new();
let mut analysis = priority::UnifiedAnalysis::new(call_graph);
for i in 0..count {
let mut item = create_test_item(&format!("func_{}", i), 100.0 - i as f64);
item.location.line = 10 + i;
analysis.add_item(item);
}
analysis.sort_by_priority();
analysis
}
fn create_unified_output(items: Vec<UnifiedDebtItemOutput>) -> UnifiedOutput {
UnifiedOutput {
format_version: "3.0".to_string(),
metadata: OutputMetadata {
debtmap_version: "test".to_string(),
generated_at: "test".to_string(),
project_root: None,
analysis_type: "unified".to_string(),
},
summary: DebtSummary {
total_items: items.len(),
total_debt_score: items.iter().map(UnifiedDebtItemOutput::score).sum(),
debt_density: 0.0,
total_loc: 0,
by_type: TypeBreakdown {
file: 0,
function: items.len(),
},
by_category: Default::default(),
score_distribution: ScoreDistribution::default(),
cohesion: None,
},
items,
}
}
fn get_function_name(item: &UnifiedDebtItemOutput) -> Option<String> {
match item {
UnifiedDebtItemOutput::Function(f) => f.location.function.clone(),
UnifiedDebtItemOutput::File(_) => None,
}
}
#[test]
fn test_top_filter_selects_complete_highest_location_group() {
let items = vec![
UnifiedDebtItemOutput::from_debt_item(
&crate::priority::DebtItem::Function(Box::new(create_test_item("other", 40.0))),
false,
),
UnifiedDebtItemOutput::from_debt_item(
&crate::priority::DebtItem::Function(Box::new(create_test_item("grouped", 30.0))),
false,
),
UnifiedDebtItemOutput::from_debt_item(
&crate::priority::DebtItem::Function(Box::new(create_test_item("grouped", 29.0))),
false,
),
];
let output = create_unified_output(items);
let filtered = apply_filters_to_unified_output(output, Some(1), None);
assert_eq!(filtered.items.len(), 2);
assert!(filtered
.items
.iter()
.all(|item| get_function_name(item) == Some("grouped".to_string())));
}
#[test]
fn test_output_json_creates_parent_directories() {
let temp_dir = TempDir::new().unwrap();
let nested_path = temp_dir
.path()
.join("nested")
.join("subdirs")
.join("output.json");
let call_graph = CallGraph::new();
let analysis = priority::UnifiedAnalysis::new(call_graph);
let result = output_json(&analysis, Some(nested_path.clone()));
assert!(
result.is_ok(),
"Failed to write JSON to nested path: {:?}",
result.err()
);
assert!(
nested_path.exists(),
"Output file was not created at nested path"
);
let content = fs::read_to_string(&nested_path).unwrap();
assert!(!content.is_empty(), "Output file is empty");
}
#[test]
fn test_output_json_with_head_parameter() {
let temp_dir = TempDir::new().unwrap();
let output_path = temp_dir.path().join("output.json");
let analysis = create_test_analysis_with_items(10);
let result = output_json_with_filters(&analysis, Some(3), None, Some(output_path.clone()));
assert!(result.is_ok(), "Failed to write JSON: {:?}", result.err());
let content = fs::read_to_string(&output_path).unwrap();
let parsed: UnifiedOutput = serde_json::from_str(&content).unwrap();
assert_eq!(
parsed.items.len(),
3,
"Expected 3 items with head=3, got {}",
parsed.items.len()
);
assert_eq!(
get_function_name(&parsed.items[0]),
Some("func_0".to_string())
);
assert_eq!(
get_function_name(&parsed.items[1]),
Some("func_1".to_string())
);
assert_eq!(
get_function_name(&parsed.items[2]),
Some("func_2".to_string())
);
}
#[test]
fn test_output_json_with_tail_parameter() {
let temp_dir = TempDir::new().unwrap();
let output_path = temp_dir.path().join("output.json");
let analysis = create_test_analysis_with_items(10);
let result = output_json_with_filters(&analysis, None, Some(3), Some(output_path.clone()));
assert!(result.is_ok(), "Failed to write JSON: {:?}", result.err());
let content = fs::read_to_string(&output_path).unwrap();
let parsed: UnifiedOutput = serde_json::from_str(&content).unwrap();
assert_eq!(
parsed.items.len(),
3,
"Expected 3 items with tail=3, got {}",
parsed.items.len()
);
assert_eq!(
get_function_name(&parsed.items[0]),
Some("func_7".to_string())
);
assert_eq!(
get_function_name(&parsed.items[1]),
Some("func_8".to_string())
);
assert_eq!(
get_function_name(&parsed.items[2]),
Some("func_9".to_string())
);
}
#[test]
fn test_output_json_without_filters() {
let temp_dir = TempDir::new().unwrap();
let output_path = temp_dir.path().join("output.json");
let analysis = create_test_analysis_with_items(10);
let result = output_json_with_filters(&analysis, None, None, Some(output_path.clone()));
assert!(result.is_ok(), "Failed to write JSON: {:?}", result.err());
let content = fs::read_to_string(&output_path).unwrap();
let parsed: UnifiedOutput = serde_json::from_str(&content).unwrap();
assert_eq!(
parsed.items.len(),
10,
"Expected all 10 items without filters, got {}",
parsed.items.len()
);
}
#[test]
fn test_output_json_head_larger_than_items() {
let temp_dir = TempDir::new().unwrap();
let output_path = temp_dir.path().join("output.json");
let analysis = create_test_analysis_with_items(5);
let result = output_json_with_filters(&analysis, Some(10), None, Some(output_path.clone()));
assert!(result.is_ok(), "Failed to write JSON: {:?}", result.err());
let content = fs::read_to_string(&output_path).unwrap();
let parsed: UnifiedOutput = serde_json::from_str(&content).unwrap();
assert_eq!(
parsed.items.len(),
5,
"Expected 5 items (all available), got {}",
parsed.items.len()
);
}
#[test]
fn test_output_json_tail_larger_than_items() {
let temp_dir = TempDir::new().unwrap();
let output_path = temp_dir.path().join("output.json");
let analysis = create_test_analysis_with_items(5);
let result = output_json_with_filters(&analysis, None, Some(10), Some(output_path.clone()));
assert!(result.is_ok(), "Failed to write JSON: {:?}", result.err());
let content = fs::read_to_string(&output_path).unwrap();
let parsed: UnifiedOutput = serde_json::from_str(&content).unwrap();
assert_eq!(
parsed.items.len(),
5,
"Expected 5 items (all available), got {}",
parsed.items.len()
);
}
#[test]
fn test_output_json_includes_file_level_items() {
use crate::priority::{FileDebtItem, FileDebtMetrics, FileImpact};
let temp_dir = TempDir::new().unwrap();
let output_path = temp_dir.path().join("output.json");
let call_graph = CallGraph::new();
let mut analysis = priority::UnifiedAnalysis::new(call_graph);
for i in 0..3 {
let mut item = create_test_item(&format!("func_{}", i), 50.0 + i as f64);
item.location.line = 10 + i;
analysis.add_item(item);
}
let file_item = FileDebtItem {
metrics: FileDebtMetrics {
path: PathBuf::from("god_object.rs"),
total_lines: 5530,
function_count: 179,
class_count: 0,
avg_complexity: 25.0,
max_complexity: 85,
total_complexity: 4500,
coverage_percent: 0.3,
uncovered_lines: 3871,
god_object_analysis: Some(crate::organization::GodObjectAnalysis {
method_count: 179,
weighted_method_count: None,
field_count: 20,
responsibility_count: 15,
is_god_object: true,
god_object_score: 8500.0,
lines_of_code: 5533,
complexity_sum: 4500,
responsibilities: vec!["Too many responsibilities".to_string()],
responsibility_method_counts: Default::default(),
recommended_splits: vec![],
confidence: crate::organization::GodObjectConfidence::Definite,
purity_distribution: None,
module_structure: None,
detection_type: crate::organization::DetectionType::GodFile,
struct_name: None,
struct_line: None,
struct_location: None,
visibility_breakdown: None,
domain_count: 0,
domain_diversity: 0.0,
struct_ratio: 0.0,
analysis_method: crate::organization::SplitAnalysisMethod::None,
cross_domain_severity: None,
domain_diversity_metrics: None,
aggregated_entropy: None,
aggregated_error_swallowing_count: None,
aggregated_error_swallowing_patterns: None,
layering_impact: None,
anti_pattern_report: None,
complexity_metrics: None, trait_method_summary: None, }),
function_scores: vec![],
god_object_type: None,
file_type: None,
..Default::default()
},
score: 606.0, priority_rank: 1,
recommendation: "Split this god object".to_string(),
impact: FileImpact {
complexity_reduction: 200.0,
maintainability_improvement: 80.0,
test_effort: 40.0,
},
};
analysis.add_file_item(file_item);
analysis.sort_by_priority();
let result = output_json_with_filters(&analysis, None, None, Some(output_path.clone()));
assert!(result.is_ok(), "Failed to write JSON: {:?}", result.err());
let content = fs::read_to_string(&output_path).unwrap();
let parsed: UnifiedOutput = serde_json::from_str(&content).unwrap();
assert_eq!(
parsed.items.len(),
4,
"Expected 4 items total (3 function + 1 file), got {}",
parsed.items.len()
);
match &parsed.items[0] {
UnifiedDebtItemOutput::File(file) => {
assert_eq!(file.score, 606.0);
assert_eq!(file.location.file, "god_object.rs");
}
_ => panic!("Expected first item to be a File debt item with highest score"),
}
for i in 1..4 {
match &parsed.items[i] {
UnifiedDebtItemOutput::Function(_) => {
}
_ => panic!("Expected item {} to be a Function debt item", i),
}
}
}
}