Skip to main content

repon_core/
lib.rs

1//! The rendering-agnostic core: it computes git state and knows nothing about terminals.
2//!
3//! Its public surface is flat, re-exported from the crate root rather than through
4//! `fanout`, `git`, `entity` or `snapshot`, which stay private: a generic scatter
5//! primitive and a single branch read are not vocabulary a second consumer needs,
6//! and neither is which file happens to define `EntityState` or `Snapshot`. The one
7//! exception is `liveness`, a `test-util`-gated module of test waits that names
8//! nothing git-shaped and so belongs under a namespace rather than at the root. The
9//! entry points on `Core` itself (`start`, `refresh`, `snapshot`, `try_settle`, ...)
10//! land in later work and get re-exported the same way once they exist.
11//!
12//! ## Reviewing an addition to this surface
13//!
14//! Every addition to what this crate exports should hold each of these, from
15//! `docs/spec/core-api.md`'s ownership table and
16//! [ADR 0015](https://github.com/paulchiu/repon/blob/main/docs/adr/0015-the-core-owns-the-table.md):
17//!
18//! - It does a git-shaped thing (discovery, a probe phase, the metadata poll, a Set
19//!   boundary, an override, Generation supersession, the row fold, the display
20//!   name, the default branch rung, the environment contract as data, Action
21//!   fan-out), never a terminal-shaped one (rendering, the cursor, glyphs, theme,
22//!   keybindings, the Launcher, config file discovery, `$HOME`, a user-specific
23//!   environment variable).
24//! - An empty Selection carries no meaning here: this crate never defaults it to
25//!   "the row under the cursor" or anything else that only makes sense on a screen.
26//! - `refresh` takes an already-ordered `&[EntityKey]`. This crate never computes
27//!   or second-guesses that order; cursor-row-first is the consumer's ordering to
28//!   make, not this crate's to infer.
29//! - A Filter is a pure predicate over these public types. Deciding when to apply
30//!   one, if ever, stays with the consumer.
31//! - It has no notification channel, update stream or callback: a consumer reads a
32//!   [`Snapshot`] when it decides to, it is never pushed one.
33//!
34//! Four refusals hold for every type this crate ever makes public, reasoned in
35//! [ADR 0015](https://github.com/paulchiu/repon/blob/main/docs/adr/0015-the-core-owns-the-table.md):
36//!
37//! - No `#[non_exhaustive]`: it forces a consumer to add a wildcard match arm even
38//!   when every variant is already matched, reintroducing by attribute the default
39//!   path ADR 0001 forbids. ADR 0015 argued this against an in-repo consumer; ADR
40//!   0021 observes that publishing widens the audience beyond that case, so the
41//!   refusal stands but is not re-argued for the wider one.
42//! - No sealed trait: the enforcement ADR 0015 relies on is a real second consumer
43//!   (`repon sets`), not defensive API ceremony guarding against a use case nothing
44//!   presents.
45//! - No separate versioning scheme: repon-core is a path dependency with one
46//!   in-workspace consumer, so a breaking change and its fix land in the same commit.
47//! - No git-backend trait abstraction: the crate's existing test drives a real
48//!   disposable repository rather than a mock, so a trait would buy testability
49//!   already paid for.
50
51// Repon is a Unix program, and the restriction lands here rather than on the
52// terminal crate because this one owns Action fan-out: a step goes into a new
53// session with setsid(2) and is read back over a PTY. See docs/spec/actions.md.
54#[cfg(not(unix))]
55compile_error!("repon-core requires a Unix target: see docs/spec/actions.md");
56
57mod auto_update;
58mod base;
59mod cell;
60mod core;
61mod default_branch;
62mod discovery;
63mod entity;
64mod environment;
65mod executor;
66mod fanout;
67mod fetch;
68mod filter;
69mod git;
70mod landing;
71/// The one module reachable by name rather than re-exported flat: it holds no vocabulary a
72/// consumer needs, only the wait a test in either crate uses for a liveness property, and a
73/// namespace is what keeps names that generic out of the crate root. Gated with it, and the
74/// gate is what
75/// `every_pub_item_documented_as_test_only_is_either_gated_or_has_a_production_use_site`
76/// reads this doc comment to require.
77#[cfg(any(test, feature = "test-util"))]
78pub mod liveness;
79mod patch_equivalence;
80mod poll;
81mod snapshot;
82#[cfg(test)]
83mod test_support;
84#[cfg(feature = "serde")]
85mod wire;
86
87pub use cell::{Cell, Generation, Settled, Timestamp, Unknown};
88pub use core::AutoUpdateAttempt;
89pub use core::FetchFailures;
90pub use core::ManagementHandle;
91pub use core::{ActionSpec, AutoUpdateSpec, Core, CoreSpec, FetchSpec, RepoOverride, Step};
92pub use discovery::{Discovery, SetSpec, count, discover};
93pub use entity::ActionReceipt;
94pub use entity::AheadBehind;
95pub use entity::CaptureElision;
96pub use entity::DefaultBranch;
97pub use entity::DefaultBranchStopped;
98pub use entity::DeleteRisk;
99pub use entity::Diagnostics;
100pub use entity::DirtyCounts;
101pub use entity::EntityKey;
102pub use entity::EntityState;
103pub use entity::Head;
104pub use entity::Kind;
105pub use entity::OwnWork;
106pub use entity::Presence;
107pub use entity::RunningStep;
108pub use entity::Skip;
109pub use entity::StepOutcome;
110pub use entity::StepResult;
111pub use entity::SyncState;
112pub use entity::WorktreeState;
113pub use environment::environment;
114pub use filter::{Applicability, Filter, KeyVocabulary, vocabulary};
115pub use git::{InProgressOperation, ProbeError, RecentCommit};
116pub use snapshot::{RowSummary, Snapshot, summary};
117#[cfg(feature = "serde")]
118pub use wire::SettledDocument;
119
120#[cfg(test)]
121mod tests {
122    /// The exported name from one `pub use` item: `Name`, or the alias in
123    /// `Name as Alias`. Panics naming `line` if `item` is neither, so a form this
124    /// cannot read fails the test rather than being silently dropped.
125    fn exported_name(item: &str, line: &str) -> String {
126        if let Some((_, alias)) = item.split_once(" as ") {
127            return alias.trim().to_string();
128        }
129        let name = item.trim();
130        assert!(
131            !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_'),
132            "cannot read an exported name from `{item}` in `pub use` line `{line}`"
133        );
134        name.to_string()
135    }
136
137    /// Everything in `source` ahead of the test module, which is the region where a
138    /// crate-root declaration's visibility is decided and the only region either scan
139    /// below reads: a `pub` item inside the test module is not part of the surface.
140    fn crate_root_declarations(source: &str) -> &str {
141        source.split("mod tests {").next().unwrap_or(source)
142    }
143
144    /// The crate's actual public surface: every name reachable at the crate root
145    /// of `source`, read from the crate's own `src/lib.rs` rather than a hand-kept
146    /// list, so a name added without a matching glossary entry has nowhere to hide.
147    ///
148    /// Three declaration forms carry a name onto that surface, and all three are
149    /// read: a `pub use` re-export, a `pub mod` namespace, and a `pub const`. A
150    /// line scan, not a parser, since this crate controls the formatting of its own
151    /// crate-root lines: it recognises `pub use path::Name;`,
152    /// `pub use path::Name as Alias;`, a single-line braced group
153    /// `pub use path::{Name, Other as Alias};`, `pub mod name;` and
154    /// `pub const NAME: Type = ...;`. Any other `pub ` line at the crate root is a
155    /// panic rather than a skip, since a scanner that quietly passes over an
156    /// unfamiliar form is the same drift this test exists to catch: `pub mod
157    /// liveness;` was invisible to the `pub use`-only version of this scan. What it
158    /// does *not* check is privacy: a name collected here is only ever asked whether
159    /// the glossary documents it, so a module made public whose name happens to be a
160    /// glossary term passes. `docs/spec/releasing.md`'s cleared-gate item 1 is the
161    /// privacy claim, and
162    /// [`modules_the_release_gate_names_are_private_at_the_crate_root`] is what
163    /// enforces it.
164    fn crate_root_public_surface(source: &str) -> Vec<String> {
165        let mut names = Vec::new();
166        for line in crate_root_declarations(source).lines() {
167            let line = line.trim();
168            if !line.starts_with("pub ") {
169                continue;
170            }
171            if let Some(rest) = line.strip_prefix("pub mod ") {
172                let name = rest
173                    .strip_suffix(';')
174                    .unwrap_or_else(|| panic!("`pub mod` line is not `;`-terminated: `{line}`"));
175                names.push(exported_name(name, line));
176                continue;
177            }
178            if let Some(rest) = line.strip_prefix("pub const ") {
179                let name = rest
180                    .split(':')
181                    .next()
182                    .unwrap_or_else(|| panic!("`pub const` line names nothing: `{line}`"));
183                names.push(exported_name(name, line));
184                continue;
185            }
186            let body = line
187                .strip_prefix("pub use ")
188                .and_then(|s| s.strip_suffix(';'))
189                .unwrap_or_else(|| {
190                    panic!("crate-root `pub` line is in no form this scan reads: `{line}`")
191                });
192            match body.split_once('{') {
193                Some((_path, rest)) => {
194                    let group = rest.strip_suffix('}').unwrap_or_else(|| {
195                        panic!("`pub use` group is not `}}`-terminated: `{line}`")
196                    });
197                    for item in group.split(',') {
198                        let item = item.trim();
199                        if !item.is_empty() {
200                            names.push(exported_name(item, line));
201                        }
202                    }
203                }
204                None => {
205                    let last = body.rsplit("::").next().unwrap_or(body);
206                    names.push(exported_name(last, line));
207                }
208            }
209        }
210        names
211    }
212
213    /// True if `name`'s words, read together as one phrase, appear in `glossary`.
214    ///
215    /// A Rust identifier's casing never matches the glossary's Capitalised prose
216    /// terms directly, so `name` is split into words on `_` and on a
217    /// lowercase-to-uppercase boundary (never inside a run of capitals, or a
218    /// SCREAMING_CASE constant would come apart one letter at a time), rejoined
219    /// with single spaces, and searched
220    /// for case-insensitively. Matching is deliberately whole-phrase rather than
221    /// any-single-word: a name like `EntityState` must find "entity state" together,
222    /// not pass because "state" alone occurs in unrelated prose such as "Worktree
223    /// state". The false-negative risk this leaves: a name whose words are the
224    /// glossary's own terms but in a different order, plural, or hyphenated (a
225    /// `RepoSet` next to a glossary that only ever writes "a Set of Repos") reads as
226    /// undocumented even though a reader would find it.
227    fn glossary_covers(glossary: &str, name: &str) -> bool {
228        let mut words = Vec::new();
229        let mut word = String::new();
230        for ch in name.chars() {
231            if ch == '_' {
232                if !word.is_empty() {
233                    words.push(std::mem::take(&mut word));
234                }
235                continue;
236            }
237            if ch.is_uppercase() && word.ends_with(|last: char| !last.is_uppercase()) {
238                words.push(std::mem::take(&mut word));
239            }
240            word.push(ch);
241        }
242        if !word.is_empty() {
243            words.push(word);
244        }
245        let phrase = words.join(" ").to_lowercase();
246        glossary.to_lowercase().contains(&phrase)
247    }
248
249    /// Every crate-root re-export must name something the project glossary already
250    /// names, so that reading the crate root and reading the glossary give the same
251    /// answer.
252    ///
253    /// Both files are read at test time from `CARGO_MANIFEST_DIR` rather than with
254    /// `include_str!`: `GLOSSARY.md` lives at the repository root, outside this
255    /// crate's own directory, so it is not among the files `cargo package` ships.
256    /// `include_str!` was tried first; it compiles fine in the workspace checkout
257    /// and does not itself break `cargo publish --dry-run` (packaging only builds,
258    /// it does not run tests), but `cargo test` against the extracted package
259    /// (`target/package/repon-core-*`) then fails to compile at all, a missing-file
260    /// error with no test to report it. Reading both paths at runtime instead
261    /// degrades that to one failing assertion in a context nothing here needs to
262    /// support, rather than a compile error in any context that later turns test
263    /// code on.
264    #[test]
265    fn public_surface_matches_glossary() {
266        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
267        let source = std::fs::read_to_string(manifest_dir.join("src/lib.rs"))
268            .expect("read this crate's own source");
269        let glossary = std::fs::read_to_string(manifest_dir.join("../../GLOSSARY.md"))
270            .expect("read the project glossary");
271
272        for name in crate_root_public_surface(&source) {
273            assert!(
274                glossary_covers(&glossary, &name),
275                "crate-root public name `{name}` has no matching entry in the project glossary"
276            );
277        }
278    }
279
280    /// The module names `docs/spec/releasing.md`'s cleared-gate item 1 requires to stay
281    /// private, read out of the document rather than restated beside it.
282    ///
283    /// The item spells each one as a backticked `mod name;` fragment, which is what this
284    /// picks out: a name the gate stops naming stops being enforced here, and one it adds
285    /// is enforced without an edit to this file. An item naming none panics, since a
286    /// reworded gate would otherwise leave the test below asserting nothing.
287    fn modules_the_release_gate_requires_to_be_private(releasing: &str) -> Vec<String> {
288        let gate = releasing
289            .split("## Before the first crates.io publish")
290            .nth(1)
291            .expect("releasing.md must carry the pre-publish gate section");
292        let item = gate
293            .lines()
294            .find(|line| line.trim_start().starts_with("1. "))
295            .expect("the pre-publish gate must carry a numbered item 1");
296        let names: Vec<String> = item
297            .split('`')
298            .skip(1)
299            .step_by(2)
300            .filter_map(|code| Some(code.strip_prefix("mod ")?.strip_suffix(';')?.to_string()))
301            .collect();
302        assert!(
303            !names.is_empty(),
304            "cleared-gate item 1 names no `mod name;` fragment any more, so the privacy it \
305             records has nothing left to check: {item}"
306        );
307        names
308    }
309
310    /// `docs/spec/releasing.md`'s cleared-gate item 1 is a privacy claim about this crate
311    /// root, so the names it makes that claim about are read from the document and asserted
312    /// against the source rather than restated here.
313    ///
314    /// Each named module must still be declared at the crate root, in either form, before
315    /// its declaration is required to be the private one: a gate naming a module this crate
316    /// no longer has is a stale gate, not a passing check.
317    #[test]
318    fn modules_the_release_gate_names_are_private_at_the_crate_root() {
319        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
320        let source = std::fs::read_to_string(manifest_dir.join("src/lib.rs"))
321            .expect("read this crate's own source");
322        let releasing = std::fs::read_to_string(manifest_dir.join("../../docs/spec/releasing.md"))
323            .expect("read the releasing spec");
324        let declarations: Vec<&str> = crate_root_declarations(&source)
325            .lines()
326            .map(str::trim)
327            .collect();
328
329        for name in modules_the_release_gate_requires_to_be_private(&releasing) {
330            let private = format!("mod {name};");
331            let public = format!("pub {private}");
332            let declaration = declarations
333                .iter()
334                .find(|line| **line == private || **line == public)
335                .unwrap_or_else(|| {
336                    panic!(
337                        "`docs/spec/releasing.md`'s cleared-gate item 1 names `{private}`, \
338                         which this crate root no longer declares in any form"
339                    )
340                });
341            assert_eq!(
342                **declaration, private,
343                "`{name}` is public at this crate root, which `docs/spec/releasing.md`'s \
344                 cleared-gate item 1 requires to stay private"
345            );
346        }
347    }
348
349    /// Every `.rs` file under `dir`, recursively.
350    fn rust_source_files(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
351        let mut files = Vec::new();
352        for entry in std::fs::read_dir(dir).expect("read a source directory") {
353            let path = entry.expect("read a directory entry").path();
354            if path.is_dir() {
355                files.extend(rust_source_files(&path));
356            } else if path.extension().is_some_and(|extension| extension == "rs") {
357                files.push(path);
358            }
359        }
360        files
361    }
362
363    /// `gix::interrupt::IS_INTERRUPTED` is a process-global static wired to
364    /// SIGINT; using it would cancel every entity's probe at once, defeating the
365    /// one `Arc<AtomicBool>` per in-flight entity [refresh.md](https://github.com/paulchiu/repon/blob/main/docs/spec/refresh.md)'s
366    /// "Cancellation" requires. Scans every source file under `src`, not just
367    /// `core.rs`, so a future module reaching for it is caught too. A line only
368    /// counts as real usage when it is not a comment, which is what lets doc
369    /// comments (this crate's own, explaining the ban) keep naming it.
370    #[test]
371    fn gix_interrupt_is_interrupted_is_never_used() {
372        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
373        // Built from two pieces rather than written as one path literal, so this
374        // check's own source line is never itself a match for what it scans for.
375        let banned = format!("interrupt::{}", "IS_INTERRUPTED");
376        let mut offending_locations = Vec::new();
377        for path in rust_source_files(&manifest_dir.join("src")) {
378            let source = std::fs::read_to_string(&path).expect("read a crate source file");
379            for (number, line) in source.lines().enumerate() {
380                if line.trim_start().starts_with("//") {
381                    continue;
382                }
383                if line.contains(&banned) {
384                    offending_locations.push(format!("{}:{}", path.display(), number + 1));
385                }
386            }
387        }
388        assert!(
389            offending_locations.is_empty(),
390            "gix's process-global interrupt static must never be used outside a comment, found at: {offending_locations:?}"
391        );
392    }
393
394    /// `docs/spec/actions.md`'s "The run on screen": "the parse cannot live in
395    /// repon-core, because ansi-to-tui produces ratatui types and the core has a CI
396    /// line asserting its tree contains no ratatui". `just check-core-isolation`
397    /// proves the dependency is absent; this proves the same claim at the source
398    /// level, so a hand-rolled parser producing ratatui types some other way (not
399    /// through the `ansi-to-tui` dependency at all) is caught too. Scans every
400    /// source file under `src`, not just `executor.rs`, since a future module is as
401    /// capable of reaching for either name as that one.
402    #[test]
403    fn no_source_file_in_this_crate_names_the_rendering_crates_that_parse_ansi() {
404        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
405        let banned = [
406            format!("{}{}", "rata", "tui"),
407            format!("{}_{}", "ansi", "to_tui"),
408        ];
409        let mut offending_locations = Vec::new();
410        for path in rust_source_files(&manifest_dir.join("src")) {
411            let source = std::fs::read_to_string(&path).expect("read a crate source file");
412            for (number, line) in source.lines().enumerate() {
413                if line.trim_start().starts_with("//") {
414                    continue;
415                }
416                if banned.iter().any(|needle| line.contains(needle)) {
417                    offending_locations.push(format!("{}:{}", path.display(), number + 1));
418                }
419            }
420        }
421        assert!(
422            offending_locations.is_empty(),
423            "found a rendering crate named in repon-core's own source, which must stay raw \
424             bytes with no interpretation: {offending_locations:?}"
425        );
426    }
427
428    /// [`RowSummary`](crate::RowSummary)'s mapping to a gutter glyph is
429    /// `docs/spec/core-api.md`'s explicit consumer-side job, never this crate's.
430    /// Scans every source file under `src` for the two shapes that mapping would
431    /// take here: a function returning a bare `char`, or a match arm whose
432    /// right-hand side is a character literal.
433    #[test]
434    fn no_state_is_mapped_to_a_character_anywhere_in_this_crate() {
435        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
436        let banned_return = format!("-{}", "> char");
437        let banned_arm = format!("={}", "> '");
438        let mut offending_locations = Vec::new();
439        for path in rust_source_files(&manifest_dir.join("src")) {
440            let source = std::fs::read_to_string(&path).expect("read a crate source file");
441            for (number, line) in source.lines().enumerate() {
442                if line.trim_start().starts_with("//") {
443                    continue;
444                }
445                if line.contains(&banned_return) || line.contains(&banned_arm) {
446                    offending_locations.push(format!("{}:{}", path.display(), number + 1));
447                }
448            }
449        }
450        assert!(
451            offending_locations.is_empty(),
452            "repon-core must never map a state to a character; the mapping belongs to the \
453             consumer, found at: {offending_locations:?}"
454        );
455    }
456
457    /// The manifest text a consumer actually resolves against, not a copy: `test-util` gates
458    /// `Timestamp::at` off the default published surface per
459    /// [ADR 0021](https://github.com/paulchiu/repon/blob/main/docs/adr/0021-a-release-is-what-the-tag-pipeline-publishes.md),
460    /// and a `default = [...]` naming it would silently turn every consumer's default build
461    /// back into the thing the gate exists to prevent.
462    #[test]
463    fn test_util_is_never_a_default_feature() {
464        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
465        let manifest = std::fs::read_to_string(manifest_dir.join("Cargo.toml"))
466            .expect("read this crate's own Cargo.toml");
467        let features_section = manifest
468            .split("[features]")
469            .nth(1)
470            .and_then(|rest| rest.split("\n[").next())
471            .unwrap_or("");
472        assert!(
473            features_section.contains("test-util"),
474            "expected a `test-util` feature declared in `[features]`; this test's own premise \
475             is stale if it moved: {manifest}"
476        );
477        let default_line = features_section
478            .lines()
479            .find(|line| line.trim_start().starts_with("default"));
480        assert!(
481            default_line.is_none_or(|line| !line.contains("test-util")),
482            "`test-util` must never be named in a default feature list, or it ships on every \
483             consumer's default build: {default_line:?}"
484        );
485    }
486}