1use std::collections::{HashMap, HashSet};
8use std::ffi::OsStr;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use rayon::prelude::*;
13use syn::visit::Visit;
14use syn::{
15 Expr, ExprCall, ExprField, ExprMethodCall, ExprStruct, File, FnArg, ItemFn, ItemImpl, ItemMod,
16 ItemStruct, ItemTrait, ItemUse, ReturnType, Signature, Type, UseTree,
17};
18use thiserror::Error;
19use walkdir::WalkDir;
20
21use crate::metrics::{
22 CouplingMetrics, Distance, IntegrationStrength, ModuleMetrics, ProjectMetrics, Visibility,
23 Volatility,
24};
25use crate::workspace::{WorkspaceError, WorkspaceInfo, resolve_crate_from_path};
26
27fn convert_visibility(vis: &syn::Visibility) -> Visibility {
29 match vis {
30 syn::Visibility::Public(_) => Visibility::Public,
31 syn::Visibility::Restricted(restricted) => {
32 let path_str = restricted
34 .path
35 .segments
36 .iter()
37 .map(|s| s.ident.to_string())
38 .collect::<Vec<_>>()
39 .join("::");
40
41 match path_str.as_str() {
42 "crate" => Visibility::PubCrate,
43 "super" => Visibility::PubSuper,
44 "self" => Visibility::Private, _ => Visibility::PubIn, }
47 }
48 syn::Visibility::Inherited => Visibility::Private,
49 }
50}
51
52fn has_test_attribute(attrs: &[syn::Attribute]) -> bool {
54 attrs.iter().any(|attr| attr.path().is_ident("test"))
55}
56
57fn has_cfg_test_attribute(attrs: &[syn::Attribute]) -> bool {
59 attrs.iter().any(|attr| {
60 if attr.path().is_ident("cfg") {
61 if let Ok(meta) = attr.meta.require_list() {
63 let tokens = meta.tokens.to_string();
64 return tokens.contains("test");
65 }
66 }
67 false
68 })
69}
70
71fn is_test_module(item: &ItemMod) -> bool {
73 item.ident == "tests" || has_cfg_test_attribute(&item.attrs)
74}
75
76fn file_path_to_module_path(file_path: &Path, src_root: &Path) -> String {
87 let relative = file_path.strip_prefix(src_root).unwrap_or(file_path);
89
90 let mut parts: Vec<String> = Vec::new();
91
92 for component in relative.components() {
93 if let Some(s) = component.as_os_str().to_str() {
94 parts.push(s.to_string());
95 }
96 }
97
98 if let Some(last) = parts.last().cloned() {
100 parts.pop();
101 match last.as_str() {
102 "lib.rs" | "main.rs" => {
103 }
105 "mod.rs" => {
106 }
108 _ => {
109 if let Some(stem) = last.strip_suffix(".rs") {
111 parts.push(stem.to_string());
112 } else {
113 parts.push(last);
114 }
115 }
116 }
117 }
118
119 parts.join("::")
120}
121
122#[derive(Error, Debug)]
124pub enum AnalyzerError {
125 #[error("Failed to read file: {0}")]
126 IoError(#[from] std::io::Error),
127
128 #[error("Failed to parse Rust file: {0}")]
129 ParseError(String),
130
131 #[error("Invalid path: {0}")]
132 InvalidPath(String),
133
134 #[error("Workspace error: {0}")]
135 WorkspaceError(#[from] WorkspaceError),
136}
137
138#[derive(Debug, Clone)]
140pub struct Dependency {
141 pub path: String,
143 pub kind: DependencyKind,
145 pub line: usize,
147 pub usage: UsageContext,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
153pub enum DependencyKind {
154 InternalUse,
156 ExternalUse,
158 TraitImpl,
160 InherentImpl,
162 TypeRef,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
168pub enum UsageContext {
169 Import,
171 TraitBound,
173 FieldAccess,
175 MethodCall,
177 FunctionCall,
179 StructConstruction,
181 TypeParameter,
183 FunctionParameter,
185 ReturnType,
187 InherentImplBlock,
189}
190
191impl UsageContext {
192 pub fn to_strength(&self) -> IntegrationStrength {
194 match self {
195 UsageContext::FieldAccess => IntegrationStrength::Intrusive,
197 UsageContext::StructConstruction => IntegrationStrength::Intrusive,
198 UsageContext::InherentImplBlock => IntegrationStrength::Intrusive,
199
200 UsageContext::MethodCall => IntegrationStrength::Functional,
202 UsageContext::FunctionCall => IntegrationStrength::Functional,
203 UsageContext::FunctionParameter => IntegrationStrength::Functional,
204 UsageContext::ReturnType => IntegrationStrength::Functional,
205
206 UsageContext::TypeParameter => IntegrationStrength::Model,
208 UsageContext::Import => IntegrationStrength::Model,
209
210 UsageContext::TraitBound => IntegrationStrength::Contract,
212 }
213 }
214}
215
216impl DependencyKind {
217 pub fn to_strength(&self) -> IntegrationStrength {
218 match self {
219 DependencyKind::TraitImpl => IntegrationStrength::Contract,
220 DependencyKind::InternalUse => IntegrationStrength::Model,
221 DependencyKind::ExternalUse => IntegrationStrength::Model,
222 DependencyKind::TypeRef => IntegrationStrength::Model,
223 DependencyKind::InherentImpl => IntegrationStrength::Intrusive,
224 }
225 }
226}
227
228#[derive(Debug)]
230pub struct CouplingAnalyzer {
231 pub current_module: String,
233 pub file_path: std::path::PathBuf,
235 pub metrics: ModuleMetrics,
237 pub dependencies: Vec<Dependency>,
239 pub defined_types: HashSet<String>,
241 pub defined_traits: HashSet<String>,
243 pub defined_functions: HashMap<String, Visibility>,
245 imported_types: HashMap<String, String>,
247 seen_dependencies: HashSet<(String, UsageContext)>,
249 pub usage_counts: UsageCounts,
251 pub type_visibility: HashMap<String, Visibility>,
253 current_item: Option<(String, ItemKind)>,
255 pub item_dependencies: Vec<ItemDependency>,
257}
258
259#[derive(Debug, Default, Clone)]
261pub struct UsageCounts {
262 pub field_accesses: usize,
263 pub method_calls: usize,
264 pub function_calls: usize,
265 pub struct_constructions: usize,
266 pub trait_bounds: usize,
267 pub type_parameters: usize,
268}
269
270#[derive(Debug, Clone)]
272pub struct ItemDependency {
273 pub source_item: String,
275 pub source_kind: ItemKind,
277 pub target: String,
279 pub target_module: Option<String>,
281 pub dep_type: ItemDepType,
283 pub line: usize,
285 pub expression: Option<String>,
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub enum ItemKind {
292 Function,
293 Method,
294 Struct,
295 Enum,
296 Trait,
297 Impl,
298 Module,
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum ItemDepType {
304 FunctionCall,
306 MethodCall,
308 TypeUsage,
310 FieldAccess,
312 StructConstruction,
314 TraitImpl,
316 TraitBound,
318 Import,
320}
321
322impl CouplingAnalyzer {
323 pub fn new(module_name: String, path: std::path::PathBuf) -> Self {
325 Self {
326 current_module: module_name.clone(),
327 file_path: path.clone(),
328 metrics: ModuleMetrics::new(path, module_name),
329 dependencies: Vec::new(),
330 defined_types: HashSet::new(),
331 defined_traits: HashSet::new(),
332 defined_functions: HashMap::new(),
333 imported_types: HashMap::new(),
334 seen_dependencies: HashSet::new(),
335 usage_counts: UsageCounts::default(),
336 type_visibility: HashMap::new(),
337 current_item: None,
338 item_dependencies: Vec::new(),
339 }
340 }
341
342 pub fn analyze_file(&mut self, content: &str) -> Result<(), AnalyzerError> {
344 let syntax: File =
345 syn::parse_file(content).map_err(|e| AnalyzerError::ParseError(e.to_string()))?;
346
347 self.visit_file(&syntax);
348
349 Ok(())
350 }
351
352 fn add_dependency(&mut self, path: String, kind: DependencyKind, usage: UsageContext) {
354 let key = (path.clone(), usage);
355 if self.seen_dependencies.contains(&key) {
356 return;
357 }
358 self.seen_dependencies.insert(key);
359
360 self.dependencies.push(Dependency {
361 path,
362 kind,
363 line: 0,
364 usage,
365 });
366 }
367
368 fn add_item_dependency(
370 &mut self,
371 target: String,
372 dep_type: ItemDepType,
373 line: usize,
374 expression: Option<String>,
375 ) {
376 if let Some((ref source_item, source_kind)) = self.current_item {
377 let target_module = self.imported_types.get(&target).cloned().or_else(|| {
379 if self.defined_types.contains(&target)
380 || self.defined_functions.contains_key(&target)
381 {
382 Some(self.current_module.clone())
383 } else {
384 None
385 }
386 });
387
388 self.item_dependencies.push(ItemDependency {
389 source_item: source_item.clone(),
390 source_kind,
391 target,
392 target_module,
393 dep_type,
394 line,
395 expression,
396 });
397 }
398 }
399
400 fn extract_use_paths(&self, tree: &UseTree, prefix: &str) -> Vec<(String, DependencyKind)> {
402 let mut paths = Vec::new();
403
404 match tree {
405 UseTree::Path(path) => {
406 let new_prefix = if prefix.is_empty() {
407 path.ident.to_string()
408 } else {
409 format!("{}::{}", prefix, path.ident)
410 };
411 paths.extend(self.extract_use_paths(&path.tree, &new_prefix));
412 }
413 UseTree::Name(name) => {
414 let full_path = if prefix.is_empty() {
415 name.ident.to_string()
416 } else {
417 format!("{}::{}", prefix, name.ident)
418 };
419 let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
420 DependencyKind::InternalUse
421 } else {
422 DependencyKind::ExternalUse
423 };
424 paths.push((full_path, kind));
425 }
426 UseTree::Rename(rename) => {
427 let full_path = if prefix.is_empty() {
428 rename.ident.to_string()
429 } else {
430 format!("{}::{}", prefix, rename.ident)
431 };
432 let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
433 DependencyKind::InternalUse
434 } else {
435 DependencyKind::ExternalUse
436 };
437 paths.push((full_path, kind));
438 }
439 UseTree::Glob(_) => {
440 let full_path = format!("{}::*", prefix);
441 let kind = if prefix.starts_with("crate") || prefix.starts_with("super") {
442 DependencyKind::InternalUse
443 } else {
444 DependencyKind::ExternalUse
445 };
446 paths.push((full_path, kind));
447 }
448 UseTree::Group(group) => {
449 for item in &group.items {
450 paths.extend(self.extract_use_paths(item, prefix));
451 }
452 }
453 }
454
455 paths
456 }
457
458 fn extract_type_name(&self, ty: &Type) -> Option<String> {
460 match ty {
461 Type::Path(type_path) => {
462 let segments: Vec<_> = type_path
463 .path
464 .segments
465 .iter()
466 .map(|s| s.ident.to_string())
467 .collect();
468 Some(segments.join("::"))
469 }
470 Type::Reference(ref_type) => self.extract_type_name(&ref_type.elem),
471 Type::Slice(slice_type) => self.extract_type_name(&slice_type.elem),
472 Type::Array(array_type) => self.extract_type_name(&array_type.elem),
473 Type::Ptr(ptr_type) => self.extract_type_name(&ptr_type.elem),
474 Type::Paren(paren_type) => self.extract_type_name(&paren_type.elem),
475 Type::Group(group_type) => self.extract_type_name(&group_type.elem),
476 _ => None,
477 }
478 }
479
480 fn analyze_signature(&mut self, sig: &Signature) {
482 for arg in &sig.inputs {
484 if let FnArg::Typed(pat_type) = arg
485 && let Some(type_name) = self.extract_type_name(&pat_type.ty)
486 && !self.is_primitive_type(&type_name)
487 {
488 self.add_dependency(
489 type_name,
490 DependencyKind::TypeRef,
491 UsageContext::FunctionParameter,
492 );
493 }
494 }
495
496 if let ReturnType::Type(_, ty) = &sig.output
498 && let Some(type_name) = self.extract_type_name(ty)
499 && !self.is_primitive_type(&type_name)
500 {
501 self.add_dependency(type_name, DependencyKind::TypeRef, UsageContext::ReturnType);
502 }
503 }
504
505 fn is_primitive_type(&self, type_name: &str) -> bool {
507 if matches!(
509 type_name,
510 "bool"
511 | "char"
512 | "str"
513 | "u8"
514 | "u16"
515 | "u32"
516 | "u64"
517 | "u128"
518 | "usize"
519 | "i8"
520 | "i16"
521 | "i32"
522 | "i64"
523 | "i128"
524 | "isize"
525 | "f32"
526 | "f64"
527 | "String"
528 | "Self"
529 | "()"
530 | "Option"
531 | "Result"
532 | "Vec"
533 | "Box"
534 | "Rc"
535 | "Arc"
536 | "RefCell"
537 | "Cell"
538 | "Mutex"
539 | "RwLock"
540 ) {
541 return true;
542 }
543
544 if type_name.len() <= 3 && type_name.chars().all(|c| c.is_lowercase()) {
547 return true;
548 }
549
550 if type_name.starts_with("self") || type_name == "self" {
552 return true;
553 }
554
555 false
556 }
557}
558
559impl<'ast> Visit<'ast> for CouplingAnalyzer {
560 fn visit_item_use(&mut self, node: &'ast ItemUse) {
561 let paths = self.extract_use_paths(&node.tree, "");
562
563 for (path, kind) in paths {
564 if path == "self" || path.starts_with("self::") {
566 continue;
567 }
568
569 if let Some(type_name) = path.split("::").last() {
571 self.imported_types
572 .insert(type_name.to_string(), path.clone());
573 }
574
575 self.add_dependency(path.clone(), kind, UsageContext::Import);
576
577 if kind == DependencyKind::InternalUse {
579 if !self.metrics.internal_deps.contains(&path) {
580 self.metrics.internal_deps.push(path.clone());
581 }
582 } else if kind == DependencyKind::ExternalUse {
583 let crate_name = path.split("::").next().unwrap_or(&path).to_string();
585 if !self.metrics.external_deps.contains(&crate_name) {
586 self.metrics.external_deps.push(crate_name);
587 }
588 }
589 }
590
591 syn::visit::visit_item_use(self, node);
592 }
593
594 fn visit_item_impl(&mut self, node: &'ast ItemImpl) {
595 if let Some((_, trait_path, _)) = &node.trait_ {
596 self.metrics.trait_impl_count += 1;
598
599 let trait_name: String = trait_path
601 .segments
602 .iter()
603 .map(|s| s.ident.to_string())
604 .collect::<Vec<_>>()
605 .join("::");
606
607 self.add_dependency(
608 trait_name,
609 DependencyKind::TraitImpl,
610 UsageContext::TraitBound,
611 );
612 self.usage_counts.trait_bounds += 1;
613 } else {
614 self.metrics.inherent_impl_count += 1;
616
617 if let Some(type_name) = self.extract_type_name(&node.self_ty)
619 && !self.defined_types.contains(&type_name)
620 {
621 self.add_dependency(
622 type_name,
623 DependencyKind::InherentImpl,
624 UsageContext::InherentImplBlock,
625 );
626 }
627 }
628 syn::visit::visit_item_impl(self, node);
629 }
630
631 fn visit_item_fn(&mut self, node: &'ast ItemFn) {
632 let fn_name = node.sig.ident.to_string();
634 let visibility = convert_visibility(&node.vis);
635 self.defined_functions.insert(fn_name.clone(), visibility);
636
637 if has_test_attribute(&node.attrs) {
639 self.metrics.test_function_count += 1;
640 }
641
642 let mut param_count = 0;
644 let mut primitive_param_count = 0;
645 let mut param_types = Vec::new();
646
647 for arg in &node.sig.inputs {
648 if let FnArg::Typed(pat_type) = arg {
649 param_count += 1;
650 if let Some(type_name) = self.extract_type_name(&pat_type.ty) {
651 param_types.push(type_name.clone());
652 if self.is_primitive_type(&type_name) {
653 primitive_param_count += 1;
654 }
655 }
656 }
657 }
658
659 self.metrics.add_function_definition_full(
661 fn_name.clone(),
662 visibility,
663 param_count,
664 primitive_param_count,
665 param_types,
666 );
667
668 let previous_item = self.current_item.take();
670 self.current_item = Some((fn_name, ItemKind::Function));
671
672 self.analyze_signature(&node.sig);
674 syn::visit::visit_item_fn(self, node);
675
676 self.current_item = previous_item;
678 }
679
680 fn visit_item_struct(&mut self, node: &'ast ItemStruct) {
681 let name = node.ident.to_string();
682 let visibility = convert_visibility(&node.vis);
683
684 self.defined_types.insert(name.clone());
685 self.type_visibility.insert(name.clone(), visibility);
686
687 let (is_newtype, inner_type) = match &node.fields {
689 syn::Fields::Unnamed(fields) if fields.unnamed.len() == 1 => {
690 let inner = fields
691 .unnamed
692 .first()
693 .and_then(|f| self.extract_type_name(&f.ty));
694 (true, inner)
695 }
696 _ => (false, None),
697 };
698
699 let has_serde_derive = node.attrs.iter().any(|attr| {
701 if attr.path().is_ident("derive")
702 && let Ok(nested) = attr.parse_args_with(
703 syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
704 )
705 {
706 return nested.iter().any(|path| {
707 let path_str = path
708 .segments
709 .iter()
710 .map(|s| s.ident.to_string())
711 .collect::<Vec<_>>()
712 .join("::");
713 path_str == "Serialize"
714 || path_str == "Deserialize"
715 || path_str == "serde::Serialize"
716 || path_str == "serde::Deserialize"
717 });
718 }
719 false
720 });
721
722 let (total_field_count, public_field_count) = match &node.fields {
724 syn::Fields::Named(fields) => {
725 let total = fields.named.len();
726 let public = fields
727 .named
728 .iter()
729 .filter(|f| matches!(f.vis, syn::Visibility::Public(_)))
730 .count();
731 (total, public)
732 }
733 syn::Fields::Unnamed(fields) => {
734 let total = fields.unnamed.len();
735 let public = fields
736 .unnamed
737 .iter()
738 .filter(|f| matches!(f.vis, syn::Visibility::Public(_)))
739 .count();
740 (total, public)
741 }
742 syn::Fields::Unit => (0, 0),
743 };
744
745 self.metrics.add_type_definition_full(
747 name,
748 visibility,
749 false, is_newtype,
751 inner_type,
752 has_serde_derive,
753 public_field_count,
754 total_field_count,
755 );
756
757 match &node.fields {
759 syn::Fields::Named(fields) => {
760 self.metrics.type_usage_count += fields.named.len();
761 for field in &fields.named {
762 if let Some(type_name) = self.extract_type_name(&field.ty)
763 && !self.is_primitive_type(&type_name)
764 {
765 self.add_dependency(
766 type_name,
767 DependencyKind::TypeRef,
768 UsageContext::TypeParameter,
769 );
770 self.usage_counts.type_parameters += 1;
771 }
772 }
773 }
774 syn::Fields::Unnamed(fields) => {
775 for field in &fields.unnamed {
776 if let Some(type_name) = self.extract_type_name(&field.ty)
777 && !self.is_primitive_type(&type_name)
778 {
779 self.add_dependency(
780 type_name,
781 DependencyKind::TypeRef,
782 UsageContext::TypeParameter,
783 );
784 }
785 }
786 }
787 syn::Fields::Unit => {}
788 }
789 syn::visit::visit_item_struct(self, node);
790 }
791
792 fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) {
793 let name = node.ident.to_string();
794 let visibility = convert_visibility(&node.vis);
795
796 self.defined_types.insert(name.clone());
797 self.type_visibility.insert(name.clone(), visibility);
798
799 self.metrics.add_type_definition(name, visibility, false);
801
802 for variant in &node.variants {
804 match &variant.fields {
805 syn::Fields::Named(fields) => {
806 for field in &fields.named {
807 if let Some(type_name) = self.extract_type_name(&field.ty)
808 && !self.is_primitive_type(&type_name)
809 {
810 self.add_dependency(
811 type_name,
812 DependencyKind::TypeRef,
813 UsageContext::TypeParameter,
814 );
815 }
816 }
817 }
818 syn::Fields::Unnamed(fields) => {
819 for field in &fields.unnamed {
820 if let Some(type_name) = self.extract_type_name(&field.ty)
821 && !self.is_primitive_type(&type_name)
822 {
823 self.add_dependency(
824 type_name,
825 DependencyKind::TypeRef,
826 UsageContext::TypeParameter,
827 );
828 }
829 }
830 }
831 syn::Fields::Unit => {}
832 }
833 }
834 syn::visit::visit_item_enum(self, node);
835 }
836
837 fn visit_item_trait(&mut self, node: &'ast ItemTrait) {
838 let name = node.ident.to_string();
839 let visibility = convert_visibility(&node.vis);
840
841 self.defined_traits.insert(name.clone());
842 self.type_visibility.insert(name.clone(), visibility);
843
844 self.metrics.add_type_definition(name, visibility, true);
846
847 self.metrics.trait_impl_count += 1;
848 syn::visit::visit_item_trait(self, node);
849 }
850
851 fn visit_item_mod(&mut self, node: &'ast ItemMod) {
852 if is_test_module(node) {
854 self.metrics.is_test_module = true;
855 }
856
857 if node.content.is_some() {
858 self.metrics.internal_deps.push(node.ident.to_string());
859 }
860 syn::visit::visit_item_mod(self, node);
861 }
862
863 fn visit_expr_field(&mut self, node: &'ast ExprField) {
865 let field_name = match &node.member {
866 syn::Member::Named(ident) => ident.to_string(),
867 syn::Member::Unnamed(idx) => format!("{}", idx.index),
868 };
869
870 if let Expr::Path(path_expr) = &*node.base {
872 let base_name = path_expr
873 .path
874 .segments
875 .iter()
876 .map(|s| s.ident.to_string())
877 .collect::<Vec<_>>()
878 .join("::");
879
880 let full_path = self
882 .imported_types
883 .get(&base_name)
884 .cloned()
885 .unwrap_or(base_name.clone());
886
887 if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
888 self.add_dependency(
889 full_path.clone(),
890 DependencyKind::TypeRef,
891 UsageContext::FieldAccess,
892 );
893 self.usage_counts.field_accesses += 1;
894 }
895
896 let expr = format!("{}.{}", base_name, field_name);
898 self.add_item_dependency(
899 format!("{}.{}", full_path, field_name),
900 ItemDepType::FieldAccess,
901 0,
902 Some(expr),
903 );
904 }
905 syn::visit::visit_expr_field(self, node);
906 }
907
908 fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) {
910 let method_name = node.method.to_string();
911
912 if let Expr::Path(path_expr) = &*node.receiver {
914 let receiver_name = path_expr
915 .path
916 .segments
917 .iter()
918 .map(|s| s.ident.to_string())
919 .collect::<Vec<_>>()
920 .join("::");
921
922 let full_path = self
923 .imported_types
924 .get(&receiver_name)
925 .cloned()
926 .unwrap_or(receiver_name.clone());
927
928 if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
929 self.add_dependency(
930 full_path.clone(),
931 DependencyKind::TypeRef,
932 UsageContext::MethodCall,
933 );
934 self.usage_counts.method_calls += 1;
935 }
936
937 let expr = format!("{}.{}()", receiver_name, method_name);
939 self.add_item_dependency(
940 format!("{}::{}", full_path, method_name),
941 ItemDepType::MethodCall,
942 0, Some(expr),
944 );
945 }
946 syn::visit::visit_expr_method_call(self, node);
947 }
948
949 fn visit_expr_call(&mut self, node: &'ast ExprCall) {
951 if let Expr::Path(path_expr) = &*node.func {
952 let path_str = path_expr
953 .path
954 .segments
955 .iter()
956 .map(|s| s.ident.to_string())
957 .collect::<Vec<_>>()
958 .join("::");
959
960 if path_str.contains("::") || path_str.chars().next().is_some_and(|c| c.is_uppercase())
962 {
963 let full_path = self
964 .imported_types
965 .get(&path_str)
966 .cloned()
967 .unwrap_or(path_str.clone());
968
969 if !self.is_primitive_type(&full_path) && !self.defined_types.contains(&full_path) {
970 self.add_dependency(
971 full_path.clone(),
972 DependencyKind::TypeRef,
973 UsageContext::FunctionCall,
974 );
975 self.usage_counts.function_calls += 1;
976 }
977
978 self.add_item_dependency(
980 full_path,
981 ItemDepType::FunctionCall,
982 0,
983 Some(format!("{}()", path_str)),
984 );
985 } else {
986 self.add_item_dependency(
988 path_str.clone(),
989 ItemDepType::FunctionCall,
990 0,
991 Some(format!("{}()", path_str)),
992 );
993 }
994 }
995 syn::visit::visit_expr_call(self, node);
996 }
997
998 fn visit_expr_struct(&mut self, node: &'ast ExprStruct) {
1000 let struct_name = node
1001 .path
1002 .segments
1003 .iter()
1004 .map(|s| s.ident.to_string())
1005 .collect::<Vec<_>>()
1006 .join("::");
1007
1008 if struct_name == "Self" || struct_name.starts_with("Self::") {
1010 syn::visit::visit_expr_struct(self, node);
1011 return;
1012 }
1013
1014 let full_path = self
1015 .imported_types
1016 .get(&struct_name)
1017 .cloned()
1018 .unwrap_or(struct_name.clone());
1019
1020 if !self.defined_types.contains(&full_path) && !self.is_primitive_type(&struct_name) {
1021 self.add_dependency(
1022 full_path,
1023 DependencyKind::TypeRef,
1024 UsageContext::StructConstruction,
1025 );
1026 self.usage_counts.struct_constructions += 1;
1027 }
1028 syn::visit::visit_expr_struct(self, node);
1029 }
1030}
1031
1032#[derive(Debug, Clone)]
1034struct AnalyzedFile {
1035 module_name: String,
1036 #[allow(dead_code)]
1037 file_path: PathBuf,
1038 metrics: ModuleMetrics,
1039 dependencies: Vec<Dependency>,
1040 type_visibility: HashMap<String, Visibility>,
1042 item_dependencies: Vec<ItemDependency>,
1044}
1045
1046pub fn analyze_project(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1048 analyze_project_parallel(path)
1049}
1050
1051fn rs_files(dir: &Path) -> impl Iterator<Item = PathBuf> {
1057 WalkDir::new(dir)
1058 .follow_links(true)
1059 .into_iter()
1060 .filter_map(|e| e.ok())
1061 .filter(move |entry| {
1062 let file_path = entry.path();
1063 let file_path = file_path.strip_prefix(dir).unwrap_or(file_path);
1068
1069 !file_path.components().any(|c| {
1071 let s = c.as_os_str().to_string_lossy();
1072 s == "target" || s.starts_with('.')
1073 }) && file_path.extension() == Some(OsStr::new("rs"))
1074 })
1075 .map(|e| e.path().to_path_buf())
1076}
1077
1078pub fn analyze_project_parallel(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1083 if !path.exists() {
1084 return Err(AnalyzerError::InvalidPath(path.display().to_string()));
1085 }
1086
1087 let file_paths: Vec<PathBuf> = rs_files(path).collect();
1089
1090 let num_threads = rayon::current_num_threads();
1094 let file_count = file_paths.len();
1095
1096 let chunk_size = if file_count < num_threads * 2 {
1099 1 } else {
1101 (file_count / (num_threads * 4)).max(1)
1104 };
1105
1106 let analyzed_results: Vec<_> = file_paths
1108 .par_chunks(chunk_size)
1109 .flat_map(|chunk| {
1110 chunk
1111 .iter()
1112 .filter_map(|file_path| match analyze_rust_file_full(file_path) {
1113 Ok(result) => {
1114 let module_path = file_path_to_module_path(file_path, path);
1116 let module_name = if module_path.is_empty() {
1117 result.metrics.name.clone()
1119 } else {
1120 module_path
1121 };
1122 Some(AnalyzedFile {
1123 module_name: module_name.clone(),
1124 file_path: file_path.clone(),
1125 metrics: {
1126 let mut m = result.metrics;
1127 m.name = module_name;
1128 m
1129 },
1130 dependencies: result.dependencies,
1131 type_visibility: result.type_visibility,
1132 item_dependencies: result.item_dependencies,
1133 })
1134 }
1135 Err(e) => {
1136 eprintln!("Warning: Failed to analyze {}: {}", file_path.display(), e);
1137 None
1138 }
1139 })
1140 .collect::<Vec<_>>()
1141 })
1142 .collect();
1143
1144 let module_names: HashSet<String> = analyzed_results
1146 .iter()
1147 .map(|a| a.module_name.clone())
1148 .collect();
1149
1150 let mut project = ProjectMetrics::new();
1152 project.total_files = analyzed_results.len();
1153
1154 for analyzed in &analyzed_results {
1156 for (type_name, visibility) in &analyzed.type_visibility {
1157 project.register_type(type_name.clone(), analyzed.module_name.clone(), *visibility);
1158 }
1159 }
1160
1161 for analyzed in &analyzed_results {
1163 let mut metrics = analyzed.metrics.clone();
1165 metrics.item_dependencies = analyzed.item_dependencies.clone();
1166 project.add_module(metrics);
1167
1168 for dep in &analyzed.dependencies {
1169 if !is_valid_dependency_path(&dep.path) {
1171 continue;
1172 }
1173
1174 let target_module = extract_target_module(&dep.path);
1176
1177 if !module_names.contains(&target_module) && !is_valid_dependency_path(&target_module) {
1179 continue;
1180 }
1181
1182 let distance = calculate_distance(&dep.path, &module_names);
1184
1185 let strength = dep.usage.to_strength();
1187
1188 let volatility = Volatility::Low;
1190
1191 let target_type = dep.path.split("::").last().unwrap_or(&dep.path);
1193 let visibility = project
1194 .get_type_visibility(target_type)
1195 .unwrap_or(Visibility::Public); let coupling = CouplingMetrics::with_location(
1199 analyzed.module_name.clone(),
1200 target_module.clone(),
1201 strength,
1202 distance,
1203 volatility,
1204 visibility,
1205 analyzed.file_path.clone(),
1206 dep.line,
1207 );
1208
1209 project.add_coupling(coupling);
1210 }
1211 }
1212
1213 project.update_coupling_visibility();
1215
1216 Ok(project)
1217}
1218
1219pub fn analyze_workspace(path: &Path) -> Result<ProjectMetrics, AnalyzerError> {
1221 let workspace = match WorkspaceInfo::from_path(path) {
1223 Ok(ws) => Some(ws),
1224 Err(e) => {
1225 eprintln!("Note: Could not load workspace metadata: {}", e);
1226 eprintln!("Falling back to basic analysis...");
1227 None
1228 }
1229 };
1230
1231 if let Some(ws) = workspace {
1232 analyze_with_workspace(path, &ws)
1233 } else {
1234 analyze_project(path)
1236 }
1237}
1238
1239fn analyze_with_workspace(
1241 _path: &Path,
1242 workspace: &WorkspaceInfo,
1243) -> Result<ProjectMetrics, AnalyzerError> {
1244 let mut project = ProjectMetrics::new();
1245
1246 project.workspace_name = Some(
1248 workspace
1249 .root
1250 .file_name()
1251 .and_then(|n| n.to_str())
1252 .unwrap_or("workspace")
1253 .to_string(),
1254 );
1255 project.workspace_members = workspace.members.clone();
1256
1257 let mut file_crate_pairs: Vec<(PathBuf, String, PathBuf)> = Vec::new();
1260
1261 for member_name in &workspace.members {
1262 if let Some(crate_info) = workspace.get_crate(member_name) {
1263 if !crate_info.src_path.exists() {
1264 continue;
1265 }
1266
1267 let src_root = crate_info.src_path.clone();
1268 for file_path in rs_files(&crate_info.src_path) {
1269 file_crate_pairs.push((
1270 file_path.to_path_buf(),
1271 member_name.clone(),
1272 src_root.clone(),
1273 ));
1274 }
1275 }
1276 }
1277
1278 let num_threads = rayon::current_num_threads();
1280 let file_count = file_crate_pairs.len();
1281 let chunk_size = if file_count < num_threads * 2 {
1282 1
1283 } else {
1284 (file_count / (num_threads * 4)).max(1)
1285 };
1286
1287 let analyzed_files: Vec<AnalyzedFileWithCrate> = file_crate_pairs
1289 .par_chunks(chunk_size)
1290 .flat_map(|chunk| {
1291 chunk
1292 .iter()
1293 .filter_map(|(file_path, crate_name, src_root)| {
1294 match analyze_rust_file_full(file_path) {
1295 Ok(result) => {
1296 let module_path = file_path_to_module_path(file_path, src_root);
1298 let module_name = if module_path.is_empty() {
1299 result.metrics.name.clone()
1301 } else {
1302 module_path
1303 };
1304 Some(AnalyzedFileWithCrate {
1305 module_name: module_name.clone(),
1306 crate_name: crate_name.clone(),
1307 file_path: file_path.clone(),
1308 metrics: {
1309 let mut m = result.metrics;
1310 m.name = module_name;
1311 m
1312 },
1313 dependencies: result.dependencies,
1314 item_dependencies: result.item_dependencies,
1315 })
1316 }
1317 Err(e) => {
1318 eprintln!("Warning: Failed to analyze {}: {}", file_path.display(), e);
1319 None
1320 }
1321 }
1322 })
1323 .collect::<Vec<_>>()
1324 })
1325 .collect();
1326
1327 project.total_files = analyzed_files.len();
1328
1329 let module_names: HashSet<String> = analyzed_files
1331 .iter()
1332 .map(|a| a.module_name.clone())
1333 .collect();
1334
1335 for analyzed in &analyzed_files {
1337 let mut metrics = analyzed.metrics.clone();
1339 metrics.item_dependencies = analyzed.item_dependencies.clone();
1340 project.add_module(metrics);
1341
1342 for dep in &analyzed.dependencies {
1343 if !is_valid_dependency_path(&dep.path) {
1345 continue;
1346 }
1347
1348 let resolved_crate =
1350 resolve_crate_from_path(&dep.path, &analyzed.crate_name, workspace);
1351
1352 let target_module = extract_target_module(&dep.path);
1353
1354 if !module_names.contains(&target_module) && !is_valid_dependency_path(&target_module) {
1356 continue;
1357 }
1358
1359 let distance =
1361 calculate_distance_with_workspace(&dep.path, &analyzed.crate_name, workspace);
1362
1363 let strength = dep.usage.to_strength();
1365
1366 let volatility = Volatility::Low;
1368
1369 let mut coupling = CouplingMetrics::with_location(
1371 format!("{}::{}", analyzed.crate_name, analyzed.module_name),
1372 if let Some(ref crate_name) = resolved_crate {
1373 format!("{}::{}", crate_name, target_module)
1374 } else {
1375 target_module.clone()
1376 },
1377 strength,
1378 distance,
1379 volatility,
1380 Visibility::Public, analyzed.file_path.clone(),
1382 dep.line,
1383 );
1384
1385 coupling.source_crate = Some(analyzed.crate_name.clone());
1387 coupling.target_crate = resolved_crate;
1388
1389 project.add_coupling(coupling);
1390 }
1391 }
1392
1393 for (crate_name, deps) in &workspace.dependency_graph {
1395 if workspace.is_workspace_member(crate_name) {
1396 for dep in deps {
1397 project
1399 .crate_dependencies
1400 .entry(crate_name.clone())
1401 .or_default()
1402 .push(dep.clone());
1403 }
1404 }
1405 }
1406
1407 Ok(project)
1408}
1409
1410fn calculate_distance_with_workspace(
1412 dep_path: &str,
1413 current_crate: &str,
1414 workspace: &WorkspaceInfo,
1415) -> Distance {
1416 if dep_path.starts_with("crate::") || dep_path.starts_with("self::") {
1417 Distance::SameModule
1419 } else if dep_path.starts_with("super::") {
1420 Distance::DifferentModule
1422 } else {
1423 if let Some(target_crate) = resolve_crate_from_path(dep_path, current_crate, workspace) {
1425 if target_crate == current_crate {
1426 Distance::SameModule
1427 } else if workspace.is_workspace_member(&target_crate) {
1428 Distance::DifferentModule
1430 } else {
1431 Distance::DifferentCrate
1433 }
1434 } else {
1435 Distance::DifferentCrate
1436 }
1437 }
1438}
1439
1440#[derive(Debug, Clone)]
1442struct AnalyzedFileWithCrate {
1443 module_name: String,
1444 crate_name: String,
1445 #[allow(dead_code)]
1446 file_path: PathBuf,
1447 metrics: ModuleMetrics,
1448 dependencies: Vec<Dependency>,
1449 item_dependencies: Vec<ItemDependency>,
1451}
1452
1453fn extract_target_module(path: &str) -> String {
1455 let cleaned = path
1457 .trim_start_matches("crate::")
1458 .trim_start_matches("super::")
1459 .trim_start_matches("::");
1460
1461 cleaned.split("::").next().unwrap_or(path).to_string()
1463}
1464
1465fn is_valid_dependency_path(path: &str) -> bool {
1467 if path.is_empty() {
1469 return false;
1470 }
1471
1472 if path == "Self" || path.starts_with("Self::") {
1474 return false;
1475 }
1476
1477 let segments: Vec<&str> = path.split("::").collect();
1478
1479 if segments.len() == 1 {
1481 let name = segments[0];
1482 if name.len() <= 8 && name.chars().all(|c| c.is_lowercase() || c == '_') {
1483 return false;
1484 }
1485 }
1486
1487 if segments.len() >= 2 {
1489 let last = segments.last().unwrap();
1490 let second_last = segments.get(segments.len() - 2).unwrap();
1491 if last == second_last {
1492 return false;
1493 }
1494 }
1495
1496 let last_segment = segments.last().unwrap_or(&path);
1498 let common_locals = [
1499 "request",
1500 "response",
1501 "result",
1502 "content",
1503 "config",
1504 "proto",
1505 "domain",
1506 "info",
1507 "data",
1508 "item",
1509 "value",
1510 "error",
1511 "message",
1512 "expected",
1513 "actual",
1514 "status",
1515 "state",
1516 "context",
1517 "params",
1518 "args",
1519 "options",
1520 "settings",
1521 "violation",
1522 "page_token",
1523 ];
1524 if common_locals.contains(last_segment) && segments.len() <= 2 {
1525 return false;
1526 }
1527
1528 true
1529}
1530
1531fn calculate_distance(dep_path: &str, _known_modules: &HashSet<String>) -> Distance {
1533 if dep_path.starts_with("crate::") || dep_path.starts_with("super::") {
1534 Distance::DifferentModule
1536 } else if dep_path.starts_with("self::") {
1537 Distance::SameModule
1538 } else {
1539 Distance::DifferentCrate
1541 }
1542}
1543
1544pub struct AnalyzedFileResult {
1547 pub metrics: ModuleMetrics,
1548 pub dependencies: Vec<Dependency>,
1549 pub type_visibility: HashMap<String, Visibility>,
1550 pub item_dependencies: Vec<ItemDependency>,
1551}
1552
1553pub fn analyze_rust_file(path: &Path) -> Result<(ModuleMetrics, Vec<Dependency>), AnalyzerError> {
1554 let result = analyze_rust_file_full(path)?;
1555 Ok((result.metrics, result.dependencies))
1556}
1557
1558pub fn analyze_rust_file_full(path: &Path) -> Result<AnalyzedFileResult, AnalyzerError> {
1560 let content = fs::read_to_string(path)?;
1561
1562 let module_name = path
1563 .file_stem()
1564 .and_then(|s| s.to_str())
1565 .unwrap_or("unknown")
1566 .to_string();
1567
1568 let mut analyzer = CouplingAnalyzer::new(module_name, path.to_path_buf());
1569 analyzer.analyze_file(&content)?;
1570
1571 Ok(AnalyzedFileResult {
1572 metrics: analyzer.metrics,
1573 dependencies: analyzer.dependencies,
1574 type_visibility: analyzer.type_visibility,
1575 item_dependencies: analyzer.item_dependencies,
1576 })
1577}
1578
1579#[cfg(test)]
1580mod tests {
1581 use super::*;
1582
1583 #[test]
1584 fn test_analyzer_creation() {
1585 let analyzer = CouplingAnalyzer::new(
1586 "test_module".to_string(),
1587 std::path::PathBuf::from("test.rs"),
1588 );
1589 assert_eq!(analyzer.current_module, "test_module");
1590 }
1591
1592 #[test]
1593 fn test_analyze_simple_file() {
1594 let mut analyzer =
1595 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1596
1597 let code = r#"
1598 pub struct User {
1599 name: String,
1600 email: String,
1601 }
1602
1603 impl User {
1604 pub fn new(name: String, email: String) -> Self {
1605 Self { name, email }
1606 }
1607 }
1608 "#;
1609
1610 let result = analyzer.analyze_file(code);
1611 assert!(result.is_ok());
1612 assert_eq!(analyzer.metrics.inherent_impl_count, 1);
1613 }
1614
1615 #[test]
1616 fn test_item_dependencies() {
1617 let mut analyzer =
1618 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1619
1620 let code = r#"
1621 pub struct Config {
1622 pub value: i32,
1623 }
1624
1625 pub fn process(config: Config) -> i32 {
1626 let x = config.value;
1627 helper(x)
1628 }
1629
1630 fn helper(n: i32) -> i32 {
1631 n * 2
1632 }
1633 "#;
1634
1635 let result = analyzer.analyze_file(code);
1636 assert!(result.is_ok());
1637
1638 assert!(analyzer.defined_functions.contains_key("process"));
1640 assert!(analyzer.defined_functions.contains_key("helper"));
1641
1642 println!(
1644 "Item dependencies count: {}",
1645 analyzer.item_dependencies.len()
1646 );
1647 for dep in &analyzer.item_dependencies {
1648 println!(
1649 " {} -> {} ({:?})",
1650 dep.source_item, dep.target, dep.dep_type
1651 );
1652 }
1653
1654 let process_deps: Vec<_> = analyzer
1656 .item_dependencies
1657 .iter()
1658 .filter(|d| d.source_item == "process")
1659 .collect();
1660
1661 assert!(
1662 !process_deps.is_empty(),
1663 "process function should have item dependencies"
1664 );
1665 }
1666
1667 #[test]
1668 fn test_analyze_trait_impl() {
1669 let mut analyzer =
1670 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1671
1672 let code = r#"
1673 trait Printable {
1674 fn print(&self);
1675 }
1676
1677 struct Document;
1678
1679 impl Printable for Document {
1680 fn print(&self) {}
1681 }
1682 "#;
1683
1684 let result = analyzer.analyze_file(code);
1685 assert!(result.is_ok());
1686 assert!(analyzer.metrics.trait_impl_count >= 1);
1687 }
1688
1689 #[test]
1690 fn test_analyze_use_statements() {
1691 let mut analyzer =
1692 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1693
1694 let code = r#"
1695 use std::collections::HashMap;
1696 use serde::Serialize;
1697 use crate::utils;
1698 use crate::models::{User, Post};
1699 "#;
1700
1701 let result = analyzer.analyze_file(code);
1702 assert!(result.is_ok());
1703 assert!(analyzer.metrics.external_deps.contains(&"std".to_string()));
1704 assert!(
1705 analyzer
1706 .metrics
1707 .external_deps
1708 .contains(&"serde".to_string())
1709 );
1710 assert!(!analyzer.dependencies.is_empty());
1711
1712 let internal_deps: Vec<_> = analyzer
1714 .dependencies
1715 .iter()
1716 .filter(|d| d.kind == DependencyKind::InternalUse)
1717 .collect();
1718 assert!(!internal_deps.is_empty());
1719 }
1720
1721 #[test]
1722 fn test_extract_use_paths() {
1723 let analyzer =
1724 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1725
1726 let tree: UseTree = syn::parse_quote!(std::collections::HashMap);
1728 let paths = analyzer.extract_use_paths(&tree, "");
1729 assert_eq!(paths.len(), 1);
1730 assert_eq!(paths[0].0, "std::collections::HashMap");
1731
1732 let tree: UseTree = syn::parse_quote!(crate::models::{User, Post});
1734 let paths = analyzer.extract_use_paths(&tree, "");
1735 assert_eq!(paths.len(), 2);
1736 }
1737
1738 #[test]
1739 fn test_extract_target_module() {
1740 assert_eq!(extract_target_module("crate::models::user"), "models");
1741 assert_eq!(extract_target_module("super::utils"), "utils");
1742 assert_eq!(extract_target_module("std::collections"), "std");
1743 }
1744
1745 #[test]
1746 fn test_field_access_detection() {
1747 let mut analyzer =
1748 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1749
1750 let code = r#"
1751 use crate::models::User;
1752
1753 fn get_name(user: &User) -> String {
1754 user.name.clone()
1755 }
1756 "#;
1757
1758 let result = analyzer.analyze_file(code);
1759 assert!(result.is_ok());
1760
1761 let _field_deps: Vec<_> = analyzer
1763 .dependencies
1764 .iter()
1765 .filter(|d| d.usage == UsageContext::FieldAccess)
1766 .collect();
1767 }
1770
1771 #[test]
1772 fn test_method_call_detection() {
1773 let mut analyzer =
1774 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1775
1776 let code = r#"
1777 fn process() {
1778 let data = String::new();
1779 data.push_str("hello");
1780 }
1781 "#;
1782
1783 let result = analyzer.analyze_file(code);
1784 assert!(result.is_ok());
1785 }
1787
1788 #[test]
1789 fn test_struct_construction_detection() {
1790 let mut analyzer =
1791 CouplingAnalyzer::new("test".to_string(), std::path::PathBuf::from("test.rs"));
1792
1793 let code = r#"
1794 use crate::config::Config;
1795
1796 fn create_config() {
1797 let c = Config { value: 42 };
1798 }
1799 "#;
1800
1801 let result = analyzer.analyze_file(code);
1802 assert!(result.is_ok());
1803
1804 let struct_deps: Vec<_> = analyzer
1806 .dependencies
1807 .iter()
1808 .filter(|d| d.usage == UsageContext::StructConstruction)
1809 .collect();
1810 assert!(!struct_deps.is_empty());
1811 }
1812
1813 #[test]
1814 fn test_usage_context_to_strength() {
1815 assert_eq!(
1816 UsageContext::FieldAccess.to_strength(),
1817 IntegrationStrength::Intrusive
1818 );
1819 assert_eq!(
1820 UsageContext::MethodCall.to_strength(),
1821 IntegrationStrength::Functional
1822 );
1823 assert_eq!(
1824 UsageContext::TypeParameter.to_strength(),
1825 IntegrationStrength::Model
1826 );
1827 assert_eq!(
1828 UsageContext::TraitBound.to_strength(),
1829 IntegrationStrength::Contract
1830 );
1831 }
1832
1833 #[test]
1836 fn test_rs_files_with_hidden_parent_directory() {
1837 use std::fs;
1838 use tempfile::TempDir;
1839
1840 let temp = TempDir::new().unwrap();
1843 let hidden_parent = temp.path().join(".hidden-parent");
1844 let project_dir = hidden_parent.join("myproject").join("src");
1845 fs::create_dir_all(&project_dir).unwrap();
1846
1847 fs::write(project_dir.join("lib.rs"), "pub fn hello() {}").unwrap();
1849 fs::write(project_dir.join("main.rs"), "fn main() {}").unwrap();
1850
1851 let files: Vec<_> = rs_files(&project_dir).collect();
1853 assert_eq!(
1854 files.len(),
1855 2,
1856 "Should find 2 .rs files in hidden parent path"
1857 );
1858
1859 let file_names: Vec<_> = files
1861 .iter()
1862 .filter_map(|p| p.file_name())
1863 .filter_map(|n| n.to_str())
1864 .collect();
1865 assert!(file_names.contains(&"lib.rs"));
1866 assert!(file_names.contains(&"main.rs"));
1867 }
1868
1869 #[test]
1871 fn test_rs_files_excludes_hidden_dirs_in_project() {
1872 use std::fs;
1873 use tempfile::TempDir;
1874
1875 let temp = TempDir::new().unwrap();
1876 let project_dir = temp.path().join("myproject").join("src");
1877 let hidden_dir = project_dir.join(".hidden");
1878 fs::create_dir_all(&hidden_dir).unwrap();
1879
1880 fs::write(project_dir.join("lib.rs"), "pub fn hello() {}").unwrap();
1882 fs::write(hidden_dir.join("secret.rs"), "fn secret() {}").unwrap();
1883
1884 let files: Vec<_> = rs_files(&project_dir).collect();
1886 assert_eq!(
1887 files.len(),
1888 1,
1889 "Should find only 1 .rs file (excluding .hidden/)"
1890 );
1891
1892 let file_names: Vec<_> = files
1893 .iter()
1894 .filter_map(|p| p.file_name())
1895 .filter_map(|n| n.to_str())
1896 .collect();
1897 assert!(file_names.contains(&"lib.rs"));
1898 assert!(!file_names.contains(&"secret.rs"));
1899 }
1900
1901 #[test]
1903 fn test_rs_files_excludes_target_directory() {
1904 use std::fs;
1905 use tempfile::TempDir;
1906
1907 let temp = TempDir::new().unwrap();
1908 let project_dir = temp.path().join("myproject");
1909 let src_dir = project_dir.join("src");
1910 let target_dir = project_dir.join("target").join("debug");
1911 fs::create_dir_all(&src_dir).unwrap();
1912 fs::create_dir_all(&target_dir).unwrap();
1913
1914 fs::write(src_dir.join("lib.rs"), "pub fn hello() {}").unwrap();
1916 fs::write(target_dir.join("generated.rs"), "// generated").unwrap();
1917
1918 let files: Vec<_> = rs_files(&project_dir).collect();
1920 assert_eq!(
1921 files.len(),
1922 1,
1923 "Should find only 1 .rs file (excluding target/)"
1924 );
1925
1926 let file_names: Vec<_> = files
1927 .iter()
1928 .filter_map(|p| p.file_name())
1929 .filter_map(|n| n.to_str())
1930 .collect();
1931 assert!(file_names.contains(&"lib.rs"));
1932 assert!(!file_names.contains(&"generated.rs"));
1933 }
1934}