diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b21ddfd --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + groups: + cargo-minor: + update-types: [minor, patch] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: ['*'] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7d45fc5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,137 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + fmt: + name: rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - run: cargo fmt --all --check + + clippy: + name: clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - run: cargo clippy --locked --all-targets --all-features -- -D warnings + + # macOS/Windows はユニットテストとビルド健全性のみ (--bins)。tests/ 配下の統合・E2E テストは + # GCC 専用 ( や実 GCC ライブラリ) で clang/MinGW では動かないため、ここでは走らせない。 + # それらフルスイートは下の coverage ジョブ (Linux + g++ + submodules) で実行している。 + test: + name: test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, macos-26-intel, windows-latest] + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - run: cargo test --locked --all-features --bins + + # Linux のフルスイート (ユニット + tests/ の統合・E2E) を計測し、Pages へカバレッジを公開する。 + coverage: + name: coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + submodules: recursive + persist-credentials: false + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - run: rustup component add llvm-tools-preview + - uses: taiki-e/install-action@0631aa6515c7d545823c67cfae7ef4fc7f490154 # v2.81.8 + with: + tool: cargo-llvm-cov + + - name: Collect coverage + run: cargo llvm-cov --locked --all-features --no-report + + - name: Build report and badge + run: | + cargo llvm-cov report --html --output-dir target/llvm-cov + pct=$(cargo llvm-cov report --json --summary-only \ + | jq '.data[0].totals.lines.percent') + rounded=$(awk -v p="$pct" 'BEGIN { printf "%.1f", p }') + color=$(awk -v p="$pct" 'BEGIN { + if (p >= 90) print "brightgreen" + else if (p >= 75) print "green" + else if (p >= 60) print "yellowgreen" + else if (p >= 40) print "yellow" + else if (p >= 20) print "orange" + else print "red" + }') + mkdir -p _site + cp -r target/llvm-cov/html/. _site/ + jq -n --arg m "${rounded}%" --arg c "$color" \ + '{schemaVersion: 1, label: "coverage", message: $m, color: $c}' > _site/badge.json + + - name: Upload Pages artifact + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: _site + + deploy-pages: + name: deploy coverage to Pages + needs: coverage + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + concurrency: + group: pages + cancel-in-progress: false + steps: + - id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 + + package: + name: package (publish dry-run) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 + - run: cargo publish --locked --dry-run + + audit: + name: cargo audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: taiki-e/install-action@0631aa6515c7d545823c67cfae7ef4fc7f490154 # v2.81.8 + with: + tool: cargo-audit + - run: cargo audit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..78357d4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,125 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + validate: + name: validate tag + runs-on: ubuntu-latest + outputs: + release: ${{ steps.check.outputs.release }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Validate SemVer tag and Cargo.toml version + id: check + run: | + semver='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' + if [[ ! "$GITHUB_REF_NAME" =~ $semver ]]; then + echo "::notice::tag '$GITHUB_REF_NAME' is not a SemVer release tag; skipping release" + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + tag="${GITHUB_REF_NAME#v}" + crate="$(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[0].version')" + if [ "$tag" != "$crate" ]; then + echo "::error::tag v$tag does not match Cargo.toml version $crate" + exit 1 + fi + echo "release=true" >> "$GITHUB_OUTPUT" + + # タグ push は CI (push/PR トリガー) を起動しないため、公開前にここでフルスイートを回して + # リリースをテストでゲートする。E2E のため submodules + g++ (ubuntu-latest に同梱) を使う。 + # リリース文脈では cache を使わない (cache-poisoning を避け、素の環境で検証する)。 + test: + name: test + needs: validate + if: needs.validate.outputs.release == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + submodules: recursive + persist-credentials: false + - run: cargo test --locked --all-features + + publish: + name: publish to crates.io + needs: [validate, test] + if: needs.validate.outputs.release == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Authenticate with crates.io + id: auth + uses: rust-lang/crates-io-auth-action@bbd81622f20ce9e2dd9622e3218b975523e45bbe # v1.0.4 + + - name: Publish + env: + CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + run: cargo publish --locked + + binaries: + name: binary (${{ matrix.target }}) + needs: [validate, publish] + if: needs.validate.outputs.release == 'true' + runs-on: ${{ matrix.os }} + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - os: macos-26-intel + target: x86_64-apple-darwin + - os: macos-latest + target: aarch64-apple-darwin + - os: windows-latest + target: x86_64-pc-windows-msvc + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Build + run: cargo build --locked --release --target ${{ matrix.target }} + + - name: Archive (Unix) + if: runner.os != 'Windows' + run: | + name="risundle-${GITHUB_REF_NAME}-${{ matrix.target }}" + tar czf "${name}.tar.gz" -C "target/${{ matrix.target }}/release" risundle + echo "ASSET=${name}.tar.gz" >> "$GITHUB_ENV" + + - name: Archive (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $name = "risundle-$env:GITHUB_REF_NAME-${{ matrix.target }}" + Compress-Archive -Path "target/${{ matrix.target }}/release/risundle.exe" -DestinationPath "$name.zip" + "ASSET=$name.zip" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Upload to release + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + with: + files: ${{ env.ASSET }} diff --git a/README.md b/README.md index d4632b4..1c02e34 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ **Tree-Shaking 機能付き、競技プログラミング用 C++ ソースバンドラー** +[![CI](https://github.com/TwoSquirrels/risundle/actions/workflows/ci.yml/badge.svg)](https://github.com/TwoSquirrels/risundle/actions/workflows/ci.yml) +[![coverage](https://img.shields.io/endpoint?url=https://twosquirrels.github.io/risundle/badge.json)](https://twosquirrels.github.io/risundle/) [![crates.io](https://img.shields.io/crates/v/risundle.svg)](https://crates.io/crates/risundle) [![license](https://img.shields.io/crates/l/risundle.svg)](LICENSE) diff --git a/src/bundle/inventory.rs b/src/bundle/inventory.rs index 009b580..8d52afb 100644 --- a/src/bundle/inventory.rs +++ b/src/bundle/inventory.rs @@ -89,9 +89,8 @@ impl Inventory { let TagsKind::Library { hash: expected, .. } = &lib.kind else { continue; // std は検証対象外 }; - let actual = hash::aggregate(&lib.path).with_context(|| { - format!("failed to recompute the hash of library `{}`", lib.id) - })?; + let actual = hash::aggregate(&lib.path) + .with_context(|| format!("failed to recompute the hash of library `{}`", lib.id))?; if &actual != expected { bail!( "library `{0}` has changed since registration; run `risundle library update {0}` to update it", diff --git a/src/bundle/rewrite.rs b/src/bundle/rewrite.rs index b3eec8d..a4ad4c9 100644 --- a/src/bundle/rewrite.rs +++ b/src/bundle/rewrite.rs @@ -72,7 +72,10 @@ pub fn rewrite( // 示すため保留中の linemarker をここで確定出力する。ダミー自身を指す marker は上で捨て済み // なので、ここで flush される pending は include の出所を指す実 marker (例: `#line 5 "main.cpp"`)。 flush_marker(&mut output, &mut pending, &mut presumed, &display); - push_line(&mut output, restore_include(line).as_deref().unwrap_or(line)); + push_line( + &mut output, + restore_include(line).as_deref().unwrap_or(line), + ); presumed.advance(); } } diff --git a/src/commands/library.rs b/src/commands/library.rs index 90a38ee..3a6d30a 100644 --- a/src/commands/library.rs +++ b/src/commands/library.rs @@ -242,7 +242,9 @@ fn resolve_source_root(path: &Path) -> Result { /// 含む ID を許すと意図しない場所を読み書きしてしまう。フェイルファストで早期に弾く。 fn validate_id(id: &str) -> Result<()> { if id.is_empty() || id == "." || id == ".." || id.contains('/') || id.contains('\\') { - bail!("library ID `{id}` is not allowed (empty, `.`/`..`, or IDs containing path separators are rejected)"); + bail!( + "library ID `{id}` is not allowed (empty, `.`/`..`, or IDs containing path separators are rejected)" + ); } Ok(()) } @@ -295,7 +297,9 @@ fn update_one(store: &LocalStore, id: &str, path: Option<&Path>) -> Result<()> { match tags.kind { TagsKind::Std { compilers } => { if path.is_some() { - bail!("a path cannot be specified for the standard library (it is auto-detected from the compiler)"); + bail!( + "a path cannot be specified for the standard library (it is auto-detected from the compiler)" + ); } let discovered = discover_all(&compilers)?; register_std(store, &discovered)?; @@ -355,7 +359,10 @@ fn show(store: &LocalStore, id: &str, verbose: bool) -> Result<()> { } TagsKind::Library { hash, files } => { show_field("Kind", "library"); - show_field("Files", &format!("{} with defined identifiers", files.len())); + show_field( + "Files", + &format!("{} with defined identifiers", files.len()), + ); if verbose { show_field("Hash", hash); println!("Definitions:"); @@ -478,8 +485,10 @@ mod tests { End of search list.\n\ trailing junk\n"; // 実在する dir のみ realpath 化される。"." はカレントなので拾われる。 + // 期待値も同じく canonicalize する: Windows の verbatim パス (`\\?\`) や macOS の + // symlink (/tmp→/private/tmp) で表記が分岐するため、関数と同じ正規化を通して比較する。 let dirs = parse_search_dirs(verbose); - assert_eq!(dirs, vec![std::env::current_dir().unwrap()]); + assert_eq!(dirs, vec![Path::new(".").canonicalize().unwrap()]); } #[test] diff --git a/src/config.rs b/src/config.rs index 8e970cb..5351b14 100644 --- a/src/config.rs +++ b/src/config.rs @@ -46,8 +46,12 @@ pub fn resolve(start_file: &Path) -> Result { fn find_config_file(start_file: &Path) -> Result> { // ancestors() が機能するよう絶対パス化する。相対パスのままだと親を辿れない。 - let absolute = std::path::absolute(start_file) - .with_context(|| format!("failed to make an absolute path for {}", start_file.display()))?; + let absolute = std::path::absolute(start_file).with_context(|| { + format!( + "failed to make an absolute path for {}", + start_file.display() + ) + })?; // 先頭 (ファイル自身) を除いた親ディレクトリ群を、近い順に探索する。 let found = absolute .ancestors() @@ -60,8 +64,8 @@ fn find_config_file(start_file: &Path) -> Result> { fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path) .with_context(|| format!("failed to read {}", path.display()))?; - let raw: RawConfig = toml::from_str(&text) - .with_context(|| format!("failed to parse {}", path.display()))?; + let raw: RawConfig = + toml::from_str(&text).with_context(|| format!("failed to parse {}", path.display()))?; Ok(raw.into()) } diff --git a/src/fs/relpath.rs b/src/fs/relpath.rs index f551183..e167c63 100644 --- a/src/fs/relpath.rs +++ b/src/fs/relpath.rs @@ -15,9 +15,9 @@ pub fn to_slash(relative: &Path) -> Result { let Component::Normal(name) = component else { continue; }; - let name = name.to_str().with_context(|| { - format!("file name is not valid UTF-8: {}", relative.display()) - })?; + let name = name + .to_str() + .with_context(|| format!("file name is not valid UTF-8: {}", relative.display()))?; parts.push(name); } Ok(parts.join("/")) diff --git a/src/library/local.rs b/src/library/local.rs index c463947..90539f6 100644 --- a/src/library/local.rs +++ b/src/library/local.rs @@ -18,8 +18,8 @@ impl LocalStore { /// OS 標準のデータディレクトリから `$LOCAL` を解決する。 pub fn discover() -> Result { - let data_local = dirs::data_local_dir() - .context("could not determine the OS local data directory")?; + let data_local = + dirs::data_local_dir().context("could not determine the OS local data directory")?; Ok(Self::with_root(data_local.join(Self::APP_DIR))) } diff --git a/src/library/tags.rs b/src/library/tags.rs index bc545eb..fdd7032 100644 --- a/src/library/tags.rs +++ b/src/library/tags.rs @@ -40,8 +40,7 @@ impl Tags { } pub fn to_json(&self) -> Result { - serde_json::to_string_pretty(&RawTags::from(self)) - .context("failed to serialize tags.json") + serde_json::to_string_pretty(&RawTags::from(self)).context("failed to serialize tags.json") } pub fn load(path: &Path) -> Result { @@ -52,8 +51,7 @@ impl Tags { pub fn save(&self, path: &Path) -> Result<()> { let json = self.to_json()?; - std::fs::write(path, json) - .with_context(|| format!("failed to write {}", path.display())) + std::fs::write(path, json).with_context(|| format!("failed to write {}", path.display())) } }