Compare commits
22 Commits
af473c4942
...
ab57553949
Author | SHA1 | Date | |
---|---|---|---|
ab57553949 | |||
40818a091d | |||
97855a247b | |||
0864516614 | |||
bb9402c643 | |||
4b8b68b33d | |||
6a9736c50a | |||
d3310695d2 | |||
03659448bc | |||
5c8a495b9f | |||
6b72bd64c0 | |||
278ca1b523 | |||
3f1e634e2d | |||
4063b4cb06 | |||
f551cc3498 | |||
90f9849cc0 | |||
9d12507cf5 | |||
b9308be930 | |||
fdf5e3370d | |||
ae0fdf802a | |||
fbf3aab23c | |||
4e117d78f8 |
45
.github/workflows/ci.yml
vendored
45
.github/workflows/ci.yml
vendored
@@ -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
40
CHANGELOG.md
Normal 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()` side‑effect 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 re‑exports `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, compile‑green patches.
|
||||||
|
- Optionally re‑enable `clippy::pedantic`, `clippy::nursery`, and `clippy::cargo` once warnings are significantly reduced, then address non‑breaking warnings.
|
146
Cargo.lock
generated
146
Cargo.lock
generated
@@ -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"
|
||||||
|
21
Cargo.toml
21
Cargo.toml
@@ -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"
|
||||||
|
23
Makefile
23
Makefile
@@ -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
|
|
12
README.md
12
README.md
@@ -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:
|
||||||
|
@@ -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.
|
|
||||||
|
@@ -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
0
examples/download_models_interactive.sh
Executable file → Normal file
0
examples/transcribe_file.sh
Executable file → Normal file
0
examples/transcribe_file.sh
Executable file → Normal file
0
examples/update_models.sh
Executable file → Normal file
0
examples/update_models.sh
Executable file → Normal 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
|
|
@@ -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
|
|
228
src/backend.rs
228
src/backend.rs
@@ -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"); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
203
src/lib.rs
203
src/lib.rs
@@ -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
|
||||||
))
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
943
src/main.rs
943
src/main.rs
File diff suppressed because it is too large
Load Diff
525
src/models.rs
525
src/models.rs
@@ -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");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
149
src/output.rs
149
src/output.rs
@@ -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'));
|
|
||||||
}
|
|
||||||
}
|
|
848
src/progress.rs
848
src/progress.rs
@@ -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
115
src/ui.rs
@@ -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())
|
|
||||||
}
|
|
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
@@ -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");
|
|
||||||
}
|
|
@@ -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)");
|
|
||||||
}
|
|
||||||
|
@@ -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);
|
|
||||||
}
|
|
@@ -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);
|
|
||||||
}
|
|
@@ -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();
|
|
||||||
}
|
|
@@ -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);
|
|
||||||
}
|
|
@@ -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);
|
|
||||||
}
|
|
@@ -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");
|
|
||||||
}
|
|
Reference in New Issue
Block a user