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