Skip to main content

soothfast_docs/
surface.rs

1//! Public API surface from rustdoc JSON: item paths, span-derived
2//! fingerprints, doc presence, and literal source signatures.
3//!
4//! Fingerprints hash the item's *source span text* (ordinary comments
5//! stripped, then whitespace-normalized), not rustdoc's structured types —
6//! rustdoc item ids shift across builds and would churn a structure-based
7//! hash.
8
9use std::collections::{BTreeMap, HashMap};
10use std::path::Path;
11
12use serde_json::Value;
13
14/// What the surface records about one public item — enough to detect any
15/// change to it (fingerprint), classify the change (signature), and audit
16/// coverage (kind, has_docs).
17#[derive(Debug, Clone)]
18pub struct ItemInfo {
19    /// Rustdoc item kind (`function`, `struct`, `module`, ...).
20    pub kind: String,
21    /// FNV-1a of the span source, comments stripped then whitespace-normalized.
22    pub fingerprint: u64,
23    /// Declaration text, normalized: the span up to the first `{` or `;`,
24    /// except that a struct or union keeps its `pub` fields and an enum its
25    /// variants, since those are what a consumer writes against.
26    pub signature: String,
27    /// Whether the item carries a `///` doc comment.
28    pub has_docs: bool,
29}
30
31/// Public items keyed by full path (`crate::module::item`).
32#[derive(Debug, Default)]
33pub struct Surface {
34    pub items: BTreeMap<String, ItemInfo>,
35    /// Pub methods and associated consts of inherent impls, keyed
36    /// `type_path::name`. Kept apart from `items` so coverage, the API
37    /// reference and the API diff keep counting top-level items only.
38    pub members: BTreeMap<String, ItemInfo>,
39}
40
41/// One distinct item, grouped by (kind, fingerprint) to collapse the extra
42/// path keys `alias_reexports` adds for the same underlying function —
43/// `representative` is the shortest path (for display), `aliases` holds
44/// every spelling (for lookups, since a `covers=` tag may name any of them).
45/// Un-fingerprinted items (span unreadable) are never merged with others.
46pub struct ItemGroup<'a> {
47    pub representative: &'a String,
48    pub aliases: Vec<&'a String>,
49}
50
51impl Surface {
52    /// An item or an inherent member by full path, for bind resolution.
53    pub fn lookup(&self, path: &str) -> Option<&ItemInfo> {
54        self.items.get(path).or_else(|| self.members.get(path))
55    }
56
57    /// Every item in this surface, deduplicated across `pub use` aliases.
58    pub fn grouped(&self) -> Vec<ItemGroup<'_>> {
59        let mut groups: HashMap<(&str, u64), Vec<&String>> = HashMap::new();
60        let mut unfingerprinted = Vec::new();
61        for (path, info) in &self.items {
62            if info.fingerprint == 0 {
63                unfingerprinted.push(path);
64                continue;
65            }
66            groups
67                .entry((info.kind.as_str(), info.fingerprint))
68                .or_default()
69                .push(path);
70        }
71        let mut out: Vec<ItemGroup<'_>> = groups
72            .into_values()
73            .map(|mut aliases| {
74                aliases.sort();
75                let representative = aliases.iter().min_by_key(|p| p.len()).copied().unwrap();
76                ItemGroup {
77                    representative,
78                    aliases,
79                }
80            })
81            .chain(unfingerprinted.into_iter().map(|p| ItemGroup {
82                representative: p,
83                aliases: vec![p],
84            }))
85            .collect();
86        out.sort_by(|a, b| a.representative.cmp(b.representative));
87        out
88    }
89}
90
91/// Item kinds surfaced in reports (impl blocks and imports are noise).
92const KINDS: &[&str] = &[
93    "function",
94    "struct",
95    "enum",
96    "trait",
97    "constant",
98    "type_alias",
99    "module",
100    "static",
101    "union",
102    "macro",
103    "proc_macro",
104];
105
106/// Build a surface from a rustdoc JSON document. `source_root` resolves the
107/// relative filenames in spans (the directory rustdoc ran in).
108pub fn from_rustdoc(doc: &Value, source_root: &Path) -> Surface {
109    let mut surface = Surface::default();
110    let (Some(index), Some(paths)) = (doc["index"].as_object(), doc["paths"].as_object()) else {
111        return surface;
112    };
113    let mut file_cache: HashMap<String, Vec<String>> = HashMap::new();
114
115    for (id, item) in index {
116        if item["visibility"] != "public" {
117            continue;
118        }
119        let Some(inner) = item["inner"].as_object() else {
120            continue;
121        };
122        let Some(kind) = inner.keys().next().map(String::as_str) else {
123            continue;
124        };
125        if !KINDS.contains(&kind) {
126            continue;
127        }
128        // unaddressable (e.g. non-re-exported assoc items) if absent
129        let Some(full) = item_path(paths, id) else {
130            continue;
131        };
132
133        let (fingerprint, signature) =
134            span_fingerprint(&item["span"], kind, source_root, &mut file_cache)
135                .unwrap_or((0, String::new()));
136        let has_docs = item["docs"].as_str().is_some_and(|d| !d.trim().is_empty());
137
138        surface.items.insert(
139            full,
140            ItemInfo {
141                kind: kind.to_string(),
142                fingerprint,
143                signature,
144                has_docs,
145            },
146        );
147    }
148
149    alias_reexports(index, paths, &mut surface);
150    index_members(index, paths, source_root, &mut file_cache, &mut surface);
151    surface
152}
153
154/// Register the pub methods and associated consts of inherent impls under
155/// `Type::name`, for the type's canonical path and every re-export alias of it.
156fn index_members(
157    index: &serde_json::Map<String, Value>,
158    paths: &serde_json::Map<String, Value>,
159    source_root: &Path,
160    file_cache: &mut HashMap<String, Vec<String>>,
161    surface: &mut Surface,
162) {
163    for item in index.values() {
164        let imp = &item["inner"]["impl"];
165        if !imp.is_object() || imp["trait"].is_object() {
166            continue;
167        }
168        let Some(type_id) = imp["for"]["resolved_path"]["id"].as_u64() else {
169            continue;
170        };
171        let Some(type_path) = item_path(paths, &type_id.to_string()) else {
172            continue;
173        };
174        let Some(owner) = surface.items.get(&type_path) else {
175            continue;
176        };
177        let spellings: Vec<String> = surface
178            .items
179            .iter()
180            .filter(|(_, info)| info.kind == owner.kind && info.fingerprint == owner.fingerprint)
181            .map(|(path, _)| path.clone())
182            .collect();
183
184        let members = imp["items"].as_array().into_iter().flatten();
185        for member in members.filter_map(|id| index.get(&id.as_u64()?.to_string())) {
186            if member["visibility"] != "public" {
187                continue;
188            }
189            let (kind, name) = match (
190                member["inner"]["function"].is_object(),
191                member["inner"]["assoc_const"].is_object(),
192                member["name"].as_str(),
193            ) {
194                (true, _, Some(name)) => ("function", name),
195                (_, true, Some(name)) => ("constant", name),
196                _ => continue,
197            };
198            let (fingerprint, signature) =
199                span_fingerprint(&member["span"], kind, source_root, file_cache)
200                    .unwrap_or((0, String::new()));
201            let info = ItemInfo {
202                kind: kind.to_string(),
203                fingerprint,
204                signature,
205                has_docs: member["docs"]
206                    .as_str()
207                    .is_some_and(|d| !d.trim().is_empty()),
208            };
209            for spelling in &spellings {
210                surface
211                    .members
212                    .insert(format!("{spelling}::{name}"), info.clone());
213            }
214        }
215    }
216}
217
218/// Register every `pub use inner::item;` re-export under an additional key:
219/// the *importing* module's path + the local alias name.
220///
221/// rustdoc's `paths` map gives each item its structural definition site,
222/// which for a private-submodule-plus-flattening `pub use` (e.g. `mod sma;
223/// pub use sma::sma;`) runs through a module that's never itself `pub` — so
224/// rustdoc reports `myc::indicators::sma::sma` even though only
225/// `myc::indicators::sma` is externally valid. Bind markers, `covers = "..."`
226/// tags, and doc prose all read naturally as the flattened path a consumer
227/// would actually import, so both spellings are registered (see
228/// `Surface::grouped` for how counting avoids double-charging the alias).
229fn alias_reexports(
230    index: &serde_json::Map<String, Value>,
231    paths: &serde_json::Map<String, Value>,
232    surface: &mut Surface,
233) {
234    for (mod_id, item) in index {
235        if item["visibility"] != "public" {
236            continue;
237        }
238        let Some(children) = item["inner"]["module"]["items"].as_array() else {
239            continue;
240        };
241        let Some(mod_path) = item_path(paths, mod_id) else {
242            continue;
243        };
244
245        for child in children {
246            let Some(child_id) = child.as_u64() else {
247                continue;
248            };
249            let child_id = child_id.to_string();
250            let Some(use_) = index.get(&child_id).map(|c| &c["inner"]["use"]) else {
251                continue;
252            };
253            let Some(target_id) = use_["id"].as_u64() else {
254                continue;
255            };
256            // A named re-export (`pub use mod::item;`) aliases exactly one
257            // item; a glob (`pub use mod::*;`) — the near-universal way this
258            // codebase flattens a whole file-per-item submodule — aliases
259            // every one of the target module's own children under its own
260            // name instead.
261            let targets: Vec<(String, u64)> = if use_["is_glob"].as_bool().unwrap_or(false) {
262                glob_targets(index, target_id)
263            } else {
264                match use_["name"].as_str() {
265                    Some(name) => vec![(name.to_string(), target_id)],
266                    None => continue,
267                }
268            };
269
270            for (name, target_id) in targets {
271                alias_one(paths, surface, &mod_path, &name, target_id);
272            }
273        }
274    }
275}
276
277/// Every direct child of a glob-imported module, as `(its own name, id)` —
278/// what `pub use module::*;` actually brings into scope.
279fn glob_targets(index: &serde_json::Map<String, Value>, module_id: u64) -> Vec<(String, u64)> {
280    let Some(children) = index
281        .get(&module_id.to_string())
282        .and_then(|m| m["inner"]["module"]["items"].as_array())
283    else {
284        return Vec::new();
285    };
286    children
287        .iter()
288        .filter_map(|c| {
289            let id = c.as_u64()?;
290            let name = index.get(&id.to_string())?["name"].as_str()?;
291            Some((name.to_string(), id))
292        })
293        .collect()
294}
295
296/// Register one alias (`{mod_path}::{name}` -> the target's info) alongside
297/// its canonical entry, never replacing it — existing bind markers and
298/// `covers=` tags may reference either spelling, so both must keep
299/// resolving. `Surface::grouped` handles deduping for consumer-facing counts.
300fn alias_one(
301    paths: &serde_json::Map<String, Value>,
302    surface: &mut Surface,
303    mod_path: &str,
304    name: &str,
305    target_id: u64,
306) {
307    let Some(target_path) = item_path(paths, &target_id.to_string()) else {
308        return;
309    };
310    let Some(info) = surface.items.get(&target_path).cloned() else {
311        return; // target wasn't a `KINDS` item (or was private/unaddressable)
312    };
313    let alias = format!("{mod_path}::{name}");
314    if alias == target_path {
315        return;
316    }
317    surface.items.entry(alias).or_insert(info);
318}
319
320fn item_path(paths: &serde_json::Map<String, Value>, id: &str) -> Option<String> {
321    let segs = paths.get(id)?["path"].as_array()?;
322    Some(
323        segs.iter()
324            .filter_map(Value::as_str)
325            .collect::<Vec<_>>()
326            .join("::"),
327    )
328}
329
330/// Kinds whose braces enclose part of the public contract rather than an
331/// implementation: every enum variant, and a struct's or union's `pub`
332/// fields.
333const MEMBER_KINDS: [&str; 3] = ["struct", "enum", "union"];
334
335fn span_fingerprint(
336    span: &Value,
337    kind: &str,
338    root: &Path,
339    cache: &mut HashMap<String, Vec<String>>,
340) -> Option<(u64, String)> {
341    let filename = span["filename"].as_str()?;
342    let begin = span["begin"].as_array()?;
343    let end = span["end"].as_array()?;
344    let (bl, el) = (begin[0].as_u64()? as usize, end[0].as_u64()? as usize);
345
346    let lines = match cache.get(filename) {
347        Some(l) => l,
348        None => {
349            // Strict root-relative read: a CWD fallback would silently read
350            // the wrong tree when fingerprinting another worktree's build.
351            let abs = root.join(filename);
352            let text = std::fs::read_to_string(&abs).ok()?;
353            cache.insert(
354                filename.to_string(),
355                text.lines().map(str::to_string).collect(),
356            );
357            &cache[filename]
358        }
359    };
360    if bl == 0 || el > lines.len() || bl > el {
361        return None;
362    }
363    let span_text = lines[bl - 1..el].join("\n");
364    let normalized = normalize(&crate::comments::strip(&span_text));
365    // Doc comments are part of the fingerprint (prose is bound to them)
366    // but not of the signature, where they would hide a member's `pub`.
367    let bare = normalize(&crate::comments::strip_all(&span_text));
368    let sig_end = bare
369        .find('{')
370        .or_else(|| bare.find(';'))
371        .unwrap_or(bare.len());
372    let declaration = bare[..sig_end].trim();
373    let signature = if MEMBER_KINDS.contains(&kind) {
374        member_signature(declaration, &bare[sig_end..], kind == "enum")
375    } else {
376        declaration.to_string()
377    };
378    Some((soothfast_registry::fnv1a(normalized.as_bytes()), signature))
379}
380
381fn normalize(source: &str) -> String {
382    source.split_whitespace().collect::<Vec<_>>().join(" ")
383}
384
385/// Declaration plus the members a consumer can name: every variant of an
386/// enum, only the `pub` fields of a struct or union. A private field edit is
387/// a body change, a public one is not.
388fn member_signature(declaration: &str, rest: &str, all_public: bool) -> String {
389    let Some(body) = rest
390        .strip_prefix('{')
391        .and_then(|inner| inner.trim_end().strip_suffix('}'))
392    else {
393        return declaration.to_string();
394    };
395    let members: Vec<String> = split_top_level(body)
396        .into_iter()
397        .map(|member| strip_attributes(member.trim()).to_string())
398        .filter(|member| !member.is_empty() && (all_public || member.starts_with("pub ")))
399        .collect();
400    format!("{declaration} {{ {} }}", members.join(", "))
401}
402
403/// Split on commas outside brackets, so `HashMap<K, V>` stays one member.
404/// The `>` of a `->` return arrow is not a bracket.
405fn split_top_level(text: &str) -> Vec<&str> {
406    let mut parts = Vec::new();
407    let mut depth = 0i32;
408    let mut start = 0;
409    let mut prev = ' ';
410    for (i, c) in text.char_indices() {
411        match c {
412            '<' | '(' | '[' | '{' => depth += 1,
413            '>' if prev == '-' => {}
414            '>' | ')' | ']' | '}' => depth -= 1,
415            ',' if depth == 0 => {
416                parts.push(&text[start..i]);
417                start = i + 1;
418            }
419            _ => {}
420        }
421        prev = c;
422    }
423    parts.push(&text[start..]);
424    parts
425}
426
427/// Drop leading `#[...]` attributes so `#[serde(rename = "x")] pub a: u8`
428/// is recognised as public.
429fn strip_attributes(member: &str) -> &str {
430    let mut rest = member;
431    while let Some(after) = rest.strip_prefix("#[") {
432        let mut depth = 1;
433        let mut end = None;
434        for (i, c) in after.char_indices() {
435            depth += match c {
436                '[' => 1,
437                ']' => -1,
438                _ => 0,
439            };
440            if depth == 0 {
441                end = Some(i + 1);
442                break;
443            }
444        }
445        match end {
446            Some(e) => rest = after[e..].trim_start(),
447            None => break,
448        }
449    }
450    rest
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use serde_json::json;
457
458    /// A rustdoc JSON slice for `mod sma;` (private) + `pub use sma::sma;`
459    /// (flattening): the canonical path runs through the private submodule.
460    fn private_submodule_reexport_doc() -> Value {
461        json!({
462            "index": {
463                "1": {
464                    "visibility": "public",
465                    "inner": { "module": { "items": [2, 3] } },
466                },
467                "2": {
468                    "name": "sma",
469                    "visibility": "public",
470                    "inner": { "function": {} },
471                    "span": { "filename": "src/sma.rs", "begin": [1, 0], "end": [1, 0] },
472                    "docs": "Simple moving average.",
473                },
474                "3": {
475                    "visibility": "public",
476                    "inner": {
477                        "use": { "source": "sma::sma", "name": "sma", "id": 2, "is_glob": false },
478                    },
479                },
480            },
481            "paths": {
482                "1": { "path": ["myc", "indicators"], "kind": "module" },
483                "2": { "path": ["myc", "indicators", "sma", "sma"], "kind": "function" },
484            },
485        })
486    }
487
488    #[test]
489    fn reexport_alias_resolves_alongside_canonical_path() {
490        let dir =
491            std::env::temp_dir().join(format!("soothfast-surface-test-{}", std::process::id()));
492        std::fs::create_dir_all(dir.join("src")).unwrap();
493        std::fs::write(dir.join("src/sma.rs"), "pub fn sma() {}\n").unwrap();
494
495        let doc = private_submodule_reexport_doc();
496        let surface = from_rustdoc(&doc, &dir);
497
498        assert!(surface.items.contains_key("myc::indicators::sma::sma"));
499        assert!(
500            surface.items.contains_key("myc::indicators::sma"),
501            "expected the flattened re-export path to resolve too: {:?}",
502            surface.items.keys().collect::<Vec<_>>()
503        );
504        let canonical = &surface.items["myc::indicators::sma::sma"];
505        let alias = &surface.items["myc::indicators::sma"];
506        assert_eq!(canonical.fingerprint, alias.fingerprint);
507        assert_eq!(alias.kind, "function");
508
509        let _ = std::fs::remove_dir_all(&dir);
510    }
511
512    #[test]
513    fn glob_reexport_aliases_every_child_by_its_own_name() {
514        // `mod sma; pub use sma::*;`, vs. the single-item form above.
515        let mut doc = private_submodule_reexport_doc();
516        doc["index"]["3"]["inner"]["use"] =
517            json!({ "source": "sma", "name": "sma", "id": 7, "is_glob": true });
518        doc["index"]["7"] = json!({
519            "visibility": "public",
520            "inner": { "module": { "items": [2] } },
521        });
522
523        let dir =
524            std::env::temp_dir().join(format!("soothfast-surface-glob-{}", std::process::id()));
525        std::fs::create_dir_all(dir.join("src")).unwrap();
526        std::fs::write(dir.join("src/sma.rs"), "pub fn sma() {}\n").unwrap();
527
528        let surface = from_rustdoc(&doc, &dir);
529        assert!(surface.items.contains_key("myc::indicators::sma::sma"));
530        assert!(surface.items.contains_key("myc::indicators::sma"));
531
532        let _ = std::fs::remove_dir_all(&dir);
533    }
534
535    fn impl_doc() -> Value {
536        let span =
537            |line: u64| json!({ "filename": "src/lib.rs", "begin": [line, 0], "end": [line, 0] });
538        json!({
539            "index": {
540                "1": { "visibility": "public", "inner": { "module": { "items": [2, 20] } } },
541                "2": {
542                    "name": "Rewriter",
543                    "visibility": "public",
544                    "inner": { "struct": {} },
545                    "span": span(1),
546                },
547                "3": {
548                    "visibility": "default",
549                    "inner": { "impl": {
550                        "trait": null,
551                        "for": { "resolved_path": { "id": 2 } },
552                        "items": [4, 5, 6],
553                    } },
554                },
555                "4": {
556                    "name": "rewrite",
557                    "visibility": "public",
558                    "inner": { "function": {} },
559                    "span": span(2),
560                },
561                "5": {
562                    "name": "hidden",
563                    "visibility": "default",
564                    "inner": { "function": {} },
565                    "span": span(3),
566                },
567                "6": {
568                    "name": "LIMIT",
569                    "visibility": "public",
570                    "inner": { "assoc_const": {} },
571                    "span": span(4),
572                },
573                "8": {
574                    "visibility": "default",
575                    "inner": { "impl": {
576                        "trait": { "path": "Default" },
577                        "for": { "resolved_path": { "id": 2 } },
578                        "items": [7],
579                    } },
580                },
581                "7": {
582                    "name": "default",
583                    "visibility": "public",
584                    "inner": { "function": {} },
585                    "span": span(5),
586                },
587                "20": {
588                    "visibility": "public",
589                    "inner": { "use": { "name": "Rewriter", "id": 2, "is_glob": false } },
590                },
591            },
592            "paths": {
593                "1": { "path": ["rw"], "kind": "module" },
594                "2": { "path": ["rw", "engine", "Rewriter"], "kind": "struct" },
595            },
596        })
597    }
598
599    #[test]
600    fn inherent_pub_members_resolve_under_the_type_path() {
601        let dir =
602            std::env::temp_dir().join(format!("soothfast-surface-impl-{}", std::process::id()));
603        std::fs::create_dir_all(dir.join("src")).unwrap();
604        std::fs::write(
605            dir.join("src/lib.rs"),
606            "pub struct Rewriter;\n    pub fn rewrite() {}\n    fn hidden() {}\n    pub const LIMIT: u8 = 1;\n    fn default() {}\n",
607        )
608        .unwrap();
609
610        let surface = from_rustdoc(&impl_doc(), &dir);
611
612        let method = surface.lookup("rw::engine::Rewriter::rewrite").unwrap();
613        assert_eq!(method.kind, "function");
614        assert_ne!(method.fingerprint, 0);
615        assert_eq!(
616            surface.lookup("rw::engine::Rewriter::LIMIT").unwrap().kind,
617            "constant"
618        );
619        assert!(surface.lookup("rw::engine::Rewriter::hidden").is_none());
620        assert!(surface.lookup("rw::engine::Rewriter::default").is_none());
621        assert!(surface.items.keys().all(|k| !k.ends_with("::rewrite")));
622        assert_eq!(
623            surface.lookup("rw::Rewriter::rewrite").unwrap().fingerprint,
624            method.fingerprint
625        );
626
627        let _ = std::fs::remove_dir_all(&dir);
628    }
629
630    fn item(kind: &str, fingerprint: u64) -> ItemInfo {
631        ItemInfo {
632            kind: kind.to_string(),
633            fingerprint,
634            signature: String::new(),
635            has_docs: false,
636        }
637    }
638
639    fn surface_of(items: Vec<(&str, ItemInfo)>) -> Surface {
640        Surface {
641            items: items.into_iter().map(|(p, i)| (p.to_string(), i)).collect(),
642            ..Surface::default()
643        }
644    }
645
646    #[test]
647    fn three_aliases_of_one_function_collapse_to_one_group() {
648        let surf = surface_of(vec![
649            ("myc::atr", item("function", 42)),
650            ("myc::indicators::atr", item("function", 42)),
651            ("myc::indicators::atr::atr", item("function", 42)),
652        ]);
653        let groups = surf.grouped();
654        assert_eq!(groups.len(), 1);
655        assert_eq!(groups[0].representative, "myc::atr");
656        assert_eq!(groups[0].aliases.len(), 3);
657    }
658
659    #[test]
660    fn covers_tag_on_a_longer_alias_still_counts_as_measured() {
661        // A `covers=` tag might name the module-level alias even though the
662        // crate-root re-export is shorter — checking "is this covered" must
663        // consult every alias, not just the (display-only) shortest one.
664        let surf = surface_of(vec![
665            ("myc::atr", item("function", 42)),
666            ("myc::indicators::atr", item("function", 42)),
667        ]);
668        let g = &surf.grouped()[0];
669        assert!(
670            g.aliases
671                .iter()
672                .any(|a| a.as_str() == "myc::indicators::atr")
673        );
674    }
675
676    #[test]
677    fn distinct_functions_with_no_fingerprint_are_never_merged() {
678        let surf = surface_of(vec![
679            ("myc::a", item("function", 0)),
680            ("myc::b", item("function", 0)),
681        ]);
682        assert_eq!(surf.grouped().len(), 2);
683    }
684
685    #[test]
686    fn different_kinds_with_the_same_fingerprint_are_not_merged() {
687        let surf = surface_of(vec![
688            ("myc::a", item("function", 7)),
689            ("myc::A", item("struct", 7)),
690        ]);
691        assert_eq!(surf.grouped().len(), 2);
692    }
693
694    fn fingerprint_of(tag: &str, source: &str) -> (u64, String) {
695        fingerprint_of_kind(tag, "struct", source)
696    }
697
698    fn fingerprint_of_kind(tag: &str, kind: &str, source: &str) -> (u64, String) {
699        let dir = std::env::temp_dir().join(format!(
700            "soothfast-fingerprint-{}-{tag}",
701            std::process::id()
702        ));
703        std::fs::create_dir_all(&dir).unwrap();
704        std::fs::write(dir.join("lib.rs"), source).unwrap();
705        let span = json!({
706            "filename": "lib.rs",
707            "begin": [1, 0],
708            "end": [source.lines().count(), 0],
709        });
710        let got = span_fingerprint(&span, kind, &dir, &mut HashMap::new()).unwrap();
711        let _ = std::fs::remove_dir_all(&dir);
712        got
713    }
714
715    #[test]
716    fn a_comment_only_edit_does_not_move_the_fingerprint() {
717        let plain = fingerprint_of("plain", "pub struct S {\n    a: u8,\n}\n");
718        let noted = fingerprint_of(
719            "noted",
720            "pub struct S {\n    // widened for the wire format\n    a: u8,\n}\n",
721        );
722        assert_eq!(plain.0, noted.0);
723    }
724
725    #[test]
726    fn a_field_change_still_moves_the_fingerprint() {
727        let before = fingerprint_of("before", "pub struct S {\n    a: u8,\n}\n");
728        let after = fingerprint_of("after", "pub struct S {\n    a: u16,\n}\n");
729        assert_ne!(before.0, after.0);
730    }
731
732    #[test]
733    fn a_struct_field_addition_changes_the_signature() {
734        let before = fingerprint_of_kind(
735            "sig-before",
736            "struct",
737            "pub struct S {\n    pub a: u8,\n}\n",
738        );
739        let after = fingerprint_of_kind(
740            "sig-after",
741            "struct",
742            "pub struct S {\n    pub a: u8,\n    pub b: u8,\n}\n",
743        );
744        assert_ne!(before.1, after.1);
745        assert_eq!(after.1, "pub struct S { pub a: u8, pub b: u8 }");
746    }
747
748    #[test]
749    fn a_private_field_edit_is_body_only() {
750        let before = fingerprint_of_kind(
751            "priv-before",
752            "struct",
753            "pub struct S {\n    pub a: u8,\n    b: u8,\n}\n",
754        );
755        let after = fingerprint_of_kind(
756            "priv-after",
757            "struct",
758            "pub struct S {\n    pub a: u8,\n    b: u16,\n    c: u8,\n}\n",
759        );
760        assert_ne!(before.0, after.0);
761        assert_eq!(before.1, after.1);
762        assert_eq!(before.1, "pub struct S { pub a: u8 }");
763    }
764
765    #[test]
766    fn generic_fields_and_attributes_are_read_as_members() {
767        let got = fingerprint_of_kind(
768            "generic",
769            "struct",
770            "pub struct S {\n    #[serde(rename = \"m\")]\n    pub map: HashMap<String, Vec<u8>>,\n    pub(crate) hidden: u8,\n}\n",
771        );
772        assert_eq!(got.1, "pub struct S { pub map: HashMap<String, Vec<u8>> }");
773    }
774
775    #[test]
776    fn a_documented_public_field_counts_as_signature() {
777        let before = fingerprint_of_kind(
778            "pubdoc-before",
779            "struct",
780            "pub struct S {\n    /// count\n    pub a: u8,\n}\n",
781        );
782        let after = fingerprint_of_kind(
783            "pubdoc-after",
784            "struct",
785            "pub struct S {\n    /// count\n    pub a: u8,\n    /// total\n    pub b: u8,\n}\n",
786        );
787        assert_ne!(before.1, after.1);
788        assert_eq!(after.1, "pub struct S { pub a: u8, pub b: u8 }");
789    }
790
791    #[test]
792    fn a_variant_doc_edit_moves_the_fingerprint_but_not_the_signature() {
793        let before = fingerprint_of_kind(
794            "vdoc-before",
795            "enum",
796            "pub enum E {\n    /// first\n    A,\n    B,\n}\n",
797        );
798        let after = fingerprint_of_kind(
799            "vdoc-after",
800            "enum",
801            "pub enum E {\n    /// the first\n    A,\n    B,\n}\n",
802        );
803        assert_ne!(before.0, after.0);
804        assert_eq!(before.1, after.1);
805        assert_eq!(before.1, "pub enum E { A, B }");
806    }
807
808    #[test]
809    fn a_function_pointer_field_does_not_swallow_its_neighbours() {
810        let got = fingerprint_of_kind(
811            "fnptr",
812            "struct",
813            "pub struct S {\n    pub f: fn(u8) -> u8,\n    pub g: u8,\n}\n",
814        );
815        assert_eq!(got.1, "pub struct S { pub f: fn(u8) -> u8, pub g: u8 }");
816    }
817
818    #[test]
819    fn an_enum_variant_addition_changes_the_signature() {
820        let before = fingerprint_of_kind("var-before", "enum", "pub enum E {\n    A,\n}\n");
821        let after = fingerprint_of_kind("var-after", "enum", "pub enum E {\n    A,\n    B,\n}\n");
822        assert_ne!(before.1, after.1);
823    }
824
825    #[test]
826    fn a_function_body_edit_keeps_its_signature() {
827        let before = fingerprint_of_kind("fn-before", "function", "pub fn f() -> u8 {\n    1\n}\n");
828        let after = fingerprint_of_kind("fn-after", "function", "pub fn f() -> u8 {\n    2\n}\n");
829        assert_ne!(before.0, after.0);
830        assert_eq!(before.1, after.1);
831        assert_eq!(before.1, "pub fn f() -> u8");
832    }
833
834    #[test]
835    fn a_doc_comment_on_a_field_is_part_of_the_fingerprint() {
836        let before = fingerprint_of(
837            "doc-before",
838            "pub struct S {\n    /// count\n    a: u8,\n}\n",
839        );
840        let after = fingerprint_of(
841            "doc-after",
842            "pub struct S {\n    /// total\n    a: u8,\n}\n",
843        );
844        assert_ne!(before.0, after.0);
845    }
846}