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
120 changes: 116 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@ env:
CARGO_TERM_COLOR: always

jobs:
test:
name: Test Suite
# Format check only runs on Linux (no need to run on all platforms)
fmt:
name: Format Check
runs-on: ubuntu-24.04

steps:
Expand All @@ -58,11 +59,122 @@ jobs:

- name: Check formatting
run: cargo fmt --all -- --check

# Test suite runs on all platforms
test:
name: Test Suite (${{ matrix.os }})
strategy:
matrix:
include:
- os: ubuntu-24.04
target: x86_64-unknown-linux-gnu
- os: windows-2022
target: x86_64-pc-windows-msvc
- os: macos-14
target: x86_64-apple-darwin

runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@stable

- name: Install Rust target
run: rustup target add ${{ matrix.target }}

- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
${{ runner.home }}/.cargo/bin/
${{ runner.home }}/.cargo/registry/index/
${{ runner.home }}/.cargo/registry/cache/
${{ runner.home }}/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-

- name: Run tests
run: cargo test --workspace --all-features
run: cargo test --workspace --all-features --target ${{ matrix.target }}

- name: Run fspec tool
run: cargo run
run: cargo run --target ${{ matrix.target }}
working-directory: ${{ github.workspace }}

# Aggregation job that reports overall test suite status
test-complete:
name: Test Suite
needs: [test]
runs-on: ubuntu-latest
if: always()
steps:
- name: Check test results
run: |
if [ "${{ needs.test.result }}" != "success" ]; then
echo "One or more test jobs failed"
exit 1
fi
echo "All test jobs passed successfully"

# Build release binaries for all platforms
build:
name: Build Release (${{ matrix.os }})
strategy:
matrix:
include:
- os: ubuntu-24.04
target: x86_64-unknown-linux-gnu
artifact_name: fspec-x86_64-unknown-linux-gnu
binary_name: fspec
- os: windows-2022
target: x86_64-pc-windows-msvc
artifact_name: fspec-x86_64-pc-windows-msvc
binary_name: fspec.exe
- os: macos-14
target: x86_64-apple-darwin
artifact_name: fspec-x86_64-apple-darwin
binary_name: fspec

runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@stable

- name: Install Rust target
run: rustup target add ${{ matrix.target }}

- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
${{ runner.home }}/.cargo/bin/
${{ runner.home }}/.cargo/registry/index/
${{ runner.home }}/.cargo/registry/cache/
${{ runner.home }}/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-

- name: Build release binary
run: cargo build --release --target ${{ matrix.target }} --bin fspec

- name: Create artifact directory
shell: bash
run: |
mkdir -p ${{ matrix.artifact_name }}
cp target/${{ matrix.target }}/release/${{ matrix.binary_name }} ${{ matrix.artifact_name }}/

- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: ${{ matrix.artifact_name }}/*
retention-days: 90

143 changes: 143 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
name: Release

# This workflow builds release binaries and creates a GitHub release.
# It can be triggered manually or automatically on version tags (v*).

on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v0.1.0)'
required: true
type: string

env:
CARGO_TERM_COLOR: always

jobs:
build:
name: Build Release (${{ matrix.os }})
strategy:
matrix:
include:
- os: ubuntu-24.04
target: x86_64-unknown-linux-gnu
artifact_name: fspec-x86_64-unknown-linux-gnu
binary_name: fspec
- os: windows-2022
target: x86_64-pc-windows-msvc
artifact_name: fspec-x86_64-pc-windows-msvc
binary_name: fspec.exe
- os: macos-14
target: x86_64-apple-darwin
artifact_name: fspec-x86_64-apple-darwin
binary_name: fspec

runs-on: ${{ matrix.os }}

steps:
- uses: actions/checkout@v4

- name: Install Rust
uses: dtolnay/rust-toolchain@stable

- name: Install Rust target
run: rustup target add ${{ matrix.target }}

- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
${{ runner.home }}/.cargo/bin/
${{ runner.home }}/.cargo/registry/index/
${{ runner.home }}/.cargo/registry/cache/
${{ runner.home }}/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-

- name: Build release binary
run: cargo build --release --target ${{ matrix.target }} --bin fspec

- name: Create artifact directory
shell: bash
run: |
mkdir -p ${{ matrix.artifact_name }}
cp target/${{ matrix.target }}/release/${{ matrix.binary_name }} ${{ matrix.artifact_name }}/

- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: ${{ matrix.artifact_name }}/*
retention-days: 90

release:
name: Create Release
needs: build
runs-on: ubuntu-24.04
permissions:
contents: write

steps:
- uses: actions/checkout@v4

- name: Determine version
id: version
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
else
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi

- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts

- name: Create release archive
shell: bash
run: |
cd artifacts
for dir in */; do
dirname=$(basename "$dir")
if [ "$dirname" = "fspec-x86_64-pc-windows-msvc" ]; then
# Windows: create zip
cd "$dirname"
zip -r "../${dirname}.zip" .
cd ..
else
# Unix: create tar.gz
tar -czf "${dirname}.tar.gz" -C "$dirname" .
fi
done

- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.version.outputs.version }}
name: Release ${{ steps.version.outputs.version }}
body: |
Release ${{ steps.version.outputs.version }}

## Downloads

- **Linux (x86_64)**: [fspec-x86_64-unknown-linux-gnu.tar.gz](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/fspec-x86_64-unknown-linux-gnu.tar.gz)
- **Windows (x86_64)**: [fspec-x86_64-pc-windows-msvc.zip](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/fspec-x86_64-pc-windows-msvc.zip)
- **macOS (x86_64)**: [fspec-x86_64-apple-darwin.tar.gz](https://github.com/${{ github.repository }}/releases/download/${{ steps.version.outputs.version }}/fspec-x86_64-apple-darwin.tar.gz)

## Installation

Extract the archive for your platform and add the binary to your PATH.
files: |
artifacts/*.tar.gz
artifacts/*.zip
draft: false
prerelease: false

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ fspec is intentionally staged. Not all features need to exist at once.
- [x] Introduce a command line tool wrapper crate.
- [x] Make the basic rule engine usable in real world cases.
- [x] Command line tool output switches and JSON report output.
- [x] Linux/Windows/Macos in CI and releases.

## Level 2 — Diagnostics and Expansion

Expand Down
7 changes: 0 additions & 7 deletions crates/fspec-core/src/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,16 +117,9 @@ mod tests {
let ast = parse_component("{name:snake_case}_{name}_{year:int(4)}.{snake|SNAKE}").unwrap();
let compiled = compile_component(&ast).unwrap();

println!("Generated regex: {}", compiled.regex.as_str());

let test1 = "snaked_name_snaked_name_1999.snake";
let test2 = "snaked_name_snaked_name_1999.SNAKE";

println!("Testing: {}", test1);
println!("Matches: {}", compiled.regex.is_match(test1));
println!("Testing: {}", test2);
println!("Matches: {}", compiled.regex.is_match(test2));

assert!(compiled.regex.is_match(test1), "Should match .snake");
assert!(compiled.regex.is_match(test2), "Should match .SNAKE");
}
Expand Down
4 changes: 2 additions & 2 deletions crates/fspec-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,13 @@ impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Io { path, source } => {
write!(f, "IO error at {}: {}", path.display(), source)
write!(f, "I/O error reading {}: {}", path.display(), source)
}
Error::Parse { line, col, msg } => {
write!(f, "Parse error at line {}, column {}: {}", line, col, msg)
}
Error::Semantic { msg } => {
write!(f, "Semantic error: {}", msg)
write!(f, "{}", msg)
}
}
}
Expand Down
41 changes: 39 additions & 2 deletions crates/fspec-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,42 @@ pub use report::Report;
pub use spec::{DirType, FSEntry, FSPattern, FileType, MatchSettings, Rule, RuleKind, Severity};
pub use walk::{WalkCtx, WalkOutput};

/// Check a directory tree against an `.fspec` file located at `{root}/.fspec`.
///
/// This is a convenience function that calls `check_tree_with_spec` with `spec_path = None`.
///
/// # Errors
///
/// Returns an error if:
/// - The `.fspec` file is not found at `{root}/.fspec`
/// - The `.fspec` file cannot be read
/// - The `.fspec` file contains invalid syntax
/// - An I/O error occurs while walking the directory tree
pub fn check_tree(root: &Path, settings: &MatchSettings) -> Result<Report, Error> {
check_tree_with_spec(root, None, settings)
}

/// Check a directory tree against an `.fspec` file.
///
/// If `spec_path` is `Some`, that path is used. Otherwise defaults to `{root}/.fspec`.
///
/// # Arguments
///
/// * `root` - The root directory to check
/// * `spec_path` - Optional path to the `.fspec` file. If `None`, looks for `.fspec` at the root.
/// * `settings` - Matching settings that control behavior (e.g., file vs directory matching)
///
/// # Returns
///
/// A `Report` containing the validation results, or an `Error` if something went wrong.
///
/// # Errors
///
/// Returns an error if:
/// - The `.fspec` file is not found
/// - The `.fspec` file cannot be read
/// - The `.fspec` file contains invalid syntax
/// - An I/O error occurs while walking the directory tree
pub fn check_tree_with_spec(
root: &Path,
spec_path: Option<&Path>,
Expand All @@ -34,14 +65,20 @@ pub fn check_tree_with_spec(

if !fspec_path.exists() {
return Err(Error::Semantic {
msg: format!(".fspec not found at {}", fspec_path.display()),
msg: format!(
".fspec file not found at {}\nHint: Create an .fspec file in the root directory or specify a different path with --spec",
fspec_path.display()
),
});
}

// Optional but usually helpful: fail early if it's not a file.
if !fspec_path.is_file() {
return Err(Error::Semantic {
msg: format!("spec path is not a file: {}", fspec_path.display()),
msg: format!(
"Spec path is not a file: {}\nHint: The .fspec file must be a regular file, not a directory",
fspec_path.display()
),
});
}

Expand Down
2 changes: 1 addition & 1 deletion crates/fspec-core/src/pattern.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ fn parse_err(line: usize, col: usize, msg: impl Into<String>) -> Error {
#[cfg(test)]
mod tests {
use super::*;
use crate::spec::{FSEntry::*, FSPattern::*};
use crate::spec::FSPattern::*;

#[test]
fn unanchored_dir_then_entry() {
Expand Down
Loading