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
134pub 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
254fn 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 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 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
307fn 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 targets.insert(name);
333 }
334 syn::UseTree::Group(g) => {
335 for item in &g.items {
336 collect_use_roots(item, targets);
337 }
338 }
339 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
391struct 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("e::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("e::quote!(#trait_path).to_string());
517 buf.push_str(" for ");
518 } else {
519 buf.push(' ');
520 }
521 buf.push_str("e::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}