use std::path::Path;
#[allow(dead_code)]
pub const DEFAULT_HEDDLEIGNORE: &str = "\
# Suggested .heddleignore template.
#
# Syntax matches `.gitignore` (globs, negation with `!`, leading `/`
# for root-anchored, trailing `/` for directory-only). See
# `docs/heddleignore.md` for details. In Git-overlay repositories,
# Heddle reads `.gitignore` first; use this file only for Heddle-only
# excludes. In native Heddle repositories, patterns you want
# suppressed during `heddle capture` must live here.
# macOS Finder / Spotlight noise
.DS_Store
.AppleDouble
.LSOverride
._*
# Note: macOS custom-folder icon metadata (`Icon\r` — four chars +
# a trailing carriage return) is intentionally NOT suppressed by
# default. The only safely-typable glob (`Icon?`) is too broad and
# would also hide legitimate basenames like `Icons` or `Icon1`,
# including directories. If your team needs this, see
# `docs/heddleignore.md` for a project-specific recipe.
# Xcode / iOS dev artifacts
xcuserdata/
*.xcuserstate
*.xcscmblueprint
*.xccheckout
# JetBrains / VS Code / Fleet IDE caches
.idea/
.vscode/
.fleet/
*.iml
# Editor backups and swap files
*~
.~lock.*
*.swp
*.swo
# Windows shell metadata
Thumbs.db
desktop.ini
# OS / shell temporary debris
*.tmp
# Shell-redirect typos (`> -r foo` lands a literal file named `-r`).
# Unanchored so a typo created from a subdirectory (`src/-r`) is also
# suppressed — leading `/` would only catch the repo-root variant.
-r
-rv
# Local tool state — uncomment if your team uses these and the
# state files leak in. Left commented by default so teams that DO
# version their tool prompts (e.g. `.claude/CLAUDE.md`) aren't
# surprised by silent suppression.
# .claude/
";
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoiseCategory {
MacOsFinder,
Xcode,
IdeCache,
EditorBackup,
WindowsMetadata,
TempFile,
ShellTypo,
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NoiseHint {
pub category: NoiseCategory,
pub label: &'static str,
pub suggested_pattern: &'static str,
}
#[allow(dead_code)]
impl NoiseHint {
pub fn render_inline(&self) -> String {
format!(
"[HINT: looks like {} — add `{}` to .heddleignore?]",
self.label, self.suggested_pattern
)
}
}
#[allow(dead_code)]
pub fn noise_hint_for(path: &Path) -> Option<NoiseHint> {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let components: Vec<&str> = path
.components()
.filter_map(|c| c.as_os_str().to_str())
.collect();
for comp in &components {
match *comp {
"xcuserdata" => {
return Some(NoiseHint {
category: NoiseCategory::Xcode,
label: "Xcode user state",
suggested_pattern: "xcuserdata/",
});
}
".idea" => {
return Some(NoiseHint {
category: NoiseCategory::IdeCache,
label: "JetBrains IDE cache",
suggested_pattern: ".idea/",
});
}
".vscode" => {
return Some(NoiseHint {
category: NoiseCategory::IdeCache,
label: "VS Code workspace cache",
suggested_pattern: ".vscode/",
});
}
".fleet" => {
return Some(NoiseHint {
category: NoiseCategory::IdeCache,
label: "Fleet IDE cache",
suggested_pattern: ".fleet/",
});
}
_ => {}
}
}
match name {
".DS_Store" => {
return Some(NoiseHint {
category: NoiseCategory::MacOsFinder,
label: "macOS Finder metadata",
suggested_pattern: ".DS_Store",
});
}
".AppleDouble" | ".LSOverride" => {
return Some(NoiseHint {
category: NoiseCategory::MacOsFinder,
label: "macOS Finder metadata",
suggested_pattern: name_to_static(name),
});
}
"Thumbs.db" => {
return Some(NoiseHint {
category: NoiseCategory::WindowsMetadata,
label: "Windows thumbnail cache",
suggested_pattern: "Thumbs.db",
});
}
"desktop.ini" => {
return Some(NoiseHint {
category: NoiseCategory::WindowsMetadata,
label: "Windows folder metadata",
suggested_pattern: "desktop.ini",
});
}
"-r" | "-rv" => {
return Some(NoiseHint {
category: NoiseCategory::ShellTypo,
label: "shell-redirect typo artifact",
suggested_pattern: if name == "-r" { "-r" } else { "-rv" },
});
}
_ => {}
}
if name.starts_with("._") && name.len() > 2 {
return Some(NoiseHint {
category: NoiseCategory::MacOsFinder,
label: "macOS AppleDouble companion",
suggested_pattern: "._*",
});
}
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
match ext {
"xcuserstate" => {
return Some(NoiseHint {
category: NoiseCategory::Xcode,
label: "Xcode user state",
suggested_pattern: "*.xcuserstate",
});
}
"xcscmblueprint" => {
return Some(NoiseHint {
category: NoiseCategory::Xcode,
label: "Xcode SCM blueprint",
suggested_pattern: "*.xcscmblueprint",
});
}
"xccheckout" => {
return Some(NoiseHint {
category: NoiseCategory::Xcode,
label: "Xcode workspace checkout",
suggested_pattern: "*.xccheckout",
});
}
"iml" => {
return Some(NoiseHint {
category: NoiseCategory::IdeCache,
label: "JetBrains module file",
suggested_pattern: "*.iml",
});
}
"swp" | "swo" => {
return Some(NoiseHint {
category: NoiseCategory::EditorBackup,
label: "Vim swap file",
suggested_pattern: if ext == "swp" { "*.swp" } else { "*.swo" },
});
}
"tmp" => {
return Some(NoiseHint {
category: NoiseCategory::TempFile,
label: "temporary file",
suggested_pattern: "*.tmp",
});
}
_ => {}
}
}
if name.ends_with('~') {
return Some(NoiseHint {
category: NoiseCategory::EditorBackup,
label: "Emacs/Vim backup file",
suggested_pattern: "*~",
});
}
if name.starts_with(".~lock.") {
return Some(NoiseHint {
category: NoiseCategory::EditorBackup,
label: "LibreOffice lock file",
suggested_pattern: ".~lock.*",
});
}
None
}
fn name_to_static(name: &str) -> &'static str {
match name {
".AppleDouble" => ".AppleDouble",
".LSOverride" => ".LSOverride",
_ => "",
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
#[test]
fn template_covers_each_category() {
let tpl = DEFAULT_HEDDLEIGNORE;
assert!(tpl.contains(".DS_Store"));
assert!(tpl.contains("xcuserdata/"));
assert!(tpl.contains(".idea/"));
assert!(tpl.contains("*~"));
assert!(tpl.contains("Thumbs.db"));
assert!(tpl.contains("*.tmp"));
assert!(tpl.contains("\n-r\n"));
assert!(tpl.contains("\n-rv\n"));
assert!(!tpl.contains("\n/-r\n"));
assert!(!tpl.contains("\n/-rv\n"));
}
#[test]
fn ds_store_hint() {
let hint = noise_hint_for(&PathBuf::from(".DS_Store")).unwrap();
assert_eq!(hint.category, NoiseCategory::MacOsFinder);
assert_eq!(hint.suggested_pattern, ".DS_Store");
assert!(hint.render_inline().contains(".DS_Store"));
}
#[test]
fn ds_store_in_subdir() {
let hint = noise_hint_for(&PathBuf::from("src/.DS_Store")).unwrap();
assert_eq!(hint.category, NoiseCategory::MacOsFinder);
}
#[test]
fn xcuserdata_directory_match() {
let hint = noise_hint_for(&PathBuf::from(
"App.xcodeproj/xcuserdata/user.xcuserdatad/UserInterfaceState.xcuserstate",
))
.unwrap();
assert_eq!(hint.category, NoiseCategory::Xcode);
assert_eq!(hint.suggested_pattern, "xcuserdata/");
}
#[test]
fn apple_double_dot_underscore() {
let hint = noise_hint_for(&PathBuf::from("._main.rs")).unwrap();
assert_eq!(hint.suggested_pattern, "._*");
}
#[test]
fn vim_swap() {
let hint = noise_hint_for(&PathBuf::from(".main.rs.swp")).unwrap();
assert_eq!(hint.category, NoiseCategory::EditorBackup);
assert_eq!(hint.suggested_pattern, "*.swp");
}
#[test]
fn shell_redirect_typo() {
let hint = noise_hint_for(&PathBuf::from("-r")).unwrap();
assert_eq!(hint.category, NoiseCategory::ShellTypo);
assert_eq!(hint.suggested_pattern, "-r");
}
#[test]
fn shell_redirect_typo_pattern_matches_subdir() {
use objects::worktree::worktree_ignore::should_ignore;
let patterns = vec!["-r".to_string(), "-rv".to_string()];
assert!(should_ignore(&PathBuf::from("-r"), &patterns));
assert!(should_ignore(&PathBuf::from("src/-r"), &patterns));
assert!(should_ignore(&PathBuf::from("a/b/-rv"), &patterns));
}
#[test]
fn libreoffice_lock() {
let hint = noise_hint_for(&PathBuf::from(".~lock.report.odt#")).unwrap();
assert_eq!(hint.suggested_pattern, ".~lock.*");
}
#[test]
fn real_source_returns_none() {
assert!(noise_hint_for(&PathBuf::from("src/main.rs")).is_none());
assert!(noise_hint_for(&PathBuf::from("docs/README.md")).is_none());
assert!(noise_hint_for(&PathBuf::from("Cargo.toml")).is_none());
assert!(noise_hint_for(&PathBuf::from("assets/Icon-512.png")).is_none());
assert!(noise_hint_for(&PathBuf::from("src/Icon")).is_none());
}
#[test]
fn macos_icon_metadata_returns_no_hint() {
assert!(noise_hint_for(&PathBuf::from("Icon\r")).is_none());
}
#[test]
fn icon_glob_not_in_default_template() {
let tpl = DEFAULT_HEDDLEIGNORE;
assert!(!tpl.contains("\nIcon?\n"));
assert!(!tpl.contains("\nIcon\n"));
use objects::worktree::worktree_ignore::should_ignore;
let patterns: Vec<String> = tpl
.lines()
.filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#'))
.map(|l| l.to_string())
.collect();
assert!(!should_ignore(&PathBuf::from("Icons"), &patterns));
assert!(!should_ignore(&PathBuf::from("Icons/foo.png"), &patterns));
assert!(!should_ignore(&PathBuf::from("src/Icon"), &patterns));
assert!(!should_ignore(&PathBuf::from("Icon1"), &patterns));
}
}