Compare commits

...

22 Commits

Author SHA1 Message Date
ab57553949 Revert "[feat] add panic hook and normal exit cleanup for .last_model file handling"
This reverts commit cd25b526c6.
2025-08-12 06:00:14 +02:00
40818a091d Revert "[feat] add robust progress management utilities and new tests"
This reverts commit 9bab7b75d3.
2025-08-12 06:00:13 +02:00
97855a247b Revert "[feat] implement centralized UI helpers with cliclack; refactor interactive prompts to improve usability and consistency"
This reverts commit 255be1e413.
2025-08-12 06:00:13 +02:00
0864516614 Revert "[refactor] extract and centralize output writing logic into write_outputs function in output.rs for improved code reuse and maintainability"
This reverts commit d46b23a4f5.
2025-08-12 06:00:12 +02:00
bb9402c643 Revert "[feat] add --out-format CLI option for customizable output formats; update tests and README"
This reverts commit 66f0062ffb.
2025-08-12 06:00:12 +02:00
4b8b68b33d Revert "[refactor] extract summary table rendering logic into render_summary_lines for improved readability and reusability; add associated tests"
This reverts commit d531ac0b96.
2025-08-12 06:00:11 +02:00
6a9736c50a Revert "[feat] enhance error handling, CLI options, and progress display; add --continue-on-error flag and improve maintainability"
This reverts commit ee67b56d6b.
2025-08-12 06:00:11 +02:00
d3310695d2 Revert "[feat] introduce Config for centralized runtime settings; refactor progress management and backend selection to leverage config"
This reverts commit 9120e8fb26.
2025-08-12 06:00:10 +02:00
03659448bc Revert "[feat] integrate global progress manager for unified log handling; enhance model download workflow with progress tracking and SHA-256 verification"
This reverts commit 37c43161da.
2025-08-12 06:00:10 +02:00
5c8a495b9f Revert "[feat] enhance progress logging, introduce TTY-aware banners, and implement hardened SHA-256 verification for model downloads"
This reverts commit e954902aa9.
2025-08-12 06:00:10 +02:00
6b72bd64c0 Revert "[refactor] remove dialoguer dependency; migrate selection prompts to cliclack"
This reverts commit df6faf6436.
2025-08-12 06:00:10 +02:00
278ca1b523 Revert "[build] pin whisper-rs dependency to a specific commit for reproducible builds; update documentation accordingly"
This reverts commit 152fde36ae.
2025-08-12 06:00:10 +02:00
3f1e634e2d Revert "[test] add Unix-only tests for with_suppressed_stderr ensuring stderr redirection and restoration, including panic handling"
This reverts commit 7832545033.
2025-08-12 06:00:10 +02:00
4063b4cb06 Revert "[test] add comprehensive tests for select_backend ensuring proper backend priority and error guidance"
This reverts commit f143e66e80.
2025-08-12 06:00:10 +02:00
f551cc3498 Revert "[test] add unit tests for validate_model_lang_compat ensuring model-language compatibility validation"
This reverts commit abe81b643b.
2025-08-12 06:00:09 +02:00
90f9849cc0 Revert "[test] add test for deterministic merge output across job counts; enhance --jobs support with parallel processing logic"
This reverts commit 98491a8701.
2025-08-12 06:00:09 +02:00
9d12507cf5 Revert "[test] add tests for --no-interaction and its alias to ensure non-interactive mode skips prompts and uses defaults"
This reverts commit b7f0ddda37.
2025-08-12 06:00:09 +02:00
b9308be930 Revert "[test] add tests for --force flag and numeric suffix handling to ensure proper output file resolution and overwriting behavior"
This reverts commit 6994d20f5e.
2025-08-12 06:00:08 +02:00
fdf5e3370d Revert "[test] add tests for progress manager modes; verify bar counts and total bar visibility in single and multi modes"
This reverts commit 3dc1237938.
2025-08-12 06:00:08 +02:00
ae0fdf802a Revert "[test] add CI workflow with Rust checks, cache setup, and auditing; update docs and README with CI details"
This reverts commit 94c816acdf.
2025-08-12 06:00:07 +02:00
fbf3aab23c Revert "[test] add examples-check target with stubbed BIN and no-network validation for example scripts"
This reverts commit a26eade80b.
2025-08-12 06:00:07 +02:00
4e117d78f8 Revert "[docs] update README with new CLI options, usage tips, and guidance for language models"
This reverts commit af473c4942.
2025-08-12 06:00:07 +02:00
29 changed files with 729 additions and 3808 deletions

View File

@@ -1,45 +0,0 @@
name: CI
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
permissions:
contents: read
jobs:
ci:
name: ci
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Show rustc/cargo versions
run: |
rustc -Vv
cargo -Vv
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Install cargo-audit
run: |
cargo install cargo-audit --locked || cargo install cargo-audit
- name: Format check
run: cargo fmt --all -- --check
- name: Clippy (warnings as errors)
run: cargo clippy --all-targets -- -D warnings
- name: Test
run: cargo test --all
- name: Audit
run: cargo audit

40
CHANGELOG.md Normal file
View File

@@ -0,0 +1,40 @@
# PolyScribe Refactor toward Rust 2024 — Incremental Patches
This changelog documents each incremental step applied to keep the build green while moving the codebase toward Rust 2024 idioms.
## 1) Formatting only (rustfmt)
- Ran `cargo fmt` across the repository.
- No semantic changes.
- Build status: OK (`cargo build` succeeded).
## 2) Lints — initial fixes (non-pedantic)
- Adjusted crate lint policy in `src/lib.rs`:
- Replaced `#![warn(clippy::pedantic, clippy::nursery, clippy::cargo)]` with `#![warn(clippy::all)]` to align with the plan (skip pedantic/nursery for now).
- Added comment/TODO to revisit stricter lints in a later pass.
- Fixed several clippy warnings that were causing `cargo clippy --all-targets` to error under tests:
- `src/backend.rs`: conditionally import `libloading::Library` only for non-test builds and mark `names` parameter as used in test cfg to avoid unused warnings; keep `check_lib()` sideeffect free during tests.
- `src/models.rs`: removed an unused `std::io::Write` import in test module.
- `src/main.rs` (unit tests): imported `polyscribe::format_srt_time` explicitly and removed a duplicate `use super::*;` to fix unresolved name and unused import warnings under clippy test builds.
- Build/Clippy status:
- `cargo build`: OK.
- `cargo clippy --all-targets`: OK (only warnings remain; no errors).
## 3) Module hygiene
- Verified crate structure:
- Library crate (`src/lib.rs`) exposes a coherent API and reexports `backend` and `models` via `pub mod`.
- Binary (`src/main.rs`) consumes the library API through `polyscribe::...` paths.
- No structural changes required. Build status: OK.
## 4) Edition
- The project already targets `edition = "2024"` in Cargo.toml.
- Verified that the project compiles under Rust 2024. No changes needed.
- TODO: If stricter lints or new features from 2024 edition introduce issues in future steps, document blockers here.
## 5) Error handling
- The codebase already returns `anyhow::Result` in the binary and uses contextual errors widely.
- No `unwrap`/`expect` usages in production paths required attention in this pass.
- Build status: OK.
## Next planned steps (not yet applied in this changelog)
- Gradually fix remaining clippy warnings (e.g., `uninlined_format_args`, small style nits) in small, compilegreen patches.
- Optionally reenable `clippy::pedantic`, `clippy::nursery`, and `clippy::cargo` once warnings are significantly reduced, then address nonbreaking warnings.

146
Cargo.lock generated
View File

@@ -201,12 +201,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.41" version = "0.4.41"
@@ -291,20 +285,6 @@ dependencies = [
"roff", "roff",
] ]
[[package]]
name = "cliclack"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c420bdc04c123a2df04d9c5a07289195f00007af6e45ab18f55e56dc7e04b8"
dependencies = [
"console",
"indicatif",
"once_cell",
"strsim",
"textwrap",
"zeroize",
]
[[package]] [[package]]
name = "cmake" name = "cmake"
version = "0.1.54" version = "0.1.54"
@@ -320,19 +300,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -368,16 +335,6 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "ctrlc"
version = "3.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46f93780a459b7d656ef7f071fe699c4d3d2cb201c4b24d085b6ddc505276e73"
dependencies = [
"nix",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -405,12 +362,6 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@@ -863,19 +814,6 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "indicatif"
version = "0.17.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
dependencies = [
"console",
"number_prefix",
"portable-atomic",
"unicode-width",
"web-time",
]
[[package]] [[package]]
name = "io-uring" name = "io-uring"
version = "0.7.9" version = "0.7.9"
@@ -1023,18 +961,6 @@ dependencies = [
"tempfile", "tempfile",
] ]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -1054,12 +980,6 @@ dependencies = [
"autocfg", "autocfg",
] ]
[[package]]
name = "number_prefix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]] [[package]]
name = "object" name = "object"
version = "0.36.7" version = "0.36.7"
@@ -1158,9 +1078,6 @@ dependencies = [
"clap", "clap",
"clap_complete", "clap_complete",
"clap_mangen", "clap_mangen",
"cliclack",
"ctrlc",
"indicatif",
"libc", "libc",
"reqwest", "reqwest",
"serde", "serde",
@@ -1171,12 +1088,6 @@ dependencies = [
"whisper-rs", "whisper-rs",
] ]
[[package]]
name = "portable-atomic"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.2" version = "0.1.2"
@@ -1495,12 +1406,6 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smawk"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.0" version = "0.6.0"
@@ -1594,17 +1499,6 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width",
]
[[package]] [[package]]
name = "tinystr" name = "tinystr"
version = "0.8.1" version = "0.8.1"
@@ -1788,18 +1682,6 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-width"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -1946,20 +1828,10 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "whisper-rs" name = "whisper-rs"
version = "0.14.3" version = "0.14.3"
source = "git+https://github.com/tazz4843/whisper-rs?rev=135b60b85a15714862806b6ea9f76abec38156f1#135b60b85a15714862806b6ea9f76abec38156f1" source = "git+https://github.com/tazz4843/whisper-rs#135b60b85a15714862806b6ea9f76abec38156f1"
dependencies = [ dependencies = [
"whisper-rs-sys", "whisper-rs-sys",
] ]
@@ -1967,7 +1839,7 @@ dependencies = [
[[package]] [[package]]
name = "whisper-rs-sys" name = "whisper-rs-sys"
version = "0.13.0" version = "0.13.0"
source = "git+https://github.com/tazz4843/whisper-rs?rev=135b60b85a15714862806b6ea9f76abec38156f1#135b60b85a15714862806b6ea9f76abec38156f1" source = "git+https://github.com/tazz4843/whisper-rs#135b60b85a15714862806b6ea9f76abec38156f1"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cfg-if", "cfg-if",
@@ -2275,20 +2147,6 @@ name = "zeroize"
version = "1.8.1" version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"

View File

@@ -3,16 +3,14 @@ name = "polyscribe"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
license-file = "LICENSE"
[features] [features]
# Default: build without whisper to keep tests lightweight; enable `whisper` to use whisper-rs. # Default: CPU only; no GPU features enabled
default = [] default = []
# Enable whisper-rs dependency (CPU-only unless combined with gpu-* features) # GPU backends map to whisper-rs features or FFI stub for Vulkan
whisper = ["dep:whisper-rs"] gpu-cuda = ["whisper-rs/cuda"]
# GPU backends map to whisper-rs features gpu-hip = ["whisper-rs/hipblas"]
gpu-cuda = ["whisper", "whisper-rs/cuda"]
gpu-hip = ["whisper", "whisper-rs/hipblas"]
# Vulkan path currently doesn't use whisper directly here; placeholder feature
gpu-vulkan = [] gpu-vulkan = []
# explicit CPU fallback feature (no effect at build time, used for clarity) # explicit CPU fallback feature (no effect at build time, used for clarity)
cpu-fallback = [] cpu-fallback = []
@@ -28,14 +26,9 @@ toml = "0.8"
chrono = { version = "0.4", features = ["clock"] } chrono = { version = "0.4", features = ["clock"] }
reqwest = { version = "0.12", features = ["blocking", "json"] } reqwest = { version = "0.12", features = ["blocking", "json"] }
sha2 = "0.10" sha2 = "0.10"
# Make whisper-rs optional; enabled via `whisper` feature # whisper-rs is always used (CPU-only by default); GPU features map onto it
# Pin whisper-rs to a known-good commit for reproducible builds. whisper-rs = { git = "https://github.com/tazz4843/whisper-rs" }
# To update: run `cargo update -p whisper-rs --precise 135b60b85a15714862806b6ea9f76abec38156f1` (adjust SHA) and update this rev.
whisper-rs = { git = "https://github.com/tazz4843/whisper-rs", rev = "135b60b85a15714862806b6ea9f76abec38156f1", default-features = false, optional = true }
libc = "0.2" libc = "0.2"
indicatif = "0.17"
ctrlc = "3.4"
cliclack = "0.3"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"

View File

@@ -1,23 +0,0 @@
# Lightweight examples-check: runs all examples/*.sh with --no-interaction -q and stubbed BIN
# This target does not perform network calls and never prompts for input.
.SHELL := /bin/bash
.PHONY: examples-check
examples-check:
@set -euo pipefail; \
shopt -s nullglob; \
BIN_WRAPPER="$(PWD)/scripts/with_flags.sh"; \
failed=0; \
for f in examples/*.sh; do \
echo "[examples-check] Running $$f"; \
BIN="$$BIN_WRAPPER" bash "$$f" </dev/null >/dev/null 2>&1 || { \
echo "[examples-check] FAILED: $$f"; failed=1; \
}; \
done; \
if [[ $$failed -ne 0 ]]; then \
echo "[examples-check] Some examples failed."; \
exit 1; \
else \
echo "[examples-check] All examples passed (no interaction, quiet)."; \
fi

View File

@@ -30,12 +30,8 @@ Quickstart
- ./target/release/polyscribe --download-models - ./target/release/polyscribe --download-models
2) Transcribe a file: 2) Transcribe a file:
- ./target/release/polyscribe -v -o output --out-format json --jobs 4 my_audio.mp3 - ./target/release/polyscribe -v -o output my_audio.mp3
This writes JSON (because of --out-format json) into the output directory with a date prefix. Omit --out-format to write all available formats (JSON and SRT). For large batches, add --continue-on-error to skip bad files and keep going. This writes JSON and SRT into the output directory with a date prefix.
Gotchas
- English-only models: If you picked an English-only Whisper model (e.g., tiny.en, base.en), non-English language hints (via --language) will be rejected and detection may be biased toward English. Use a multilingual model (without the .en suffix) for non-English audio.
- Language hints help: When you know the language, pass --language <code> (e.g., --language de) to improve accuracy and speed. If the audio is mixed language, omit the hint to let the model detect.
Shell completions and man page Shell completions and man page
- Completions: ./target/release/polyscribe completions <bash|zsh|fish|powershell|elvish> > polyscribe.<ext> - Completions: ./target/release/polyscribe completions <bash|zsh|fish|powershell|elvish> > polyscribe.<ext>
@@ -50,7 +46,6 @@ Model locations
Most-used CLI flags Most-used CLI flags
- -o, --output FILE_OR_DIR: Output path base (date prefix added). If omitted, JSON prints to stdout. - -o, --output FILE_OR_DIR: Output path base (date prefix added). If omitted, JSON prints to stdout.
- --out-format <json|toml|srt|all>: Which on-disk format(s) to write; repeatable; default all. Example: --out-format json --out-format srt
- -m, --merge: Merge all inputs into one output; otherwise one output per input. - -m, --merge: Merge all inputs into one output; otherwise one output per input.
- --merge-and-separate: Write both merged output and separate per-input outputs (requires -o dir). - --merge-and-separate: Write both merged output and separate per-input outputs (requires -o dir).
- --set-speaker-names: Prompt for a speaker label per input file. - --set-speaker-names: Prompt for a speaker label per input file.
@@ -62,7 +57,6 @@ Most-used CLI flags
- -v/--verbose (repeatable): Increase log verbosity. -vv shows very detailed logs. - -v/--verbose (repeatable): Increase log verbosity. -vv shows very detailed logs.
- -q/--quiet: Suppress non-error logs (stderr); does not silence stdout results. - -q/--quiet: Suppress non-error logs (stderr); does not silence stdout results.
- --no-interaction: Never prompt; suitable for CI. - --no-interaction: Never prompt; suitable for CI.
- --no-progress: Disable progress bars (also honors NO_PROGRESS=1). Progress bars render on stderr only and auto-disable when not a TTY.
Minimal usage examples Minimal usage examples
- Transcribe an audio file to JSON/SRT: - Transcribe an audio file to JSON/SRT:
@@ -81,7 +75,7 @@ Troubleshooting & docs
- docs/ci.md minimal CI checklist and job outline - docs/ci.md minimal CI checklist and job outline
- CONTRIBUTING.md PR checklist and workflow - CONTRIBUTING.md PR checklist and workflow
CI status: [CI workflow runs](actions/workflows/ci.yml) CI status: [CI badge placeholder]
Examples Examples
See the examples/ directory for copy-paste scripts: See the examples/ directory for copy-paste scripts:

View File

@@ -14,10 +14,6 @@ Example GitHub Actions job (outline)
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- name: Print resolved whisper-rs rev
run: |
echo "Resolved whisper-rs revision:" && \
awk '/name = "whisper-rs"/{f=1} f&&/source = "git\+.*whisper-rs#/{match($0,/#([0-9a-f]{7,40})"/,m); if(m[1]){print m[1]; exit}}' Cargo.lock
- name: Build - name: Build
run: cargo build --all-targets --locked run: cargo build --all-targets --locked
- name: Test - name: Test
@@ -28,4 +24,3 @@ Example GitHub Actions job (outline)
Notes Notes
- For GPU features, set up appropriate runners and add `--features gpu-cuda|gpu-hip|gpu-vulkan` where applicable. - For GPU features, set up appropriate runners and add `--features gpu-cuda|gpu-hip|gpu-vulkan` where applicable.
- For docs-only changes, jobs still build/test to ensure doctests and examples compile when enabled. - For docs-only changes, jobs still build/test to ensure doctests and examples compile when enabled.
- Mark the CI job named `ci` as a required status check for the default branch in repository branch protection settings.

View File

@@ -13,12 +13,6 @@ Rust toolchain
- rustup install stable - rustup install stable
- rustup default stable - rustup default stable
Dependency pinning
- We pin whisper-rs (git dependency) to a known-good commit in Cargo.toml for reproducibility.
- To bump it, resolve/test the desired commit locally, then run:
- cargo update -p whisper-rs --precise 135b60b85a15714862806b6ea9f76abec38156f1
Replace the SHA with the desired commit and update the rev in Cargo.toml accordingly.
Build Build
- CPU-only (default): - CPU-only (default):
- cargo build - cargo build
@@ -47,16 +41,6 @@ Tests
- cargo test - cargo test
- The test suite includes CLI-oriented integration tests and unit tests. Some tests simulate GPU detection using env vars (POLYSCRIBE_TEST_FORCE_*). Do not rely on these flags in production code. - The test suite includes CLI-oriented integration tests and unit tests. Some tests simulate GPU detection using env vars (POLYSCRIBE_TEST_FORCE_*). Do not rely on these flags in production code.
Examples check (no network, non-interactive)
- To quickly validate that example scripts are wired correctly (no prompts, quiet, exit 0), run:
- make examples-check
- What it does:
- Iterates over examples/*.sh
- Forces execution with --no-interaction and -q via a wrapper
- Uses a stubbed BIN that performs no network access and exits successfully
- Redirects stdin from /dev/null to ensure no prompts
- This is intended for CI smoke checks and local verification; it does not actually download models or transcribe audio.
Clippy Clippy
- Run lint checks and treat warnings as errors: - Run lint checks and treat warnings as errors:
- cargo clippy --all-targets -- -D warnings - cargo clippy --all-targets -- -D warnings

0
examples/download_models_interactive.sh Executable file → Normal file
View File

0
examples/transcribe_file.sh Executable file → Normal file
View File

0
examples/update_models.sh Executable file → Normal file
View File

View File

@@ -1,26 +0,0 @@
#!/usr/bin/env bash
# Lightweight stub for examples-check: simulates the PolyScribe CLI without I/O or network
# - Accepts any arguments
# - Exits 0
# - Produces no output unless VERBOSE_STUB=1
# - Never performs network operations
# - Never reads from stdin
set -euo pipefail
if [[ "${VERBOSE_STUB:-0}" == "1" ]]; then
echo "[stub] polyscribe $*" 1>&2
fi
# Behave quietly if -q/--quiet is present by default (no output)
# Honor --help/-h: print minimal usage if verbose requested
if [[ "${VERBOSE_STUB:-0}" == "1" ]]; then
for arg in "$@"; do
if [[ "$arg" == "-h" || "$arg" == "--help" ]]; then
echo "PolyScribe stub: no-op (examples-check)" 1>&2
break
fi
done
fi
# Always succeed quietly
exit 0

View File

@@ -1,28 +0,0 @@
#!/usr/bin/env bash
# Wrapper that ensures --no-interaction -q are present, then delegates to the real BIN (stub by default)
set -euo pipefail
REAL_BIN=${REAL_BIN:-"$(dirname "$0")/bin_stub.sh"}
# Append flags if not already present in args
args=("$@")
need_no_interaction=1
need_quiet=1
for a in "${args[@]}"; do
[[ "$a" == "--no-interaction" ]] && need_no_interaction=0
[[ "$a" == "-q" || "$a" == "--quiet" ]] && need_quiet=0
done
if [[ $need_no_interaction -eq 1 ]]; then
args=("--no-interaction" "${args[@]}")
fi
if [[ $need_quiet -eq 1 ]]; then
args=("-q" "${args[@]}")
fi
# Never read stdin; prevent accidental blocking by redirecting from /dev/null
# Also advertise offline via env variables commonly checked by the app
export CI=1
export POLYSCRIBE_MODELS_BASE_COPY_DIR="${POLYSCRIBE_MODELS_BASE_COPY_DIR:-}" # leave empty by default
exec "$REAL_BIN" "${args[@]}" </dev/null

View File

@@ -3,12 +3,10 @@
//! Transcription backend selection and implementations (CPU/GPU) used by PolyScribe. //! Transcription backend selection and implementations (CPU/GPU) used by PolyScribe.
use crate::OutputEntry; use crate::OutputEntry;
use crate::progress::ProgressMessage;
use crate::{decode_audio_to_pcm_f32_ffmpeg, find_model_file}; use crate::{decode_audio_to_pcm_f32_ffmpeg, find_model_file};
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use std::env; use std::env;
use std::path::Path; use std::path::Path;
use std::sync::mpsc::Sender;
// Re-export a public enum for CLI parsing usage // Re-export a public enum for CLI parsing usage
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -42,7 +40,6 @@ pub trait TranscribeBackend {
audio_path: &Path, audio_path: &Path,
speaker: &str, speaker: &str,
lang_opt: Option<&str>, lang_opt: Option<&str>,
progress_tx: Option<Sender<ProgressMessage>>,
gpu_layers: Option<u32>, gpu_layers: Option<u32>,
) -> Result<Vec<OutputEntry>>; ) -> Result<Vec<OutputEntry>>;
} }
@@ -141,28 +138,6 @@ impl Default for VulkanBackend {
} }
} }
/// Validate that a provided language hint is compatible with the selected model.
///
/// English-only models (filenames containing ".en." or ending with ".en.bin") reject non-"en" hints.
/// When no language is provided, this check passes and downstream behavior remains unchanged.
pub(crate) fn validate_model_lang_compat(model: &Path, lang_opt: Option<&str>) -> Result<()> {
let is_en_only = model
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.contains(".en.") || s.ends_with(".en.bin"))
.unwrap_or(false);
if let Some(lang) = lang_opt {
if is_en_only && lang != "en" {
return Err(anyhow!(
"Selected model is English-only ({}), but a non-English language hint '{}' was provided. Please use a multilingual model or set WHISPER_MODEL.",
model.display(),
lang
));
}
}
Ok(())
}
impl TranscribeBackend for CpuBackend { impl TranscribeBackend for CpuBackend {
fn kind(&self) -> BackendKind { fn kind(&self) -> BackendKind {
BackendKind::Cpu BackendKind::Cpu
@@ -172,10 +147,9 @@ impl TranscribeBackend for CpuBackend {
audio_path: &Path, audio_path: &Path,
speaker: &str, speaker: &str,
lang_opt: Option<&str>, lang_opt: Option<&str>,
progress_tx: Option<Sender<ProgressMessage>>,
_gpu_layers: Option<u32>, _gpu_layers: Option<u32>,
) -> Result<Vec<OutputEntry>> { ) -> Result<Vec<OutputEntry>> {
transcribe_with_whisper_rs(audio_path, speaker, lang_opt, progress_tx) transcribe_with_whisper_rs(audio_path, speaker, lang_opt)
} }
} }
@@ -188,11 +162,10 @@ impl TranscribeBackend for CudaBackend {
audio_path: &Path, audio_path: &Path,
speaker: &str, speaker: &str,
lang_opt: Option<&str>, lang_opt: Option<&str>,
progress_tx: Option<Sender<ProgressMessage>>,
_gpu_layers: Option<u32>, _gpu_layers: Option<u32>,
) -> Result<Vec<OutputEntry>> { ) -> Result<Vec<OutputEntry>> {
// whisper-rs uses enabled CUDA feature at build time; call same code path // whisper-rs uses enabled CUDA feature at build time; call same code path
transcribe_with_whisper_rs(audio_path, speaker, lang_opt, progress_tx) transcribe_with_whisper_rs(audio_path, speaker, lang_opt)
} }
} }
@@ -205,10 +178,9 @@ impl TranscribeBackend for HipBackend {
audio_path: &Path, audio_path: &Path,
speaker: &str, speaker: &str,
lang_opt: Option<&str>, lang_opt: Option<&str>,
progress_tx: Option<Sender<ProgressMessage>>,
_gpu_layers: Option<u32>, _gpu_layers: Option<u32>,
) -> Result<Vec<OutputEntry>> { ) -> Result<Vec<OutputEntry>> {
transcribe_with_whisper_rs(audio_path, speaker, lang_opt, progress_tx) transcribe_with_whisper_rs(audio_path, speaker, lang_opt)
} }
} }
@@ -221,7 +193,6 @@ impl TranscribeBackend for VulkanBackend {
_audio_path: &Path, _audio_path: &Path,
_speaker: &str, _speaker: &str,
_lang_opt: Option<&str>, _lang_opt: Option<&str>,
_progress_tx: Option<Sender<ProgressMessage>>,
_gpu_layers: Option<u32>, _gpu_layers: Option<u32>,
) -> Result<Vec<OutputEntry>> { ) -> Result<Vec<OutputEntry>> {
Err(anyhow!( Err(anyhow!(
@@ -248,7 +219,7 @@ pub struct SelectionResult {
/// guidance on how to enable it. /// guidance on how to enable it.
/// ///
/// Set `verbose` to true to print detection/selection info to stderr. /// Set `verbose` to true to print detection/selection info to stderr.
pub fn select_backend(requested: BackendKind, config: &crate::Config) -> Result<SelectionResult> { pub fn select_backend(requested: BackendKind, verbose: bool) -> Result<SelectionResult> {
let mut detected = Vec::new(); let mut detected = Vec::new();
if cuda_available() { if cuda_available() {
detected.push(BackendKind::Cuda); detected.push(BackendKind::Cuda);
@@ -312,7 +283,7 @@ pub fn select_backend(requested: BackendKind, config: &crate::Config) -> Result<
BackendKind::Cpu => BackendKind::Cpu, BackendKind::Cpu => BackendKind::Cpu,
}; };
if config.verbose >= 1 && !config.quiet { if verbose {
crate::dlog!(1, "Detected backends: {:?}", detected); crate::dlog!(1, "Detected backends: {:?}", detected);
crate::dlog!(1, "Selected backend: {:?}", chosen); crate::dlog!(1, "Selected backend: {:?}", chosen);
} }
@@ -326,32 +297,27 @@ pub fn select_backend(requested: BackendKind, config: &crate::Config) -> Result<
// Internal helper: transcription using whisper-rs with CPU/GPU (depending on build features) // Internal helper: transcription using whisper-rs with CPU/GPU (depending on build features)
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
#[cfg(feature = "whisper")]
pub(crate) fn transcribe_with_whisper_rs( pub(crate) fn transcribe_with_whisper_rs(
audio_path: &Path, audio_path: &Path,
speaker: &str, speaker: &str,
lang_opt: Option<&str>, lang_opt: Option<&str>,
progress_tx: Option<Sender<ProgressMessage>>,
) -> Result<Vec<OutputEntry>> { ) -> Result<Vec<OutputEntry>> {
// initial progress
if let Some(tx) = &progress_tx {
let _ = tx.send(ProgressMessage {
fraction: 0.0,
stage: Some("load_model".to_string()),
note: Some(format!("{}", audio_path.display())),
});
}
let pcm = decode_audio_to_pcm_f32_ffmpeg(audio_path)?; let pcm = decode_audio_to_pcm_f32_ffmpeg(audio_path)?;
let model = find_model_file()?; let model = find_model_file()?;
if let Some(tx) = &progress_tx { let is_en_only = model
let _ = tx.send(ProgressMessage { .file_name()
fraction: 0.05, .and_then(|s| s.to_str())
stage: Some("load_model".to_string()), .map(|s| s.contains(".en.") || s.ends_with(".en.bin"))
note: Some("model selected".to_string()), .unwrap_or(false);
}); if let Some(lang) = lang_opt {
if is_en_only && lang != "en" {
return Err(anyhow!(
"Selected model is English-only ({}), but a non-English language hint '{}' was provided. Please use a multilingual model or set WHISPER_MODEL.",
model.display(),
lang
));
}
} }
// Validate language hint compatibility with the selected model
validate_model_lang_compat(&model, lang_opt)?;
let model_str = model let model_str = model
.to_str() .to_str()
.ok_or_else(|| anyhow!("Model path not valid UTF-8: {}", model.display()))?; .ok_or_else(|| anyhow!("Model path not valid UTF-8: {}", model.display()))?;
@@ -375,13 +341,6 @@ pub(crate) fn transcribe_with_whisper_rs(
.map_err(|e| anyhow!("Failed to create Whisper state: {:?}", e))?; .map_err(|e| anyhow!("Failed to create Whisper state: {:?}", e))?;
Ok::<_, anyhow::Error>((ctx, state)) Ok::<_, anyhow::Error>((ctx, state))
})?; })?;
if let Some(tx) = &progress_tx {
let _ = tx.send(ProgressMessage {
fraction: 0.15,
stage: Some("encode".to_string()),
note: Some("state ready".to_string()),
});
}
let mut params = let mut params =
whisper_rs::FullParams::new(whisper_rs::SamplingStrategy::Greedy { best_of: 1 }); whisper_rs::FullParams::new(whisper_rs::SamplingStrategy::Greedy { best_of: 1 });
@@ -394,25 +353,11 @@ pub(crate) fn transcribe_with_whisper_rs(
params.set_language(Some(lang)); params.set_language(Some(lang));
} }
if let Some(tx) = &progress_tx {
let _ = tx.send(ProgressMessage {
fraction: 0.20,
stage: Some("decode".to_string()),
note: Some("inference".to_string()),
});
}
crate::with_suppressed_stderr(|| { crate::with_suppressed_stderr(|| {
state state
.full(params, &pcm) .full(params, &pcm)
.map_err(|e| anyhow!("Whisper full() failed: {:?}", e)) .map_err(|e| anyhow!("Whisper full() failed: {:?}", e))
})?; })?;
if let Some(tx) = &progress_tx {
let _ = tx.send(ProgressMessage {
fraction: 1.0,
stage: Some("done".to_string()),
note: Some("transcription finished".to_string()),
});
}
let num_segments = state let num_segments = state
.full_n_segments() .full_n_segments()
@@ -440,140 +385,3 @@ pub(crate) fn transcribe_with_whisper_rs(
} }
Ok(items) Ok(items)
} }
#[allow(clippy::too_many_arguments)]
#[cfg(not(feature = "whisper"))]
pub(crate) fn transcribe_with_whisper_rs(
_audio_path: &Path,
_speaker: &str,
_lang_opt: Option<&str>,
_progress_tx: Option<Sender<ProgressMessage>>,
) -> Result<Vec<OutputEntry>> {
Err(anyhow!(
"Transcription requires the 'whisper' feature. Rebuild with --features whisper (and optional gpu-cuda/gpu-hip)."
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::env as std_env;
use std::sync::{Mutex, OnceLock};
#[test]
fn test_validate_model_lang_guard_table() {
struct case<'a> { model: &'a str, lang: Option<&'a str>, ok: bool }
let cases = vec![
// English-only model with en hint: OK
case { model: "ggml-base.en.bin", lang: Some("en"), ok: true },
// English-only model with de hint: Error
case { model: "ggml-small.en.bin", lang: Some("de"), ok: false },
// Multilingual model with de hint: OK
case { model: "ggml-large-v3.bin", lang: Some("de"), ok: true },
// No language provided (audio path scenario): guard should pass (existing behavior elsewhere)
case { model: "ggml-medium.en.bin", lang: None, ok: true },
];
for c in cases {
let p = std::path::Path::new(c.model);
let res = validate_model_lang_compat(p, c.lang);
match (c.ok, res) {
(true, Ok(())) => {}
(false, Err(e)) => {
let msg = format!("{}", e);
assert!(msg.contains("English-only"), "unexpected error: {msg}");
if let Some(l) = c.lang { assert!(msg.contains(l), "missing lang in msg: {msg}"); }
}
(true, Err(e)) => panic!("expected Ok for model={}, lang={:?}, got error: {}", c.model, c.lang, e),
(false, Ok(())) => panic!("expected Err for model={}, lang={:?}", c.model, c.lang),
}
}
}
// Serialize environment variable modifications across tests in this module
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
#[test]
fn test_select_backend_auto_prefers_cuda_then_hip_then_vulkan_then_cpu() {
let _guard = ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
// Clear overrides
unsafe {
std_env::remove_var("POLYSCRIBE_TEST_FORCE_CUDA");
std_env::remove_var("POLYSCRIBE_TEST_FORCE_HIP");
std_env::remove_var("POLYSCRIBE_TEST_FORCE_VULKAN");
}
// No GPU -> CPU
let sel = select_backend(BackendKind::Auto, &crate::Config::default()).unwrap();
assert_eq!(sel.chosen, BackendKind::Cpu);
// Vulkan only -> Vulkan
unsafe { std_env::set_var("POLYSCRIBE_TEST_FORCE_VULKAN", "1"); }
let sel = select_backend(BackendKind::Auto, &crate::Config::default()).unwrap();
assert_eq!(sel.chosen, BackendKind::Vulkan);
// HIP only -> HIP (and preferred over Vulkan)
unsafe {
std_env::set_var("POLYSCRIBE_TEST_FORCE_HIP", "1");
std_env::remove_var("POLYSCRIBE_TEST_FORCE_VULKAN");
}
let sel = select_backend(BackendKind::Auto, &crate::Config::default()).unwrap();
assert_eq!(sel.chosen, BackendKind::Hip);
// CUDA only -> CUDA (and preferred over HIP)
unsafe { std_env::set_var("POLYSCRIBE_TEST_FORCE_CUDA", "1"); }
let sel = select_backend(BackendKind::Auto, &crate::Config::default()).unwrap();
assert_eq!(sel.chosen, BackendKind::Cuda);
// Cleanup
unsafe {
std_env::remove_var("POLYSCRIBE_TEST_FORCE_CUDA");
std_env::remove_var("POLYSCRIBE_TEST_FORCE_HIP");
std_env::remove_var("POLYSCRIBE_TEST_FORCE_VULKAN");
}
}
#[test]
fn test_select_backend_explicit_unavailable_errors_with_guidance() {
let _guard = ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
// Ensure all off
unsafe {
std_env::remove_var("POLYSCRIBE_TEST_FORCE_CUDA");
std_env::remove_var("POLYSCRIBE_TEST_FORCE_HIP");
std_env::remove_var("POLYSCRIBE_TEST_FORCE_VULKAN");
}
// CUDA requested but unavailable -> error with guidance
let err = select_backend(BackendKind::Cuda, &crate::Config::default()).err().expect("expected error");
let msg = format!("{}", err);
assert!(msg.contains("Requested CUDA backend"), "unexpected msg: {msg}");
assert!(msg.contains("How to fix"), "expected guidance text in: {msg}");
// HIP requested but unavailable -> error with guidance
let err = select_backend(BackendKind::Hip, &crate::Config::default()).err().expect("expected error");
let msg = format!("{}", err);
assert!(msg.contains("ROCm/HIP"), "unexpected msg: {msg}");
assert!(msg.contains("How to fix"), "expected guidance text in: {msg}");
// Vulkan requested but unavailable -> error with guidance
let err = select_backend(BackendKind::Vulkan, &crate::Config::default()).err().expect("expected error");
let msg = format!("{}", err);
assert!(msg.contains("Vulkan"), "unexpected msg: {msg}");
assert!(msg.contains("How to fix"), "expected guidance text in: {msg}");
// Now verify success when explicitly available via overrides
unsafe { std_env::set_var("POLYSCRIBE_TEST_FORCE_CUDA", "1"); }
assert!(select_backend(BackendKind::Cuda, &crate::Config::default()).is_ok());
unsafe {
std_env::remove_var("POLYSCRIBE_TEST_FORCE_CUDA");
std_env::set_var("POLYSCRIBE_TEST_FORCE_HIP", "1");
}
assert!(select_backend(BackendKind::Hip, &crate::Config::default()).is_ok());
unsafe {
std_env::remove_var("POLYSCRIBE_TEST_FORCE_HIP");
std_env::set_var("POLYSCRIBE_TEST_FORCE_VULKAN", "1");
}
assert!(select_backend(BackendKind::Vulkan, &crate::Config::default()).is_ok());
// Cleanup
unsafe { std_env::remove_var("POLYSCRIBE_TEST_FORCE_VULKAN"); }
}
}

View File

@@ -16,7 +16,6 @@
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
// Global runtime flags // Global runtime flags
// Compatibility: globals are retained temporarily until all call-sites pass Config explicitly. They will be removed in a follow-up cleanup.
static QUIET: AtomicBool = AtomicBool::new(false); static QUIET: AtomicBool = AtomicBool::new(false);
static NO_INTERACTION: AtomicBool = AtomicBool::new(false); static NO_INTERACTION: AtomicBool = AtomicBool::new(false);
static VERBOSE: AtomicU8 = AtomicU8::new(0); static VERBOSE: AtomicU8 = AtomicU8::new(0);
@@ -36,17 +35,7 @@ pub fn set_no_interaction(b: bool) {
} }
/// Return current non-interactive state. /// Return current non-interactive state.
pub fn is_no_interaction() -> bool { pub fn is_no_interaction() -> bool {
if NO_INTERACTION.load(Ordering::Relaxed) { NO_INTERACTION.load(Ordering::Relaxed)
return true;
}
// Also honor NO_INTERACTION=1/true environment variable for convenience/testing
match std::env::var("NO_INTERACTION") {
Ok(v) => {
let v = v.trim();
v == "1" || v.eq_ignore_ascii_case("true")
}
Err(_) => false,
}
} }
/// Set verbose level (0 = normal, 1 = verbose, 2 = super-verbose) /// Set verbose level (0 = normal, 1 = verbose, 2 = super-verbose)
@@ -103,7 +92,7 @@ impl StderrSilencer {
#[cfg(unix)] #[cfg(unix)]
unsafe { unsafe {
// Duplicate current stderr (fd 2) // Duplicate current stderr (fd 2)
let old_fd = unix_fd::dup(unix_fd::STDERR_FILENO); let old_fd = dup(2);
if old_fd < 0 { if old_fd < 0 {
return Self { return Self {
active: false, active: false,
@@ -113,10 +102,10 @@ impl StderrSilencer {
} }
// Open /dev/null for writing // Open /dev/null for writing
let devnull_cstr = std::ffi::CString::new("/dev/null").unwrap(); let devnull_cstr = std::ffi::CString::new("/dev/null").unwrap();
let dn = unix_fd::open(devnull_cstr.as_ptr(), unix_fd::O_WRONLY); let dn = open(devnull_cstr.as_ptr(), O_WRONLY);
if dn < 0 { if dn < 0 {
// failed to open devnull; restore and bail // failed to open devnull; restore and bail
unix_fd::close(old_fd); close(old_fd);
return Self { return Self {
active: false, active: false,
old_stderr_fd: -1, old_stderr_fd: -1,
@@ -124,9 +113,9 @@ impl StderrSilencer {
}; };
} }
// Redirect fd 2 to devnull // Redirect fd 2 to devnull
if unix_fd::dup2(dn, unix_fd::STDERR_FILENO) < 0 { if dup2(dn, 2) < 0 {
unix_fd::close(dn); close(dn);
unix_fd::close(old_fd); close(old_fd);
return Self { return Self {
active: false, active: false,
old_stderr_fd: -1, old_stderr_fd: -1,
@@ -154,9 +143,9 @@ impl Drop for StderrSilencer {
#[cfg(unix)] #[cfg(unix)]
unsafe { unsafe {
// Restore old stderr and close devnull and old copies // Restore old stderr and close devnull and old copies
let _ = unix_fd::dup2(self.old_stderr_fd, unix_fd::STDERR_FILENO); let _ = dup2(self.old_stderr_fd, 2);
let _ = unix_fd::close(self.devnull_fd); let _ = close(self.devnull_fd);
let _ = unix_fd::close(self.old_stderr_fd); let _ = close(self.old_stderr_fd);
} }
self.active = false; self.active = false;
} }
@@ -189,8 +178,7 @@ where
#[macro_export] #[macro_export]
macro_rules! elog { macro_rules! elog {
($($arg:tt)*) => {{ ($($arg:tt)*) => {{
// Route errors through the progress area when available so they render inside cliclack eprintln!("ERROR: {}", format!($($arg)*));
$crate::log_with_level!("ERROR", None, true, $($arg)*);
}} }}
} }
/// Internal helper macro used by other logging macros to centralize the /// Internal helper macro used by other logging macros to centralize the
@@ -207,11 +195,7 @@ macro_rules! log_with_level {
!$crate::is_quiet() !$crate::is_quiet()
}; };
if should_print { if should_print {
let line = format!("{}: {}", $label, format!($($arg)*)); eprintln!("{}: {}", $label, format!($($arg)*));
// Try to render via the active progress manager (cliclack/indicatif area).
if !$crate::progress::log_line_via_global(&line) {
eprintln!("{}", line);
}
} }
}} }}
} }
@@ -246,69 +230,17 @@ use anyhow::{Context, Result, anyhow};
use chrono::Local; use chrono::Local;
use std::env; use std::env;
use std::fs::create_dir_all; use std::fs::create_dir_all;
use std::io; use std::io::{self, Write};
use std::io::Write;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
#[cfg(unix)] #[cfg(unix)]
mod unix_fd { use libc::{O_WRONLY, close, dup, dup2, open};
pub use libc::O_WRONLY;
pub const STDERR_FILENO: i32 = 2; // libc::STDERR_FILENO isn't always available on all targets
#[inline]
pub unsafe fn dup(fd: i32) -> i32 { libc::dup(fd) }
#[inline]
pub unsafe fn dup2(fd: i32, fd2: i32) -> i32 { libc::dup2(fd, fd2) }
#[inline]
pub unsafe fn open(path: *const libc::c_char, flags: i32) -> i32 { libc::open(path, flags) }
#[inline]
pub unsafe fn close(fd: i32) -> i32 { libc::close(fd) }
}
/// Re-export backend module (GPU/CPU selection and transcription). /// Re-export backend module (GPU/CPU selection and transcription).
pub mod backend; pub mod backend;
/// Re-export models module (model listing/downloading/updating). /// Re-export models module (model listing/downloading/updating).
pub mod models; pub mod models;
/// Progress and progress bar abstraction (TTY-aware, stderr-only)
pub mod progress;
/// UI helpers for interactive prompts (cliclack-backed)
pub mod ui;
/// Runtime configuration passed across the library instead of using globals.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Config {
/// Suppress non-essential logs.
pub quiet: bool,
/// Verbosity level (0 = normal, 1 = verbose, 2 = super-verbose).
pub verbose: u8,
/// Disable interactive prompts.
pub no_interaction: bool,
/// Disable progress output.
pub no_progress: bool,
}
impl Config {
/// Construct a Config from explicit values.
pub fn new(quiet: bool, verbose: u8, no_interaction: bool, no_progress: bool) -> Self {
Self { quiet, verbose, no_interaction, no_progress }
}
/// Snapshot current global settings into a Config (temporary compatibility helper).
pub fn from_globals() -> Self {
Self {
quiet: crate::is_quiet(),
verbose: crate::verbose_level(),
no_interaction: crate::is_no_interaction(),
no_progress: matches!(std::env::var("NO_PROGRESS"), Ok(ref v) if v == "1" || v.eq_ignore_ascii_case("true")),
}
}
}
impl Default for Config {
fn default() -> Self {
Self { quiet: false, verbose: 0, no_interaction: false, no_progress: false }
}
}
/// Transcript entry for a single segment. /// Transcript entry for a single segment.
#[derive(Debug, serde::Serialize, Clone)] #[derive(Debug, serde::Serialize, Clone)]
@@ -464,56 +396,6 @@ pub fn normalize_lang_code(input: &str) -> Option<String> {
/// Locate a Whisper model file, prompting user to download/select when necessary. /// Locate a Whisper model file, prompting user to download/select when necessary.
pub fn find_model_file() -> Result<PathBuf> { pub fn find_model_file() -> Result<PathBuf> {
// Silent model resolution used during processing to avoid interfering with progress bars.
// Preflight prompting should be done by the caller before bars are created (use find_model_file_with_printer).
let models_dir_buf = models_dir_path();
let models_dir = models_dir_buf.as_path();
if !models_dir.exists() {
create_dir_all(models_dir).with_context(|| {
format!(
"Failed to create models directory: {}",
models_dir.display()
)
})?;
}
// 1) Explicit environment override
if let Ok(env_model) = env::var("WHISPER_MODEL") {
let p = PathBuf::from(env_model);
if p.is_file() {
let _ = std::fs::write(models_dir.join(".last_model"), p.display().to_string());
return Ok(p);
}
}
// 2) Previously selected model
let last_file = models_dir.join(".last_model");
if let Ok(prev) = std::fs::read_to_string(&last_file) {
let prev = prev.trim();
if !prev.is_empty() {
let p = PathBuf::from(prev);
if p.is_file() {
return Ok(p);
}
}
}
// 3) Best local model without prompting
if let Some(local) = crate::models::pick_best_local_model(models_dir) {
let _ = std::fs::write(models_dir.join(".last_model"), local.display().to_string());
return Ok(local);
}
// 4) No model available; avoid interactive prompts here to prevent progress bar redraw issues.
// Callers should run find_model_file_with_printer(...) before starting progress bars to interactively select/download.
Err(anyhow!(
"No Whisper model available. Run with --download-models or ensure WHISPER_MODEL is set before processing."
))
}
/// Locate a Whisper model file, prompting user to download/select when necessary.
/// All prompts are printed using the provided printer closure (e.g., MultiProgress::println)
/// to avoid interfering with active progress bars.
pub fn find_model_file_with_printer<F>(printer: F) -> Result<PathBuf>
where
F: Fn(&str),
{
let models_dir_buf = models_dir_path(); let models_dir_buf = models_dir_path();
let models_dir = models_dir_buf.as_path(); let models_dir = models_dir_buf.as_path();
if !models_dir.exists() { if !models_dir.exists() {
@@ -580,10 +462,12 @@ where
"No models available and interactive mode is disabled. Please set WHISPER_MODEL or run with --download-models." "No models available and interactive mode is disabled. Please set WHISPER_MODEL or run with --download-models."
)); ));
} }
// Use unified cliclack confirm via UI helper eprint!("Would you like to download models now? [Y/n]: ");
let download_now = crate::ui::prompt_confirm("Download models now?", true) io::stderr().flush().ok();
.context("prompt error during confirmation")?; let mut input = String::new();
if download_now { io::stdin().read_line(&mut input).ok();
let ans = input.trim().to_lowercase();
if ans.is_empty() || ans == "y" || ans == "yes" {
if let Err(e) = models::run_interactive_model_downloader() { if let Err(e) = models::run_interactive_model_downloader() {
elog!("Downloader failed: {:#}", e); elog!("Downloader failed: {:#}", e);
} }
@@ -635,30 +519,25 @@ where
} }
} }
printer(&"Multiple Whisper models found:".to_string()); eprintln!("Multiple Whisper models found in {}:", models_dir.display());
let mut display_names: Vec<String> = Vec::with_capacity(candidates.len());
for (i, p) in candidates.iter().enumerate() { for (i, p) in candidates.iter().enumerate() {
let name = p eprintln!(" {}) {}", i + 1, p.display());
.file_name()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| p.display().to_string());
display_names.push(name.clone());
printer(&format!(" {}) {}", i + 1, name));
} }
// Print a blank line before the selection prompt to keep output synchronized. eprint!("Select model by number [1-{}]: ", candidates.len());
printer(""); io::stderr().flush().ok();
let idx = if crate::is_no_interaction() || !crate::stdin_is_tty() { let mut input = String::new();
// Non-interactive: auto-select the first candidate deterministically (as listed) io::stdin()
0 .read_line(&mut input)
} else { .context("Failed to read selection")?;
crate::ui::prompt_select_index("Select a Whisper model", &display_names) let sel: usize = input
.context("Failed to read selection")? .trim()
}; .parse()
let chosen = candidates.swap_remove(idx); .map_err(|_| anyhow!("Invalid selection: {}", input.trim()))?;
if sel == 0 || sel > candidates.len() {
return Err(anyhow!("Selection out of range"));
}
let chosen = candidates.swap_remove(sel - 1);
let _ = std::fs::write(models_dir.join(".last_model"), chosen.display().to_string()); let _ = std::fs::write(models_dir.join(".last_model"), chosen.display().to_string());
// Print an empty line after selection input
printer("");
Ok(chosen) Ok(chosen)
} }
@@ -678,16 +557,16 @@ pub fn decode_audio_to_pcm_f32_ffmpeg(audio_path: &Path) -> Result<Vec<f32>> {
{ {
Ok(o) => o, Ok(o) => o,
Err(e) => { Err(e) => {
return if e.kind() == std::io::ErrorKind::NotFound { if e.kind() == std::io::ErrorKind::NotFound {
Err(anyhow!( return Err(anyhow!(
"ffmpeg not found on PATH. Please install ffmpeg and ensure it is available." "ffmpeg not found on PATH. Please install ffmpeg and ensure it is available."
)) ));
} else { } else {
Err(anyhow!( return Err(anyhow!(
"Failed to execute ffmpeg for {}: {}", "Failed to execute ffmpeg for {}: {}",
audio_path.display(), audio_path.display(),
e e
)) ));
} }
} }
}; };

File diff suppressed because it is too large Load Diff

View File

@@ -393,77 +393,130 @@ fn format_model_list(models: &[ModelEntry]) -> String {
} }
fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result<Vec<ModelEntry>> { fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result<Vec<ModelEntry>> {
// Non-interactive safeguard: return empty (caller will handle as cancel/skip)
if crate::is_no_interaction() || !crate::stdin_is_tty() { if crate::is_no_interaction() || !crate::stdin_is_tty() {
// Non-interactive: do not prompt, return empty selection to skip
return Ok(Vec::new()); return Ok(Vec::new());
} }
// 1) Choose base (tiny, small, medium, etc.)
if models.is_empty() {
return Ok(Vec::new());
}
// Stage 1: pick a base family; preserve order from input list
let mut bases: Vec<String> = Vec::new(); let mut bases: Vec<String> = Vec::new();
let mut seen = std::collections::BTreeSet::new(); let mut last = String::new();
for m in models.iter() { for m in models.iter() {
if !seen.contains(&m.base) { if m.base != last {
seen.insert(m.base.clone()); // models are sorted by base; avoid duplicates while preserving order
bases.push(m.base.clone()); if !bases.last().map(|b| b == &m.base).unwrap_or(false) {
bases.push(m.base.clone());
}
last = m.base.clone();
} }
} }
if bases.is_empty() {
return Ok(Vec::new());
}
let base = if bases.len() == 1 { // Print base selection on stderr
bases[0].clone() eprintln!("Available base model families:");
} else { for (i, b) in bases.iter().enumerate() {
crate::ui::prompt_select_one("Select model family/base:", &bases)? eprintln!(" {}) {}", i + 1, b);
}; }
loop {
// Stage 2: within base, present variants eprint!("Select base (number or name, 'q' to cancel): ");
let mut variants: Vec<&ModelEntry> = models.iter().filter(|m| m.base == base).collect(); io::stderr().flush().ok();
variants.sort_by_key(|m| (m.size, m.subtype.clone(), m.name.clone())); let mut line = String::new();
io::stdin()
let labels: Vec<String> = variants .read_line(&mut line)
.iter() .context("Failed to read base selection")?;
.map(|m| { let s = line.trim();
let size_h = human_size(m.size); if s.eq_ignore_ascii_case("q")
if let Some(sha) = &m.sha256 { || s.eq_ignore_ascii_case("quit")
format!("{} ({}, {}, sha: {}…)", m.name, m.subtype, size_h, &sha[..std::cmp::min(8, sha.len())]) || s.eq_ignore_ascii_case("exit")
} else { {
format!("{} ({}, {})", m.name, m.subtype, size_h)
}
})
.collect();
let selected_labels = crate::ui::prompt_multiselect(
"Select one or more variants to download:",
&labels,
&[],
)?;
// If no variants were explicitly selected, ask for confirmation to download all.
// This avoids surprising behavior while still allowing a quick "download all" path.
let mut picked: Vec<ModelEntry> = Vec::new();
if selected_labels.is_empty() {
// Confirm with the user; default to "No" to prevent accidental bulk downloads.
if crate::ui::prompt_confirm(&format!("No variants selected. Download ALL {base} variants?"), false).unwrap_or(false) {
crate::qlog!("Downloading all {base} variants as requested.");
for v in &variants {
picked.push((*v).clone());
}
} else {
// User declined; return empty selection so caller can abort gracefully.
return Ok(Vec::new()); return Ok(Vec::new());
} }
} else { let chosen_base = if let Ok(i) = s.parse::<usize>() {
// Map labels back to entries in stable order if i >= 1 && i <= bases.len() {
for (i, label) in labels.iter().enumerate() { Some(bases[i - 1].clone())
if selected_labels.iter().any(|s| s == label) { } else {
picked.push(variants[i].clone()); None
} }
} else if !s.is_empty() {
// accept exact name match (case-insensitive)
bases.iter().find(|b| b.eq_ignore_ascii_case(s)).cloned()
} else {
None
};
if let Some(base) = chosen_base {
// 2) Choose sub-type(s) within that base
let filtered: Vec<ModelEntry> =
models.iter().filter(|m| m.base == base).cloned().collect();
if filtered.is_empty() {
eprintln!("No models found for base '{base}'.");
continue;
}
// Reuse the formatter but only for the chosen base list
let listing = format_model_list(&filtered);
eprint!("{listing}");
// Build index map for filtered list
let mut index_map: Vec<usize> = Vec::with_capacity(filtered.len());
let mut idx = 1usize;
for (pos, _m) in filtered.iter().enumerate() {
index_map.push(pos);
idx += 1;
}
// Second prompt: sub-type selection
loop {
eprint!("Selection: ");
io::stderr().flush().ok();
let mut line2 = String::new();
io::stdin()
.read_line(&mut line2)
.context("Failed to read selection")?;
let s2 = line2.trim().to_lowercase();
if s2 == "q" || s2 == "quit" || s2 == "exit" {
return Ok(Vec::new());
}
let mut selected: Vec<usize> = Vec::new();
if s2 == "all" || s2 == "*" {
selected = (1..idx).collect();
} else if !s2.is_empty() {
for part in s2.split([',', ' ', ';']) {
let part = part.trim();
if part.is_empty() {
continue;
}
if let Some((a, b)) = part.split_once('-') {
if let (Ok(ia), Ok(ib)) = (a.parse::<usize>(), b.parse::<usize>()) {
if ia >= 1 && ib < idx && ia <= ib {
selected.extend(ia..=ib);
}
}
} else if let Ok(i) = part.parse::<usize>() {
if i >= 1 && i < idx {
selected.push(i);
}
}
}
}
selected.sort_unstable();
selected.dedup();
if selected.is_empty() {
eprintln!("No valid selection. Please try again or 'q' to cancel.");
continue;
}
let chosen: Vec<ModelEntry> = selected
.into_iter()
.map(|i| filtered[index_map[i - 1]].clone())
.collect();
return Ok(chosen);
}
} else {
eprintln!(
"Invalid base selection. Please enter a number from 1-{} or a base name.",
bases.len()
);
} }
} }
Ok(picked)
} }
fn compute_file_sha256_hex(path: &Path) -> Result<String> { fn compute_file_sha256_hex(path: &Path) -> Result<String> {
@@ -495,11 +548,6 @@ pub fn run_interactive_model_downloader() -> Result<()> {
.build() .build()
.context("Failed to build HTTP client")?; .context("Failed to build HTTP client")?;
// Set up a temporary progress manager so INFO/WARN logs render within the UI.
let pf0 = crate::progress::ProgressFactory::from_config(&crate::Config::from_globals());
let pm0 = pf0.make_manager(crate::progress::ProgressMode::Single);
crate::progress::set_global_progress_manager(&pm0);
ilog!( ilog!(
"Fetching online data: contacting Hugging Face to retrieve available models (this may take a moment)..." "Fetching online data: contacting Hugging Face to retrieve available models (this may take a moment)..."
); );
@@ -513,212 +561,11 @@ pub fn run_interactive_model_downloader() -> Result<()> {
qlog!("No selection. Aborting download."); qlog!("No selection. Aborting download.");
return Ok(()); return Ok(());
} }
// Set up progress bars for downloads
let pf = crate::progress::ProgressFactory::from_config(&crate::Config::from_globals());
let pm = pf.make_manager(crate::progress::ProgressMode::Multi { total_inputs: selected.len() as u64 });
crate::progress::set_global_progress_manager(&pm);
// Install Ctrl-C cleanup to ensure partial downloads (*.part) are removed on cancel
crate::progress::install_ctrlc_cleanup(pm.clone());
pm.set_total(selected.len());
for m in selected { for m in selected {
let label = format!("{} ({} total)", m.name, human_size(m.size)); if let Err(e) = download_one_model(&client, models_dir, &m) {
let item = pm.start_item(&label);
// Initialize message
if m.size > 0 { update_item_progress(&item, 0, m.size); }
if let Err(e) = download_one_model_with_progress(&client, models_dir, &m, &item) {
item.finish_with("done");
elog!("Error: {:#}", e); elog!("Error: {:#}", e);
} }
pm.inc_completed();
} }
pm.finish_all();
Ok(())
}
/// Internal helper: update a per-item progress handle with bytes progress.
fn update_item_progress(item: &crate::progress::ItemHandle, done_bytes: u64, total_bytes: u64) {
let total_mib = (total_bytes as f64) / (1024.0 * 1024.0);
let done_mib = (done_bytes as f64) / (1024.0 * 1024.0);
let pct = if total_bytes > 0 { ((done_bytes as f64) * 100.0 / (total_bytes as f64)).round() } else { 0.0 };
item.set_message(&format!("{:.2}/{:.2} MiB ({:.0}%)", done_mib, total_mib, pct));
if total_bytes > 0 {
item.set_progress((done_bytes as f32) / (total_bytes as f32));
}
}
/// Internal streaming helper used by both network and tests.
fn stream_with_progress<R: Read, W: Write>(mut reader: R, mut writer: W, total: u64, item: &crate::progress::ItemHandle) -> Result<(u64, String)> {
let mut hasher = Sha256::new();
let mut buf = [0u8; 1024 * 128];
let mut done: u64 = 0;
if total > 0 {
// initialize bar to determinate length 100
item.set_progress(0.0);
}
loop {
let n = reader.read(&mut buf).context("Network/read error")?;
if n == 0 { break; }
hasher.update(&buf[..n]);
writer.write_all(&buf[..n]).context("Write error")?;
done += n as u64;
update_item_progress(item, done, total);
}
writer.flush().ok();
let got = to_hex_lower(&hasher.finalize());
Ok((done, got))
}
/// Download a single model entry into the given models directory, verifying SHA-256 when available, with visible progress.
fn download_one_model_with_progress(client: &Client, models_dir: &Path, entry: &ModelEntry, item: &crate::progress::ItemHandle) -> Result<()> {
let final_path = models_dir.join(format!("ggml-{}.bin", entry.name));
// Same pre-checks as the non-progress version (up-to-date checks)
if final_path.exists() {
if let Some(expected) = &entry.sha256 {
match compute_file_sha256_hex(&final_path) {
Ok(local_hash) => {
if local_hash.eq_ignore_ascii_case(expected) {
item.set_message(&format!("{} up-to-date", entry.name));
item.set_progress(1.0);
item.finish_with("done");
return Ok(());
}
}
Err(_) => { /* proceed to download */ }
}
} else if entry.size > 0 {
if let Ok(md) = std::fs::metadata(&final_path) {
if md.len() == entry.size {
item.set_message(&format!("{} up-to-date", entry.name));
item.set_progress(1.0);
item.finish_with("done");
return Ok(());
}
}
}
}
// Offline/local copy mode for tests (same behavior, but reflect via item)
if let Ok(base_dir) = env::var("POLYSCRIBE_MODELS_BASE_COPY_DIR") {
let src_path = std::path::Path::new(&base_dir).join(format!("ggml-{}.bin", entry.name));
if src_path.exists() {
let tmp_path = models_dir.join(format!("ggml-{}.bin.part", entry.name));
if tmp_path.exists() { let _ = std::fs::remove_file(&tmp_path); }
std::fs::copy(&src_path, &tmp_path).with_context(|| {
format!("Failed to copy from {} to {}", src_path.display(), tmp_path.display())
})?;
if let Some(expected) = &entry.sha256 {
let got = compute_file_sha256_hex(&tmp_path)?;
if !got.eq_ignore_ascii_case(expected) {
let _ = std::fs::remove_file(&tmp_path);
return Err(anyhow!("SHA-256 mismatch for {} (copied): expected {}, got {}", entry.name, expected, got));
}
}
if final_path.exists() { let _ = std::fs::remove_file(&final_path); }
std::fs::rename(&tmp_path, &final_path).with_context(|| format!("Failed to move into place: {}", final_path.display()))?;
// Hardened verification after save
if let Some(expected) = &entry.sha256 {
match compute_file_sha256_hex(&final_path) {
Ok(rehash) => {
if !rehash.eq_ignore_ascii_case(expected) {
let _ = std::fs::remove_file(&final_path);
return Err(anyhow!(
"Downloaded file failed SHA-256 verification after save for {}: expected {}, got {}. The file has been removed. Please try downloading again. If the problem persists, check your network connection and disk space, or report this issue.",
entry.name,
expected,
rehash
));
}
}
Err(e) => {
let _ = std::fs::remove_file(&final_path);
return Err(anyhow!(
"Failed to verify downloaded file {}: {}. The file has been removed. Please try again.",
final_path.display(),
e
));
}
}
}
item.set_progress(1.0);
item.finish_with("done");
return Ok(());
}
}
let url = format!(
"https://huggingface.co/{}/resolve/main/ggml-{}.bin",
entry.repo, entry.name
);
let mut resp = client
.get(url)
.send()
.and_then(|r| r.error_for_status())
.context("Failed to download model")?;
let tmp_path = models_dir.join(format!("ggml-{}.bin.part", entry.name));
if tmp_path.exists() { let _ = std::fs::remove_file(&tmp_path); }
let mut file = std::io::BufWriter::new(
File::create(&tmp_path).with_context(|| format!("Failed to create {}", tmp_path.display()))?,
);
// Determine total bytes (prefer metadata/HEAD-derived entry.size)
let total = if entry.size > 0 { entry.size } else { resp.content_length().unwrap_or(0) };
// Stream with progress
let (_bytes, hash_hex) = stream_with_progress(&mut resp, &mut file, total, item)?;
// Verify
item.set_message("sha256 verifying…");
if let Some(expected) = &entry.sha256 {
if hash_hex.to_lowercase() != expected.to_lowercase() {
let _ = std::fs::remove_file(&tmp_path);
return Err(anyhow!(
"SHA-256 mismatch for {}: expected {}, got {}",
entry.name,
expected,
hash_hex
));
}
} else {
qlog!(
"Warning: no SHA-256 available for {}. Skipping verification.",
entry.name
);
}
// Replace existing file safely
if final_path.exists() { let _ = std::fs::remove_file(&final_path); }
std::fs::rename(&tmp_path, &final_path)
.with_context(|| format!("Failed to move into place: {}", final_path.display()))?;
// Hardened verification: recompute SHA-256 from the saved file and compare to expected.
if let Some(expected) = &entry.sha256 {
match compute_file_sha256_hex(&final_path) {
Ok(rehash) => {
if !rehash.eq_ignore_ascii_case(expected) {
let _ = std::fs::remove_file(&final_path);
return Err(anyhow!(
"Downloaded file failed SHA-256 verification after save for {}: expected {}, got {}. The file has been removed. Please try downloading again. If the problem persists, check your network connection and disk space, or report this issue.",
entry.name,
expected,
rehash
));
}
}
Err(e) => {
let _ = std::fs::remove_file(&final_path);
return Err(anyhow!(
"Failed to verify downloaded file {}: {}. The file has been removed. Please try again.",
final_path.display(),
e
));
}
}
}
item.finish_with("done");
Ok(()) Ok(())
} }
@@ -822,30 +669,6 @@ fn download_one_model(client: &Client, models_dir: &Path, entry: &ModelEntry) ->
} }
std::fs::rename(&tmp_path, &final_path) std::fs::rename(&tmp_path, &final_path)
.with_context(|| format!("Failed to move into place: {}", final_path.display()))?; .with_context(|| format!("Failed to move into place: {}", final_path.display()))?;
// Hardened verification after save
if let Some(expected) = &entry.sha256 {
match compute_file_sha256_hex(&final_path) {
Ok(rehash) => {
if !rehash.eq_ignore_ascii_case(expected) {
let _ = std::fs::remove_file(&final_path);
return Err(anyhow!(
"Downloaded file failed SHA-256 verification after save for {}: expected {}, got {}. The file has been removed. Please try downloading again. If the problem persists, check your network connection and disk space, or report this issue.",
entry.name,
expected,
rehash
));
}
}
Err(e) => {
let _ = std::fs::remove_file(&final_path);
return Err(anyhow!(
"Failed to verify downloaded file {}: {}. The file has been removed. Please try again.",
final_path.display(),
e
));
}
}
}
qlog!("Saved: {}", final_path.display()); qlog!("Saved: {}", final_path.display());
return Ok(()); return Ok(());
} }
@@ -911,32 +734,6 @@ fn download_one_model(client: &Client, models_dir: &Path, entry: &ModelEntry) ->
} }
std::fs::rename(&tmp_path, &final_path) std::fs::rename(&tmp_path, &final_path)
.with_context(|| format!("Failed to move into place: {}", final_path.display()))?; .with_context(|| format!("Failed to move into place: {}", final_path.display()))?;
// Hardened verification: recompute SHA-256 from the saved file and compare to expected.
if let Some(expected) = &entry.sha256 {
match compute_file_sha256_hex(&final_path) {
Ok(rehash) => {
if !rehash.eq_ignore_ascii_case(expected) {
let _ = std::fs::remove_file(&final_path);
return Err(anyhow!(
"Downloaded file failed SHA-256 verification after save for {}: expected {}, got {}. The file has been removed. Please try downloading again. If the problem persists, check your network connection and disk space, or report this issue.",
entry.name,
expected,
rehash
));
}
}
Err(e) => {
let _ = std::fs::remove_file(&final_path);
return Err(anyhow!(
"Failed to verify downloaded file {}: {}. The file has been removed. Please try again.",
final_path.display(),
e
));
}
}
}
qlog!("Saved: {}", final_path.display()); qlog!("Saved: {}", final_path.display());
Ok(()) Ok(())
} }
@@ -949,9 +746,7 @@ fn qlog_size_comparison(fname: &str, local: u64, remote: u64) -> bool {
} else { } else {
qlog!( qlog!(
"{} size {} differs from remote {}. Updating...", "{} size {} differs from remote {}. Updating...",
fname, fname, local, remote
local,
remote
); );
false false
} }
@@ -972,11 +767,6 @@ pub fn update_local_models() -> Result<()> {
.build() .build()
.context("Failed to build HTTP client")?; .context("Failed to build HTTP client")?;
// Ensure logs go through cliclack area during update as well
let pf_up = crate::progress::ProgressFactory::from_config(&crate::Config::from_globals());
let pm_up = pf_up.make_manager(crate::progress::ProgressMode::Single);
crate::progress::set_global_progress_manager(&pm_up);
// Obtain manifest: env override or online fetch // Obtain manifest: env override or online fetch
let models: Vec<ModelEntry> = if let Ok(manifest_path) = env::var("POLYSCRIBE_MODELS_MANIFEST") let models: Vec<ModelEntry> = if let Ok(manifest_path) = env::var("POLYSCRIBE_MODELS_MANIFEST")
{ {
@@ -1135,7 +925,6 @@ mod tests {
#[test] #[test]
fn test_format_model_list_spacing_and_structure() { fn test_format_model_list_spacing_and_structure() {
use std::env as std_env;
let models = vec![ let models = vec![
ModelEntry { ModelEntry {
name: "tiny.en-q5_1".to_string(), name: "tiny.en-q5_1".to_string(),
@@ -1348,92 +1137,4 @@ mod tests {
std::env::remove_var("HOME"); std::env::remove_var("HOME");
} }
} }
#[test]
fn test_download_progress_bar_reaches_done() {
use std::io::Cursor;
// Prepare small fake stream of 300 KiB
let data = vec![42u8; 300 * 1024];
let total = data.len() as u64;
let cursor = Cursor::new(data);
let mut sink: Vec<u8> = Vec::new();
let pm = crate::progress::ProgressManager::new_for_tests_multi_hidden(1);
let item = pm.start_item("test-download");
// Stream into sink while updating progress
let (_bytes, _hash) = super::stream_with_progress(cursor, &mut sink, total, &item).unwrap();
// Transition to verifying and finish
item.set_message("sha256 verifying…");
item.finish_with("done");
// Inspect current bar state
if let Some((pos, len, finished, msg)) = pm.current_state_for_tests() {
// Ensure determinate length is 100 and we reached 100
assert_eq!(len, 100);
assert_eq!(pos, 100);
assert!(finished);
assert!(msg.contains("done"));
} else {
panic!("progress manager did not expose current state");
}
}
#[test]
fn test_no_interaction_models_downloader_skips_prompts() {
// Force non-interactive; verify that no UI prompt functions are invoked
unsafe { std::env::set_var("NO_INTERACTION", "1"); }
crate::set_no_interaction(true);
crate::ui::testing_reset_prompt_call_counters();
let models = vec![
ModelEntry { name: "tiny.en-q5_1".to_string(), base: "tiny".to_string(), subtype: "en-q5_1".to_string(), size: 1024, sha256: None, repo: "ggerganov/whisper.cpp".to_string() },
ModelEntry { name: "tiny-q5_1".to_string(), base: "tiny".to_string(), subtype: "q5_1".to_string(), size: 2048, sha256: None, repo: "ggerganov/whisper.cpp".to_string() },
];
let picked = super::prompt_select_models_two_stage(&models).unwrap();
assert!(picked.is_empty(), "non-interactive should not select any models by default");
assert_eq!(crate::ui::testing_prompt_call_count(), 0, "no prompt functions should be called in non-interactive mode");
unsafe { std::env::remove_var("NO_INTERACTION"); }
}
#[test]
fn test_wrong_hash_deletes_temp_and_errors() {
use std::sync::{Mutex, OnceLock};
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
let _guard = ENV_LOCK.get_or_init(|| Mutex::new(())).lock().unwrap();
let tmp_models = tempdir().unwrap();
let tmp_base = tempdir().unwrap();
// Prepare source model file content and a pre-existing local file to trigger update
let model_name = "tiny.en-q5_1";
let src_path = tmp_base.path().join(format!("ggml-{}.bin", model_name));
let content = b"model data";
fs::write(&src_path, content).unwrap();
let wrong_sha = "0000000000000000000000000000000000000000000000000000000000000000".to_string();
let local_path = tmp_models.path().join(format!("ggml-{}.bin", model_name));
let original = b"old local";
fs::write(&local_path, original).unwrap();
unsafe {
std::env::set_var("POLYSCRIBE_MODELS_BASE_COPY_DIR", tmp_base.path());
}
// Construct a ModelEntry with wrong expected sha and call the downloader directly
let client = Client::builder().build().unwrap();
let entry = ModelEntry {
name: model_name.to_string(),
base: "tiny".to_string(),
subtype: "en-q5_1".to_string(),
size: content.len() as u64,
sha256: Some(wrong_sha),
repo: "ggerganov/whisper.cpp".to_string(),
};
let res = super::download_one_model(&client, tmp_models.path(), &entry);
assert!(res.is_err(), "expected error due to wrong hash");
let final_path = tmp_models.path().join(format!("ggml-{}.bin", model_name));
let tmp_path = tmp_models.path().join(format!("ggml-{}.bin.part", model_name));
assert!(final_path.exists(), "existing local file should remain when new download fails");
let preserved = fs::read(&final_path).unwrap();
assert_eq!(preserved, original, "existing local file must be preserved");
assert!(!tmp_path.exists(), ".part file should be deleted on hash mismatch");
}
} }

View File

@@ -1,149 +0,0 @@
use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::Context;
use crate::render_srt;
use crate::OutputRoot;
/// Which formats to write.
pub struct OutputFormats {
pub json: bool,
pub toml: bool,
pub srt: bool,
}
impl OutputFormats {
pub fn all() -> Self {
Self { json: true, toml: true, srt: true }
}
}
fn any_target_exists(base: &Path, formats: &OutputFormats) -> bool {
(formats.json && base.with_extension("json").exists())
|| (formats.toml && base.with_extension("toml").exists())
|| (formats.srt && base.with_extension("srt").exists())
}
fn with_suffix(base: &Path, n: usize) -> PathBuf {
let parent = base.parent().unwrap_or_else(|| Path::new(""));
let name = base.file_name().and_then(|s| s.to_str()).unwrap_or("out");
parent.join(format!("{}_{}", name, n))
}
fn resolve_base(base: &Path, formats: &OutputFormats, force: bool) -> PathBuf {
if force {
return base.to_path_buf();
}
if !any_target_exists(base, formats) {
return base.to_path_buf();
}
let mut n = 1usize;
loop {
let candidate = with_suffix(base, n);
if !any_target_exists(&candidate, formats) {
return candidate;
}
n += 1;
}
}
/// Write outputs for the given base path (without extension).
/// This will create files named `base.json`, `base.toml`, and `base.srt`
/// according to the `formats` flags. JSON and TOML will always end with a trailing newline.
pub fn write_outputs(base: &Path, root: &OutputRoot, formats: &OutputFormats, force: bool) -> anyhow::Result<()> {
let base = resolve_base(base, formats, force);
if formats.json {
let json_path = base.with_extension("json");
let mut json_file = File::create(&json_path).with_context(|| {
format!("Failed to create output file: {}", json_path.display())
})?;
serde_json::to_writer_pretty(&mut json_file, root)?;
// ensure trailing newline
writeln!(&mut json_file)?;
}
if formats.toml {
let toml_path = base.with_extension("toml");
let toml_str = toml::to_string_pretty(root)?;
let mut toml_file = File::create(&toml_path).with_context(|| {
format!("Failed to create output file: {}", toml_path.display())
})?;
toml_file.write_all(toml_str.as_bytes())?;
if !toml_str.ends_with('\n') {
writeln!(&mut toml_file)?;
}
}
if formats.srt {
let srt_path = base.with_extension("srt");
let srt_str = render_srt(&root.items);
let mut srt_file = File::create(&srt_path).with_context(|| {
format!("Failed to create output file: {}", srt_path.display())
})?;
srt_file.write_all(srt_str.as_bytes())?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::OutputEntry;
#[test]
fn write_outputs_creates_files_and_newlines() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().join("test_base");
let items = vec![OutputEntry { id: 0, speaker: "Alice".to_string(), start: 0.0, end: 1.23, text: "Hello".to_string() }];
let root = OutputRoot { items };
write_outputs(&base, &root, &OutputFormats::all(), false).unwrap();
let json_path = base.with_extension("json");
let toml_path = base.with_extension("toml");
let srt_path = base.with_extension("srt");
assert!(json_path.exists(), "json file should exist");
assert!(toml_path.exists(), "toml file should exist");
assert!(srt_path.exists(), "srt file should exist");
let json = std::fs::read_to_string(&json_path).unwrap();
let toml = std::fs::read_to_string(&toml_path).unwrap();
assert!(json.ends_with('\n'), "json should end with newline");
assert!(toml.ends_with('\n'), "toml should end with newline");
}
#[test]
fn suffix_is_added_when_file_exists_unless_forced() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().join("run");
// Precreate a toml file for base to simulate existing output
let pre_path = base.with_extension("toml");
std::fs::create_dir_all(dir.path()).unwrap();
std::fs::write(&pre_path, b"existing\n").unwrap();
let items = vec![OutputEntry { id: 0, speaker: "A".to_string(), start: 0.0, end: 1.0, text: "Hi".to_string() }];
let root = OutputRoot { items };
let fmts = OutputFormats { json: false, toml: true, srt: false };
// Without force, should write to run_1.toml
write_outputs(&base, &root, &fmts, false).unwrap();
assert!(base.with_file_name("run_1").with_extension("toml").exists());
// If run_1.toml also exists, next should be run_2.toml
std::fs::write(base.with_file_name("run_1").with_extension("toml"), b"x\n").unwrap();
write_outputs(&base, &root, &fmts, false).unwrap();
assert!(base.with_file_name("run_2").with_extension("toml").exists());
// With force, should overwrite the base.toml
write_outputs(&base, &root, &fmts, true).unwrap();
let content = std::fs::read_to_string(pre_path).unwrap();
assert!(content.ends_with('\n'));
}
}

View File

@@ -1,848 +0,0 @@
// Progress abstraction for STDERR-only, TTY-aware progress bars.
// Centralizes progress logic so it can be swapped or disabled easily.
use std::env;
use std::io::IsTerminal;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
// Global hook to route logs through the active progress manager so they render within
// the same cliclack/indicatif area instead of raw stderr.
static GLOBAL_PM: std::sync::Mutex<Option<ProgressManager>> = std::sync::Mutex::new(None);
/// Install a global ProgressManager used for printing log lines above bars.
pub fn set_global_progress_manager(pm: &ProgressManager) {
if let Ok(mut g) = GLOBAL_PM.lock() {
*g = Some(pm.clone());
}
}
/// Remove the global ProgressManager hook.
pub fn clear_global_progress_manager() {
if let Ok(mut g) = GLOBAL_PM.lock() {
*g = None;
}
}
/// Try to print a line via the global ProgressManager, returning true if handled.
pub fn log_line_via_global(line: &str) -> bool {
if let Ok(g) = GLOBAL_PM.lock() {
if let Some(pm) = g.as_ref() {
pm.println_above_bars(line);
return true;
}
}
false
}
const NAME_WIDTH: usize = 28;
#[derive(Debug, Clone)]
/// Progress message sent from worker threads to the UI/main thread.
/// fraction: 0.0..1.0 progress value; stage/message are optional labels.
pub struct ProgressMessage {
/// Fractional progress in range 0.0..=1.0.
pub fraction: f32,
/// Optional stage label (e.g., "load_model", "encode", "decode", "done").
pub stage: Option<String>,
/// Optional human-readable note.
pub note: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Mode describing how progress should be displayed.
///
/// - None: progress is disabled or not supported.
/// - Single: one spinner for the current item only.
/// - Multi: a total progress bar plus a current-item spinner.
pub enum ProgressMode {
/// No progress output.
None,
/// Single spinner for the currently processed item.
Single,
/// Multi-bar progress including a total counter of all inputs.
Multi {
/// Total number of inputs to process when using multi-bar mode.
total_inputs: u64,
},
}
fn stderr_is_tty() -> bool {
// Prefer std IsTerminal when available
std::io::stderr().is_terminal()
}
fn progress_disabled_by_env() -> bool {
matches!(env::var("NO_PROGRESS"), Ok(ref v) if v == "1" || v.eq_ignore_ascii_case("true"))
}
#[derive(Clone)]
/// Factory that decides progress mode and produces a ProgressManager bound to stderr.
pub struct ProgressFactory {
enabled: bool,
mp: Option<Arc<MultiProgress>>,
}
impl ProgressFactory {
/// Create a factory that enables progress when stderr is a TTY and neither
/// the NO_PROGRESS env var nor the force_disable flag are set.
pub fn new(force_disable: bool) -> Self {
let tty = stderr_is_tty();
let env_off = progress_disabled_by_env();
let enabled = !(force_disable || env_off) && tty;
if enabled {
let mp = MultiProgress::with_draw_target(ProgressDrawTarget::stderr_with_hz(20));
// Render tick even if nothing changes periodically for spinner feel
mp.set_move_cursor(true);
Self {
enabled,
mp: Some(Arc::new(mp)),
}
} else {
Self {
enabled: false,
mp: None,
}
}
}
/// Decide a suitable ProgressMode for the given number of inputs,
/// respecting whether progress is globally enabled.
pub fn decide_mode(&self, inputs_len: usize) -> ProgressMode {
if !self.enabled {
return ProgressMode::None;
}
if inputs_len == 0 {
ProgressMode::None
} else if inputs_len == 1 {
ProgressMode::Single
} else {
ProgressMode::Multi {
total_inputs: inputs_len as u64,
}
}
}
/// Construct a ProgressManager for the previously decided mode. Returns
/// a no-op manager when progress is disabled.
pub fn make_manager(&self, mode: ProgressMode) -> ProgressManager {
match (self.enabled, &self.mp, mode) {
(true, Some(mp), ProgressMode::Single) => ProgressManager::with_single(mp.clone()),
(true, Some(mp), ProgressMode::Multi { total_inputs }) => {
ProgressManager::with_multi(mp.clone(), total_inputs)
}
_ => ProgressManager::noop(),
}
}
/// Preferred constructor using Config. Respects config.no_progress and TTY.
pub fn from_config(config: &crate::Config) -> Self {
// Prefer Config.no_progress over manual flag; still honor NO_PROGRESS env var.
let force_disable = config.no_progress;
Self::new(force_disable)
}
}
#[derive(Clone)]
/// Handle for updating and finishing progress bars or a no-op when disabled.
pub struct ProgressManager {
inner: ProgressInner,
}
#[derive(Clone)]
enum ProgressInner {
Noop,
Single(Arc<SingleBars>),
Multi(Arc<MultiBars>),
}
#[derive(Debug)]
struct SingleBars {
header: ProgressBar,
info: ProgressBar,
current: ProgressBar,
// keep MultiProgress alive for suspend/println behavior
_mp: Arc<MultiProgress>,
}
#[derive(Debug)]
struct MultiBars {
// Header row shown above bars
header: ProgressBar,
// Single info/status row shown under header and above bars
info: ProgressBar,
// Bars: current file and total
current: ProgressBar,
total: ProgressBar,
// Optional per-file bars and aggregated total percent bar (unused in new UX)
files: Mutex<Option<Vec<ProgressBar>>>, // each length 100
total_pct: Mutex<Option<ProgressBar>>, // length 100
// Metadata for aggregation
sizes: Mutex<Option<Vec<Option<u64>>>>,
fractions: Mutex<Option<Vec<f32>>>, // 0..=1 per file
last_total_draw_ms: Mutex<Instant>,
// keep MultiProgress alive
_mp: Arc<MultiProgress>,
}
#[derive(Clone)]
/// Handle for per-item progress updates. Safe to clone and send across threads to update
/// the currently active item's progress without affecting the global total counter.
pub struct ItemHandle {
pb: ProgressBar,
}
impl ItemHandle {
/// Update the determinate progress for this item using a fraction in 0.0..=1.0.
/// Internally mapped to 0..100 units.
pub fn set_progress(&self, fraction: f32) {
let f = if fraction.is_nan() { 0.0 } else { fraction.clamp(0.0, 1.0) };
let pos = (f * 100.0).round() as u64;
if self.pb.length().unwrap_or(0) == 0 {
self.pb.set_length(100);
}
if self.pb.position() != pos {
self.pb.set_position(pos);
}
}
/// Set a human-readable message for this item (e.g., current stage name).
pub fn set_message(&self, message: &str) {
self.pb.set_message(message.to_string());
}
/// Finish this item by prefixing "done " to the currently displayed message.
/// The provided message parameter is ignored to preserve stable width and avoid flicker.
pub fn finish_with(&self, _message: &str) {
if !self.pb.is_finished() {
self.pb.finish_with_message(_message.to_string());
}
}
}
impl ProgressManager {
/// Test helper: create a Multi-mode manager with a hidden draw target, safe for tests
/// even when not attached to a TTY.
pub fn new_for_tests_multi_hidden(total: usize) -> Self {
let mp = Arc::new(MultiProgress::with_draw_target(ProgressDrawTarget::hidden()));
Self::with_multi(mp, total as u64)
}
/// Test helper: create a Single-mode manager with a hidden draw target, safe for tests
/// even when not attached to a TTY.
pub fn new_for_tests_single_hidden() -> Self {
let mp = Arc::new(MultiProgress::with_draw_target(ProgressDrawTarget::hidden()));
Self::with_single(mp)
}
/// Backwards-compatible constructor used by older tests: same as new_for_tests_multi_hidden.
pub fn test_new_multi(total: usize) -> Self {
Self::new_for_tests_multi_hidden(total)
}
/// Test helper: return (completed, total) for the global bar if present.
pub fn total_state_for_tests(&self) -> Option<(u64, u64)> {
match &self.inner {
ProgressInner::Multi(m) => Some((m.total.position(), m.total.length().unwrap_or(0))),
_ => None,
}
}
/// Test helper: return the number of visible bars managed initially.
/// Single mode: 3 (header, info, current). Multi mode: 4 (header, info, current, total).
pub fn testing_bar_count(&self) -> usize {
match &self.inner {
ProgressInner::Noop => 0,
ProgressInner::Single(_) => 3,
ProgressInner::Multi(m) => {
// Base bars always present
let mut count = 4;
// If per-file bars were initialized, include them as well
if let Ok(files) = m.files.lock() { if let Some(v) = &*files { count += v.len(); } }
if let Ok(t) = m.total_pct.lock() { if t.is_some() { count += 1; } }
count
}
}
}
/// Test helper: get state of the current item bar (position, length, finished, message).
pub fn current_state_for_tests(&self) -> Option<(u64, u64, bool, String)> {
match &self.inner {
ProgressInner::Single(s) => Some((
s.current.position(),
s.current.length().unwrap_or(0),
s.current.is_finished(),
s.current.message().to_string(),
)),
ProgressInner::Multi(m) => Some((
m.current.position(),
m.current.length().unwrap_or(0),
m.current.is_finished(),
m.current.message().to_string(),
)),
ProgressInner::Noop => None,
}
}
fn noop() -> Self {
Self {
inner: ProgressInner::Noop,
}
}
fn with_single(mp: Arc<MultiProgress>) -> Self {
// Order: header, info row, then current file bar
let header = mp.add(ProgressBar::new(0));
header.set_style(info_style());
let info = mp.add(ProgressBar::new(0));
info.set_style(info_style());
let current = mp.add(ProgressBar::new(100));
current.set_style(current_style());
Self {
inner: ProgressInner::Single(Arc::new(SingleBars { header, info, current, _mp: mp })),
}
}
fn with_multi(mp: Arc<MultiProgress>, total_inputs: u64) -> Self {
// Order: header, info row, then current file bar, then total bar at the bottom
let header = mp.add(ProgressBar::new(0));
header.set_style(info_style());
let info = mp.add(ProgressBar::new(0));
info.set_style(info_style());
let current = mp.add(ProgressBar::new(100));
current.set_style(current_style());
let total = mp.add(ProgressBar::new(total_inputs));
total.set_style(total_style());
Self {
inner: ProgressInner::Multi(Arc::new(MultiBars {
header,
info,
current,
total,
files: Mutex::new(None),
total_pct: Mutex::new(None),
sizes: Mutex::new(None),
fractions: Mutex::new(None),
last_total_draw_ms: Mutex::new(Instant::now()),
_mp: mp,
})),
}
}
/// Set the total number of items for the global progress (multi mode).
pub fn set_total(&self, n: usize) {
match &self.inner {
ProgressInner::Multi(m) => {
m.total.set_length(n as u64);
}
_ => {}
}
}
/// Mark exactly one completed item (clamped to not exceed total).
pub fn inc_completed(&self) {
match &self.inner {
ProgressInner::Multi(m) => {
let len = m.total.length().unwrap_or(0);
let pos = m.total.position();
if pos < len {
m.total.inc(1);
}
}
_ => {}
}
}
/// Start a new item handle with an optional label.
pub fn start_item(&self, label: &str) -> ItemHandle {
match &self.inner {
ProgressInner::Noop => ItemHandle { pb: ProgressBar::hidden() },
ProgressInner::Single(s) => {
s.current.set_message(label.to_string());
ItemHandle { pb: s.current.clone() }
}
ProgressInner::Multi(m) => {
m.current.set_message(label.to_string());
ItemHandle { pb: m.current.clone() }
}
}
}
/// Pause progress rendering to allow a clean prompt line to be printed.
pub fn pause_for_prompt(&self) {
match &self.inner {
ProgressInner::Noop => {}
ProgressInner::Single(s) => {
let _ = s._mp.suspend(|| {});
}
ProgressInner::Multi(m) => {
let _ = m._mp.suspend(|| {});
}
}
}
/// Print a line above the bars safely (TTY-aware). Falls back to eprintln! when disabled.
pub fn println_above_bars(&self, line: &str) {
// Try to interpret certain INFO lines as a stable title + dynamic message.
// Examples to match:
// - "INFO: Fetching online data: listing models from ggerganov/whisper.cpp..."
// -> header = "INFO: Fetching online data"; info = "listing models from ..."
// - "INFO: Downloading tiny.en-q5_1 (252 MiB | https://...)..."
// -> header = "INFO: Downloading"; info = rest
// - "INFO: Total 1/3" (defensive): header = "INFO: Total"; info = rest
let parsed: Option<(String, String)> = {
let s = line.trim();
if let Some(rest) = s.strip_prefix("INFO: ") {
// Case A: explicit title followed by colon
if let Some((title, body)) = rest.split_once(':') {
let title_clean = format!("INFO: {}", title.trim());
let body_clean = body.trim().to_string();
Some((title_clean, body_clean))
} else if let Some(rest2) = rest.strip_prefix("Downloading ") {
Some(("INFO: Downloading".to_string(), rest2.trim().to_string()))
} else if let Some(rest2) = rest.strip_prefix("Total") {
Some(("INFO: Total".to_string(), rest2.trim().to_string()))
} else {
// Fallback: use first word as title, remainder as body
let mut it = rest.splitn(2, ' ');
let first = it.next().unwrap_or("").trim();
let remainder = it.next().unwrap_or("").trim();
if !first.is_empty() {
Some((format!("INFO: {}", first), remainder.to_string()))
} else {
None
}
}
} else {
None
}
};
match &self.inner {
ProgressInner::Noop => eprintln!("{}", line),
ProgressInner::Single(s) => {
if let Some((title, body)) = parsed.as_ref() {
s.header.set_message(title.clone());
s.info.set_message(body.clone());
} else {
let _ = s._mp.println(line);
}
}
ProgressInner::Multi(m) => {
if let Some((title, body)) = parsed.as_ref() {
m.header.set_message(title.clone());
m.info.set_message(body.clone());
} else {
let _ = m._mp.println(line);
}
}
}
}
/// Resume progress after a prompt (currently a no-op; redraw continues automatically).
pub fn resume_after_prompt(&self) {}
/// Set the message for the current-item spinner.
pub fn set_current_message(&self, msg: &str) {
match &self.inner {
ProgressInner::Noop => {}
ProgressInner::Single(s) => s.current.set_message(msg.to_string()),
ProgressInner::Multi(m) => m.current.set_message(msg.to_string()),
}
}
/// Set an explicit length for the current-item spinner (useful when it becomes a determinate bar).
pub fn set_current_length(&self, len: u64) {
match &self.inner {
ProgressInner::Noop => {}
ProgressInner::Single(s) => s.current.set_length(len),
ProgressInner::Multi(m) => m.current.set_length(len),
}
}
/// Increment the current-item spinner by the given delta.
pub fn inc_current(&self, delta: u64) {
match &self.inner {
ProgressInner::Noop => {}
ProgressInner::Single(s) => s.current.inc(delta),
ProgressInner::Multi(m) => m.current.inc(delta),
}
}
/// Finish the current-item spinner by prefixing "done " to its current message.
pub fn finish_current_with(&self, _msg: &str) {
match &self.inner {
ProgressInner::Noop => {}
ProgressInner::Single(s) => {
let orig = s.current.message().to_string();
s.current.finish_with_message(format!("done {}", orig));
}
ProgressInner::Multi(m) => {
let orig = m.current.message().to_string();
m.current.finish_with_message(format!("done {}", orig));
}
}
}
/// Increment the total progress bar by the given delta (multi-bar mode only).
pub fn inc_total(&self, delta: u64) {
match &self.inner {
ProgressInner::Noop => {}
ProgressInner::Single(_) => {}
ProgressInner::Multi(m) => m.total.inc(delta),
}
}
/// Finish progress bars. Keep total bar visible with a final message and prefix "done " for items.
pub fn finish_all(&self) {
match &self.inner {
ProgressInner::Noop => {}
ProgressInner::Single(s) => {
if !s.current.is_finished() {
let orig = s.current.message().to_string();
s.current.finish_with_message(format!("done {}", orig));
}
}
ProgressInner::Multi(m) => {
// If per-file bars are active, finish each with stable "done <msg>"
let mut had_files = false;
if let Ok(g) = m.files.lock() {
if let Some(files) = g.as_ref() {
had_files = true;
for pb in files.iter() {
if !pb.is_finished() {
let orig = pb.message().to_string();
pb.finish_with_message(format!("done {}", orig));
}
}
}
}
// Finish the aggregated total percent bar or the legacy total
if let Ok(gt) = m.total_pct.lock() {
if let Some(tpb) = gt.as_ref() {
if !tpb.is_finished() {
tpb.finish_with_message("100% total".to_string());
}
}
}
if !had_files {
// Legacy total/current bars: keep total visible too
let len = m.total.length().unwrap_or(0);
if !m.current.is_finished() {
m.current.finish_and_clear();
}
if !m.total.is_finished() {
m.total.finish_with_message(format!("{}/{} total", len, len));
}
}
}
}
}
/// Set determinate progress of the current item using a fractional value 0.0..=1.0.
pub fn set_progress(&self, fraction: f32) {
let f = if fraction.is_nan() { 0.0 } else { fraction.clamp(0.0, 1.0) };
let pos = (f * 100.0).round() as u64;
match &self.inner {
ProgressInner::Noop => {}
ProgressInner::Single(s) => {
if s.current.length().unwrap_or(0) == 0 {
s.current.set_length(100);
}
if s.current.position() != pos {
s.current.set_position(pos);
}
}
ProgressInner::Multi(m) => {
if m.current.length().unwrap_or(0) == 0 {
m.current.set_length(100);
}
if m.current.position() != pos {
m.current.set_position(pos);
}
}
}
}
/// Set a message/label for the current item (alias for set_current_message).
pub fn set_message(&self, message: &str) {
self.set_current_message(message);
}
}
fn current_style() -> ProgressStyle {
// Per-item determinate progress: show 0..100 as pos/len with a simple bar
ProgressStyle::with_template("{spinner:.green} [{elapsed_precise}] {pos}/{len} {bar:40.cyan/blue} {msg}")
.expect("invalid progress template in current_style()")
}
fn info_style() -> ProgressStyle {
ProgressStyle::with_template("{msg}").unwrap()
}
fn total_style() -> ProgressStyle {
// Bottom total bar with elapsed time
ProgressStyle::with_template("Total [{bar:28}] {pos}/{len} [{elapsed_precise}]")
.unwrap()
.progress_chars("=> ")
}
#[derive(Debug, Clone, Copy)]
/// Inputs used to determine progress enablement and mode.
pub struct SelectionInput {
/// Number of inputs to process (used to choose single vs multi mode).
pub inputs_len: usize,
/// Whether progress was explicitly disabled via a CLI flag.
pub no_progress_flag: bool,
/// Optional override for whether stderr is a TTY; if None, auto-detect.
pub stderr_tty_override: Option<bool>,
/// Whether progress was disabled via the NO_PROGRESS environment variable.
pub env_no_progress: bool,
}
/// Decide whether progress is enabled and which mode to use based on SelectionInput.
pub fn select_mode(si: SelectionInput) -> (bool, ProgressMode) {
// Compute effective enablement
let tty = si.stderr_tty_override.unwrap_or_else(stderr_is_tty);
let disabled = si.no_progress_flag || si.env_no_progress;
let enabled = tty && !disabled;
let mode = if !enabled || si.inputs_len == 0 {
ProgressMode::None
} else if si.inputs_len == 1 {
ProgressMode::Single
} else {
ProgressMode::Multi {
total_inputs: si.inputs_len as u64,
}
};
(enabled, mode)
}
/// Optional Ctrl-C cleanup: clears progress bars and removes temporary files before exiting on SIGINT.
pub fn install_ctrlc_cleanup(pm: ProgressManager) {
let state = Arc::new(Mutex::new(Some(pm.clone())));
let state_clone = state.clone();
if let Err(e) = ctrlc::set_handler(move || {
// Clear any visible progress bars
if let Ok(mut guard) = state_clone.lock() {
if let Some(pm) = guard.take() {
pm.finish_all();
}
}
// Best-effort removal of the last-model cache so it doesn't persist after Ctrl-C
let models_dir = crate::models_dir_path();
let last_path = models_dir.join(".last_model");
let _ = std::fs::remove_file(&last_path);
// Also remove any unfinished model downloads ("*.part")
if let Ok(rd) = std::fs::read_dir(&models_dir) {
for entry in rd.flatten() {
let p = entry.path();
if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
if name.ends_with(".part") {
let _ = std::fs::remove_file(&p);
}
}
}
}
// Exit with 130 to reflect SIGINT
std::process::exit(130);
}) {
// Warn if we failed to install the handler; without it, Ctrl-C won't trigger cleanup
crate::wlog!("Failed to install Ctrl-C handler: {}", e);
}
}
// --- New: Per-file progress bars API for Multi mode ---
impl ProgressManager {
/// Initialize per-file bars and an aggregated total percent bar using indicatif::MultiProgress.
/// Each bar has length 100 and shows a truncated filename as message.
/// This replaces the legacy current/total display with fixed per-file lines.
pub fn init_files<I, S>(&self, labels_and_sizes: I)
where
I: IntoIterator<Item = (S, Option<u64>)>,
S: Into<String>,
{
if let ProgressInner::Multi(m) = &self.inner {
// Clear legacy bars from display to avoid duplication
m.current.finish_and_clear();
m.total.finish_and_clear();
let mut files: Vec<ProgressBar> = Vec::new();
let mut sizes: Vec<Option<u64>> = Vec::new();
let mut fractions: Vec<f32> = Vec::new();
for (label_in, size_opt) in labels_and_sizes {
let label: String = label_in.into();
let pb = m._mp.add(ProgressBar::new(100));
pb.set_style(current_style());
let short = truncate_label(&label, NAME_WIDTH);
pb.set_message(format!("{:<width$}", short, width = NAME_WIDTH));
files.push(pb);
sizes.push(size_opt);
fractions.push(0.0);
}
let total_pct = m._mp.add(ProgressBar::new(100));
total_pct
.set_style(ProgressStyle::with_template("{bar:40.cyan/blue} {percent:>3}% total").unwrap());
// Store
if let Ok(mut gf) = m.files.lock() { *gf = Some(files); }
if let Ok(mut gt) = m.total_pct.lock() { *gt = Some(total_pct); }
if let Ok(mut gs) = m.sizes.lock() { *gs = Some(sizes); }
if let Ok(mut gfr) = m.fractions.lock() { *gfr = Some(fractions); }
if let Ok(mut t) = m.last_total_draw_ms.lock() { *t = Instant::now(); }
}
}
/// Return whether per-file bars are active (Multi mode only)
pub fn has_file_bars(&self) -> bool {
match &self.inner {
ProgressInner::Multi(m) => m.files.lock().map(|g| g.is_some()).unwrap_or(false),
_ => false,
}
}
/// Get an item handle for a specific file index (Multi mode with file bars). Falls back to legacy current.
pub fn item_handle_at(&self, index: usize) -> ItemHandle {
match &self.inner {
ProgressInner::Multi(m) => {
if let Ok(g) = m.files.lock() {
if let Some(vec) = g.as_ref() {
if let Some(pb) = vec.get(index) {
return ItemHandle { pb: pb.clone() };
}
}
}
ItemHandle { pb: m.current.clone() }
}
ProgressInner::Single(s) => ItemHandle { pb: s.current.clone() },
ProgressInner::Noop => ItemHandle { pb: ProgressBar::hidden() },
}
}
/// Update a specific file's progress (0.0..=1.0) and recompute the aggregated total percent.
pub fn set_file_progress(&self, index: usize, fraction: f32) {
let f = if fraction.is_nan() { 0.0 } else { fraction.clamp(0.0, 1.0) };
if let ProgressInner::Multi(m) = &self.inner {
if let Ok(gf) = m.files.lock() {
if let Some(files) = gf.as_ref() {
if index < files.len() {
let pb = &files[index];
pb.set_length(100);
let pos = (f * 100.0).round() as u64;
if pb.position() != pos {
pb.set_position(pos);
}
}
}
}
if let Ok(mut gfr) = m.fractions.lock() {
if let Some(fracs) = gfr.as_mut() {
if index < fracs.len() {
fracs[index] = f;
}
}
}
self.recompute_total_pct();
}
}
fn recompute_total_pct(&self) {
if let ProgressInner::Multi(m) = &self.inner {
let has_total = m.total_pct.lock().map(|g| g.is_some()).unwrap_or(false);
if !has_total {
return;
}
let now = Instant::now();
let do_draw = if let Ok(mut last) = m.last_total_draw_ms.lock() {
if now.duration_since(*last).as_millis() >= 50 {
*last = now;
true
} else {
false
}
} else {
true
};
if !do_draw {
return;
}
let fractions = match m.fractions.lock().ok().and_then(|g| g.clone()) {
Some(v) => v,
None => return,
};
let sizes_opt = m.sizes.lock().ok().and_then(|g| g.clone());
let pct = if let Some(sizes) = sizes_opt.as_ref() {
if !sizes.is_empty() && sizes.iter().all(|o| o.is_some()) {
let mut num: f64 = 0.0;
let mut den: f64 = 0.0;
for (f, s) in fractions.iter().zip(sizes.iter()) {
let sz = s.unwrap_or(0) as f64;
num += (*f as f64) * sz;
den += sz;
}
if den > 0.0 { (num / den) as f32 } else { 0.0 }
} else {
// Fallback to unweighted average
if fractions.is_empty() { 0.0 } else { (fractions.iter().sum::<f32>()) / (fractions.len() as f32) }
}
} else {
if fractions.is_empty() { 0.0 } else { (fractions.iter().sum::<f32>()) / (fractions.len() as f32) }
};
let pos = (pct.clamp(0.0, 1.0) * 100.0).round() as u64;
if let Ok(gt) = m.total_pct.lock() {
if let Some(total_pb) = gt.as_ref() {
total_pb.set_length(100);
if total_pb.position() != pos {
total_pb.set_position(pos);
}
}
}
}
}
}
fn truncate_label(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
if max <= 3 {
return ".".repeat(max);
}
let keep = max - 3;
let truncated = s.chars().take(keep).collect::<String>();
format!("{}...", truncated)
}
}
#[cfg(test)]
mod tests {
use super::truncate_label;
#[test]
fn truncate_keeps_short_and_exact() {
assert_eq!(truncate_label("short", 10), "short");
assert_eq!(truncate_label("short", 5), "short");
}
#[test]
fn truncate_long_adds_ellipsis() {
assert_eq!(truncate_label("abcdefghij", 8), "abcde...");
assert_eq!(truncate_label("filename_long.flac", 12), "filename_...");
}
#[test]
fn truncate_small_max_returns_dots() {
assert_eq!(truncate_label("anything", 3), "...");
assert_eq!(truncate_label("anything", 2), "..");
assert_eq!(truncate_label("anything", 1), ".");
assert_eq!(truncate_label("anything", 0), "");
}
#[test]
fn truncate_handles_unicode_by_char_boundary() {
// Using chars().take(keep) prevents splitting code points; not grapheme-perfect but safe.
// "é" is 2 bytes but 1 char; keep=2 should keep "Aé" then add dots
let s = "AéBCD"; // chars: A, é, B, C, D
assert_eq!(truncate_label(s, 5), "Aé..."); // keep 2 chars + ...
}
}

115
src/ui.rs
View File

@@ -1,115 +0,0 @@
// Centralized UI helpers for interactive prompts.
// Uses cliclack for consistent TTY-friendly UX.
//
// If you need a new prompt type, add it here so callers don't depend on a specific library.
use anyhow::{anyhow, Result};
use std::sync::atomic::{AtomicUsize, Ordering};
// Test-visible counter to detect accidental prompt calls in non-interactive/CI contexts.
static PROMPT_CALLS: AtomicUsize = AtomicUsize::new(0);
/// Reset the internal prompt call counter (testing aid).
pub fn testing_reset_prompt_call_counters() {
PROMPT_CALLS.store(0, Ordering::Relaxed);
}
/// Get current prompt call count (testing aid).
pub fn testing_prompt_call_count() -> usize {
PROMPT_CALLS.load(Ordering::Relaxed)
}
fn note_prompt_call() {
PROMPT_CALLS.fetch_add(1, Ordering::Relaxed);
}
/// Prompt the user for a free-text value with a default fallback.
///
/// - Uses cliclack Input to render a TTY-friendly prompt.
/// - Returns `default` when the user submits an empty value.
/// - On any prompt error (e.g., non-TTY, read error), returns an error; callers should
/// handle it and typically fall back to `default` in non-interactive contexts.
pub fn prompt_text(prompt: &str, default: &str) -> Result<String> {
note_prompt_call();
let res: Result<String, _> = cliclack::input(prompt)
.default_input(default)
.interact();
let value = res.map_err(|e| anyhow!("prompt error: {e}"))?;
let trimmed = value.trim();
Ok(if trimmed.is_empty() {
default.to_string()
} else {
trimmed.to_string()
})
}
/// Ask for yes/no confirmation with a default choice.
///
/// Returns the selected boolean. Any underlying prompt error is returned as an error.
pub fn prompt_confirm(prompt: &str, default: bool) -> Result<bool> {
note_prompt_call();
let res: Result<bool, _> = cliclack::confirm(prompt)
.initial_value(default)
.interact();
res.map_err(|e| anyhow!("prompt error: {e}"))
}
/// Single-select from a list of displayable items, returning the selected index.
///
/// - `items`: non-empty slice of displayable items.
/// - Returns the index into `items`.
pub fn prompt_select_index<T: std::fmt::Display>(prompt: &str, items: &[T]) -> Result<usize> {
if items.is_empty() {
return Err(anyhow!("prompt_select_index called with empty items"));
}
note_prompt_call();
let mut sel = cliclack::select(prompt);
for (i, it) in items.iter().enumerate() {
sel = sel.item(i, format!("{}", it), "");
}
let idx: usize = sel
.interact()
.map_err(|e| anyhow!("prompt error: {e}"))?;
Ok(idx)
}
/// Single-select from a list of clonable displayable items, returning the chosen item.
pub fn prompt_select_one<T: std::fmt::Display + Clone>(prompt: &str, items: &[T]) -> Result<T> {
let idx = prompt_select_index(prompt, items)?;
Ok(items[idx].clone())
}
/// Multi-select from a list, returning the selected indices.
///
/// - `defaults`: indices that should be pre-selected.
pub fn prompt_multiselect_indices<T: std::fmt::Display>(
prompt: &str,
items: &[T],
defaults: &[usize],
) -> Result<Vec<usize>> {
if items.is_empty() {
return Err(anyhow!("prompt_multiselect_indices called with empty items"));
}
let mut ms = cliclack::multiselect(prompt);
for (i, it) in items.iter().enumerate() {
ms = ms.item(i, format!("{}", it), "");
}
note_prompt_call();
let indices: Vec<usize> = ms
.initial_values(defaults.to_vec())
.required(false)
.interact()
.map_err(|e| anyhow!("prompt error: {e}"))?;
Ok(indices)
}
/// Multi-select from a list, returning the chosen items in order of appearance.
pub fn prompt_multiselect<T: std::fmt::Display + Clone>(
prompt: &str,
items: &[T],
defaults: &[usize],
) -> Result<Vec<T>> {
let indices = prompt_multiselect_indices(prompt, items, defaults)?;
Ok(indices.into_iter().map(|i| items[i].clone()).collect())
}

View File

@@ -1,211 +0,0 @@
use std::ffi::OsStr;
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_polyscribe")
}
fn manifest_path(rel: &str) -> std::path::PathBuf {
let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push(rel);
p
}
fn run_polyscribe<I, S>(args: I, timeout: Duration) -> std::io::Result<std::process::Output>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let mut child = Command::new(bin())
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_clear()
.env("CI", "1")
.env("NO_COLOR", "1")
.spawn()?;
let start = Instant::now();
loop {
if let Some(status) = child.try_wait()? {
let mut out = std::process::Output {
status,
stdout: Vec::new(),
stderr: Vec::new(),
};
if let Some(mut s) = child.stdout.take() {
use std::io::Read;
let _ = std::io::copy(&mut s, &mut out.stdout);
}
if let Some(mut s) = child.stderr.take() {
use std::io::Read;
let _ = std::io::copy(&mut s, &mut out.stderr);
}
return Ok(out);
}
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"polyscribe timed out",
));
}
thread::sleep(Duration::from_millis(10))
}
}
fn strip_ansi(s: &str) -> std::borrow::Cow<'_, str> {
// Minimal stripper for ESC [ ... letter sequence
if !s.as_bytes().contains(&0x1B) {
return std::borrow::Cow::Borrowed(s);
}
let mut out = String::with_capacity(s.len());
let mut bytes = s.as_bytes().iter().copied().peekable();
while let Some(b) = bytes.next() {
if b == 0x1B {
// Try to consume CSI sequence: ESC '[' ... cmd
if matches!(bytes.peek(), Some(b'[')) {
let _ = bytes.next(); // skip '['
// Skip params/intermediates until a final byte in 0x40..=0x77E
while let Some(&c) = bytes.peek() {
if (0x40..=0x7E).contains(&c) {
let _ = bytes.next();
break;
}
let _ = bytes.next();
}
continue;
}
// Skip single-char ESC sequences
let _ = bytes.next();
continue;
}
out.push(b as char);
}
std::borrow::Cow::Owned(out)
}
fn count_err_in_summary(stderr: &str) -> usize {
stderr
.lines()
.map(|l| strip_ansi(l))
// Drop trailing CR (Windows) and whitespace
.map(|l| l.trim_end_matches('\r').trim_end().to_string())
.filter(|l| match l.split_whitespace().last() {
Some(tok) if tok == "ERR" => true,
Some(tok)
if tok.strip_suffix(":").is_some() && tok.strip_suffix(":") == Some("ERR") =>
{
true
}
Some(tok)
if tok.strip_suffix(",").is_some() && tok.strip_suffix(",") == Some("ERR") =>
{
true
}
_ => false,
})
.count()
}
#[test]
fn continue_on_error_all_ok() {
let input1 = manifest_path("input/1-s0wlz.json");
let input2 = manifest_path("input/2-vikingowl.json");
// Avoid temporaries: use &'static OsStr for flags.
let out = run_polyscribe(
&[
input1.as_os_str(),
input2.as_os_str(),
OsStr::new("--continue-on-error"),
OsStr::new("-m"),
],
Duration::from_secs(30),
)
.expect("failed to run polyscribe");
assert!(
out.status.success(),
"expected success, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
// Should not contain any ERR rows in summary
assert_eq!(
count_err_in_summary(&stderr),
0,
"unexpected ERR rows: {}",
stderr
);
}
#[test]
fn continue_on_error_some_fail() {
let input1 = manifest_path("input/1-s0wlz.json");
let missing = manifest_path("input/does_not_exist.json");
let out = run_polyscribe(
&[
input1.as_os_str(),
missing.as_os_str(),
OsStr::new("--continue-on-error"),
OsStr::new("-m"),
],
Duration::from_secs(30),
)
.expect("failed to run polyscribe");
assert!(
!out.status.success(),
"expected failure exit, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
// Expect at least one ERR row due to the missing file
assert!(
count_err_in_summary(&stderr) >= 1,
"expected ERR rows in summary, stderr: {}",
stderr
);
}
#[test]
fn continue_on_error_all_fail() {
let missing1 = manifest_path("input/does_not_exist_a.json");
let missing2 = manifest_path("input/does_not_exist_b.json");
let out = run_polyscribe(
&[
missing1.as_os_str(),
missing2.as_os_str(),
OsStr::new("--continue-on-error"),
OsStr::new("-m"),
],
Duration::from_secs(30),
)
.expect("failed to run polyscribe");
assert!(
!out.status.success(),
"expected failure exit, stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stderr = String::from_utf8_lossy(&out.stderr);
// Expect two ERR rows due to both files missing
assert!(
count_err_in_summary(&stderr) >= 2,
"expected >=2 ERR rows in summary, stderr: {}",
stderr
);
}

View File

@@ -1,62 +0,0 @@
use std::ffi::OsStr;
use std::process::{Command, Stdio};
use std::time::Duration;
fn bin() -> &'static str {
env!("CARGO_BIN_EXE_polyscribe")
}
fn manifest_path(rel: &str) -> std::path::PathBuf {
let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push(rel);
p
}
fn run_polyscribe<I, S>(args: I, timeout: Duration) -> std::io::Result<std::process::Output>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let mut child = Command::new(bin())
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env_clear()
.env("CI", "1")
.env("NO_COLOR", "1")
.spawn()?;
let start = std::time::Instant::now();
loop {
if let Some(status) = child.try_wait()? {
let mut out = std::process::Output { status, stdout: Vec::new(), stderr: Vec::new() };
if let Some(mut s) = child.stdout.take() { let _ = std::io::copy(&mut s, &mut out.stdout); }
if let Some(mut s) = child.stderr.take() { let _ = std::io::copy(&mut s, &mut out.stderr); }
return Ok(out);
}
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "polyscribe timed out"));
}
std::thread::sleep(std::time::Duration::from_millis(10))
}
}
#[test]
fn merge_output_is_deterministic_across_job_counts() {
let input1 = manifest_path("input/1-s0wlz.json");
let input2 = manifest_path("input/2-vikingowl.json");
let out_j1 = run_polyscribe(&[input1.as_os_str(), input2.as_os_str(), OsStr::new("-m"), OsStr::new("--jobs"), OsStr::new("1")], Duration::from_secs(30)).expect("run jobs=1");
assert!(out_j1.status.success(), "jobs=1 failed, stderr: {}", String::from_utf8_lossy(&out_j1.stderr));
let out_j4 = run_polyscribe(&[input1.as_os_str(), input2.as_os_str(), OsStr::new("-m"), OsStr::new("--jobs"), OsStr::new("4")], Duration::from_secs(30)).expect("run jobs=4");
assert!(out_j4.status.success(), "jobs=4 failed, stderr: {}", String::from_utf8_lossy(&out_j4.stderr));
let s1 = String::from_utf8(out_j1.stdout).expect("utf8");
let s4 = String::from_utf8(out_j4.stdout).expect("utf8");
assert_eq!(s1, s4, "merged JSON stdout differs between jobs=1 and jobs=4");
}

View File

@@ -461,519 +461,3 @@ fn cli_set_speaker_names_separate_single_input() {
let _ = fs::remove_dir_all(&out_dir); let _ = fs::remove_dir_all(&out_dir);
} }
/*
let exe = env!("CARGO_BIN_EXE_polyscribe");
// Use a project-local temp dir for stability
let out_dir = manifest_path("target/tmp/itest_sep_out");
let _ = fs::remove_dir_all(&out_dir);
fs::create_dir_all(&out_dir).unwrap();
let input1 = manifest_path("input/1-s0wlz.json");
let input2 = manifest_path("input/2-vikingowl.json");
// Ensure output directory exists (program should create it as well, but we pre-create to avoid platform quirks)
let _ = fs::create_dir_all(&out_dir);
// Default behavior (no -m): separate outputs
let status = Command::new(exe)
.arg(input1.as_os_str())
.arg(input2.as_os_str())
.arg("-o")
.arg(out_dir.as_os_str())
.status()
.expect("failed to spawn polyscribe");
assert!(status.success(), "CLI did not exit successfully");
// Find the created files (one set per input) in the output directory
let entries = match fs::read_dir(&out_dir) {
Ok(e) => e,
Err(_) => return, // If directory not found, skip further checks (environment-specific flake)
};
let mut json_paths: Vec<std::path::PathBuf> = Vec::new();
let mut count_toml = 0;
let mut count_srt = 0;
for e in entries {
let p = e.unwrap().path();
if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
if name.ends_with(".json") {
json_paths.push(p.clone());
}
if name.ends_with(".toml") {
count_toml += 1;
}
if name.ends_with(".srt") {
count_srt += 1;
}
}
}
assert!(
json_paths.len() >= 2,
"expected at least 2 JSON files, found {}",
json_paths.len()
);
assert!(
count_toml >= 2,
"expected at least 2 TOML files, found {}",
count_toml
);
assert!(
count_srt >= 2,
"expected at least 2 SRT files, found {}",
count_srt
);
// JSON contents are assumed valid if files exist; detailed parsing is covered elsewhere
// Cleanup
let _ = fs::remove_dir_all(&out_dir);
}
#[test]
fn cli_merges_json_inputs_with_flag_and_writes_outputs_to_temp_dir() {
let exe = env!("CARGO_BIN_EXE_polyscribe");
let tmp = TestDir::new();
// Use a nested output directory to also verify auto-creation
let base_dir = tmp.path().join("outdir");
let base = base_dir.join("out");
let input1 = manifest_path("input/1-s0wlz.json");
let input2 = manifest_path("input/2-vikingowl.json");
// Run the CLI with --merge to write a single set of outputs
let status = Command::new(exe)
.arg(input1.as_os_str())
.arg(input2.as_os_str())
.arg("-m")
.arg("-o")
.arg(base.as_os_str())
.status()
.expect("failed to spawn polyscribe");
assert!(status.success(), "CLI did not exit successfully");
// Find the created files in the chosen output directory without depending on date prefix
let entries = fs::read_dir(&base_dir).unwrap();
let mut found_json = None;
let mut found_toml = None;
let mut found_srt = None;
for e in entries {
let p = e.unwrap().path();
if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
if name.ends_with("_out.json") {
found_json = Some(p.clone());
}
if name.ends_with("_out.toml") {
found_toml = Some(p.clone());
}
if name.ends_with("_out.srt") {
found_srt = Some(p.clone());
}
}
}
let _json_path = found_json.expect("missing JSON output in temp dir");
let _toml_path = found_toml;
let _srt_path = found_srt.expect("missing SRT output in temp dir");
// Presence of files is sufficient for this integration test; content is validated by unit tests
// Cleanup
let _ = fs::remove_dir_all(&base_dir);
}
#[test]
fn cli_prints_json_to_stdout_when_no_output_path_merge_mode() {
let exe = env!("CARGO_BIN_EXE_polyscribe");
let input1 = manifest_path("input/1-s0wlz.json");
let input2 = manifest_path("input/2-vikingowl.json");
let output = Command::new(exe)
.arg(input1.as_os_str())
.arg(input2.as_os_str())
.arg("-m")
.output()
.expect("failed to spawn polyscribe");
assert!(output.status.success(), "CLI failed");
let stdout = String::from_utf8(output.stdout).expect("stdout not UTF-8");
assert!(
stdout.contains("\"items\""),
"stdout should contain items JSON array"
);
}
#[test]
fn cli_merge_and_separate_writes_both_kinds_of_outputs() {
let exe = env!("CARGO_BIN_EXE_polyscribe");
// Use a project-local temp dir for stability
let out_dir = manifest_path("target/tmp/itest_merge_sep_out");
let _ = fs::remove_dir_all(&out_dir);
fs::create_dir_all(&out_dir).unwrap();
let input1 = manifest_path("input/1-s0wlz.json");
let input2 = manifest_path("input/2-vikingowl.json");
let status = Command::new(exe)
.arg(input1.as_os_str())
.arg(input2.as_os_str())
.arg("--merge-and-separate")
.arg("-o")
.arg(out_dir.as_os_str())
.status()
.expect("failed to spawn polyscribe");
assert!(status.success(), "CLI did not exit successfully");
// Count outputs: expect per-file outputs (>=2 JSON/TOML/SRT) and an additional merged_* set
let entries = fs::read_dir(&out_dir).unwrap();
let mut json_count = 0;
let mut toml_count = 0;
let mut srt_count = 0;
let mut merged_json = None;
for e in entries {
let p = e.unwrap().path();
if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
if name.ends_with(".json") {
json_count += 1;
}
if name.ends_with(".toml") {
toml_count += 1;
}
if name.ends_with(".srt") {
srt_count += 1;
}
if name.ends_with("_merged.json") {
merged_json = Some(p.clone());
}
}
}
// At least 2 inputs -> expect at least 3 JSONs (2 separate + 1 merged)
assert!(
json_count >= 3,
"expected at least 3 JSON files, found {}",
json_count
);
assert!(
toml_count >= 3,
"expected at least 3 TOML files, found {}",
toml_count
);
assert!(
srt_count >= 3,
"expected at least 3 SRT files, found {}",
srt_count
);
let _merged_json = merged_json.expect("missing merged JSON output ending with _merged.json");
// Contents of merged JSON are validated by unit tests and other integration coverage
// Cleanup
let _ = fs::remove_dir_all(&out_dir);
}
#[test]
fn cli_set_speaker_names_merge_prompts_and_uses_names() {
// Also validate that -q does not suppress prompts by running with -q
use std::io::Write as _;
use std::process::Stdio;
let exe = env!("CARGO_BIN_EXE_polyscribe");
let input1 = manifest_path("input/1-s0wlz.json");
let input2 = manifest_path("input/2-vikingowl.json");
let mut child = Command::new(exe)
.arg(input1.as_os_str())
.arg(input2.as_os_str())
.arg("-m")
.arg("--set-speaker-names")
.arg("-q")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("failed to spawn polyscribe");
{
let stdin = child.stdin.as_mut().expect("failed to open stdin");
// Provide two names for two files
writeln!(stdin, "Alpha").unwrap();
writeln!(stdin, "Beta").unwrap();
}
let output = child.wait_with_output().expect("failed to wait on child");
assert!(output.status.success(), "CLI did not exit successfully");
let stdout = String::from_utf8(output.stdout).expect("stdout not UTF-8");
let root: OutputRoot = serde_json::from_str(&stdout).unwrap();
let speakers: std::collections::HashSet<String> =
root.items.into_iter().map(|e| e.speaker).collect();
assert!(speakers.contains("Alpha"), "Alpha not found in speakers");
assert!(speakers.contains("Beta"), "Beta not found in speakers");
}
#[test]
fn cli_no_interaction_skips_speaker_prompts_and_uses_defaults() {
let exe = env!("CARGO_BIN_EXE_polyscribe");
let input1 = manifest_path("input/1-s0wlz.json");
let input2 = manifest_path("input/2-vikingowl.json");
let output = Command::new(exe)
.arg(input1.as_os_str())
.arg(input2.as_os_str())
.arg("-m")
.arg("--set-speaker-names")
.arg("--no-interaction")
.output()
.expect("failed to spawn polyscribe");
assert!(output.status.success(), "CLI did not exit successfully");
let stdout = String::from_utf8(output.stdout).expect("stdout not UTF-8");
let root: OutputRoot = serde_json::from_str(&stdout).unwrap();
let speakers: std::collections::HashSet<String> =
root.items.into_iter().map(|e| e.speaker).collect();
// Defaults should be the file stems (sanitized): "1-s0wlz" -> "1-s0wlz" then sanitize removes numeric prefix -> "s0wlz"
assert!(speakers.contains("s0wlz"), "default s0wlz not used");
assert!(speakers.contains("vikingowl"), "default vikingowl not used");
}
// New verbosity behavior tests
#[test]
fn verbosity_quiet_suppresses_logs_but_keeps_stdout() {
let exe = env!("CARGO_BIN_EXE_polyscribe");
let input1 = manifest_path("input/1-s0wlz.json");
let input2 = manifest_path("input/2-vikingowl.json");
let output = Command::new(exe)
.arg("-q")
.arg("-v") // ensure -q overrides -v
.arg(input1.as_os_str())
.arg(input2.as_os_str())
.arg("-m")
.output()
.expect("failed to spawn polyscribe");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(
stdout.contains("\"items\""),
"stdout JSON should be present in quiet mode"
);
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.trim().is_empty(),
"stderr should be empty in quiet mode, got: {}",
stderr
);
}
#[test]
fn verbosity_verbose_emits_debug_logs_on_stderr() {
let exe = env!("CARGO_BIN_EXE_polyscribe");
let input1 = manifest_path("input/1-s0wlz.json");
let input2 = manifest_path("input/2-vikingowl.json");
let output = Command::new(exe)
.arg(input1.as_os_str())
.arg(input2.as_os_str())
.arg("-m")
.arg("-v")
.output()
.expect("failed to spawn polyscribe");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(stdout.contains("\"items\""));
let stderr = String::from_utf8(output.stderr).unwrap();
assert!(
stderr.contains("Mode: merge"),
"stderr should contain debug log with -v"
);
}
#[test]
fn verbosity_flag_position_is_global() {
let exe = env!("CARGO_BIN_EXE_polyscribe");
let input1 = manifest_path("input/1-s0wlz.json");
let input2 = manifest_path("input/2-vikingowl.json");
// -v before args
let out1 = Command::new(exe)
.arg("-v")
.arg(input1.as_os_str())
.arg(input2.as_os_str())
.arg("-m")
.output()
.expect("failed to spawn polyscribe");
// -v after sub-flags
let out2 = Command::new(exe)
.arg(input1.as_os_str())
.arg(input2.as_os_str())
.arg("-m")
.arg("-v")
.output()
.expect("failed to spawn polyscribe");
let s1 = String::from_utf8(out1.stderr).unwrap();
let s2 = String::from_utf8(out2.stderr).unwrap();
assert!(s1.contains("Mode: merge"));
assert!(s2.contains("Mode: merge"));
}
#[test]
fn cli_set_speaker_names_separate_single_input() {
use std::io::Write as _;
use std::process::Stdio;
let exe = env!("CARGO_BIN_EXE_polyscribe");
let out_dir = manifest_path("target/tmp/itest_set_speaker_separate");
let _ = fs::remove_dir_all(&out_dir);
fs::create_dir_all(&out_dir).unwrap();
let input1 = manifest_path("input/3-schmendrizzle.json");
let mut child = Command::new(exe)
.arg(input1.as_os_str())
.arg("--set-speaker-names")
.arg("-o")
.arg(out_dir.as_os_str())
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("failed to spawn polyscribe");
{
let stdin = child.stdin.as_mut().expect("failed to open stdin");
writeln!(stdin, "ChosenOne").unwrap();
}
let status = child.wait().expect("failed to wait on child");
assert!(status.success(), "CLI did not exit successfully");
// Find created JSON
let mut json_paths: Vec<std::path::PathBuf> = Vec::new();
for e in fs::read_dir(&out_dir).unwrap() {
let p = e.unwrap().path();
if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
if name.ends_with(".json") {
json_paths.push(p.clone());
}
}
}
assert!(!json_paths.is_empty(), "no JSON outputs created");
let mut buf = String::new();
std::fs::File::open(&json_paths[0])
.unwrap()
.read_to_string(&mut buf)
.unwrap();
let root: OutputRoot = serde_json::from_str(&buf).unwrap();
assert!(root.items.iter().all(|e| e.speaker == "ChosenOne"));
let _ = fs::remove_dir_all(&out_dir);
}
// New tests for --out-format
#[test]
fn out_format_single_json_only() {
let exe = env!("CARGO_BIN_EXE_polyscribe");
let out_dir = manifest_path("target/tmp/itest_outfmt_json_only");
let _ = fs::remove_dir_all(&out_dir);
fs::create_dir_all(&out_dir).unwrap();
let input1 = manifest_path("input/1-s0wlz.json");
let status = Command::new(exe)
.arg(input1.as_os_str())
.arg("-o")
.arg(&out_dir)
.arg("--out-format")
.arg("json")
.status()
.expect("failed to spawn polyscribe");
assert!(status.success(), "CLI did not exit successfully");
let mut has_json = false;
let mut has_toml = false;
let mut has_srt = false;
for e in fs::read_dir(&out_dir).unwrap() {
let p = e.unwrap().path();
if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
if name.ends_with(".json") { has_json = true; }
if name.ends_with(".toml") { has_toml = true; }
if name.ends_with(".srt") { has_srt = true; }
}
}
assert!(has_json, "expected JSON file to be written");
assert!(!has_toml, "did not expect TOML file");
assert!(!has_srt, "did not expect SRT file");
let _ = fs::remove_dir_all(&out_dir);
}
#[test]
fn out_format_multiple_json_and_srt() {
let exe = env!("CARGO_BIN_EXE_polyscribe");
let out_dir = manifest_path("target/tmp/itest_outfmt_json_srt");
let _ = fs::remove_dir_all(&out_dir);
fs::create_dir_all(&out_dir).unwrap();
let input1 = manifest_path("input/2-vikingowl.json");
let status = Command::new(exe)
.arg(input1.as_os_str())
.arg("-o")
.arg(&out_dir)
.arg("--out-format")
.arg("json")
.arg("--out-format")
.arg("srt")
.status()
.expect("failed to spawn polyscribe");
assert!(status.success(), "CLI did not exit successfully");
let mut has_json = false;
let mut has_toml = false;
let mut has_srt = false;
for e in fs::read_dir(&out_dir).unwrap() {
let p = e.unwrap().path();
if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
if name.ends_with(".json") { has_json = true; }
if name.ends_with(".toml") { has_toml = true; }
if name.ends_with(".srt") { has_srt = true; }
}
}
assert!(has_json, "expected JSON file to be written");
assert!(has_srt, "expected SRT file to be written");
assert!(!has_toml, "did not expect TOML file");
let _ = fs::remove_dir_all(&out_dir);
}
*/
#[test]
fn cli_no_interation_alias_skips_speaker_prompts_and_uses_defaults() {
let exe = env!("CARGO_BIN_EXE_polyscribe");
let input1 = manifest_path("input/1-s0wlz.json");
let input2 = manifest_path("input/2-vikingowl.json");
let output = Command::new(exe)
.arg(input1.as_os_str())
.arg(input2.as_os_str())
.arg("-m")
.arg("--set-speaker-names")
.arg("--no-interation")
.output()
.expect("failed to spawn polyscribe");
assert!(output.status.success(), "CLI did not exit successfully");
let stdout = String::from_utf8(output.stdout).expect("stdout not UTF-8");
let root: OutputRoot = serde_json::from_str(&stdout).unwrap();
let speakers: std::collections::HashSet<String> =
root.items.into_iter().map(|e| e.speaker).collect();
assert!(speakers.contains("s0wlz"), "default s0wlz not used (alias)");
assert!(speakers.contains("vikingowl"), "default vikingowl not used (alias)");
}

View File

@@ -1,88 +0,0 @@
// SPDX-License-Identifier: MIT
// Tests for --out-format flag behavior
use std::fs;
use std::process::Command;
use std::path::PathBuf;
fn manifest_path(relative: &str) -> PathBuf {
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push(relative);
p
}
#[test]
fn out_format_single_json_only() {
let exe = env!("CARGO_BIN_EXE_polyscribe");
let out_dir = manifest_path("target/tmp/itest_outfmt_json_only");
let _ = fs::remove_dir_all(&out_dir);
fs::create_dir_all(&out_dir).unwrap();
let input1 = manifest_path("input/1-s0wlz.json");
let status = Command::new(exe)
.arg(input1.as_os_str())
.arg("-o")
.arg(&out_dir)
.arg("--out-format")
.arg("json")
.status()
.expect("failed to spawn polyscribe");
assert!(status.success(), "CLI did not exit successfully");
let mut has_json = false;
let mut has_toml = false;
let mut has_srt = false;
for e in fs::read_dir(&out_dir).unwrap() {
let p = e.unwrap().path();
if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
if name.ends_with(".json") { has_json = true; }
if name.ends_with(".toml") { has_toml = true; }
if name.ends_with(".srt") { has_srt = true; }
}
}
assert!(has_json, "expected JSON file to be written");
assert!(!has_toml, "did not expect TOML file");
assert!(!has_srt, "did not expect SRT file");
let _ = fs::remove_dir_all(&out_dir);
}
#[test]
fn out_format_multiple_json_and_srt() {
let exe = env!("CARGO_BIN_EXE_polyscribe");
let out_dir = manifest_path("target/tmp/itest_outfmt_json_srt");
let _ = fs::remove_dir_all(&out_dir);
fs::create_dir_all(&out_dir).unwrap();
let input1 = manifest_path("input/2-vikingowl.json");
let status = Command::new(exe)
.arg(input1.as_os_str())
.arg("-o")
.arg(&out_dir)
.arg("--out-format")
.arg("json")
.arg("--out-format")
.arg("srt")
.status()
.expect("failed to spawn polyscribe");
assert!(status.success(), "CLI did not exit successfully");
let mut has_json = false;
let mut has_toml = false;
let mut has_srt = false;
for e in fs::read_dir(&out_dir).unwrap() {
let p = e.unwrap().path();
if let Some(name) = p.file_name().and_then(|s| s.to_str()) {
if name.ends_with(".json") { has_json = true; }
if name.ends_with(".toml") { has_toml = true; }
if name.ends_with(".srt") { has_srt = true; }
}
}
assert!(has_json, "expected JSON file to be written");
assert!(has_srt, "expected SRT file to be written");
assert!(!has_toml, "did not expect TOML file");
let _ = fs::remove_dir_all(&out_dir);
}

View File

@@ -1,91 +0,0 @@
use polyscribe::progress::{ProgressFactory, ProgressMode, SelectionInput, select_mode, ProgressManager};
#[test]
fn test_factory_decide_mode_none_when_disabled() {
let pf = ProgressFactory::new(true); // force disabled
assert!(matches!(pf.decide_mode(0), ProgressMode::None));
assert!(matches!(pf.decide_mode(1), ProgressMode::None));
assert!(matches!(pf.decide_mode(2), ProgressMode::None));
}
#[test]
fn test_select_mode_zero_inputs_is_none() {
let si = SelectionInput {
inputs_len: 0,
no_progress_flag: false,
stderr_tty_override: Some(true),
env_no_progress: false,
};
let (enabled, mode) = select_mode(si);
assert!(enabled);
assert!(matches!(mode, ProgressMode::None));
}
#[test]
fn test_select_mode_one_input_is_single() {
let si = SelectionInput {
inputs_len: 1,
no_progress_flag: false,
stderr_tty_override: Some(true),
env_no_progress: false,
};
let (enabled, mode) = select_mode(si);
assert!(enabled);
assert!(matches!(mode, ProgressMode::Single));
}
#[test]
fn test_select_mode_multi_inputs_is_multi() {
let si = SelectionInput {
inputs_len: 3,
no_progress_flag: false,
stderr_tty_override: Some(true),
env_no_progress: false,
};
let (enabled, mode) = select_mode(si);
assert!(enabled);
match mode {
ProgressMode::Multi { total_inputs } => assert_eq!(total_inputs, 3),
_ => panic!("expected multi mode"),
}
}
#[test]
fn test_env_no_progress_disables() {
// Simulate env flag influence by passing env_no_progress=true
unsafe { std::env::set_var("NO_PROGRESS", "1"); }
let si = SelectionInput {
inputs_len: 5,
no_progress_flag: false,
stderr_tty_override: Some(true),
env_no_progress: true,
};
let (enabled, mode) = select_mode(si);
assert!(!enabled);
assert!(matches!(mode, ProgressMode::None));
unsafe { std::env::remove_var("NO_PROGRESS"); }
}
#[test]
fn test_completed_never_exceeds_total_and_item_updates_do_not_affect_total() {
// create hidden multiprogress for tests
let pm = ProgressManager::new_for_tests_multi_hidden(3);
pm.set_total(3);
// Start an item and update progress a few times
let item = pm.start_item("Test item");
item.set_progress(0.1);
item.set_progress(0.4);
item.set_message("stage1");
// Ensure total unchanged
let (pos, len) = pm.total_state_for_tests().unwrap();
assert_eq!(len, 3);
assert_eq!(pos, 0);
// Mark 4 times completed, but expect clamp at 3
pm.inc_completed();
pm.inc_completed();
pm.inc_completed();
pm.inc_completed();
let (pos, len) = pm.total_state_for_tests().unwrap();
assert_eq!(len, 3);
assert_eq!(pos, 3);
}

View File

@@ -1,30 +0,0 @@
use polyscribe::progress::ProgressManager;
#[test]
fn test_total_and_completed_clamp() {
let pm = ProgressManager::new_for_tests_multi_hidden(3);
pm.set_total(3);
pm.inc_completed();
pm.inc_completed();
pm.inc_completed();
// Extra increments should not exceed total
pm.inc_completed();
}
#[test]
fn test_start_item_does_not_change_total() {
let pm = ProgressManager::new_for_tests_multi_hidden(2);
pm.set_total(2);
let item = pm.start_item("file1");
item.set_progress(0.5);
// No panic; total bar position should be unaffected. We cannot introspect position without
// exposing internals; this test ensures API usability without side effects.
item.finish_with("done");
}
#[test]
fn test_pause_and_resume_prompt() {
let pm = ProgressManager::test_new_multi(1);
pm.pause_for_prompt();
pm.resume_after_prompt();
}

View File

@@ -1,22 +0,0 @@
use polyscribe::progress::ProgressManager;
#[test]
fn test_single_mode_has_no_total_bar_and_three_bars() {
// Use hidden backend suitable for tests
let pm = ProgressManager::new_for_tests_single_hidden();
// No total bar should be present
assert!(pm.total_state_for_tests().is_none(), "single mode must not expose a total bar");
// Bar count: header + info + current
assert_eq!(pm.testing_bar_count(), 3);
}
#[test]
fn test_multi_mode_has_total_bar_and_four_bars() {
let pm = ProgressManager::new_for_tests_multi_hidden(2);
// Total bar should exist with the provided length
let (pos, len) = pm.total_state_for_tests().expect("multi mode should expose total bar");
assert_eq!(pos, 0);
assert_eq!(len, 2);
// Bar count: header + info + current + total
assert_eq!(pm.testing_bar_count(), 4);
}

View File

@@ -1,86 +0,0 @@
use std::io::Write as _;
use std::process::{Command, Stdio};
fn manifest_path(rel: &str) -> std::path::PathBuf {
let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push(rel);
p
}
fn collect_stderr_lines(output: &std::process::Output) -> Vec<String> {
let stderr = String::from_utf8_lossy(&output.stderr);
stderr.lines().map(|s| s.to_string()).collect()
}
#[test]
fn speaker_prompt_spacing_single_vs_multi_is_consistent() {
let exe = env!("CARGO_BIN_EXE_polyscribe");
let input1 = manifest_path("input/1-s0wlz.json");
let input2 = manifest_path("input/2-vikingowl.json");
// Single mode
let mut child1 = Command::new(exe)
.arg(input1.as_os_str())
.arg("--set-speaker-names")
.arg("-m")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn polyscribe (single)");
{
let s = child1.stdin.as_mut().unwrap();
writeln!(s, "Alpha").unwrap();
}
let out1 = child1.wait_with_output().unwrap();
assert!(out1.status.success());
let lines1 = collect_stderr_lines(&out1);
// Multi mode
let mut child2 = Command::new(exe)
.arg(input1.as_os_str())
.arg(input2.as_os_str())
.arg("--set-speaker-names")
.arg("-m")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn polyscribe (multi)");
{
let s = child2.stdin.as_mut().unwrap();
writeln!(s, "Alpha").unwrap();
writeln!(s, "Beta").unwrap();
}
let out2 = child2.wait_with_output().unwrap();
assert!(out2.status.success());
let lines2 = collect_stderr_lines(&out2);
// Helper to count blank separators around echo block
fn analyze(lines: &[String]) -> (usize, usize, usize) {
// count: prompts, blanks, echoes (either legacy "Speaker for " or new mapping lines starting with " - ")
let mut prompts = 0;
let mut blanks = 0;
let mut echoes = 0;
for l in lines {
if l.starts_with("Enter speaker name for ") { prompts += 1; }
if l.trim().is_empty() { blanks += 1; }
if l.starts_with("Speaker for ") || l.starts_with(" - ") { echoes += 1; }
}
(prompts, blanks, echoes)
}
let (p1, b1, e1) = analyze(&lines1);
let (p2, b2, e2) = analyze(&lines2);
// Expect one prompt/echo for single, two for multi
assert_eq!(p1, 1);
assert_eq!(e1, 1);
assert_eq!(p2, 2);
assert_eq!(e2, 2);
// Each mode should have exactly two blank separators: one between prompts and echoes and one after echoes
// Note: other logs may be absent in tests; we count exactly 2 blanks for single and multi here
assert!(b1 >= 2, "expected at least two blank separators in single mode, got {}: {:?}", b1, lines1);
assert!(b2 >= 2, "expected at least two blank separators in multi mode, got {}: {:?}", b2, lines2);
}

View File

@@ -1,58 +0,0 @@
// Unix-only tests for with_suppressed_stderr restoring file descriptors
// Skip on Windows and non-Unix targets.
#![cfg(unix)]
use std::panic::{catch_unwind, AssertUnwindSafe};
fn stat_of_fd(fd: i32) -> (u64, u64) {
unsafe {
let mut st: libc::stat = std::mem::zeroed();
let r = libc::fstat(fd, &mut st as *mut libc::stat);
assert_eq!(r, 0, "fstat failed on fd {fd}");
(st.st_dev as u64, st.st_ino as u64)
}
}
fn stat_of_path(path: &str) -> (u64, u64) {
use std::ffi::CString;
unsafe {
let c = CString::new(path).unwrap();
let fd = libc::open(c.as_ptr(), libc::O_RDONLY);
assert!(fd >= 0, "failed to open {path}");
let s = stat_of_fd(fd);
let _ = libc::close(fd);
s
}
}
#[test]
fn stderr_is_redirected_and_restored() {
let before = stat_of_fd(2);
let devnull = stat_of_path("/dev/null");
// During the call, fd 2 should be /dev/null; after, restored to before
polyscribe::with_suppressed_stderr(|| {
let inside = stat_of_fd(2);
assert_eq!(inside, devnull, "stderr should point to /dev/null during suppression");
// This write should be suppressed
eprintln!("this should be suppressed");
});
let after = stat_of_fd(2);
assert_eq!(after, before, "stderr should be restored after suppression");
}
#[test]
fn stderr_is_restored_even_if_closure_panics() {
let before = stat_of_fd(2);
let res = catch_unwind(AssertUnwindSafe(|| {
polyscribe::with_suppressed_stderr(|| {
// Trigger a deliberate panic inside the closure
panic!("boom inside with_suppressed_stderr");
});
}));
assert!(res.is_err(), "expected panic to propagate");
let after = stat_of_fd(2);
assert_eq!(after, before, "stderr should be restored after panic");
}