diff --git a/hive-agent/src/stream_enrich.rs b/hive-agent/src/stream_enrich.rs index 9f972a97..39921b9d 100644 --- a/hive-agent/src/stream_enrich.rs +++ b/hive-agent/src/stream_enrich.rs @@ -1009,11 +1009,9 @@ fn fmt_tok(n: u64) -> String { fn fmt_room(r: &str) -> String { if r.starts_with('!') { // Room id: keep only the local part before the colon (up to 9 chars). - // Both halves of that are character counts — `find` returns a byte - // offset, and spending it as a character budget lets a multi-byte - // local part buy extra characters from the server half. - let local = r.split(':').next().unwrap_or(r); - local.chars().take(9).collect() + // Use chars().take() so we never slice on a non-ASCII byte boundary. + let colon = r.find(':').unwrap_or(r.len()); + r.chars().take(colon.min(9)).collect() } else if r.starts_with('#') { // Alias: keep `#name` part before the server. r.split(':').next().unwrap_or(r).to_owned() @@ -1050,422 +1048,3 @@ fn trim_str(s: &str, max: usize) -> String { fn json_str(s: &str) -> String { serde_json::to_string(s).unwrap_or_else(|_| s.to_owned()) } - -#[cfg(test)] -mod tests { - use super::*; - - fn one(v: &Value) -> TermMsg { - let mut ctx = ClassifyCtx::default(); - let rows = classify_stream_value(v, &mut ctx); - assert_eq!(rows.len(), 1, "expected exactly one row for {v}"); - rows.into_iter().next().unwrap() - } - - // ----------------------------------------------------------------- - // dispatch - // ----------------------------------------------------------------- - - #[test] - fn terminal_event_types_render_nothing() { - for t in ["result", "rate_limit_event"] { - let mut ctx = ClassifyCtx::default(); - assert!( - classify_stream_value(&json!({ "type": t }), &mut ctx).is_empty(), - "{t} should be dropped" - ); - } - } - - /// The catch-all exists so a claude event shape nobody anticipated stays - /// *visible* rather than vanishing. Losing it is silent, which is why it - /// gets a test rather than a comment. - #[test] - fn unrecognised_shape_falls_through_loudly() { - let m = one(&json!({ "type": "something_new", "detail": "x" })); - assert_eq!(m.level, Level::Warn); - assert_eq!(m.icon.as_deref(), Some("!")); - assert!(m.summary.contains("something_new")); - } - - #[test] - fn catch_all_truncates_a_huge_payload() { - let m = one(&json!({ "type": "unknown", "blob": "x".repeat(5_000) })); - assert_eq!( - m.summary.chars().count(), - 201, - "200 chars plus the ellipsis" - ); - assert!(m.summary.ends_with('…')); - } - - /// Task events are matched on `subtype` *regardless of `type`* — a - /// deliberate asymmetry with every other arm, and one a refactor that - /// "tidies" the dispatch into a single match on `type` would silently - /// break. - #[test] - fn task_events_dispatch_on_subtype_not_type() { - let m = one(&json!({ - "type": "assistant", - "subtype": "task_started", - "task_id": "abcdef1234567890", - "description": "do a thing", - })); - assert!( - m.summary.starts_with("task abcdef12 started"), - "{}", - m.summary - ); - assert!( - !m.summary.contains("1234567890"), - "task id is truncated to 8 chars" - ); - } - - #[test] - fn a_failed_task_notification_is_an_error_row() { - let m = one(&json!({ - "type": "x", "subtype": "task_notification", - "task_id": "t1", "status": "failed", - })); - assert_eq!(m.level, Level::Error); - } - - // ----------------------------------------------------------------- - // system events - // ----------------------------------------------------------------- - - #[test] - fn startup_noise_is_dropped_but_an_unknown_subtype_is_not() { - let mut ctx = ClassifyCtx::default(); - assert!( - classify_stream_value(&json!({ "type": "system", "subtype": "init" }), &mut ctx) - .is_empty() - ); - // The pairing is the point: "init produces nothing" only means - // something if a *different* subtype produces something. - let m = one(&json!({ "type": "system", "subtype": "brand_new" })); - assert_eq!(m.summary, "⚙ brand_new"); - } - - #[test] - fn system_severity_is_carried_by_subtype() { - let cases = [ - ("api_error", Level::Error), - ("api_retry", Level::Warn), - ("status", Level::Debug), - ]; - for (subtype, want) in cases { - let m = one(&json!({ "type": "system", "subtype": subtype })); - assert_eq!(m.level, want, "{subtype}"); - } - } - - /// Coalescing is what stops a heartbeat from flooding the terminal: rows - /// sharing a key collapse into one. A missing key is invisible in every - /// unit except the rendered stream. - #[test] - fn repeating_rows_carry_a_coalesce_key() { - let tick = one(&json!({ "type": "system", "subtype": "status" })); - assert_eq!(tick.coalesce_key.as_deref(), Some("status-tick")); - - let think = one(&json!({ - "type": "system", "subtype": "thinking_tokens", "estimated_tokens": 1234, - })); - assert_eq!(think.coalesce_key.as_deref(), Some("thinking-tok")); - assert_eq!(think.summary, "thinking… ~1234 tokens"); - - // Same subtype, no count: still coalesces, still says something. - let bare = one(&json!({ "type": "system", "subtype": "thinking_tokens" })); - assert_eq!(bare.summary, "thinking…"); - assert_eq!(bare.coalesce_key.as_deref(), Some("thinking-tok")); - } - - #[test] - fn commands_changed_expands_into_the_command_list() { - let m = one(&json!({ - "type": "system", "subtype": "commands_changed", - "commands": [{ "name": "compact" }, { "name": "loop" }], - })); - assert_eq!(m.summary, "⚙ commands changed · 2 available"); - assert_eq!(m.body.as_deref(), Some("/compact\n/loop")); - } - - #[test] - fn api_retry_summarises_every_field_it_was_given() { - let m = one(&json!({ - "type": "system", "subtype": "api_retry", - "attempt": 2, "max_retries": 5, - "error_status": 529, "retry_delay_ms": 1500.4, - })); - assert_eq!(m.summary, "⚠ api retry · 2/5 · HTTP 529 · 1500ms"); - } - - /// `error` wins over `error_status` — the operator gets the message, not - /// the number, whenever there is one. - #[test] - fn api_error_prefers_the_message_over_the_status() { - let with_msg = one(&json!({ - "type": "system", "subtype": "api_error", - "error": "overloaded", "error_status": 529, - })); - assert_eq!(with_msg.summary, "✗ api error · overloaded"); - - let status_only = - one(&json!({ "type": "system", "subtype": "api_error", "error_status": 529 })); - assert_eq!(status_only.summary, "✗ api error · HTTP 529"); - - let neither = one(&json!({ "type": "system", "subtype": "api_error" })); - assert_eq!(neither.summary, "✗ api error · unknown"); - } - - // ----------------------------------------------------------------- - // tool results - // ----------------------------------------------------------------- - - /// The 120-char boundary decides between an inline summary and an - /// expandable body, and it counts *characters*, so a multi-byte result is - /// the case that would break a byte-based rewrite. - #[test] - fn tool_result_summary_boundary_counts_characters() { - let at = "a".repeat(120); - assert_eq!(summarize_tool_result(&at, &at), at); - - let over = "a".repeat(121); - let s = summarize_tool_result(&over, &over); - assert!(s.starts_with("1L · "), "{s}"); - assert!(s.ends_with('…')); - - // 120 non-ASCII chars is 240 bytes: byte-length would call this long. - let wide = "é".repeat(120); - assert_eq!(summarize_tool_result(&wide, &wide), wide); - } - - #[test] - fn an_empty_tool_result_says_so() { - assert_eq!(summarize_tool_result("", ""), "(empty)"); - assert_eq!(summarize_tool_result(" \n\t ", ""), "(empty)"); - } - - #[test] - fn a_long_tool_result_reports_its_line_count() { - let txt = "line\n".repeat(40); - let trimmed: String = txt.split_whitespace().collect::>().join(" "); - assert!(summarize_tool_result(&txt, &trimmed).starts_with("40L · ")); - } - - /// Only a *matched* pair is claude's wrapper. A result that merely starts - /// with the opening tag is real output and must survive intact. - #[test] - fn the_error_wrapper_is_stripped_only_when_balanced() { - assert_eq!( - strip_tool_use_error_wrapper("boom"), - "boom" - ); - assert_eq!( - strip_tool_use_error_wrapper(" boom "), - "boom" - ); - let unbalanced = "boom"; - assert_eq!(strip_tool_use_error_wrapper(unbalanced), unbalanced); - assert_eq!(strip_tool_use_error_wrapper("plain"), "plain"); - } - - fn tool_result(text: &str, is_error: bool, id: &str) -> Value { - json!({ - "type": "user", - "message": { "content": [{ - "type": "tool_result", - "tool_use_id": id, - "is_error": is_error, - "content": [{ "type": "text", "text": text }], - }]}, - }) - } - - /// `classify_tool_result` has its **own** `<= 120`, separate from the one - /// in `summarize_tool_result`, and the two have to agree: one picks the - /// summary text, the other picks icon-vs-body. A result that summarises as - /// short but renders with a body is the inconsistency this pins. - /// Added because mutation testing found the gap — the case below uses 2 - /// and 500 characters, so neither arm goes near the boundary and moving it - /// to 119 changed nothing. - #[test] - fn the_icon_or_body_choice_turns_on_the_same_120_boundary() { - let at = one(&tool_result(&"a".repeat(120), false, "t1")); - assert_eq!(at.icon.as_deref(), Some("←"), "120 chars is still short"); - assert!(at.body.is_none()); - - let over = one(&tool_result(&"a".repeat(121), false, "t1")); - assert!(over.icon.is_none(), "121 chars stops being short"); - assert_eq!(over.body.as_deref().map(str::len), Some(121)); - } - - #[test] - fn a_short_result_gets_an_icon_and_a_long_one_gets_a_body() { - let short = one(&tool_result("ok", false, "t1")); - assert_eq!(short.icon.as_deref(), Some("←")); - assert!(short.body.is_none(), "short results are not expandable"); - - let long = one(&tool_result(&"x".repeat(500), false, "t1")); - assert!(long.icon.is_none()); - assert_eq!(long.body.as_deref().map(str::len), Some(500)); - } - - #[test] - fn an_error_result_is_an_error_row_with_the_wrapper_gone() { - let m = one(&tool_result( - "file not found", - true, - "t1", - )); - assert_eq!(m.level, Level::Error); - assert_eq!(m.icon.as_deref(), Some("✗")); - assert_eq!(m.summary, "file not found"); - } - - /// The one piece of *cross-event* state in this module: the tool name is - /// learned from the assistant event and read back on the user event that - /// answers it. Nothing but a test spanning both events can catch it - /// breaking — and when it does, an inbox message silently renders as - /// plain text instead of markdown. - #[test] - fn a_recv_result_is_markdown_only_because_the_tool_use_was_seen_first() { - let mut ctx = ClassifyCtx::default(); - let body = "**bold** message from a peer"; - - // Without the correlation, it is an ordinary result. - let cold = classify_stream_value(&tool_result(body, false, "call-1"), &mut ctx); - assert_eq!(cold[0].body_format, None); - assert!(!cold[0].summary.starts_with("recv ←")); - - // Feed the assistant event that names the tool, then the same result. - classify_stream_value( - &json!({ - "type": "assistant", - "message": { "content": [{ - "type": "tool_use", "id": "call-1", - "name": "mcp__hyperhive__recv", "input": {}, - }]}, - }), - &mut ctx, - ); - let warm = classify_stream_value(&tool_result(body, false, "call-1"), &mut ctx); - assert!( - warm[0].summary.starts_with("recv ← "), - "{}", - warm[0].summary - ); - assert_eq!(warm[0].body_format, Some(BodyFormat::Markdown)); - assert_eq!(warm[0].body.as_deref(), Some(body)); - } - - #[test] - fn a_recv_result_with_no_content_stays_an_ordinary_row() { - let mut ctx = ClassifyCtx::default(); - ctx.record_tool_use("call-1", "mcp__hyperhive__recv"); - let m = one_with(&tool_result(" ", false, "call-1"), &mut ctx); - assert!(!m.summary.starts_with("recv ←")); - assert_eq!(m.summary, "(empty)"); - } - - fn one_with(v: &Value, ctx: &mut ClassifyCtx) -> TermMsg { - let rows = classify_stream_value(v, ctx); - assert_eq!(rows.len(), 1, "expected exactly one row for {v}"); - rows.into_iter().next().unwrap() - } - - // ----------------------------------------------------------------- - // assistant content - // ----------------------------------------------------------------- - - #[test] - fn assistant_text_becomes_a_markdown_body_with_no_summary() { - let m = one(&json!({ - "type": "assistant", - "message": { "content": [{ "type": "text", "text": "# hi" }]}, - })); - assert_eq!(m.summary, ""); - assert_eq!(m.body.as_deref(), Some("# hi")); - assert_eq!(m.body_format, Some(BodyFormat::Markdown)); - } - - #[test] - fn blank_assistant_text_produces_no_row_at_all() { - let mut ctx = ClassifyCtx::default(); - let rows = classify_stream_value( - &json!({ - "type": "assistant", - "message": { "content": [{ "type": "text", "text": " \n " }]}, - }), - &mut ctx, - ); - assert!(rows.is_empty()); - } - - #[test] - fn one_assistant_event_can_produce_several_rows() { - let mut ctx = ClassifyCtx::default(); - let rows = classify_stream_value( - &json!({ - "type": "assistant", - "message": { "content": [ - { "type": "text", "text": "doing it" }, - { "type": "thinking", "thinking": "hmm" }, - { "type": "tool_use", "id": "t1", "name": "Bash", - "input": { "command": "ls" } }, - { "type": "something_else" }, - ]}, - }), - &mut ctx, - ); - assert_eq!(rows.len(), 3, "the unknown content block is skipped"); - assert_eq!(rows[1].icon.as_deref(), Some("💭")); - // The tool_use was recorded for later correlation as a side effect. - assert_eq!(ctx.tool_name("t1"), Some("Bash")); - } - - // ----------------------------------------------------------------- - // string helpers - // ----------------------------------------------------------------- - - #[test] - fn token_counts_shorten_at_each_threshold() { - assert_eq!(fmt_tok(0), "0"); - assert_eq!(fmt_tok(999), "999"); - assert_eq!(fmt_tok(1_000), "1k"); - assert_eq!(fmt_tok(999_999), "999k"); - assert_eq!(fmt_tok(1_000_000), "1.0M"); - assert_eq!(fmt_tok(1_250_000), "1.2M"); - } - - #[test] - fn trim_str_collapses_whitespace_and_counts_characters() { - assert_eq!(trim_str("a b\n\tc", 99), "a b c"); - assert_eq!(trim_str("abcdef", 3), "abc…"); - // Exactly at the limit: no ellipsis. - assert_eq!(trim_str("abc", 3), "abc"); - // 4 chars / 8 bytes — a byte-based limit would cut this at 2. - assert_eq!(trim_str("éééé", 4), "éééé"); - } - - #[test] - fn matrix_ids_shorten_to_something_recognisable() { - assert_eq!(fmt_user("@mara:pr1ma.darkest.space"), "@mara"); - assert_eq!(fmt_user("@mara"), "@mara"); - assert_eq!(fmt_room("#hive-chat:pr1ma.darkest.space"), "#hive-chat"); - assert_eq!(fmt_room("!abcdefghijkl:server"), "!abcdefgh"); - assert_eq!(fmt_room("plain name"), "plain name"); - } - - /// A short room id keeps its whole local part and stops at the colon. - /// The non-ASCII case is the one that used to leak: `find(':')` is a - /// *byte* offset and it was being spent as a *character* budget, so a - /// multi-byte local part bought extra characters from the server half. - #[test] - fn a_room_id_never_shows_part_of_the_server() { - assert_eq!(fmt_room("!short:server"), "!short"); - assert_eq!(fmt_room("!ÄÖÜ:server"), "!ÄÖÜ"); - assert_eq!(fmt_room("!ÄÖÜ"), "!ÄÖÜ"); - } -} diff --git a/hive-forge/src/verbs/lint.rs b/hive-forge/src/verbs/lint.rs index 4df42ef3..86046482 100644 --- a/hive-forge/src/verbs/lint.rs +++ b/hive-forge/src/verbs/lint.rs @@ -527,171 +527,3 @@ where Ok(()) } } -#[cfg(test)] -mod tests { - use super::*; - - /// Every field on the forgejo structs is `Option`, so a fixture names - /// only what a case is about. Going through `from_value` rather than a - /// struct literal means these exercise the same deserialisation a real - /// API response does — a wire-shape change breaks the tests too. - fn issue(v: Value) -> Issue { - serde_json::from_value(v).expect("fixture is a valid Issue") - } - - fn pull(v: Value) -> PullRequest { - serde_json::from_value(v).expect("fixture is a valid PullRequest") - } - - /// `exclusive` is Forgejo's real scoped-label marker. A label that - /// merely *looks* scoped (a `/` in a plain label's name) must not - /// count, or `lint unlabeled` silently stops reporting an item that - /// genuinely has no `type/*`. - #[test] - fn only_an_exclusive_label_counts_as_scoped() { - let scoped = issue(json!({ "labels": [{ "name": "area/ops", "exclusive": true }] })); - assert!(has_scoped_label(&scoped, "area/")); - - let lookalike = issue(json!({ "labels": [{ "name": "area/ops", "exclusive": false }] })); - assert!( - !has_scoped_label(&lookalike, "area/"), - "a non-exclusive label with a slash in it is not a scoped label" - ); - - let unset = issue(json!({ "labels": [{ "name": "area/ops" }] })); - assert!( - !has_scoped_label(&unset, "area/"), - "absent means not exclusive" - ); - } - - /// The caller builds the prefix as `"/"`, which is what stops - /// `--scope area` from matching a label called `areaology`. - #[test] - fn the_scope_prefix_stops_at_the_slash() { - let other = issue(json!({ "labels": [{ "name": "areaology", "exclusive": true }] })); - assert!(!has_scoped_label(&other, "area/")); - - let wrong_scope = issue(json!({ "labels": [{ "name": "type/bug", "exclusive": true }] })); - assert!(!has_scoped_label(&wrong_scope, "area/")); - assert!(has_scoped_label(&wrong_scope, "type/")); - } - - #[test] - fn an_item_with_no_labels_has_no_scoped_label() { - assert!(!has_scoped_label(&issue(json!({ "labels": [] })), "area/")); - assert!(!has_scoped_label(&issue(json!({})), "area/")); - } - - /// `--reviewer NAME` and a bare `no-reviewer` are different questions: - /// "is X on it" versus "is anyone on it". - #[test] - fn requested_reviewer_asks_a_different_question_with_and_without_a_name() { - let pr = pull(json!({ "requested_reviewers": [{ "login": "argus" }] })); - assert!(has_requested_reviewer(&pr, None), "someone is requested"); - assert!(has_requested_reviewer(&pr, Some("argus"))); - assert!(!has_requested_reviewer(&pr, Some("iris"))); - - let bare = pull(json!({ "requested_reviewers": [] })); - assert!(!has_requested_reviewer(&bare, None)); - assert!(!has_requested_reviewer(&bare, Some("argus"))); - - // `null`, not an omitted key: `requested_reviewers` carries a - // `deserialize_with`, so serde requires it to be present even though - // its type is `Option`. The forge sends the key with a null value. - let absent = pull(json!({ "requested_reviewers": null })); - assert!( - !has_requested_reviewer(&absent, None), - "null is not a reviewer" - ); - } - - #[test] - fn logins_drops_users_that_have_none() { - let users: Vec = - serde_json::from_value(json!([{ "login": "atlas" }, {}, { "login": "mara" }])) - .expect("fixture"); - assert_eq!(logins(Some(&users)), vec!["atlas", "mara"]); - assert!(logins(None).is_empty()); - } - - /// Issues and PRs come back from the *same* endpoint; the only thing - /// telling them apart is whether `pull_request` is present. Getting - /// this wrong mislabels every row `lint` prints. - #[test] - fn is_pr_is_decided_by_the_pull_request_object() { - let plain = trim_issue(&issue(json!({ "number": 7, "title": "t" }))); - assert_eq!(plain["is_pr"], json!(false)); - assert_eq!(kind_label(&plain), "issue"); - - let pr = trim_issue(&issue(json!({ - "number": 8, "title": "t", "pull_request": { "merged": false }, - }))); - assert_eq!(pr["is_pr"], json!(true)); - assert_eq!(kind_label(&pr), "pr"); - } - - #[test] - fn trim_issue_keeps_the_fields_the_output_prints() { - let v = trim_issue(&issue(json!({ - "number": 42, - "title": "a title", - "assignees": [{ "login": "atlas" }, { "login": "argus" }], - }))); - assert_eq!(num(&v), 42); - assert_eq!(title(&v), "a title"); - assert_eq!(v["assignees"], json!(["atlas", "argus"])); - } - - #[test] - fn accessors_survive_a_row_that_is_missing_everything() { - let empty = json!({}); - assert_eq!(num(&empty), 0); - assert_eq!(title(&empty), ""); - assert_eq!(kind_label(&empty), "issue"); - } - - /// `All` is **absence** of the filter, not a third value — the forge - /// returns both kinds when `type` is omitted. Turning it into a value - /// would silently narrow every `--type all` query. - #[test] - fn the_both_kinds_filter_is_an_absent_query_param() { - assert!(Kind::All.query_type().is_none()); - assert!(matches!( - Kind::Issues.query_type(), - Some(IssueListIssuesQueryType::Issues) - )); - assert!(matches!( - Kind::Pulls.query_type(), - Some(IssueListIssuesQueryType::Pulls) - )); - } - - #[test] - fn state_maps_onto_both_query_enums() { - assert!(matches!( - State::Open.issue_state(), - IssueListIssuesQueryState::Open - )); - assert!(matches!( - State::Closed.issue_state(), - IssueListIssuesQueryState::Closed - )); - assert!(matches!( - State::All.issue_state(), - IssueListIssuesQueryState::All - )); - assert!(matches!( - State::Open.pull_state(), - RepoListPullRequestsQueryState::Open - )); - assert!(matches!( - State::Closed.pull_state(), - RepoListPullRequestsQueryState::Closed - )); - assert!(matches!( - State::All.pull_state(), - RepoListPullRequestsQueryState::All - )); - } -} diff --git a/scripts/check-doc-refs.sh b/scripts/check-doc-refs.sh index 24894438..b4ead89e 100755 --- a/scripts/check-doc-refs.sh +++ b/scripts/check-doc-refs.sh @@ -60,26 +60,6 @@ while IFS=: read -r file lineno match; do [ -n "${match:-}" ] || continue exempt "$file" "$lineno" && continue link="${match#](}" - # A `.rs` doc comment has a second renderer, and it does not count `../` - # the way the rest of this arm assumes. rustdoc emits the href verbatim - # onto a page at `target/doc///`, so the link resolves - # against *that* directory, not the source file's. Resolving from the - # source therefore certifies links that render broken — a false pass, not - # a miss, and worse than no check. - # - # Refused rather than resolved rustdoc-aware: the repo has zero relative - # links in `.rs` doc comments (measured on this commit), so nothing needs - # the second reader, and the backticked repo-root form the root arm above - # already gates works under both. Inferring module nesting would be - # machinery in service of a form nothing uses. - case "$file" in - *.rs) - dead=$((dead + 1)) - printf '::error file=%s,line=%s::relative markdown link in a rust doc comment renders against the rustdoc output directory, not this file; use a backticked repo-root pointer instead: %s\n' \ - "$file" "$lineno" "$link" - continue - ;; - esac # Resolved from the linking file's directory, so `../` counts the same way # the renderer counts it. `-m` keeps a link that escapes the repo root # resolvable enough to report rather than erroring out here.