Skip to main content

cargo_shape_check/
lib.rs

1use anyhow::{Context, Result};
2use sha2::{Digest, Sha256};
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6use syn::visit::Visit;
7
8#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
9pub struct ShapeManifest {
10    pub crates: BTreeMap<String, String>,
11    pub version: String,
12}
13
14#[derive(Debug)]
15pub struct CrateShape {
16    pub name: String,
17    pub path: PathBuf,
18    pub canonical_text: String,
19    pub hash: String,
20}
21
22pub fn hash_workspace(workspace_root: &Path) -> Result<Vec<CrateShape>> {
23    let crate_paths = resolve_workspace_members(workspace_root)?;
24    let mut results = Vec::new();
25    for crate_path in crate_paths {
26        match hash_crate(&crate_path) {
27            Ok(shape) => results.push(shape),
28            Err(e) => {
29                eprintln!("warn: {}: {}", crate_path.display(), e);
30            }
31        }
32    }
33    results.sort_by(|a, b| a.name.cmp(&b.name));
34    Ok(results)
35}
36
37pub fn hash_crate(crate_path: &Path) -> Result<CrateShape> {
38    let name = crate_name(crate_path)?;
39    let entry = find_crate_entry(crate_path)?;
40    if !entry.exists() {
41        anyhow::bail!("entry point {} does not exist", entry.display());
42    }
43
44    let mut canonical = String::new();
45    walk_module(&entry, "", true, &mut canonical)?;
46
47    let mut hasher = Sha256::new();
48    hasher.update(canonical.as_bytes());
49    let hash = hex_encode(hasher.finalize());
50
51    Ok(CrateShape {
52        name,
53        path: crate_path.to_path_buf(),
54        canonical_text: canonical,
55        hash,
56    })
57}
58
59pub fn to_manifest(shapes: &[CrateShape]) -> ShapeManifest {
60    let mut crates = BTreeMap::new();
61    for s in shapes {
62        crates.insert(s.name.clone(), s.hash.clone());
63    }
64    ShapeManifest {
65        crates,
66        version: env!("CARGO_PKG_VERSION").to_string(),
67    }
68}
69
70pub fn diff_manifests(
71    old: &ShapeManifest,
72    new: &ShapeManifest,
73) -> (Vec<String>, Vec<String>, Vec<String>) {
74    let mut unchanged = Vec::new();
75    let mut changed = Vec::new();
76    let mut added = Vec::new();
77
78    for (name, new_hash) in &new.crates {
79        match old.crates.get(name) {
80            Some(old_hash) if old_hash == new_hash => unchanged.push(name.clone()),
81            Some(_) => changed.push(name.clone()),
82            None => added.push(name.clone()),
83        }
84    }
85    (unchanged, changed, added)
86}
87
88pub fn workspace_crate_map(workspace_root: &Path) -> Result<BTreeMap<String, PathBuf>> {
89    let members = resolve_workspace_members(workspace_root)?;
90    let mut map = BTreeMap::new();
91    for crate_path in members {
92        if let Ok(name) = crate_name(&crate_path) {
93            map.insert(name, crate_path);
94        }
95    }
96    Ok(map)
97}
98
99pub fn crate_rel_paths(workspace_root: &Path) -> Result<BTreeMap<String, String>> {
100    let members = resolve_workspace_members(workspace_root)?;
101    let mut map = BTreeMap::new();
102    for crate_path in members {
103        if let Ok(name) = crate_name(&crate_path) {
104            let rel = crate_path
105                .strip_prefix(workspace_root)
106                .unwrap_or(&crate_path)
107                .to_string_lossy()
108                .replace('\\', "/");
109            map.insert(name, rel);
110        }
111    }
112    Ok(map)
113}
114
115pub const MANIFEST_FILENAME: &str = ".shape-check.json";
116
117pub fn load_manifest(workspace_root: &Path) -> Result<Option<ShapeManifest>> {
118    let path = workspace_root.join(MANIFEST_FILENAME);
119    if !path.exists() {
120        return Ok(None);
121    }
122    let text = fs::read_to_string(&path)?;
123    let manifest: ShapeManifest = serde_json::from_str(&text)?;
124    Ok(Some(manifest))
125}
126
127pub fn save_manifest(workspace_root: &Path, manifest: &ShapeManifest) -> Result<()> {
128    let path = workspace_root.join(MANIFEST_FILENAME);
129    let text = serde_json::to_string_pretty(manifest)?;
130    fs::write(&path, text)?;
131    Ok(())
132}
133
134// ============================================================================
135// Workspace parsing
136// ============================================================================
137
138pub fn resolve_workspace_members(workspace_root: &Path) -> Result<Vec<PathBuf>> {
139    let cargo_toml = workspace_root.join("Cargo.toml");
140    let toml_text =
141        fs::read_to_string(&cargo_toml).with_context(|| format!("read {}", cargo_toml.display()))?;
142    let manifest: toml::Value = toml::from_str(&toml_text)?;
143
144    let workspace = manifest
145        .get("workspace")
146        .context("no [workspace] section in Cargo.toml")?
147        .as_table()
148        .context("[workspace] is not a table")?;
149
150    let members = workspace
151        .get("members")
152        .and_then(|v| v.as_array())
153        .context("no workspace.members")?;
154
155    let excludes: std::collections::HashSet<String> = workspace
156        .get("exclude")
157        .and_then(|v| v.as_array())
158        .map(|arr| {
159            arr.iter()
160                .filter_map(|v| v.as_str().map(String::from))
161                .collect()
162        })
163        .unwrap_or_default();
164
165    let mut crate_paths: Vec<PathBuf> = Vec::new();
166    for m in members {
167        let glob_str = m.as_str().unwrap_or("").trim_end_matches('/');
168        if glob_str.is_empty() {
169            continue;
170        }
171        if glob_str.contains('*') {
172            let parent_glob = glob_str.replace("/*", "").replace("*", "");
173            let parent = workspace_root.join(&parent_glob);
174            if parent.is_dir() {
175                let mut entries: Vec<_> = fs::read_dir(&parent)?
176                    .filter_map(|e| e.ok())
177                    .filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
178                    .collect();
179                entries.sort_by_key(|e| e.file_name());
180                for entry in entries {
181                    let p = entry.path();
182                    if p.join("Cargo.toml").exists() {
183                        let rel = p
184                            .strip_prefix(workspace_root)
185                            .unwrap_or(&p)
186                            .to_string_lossy()
187                            .replace('\\', "/");
188                        if !excludes.contains(&rel) {
189                            crate_paths.push(p);
190                        }
191                    }
192                }
193            }
194        } else {
195            let p = workspace_root.join(glob_str);
196            if p.join("Cargo.toml").exists() {
197                let rel = p
198                    .strip_prefix(workspace_root)
199                    .unwrap_or(&p)
200                    .to_string_lossy()
201                    .replace('\\', "/");
202                if !excludes.contains(&rel) {
203                    crate_paths.push(p);
204                }
205            }
206        }
207    }
208
209    Ok(crate_paths)
210}
211
212fn crate_name(crate_path: &Path) -> Result<String> {
213    let toml_text = fs::read_to_string(crate_path.join("Cargo.toml"))?;
214    let v: toml::Value = toml::from_str(&toml_text)?;
215    let name = v
216        .get("package")
217        .and_then(|p| p.get("name"))
218        .and_then(|n| n.as_str())
219        .context("no package.name in Cargo.toml")?
220        .to_string();
221    Ok(name)
222}
223
224fn find_crate_entry(crate_path: &Path) -> Result<PathBuf> {
225    let cargo_toml = crate_path.join("Cargo.toml");
226    if cargo_toml.exists() {
227        let toml_text = fs::read_to_string(&cargo_toml)?;
228        let v: toml::Value = toml::from_str(&toml_text)?;
229        if let Some(lib) = v.get("lib").and_then(|l| l.get("path")).and_then(|p| p.as_str()) {
230            return Ok(crate_path.join(lib));
231        }
232        if let Some(bins) = v.get("bin").and_then(|b| b.as_array()) {
233            if let Some(first) = bins.first() {
234                if let Some(p) = first.get("path").and_then(|p| p.as_str()) {
235                    return Ok(crate_path.join(p));
236                }
237            }
238        }
239    }
240    let candidates = [
241        crate_path.join("src/lib.rs"),
242        crate_path.join("src/main.rs"),
243        crate_path.join("lib.rs"),
244        crate_path.join("main.rs"),
245    ];
246    for c in &candidates {
247        if c.exists() {
248            return Ok(c.clone());
249        }
250    }
251    anyhow::bail!("no entry point found for crate at {}", crate_path.display())
252}
253
254// ============================================================================
255// Module walking + public surface extraction
256// ============================================================================
257
258fn walk_module(file_path: &Path, mod_path: &str, is_crate_root: bool, out: &mut String) -> Result<()> {
259    let src = fs::read_to_string(file_path).with_context(|| format!("parse {}", file_path.display()))?;
260    let file = syn::parse_file(&src).with_context(|| format!("parse {}", file_path.display()))?;
261
262    let file_stem = file_path
263        .file_stem()
264        .and_then(|s| s.to_str())
265        .unwrap_or("")
266        .to_string();
267    // Crate entry points resolve submodules as siblings regardless of filename
268    let effective_stem = if is_crate_root { "lib".to_string() } else { file_stem.clone() };
269
270    let mut visitor = ShapeVisitor {
271        items: Vec::new(),
272        mod_path: mod_path.to_string(),
273        file_dir: file_path.parent().map(Path::to_path_buf).unwrap_or_default(),
274        file_stem: effective_stem,
275    };
276    visitor.visit_file(&file);
277
278    let mut items = visitor.items;
279    items.sort();
280
281    out.push_str(&format!("=== mod {} ===\n", mod_path));
282    for item_text in &items {
283        out.push_str(item_text);
284        out.push('\n');
285    }
286
287    // Collect which private modules have items re-exported via `pub use`
288    let reexport_targets = find_reexport_targets(&file);
289
290    for sub in find_submodules(&file) {
291        let sub_path = resolve_submodule(&visitor.file_dir, &visitor.file_stem, &sub.name)?;
292        let new_mod_path = if mod_path.is_empty() {
293            sub.name.clone()
294        } else {
295            format!("{}::{}", mod_path, sub.name)
296        };
297        if sub.is_pub {
298            walk_module(&sub_path, &new_mod_path, false, out)?;
299        } else if reexport_targets.contains(&sub.name) {
300            walk_module(&sub_path, &new_mod_path, false, out)?;
301        }
302    }
303
304    Ok(())
305}
306
307/// Find private module names that are targets of `pub use` re-exports.
308///
309/// Matches patterns like:
310///   pub use foo::Bar;
311///   pub use foo::*;
312///   pub use foo::{A, B};
313fn find_reexport_targets(file: &syn::File) -> std::collections::HashSet<String> {
314    let mut targets = std::collections::HashSet::new();
315    for item in &file.items {
316        if let syn::Item::Use(u) = item {
317            if !matches!(u.vis, syn::Visibility::Public(_)) {
318                continue;
319            }
320            collect_use_roots(&u.tree, &mut targets);
321        }
322    }
323    targets
324}
325
326fn collect_use_roots(tree: &syn::UseTree, targets: &mut std::collections::HashSet<String>) {
327    match tree {
328        syn::UseTree::Path(p) => {
329            let name = p.ident.to_string();
330            let name = name.strip_prefix("r#").unwrap_or(&name).to_string();
331            // Only the first path segment is a local module name
332            targets.insert(name);
333        }
334        syn::UseTree::Group(g) => {
335            for item in &g.items {
336                collect_use_roots(item, targets);
337            }
338        }
339        // `pub use SomeItem;` or `pub use *;` at root level -- no module prefix
340        syn::UseTree::Name(_) | syn::UseTree::Rename(_) | syn::UseTree::Glob(_) => {}
341    }
342}
343
344struct SubmoduleDecl {
345    name: String,
346    is_pub: bool,
347}
348
349fn find_submodules(file: &syn::File) -> Vec<SubmoduleDecl> {
350    let mut subs = Vec::new();
351    for item in &file.items {
352        if let syn::Item::Mod(m) = item {
353            if m.content.is_none() {
354                let is_pub = matches!(m.vis, syn::Visibility::Public(_));
355                let name = m.ident.to_string();
356                let name = name.strip_prefix("r#").unwrap_or(&name).to_string();
357                subs.push(SubmoduleDecl { name, is_pub });
358            }
359        }
360    }
361    subs
362}
363
364fn resolve_submodule(parent_dir: &Path, file_stem: &str, name: &str) -> Result<PathBuf> {
365    let candidates: Vec<PathBuf> = if file_stem == "lib" || file_stem == "mod" || file_stem == "main"
366    {
367        vec![
368            parent_dir.join(format!("{}.rs", name)),
369            parent_dir.join(name).join("mod.rs"),
370        ]
371    } else {
372        vec![
373            parent_dir.join(file_stem).join(format!("{}.rs", name)),
374            parent_dir.join(file_stem).join(name).join("mod.rs"),
375        ]
376    };
377
378    for c in &candidates {
379        if c.exists() {
380            return Ok(c.clone());
381        }
382    }
383    anyhow::bail!(
384        "could not resolve submodule `{}` from `{}/{}.rs`",
385        name,
386        parent_dir.display(),
387        file_stem,
388    )
389}
390
391// ============================================================================
392// syn visitor — extracts public surface items
393// ============================================================================
394
395struct ShapeVisitor {
396    items: Vec<String>,
397    mod_path: String,
398    file_dir: PathBuf,
399    file_stem: String,
400}
401
402impl ShapeVisitor {
403    fn is_visible(&self, vis: &syn::Visibility) -> bool {
404        matches!(vis, syn::Visibility::Public(_))
405    }
406}
407
408impl<'ast> Visit<'ast> for ShapeVisitor {
409    fn visit_item_fn(&mut self, f: &'ast syn::ItemFn) {
410        if !self.is_visible(&f.vis) {
411            return;
412        }
413        let inline = has_inline_attr(&f.attrs);
414        let generic = has_type_or_const_generics(&f.sig.generics);
415        let constfn = f.sig.constness.is_some();
416        let body_visible_downstream = inline || generic || constfn;
417        let body = if body_visible_downstream {
418            quote::quote!(#f).to_string()
419        } else {
420            String::new()
421        };
422        let text = format!(
423            "fn {} {} {}{}{}",
424            sig_text(&f.sig),
425            relevant_attrs(&f.attrs),
426            if body_visible_downstream {
427                "<<BODY: "
428            } else {
429                ""
430            },
431            body,
432            if body_visible_downstream { ">>" } else { "" }
433        );
434        self.items.push(format!("[fn] {}::{}", self.mod_path, text));
435    }
436
437    fn visit_item_struct(&mut self, s: &'ast syn::ItemStruct) {
438        if !self.is_visible(&s.vis) {
439            return;
440        }
441        let text = quote::quote!(#s).to_string();
442        self.items
443            .push(format!("[struct] {}::{}", self.mod_path, text));
444    }
445
446    fn visit_item_enum(&mut self, e: &'ast syn::ItemEnum) {
447        if !self.is_visible(&e.vis) {
448            return;
449        }
450        let text = quote::quote!(#e).to_string();
451        self.items.push(format!("[enum] {}::{}", self.mod_path, text));
452    }
453
454    fn visit_item_trait(&mut self, t: &'ast syn::ItemTrait) {
455        if !self.is_visible(&t.vis) {
456            return;
457        }
458        let text = quote::quote!(#t).to_string();
459        self.items
460            .push(format!("[trait] {}::{}", self.mod_path, text));
461    }
462
463    fn visit_item_type(&mut self, t: &'ast syn::ItemType) {
464        if !self.is_visible(&t.vis) {
465            return;
466        }
467        let text = quote::quote!(#t).to_string();
468        self.items
469            .push(format!("[type] {}::{}", self.mod_path, text));
470    }
471
472    fn visit_item_const(&mut self, c: &'ast syn::ItemConst) {
473        if !self.is_visible(&c.vis) {
474            return;
475        }
476        let text = quote::quote!(#c).to_string();
477        self.items.push(format!("[const] {}::{}", self.mod_path, text));
478    }
479
480    fn visit_item_static(&mut self, s: &'ast syn::ItemStatic) {
481        if !self.is_visible(&s.vis) {
482            return;
483        }
484        let text = quote::quote!(#s).to_string();
485        self.items
486            .push(format!("[static] {}::{}", self.mod_path, text));
487    }
488
489    fn visit_item_use(&mut self, u: &'ast syn::ItemUse) {
490        if !self.is_visible(&u.vis) {
491            return;
492        }
493        let text = quote::quote!(#u).to_string();
494        self.items.push(format!("[use] {}::{}", self.mod_path, text));
495    }
496
497    fn visit_item_macro(&mut self, m: &'ast syn::ItemMacro) {
498        let exported = m.attrs.iter().any(|a| a.path().is_ident("macro_export"));
499        if !exported {
500            return;
501        }
502        let text = quote::quote!(#m).to_string();
503        self.items
504            .push(format!("[macro] {}::{}", self.mod_path, text));
505    }
506
507    fn visit_item_impl(&mut self, i: &'ast syn::ItemImpl) {
508        let mut buf = String::from("impl");
509        let generics = &i.generics;
510        if !i.generics.params.is_empty() {
511            buf.push_str(&quote::quote!(#generics).to_string());
512        }
513        let self_ty = &i.self_ty;
514        if let Some((_, trait_path, _)) = &i.trait_ {
515            buf.push(' ');
516            buf.push_str(&quote::quote!(#trait_path).to_string());
517            buf.push_str(" for ");
518        } else {
519            buf.push(' ');
520        }
521        buf.push_str(&quote::quote!(#self_ty).to_string());
522
523        let is_trait_impl = i.trait_.is_some();
524        for item in &i.items {
525            match item {
526                syn::ImplItem::Fn(f) => {
527                    let visible = is_trait_impl || matches!(f.vis, syn::Visibility::Public(_));
528                    if !visible {
529                        continue;
530                    }
531                    let inline = has_inline_attr(&f.attrs);
532                    let generic = has_type_or_const_generics(&f.sig.generics);
533                    let constfn = f.sig.constness.is_some();
534                    let body_visible = inline || generic || constfn;
535                    let sig = sig_text(&f.sig);
536                    if body_visible {
537                        let body = quote::quote!(#f).to_string();
538                        buf.push_str(&format!("\n  fn {} <<BODY: {}>>", sig, body));
539                    } else {
540                        buf.push_str(&format!("\n  fn {};", sig));
541                    }
542                }
543                syn::ImplItem::Const(c) => {
544                    let visible = is_trait_impl || matches!(c.vis, syn::Visibility::Public(_));
545                    if !visible {
546                        continue;
547                    }
548                    buf.push_str(&format!("\n  {}", quote::quote!(#c)));
549                }
550                syn::ImplItem::Type(t) => {
551                    let visible = is_trait_impl || matches!(t.vis, syn::Visibility::Public(_));
552                    if !visible {
553                        continue;
554                    }
555                    buf.push_str(&format!("\n  {}", quote::quote!(#t)));
556                }
557                _ => {}
558            }
559        }
560
561        self.items
562            .push(format!("[impl] {}::{}", self.mod_path, buf));
563    }
564
565    fn visit_item_mod(&mut self, m: &'ast syn::ItemMod) {
566        if m.content.is_none() {
567            return;
568        }
569        let visible = matches!(m.vis, syn::Visibility::Public(_));
570        if !visible {
571            return;
572        }
573        let new_mod_path = if self.mod_path.is_empty() {
574            m.ident.to_string()
575        } else {
576            format!("{}::{}", self.mod_path, m.ident)
577        };
578        let mut sub_visitor = ShapeVisitor {
579            items: Vec::new(),
580            mod_path: new_mod_path.clone(),
581            file_dir: self.file_dir.clone(),
582            file_stem: self.file_stem.clone(),
583        };
584        if let Some((_, items)) = &m.content {
585            for item in items {
586                sub_visitor.visit_item(item);
587            }
588        }
589        sub_visitor.items.sort();
590        self.items.push(format!(
591            "[mod] {}::{} {{\n{}\n}}",
592            self.mod_path,
593            m.ident,
594            sub_visitor.items.join("\n")
595        ));
596    }
597}
598
599fn sig_text(sig: &syn::Signature) -> String {
600    quote::quote!(#sig).to_string()
601}
602
603fn has_inline_attr(attrs: &[syn::Attribute]) -> bool {
604    attrs.iter().any(|a| a.path().is_ident("inline"))
605}
606
607fn has_type_or_const_generics(g: &syn::Generics) -> bool {
608    g.params.iter().any(|p| {
609        matches!(
610            p,
611            syn::GenericParam::Type(_) | syn::GenericParam::Const(_)
612        )
613    })
614}
615
616fn relevant_attrs(attrs: &[syn::Attribute]) -> String {
617    let mut buf = String::new();
618    for a in attrs {
619        let path = quote::quote!(#a).to_string();
620        if !path.contains("# [doc") && !path.contains("#[doc") {
621            buf.push_str(&path);
622            buf.push(' ');
623        }
624    }
625    buf
626}
627
628fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
629    const HEX: &[u8; 16] = b"0123456789abcdef";
630    let bytes = bytes.as_ref();
631    let mut out = String::with_capacity(bytes.len() * 2);
632    for &b in bytes {
633        out.push(HEX[(b >> 4) as usize] as char);
634        out.push(HEX[(b & 0xf) as usize] as char);
635    }
636    out
637}