use crate::prelude::*;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Broken {
pub manifest: String,
pub kind: &'static str,
pub declared: String,
pub resolves_to: String,
}
fn skipped(name: &str) -> bool {
matches!(
name,
"node_modules"
| "target"
| ".build"
| ".git"
| ".wsb"
| "dist"
| "out"
| "build"
| "DerivedData"
| "vendor"
| "__pycache__"
| ".venv"
| "retired_by_wsb"
| "Pods"
)
}
fn manifests(root: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if path.is_dir() {
if !skipped(name) {
stack.push(path);
}
continue;
}
let by_name = matches!(
name,
"Cargo.toml" | "Package.swift" | "package.json" | "package-lock.json"
);
let by_extension = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| matches!(ext, "pbxproj" | "xcscheme" | "xcconfig"));
if by_name || by_extension {
found.push(path);
}
}
}
found.sort();
found
}
pub fn check(root: &Path) -> Vec<Broken> {
let mut broken = Vec::new();
for manifest in manifests(root) {
let Ok(text) = std::fs::read_to_string(&manifest) else {
continue;
};
let base = manifest.parent().unwrap_or(root);
let name = manifest
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
let declared: Vec<(&'static str, String)> = match name {
"Cargo.toml" => quoted_after(&text, "path", "cargo path dependency"),
"Package.swift" => swift_paths(&text),
"package.json" => json_file_deps(&text),
"package-lock.json" => lock_links(&text),
_ => Vec::new(),
};
for (kind, path) in declared {
if !path.starts_with('.') {
continue;
}
let target = normalize(&base.join(&path));
if target.exists() {
continue;
}
broken.push(Broken {
manifest: relative(&manifest, root),
kind,
declared: path,
resolves_to: relative(&target, root),
});
}
}
broken
}
fn quoted_after(text: &str, key: &str, kind: &'static str) -> Vec<(&'static str, String)> {
let mut found = Vec::new();
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
continue;
}
let mut rest = trimmed;
while let Some(at) = rest.find(key) {
rest = &rest[at + key.len()..];
let after = rest.trim_start();
let Some(after) = after.strip_prefix('=') else {
continue;
};
let after = after.trim_start();
if let Some(value) = quoted(after) {
found.push((kind, value));
}
}
}
found
}
fn swift_paths(text: &str) -> Vec<(&'static str, String)> {
let mut found = Vec::new();
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("//") {
continue;
}
let mut rest = trimmed;
while let Some(at) = rest.find("path:") {
rest = &rest["path:".len() + at..];
if let Some(value) = quoted(rest.trim_start()) {
found.push(("swift package path", value));
}
}
}
found
}
fn json_file_deps(text: &str) -> Vec<(&'static str, String)> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
return Vec::new();
};
let mut found = Vec::new();
for section in [
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies",
] {
let Some(table) = value.get(section).and_then(serde_json::Value::as_object) else {
continue;
};
for spec in table.values().filter_map(serde_json::Value::as_str) {
if let Some(path) = spec
.strip_prefix("file:")
.or_else(|| spec.strip_prefix("link:"))
{
found.push(("npm file: dependency", path.to_string()));
}
}
}
found
}
fn lock_links(text: &str) -> Vec<(&'static str, String)> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
return Vec::new();
};
let Some(packages) = value.get("packages").and_then(serde_json::Value::as_object) else {
return Vec::new();
};
let mut found = Vec::new();
for (key, entry) in packages {
if key.starts_with("..") {
found.push(("lockfile package", key.clone()));
}
if let Some(resolved) = entry.get("resolved").and_then(serde_json::Value::as_str) {
if resolved.starts_with("..") {
found.push(("lockfile link", resolved.to_string()));
}
}
}
found
}
fn quoted(text: &str) -> Option<String> {
let rest = text.strip_prefix('"')?;
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for part in path.components() {
match part {
std::path::Component::ParentDir => {
out.pop();
}
std::path::Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
fn relative(path: &Path, root: &Path) -> String {
crate::platform::slash_path(path.strip_prefix(root).unwrap_or(path))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HomeRooted {
pub manifest: String,
pub line: usize,
pub path: String,
}
pub fn home_rooted(root: &Path) -> Vec<HomeRooted> {
let pattern = regex_lite_home();
let mut found = Vec::new();
for manifest in manifests(root) {
let relative_path = relative(&manifest, root);
if !tracked(&manifest) {
continue;
}
let Ok(text) = std::fs::read_to_string(&manifest) else {
continue;
};
for (number, line) in text.lines().enumerate() {
for hit in pattern(line) {
found.push(HomeRooted {
manifest: relative_path.clone(),
line: number + 1,
path: hit,
});
}
}
}
found
}
fn tracked(file: &Path) -> bool {
let Some(repository) = repository_of(file) else {
return false;
};
let Ok(relative_path) = file.strip_prefix(&repository) else {
return false;
};
crate::git_backend::git_command()
.arg("-C")
.arg(&repository)
.args(["ls-files", "--error-unmatch"])
.arg(relative_path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
fn repository_of(file: &Path) -> Option<PathBuf> {
let mut at = file.parent()?;
loop {
if at.join(".git").exists() {
return Some(at.to_path_buf());
}
at = at.parent()?;
}
}
fn regex_lite_home() -> impl Fn(&str) -> Vec<String> {
|line: &str| {
let mut found = Vec::new();
for prefix in ["/Users/", "/home/"] {
let mut rest = line;
while let Some(at) = rest.find(prefix) {
let tail = &rest[at..];
let end = tail
.find(|c: char| c.is_whitespace() || c == '"' || c == ';' || c == '\'')
.unwrap_or(tail.len());
let path = &tail[..end];
let parts: Vec<&str> = path.trim_end_matches('/').split('/').collect();
if parts.len() > 3 && !parts[2].is_empty() {
found.push(path.to_string());
}
rest = &tail[end.max(1)..];
}
}
found
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hidden {
pub path: String,
pub rule: String,
}
pub fn hidden_source(root: &Path) -> Vec<Hidden> {
let mut found = Vec::new();
for repository in crate::catalogue::scan::repositories(root) {
for mut hidden in hidden_source_in(&repository) {
if let Ok(prefix) = repository.strip_prefix(root) {
if !prefix.as_os_str().is_empty() {
hidden.path = format!("{}/{}", crate::platform::slash_path(prefix), hidden.path);
}
}
found.push(hidden);
}
}
found
}
fn hidden_source_in(root: &Path) -> Vec<Hidden> {
const SOURCE: [&str; 9] = ["rs", "swift", "go", "py", "ts", "tsx", "svelte", "proto", "sql"];
const OUTPUT: [&str; 14] = [
"node_modules", "target", ".build", "dist", "out", "build", "DerivedData",
"__pycache__", ".venv", "vendor", "generated", "Generated", ".scrap",
"coverage",
];
let listed = crate::git_backend::git_command()
.arg("-C")
.arg(root)
.args(["ls-files", "--others", "--ignored", "--exclude-standard"])
.output();
let Ok(listed) = listed else {
return Vec::new();
};
if !listed.status.success() {
return Vec::new();
}
let mut hidden = Vec::new();
for line in String::from_utf8_lossy(&listed.stdout).lines() {
let path = Path::new(line);
let is_source = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| SOURCE.contains(&ext));
if !is_source {
continue;
}
if path
.components()
.filter_map(|c| c.as_os_str().to_str())
.any(|part| OUTPUT.contains(&part) || part.starts_with("dist-"))
{
continue;
}
let rule = crate::git_backend::git_command()
.arg("-C")
.arg(root)
.args(["check-ignore", "-v", line])
.output()
.ok()
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
.filter(|text| !text.is_empty())
.unwrap_or_else(|| "an ignore rule".to_string());
hidden.push(Hidden {
path: line.to_string(),
rule,
});
}
hidden
}
#[cfg(test)]
#[path = "local_paths_tests.rs"]
mod tests;