Skip to main content

testing_conventions/
lint.rs

1//! Integration-test lints (issue #19; rules #48–#52) — the `integration lint`
2//! command.
3//!
4//! A *lint* here is a deterministic style/mechanism check on test code, as
5//! opposed to the structural `colocated-test` / `coverage` rules. This module hosts
6//! the mocking mechanism & style lints; more lints will join them under the
7//! same command.
8//!
9//! Detection is AST-based: each Python test file is parsed with
10//! `rustpython_parser` and the tree is walked with a [`Visitor`].
11//!
12//! Implemented lints:
13//! - **`no-monkeypatch`** (#49): a test/fixture function that declares the
14//!   `monkeypatch` parameter (pytest's fixture). Patch with `unittest.mock`
15//!   wrapped in a `pytest.fixture` instead.
16//! - **`no-inline-patch`** (#50): a `patch(...)` / `patch.object(...)` /
17//!   `patch.dict(...)` call inside a test body — the `with patch(...)` form or a
18//!   bare call. Patches belong in a `pytest.fixture`; a patch *inside* a fixture
19//!   is allowed.
20//! - **`no-environ-mutation`** (#51): direct mutation of `os.environ` —
21//!   `os.environ[...] = …`, `del os.environ[...]`, or a mutating method
22//!   (`update` / `pop` / `setdefault` / `clear` / `popitem`). Set env via
23//!   `patch.dict(os.environ, {...})` instead.
24//! - **`no-constant-patch`** (#52): patching a module-global UPPER_CASE constant,
25//!   e.g. `patch("pkg.config.CACHE_DIR", …)`. Inject config explicitly. Waivable
26//!   per file via the config `exempt` list.
27
28use std::path::{Path, PathBuf};
29
30use anyhow::{anyhow, Context, Result};
31use rustpython_ast::Visitor;
32use rustpython_parser::ast::{
33    self, Arg, Arguments, Constant, Expr, ExprCall, StmtAssign, StmtAsyncFunctionDef,
34    StmtAugAssign, StmtDelete, StmtFunctionDef, StmtIf, StmtImport, StmtImportFrom, WithItem,
35};
36use rustpython_parser::text_size::{TextRange, TextSize};
37use rustpython_parser::Parse;
38
39// `Violation` is shared with the Rust `isolation` lint; it lives in `violation`
40// and is re-exported here so `testing_conventions::lint::Violation` still resolves.
41pub use crate::violation::Violation;
42
43/// Scan the Python test files under `root` and return every lint violation,
44/// sorted by `(file, line)` for deterministic output.
45///
46/// A *Python test file* is `*_test.py` or `conftest.py` (where fixtures live); a
47/// legacy `test_*.py` is ordinary source (#145). Each is parsed and walked. A file
48/// that cannot be read or parsed is an error.
49pub fn find_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
50    let root = root.as_ref();
51    // The dist's own top-level package, for `no-first-party-patch` (#42). Resolved
52    // once for the whole tree; `None` (no declared package) means that rule flags
53    // nothing.
54    let first_party = first_party_package(root);
55    let mut files = Vec::new();
56    collect_python_files(root, &mut files, is_python_test_file)?;
57    files.sort();
58
59    let mut violations = Vec::new();
60    for file in &files {
61        let source = std::fs::read_to_string(file)
62            .with_context(|| format!("reading test file `{}`", file.display()))?;
63        let suite = ast::Suite::parse(&source, &file.to_string_lossy())
64            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
65        let mut visitor = LintVisitor {
66            file,
67            source: &source,
68            fixture_depth: 0,
69            first_party: first_party.as_deref(),
70            violations: Vec::new(),
71        };
72        for stmt in suite {
73            visitor.visit_stmt(stmt);
74        }
75        violations.append(&mut visitor.violations);
76    }
77
78    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
79    Ok(violations)
80}
81
82/// Scan the colocated Python unit tests under `root` and return every
83/// `unmocked-collaborator` violation (#42 slice 2): a first-party collaborator a
84/// unit test imports without mocking it. The Python arm of `unit isolation`
85/// ([`crate::isolation::Language::Python`]).
86///
87/// A *unit test* here is `*_test.py` (not `conftest.py`); a legacy `test_*.py` is
88/// ordinary source (#145). First-party is the dist's own package
89/// ([`first_party_package`]); a tree with no declared package has no first-party
90/// collaborators and so reports nothing.
91pub fn find_unit_isolation_violations(root: impl AsRef<Path>) -> Result<Vec<Violation>> {
92    let root = root.as_ref();
93    // First-party is the dist's own package; with none declared there are no
94    // first-party collaborators to flag.
95    let Some(first_party) = first_party_package(root) else {
96        return Ok(Vec::new());
97    };
98    let mut files = Vec::new();
99    collect_python_files(root, &mut files, is_python_unit_test_file)?;
100    files.sort();
101
102    let mut violations = Vec::new();
103    for file in &files {
104        let source = std::fs::read_to_string(file)
105            .with_context(|| format!("reading test file `{}`", file.display()))?;
106        let suite = ast::Suite::parse(&source, &file.to_string_lossy())
107            .map_err(|err| anyhow!("parsing `{}`: {err}", file.display()))?;
108        let base = unit_under_test_base(file);
109        let mut visitor = UnitIsolationVisitor {
110            source: &source,
111            first_party: &first_party,
112            base: &base,
113            type_checking_depth: 0,
114            imports: Vec::new(),
115            patch_targets: Vec::new(),
116        };
117        for stmt in suite {
118            visitor.visit_stmt(stmt);
119        }
120        // A first-party import that is neither the unit under test nor mocked by
121        // some `patch(...)` in the file is an un-mocked collaborator.
122        for import in &visitor.imports {
123            if import.is_uut || import.is_mocked(&visitor.patch_targets) {
124                continue;
125            }
126            violations.push(Violation {
127                file: file.to_path_buf(),
128                line: import.line,
129                rule: "unmocked-collaborator",
130                message: format!(
131                    "unit test imports `{}` without mocking it — a unit test isolates the \
132                     unit under test, so mock every collaborator (patch it by string in a \
133                     fixture)",
134                    import.display
135                ),
136            });
137        }
138    }
139
140    violations.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
141    Ok(violations)
142}
143
144/// One first-party import seen in a unit test, with what it takes to decide
145/// whether it's the unit under test or mocked.
146struct ImportRecord {
147    /// The module path to name in the message (`myproject.ledger`, `.ledger`).
148    display: String,
149    line: usize,
150    /// `true` when this import *is* the unit under test (never a collaborator).
151    is_uut: bool,
152    /// For `from X import a, b` — the bound symbols (matched against a patch's last
153    /// dotted segment). Empty for a plain module import.
154    symbols: Vec<String>,
155    /// For `import X.Y` — the module path (a patch reaching into it counts as a mock).
156    module: Option<String>,
157}
158
159impl ImportRecord {
160    /// `true` when some `patch("…")` target mocks this import: a matching last
161    /// segment for a `from`-import symbol, or a patch reaching into a module import.
162    fn is_mocked(&self, patch_targets: &[String]) -> bool {
163        let symbol_mocked = patch_targets.iter().any(|target| {
164            let last = target.rsplit('.').next().unwrap_or(target);
165            self.symbols.iter().any(|symbol| symbol == last)
166        });
167        if symbol_mocked {
168            return true;
169        }
170        match &self.module {
171            Some(module) => {
172                let prefix = format!("{module}.");
173                patch_targets
174                    .iter()
175                    .any(|target| target == module || target.starts_with(&prefix))
176            }
177            None => false,
178        }
179    }
180}
181
182/// Walks one parsed unit test, collecting its first-party imports and every
183/// `patch("…")` string target so [`find_unit_isolation_violations`] can pair them.
184/// Imports guarded by `if TYPE_CHECKING:` are type-only (erased at runtime) and
185/// skipped.
186struct UnitIsolationVisitor<'a> {
187    source: &'a str,
188    first_party: &'a str,
189    base: &'a str,
190    type_checking_depth: usize,
191    imports: Vec<ImportRecord>,
192    patch_targets: Vec<String>,
193}
194
195impl Visitor for UnitIsolationVisitor<'_> {
196    fn visit_stmt_import(&mut self, node: StmtImport) {
197        if self.type_checking_depth == 0 {
198            let line = line_of(self.source, node.range.start());
199            for alias in &node.names {
200                let module = alias.name.as_str();
201                if is_checked_import(import_head(module), self.first_party) {
202                    self.imports.push(ImportRecord {
203                        display: module.to_string(),
204                        line,
205                        is_uut: last_segment(module) == self.base,
206                        symbols: Vec::new(),
207                        module: Some(module.to_string()),
208                    });
209                }
210            }
211        }
212        self.generic_visit_stmt_import(node);
213    }
214
215    fn visit_stmt_import_from(&mut self, node: StmtImportFrom) {
216        if self.type_checking_depth == 0 {
217            let level = relative_level(&node);
218            let module = node.module.as_ref().map(|m| m.as_str());
219            // Relative imports are first-party; an absolute import is checked when
220            // its head is first-party or external (third-party / effectful stdlib).
221            let should_check = level > 0
222                || module.is_some_and(|m| is_checked_import(import_head(m), self.first_party));
223            if should_check {
224                let line = line_of(self.source, node.range.start());
225                let dots = ".".repeat(level);
226                match module {
227                    // `from <module> import a, b` — the bound symbols are collaborators.
228                    Some(module) => self.imports.push(ImportRecord {
229                        display: format!("{dots}{module}"),
230                        line,
231                        is_uut: last_segment(module) == self.base,
232                        symbols: node.names.iter().map(|a| a.name.to_string()).collect(),
233                        module: None,
234                    }),
235                    // `from . import sub` — each name is a submodule.
236                    None => {
237                        for alias in &node.names {
238                            let name = alias.name.as_str();
239                            self.imports.push(ImportRecord {
240                                display: format!("{dots}{name}"),
241                                line,
242                                is_uut: name == self.base,
243                                symbols: vec![name.to_string()],
244                                module: None,
245                            });
246                        }
247                    }
248                }
249            }
250        }
251        self.generic_visit_stmt_import_from(node);
252    }
253
254    fn visit_expr_call(&mut self, node: ExprCall) {
255        if is_patch_call(&node) {
256            if let Some(target) = patch_string_target(&node) {
257                self.patch_targets.push(target.to_string());
258            }
259        }
260        self.generic_visit_expr_call(node);
261    }
262
263    fn visit_stmt_if(&mut self, node: StmtIf) {
264        // Imports under `if TYPE_CHECKING:` are type-only — skip the body (the
265        // runtime `else` is still walked). Other `if`s recurse normally.
266        if is_type_checking(node.test.as_ref()) {
267            self.type_checking_depth += 1;
268            for stmt in node.body {
269                self.visit_stmt(stmt);
270            }
271            self.type_checking_depth -= 1;
272            for stmt in node.orelse {
273                self.visit_stmt(stmt);
274            }
275        } else {
276            self.generic_visit_stmt_if(node);
277        }
278    }
279}
280
281/// The leading dotted segment of a module path (`myproject.db` → `myproject`).
282fn import_head(module: &str) -> &str {
283    module.split('.').next().unwrap_or(module)
284}
285
286/// `true` when an import head names a collaborator the unit-isolation rule checks:
287/// **first-party** (the dist package) or **external** — a third-party package, or an
288/// effectful-stdlib module. The test framework and **pure** stdlib are not
289/// collaborators (#121).
290fn is_checked_import(head: &str, first_party: &str) -> bool {
291    if head == first_party {
292        return true; // first-party
293    }
294    if TEST_FRAMEWORK.contains(&head) {
295        return false; // pytest et al. — the harness, never a collaborator
296    }
297    if EFFECTFUL_STDLIB.contains(&head) {
298        return true; // external — effectful stdlib
299    }
300    if STDLIB_MODULES.contains(&head) {
301        return false; // pure stdlib
302    }
303    true // external — a third-party package
304}
305
306/// The test harness — never a collaborator to mock. `unittest` / `unittest.mock`
307/// are stdlib (handled by [`STDLIB_MODULES`]); these are the rest.
308const TEST_FRAMEWORK: &[&str] = &["pytest", "_pytest", "mock"];
309
310/// Standard-library modules that are **effectful at the head** — the README's
311/// External Dependencies (network / subprocess / process & IPC / randomness /
312/// database / low-level OS). **Dual-nature** heads (`os`, `pathlib`, `datetime`,
313/// `time`, `io`, `logging`, `threading`) are deliberately excluded: a pure use
314/// (`os.path.join`, `datetime(2020, 1, 1)`) can't be told from an effectful one at
315/// the import, so the clock / filesystem stay caught by the patch convention, not
316/// here (a documented non-goal). A tunable heuristic, not an exhaustive map.
317const EFFECTFUL_STDLIB: &[&str] = &[
318    "asynchat",
319    "asyncore",
320    "ctypes",
321    "curses",
322    "dbm",
323    "fcntl",
324    "ftplib",
325    "imaplib",
326    "mmap",
327    "msvcrt",
328    "multiprocessing",
329    "nis",
330    "nntplib",
331    "ossaudiodev",
332    "poplib",
333    "pty",
334    "random",
335    "secrets",
336    "select",
337    "selectors",
338    "signal",
339    "smtpd",
340    "smtplib",
341    "socket",
342    "socketserver",
343    "spwd",
344    "sqlite3",
345    "ssl",
346    "subprocess",
347    "syslog",
348    "telnetlib",
349    "termios",
350    "tty",
351    "webbrowser",
352    "winreg",
353    "winsound",
354];
355
356/// Top-level standard-library module names (Python's `sys.stdlib_module_names`).
357/// Used to tell **pure** stdlib (allowed) from a **third-party** package (checked);
358/// the [`EFFECTFUL_STDLIB`] subset is what's actually flagged.
359const STDLIB_MODULES: &[&str] = &[
360    "abc",
361    "aifc",
362    "antigravity",
363    "argparse",
364    "array",
365    "ast",
366    "asynchat",
367    "asyncio",
368    "asyncore",
369    "atexit",
370    "audioop",
371    "base64",
372    "bdb",
373    "binascii",
374    "bisect",
375    "builtins",
376    "bz2",
377    "cProfile",
378    "calendar",
379    "cgi",
380    "cgitb",
381    "chunk",
382    "cmath",
383    "cmd",
384    "code",
385    "codecs",
386    "codeop",
387    "collections",
388    "colorsys",
389    "compileall",
390    "concurrent",
391    "configparser",
392    "contextlib",
393    "contextvars",
394    "copy",
395    "copyreg",
396    "crypt",
397    "csv",
398    "ctypes",
399    "curses",
400    "dataclasses",
401    "datetime",
402    "dbm",
403    "decimal",
404    "difflib",
405    "dis",
406    "distutils",
407    "doctest",
408    "email",
409    "encodings",
410    "ensurepip",
411    "enum",
412    "errno",
413    "faulthandler",
414    "fcntl",
415    "filecmp",
416    "fileinput",
417    "fnmatch",
418    "fractions",
419    "ftplib",
420    "functools",
421    "gc",
422    "genericpath",
423    "getopt",
424    "getpass",
425    "gettext",
426    "glob",
427    "graphlib",
428    "grp",
429    "gzip",
430    "hashlib",
431    "heapq",
432    "hmac",
433    "html",
434    "http",
435    "idlelib",
436    "imaplib",
437    "imghdr",
438    "imp",
439    "importlib",
440    "inspect",
441    "io",
442    "ipaddress",
443    "itertools",
444    "json",
445    "keyword",
446    "lib2to3",
447    "linecache",
448    "locale",
449    "logging",
450    "lzma",
451    "mailbox",
452    "mailcap",
453    "marshal",
454    "math",
455    "mimetypes",
456    "mmap",
457    "modulefinder",
458    "msilib",
459    "msvcrt",
460    "multiprocessing",
461    "netrc",
462    "nis",
463    "nntplib",
464    "nt",
465    "ntpath",
466    "nturl2path",
467    "numbers",
468    "opcode",
469    "operator",
470    "optparse",
471    "os",
472    "ossaudiodev",
473    "pathlib",
474    "pdb",
475    "pickle",
476    "pickletools",
477    "pipes",
478    "pkgutil",
479    "platform",
480    "plistlib",
481    "poplib",
482    "posix",
483    "posixpath",
484    "pprint",
485    "profile",
486    "pstats",
487    "pty",
488    "pwd",
489    "py_compile",
490    "pyclbr",
491    "pydoc",
492    "pydoc_data",
493    "pyexpat",
494    "queue",
495    "quopri",
496    "random",
497    "re",
498    "readline",
499    "reprlib",
500    "resource",
501    "rlcompleter",
502    "runpy",
503    "sched",
504    "secrets",
505    "select",
506    "selectors",
507    "shelve",
508    "shlex",
509    "shutil",
510    "signal",
511    "site",
512    "smtpd",
513    "smtplib",
514    "sndhdr",
515    "socket",
516    "socketserver",
517    "spwd",
518    "sqlite3",
519    "sre_compile",
520    "sre_constants",
521    "sre_parse",
522    "ssl",
523    "stat",
524    "statistics",
525    "string",
526    "stringprep",
527    "struct",
528    "subprocess",
529    "sunau",
530    "symtable",
531    "sys",
532    "sysconfig",
533    "syslog",
534    "tabnanny",
535    "tarfile",
536    "telnetlib",
537    "tempfile",
538    "termios",
539    "textwrap",
540    "this",
541    "threading",
542    "time",
543    "timeit",
544    "tkinter",
545    "token",
546    "tokenize",
547    "tomllib",
548    "trace",
549    "traceback",
550    "tracemalloc",
551    "tty",
552    "turtle",
553    "turtledemo",
554    "types",
555    "typing",
556    "unicodedata",
557    "unittest",
558    "urllib",
559    "uu",
560    "uuid",
561    "venv",
562    "warnings",
563    "wave",
564    "weakref",
565    "webbrowser",
566    "winreg",
567    "winsound",
568    "wsgiref",
569    "xdrlib",
570    "xml",
571    "xmlrpc",
572    "zipapp",
573    "zipfile",
574    "zipimport",
575    "zlib",
576    "zoneinfo",
577];
578
579/// The trailing dotted segment of a module path (`myproject.db` → `db`).
580fn last_segment(module: &str) -> &str {
581    module.rsplit('.').next().unwrap_or(module)
582}
583
584/// The number of leading dots on a `from`-import (`from ..pkg import x` → 2; an
585/// absolute import → 0).
586fn relative_level(node: &StmtImportFrom) -> usize {
587    node.level.map_or(0, |level| level.to_usize())
588}
589
590/// `true` for `TYPE_CHECKING` / `typing.TYPE_CHECKING` — the guard whose body holds
591/// type-only imports.
592fn is_type_checking(test: &Expr) -> bool {
593    match test {
594        Expr::Name(name) => name.id.as_str() == "TYPE_CHECKING",
595        Expr::Attribute(attr) => attr.attr.as_str() == "TYPE_CHECKING",
596        _ => false,
597    }
598}
599
600/// The unit-under-test base name for a test file: `widget_test.py` → `widget`.
601/// Only `*_test.py` reaches here (the unit-isolation scan no longer recognizes a
602/// legacy `test_*.py` — #145), so stripping the `_test` suffix is all it takes.
603fn unit_under_test_base(file: &Path) -> String {
604    let name = file
605        .file_name()
606        .and_then(|n| n.to_str())
607        .unwrap_or_default();
608    let stem = name.strip_suffix(".py").unwrap_or(name);
609    stem.strip_suffix("_test").unwrap_or(stem).to_string()
610}
611
612/// Walks one parsed test file, collecting lint violations. Tracks how deep we
613/// are inside `@pytest.fixture` functions so `no-inline-patch` can allow patches
614/// there while flagging them in test bodies.
615struct LintVisitor<'a> {
616    file: &'a Path,
617    source: &'a str,
618    fixture_depth: usize,
619    /// The dist's own top-level package (#42), or `None` when undiscoverable.
620    first_party: Option<&'a str>,
621    violations: Vec<Violation>,
622}
623
624impl LintVisitor<'_> {
625    fn report(&mut self, range: TextRange, rule: &'static str, message: &str) {
626        self.violations.push(Violation {
627            file: self.file.to_path_buf(),
628            line: line_of(self.source, range.start()),
629            rule,
630            message: message.to_string(),
631        });
632    }
633
634    /// Shared entry for both function kinds: run the parameter lint, then return
635    /// whether this function is a fixture (so the caller bumps `fixture_depth`).
636    fn enter_function(&mut self, args: &Arguments, decorators: &[Expr], range: TextRange) -> bool {
637        // `no-monkeypatch` (#49): the `monkeypatch` parameter is the signal.
638        let takes_monkeypatch = args
639            .posonlyargs
640            .iter()
641            .chain(&args.args)
642            .chain(&args.kwonlyargs)
643            .any(|arg| arg.def.arg.as_str() == "monkeypatch")
644            || arg_named(&args.vararg, "monkeypatch")
645            || arg_named(&args.kwarg, "monkeypatch");
646        if takes_monkeypatch {
647            self.report(
648                range,
649                "no-monkeypatch",
650                "test takes pytest's `monkeypatch` fixture; patch with `unittest.mock` wrapped in a `pytest.fixture` instead",
651            );
652        }
653
654        decorators.iter().any(is_fixture_decorator)
655    }
656}
657
658impl Visitor for LintVisitor<'_> {
659    fn visit_stmt_function_def(&mut self, node: StmtFunctionDef) {
660        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
661        if is_fixture {
662            self.fixture_depth += 1;
663        }
664        self.generic_visit_stmt_function_def(node);
665        if is_fixture {
666            self.fixture_depth -= 1;
667        }
668    }
669
670    fn visit_stmt_async_function_def(&mut self, node: StmtAsyncFunctionDef) {
671        let is_fixture = self.enter_function(&node.args, &node.decorator_list, node.range);
672        if is_fixture {
673            self.fixture_depth += 1;
674        }
675        self.generic_visit_stmt_async_function_def(node);
676        if is_fixture {
677            self.fixture_depth -= 1;
678        }
679    }
680
681    fn visit_expr_call(&mut self, node: ExprCall) {
682        let is_patch = is_patch_call(&node);
683        // `no-inline-patch` (#50): a patch(...) call outside any fixture is a
684        // patch in a test body. Inside a fixture it is the right place.
685        if is_patch && self.fixture_depth == 0 {
686            self.report(
687                node.range,
688                "no-inline-patch",
689                "patch is called inline in a test body; move it into a `pytest.fixture`",
690            );
691        }
692        // `no-constant-patch` (#52): patching a module-global UPPER_CASE constant.
693        // Fires regardless of fixture — config constants are usually patched in one.
694        if is_patch && patches_constant(&node) {
695            self.report(node.range, "no-constant-patch", CONSTANT_PATCH_MSG);
696        }
697        // `no-first-party-patch` (#42): in an integration test, patching a
698        // first-party target — `patch("ourpkg.mod.fn")` — is forbidden; an
699        // integration test runs first-party code for real. Fires regardless of
700        // fixture (the patch belongs in one); only when the dist's own package is
701        // known (`first_party`) and the target's head segment names it.
702        if is_patch {
703            if let Some(pkg) = self.first_party {
704                if patch_string_target(&node).is_some_and(|target| patches_first_party(target, pkg))
705                {
706                    self.report(node.range, "no-first-party-patch", FIRST_PARTY_PATCH_MSG);
707                }
708            }
709        }
710        // `no-environ-mutation` (#51): `os.environ.update(...)` and friends.
711        if is_environ_mutation_call(&node) {
712            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
713        }
714        self.generic_visit_expr_call(node);
715    }
716
717    // The generated `generic_visit_withitem` is a no-op, so a `with patch(...)`
718    // context expression is never walked unless we descend into it here.
719    fn visit_withitem(&mut self, node: WithItem) {
720        self.visit_expr(node.context_expr);
721        if let Some(optional_vars) = node.optional_vars {
722            self.visit_expr(*optional_vars);
723        }
724    }
725
726    // `no-environ-mutation` (#51): `os.environ[...] = …`, augmented assignment,
727    // and `del os.environ[...]`.
728    fn visit_stmt_assign(&mut self, node: StmtAssign) {
729        if node.targets.iter().any(is_os_environ_subscript) {
730            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
731        }
732        self.generic_visit_stmt_assign(node);
733    }
734
735    fn visit_stmt_aug_assign(&mut self, node: StmtAugAssign) {
736        if is_os_environ_subscript(node.target.as_ref()) {
737            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
738        }
739        self.generic_visit_stmt_aug_assign(node);
740    }
741
742    fn visit_stmt_delete(&mut self, node: StmtDelete) {
743        if node.targets.iter().any(is_os_environ_subscript) {
744            self.report(node.range, "no-environ-mutation", ENVIRON_MUTATION_MSG);
745        }
746        self.generic_visit_stmt_delete(node);
747    }
748}
749
750/// `true` when a `*args` / `**kwargs` arg is named `name`.
751fn arg_named(arg: &Option<Box<Arg>>, name: &str) -> bool {
752    arg.as_ref().is_some_and(|arg| arg.arg.as_str() == name)
753}
754
755/// `true` for an `@pytest.fixture` / `@fixture` decorator, with or without a
756/// call (`@pytest.fixture(autouse=True)`).
757fn is_fixture_decorator(decorator: &Expr) -> bool {
758    let target = match decorator {
759        Expr::Call(call) => call.func.as_ref(),
760        other => other,
761    };
762    match target {
763        Expr::Name(name) => name.id.as_str() == "fixture",
764        Expr::Attribute(attr) => attr.attr.as_str() == "fixture",
765        _ => false,
766    }
767}
768
769/// `true` when a call is `patch(...)`, `patch.object(...)`, `patch.dict(...)`, or
770/// the same reached through a module (`mock.patch(...)`, `unittest.mock.patch`).
771fn is_patch_call(call: &ExprCall) -> bool {
772    match call.func.as_ref() {
773        Expr::Name(name) => name.id.as_str() == "patch",
774        Expr::Attribute(attr) => {
775            let name = attr.attr.as_str();
776            name == "patch"
777                || ((name == "object" || name == "dict") && attr_base_is_patch(attr.value.as_ref()))
778        }
779        _ => false,
780    }
781}
782
783/// `true` when an attribute's base resolves to `patch` — the receiver of
784/// `patch.object` / `patch.dict`.
785fn attr_base_is_patch(expr: &Expr) -> bool {
786    match expr {
787        Expr::Name(name) => name.id.as_str() == "patch",
788        Expr::Attribute(attr) => attr.attr.as_str() == "patch",
789        _ => false,
790    }
791}
792
793/// Message for the `no-constant-patch` lint.
794const CONSTANT_PATCH_MSG: &str = "patches a module-global config constant; inject config explicitly (a consumer that did `from pkg import CONSTANT` snapshots the value at import time and ignores the patch)";
795
796/// Message for the `no-first-party-patch` lint (#42).
797const FIRST_PARTY_PATCH_MSG: &str = "patches a first-party target; an integration test must run first-party code for real — only third-party packages and effectful stdlib may be patched";
798
799/// The string-literal first argument of a `patch(...)` call — the dotted target
800/// like `"pkg.mod.attr"`. `None` when the first argument isn't a string literal
801/// (a non-literal target can't be classified deterministically).
802fn patch_string_target(call: &ExprCall) -> Option<&str> {
803    if let Some(Expr::Constant(constant)) = call.args.first() {
804        if let Constant::Str(target) = &constant.value {
805            return Some(target.as_str());
806        }
807    }
808    None
809}
810
811/// `true` when a `patch(...)` call's first string argument names a module-global
812/// UPPER_CASE constant, e.g. `patch("pkg.config.CACHE_DIR", …)`.
813fn patches_constant(call: &ExprCall) -> bool {
814    patch_string_target(call)
815        .and_then(|target| target.rsplit('.').next())
816        .is_some_and(is_upper_constant)
817}
818
819/// `true` when a patch `target`'s head dotted segment names the first-party
820/// package `pkg`, e.g. `target = "ourpkg.mod.fn"`, `pkg = "ourpkg"` (#42).
821fn patches_first_party(target: &str, pkg: &str) -> bool {
822    target
823        .split('.')
824        .next()
825        .is_some_and(|head| !head.is_empty() && head == pkg)
826}
827
828/// `true` for an ALL-CAPS constant name — letters uppercase, digits and
829/// underscores allowed, at least one letter (`CACHE_DIR`, `DEBUG`, `MAX_SIZE`).
830fn is_upper_constant(name: &str) -> bool {
831    !name.is_empty()
832        && name
833            .chars()
834            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
835        && name.chars().any(|c| c.is_ascii_uppercase())
836}
837
838/// Message for the `no-environ-mutation` lint.
839const ENVIRON_MUTATION_MSG: &str =
840    "os.environ is mutated directly; set env via `patch.dict(os.environ, {...})` instead";
841
842/// `true` for the expression `os.environ`.
843fn is_os_environ(expr: &Expr) -> bool {
844    matches!(
845        expr,
846        Expr::Attribute(attr)
847            if attr.attr.as_str() == "environ"
848                && matches!(attr.value.as_ref(), Expr::Name(name) if name.id.as_str() == "os")
849    )
850}
851
852/// `true` for `os.environ[...]` — a subscript of `os.environ`, the form used as
853/// an assignment or `del` target.
854fn is_os_environ_subscript(expr: &Expr) -> bool {
855    matches!(expr, Expr::Subscript(sub) if is_os_environ(sub.value.as_ref()))
856}
857
858/// `true` for a mutating method call on `os.environ` (`os.environ.update(...)`
859/// and friends).
860fn is_environ_mutation_call(call: &ExprCall) -> bool {
861    matches!(
862        call.func.as_ref(),
863        Expr::Attribute(attr)
864            if is_os_environ(attr.value.as_ref()) && is_environ_mutator(attr.attr.as_str())
865    )
866}
867
868/// `true` for a `dict` method that mutates in place.
869fn is_environ_mutator(method: &str) -> bool {
870    matches!(
871        method,
872        "update" | "pop" | "setdefault" | "clear" | "popitem"
873    )
874}
875
876/// The 1-based line containing byte `offset` in `source`.
877fn line_of(source: &str, offset: TextSize) -> usize {
878    let offset = (u32::from(offset) as usize).min(source.len());
879    source.as_bytes()[..offset]
880        .iter()
881        .filter(|&&byte| byte == b'\n')
882        .count()
883        + 1
884}
885
886/// The dist's own top-level import package — the first-party root for
887/// `no-first-party-patch` (#42).
888///
889/// Walk up from `root` to the nearest `pyproject.toml`, read its `[project].name`,
890/// and [normalize](normalize_dist_name) it to an import name. Returns `None` when
891/// no `pyproject.toml` (with a `[project].name`) is found, so a tree with no
892/// declared package flags nothing rather than guess. The walk stops at a `.git`
893/// boundary so it can't escape the project into an unrelated `pyproject.toml`.
894fn first_party_package(root: &Path) -> Option<String> {
895    for dir in root.ancestors() {
896        let candidate = dir.join("pyproject.toml");
897        if candidate.is_file() {
898            return read_project_name(&candidate).map(|name| normalize_dist_name(&name));
899        }
900        if dir.join(".git").exists() {
901            break;
902        }
903    }
904    None
905}
906
907/// `[project].name` from a `pyproject.toml`, if present and a string.
908fn read_project_name(path: &Path) -> Option<String> {
909    let contents = std::fs::read_to_string(path).ok()?;
910    let value: toml::Value = toml::from_str(&contents).ok()?;
911    value
912        .get("project")?
913        .get("name")?
914        .as_str()
915        .map(str::to_owned)
916}
917
918/// Normalize a distribution name to its import package name: lower-cased, with
919/// `-` and `.` mapped to `_` (PEP 503-flavoured — `My-Project` → `my_project`).
920fn normalize_dist_name(name: &str) -> String {
921    name.trim().to_ascii_lowercase().replace(['-', '.'], "_")
922}
923
924/// Recursively collect every Python file under `dir` matching `is_match` into `out`.
925fn collect_python_files(
926    dir: &Path,
927    out: &mut Vec<PathBuf>,
928    is_match: fn(&Path) -> bool,
929) -> Result<()> {
930    let entries =
931        std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
932    for entry in entries {
933        let path = entry
934            .with_context(|| format!("reading an entry under `{}`", dir.display()))?
935            .path();
936        if path.is_dir() {
937            collect_python_files(&path, out, is_match)?;
938        } else if is_match(&path) {
939            out.push(path);
940        }
941    }
942    Ok(())
943}
944
945/// `true` for a file the integration lints scan: `*_test.py` or `conftest.py`
946/// (where fixtures live). A legacy `test_*.py` is ordinary source (#112, #145), so
947/// it is not scanned.
948fn is_python_test_file(path: &Path) -> bool {
949    let name = path
950        .file_name()
951        .and_then(|n| n.to_str())
952        .unwrap_or_default();
953    name == "conftest.py" || name.ends_with("_test.py")
954}
955
956/// `true` for a colocated *unit* test the isolation rule scans: `*_test.py`. A
957/// legacy `test_*.py` is ordinary source (#112, #145), and `conftest.py` holds
958/// fixtures (not a unit) — neither is scanned.
959fn is_python_unit_test_file(path: &Path) -> bool {
960    let name = path
961        .file_name()
962        .and_then(|n| n.to_str())
963        .unwrap_or_default();
964    name.ends_with("_test.py")
965}
966
967#[cfg(test)]
968mod tests {
969    use super::*;
970    use std::sync::atomic::{AtomicU64, Ordering};
971
972    /// A throwaway directory, removed on drop — for the `pyproject.toml` discovery.
973    struct TempDir(PathBuf);
974
975    impl TempDir {
976        fn new() -> Self {
977            static COUNTER: AtomicU64 = AtomicU64::new(0);
978            let dir = std::env::temp_dir().join(format!(
979                "tc-lint-{}-{}",
980                std::process::id(),
981                COUNTER.fetch_add(1, Ordering::Relaxed),
982            ));
983            std::fs::create_dir_all(&dir).unwrap();
984            TempDir(dir)
985        }
986
987        fn write(&self, name: &str, contents: &str) {
988            let path = self.0.join(name);
989            if let Some(parent) = path.parent() {
990                std::fs::create_dir_all(parent).unwrap();
991            }
992            std::fs::write(path, contents).unwrap();
993        }
994    }
995
996    impl Drop for TempDir {
997        fn drop(&mut self) {
998            let _ = std::fs::remove_dir_all(&self.0);
999        }
1000    }
1001
1002    #[test]
1003    fn normalize_dist_name_maps_to_import_name() {
1004        assert_eq!(normalize_dist_name("My-Project"), "my_project");
1005        assert_eq!(normalize_dist_name("ns.pkg"), "ns_pkg");
1006        assert_eq!(normalize_dist_name("  myproject  "), "myproject");
1007        assert_eq!(normalize_dist_name("myproject"), "myproject");
1008    }
1009
1010    /// Parse `src` (a single expression statement) and return its call.
1011    fn parse_call(src: &str) -> ExprCall {
1012        let suite = ast::Suite::parse(src, "t.py").expect("snippet should parse");
1013        match suite.into_iter().next().expect("one statement") {
1014            ast::Stmt::Expr(stmt) => match *stmt.value {
1015                Expr::Call(call) => call,
1016                other => panic!("expected a call, got {other:?}"),
1017            },
1018            other => panic!("expected an expression statement, got {other:?}"),
1019        }
1020    }
1021
1022    #[test]
1023    fn patch_string_target_only_reads_string_literals() {
1024        let str_call = parse_call("patch(\"pkg.mod.attr\")\n");
1025        assert_eq!(patch_string_target(&str_call), Some("pkg.mod.attr"));
1026        // A non-string literal (`patch(42)`), a name (`patch(target)`), and no args
1027        // all yield `None` — a non-literal target can't be classified.
1028        let int_call = parse_call("patch(42)\n");
1029        assert_eq!(patch_string_target(&int_call), None);
1030        let name_call = parse_call("patch(target)\n");
1031        assert_eq!(patch_string_target(&name_call), None);
1032        let empty_call = parse_call("patch()\n");
1033        assert_eq!(patch_string_target(&empty_call), None);
1034    }
1035
1036    #[test]
1037    fn patches_first_party_matches_head_segment() {
1038        assert!(patches_first_party("myproject.ledger.record", "myproject"));
1039        assert!(patches_first_party("myproject", "myproject"));
1040        assert!(!patches_first_party("requests.get", "myproject"));
1041        assert!(!patches_first_party("myproject_extra.x", "myproject"));
1042        assert!(!patches_first_party("", "myproject"));
1043        assert!(!patches_first_party(".leading", "myproject"));
1044    }
1045
1046    #[test]
1047    fn first_party_package_reads_pyproject_name() {
1048        let tree = TempDir::new();
1049        tree.write(
1050            "pyproject.toml",
1051            "[project]\nname = \"My-Project\"\nversion = \"0.0.0\"\n",
1052        );
1053        // Normalized to the import name.
1054        assert_eq!(first_party_package(&tree.0).as_deref(), Some("my_project"));
1055    }
1056
1057    #[test]
1058    fn first_party_package_is_none_without_a_project_name() {
1059        let tree = TempDir::new();
1060        // A pyproject with no `[project].name` — found, but no usable package.
1061        tree.write("pyproject.toml", "[build-system]\nrequires = []\n");
1062        tree.write(".git", "");
1063        assert_eq!(first_party_package(&tree.0), None);
1064    }
1065
1066    #[test]
1067    fn first_party_package_is_none_when_absent() {
1068        // No pyproject.toml anywhere up the (temp) tree → nothing first-party.
1069        let tree = TempDir::new();
1070        assert_eq!(first_party_package(&tree.0), None);
1071    }
1072
1073    /// Run the unit-isolation visitor over `source` and return the flagged
1074    /// (un-mocked, non-UUT first-party) import displays.
1075    fn unmocked(base: &str, first_party: &str, source: &str) -> Vec<String> {
1076        let suite = ast::Suite::parse(source, "t.py").expect("snippet should parse");
1077        let mut visitor = UnitIsolationVisitor {
1078            source,
1079            first_party,
1080            base,
1081            type_checking_depth: 0,
1082            imports: Vec::new(),
1083            patch_targets: Vec::new(),
1084        };
1085        for stmt in suite {
1086            visitor.visit_stmt(stmt);
1087        }
1088        visitor
1089            .imports
1090            .iter()
1091            .filter(|i| !i.is_uut && !i.is_mocked(&visitor.patch_targets))
1092            .map(|i| i.display.clone())
1093            .collect()
1094    }
1095
1096    #[test]
1097    fn import_head_and_last_segment() {
1098        assert_eq!(import_head("myproject.db.conn"), "myproject");
1099        assert_eq!(import_head("requests"), "requests");
1100        assert_eq!(last_segment("myproject.db.conn"), "conn");
1101        assert_eq!(last_segment("widget"), "widget");
1102    }
1103
1104    #[test]
1105    fn unit_under_test_base_strips_test_suffix() {
1106        assert_eq!(
1107            unit_under_test_base(Path::new("pkg/widget_test.py")),
1108            "widget"
1109        );
1110        // Only `*_test.py` reaches here (#145), so a legacy `test_*.py` keeps its
1111        // `test_` prefix, and a name without the `_test` suffix is its own stem.
1112        assert_eq!(
1113            unit_under_test_base(Path::new("test_widget.py")),
1114            "test_widget"
1115        );
1116        assert_eq!(unit_under_test_base(Path::new("plain.py")), "plain");
1117    }
1118
1119    #[test]
1120    fn recognizes_python_unit_test_files() {
1121        assert!(is_python_unit_test_file(Path::new("widget_test.py")));
1122        assert!(is_python_unit_test_file(Path::new("pkg/widget_test.py")));
1123        // A legacy `test_*.py` is ordinary source, not a unit test (#145).
1124        assert!(!is_python_unit_test_file(Path::new("test_widget.py")));
1125        // conftest holds fixtures, not a unit — excluded from unit isolation.
1126        assert!(!is_python_unit_test_file(Path::new("conftest.py")));
1127        assert!(!is_python_unit_test_file(Path::new("widget.py")));
1128    }
1129
1130    #[test]
1131    fn is_mocked_matches_symbol_last_segment_and_module_prefix() {
1132        let symbol = ImportRecord {
1133            display: "myproject.ledger".into(),
1134            line: 1,
1135            is_uut: false,
1136            symbols: vec!["record".into()],
1137            module: None,
1138        };
1139        // The consuming-module patch and the source patch both mock it.
1140        assert!(symbol.is_mocked(&["myproject.widget.record".into()]));
1141        assert!(symbol.is_mocked(&["myproject.ledger.record".into()]));
1142        assert!(!symbol.is_mocked(&["myproject.widget.other".into()]));
1143
1144        let module = ImportRecord {
1145            display: "myproject.db".into(),
1146            line: 1,
1147            is_uut: false,
1148            symbols: Vec::new(),
1149            module: Some("myproject.db".into()),
1150        };
1151        assert!(module.is_mocked(&["myproject.db.conn".into()])); // reaches into it
1152        assert!(module.is_mocked(&["myproject.db".into()])); // the module itself
1153        assert!(!module.is_mocked(&["myproject.dbx.y".into()])); // a different module
1154    }
1155
1156    #[test]
1157    fn visitor_flags_first_party_and_external_collaborators() {
1158        // The UUT is left alone; the first-party collaborator and the third-party
1159        // import are both flagged (slice 3 broadened the rule to external deps).
1160        let found = unmocked(
1161            "widget",
1162            "myproject",
1163            "from myproject.widget import build\n\
1164             from myproject.ledger import record\n\
1165             import requests\n",
1166        );
1167        assert_eq!(
1168            found,
1169            vec!["myproject.ledger".to_string(), "requests".to_string()]
1170        );
1171    }
1172
1173    #[test]
1174    fn visitor_clears_a_mocked_collaborator() {
1175        // The imported `record` is patched (consuming-module name) → not flagged.
1176        let found = unmocked(
1177            "widget",
1178            "myproject",
1179            "from myproject.ledger import record\npatch(\"myproject.widget.record\")\n",
1180        );
1181        assert!(found.is_empty(), "got: {found:?}");
1182    }
1183
1184    #[test]
1185    fn visitor_handles_module_and_relative_imports() {
1186        // A first-party module import, not the UUT, un-mocked → flagged.
1187        assert_eq!(
1188            unmocked("widget", "myproject", "import myproject.db\n"),
1189            vec!["myproject.db".to_string()]
1190        );
1191        // `import myproject.db` reached by a patch → mocked.
1192        assert!(unmocked(
1193            "widget",
1194            "myproject",
1195            "import myproject.db\npatch(\"myproject.db.connect\")\n"
1196        )
1197        .is_empty());
1198        // Relative imports are first-party; `from . import widget` is the UUT.
1199        assert_eq!(
1200            unmocked("widget", "myproject", "from .ledger import record\n"),
1201            vec![".ledger".to_string()]
1202        );
1203        assert_eq!(
1204            unmocked(
1205                "widget",
1206                "myproject",
1207                "from . import ledger\nfrom . import widget\n"
1208            ),
1209            vec![".ledger".to_string()]
1210        );
1211    }
1212
1213    #[test]
1214    fn visitor_skips_type_checking_imports() {
1215        // A first-party import guarded by TYPE_CHECKING is type-only — not a runtime
1216        // collaborator; the runtime `else` import is still seen.
1217        let found = unmocked(
1218            "widget",
1219            "myproject",
1220            "if TYPE_CHECKING:\n    from myproject.models import Widget\nelse:\n    from myproject.ledger import record\n",
1221        );
1222        assert_eq!(found, vec!["myproject.ledger".to_string()]);
1223    }
1224
1225    #[test]
1226    fn is_checked_import_classifies_origins() {
1227        assert!(is_checked_import("myproject", "myproject")); // first-party
1228        assert!(!is_checked_import("pytest", "myproject")); // test framework
1229        assert!(!is_checked_import("_pytest", "myproject"));
1230        assert!(is_checked_import("subprocess", "myproject")); // effectful stdlib
1231        assert!(is_checked_import("socket", "myproject"));
1232        assert!(!is_checked_import("json", "myproject")); // pure stdlib
1233        assert!(!is_checked_import("dataclasses", "myproject"));
1234        assert!(is_checked_import("requests", "myproject")); // third-party
1235        assert!(is_checked_import("stripe", "myproject"));
1236        // dual-nature stdlib heads stay pure (not flagged) — caught by patching, not import
1237        assert!(!is_checked_import("os", "myproject"));
1238        assert!(!is_checked_import("pathlib", "myproject"));
1239        assert!(!is_checked_import("datetime", "myproject"));
1240    }
1241
1242    #[test]
1243    fn visitor_flags_external_collaborators() {
1244        // Third-party + effectful stdlib are flagged; pure stdlib and the framework aren't.
1245        let found = unmocked(
1246            "widget",
1247            "myproject",
1248            "import requests\nimport subprocess\nimport json\nimport pytest\n",
1249        );
1250        assert_eq!(found.len(), 2, "got: {found:?}");
1251        assert!(found.contains(&"requests".to_string()));
1252        assert!(found.contains(&"subprocess".to_string()));
1253    }
1254
1255    #[test]
1256    fn visitor_type_checking_variants_and_plain_if() {
1257        // `typing.TYPE_CHECKING` (attribute form) guards type-only imports — both
1258        // the `from`-import and the plain module import are skipped.
1259        assert!(unmocked(
1260            "widget",
1261            "myproject",
1262            "if typing.TYPE_CHECKING:\n    from myproject.models import W\n    import myproject.db\n"
1263        )
1264        .is_empty());
1265        // A plain `if` (not TYPE_CHECKING) is walked normally — its first-party
1266        // import is still a collaborator.
1267        assert_eq!(
1268            unmocked(
1269                "widget",
1270                "myproject",
1271                "if ready == 1:\n    from myproject.ledger import record\n"
1272            ),
1273            vec!["myproject.ledger".to_string()]
1274        );
1275    }
1276
1277    #[test]
1278    fn find_unit_isolation_without_pyproject_reports_nothing() {
1279        // No declared package → no first-party collaborators (the early return).
1280        let tree = TempDir::new();
1281        tree.write("widget_test.py", "from myproject.ledger import record\n");
1282        tree.write(".git", "");
1283        assert!(find_unit_isolation_violations(&tree.0)
1284            .expect("a readable tree should succeed")
1285            .is_empty());
1286    }
1287
1288    #[test]
1289    fn find_unit_isolation_walks_subdirs_and_flags() {
1290        let tree = TempDir::new();
1291        tree.write("pyproject.toml", "[project]\nname = \"myproject\"\n");
1292        // A nested unit test — exercises the directory recursion.
1293        tree.write("pkg/thing_test.py", "from myproject.ledger import record\n");
1294        let found =
1295            find_unit_isolation_violations(&tree.0).expect("a readable tree should succeed");
1296        assert_eq!(found.len(), 1, "got: {found:?}");
1297        assert_eq!(found[0].rule, "unmocked-collaborator");
1298        assert!(found[0].message.contains("myproject.ledger"));
1299    }
1300
1301    #[test]
1302    fn recognizes_python_test_files() {
1303        assert!(is_python_test_file(Path::new("widget_test.py")));
1304        assert!(is_python_test_file(Path::new("pkg/widget_test.py")));
1305        assert!(is_python_test_file(Path::new("conftest.py")));
1306        // A legacy `test_*.py` is ordinary source, not a test file (#145).
1307        assert!(!is_python_test_file(Path::new("test_widget.py")));
1308    }
1309
1310    #[test]
1311    fn ignores_non_test_files() {
1312        assert!(!is_python_test_file(Path::new("widget.py")));
1313        assert!(!is_python_test_file(Path::new("conftest.pyi")));
1314        assert!(!is_python_test_file(Path::new("README.md")));
1315        assert!(!is_python_test_file(Path::new("testing.py")));
1316    }
1317
1318    #[test]
1319    fn line_of_counts_newlines() {
1320        let src = "a\nb\nc\n";
1321        assert_eq!(line_of(src, TextSize::from(0)), 1);
1322        assert_eq!(line_of(src, TextSize::from(2)), 2);
1323        assert_eq!(line_of(src, TextSize::from(4)), 3);
1324    }
1325
1326    #[test]
1327    fn recognizes_environ_mutators() {
1328        assert!(is_environ_mutator("update"));
1329        assert!(is_environ_mutator("pop"));
1330        assert!(is_environ_mutator("clear"));
1331        assert!(!is_environ_mutator("get"));
1332        assert!(!is_environ_mutator("keys"));
1333    }
1334
1335    #[test]
1336    fn recognizes_upper_constants() {
1337        assert!(is_upper_constant("CACHE_DIR"));
1338        assert!(is_upper_constant("DEBUG"));
1339        assert!(is_upper_constant("MAX_2"));
1340        assert!(!is_upper_constant("cache_dir"));
1341        assert!(!is_upper_constant("CacheDir"));
1342        assert!(!is_upper_constant("fetch"));
1343        assert!(!is_upper_constant(""));
1344        assert!(!is_upper_constant("_"));
1345        assert!(!is_upper_constant("123"));
1346    }
1347}