use std::collections::HashMap;
use std::path::Path;
use super::{AiInsight, CommandGap, RankedDup};
use crate::types::FileAnalysis;
fn find_cross_lang_stem_matches(files: &[FileAnalysis]) -> Vec<(String, Vec<(String, String)>)> {
let binding_langs: &[&str] = &["py", "ts", "rs", "js"];
let mut stem_map: HashMap<String, Vec<(String, String)>> = HashMap::new();
for file in files {
if file.is_test || file.is_generated {
continue;
}
if !binding_langs.contains(&file.language.as_str()) {
continue;
}
let path = Path::new(&file.path);
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let lower_stem = stem.to_lowercase();
if matches!(
lower_stem.as_str(),
"index" | "mod" | "lib" | "main" | "utils" | "helpers" | "types" | "constants"
) {
continue;
}
stem_map
.entry(stem.to_string())
.or_default()
.push((file.path.clone(), file.language.clone()));
}
}
let mut matches: Vec<(String, Vec<(String, String)>)> = stem_map
.into_iter()
.filter(|(_, entries)| {
let langs: std::collections::HashSet<_> = entries.iter().map(|(_, l)| l).collect();
langs.len() > 1 })
.collect();
matches.sort_by(|a, b| a.0.cmp(&b.0));
matches
}
pub fn collect_ai_insights(
files: &[FileAnalysis],
dups: &[RankedDup],
cascades: &[(String, String)],
gap_missing: &[CommandGap],
_gap_unused: &[CommandGap],
) -> Vec<AiInsight> {
let mut insights = Vec::new();
let cross_lang_matches = find_cross_lang_stem_matches(files);
if !cross_lang_matches.is_empty() {
let examples: Vec<String> = cross_lang_matches
.iter()
.take(5)
.map(|(stem, entries)| {
let langs: Vec<_> = entries.iter().map(|(_, l)| l.as_str()).collect();
format!("'{}' ({})", stem, langs.join("/"))
})
.collect();
insights.push(AiInsight {
title: "Potential cross-language binding pairs".to_string(),
severity: "info".to_string(),
message: format!(
"Found {} file stem(s) shared across languages: {}. These may be binding pairs (e.g., Python/Rust FFI or TS/Rust Tauri commands). Check if they should share types/interfaces.",
cross_lang_matches.len(),
examples.join(", ")
),
});
}
let huge_files: Vec<_> = files.iter().filter(|f| f.loc > 2000).collect();
if !huge_files.is_empty() {
insights.push(AiInsight {
title: "Huge files detected".to_string(),
severity: "medium".to_string(),
message: format!(
"Found {} files with > 2000 LOC (e.g. {}). Consider splitting them.",
huge_files.len(),
huge_files[0].path
),
});
}
if dups.len() > 10 {
insights.push(AiInsight {
title: "High number of duplicate exports".to_string(),
severity: "medium".to_string(),
message: format!(
"Found {} duplicate export groups. Consider refactoring.",
dups.len()
),
});
}
if cascades.len() > 20 {
insights.push(AiInsight {
title: "Many re-export chains".to_string(),
severity: "low".to_string(),
message: format!(
"Found {} re-export cascades. This might affect tree-shaking/bundling.",
cascades.len()
),
});
}
if !gap_missing.is_empty() {
insights.push(AiInsight {
title: "Missing Tauri Handlers".to_string(),
severity: "high".to_string(),
message: format!(
"Frontend calls {} commands that are missing in Backend.",
gap_missing.len()
),
});
}
insights
}
#[cfg(test)]
mod tests {
use super::*;
use crate::analyzer::report::DupSeverity;
fn mock_file(path: &str, language: &str, loc: usize) -> FileAnalysis {
FileAnalysis {
path: path.to_string(),
language: language.to_string(),
loc,
..Default::default()
}
}
fn mock_file_test(path: &str, language: &str, loc: usize) -> FileAnalysis {
FileAnalysis {
path: path.to_string(),
language: language.to_string(),
loc,
is_test: true,
..Default::default()
}
}
#[test]
fn test_cross_lang_stem_matches_finds_pairs() {
let files = vec![
mock_file("src/audio.rs", "rs", 100),
mock_file("src/audio.ts", "ts", 50),
mock_file("lib/player.py", "py", 80),
mock_file("lib/player.ts", "ts", 60),
];
let matches = find_cross_lang_stem_matches(&files);
assert_eq!(matches.len(), 2);
let stems: Vec<&str> = matches.iter().map(|(s, _)| s.as_str()).collect();
assert!(stems.contains(&"audio"));
assert!(stems.contains(&"player"));
}
#[test]
fn test_cross_lang_stem_matches_ignores_generic_names() {
let files = vec![
mock_file("src/index.rs", "rs", 100),
mock_file("src/index.ts", "ts", 50),
mock_file("lib/utils.py", "py", 80),
mock_file("lib/utils.ts", "ts", 60),
mock_file("mod.rs", "rs", 10),
mock_file("mod.py", "py", 10),
];
let matches = find_cross_lang_stem_matches(&files);
assert!(matches.is_empty(), "Should ignore generic names");
}
#[test]
fn test_cross_lang_stem_matches_ignores_test_files() {
let files = vec![
mock_file_test("src/audio.rs", "rs", 100),
mock_file("src/audio.ts", "ts", 50),
];
let matches = find_cross_lang_stem_matches(&files);
assert!(matches.is_empty(), "Should ignore test files");
}
#[test]
fn test_cross_lang_stem_matches_ignores_same_lang() {
let files = vec![
mock_file("src/audio.ts", "ts", 100),
mock_file("lib/audio.ts", "ts", 50),
];
let matches = find_cross_lang_stem_matches(&files);
assert!(matches.is_empty(), "Should not match same language");
}
#[test]
fn test_collect_ai_insights_huge_files() {
let files = vec![
mock_file("src/huge.ts", "ts", 3000),
mock_file("src/small.ts", "ts", 100),
];
let insights = collect_ai_insights(&files, &[], &[], &[], &[]);
assert!(insights.iter().any(|i| i.title.contains("Huge files")));
}
#[test]
fn test_collect_ai_insights_many_dups() {
let files = vec![mock_file("src/a.ts", "ts", 100)];
let dups: Vec<RankedDup> = (0..15)
.map(|i| RankedDup {
name: format!("dup{}", i),
files: vec![format!("file{}.ts", i)],
locations: vec![],
score: i,
prod_count: 1,
dev_count: 0,
canonical: format!("file{}.ts", i),
canonical_line: None,
refactors: vec![],
severity: DupSeverity::SamePackage,
is_cross_lang: false,
packages: vec![],
reason: String::new(),
})
.collect();
let insights = collect_ai_insights(&files, &dups, &[], &[], &[]);
assert!(
insights
.iter()
.any(|i| i.title.contains("duplicate exports"))
);
}
#[test]
fn test_collect_ai_insights_many_cascades() {
let files = vec![mock_file("src/a.ts", "ts", 100)];
let cascades: Vec<(String, String)> = (0..25)
.map(|i| (format!("from{}.ts", i), format!("to{}.ts", i)))
.collect();
let insights = collect_ai_insights(&files, &[], &cascades, &[], &[]);
assert!(
insights
.iter()
.any(|i| i.title.contains("re-export chains"))
);
}
#[test]
fn test_collect_ai_insights_missing_handlers() {
let files = vec![mock_file("src/a.ts", "ts", 100)];
let missing = vec![CommandGap {
name: "missing_cmd".to_string(),
implementation_name: None,
locations: vec![("src/a.ts".to_string(), 10)],
confidence: None,
string_literal_matches: vec![],
}];
let insights = collect_ai_insights(&files, &[], &[], &missing, &[]);
assert!(
insights
.iter()
.any(|i| i.title.contains("Missing Tauri Handlers"))
);
assert!(insights.iter().any(|i| i.severity == "high"));
}
#[test]
fn test_collect_ai_insights_empty_inputs() {
let insights = collect_ai_insights(&[], &[], &[], &[], &[]);
assert!(insights.is_empty());
}
#[test]
fn test_cross_lang_with_generated_files() {
let mut generated = mock_file("src/audio.rs", "rs", 100);
generated.is_generated = true;
let files = vec![generated, mock_file("src/audio.ts", "ts", 50)];
let matches = find_cross_lang_stem_matches(&files);
assert!(matches.is_empty(), "Should ignore generated files");
}
#[test]
fn test_collect_ai_insights_cross_lang_binding() {
let files = vec![
mock_file("src/audio_processor.rs", "rs", 200),
mock_file("src/audio_processor.ts", "ts", 150),
mock_file("lib/video_encoder.py", "py", 100),
mock_file("lib/video_encoder.rs", "rs", 120),
];
let insights = collect_ai_insights(&files, &[], &[], &[], &[]);
assert!(
insights
.iter()
.any(|i| i.title.contains("cross-language binding"))
);
assert!(insights.iter().any(|i| i.severity == "info"));
}
}