Skip to main content

cargo_context_core/collect/
entry.rs

1//! Entry-point extraction.
2//!
3//! For each workspace member, locate the crate's `main.rs` and/or `lib.rs`
4//! and surface its shape to the LLM without burning tokens on private
5//! implementation details.
6//!
7//! Strategy:
8//! - `main.rs`: included in full. Binary entry points are usually short
9//!   and the LLM often needs the actual setup logic.
10//! - `lib.rs`: filtered to public items only. Function bodies are replaced
11//!   with `{ /* ... */ }` so the LLM sees the API surface, not the
12//!   implementation. Structs/enums/traits/consts/types are kept whole
13//!   since they *are* the signature.
14//!
15//! Parsing failures degrade gracefully: a file that doesn't parse (e.g.
16//! a nightly-only syntax flag) is emitted verbatim with a note.
17
18use std::path::{Path, PathBuf};
19
20use serde::{Deserialize, Serialize};
21use syn::visit_mut::VisitMut;
22
23use crate::collect::meta::cargo_metadata;
24use crate::error::Result;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum EntryKind {
29    /// Binary entry (`src/main.rs` or an explicit `[[bin]]` target).
30    Main,
31    /// Library entry (`src/lib.rs`).
32    Lib,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct EntryFile {
37    pub crate_name: String,
38    pub kind: EntryKind,
39    pub path: PathBuf,
40    /// Rendered content: full for `Main`, filtered signatures for `Lib`,
41    /// or verbatim fallback when parsing failed.
42    pub rendered: String,
43    /// `true` when parsing failed and `rendered` is the raw source.
44    pub parse_failed: bool,
45    pub raw_line_count: usize,
46}
47
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
49pub struct EntryPoints {
50    pub files: Vec<EntryFile>,
51}
52
53impl EntryPoints {
54    pub fn is_empty(&self) -> bool {
55        self.files.is_empty()
56    }
57}
58
59/// Collect entry points for every member in `root`'s workspace.
60pub fn entry_points(root: &Path) -> Result<EntryPoints> {
61    let meta = cargo_metadata(root)?;
62    let mut files = Vec::new();
63    for member in &meta.members {
64        let manifest_dir = match member.manifest_path.parent() {
65            Some(d) => d,
66            None => continue,
67        };
68        let candidates = [
69            ("src/main.rs", EntryKind::Main),
70            ("src/lib.rs", EntryKind::Lib),
71        ];
72        for (rel, kind) in candidates {
73            let path = manifest_dir.join(rel);
74            if path.exists() {
75                match parse_entry_file(&member.name, &path, kind) {
76                    Ok(f) => files.push(f),
77                    Err(_) => continue, // skip unreadable files silently
78                }
79            }
80        }
81    }
82    Ok(EntryPoints { files })
83}
84
85fn parse_entry_file(crate_name: &str, path: &Path, kind: EntryKind) -> Result<EntryFile> {
86    let source = std::fs::read_to_string(path)?;
87    let raw_line_count = source.lines().count();
88
89    let (rendered, parse_failed) = match syn::parse_file(&source) {
90        Ok(file) => (render_file(file, kind), false),
91        Err(_) => (source.clone(), true),
92    };
93
94    Ok(EntryFile {
95        crate_name: crate_name.to_string(),
96        kind,
97        path: path.to_path_buf(),
98        rendered,
99        parse_failed,
100        raw_line_count,
101    })
102}
103
104fn render_file(mut file: syn::File, kind: EntryKind) -> String {
105    match kind {
106        EntryKind::Main => prettyplease::unparse(&file),
107        EntryKind::Lib => {
108            // Keep only public items; strip fn bodies.
109            file.items.retain(is_public_api_item);
110            let mut stripper = BodyStripper;
111            stripper.visit_file_mut(&mut file);
112            prettyplease::unparse(&file)
113        }
114    }
115}
116
117fn is_public_api_item(item: &syn::Item) -> bool {
118    use syn::{Item, Visibility};
119    let vis: Option<&Visibility> = match item {
120        Item::Fn(f) => Some(&f.vis),
121        Item::Struct(s) => Some(&s.vis),
122        Item::Enum(e) => Some(&e.vis),
123        Item::Trait(t) => Some(&t.vis),
124        Item::Mod(m) => Some(&m.vis),
125        Item::Const(c) => Some(&c.vis),
126        Item::Static(s) => Some(&s.vis),
127        Item::Type(t) => Some(&t.vis),
128        Item::Use(u) => Some(&u.vis),
129        Item::TraitAlias(t) => Some(&t.vis),
130        Item::Union(u) => Some(&u.vis),
131        // Macros and extern blocks are part of the API surface regardless of vis.
132        Item::Macro(_) | Item::ExternCrate(_) | Item::ForeignMod(_) => return true,
133        // Impls contribute behavior but inflate tokens; skip.
134        Item::Impl(_) => return false,
135        _ => None,
136    };
137    matches!(vis, Some(Visibility::Public(_)))
138}
139
140/// Replaces function bodies with a single-statement placeholder so the LLM
141/// sees signatures without the implementation.
142struct BodyStripper;
143
144impl VisitMut for BodyStripper {
145    fn visit_item_fn_mut(&mut self, node: &mut syn::ItemFn) {
146        *node.block = stub_block();
147        // Recurse so nested `fn` inside `mod`s also get stripped (handled via
148        // visit_item_mod_mut).
149    }
150
151    fn visit_item_impl_mut(&mut self, _node: &mut syn::ItemImpl) {
152        // Impls are filtered out before we get here; no recursion.
153    }
154
155    fn visit_item_mod_mut(&mut self, node: &mut syn::ItemMod) {
156        if let Some((_, items)) = node.content.as_mut() {
157            items.retain(is_public_api_item);
158            for item in items.iter_mut() {
159                self.visit_item_mut(item);
160            }
161        }
162    }
163}
164
165fn stub_block() -> syn::Block {
166    // `{ /* ... */ }` — a block that contains only a comment token would
167    // require a custom token stream; an empty block with a todo-style
168    // expression parses cleanly and reads fine.
169    syn::parse_quote! { { /* ... */ } }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    fn write_tmp(contents: &str) -> (tempfile::TempDir, PathBuf) {
177        let tmp = tempfile::tempdir().unwrap();
178        let path = tmp.path().join("src.rs");
179        std::fs::write(&path, contents).unwrap();
180        (tmp, path)
181    }
182
183    #[test]
184    fn lib_strips_private_items() {
185        let src = r#"
186            pub fn kept() -> i32 { 42 }
187            fn dropped() -> i32 { 0 }
188            pub struct Keeper { pub x: i32 }
189            struct Hidden { x: i32 }
190        "#;
191        let (_tmp, path) = write_tmp(src);
192        let f = parse_entry_file("x", &path, EntryKind::Lib).unwrap();
193        assert!(f.rendered.contains("pub fn kept"));
194        assert!(!f.rendered.contains("fn dropped"));
195        assert!(f.rendered.contains("pub struct Keeper"));
196        assert!(!f.rendered.contains("struct Hidden"));
197    }
198
199    #[test]
200    fn lib_strips_function_bodies() {
201        let src = "pub fn compute(x: i32) -> i32 { x * 2 + 7 }";
202        let (_tmp, path) = write_tmp(src);
203        let f = parse_entry_file("x", &path, EntryKind::Lib).unwrap();
204        assert!(
205            f.rendered.contains("pub fn compute"),
206            "signature dropped; rendered:\n{}",
207            f.rendered
208        );
209        assert!(
210            !f.rendered.contains("x * 2 + 7"),
211            "body leaked; rendered:\n{}",
212            f.rendered
213        );
214    }
215
216    #[test]
217    fn main_preserves_bodies() {
218        let src = r#"fn main() { println!("hi"); }"#;
219        let (_tmp, path) = write_tmp(src);
220        let f = parse_entry_file("x", &path, EntryKind::Main).unwrap();
221        assert!(f.rendered.contains("println"));
222    }
223
224    #[test]
225    fn unparseable_source_returns_raw_with_flag() {
226        let src = "fn main( { // intentionally broken }";
227        let (_tmp, path) = write_tmp(src);
228        let f = parse_entry_file("x", &path, EntryKind::Main).unwrap();
229        assert!(f.parse_failed);
230        assert_eq!(f.rendered, src);
231    }
232
233    #[test]
234    fn impls_are_filtered_from_lib() {
235        let src = r#"
236            pub struct S;
237            impl S {
238                pub fn hi() -> i32 { 1 }
239            }
240        "#;
241        let (_tmp, path) = write_tmp(src);
242        let f = parse_entry_file("x", &path, EntryKind::Lib).unwrap();
243        assert!(f.rendered.contains("pub struct S"));
244        assert!(!f.rendered.contains("impl S"));
245    }
246
247    #[test]
248    fn entry_points_finds_own_workspace_lib() {
249        // Running from inside this workspace, we should find our own lib.rs.
250        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
251        let ep = entry_points(root).expect("collect entry points");
252        assert!(
253            ep.files
254                .iter()
255                .any(|f| f.kind == EntryKind::Lib && f.crate_name == "cargo-context-core"),
256            "expected cargo-context-core lib entry, got: {:?}",
257            ep.files
258                .iter()
259                .map(|f| (&f.crate_name, f.kind))
260                .collect::<Vec<_>>()
261        );
262    }
263}