arcella_inspect/
lib.rs

1// arcella-rust-inspect/arcella-inspect/src/lib.rs
2//
3// Copyright (c) 2025 Alexey Rybakov, Arcella Team
4//
5// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE>
6// or the MIT license <LICENSE-MIT>, at your option.
7// This file may not be copied, modified, or distributed
8// except according to those terms.
9
10//! `arcella-inspect`: Structured static analysis of Rust source code.
11//!
12//! This crate provides tools to extract rich metadata from Rust projects:
13//! - Functions and methods (with full paths, e.g. `my_mod::MyStruct::method`)
14//! - Structs
15//! - Call graphs (internal vs external calls)
16//! - Function signatures (parameters, return types)
17//! - Documentation comments (`///`)
18//! - Attributes (`pub`, `async`, `#[cfg(...)]`, etc.)
19//!
20//! The primary output format is **YAML 1.2**, designed for consumption by:
21//! - AI code reasoning agents
22//! - Architecture visualization tools
23//! - Documentation generators
24//! - Dependency and compliance auditors
25//!
26//! ## Usage
27//!
28//! ### As a library
29//!
30//! ```rust,no_run
31//! use arcella_inspect::{analyze_project, analysis_to_yaml};
32//! use std::path::Path;
33//!
34//! let root = Path::new("./my-rust-project");
35//! let analysis = analyze_project(root).unwrap();
36//! let yaml = analysis_to_yaml(&analysis, root).unwrap();
37//! println!("{}", yaml);
38//! ```
39//!
40//! ### As a CLI
41//!
42//! Install via `cargo install arcella-inspect` and run:
43//!
44//! ```bash
45//! arc-inspect ./my-project > metadata.yaml
46//! ```
47//!
48//! ## Output Format
49//!
50//! See the full specification in [`FORMAT.md`](https://github.com/ArcellaTeam/arcella-rust-inspect/blob/main/FORMAT.md).
51//!
52//! ## Design Notes
53//!
54//! - Analysis is based on the **AST** (via `syn`), not MIR/HIRpreserving source-level fidelity.
55//! - Only **meaningful function calls** are recorded (noise like `.clone()`, `.unwrap()` is filtered).
56//! - Supports **Cargo workspaces** (each crate = subproject).
57//! - External calls (e.g. from `std`, `tokio`) are marked with `external: true`.
58
59use quote::quote;
60use serde_yaml_ng as serde_yaml;
61use std::{
62    collections::HashMap,
63    fs,
64    path::{Path},
65};
66use syn::{
67    Attribute,
68    ItemFn, ItemStruct, ImplItem,
69    Meta,
70    Visibility,
71    visit::Visit,
72    FnArg, Pat, Type, ReturnType, Expr, ExprCall, ExprMethodCall, ExprPath,
73};
74use walkdir::{WalkDir, DirEntry};
75
76// =============== PUBLIC API ===============
77
78pub use data::{
79    AnalysisResult, StructDecl, FunctionDecl,
80    YamlOutput, YamlSubproject, YamlFunction, YamlStruct, YamlCall,
81};
82
83/// Analyzes a Rust project directory and returns structured metadata about its code.
84///
85/// This function recursively scans all `.rs` files (excluding common ignored paths like `target/`,
86/// `.git/`, etc.), parses them using `syn`, and extracts:
87/// - Struct definitions
88/// - Free functions and methods
89/// - Function calls (filtered for semantic relevance)
90/// - Documentation, attributes, and type signatures
91///
92/// # Arguments
93///
94/// * `root` - Path to the root of the Rust project (must contain `Cargo.toml` or be a valid crate/workspace root).
95///
96/// # Returns
97///
98/// A `Result` containing an [`AnalysisResult`] on success, or an error if:
99/// - `root` is not a directory
100/// - I/O errors occur during file reading
101///
102/// # Example
103///
104/// ```rust,no_run
105/// use arcella_inspect::analyze_project;
106/// use std::path::Path;
107///
108/// let analysis = analyze_project(Path::new("./examples/hello-world")).unwrap();
109/// println!("Found {} functions", analysis.functions.len());
110/// ```
111pub fn analyze_project(root: &Path) -> Result<AnalysisResult, Box<dyn std::error::Error>> {
112    if !root.is_dir() {
113        return Err(format!("'{}' is not a directory", root.display()).into());
114    }
115    let (structs, functions) = collect_all_items(root)?;
116    Ok(AnalysisResult { structs, functions })
117}
118
119/// Serializes an [`AnalysisResult`] to a YAML 1.2 string.
120///
121/// The output conforms to the `arcella-inspect` metadata schema:
122/// - Includes crate name (from `Cargo.toml`)
123/// - Maps each function to its calls, distinguishing internal vs external
124/// - Preserves file paths relative to the project root
125///
126/// # Arguments
127///
128/// * `analysis` - The result of a prior call to [`analyze_project`].
129/// * `root` - The same project root passed to `analyze_project`.
130///
131/// # Returns
132///
133/// A `Result` containing a YAML string on success, or an error if:
134/// - `Cargo.toml` cannot be read or parsed
135/// - Serialization fails
136///
137/// # Example
138///
139/// ```rust,no_run
140/// use arcella_inspect::{analyze_project, analysis_to_yaml};
141/// use std::path::Path;
142///
143/// let root = Path::new("./my-crate");
144/// let analysis = analyze_project(root).unwrap();
145/// let yaml = analysis_to_yaml(&analysis, root).unwrap();
146/// assert!(yaml.contains("version: \"1.0\""));
147/// ```
148pub fn analysis_to_yaml(
149    analysis: &AnalysisResult,
150    root: &Path,
151) -> Result<String, Box<dyn std::error::Error>> {
152    let cargo_toml = root.join("Cargo.toml");
153    let crate_name = read_crate_name(&cargo_toml);
154
155    let index = index_functions(&analysis.functions);
156
157    let yaml_functions: Vec<YamlFunction> = analysis
158        .functions
159        .iter()
160        .map(|f| {
161            let calls: Vec<YamlCall> = f
162                .calls
163                .iter()
164                .map(|call_name| {
165                    if index.contains_key(call_name) {
166                        let target = &index[call_name];
167                        YamlCall {
168                            name: call_name.clone(),
169                            file: Some(target.file.clone()),
170                            line: Some(target.line),
171                            external: None,
172                        }
173                    } else {
174                        YamlCall {
175                            name: call_name.clone(),
176                            file: None,
177                            line: None,
178                            external: Some(true),
179                        }
180                    }
181                })
182                .collect();
183
184            YamlFunction {
185                name: f.full_name.clone(),
186                file: f.file.clone(),
187                line: f.line,
188                returns: f.returns.clone(),
189                parameters: if f.parameters.is_empty() {
190                    None
191                } else {
192                    Some(f.parameters.clone())
193                },
194                docstring: f.docstring.clone(),
195                attributes: f.attributes.clone(),
196                calls,
197            }
198        })
199        .collect();
200
201    let yaml_structs: Vec<YamlStruct> = analysis
202        .structs
203        .iter()
204        .map(|s| YamlStruct {
205            name: s.name.clone(),
206            file: s.file.clone(),
207            line: s.line,
208        })
209        .collect();
210
211    let subproject = YamlSubproject {
212        name: crate_name,
213        root: ".".to_string(),
214        structures: yaml_structs,
215        functions: yaml_functions,
216    };
217
218    let output = YamlOutput {
219        version: "1.0".to_string(),
220        project_name: None,
221        subprojects: vec![subproject],
222    };
223
224    let yaml = serde_yaml::to_string(&output)?;
225    Ok(yaml)
226}
227
228// =============== INTERNAL MODULES ===============
229
230/// Internal data structures for analysis results and YAML serialization.
231mod data {
232    use serde::Serialize;
233
234    /// Represents a struct definition found in the source code.
235    #[derive(Debug, Clone)]
236    pub struct StructDecl {
237        /// Name of the struct (e.g. `"AuthState"`).
238        pub name: String,
239        /// File path relative to the subproject root (e.g. `"src/state.rs"`).
240        pub file: String,
241        /// Line number where the struct is defined.
242        pub line: usize,
243    }
244
245    /// Represents a function or method found in the source code.
246    #[derive(Debug, Clone)]
247    pub struct FunctionDecl {
248        /// Fully qualified name (e.g. `"my_mod::Auth::validate"` or `"free_function"`).
249        pub full_name: String,
250        /// File path relative to the subproject root.
251        pub file: String,
252        /// Line number of the function definition.
253        pub line: usize,
254        /// String representation of the return type (e.g. `"Result<(), Error>"`).
255        pub returns: String,
256        /// List of parameters in `"name: Type"` format.
257        pub parameters: Vec<String>,
258        /// Documentation comment text (if any), with newlines preserved.
259        pub docstring: Option<String>,
260        /// List of attributes and qualifiers:
261        /// - Language keywords: `"pub"`, `"async"`, `"unsafe"`, `"const"`
262        /// - Attribute macros: `"#[test]"`, `"#[instrument]"`
263        pub attributes: Vec<String>,
264        /// List of **meaningful** called function names (filtered for noise).
265        pub calls: Vec<String>,
266    }
267
268    /// The top-level result of a project analysis.
269    #[derive(Debug)]
270    pub struct AnalysisResult {
271        /// All struct definitions found.
272        pub structs: Vec<StructDecl>,
273        /// All function and method definitions found.
274        pub functions: Vec<FunctionDecl>,
275    }
276
277    // === YAML OUTPUT STRUCTS ===
278
279    /// YAML representation of a struct.
280    #[derive(Serialize, Debug)]
281    pub struct YamlStruct {
282        pub name: String,
283        pub file: String,
284        pub line: usize,
285    }
286
287    /// YAML representation of a function call.
288    #[derive(Serialize, Debug)]
289    pub struct YamlCall {
290        /// Fully qualified name of the called function.
291        pub name: String,
292        /// Present only for internal calls: file path relative to subproject root.
293        #[serde(skip_serializing_if = "Option::is_none")]
294        pub file: Option<String>,
295        /// Present only for internal calls: line number.
296        #[serde(skip_serializing_if = "Option::is_none")]
297        pub line: Option<usize>,
298        /// `true` if the call is to an external crate (e.g. `std`, `tokio`).
299        #[serde(skip_serializing_if = "Option::is_none")]
300        pub external: Option<bool>,
301    }
302
303    /// YAML representation of a function or method.
304    #[derive(Serialize, Debug)]
305    pub struct YamlFunction {
306        pub name: String,
307        pub file: String,
308        pub line: usize,
309        pub returns: String,
310        #[serde(skip_serializing_if = "Option::is_none")]
311        pub parameters: Option<Vec<String>>,
312        #[serde(skip_serializing_if = "Option::is_none")]
313        pub docstring: Option<String>,
314        #[serde(skip_serializing_if = "Vec::is_empty")]
315        pub attributes: Vec<String>,
316        #[serde(skip_serializing_if = "Vec::is_empty")]
317        pub calls: Vec<YamlCall>,
318    }
319
320    /// YAML representation of a crate (subproject).
321    #[derive(Serialize, Debug)]
322    pub struct YamlSubproject {
323        /// Crate name from `Cargo.toml`.
324        pub name: String,
325        /// Path from analysis root to this subproject root.
326        pub root: String,
327        #[serde(skip_serializing_if = "Vec::is_empty")]
328        pub structures: Vec<YamlStruct>,
329        #[serde(skip_serializing_if = "Vec::is_empty")]
330        pub functions: Vec<YamlFunction>,
331    }
332
333    /// Top-level YAML output document.
334    #[derive(Serialize, Debug)]
335    pub struct YamlOutput {
336        /// Schema version (`"1.0"`).
337        pub version: String,
338        /// Optional top-level project name (e.g. from workspace).
339        #[serde(skip_serializing_if = "Option::is_none")]
340        pub project_name: Option<String>,
341        /// List of analyzed crates (subprojects).
342        pub subprojects: Vec<YamlSubproject>,
343    }
344}
345
346// =============== HELPER FUNCTIONS ===============
347
348/// Determines if a function call name is considered "noise" and should be filtered out.
349///
350/// Examples of noise: `.clone()`, `.unwrap()`, iterator adapters, logging macros.
351/// This improves signal-to-noise ratio in call graphs.
352fn is_noise_call(name: &str) -> bool {
353    const NOISE: &[&str] = &[
354        "clone", "to_string", "into", "from", "as_ref", "deref", "borrow", "as_mut",
355        "map", "map_err", "and_then", "or_else", "unwrap", "expect", "ok", "err",
356        "is_ok", "is_err", "is_some", "is_none", "unwrap_or", "unwrap_or_else",
357        "iter", "into_iter", "next", "collect", "filter", "find", "for_each",
358        "enumerate", "zip", "take", "skip", "inspect",
359        "push", "pop", "insert", "remove", "get", "contains", "len", "is_empty",
360        "clear", "extend", "keys", "values",
361        "new", "default", "Some", "Ok", "Err",
362        "info", "warn", "error", "debug", "trace",
363        "serialize", "deserialize",
364        "join", "starts_with", "ends_with", "contains", "file_name", "extension",
365    ];
366    NOISE.contains(&name)
367}
368
369/// Checks whether a call name should be included in the output.
370///
371/// A call is meaningful if:
372/// - It is not in the noise list
373/// - It is not an empty or single/two-letter all-lowercase identifier (e.g. `x`, `ok`)
374/// - It starts with a letter or `_`, or contains `::` (indicating a path)
375fn is_meaningful_call(name: &str) -> bool {
376    if name.is_empty() {
377        return false;
378    }
379    if is_noise_call(name) {
380        return false;
381    }
382    if name.len() <= 2 && name.chars().all(|c| c.is_ascii_lowercase()) {
383        return false;
384    }
385    name.starts_with(|c: char| c.is_alphabetic() || c == '_') || name.contains("::")
386}
387
388/// Converts a `syn::Path` (e.g. `std::fmt::Display`) to a string (`"std::fmt::Display"`).
389fn path_to_string(path: &syn::Path) -> String {
390    path.segments
391        .iter()
392        .map(|s| s.ident.to_string())
393        .collect::<Vec<_>>()
394        .join("::")
395}
396
397/// Checks if an attribute is a documentation attribute (`#[doc = "..."]` or `///`).
398fn is_doc_attr(attr: &Attribute) -> bool {
399    attr.path().segments.len() == 1 && attr.path().is_ident("doc")
400}
401
402/// Extracts documentation from a list of attributes.
403///
404/// Combines all `#[doc = "..."]` and `///` lines into a single string with `\n` separators.
405fn extract_docstring(attrs: &[Attribute]) -> Option<String> {
406    let mut lines = Vec::new();
407    for attr in attrs {
408        if !attr.path().is_ident("doc") {
409            continue;
410        }
411        match &attr.meta {
412            Meta::NameValue(namevalue) => {
413                if let syn::Expr::Lit(syn::ExprLit {
414                    lit: syn::Lit::Str(lit),
415                    ..
416                }) = &namevalue.value
417                {
418                    let mut s = lit.value();
419                    if s.starts_with(' ') {
420                        s = s[1..].to_string();
421                    }
422                    lines.push(s);
423                }
424            }
425            Meta::List(meta_list) => {
426                if let Ok(lit) = meta_list.parse_args::<syn::LitStr>() {
427                    lines.push(lit.value());
428                }
429            }
430            Meta::Path(_) => {}
431        }
432    }
433    if lines.is_empty() {
434        None
435    } else {
436        Some(lines.join("\n"))
437    }
438}
439
440/// Extracts relevant attributes from a free function (`ItemFn`).
441///
442/// Includes:
443/// - Non-doc attributes (e.g. `#[test]`, `#[instrument]`)
444/// - Visibility (`pub`)
445/// - Qualifiers (`async`, `unsafe`, `const`)
446fn extract_attributes_from_fn(item_fn: &ItemFn) -> Vec<String> {
447    let mut attrs = Vec::new();
448    for attr in &item_fn.attrs {
449        if is_doc_attr(attr) {
450            continue;
451        }
452        if let Some(seg) = attr.path().segments.last() {
453            attrs.push(format!("#[{}]", seg.ident));
454        }
455    }
456    if matches!(item_fn.vis, Visibility::Public(_)) {
457        attrs.push("pub".to_string());
458    }
459    if item_fn.sig.asyncness.is_some() {
460        attrs.push("async".to_string());
461    }
462    if item_fn.sig.unsafety.is_some() {
463        attrs.push("unsafe".to_string());
464    }
465    if item_fn.sig.constness.is_some() {
466        attrs.push("const".to_string());
467    }
468    attrs
469}
470
471/// Extracts relevant attributes from an `impl` method.
472///
473/// Similar to `extract_attributes_from_fn`, but operates on a `Signature` and attribute slice.
474fn extract_attributes_from_impl_method(
475    sig: &syn::Signature,
476    attrs: &[Attribute],
477) -> Vec<String> {
478    let mut result = Vec::new();
479    for attr in attrs {
480        if is_doc_attr(attr) {
481            continue;
482        }
483        if let Some(seg) = attr.path().segments.last() {
484            result.push(format!("#[{}]", seg.ident));
485        }
486    }
487    if sig.asyncness.is_some() {
488        result.push("async".to_string());
489    }
490    if sig.unsafety.is_some() {
491        result.push("unsafe".to_string());
492    }
493    if sig.constness.is_some() {
494        result.push("const".to_string());
495    }
496    result
497}
498
499/// Formats a function's return type as a string.
500///
501/// Truncates very long types (>60 chars) to `"> &"` for readability in YAML.
502fn format_return_type(ret: &ReturnType) -> String {
503    match ret {
504        ReturnType::Default => "()".to_string(),
505        ReturnType::Type(_, ty) => {
506            let s = quote! { #ty }.to_string();
507            if s.len() > 60 {
508                "> &".to_string()
509            } else {
510                s
511            }
512        }
513    }
514}
515
516/// Formats function parameters as `"name: Type"` strings.
517///
518/// Handles:
519/// - Named parameters (`x: i32`)
520/// - Self receivers (`self`, `&self`, `&mut self`, `mut self`)
521fn format_parameters(
522    inputs: &syn::punctuated::Punctuated<FnArg,
523	syn::Token![,]>
524) -> Vec<String> {
525    inputs.iter().map(|arg| {
526        match arg {
527            FnArg::Typed(pat_type) => {
528                let pat_str = match &*pat_type.pat {
529                    Pat::Ident(p) => p.ident.to_string(),
530                    _ => "_".to_string(),
531                };
532                let ty_str = quote! { #pat_type.ty }.to_string();
533                format!("{}: {}", pat_str, ty_str)
534            }
535            FnArg::Receiver(r) => {
536                let mut s = String::from("self");
537                if let Some((_, lifetime)) = &r.reference {
538                    s.insert_str(0, "&");
539                    if let Some(lt) = lifetime {
540                        s.insert_str(1, &format!("{} ", lt));
541                    } else {
542                        s.insert(1, ' ');
543                    }
544                    if r.mutability.is_some() {
545                        let pos = s.find(' ').unwrap_or(0) + 1;
546                        s.insert_str(pos, "mut ");
547                    }
548                } else if r.mutability.is_some() {
549                    s = "mut self".to_string();
550                }
551                s
552            }
553        }
554    }).collect()
555}
556
557// =============== AST VISITORS ===============
558
559/// Visitor that walks the AST and collects struct/function declarations.
560struct FullVisitor {
561    current_file: String,
562    functions: Vec<FunctionDecl>,
563    structs: Vec<data::StructDecl>,
564}
565
566/// Visitor that walks a function body and collects called function names.
567#[derive(Default)]
568struct CallVisitor {
569    calls: Vec<String>,
570}
571
572impl<'ast> Visit<'ast> for FullVisitor {
573    fn visit_item_struct(&mut self, i: &'ast ItemStruct) {
574        self.structs.push(data::StructDecl {
575            name: i.ident.to_string(),
576            file: self.current_file.clone(),
577            line: i.ident.span().start().line,
578        });
579        syn::visit::visit_item_struct(self, i);
580    }
581
582    fn visit_item_fn(&mut self, i: &'ast ItemFn) {
583        let name = i.sig.ident.to_string();
584        let line = i.sig.ident.span().start().line;
585        let returns = format_return_type(&i.sig.output);
586        let parameters = format_parameters(&i.sig.inputs);
587        let docstring = extract_docstring(&i.attrs);
588        let attributes = extract_attributes_from_fn(i);
589
590        let mut call_visitor = CallVisitor::default();
591        call_visitor.visit_block(&i.block);
592
593        let calls = call_visitor
594            .calls
595            .into_iter()
596            .filter(|c| is_meaningful_call(c))
597            .collect();
598
599        self.functions.push(FunctionDecl {
600            full_name: name,
601            file: self.current_file.clone(),
602            line,
603            returns,
604            parameters,
605            docstring,
606            attributes,
607            calls,
608        });
609        syn::visit::visit_item_fn(self, i);
610    }
611
612    fn visit_item_impl(&mut self, i: &'ast syn::ItemImpl) {
613        let impl_type_name = match &*i.self_ty {
614            Type::Path(type_path) => {
615                type_path
616                    .path
617                    .segments
618                    .iter()
619                    .map(|s| s.ident.to_string())
620                    .collect::<Vec<_>>()
621                    .join("::")
622            }
623            _ => "UnknownType".to_string(),
624        };
625
626        for item in &i.items {
627            if let ImplItem::Fn(method) = item {
628                let method_name = method.sig.ident.to_string();
629                let full_name = format!("{}::{}", impl_type_name, method_name);
630                let line = method.sig.ident.span().start().line;
631                let returns = format_return_type(&method.sig.output);
632                let parameters = format_parameters(&method.sig.inputs);
633                let docstring = extract_docstring(&method.attrs);
634                let attributes = extract_attributes_from_impl_method(&method.sig, &method.attrs);
635
636                let mut call_visitor = CallVisitor::default();
637                call_visitor.visit_block(&method.block);
638
639                let calls = call_visitor
640                    .calls
641                    .into_iter()
642                    .filter(|c| is_meaningful_call(c))
643                    .collect();
644
645                self.functions.push(FunctionDecl {
646                    full_name,
647                    file: self.current_file.clone(),
648                    line,
649                    returns,
650                    parameters,
651                    docstring,
652                    attributes,
653                    calls,
654                });
655            }
656        }
657        syn::visit::visit_item_impl(self, i);
658    }
659}
660
661impl<'ast> Visit<'ast> for CallVisitor {
662    fn visit_expr_call(&mut self, node: &'ast ExprCall) {
663        if let Expr::Path(ExprPath { path, .. }) = &*node.func {
664            let path_str = path_to_string(path);
665            if !path_str.is_empty() {
666                self.calls.push(path_str);
667            }
668        }
669        syn::visit::visit_expr_call(self, node);
670    }
671
672    fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) {
673        let method_name = node.method.to_string();
674        self.calls.push(method_name);
675        syn::visit::visit_expr_method_call(self, node);
676    }
677}
678
679// =============== FILE SYSTEM & PARSING ===============
680
681/// Parses a single Rust source file and extracts metadata.
682fn parse_file(
683    path: &Path,
684    root: &Path,
685) -> Result<(Vec<data::StructDecl>, Vec<FunctionDecl>), Box<dyn std::error::Error>> {
686    let code = fs::read_to_string(path)?;
687
688    if code.trim().is_empty() {
689        return Ok((Vec::new(), Vec::new()));
690    }
691
692    let syntax = match syn::parse_file(&code) {
693        Ok(syntax) => syntax,
694        Err(e) => {
695            eprintln!("Skipping invalid Rust file: {}: {}", path.display(), e);
696            return Ok((Vec::new(), Vec::new()));
697        }
698    };
699
700    let relative_path = path
701        .strip_prefix(root)
702        .unwrap_or(path)
703        .to_string_lossy()
704        .to_string();
705
706    let mut visitor = FullVisitor {
707        current_file: relative_path,
708        functions: Vec::new(),
709        structs: Vec::new(),
710    };
711    visitor.visit_file(&syntax);
712    Ok((visitor.structs, visitor.functions))
713}
714
715/// Checks if a directory entry is hidden (starts with `.`).
716fn is_hidden(entry: &DirEntry) -> bool {
717    entry
718        .file_name()
719        .to_str()
720        .map(|s| s.starts_with('.'))
721        .unwrap_or(false)
722}
723
724/// Determines whether a directory entry should be skipped during traversal.
725///
726/// Skips:
727/// - Hidden files/dirs (`.git`, `.env`)
728/// - Common build/output directories (`target`, `dist`, `build`, `node_modules`)
729fn should_skip_entry(entry: &DirEntry) -> bool {
730    if is_hidden(entry) {
731        return true;
732    }
733    let name = entry.file_name();
734    name == "target"
735        || name == ".git"
736        || name == "node_modules"
737        || name == "dist"
738        || name == "build"
739}
740
741/// Recursively collects all items from a project root.
742fn collect_all_items(
743    root_dir: &Path,
744) -> Result<(Vec<data::StructDecl>, Vec<FunctionDecl>), Box<dyn std::error::Error>> {
745    let mut all_structs = Vec::new();
746    let mut all_functions = Vec::new();
747
748    for entry in WalkDir::new(root_dir)
749        .into_iter()
750        .filter_entry(|e| !should_skip_entry(e))
751        .filter_map(|e| e.ok())
752        .filter(|e| e.path().extension().map_or(false, |ext| ext == "rs"))
753    {
754        if let Some(name) = entry.path().file_name().and_then(|n| n.to_str()) {
755            if name.contains('~') || name.ends_with(".bk") || name.ends_with(".tmp") {
756                continue;
757            }
758        }
759
760        let (structs, functions) = parse_file(entry.path(), root_dir)?;
761        all_structs.extend(structs);
762        all_functions.extend(functions);
763    }
764
765    Ok((all_structs, all_functions))
766}
767
768/// Builds an index of functions by their full name for fast lookup.
769fn index_functions(functions: &[FunctionDecl]) -> HashMap<String, &FunctionDecl> {
770    let mut map = HashMap::new();
771    for f in functions {
772        map.insert(f.full_name.clone(), f);
773    }
774    map
775}
776
777/// Reads the crate name from `Cargo.toml`.
778///
779/// Returns `"unknown"` if the file is missing or malformed.
780fn read_crate_name(cargo_toml_path: &Path) -> String {
781    let contents = match fs::read_to_string(cargo_toml_path) {
782        Ok(c) => c,
783        Err(_) => return "unknown".to_string(),
784    };
785    let contents = contents.trim_start_matches('\u{feff}'); // Remove UTF-8 BOM
786    let table: toml::Table = match contents.parse() {
787        Ok(t) => t,
788        Err(_) => return "unknown".to_string(),
789    };
790    table
791        .get("package")
792        .and_then(|p| p.as_table())
793        .and_then(|p| p.get("name"))
794        .and_then(|n| n.as_str())
795        .map(|s| s.to_string())
796        .unwrap_or_else(|| "unknown".to_string())
797}
798
799#[cfg(test)]
800mod tests {
801    use super::*;
802    use syn::parse_quote;
803
804    #[test]
805    fn test_is_meaningful_call() {
806        assert!(is_meaningful_call("validate_token"));
807        assert!(is_meaningful_call("std::time::sleep"));
808        assert!(!is_meaningful_call("unwrap"));
809        assert!(!is_meaningful_call("x"));
810        assert!(!is_meaningful_call("ok"));
811        assert!(is_meaningful_call("x::y")); // содержит :: → значимо
812    }
813
814    #[test]
815    fn test_path_to_string() {
816        let path = syn::parse_str::<syn::Path>("auth::validator::check").unwrap();
817        assert_eq!(path_to_string(&path), "auth::validator::check");
818    }
819
820    #[test]
821    fn test_extract_docstring() {
822        let attrs = vec![
823            syn::parse_quote!(#[doc = "First line"]),
824            syn::parse_quote!(#[doc = "Second line"]),
825        ];
826        assert_eq!(
827            extract_docstring(&attrs),
828            Some("First line\nSecond line".to_string())
829        );
830    }
831
832    #[test]
833    fn test_extract_attributes_from_fn() {
834        let item_fn: syn::ItemFn = syn::parse_str(
835            r#"#[test]
836            #[instrument]
837            pub async unsafe fn demo() {}"#
838        ).unwrap();
839        let attrs = extract_attributes_from_fn(&item_fn);
840        assert!(attrs.contains(&"#[test]".to_string()));
841        assert!(attrs.contains(&"#[instrument]".to_string()));
842        assert!(attrs.contains(&"pub".to_string()));
843        assert!(attrs.contains(&"async".to_string()));
844        assert!(attrs.contains(&"unsafe".to_string()));
845    }
846
847    #[test]
848    fn test_format_parameters_regular() {
849        let arg1: syn::FnArg = parse_quote!(a: i32);
850        let arg2: syn::FnArg = parse_quote!(b: &str);
851
852        let mut inputs = syn::punctuated::Punctuated::new();
853        inputs.push(arg1);
854        inputs.push(arg2);        
855
856        let params = format_parameters(&inputs);
857        assert_eq!(params.len(), 2);
858        assert!(params[0].starts_with("a: "));
859        assert!(params[1].starts_with("b: "));
860    }
861
862}