Skip to main content

sphinx_ultra/env/
mod.rs

1//! Serialized `BuildEnvironment` — the persistent build-state record that
2//! mirrors Sphinx's `BuildEnvironment` (`environment/__init__.py`), scoped to
3//! the fields this wave's read-and-resolve phase populates (see
4//! `docs/superpowers/plans/2026-08-31-m2-wave4-research-spec-sphinx-env-toctree-domains.md`
5//! §1 for the full attribute-by-attribute mapping this struct is drawn
6//! from).
7//!
8//! Persisted as bincode (`bincode::serde` + `bincode::config::standard()`,
9//! the same config [`crate::doctree::to_bincode`]/`from_bincode` use) to
10//! `<cache_dir>/env.bin`. That file lives inside the cache directory
11//! governed by the `.config-fingerprint` wipe protocol in `src/cache.rs`: a
12//! change to a content-bearing configuration value nukes the whole cache
13//! dir, `env.bin` included — which is the desired behavior, since the
14//! environment was built under the old configuration. Note that this is
15//! *broader* than Sphinx, which only invalidates its environment for config
16//! values with rebuild class `'env'` and never deletes its doctrees; the
17//! filter in `builder::EXCLUDED_FROM_FINGERPRINT` keeps at least the purely
18//! operational flags (`-W`, `-n`) from triggering it.
19//!
20//! Every collection here is a `BTreeMap`/`BTreeSet` rather than the
21//! `Hash*` equivalent so that bincode bytes and [`BuildEnvironment::snapshot`]
22//! output are deterministic across runs and processes.
23
24pub mod dependencies;
25pub mod genindex;
26pub mod metadata;
27pub mod numbers;
28pub mod objects;
29pub mod py_domain;
30pub mod resolve;
31pub mod std_domain;
32pub mod toctree;
33
34use std::collections::{BTreeMap, BTreeSet};
35use std::path::{Path, PathBuf};
36
37use serde::{Deserialize, Serialize};
38use serde_json::{json, Map as JsonMap, Value as JsonValue};
39
40use crate::doctree::Node;
41
42// Seed data harvested from M1 create_standard_domains (deleted): the std/py
43// object-type -> role tables, kept here verbatim for Task 8 to fold into the
44// real `std`/`py` domain implementations that populate `StdDomainData`.
45//
46// py domain object types -> roles (lname == the type name itself):
47//   module    -> [mod, obj]
48//   function  -> [func, obj]
49//   class     -> [class, obj]
50//   method    -> [meth, obj]
51//   attribute -> [attr, obj]
52//   exception -> [exc, obj]
53//   data      -> [data, obj]
54//
55// std domain object types -> roles:
56//   doc       -> [doc]       (lname: "document")
57//   label     -> [ref]       (lname: "label")
58//   term      -> [term]      (lname: "term")
59//   cmdoption -> [option]    (lname: "command line option")
60//   envvar    -> [envvar]    (lname: "environment variable")
61
62/// Bumped whenever the on-disk shape of [`BuildEnvironment`] changes.
63/// [`BuildEnvironment::load`] discards (returns `None` for) any file whose
64/// stored `version` doesn't match current — mirroring Sphinx's own
65/// `ENV_VERSION` check, where a stale environment is simply rebuilt from
66/// scratch rather than partially trusted.
67///
68/// Version 3: wave 4.5's `Span` change (a `line` provenance field) alters
69/// the shape of every serialized `Node` tree in `tocs`/`titles`. The same
70/// wave later added the `py` field; a v3 `env.bin` written before it fails
71/// to decode (the trailing bytes don't parse as a `PyDomainData`) and is
72/// rebuilt — only dev builds of this branch ever wrote one, so no second
73/// bump.
74pub const ENV_VERSION: u32 = 3;
75
76/// The `env.bin` filename inside a build's cache directory.
77const ENV_FILENAME: &str = "env.bin";
78
79pub use py_domain::PyDomainData;
80pub use std_domain::StdDomainData;
81
82/// One entry harvested from a document's `index` nodes, as recorded in
83/// Sphinx's `env.domaindata['index']['entries'][docname]`. `main` mirrors
84/// Sphinx's literal `'main'`/`''` marker string as a bool; [`BuildEnvironment::snapshot`]
85/// converts it back to that string form to match the oracle fixture shape.
86///
87/// Collected by [`genindex::process_doc`]; consumed by
88/// [`genindex::create_index`].
89#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
90pub struct IndexEntryRecord {
91    pub entry_type: String,
92    pub value: String,
93    pub target_id: String,
94    pub main: bool,
95    pub category_key: Option<String>,
96}
97
98/// What [`BuildEnvironment::get_outdated_files`] needs to know about the
99/// filesystem, as the three questions it actually asks — so the computation
100/// itself stays pure and the caller decides what "the source of `docname`"
101/// and "the doctree of `docname`" mean.
102///
103/// Times are microseconds since the Unix epoch, the unit `all_docs` stores
104/// (Sphinx's `_last_modified_time`). `None` means "cannot be stated" —
105/// the file is gone, or unstat-able — which is Sphinx's `except OSError:
106/// return True`: the document is outdated.
107pub struct FileTimes<'a> {
108    /// Modification time of the document's own source file.
109    pub source_modified_us: &'a dyn Fn(&str) -> Option<u64>,
110    /// Whether the document's persisted doctree is on disk. Sphinx stats
111    /// `doctreedir/<docname>.doctree`; a doctree that exists but can no
112    /// longer be *read* is caught later, when the read phase tries to load
113    /// it, and re-read then.
114    pub doctree_exists: &'a dyn Fn(&str) -> bool,
115    /// Modification time of one of a document's `dependencies` entries.
116    pub dependency_modified_us: &'a dyn Fn(&Path) -> Option<u64>,
117}
118
119/// The three sets `env.get_outdated_files` splits the project into
120/// (`environment/__init__.py:521-554`).
121///
122/// `added` and `changed` are both read; they are kept apart because Sphinx
123/// keeps them apart — the distinction drives the glob-toctree rule below
124/// and the `%s added, %s changed, %s removed` progress line.
125#[derive(Debug, Clone, Default, PartialEq, Eq)]
126pub struct Outdated {
127    /// Documents the environment has never seen.
128    pub added: BTreeSet<String>,
129    /// Documents whose recorded read is no longer good enough.
130    pub changed: BTreeSet<String>,
131    /// Documents the environment knows that the project no longer has.
132    /// Each must be [`BuildEnvironment::clear_doc`]'d.
133    pub removed: BTreeSet<String>,
134}
135
136impl Outdated {
137    /// The documents this build has to read: `added | changed`.
138    pub fn to_read(&self) -> BTreeSet<String> {
139        self.added.union(&self.changed).cloned().collect()
140    }
141}
142
143/// Persistent build-state record: the subset of Sphinx's `BuildEnvironment`
144/// attributes this wave's read-and-resolve phase populates. See the module
145/// doc comment for the persistence protocol.
146#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
147pub struct BuildEnvironment {
148    /// Format version of this serialized record. [`BuildEnvironment::save`]
149    /// always stamps [`ENV_VERSION`] here; [`BuildEnvironment::load`]
150    /// discards (returns `None` for) anything else.
151    pub version: u32,
152    /// Sphinx's `config.root_doc`: the document every whole-project walk of
153    /// the toctree graph starts from ([`toctree::collect_relations`]) and
154    /// the one document [`toctree::check_consistency`] never calls an
155    /// orphan. Empty in a [`BuildEnvironment::default`]; the build stamps
156    /// it from the configuration.
157    pub root_doc: String,
158    /// docname -> read time, in microseconds since the Unix epoch.
159    pub all_docs: BTreeMap<String, u64>,
160    /// docname -> absolute paths the document depends on (via `include`,
161    /// literalinclude, etc.).
162    pub dependencies: BTreeMap<String, BTreeSet<PathBuf>>,
163    /// docname -> docnames it textually includes (docutils `include`).
164    pub included: BTreeMap<String, BTreeSet<String>>,
165    /// docnames that must always be re-read (e.g. they use `today`/`now`).
166    pub reread_always: BTreeSet<String>,
167    /// docname -> its bibliographic field list (`:orphan:`, `:tocdepth:`,
168    /// ...), per [`metadata::document_metadata`].
169    pub metadata: BTreeMap<String, BTreeMap<String, String>>,
170    pub titles: BTreeMap<String, Node>,
171    pub longtitles: BTreeMap<String, Node>,
172    /// docname -> that document's local table of contents, doctree-shaped
173    /// (a `bullet_list` node, mirroring Sphinx's `env.tocs`).
174    pub tocs: BTreeMap<String, Node>,
175    pub toc_num_entries: BTreeMap<String, u32>,
176    /// docname -> (anchorname -> section-number tuple). `anchorname` is `''`
177    /// for a document's own top entry, else `'#<id>'`.
178    pub toc_secnumbers: BTreeMap<String, BTreeMap<String, Vec<u32>>>,
179    /// docname -> (figtype -> (figure id -> figure-number tuple)).
180    pub toc_fignumbers: BTreeMap<String, BTreeMap<String, BTreeMap<String, Vec<u32>>>>,
181    /// docname -> docnames its toctree(s) directly include.
182    pub toctree_includes: BTreeMap<String, Vec<String>>,
183    /// included-docname -> docnames whose toctree includes it (the reverse
184    /// of `toctree_includes`; used to know what to rebuild when a doc
185    /// changes).
186    pub files_to_rebuild: BTreeMap<String, BTreeSet<String>>,
187    pub glob_toctrees: BTreeSet<String>,
188    pub numbered_toctrees: BTreeSet<String>,
189    pub std: StdDomainData,
190    /// The python domain's registries (`domaindata['py']`), insertion-
191    /// ordered — see [`PyDomainData`] for why the order is data.
192    pub py: PyDomainData,
193    /// docname -> its `.. index::` entries, in document order.
194    pub index_entries: BTreeMap<String, Vec<IndexEntryRecord>>,
195}
196
197impl BuildEnvironment {
198    /// Load a previously saved environment from `<cache_dir>/env.bin`.
199    ///
200    /// Returns `None` if the file is missing, fails to decode, or was
201    /// written by a different [`ENV_VERSION`] — in every case the caller's
202    /// correct fallback is a fresh [`BuildEnvironment::default`], exactly
203    /// like Sphinx discarding an incompatible `environment.pickle`.
204    pub fn load(cache_dir: &Path) -> Option<Self> {
205        let bytes = std::fs::read(cache_dir.join(ENV_FILENAME)).ok()?;
206        let (env, _consumed): (Self, usize) =
207            bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).ok()?;
208        if env.version != ENV_VERSION {
209            return None;
210        }
211        Some(env)
212    }
213
214    /// Save this environment to `<cache_dir>/env.bin`, creating `cache_dir`
215    /// if needed. Always stamps [`ENV_VERSION`] into the persisted bytes
216    /// (regardless of `self.version`'s current in-memory value), so callers
217    /// never need to remember to set it before saving.
218    ///
219    /// The in-memory `version` is only updated once the write has actually
220    /// succeeded: a failed save must not leave the caller holding an
221    /// environment that claims to have been written at the current version.
222    pub fn save(&mut self, cache_dir: &Path) -> anyhow::Result<()> {
223        let previous = std::mem::replace(&mut self.version, ENV_VERSION);
224        let write = || -> anyhow::Result<()> {
225            std::fs::create_dir_all(cache_dir)?;
226            let bytes = bincode::serde::encode_to_vec(&*self, bincode::config::standard())?;
227            std::fs::write(cache_dir.join(ENV_FILENAME), bytes)?;
228            Ok(())
229        };
230        match write() {
231            Ok(()) => Ok(()),
232            Err(e) => {
233                self.version = previous;
234                Err(e)
235            }
236        }
237    }
238
239    /// Split `found` — the documents the project currently has — into what
240    /// this build must read, and what it must forget.
241    ///
242    /// Port of `BuildEnvironment.get_outdated_files`
243    /// (`environment/__init__.py:521-554`) together with the two steps
244    /// `Builder.read` wraps around it (`builders/__init__.py:477-491`): a
245    /// changed configuration re-reads everything, and adding or removing
246    /// *any* file re-reads every document with a globbed toctree, whose
247    /// entry list depends on which files exist rather than on its own text.
248    ///
249    /// Sphinx's `env-get-outdated` event — an extension's chance to add its
250    /// own outdated documents — has no counterpart here; there are no
251    /// extensions with read-phase state yet.
252    pub fn get_outdated_files(
253        &self,
254        found: &BTreeSet<String>,
255        config_changed: bool,
256        times: &FileTimes<'_>,
257    ) -> Outdated {
258        let mut outdated = Outdated {
259            removed: self
260                .all_docs
261                .keys()
262                .filter(|docname| !found.contains(docname.as_str()))
263                .cloned()
264                .collect(),
265            ..Default::default()
266        };
267
268        if config_changed {
269            // Sphinx: `added = found_docs`, `changed` left empty — every
270            // document is new as far as the old environment is concerned.
271            outdated.added = found.clone();
272            return outdated;
273        }
274
275        for docname in found {
276            if !self.all_docs.contains_key(docname) {
277                outdated.added.insert(docname.clone());
278            } else if self.has_doc_changed(docname, times) {
279                outdated.changed.insert(docname.clone());
280            }
281        }
282
283        if !outdated.added.is_empty() || !outdated.removed.is_empty() {
284            for docname in &self.glob_toctrees {
285                if found.contains(docname) && !outdated.added.contains(docname) {
286                    outdated.changed.insert(docname.clone());
287                }
288            }
289        }
290
291        outdated
292    }
293
294    /// `BuildEnvironment._has_doc_changed` (`environment/__init__.py:849-911`)
295    /// for a document the environment already knows: the first of Sphinx's
296    /// four reasons that holds wins.
297    fn has_doc_changed(&self, docname: &str, times: &FileTimes<'_>) -> bool {
298        if self.reread_always.contains(docname) {
299            return true;
300        }
301        if !(times.doctree_exists)(docname) {
302            return true;
303        }
304        let Some(&read_time) = self.all_docs.get(docname) else {
305            return true;
306        };
307        match (times.source_modified_us)(docname) {
308            None => return true,
309            Some(modified) if modified > read_time => return true,
310            Some(_) => {}
311        }
312        // Every dependency is compared against the time the *document* was
313        // read, not against the document's own mtime: a file that changed
314        // after the read invalidates it however old the document is.
315        for dependency in self.dependencies.get(docname).into_iter().flatten() {
316            match (times.dependency_modified_us)(dependency) {
317                None => return true,
318                Some(modified) if modified > read_time => return true,
319                Some(_) => {}
320            }
321        }
322        false
323    }
324
325    /// Remove every trace of `docname` from the environment — the Rust
326    /// mirror of Sphinx's `BuildEnvironment.clear_doc` *plus* every
327    /// `EnvironmentCollector.clear_doc`/`Domain.clear_doc` that fires
328    /// alongside it via the `env-purge-doc` event (Sphinx dispatches these
329    /// separately; here they're one method since there's no event bus).
330    /// See `environment/__init__.py:412` (base), `environment/collectors/
331    /// toctree.py:30` (toctree fields + `files_to_rebuild`),
332    /// `environment/collectors/title.py:23` (titles/longtitles),
333    /// `environment/collectors/dependencies.py:24` (dependencies),
334    /// `environment/collectors/metadata.py:22` (metadata),
335    /// `domains/std/__init__.py:896` (std domain), `domains/index.py:41`
336    /// (index entries).
337    pub fn clear_doc(&mut self, docname: &str) {
338        self.all_docs.remove(docname);
339        self.included.remove(docname);
340        self.reread_always.remove(docname);
341        self.dependencies.remove(docname);
342        self.metadata.remove(docname);
343
344        self.titles.remove(docname);
345        self.longtitles.remove(docname);
346
347        self.tocs.remove(docname);
348        self.toc_secnumbers.remove(docname);
349        self.toc_fignumbers.remove(docname);
350        self.toc_num_entries.remove(docname);
351        self.toctree_includes.remove(docname);
352        self.glob_toctrees.remove(docname);
353        self.numbered_toctrees.remove(docname);
354
355        // Sphinx: `for subfn, fnset in list(files_to_rebuild.items()):
356        // fnset.discard(docname); if not fnset: del files_to_rebuild[subfn]`.
357        self.files_to_rebuild.retain(|_, containing| {
358            containing.remove(docname);
359            !containing.is_empty()
360        });
361
362        self.std
363            .progoptions
364            .retain(|_, (fn_, _)| fn_.as_str() != docname);
365        self.std
366            .objects
367            .retain(|_, (fn_, _)| fn_.as_str() != docname);
368        self.std.terms.retain(|_, (fn_, _)| fn_.as_str() != docname);
369        self.std
370            .labels
371            .retain(|_, (fn_, _, _)| fn_.as_str() != docname);
372        self.std
373            .anonlabels
374            .retain(|_, (fn_, _)| fn_.as_str() != docname);
375
376        // `PythonDomain.clear_doc`, dispatched by the same `env-purge-doc`
377        // event (`domains/python/__init__.py:744-751`).
378        self.py.clear_doc(docname);
379
380        self.index_entries.remove(docname);
381    }
382
383    /// A deterministic JSON view of this environment, shaped to line up
384    /// with the `env_differential` oracle fixture (`tests/env_differential.rs`,
385    /// `tests/fixtures/env_differential.json`) so later tasks can diff
386    /// straight against it. `std.objects`/`std.progoptions` use tuple keys,
387    /// which `serde_json` cannot serialize as map keys directly, so those
388    /// (and `index_entries`, whose `main: bool` must become the oracle's
389    /// literal `"main"`/`""` string) are hand-converted into the fixture's
390    /// list/tuple shapes rather than derived via a blanket `to_value(self)`.
391    pub fn snapshot(&self) -> JsonValue {
392        let objects: Vec<JsonValue> = self
393            .std
394            .objects
395            .iter()
396            .map(|((objtype, name), (docname, labelid))| {
397                json!({
398                    "objtype": objtype,
399                    "name": name,
400                    "docname": docname,
401                    "labelid": labelid,
402                })
403            })
404            .collect();
405
406        let progoptions: Vec<JsonValue> = self
407            .std
408            .progoptions
409            .iter()
410            .map(|((program, name), (docname, labelid))| {
411                json!({
412                    "program": program,
413                    "name": name,
414                    "docname": docname,
415                    "labelid": labelid,
416                })
417            })
418            .collect();
419
420        // `domaindata['py']` as record lists, in REGISTRATION order — the
421        // order is oracle data (Sphinx's fuzzy resolution iterates it), so
422        // unlike the std lists these must not be re-sorted. Field names
423        // follow the `ObjectEntry`/`ModuleEntry` tuple fields; T14's
424        // fixture generator records the same shapes.
425        let py_objects: Vec<JsonValue> = self
426            .py
427            .objects
428            .iter()
429            .map(|(name, entry)| {
430                json!({
431                    "name": name,
432                    "docname": entry.docname,
433                    "node_id": entry.node_id,
434                    "objtype": entry.objtype,
435                    "aliased": entry.aliased,
436                })
437            })
438            .collect();
439
440        let py_modules: Vec<JsonValue> = self
441            .py
442            .modules
443            .iter()
444            .map(|(name, entry)| {
445                json!({
446                    "name": name,
447                    "docname": entry.docname,
448                    "node_id": entry.node_id,
449                    "synopsis": entry.synopsis,
450                    "platform": entry.platform,
451                    "deprecated": entry.deprecated,
452                })
453            })
454            .collect();
455
456        let mut index_entries = JsonMap::new();
457        for (docname, entries) in &self.index_entries {
458            let arr: Vec<JsonValue> = entries
459                .iter()
460                .map(|e| {
461                    json!([
462                        e.entry_type,
463                        e.value,
464                        e.target_id,
465                        if e.main { "main" } else { "" },
466                        e.category_key,
467                    ])
468                })
469                .collect();
470            index_entries.insert(docname.clone(), JsonValue::Array(arr));
471        }
472
473        // `relations` is derived, not stored — exactly like Sphinx's
474        // `collect_relations()`, which recomputes it from the toctree graph
475        // on demand.
476        let relations: JsonMap<String, JsonValue> = toctree::collect_relations(self)
477            .into_iter()
478            .map(|(docname, (parent, prev, next))| (docname, json!([parent, prev, next])))
479            .collect();
480
481        json!({
482            "version": self.version,
483            "root_doc": self.root_doc,
484            "all_docs": self.all_docs,
485            "relations": JsonValue::Object(relations),
486            "metadata": self.metadata,
487            "dependencies": self.dependencies,
488            "included": self.included,
489            "reread_always": self.reread_always,
490            "titles_pformat": pformat_map(&self.titles),
491            "longtitles_pformat": pformat_map(&self.longtitles),
492            "tocs_pformat": pformat_map(&self.tocs),
493            "toc_num_entries": self.toc_num_entries,
494            "toc_secnumbers": self.toc_secnumbers,
495            "toc_fignumbers": self.toc_fignumbers,
496            "toctree_includes": self.toctree_includes,
497            "files_to_rebuild": self.files_to_rebuild,
498            "glob_toctrees": self.glob_toctrees,
499            "numbered_toctrees": self.numbered_toctrees,
500            "std": {
501                "labels": self.std.labels,
502                "anonlabels": self.std.anonlabels,
503                "objects": objects,
504                "progoptions": progoptions,
505                "terms": self.std.terms,
506            },
507            "py_objects": py_objects,
508            "py_modules": py_modules,
509            "index_entries": JsonValue::Object(index_entries),
510        })
511    }
512}
513
514/// docname -> pseudo-XML pformat of a doctree-shaped node, matching the
515/// oracle fixture's `tocs_pformat` string shape.
516fn pformat_map(nodes: &BTreeMap<String, Node>) -> JsonValue {
517    let map: JsonMap<String, JsonValue> = nodes
518        .iter()
519        .map(|(docname, node)| (docname.clone(), JsonValue::String(node.pformat())))
520        .collect();
521    JsonValue::Object(map)
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use crate::doctree::{kinds, Span};
528
529    fn sample_node() -> Node {
530        let mut root = Node::elem(kinds::BULLET_LIST, Span::ZERO);
531        let mut item = Node::elem(kinds::LIST_ITEM, Span::ZERO);
532        item.children
533            .push(Node::text_node("Chapter One", Span::ZERO));
534        root.children.push(item);
535        root
536    }
537
538    fn populated_env() -> BuildEnvironment {
539        let mut env = BuildEnvironment {
540            version: ENV_VERSION,
541            root_doc: "index".to_string(),
542            ..Default::default()
543        };
544        env.all_docs.insert("index".to_string(), 1_700_000_000);
545        env.metadata.insert(
546            "index".to_string(),
547            BTreeMap::from([("orphan".to_string(), String::new())]),
548        );
549        env.dependencies.insert(
550            "index".to_string(),
551            BTreeSet::from([PathBuf::from("/src/index.rst")]),
552        );
553        env.included.insert(
554            "index".to_string(),
555            BTreeSet::from(["chapters/intro".to_string()]),
556        );
557        env.reread_always.insert("index".to_string());
558        env.titles.insert("index".to_string(), sample_node());
559        env.longtitles.insert("index".to_string(), sample_node());
560        env.tocs.insert("index".to_string(), sample_node());
561        env.toc_num_entries.insert("index".to_string(), 3);
562        env.toc_secnumbers.insert(
563            "index".to_string(),
564            BTreeMap::from([(String::new(), vec![1]), ("#sec".to_string(), vec![1, 1])]),
565        );
566        env.toc_fignumbers.insert(
567            "index".to_string(),
568            BTreeMap::from([(
569                "figure".to_string(),
570                BTreeMap::from([("fig1".to_string(), vec![1])]),
571            )]),
572        );
573        env.toctree_includes.insert(
574            "index".to_string(),
575            vec!["chapters/intro".to_string(), "chapters/two".to_string()],
576        );
577        env.files_to_rebuild.insert(
578            "chapters/intro".to_string(),
579            BTreeSet::from(["index".to_string()]),
580        );
581        env.glob_toctrees.insert("index".to_string());
582        env.numbered_toctrees.insert("index".to_string());
583        // std/index entries "owned" by the "index" doc itself (docname is
584        // the value's first component) -- e.g. "index.rst" contains
585        // `.. envvar:: PATH` directly.
586        env.std.labels.insert(
587            "intro".to_string(),
588            (
589                "index".to_string(),
590                "intro-id".to_string(),
591                "Introduction".to_string(),
592            ),
593        );
594        env.std.anonlabels.insert(
595            "intro".to_string(),
596            ("index".to_string(), "intro-id".to_string()),
597        );
598        env.std.objects.insert(
599            ("envvar".to_string(), "PATH".to_string()),
600            ("index".to_string(), "envvar-path".to_string()),
601        );
602        env.std.progoptions.insert(
603            (Some("myprog".to_string()), "--verbose".to_string()),
604            ("index".to_string(), "cmdoption-verbose".to_string()),
605        );
606        env.std.terms.insert(
607            "glossary term".to_string(),
608            ("index".to_string(), "term-glossary-term".to_string()),
609        );
610        // py entries owned by "index" — registered out of alphabetical
611        // order so the snapshot's order-preservation is visible.
612        env.py.note_object(
613            "zeta.func",
614            py_domain::PyObjectEntry {
615                docname: "index".to_string(),
616                node_id: "zeta.func".to_string(),
617                objtype: "function".to_string(),
618                aliased: false,
619            },
620        );
621        env.py.note_object(
622            "alpha.func",
623            py_domain::PyObjectEntry {
624                docname: "index".to_string(),
625                node_id: "alpha.func".to_string(),
626                objtype: "function".to_string(),
627                aliased: true,
628            },
629        );
630        env.py.note_module(
631            "zeta",
632            py_domain::PyModuleEntry {
633                docname: "index".to_string(),
634                node_id: "module-zeta".to_string(),
635                synopsis: "Zed things.".to_string(),
636                platform: "posix".to_string(),
637                deprecated: true,
638            },
639        );
640        env.index_entries.insert(
641            "index".to_string(),
642            vec![IndexEntryRecord {
643                entry_type: "single".to_string(),
644                value: "PATH".to_string(),
645                target_id: "index-0".to_string(),
646                main: true,
647                category_key: None,
648            }],
649        );
650        env
651    }
652
653    #[test]
654    fn round_trip_through_bincode_preserves_node_valued_fields() {
655        let tmp = tempfile::TempDir::new().unwrap();
656        let mut env = populated_env();
657
658        env.save(tmp.path()).expect("save succeeds");
659        let restored = BuildEnvironment::load(tmp.path()).expect("load succeeds");
660
661        assert_eq!(restored, env);
662        // Node-valued fields specifically: bincode round-trips them exactly,
663        // not just "some value under the same key".
664        assert_eq!(restored.titles["index"], sample_node());
665        assert_eq!(restored.tocs["index"].pformat(), sample_node().pformat());
666    }
667
668    #[test]
669    fn save_always_stamps_current_env_version() {
670        let tmp = tempfile::TempDir::new().unwrap();
671        let mut env = BuildEnvironment {
672            version: 0, // stale/uninitialized in-memory value
673            ..Default::default()
674        };
675
676        env.save(tmp.path()).unwrap();
677
678        assert_eq!(env.version, ENV_VERSION);
679        let restored = BuildEnvironment::load(tmp.path()).unwrap();
680        assert_eq!(restored.version, ENV_VERSION);
681    }
682
683    #[test]
684    fn failed_save_leaves_the_in_memory_version_untouched() {
685        // A file where the cache dir should be: create_dir_all fails, so the
686        // environment must not be left claiming it was saved at the current
687        // version.
688        let tmp = tempfile::TempDir::new().unwrap();
689        let blocked = tmp.path().join("not-a-dir");
690        std::fs::write(&blocked, b"").unwrap();
691
692        let mut env = BuildEnvironment {
693            version: 0,
694            ..Default::default()
695        };
696        assert!(env.save(&blocked).is_err());
697        assert_eq!(env.version, 0);
698    }
699
700    #[test]
701    fn load_returns_none_when_file_is_missing() {
702        let tmp = tempfile::TempDir::new().unwrap();
703        assert!(BuildEnvironment::load(tmp.path()).is_none());
704    }
705
706    #[test]
707    fn load_returns_none_on_decode_error() {
708        let tmp = tempfile::TempDir::new().unwrap();
709        std::fs::write(
710            tmp.path().join(ENV_FILENAME),
711            b"not a valid bincode blob at all",
712        )
713        .unwrap();
714        assert!(BuildEnvironment::load(tmp.path()).is_none());
715    }
716
717    #[test]
718    fn load_returns_none_when_version_does_not_match_current() {
719        let tmp = tempfile::TempDir::new().unwrap();
720        let stale = BuildEnvironment {
721            version: ENV_VERSION + 1,
722            ..Default::default()
723        };
724        let bytes = bincode::serde::encode_to_vec(&stale, bincode::config::standard()).unwrap();
725        std::fs::write(tmp.path().join(ENV_FILENAME), bytes).unwrap();
726
727        assert!(BuildEnvironment::load(tmp.path()).is_none());
728    }
729
730    #[test]
731    fn clear_doc_scrubs_every_per_doc_field() {
732        let mut env = populated_env();
733        // A second doc ("other") also has "chapters/intro" in its toctree,
734        // to prove the files_to_rebuild key survives clear_doc("index")
735        // because the value-set isn't left empty.
736        env.files_to_rebuild
737            .get_mut("chapters/intro")
738            .unwrap()
739            .insert("other".to_string());
740        env.all_docs.insert("other".to_string(), 1_700_000_001);
741
742        env.clear_doc("index");
743
744        assert!(!env.all_docs.contains_key("index"));
745        assert!(!env.included.contains_key("index"));
746        assert!(!env.reread_always.contains("index"));
747        assert!(!env.dependencies.contains_key("index"));
748        assert!(!env.metadata.contains_key("index"));
749        assert!(!env.titles.contains_key("index"));
750        assert!(!env.longtitles.contains_key("index"));
751        assert!(!env.tocs.contains_key("index"));
752        assert!(!env.toc_secnumbers.contains_key("index"));
753        assert!(!env.toc_fignumbers.contains_key("index"));
754        assert!(!env.toc_num_entries.contains_key("index"));
755        assert!(!env.toctree_includes.contains_key("index"));
756        assert!(!env.glob_toctrees.contains("index"));
757        assert!(!env.numbered_toctrees.contains("index"));
758
759        // files_to_rebuild: "index" removed from the value-set, key
760        // survives because "other" still references it.
761        assert_eq!(
762            env.files_to_rebuild.get("chapters/intro"),
763            Some(&BTreeSet::from(["other".to_string()]))
764        );
765
766        // std/index domain entries are keyed by label/term/object name, not
767        // docname; clear_doc scrubs them by matching the docname *inside*
768        // each entry's value, which is "index" here (an envvar/label/term
769        // defined directly in index.rst).
770        // The preseeded virtual labels (genindex/modindex/py-modindex/
771        // search) belong to no source document, so they survive.
772        assert!(!env.std.labels.contains_key("intro"));
773        assert!(!env.std.anonlabels.contains_key("intro"));
774        assert_eq!(env.std.labels, StdDomainData::default().labels);
775        assert_eq!(env.std.anonlabels, StdDomainData::default().anonlabels);
776        assert!(env.std.objects.is_empty());
777        assert!(env.std.progoptions.is_empty());
778        assert!(env.std.terms.is_empty());
779        assert!(env.py.objects.is_empty() && env.py.objects_index.is_empty());
780        assert!(env.py.modules.is_empty() && env.py.modules_index.is_empty());
781        assert!(env.index_entries.is_empty());
782    }
783
784    #[test]
785    fn clear_doc_deletes_files_to_rebuild_key_when_value_set_becomes_empty() {
786        let mut env = BuildEnvironment::default();
787        env.files_to_rebuild.insert(
788            "chapters/intro".to_string(),
789            BTreeSet::from(["index".to_string()]),
790        );
791
792        env.clear_doc("index");
793
794        assert!(
795            !env.files_to_rebuild.contains_key("chapters/intro"),
796            "an emptied value-set must delete its key, not linger as an empty set"
797        );
798    }
799
800    /// A synthetic filesystem for the outdated computation: every document
801    /// has a doctree and a source read one second before its recorded read
802    /// time, and no dependency exists unless the test adds one.
803    #[derive(Default)]
804    struct Fs {
805        sources: BTreeMap<String, Option<u64>>,
806        doctrees: BTreeSet<String>,
807        deps: BTreeMap<PathBuf, Option<u64>>,
808    }
809
810    const READ_TIME: u64 = 1_000_000;
811
812    /// Two documents, both read at [`READ_TIME`], both up to date.
813    fn steady_state() -> (BuildEnvironment, Fs) {
814        let mut env = BuildEnvironment {
815            root_doc: "index".to_string(),
816            ..Default::default()
817        };
818        env.all_docs.insert("index".to_string(), READ_TIME);
819        env.all_docs.insert("a".to_string(), READ_TIME);
820        let fs = Fs {
821            sources: BTreeMap::from([
822                ("index".to_string(), Some(READ_TIME - 1)),
823                ("a".to_string(), Some(READ_TIME - 1)),
824            ]),
825            doctrees: BTreeSet::from(["index".to_string(), "a".to_string()]),
826            deps: BTreeMap::new(),
827        };
828        (env, fs)
829    }
830
831    fn found(docnames: &[&str]) -> BTreeSet<String> {
832        docnames.iter().map(|d| d.to_string()).collect()
833    }
834
835    fn outdated_with(
836        env: &BuildEnvironment,
837        fs: &Fs,
838        docnames: &[&str],
839        config_changed: bool,
840    ) -> Outdated {
841        env.get_outdated_files(
842            &found(docnames),
843            config_changed,
844            &FileTimes {
845                source_modified_us: &|docname| fs.sources.get(docname).copied().flatten(),
846                doctree_exists: &|docname| fs.doctrees.contains(docname),
847                dependency_modified_us: &|path| fs.deps.get(path).copied().flatten(),
848            },
849        )
850    }
851
852    fn outdated(env: &BuildEnvironment, fs: &Fs, docnames: &[&str]) -> Outdated {
853        outdated_with(env, fs, docnames, false)
854    }
855
856    #[test]
857    fn nothing_is_outdated_in_a_steady_state() {
858        let (env, fs) = steady_state();
859        let out = outdated(&env, &fs, &["index", "a"]);
860        assert_eq!(out, Outdated::default());
861        assert!(out.to_read().is_empty());
862    }
863
864    #[test]
865    fn a_document_the_environment_has_never_seen_is_added() {
866        let (env, mut fs) = steady_state();
867        fs.sources.insert("new".to_string(), Some(READ_TIME));
868        let out = outdated(&env, &fs, &["index", "a", "new"]);
869        assert_eq!(out.added, found(&["new"]));
870        assert!(out.changed.is_empty());
871        assert!(out.removed.is_empty());
872    }
873
874    #[test]
875    fn a_document_that_is_gone_is_removed() {
876        let (env, fs) = steady_state();
877        let out = outdated(&env, &fs, &["index"]);
878        assert_eq!(out.removed, found(&["a"]));
879        assert!(out.added.is_empty());
880        // The still-present document is *not* dragged along by its
881        // neighbour's disappearance (only glob toctrees are).
882        assert!(out.changed.is_empty());
883    }
884
885    #[test]
886    fn a_changed_configuration_re_reads_everything() {
887        let (env, fs) = steady_state();
888        let out = outdated_with(&env, &fs, &["index", "a"], true);
889        assert_eq!(out.added, found(&["index", "a"]));
890        assert!(
891            out.changed.is_empty(),
892            "sphinx puts every document in `added` and leaves `changed` empty"
893        );
894    }
895
896    #[test]
897    fn a_source_newer_than_its_read_time_has_changed() {
898        let (env, mut fs) = steady_state();
899        fs.sources.insert("a".to_string(), Some(READ_TIME + 1));
900        assert_eq!(outdated(&env, &fs, &["index", "a"]).changed, found(&["a"]));
901    }
902
903    #[test]
904    fn a_source_read_within_the_same_microsecond_has_not_changed() {
905        let (env, mut fs) = steady_state();
906        fs.sources.insert("a".to_string(), Some(READ_TIME));
907        assert!(
908            outdated(&env, &fs, &["index", "a"]).changed.is_empty(),
909            "the comparison is strictly-newer, like sphinx's"
910        );
911    }
912
913    #[test]
914    fn an_unstattable_source_has_changed() {
915        let (env, mut fs) = steady_state();
916        fs.sources.insert("a".to_string(), None);
917        assert_eq!(outdated(&env, &fs, &["index", "a"]).changed, found(&["a"]));
918    }
919
920    #[test]
921    fn a_missing_doctree_file_has_changed() {
922        let (env, mut fs) = steady_state();
923        fs.doctrees.remove("a");
924        assert_eq!(outdated(&env, &fs, &["index", "a"]).changed, found(&["a"]));
925    }
926
927    #[test]
928    fn a_document_that_asked_to_be_re_read_always_has_changed() {
929        let (mut env, fs) = steady_state();
930        env.reread_always.insert("a".to_string());
931        assert_eq!(outdated(&env, &fs, &["index", "a"]).changed, found(&["a"]));
932    }
933
934    #[test]
935    fn a_dependency_newer_than_the_read_time_has_changed() {
936        let (mut env, mut fs) = steady_state();
937        let pic = PathBuf::from("/src/pic.png");
938        env.dependencies
939            .insert("a".to_string(), BTreeSet::from([pic.clone()]));
940
941        fs.deps.insert(pic.clone(), Some(READ_TIME - 1));
942        assert!(outdated(&env, &fs, &["index", "a"]).changed.is_empty());
943
944        // Note the comparison: the dependency's mtime against the time the
945        // *document* was read, not against the document's own mtime.
946        fs.deps.insert(pic, Some(READ_TIME + 1));
947        assert_eq!(outdated(&env, &fs, &["index", "a"]).changed, found(&["a"]));
948    }
949
950    #[test]
951    fn a_missing_dependency_has_changed() {
952        let (mut env, mut fs) = steady_state();
953        let pic = PathBuf::from("/src/pic.png");
954        env.dependencies
955            .insert("a".to_string(), BTreeSet::from([pic.clone()]));
956        fs.deps.insert(pic, None);
957        assert_eq!(outdated(&env, &fs, &["index", "a"]).changed, found(&["a"]));
958    }
959
960    #[test]
961    fn adding_or_removing_a_file_re_reads_every_glob_toctree() {
962        let (mut env, mut fs) = steady_state();
963        env.glob_toctrees.insert("index".to_string());
964        // A glob container that is no longer part of the project is not
965        // resurrected by the re-read.
966        env.glob_toctrees.insert("gone".to_string());
967
968        // Nothing added or removed: the container is left alone.
969        assert!(outdated(&env, &fs, &["index", "a"]).changed.is_empty());
970
971        fs.sources.insert("new".to_string(), Some(READ_TIME));
972        fs.doctrees.insert("new".to_string());
973        let added = outdated(&env, &fs, &["index", "a", "new"]);
974        assert_eq!(added.added, found(&["new"]));
975        assert_eq!(added.changed, found(&["index"]));
976
977        let removed = outdated(&env, &fs, &["index"]);
978        assert_eq!(removed.removed, found(&["a"]));
979        assert_eq!(removed.changed, found(&["index"]));
980    }
981
982    #[test]
983    fn a_glob_container_that_is_new_itself_stays_in_added() {
984        let (mut env, mut fs) = steady_state();
985        env.glob_toctrees.insert("new".to_string());
986        fs.sources.insert("new".to_string(), Some(READ_TIME));
987        let out = outdated(&env, &fs, &["index", "a", "new"]);
988        assert_eq!(out.added, found(&["new"]));
989        assert!(
990            out.changed.is_empty(),
991            "a document is read once; being added already covers it"
992        );
993    }
994
995    #[test]
996    fn the_read_set_is_the_added_and_changed_documents() {
997        let (mut env, mut fs) = steady_state();
998        fs.sources.insert("new".to_string(), Some(READ_TIME));
999        fs.doctrees.remove("a");
1000        env.all_docs.insert("gone".to_string(), READ_TIME);
1001
1002        let out = outdated(&env, &fs, &["index", "a", "new"]);
1003        assert_eq!(out.to_read(), found(&["a", "new"]));
1004        assert_eq!(out.removed, found(&["gone"]));
1005    }
1006
1007    #[test]
1008    fn snapshot_converts_tuple_keyed_maps_and_index_entry_main_flag() {
1009        let env = populated_env();
1010        let snapshot = env.snapshot();
1011
1012        let objects = snapshot["std"]["objects"].as_array().unwrap();
1013        assert_eq!(objects.len(), 1);
1014        assert_eq!(objects[0]["objtype"], "envvar");
1015        assert_eq!(objects[0]["name"], "PATH");
1016        assert_eq!(objects[0]["docname"], "index");
1017
1018        let progoptions = snapshot["std"]["progoptions"].as_array().unwrap();
1019        assert_eq!(progoptions[0]["program"], "myprog");
1020        assert_eq!(progoptions[0]["name"], "--verbose");
1021
1022        let entries = snapshot["index_entries"]["index"].as_array().unwrap();
1023        assert_eq!(entries.len(), 1);
1024        let entry = entries[0].as_array().unwrap();
1025        assert_eq!(entry[0], "single");
1026        assert_eq!(entry[3], "main"); // bool true -> literal "main"
1027
1028        // py lists keep REGISTRATION order — zeta was noted before alpha.
1029        let py_objects = snapshot["py_objects"].as_array().unwrap();
1030        assert_eq!(
1031            py_objects
1032                .iter()
1033                .map(|o| o["name"].as_str().unwrap())
1034                .collect::<Vec<_>>(),
1035            vec!["zeta.func", "alpha.func"]
1036        );
1037        assert_eq!(py_objects[1]["aliased"], true);
1038        let py_modules = snapshot["py_modules"].as_array().unwrap();
1039        assert_eq!(py_modules.len(), 1);
1040        assert_eq!(py_modules[0]["name"], "zeta");
1041        assert_eq!(py_modules[0]["node_id"], "module-zeta");
1042        assert_eq!(py_modules[0]["synopsis"], "Zed things.");
1043        assert_eq!(py_modules[0]["platform"], "posix");
1044        assert_eq!(py_modules[0]["deprecated"], true);
1045
1046        assert_eq!(
1047            snapshot["tocs_pformat"]["index"],
1048            JsonValue::String(sample_node().pformat())
1049        );
1050    }
1051}