Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions crates/cargo-heather/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@

## cargo-heather

A cargo sub-command to validate license headers in Rust (`.rs`) and TOML (`.toml`) source files.
A cargo sub-command to validate license headers in Rust (`.rs`), TOML (`.toml`),
PowerShell (`.ps1`, `.psd1`, `.psm1`), Just (`justfile`, `*.just`), and env
(`constants.env`) source files.

### Setup

Expand Down Expand Up @@ -57,7 +59,7 @@ exist on disk produce a warning and are ignored.
### Usage

```bash
# Check all .rs and .toml files for correct license headers
# Check all supported source files for correct license headers
cargo heather

# Automatically fix files by adding/replacing headers
Expand Down Expand Up @@ -116,10 +118,22 @@ Fixed 2 file(s).
### How it works

1. **Config loading** — Reads `.cargo-heather.toml` from the project root and resolves the expected header text (from SPDX identifier or custom text).
1. **File scanning** — Walks the project directory to find all `.rs` and `.toml` files, skipping `target/`, hidden directories, and the config file itself.
1. **Header validation** — Extracts the first comment block from each file (`//` for Rust, `#` for TOML) and compares it to the expected header. Reports missing or mismatched headers.
1. **File scanning** — Walks the project directory for supported source files (see [Supported file kinds](#supported-file-kinds) below), skipping `target/`, hidden directories, and the config file itself.
1. **Header validation** — Extracts the header comment block from each file (placement depends on the file kind) and compares it to the expected header. Reports missing or mismatched headers.
1. **Fix mode** — When `--fix` is passed, automatically prepends the correct header to files that are missing it, or replaces incorrect headers.

### Supported file kinds

| File | Comment style | Header placement |
| ----------------------------- | ------------- | -------------------------------------------------- |
| `.rs` (regular) | `//` | Top of file |
| `.rs` (cargo-script) | `#` | Inside the `---` frontmatter |
| `.toml` | `#` | Top of file |
| `.ps1`, `.psd1`, `.psm1` | `#` | Top of file, or after a leading `#!` shebang |
| `*.just`, `justfile` | `#` | Top of file |
| `constants.env` | `#` | Top of file |



<hr/>
<sub>
Expand Down
42 changes: 40 additions & 2 deletions crates/cargo-heather/src/comment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ pub enum FileKind {
Rust,
/// A TOML file (`.toml`). Header uses `#` at the top.
Toml,
/// A `PowerShell` script (`.ps1`). Header uses `#` at the top.
/// A `PowerShell` script (`.ps1`) or data/module file (`.psd1`, `.psm1`).
/// Header uses `#`, placed at the top of the file or immediately after a
/// leading `#!` shebang line if one is present.
PowerShell,
/// A Just recipe file (`*.just` or `justfile`). Header uses `#` at the top.
Just,
Expand Down Expand Up @@ -50,7 +52,9 @@ impl FileKind {
}
}
ext if ext.eq_ignore_ascii_case("toml") => Some(Self::Toml),
ext if ext.eq_ignore_ascii_case("ps1") => Some(Self::PowerShell),
ext if ext.eq_ignore_ascii_case("ps1") || ext.eq_ignore_ascii_case("psd1") || ext.eq_ignore_ascii_case("psm1") => {
Some(Self::PowerShell)
}
ext if ext.eq_ignore_ascii_case("just") => Some(Self::Just),
_ => None,
}
Expand Down Expand Up @@ -209,4 +213,38 @@ mod tests {
assert!(style.is_header_comment_line("// Copyright"));
assert!(style.is_header_comment_line("//"));
}

#[test]
fn detect_powershell_script_by_ps1_extension() {
assert_eq!(FileKind::detect(Path::new("build.ps1"), None), Some(FileKind::PowerShell));
// Case-insensitive — Windows paths often surface as `.PS1`.
assert_eq!(FileKind::detect(Path::new("BUILD.PS1"), None), Some(FileKind::PowerShell));
}

#[test]
fn detect_powershell_data_file_by_psd1_extension() {
// `.psd1` is plain PowerShell data syntax with the same `#`
// line-comment style as `.ps1`; should be classified as
// PowerShell so `cargo-heather` can validate / fix headers.
assert_eq!(FileKind::detect(Path::new("Module.psd1"), None), Some(FileKind::PowerShell));
assert_eq!(FileKind::detect(Path::new("scenario.psd1"), None), Some(FileKind::PowerShell));
assert_eq!(FileKind::detect(Path::new("MODULE.PSD1"), None), Some(FileKind::PowerShell));
}

#[test]
fn detect_powershell_module_by_psm1_extension() {
// `.psm1` is a PowerShell module file; same `#` comment style as `.ps1`.
assert_eq!(FileKind::detect(Path::new("Module.psm1"), None), Some(FileKind::PowerShell));
assert_eq!(FileKind::detect(Path::new("MODULE.PSM1"), None), Some(FileKind::PowerShell));
}

#[test]
fn detect_returns_none_for_unsupported_extensions() {
assert_eq!(FileKind::detect(Path::new("notes.txt"), None), None);
assert_eq!(FileKind::detect(Path::new("README.md"), None), None);
// Confirm we did not start matching on a substring (e.g. `ps`, `psd`).
assert_eq!(FileKind::detect(Path::new("a.ps"), None), None);
assert_eq!(FileKind::detect(Path::new("a.psd"), None), None);
assert_eq!(FileKind::detect(Path::new("a.psm"), None), None);
}
}
3 changes: 2 additions & 1 deletion crates/cargo-heather/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
//!
//! - [`FileKind::Rust`] — regular Rust source (`//` comments).
//! - [`FileKind::Toml`] — TOML files (`#` comments).
//! - [`FileKind::PowerShell`] — `PowerShell` scripts (`#` comments).
//! - [`FileKind::PowerShell`] — `PowerShell` scripts (`.ps1`), data
//! files (`.psd1`), and module files (`.psm1`) (all use `#` comments).
//! - [`FileKind::Just`] — Just recipes (`#` comments).
//! - [`FileKind::Env`] — `constants.env` files (`#` comments).
//! - [`FileKind::CargoScript`] — Rust script with shebang + `---`
Expand Down
39 changes: 37 additions & 2 deletions crates/cargo-heather/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,12 @@ fn scanner_includes_legacy_hash_comment_file_types() {
&p.join("build.ps1"),
&format!("#!/usr/bin/env pwsh\n{HEADER_TEXT_HASH}\nWrite-Host 'ok'\n"),
);
// `.psd1` is plain PowerShell data syntax (used for module manifests
// and config-style hashtables) and `.psm1` is a PowerShell module file;
// both use the same `#` line-comment syntax as `.ps1` and must be
// scanned alongside it.
write(&p.join("Settings.psd1"), &format!("{HEADER_TEXT_HASH}\n@{{ Name = 'Settings' }}\n"));
write(&p.join("Module.psm1"), &format!("{HEADER_TEXT_HASH}\nfunction Get-Foo {{ }}\n"));
write(&p.join("recipes.just"), &format!("{HEADER_TEXT_HASH}\nbuild:\n cargo build\n"));
write(&p.join("justfile"), &format!("{HEADER_TEXT_HASH}\ntest:\n cargo test\n"));
write(&p.join("constants.env"), &format!("{HEADER_TEXT_HASH}\nRUST_LATEST=1.88.0\n"));
Expand All @@ -402,9 +408,9 @@ fn scanner_includes_legacy_hash_comment_file_types() {
out.status.success(),
"legacy hash-comment files should be checked and pass: {stderr}"
);
assert!(stderr.contains("Checking 4 file(s)"), "{stderr}");
assert!(stderr.contains("Checking 6 file(s)"), "{stderr}");
assert!(
stderr.contains("All 4 file(s) have correct license headers."),
stderr.contains("All 6 file(s) have correct license headers."),
"summary missing for legacy hash-comment files: {stderr}"
);
}
Expand All @@ -422,6 +428,35 @@ fn scanner_reports_missing_header_in_legacy_hash_comment_file_type() {
assert!(stderr.contains("MISSING header: build.ps1"), "missing log absent: {stderr}");
}

#[test]
fn scanner_reports_missing_header_in_powershell_data_file() {
// Regression guard: `.psd1` must be classified as PowerShell so a
// missing header is reported (rather than the file being silently
// skipped, which was the pre-fix behaviour).
let dir = TempDir::new().unwrap();
let p = dir.path();
write(&p.join(".cargo-heather.toml"), CONFIG_MIT);
write(&p.join("Settings.psd1"), "@{ Name = 'Settings' }\n");

let out = run_heather(p, &[]);
let stderr = stderr_of(&out);
assert!(!out.status.success(), "missing .psd1 header should fail: {stderr}");
assert!(stderr.contains("MISSING header: Settings.psd1"), "missing log absent: {stderr}");
}

#[test]
fn scanner_reports_missing_header_in_powershell_module_file() {
let dir = TempDir::new().unwrap();
let p = dir.path();
write(&p.join(".cargo-heather.toml"), CONFIG_MIT);
write(&p.join("Module.psm1"), "function Get-Foo { }\n");

let out = run_heather(p, &[]);
let stderr = stderr_of(&out);
assert!(!out.status.success(), "missing .psm1 header should fail: {stderr}");
assert!(stderr.contains("MISSING header: Module.psm1"), "missing log absent: {stderr}");
}

#[test]
fn scanner_ignores_unsupported_extension_even_when_hash_comments_would_work() {
let dir = TempDir::new().unwrap();
Expand Down
Loading