use std::fs;
use std::path::{Path, PathBuf};
fn project_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
const RUST_SCAN_ROOTS: &[&str] = &["src", "cli/src"];
fn collect_all_rust_sources(root: &Path) -> Vec<PathBuf> {
let mut sources = Vec::new();
for rel in RUST_SCAN_ROOTS {
let dir = root.join(rel);
assert!(
dir.is_dir(),
"expected scan root `{}` to exist; update RUST_SCAN_ROOTS \
if the workspace layout changed",
dir.display(),
);
collect_rust_files(&dir, &mut sources);
}
sources
}
fn collect_rust_files(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in fs::read_dir(dir).expect("read src dir") {
let entry = entry.expect("read src entry");
let path = entry.path();
let file_type = entry.file_type().expect("file type");
if file_type.is_dir() {
collect_rust_files(&path, out);
} else if path.extension().is_some_and(|ext| ext == "rs") {
out.push(path);
}
}
}
fn extract_env_constants(source: &str) -> Vec<(String, String)> {
let mut found = Vec::new();
for line in source.lines() {
let trimmed = line.trim_start();
let after_pub = trimmed
.strip_prefix("pub(crate) const ENV_")
.or_else(|| trimmed.strip_prefix("pub const ENV_"));
let Some(rest) = after_pub else { continue };
let Some(name_end) = rest.find(':') else {
continue;
};
let name = format!("ENV_{}", rest[..name_end].trim());
let Some(first_quote) = rest.find('"') else {
continue;
};
let after_quote = &rest[first_quote + 1..];
let Some(closing) = after_quote.find('"') else {
continue;
};
let value = after_quote[..closing].to_owned();
found.push((name, value));
}
found
}
fn doc_mentions_env_var(doc: &str, value: &str) -> bool {
let haystack = doc.as_bytes();
let needle = value.as_bytes();
if needle.is_empty() || needle.len() > haystack.len() {
return false;
}
let is_word_byte = |b: u8| b.is_ascii_alphanumeric() || b == b'_';
haystack
.windows(needle.len())
.enumerate()
.any(|(i, window)| {
if window != needle {
return false;
}
let before_ok = i == 0 || !is_word_byte(haystack[i - 1]);
let after_idx = i + needle.len();
let after_ok = after_idx == haystack.len() || !is_word_byte(haystack[after_idx]);
before_ok && after_ok
})
}
#[test]
fn every_env_constant_has_a_documentation_row() {
let root = project_root();
let sources = collect_all_rust_sources(&root);
assert!(
!sources.is_empty(),
"no Rust files found under any of {RUST_SCAN_ROOTS:?}",
);
let mut declared = Vec::new();
for path in &sources {
let body =
fs::read_to_string(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
for (name, value) in extract_env_constants(&body) {
declared.push((name, value, path.clone()));
}
}
assert!(
!declared.is_empty(),
"scan found zero ENV_ constants under {RUST_SCAN_ROOTS:?}; the regex shape \
probably drifted — update `extract_env_constants` to match the project's \
current declaration style",
);
let doc_path = root.join("docs/environment-variables.md");
let doc = fs::read_to_string(&doc_path)
.unwrap_or_else(|err| panic!("read {}: {err}", doc_path.display()));
let missing: Vec<_> = declared
.iter()
.filter(|(_, value, _)| !doc_mentions_env_var(&doc, value))
.collect();
assert!(
missing.is_empty(),
"the following env-var constants are declared in the workspace but not \
mentioned in docs/environment-variables.md (the single index, per \
.claude/rules/environment-variables.md):\n{}",
missing
.iter()
.map(|(name, value, path)| format!(
" - `{value}` (constant `{name}` in {})",
path.strip_prefix(&root).unwrap_or(path).display()
))
.collect::<Vec<_>>()
.join("\n")
);
}
fn extract_default_constants(source: &str) -> Vec<(String, u64)> {
let mut found = Vec::new();
for line in source.lines() {
let trimmed = line.trim_start();
let after_pub = trimmed
.strip_prefix("pub(crate) const DEFAULT_")
.or_else(|| trimmed.strip_prefix("pub const DEFAULT_"));
let Some(rest) = after_pub else { continue };
let Some(name_end) = rest.find(':') else {
continue;
};
let name = format!("DEFAULT_{}", rest[..name_end].trim());
let after_colon = rest[name_end + 1..].trim_start();
let Some(rest_after_type) = after_colon.strip_prefix("u64") else {
continue;
};
let after_eq = rest_after_type.trim_start();
let Some(after_eq) = after_eq.strip_prefix('=') else {
continue;
};
let value_str: String = after_eq
.chars()
.skip_while(|c| c.is_whitespace())
.take_while(|c| c.is_ascii_digit() || *c == '_')
.filter(|c| *c != '_')
.collect();
let Ok(value) = value_str.parse::<u64>() else {
continue;
};
found.push((name, value));
}
found
}
#[derive(Debug, Clone)]
struct DocumentedMention {
path: PathBuf,
line: usize,
snippet: String,
documented: u64,
}
fn normalize_with_line_map(haystack: &str) -> (Vec<u8>, Vec<usize>) {
let bytes = haystack.as_bytes();
let mut normalized: Vec<u8> = Vec::with_capacity(bytes.len());
let mut line_for: Vec<usize> = Vec::with_capacity(bytes.len() + 1);
let mut norm_to_orig: Vec<usize> = Vec::with_capacity(bytes.len() + 1);
let mut line = 1usize;
for &b in bytes {
line_for.push(line);
if b == b'\n' {
line += 1;
}
}
line_for.push(line);
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'\n' {
normalized.push(b' ');
norm_to_orig.push(i);
i += 1;
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
if i + 2 < bytes.len() && (&bytes[i..i + 3] == b"///" || &bytes[i..i + 3] == b"//!") {
i += 3;
} else if i + 1 < bytes.len() && &bytes[i..i + 2] == b"//" {
i += 2;
}
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
} else {
normalized.push(b);
norm_to_orig.push(i);
i += 1;
}
}
norm_to_orig.push(bytes.len());
let mut hit_to_line: Vec<usize> = Vec::with_capacity(norm_to_orig.len());
for &orig in &norm_to_orig {
let l = *line_for.get(orig).unwrap_or(&line);
hit_to_line.push(l);
}
(normalized, hit_to_line)
}
fn find_bytes(haystack: &[u8], needle: &[u8], from: usize) -> Option<usize> {
if needle.is_empty() || from > haystack.len() {
return None;
}
haystack[from..]
.windows(needle.len())
.position(|w| w == needle)
.map(|p| p + from)
}
fn find_anchored_values(haystack: &str, prefix: &str, suffix: &str) -> Vec<(usize, String, u64)> {
let (normalized, hit_to_line) = normalize_with_line_map(haystack);
let prefix_bytes = prefix.as_bytes();
let suffix_bytes = suffix.as_bytes();
let mut out = Vec::new();
let mut cursor = 0usize;
while let Some(p) = find_bytes(&normalized, prefix_bytes, cursor) {
let after_start = p + prefix_bytes.len();
let mut end = after_start;
while end < normalized.len() {
let b = normalized[end];
if b.is_ascii_digit() || b == b'_' {
end += 1;
} else {
break;
}
}
let digits_slice = &normalized[after_start..end];
if digits_slice.is_empty() {
cursor = after_start;
continue;
}
if normalized[end..].starts_with(suffix_bytes) {
let digits = std::str::from_utf8(digits_slice).expect("ascii digits are valid UTF-8");
let value: u64 = digits.replace('_', "").parse().unwrap_or(0);
let snippet = format!("{prefix}{digits}{suffix}");
let line = hit_to_line.get(p).copied().unwrap_or(1);
out.push((line, snippet, value));
}
cursor = after_start;
}
out
}
struct DefaultPatterns {
constant: &'static str,
anchors: &'static [(&'static str, &'static str)],
doc_paths: &'static [&'static str],
}
const DEFAULT_PATTERNS: &[DefaultPatterns] = &[
DefaultPatterns {
constant: "DEFAULT_LOCK_TTL_SECONDS",
anchors: &[
("(falling back to ", "s)"),
("falling back to ", " seconds"),
("(", "s default)"),
],
doc_paths: &[
"docs/getting-started.md",
"cli/src/management.rs",
"man/git-remote-object-store-doctor.1",
"man/git-remote-object-store-compact.1",
"man/git-remote-object-store-delete-branch.1",
"man/git-remote-object-store.1",
],
},
DefaultPatterns {
constant: "DEFAULT_GRACE_HOURS",
anchors: &[
("(falling back to ", ")"),
("falling back to ", ");"),
("Default is ", " hours."),
("default ", " hours"),
("**", "h**"),
],
doc_paths: &[
"docs/getting-started.md",
"docs/storage-engines.md",
"cli/src/management.rs",
"man/git-remote-object-store-gc.1",
"man/git-remote-object-store-compact.1",
],
},
];
const ENV_TABLE_BINDINGS: &[(&str, &str)] = &[
(
"DEFAULT_LOCK_TTL_SECONDS",
"GIT_REMOTE_OBJECT_STORE_LOCK_TTL_SECONDS",
),
(
"DEFAULT_GRACE_HOURS",
"GIT_REMOTE_OBJECT_STORE_GC_GRACE_HOURS",
),
];
fn parse_env_table_default(doc: &str, env_var_name: &str) -> Option<(usize, String, u64)> {
for (idx, line) in doc.lines().enumerate() {
let trimmed = line.trim_start();
if !trimmed.starts_with("| `") {
continue;
}
let after_first_tick = trimmed.trim_start_matches("| `");
let Some(close) = after_first_tick.find('`') else {
continue;
};
if &after_first_tick[..close] != env_var_name {
continue;
}
let rest = &after_first_tick[close + 1..];
let Some(open) = rest.find('`') else { continue };
let after_open = &rest[open + 1..];
let Some(close2) = after_open.find('`') else {
continue;
};
let cell = &after_open[..close2];
let digits: String = cell.chars().filter(char::is_ascii_digit).collect();
if digits.is_empty() {
return None;
}
let value: u64 = digits.parse().ok()?;
return Some((idx + 1, cell.to_owned(), value));
}
None
}
fn collect_defaults(root: &Path) -> Vec<(String, u64, PathBuf)> {
let sources = collect_all_rust_sources(root);
let mut defaults: Vec<(String, u64, PathBuf)> = Vec::new();
for path in &sources {
let body =
fs::read_to_string(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
for (name, value) in extract_default_constants(&body) {
defaults.push((name, value, path.clone()));
}
}
defaults
}
fn check_anchored_patterns(
root: &Path,
patterns: &DefaultPatterns,
live: u64,
src_path: &Path,
divergences: &mut Vec<String>,
) {
let mut mentions: Vec<DocumentedMention> = Vec::new();
for rel in patterns.doc_paths {
let path = root.join(rel);
let body = fs::read_to_string(&path)
.unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
for (prefix, suffix) in patterns.anchors {
for (line_no, snippet, value) in find_anchored_values(&body, prefix, suffix) {
mentions.push(DocumentedMention {
path: PathBuf::from(rel),
line: line_no,
snippet,
documented: value,
});
}
}
}
assert!(
!mentions.is_empty(),
"no documented mention of `{}` was located via the configured anchors. \
Either the docs no longer mention the default (re-add it) or the anchor \
patterns in DEFAULT_PATTERNS drifted from the prose (update them). \
Constant is declared in {}.",
patterns.constant,
src_path.strip_prefix(root).unwrap_or(src_path).display(),
);
for mention in &mentions {
if mention.documented != live {
let constant = patterns.constant;
let path = mention.path.display();
let line_no = mention.line;
let snippet = &mention.snippet;
let documented = mention.documented;
divergences.push(format!(
" - {path}:{line_no} — `{snippet}` documents `{documented}` but \
`{constant}` is currently `{live}`",
));
}
}
}
fn check_env_table_row(
env_doc: &str,
constant: &str,
env_name: &str,
live: u64,
divergences: &mut Vec<String>,
) {
match parse_env_table_default(env_doc, env_name) {
Some((row_line, cell, documented)) if documented != live => {
divergences.push(format!(
" - docs/environment-variables.md:{row_line} — row for `{env_name}` \
has default cell `{cell}` ({documented}) but `{constant}` is \
currently `{live}`",
));
}
Some(_) => {}
None => {
divergences.push(format!(
" - docs/environment-variables.md — row for `{env_name}` is missing \
or has no numeric default cell; expected to match `{constant}` \
(= `{live}`)",
));
}
}
}
#[test]
fn documented_defaults_match_live_constants() {
let root = project_root();
let defaults = collect_defaults(&root);
assert!(
!defaults.is_empty(),
"scan found zero DEFAULT_ constants under {RUST_SCAN_ROOTS:?}; \
the matcher probably drifted",
);
for patterns in DEFAULT_PATTERNS {
assert!(
defaults
.iter()
.any(|(name, _, _)| name == patterns.constant),
"DEFAULT_PATTERNS names `{}` but no such constant exists under \
{RUST_SCAN_ROOTS:?}; remove the entry or fix the spelling",
patterns.constant,
);
}
for (constant, _) in ENV_TABLE_BINDINGS {
assert!(
defaults.iter().any(|(name, _, _)| name == constant),
"ENV_TABLE_BINDINGS names `{constant}` but no such constant exists \
under {RUST_SCAN_ROOTS:?}",
);
}
let env_doc_path = root.join("docs/environment-variables.md");
let env_doc = fs::read_to_string(&env_doc_path)
.unwrap_or_else(|err| panic!("read {}: {err}", env_doc_path.display()));
let mut divergences: Vec<String> = Vec::new();
for patterns in DEFAULT_PATTERNS {
let Some((_, live, src_path)) = defaults
.iter()
.find(|(name, _, _)| name == patterns.constant)
else {
continue;
};
check_anchored_patterns(&root, patterns, *live, src_path, &mut divergences);
}
for (constant, env_name) in ENV_TABLE_BINDINGS {
let Some((_, live, _)) = defaults.iter().find(|(name, _, _)| name == constant) else {
continue;
};
check_env_table_row(&env_doc, constant, env_name, *live, &mut divergences);
}
assert!(
divergences.is_empty(),
"documented default values diverge from the live `DEFAULT_*` constants \
(see issue #184 — defaults are duplicated across docs, man pages, and \
CLI doc-comments without mechanical sync). Update either the constant \
or each documented mention below:\n{}",
divergences.join("\n"),
);
}
#[test]
fn scan_covers_cli_src_root() {
let root = project_root();
let sources = collect_all_rust_sources(&root);
for rel in RUST_SCAN_ROOTS {
let expected_prefix = root.join(rel);
assert!(
sources.iter().any(|p| p.starts_with(&expected_prefix)),
"scan visited zero files under `{}`; RUST_SCAN_ROOTS or \
collect_rust_files drifted",
expected_prefix.display(),
);
}
let mut declared_values: Vec<String> = Vec::new();
for path in &sources {
let body =
fs::read_to_string(path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
for (_, value) in extract_env_constants(&body) {
declared_values.push(value);
}
}
assert!(
declared_values.iter().any(|v| v == "GIT_DIR"),
"scan did not pick up `GIT_DIR` from cli/src/lib.rs; either the \
constant was renamed or the scan stopped covering cli/src/. \
Declared values found: {declared_values:?}",
);
}
#[cfg(test)]
mod unit {
use super::*;
#[test]
fn extract_picks_up_pub_const() {
let src = r#"
pub const ENV_FOO: &str = "GIT_REMOTE_FOO";
other line
pub(crate) const ENV_BAR: &str = "GIT_REMOTE_BAR";
"#;
assert_eq!(
extract_env_constants(src),
vec![
("ENV_FOO".to_owned(), "GIT_REMOTE_FOO".to_owned()),
("ENV_BAR".to_owned(), "GIT_REMOTE_BAR".to_owned()),
]
);
}
#[test]
fn extract_ignores_non_env_constants() {
let src = "pub const TIMEOUT: u64 = 30;";
assert!(extract_env_constants(src).is_empty());
}
#[test]
fn extract_ignores_private_constants() {
let src = r#"const ENV_PRIVATE: &str = "PRIVATE";"#;
assert!(extract_env_constants(src).is_empty());
}
#[test]
fn extract_default_picks_up_pub_u64() {
let src = "pub const DEFAULT_LOCK_TTL_SECONDS: u64 = 60;\n\
pub(crate) const DEFAULT_GRACE_HOURS: u64 = 24;\n\
pub const DEFAULT_BIG: u64 = 1_024;\n";
assert_eq!(
extract_default_constants(src),
vec![
("DEFAULT_LOCK_TTL_SECONDS".to_owned(), 60),
("DEFAULT_GRACE_HOURS".to_owned(), 24),
("DEFAULT_BIG".to_owned(), 1_024),
]
);
}
#[test]
fn extract_default_ignores_other_types() {
let src = "pub const DEFAULT_RATIO: f64 = 0.5;\n\
pub const DEFAULT_NAME: &str = \"hi\";\n";
assert!(extract_default_constants(src).is_empty());
}
#[test]
fn extract_default_ignores_private() {
let src = "const DEFAULT_INTERNAL: u64 = 5;";
assert!(extract_default_constants(src).is_empty());
}
#[test]
fn anchored_scan_extracts_matching_digits() {
let body = "see (falling back to 60s) for details\n\
unrelated 60-something line\n\
and (falling back to 120s) elsewhere";
let hits = find_anchored_values(body, "(falling back to ", "s)");
let values: Vec<u64> = hits.iter().map(|(_, _, v)| *v).collect();
assert_eq!(values, vec![60, 120]);
assert!(hits.iter().all(|(_, snippet, _)| snippet.contains("s)")));
}
#[test]
fn anchored_scan_handles_multiple_matches_on_one_line() {
let body = "first (falling back to 60s) and second (falling back to 30s) on the same line";
let hits = find_anchored_values(body, "(falling back to ", "s)");
let values: Vec<u64> = hits.iter().map(|(_, _, v)| *v).collect();
assert_eq!(values, vec![60, 30]);
}
#[test]
fn anchored_scan_crosses_rust_doc_comment_wrap() {
let body =
" /// Default reads `ENV_X` (falling\n /// back to 60s) — go on.\n";
let hits = find_anchored_values(body, "(falling back to ", "s)");
let values: Vec<u64> = hits.iter().map(|(_, _, v)| *v).collect();
assert_eq!(values, vec![60]);
assert_eq!(hits[0].0, 1);
}
#[test]
fn anchored_scan_handles_non_ascii_after_match() {
let body = "line 1\nlock (falling back to 60s) — em-dash\nline 3";
let hits = find_anchored_values(body, "(falling back to ", "s)");
let values: Vec<u64> = hits.iter().map(|(_, _, v)| *v).collect();
assert_eq!(values, vec![60]);
assert_eq!(hits[0].0, 2);
}
#[test]
fn env_table_default_extracts_value() {
let doc = "intro\n\
| Variable | Default | Effect | Read at |\n\
|---|---|---|---|\n\
| `GIT_REMOTE_OBJECT_STORE_FOO` | `42` | something | `src/foo.rs` |\n\
trailing";
let parsed = parse_env_table_default(doc, "GIT_REMOTE_OBJECT_STORE_FOO");
let (_, cell, value) = parsed.expect("row present");
assert_eq!(value, 42);
assert_eq!(cell, "42");
}
#[test]
fn env_table_default_returns_none_for_non_numeric() {
let doc = "| `GIT_REMOTE_OBJECT_STORE_BAR` | `unset` | x | `src/bar.rs` |";
assert!(parse_env_table_default(doc, "GIT_REMOTE_OBJECT_STORE_BAR").is_none());
}
#[test]
fn env_table_default_returns_none_for_missing_row() {
let doc = "no rows here";
assert!(parse_env_table_default(doc, "GIT_REMOTE_OBJECT_STORE_BAR").is_none());
}
#[test]
fn doc_mention_check_matches_backtick_wrapped_name() {
let doc = "row: | `GIT_REMOTE_OBJECT_STORE_FOO` | unset | x | y |";
assert!(doc_mentions_env_var(doc, "GIT_REMOTE_OBJECT_STORE_FOO"));
}
#[test]
fn doc_mention_check_matches_at_start_or_end_of_input() {
let at_start = "GIT_REMOTE_OBJECT_STORE_FOO appears first";
let at_end = "trailing GIT_REMOTE_OBJECT_STORE_FOO";
assert!(doc_mentions_env_var(
at_start,
"GIT_REMOTE_OBJECT_STORE_FOO"
));
assert!(doc_mentions_env_var(at_end, "GIT_REMOTE_OBJECT_STORE_FOO"));
}
#[test]
fn doc_mention_check_rejects_substring_of_longer_name() {
let doc_with_short = "row: | `FOO` | unset | x | y |";
assert!(
!doc_mentions_env_var(doc_with_short, "FOO_BAR"),
"the new longer var `FOO_BAR` must not be considered \
documented merely because `FOO` appears in the doc"
);
let doc_with_long = "row: | `FOO_BAR` | unset | x | y |";
assert!(
!doc_mentions_env_var(doc_with_long, "FOO"),
"the bare `FOO` must not be considered documented \
merely because `FOO_BAR` appears in the doc"
);
}
#[test]
fn doc_mention_check_rejects_alphanumeric_or_underscore_neighbor() {
assert!(!doc_mentions_env_var("xFOO", "FOO"));
assert!(!doc_mentions_env_var("FOOx", "FOO"));
assert!(!doc_mentions_env_var("FOO9", "FOO"));
assert!(!doc_mentions_env_var("9FOO", "FOO"));
}
}