diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34fdc7b..2b2ea1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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 + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9103453 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 + diff --git a/README.md b/README.md index 995b56b..7235ef9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/fspec-core/src/compile.rs b/crates/fspec-core/src/compile.rs index 8080be1..3351c26 100644 --- a/crates/fspec-core/src/compile.rs +++ b/crates/fspec-core/src/compile.rs @@ -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"); } diff --git a/crates/fspec-core/src/error.rs b/crates/fspec-core/src/error.rs index 1047fc5..fc6229f 100644 --- a/crates/fspec-core/src/error.rs +++ b/crates/fspec-core/src/error.rs @@ -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) } } } diff --git a/crates/fspec-core/src/lib.rs b/crates/fspec-core/src/lib.rs index f36658b..ab3728c 100644 --- a/crates/fspec-core/src/lib.rs +++ b/crates/fspec-core/src/lib.rs @@ -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 { 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>, @@ -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() + ), }); } diff --git a/crates/fspec-core/src/pattern.rs b/crates/fspec-core/src/pattern.rs index ebbdef1..62260f1 100644 --- a/crates/fspec-core/src/pattern.rs +++ b/crates/fspec-core/src/pattern.rs @@ -123,7 +123,7 @@ fn parse_err(line: usize, col: usize, msg: impl Into) -> Error { #[cfg(test)] mod tests { use super::*; - use crate::spec::{FSEntry::*, FSPattern::*}; + use crate::spec::FSPattern::*; #[test] fn unanchored_dir_then_entry() { diff --git a/crates/fspec-core/src/report.rs b/crates/fspec-core/src/report.rs index d2c687a..176568f 100644 --- a/crates/fspec-core/src/report.rs +++ b/crates/fspec-core/src/report.rs @@ -40,15 +40,26 @@ fn canon_key(s: &str) -> String { t } +/// A diagnostic message about a path or rule. #[derive(Debug, Clone)] pub struct Diagnostic { - pub code: &'static str, // e.g. "ambiguous_match", "reallowed_under_ignore" - pub severity: Severity, // usually Warning - pub path: String, // normalized relative path with '/' - pub message: String, // human-readable - pub rule_lines: Vec, // optional: lines involved + /// Diagnostic code (e.g., "ambiguous_match", "reallowed_under_ignore") + pub code: &'static str, + /// Severity level of the diagnostic + pub severity: Severity, + /// Normalized relative path (using '/' as separator) + pub path: String, + /// Human-readable message describing the issue + pub message: String, + /// Line numbers in the `.fspec` file that are involved (if applicable) + pub rule_lines: Vec, } +/// A report containing the results of validating a directory tree against an `.fspec` file. +/// +/// The report contains: +/// - Status information for each path (allowed, ignored, or unaccounted) +/// - Diagnostic messages about potential issues #[derive(Debug, Default)] pub struct Report { // Key: normalized relative path string ("src/main.rs", "bin", ...) diff --git a/crates/fspec-core/src/spec.rs b/crates/fspec-core/src/spec.rs index 396db73..e30ac66 100644 --- a/crates/fspec-core/src/spec.rs +++ b/crates/fspec-core/src/spec.rs @@ -25,11 +25,14 @@ pub enum Severity { Error, } -/// core settings (to be expanded) +/// Core settings that control matching behavior. #[derive(Debug, Clone, Copy)] pub struct MatchSettings { - /// If true, a non-slash-terminated leaf may match a file OR a directory + /// If `true`, a non-slash-terminated leaf pattern may match either a file or a directory. + /// This matches the behavior of tools like `find` and `.gitignore`. + /// If `false`, patterns without a trailing slash only match files. pub allow_file_or_dir_leaf: bool, + /// Default severity level for unaccounted paths in the report. pub default_severity: Severity, } diff --git a/crates/fspec-core/src/walk.rs b/crates/fspec-core/src/walk.rs index f1cd50b..404d21e 100644 --- a/crates/fspec-core/src/walk.rs +++ b/crates/fspec-core/src/walk.rs @@ -194,7 +194,7 @@ fn walk_dir(ctx: &mut WalkCtx, rules: &[Rule]) -> Result<(), Error> { let rel_path = ctx.rel.clone(); match classify_entry_last_wins(ctx, rules, &rel_path, EntryKind::Dir) { - Verdict::Allow { .. } => ctx + Verdict::Allow { rule_idx: _ } => ctx .walk_output .allow_with_ancestors(rel_path.clone(), false), Verdict::Unaccounted => ctx.walk_output.mark_unaccounted_dir(rel_path), @@ -203,7 +203,7 @@ fn walk_dir(ctx: &mut WalkCtx, rules: &[Rule]) -> Result<(), Error> { // we just ignored a directory. set the inherited context flag. ctx.inherited = InheritedState::SubtreeIgnored { rule_idx }; } - Verdict::IgnoredByInheritance { .. } => { + Verdict::IgnoredByInheritance { rule_idx: _ } => { ctx.walk_output.mark_ignored_dir(rel_path); } } @@ -220,14 +220,14 @@ fn walk_dir(ctx: &mut WalkCtx, rules: &[Rule]) -> Result<(), Error> { let rel_path = ctx.rel.join(name.as_ref()); match classify_entry_last_wins(ctx, rules, &rel_path, EntryKind::File) { - Verdict::Allow { .. } => { + Verdict::Allow { rule_idx: _ } => { ctx.walk_output.allow_with_ancestors(rel_path.clone(), true) } Verdict::Unaccounted => ctx.walk_output.mark_unaccounted_file(rel_path), - Verdict::Ignore { .. } => { + Verdict::Ignore { rule_idx: _ } => { ctx.walk_output.mark_ignored_file(rel_path.clone()); } - Verdict::IgnoredByInheritance { .. } => { + Verdict::IgnoredByInheritance { rule_idx: _ } => { ctx.walk_output.mark_ignored_file(rel_path); } } @@ -247,10 +247,13 @@ enum EntryKind { } #[derive(Debug, Clone, Copy)] +#[allow(dead_code)] // rule_idx fields reserved for future diagnostics enum Verdict { - Allow { rule_idx: usize }, // rule_idx reserved for future diagnostics + // rule_idx reserved for future diagnostics + Allow { rule_idx: usize }, Ignore { rule_idx: usize }, - IgnoredByInheritance { rule_idx: usize }, // rule_idx reserved for future diagnostics + // rule_idx reserved for future diagnostics + IgnoredByInheritance { rule_idx: usize }, Unaccounted, } diff --git a/crates/fspec-core/tests/golden_limiters.rs b/crates/fspec-core/tests/golden_limiters.rs index 0529d18..e44e18f 100644 --- a/crates/fspec-core/tests/golden_limiters.rs +++ b/crates/fspec-core/tests/golden_limiters.rs @@ -10,6 +10,7 @@ fn write_file(path: &Path, contents: &str) { fs::write(path, contents).unwrap(); } +#[allow(dead_code)] fn create_dir(path: &Path) { fs::create_dir_all(path).unwrap(); assert!(path.is_dir()); diff --git a/crates/fspec/src/main.rs b/crates/fspec/src/main.rs index eb5e922..b5ae0de 100644 --- a/crates/fspec/src/main.rs +++ b/crates/fspec/src/main.rs @@ -1,7 +1,7 @@ mod args; mod render; -use crate::args::{Cli, LeafMode, OutputFormat, SeverityArg}; +use crate::args::{Cli, LeafMode, SeverityArg}; use clap::Parser; use fspec_core::{MatchSettings, Severity, check_tree, check_tree_with_spec}; use std::path::{Path, PathBuf}; @@ -12,15 +12,13 @@ fn main() -> ExitCode { let root: PathBuf = resolve_root(&cli); - let mut settings = MatchSettings::default(); - - // These fields are described in your CLI design doc. - // If your MatchSettings uses setters instead of public fields, adapt accordingly. - settings.allow_file_or_dir_leaf = matches!(cli.leaf, LeafMode::Loose); - settings.default_severity = match cli.severity { - SeverityArg::Info => Severity::Info, - SeverityArg::Warning => Severity::Warning, - SeverityArg::Error => Severity::Error, + let settings = MatchSettings { + allow_file_or_dir_leaf: matches!(cli.leaf, LeafMode::Loose), + default_severity: match cli.severity { + SeverityArg::Info => Severity::Info, + SeverityArg::Warning => Severity::Warning, + SeverityArg::Error => Severity::Error, + }, }; let report = (if let Some(spec) = cli.spec.as_deref() { diff --git a/crates/fspec/src/render.rs b/crates/fspec/src/render.rs index 18ee8d9..f12852f 100644 --- a/crates/fspec/src/render.rs +++ b/crates/fspec/src/render.rs @@ -42,7 +42,7 @@ fn severity_to_string(sev: Severity) -> String { .to_string() } -pub fn render_json(report: &Report, settings: &MatchSettings) -> String { +pub fn render_json(report: &Report, _settings: &MatchSettings) -> String { let un = report.unaccounted_paths(); let diags = report.diagnostics(); diff --git a/docs/ci_release_setup.md b/docs/ci_release_setup.md new file mode 100644 index 0000000..3cac1ba --- /dev/null +++ b/docs/ci_release_setup.md @@ -0,0 +1,116 @@ +# CI and Release Setup + +This document describes the CI/CD setup for cross-platform builds and releases. + +## CI Workflow (`.github/workflows/ci.yml`) + +The CI workflow runs on every push and pull request, and includes: + +### Jobs + +1. **Format Check** (`fmt`) + - Runs only on Linux (Ubuntu 24.04) + - Checks that all code is properly formatted with `cargo fmt` + +2. **Test Suite** (`test`) + - Runs on all three platforms: + - Linux (Ubuntu 24.04) - `x86_64-unknown-linux-gnu` + - Windows (Windows 2022) - `x86_64-pc-windows-msvc` + - macOS (macOS 14) - `x86_64-apple-darwin` + - Runs the full test suite with `cargo test` + - Verifies the tool runs correctly with `cargo run` + +3. **Build Release** (`build`) + - Builds release binaries for all three platforms + - Uploads artifacts that are retained for 90 days + - Artifacts can be downloaded from the Actions UI for testing + +## Release Workflow (`.github/workflows/release.yml`) + +The release workflow creates GitHub releases with downloadable binaries. + +### Triggers + +- **Automatic**: Pushes to tags matching `v*` (e.g., `v0.1.0`) +- **Manual**: Can be triggered from the Actions UI with a version input + +### Process + +1. Builds release binaries for all platforms +2. Creates archives: + - Linux/macOS: `.tar.gz` files + - Windows: `.zip` files +3. Creates a GitHub release with: + - Tag name matching the version + - Release notes with download links + - Attached binary archives + +### Creating a Release + +#### Option 1: Using Git Tags (Recommended) + +```bash +# Update version in Cargo.toml files first +git tag v0.1.0 +git push origin v0.1.0 +``` + +This will automatically trigger the release workflow. + +#### Option 2: Manual Trigger + +1. Go to Actions → Release workflow +2. Click "Run workflow" +3. Enter the version tag (e.g., `v0.1.0`) +4. Click "Run workflow" + +## Artifacts + +### CI Artifacts + +- Available in the Actions UI for each workflow run +- Retained for 90 days +- Useful for testing builds before creating a release + +### Release Artifacts + +- Attached to GitHub releases +- Permanently available for download +- Named with platform identifiers: + - `fspec-x86_64-unknown-linux-gnu.tar.gz` + - `fspec-x86_64-pc-windows-msvc.zip` + - `fspec-x86_64-apple-darwin.tar.gz` + +## Platform Support + +Currently supported platforms (x86_64 only): + +- **Linux**: `x86_64-unknown-linux-gnu` +- **Windows**: `x86_64-pc-windows-msvc` +- **macOS**: `x86_64-apple-darwin` + +### Future Expansion + +To add more platforms (e.g., ARM64, other architectures): + +1. Add entries to the `matrix.include` arrays in both workflows +2. Update the artifact naming convention if needed +3. Test the builds + +Example for adding ARM64 macOS: + +```yaml +- os: macos-14 + target: aarch64-apple-darwin + artifact_name: fspec-aarch64-apple-darwin + binary_name: fspec +``` + +## Notes + +- All builds use the stable Rust toolchain +- Cargo cache is used to speed up builds +- Format checking only runs on Linux (no need to check on all platforms) +- Tests run on all platforms to catch platform-specific issues +- Release binaries are built with `--release` flag for optimization +