use scribe_core::{Result, ScribeError};
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use ignore::{overrides::OverrideBuilder, WalkBuilder};
use serde::{Deserialize, Serialize};
#[derive(Debug)]
pub struct GitignoreMatcher {
patterns: Vec<GitignorePattern>,
ignore_files: Vec<IgnoreFile>,
overrides: Option<ignore::overrides::Override>,
case_sensitive: bool,
require_literal_separator: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitignorePattern {
pub original: String,
pub pattern: String,
pub negated: bool,
pub directory_only: bool,
pub anchored: bool,
pub rule_type: GitignoreRule,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum GitignoreRule {
Include,
Exclude,
Comment,
Empty,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IgnoreFile {
pub path: PathBuf,
pub ignore_type: IgnoreType,
pub patterns: Vec<GitignorePattern>,
pub line_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum IgnoreType {
Gitignore,
GlobalGitignore,
CustomIgnore,
DotIgnore,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IgnoreMatchResult {
pub ignored: bool,
pub matched_pattern: Option<String>,
pub matched_file: Option<PathBuf>,
pub rule_type: GitignoreRule,
pub line_number: Option<usize>,
}
impl GitignorePattern {
pub fn new(line: &str) -> Result<Self> {
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok(Self {
original: line.to_string(),
pattern: String::new(),
negated: false,
directory_only: false,
anchored: false,
rule_type: GitignoreRule::Empty,
});
}
if trimmed.starts_with('#') {
return Ok(Self {
original: line.to_string(),
pattern: trimmed.to_string(),
negated: false,
directory_only: false,
anchored: false,
rule_type: GitignoreRule::Comment,
});
}
let mut pattern = trimmed.to_string();
let mut negated = false;
let mut directory_only = false;
let mut anchored = false;
if pattern.starts_with('!') {
negated = true;
pattern = pattern[1..].to_string();
}
if pattern.ends_with('/') {
directory_only = true;
pattern = pattern.trim_end_matches('/').to_string();
}
if pattern.starts_with('/') {
anchored = true;
pattern = pattern[1..].to_string();
}
let rule_type = if negated {
GitignoreRule::Include
} else {
GitignoreRule::Exclude
};
Ok(Self {
original: line.to_string(),
pattern,
negated,
directory_only,
anchored,
rule_type,
})
}
pub fn matches<P: AsRef<Path>>(
&self,
path: P,
is_directory: bool,
case_sensitive: bool,
) -> bool {
if matches!(
self.rule_type,
GitignoreRule::Comment | GitignoreRule::Empty
) {
return false;
}
let path_str = path.as_ref().to_string_lossy();
self.matches_glob(&self.pattern, &path_str, is_directory, case_sensitive)
}
fn to_glob_pattern(&self) -> String {
let pattern = self.pattern.clone();
if self.anchored {
pattern
} else {
if pattern.contains('/') {
format!("**/{}", pattern)
} else {
format!("**/{}", pattern)
}
}
}
fn matches_glob(
&self,
pattern: &str,
path: &str,
is_directory: bool,
case_sensitive: bool,
) -> bool {
if pattern.contains("**") {
let parts: Vec<&str> = pattern.split("**").collect();
if parts.len() == 2 {
let prefix = parts[0];
let suffix = parts[1].trim_start_matches('/');
if prefix.is_empty() {
if suffix.contains('*') {
let path_parts: Vec<&str> = path.split('/').collect();
return path_parts
.iter()
.any(|part| self.wildcard_match(suffix, part, case_sensitive));
} else {
return path.ends_with(suffix) || path.contains(&format!("/{}", suffix));
}
} else if suffix.is_empty() {
return path.starts_with(prefix.trim_end_matches('/'));
} else {
return path.starts_with(prefix.trim_end_matches('/'))
&& (path.ends_with(suffix) || path.contains(&format!("/{}", suffix)));
}
}
}
if pattern.contains('*') {
return self.wildcard_match(pattern, path, case_sensitive);
}
if self.directory_only {
if case_sensitive {
if self.anchored {
let dir_pattern = format!("{}/", pattern);
path.starts_with(&dir_pattern) || (path == pattern && is_directory)
} else {
let dir_pattern = format!("{}/", pattern);
let component_pattern = format!("/{}", pattern);
path.starts_with(&dir_pattern)
|| (path == pattern && is_directory)
|| path.contains(&dir_pattern)
|| (path.ends_with(&component_pattern) && is_directory)
}
} else {
let path_lower = path.to_ascii_lowercase();
let pattern_lower = pattern.to_ascii_lowercase();
let dir_pattern_lower = format!("{}/", pattern_lower);
let component_pattern_lower = format!("/{}", pattern_lower);
if self.anchored {
path_lower.starts_with(&dir_pattern_lower)
|| (path_lower == pattern_lower && is_directory)
} else {
path_lower.starts_with(&dir_pattern_lower)
|| (path_lower == pattern_lower && is_directory)
|| path_lower.contains(&dir_pattern_lower)
|| (path_lower.ends_with(&component_pattern_lower) && is_directory)
}
}
} else {
let component_pattern = format!("/{}", pattern);
if case_sensitive {
path == pattern || path.ends_with(&component_pattern)
} else {
path.to_ascii_lowercase() == pattern.to_ascii_lowercase()
|| path
.to_ascii_lowercase()
.ends_with(&component_pattern.to_ascii_lowercase())
}
}
}
fn wildcard_match(&self, pattern: &str, text: &str, case_sensitive: bool) -> bool {
let pattern_chars: Vec<char> = pattern.chars().collect();
let text_chars: Vec<char> = text.chars().collect();
self.wildcard_match_recursive(&pattern_chars, &text_chars, 0, 0, case_sensitive)
}
fn wildcard_match_recursive(
&self,
pattern: &[char],
text: &[char],
p: usize,
t: usize,
case_sensitive: bool,
) -> bool {
if p == pattern.len() {
return t == text.len();
}
if pattern[p] == '*' {
if self.wildcard_match_recursive(pattern, text, p + 1, t, case_sensitive) {
return true;
}
for i in t..text.len() {
if text[i] == '/' {
break; }
if self.wildcard_match_recursive(pattern, text, p + 1, i + 1, case_sensitive) {
return true;
}
}
false
} else if pattern[p] == '?' {
if t < text.len() {
self.wildcard_match_recursive(pattern, text, p + 1, t + 1, case_sensitive)
} else {
false
}
} else {
if t < text.len() {
let chars_match = if case_sensitive {
pattern[p] == text[t]
} else {
pattern[p].to_ascii_lowercase() == text[t].to_ascii_lowercase()
};
if chars_match {
self.wildcard_match_recursive(pattern, text, p + 1, t + 1, case_sensitive)
} else {
false
}
} else {
false
}
}
}
pub fn is_comment(&self) -> bool {
self.rule_type == GitignoreRule::Comment
}
pub fn is_empty(&self) -> bool {
self.rule_type == GitignoreRule::Empty
}
pub fn effective_pattern(&self) -> &str {
&self.pattern
}
}
impl GitignoreMatcher {
pub fn new() -> Self {
Self {
patterns: Vec::new(),
ignore_files: Vec::new(),
overrides: None,
case_sensitive: true,
require_literal_separator: false,
}
}
pub fn case_insensitive() -> Self {
Self {
patterns: Vec::new(),
ignore_files: Vec::new(),
overrides: None,
case_sensitive: false,
require_literal_separator: false,
}
}
pub fn add_pattern(&mut self, pattern: &str) -> Result<()> {
let gitignore_pattern = GitignorePattern::new(pattern)?;
self.patterns.push(gitignore_pattern);
self.invalidate_overrides();
Ok(())
}
pub fn add_patterns<I, S>(&mut self, patterns: I) -> Result<()>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
for pattern in patterns {
self.add_pattern(pattern.as_ref())?;
}
Ok(())
}
pub fn add_gitignore_file<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
let path = path.as_ref();
let ignore_type = self.determine_ignore_type(path);
let ignore_file = self.load_ignore_file(path, ignore_type)?;
for pattern in &ignore_file.patterns {
self.patterns.push(pattern.clone());
}
self.ignore_files.push(ignore_file);
self.invalidate_overrides();
Ok(())
}
pub fn add_gitignore_files<P, I>(&mut self, paths: I) -> Result<()>
where
P: AsRef<Path>,
I: IntoIterator<Item = P>,
{
for path in paths {
self.add_gitignore_file(path)?;
}
Ok(())
}
pub fn is_ignored<P: AsRef<Path>>(&mut self, path: P) -> Result<bool> {
let result = self.match_path(path)?;
Ok(result.ignored)
}
pub fn match_path<P: AsRef<Path>>(&mut self, path: P) -> Result<IgnoreMatchResult> {
let path = path.as_ref();
let path_str = path.to_string_lossy();
let is_directory = path_str.ends_with('/') || path.is_dir();
let mut result = IgnoreMatchResult {
ignored: false,
matched_pattern: None,
matched_file: None,
rule_type: GitignoreRule::Exclude,
line_number: None,
};
for (index, pattern) in self.patterns.iter().enumerate().rev() {
if pattern.matches(path, is_directory, self.case_sensitive) {
result.matched_pattern = Some(pattern.original.clone());
result.rule_type = pattern.rule_type.clone();
let mut line_count = 0;
for ignore_file in &self.ignore_files {
if index < line_count + ignore_file.patterns.len() {
result.matched_file = Some(ignore_file.path.clone());
result.line_number = Some(index - line_count + 1);
break;
}
line_count += ignore_file.patterns.len();
}
match pattern.rule_type {
GitignoreRule::Exclude => {
result.ignored = true;
}
GitignoreRule::Include => {
result.ignored = false; }
_ => continue, }
break;
}
}
Ok(result)
}
pub fn filter_paths<P>(&mut self, paths: &[P]) -> Result<Vec<P>>
where
P: AsRef<Path> + Clone,
{
if self.overrides.is_none() {
self.build_overrides()?;
}
let mut result = Vec::new();
for path in paths {
if !self.is_ignored(path)? {
result.push(path.clone());
}
}
Ok(result)
}
pub fn ignore_files(&self) -> &[IgnoreFile] {
&self.ignore_files
}
pub fn patterns(&self) -> &[GitignorePattern] {
&self.patterns
}
pub fn clear(&mut self) {
self.patterns.clear();
self.ignore_files.clear();
self.invalidate_overrides();
}
pub fn stats(&self) -> GitignoreStats {
let total_patterns = self.patterns.len();
let exclude_patterns = self
.patterns
.iter()
.filter(|p| p.rule_type == GitignoreRule::Exclude)
.count();
let include_patterns = self
.patterns
.iter()
.filter(|p| p.rule_type == GitignoreRule::Include)
.count();
let comment_lines = self
.patterns
.iter()
.filter(|p| p.rule_type == GitignoreRule::Comment)
.count();
GitignoreStats {
total_patterns,
exclude_patterns,
include_patterns,
comment_lines,
ignore_files: self.ignore_files.len(),
}
}
fn load_ignore_file(&self, path: &Path, ignore_type: IgnoreType) -> Result<IgnoreFile> {
if !path.exists() {
return Err(ScribeError::path(
format!("Ignore file does not exist: {}", path.display()),
path,
));
}
let file = fs::File::open(path).map_err(|e| {
ScribeError::io(
format!("Failed to open ignore file {}: {}", path.display(), e),
e,
)
})?;
let reader = BufReader::new(file);
let mut patterns = Vec::new();
let mut line_count = 0;
for line in reader.lines() {
let line =
line.map_err(|e| ScribeError::io(format!("Failed to read ignore file: {}", e), e))?;
line_count += 1;
match GitignorePattern::new(&line) {
Ok(pattern) => patterns.push(pattern),
Err(e) => {
log::warn!(
"Invalid gitignore pattern in {} line {}: {} ({})",
path.display(),
line_count,
line,
e
);
}
}
}
Ok(IgnoreFile {
path: path.to_path_buf(),
ignore_type,
patterns,
line_count,
})
}
fn determine_ignore_type(&self, path: &Path) -> IgnoreType {
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
match filename {
".gitignore" => IgnoreType::Gitignore,
".ignore" => IgnoreType::DotIgnore,
_ => IgnoreType::CustomIgnore,
}
} else {
IgnoreType::CustomIgnore
}
}
fn build_overrides(&mut self) -> Result<()> {
let mut builder = OverrideBuilder::new(".");
for pattern in &self.patterns {
if matches!(
pattern.rule_type,
GitignoreRule::Exclude | GitignoreRule::Include
) {
let glob_pattern = pattern.to_glob_pattern();
let override_pattern = if pattern.negated {
format!("!{}", glob_pattern)
} else {
glob_pattern
};
if let Err(e) = builder.add(&override_pattern) {
log::warn!("Failed to add override pattern {}: {}", override_pattern, e);
}
}
}
self.overrides = Some(builder.build()?);
Ok(())
}
fn invalidate_overrides(&mut self) {
self.overrides = None;
}
pub fn discover_gitignore_files<P: AsRef<Path>>(root: P) -> Result<Vec<PathBuf>> {
let root = root.as_ref();
let mut gitignore_files = Vec::new();
let walker = WalkBuilder::new(root)
.hidden(false) .git_ignore(false) .build();
for entry in walker {
match entry {
Ok(entry) => {
let path = entry.path();
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
if matches!(filename, ".gitignore" | ".ignore") {
gitignore_files.push(path.to_path_buf());
}
}
}
Err(e) => {
log::warn!("Error walking directory tree: {}", e);
}
}
}
Ok(gitignore_files)
}
pub fn from_directory<P: AsRef<Path>>(root: P) -> Result<Self> {
let mut matcher = Self::new();
let gitignore_files = Self::discover_gitignore_files(&root)?;
for file in gitignore_files {
if let Err(e) = matcher.add_gitignore_file(&file) {
log::warn!("Failed to load gitignore file {}: {}", file.display(), e);
}
}
Ok(matcher)
}
pub fn with_defaults() -> Self {
let mut matcher = Self::new();
let default_patterns = [
".DS_Store",
"Thumbs.db",
"*.tmp",
"*.temp",
".git/",
".svn/",
".hg/",
"node_modules/",
"target/",
"build/",
"dist/",
"__pycache__/",
"*.pyc",
"*.pyo",
];
for pattern in &default_patterns {
if let Err(e) = matcher.add_pattern(pattern) {
log::warn!("Failed to add default pattern {}: {}", pattern, e);
}
}
matcher
}
}
impl Default for GitignoreMatcher {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitignoreStats {
pub total_patterns: usize,
pub exclude_patterns: usize,
pub include_patterns: usize,
pub comment_lines: usize,
pub ignore_files: usize,
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_gitignore_pattern_parsing() {
let pattern = GitignorePattern::new("*.rs").unwrap();
assert_eq!(pattern.pattern, "*.rs");
assert!(!pattern.negated);
assert!(!pattern.directory_only);
assert!(!pattern.anchored);
assert_eq!(pattern.rule_type, GitignoreRule::Exclude);
let pattern = GitignorePattern::new("!important.rs").unwrap();
assert_eq!(pattern.pattern, "important.rs");
assert!(pattern.negated);
assert_eq!(pattern.rule_type, GitignoreRule::Include);
let pattern = GitignorePattern::new("build/").unwrap();
assert_eq!(pattern.pattern, "build");
assert!(pattern.directory_only);
assert_eq!(pattern.rule_type, GitignoreRule::Exclude);
let pattern = GitignorePattern::new("/root-only").unwrap();
assert_eq!(pattern.pattern, "root-only");
assert!(pattern.anchored);
assert_eq!(pattern.rule_type, GitignoreRule::Exclude);
let pattern = GitignorePattern::new("# This is a comment").unwrap();
assert_eq!(pattern.rule_type, GitignoreRule::Comment);
let pattern = GitignorePattern::new(" ").unwrap();
assert_eq!(pattern.rule_type, GitignoreRule::Empty);
}
#[test]
fn test_gitignore_pattern_matching() {
let pattern = GitignorePattern::new("*.rs").unwrap();
assert!(pattern.matches("lib.rs", false, true));
assert!(!pattern.matches("src/lib.rs", false, true)); assert!(!pattern.matches("lib.py", false, true));
let pattern = GitignorePattern::new("**/*.rs").unwrap();
assert!(pattern.matches("lib.rs", false, true));
assert!(pattern.matches("src/lib.rs", false, true));
let pattern = GitignorePattern::new("build/").unwrap();
assert!(pattern.matches("build", true, true)); assert!(!pattern.matches("build", false, true)); assert!(pattern.matches("src/build", true, true));
let pattern = GitignorePattern::new("/root-only").unwrap();
assert!(pattern.matches("root-only", false, true));
let pattern = GitignorePattern::new("!*.rs").unwrap();
assert!(pattern.negated);
assert_eq!(pattern.rule_type, GitignoreRule::Include);
}
#[test]
fn test_gitignore_matcher_basic() {
let mut matcher = GitignoreMatcher::new();
matcher.add_pattern("**/*.rs").unwrap(); matcher.add_pattern("build/").unwrap();
matcher.add_pattern("!important.rs").unwrap();
assert!(matcher.is_ignored("lib.rs").unwrap());
assert!(matcher.is_ignored("src/lib.rs").unwrap());
assert!(!matcher.is_ignored("lib.py").unwrap());
assert!(!matcher.is_ignored("important.rs").unwrap());
}
#[test]
fn test_gitignore_file_loading() {
let temp_dir = TempDir::new().unwrap();
let gitignore_path = temp_dir.path().join(".gitignore");
let gitignore_content = r#"
# Ignore compiled files
*.o
*.so
*.dylib
# Ignore build directory
build/
# Don't ignore important files
!important.txt
# Empty line above
"#;
fs::write(&gitignore_path, gitignore_content).unwrap();
let mut matcher = GitignoreMatcher::new();
matcher.add_gitignore_file(&gitignore_path).unwrap();
let stats = matcher.stats();
assert_eq!(stats.ignore_files, 1);
assert!(stats.exclude_patterns > 0);
assert!(stats.include_patterns > 0);
assert!(stats.comment_lines > 0);
assert!(matcher.is_ignored("test.o").unwrap());
assert!(matcher.is_ignored("libtest.so").unwrap());
assert!(matcher.is_ignored("build/").unwrap()); assert!(!matcher.is_ignored("important.txt").unwrap()); assert!(!matcher.is_ignored("source.c").unwrap()); }
#[test]
fn test_gitignore_match_details() {
let mut matcher = GitignoreMatcher::new();
matcher.add_pattern("*.tmp").unwrap();
matcher.add_pattern("!keep.tmp").unwrap();
let result = matcher.match_path("test.tmp").unwrap();
assert!(result.ignored);
assert!(result.matched_pattern.is_some());
assert_eq!(result.rule_type, GitignoreRule::Exclude);
let result = matcher.match_path("keep.tmp").unwrap();
assert!(!result.ignored);
assert!(result.matched_pattern.is_some());
assert_eq!(result.rule_type, GitignoreRule::Include);
let result = matcher.match_path("test.rs").unwrap();
assert!(!result.ignored);
assert!(result.matched_pattern.is_none());
}
#[test]
fn test_gitignore_discovery() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::create_dir_all(root.join("tests")).unwrap();
fs::create_dir_all(root.join("docs")).unwrap();
fs::write(root.join(".gitignore"), "*.tmp\nbuild/").unwrap();
fs::write(root.join("src/.gitignore"), "*.o").unwrap();
fs::write(root.join("tests/.gitignore"), "fixtures/").unwrap();
let gitignore_files = GitignoreMatcher::discover_gitignore_files(root).unwrap();
assert_eq!(gitignore_files.len(), 3);
let filenames: Vec<String> = gitignore_files
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(filenames.iter().all(|name| name == ".gitignore"));
}
#[test]
fn test_gitignore_from_directory() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
fs::write(root.join(".gitignore"), "*.tmp\n*.log").unwrap();
fs::create_dir_all(root.join("subdir")).unwrap();
fs::write(root.join("subdir/.gitignore"), "*.bak").unwrap();
let matcher = GitignoreMatcher::from_directory(root).unwrap();
let stats = matcher.stats();
assert_eq!(stats.ignore_files, 2);
assert!(stats.total_patterns >= 3); }
#[test]
fn test_gitignore_defaults() {
let matcher = GitignoreMatcher::with_defaults();
let stats = matcher.stats();
assert!(stats.total_patterns > 0);
assert!(stats.exclude_patterns > 0);
let mut matcher = matcher;
assert!(matcher.is_ignored("node_modules/package.json").unwrap());
assert!(matcher.is_ignored("target/debug/main").unwrap());
assert!(matcher.is_ignored(".DS_Store").unwrap());
assert!(matcher.is_ignored("__pycache__/module.pyc").unwrap());
}
#[test]
fn test_gitignore_case_sensitivity() {
let mut matcher = GitignoreMatcher::new();
matcher.add_pattern("*.TMP").unwrap();
assert!(matcher.is_ignored("file.TMP").unwrap());
assert!(!matcher.is_ignored("file.tmp").unwrap());
let mut matcher = GitignoreMatcher::case_insensitive();
matcher.add_pattern("*.TMP").unwrap();
assert!(matcher.is_ignored("file.TMP").unwrap());
assert!(matcher.is_ignored("file.tmp").unwrap());
assert!(matcher.is_ignored("file.Tmp").unwrap());
}
#[test]
fn test_gitignore_pattern_precedence() {
let mut matcher = GitignoreMatcher::new();
matcher.add_pattern("*.txt").unwrap(); matcher.add_pattern("!important.txt").unwrap(); matcher.add_pattern("important.txt").unwrap();
assert!(matcher.is_ignored("important.txt").unwrap());
assert!(matcher.is_ignored("other.txt").unwrap());
}
#[test]
fn test_complex_gitignore_patterns() {
let mut matcher = GitignoreMatcher::new();
matcher.add_pattern("**/*.tmp").unwrap(); matcher.add_pattern("build/**/output").unwrap(); matcher.add_pattern("logs/*.log").unwrap(); matcher.add_pattern("cache/*/data").unwrap();
assert!(matcher.is_ignored("file.tmp").unwrap());
assert!(matcher.is_ignored("deep/nested/file.tmp").unwrap());
assert!(matcher.is_ignored("logs/error.log").unwrap());
assert!(!matcher.is_ignored("logs/nested/error.log").unwrap()); }
#[test]
fn test_gitignore_filter_paths() {
let mut matcher = GitignoreMatcher::new();
matcher.add_pattern("*.tmp").unwrap();
matcher.add_pattern("build/").unwrap();
let paths = vec![
"src/lib.rs",
"temp.tmp",
"build/output",
"README.md",
"test.tmp",
];
let filtered = matcher.filter_paths(&paths).unwrap();
assert_eq!(filtered.len(), 2);
assert!(filtered.contains(&"src/lib.rs"));
assert!(filtered.contains(&"README.md"));
assert!(!filtered.contains(&"temp.tmp"));
assert!(!filtered.contains(&"test.tmp"));
assert!(!filtered.contains(&"build/output"));
}
#[test]
fn test_gitignore_empty_and_comments() {
let mut matcher = GitignoreMatcher::new();
matcher.add_pattern("").unwrap(); matcher.add_pattern(" ").unwrap(); matcher.add_pattern("# Comment").unwrap(); matcher.add_pattern("*.rs").unwrap();
let stats = matcher.stats();
assert_eq!(stats.exclude_patterns, 1); assert!(stats.comment_lines >= 1);
assert!(matcher.is_ignored("test.rs").unwrap());
assert!(!matcher.is_ignored("test.py").unwrap());
}
}