1use std::collections::HashMap;
7
8use crate::graph::Node;
9
10pub fn build_community_labels(
18 all_nodes: &[Node],
19 cgx_labels: &HashMap<i64, String>,
20) -> HashMap<i64, String> {
21 let mut by_community: HashMap<i64, Vec<&str>> = HashMap::new();
22 for n in all_nodes {
23 if n.path.is_empty() {
24 continue;
25 }
26 by_community
27 .entry(n.community)
28 .or_default()
29 .push(n.path.as_str());
30 }
31
32 let mut out: HashMap<i64, String> = HashMap::new();
33 for (community, paths) in by_community.iter() {
34 let label = derive_label(
35 paths,
36 cgx_labels.get(community).map(|s| s.as_str()),
37 *community,
38 );
39 out.insert(*community, label);
40 }
41 out
42}
43
44fn derive_label(paths: &[&str], fallback: Option<&str>, community: i64) -> String {
45 if paths.is_empty() {
46 return fallback_label(fallback, community);
47 }
48
49 let unique: std::collections::HashSet<&&str> = paths.iter().collect();
51 if unique.len() == 1 {
52 let path = paths[0];
53 return path_to_label(path);
54 }
55
56 let prefix = common_dir_prefix(paths);
57 if !prefix.is_empty() && segment_count(&prefix) >= 2 {
58 return condense(&prefix);
59 }
60 if !prefix.is_empty() {
61 return condense(&prefix);
62 }
63 fallback_label(fallback, community)
64}
65
66fn fallback_label(fallback: Option<&str>, community: i64) -> String {
67 if let Some(s) = fallback {
68 let trimmed = s.trim();
69 if !trimmed.is_empty() && !trimmed.starts_with("File:") && !trimmed.starts_with("Function:")
70 {
71 return trimmed.to_string();
72 }
73 }
74 format!("community-{}", community)
75}
76
77fn path_to_label(path: &str) -> String {
78 let last_segment = path.rsplit('/').next().unwrap_or(path);
80 let stem = last_segment
81 .rsplit_once('.')
82 .map(|(a, _)| a)
83 .unwrap_or(last_segment);
84 let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
85 let parent_tail = parent.rsplit('/').next().unwrap_or("");
86 if parent_tail.is_empty() || parent_tail == "src" || parent_tail == "." {
87 stem.to_string()
88 } else {
89 format!("{}/{}", parent_tail, stem)
90 }
91}
92
93fn common_dir_prefix(paths: &[&str]) -> String {
94 if paths.is_empty() {
95 return String::new();
96 }
97 let first_dirs: Vec<&str> = path_dirs(paths[0]);
98 let mut common: Vec<&str> = first_dirs;
99 for p in paths.iter().skip(1) {
100 let dirs = path_dirs(p);
101 let take = common
102 .iter()
103 .zip(dirs.iter())
104 .take_while(|(a, b)| a == b)
105 .count();
106 common.truncate(take);
107 if common.is_empty() {
108 break;
109 }
110 }
111 common.join("/")
112}
113
114fn path_dirs(path: &str) -> Vec<&str> {
115 let mut parts: Vec<&str> = path.split('/').collect();
116 parts.pop(); parts
118}
119
120fn segment_count(prefix: &str) -> usize {
121 prefix.split('/').filter(|s| !s.is_empty()).count()
122}
123
124fn condense(prefix: &str) -> String {
126 let trimmed = prefix
127 .trim_start_matches("crates/")
128 .trim_start_matches("packages/")
129 .trim_start_matches("src/")
130 .trim_start_matches("./");
131 if trimmed.is_empty() {
132 prefix.trim_start_matches('/').to_string()
133 } else {
134 trimmed.to_string()
135 }
136}