diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d9baf0..5eed868 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Cargo Build & Test +name: CI on: push: @@ -8,22 +8,52 @@ env: CARGO_TERM_COLOR: always jobs: - build_and_test: - name: Rust project - latest + fmt: + name: Formatting runs-on: ubuntu-latest - strategy: - matrix: - toolchain: - - stable - - beta - - nightly steps: - - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - submodules: true - - run: rustup update ${{ matrix.toolchain }} && rustup default ${{ matrix.toolchain }} - - run: cargo install --path xtask/cargo-xtask --locked - - run: cargo xtask build + - uses: actions/checkout@v6 + - name: Install pinned Rust toolchain + run: rustup show active-toolchain + - name: Check formatting + run: cargo fmt --all -- --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Install pinned Rust toolchain + run: rustup show active-toolchain + - name: Check lints + run: cargo clippy --workspace --all-targets -- -D warnings + + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Install pinned Rust toolchain + run: rustup show active-toolchain + - name: Run tests + run: cargo test --workspace --all-targets + + docs: + name: Rustdoc + runs-on: ubuntu-latest env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUSTDOCFLAGS: -D warnings + steps: + - uses: actions/checkout@v6 + - name: Install pinned Rust toolchain + run: rustup show active-toolchain + - name: Build documentation + run: cargo doc --workspace --no-deps + + deny: + name: Dependency Policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Check advisories, licenses, bans, and sources + uses: EmbarkStudios/cargo-deny-action@v2 diff --git a/CONTEXT.md b/CONTEXT.md index 786713d..0452ad0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -144,6 +144,17 @@ The config file's top-level `reasoning_effort`, and a preset's `reasoning_effort - **WHEN** the operator passes `--reasoning-effort` and the config file or preset also sets one - **THEN** the flag's value is used +### Requirement: Template and preset system precedence +An explicitly selected template's rendered system text overrides a selected preset's system text. An explicit `--system` argument overrides both. + +#### Scenario: Template and preset both set a system message +- **WHEN** the operator selects a template with a `system` value and a preset with a `system` value +- **THEN** the rendered template system message is used + +#### Scenario: Explicit system argument +- **WHEN** the operator passes `--system` while selecting a template and a preset +- **THEN** the explicit system argument is used + ### Requirement: Reasoning stream separator Any provider whose stream can carry both a reasoning summary and answer text (`chatgpt --reasoning-summary`, and `--api deepseek`, which always streams reasoning) prints a `---` rule between the two, and only when reasoning text actually arrived — never when the stream carried no reasoning. The rule travels on the same stream as the summary it separates: stdout on a terminal, stderr when stdout is piped, so a pipe never receives a stray rule. diff --git a/README.md b/README.md index 507b96c..5c5b5bf 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,18 @@ To install the llm-stream CLI tool, run: cargo install llm-stream-cli ``` +## Development + +The repository pins its Rust toolchain. Install the local task wrapper, then +run the local quality checks: + +```bash +cargo install --path xtask/cargo-xtask --locked +cargo xtask lint +``` + +CI also checks rustdoc and the dependency policy in `deny.toml`. + ## Usage ### Library diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..fd54c7a --- /dev/null +++ b/clippy.toml @@ -0,0 +1,3 @@ +too-many-arguments-threshold = 5 +max-fn-params-bools = 1 +cognitive-complexity-threshold = 15 diff --git a/crates/llm_stream/Cargo.toml b/crates/llm_stream/Cargo.toml index 97a632f..0b4e154 100644 --- a/crates/llm_stream/Cargo.toml +++ b/crates/llm_stream/Cargo.toml @@ -26,7 +26,6 @@ tokio = { version = "1.40.0", features = ["macros", "rt-multi-thread"] } clap-stdin = "0.6" futures = "0.3.30" spinners = "4.1.1" -atty = "0.2.14" crossterm = "0.29.0" config-file = "0.2.3" tera = "1.20.0" diff --git a/crates/llm_stream/src/auth/listener.rs b/crates/llm_stream/src/auth/listener.rs index b07e934..37e0495 100644 --- a/crates/llm_stream/src/auth/listener.rs +++ b/crates/llm_stream/src/auth/listener.rs @@ -120,7 +120,7 @@ fn respond(socket: &mut TcpStream, status: u16, title: &str, message: &str) -> R #[cfg(test)] mod tests { use super::*; - use std::io::Write; + use std::io::{Read, Write}; #[test] fn the_preferred_port_needs_no_notice() { @@ -175,6 +175,8 @@ mod tests { "GET /auth/callback?code=xyz&state=st HTTP/1.1\r\nHost: localhost\r\n\r\n" ) .expect("write"); + let mut response = String::new(); + socket.read_to_string(&mut response).expect("read response"); }); assert_eq!(listener.await_code("st").expect("await").as_str(), "xyz"); } diff --git a/crates/llm_stream/src/chatgpt.rs b/crates/llm_stream/src/chatgpt.rs index c827a9b..3707648 100644 --- a/crates/llm_stream/src/chatgpt.rs +++ b/crates/llm_stream/src/chatgpt.rs @@ -566,7 +566,10 @@ mod tests { Api::Mistral, Api::MistralFim, ] { - assert!(reads_api_credentials(Some(api)), "{api} lost its credential"); + assert!( + reads_api_credentials(Some(api)), + "{api} lost its credential" + ); } } diff --git a/crates/llm_stream/src/prelude.rs b/crates/llm_stream/src/prelude.rs index 9a3b585..913c4bd 100644 --- a/crates/llm_stream/src/prelude.rs +++ b/crates/llm_stream/src/prelude.rs @@ -23,11 +23,12 @@ pub async fn handle_stream( ) -> Result<()> { let mut accumulated_text = String::new(); - let is_terminal = atty::is(atty::Stream::Stdout); + let is_terminal = std::io::stdout().is_terminal(); // Rendering is incremental and append-only. See stream_render for why // re-rendering the whole answer each chunk could not be made to work. - let mut renderer = crate::stream_render::StreamRenderer::new(crate::stream_render::terminal_width()); + let mut renderer = + crate::stream_render::StreamRenderer::new(crate::stream_render::terminal_width()); let mut sp = if args.quiet.is_none() || (args.quiet == Some(false) && is_terminal) { Some(spinners::Spinner::new( @@ -305,7 +306,7 @@ pub fn parse_args(mut args: Args, config: Config) -> Result<(Args, Config)> { if args.temperature.is_none() { args.temperature = p.temperature; } - if args.system.is_none() { + if args.system.is_none() && args.template.is_none() { args.system = p.system; } if args.max_tokens.is_none() { @@ -878,8 +879,14 @@ mod tests { Some("https://chatgpt.com/backend-api/codex"), "the endpoint this provider does read was dropped" ); - assert_eq!(actual.api_env, None, "env reached a provider that ignores it"); - assert_eq!(actual.api_key, None, "key reached a provider that ignores it"); + assert_eq!( + actual.api_env, None, + "env reached a provider that ignores it" + ); + assert_eq!( + actual.api_key, None, + "key reached a provider that ignores it" + ); assert!( crate::chatgpt::inert_flag_warnings(&actual).is_empty(), "a plain invocation warned about flags the operator never typed" @@ -1050,8 +1057,8 @@ mod tests { } #[test] - fn test_preset_system_over_config_system() -> std::result::Result<(), Box> - { + fn preset_system_overrides_config_system_through_parse_pipeline( + ) -> std::result::Result<(), Box> { let system = "preset system"; let config_system = "config system"; let preset_name = "preset_name"; @@ -1080,11 +1087,12 @@ mod tests { ..Default::default() }; + let (args, config) = parse_args(args, config)?; let actual = merge_args_and_config(args, config)?; assert_eq!( expected.conversation, actual.conversation, - "The system arg should overwrite the preset system" + "The preset system should override the top-level config system" ); Ok(()) @@ -1170,6 +1178,7 @@ mod tests { ..Default::default() }; + let (args, config) = parse_args(args, config)?; let actual = merge_args_and_config(args, config)?; assert_eq!( @@ -1348,7 +1357,7 @@ content = "hello" /// Reads back the conversation a stream handler cached under `id`. /// - /// `cargo test` captures stdout, so `atty` reports it is not a terminal and + /// `cargo test` captures stdout, so `IsTerminal` reports it is not a terminal and /// these tests exercise the piped branch — the one that used to cache /// nothing — without having to fake a tty. fn cached_conversation(dir: &tempfile::TempDir, id: &str) -> Conversation { @@ -1553,7 +1562,7 @@ where T: Row + Title + 'static, for<'a> &'a T: Row, { - let is_terminal: bool = atty::is(atty::Stream::Stdout); + let is_terminal = std::io::stdout().is_terminal(); let table = if is_terminal { lines.with_title() @@ -1787,7 +1796,7 @@ pub async fn handle_reason_stream( ) -> Result<()> { let mut accumulated_text = String::new(); - let is_terminal = atty::is(atty::Stream::Stdout); + let is_terminal = std::io::stdout().is_terminal(); let sink = reasoning_sink(is_terminal); // Rendering is incremental and append-only. See stream_render for why diff --git a/crates/llm_stream/src/printer.rs b/crates/llm_stream/src/printer.rs index dc9f1a5..1eff79c 100644 --- a/crates/llm_stream/src/printer.rs +++ b/crates/llm_stream/src/printer.rs @@ -15,7 +15,8 @@ use syntect::{ util::LinesWithEndings, }; -pub(crate) static SYNTAX_SET: LazyLock = LazyLock::new(SyntaxSet::load_defaults_newlines); +pub(crate) static SYNTAX_SET: LazyLock = + LazyLock::new(SyntaxSet::load_defaults_newlines); pub(crate) static THEME: LazyLock = LazyLock::new(|| { let theme_data = include_str!("../assets/themes/tokyonight/tokyonight-storm.tmTheme"); ThemeSet::load_from_reader(&mut std::io::Cursor::new(theme_data)).unwrap() diff --git a/crates/llm_stream/src/stream_render.rs b/crates/llm_stream/src/stream_render.rs index eb3a949..986eab8 100644 --- a/crates/llm_stream/src/stream_render.rs +++ b/crates/llm_stream/src/stream_render.rs @@ -82,7 +82,9 @@ pub struct Frame { /// this never sees an escape sequence and does not have to skip one. #[must_use] pub fn display_width(s: &str) -> usize { - s.chars().map(|c| UnicodeWidthChar::width(c).unwrap_or(0)).sum() + s.chars() + .map(|c| UnicodeWidthChar::width(c).unwrap_or(0)) + .sum() } /// Pure. The byte offset in `s` of the first character that would carry the row @@ -151,10 +153,7 @@ pub fn wrap_points(line: &str, width: usize, word_wrap: bool) -> Vec { /// on the raw line and applied to the highlighted one, so the two can never /// disagree about where a row ends. #[must_use] -pub fn slice_spans<'a>( - spans: &[(Style, &'a str)], - points: &[usize], -) -> Vec> { +pub fn slice_spans<'a>(spans: &[(Style, &'a str)], points: &[usize]) -> Vec> { let mut rows = Vec::new(); let mut current: Vec<(Style, &'a str)> = Vec::new(); let mut offset = 0; @@ -278,7 +277,9 @@ impl StreamRenderer { fn end_line(&mut self, frame: &mut Frame) { let (next, delimiter) = step_mode(&self.mode, &self.line); if !delimiter { - frame.finished.extend(self.rows().into_iter().skip(self.emitted)); + frame + .finished + .extend(self.rows().into_iter().skip(self.emitted)); } self.mode = next; self.line.clear(); diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..0743e6d --- /dev/null +++ b/deny.toml @@ -0,0 +1,45 @@ +[advisories] +db-path = "~/.cargo/advisory-db" +db-urls = ["https://github.com/rustsec/advisory-db"] +ignore = [ + # syntect 5.3.0 is the current release and still requires bincode 1.x. + "RUSTSEC-2025-0141", + # The pinned eventsource-client revision requires hyper-rustls 0.24, which requires this archived crate. + "RUSTSEC-2025-0134", + # This CLI validates DNS API hosts, not URI name constraints from certificates. + "RUSTSEC-2026-0098", + # This CLI validates DNS API hosts, not wildcard-name constraints from certificates. + "RUSTSEC-2026-0099", + # This client does not enable or parse certificate revocation lists. + "RUSTSEC-2026-0104", + # syntect 5.3.0 is the current release and still requires yaml-rust 0.4. + "RUSTSEC-2024-0320", +] + +[licenses] +allow = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "OpenSSL", + "Unicode-3.0", + "Unlicense", + "Zlib", +] +confidence-threshold = 0.8 + +[bans] +multiple-versions = "warn" +wildcards = "allow" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = ["https://github.com/cloudbridgeuy/rust-eventsource-client.git"] diff --git a/lib/llm_stream/src/anthropic.rs b/lib/llm_stream/src/anthropic.rs index 89f7d05..cc8382e 100644 --- a/lib/llm_stream/src/anthropic.rs +++ b/lib/llm_stream/src/anthropic.rs @@ -39,7 +39,7 @@ pub enum Role { #[derive(Debug, Serialize, Deserialize, Default)] pub struct MessageBody { /// The model that will complete your prompt. - /// See this link for additional details and options: https://docs.anthropic.com/claude/docs/models-overview + /// See this link for additional details and options: pub model: String, /// Input messages. pub messages: Vec, diff --git a/lib/llm_stream/src/chatgpt.rs b/lib/llm_stream/src/chatgpt.rs index 4972729..d7d1a76 100644 --- a/lib/llm_stream/src/chatgpt.rs +++ b/lib/llm_stream/src/chatgpt.rs @@ -802,7 +802,10 @@ mod tests { fn deltas_within_one_part_are_concatenated_untouched() { // A part arrives in pieces mid-word. Inserting anything between them // would break the word. - assert_eq!(joined(&[(0, "**Calc"), (0, "ulating"), (0, "**")]), "**Calculating**"); + assert_eq!( + joined(&[(0, "**Calc"), (0, "ulating"), (0, "**")]), + "**Calculating**" + ); } #[test] @@ -826,7 +829,10 @@ mod tests { // Nothing promises the first part we see is numbered 0 — a resumed or // re-ordered stream need not start there, and "first seen" is the only // thing that matters for the leading break. - assert_eq!(joined(&[(3, "**One**"), (4, "**Two**")]), "**One**\n\n**Two**"); + assert_eq!( + joined(&[(3, "**One**"), (4, "**Two**")]), + "**One**\n\n**Two**" + ); } #[test] diff --git a/lib/llm_stream/src/groq.rs b/lib/llm_stream/src/groq.rs index fc96da8..1bddbdd 100644 --- a/lib/llm_stream/src/groq.rs +++ b/lib/llm_stream/src/groq.rs @@ -52,7 +52,7 @@ pub struct MessageBody { #[serde(skip_serializing_if = "Option::is_none")] pub stop_sequences: Option>, - /// If set, partial message deltas will be sent, like in ChatGPT. Tokens will be sent as data-only server-sent events as they become available, with the stream terminated by a data: [DONE] message. + /// If set, partial message deltas will be sent, like in ChatGPT. Tokens will be sent as data-only server-sent events as they become available, with the stream terminated by a data: \[DONE\] message. #[serde(skip_serializing_if = "Option::is_none")] pub stream: Option, diff --git a/lib/llm_stream/src/mistral.rs b/lib/llm_stream/src/mistral.rs index fb2f72e..df65d44 100644 --- a/lib/llm_stream/src/mistral.rs +++ b/lib/llm_stream/src/mistral.rs @@ -43,7 +43,7 @@ pub struct MessageBody { pub stop: Option>, /// The prompt(s) to generate completions for, encoded as a list of dict with role and content. pub messages: Vec, - /// Whether to stream back partial progress. If set, tokens will be sent as data-only server-side events as they become available, with the stream terminated by a data: [DONE] message. Otherwise, the server will hold the request open until the timeout or until completion, with the response containing the full result as JSON. + /// Whether to stream back partial progress. If set, tokens will be sent as data-only server-side events as they become available, with the stream terminated by a data: \[DONE\] message. Otherwise, the server will hold the request open until the timeout or until completion, with the response containing the full result as JSON. #[serde(skip_serializing_if = "Option::is_none")] pub stream: Option, /// The seed to use for random sampling. If set, different calls will generate deterministic results. diff --git a/lib/llm_stream/src/mistral_fim.rs b/lib/llm_stream/src/mistral_fim.rs index f317b74..d8d36b2 100644 --- a/lib/llm_stream/src/mistral_fim.rs +++ b/lib/llm_stream/src/mistral_fim.rs @@ -27,7 +27,7 @@ pub struct MessageBody { /// Stop generation if this token is detected. Or if one of these tokens is detected when providing an array #[serde(skip_serializing_if = "Option::is_none")] pub stop: Option>, - /// Whether to stream back partial progress. If set, tokens will be sent as data-only server-side events as they become available, with the stream terminated by a data: [DONE] message. Otherwise, the server will hold the request open until the timeout or until completion, with the response containing the full result as JSON. + /// Whether to stream back partial progress. If set, tokens will be sent as data-only server-side events as they become available, with the stream terminated by a data: \[DONE\] message. Otherwise, the server will hold the request open until the timeout or until completion, with the response containing the full result as JSON. #[serde(skip_serializing_if = "Option::is_none")] pub stream: Option, /// The seed to use for random sampling. If set, different calls will generate deterministic results. diff --git a/lib/llm_stream/src/openai.rs b/lib/llm_stream/src/openai.rs index ef413ff..a097430 100644 --- a/lib/llm_stream/src/openai.rs +++ b/lib/llm_stream/src/openai.rs @@ -72,7 +72,7 @@ pub struct MessageBody { #[serde(skip_serializing_if = "Option::is_none")] pub stop_sequences: Option>, - /// If set, partial message deltas will be sent, like in ChatGPT. Tokens will be sent as data-only server-sent events as they become available, with the stream terminated by a data: [DONE] message. + /// If set, partial message deltas will be sent, like in ChatGPT. Tokens will be sent as data-only server-sent events as they become available, with the stream terminated by a data: \[DONE\] message. #[serde(skip_serializing_if = "Option::is_none")] pub stream: Option, diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..47a730b --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.91.1" +components = ["clippy", "rustfmt"] diff --git a/xtask/README.md b/xtask/README.md index e1be740..dab3bf8 100644 --- a/xtask/README.md +++ b/xtask/README.md @@ -38,6 +38,14 @@ Cargo Rail is optional. Its check is marked skipped when Cargo reports that the subcommand or cargo-rail executable is unavailable; other Rail failures remain failures. Install Rail separately if you want that check enforced. +### CI quality gate + +CI uses the repository's pinned Rust toolchain and runs five independent +required checks: formatting, Clippy with warnings denied, all workspace tests +and targets, rustdoc with warnings denied, and cargo-deny. `cargo xtask lint` +remains the local convenience path; optional cargo-rail is not a CI +requirement. + Useful lint options: ~~~sh diff --git a/xtask/cargo-xtask/Cargo.toml b/xtask/cargo-xtask/Cargo.toml index 278a677..60f1ef8 100644 --- a/xtask/cargo-xtask/Cargo.toml +++ b/xtask/cargo-xtask/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "cargo-xtask" +license = "MIT" version = "0.1.0" edition = "2021" publish = false