use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use crate::error::{RebeccaError, Result};
use super::{ProjectArtifactContextMatch, ProjectArtifactDefinition};
#[derive(Debug, Clone, Copy)]
struct ProjectArtifactRule {
definition: ProjectArtifactDefinition,
context: ProjectArtifactContext,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct ProjectArtifactRuleMatch {
pub(super) definition: ProjectArtifactDefinition,
pub(super) context: ProjectArtifactContextMatch,
}
#[derive(Debug, Clone, Copy)]
enum ProjectArtifactContext {
NodeProject,
TargetProject,
PythonProject,
PythonCache,
GradleProject,
DartProject,
ZigProject,
GenericProjectOutput,
CocoapodsProject,
CxxProject,
ExpoProject,
SwiftPackage,
DotnetBin,
DotnetObj,
ComposerVendor,
}
const fn artifact_rule(
directory_name: &'static str,
rule_id: &'static str,
restore_hint: &'static str,
context: ProjectArtifactContext,
) -> ProjectArtifactRule {
ProjectArtifactRule {
definition: ProjectArtifactDefinition {
directory_name,
rule_id,
restore_hint,
},
context,
}
}
const PROJECT_ARTIFACT_RULES: &[ProjectArtifactRule] = &[
artifact_rule(
"node_modules",
"windows.project-artifact-node-modules",
"Dependencies can be restored with the project's package manager.",
ProjectArtifactContext::NodeProject,
),
artifact_rule(
"target",
"windows.project-artifact-target",
"Build outputs can be regenerated by the project build tool.",
ProjectArtifactContext::TargetProject,
),
artifact_rule(
"build",
"windows.project-artifact-build",
"Build outputs can be regenerated by the project build tool.",
ProjectArtifactContext::GenericProjectOutput,
),
artifact_rule(
"dist",
"windows.project-artifact-dist",
"Distribution outputs can be regenerated by the project build tool.",
ProjectArtifactContext::GenericProjectOutput,
),
artifact_rule(
"venv",
"windows.project-artifact-python-venv",
"Python virtual environments can be recreated from project dependencies.",
ProjectArtifactContext::PythonProject,
),
artifact_rule(
".venv",
"windows.project-artifact-python-venv",
"Python virtual environments can be recreated from project dependencies.",
ProjectArtifactContext::PythonProject,
),
artifact_rule(
".pytest_cache",
"windows.project-artifact-pytest-cache",
"Pytest cache data is rebuildable.",
ProjectArtifactContext::PythonProject,
),
artifact_rule(
".mypy_cache",
"windows.project-artifact-mypy-cache",
"Mypy cache data is rebuildable.",
ProjectArtifactContext::PythonProject,
),
artifact_rule(
".tox",
"windows.project-artifact-tox-cache",
"Tox environments can be recreated from project dependencies.",
ProjectArtifactContext::PythonProject,
),
artifact_rule(
".nox",
"windows.project-artifact-nox-cache",
"Nox environments can be recreated from project dependencies.",
ProjectArtifactContext::PythonProject,
),
artifact_rule(
".ruff_cache",
"windows.project-artifact-ruff-cache",
"Ruff cache data is rebuildable.",
ProjectArtifactContext::PythonProject,
),
artifact_rule(
"__pycache__",
"windows.project-artifact-python-bytecode",
"Python bytecode cache data is rebuildable.",
ProjectArtifactContext::PythonCache,
),
artifact_rule(
".gradle",
"windows.project-artifact-gradle-cache",
"Gradle project cache data is rebuildable.",
ProjectArtifactContext::GradleProject,
),
artifact_rule(
".next",
"windows.project-artifact-next-cache",
"Next.js build output can be regenerated.",
ProjectArtifactContext::NodeProject,
),
artifact_rule(
".nuxt",
"windows.project-artifact-nuxt-cache",
"Nuxt build output can be regenerated.",
ProjectArtifactContext::NodeProject,
),
artifact_rule(
".output",
"windows.project-artifact-nuxt-output",
"Nuxt output can be regenerated.",
ProjectArtifactContext::NodeProject,
),
artifact_rule(
".turbo",
"windows.project-artifact-turbo-cache",
"Turborepo cache data is rebuildable.",
ProjectArtifactContext::NodeProject,
),
artifact_rule(
".parcel-cache",
"windows.project-artifact-parcel-cache",
"Parcel cache data is rebuildable.",
ProjectArtifactContext::NodeProject,
),
artifact_rule(
".dart_tool",
"windows.project-artifact-dart-tool",
"Dart and Flutter tool data can be regenerated.",
ProjectArtifactContext::DartProject,
),
artifact_rule(
".zig-cache",
"windows.project-artifact-zig-cache",
"Zig cache data is rebuildable.",
ProjectArtifactContext::ZigProject,
),
artifact_rule(
"zig-out",
"windows.project-artifact-zig-out",
"Zig output can be regenerated.",
ProjectArtifactContext::ZigProject,
),
artifact_rule(
".angular",
"windows.project-artifact-angular-cache",
"Angular cache data is rebuildable.",
ProjectArtifactContext::NodeProject,
),
artifact_rule(
".svelte-kit",
"windows.project-artifact-svelte-kit",
"SvelteKit build data can be regenerated.",
ProjectArtifactContext::NodeProject,
),
artifact_rule(
".astro",
"windows.project-artifact-astro-cache",
"Astro build data can be regenerated.",
ProjectArtifactContext::NodeProject,
),
artifact_rule(
"coverage",
"windows.project-artifact-coverage",
"Coverage reports can be regenerated by test tooling.",
ProjectArtifactContext::GenericProjectOutput,
),
artifact_rule(
"bin",
"windows.project-artifact-dotnet-bin",
".NET bin outputs can be regenerated by the project build tool.",
ProjectArtifactContext::DotnetBin,
),
artifact_rule(
"obj",
"windows.project-artifact-dotnet-obj",
".NET object output can be regenerated by the build tool.",
ProjectArtifactContext::DotnetObj,
),
artifact_rule(
"vendor",
"windows.project-artifact-composer-vendor",
"Composer vendor dependencies can be restored with composer install.",
ProjectArtifactContext::ComposerVendor,
),
artifact_rule(
"Pods",
"windows.project-artifact-cocoapods",
"CocoaPods dependencies can be restored with pod install.",
ProjectArtifactContext::CocoapodsProject,
),
artifact_rule(
".cxx",
"windows.project-artifact-cxx-cache",
"C/C++ build cache data is rebuildable.",
ProjectArtifactContext::CxxProject,
),
artifact_rule(
".expo",
"windows.project-artifact-expo-cache",
"Expo cache data is rebuildable.",
ProjectArtifactContext::ExpoProject,
),
artifact_rule(
".build",
"windows.project-artifact-swift-build",
"Swift build output can be regenerated.",
ProjectArtifactContext::SwiftPackage,
),
];
const CACHEDIR_TAG_DEFINITION: ProjectArtifactDefinition = ProjectArtifactDefinition {
directory_name: "CACHEDIR.TAG",
rule_id: "windows.project-artifact-cachedir-tag",
restore_hint: "CACHEDIR.TAG marks this directory as rebuildable cache data.",
};
const PRUNED_SCAN_DIRS: &[&str] = &[
".git",
".hg",
".svn",
".trash",
"$recycle.bin",
"library",
"applications",
];
pub fn project_artifact_definitions() -> &'static [ProjectArtifactDefinition] {
static DEFINITIONS: OnceLock<Vec<ProjectArtifactDefinition>> = OnceLock::new();
DEFINITIONS
.get_or_init(|| {
PROJECT_ARTIFACT_RULES
.iter()
.map(|rule| rule.definition)
.collect()
})
.as_slice()
}
pub fn all_project_artifact_definitions() -> impl Iterator<Item = ProjectArtifactDefinition> {
project_artifact_definitions()
.iter()
.copied()
.chain([CACHEDIR_TAG_DEFINITION])
}
pub fn project_artifact_definition_for_dir_name(name: &str) -> Option<ProjectArtifactDefinition> {
project_artifact_rule_for_dir_name(name).map(|rule| rule.definition)
}
pub fn validate_project_artifact_selectors(selectors: &[String]) -> Result<()> {
for selector in selectors {
if selector.trim().is_empty() {
return Err(RebeccaError::InvalidProjectArtifactSelector(
"selector cannot be empty".to_string(),
));
}
let known = all_project_artifact_definitions()
.any(|definition| project_artifact_matches_selector(definition, selector));
if !known {
return Err(RebeccaError::InvalidProjectArtifactSelector(
selector.clone(),
));
}
}
Ok(())
}
pub fn project_artifact_matches_selectors(
definition: ProjectArtifactDefinition,
selectors: &[String],
) -> bool {
selectors.is_empty()
|| selectors
.iter()
.any(|selector| project_artifact_matches_selector(definition, selector))
}
pub(super) fn rule_match_for_directory(dir: &Path, name: &str) -> Option<ProjectArtifactRuleMatch> {
let rule = project_artifact_rule_for_dir_name(name)?;
let context = project_artifact_context_match(dir, rule.context)?;
Some(ProjectArtifactRuleMatch {
definition: rule.definition,
context,
})
}
pub(super) fn cachedir_tag_definition() -> ProjectArtifactDefinition {
CACHEDIR_TAG_DEFINITION
}
pub(super) fn cachedir_tag_context_match(dir: &Path) -> ProjectArtifactContextMatch {
context_match("cachedir-tag", dir, dir.join("CACHEDIR.TAG"))
}
pub(super) fn is_known_project_artifact_dir_name(name: &str) -> bool {
project_artifact_rule_for_dir_name(name).is_some()
}
pub(super) fn should_prune_scan_dir(name: &str) -> bool {
PRUNED_SCAN_DIRS
.iter()
.any(|pruned| pruned.eq_ignore_ascii_case(name))
}
fn project_artifact_rule_for_dir_name(name: &str) -> Option<ProjectArtifactRule> {
PROJECT_ARTIFACT_RULES
.iter()
.copied()
.find(|rule| rule.definition.directory_name.eq_ignore_ascii_case(name))
}
fn project_artifact_matches_selector(
definition: ProjectArtifactDefinition,
selector: &str,
) -> bool {
let selector = selector.trim();
if selector.eq_ignore_ascii_case(definition.rule_id)
|| selector.eq_ignore_ascii_case(definition.directory_name)
{
return true;
}
let rule_suffix = definition
.rule_id
.strip_prefix("windows.project-artifact-")
.unwrap_or(definition.rule_id);
if selector.eq_ignore_ascii_case(rule_suffix) {
return true;
}
selector_alias(selector) == selector_alias(definition.directory_name)
}
fn selector_alias(value: &str) -> String {
value.trim().to_ascii_lowercase().replace(['_', '.'], "-")
}
fn project_artifact_context_match(
path: &Path,
context: ProjectArtifactContext,
) -> Option<ProjectArtifactContextMatch> {
let parent = path.parent()?;
match context {
ProjectArtifactContext::NodeProject => node_project_context(parent),
ProjectArtifactContext::TargetProject => target_project_context(parent),
ProjectArtifactContext::PythonProject => python_project_context(parent),
ProjectArtifactContext::PythonCache => {
project_anchor_ancestor(parent, python_cache_context, 4)
}
ProjectArtifactContext::GradleProject => gradle_project_context(parent),
ProjectArtifactContext::DartProject => {
regular_file_context(parent, "dart-project", &["pubspec.yaml"])
}
ProjectArtifactContext::ZigProject => zig_project_context(parent),
ProjectArtifactContext::GenericProjectOutput => project_output_context(parent),
ProjectArtifactContext::CocoapodsProject => cocoapods_project_context(parent),
ProjectArtifactContext::CxxProject => cxx_project_context(parent),
ProjectArtifactContext::ExpoProject => expo_project_context(parent),
ProjectArtifactContext::SwiftPackage => {
regular_file_context(parent, "swift-package", &["Package.swift"])
}
ProjectArtifactContext::DotnetBin => dotnet_bin_context(path),
ProjectArtifactContext::DotnetObj => dotnet_obj_context(path),
ProjectArtifactContext::ComposerVendor => {
regular_file_context(parent, "composer-vendor", &["composer.json"])
}
}
}
fn context_match(
matched_context: &'static str,
project_root: &Path,
project_anchor: PathBuf,
) -> ProjectArtifactContextMatch {
ProjectArtifactContextMatch {
matched_context: matched_context.to_string(),
project_root: project_root.to_path_buf(),
project_anchor,
}
}
fn regular_file_context(
dir: &Path,
matched_context: &'static str,
markers: &[&str],
) -> Option<ProjectArtifactContextMatch> {
find_regular_file_anchor(dir, markers).map(|anchor| context_match(matched_context, dir, anchor))
}
fn node_project_context(dir: &Path) -> Option<ProjectArtifactContextMatch> {
regular_file_context(
dir,
"node-project",
&[
"package.json",
"pnpm-workspace.yaml",
"nx.json",
"rush.json",
"lerna.json",
],
)
}
fn target_project_context(dir: &Path) -> Option<ProjectArtifactContextMatch> {
regular_file_context(dir, "target-project", &["Cargo.toml", "pom.xml"])
}
fn python_project_context(dir: &Path) -> Option<ProjectArtifactContextMatch> {
regular_file_context(
dir,
"python-project",
&[
"pyproject.toml",
"requirements.txt",
"Pipfile",
"poetry.lock",
"setup.py",
"setup.cfg",
"tox.ini",
"pytest.ini",
"ruff.toml",
".python-version",
],
)
}
fn python_cache_context(dir: &Path) -> Option<ProjectArtifactContextMatch> {
regular_file_context(
dir,
"python-cache",
&[
"pyproject.toml",
"requirements.txt",
"Pipfile",
"poetry.lock",
"tox.ini",
"pytest.ini",
"ruff.toml",
],
)
.or_else(|| {
has_child_dir(dir, ".git").then(|| context_match("python-cache", dir, dir.join(".git")))
})
}
fn gradle_project_context(dir: &Path) -> Option<ProjectArtifactContextMatch> {
regular_file_context(
dir,
"gradle-project",
&[
"build.gradle",
"build.gradle.kts",
"settings.gradle",
"settings.gradle.kts",
],
)
}
fn zig_project_context(dir: &Path) -> Option<ProjectArtifactContextMatch> {
regular_file_context(dir, "zig-project", &["build.zig", "build.zig.zon"])
}
fn project_output_context(dir: &Path) -> Option<ProjectArtifactContextMatch> {
regular_file_context(
dir,
"project-output",
&[
"package.json",
"pnpm-workspace.yaml",
"nx.json",
"rush.json",
"lerna.json",
"pyproject.toml",
"requirements.txt",
"Pipfile",
"Cargo.toml",
"pom.xml",
"build.gradle",
"build.gradle.kts",
"settings.gradle",
"settings.gradle.kts",
"CMakeLists.txt",
"Makefile",
"go.mod",
"Gemfile",
"composer.json",
"pubspec.yaml",
"Package.swift",
"Podfile",
"build.zig",
"build.zig.zon",
],
)
.or_else(|| {
has_child_dir(dir, ".git").then(|| context_match("project-output", dir, dir.join(".git")))
})
.or_else(|| {
dotnet_project_file_anchor(dir).map(|anchor| context_match("project-output", dir, anchor))
})
.or_else(|| {
file_with_extension_anchor(dir, &["sln", "vcxproj"])
.map(|anchor| context_match("project-output", dir, anchor))
})
}
fn cocoapods_project_context(dir: &Path) -> Option<ProjectArtifactContextMatch> {
regular_file_context(dir, "cocoapods-project", &["Podfile", "Podfile.lock"])
}
fn cxx_project_context(dir: &Path) -> Option<ProjectArtifactContextMatch> {
regular_file_context(dir, "cxx-project", &["CMakeLists.txt"]).or_else(|| {
gradle_project_context(dir).map(|mut context| {
context.matched_context = "cxx-project".to_string();
context
})
})
}
fn expo_project_context(dir: &Path) -> Option<ProjectArtifactContextMatch> {
regular_file_context(
dir,
"expo-project",
&[
"package.json",
"app.json",
"app.config.js",
"app.config.ts",
"app.config.mjs",
],
)
}
fn dotnet_bin_context(path: &Path) -> Option<ProjectArtifactContextMatch> {
let parent = path.parent()?;
if has_child_dir(path, "Debug") || has_child_dir(path, "Release") {
dotnet_project_file_anchor(parent).map(|anchor| context_match("dotnet-bin", parent, anchor))
} else {
None
}
}
fn dotnet_obj_context(path: &Path) -> Option<ProjectArtifactContextMatch> {
let parent = path.parent()?;
dotnet_project_file_anchor(parent).map(|anchor| context_match("dotnet-obj", parent, anchor))
}
fn project_anchor_ancestor(
start: &Path,
predicate: fn(&Path) -> Option<ProjectArtifactContextMatch>,
max_parent_hops: usize,
) -> Option<ProjectArtifactContextMatch> {
let mut current = Some(start);
for _ in 0..=max_parent_hops {
let dir = current?;
if let Some(context) = predicate(dir) {
return Some(context);
}
current = dir.parent();
}
None
}
fn find_regular_file_anchor(dir: &Path, markers: &[&str]) -> Option<PathBuf> {
markers
.iter()
.map(|marker| dir.join(marker))
.find(|path| has_regular_file(path))
}
fn dotnet_project_file_anchor(dir: &Path) -> Option<PathBuf> {
let Ok(entries) = fs::read_dir(dir) else {
return None;
};
entries.flatten().find_map(|entry| {
let path = entry.path();
let Ok(metadata) = fs::symlink_metadata(&path) else {
return None;
};
if !metadata.is_file() || crate::safety::is_reparse_like(&metadata) {
return None;
}
let is_dotnet_project = path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
["csproj", "fsproj", "vbproj"]
.iter()
.any(|expected| extension.eq_ignore_ascii_case(expected))
});
is_dotnet_project.then_some(path)
})
}
fn file_with_extension_anchor(dir: &Path, extensions: &[&str]) -> Option<PathBuf> {
let Ok(entries) = fs::read_dir(dir) else {
return None;
};
entries.flatten().find_map(|entry| {
let path = entry.path();
let Ok(metadata) = fs::symlink_metadata(&path) else {
return None;
};
if !metadata.is_file() || crate::safety::is_reparse_like(&metadata) {
return None;
}
let matches_extension = path
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
extensions
.iter()
.any(|expected| extension.eq_ignore_ascii_case(expected))
});
matches_extension.then_some(path)
})
}
fn has_child_dir(parent: &Path, name: &str) -> bool {
let path = parent.join(name);
let Ok(metadata) = fs::symlink_metadata(path) else {
return false;
};
metadata.is_dir() && !crate::safety::is_reparse_like(&metadata)
}
fn has_regular_file(path: &Path) -> bool {
let Ok(metadata) = fs::symlink_metadata(path) else {
return false;
};
metadata.is_file() && !crate::safety::is_reparse_like(&metadata)
}