diff --git a/Cargo.lock b/Cargo.lock index 6994c9b01..f16354771 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6143,8 +6143,10 @@ dependencies = [ "okena-git", "okena-hooks", "okena-remote-server", + "okena-review", "okena-services", "okena-state", + "okena-syntax", "okena-terminal", "okena-theme", "okena-workspace", @@ -6401,6 +6403,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "okena-review" +version = "0.1.0" +dependencies = [ + "okena-core", + "okena-syntax", + "serde", + "serde_json", +] + [[package]] name = "okena-services" version = "0.1.0" @@ -6431,6 +6443,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "okena-syntax" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tree-sitter", + "tree-sitter-rust", + "tree-sitter-typescript", +] + [[package]] name = "okena-terminal" version = "0.1.0" @@ -6539,6 +6562,8 @@ dependencies = [ "okena-extensions", "okena-files", "okena-git", + "okena-review", + "okena-syntax", "okena-transport", "okena-ui", "okena-workspace", @@ -9656,6 +9681,26 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "tree-sitter-rust" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "triomphe" version = "0.1.15" diff --git a/Cargo.toml b/Cargo.toml index dbcb2fa06..5bd1676fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = [".", "crates/okena-mobile-ffi", "crates/okena-core", "crates/okena-transport", "crates/okena-git", "crates/okena-views-git", "crates/okena-views-services", "crates/okena-views-sidebar", "crates/okena-views-terminal", "crates/okena-terminal", "crates/okena-layout", "crates/okena-state", "crates/okena-hooks", "crates/okena-workspace", "crates/okena-ui", "crates/okena-usage", "crates/okena-files", "crates/okena-markdown", "crates/okena-extensions", "crates/okena-ext-claude", "crates/okena-ext-codex", "crates/okena-ext-github", "crates/okena-ext-updater", "crates/okena-services", "crates/okena-remote-client", "crates/okena-remote-server", "crates/okena-views-remote", "crates/okena-theme", "crates/okena-cli", "crates/okena-app-core", "crates/okena-app", "crates/okena-daemon-core", "crates/okena-daemon", "crates/okena-tui"] +members = [".", "crates/okena-mobile-ffi", "crates/okena-core", "crates/okena-transport", "crates/okena-git", "crates/okena-syntax", "crates/okena-review", "crates/okena-views-git", "crates/okena-views-services", "crates/okena-views-sidebar", "crates/okena-views-terminal", "crates/okena-terminal", "crates/okena-layout", "crates/okena-state", "crates/okena-hooks", "crates/okena-workspace", "crates/okena-ui", "crates/okena-usage", "crates/okena-files", "crates/okena-markdown", "crates/okena-extensions", "crates/okena-ext-claude", "crates/okena-ext-codex", "crates/okena-ext-github", "crates/okena-ext-updater", "crates/okena-services", "crates/okena-remote-client", "crates/okena-remote-server", "crates/okena-views-remote", "crates/okena-theme", "crates/okena-cli", "crates/okena-app-core", "crates/okena-app", "crates/okena-daemon-core", "crates/okena-daemon", "crates/okena-tui"] resolver = "2" [workspace.package] diff --git a/crates/okena-app-core/src/workspace/actions/execute/mod.rs b/crates/okena-app-core/src/workspace/actions/execute/mod.rs index fa5968597..a04fe8e2b 100644 --- a/crates/okena-app-core/src/workspace/actions/execute/mod.rs +++ b/crates/okena-app-core/src/workspace/actions/execute/mod.rs @@ -299,6 +299,12 @@ pub fn execute_action( mode, ignore_whitespace, } => git::diff(ws, project_id, mode, ignore_whitespace), + ActionRequest::ReviewInventory { .. } + | ActionRequest::ReviewDiff { .. } + | ActionRequest::ReviewSource { .. } + | ActionRequest::ReviewStructure { .. } => ActionResult::Err( + "internal error: review actions require the daemon executor".to_string(), + ), ActionRequest::GitBranches { project_id } => git::branches(ws, project_id), ActionRequest::GitListPullRequests { project_id, limit } => { git::list_pull_requests(ws, project_id, limit) diff --git a/crates/okena-app/src/action_dispatch.rs b/crates/okena-app/src/action_dispatch.rs index 36472deec..899457ac1 100644 --- a/crates/okena-app/src/action_dispatch.rs +++ b/crates/okena-app/src/action_dispatch.rs @@ -760,6 +760,31 @@ fn strip_remote_ids(action: ActionRequest, connection_id: &str) -> ActionRequest mode, ignore_whitespace, }, + ActionRequest::ReviewInventory { project_id, mode } => ActionRequest::ReviewInventory { + project_id: s(&project_id), + mode, + }, + ActionRequest::ReviewDiff { + project_id, + request, + } => ActionRequest::ReviewDiff { + project_id: s(&project_id), + request, + }, + ActionRequest::ReviewSource { + project_id, + request, + } => ActionRequest::ReviewSource { + project_id: s(&project_id), + request, + }, + ActionRequest::ReviewStructure { + project_id, + request, + } => ActionRequest::ReviewStructure { + project_id: s(&project_id), + request, + }, ActionRequest::GitBranches { project_id } => ActionRequest::GitBranches { project_id: s(&project_id), }, @@ -1255,6 +1280,42 @@ mod tests { use super::*; use crate::workspace::state::SplitDirection; + fn review_diff_request() -> okena_core::review::ReviewDiffRequest { + let requested_base = "1".repeat(40); + let merge_base = "2".repeat(40); + let head = "3".repeat(40); + let identity = format!("branch:merge-base:{requested_base}:{head}:{merge_base}"); + serde_json::from_value(serde_json::json!({ + "comparison": { + "requested": { + "branch_compare": { + "base": "origin/main", + "head": "feature/review" + } + }, + "requested_base_oid": requested_base, + "requested_head_oid": head, + "strategy": "merge_base_to_head", + "base": { "kind": "commit", "oid": merge_base }, + "head": { "kind": "commit", "oid": head }, + "merge_base_oid": merge_base, + "identity": identity + }, + "ignore_whitespace": false + })) + .unwrap() + } + + fn review_source_request() -> okena_core::review::ReviewSourceRequest { + let comparison = review_diff_request().comparison; + okena_core::review::ReviewSourceRequest::new( + comparison.into_resolved(), + Some("src/old.rs".to_string()), + Some("src/new.rs".to_string()), + ) + .unwrap() + } + #[test] fn rows_map_visual_split_axis_back_to_canonical_axis() { let action = canonicalize_layout_action( @@ -1305,4 +1366,41 @@ mod tests { "worktree (feature/fallback)" ); } + + #[test] + fn review_actions_strip_only_the_remote_project_prefix() { + let project_id = "remote:connection-1:project-1".to_string(); + let request = review_diff_request(); + let source_request = review_source_request(); + let actions = [ + ActionRequest::ReviewInventory { + project_id: project_id.clone(), + mode: okena_core::types::DiffMode::BranchCompare { + base: "origin/main".to_string(), + head: "feature/review".to_string(), + }, + }, + ActionRequest::ReviewDiff { + project_id: project_id.clone(), + request: request.clone(), + }, + ActionRequest::ReviewSource { + project_id: project_id.clone(), + request: Box::new(source_request), + }, + ActionRequest::ReviewStructure { + project_id, + request, + }, + ]; + + for action in actions { + let original = serde_json::to_value(&action).unwrap(); + let value = serde_json::to_value(strip_remote_ids(action, "connection-1")).unwrap(); + assert_eq!(value["project_id"], "project-1"); + if original.get("request").is_some() { + assert_eq!(value["request"], original["request"]); + } + } + } } diff --git a/crates/okena-app/src/views/overlay_manager.rs b/crates/okena-app/src/views/overlay_manager.rs index edc348e38..769afbf67 100644 --- a/crates/okena-app/src/views/overlay_manager.rs +++ b/crates/okena-app/src/views/overlay_manager.rs @@ -745,7 +745,7 @@ impl OverlayManager { this.close_modal(cx); } SessionManagerEvent::Action(action) => { - cx.emit(OverlayManagerEvent::SessionAction(action.clone())); + cx.emit(OverlayManagerEvent::SessionAction(action.as_ref().clone())); // Load/import close the manager (state swaps); save/export // are quick fire-and-forget — close in all cases. this.close_modal(cx); diff --git a/crates/okena-app/src/views/overlays/session_manager/actions.rs b/crates/okena-app/src/views/overlays/session_manager/actions.rs index b25891445..44f6d0f6e 100644 --- a/crates/okena-app/src/views/overlays/session_manager/actions.rs +++ b/crates/okena-app/src/views/overlays/session_manager/actions.rs @@ -58,9 +58,9 @@ impl SessionManager { // The daemon owns the authoritative workspace (local ids) + session // files; saving from the client mirror would persist prefixed-id garbage. // Dispatch SaveSession and let the daemon write its own data. - cx.emit(SessionManagerEvent::Action(ActionRequest::SaveSession { - name, - })); + cx.emit(SessionManagerEvent::Action(Box::new( + ActionRequest::SaveSession { name }, + ))); self.new_session_input.update(cx, |input, cx| { input.set_value("", cx); }); @@ -71,9 +71,11 @@ impl SessionManager { pub(super) fn load_session(&mut self, name: &str, cx: &mut Context) { // The daemon loads its own session file + swaps state; the new workspace // mirrors back via snapshot. - cx.emit(SessionManagerEvent::Action(ActionRequest::LoadSession { - name: name.to_string(), - })); + cx.emit(SessionManagerEvent::Action(Box::new( + ActionRequest::LoadSession { + name: name.to_string(), + }, + ))); self.error_message = None; } @@ -194,9 +196,9 @@ impl SessionManager { } // Export the DAEMON's authoritative workspace (not the client mirror). - cx.emit(SessionManagerEvent::Action( + cx.emit(SessionManagerEvent::Action(Box::new( ActionRequest::ExportWorkspace { path }, - )); + ))); self.error_message = None; cx.notify(); } @@ -210,9 +212,9 @@ impl SessionManager { } // The daemon imports the file + swaps state; the result mirrors back. - cx.emit(SessionManagerEvent::Action( + cx.emit(SessionManagerEvent::Action(Box::new( ActionRequest::ImportWorkspace { path }, - )); + ))); self.error_message = None; cx.notify(); } diff --git a/crates/okena-app/src/views/overlays/session_manager/mod.rs b/crates/okena-app/src/views/overlays/session_manager/mod.rs index 9f632fe35..9f0f7627f 100644 --- a/crates/okena-app/src/views/overlays/session_manager/mod.rs +++ b/crates/okena-app/src/views/overlays/session_manager/mod.rs @@ -87,7 +87,7 @@ pub enum SessionManagerEvent { /// A ready-to-dispatch session/workspace action for the host to route to the /// local daemon (load/save/import/export). The daemon owns session files and /// the authoritative workspace, so these never touch the client's mirror. - Action(okena_core::api::ActionRequest), + Action(Box), } impl EventEmitter for SessionManager {} diff --git a/crates/okena-core/src/api.rs b/crates/okena-core/src/api.rs index 1a9039115..a055f753a 100644 --- a/crates/okena-core/src/api.rs +++ b/crates/okena-core/src/api.rs @@ -1,4 +1,5 @@ use crate::keys::SpecialKey; +use crate::review::{ReviewDiffRequest, ReviewSourceRequest}; use crate::shell::ShellType; use crate::theme::FolderColor; use crate::types::{DiffMode, SplitDirection}; @@ -760,6 +761,23 @@ pub enum ActionRequest { #[serde(default)] ignore_whitespace: bool, }, + ReviewInventory { + project_id: String, + mode: DiffMode, + }, + ReviewDiff { + project_id: String, + request: ReviewDiffRequest, + }, + ReviewSource { + project_id: String, + // Boxed to keep the exact source paths from inflating every action. + request: Box, + }, + ReviewStructure { + project_id: String, + request: ReviewDiffRequest, + }, GitBranches { project_id: String, }, @@ -1285,6 +1303,163 @@ impl ApiLayoutNode { #[cfg(test)] mod tests { use super::*; + use serde_json::{Value, json}; + + fn review_comparison_json() -> Value { + let requested_base = "1".repeat(40); + let merge_base = "2".repeat(40); + let head = "3".repeat(40); + let identity = format!("branch:merge-base:{requested_base}:{head}:{merge_base}"); + json!({ + "requested": { + "branch_compare": { + "base": "origin/main", + "head": "feature/review" + } + }, + "requested_base_oid": requested_base, + "requested_head_oid": head, + "strategy": "merge_base_to_head", + "base": { "kind": "commit", "oid": merge_base }, + "head": { "kind": "commit", "oid": head }, + "merge_base_oid": merge_base, + "identity": identity + }) + } + + fn review_diff_request() -> ReviewDiffRequest { + serde_json::from_value(json!({ + "comparison": review_comparison_json(), + "ignore_whitespace": false + })) + .unwrap() + } + + fn review_source_request() -> ReviewSourceRequest { + serde_json::from_value(json!({ + "comparison": review_comparison_json(), + "old_path": "src/old.rs", + "new_path": "src/new.rs" + })) + .unwrap() + } + + #[test] + fn review_actions_have_stable_json_shapes() { + let inventory = ActionRequest::ReviewInventory { + project_id: "project-1".to_string(), + mode: DiffMode::BranchCompare { + base: "origin/main".to_string(), + head: "feature/review".to_string(), + }, + }; + assert_eq!( + serde_json::to_value(inventory).unwrap(), + json!({ + "action": "review_inventory", + "project_id": "project-1", + "mode": { + "branch_compare": { + "base": "origin/main", + "head": "feature/review" + } + } + }) + ); + + for (action, name) in [ + ( + ActionRequest::ReviewDiff { + project_id: "project-1".to_string(), + request: review_diff_request(), + }, + "review_diff", + ), + ( + ActionRequest::ReviewStructure { + project_id: "project-1".to_string(), + request: review_diff_request(), + }, + "review_structure", + ), + ] { + let expected = json!({ + "action": name, + "project_id": "project-1", + "request": { + "comparison": review_comparison_json(), + "ignore_whitespace": false + } + }); + let value = serde_json::to_value(&action).unwrap(); + assert_eq!(value, expected); + serde_json::from_value::(value).unwrap(); + } + + let source = ActionRequest::ReviewSource { + project_id: "project-1".to_string(), + request: Box::new(review_source_request()), + }; + let source_json = json!({ + "action": "review_source", + "project_id": "project-1", + "request": { + "comparison": review_comparison_json(), + "old_path": "src/old.rs", + "new_path": "src/new.rs" + } + }); + assert_eq!(serde_json::to_value(&source).unwrap(), source_json); + serde_json::from_value::(source_json).unwrap(); + } + + #[test] + fn review_actions_reject_mutable_and_malformed_comparisons() { + let mutable = json!({ + "action": "review_diff", + "project_id": "project-1", + "request": { + "comparison": { + "requested": "staged", + "requested_base_oid": "1".repeat(40), + "strategy": "head_to_index", + "base": { "kind": "commit", "oid": "1".repeat(40) }, + "head": { "kind": "index", "fingerprint": "index-v1" }, + "identity": "staged:index-v1" + }, + "ignore_whitespace": false + } + }); + assert!(serde_json::from_value::(mutable).is_err()); + + let mutable_source = json!({ + "action": "review_source", + "project_id": "project-1", + "request": { + "comparison": { + "requested": "staged", + "requested_base_oid": "1".repeat(40), + "strategy": "head_to_index", + "base": { "kind": "commit", "oid": "1".repeat(40) }, + "head": { "kind": "index", "fingerprint": "index-v1" }, + "identity": "staged:index-v1" + }, + "new_path": "src/new.rs" + } + }); + assert!(serde_json::from_value::(mutable_source).is_err()); + + let mut malformed = json!({ + "action": "review_structure", + "project_id": "project-1", + "request": { + "comparison": review_comparison_json(), + "ignore_whitespace": false + } + }); + malformed["request"]["comparison"]["requested_head_oid"] = json!("abcdef0"); + assert!(serde_json::from_value::(malformed).is_err()); + } #[test] fn state_response_round_trip() { @@ -1633,6 +1808,25 @@ mod tests { mode: DiffMode::WorkingTree, ignore_whitespace: false, }, + ActionRequest::ReviewInventory { + project_id: "p1".into(), + mode: DiffMode::BranchCompare { + base: "origin/main".into(), + head: "feature/review".into(), + }, + }, + ActionRequest::ReviewDiff { + project_id: "p1".into(), + request: review_diff_request(), + }, + ActionRequest::ReviewSource { + project_id: "p1".into(), + request: Box::new(review_source_request()), + }, + ActionRequest::ReviewStructure { + project_id: "p1".into(), + request: review_diff_request(), + }, ActionRequest::GitBranches { project_id: "p1".into(), }, diff --git a/crates/okena-core/src/lib.rs b/crates/okena-core/src/lib.rs index d9023bc3c..f0f4f3d4a 100644 --- a/crates/okena-core/src/lib.rs +++ b/crates/okena-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod latency_probe; pub mod process; pub mod profiles; pub mod render_probe; +pub mod review; pub mod selection; pub mod send_payload; pub mod shell; diff --git a/crates/okena-core/src/process/bus.rs b/crates/okena-core/src/process/bus.rs index 382db4911..e2a0fd7b4 100644 --- a/crates/okena-core/src/process/bus.rs +++ b/crates/okena-core/src/process/bus.rs @@ -25,6 +25,9 @@ //! for the same permits. use std::collections::{HashMap, VecDeque}; +use std::fmt; +use std::io::Read; +use std::num::NonZeroU64; use std::path::PathBuf; use std::process::Output; use std::sync::atomic::{AtomicBool, Ordering}; @@ -48,7 +51,7 @@ pub enum Lane { } impl Lane { - fn workers(self) -> usize { + pub(super) fn workers(self) -> usize { match self { // Sum across lanes (4 + 4 + 2 = 10) is the effective global cap on // concurrent child processes — comfortably under every platform's @@ -103,6 +106,192 @@ pub fn current_lane() -> Lane { CURRENT_LANE.with(|c| c.get()) } +/// Fixed-width capture limits for one command's stdout and stderr streams. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutputLimits { + stdout_bytes: NonZeroU64, + stderr_bytes: NonZeroU64, +} + +impl OutputLimits { + pub fn new(stdout_bytes: NonZeroU64, stderr_bytes: NonZeroU64) -> Self { + Self { + stdout_bytes, + stderr_bytes, + } + } + + pub fn stdout_bytes(self) -> NonZeroU64 { + self.stdout_bytes + } + + pub fn stderr_bytes(self) -> NonZeroU64 { + self.stderr_bytes + } +} + +/// Stage at which command execution or cleanup failed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandOperation { + Spawn, + SpawnStdoutReader, + SpawnStderrReader, + Poll, + TerminateTree, + KillChild, + WaitChild, + ReadStdout, + ReadStderr, + JoinStdoutReader, + JoinStderrReader, + ComputeDeadline, + Mock, + Worker, +} + +impl fmt::Display for CommandOperation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + Self::Spawn => "spawn", + Self::SpawnStdoutReader => "spawn stdout reader", + Self::SpawnStderrReader => "spawn stderr reader", + Self::Poll => "poll", + Self::TerminateTree => "terminate process tree", + Self::KillChild => "kill child", + Self::WaitChild => "wait for child", + Self::ReadStdout => "read stdout", + Self::ReadStderr => "read stderr", + Self::JoinStdoutReader => "join stdout reader", + Self::JoinStderrReader => "join stderr reader", + Self::ComputeDeadline => "compute deadline", + Self::Mock => "mock command", + Self::Worker => "command bus worker", + }; + formatter.write_str(name) + } +} + +/// The first condition that prevented a command from completing normally. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandFailureCause { + Cancelled { + at: Instant, + }, + DeadlineExceeded { + deadline: Instant, + }, + StdoutLimitExceeded { + at: Instant, + limit: u64, + observed: u64, + }, + StderrLimitExceeded { + at: Instant, + limit: u64, + observed: u64, + }, + Process { + at: Instant, + operation: CommandOperation, + kind: std::io::ErrorKind, + message: String, + }, +} + +impl CommandFailureCause { + fn at(&self) -> Instant { + match self { + Self::Cancelled { at } + | Self::StdoutLimitExceeded { at, .. } + | Self::StderrLimitExceeded { at, .. } + | Self::Process { at, .. } => *at, + Self::DeadlineExceeded { deadline } => *deadline, + } + } + + fn tie_priority(&self) -> u8 { + match self { + Self::DeadlineExceeded { .. } => 0, + Self::Cancelled { .. } => 1, + Self::StdoutLimitExceeded { .. } => 2, + Self::StderrLimitExceeded { .. } => 3, + Self::Process { .. } => 4, + } + } + + fn precedes(&self, current: &Self) -> bool { + (self.at(), self.tie_priority()) < (current.at(), current.tie_priority()) + } + + fn io_kind(&self) -> std::io::ErrorKind { + match self { + Self::Cancelled { .. } => std::io::ErrorKind::Interrupted, + Self::DeadlineExceeded { .. } => std::io::ErrorKind::TimedOut, + Self::StdoutLimitExceeded { .. } | Self::StderrLimitExceeded { .. } => { + std::io::ErrorKind::Other + } + Self::Process { kind, .. } => *kind, + } + } +} + +impl fmt::Display for CommandFailureCause { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Cancelled { .. } => formatter.write_str("command cancelled"), + Self::DeadlineExceeded { .. } => formatter.write_str("process timed out"), + Self::StdoutLimitExceeded { + limit, observed, .. + } => write!( + formatter, + "stdout exceeded {limit} bytes (observed at least {observed})" + ), + Self::StderrLimitExceeded { + limit, observed, .. + } => write!( + formatter, + "stderr exceeded {limit} bytes (observed at least {observed})" + ), + Self::Process { + operation, message, .. + } => write!(formatter, "{operation}: {message}"), + } + } +} + +/// A secondary failure observed while terminating, reaping, or draining a command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandCleanupFailure { + pub operation: CommandOperation, + pub kind: std::io::ErrorKind, + pub message: String, +} + +/// Detailed command failure with a stable primary cause and cleanup evidence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandFailure { + pub primary: CommandFailureCause, + pub cleanup: Vec, +} + +impl CommandFailure { + fn into_io_error(self) -> std::io::Error { + std::io::Error::new(self.primary.io_kind(), self) + } +} + +impl fmt::Display for CommandFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}", self.primary)?; + if !self.cleanup.is_empty() { + write!(formatter, " ({} cleanup failure(s))", self.cleanup.len())?; + } + Ok(()) + } +} + +impl std::error::Error for CommandFailure {} + /// A fully-described one-shot command. Built directly, or extracted from a /// configured [`std::process::Command`] via [`CommandSpec::from_command`]. #[derive(Debug, Clone)] @@ -112,6 +301,10 @@ pub struct CommandSpec { pub cwd: Option, pub env: Vec<(String, String)>, pub timeout: Option, + /// Optional request-wide absolute deadline. Unlike `timeout`, queue time is included. + pub deadline: Option, + /// Optional independent stdout/stderr capture limits. + pub output_limits: Option, pub lane: Lane, /// Stable short label for the audit log (e.g. `"git.worktree.list"`). Falls /// back to the program name when `None`. @@ -130,6 +323,8 @@ impl CommandSpec { cwd: None, env: Vec::new(), timeout: None, + deadline: None, + output_limits: None, lane: current_lane(), label: None, scope: None, @@ -165,6 +360,16 @@ impl CommandSpec { self } + pub fn deadline(mut self, deadline: Instant) -> Self { + self.deadline = Some(deadline); + self + } + + pub fn output_limits(mut self, limits: OutputLimits) -> Self { + self.output_limits = Some(limits); + self + } + pub fn lane(mut self, lane: Lane) -> Self { self.lane = lane; self @@ -213,6 +418,8 @@ impl CommandSpec { cwd, env, timeout: None, + deadline: None, + output_limits: None, lane: current_lane(), label: None, scope: None, @@ -253,11 +460,18 @@ impl CommandSpec { } } -/// Per-job shared control block. Lets the submitter (and a scope-wide cancel) -/// signal cancellation, and lets the running worker register the live child so -/// it can be killed mid-flight. +#[derive(Default)] +struct FailureState { + primary: Option, + cleanup: Vec, + completion_accepted: bool, + finalized: bool, +} + +/// Per-job shared control block. It latches the actual first stop condition and +/// owns the live process-tree registration used by every cancellation source. struct JobControl { - cancelled: AtomicBool, + failure: Mutex, /// Set by the worker once the process tree is owned; taken to kill it. kill: Mutex>, } @@ -268,8 +482,8 @@ struct KillHandle { } impl KillHandle { - fn kill(&self) { - self.tree.terminate(); + fn kill(&self) -> std::io::Result<()> { + self.tree.terminate() } } @@ -277,10 +491,17 @@ impl KillHandle { /// identifier or job handle can become stale. struct KillRegistration<'a> { control: &'a JobControl, + tree: Arc, } impl Drop for KillRegistration<'_> { fn drop(&mut self) { + if std::thread::panicking() + && let Err(error) = self.tree.terminate() + { + self.control + .append_cleanup(CommandOperation::TerminateTree, error); + } let mut guard = self.control.kill.lock().unwrap_or_else(|e| e.into_inner()); *guard = None; } @@ -289,52 +510,289 @@ impl Drop for KillRegistration<'_> { impl JobControl { fn new() -> Arc { Arc::new(Self { - cancelled: AtomicBool::new(false), + failure: Mutex::new(FailureState::default()), kill: Mutex::new(None), }) } fn cancel(&self) { - self.cancelled.store(true, Ordering::SeqCst); - if let Ok(guard) = self.kill.lock() - && let Some(handle) = guard.as_ref() + let should_terminate = { + let mut state = self + .failure + .lock() + .unwrap_or_else(|error| error.into_inner()); + if state.completion_accepted || state.finalized { + false + } else { + let cause = CommandFailureCause::Cancelled { at: Instant::now() }; + Self::insert_cause(&mut state, cause); + true + } + }; + if should_terminate { + self.terminate_registered(); + } + } + + fn register(&self, tree: Arc) -> KillRegistration<'_> { + { + let mut guard = self.kill.lock().unwrap_or_else(|error| error.into_inner()); + *guard = Some(KillHandle { tree: tree.clone() }); + } + // A pre-publication stop is visible here; a later stop sees the handle. + if self.has_failure() + && let Err(error) = tree.terminate() { - handle.kill(); + self.record_cleanup(CommandOperation::TerminateTree, error); + } + KillRegistration { + control: self, + tree, } } - fn is_cancelled(&self) -> bool { - self.cancelled.load(Ordering::SeqCst) + fn has_failure(&self) -> bool { + self.failure + .lock() + .unwrap_or_else(|error| error.into_inner()) + .primary + .is_some() } - fn register(&self, tree: Arc) -> KillRegistration<'_> { - let mut guard = self.kill.lock().unwrap_or_else(|e| e.into_inner()); - *guard = Some(KillHandle { tree }); - KillRegistration { control: self } + fn record_cause(&self, cause: CommandFailureCause, terminate: bool) { + let accepted = { + let mut state = self + .failure + .lock() + .unwrap_or_else(|error| error.into_inner()); + if state.finalized + || state.completion_accepted + && matches!(cause, CommandFailureCause::Cancelled { .. }) + { + false + } else { + Self::insert_cause(&mut state, cause); + true + } + }; + if terminate && accepted { + self.terminate_registered(); + } + } + + fn insert_cause(state: &mut FailureState, cause: CommandFailureCause) { + match state.primary.as_ref() { + Some(current) if !cause.precedes(current) => { + if let CommandFailureCause::Process { + operation, + kind, + message, + .. + } = cause + { + state.cleanup.push(CommandCleanupFailure { + operation, + kind, + message, + }); + } + } + Some(_) => { + let previous = state.primary.replace(cause); + if let Some(CommandFailureCause::Process { + operation, + kind, + message, + .. + }) = previous + { + state.cleanup.push(CommandCleanupFailure { + operation, + kind, + message, + }); + } + } + None => state.primary = Some(cause), + } + } + + fn terminate_registered(&self) { + let result = self + .kill + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + .map(KillHandle::kill); + if let Some(Err(error)) = result { + self.record_cleanup(CommandOperation::TerminateTree, error); + } + } + + fn record_io(&self, operation: CommandOperation, error: std::io::Error, terminate: bool) { + self.record_cause( + CommandFailureCause::Process { + at: Instant::now(), + operation, + kind: error.kind(), + message: error.to_string(), + }, + terminate, + ); + } + + fn record_cleanup(&self, operation: CommandOperation, error: std::io::Error) { + let mut state = self + .failure + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.primary.is_none() { + state.primary = Some(CommandFailureCause::Process { + at: Instant::now(), + operation, + kind: error.kind(), + message: error.to_string(), + }); + } else { + state.cleanup.push(CommandCleanupFailure { + operation, + kind: error.kind(), + message: error.to_string(), + }); + } + } + + fn append_cleanup(&self, operation: CommandOperation, error: std::io::Error) { + self.failure + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .cleanup + .push(CommandCleanupFailure { + operation, + kind: error.kind(), + message: error.to_string(), + }); + } + + fn latch_deadline(&self, deadline: Option, now: Instant) { + if let Some(deadline) = deadline + && now >= deadline + { + self.record_cause(CommandFailureCause::DeadlineExceeded { deadline }, true); + } + } + + fn accept_completion(&self, deadline: Option, observed_at: Instant) -> bool { + let mut state = self + .failure + .lock() + .unwrap_or_else(|error| error.into_inner()); + if let Some(deadline) = deadline + && observed_at >= deadline + { + Self::insert_cause( + &mut state, + CommandFailureCause::DeadlineExceeded { deadline }, + ); + } + if state.primary.is_some() { + return false; + } + state.completion_accepted = true; + true + } + + fn finalize_success( + &self, + deadline: Option, + observed_at: Instant, + ) -> Result<(), CommandFailure> { + let mut state = self + .failure + .lock() + .unwrap_or_else(|error| error.into_inner()); + if let Some(deadline) = deadline + && observed_at >= deadline + { + Self::insert_cause( + &mut state, + CommandFailureCause::DeadlineExceeded { deadline }, + ); + } + if let Some(primary) = state.primary.clone() { + return Err(CommandFailure { + primary, + cleanup: state.cleanup.clone(), + }); + } + state.completion_accepted = true; + state.finalized = true; + Ok(()) + } + + fn failure(&self) -> Option { + let state = self + .failure + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.primary.clone().map(|primary| CommandFailure { + primary, + cleanup: state.cleanup.clone(), + }) } } struct Job { spec: CommandSpec, ctl: Arc, - result_tx: SyncSender>, + result_tx: SyncSender>, } /// Handle to a submitted command. Block on [`wait`](Self::wait) to get the /// output, or [`cancel`](Self::cancel) to kill it. pub struct CommandHandle { - rx: Receiver>, + rx: Receiver>, + ctl: Arc, +} + +/// Cloneable cancellation capability for one submitted command. +#[derive(Clone)] +pub struct CommandCancellationHandle { ctl: Arc, } +impl fmt::Debug for CommandCancellationHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CommandCancellationHandle") + } +} + +impl CommandCancellationHandle { + pub fn cancel(&self) { + self.ctl.cancel(); + } +} + impl CommandHandle { /// Block until the command finishes, returning its captured output. Returns /// an `Other` error if the bus worker died, or `Interrupted` if cancelled. pub fn wait(self) -> std::io::Result { - match self.rx.recv() { - Ok(result) => result, - Err(_) => Err(std::io::Error::other("command bus worker dropped result")), - } + self.wait_detailed().map_err(CommandFailure::into_io_error) + } + + /// Block until completion while preserving the typed primary and cleanup causes. + pub fn wait_detailed(self) -> Result { + self.rx.recv().unwrap_or_else(|_| { + Err(CommandFailure { + primary: CommandFailureCause::Process { + at: Instant::now(), + operation: CommandOperation::Worker, + kind: std::io::ErrorKind::Other, + message: "command bus worker dropped result".to_string(), + }, + cleanup: Vec::new(), + }) + }) } /// Request cancellation: kills the child if it is already running, or @@ -342,6 +800,13 @@ impl CommandHandle { pub fn cancel(&self) { self.ctl.cancel(); } + + /// Obtain a cloneable cancellation capability without moving the result receiver. + pub fn cancellation_handle(&self) -> CommandCancellationHandle { + CommandCancellationHandle { + ctl: self.ctl.clone(), + } + } } /// FIFO work queue shared by one lane's workers. @@ -444,7 +909,16 @@ impl CommandBus { if let Ok(guard) = self.mock.lock() && let Some(mock) = guard.as_ref() { - let _ = tx.send(mock(&spec)); + let result = mock(&spec).map_err(|error| CommandFailure { + primary: CommandFailureCause::Process { + at: Instant::now(), + operation: CommandOperation::Mock, + kind: error.kind(), + message: error.to_string(), + }, + cleanup: Vec::new(), + }); + let _ = tx.send(result); return CommandHandle { rx, ctl }; } @@ -502,13 +976,26 @@ const POLL_MAX: Duration = Duration::from_millis(20); /// Give reader threads a brief chance to collect bytes already in the pipes /// before treating open pipe handles as descendants left behind by the parent. const POST_EXIT_DRAIN: Duration = Duration::from_millis(100); +/// Never let an inherited pipe held by an escaped descendant pin a bus lane. +const READER_JOIN_TIMEOUT: Duration = Duration::from_millis(250); -fn run_job(spec: &CommandSpec, ctl: &Arc) -> std::io::Result { - // Cancelled before we even started. - if ctl.is_cancelled() { - return Err(cancelled_err()); +fn run_job(spec: &CommandSpec, ctl: &Arc) -> Result { + ctl.latch_deadline(spec.deadline, Instant::now()); + if let Some(failure) = ctl.failure() { + return Err(failure); + } + if let Err(error) = validate_relative_timeout(spec.timeout, Instant::now()) { + ctl.record_io(CommandOperation::ComputeDeadline, error, false); + return Err(ctl.failure().unwrap_or_else(|| CommandFailure { + primary: CommandFailureCause::Process { + at: Instant::now(), + operation: CommandOperation::ComputeDeadline, + kind: std::io::ErrorKind::InvalidInput, + message: "relative command timeout validation failed".to_string(), + }, + cleanup: Vec::new(), + })); } - let started = Instant::now(); let detail = spec.audit_detail(); log::trace!(target: "okena::cmd", "[{}] start {}", spec.lane.name(), detail); @@ -519,9 +1006,22 @@ fn run_job(spec: &CommandSpec, ctl: &Arc) -> std::io::Result spawn_and_collect(spec, ctl) })) .unwrap_or_else(|panic| { - let msg = panic_message(&panic); - log::error!(target: "okena::cmd", "[{}] {} panicked: {msg}", spec.lane.name(), detail); - Err(std::io::Error::other(format!("command panicked: {msg}"))) + let message = panic_message(&panic); + log::error!(target: "okena::cmd", "[{}] {} panicked: {message}", spec.lane.name(), detail); + ctl.record_io( + CommandOperation::Worker, + std::io::Error::other(format!("command panicked: {message}")), + true, + ); + Err(ctl.failure().unwrap_or_else(|| CommandFailure { + primary: CommandFailureCause::Process { + at: Instant::now(), + operation: CommandOperation::Worker, + kind: std::io::ErrorKind::Other, + message, + }, + cleanup: Vec::new(), + })) }); let elapsed = started.elapsed().as_millis(); @@ -532,9 +1032,9 @@ fn run_job(spec: &CommandSpec, ctl: &Arc) -> std::io::Result spec.lane.name(), detail, out.status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into()), ), - Err(e) => log::warn!( + Err(error) => log::warn!( target: "okena::cmd", - "[{}] {} failed: {e} ({elapsed}ms)", spec.lane.name(), detail, + "[{}] {} failed: {error} ({elapsed}ms)", spec.lane.name(), detail, ), } result @@ -542,75 +1042,208 @@ fn run_job(spec: &CommandSpec, ctl: &Arc) -> std::io::Result /// Spawn the child with piped stdio and poll until it exits, the deadline /// passes, or cancellation is requested. -fn spawn_and_collect(spec: &CommandSpec, ctl: &Arc) -> std::io::Result { +fn spawn_and_collect(spec: &CommandSpec, ctl: &Arc) -> Result { let mut cmd = spec.build(); cmd.stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); - let (mut child, tree) = ProcessTree::spawn(&mut cmd)?; - - // Drain stdout/stderr on dedicated threads, concurrently with the wait loop - // below. Otherwise a child that writes more than the OS pipe buffer (~64KB) - // blocks on `write` forever while we wait for it to exit and never drain — - // a classic deadlock, hit by e.g. a large `docker ps -a` or `git diff`. - let out_reader = spawn_pipe_reader(child.stdout.take()); - let err_reader = spawn_pipe_reader(child.stderr.take()); + let (mut child, tree) = match ProcessTree::spawn(&mut cmd, ctl) { + Ok(process) => process, + Err(error) => { + ctl.record_io(CommandOperation::Spawn, error, false); + return Err(ctl.failure().unwrap_or_else(|| CommandFailure { + primary: CommandFailureCause::Process { + at: Instant::now(), + operation: CommandOperation::Spawn, + kind: std::io::ErrorKind::Other, + message: "process spawn failed without error evidence".to_string(), + }, + cleanup: Vec::new(), + })); + } + }; - // Publish the kill handle so cancel()/cancel_scope() can reach the whole - // process tree. The registration clears it before the OS identity can be - // reused for an unrelated process. + // Publish ownership before creating readers. A cancellation that raced + // with spawn either sees this handle or is observed by register itself. let _registration = ctl.register(tree.clone()); - // Lost a cancellation race between the check above and registering: honor it. - if ctl.is_cancelled() { - terminate_and_reap(&tree, &mut child); - let _ = out_reader.join(); - let _ = err_reader.join(); - return Err(cancelled_err()); + let mut deadline = spec.deadline; + let mut out_reader = None; + let mut err_reader = None; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + deadline = match effective_deadline(spec.deadline, spec.timeout, Instant::now()) { + Ok(deadline) => deadline, + Err(error) => { + ctl.record_io(CommandOperation::ComputeDeadline, error, true); + return finish_child(&tree, &mut child, None, None, ctl, spec.deadline, false); + } + }; + + // Drain both pipes while polling. A child can otherwise block after + // filling an OS pipe buffer before the parent observes its exit. + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + if stdout.is_none() { + ctl.record_io( + CommandOperation::SpawnStdoutReader, + std::io::Error::other("stdout pipe was not created"), + true, + ); + } + if stderr.is_none() { + ctl.record_io( + CommandOperation::SpawnStderrReader, + std::io::Error::other("stderr pipe was not created"), + true, + ); + } + let stdout_limit = spec.output_limits.map(OutputLimits::stdout_bytes); + let stderr_limit = spec.output_limits.map(OutputLimits::stderr_bytes); + out_reader = stdout.and_then(|pipe| { + match spawn_pipe_reader(pipe, OutputStream::Stdout, stdout_limit, ctl.clone()) { + Ok(reader) => Some(reader), + Err(error) => { + ctl.record_io(CommandOperation::SpawnStdoutReader, error, true); + None + } + } + }); + err_reader = stderr.and_then(|pipe| { + match spawn_pipe_reader(pipe, OutputStream::Stderr, stderr_limit, ctl.clone()) { + Ok(reader) => Some(reader), + Err(error) => { + ctl.record_io(CommandOperation::SpawnStderrReader, error, true); + None + } + } + }); + + maybe_panic_after_spawn(spec); + drive_child( + &tree, + &mut child, + &mut out_reader, + &mut err_reader, + ctl, + deadline, + ) + })); + match result { + Ok(result) => result, + Err(panic) => { + let message = panic_message(&panic); + ctl.record_io( + CommandOperation::Worker, + std::io::Error::other(format!("command panicked after spawn: {message}")), + true, + ); + finish_child( + &tree, + &mut child, + out_reader.take(), + err_reader.take(), + ctl, + deadline, + false, + ) + } } +} - let deadline = spec.timeout.map(|t| Instant::now() + t); - let mut backoff = POLL_MIN; +fn effective_deadline( + absolute: Option, + relative_timeout: Option, + relative_start: Instant, +) -> std::io::Result> { + let relative = relative_timeout + .map(|timeout| checked_relative_deadline(relative_start, timeout)) + .transpose()?; + Ok(match (absolute, relative) { + (Some(absolute), Some(relative)) => Some(absolute.min(relative)), + (Some(absolute), None) => Some(absolute), + (None, relative) => relative, + }) +} + +fn validate_relative_timeout(timeout: Option, at: Instant) -> std::io::Result<()> { + timeout + .map(|timeout| checked_relative_deadline(at, timeout).map(|_| ())) + .transpose() + .map(|_| ()) +} +fn checked_relative_deadline(start: Instant, timeout: Duration) -> std::io::Result { + start.checked_add(timeout).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "relative command timeout exceeds the supported Instant range", + ) + }) +} + +fn drive_child( + tree: &ProcessTree, + child: &mut std::process::Child, + stdout_reader: &mut Option, + stderr_reader: &mut Option, + ctl: &JobControl, + deadline: Option, +) -> Result { + let mut backoff = POLL_MIN; loop { - // Check cancellation before reaping: a killed child exits, and we must - // report that as cancelled rather than as a (signal) success. - if ctl.is_cancelled() { - terminate_and_reap(&tree, &mut child); - let _ = out_reader.join(); - let _ = err_reader.join(); - return Err(cancelled_err()); + ctl.latch_deadline(deadline, Instant::now()); + if ctl.has_failure() { + return finish_child( + tree, + child, + stdout_reader.take(), + stderr_reader.take(), + ctl, + deadline, + false, + ); } - if process_exited(&mut child)? { - // A direct parent may exit while a background descendant still - // owns the inherited pipes. Bound the drain, then terminate the - // owned tree so joining the readers cannot hang forever. - let _ = wait_for_readers(&out_reader, &err_reader, POST_EXIT_DRAIN); - tree.terminate(); - let status = child.wait()?; - let stdout = out_reader.join().unwrap_or_default(); - let stderr = err_reader.join().unwrap_or_default(); - if ctl.is_cancelled() { - return Err(cancelled_err()); + let exited = match process_exited(child) { + Ok(exited) => exited, + Err(error) => { + ctl.record_io(CommandOperation::Poll, error, true); + return finish_child( + tree, + child, + stdout_reader.take(), + stderr_reader.take(), + ctl, + deadline, + false, + ); } - return Ok(Output { - status, - stdout, - stderr, - }); - } - - if let Some(deadline) = deadline - && Instant::now() >= deadline - { - terminate_and_reap(&tree, &mut child); - let _ = out_reader.join(); - let _ = err_reader.join(); - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "process timed out", - )); + }; + if exited { + let observed_at = Instant::now(); + if !ctl.accept_completion(deadline, observed_at) { + return finish_child( + tree, + child, + stdout_reader.take(), + stderr_reader.take(), + ctl, + deadline, + false, + ); + } + // A direct parent may exit while a background descendant still + // owns inherited pipes. Drain briefly, then terminate the tree. + wait_for_readers(stdout_reader, stderr_reader, POST_EXIT_DRAIN); + return finish_child( + tree, + child, + stdout_reader.take(), + stderr_reader.take(), + ctl, + deadline, + true, + ); } std::thread::sleep(backoff); @@ -618,6 +1251,85 @@ fn spawn_and_collect(spec: &CommandSpec, ctl: &Arc) -> std::io::Resu } } +fn finish_child( + tree: &ProcessTree, + child: &mut std::process::Child, + stdout_reader: Option, + stderr_reader: Option, + ctl: &JobControl, + deadline: Option, + parent_exited: bool, +) -> Result { + if let Err(error) = tree.terminate() { + ctl.record_cleanup(CommandOperation::TerminateTree, error); + } + + let status = if parent_exited { + match child.wait() { + Ok(status) => Some(status), + Err(error) => { + ctl.record_cleanup(CommandOperation::WaitChild, error); + None + } + } + } else { + match child.try_wait() { + Ok(Some(status)) => Some(status), + Ok(None) => { + if let Err(error) = child.kill() { + ctl.record_cleanup(CommandOperation::KillChild, error); + } + match child.wait() { + Ok(status) => Some(status), + Err(error) => { + ctl.record_cleanup(CommandOperation::WaitChild, error); + None + } + } + } + Err(error) => { + ctl.record_cleanup(CommandOperation::Poll, error); + if let Err(error) = child.kill() { + ctl.record_cleanup(CommandOperation::KillChild, error); + } + match child.wait() { + Ok(status) => Some(status), + Err(error) => { + ctl.record_cleanup(CommandOperation::WaitChild, error); + None + } + } + } + } + }; + + let stdout = join_pipe_reader(stdout_reader, OutputStream::Stdout, ctl); + let stderr = join_pipe_reader(stderr_reader, OutputStream::Stderr, ctl); + + if parent_exited { + ctl.finalize_success(deadline, Instant::now())?; + } else { + ctl.latch_deadline(deadline, Instant::now()); + if let Some(failure) = ctl.failure() { + return Err(failure); + } + } + let status = status.ok_or_else(|| CommandFailure { + primary: CommandFailureCause::Process { + at: Instant::now(), + operation: CommandOperation::WaitChild, + kind: std::io::ErrorKind::Other, + message: "child status was unavailable".to_string(), + }, + cleanup: Vec::new(), + })?; + Ok(Output { + status, + stdout, + stderr, + }) +} + /// Detect exit without reaping on supported Unix hosts. Keeping the group /// leader as a zombie until tree cleanup prevents its process-group ID from /// being reused for an unrelated process before the group signal is sent. @@ -647,52 +1359,178 @@ fn process_exited(child: &mut std::process::Child) -> std::io::Result { child.try_wait().map(|status| status.is_some()) } -/// Spawn a thread that reads a child pipe to EOF into a buffer. Reading -/// concurrently with the wait loop prevents a full-pipe write deadlock. -fn spawn_pipe_reader( - pipe: Option, -) -> std::thread::JoinHandle> { - std::thread::spawn(move || { - let mut buf = Vec::new(); - if let Some(mut r) = pipe { - let _ = r.read_to_end(&mut buf); - } - buf - }) +#[derive(Clone, Copy)] +enum OutputStream { + Stdout, + Stderr, +} + +struct PipeReader { + handle: std::thread::JoinHandle<()>, + captured: Arc>>, + discard: Arc, } -fn wait_for_readers( - stdout: &std::thread::JoinHandle>, - stderr: &std::thread::JoinHandle>, - timeout: Duration, -) -> bool { +/// Drain one pipe concurrently. Once the first byte beyond the limit arrives, +/// retain no more data, latch the event, and keep draining until tree cleanup. +fn spawn_pipe_reader( + mut pipe: R, + stream: OutputStream, + limit: Option, + ctl: Arc, +) -> std::io::Result { + let name = match stream { + OutputStream::Stdout => "okena-cmd-stdout", + OutputStream::Stderr => "okena-cmd-stderr", + }; + let captured = Arc::new(Mutex::new(Vec::new())); + let reader_capture = captured.clone(); + let discard = Arc::new(AtomicBool::new(false)); + let reader_discard = discard.clone(); + std::thread::Builder::new() + .name(name.to_string()) + .spawn(move || { + let mut observed = 0_u64; + let mut overflowed = false; + let mut chunk = [0_u8; 8192]; + loop { + let count = match pipe.read(&mut chunk) { + Ok(0) => return, + Ok(count) => count, + Err(error) => { + let operation = match stream { + OutputStream::Stdout => CommandOperation::ReadStdout, + OutputStream::Stderr => CommandOperation::ReadStderr, + }; + ctl.record_io(operation, error, true); + return; + } + }; + let previous = observed; + observed = observed.saturating_add(u64::try_from(count).unwrap_or(u64::MAX)); + if let Some(limit) = limit { + let limit = limit.get(); + if previous < limit { + let retained = usize::try_from(limit - previous) + .unwrap_or(usize::MAX) + .min(count); + let mut capture = reader_capture + .lock() + .unwrap_or_else(|error| error.into_inner()); + if !reader_discard.load(Ordering::Acquire) { + capture.extend_from_slice(&chunk[..retained]); + } + } + if !overflowed && observed > limit { + overflowed = true; + let at = Instant::now(); + let cause = match stream { + OutputStream::Stdout => CommandFailureCause::StdoutLimitExceeded { + at, + limit, + observed: limit.saturating_add(1), + }, + OutputStream::Stderr => CommandFailureCause::StderrLimitExceeded { + at, + limit, + observed: limit.saturating_add(1), + }, + }; + ctl.record_cause(cause, true); + } + } else { + let mut capture = reader_capture + .lock() + .unwrap_or_else(|error| error.into_inner()); + if !reader_discard.load(Ordering::Acquire) { + capture.extend_from_slice(&chunk[..count]); + } + } + } + }) + .map(|handle| PipeReader { + handle, + captured, + discard, + }) +} + +fn wait_for_readers(stdout: &Option, stderr: &Option, timeout: Duration) { let deadline = Instant::now() + timeout; - while !(stdout.is_finished() && stderr.is_finished()) { + while !(reader_finished(stdout) && reader_finished(stderr)) { if Instant::now() >= deadline { - return false; + return; } std::thread::sleep(POLL_MIN); } - true } -fn terminate_and_reap(tree: &ProcessTree, child: &mut std::process::Child) { - tree.terminate(); - // The direct kill is an exact Child handle fallback if platform tree - // setup succeeded but group/job termination itself was denied. - let _ = child.kill(); - let _ = child.wait(); +fn reader_finished(reader: &Option) -> bool { + reader + .as_ref() + .is_none_or(|reader| reader.handle.is_finished()) } +fn join_pipe_reader(reader: Option, stream: OutputStream, ctl: &JobControl) -> Vec { + let Some(reader) = reader else { + return Vec::new(); + }; + let operation = match stream { + OutputStream::Stdout => CommandOperation::JoinStdoutReader, + OutputStream::Stderr => CommandOperation::JoinStderrReader, + }; + let deadline = Instant::now() + READER_JOIN_TIMEOUT; + while !reader.handle.is_finished() && Instant::now() < deadline { + std::thread::sleep(POLL_MIN); + } + if reader.handle.is_finished() { + if reader.handle.join().is_err() { + ctl.record_cleanup(operation, std::io::Error::other("output reader panicked")); + } + } else { + reader.discard.store(true, Ordering::Release); + ctl.record_cleanup( + operation, + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "output reader did not stop after process-tree cleanup; detached", + ), + ); + } + reader + .captured + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() +} + +#[cfg(test)] +fn maybe_panic_after_spawn(spec: &CommandSpec) { + if spec + .env + .iter() + .any(|(key, value)| key == "OKENA_TEST_PANIC_AFTER_SPAWN" && value == "1") + { + std::thread::sleep(Duration::from_millis(50)); + panic!("injected post-spawn panic"); + } +} + +#[cfg(not(test))] +fn maybe_panic_after_spawn(_spec: &CommandSpec) {} + #[cfg(unix)] struct ProcessTree { process_group: libc::pid_t, - terminated: AtomicBool, + terminated: Mutex, } #[cfg(unix)] impl ProcessTree { - fn spawn(cmd: &mut std::process::Command) -> std::io::Result<(std::process::Child, Arc)> { + fn spawn( + cmd: &mut std::process::Command, + ctl: &JobControl, + ) -> std::io::Result<(std::process::Child, Arc)> { use std::os::unix::process::CommandExt; cmd.process_group(0); @@ -700,45 +1538,65 @@ impl ProcessTree { let process_group = match libc::pid_t::try_from(child.id()) { Ok(process_group) if process_group > 0 => process_group, _ => { - let _ = child.kill(); - let _ = child.wait(); - return Err(std::io::Error::other( - "spawned process has an invalid process group", - )); + let error = std::io::Error::other("spawned process has an invalid process group"); + if let Err(cleanup) = child.kill() { + ctl.append_cleanup(CommandOperation::KillChild, cleanup); + } + if let Err(cleanup) = child.wait() { + ctl.append_cleanup(CommandOperation::WaitChild, cleanup); + } + return Err(error); } }; Ok(( child, Arc::new(Self { process_group, - terminated: AtomicBool::new(false), + terminated: Mutex::new(false), }), )) } - fn terminate(&self) { - if self.terminated.swap(true, Ordering::SeqCst) { - return; + fn terminate(&self) -> std::io::Result<()> { + let mut terminated = self + .terminated + .lock() + .unwrap_or_else(|error| error.into_inner()); + if *terminated { + return Ok(()); } let Some(group_target) = self.process_group.checked_neg() else { - return; + return Err(std::io::Error::other("invalid process group")); }; // SAFETY: process_group is the positive PID returned for a child that // was atomically placed in its own group before exec. Registration is // cleared before this identity can be reused by an unrelated process. - let _ = unsafe { libc::kill(group_target, libc::SIGKILL) }; + if unsafe { libc::kill(group_target, libc::SIGKILL) } == 0 { + *terminated = true; + return Ok(()); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + *terminated = true; + Ok(()) + } else { + Err(error) + } } } #[cfg(windows)] struct ProcessTree { job: std::os::windows::io::OwnedHandle, - terminated: AtomicBool, + terminated: Mutex, } #[cfg(windows)] impl ProcessTree { - fn spawn(cmd: &mut std::process::Command) -> std::io::Result<(std::process::Child, Arc)> { + fn spawn( + cmd: &mut std::process::Command, + ctl: &JobControl, + ) -> std::io::Result<(std::process::Child, Arc)> { use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::os::windows::process::CommandExt; use windows_sys::Win32::System::JobObjects::{ @@ -782,13 +1640,21 @@ impl ProcessTree { // SAFETY: both handles are live and owned for the duration of the call. if unsafe { AssignProcessToJobObject(job.as_raw_handle(), child.as_raw_handle()) } == 0 { let error = std::io::Error::last_os_error(); - let _ = child.kill(); - let _ = child.wait(); + if let Err(cleanup) = child.kill() { + ctl.append_cleanup(CommandOperation::KillChild, cleanup); + } + if let Err(cleanup) = child.wait() { + ctl.append_cleanup(CommandOperation::WaitChild, cleanup); + } return Err(error); } if let Err(error) = resume_suspended_process(child.id()) { - let _ = child.kill(); - let _ = child.wait(); + if let Err(cleanup) = child.kill() { + ctl.append_cleanup(CommandOperation::KillChild, cleanup); + } + if let Err(cleanup) = child.wait() { + ctl.append_cleanup(CommandOperation::WaitChild, cleanup); + } return Err(error); } @@ -796,20 +1662,29 @@ impl ProcessTree { child, Arc::new(Self { job, - terminated: AtomicBool::new(false), + terminated: Mutex::new(false), }), )) } - fn terminate(&self) { + fn terminate(&self) -> std::io::Result<()> { use std::os::windows::io::AsRawHandle; use windows_sys::Win32::System::JobObjects::TerminateJobObject; - if self.terminated.swap(true, Ordering::SeqCst) { - return; + let mut terminated = self + .terminated + .lock() + .unwrap_or_else(|error| error.into_inner()); + if *terminated { + return Ok(()); } // SAFETY: the owned job handle remains live for this call. - let _ = unsafe { TerminateJobObject(self.job.as_raw_handle(), 1) }; + if unsafe { TerminateJobObject(self.job.as_raw_handle(), 1) } != 0 { + *terminated = true; + Ok(()) + } else { + Err(std::io::Error::last_os_error()) + } } } @@ -862,10 +1737,6 @@ fn resume_suspended_process(process_id: u32) -> std::io::Result<()> { )) } -fn cancelled_err() -> std::io::Error { - std::io::Error::new(std::io::ErrorKind::Interrupted, "command cancelled") -} - fn panic_message(panic: &Box) -> String { if let Some(s) = panic.downcast_ref::() { s.clone() @@ -875,3 +1746,335 @@ fn panic_message(panic: &Box) -> String { "unknown panic".to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + + struct BrokenReader; + + impl Read for BrokenReader { + fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { + Err(std::io::Error::other("synthetic read failure")) + } + } + + struct BlockingReader { + released: Arc<(Mutex, Condvar)>, + } + + impl Read for BlockingReader { + fn read(&mut self, _buffer: &mut [u8]) -> std::io::Result { + let (released, cv) = &*self.released; + let mut released = released.lock().unwrap_or_else(|error| error.into_inner()); + while !*released { + released = cv.wait(released).unwrap_or_else(|error| error.into_inner()); + } + Ok(0) + } + } + + fn stdout(at: Instant) -> CommandFailureCause { + CommandFailureCause::StdoutLimitExceeded { + at, + limit: 10, + observed: 11, + } + } + + fn stderr(at: Instant) -> CommandFailureCause { + CommandFailureCause::StderrLimitExceeded { + at, + limit: 10, + observed: 11, + } + } + + #[test] + fn first_cause_uses_time_then_deterministic_tie_priority() { + let at = Instant::now(); + let ctl = JobControl::new(); + ctl.record_cause(stderr(at), false); + ctl.record_cause(stdout(at), false); + ctl.record_cause(CommandFailureCause::Cancelled { at }, false); + ctl.record_cause( + CommandFailureCause::DeadlineExceeded { deadline: at }, + false, + ); + + assert!(matches!( + ctl.failure().unwrap().primary, + CommandFailureCause::DeadlineExceeded { .. } + )); + } + + #[test] + fn earlier_cause_replaces_later_but_later_cause_never_replaces_first() { + let base = Instant::now(); + let early = base + Duration::from_millis(1); + let late = base + Duration::from_millis(2); + let ctl = JobControl::new(); + ctl.record_cause(stderr(late), false); + ctl.record_cause(stdout(early), false); + ctl.record_cause(CommandFailureCause::Cancelled { at: late }, false); + + assert!(matches!( + ctl.failure().unwrap().primary, + CommandFailureCause::StdoutLimitExceeded { .. } + )); + } + + #[test] + fn cleanup_failure_does_not_replace_primary() { + let ctl = JobControl::new(); + ctl.record_cause(CommandFailureCause::Cancelled { at: Instant::now() }, false); + ctl.record_cleanup( + CommandOperation::WaitChild, + std::io::Error::other("synthetic cleanup failure"), + ); + + let failure = ctl.failure().unwrap(); + assert!(matches!( + failure.primary, + CommandFailureCause::Cancelled { .. } + )); + assert_eq!(failure.cleanup.len(), 1); + assert_eq!(failure.cleanup[0].operation, CommandOperation::WaitChild); + } + + #[test] + fn reader_cleanup_evidence_is_attached_to_existing_primary() { + let ctl = JobControl::new(); + ctl.record_cause(CommandFailureCause::Cancelled { at: Instant::now() }, false); + let reader = spawn_pipe_reader(BrokenReader, OutputStream::Stdout, None, ctl.clone()) + .expect("reader thread"); + let _ = join_pipe_reader(Some(reader), OutputStream::Stdout, &ctl); + + let failure = ctl.failure().unwrap(); + assert!(matches!( + failure.primary, + CommandFailureCause::Cancelled { .. } + )); + assert!( + failure + .cleanup + .iter() + .any(|evidence| evidence.operation == CommandOperation::ReadStdout) + ); + } + + #[test] + fn stuck_reader_is_detached_with_cleanup_evidence() { + let ctl = JobControl::new(); + ctl.record_cause(CommandFailureCause::Cancelled { at: Instant::now() }, false); + let released = Arc::new((Mutex::new(false), Condvar::new())); + let reader = spawn_pipe_reader( + BlockingReader { + released: released.clone(), + }, + OutputStream::Stdout, + None, + ctl.clone(), + ) + .expect("reader thread"); + + let started = Instant::now(); + let _ = join_pipe_reader(Some(reader), OutputStream::Stdout, &ctl); + let elapsed = started.elapsed(); + let (flag, cv) = &*released; + *flag.lock().unwrap_or_else(|error| error.into_inner()) = true; + cv.notify_all(); + + assert!(elapsed < Duration::from_secs(1), "elapsed: {elapsed:?}"); + assert!(ctl.failure().unwrap().cleanup.iter().any(|evidence| { + evidence.operation == CommandOperation::JoinStdoutReader + && evidence.kind == std::io::ErrorKind::TimedOut + })); + } + + #[cfg(unix)] + #[test] + fn termination_failure_is_cleanup_evidence_not_a_new_primary() { + let ctl = JobControl::new(); + ctl.record_cause(CommandFailureCause::Cancelled { at: Instant::now() }, false); + let tree = ProcessTree { + process_group: libc::pid_t::MIN, + terminated: Mutex::new(false), + }; + let error = tree.terminate().expect_err("invalid process group"); + ctl.record_cleanup(CommandOperation::TerminateTree, error); + + let failure = ctl.failure().unwrap(); + assert!(matches!( + failure.primary, + CommandFailureCause::Cancelled { .. } + )); + assert!( + failure + .cleanup + .iter() + .any(|evidence| evidence.operation == CommandOperation::TerminateTree) + ); + } + + #[test] + fn cancellation_before_observed_completion_wins() { + let ctl = JobControl::new(); + ctl.cancel(); + assert!(!ctl.accept_completion(None, Instant::now())); + assert!(matches!( + ctl.failure().unwrap().primary, + CommandFailureCause::Cancelled { .. } + )); + } + + #[test] + fn cancellation_after_accepted_completion_cannot_replace_success() { + let ctl = JobControl::new(); + assert!(ctl.accept_completion(None, Instant::now())); + ctl.cancel(); + assert!(ctl.finalize_success(None, Instant::now()).is_ok()); + assert!(ctl.failure().is_none()); + } + + #[test] + fn overflow_during_final_drain_beats_accepted_completion() { + let ctl = JobControl::new(); + assert!(ctl.accept_completion(None, Instant::now())); + ctl.record_cause(stdout(Instant::now()), false); + let failure = ctl + .finalize_success(None, Instant::now()) + .expect_err("final-drain overflow"); + assert!(matches!( + failure.primary, + CommandFailureCause::StdoutLimitExceeded { .. } + )); + } + + #[test] + fn deadline_is_checked_atomically_when_completion_is_observed() { + let ctl = JobControl::new(); + let deadline = Instant::now(); + assert!(!ctl.accept_completion(Some(deadline), deadline)); + assert!(matches!( + ctl.failure().unwrap().primary, + CommandFailureCause::DeadlineExceeded { .. } + )); + } + + #[test] + fn relative_timeout_overflow_is_rejected() { + let error = effective_deadline(None, Some(Duration::MAX), Instant::now()) + .expect_err("overflowing timeout"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + } + + #[cfg(target_os = "linux")] + #[test] + fn escaped_noisy_reader_stops_retaining_after_detach() { + let pid_file = tempfile::NamedTempFile::new().expect("pid file"); + let pid_path = pid_file.path().to_string_lossy().into_owned(); + let ctl = JobControl::new(); + let spec = CommandSpec::new("/bin/sh").args([ + "-c", + "setsid /bin/sh -c 'echo $$ > \"$1\"; i=0; while [ \"$i\" -lt 300 ]; do printf 0123456789abcdef; i=$((i + 1)); sleep 0.01; done' okena-escaped \"$1\" &", + "okena-test", + &pid_path, + ]); + let mut command = spec.build(); + command + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let (mut child, tree) = ProcessTree::spawn(&mut command, &ctl).expect("spawn parent"); + let _registration = ctl.register(tree.clone()); + let mut stdout_reader = Some( + spawn_pipe_reader( + child.stdout.take().expect("stdout pipe"), + OutputStream::Stdout, + None, + ctl.clone(), + ) + .expect("stdout reader"), + ); + let retained = stdout_reader.as_ref().unwrap().captured.clone(); + let mut stderr_reader = Some( + spawn_pipe_reader( + child.stderr.take().expect("stderr pipe"), + OutputStream::Stderr, + None, + ctl.clone(), + ) + .expect("stderr reader"), + ); + + let exit_deadline = Instant::now() + Duration::from_secs(2); + while !process_exited(&mut child).expect("poll parent") { + assert!(Instant::now() < exit_deadline, "parent did not exit"); + std::thread::sleep(POLL_MIN); + } + assert!(ctl.accept_completion(None, Instant::now())); + wait_for_readers(&stdout_reader, &stderr_reader, POST_EXIT_DRAIN); + let failure = finish_child( + &tree, + &mut child, + stdout_reader.take(), + stderr_reader.take(), + &ctl, + None, + true, + ) + .expect_err("escaped readers must detach"); + + let length_at_return = retained + .lock() + .unwrap_or_else(|error| error.into_inner()) + .len(); + std::thread::sleep(Duration::from_millis(150)); + let length_later = retained + .lock() + .unwrap_or_else(|error| error.into_inner()) + .len(); + let escaped_pid = wait_for_pid_file(pid_file.path(), Duration::from_secs(1)); + kill_process_group(escaped_pid); + + assert!(matches!( + failure.primary, + CommandFailureCause::Process { + operation: CommandOperation::JoinStdoutReader | CommandOperation::JoinStderrReader, + kind: std::io::ErrorKind::TimedOut, + .. + } + )); + assert!( + length_at_return < 128 * 1024, + "retained {length_at_return} bytes before detach" + ); + assert_eq!( + length_later, length_at_return, + "detached reader kept retaining output" + ); + } + + #[cfg(target_os = "linux")] + fn wait_for_pid_file(path: &std::path::Path, timeout: Duration) -> u32 { + let deadline = Instant::now() + timeout; + loop { + if let Ok(contents) = std::fs::read_to_string(path) + && let Ok(pid) = contents.trim().parse() + { + return pid; + } + assert!(Instant::now() < deadline, "timed out waiting for pid"); + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[cfg(target_os = "linux")] + fn kill_process_group(pid: u32) { + let pid = libc::pid_t::try_from(pid).expect("test pid fits pid_t"); + let group = pid.checked_neg().expect("positive test pid"); + // SAFETY: the test created this session and read its group leader PID. + let _ = unsafe { libc::kill(group, libc::SIGKILL) }; + } +} diff --git a/crates/okena-core/src/process/mod.rs b/crates/okena-core/src/process/mod.rs index 7914cc691..1b5903f57 100644 --- a/crates/okena-core/src/process/mod.rs +++ b/crates/okena-core/src/process/mod.rs @@ -14,7 +14,11 @@ mod bus; -pub use bus::{CommandBus, CommandHandle, CommandSpec, Lane, current_lane, with_lane}; +pub use bus::{ + CommandBus, CommandCancellationHandle, CommandCleanupFailure, CommandFailure, + CommandFailureCause, CommandHandle, CommandOperation, CommandSpec, Lane, OutputLimits, + current_lane, with_lane, +}; /// Create a [`std::process::Command`] that does **not** flash a console /// window on Windows. On other platforms this is identical to @@ -38,6 +42,11 @@ pub fn run(spec: CommandSpec) -> std::io::Result { CommandBus::global().submit(spec).wait() } +/// Submit a command and preserve typed stop and cleanup evidence. +pub fn run_detailed(spec: CommandSpec) -> Result { + CommandBus::global().submit(spec).wait_detailed() +} + /// Spawn a child process and reap it on a background thread. /// /// Fire-and-forget with no output capture, so it bypasses the bus (nothing to @@ -274,8 +283,9 @@ pub mod testing { #[cfg(test)] mod tests { use super::*; + use std::num::NonZeroU64; use std::sync::Mutex; - use std::time::Duration; + use std::time::{Duration, Instant}; // The bus and its mock slot are process-global, so bus tests must not run // concurrently (one test's mock would intercept another's commands). @@ -285,6 +295,13 @@ mod tests { TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()) } + fn limits(stdout: u64, stderr: u64) -> OutputLimits { + OutputLimits::new( + NonZeroU64::new(stdout).unwrap(), + NonZeroU64::new(stderr).unwrap(), + ) + } + #[test] fn safe_output_runs_through_bus() { let _g = guard(); @@ -316,6 +333,206 @@ mod tests { assert_eq!(err.kind(), std::io::ErrorKind::TimedOut); } + #[cfg(unix)] + #[test] + fn overflowing_relative_timeout_never_spawns_command() { + let _g = guard(); + let directory = tempfile::tempdir().expect("marker directory"); + let marker = directory.path().join("spawned"); + let marker_path = marker.to_string_lossy().into_owned(); + let failure = run_detailed( + CommandSpec::new("/bin/sh") + .args(["-c", "touch \"$1\"", "okena-test", &marker_path]) + .timeout(Duration::MAX), + ) + .expect_err("unsupported relative timeout"); + assert!(matches!( + failure.primary, + CommandFailureCause::Process { + operation: CommandOperation::ComputeDeadline, + kind: std::io::ErrorKind::InvalidInput, + .. + } + )); + assert!(!marker.exists(), "invalid timeout command was spawned"); + } + + #[cfg(unix)] + #[test] + fn bounded_output_accepts_exact_stdout_and_stderr_limits() { + let _g = guard(); + let output = run_detailed( + CommandSpec::new("/bin/sh") + .args(["-c", "printf 12345; printf abcde >&2"]) + .output_limits(limits(5, 5)), + ) + .expect("exact limits"); + assert_eq!(output.stdout, b"12345"); + assert_eq!(output.stderr, b"abcde"); + } + + #[cfg(unix)] + #[test] + fn bounded_output_rejects_first_stdout_byte_beyond_limit() { + let _g = guard(); + let failure = run_detailed( + CommandSpec::new("/bin/sh") + .args(["-c", "printf 123456"]) + .output_limits(limits(5, 64)), + ) + .expect_err("stdout overflow"); + assert!(matches!( + failure.primary, + CommandFailureCause::StdoutLimitExceeded { + limit: 5, + observed: 6, + .. + } + )); + } + + #[cfg(unix)] + #[test] + fn bounded_output_rejects_first_stderr_byte_beyond_limit() { + let _g = guard(); + let failure = run_detailed( + CommandSpec::new("/bin/sh") + .args(["-c", "printf 123456 >&2"]) + .output_limits(limits(64, 5)), + ) + .expect_err("stderr overflow"); + assert!(matches!( + failure.primary, + CommandFailureCause::StderrLimitExceeded { + limit: 5, + observed: 6, + .. + } + )); + } + + #[cfg(unix)] + #[test] + fn final_pipe_drain_overflow_beats_apparent_parent_success() { + let _g = guard(); + let failure = run_detailed( + CommandSpec::new("/bin/sh") + .args(["-c", "(sleep 0.02; printf 123456) &"]) + .output_limits(limits(5, 64)), + ) + .expect_err("descendant stdout overflow"); + assert!(matches!( + failure.primary, + CommandFailureCause::StdoutLimitExceeded { + limit: 5, + observed: 6, + .. + } + )); + } + + #[test] + fn cancellation_handle_is_cloneable_and_preserves_typed_primary() { + let _g = guard(); + let handle = CommandBus::global().submit( + CommandSpec::new("sleep") + .arg("30") + .deadline(Instant::now() + Duration::from_secs(5)), + ); + let cancellation = handle.cancellation_handle(); + let cloned = cancellation.clone(); + std::thread::sleep(Duration::from_millis(80)); + cloned.cancel(); + + let failure = handle.wait_detailed().expect_err("cancelled"); + assert!(matches!( + failure.primary, + CommandFailureCause::Cancelled { .. } + )); + } + + #[test] + fn expired_absolute_deadline_beats_later_cancellation() { + let _g = guard(); + let handle = CommandBus::global().submit( + CommandSpec::new("sleep") + .arg("30") + .deadline(Instant::now() + Duration::from_millis(100)), + ); + let cancellation = handle.cancellation_handle(); + let canceller = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(180)); + cancellation.cancel(); + }); + let failure = handle.wait_detailed().expect_err("deadline"); + canceller.join().unwrap(); + + assert!(matches!( + failure.primary, + CommandFailureCause::DeadlineExceeded { .. } + )); + } + + #[cfg(unix)] + #[test] + fn queued_expired_command_never_spawns() { + let _g = guard(); + let blockers = occupy_long_lane(); + let directory = tempfile::tempdir().expect("marker directory"); + let marker = directory.path().join("spawned"); + let marker_path = marker.to_string_lossy().into_owned(); + let handle = CommandBus::global().submit( + CommandSpec::new("/bin/sh") + .args(["-c", "touch \"$1\"", "okena-test", &marker_path]) + .lane(Lane::Long) + .deadline(Instant::now()), + ); + + for blocker in &blockers { + blocker.cancel(); + } + for blocker in blockers { + let _ = blocker.wait_detailed(); + } + let failure = handle.wait_detailed().expect_err("queued deadline"); + + assert!(matches!( + failure.primary, + CommandFailureCause::DeadlineExceeded { .. } + )); + assert!(!marker.exists(), "expired command was spawned"); + } + + #[cfg(unix)] + #[test] + fn queued_cancellation_before_registration_never_spawns() { + let _g = guard(); + let blockers = occupy_long_lane(); + let directory = tempfile::tempdir().expect("marker directory"); + let marker = directory.path().join("spawned"); + let marker_path = marker.to_string_lossy().into_owned(); + let handle = CommandBus::global().submit( + CommandSpec::new("/bin/sh") + .args(["-c", "touch \"$1\"", "okena-test", &marker_path]) + .lane(Lane::Long), + ); + handle.cancel(); + + for blocker in &blockers { + blocker.cancel(); + } + for blocker in blockers { + let _ = blocker.wait_detailed(); + } + let failure = handle.wait_detailed().expect_err("queued cancellation"); + + assert!(matches!( + failure.primary, + CommandFailureCause::Cancelled { .. } + )); + assert!(!marker.exists(), "cancelled command was spawned"); + } + #[cfg(unix)] #[test] fn timeout_kills_foreground_tree_without_touching_unrelated_process() { @@ -354,6 +571,46 @@ mod tests { assert!(unrelated_alive, "unrelated process was terminated"); } + #[cfg(unix)] + #[test] + fn output_overflow_kills_descendants_without_touching_unrelated_process() { + let _g = guard(); + let pid_file = tempfile::NamedTempFile::new().expect("pid file"); + let pid_path = pid_file.path().to_string_lossy().into_owned(); + let mut unrelated = std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .expect("unrelated process"); + + let failure = run_detailed( + CommandSpec::new("/bin/sh") + .args([ + "-c", + "(sleep 0.05; while :; do printf xxxxxxxxxxxxxxxx; done) & echo $! > \"$1\"; wait $!", + "okena-test", + &pid_path, + ]) + .output_limits(limits(1024, 1024)), + ) + .expect_err("stdout overflow"); + let descendant_pid = read_test_pid(pid_file.path()); + let descendant_dead = wait_for_test_process_exit(descendant_pid, Duration::from_secs(1)); + let unrelated_alive = unrelated.try_wait().expect("probe unrelated").is_none(); + + let _ = unrelated.kill(); + let _ = unrelated.wait(); + if !descendant_dead { + kill_test_process(descendant_pid); + } + + assert!(matches!( + failure.primary, + CommandFailureCause::StdoutLimitExceeded { .. } + )); + assert!(descendant_dead, "overflow descendant survived cleanup"); + assert!(unrelated_alive, "unrelated process was terminated"); + } + #[cfg(unix)] #[test] fn exited_parent_with_background_pipe_descendant_finishes_bounded() { @@ -382,6 +639,114 @@ mod tests { assert!(descendant_dead, "background descendant survived collection"); } + #[cfg(target_os = "linux")] + #[test] + fn escaped_pipe_holder_cannot_pin_bus_lane() { + let _g = guard(); + let pid_file = tempfile::NamedTempFile::new().expect("pid file"); + let pid_path = pid_file.path().to_string_lossy().into_owned(); + + let started = Instant::now(); + let failure = run_detailed(CommandSpec::new("/bin/sh").args([ + "-c", + "setsid /bin/sh -c 'echo $$ > \"$1\"; sleep 30' okena-escaped \"$1\" &", + "okena-test", + &pid_path, + ])) + .expect_err("escaped descendant retains pipes"); + let elapsed = started.elapsed(); + let escaped_pid = wait_for_test_pid(pid_file.path(), Duration::from_secs(1)); + kill_test_process_group(escaped_pid); + let escaped_dead = wait_for_test_process_exit(escaped_pid, Duration::from_secs(1)); + + assert!(elapsed < Duration::from_secs(2), "elapsed: {elapsed:?}"); + assert!(matches!( + failure.primary, + CommandFailureCause::Process { + operation: CommandOperation::JoinStdoutReader | CommandOperation::JoinStderrReader, + kind: std::io::ErrorKind::TimedOut, + .. + } + )); + assert!( + escaped_dead, + "escaped test process survived explicit cleanup" + ); + } + + #[cfg(unix)] + #[test] + fn post_spawn_panic_reaps_child_and_returns_typed_failure() { + let _g = guard(); + let pid_file = tempfile::NamedTempFile::new().expect("pid file"); + let pid_path = pid_file.path().to_string_lossy().into_owned(); + + let started = Instant::now(); + let failure = run_detailed( + CommandSpec::new("/bin/sh") + .args(["-c", "echo $$ > \"$1\"; sleep 30", "okena-test", &pid_path]) + .env("OKENA_TEST_PANIC_AFTER_SPAWN", "1"), + ) + .expect_err("injected panic"); + let elapsed = started.elapsed(); + let child_pid = read_test_pid(pid_file.path()); + let child_dead = wait_for_test_process_exit(child_pid, Duration::from_secs(1)); + let mut wait_status = 0; + // SAFETY: this probes only the PID written by our direct child. + let wait_result = unsafe { + libc::waitpid( + libc::pid_t::try_from(child_pid).expect("test pid fits pid_t"), + &mut wait_status, + libc::WNOHANG, + ) + }; + let wait_error = std::io::Error::last_os_error(); + if !child_dead { + kill_test_process(child_pid); + } + + assert!(elapsed < Duration::from_secs(2), "elapsed: {elapsed:?}"); + assert!(matches!( + failure.primary, + CommandFailureCause::Process { + operation: CommandOperation::Worker, + .. + } + )); + assert!(child_dead, "post-spawn panic left a live or zombie child"); + assert_eq!(wait_result, -1, "child remained waitable after return"); + assert_eq!( + wait_error.raw_os_error(), + Some(libc::ECHILD), + "child was not reaped by the command bus" + ); + } + + #[cfg(unix)] + fn occupy_long_lane() -> Vec { + let directory = tempfile::tempdir().expect("lane marker directory"); + let handles: Vec<_> = (0..Lane::Long.workers()) + .map(|index| { + let marker = directory.path().join(index.to_string()); + let marker_path = marker.to_string_lossy().into_owned(); + CommandBus::global().submit( + CommandSpec::new("/bin/sh") + .args(["-c", "touch \"$1\"; sleep 30", "okena-test", &marker_path]) + .lane(Lane::Long), + ) + }) + .collect(); + for index in 0..Lane::Long.workers() { + let marker = directory.path().join(index.to_string()); + let deadline = Instant::now() + Duration::from_secs(2); + while !marker.exists() { + assert!(Instant::now() < deadline, "long lane was not occupied"); + std::thread::sleep(Duration::from_millis(10)); + } + } + handles + } + #[cfg(unix)] fn read_test_pid(path: &std::path::Path) -> u32 { std::fs::read_to_string(path) @@ -429,6 +794,18 @@ mod tests { let _ = unsafe { libc::kill(pid, libc::SIGKILL) }; } + #[cfg(target_os = "linux")] + fn kill_test_process_group(pid: u32) { + let Ok(pid) = libc::pid_t::try_from(pid) else { + return; + }; + let Some(group) = pid.checked_neg() else { + return; + }; + // SAFETY: the test created this session and read its group leader PID. + let _ = unsafe { libc::kill(group, libc::SIGKILL) }; + } + #[test] fn lane_default_is_interactive() { let _g = guard(); diff --git a/crates/okena-core/src/review.rs b/crates/okena-core/src/review.rs new file mode 100644 index 000000000..bcd93b5f4 --- /dev/null +++ b/crates/okena-core/src/review.rs @@ -0,0 +1,1558 @@ +//! Shared review-workspace wire models. +//! +//! These types describe comparison identity and review facts without depending +//! on Git execution, syntax parsers, transport, or UI code. + +use std::fmt; +use std::num::NonZeroU32; + +use serde::{Deserialize, Deserializer, Serialize}; + +use crate::types::DiffMode; + +/// A complete SHA-1 or SHA-256 Git object ID. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct GitObjectId(String); + +impl GitObjectId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if !matches!(value.len(), 40 | 64) || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(ReviewModelError::new( + "Git object ID must be 40 or 64 hexadecimal characters", + )); + } + Ok(Self(value.to_ascii_lowercase())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for GitObjectId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl TryFrom for GitObjectId { + type Error = ReviewModelError; + + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl<'de> Deserialize<'de> for GitObjectId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(serde::de::Error::custom) + } +} + +/// Opaque identity of one resolved review comparison. +/// +/// Producers derive this from the strategy and resolved snapshots. Mutable +/// snapshots include their fingerprints, so a changed index or working tree +/// produces a different identity without being described as immutable. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ReviewComparisonId(pub String); + +/// A source snapshot used by a resolved comparison. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ReviewSnapshot { + Commit { + oid: GitObjectId, + }, + EmptyTree { + oid: GitObjectId, + }, + /// A mutable index state identified by the observed-state fingerprint. + Index { + fingerprint: String, + }, + /// A mutable worktree state identified by the observed-state fingerprint. + WorkingTree { + fingerprint: String, + }, +} + +impl ReviewSnapshot { + pub fn is_immutable(&self) -> bool { + matches!(self, Self::Commit { .. } | Self::EmptyTree { .. }) + } + + pub fn oid(&self) -> Option<&GitObjectId> { + match self { + Self::Commit { oid } | Self::EmptyTree { oid } => Some(oid), + Self::Index { .. } | Self::WorkingTree { .. } => None, + } + } + + fn has_valid_fingerprint(&self) -> bool { + match self { + Self::Index { fingerprint } | Self::WorkingTree { fingerprint } => { + !fingerprint.is_empty() + } + Self::Commit { .. } | Self::EmptyTree { .. } => true, + } + } +} + +/// The exact rule used to select the two effective snapshots. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComparisonStrategy { + IndexToWorkingTree, + HeadToIndex, + ParentToCommit, + EmptyTreeToCommit, + MergeBaseToHead, + DirectBaseToHeadWithoutMergeBase, +} + +/// One requested target resolved to stale-detection refs and effective inputs. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "ResolvedComparisonWire")] +pub struct ResolvedComparison { + /// User-facing refs used to open the comparison. + requested: DiffMode, + /// Resolved requested base tip, before merge-base selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + requested_base_oid: Option, + /// Resolved requested head tip used for stale detection. + #[serde(default, skip_serializing_if = "Option::is_none")] + requested_head_oid: Option, + strategy: ComparisonStrategy, + /// Effective old snapshot consumed by diff, source, and syntax analysis. + base: ReviewSnapshot, + /// Effective new snapshot consumed by diff, source, and syntax analysis. + head: ReviewSnapshot, + /// Full merge-base commit OID for a three-dot comparison. + #[serde(default, skip_serializing_if = "Option::is_none")] + merge_base_oid: Option, + identity: ReviewComparisonId, +} + +#[derive(Deserialize)] +struct ResolvedComparisonWire { + requested: DiffMode, + #[serde(default)] + requested_base_oid: Option, + #[serde(default)] + requested_head_oid: Option, + strategy: ComparisonStrategy, + base: ReviewSnapshot, + head: ReviewSnapshot, + #[serde(default)] + merge_base_oid: Option, + identity: ReviewComparisonId, +} + +impl TryFrom for ResolvedComparison { + type Error = ReviewModelError; + + fn try_from(value: ResolvedComparisonWire) -> Result { + let comparison = Self { + requested: value.requested, + requested_base_oid: value.requested_base_oid, + requested_head_oid: value.requested_head_oid, + strategy: value.strategy, + base: value.base, + head: value.head, + merge_base_oid: value.merge_base_oid, + identity: value.identity, + }; + comparison.validate()?; + Ok(comparison) + } +} + +impl ResolvedComparison { + #[allow(clippy::too_many_arguments)] + pub fn new( + requested: DiffMode, + requested_base_oid: Option, + requested_head_oid: Option, + strategy: ComparisonStrategy, + base: ReviewSnapshot, + head: ReviewSnapshot, + merge_base_oid: Option, + identity: ReviewComparisonId, + ) -> Result { + Self::try_from(ResolvedComparisonWire { + requested, + requested_base_oid, + requested_head_oid, + strategy, + base, + head, + merge_base_oid, + identity, + }) + } + + pub fn is_immutable(&self) -> bool { + self.base.is_immutable() && self.head.is_immutable() + } + + pub fn requested(&self) -> &DiffMode { + &self.requested + } + + pub fn requested_base_oid(&self) -> Option<&GitObjectId> { + self.requested_base_oid.as_ref() + } + + pub fn requested_head_oid(&self) -> Option<&GitObjectId> { + self.requested_head_oid.as_ref() + } + + pub fn strategy(&self) -> ComparisonStrategy { + self.strategy + } + + pub fn base(&self) -> &ReviewSnapshot { + &self.base + } + + pub fn head(&self) -> &ReviewSnapshot { + &self.head + } + + pub fn merge_base_oid(&self) -> Option<&GitObjectId> { + self.merge_base_oid.as_ref() + } + + pub fn identity(&self) -> &ReviewComparisonId { + &self.identity + } + + pub fn validate(&self) -> Result<(), ReviewModelError> { + if self.identity.0.is_empty() { + return Err(ReviewModelError::new("comparison identity cannot be empty")); + } + if !self.base.has_valid_fingerprint() || !self.head.has_valid_fingerprint() { + return Err(ReviewModelError::new("mutable fingerprint cannot be empty")); + } + + match (&self.requested, self.strategy, &self.base, &self.head) { + ( + DiffMode::WorkingTree, + ComparisonStrategy::IndexToWorkingTree, + ReviewSnapshot::Index { .. }, + ReviewSnapshot::WorkingTree { .. }, + ) => { + self.require_no_merge_base()?; + self.require_requested_oids(None, None) + } + ( + DiffMode::Staged, + ComparisonStrategy::HeadToIndex, + ReviewSnapshot::Commit { oid }, + ReviewSnapshot::Index { .. }, + ) => { + self.require_no_merge_base()?; + self.require_requested_oids(Some(oid), None) + } + ( + DiffMode::Commit(_), + ComparisonStrategy::ParentToCommit, + ReviewSnapshot::Commit { oid: base_oid }, + ReviewSnapshot::Commit { oid: head_oid }, + ) => { + self.require_no_merge_base()?; + self.require_requested_oids(Some(base_oid), Some(head_oid)) + } + ( + DiffMode::Commit(_), + ComparisonStrategy::EmptyTreeToCommit, + ReviewSnapshot::EmptyTree { .. }, + ReviewSnapshot::Commit { oid: head_oid }, + ) => { + self.require_no_merge_base()?; + self.require_requested_oids(None, Some(head_oid)) + } + ( + DiffMode::BranchCompare { .. }, + ComparisonStrategy::MergeBaseToHead, + ReviewSnapshot::Commit { oid: base_oid }, + ReviewSnapshot::Commit { oid: head_oid }, + ) => { + if self.merge_base_oid.as_ref() != Some(base_oid) { + return Err(ReviewModelError::new( + "merge-base strategy must use the merge-base as effective base", + )); + } + if self.requested_base_oid.is_none() { + return Err(ReviewModelError::new( + "branch comparison requires a requested base OID", + )); + } + self.require_requested_head(head_oid) + } + ( + DiffMode::BranchCompare { .. }, + ComparisonStrategy::DirectBaseToHeadWithoutMergeBase, + ReviewSnapshot::Commit { oid: base_oid }, + ReviewSnapshot::Commit { oid: head_oid }, + ) => { + self.require_no_merge_base()?; + self.require_requested_oids(Some(base_oid), Some(head_oid)) + } + _ => Err(ReviewModelError::new( + "comparison strategy does not match its requested mode and snapshots", + )), + } + } + + fn require_no_merge_base(&self) -> Result<(), ReviewModelError> { + if self.merge_base_oid.is_some() { + Err(ReviewModelError::new( + "comparison strategy cannot carry a merge-base OID", + )) + } else { + Ok(()) + } + } + + fn require_requested_oids( + &self, + base: Option<&GitObjectId>, + head: Option<&GitObjectId>, + ) -> Result<(), ReviewModelError> { + if self.requested_base_oid.as_ref() != base || self.requested_head_oid.as_ref() != head { + Err(ReviewModelError::new( + "requested OIDs do not match the comparison snapshots", + )) + } else { + Ok(()) + } + } + + fn require_requested_head(&self, head: &GitObjectId) -> Result<(), ReviewModelError> { + if self.requested_head_oid.as_ref() == Some(head) { + Ok(()) + } else { + Err(ReviewModelError::new( + "effective branch head must match the requested head OID", + )) + } + } +} + +/// A comparison proven to contain immutable snapshots only. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct ImmutableResolvedComparison(ResolvedComparison); + +impl ImmutableResolvedComparison { + pub fn as_resolved(&self) -> &ResolvedComparison { + &self.0 + } + + pub fn into_resolved(self) -> ResolvedComparison { + self.0 + } + + pub fn requested(&self) -> &DiffMode { + self.0.requested() + } + + pub fn requested_base_oid(&self) -> Option<&GitObjectId> { + self.0.requested_base_oid() + } + + pub fn requested_head_oid(&self) -> Option<&GitObjectId> { + self.0.requested_head_oid() + } + + pub fn strategy(&self) -> ComparisonStrategy { + self.0.strategy() + } + + pub fn base(&self) -> &ReviewSnapshot { + self.0.base() + } + + pub fn head(&self) -> &ReviewSnapshot { + self.0.head() + } + + pub fn merge_base_oid(&self) -> Option<&GitObjectId> { + self.0.merge_base_oid() + } + + pub fn identity(&self) -> &ReviewComparisonId { + self.0.identity() + } +} + +impl TryFrom for ImmutableResolvedComparison { + type Error = ReviewModelError; + + fn try_from(value: ResolvedComparison) -> Result { + value.validate()?; + if !value.is_immutable() { + return Err(ReviewModelError::new( + "exact diff and source requests require immutable snapshots", + )); + } + Ok(Self(value)) + } +} + +impl<'de> Deserialize<'de> for ImmutableResolvedComparison { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let comparison = ResolvedComparison::deserialize(deserializer)?; + Self::try_from(comparison).map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReviewModelError(String); + +impl ReviewModelError { + fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl fmt::Display for ReviewModelError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for ReviewModelError {} + +/// Origin of a deterministic or syntax-derived review fact. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "source", rename_all = "snake_case")] +pub enum FactProvenance { + Git, + RuleDerived { rule_id: String }, + SyntaxDerived { language: String, parser: String }, +} + +/// Why an analysis result stopped before covering every candidate. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TruncationReason { + ItemLimit, + ByteLimit, + TimeLimit, + CaptureLimit, + ResponseLimit, + Cancelled, + Other, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewTruncation { + pub reason: TruncationReason, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +/// Inspectable coverage shared by deterministic and syntax-derived analysis. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "ReviewCoverageWire")] +pub struct ReviewCoverage { + total_items: u64, + analyzed_items: u64, + pending_items: u64, + skipped_items: u64, + unsupported_items: u64, + failed_items: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + truncation: Option, +} + +#[derive(Deserialize)] +struct ReviewCoverageWire { + total_items: u64, + analyzed_items: u64, + pending_items: u64, + skipped_items: u64, + unsupported_items: u64, + failed_items: u64, + #[serde(default)] + truncation: Option, +} + +impl TryFrom for ReviewCoverage { + type Error = ReviewModelError; + + fn try_from(value: ReviewCoverageWire) -> Result { + Self::new( + value.total_items, + value.analyzed_items, + value.pending_items, + value.skipped_items, + value.unsupported_items, + value.failed_items, + value.truncation, + ) + } +} + +impl ReviewCoverage { + #[allow(clippy::too_many_arguments)] + pub fn new( + total_items: u64, + analyzed_items: u64, + pending_items: u64, + skipped_items: u64, + unsupported_items: u64, + failed_items: u64, + truncation: Option, + ) -> Result { + let categorized = analyzed_items + .checked_add(pending_items) + .and_then(|count| count.checked_add(skipped_items)) + .and_then(|count| count.checked_add(unsupported_items)) + .and_then(|count| count.checked_add(failed_items)); + if categorized != Some(total_items) { + return Err(ReviewModelError::new( + "coverage categories must sum to total_items", + )); + } + Ok(Self { + total_items, + analyzed_items, + pending_items, + skipped_items, + unsupported_items, + failed_items, + truncation, + }) + } + + pub fn total_items(&self) -> u64 { + self.total_items + } + + pub fn analyzed_items(&self) -> u64 { + self.analyzed_items + } + + pub fn pending_items(&self) -> u64 { + self.pending_items + } + + pub fn skipped_items(&self) -> u64 { + self.skipped_items + } + + pub fn unsupported_items(&self) -> u64 { + self.unsupported_items + } + + pub fn failed_items(&self) -> u64 { + self.failed_items + } + + pub fn truncation(&self) -> Option<&ReviewTruncation> { + self.truncation.as_ref() + } + + pub fn is_complete(&self) -> bool { + self.truncation.is_none() + && self.analyzed_items == self.total_items + && self.pending_items == 0 + && self.skipped_items == 0 + && self.unsupported_items == 0 + && self.failed_items == 0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FileRole { + Implementation, + Test, + Fixture, + Snapshot, + Example, + Documentation, + Generated, + Vendored, + Lockfile, + Configuration, + Unclassified, +} + +/// Stable identity of one deterministic file-classification rule. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct ClassificationRuleId(String); + +impl ClassificationRuleId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() || value.chars().any(char::is_whitespace) { + return Err(ReviewModelError::new( + "classification rule ID must be non-empty and contain no whitespace", + )); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for ClassificationRuleId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::new(value).map_err(serde::de::Error::custom) + } +} + +/// A file role and the sole rule identity that produced it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FileClassification { + role: FileRole, + rule_id: ClassificationRuleId, +} + +impl FileClassification { + pub fn from_rule(role: FileRole, rule_id: impl Into) -> Result { + Ok(Self { + role, + rule_id: ClassificationRuleId::new(rule_id)?, + }) + } + + pub fn role(&self) -> FileRole { + self.role + } + + pub fn rule_id(&self) -> &ClassificationRuleId { + &self.rule_id + } + + pub fn provenance(&self) -> FactProvenance { + FactProvenance::RuleDerived { + rule_id: self.rule_id.0.clone(), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReviewFileStatus { + Added, + Deleted, + Modified, + Renamed, + Copied, + TypeChanged, + ModeChanged, + SubmoduleChanged, + Unmerged, + Unknown, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewSubmoduleChange { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub old_oid: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_oid: Option, + #[serde(default)] + pub worktree_dirty: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewFileFact { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub old_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_path: Option, + pub status: ReviewFileStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub similarity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub old_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub new_mode: Option, + /// `None` when Git reports `-`, normally for binary content. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lines_added: Option, + /// `None` when Git reports `-`, normally for binary content. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lines_deleted: Option, + pub binary: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub submodule: Option, + pub classification: FileClassification, + pub provenance: FactProvenance, +} + +impl ReviewFileFact { + pub fn path_on(&self, side: ComparisonSide) -> Option<&str> { + match side { + ComparisonSide::Base => self.old_path.as_deref(), + ComparisonSide::Head => self.new_path.as_deref(), + } + } +} + +/// One full-OID entry in the comparison's chronological commit ledger. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewCommitFact { + pub oid: GitObjectId, + pub parent_oids: Vec, + pub subject: String, + pub author_name: String, + pub timestamp: i64, + pub provenance: FactProvenance, +} + +/// Raw totals for an inventory. Binary files contribute to file counts only. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewChangeTotals { + pub commits: u64, + pub files: u64, + pub files_added: u64, + pub files_deleted: u64, + pub files_modified: u64, + pub files_renamed: u64, + pub files_copied: u64, + pub files_type_changed: u64, + pub files_mode_changed: u64, + pub submodule_changes: u64, + pub binary_files: u64, + pub lines_added: u64, + pub lines_deleted: u64, + pub provenance: FactProvenance, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewInventory { + pub comparison: ResolvedComparison, + pub totals: ReviewChangeTotals, + pub commits: Vec, + pub files: Vec, + pub coverage: ReviewCoverage, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ComparisonSide { + Base, + Head, +} + +/// Descriptive syntax context only; this is not a stable symbol identity. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SymbolContext { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewNavigationTarget { + pub path: String, + pub side: ComparisonSide, + /// One-based source line. + pub line: NonZeroU32, + /// Zero-based UTF-8 byte offset when the producer has one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub byte_offset: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub symbol_context: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewDiffRequest { + pub comparison: ImmutableResolvedComparison, + #[serde(default)] + pub ignore_whitespace: bool, +} + +impl ReviewDiffRequest { + pub fn new( + comparison: ResolvedComparison, + ignore_whitespace: bool, + ) -> Result { + Ok(Self { + comparison: comparison.try_into()?, + ignore_whitespace, + }) + } +} + +/// Exact source request with distinct paths for renames and deletions. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "ReviewSourceRequestWire")] +pub struct ReviewSourceRequest { + comparison: ImmutableResolvedComparison, + #[serde(default, skip_serializing_if = "Option::is_none")] + old_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + new_path: Option, +} + +#[derive(Deserialize)] +struct ReviewSourceRequestWire { + comparison: ImmutableResolvedComparison, + #[serde(default)] + old_path: Option, + #[serde(default)] + new_path: Option, +} + +impl TryFrom for ReviewSourceRequest { + type Error = ReviewModelError; + + fn try_from(value: ReviewSourceRequestWire) -> Result { + Self::from_immutable(value.comparison, value.old_path, value.new_path) + } +} + +impl ReviewSourceRequest { + pub fn new( + comparison: ResolvedComparison, + old_path: Option, + new_path: Option, + ) -> Result { + Self::from_immutable(comparison.try_into()?, old_path, new_path) + } + + fn from_immutable( + comparison: ImmutableResolvedComparison, + old_path: Option, + new_path: Option, + ) -> Result { + if old_path.is_none() && new_path.is_none() { + return Err(ReviewModelError::new( + "source request requires an old path, a new path, or both", + )); + } + Ok(Self { + comparison, + old_path, + new_path, + }) + } + + pub fn comparison(&self) -> &ImmutableResolvedComparison { + &self.comparison + } + + pub fn old_path(&self) -> Option<&str> { + self.old_path.as_deref() + } + + pub fn new_path(&self) -> Option<&str> { + self.new_path.as_deref() + } +} + +/// Exact source contents paired with the immutable request that produced them. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "ExactReviewSourceResponseWire")] +pub struct ExactReviewSourceResponse { + comparison: ImmutableResolvedComparison, + #[serde(default, skip_serializing_if = "Option::is_none")] + old_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + new_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + old_content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + new_content: Option, +} + +#[derive(Deserialize)] +struct ExactReviewSourceResponseWire { + comparison: ImmutableResolvedComparison, + #[serde(default)] + old_path: Option, + #[serde(default)] + new_path: Option, + #[serde(default)] + old_content: Option, + #[serde(default)] + new_content: Option, +} + +impl TryFrom for ExactReviewSourceResponse { + type Error = ReviewModelError; + + fn try_from(value: ExactReviewSourceResponseWire) -> Result { + let request = + ReviewSourceRequest::from_immutable(value.comparison, value.old_path, value.new_path)?; + Self::new(request, value.old_content, value.new_content) + } +} + +impl ExactReviewSourceResponse { + pub fn new( + request: ReviewSourceRequest, + old_content: Option, + new_content: Option, + ) -> Result { + if request.old_path.is_some() != old_content.is_some() { + return Err(ReviewModelError::new( + "exact source response must contain content for exactly the requested old side", + )); + } + if request.new_path.is_some() != new_content.is_some() { + return Err(ReviewModelError::new( + "exact source response must contain content for exactly the requested new side", + )); + } + Ok(Self { + comparison: request.comparison, + old_path: request.old_path, + new_path: request.new_path, + old_content, + new_content, + }) + } + + pub fn comparison(&self) -> &ImmutableResolvedComparison { + &self.comparison + } + + pub fn old_path(&self) -> Option<&str> { + self.old_path.as_deref() + } + + pub fn new_path(&self) -> Option<&str> { + self.new_path.as_deref() + } + + pub fn old_content(&self) -> Option<&str> { + self.old_content.as_deref() + } + + pub fn new_content(&self) -> Option<&str> { + self.new_content.as_deref() + } + + pub fn into_parts(self) -> (ReviewSourceRequest, Option, Option) { + ( + ReviewSourceRequest { + comparison: self.comparison, + old_path: self.old_path, + new_path: self.new_path, + }, + self.old_content, + self.new_content, + ) + } +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use super::*; + + const REQUESTED_BASE_OID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const MERGE_BASE_OID: &str = "1111111111111111111111111111111111111111"; + const HEAD_OID: &str = "2222222222222222222222222222222222222222"; + + fn oid(value: &str) -> GitObjectId { + GitObjectId::new(value).unwrap() + } + + fn branch_comparison() -> ResolvedComparison { + ResolvedComparison::new( + DiffMode::BranchCompare { + base: "origin/main".to_string(), + head: "feature".to_string(), + }, + Some(oid(REQUESTED_BASE_OID)), + Some(oid(HEAD_OID)), + ComparisonStrategy::MergeBaseToHead, + ReviewSnapshot::Commit { + oid: oid(MERGE_BASE_OID), + }, + ReviewSnapshot::Commit { oid: oid(HEAD_OID) }, + Some(oid(MERGE_BASE_OID)), + ReviewComparisonId(format!("merge-base:{MERGE_BASE_OID}:{HEAD_OID}")), + ) + .unwrap() + } + + fn branch_comparison_json() -> Value { + json!({ + "requested": { + "branch_compare": { + "base": "origin/main", + "head": "feature" + } + }, + "requested_base_oid": REQUESTED_BASE_OID, + "requested_head_oid": HEAD_OID, + "strategy": "merge_base_to_head", + "base": { "kind": "commit", "oid": MERGE_BASE_OID }, + "head": { "kind": "commit", "oid": HEAD_OID }, + "merge_base_oid": MERGE_BASE_OID, + "identity": format!("merge-base:{MERGE_BASE_OID}:{HEAD_OID}") + }) + } + + fn added_file() -> ReviewFileFact { + ReviewFileFact { + old_path: None, + new_path: Some("src/review.rs".to_string()), + status: ReviewFileStatus::Added, + similarity: None, + old_mode: None, + new_mode: Some("100644".to_string()), + lines_added: Some(3), + lines_deleted: Some(0), + binary: false, + submodule: None, + classification: FileClassification::from_rule( + FileRole::Implementation, + "builtin.source.rs", + ) + .unwrap(), + provenance: FactProvenance::Git, + } + } + + fn totals() -> ReviewChangeTotals { + ReviewChangeTotals { + commits: 1, + files: 1, + files_added: 1, + files_deleted: 0, + files_modified: 0, + files_renamed: 0, + files_copied: 0, + files_type_changed: 0, + files_mode_changed: 0, + submodule_changes: 0, + binary_files: 0, + lines_added: 3, + lines_deleted: 0, + provenance: FactProvenance::Git, + } + } + + #[test] + fn comparison_distinguishes_requested_base_from_merge_base() { + let comparison = branch_comparison(); + assert!(matches!( + comparison.requested(), + DiffMode::BranchCompare { base, head } + if base == "origin/main" && head == "feature" + )); + assert_eq!(comparison.strategy(), ComparisonStrategy::MergeBaseToHead); + assert_eq!( + comparison.requested_base_oid().unwrap().as_str(), + REQUESTED_BASE_OID + ); + assert_eq!(comparison.requested_head_oid().unwrap().as_str(), HEAD_OID); + assert_eq!(comparison.base().oid().unwrap().as_str(), MERGE_BASE_OID); + assert_eq!(comparison.head().oid().unwrap().as_str(), HEAD_OID); + assert_eq!( + comparison.merge_base_oid().unwrap().as_str(), + MERGE_BASE_OID + ); + assert_eq!( + comparison.identity().0, + format!("merge-base:{MERGE_BASE_OID}:{HEAD_OID}") + ); + assert_ne!(comparison.requested_base_oid(), comparison.base().oid()); + assert_eq!( + serde_json::to_value(comparison).unwrap(), + branch_comparison_json() + ); + } + + #[test] + fn immutable_and_mutable_comparisons_are_distinct() { + assert!(branch_comparison().is_immutable()); + + let staged = ResolvedComparison::new( + DiffMode::Staged, + Some(oid(MERGE_BASE_OID)), + None, + ComparisonStrategy::HeadToIndex, + ReviewSnapshot::Commit { + oid: oid(MERGE_BASE_OID), + }, + ReviewSnapshot::Index { + fingerprint: "index-v1".to_string(), + }, + None, + ReviewComparisonId("staged:index-v1".to_string()), + ) + .unwrap(); + + assert!(!staged.is_immutable()); + assert!(ReviewDiffRequest::new(staged.clone(), false).is_err()); + assert!(ReviewSourceRequest::new(staged.clone(), None, None).is_err()); + + let mutable_json = serde_json::to_value(staged).unwrap(); + let diff_json = json!({ "comparison": mutable_json, "ignore_whitespace": false }); + assert!(serde_json::from_value::(diff_json).is_err()); + } + + #[test] + fn invalid_strategy_snapshot_combination_is_rejected() { + let mut value = branch_comparison_json(); + value["strategy"] = json!("head_to_index"); + assert!(serde_json::from_value::(value).is_err()); + } + + #[test] + fn malformed_and_abbreviated_object_ids_are_rejected() { + for value in ["abc1234", "z111111111111111111111111111111111111111"] { + assert!(serde_json::from_value::(json!(value)).is_err()); + } + assert!(GitObjectId::new("f".repeat(40)).is_ok()); + assert!(GitObjectId::new("f".repeat(64)).is_ok()); + + let mut comparison = branch_comparison_json(); + comparison["requested_head_oid"] = json!("2222222"); + assert!(serde_json::from_value::(comparison).is_err()); + } + + #[test] + fn renamed_and_deleted_navigation_uses_the_selected_side() { + let renamed = ReviewFileFact { + old_path: Some("src/old.rs".to_string()), + new_path: Some("src/new.rs".to_string()), + status: ReviewFileStatus::Renamed, + similarity: Some(94), + old_mode: Some("100644".to_string()), + new_mode: Some("100644".to_string()), + lines_added: Some(2), + lines_deleted: Some(1), + binary: false, + submodule: None, + classification: FileClassification::from_rule( + FileRole::Implementation, + "builtin.source.rs", + ) + .unwrap(), + provenance: FactProvenance::Git, + }; + assert_eq!(renamed.path_on(ComparisonSide::Base), Some("src/old.rs")); + assert_eq!(renamed.path_on(ComparisonSide::Head), Some("src/new.rs")); + + let deleted = ReviewFileFact { + old_path: Some("src/deleted.rs".to_string()), + new_path: None, + status: ReviewFileStatus::Deleted, + similarity: None, + old_mode: Some("100644".to_string()), + new_mode: None, + lines_added: Some(0), + lines_deleted: Some(8), + binary: false, + submodule: None, + classification: FileClassification::from_rule( + FileRole::Implementation, + "builtin.source.rs", + ) + .unwrap(), + provenance: FactProvenance::Git, + }; + assert_eq!( + deleted.path_on(ComparisonSide::Base), + Some("src/deleted.rs") + ); + assert_eq!(deleted.path_on(ComparisonSide::Head), None); + + let target = ReviewNavigationTarget { + path: "src/deleted.rs".to_string(), + side: ComparisonSide::Base, + line: NonZeroU32::new(4).unwrap(), + byte_offset: Some(31), + symbol_context: Some(SymbolContext { + name: "removed_function".to_string(), + kind: Some("function".to_string()), + signature: None, + }), + }; + assert_eq!( + serde_json::to_value(target).unwrap(), + json!({ + "path": "src/deleted.rs", + "side": "base", + "line": 4, + "byte_offset": 31, + "symbol_context": { + "name": "removed_function", + "kind": "function" + } + }) + ); + } + + #[test] + fn inventory_and_provenance_have_stable_json_shapes() { + let inventory = ReviewInventory { + comparison: branch_comparison(), + totals: totals(), + commits: vec![ReviewCommitFact { + oid: oid(HEAD_OID), + parent_oids: vec![oid(MERGE_BASE_OID)], + subject: "feat: add review facts".to_string(), + author_name: "Reviewer".to_string(), + timestamp: 1_786_742_400, + provenance: FactProvenance::Git, + }], + files: vec![added_file()], + coverage: ReviewCoverage::new(1, 1, 0, 0, 0, 0, None).unwrap(), + }; + let value = serde_json::to_value(&inventory).unwrap(); + assert_eq!( + value, + json!({ + "comparison": branch_comparison_json(), + "totals": { + "commits": 1, + "files": 1, + "files_added": 1, + "files_deleted": 0, + "files_modified": 0, + "files_renamed": 0, + "files_copied": 0, + "files_type_changed": 0, + "files_mode_changed": 0, + "submodule_changes": 0, + "binary_files": 0, + "lines_added": 3, + "lines_deleted": 0, + "provenance": { "source": "git" } + }, + "commits": [{ + "oid": HEAD_OID, + "parent_oids": [MERGE_BASE_OID], + "subject": "feat: add review facts", + "author_name": "Reviewer", + "timestamp": 1_786_742_400_i64, + "provenance": { "source": "git" } + }], + "files": [{ + "new_path": "src/review.rs", + "status": "added", + "new_mode": "100644", + "lines_added": 3, + "lines_deleted": 0, + "binary": false, + "classification": { + "role": "implementation", + "rule_id": "builtin.source.rs" + }, + "provenance": { "source": "git" } + }], + "coverage": { + "total_items": 1, + "analyzed_items": 1, + "pending_items": 0, + "skipped_items": 0, + "unsupported_items": 0, + "failed_items": 0 + } + }) + ); + assert_eq!( + serde_json::to_value(FactProvenance::SyntaxDerived { + language: "rust".to_string(), + parser: "tree-sitter-rust".to_string(), + }) + .unwrap(), + json!({ + "source": "syntax_derived", + "language": "rust", + "parser": "tree-sitter-rust" + }) + ); + assert_eq!( + serde_json::from_value::(value).unwrap(), + inventory + ); + + let contradictory = json!({ + "role": "implementation", + "rule_id": "builtin.source.rs", + "provenance": { "source": "git" } + }); + assert!(serde_json::from_value::(contradictory).is_err()); + assert_eq!( + inventory.files[0].classification.provenance(), + FactProvenance::RuleDerived { + rule_id: "builtin.source.rs".to_string() + } + ); + } + + #[test] + fn exact_request_json_shapes_and_whitespace_default_are_stable() { + let diff = ReviewDiffRequest::new(branch_comparison(), true).unwrap(); + assert_eq!( + serde_json::to_value(&diff).unwrap(), + json!({ + "comparison": branch_comparison_json(), + "ignore_whitespace": true + }) + ); + + let without_option = json!({ "comparison": branch_comparison_json() }); + let decoded: ReviewDiffRequest = serde_json::from_value(without_option).unwrap(); + assert!(!decoded.ignore_whitespace); + + let source = ReviewSourceRequest::new( + branch_comparison(), + Some("src/old.rs".to_string()), + Some("src/new.rs".to_string()), + ) + .unwrap(); + assert_eq!( + serde_json::to_value(source).unwrap(), + json!({ + "comparison": branch_comparison_json(), + "old_path": "src/old.rs", + "new_path": "src/new.rs" + }) + ); + } + + #[test] + fn source_request_requires_the_paths_present_on_each_change_side() { + let addition = + ReviewSourceRequest::new(branch_comparison(), None, Some("src/new.rs".to_string())) + .unwrap(); + assert_eq!(addition.old_path(), None); + assert_eq!(addition.new_path(), Some("src/new.rs")); + + let deletion = ReviewSourceRequest::new( + branch_comparison(), + Some("src/deleted.rs".to_string()), + None, + ) + .unwrap(); + assert_eq!(deletion.old_path(), Some("src/deleted.rs")); + assert_eq!(deletion.new_path(), None); + + let rename = ReviewSourceRequest::new( + branch_comparison(), + Some("src/old.rs".to_string()), + Some("src/new.rs".to_string()), + ) + .unwrap(); + assert_eq!(rename.old_path(), Some("src/old.rs")); + assert_eq!(rename.new_path(), Some("src/new.rs")); + + assert!(ReviewSourceRequest::new(branch_comparison(), None, None).is_err()); + assert!( + serde_json::from_value::(json!({ + "comparison": branch_comparison_json() + })) + .is_err() + ); + } + + #[test] + fn exact_source_response_json_shapes_are_stable() { + let rename = ExactReviewSourceResponse::new( + ReviewSourceRequest::new( + branch_comparison(), + Some("src/old.rs".to_string()), + Some("src/new.rs".to_string()), + ) + .unwrap(), + Some("old source\n".to_string()), + Some("new source\n".to_string()), + ) + .unwrap(); + let rename_json = json!({ + "comparison": branch_comparison_json(), + "old_path": "src/old.rs", + "new_path": "src/new.rs", + "old_content": "old source\n", + "new_content": "new source\n" + }); + assert_eq!(serde_json::to_value(&rename).unwrap(), rename_json); + assert_eq!( + serde_json::from_value::(rename_json).unwrap(), + rename + ); + + let addition = ExactReviewSourceResponse::new( + ReviewSourceRequest::new(branch_comparison(), None, Some("src/added.rs".to_string())) + .unwrap(), + None, + Some(String::new()), + ) + .unwrap(); + assert_eq!( + serde_json::to_value(addition).unwrap(), + json!({ + "comparison": branch_comparison_json(), + "new_path": "src/added.rs", + "new_content": "" + }) + ); + + let deletion = ExactReviewSourceResponse::new( + ReviewSourceRequest::new( + branch_comparison(), + Some("src/deleted.rs".to_string()), + None, + ) + .unwrap(), + Some("deleted source\n".to_string()), + None, + ) + .unwrap(); + assert_eq!( + serde_json::to_value(deletion).unwrap(), + json!({ + "comparison": branch_comparison_json(), + "old_path": "src/deleted.rs", + "old_content": "deleted source\n" + }) + ); + + let (request, old_content, new_content) = rename.into_parts(); + assert_eq!(request.old_path(), Some("src/old.rs")); + assert_eq!(request.new_path(), Some("src/new.rs")); + assert_eq!(old_content.as_deref(), Some("old source\n")); + assert_eq!(new_content.as_deref(), Some("new source\n")); + } + + #[test] + fn exact_source_response_rejects_malformed_or_mutable_sides() { + let comparison = branch_comparison_json(); + for malformed in [ + json!({ + "comparison": comparison, + "old_path": "src/old.rs" + }), + json!({ + "comparison": branch_comparison_json(), + "new_path": "src/new.rs", + "old_content": "unexpected", + "new_content": "new" + }), + json!({ + "comparison": branch_comparison_json() + }), + ] { + assert!(serde_json::from_value::(malformed).is_err()); + } + + let staged = ResolvedComparison::new( + DiffMode::Staged, + Some(oid(MERGE_BASE_OID)), + None, + ComparisonStrategy::HeadToIndex, + ReviewSnapshot::Commit { + oid: oid(MERGE_BASE_OID), + }, + ReviewSnapshot::Index { + fingerprint: "index-v1".to_string(), + }, + None, + ReviewComparisonId("staged:index-v1".to_string()), + ) + .unwrap(); + assert!( + serde_json::from_value::(json!({ + "comparison": serde_json::to_value(staged).unwrap(), + "new_path": "src/new.rs", + "new_content": "new" + })) + .is_err() + ); + + let request = + ReviewSourceRequest::new(branch_comparison(), Some("src/old.rs".to_string()), None) + .unwrap(); + assert!(ExactReviewSourceResponse::new(request, None, None).is_err()); + } + + #[test] + fn partial_coverage_has_a_stable_json_shape() { + let coverage = ReviewCoverage::new( + 12, + 4, + 4, + 1, + 2, + 1, + Some(ReviewTruncation { + reason: TruncationReason::TimeLimit, + limit: Some(500), + observed: Some(731), + detail: Some("parser budget exhausted".to_string()), + }), + ) + .unwrap(); + assert!(!coverage.is_complete()); + assert_eq!( + serde_json::to_value(coverage).unwrap(), + json!({ + "total_items": 12, + "analyzed_items": 4, + "pending_items": 4, + "skipped_items": 1, + "unsupported_items": 2, + "failed_items": 1, + "truncation": { + "reason": "time_limit", + "limit": 500, + "observed": 731, + "detail": "parser budget exhausted" + } + }) + ); + + let invalid = json!({ + "total_items": 12, + "analyzed_items": 4, + "pending_items": 2, + "skipped_items": 1, + "unsupported_items": 2, + "failed_items": 1 + }); + assert!(serde_json::from_value::(invalid).is_err()); + } + + #[test] + fn navigation_rejects_zero_line_values() { + let json = r#"{ + "path":"src/main.rs", + "side":"head", + "line":0 + }"#; + assert!(serde_json::from_str::(json).is_err()); + } +} diff --git a/crates/okena-daemon-core/Cargo.toml b/crates/okena-daemon-core/Cargo.toml index 7b3248fa7..531101db3 100644 --- a/crates/okena-daemon-core/Cargo.toml +++ b/crates/okena-daemon-core/Cargo.toml @@ -11,6 +11,8 @@ okena-core = { path = "../okena-core" } okena-state = { path = "../okena-state" } okena-terminal = { path = "../okena-terminal" } okena-git = { path = "../okena-git" } +okena-review = { path = "../okena-review" } +okena-syntax = { path = "../okena-syntax" } okena-hooks = { path = "../okena-hooks", default-features = false } okena-workspace = { path = "../okena-workspace", default-features = false } okena-services = { path = "../okena-services", default-features = false } diff --git a/crates/okena-daemon-core/src/command_loop.rs b/crates/okena-daemon-core/src/command_loop.rs index bf258e4fc..a15b4b6b3 100644 --- a/crates/okena-daemon-core/src/command_loop.rs +++ b/crates/okena-daemon-core/src/command_loop.rs @@ -2464,6 +2464,7 @@ pub async fn daemon_command_loop( service_tick.clone(), ); let content_search_permits = Arc::new(Semaphore::new(MAX_CONCURRENT_CONTENT_SEARCHES)); + let review_permits = Arc::new(Semaphore::new(crate::review::MAX_CONCURRENT_REVIEWS)); loop { let BridgeMessage { command, reply } = match bridge_rx.recv().await { @@ -2533,6 +2534,30 @@ pub async fn daemon_command_loop( command => command, }; + let command = match command { + RemoteCommand::Action(action) if crate::review::is_review_action(&action) => { + let prepared = { + let workspace = workspace.lock(); + crate::review::prepare_review_action(&workspace, action) + }; + match prepared { + Ok(action) => crate::review::spawn_review_action( + action, + reply, + &runtime, + review_permits.clone(), + ), + Err(error) => { + if let Some(reply) = reply { + let _ = reply.send(CommandResult::Err(error)); + } + } + } + continue; + } + command => command, + }; + let result: CommandResult = match command { RemoteCommand::Action(action) => { match action { diff --git a/crates/okena-daemon-core/src/lib.rs b/crates/okena-daemon-core/src/lib.rs index 4897d0c21..e22098fda 100644 --- a/crates/okena-daemon-core/src/lib.rs +++ b/crates/okena-daemon-core/src/lib.rs @@ -36,6 +36,7 @@ pub mod git_poll; pub mod observers; pub mod pty_loop; pub mod reactor; +mod review; pub mod service_cx; pub mod soft_close; pub mod toast_poll; diff --git a/crates/okena-daemon-core/src/review.rs b/crates/okena-daemon-core/src/review.rs new file mode 100644 index 000000000..4b704d5bd --- /dev/null +++ b/crates/okena-daemon-core/src/review.rs @@ -0,0 +1,2941 @@ +//! Bounded, off-reactor execution for deterministic review actions. + +use std::collections::HashMap; +use std::io::{self, Read, Write}; +use std::num::{NonZeroU32, NonZeroU64}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use okena_core::api::{ActionRequest, CommandResult}; +use okena_core::review::{ + ExactReviewSourceResponse, ImmutableResolvedComparison, ReviewCoverage, ReviewDiffRequest, + ReviewFileFact, ReviewFileStatus, ReviewInventory, ReviewSourceRequest, ReviewTruncation, + TruncationReason, +}; +use okena_core::types::DiffMode; +use okena_git::{ + DiffLineType, ExactReviewDiffResponse, FileDiff, GitError, ReviewGitControl, + ReviewSourceBudget, ReviewSourceBudgetKind, get_exact_review_diff_response_with_control, + get_exact_review_source_response_with_control, get_exact_review_source_with_control, + get_review_inventory_with_control, resolve_review_comparison_with_control, +}; +use okena_review::call_diff::ComparisonStopReason; +use okena_review::classification::classify_file_fact; +use okena_review::structure::compare_structured_file_controlled; +use okena_review::{ + AnalysisError, AnalysisStage, ChangedHunk, ChangedLineRange, FileAnalysisStatus, + LanguageCoverage, OmittedFileGroup, OmittedFileReason, ReviewStructure, StructuredFile, +}; +use okena_syntax::rust::RustAdapter; +use okena_syntax::typescript::TypeScriptAdapter; +use okena_syntax::{ + AnalysisBudget, AnalysisControl, AnalysisInput, SyntaxAdapter, SyntaxLanguage, + SyntaxTruncation, SyntaxTruncationReason, +}; +use okena_workspace::state::Workspace; +use tokio::sync::{Semaphore, oneshot}; + +pub(crate) const MAX_CONCURRENT_REVIEWS: usize = 2; + +const MAX_FILES: usize = 200; +const MAX_SOURCE_SIDE_BYTES: u64 = 2 * 1024 * 1024; +const MAX_SOURCE_TOTAL_BYTES: u64 = 32 * 1024 * 1024; +const MAX_STANDALONE_SOURCE_TOTAL_BYTES: u64 = 4 * 1024 * 1024; +const MAX_CAPTURE_SIDE_BYTES: u64 = 2 * 1024 * 1024; +const MAX_CAPTURE_TOTAL_BYTES: u64 = 32 * 1024 * 1024; +const MAX_RESPONSE_BYTES: usize = 24 * 1024 * 1024; +const MAX_SYMBOLS: u32 = 10_000; +const MAX_CALLS: u32 = 20_000; +const MAX_DIAGNOSTICS: u32 = 64; +const ANALYSIS_TIME_MICROS: u64 = 30 * 1_000_000; +const MAX_AGGREGATE_FACTS: u64 = 100_000; +const MAX_CONSTRUCTED_RESPONSE_BYTES: usize = 20 * 1024 * 1024; +const RESPONSE_CHECKPOINT_BYTES: usize = 16 * 1024; + +enum ReviewRequest { + Inventory(DiffMode), + Diff(ReviewDiffRequest), + Source(Box), + Structure(ReviewDiffRequest), +} + +type FilePathPair = (Option, Option); +type IndexedFileDiffs = HashMap; + +pub(crate) struct PreparedReviewAction { + project_path: PathBuf, + request: ReviewRequest, +} + +#[derive(Default)] +struct ReviewWorkerControl { + cancelled: AtomicBool, + analysis: Mutex>, +} + +impl ReviewWorkerControl { + fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + let analysis = self + .analysis + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + if let Some(analysis) = analysis { + analysis.cancel(); + } + } + + fn checkpoint(&self) -> Result<(), String> { + if self.cancelled.load(Ordering::Acquire) { + Err("review request cancelled".to_string()) + } else { + Ok(()) + } + } + + fn start_analysis(&self) -> AnalysisControl { + let analysis = + AnalysisControl::new(NonZeroU64::new(ANALYSIS_TIME_MICROS).unwrap_or(NonZeroU64::MIN)); + let mut published = self + .analysis + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if self.cancelled.load(Ordering::Acquire) { + analysis.cancel(); + } + *published = Some(analysis.clone()); + analysis + } + + fn analysis(&self) -> Option { + self.analysis + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } +} + +pub(crate) fn is_review_action(action: &ActionRequest) -> bool { + matches!( + action, + ActionRequest::ReviewInventory { .. } + | ActionRequest::ReviewDiff { .. } + | ActionRequest::ReviewSource { .. } + | ActionRequest::ReviewStructure { .. } + ) +} + +/// Copy all workspace-owned state before the caller releases the workspace lock. +pub(crate) fn prepare_review_action( + workspace: &Workspace, + action: ActionRequest, +) -> Result { + let (project_id, request) = match action { + ActionRequest::ReviewInventory { project_id, mode } => { + (project_id, ReviewRequest::Inventory(mode)) + } + ActionRequest::ReviewDiff { + project_id, + request, + } => (project_id, ReviewRequest::Diff(request)), + ActionRequest::ReviewSource { + project_id, + request, + } => (project_id, ReviewRequest::Source(request)), + ActionRequest::ReviewStructure { + project_id, + request, + } => (project_id, ReviewRequest::Structure(request)), + _ => return Err("action is not a review request".to_string()), + }; + let project = workspace + .project(&project_id) + .ok_or_else(|| format!("project not found: {project_id}"))?; + if project.is_remote { + return Err(format!( + "review project is not local to this daemon: {project_id}" + )); + } + Ok(PreparedReviewAction { + project_path: PathBuf::from(&project.path), + request, + }) +} + +pub(crate) fn spawn_review_action( + action: PreparedReviewAction, + reply: Option>, + runtime: &tokio::runtime::Handle, + permits: Arc, +) { + spawn_review_action_with(action, reply, runtime, permits, execute_review_action); +} + +fn spawn_review_action_with( + action: PreparedReviewAction, + reply: Option>, + runtime: &tokio::runtime::Handle, + permits: Arc, + run: Run, +) where + Run: FnOnce(PreparedReviewAction, &ReviewGitControl, &ReviewWorkerControl) -> CommandResult + + Send + + 'static, +{ + if reply.as_ref().is_some_and(oneshot::Sender::is_closed) { + return; + } + let permit = match permits.try_acquire_owned() { + Ok(permit) => permit, + Err(tokio::sync::TryAcquireError::NoPermits) => { + if let Some(reply) = reply { + let _ = reply.send(CommandResult::Err( + "review executor busy: at most 2 review requests may run concurrently" + .to_string(), + )); + } + return; + } + Err(tokio::sync::TryAcquireError::Closed) => { + if let Some(reply) = reply { + let _ = reply.send(CommandResult::Err( + "review executor unavailable".to_string(), + )); + } + return; + } + }; + let worker_runtime = runtime.clone(); + let _task = runtime.spawn(async move { + let _permit = permit; + if reply.as_ref().is_some_and(oneshot::Sender::is_closed) { + return; + } + + let git_control = ReviewGitControl::new(Default::default()); + let worker_control = Arc::new(ReviewWorkerControl::default()); + let worker_git_control = git_control.clone(); + let blocking_control = worker_control.clone(); + let mut worker = worker_runtime + .spawn_blocking(move || run(action, &worker_git_control, &blocking_control)); + + match reply { + Some(mut reply) => { + tokio::select! { + result = &mut worker => { + let result = result.unwrap_or_else(|error| { + CommandResult::Err(format!("review worker failed: {error}")) + }); + let _ = reply.send(result); + } + _ = reply.closed() => { + git_control.cancel(); + worker_control.cancel(); + let _ = worker.await; + } + } + } + None => { + if let Err(error) = worker.await { + log::warn!("detached review worker failed: {error}"); + } + } + } + }); +} + +fn execute_review_action( + action: PreparedReviewAction, + git_control: &ReviewGitControl, + control: &ReviewWorkerControl, +) -> CommandResult { + let result = match action.request { + ReviewRequest::Inventory(mode) => { + build_inventory(&action.project_path, mode, git_control, control) + .and_then(|response| serialize_inventory_response(response, control)) + } + ReviewRequest::Diff(request) => { + get_exact_review_diff_response_with_control(&action.project_path, &request, git_control) + .map_err(|error| error.to_string()) + .and_then(|response| serialize_diff_response(response, control)) + } + ReviewRequest::Source(request) => { + build_source(&action.project_path, &request, git_control, control) + .and_then(|response| serialize_source_response(response, control)) + } + ReviewRequest::Structure(request) => { + build_structure(&action.project_path, request, git_control, control) + .and_then(|response| serialize_structure_response(response, control)) + } + }; + match result { + Ok(value) => CommandResult::Ok(Some(value)), + Err(error) => CommandResult::Err(error), + } +} + +fn build_source( + project_path: &Path, + request: &ReviewSourceRequest, + git_control: &ReviewGitControl, + control: &ReviewWorkerControl, +) -> Result { + control.checkpoint()?; + let budget = ReviewSourceBudget::new(MAX_SOURCE_SIDE_BYTES, MAX_STANDALONE_SOURCE_TOTAL_BYTES) + .map_err(|error| error.to_string())?; + let response = + get_exact_review_source_response_with_control(project_path, request, budget, git_control) + .map_err(|error| error.to_string())?; + control.checkpoint()?; + Ok(response) +} + +fn build_inventory( + project_path: &Path, + mode: DiffMode, + git_control: &ReviewGitControl, + control: &ReviewWorkerControl, +) -> Result { + if matches!(mode, DiffMode::WorkingTree | DiffMode::Staged) { + return Err("review inventory V1 requires an immutable commit or branch comparison".into()); + } + let resolved = resolve_review_comparison_with_control(project_path, mode, git_control) + .map_err(|error| error.to_string())?; + let immutable = ImmutableResolvedComparison::try_from(resolved) + .map_err(|error| format!("resolved review comparison is not immutable: {error}"))?; + let mut inventory = get_review_inventory_with_control(project_path, &immutable, git_control) + .map_err(|error| error.to_string())?; + control.checkpoint()?; + classify_inventory(&mut inventory, control)?; + Ok(inventory) +} + +fn classify_inventory( + inventory: &mut ReviewInventory, + control: &ReviewWorkerControl, +) -> Result<(), String> { + for file in &mut inventory.files { + control.checkpoint()?; + file.classification = classify_file_fact(file).map_err(|error| { + format!( + "failed to classify review path {}: {error}", + selected_path(file).unwrap_or("") + ) + })?; + } + Ok(()) +} + +fn build_structure( + project_path: &Path, + request: ReviewDiffRequest, + git_control: &ReviewGitControl, + control: &ReviewWorkerControl, +) -> Result { + let exact_diff = + get_exact_review_diff_response_with_control(project_path, &request, git_control) + .map_err(|error| error.to_string())?; + control.checkpoint()?; + let (comparison, diff) = exact_diff.into_parts(); + let mut inventory = get_review_inventory_with_control(project_path, &comparison, git_control) + .map_err(|error| error.to_string())?; + control.checkpoint()?; + classify_inventory(&mut inventory, control)?; + let mut diffs = index_file_diffs(diff.files, control)?; + control.checkpoint()?; + let analysis_control = control.start_analysis(); + control.checkpoint()?; + + let mut files = Vec::with_capacity(inventory.files.len().min(MAX_FILES)); + let mut omissions = Vec::::new(); + let mut source_bytes = 0_u64; + let mut capture_bytes = 0_u64; + let mut aggregate_facts = 0_u64; + let mut response_bytes = 1024_usize; + let mut analyzable_started = 0_usize; + let mut halted: Option<(OmittedFileReason, ReviewTruncation)> = None; + for fact in &inventory.files { + control.checkpoint()?; + let key = (fact.old_path.clone(), fact.new_path.clone()); + let Some(file_diff) = diffs.remove(&key) else { + if request.ignore_whitespace { + add_omission( + &mut omissions, + detect_language(fact), + OmittedFileReason::WhitespaceIgnored, + None, + ); + continue; + } + return Err(format!( + "exact diff omitted inventory path {}", + display_paths(fact) + )); + }; + if let Some((language, reason)) = deterministic_omission(fact, file_diff.hunks.is_empty()) { + add_omission(&mut omissions, language, reason, None); + continue; + } + let language = detect_language(fact); + if let Some((reason, truncation)) = halted_omission(&halted) { + add_omission(&mut omissions, language, reason, Some(truncation)); + continue; + } + if aggregate_facts >= MAX_AGGREGATE_FACTS { + let truncation = measured_truncation( + TruncationReason::CaptureLimit, + MAX_AGGREGATE_FACTS, + aggregate_facts + .checked_add(1) + .ok_or_else(|| "aggregate fact observation overflowed".to_string())?, + "aggregate structured facts", + ); + add_omission( + &mut omissions, + language, + OmittedFileReason::FactLimit, + Some(truncation.clone()), + ); + halted = Some((OmittedFileReason::FactLimit, truncation)); + continue; + } + if response_bytes >= MAX_CONSTRUCTED_RESPONSE_BYTES { + let limit = u64::try_from(MAX_CONSTRUCTED_RESPONSE_BYTES) + .map_err(|_| "response byte limit does not fit u64".to_string())?; + let truncation = measured_truncation( + TruncationReason::ResponseLimit, + limit, + limit + .checked_add(1) + .ok_or_else(|| "response byte observation overflowed".to_string())?, + "constructed structured-review response", + ); + add_omission( + &mut omissions, + language, + OmittedFileReason::ResponseLimit, + Some(truncation.clone()), + ); + halted = Some((OmittedFileReason::ResponseLimit, truncation)); + continue; + } + if !claim_analyzable_slot(&mut analyzable_started) { + let truncation = measured_truncation( + TruncationReason::ItemLimit, + MAX_FILES as u64, + MAX_FILES as u64 + 1, + "analyzable structured-review files", + ); + add_omission( + &mut omissions, + language, + OmittedFileReason::FileLimit, + Some(truncation.clone()), + ); + halted = Some((OmittedFileReason::FileLimit, truncation)); + continue; + } + let hunks = changed_hunks(&file_diff, control)?; + match structure_file( + &mut StructureFileContext { + project_path, + comparison: &comparison, + source_bytes: &mut source_bytes, + capture_bytes: &mut capture_bytes, + git_control, + analysis_control: &analysis_control, + worker_control: control, + }, + fact, + hunks, + )? { + FileBuildOutcome::Omitted(reason, truncation) => { + add_omission(&mut omissions, language, reason, Some(truncation.clone())); + if matches!( + reason, + OmittedFileReason::AggregateByteLimit + | OmittedFileReason::TimeLimit + | OmittedFileReason::Cancelled + ) { + halted = Some((reason, truncation)); + } + } + FileBuildOutcome::Halted(reason, truncation) => { + add_omission(&mut omissions, language, reason, Some(truncation.clone())); + halted = Some((reason, truncation)); + } + FileBuildOutcome::File(file) => { + let file = *file; + control.checkpoint()?; + if let Some((reason, truncation)) = analysis_omission_now(&analysis_control)? { + add_omission(&mut omissions, language, reason, Some(truncation.clone())); + halted = Some((reason, truncation)); + continue; + } + let fact_count = structured_fact_count(&file, control)?; + if let Some((reason, truncation)) = analysis_omission_now(&analysis_control)? { + add_omission(&mut omissions, language, reason, Some(truncation.clone())); + halted = Some((reason, truncation)); + continue; + } + let observed = aggregate_facts + .checked_add(fact_count) + .ok_or_else(|| "aggregate structured fact count overflowed".to_string())?; + if observed > MAX_AGGREGATE_FACTS { + let truncation = measured_truncation( + TruncationReason::CaptureLimit, + MAX_AGGREGATE_FACTS, + observed, + "aggregate structured facts", + ); + add_omission( + &mut omissions, + language, + OmittedFileReason::FactLimit, + Some(truncation.clone()), + ); + halted = Some((OmittedFileReason::FactLimit, truncation)); + continue; + } + let remaining = MAX_CONSTRUCTED_RESPONSE_BYTES + .checked_sub(response_bytes) + .ok_or_else(|| { + "constructed response byte count exceeded its limit".to_string() + })?; + let file_bytes = + match serialized_file_size(&file, remaining, control, &analysis_control)? { + MeasuredFileSize::Bytes(bytes) => bytes, + MeasuredFileSize::Exceeded => { + let truncation = measured_truncation( + TruncationReason::ResponseLimit, + MAX_CONSTRUCTED_RESPONSE_BYTES as u64, + MAX_CONSTRUCTED_RESPONSE_BYTES as u64 + 1, + "constructed structured-review response", + ); + add_omission( + &mut omissions, + language, + OmittedFileReason::ResponseLimit, + Some(truncation.clone()), + ); + halted = Some((OmittedFileReason::ResponseLimit, truncation)); + continue; + } + MeasuredFileSize::AnalysisStopped(reason, truncation) => { + add_omission( + &mut omissions, + language, + reason, + Some(truncation.clone()), + ); + halted = Some((reason, truncation)); + continue; + } + }; + if let Some((reason, truncation)) = analysis_omission_now(&analysis_control)? { + add_omission(&mut omissions, language, reason, Some(truncation.clone())); + halted = Some((reason, truncation)); + continue; + } + aggregate_facts = observed; + response_bytes = response_bytes + .checked_add(file_bytes) + .ok_or_else(|| "constructed response byte count overflowed".to_string())?; + files.push(file); + } + } + } + if !diffs.is_empty() { + return Err(format!( + "exact diff contained {} path(s) absent from inventory", + diffs.len() + )); + } + + control.checkpoint()?; + ensure_analysis_active(&analysis_control, "before final review coverage")?; + let omissions = finish_omissions(omissions)?; + let coverage = coverage_for(&files, &omissions, None)?; + let language_coverage = language_coverage_for(&files, &omissions)?; + control.checkpoint()?; + ensure_analysis_active(&analysis_control, "before final review construction")?; + let response = ReviewStructure::new_with_omissions( + comparison, + files, + omissions, + coverage, + language_coverage, + Vec::new(), + ) + .map_err(|error| error.to_string())?; + control.checkpoint()?; + ensure_analysis_active(&analysis_control, "before accepting final review structure")?; + Ok(response) +} + +enum FileBuildOutcome { + File(Box), + Omitted(OmittedFileReason, ReviewTruncation), + Halted(OmittedFileReason, ReviewTruncation), +} + +fn halted_omission( + halted: &Option<(OmittedFileReason, ReviewTruncation)>, +) -> Option<(OmittedFileReason, ReviewTruncation)> { + halted.clone() +} + +enum AggregateSourceCapacity { + Remaining(u64), + Exhausted(ReviewTruncation), +} + +fn aggregate_source_capacity(consumed: u64) -> Result { + if consumed < MAX_SOURCE_TOTAL_BYTES { + return Ok(AggregateSourceCapacity::Remaining( + MAX_SOURCE_TOTAL_BYTES - consumed, + )); + } + let first_rejected_byte = MAX_SOURCE_TOTAL_BYTES + .checked_add(1) + .ok_or_else(|| "aggregate source limit observation overflowed".to_string())?; + Ok(AggregateSourceCapacity::Exhausted(measured_truncation( + TruncationReason::ByteLimit, + MAX_SOURCE_TOTAL_BYTES, + consumed.max(first_rejected_byte), + "aggregate structured-review source", + ))) +} + +fn claim_analyzable_slot(started: &mut usize) -> bool { + if *started >= MAX_FILES { + false + } else { + *started += 1; + true + } +} + +struct StructureFileContext<'a> { + project_path: &'a Path, + comparison: &'a ImmutableResolvedComparison, + source_bytes: &'a mut u64, + capture_bytes: &'a mut u64, + git_control: &'a ReviewGitControl, + analysis_control: &'a AnalysisControl, + worker_control: &'a ReviewWorkerControl, +} + +fn structure_file( + context: &mut StructureFileContext<'_>, + fact: &ReviewFileFact, + hunks: Vec, +) -> Result { + if let Some(truncation) = context + .analysis_control + .stop_truncation(std::time::Instant::now()) + .map_err(|error| error.to_string())? + { + let (reason, truncation) = syntax_omission(&truncation); + return Ok(FileBuildOutcome::Omitted(reason, truncation)); + } + + let (old_language, new_language) = side_languages(fact); + if let (Some(old_language), Some(new_language)) = (old_language, new_language) + && old_language != new_language + { + let path = selected_path(fact).map(str::to_owned); + let errors = vec![ + AnalysisError::new( + path.clone(), + AnalysisStage::Detection, + format!( + "base language {old_language:?} differs from head language {new_language:?}" + ), + ) + .map_err(|error| error.to_string())?, + AnalysisError::new( + path, + AnalysisStage::Comparison, + "cross-language symbol matching is not supported", + ) + .map_err(|error| error.to_string())?, + ]; + return empty_file(fact, None, FileAnalysisStatus::Failed, hunks, errors, None) + .map(Box::new) + .map(FileBuildOutcome::File); + } + let Some(language) = new_language.or(old_language) else { + return Err("analyzable file has no syntax language after deterministic preflight".into()); + }; + + let remaining = match aggregate_source_capacity(*context.source_bytes)? { + AggregateSourceCapacity::Remaining(remaining) => remaining, + AggregateSourceCapacity::Exhausted(truncation) => { + return Ok(FileBuildOutcome::Halted( + OmittedFileReason::AggregateByteLimit, + truncation, + )); + } + }; + let source_request = ReviewSourceRequest::new( + context.comparison.as_resolved().clone(), + fact.old_path.clone(), + fact.new_path.clone(), + ) + .map_err(|error| error.to_string())?; + let source_budget = ReviewSourceBudget::new(MAX_SOURCE_SIDE_BYTES, remaining) + .map_err(|error| error.to_string())?; + let source = match get_exact_review_source_with_control( + context.project_path, + &source_request, + source_budget, + context.git_control, + ) { + Ok(source) => source, + Err(GitError::ReviewSourceBudgetExceeded { + kind, + observed, + limit, + }) => { + let (reason, truncation) = + source_limit_omission(kind, observed, limit, *context.source_bytes)?; + return Ok(FileBuildOutcome::Omitted(reason, truncation)); + } + Err(error) => { + return unsuccessful_file( + fact, + detect_language(fact), + FileAnalysisStatus::Failed, + hunks, + format!("failed to load exact source: {error}"), + AnalysisStage::Parsing, + ) + .map(Box::new) + .map(FileBuildOutcome::File); + } + }; + if fact.old_path.is_some() != source.old_content.is_some() + || fact.new_path.is_some() != source.new_content.is_some() + { + return unsuccessful_file( + fact, + Some(language), + FileAnalysisStatus::Failed, + hunks, + "exact source response did not contain every requested comparison side", + AnalysisStage::Parsing, + ) + .map(Box::new) + .map(FileBuildOutcome::File); + } + let loaded = source + .old_content + .as_ref() + .map(|content| u64::try_from(content.len())) + .transpose() + .map_err(|_| "base source length does not fit the review byte counter".to_string())? + .unwrap_or(0) + .checked_add( + source + .new_content + .as_ref() + .map(|content| u64::try_from(content.len())) + .transpose() + .map_err(|_| "head source length does not fit the review byte counter".to_string())? + .unwrap_or(0), + ) + .ok_or_else(|| "source byte count overflowed".to_string())?; + *context.source_bytes = context + .source_bytes + .checked_add(loaded) + .ok_or_else(|| "aggregate source byte count overflowed".to_string())?; + + let rust = RustAdapter::new(); + let typescript = TypeScriptAdapter::new(); + let adapter: &dyn SyntaxAdapter = if rust.supports(language) { + &rust + } else if typescript.supports(language) { + &typescript + } else { + return unsuccessful_file( + fact, + None, + FileAnalysisStatus::Unsupported, + hunks, + "detected language has no registered syntax adapter", + AnalysisStage::Detection, + ) + .map(Box::new) + .map(FileBuildOutcome::File); + }; + let mut file_capture_bytes = 0_u64; + let old_document = source + .old_content + .map(|content| { + analyze_side_bounded( + adapter, + fact.old_path.as_deref(), + language, + content, + context + .capture_bytes + .checked_add(file_capture_bytes) + .ok_or_else(|| "aggregate capture byte count overflowed".to_string())?, + context.analysis_control, + ) + }) + .transpose(); + let old_document = match old_document { + Ok(Some(CapturedAnalysis::Document { document, bytes })) => { + file_capture_bytes = file_capture_bytes + .checked_add(bytes) + .ok_or_else(|| "file capture byte count overflowed".to_string())?; + Some(document) + } + Ok(Some(CapturedAnalysis::AggregateExhausted(truncation))) => { + return Ok(FileBuildOutcome::Halted( + OmittedFileReason::FactLimit, + truncation, + )); + } + Ok(None) => None, + Err(error) => { + return unsuccessful_file( + fact, + Some(language), + FileAnalysisStatus::Failed, + hunks, + format!("base syntax analysis failed: {error}"), + AnalysisStage::Parsing, + ) + .map(Box::new) + .map(FileBuildOutcome::File); + } + }; + let new_document = source + .new_content + .map(|content| { + analyze_side_bounded( + adapter, + fact.new_path.as_deref(), + language, + content, + context + .capture_bytes + .checked_add(file_capture_bytes) + .ok_or_else(|| "aggregate capture byte count overflowed".to_string())?, + context.analysis_control, + ) + }) + .transpose(); + let new_document = match new_document { + Ok(Some(CapturedAnalysis::Document { document, bytes })) => { + file_capture_bytes = file_capture_bytes + .checked_add(bytes) + .ok_or_else(|| "file capture byte count overflowed".to_string())?; + Some(document) + } + Ok(Some(CapturedAnalysis::AggregateExhausted(truncation))) => { + // Charge the retained base document before globally halting later analysis. + commit_file_capture(context.capture_bytes, file_capture_bytes)?; + return Ok(FileBuildOutcome::Halted( + OmittedFileReason::FactLimit, + truncation, + )); + } + Ok(None) => None, + Err(error) => { + return unsuccessful_file( + fact, + Some(language), + FileAnalysisStatus::Failed, + hunks, + format!("head syntax analysis failed: {error}"), + AnalysisStage::Parsing, + ) + .map(Box::new) + .map(FileBuildOutcome::File); + } + }; + commit_file_capture(context.capture_bytes, file_capture_bytes)?; + + let mut checkpoint_error = None; + let comparison = compare_structured_file_controlled( + fact.old_path.as_deref(), + fact.new_path.as_deref(), + old_document.as_ref(), + new_document.as_ref(), + &hunks, + &mut || { + comparison_stop( + context.worker_control, + context.analysis_control, + &mut checkpoint_error, + ) + }, + ); + if let Some(error) = checkpoint_error { + return Err(error); + } + match comparison { + Ok(file) => Ok(FileBuildOutcome::File(Box::new(file))), + Err(error) if error.stop_reason().is_some() => { + context.worker_control.checkpoint()?; + let truncation = context + .analysis_control + .stop_truncation(std::time::Instant::now()) + .map_err(|error| error.to_string())? + .ok_or_else(|| { + "structured comparison stopped without control evidence".to_string() + })?; + let (reason, truncation) = syntax_omission(&truncation); + Ok(FileBuildOutcome::Omitted(reason, truncation)) + } + Err(error) => unsuccessful_file( + fact, + Some(language), + FileAnalysisStatus::Failed, + hunks, + format!("structured comparison failed: {error}"), + AnalysisStage::Comparison, + ) + .map(Box::new) + .map(FileBuildOutcome::File), + } +} + +fn deterministic_omission( + fact: &ReviewFileFact, + has_no_hunks: bool, +) -> Option<(Option, OmittedFileReason)> { + if fact.binary { + return Some((detect_language(fact), OmittedFileReason::Binary)); + } + if fact.submodule.is_some() + || fact.old_mode.as_deref() == Some("160000") + || fact.new_mode.as_deref() == Some("160000") + { + return Some((detect_language(fact), OmittedFileReason::Submodule)); + } + if fact.status == ReviewFileStatus::ModeChanged && has_no_hunks { + return Some((detect_language(fact), OmittedFileReason::ModeOnly)); + } + let (old_language, new_language) = side_languages(fact); + if fact.old_path.is_some() && old_language.is_none() + || fact.new_path.is_some() && new_language.is_none() + { + return Some((None, OmittedFileReason::UnsupportedLanguage)); + } + None +} + +fn side_languages(fact: &ReviewFileFact) -> (Option, Option) { + let old = fact + .old_path + .as_deref() + .and_then(|path| SyntaxLanguage::from_path(Path::new(path))); + let new = fact + .new_path + .as_deref() + .and_then(|path| SyntaxLanguage::from_path(Path::new(path))); + (old, new) +} + +#[derive(Clone)] +struct OmissionAccumulator { + count: u64, + language: Option, + reason: OmittedFileReason, + truncation: Option, +} + +fn add_omission( + omissions: &mut Vec, + language: Option, + reason: OmittedFileReason, + truncation: Option, +) { + if let Some(existing) = omissions.iter_mut().find(|existing| { + existing.language == language + && existing.reason == reason + && existing.truncation == truncation + }) { + existing.count = existing.count.saturating_add(1); + } else { + omissions.push(OmissionAccumulator { + count: 1, + language, + reason, + truncation, + }); + } +} + +fn finish_omissions(omissions: Vec) -> Result, String> { + omissions + .into_iter() + .map(|omission| { + OmittedFileGroup::new( + omission.count, + omission.language, + omission.reason, + omission.truncation, + ) + .map_err(|error| error.to_string()) + }) + .collect() +} + +fn measured_truncation( + reason: TruncationReason, + limit: u64, + observed: u64, + detail: &str, +) -> ReviewTruncation { + ReviewTruncation { + reason, + limit: Some(limit), + observed: Some(observed), + detail: Some(detail.to_string()), + } +} + +fn syntax_omission(truncation: &SyntaxTruncation) -> (OmittedFileReason, ReviewTruncation) { + match truncation.reason() { + SyntaxTruncationReason::Cancelled => ( + OmittedFileReason::Cancelled, + ReviewTruncation { + reason: TruncationReason::Cancelled, + limit: None, + observed: None, + detail: Some("shared structured-review analysis".to_string()), + }, + ), + SyntaxTruncationReason::Time => ( + OmittedFileReason::TimeLimit, + ReviewTruncation { + reason: TruncationReason::TimeLimit, + limit: truncation.limit(), + observed: truncation.observed(), + detail: Some("shared structured-review analysis".to_string()), + }, + ), + _ => ( + OmittedFileReason::FactLimit, + ReviewTruncation { + reason: TruncationReason::CaptureLimit, + limit: truncation.limit(), + observed: truncation.observed(), + detail: Some("syntax capture".to_string()), + }, + ), + } +} + +fn analysis_omission_at( + control: &AnalysisControl, + now: std::time::Instant, +) -> Result, String> { + control + .stop_truncation(now) + .map_err(|error| error.to_string()) + .map(|truncation| truncation.as_ref().map(syntax_omission)) +} + +fn analysis_omission_now( + control: &AnalysisControl, +) -> Result, String> { + analysis_omission_at(control, std::time::Instant::now()) +} + +fn ensure_analysis_active(control: &AnalysisControl, phase: &str) -> Result<(), String> { + let Some((reason, truncation)) = analysis_omission_now(control)? else { + return Ok(()); + }; + Err(format!( + "structured review stopped {phase}: {reason:?} ({:?})", + truncation.reason + )) +} + +fn source_limit_omission( + kind: ReviewSourceBudgetKind, + observed: u64, + limit: u64, + consumed: u64, +) -> Result<(OmittedFileReason, ReviewTruncation), String> { + match kind { + ReviewSourceBudgetKind::PerFileSourceBytes => Ok(( + OmittedFileReason::SourceByteLimit, + measured_truncation(TruncationReason::ByteLimit, limit, observed, "source side"), + )), + ReviewSourceBudgetKind::AggregateSourceBytes => Ok(( + OmittedFileReason::AggregateByteLimit, + measured_truncation( + TruncationReason::ByteLimit, + MAX_SOURCE_TOTAL_BYTES, + consumed + .checked_add(observed) + .ok_or_else(|| "aggregate source budget observation overflowed".to_string())?, + "aggregate structured-review source", + ), + )), + } +} + +fn structured_fact_count( + file: &StructuredFile, + control: &ReviewWorkerControl, +) -> Result { + let mut outline_count = 0_u64; + let mut pending: Vec<&okena_review::OutlineFact> = file + .old_outline() + .iter() + .chain(file.new_outline()) + .collect(); + while let Some(fact) = pending.pop() { + control.checkpoint()?; + outline_count = outline_count + .checked_add(1) + .ok_or_else(|| "structured fact count overflowed".to_string())?; + pending.extend(fact.children()); + } + let counts = [ + outline_count, + file.symbol_changes().len() as u64, + file.hotspots().len() as u64, + file.call_diff().len() as u64, + ]; + counts.into_iter().try_fold(0_u64, |total, count| { + total + .checked_add(count) + .ok_or_else(|| "structured fact count overflowed".to_string()) + }) +} + +enum MeasuredFileSize { + Bytes(usize), + Exceeded, + AnalysisStopped(OmittedFileReason, ReviewTruncation), +} + +fn serialized_file_size( + file: &StructuredFile, + limit: usize, + control: &ReviewWorkerControl, + analysis: &AnalysisControl, +) -> Result { + let mut writer = SizeLimitedWriter::new(limit, control, analysis); + match serde_json::to_writer(&mut writer, file) { + Ok(()) => Ok(MeasuredFileSize::Bytes(writer.written)), + Err(_) if writer.exceeded => Ok(MeasuredFileSize::Exceeded), + Err(_) if writer.stop_error.is_some() => { + control.checkpoint()?; + if let Some((reason, truncation)) = analysis_omission_now(analysis)? { + return Ok(MeasuredFileSize::AnalysisStopped(reason, truncation)); + } + Err(writer + .stop_error + .unwrap_or_else(|| "review response sizing stopped".to_string())) + } + Err(error) => Err(format!("failed to size structured file response: {error}")), + } +} + +struct SizeLimitedWriter<'a> { + written: usize, + limit: usize, + exceeded: bool, + stop_error: Option, + control: &'a ReviewWorkerControl, + analysis: &'a AnalysisControl, +} + +impl<'a> SizeLimitedWriter<'a> { + fn new(limit: usize, control: &'a ReviewWorkerControl, analysis: &'a AnalysisControl) -> Self { + Self { + written: 0, + limit, + exceeded: false, + stop_error: None, + control, + analysis, + } + } +} + +impl Write for SizeLimitedWriter<'_> { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if let Err(error) = response_checkpoint(self.control, Some(self.analysis)) { + self.stop_error = Some(error); + return Err(io::Error::other("review response sizing stopped")); + } + let write_len = bytes.len().min(RESPONSE_CHECKPOINT_BYTES); + let Some(next) = self.written.checked_add(write_len) else { + self.exceeded = true; + return Err(io::Error::other("structured file size overflowed")); + }; + if next > self.limit { + self.exceeded = true; + return Err(io::Error::other("structured file response limit reached")); + } + self.written = next; + Ok(write_len) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +enum CapturedAnalysis { + Document { + document: okena_syntax::DocumentStructure, + bytes: u64, + }, + AggregateExhausted(ReviewTruncation), +} + +fn commit_file_capture(aggregate: &mut u64, retained: u64) -> Result<(), String> { + *aggregate = aggregate + .checked_add(retained) + .ok_or_else(|| "aggregate capture byte count overflowed".to_string())?; + Ok(()) +} + +fn analyze_side_bounded( + adapter: &dyn SyntaxAdapter, + path: Option<&str>, + language: SyntaxLanguage, + content: String, + retained_before: u64, + control: &AnalysisControl, +) -> Result { + let aggregate_remaining = MAX_CAPTURE_TOTAL_BYTES.saturating_sub(retained_before); + if aggregate_remaining == 0 { + return Ok(CapturedAnalysis::AggregateExhausted(measured_truncation( + TruncationReason::CaptureLimit, + MAX_CAPTURE_TOTAL_BYTES, + retained_before + .checked_add(1) + .ok_or_else(|| "aggregate capture observation overflowed".to_string())?, + "aggregate retained syntax capture", + ))); + } + let capture_limit = MAX_CAPTURE_SIDE_BYTES.min(aggregate_remaining); + let budget = AnalysisBudget::new( + NonZeroU64::new(MAX_SOURCE_SIDE_BYTES).unwrap_or(NonZeroU64::MIN), + NonZeroU32::new(MAX_SYMBOLS).unwrap_or(NonZeroU32::MIN), + NonZeroU32::new(MAX_CALLS).unwrap_or(NonZeroU32::MIN), + NonZeroU32::new(MAX_DIAGNOSTICS).unwrap_or(NonZeroU32::MIN), + ) + .with_max_capture_bytes(NonZeroU64::new(capture_limit).unwrap_or(NonZeroU64::MIN)); + let path = path.ok_or_else(|| "source content has no comparison path".to_string())?; + let input = AnalysisInput::new(path, language, content).map_err(|error| error.to_string())?; + let document = adapter + .analyze(input, budget, control) + .map_err(|error| error.to_string())?; + let bytes = document.estimated_owned_bytes(); + let observed = retained_before + .checked_add(bytes) + .ok_or_else(|| "aggregate capture byte count overflowed".to_string())?; + if observed > MAX_CAPTURE_TOTAL_BYTES { + return Ok(CapturedAnalysis::AggregateExhausted(measured_truncation( + TruncationReason::CaptureLimit, + MAX_CAPTURE_TOTAL_BYTES, + observed, + "aggregate retained syntax capture", + ))); + } + Ok(CapturedAnalysis::Document { document, bytes }) +} + +fn comparison_stop( + worker: &ReviewWorkerControl, + analysis: &AnalysisControl, + error: &mut Option, +) -> Option { + if worker.cancelled.load(Ordering::Acquire) { + return Some(ComparisonStopReason::Disconnected); + } + match analysis.stop_truncation(std::time::Instant::now()) { + Ok(Some(truncation)) => match truncation.reason() { + SyntaxTruncationReason::Cancelled => Some(ComparisonStopReason::Cancelled), + SyntaxTruncationReason::Time => Some(ComparisonStopReason::Deadline), + _ => None, + }, + Ok(None) => None, + Err(model_error) => { + *error = Some(format!("failed to read comparison control: {model_error}")); + Some(ComparisonStopReason::Disconnected) + } + } +} + +fn index_file_diffs( + files: Vec, + control: &ReviewWorkerControl, +) -> Result { + let mut indexed = HashMap::with_capacity(files.len()); + for file in files { + control.checkpoint()?; + let key = (file.old_path.clone(), file.new_path.clone()); + if indexed.insert(key.clone(), file).is_some() { + return Err(format!( + "exact diff contains duplicate path pair {:?} -> {:?}", + key.0, key.1 + )); + } + } + Ok(indexed) +} + +fn changed_hunks( + file: &FileDiff, + control: &ReviewWorkerControl, +) -> Result, String> { + control.checkpoint()?; + let mut blocks = Vec::new(); + for hunk in &file.hunks { + let mut old = Vec::new(); + let mut new = Vec::new(); + for line in &hunk.lines { + control.checkpoint()?; + match line.line_type { + DiffLineType::Removed => { + if let Some(line) = line.old_line_num { + old.push(line); + } + } + DiffLineType::Added => { + if let Some(line) = line.new_line_num { + new.push(line); + } + } + DiffLineType::Context | DiffLineType::Header => { + flush_edit_block(&mut blocks, &mut old, &mut new)?; + } + } + } + flush_edit_block(&mut blocks, &mut old, &mut new)?; + } + Ok(blocks) +} + +fn flush_edit_block( + blocks: &mut Vec, + old: &mut Vec, + new: &mut Vec, +) -> Result<(), String> { + if old.is_empty() && new.is_empty() { + return Ok(()); + } + let old_range = changed_range(old.drain(..))?; + let new_range = changed_range(new.drain(..))?; + blocks.push(ChangedHunk::new(old_range, new_range).map_err(|error| error.to_string())?); + Ok(()) +} + +fn changed_range(lines: impl Iterator) -> Result, String> { + let mut lines = lines.peekable(); + let Some(first) = lines.peek().copied() else { + return Ok(None); + }; + let mut last = first; + for line in lines { + last = line; + } + let start = u32::try_from(first) + .ok() + .and_then(NonZeroU32::new) + .ok_or_else(|| "changed line does not fit the 1-based review range".to_string())?; + let end = u32::try_from(last) + .ok() + .and_then(NonZeroU32::new) + .ok_or_else(|| "changed line does not fit the 1-based review range".to_string())?; + ChangedLineRange::new(start, end) + .map(Some) + .map_err(|error| error.to_string()) +} + +fn unsuccessful_file( + fact: &ReviewFileFact, + language: Option, + status: FileAnalysisStatus, + hunks: Vec, + message: impl Into, + stage: AnalysisStage, +) -> Result { + let error = AnalysisError::new(selected_path(fact).map(str::to_owned), stage, message) + .map_err(|error| error.to_string())?; + empty_file(fact, language, status, hunks, vec![error], None) +} + +fn empty_file( + fact: &ReviewFileFact, + language: Option, + status: FileAnalysisStatus, + hunks: Vec, + errors: Vec, + truncation: Option, +) -> Result { + StructuredFile::new( + fact.old_path.clone(), + fact.new_path.clone(), + language, + None, + None, + status, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + hunks, + errors, + truncation, + ) + .map_err(|error| error.to_string()) +} + +fn coverage_for( + files: &[StructuredFile], + omissions: &[OmittedFileGroup], + language: Option, +) -> Result { + let mut counts = [0_u64; 5]; + for file in files + .iter() + .filter(|file| language.is_none_or(|language| file.language() == Some(language))) + { + add_status_count(&mut counts, file.status(), 1)?; + } + for omission in omissions + .iter() + .filter(|omission| language.is_none_or(|language| omission.language() == Some(language))) + { + add_status_count(&mut counts, omission.status(), omission.count())?; + } + let total = counts.into_iter().try_fold(0_u64, |total, count| { + total + .checked_add(count) + .ok_or_else(|| "review coverage count overflowed".to_string()) + })?; + ReviewCoverage::new( + total, + counts[0], + counts[1], + counts[2], + counts[3], + counts[4], + aggregate_truncation(files, omissions, language), + ) + .map_err(|error| error.to_string()) +} + +fn add_status_count( + counts: &mut [u64; 5], + status: FileAnalysisStatus, + count: u64, +) -> Result<(), String> { + let index = match status { + FileAnalysisStatus::Parsed | FileAnalysisStatus::Partial => 0, + FileAnalysisStatus::Pending => 1, + FileAnalysisStatus::Skipped => 2, + FileAnalysisStatus::Unsupported => 3, + FileAnalysisStatus::Failed => 4, + }; + counts[index] = counts[index] + .checked_add(count) + .ok_or_else(|| "review coverage count overflowed".to_string())?; + Ok(()) +} + +fn language_coverage_for( + files: &[StructuredFile], + omissions: &[OmittedFileGroup], +) -> Result, String> { + let mut entries = Vec::new(); + for language in [ + SyntaxLanguage::Rust, + SyntaxLanguage::TypeScript, + SyntaxLanguage::Tsx, + ] { + if files.iter().any(|file| file.language() == Some(language)) + || omissions + .iter() + .any(|omission| omission.language() == Some(language)) + { + entries.push(LanguageCoverage::new( + language, + coverage_for(files, omissions, Some(language))?, + )); + } + } + Ok(entries) +} + +fn aggregate_truncation( + files: &[StructuredFile], + omissions: &[OmittedFileGroup], + language: Option, +) -> Option { + let truncations: Vec<&ReviewTruncation> = files + .iter() + .filter(|file| language.is_none_or(|language| file.language() == Some(language))) + .filter_map(StructuredFile::truncation) + .chain( + omissions + .iter() + .filter(|omission| { + language.is_none_or(|language| omission.language() == Some(language)) + }) + .filter_map(OmittedFileGroup::truncation), + ) + .collect(); + let first = truncations.first()?.to_owned().clone(); + if truncations.iter().all(|candidate| **candidate == first) { + return Some(first); + } + let mut reasons: Vec = truncations + .iter() + .map(|truncation| format!("{:?}", truncation.reason)) + .collect(); + reasons.sort(); + reasons.dedup(); + Some(ReviewTruncation { + reason: TruncationReason::Other, + limit: None, + observed: None, + detail: Some(format!( + "multiple truncation reasons: {}", + reasons.join(", ") + )), + }) +} + +fn detect_language(file: &ReviewFileFact) -> Option { + selected_path(file).and_then(|path| SyntaxLanguage::from_path(Path::new(path))) +} + +fn selected_path(file: &ReviewFileFact) -> Option<&str> { + match file.status { + ReviewFileStatus::Deleted => file.old_path.as_deref(), + _ => file.new_path.as_deref().or(file.old_path.as_deref()), + } +} + +fn display_paths(file: &ReviewFileFact) -> String { + match (&file.old_path, &file.new_path) { + (Some(old), Some(new)) if old != new => format!("{old} -> {new}"), + (Some(path), _) | (_, Some(path)) => path.clone(), + (None, None) => "".to_string(), + } +} + +fn serialize_inventory_response( + response: ReviewInventory, + control: &ReviewWorkerControl, +) -> Result { + serialize_bounded(control, |writer| serde_json::to_writer(writer, &response)) +} + +fn serialize_diff_response( + response: ExactReviewDiffResponse, + control: &ReviewWorkerControl, +) -> Result { + serialize_bounded(control, |writer| serde_json::to_writer(writer, &response)) +} + +fn serialize_source_response( + response: ExactReviewSourceResponse, + control: &ReviewWorkerControl, +) -> Result { + serialize_bounded(control, |writer| serde_json::to_writer(writer, &response)) +} + +fn serialize_structure_response( + response: ReviewStructure, + control: &ReviewWorkerControl, +) -> Result { + serialize_bounded(control, |writer| serde_json::to_writer(writer, &response)) +} + +fn serialize_bounded( + control: &ReviewWorkerControl, + serialize: impl FnOnce(&mut LimitedWriter<'_>) -> serde_json::Result<()>, +) -> Result { + serialize_bounded_with_limit(MAX_RESPONSE_BYTES, control, serialize) +} + +fn serialize_bounded_with_limit( + limit: usize, + control: &ReviewWorkerControl, + serialize: impl FnOnce(&mut LimitedWriter<'_>) -> serde_json::Result<()>, +) -> Result { + let analysis = control.analysis(); + response_checkpoint(control, analysis.as_ref())?; + let mut writer = LimitedWriter::new(limit, control, analysis.as_ref()); + if let Err(error) = serialize(&mut writer) { + if let Some(stop_error) = writer.stop_error { + return Err(stop_error); + } + if writer.exceeded { + return Err(format!( + "review response exceeds the {} byte response limit", + limit + )); + } + return Err(format!("failed to serialize review response: {error}")); + } + response_checkpoint(control, analysis.as_ref())?; + let mut reader = CheckpointReader::new(&writer.bytes, control, analysis.as_ref()); + let materialized = { + let buffered = io::BufReader::with_capacity(RESPONSE_CHECKPOINT_BYTES, &mut reader); + serde_json::from_reader(buffered) + }; + let value = match materialized { + Ok(value) => value, + Err(_) if reader.stop_error.is_some() => { + return Err(reader + .stop_error + .unwrap_or_else(|| "review response materialization stopped".to_string())); + } + Err(error) => { + return Err(format!( + "failed to materialize review response JSON: {error}" + )); + } + }; + response_checkpoint(control, analysis.as_ref())?; + Ok(value) +} + +fn response_checkpoint( + control: &ReviewWorkerControl, + analysis: Option<&AnalysisControl>, +) -> Result<(), String> { + response_checkpoint_at(control, analysis, std::time::Instant::now()) +} + +fn response_checkpoint_at( + control: &ReviewWorkerControl, + analysis: Option<&AnalysisControl>, + now: std::time::Instant, +) -> Result<(), String> { + control.checkpoint()?; + if let Some(analysis) = analysis { + let Some((reason, truncation)) = analysis_omission_at(analysis, now)? else { + return Ok(()); + }; + return Err(format!( + "structured review stopped during response materialization: {reason:?} ({:?})", + truncation.reason + )); + } + Ok(()) +} + +struct LimitedWriter<'a> { + bytes: Vec, + limit: usize, + exceeded: bool, + stop_error: Option, + control: &'a ReviewWorkerControl, + analysis: Option<&'a AnalysisControl>, +} + +impl<'a> LimitedWriter<'a> { + fn new( + limit: usize, + control: &'a ReviewWorkerControl, + analysis: Option<&'a AnalysisControl>, + ) -> Self { + Self { + bytes: Vec::with_capacity(limit.min(64 * 1024)), + limit, + exceeded: false, + stop_error: None, + control, + analysis, + } + } +} + +impl Write for LimitedWriter<'_> { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if let Err(error) = response_checkpoint(self.control, self.analysis) { + self.stop_error = Some(error); + return Err(io::Error::other("review response serialization stopped")); + } + let write_len = bytes.len().min(RESPONSE_CHECKPOINT_BYTES); + let Some(next_len) = self.bytes.len().checked_add(write_len) else { + self.exceeded = true; + return Err(io::Error::other("review response size overflowed")); + }; + if next_len > self.limit { + self.exceeded = true; + return Err(io::Error::other("review response limit reached")); + } + self.bytes.extend_from_slice(&bytes[..write_len]); + Ok(write_len) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +struct CheckpointReader<'a> { + bytes: &'a [u8], + position: usize, + stop_error: Option, + control: &'a ReviewWorkerControl, + analysis: Option<&'a AnalysisControl>, +} + +impl<'a> CheckpointReader<'a> { + fn new( + bytes: &'a [u8], + control: &'a ReviewWorkerControl, + analysis: Option<&'a AnalysisControl>, + ) -> Self { + Self { + bytes, + position: 0, + stop_error: None, + control, + analysis, + } + } +} + +impl Read for CheckpointReader<'_> { + fn read(&mut self, output: &mut [u8]) -> io::Result { + if let Err(error) = response_checkpoint(self.control, self.analysis) { + self.stop_error = Some(error); + return Err(io::Error::other("review response materialization stopped")); + } + if self.position == self.bytes.len() || output.is_empty() { + return Ok(0); + } + let remaining = self.bytes.len() - self.position; + let read_len = remaining.min(output.len()).min(RESPONSE_CHECKPOINT_BYTES); + let end = self.position + read_len; + output[..read_len].copy_from_slice(&self.bytes[self.position..end]); + self.position = end; + Ok(read_len) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use okena_core::review::{ + ComparisonSide, FactProvenance, FileClassification, FileRole, ReviewFileStatus, + }; + use std::process::Command; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct TestRepo(PathBuf); + + impl TestRepo { + fn new() -> Self { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "okena-daemon-review-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir_all(path.join("src")).unwrap(); + let repo = Self(path); + repo.git(&["init", "-b", "main"]); + repo.git(&["config", "user.email", "review@example.com"]); + repo.git(&["config", "user.name", "Review Test"]); + repo + } + + fn git(&self, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(&self.0) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_string() + } + + fn write(&self, path: &str, content: &str) { + std::fs::write(self.0.join(path), content).unwrap(); + } + + fn commit_all(&self, message: &str) { + self.git(&["add", "."]); + self.git(&["commit", "-m", message]); + } + } + + impl Drop for TestRepo { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn file(old: Option<&str>, new: Option<&str>, status: ReviewFileStatus) -> ReviewFileFact { + ReviewFileFact { + old_path: old.map(str::to_owned), + new_path: new.map(str::to_owned), + status, + similarity: None, + old_mode: old.map(|_| "100644".to_string()), + new_mode: new.map(|_| "100644".to_string()), + lines_added: Some(1), + lines_deleted: Some(1), + binary: false, + submodule: None, + classification: FileClassification::from_rule( + FileRole::Unclassified, + "builtin.unclassified", + ) + .unwrap(), + provenance: FactProvenance::Git, + } + } + + fn fake_action() -> PreparedReviewAction { + PreparedReviewAction { + project_path: PathBuf::from("/unused/review-test"), + request: ReviewRequest::Inventory(DiffMode::WorkingTree), + } + } + + fn resolved_comparison(repo: &TestRepo) -> okena_core::review::ResolvedComparison { + resolve_review_comparison_with_control( + &repo.0, + DiffMode::BranchCompare { + base: "main".to_string(), + head: "feature".to_string(), + }, + &ReviewGitControl::new(Default::default()), + ) + .unwrap() + } + + fn execute_source_request( + repo: &TestRepo, + request: ReviewSourceRequest, + ) -> Result { + let result = execute_review_action( + PreparedReviewAction { + project_path: repo.0.clone(), + request: ReviewRequest::Source(Box::new(request)), + }, + &ReviewGitControl::new(Default::default()), + &ReviewWorkerControl::default(), + ); + match result { + CommandResult::Ok(Some(value)) => serde_json::from_value(value) + .map_err(|error| format!("invalid exact source response: {error}")), + CommandResult::Ok(None) => Err("exact source response had no payload".to_string()), + CommandResult::OkBytes(_) => { + Err("exact source response unexpectedly returned raw bytes".to_string()) + } + CommandResult::OkSnapshot { .. } => { + Err("exact source response unexpectedly returned a snapshot".to_string()) + } + CommandResult::Err(error) => Err(error), + } + } + + #[test] + fn status_aware_language_detection_uses_the_surviving_side() { + assert_eq!( + detect_language(&file(None, Some("src/view.tsx"), ReviewFileStatus::Added)), + Some(SyntaxLanguage::Tsx) + ); + assert_eq!( + detect_language(&file(Some("src/lib.rs"), None, ReviewFileStatus::Deleted)), + Some(SyntaxLanguage::Rust) + ); + } + + #[test] + fn exact_source_action_returns_rename_add_and_delete_sides() { + let repo = TestRepo::new(); + repo.write("src/old.rs", "pub fn renamed() -> u32 { 1 }\n"); + repo.write("src/deleted.rs", "pub fn deleted() {}\n"); + repo.commit_all("base"); + repo.git(&["checkout", "-b", "feature"]); + repo.git(&["mv", "src/old.rs", "src/new.rs"]); + repo.write("src/new.rs", "pub fn renamed() -> u32 { 2 }\n"); + repo.write("src/added.rs", "pub fn added() {}\n"); + std::fs::remove_file(repo.0.join("src/deleted.rs")).unwrap(); + repo.commit_all("feature"); + + let comparison = resolved_comparison(&repo); + assert!(is_review_action(&ActionRequest::ReviewSource { + project_id: "test".to_string(), + request: Box::new( + ReviewSourceRequest::new( + comparison.clone(), + Some("src/old.rs".to_string()), + Some("src/new.rs".to_string()), + ) + .unwrap(), + ), + })); + let rename = execute_source_request( + &repo, + ReviewSourceRequest::new( + comparison.clone(), + Some("src/old.rs".to_string()), + Some("src/new.rs".to_string()), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(rename.old_path(), Some("src/old.rs")); + assert_eq!(rename.new_path(), Some("src/new.rs")); + assert_eq!( + rename.old_content(), + Some("pub fn renamed() -> u32 { 1 }\n") + ); + assert_eq!( + rename.new_content(), + Some("pub fn renamed() -> u32 { 2 }\n") + ); + assert_eq!(rename.comparison().as_resolved(), &comparison); + + let addition = execute_source_request( + &repo, + ReviewSourceRequest::new(comparison.clone(), None, Some("src/added.rs".to_string())) + .unwrap(), + ) + .unwrap(); + assert_eq!(addition.old_content(), None); + assert_eq!(addition.new_content(), Some("pub fn added() {}\n")); + + let deletion = execute_source_request( + &repo, + ReviewSourceRequest::new(comparison, Some("src/deleted.rs".to_string()), None).unwrap(), + ) + .unwrap(); + assert_eq!(deletion.old_content(), Some("pub fn deleted() {}\n")); + assert_eq!(deletion.new_content(), None); + } + + #[test] + fn exact_source_request_is_immune_to_a_moved_head_ref() { + let repo = TestRepo::new(); + repo.write("src/lib.rs", "pub fn value() -> u32 { 1 }\n"); + repo.commit_all("base"); + repo.git(&["checkout", "-b", "feature"]); + repo.write("src/lib.rs", "pub fn value() -> u32 { 2 }\n"); + repo.commit_all("frozen feature"); + let comparison = resolved_comparison(&repo); + let request = ReviewSourceRequest::new( + comparison.clone(), + Some("src/lib.rs".to_string()), + Some("src/lib.rs".to_string()), + ) + .unwrap(); + + repo.write("src/lib.rs", "pub fn value() -> u32 { 3 }\n"); + repo.commit_all("move feature ref"); + + let source = execute_source_request(&repo, request).unwrap(); + assert_eq!(source.new_content(), Some("pub fn value() -> u32 { 2 }\n")); + assert_eq!(source.comparison().as_resolved(), &comparison); + } + + #[test] + fn exact_source_enforces_side_limit_and_accepts_full_pair_boundary() { + let repo = TestRepo::new(); + let side_len = usize::try_from(MAX_SOURCE_SIDE_BYTES).unwrap(); + repo.write("src/old.txt", &"a".repeat(side_len)); + repo.commit_all("base"); + repo.git(&["checkout", "-b", "feature"]); + repo.git(&["mv", "src/old.txt", "src/new.txt"]); + repo.write("src/new.txt", &"b".repeat(side_len)); + repo.write("src/oversize.txt", &"x".repeat(side_len + 1)); + repo.commit_all("feature"); + let comparison = resolved_comparison(&repo); + + let boundary = execute_source_request( + &repo, + ReviewSourceRequest::new( + comparison.clone(), + Some("src/old.txt".to_string()), + Some("src/new.txt".to_string()), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!(boundary.old_content().unwrap().len(), side_len); + assert_eq!(boundary.new_content().unwrap().len(), side_len); + + let error = execute_source_request( + &repo, + ReviewSourceRequest::new(comparison, None, Some("src/oversize.txt".to_string())) + .unwrap(), + ) + .unwrap_err(); + assert!(error.contains("per-file byte budget exceeded"), "{error}"); + assert!(error.contains(&(MAX_SOURCE_SIDE_BYTES + 1).to_string())); + assert!(error.contains(&MAX_SOURCE_SIDE_BYTES.to_string())); + } + + #[test] + fn exact_source_honors_worker_and_git_cancellation() { + let repo = TestRepo::new(); + repo.write("src/lib.rs", "pub fn value() {}\n"); + repo.commit_all("base"); + repo.git(&["checkout", "-b", "feature"]); + repo.write("src/lib.rs", "pub fn value() { changed(); }\n"); + repo.commit_all("feature"); + let request = ReviewSourceRequest::new( + resolved_comparison(&repo), + Some("src/lib.rs".to_string()), + Some("src/lib.rs".to_string()), + ) + .unwrap(); + let budget = + ReviewSourceBudget::new(MAX_SOURCE_SIDE_BYTES, MAX_STANDALONE_SOURCE_TOTAL_BYTES) + .unwrap(); + + let git = ReviewGitControl::new(Default::default()); + git.cancel(); + assert!( + get_exact_review_source_response_with_control(&repo.0, &request, budget, &git).is_err() + ); + + let worker = ReviewWorkerControl::default(); + worker.cancel(); + assert!( + build_source( + &repo.0, + &request, + &ReviewGitControl::new(Default::default()), + &worker, + ) + .is_err() + ); + } + + #[test] + fn changed_hunks_only_include_changed_lines() { + let diff = FileDiff { + old_path: Some("src/lib.rs".to_string()), + new_path: Some("src/lib.rs".to_string()), + hunks: vec![okena_git::diff::DiffHunk { + header: "@@ -2,2 +2,2 @@".to_string(), + old_start: 2, + new_start: 2, + lines: vec![ + okena_git::diff::DiffLine { + line_type: DiffLineType::Removed, + content: "old".to_string(), + old_line_num: Some(2), + new_line_num: None, + }, + okena_git::diff::DiffLine { + line_type: DiffLineType::Added, + content: "new".to_string(), + old_line_num: None, + new_line_num: Some(2), + }, + ], + }], + is_binary: false, + lines_added: 1, + lines_removed: 1, + }; + let hunks = changed_hunks(&diff, &ReviewWorkerControl::default()).unwrap(); + assert_eq!(hunks[0].old().unwrap().start().get(), 2); + assert_eq!(hunks[0].new_range().unwrap().end().get(), 2); + } + + #[test] + fn changed_hunks_split_clusters_separated_by_context() { + use okena_git::diff::DiffLine; + let diff = FileDiff { + old_path: Some("src/lib.rs".to_string()), + new_path: Some("src/lib.rs".to_string()), + hunks: vec![okena_git::diff::DiffHunk { + header: "@@ -2,4 +2,4 @@".to_string(), + old_start: 2, + new_start: 2, + lines: vec![ + DiffLine { + line_type: DiffLineType::Removed, + content: "old_a".into(), + old_line_num: Some(2), + new_line_num: None, + }, + DiffLine { + line_type: DiffLineType::Added, + content: "new_a".into(), + old_line_num: None, + new_line_num: Some(2), + }, + DiffLine { + line_type: DiffLineType::Context, + content: "unchanged_symbol".into(), + old_line_num: Some(3), + new_line_num: Some(3), + }, + DiffLine { + line_type: DiffLineType::Removed, + content: "old_b".into(), + old_line_num: Some(4), + new_line_num: None, + }, + DiffLine { + line_type: DiffLineType::Added, + content: "new_b".into(), + old_line_num: None, + new_line_num: Some(4), + }, + ], + }], + is_binary: false, + lines_added: 2, + lines_removed: 2, + }; + let blocks = changed_hunks(&diff, &ReviewWorkerControl::default()).unwrap(); + assert_eq!(blocks.len(), 2); + assert_eq!(blocks[0].old().unwrap().end().get(), 2); + assert_eq!(blocks[1].new_range().unwrap().start().get(), 4); + } + + #[test] + fn partial_file_truncation_reaches_aggregate_and_language_coverage() { + use okena_syntax::{DocumentStatus, DocumentStructure, SyntaxProvenance}; + let truncation = + SyntaxTruncation::new(SyntaxTruncationReason::SymbolCount, Some(1), Some(2)).unwrap(); + let document = DocumentStructure::new( + "src/lib.rs", + SyntaxProvenance::tree_sitter(SyntaxLanguage::Rust, "test-parser").unwrap(), + DocumentStatus::Partial, + Vec::new(), + Vec::new(), + Vec::new(), + Some(truncation), + ) + .unwrap(); + let file = compare_structured_file_controlled( + None, + Some("src/lib.rs"), + None, + Some(&document), + &[], + &mut || None, + ) + .unwrap(); + let coverage = coverage_for(std::slice::from_ref(&file), &[], None).unwrap(); + let language = language_coverage_for(std::slice::from_ref(&file), &[]).unwrap(); + assert_eq!( + coverage.truncation().unwrap().reason, + TruncationReason::CaptureLimit + ); + assert_eq!( + language[0].coverage().truncation().unwrap().reason, + TruncationReason::CaptureLimit + ); + } + + #[test] + fn parser_clock_starts_when_analysis_is_published() { + let control = ReviewWorkerControl::default(); + std::thread::sleep(std::time::Duration::from_millis(60)); + let analysis = control.start_analysis(); + assert!(analysis.elapsed_micros(std::time::Instant::now()) < 20_000); + + let cancelled = ReviewWorkerControl::default(); + cancelled.cancel(); + assert!(cancelled.start_analysis().is_cancelled()); + } + + #[test] + fn exact_aggregate_source_fill_rejects_the_next_file_strictly() { + let AggregateSourceCapacity::Exhausted(truncation) = + aggregate_source_capacity(MAX_SOURCE_TOTAL_BYTES).unwrap() + else { + panic!("an exact aggregate fill must reject the next analyzable file"); + }; + assert_eq!(truncation.reason, TruncationReason::ByteLimit); + assert_eq!(truncation.limit, Some(MAX_SOURCE_TOTAL_BYTES)); + assert_eq!( + truncation.observed, + Some(MAX_SOURCE_TOTAL_BYTES.checked_add(1).unwrap()) + ); + } + + #[test] + fn deterministic_deadline_checkpoint_rejects_late_success() { + let analysis = AnalysisControl::new(NonZeroU64::new(1_000_000).unwrap()); + let future = std::time::Instant::now() + .checked_add(std::time::Duration::from_secs(2)) + .unwrap(); + let (reason, truncation) = analysis_omission_at(&analysis, future) + .unwrap() + .expect("the injected checkpoint instant is beyond the deadline"); + assert_eq!(reason, OmittedFileReason::TimeLimit); + assert_eq!(truncation.reason, TruncationReason::TimeLimit); + assert!( + response_checkpoint_at(&ReviewWorkerControl::default(), Some(&analysis), future,) + .is_err() + ); + } + + #[test] + fn daemon_control_maps_disconnect_cancellation_and_deadline() { + let disconnected = ReviewWorkerControl::default(); + disconnected.cancel(); + let analysis = AnalysisControl::new(NonZeroU64::new(1_000_000).unwrap()); + assert_eq!( + comparison_stop(&disconnected, &analysis, &mut None), + Some(ComparisonStopReason::Disconnected) + ); + + let cancelled = AnalysisControl::new(NonZeroU64::new(1_000_000).unwrap()); + cancelled.cancel(); + assert_eq!( + comparison_stop(&ReviewWorkerControl::default(), &cancelled, &mut None), + Some(ComparisonStopReason::Cancelled) + ); + + let expired = AnalysisControl::new(NonZeroU64::MIN); + while !expired.deadline_exceeded(std::time::Instant::now()) { + std::hint::spin_loop(); + } + assert_eq!( + comparison_stop(&ReviewWorkerControl::default(), &expired, &mut None), + Some(ComparisonStopReason::Deadline) + ); + } + + #[test] + fn aggregate_capture_budget_rejects_a_document_before_comparison() { + let analysis = AnalysisControl::new(NonZeroU64::new(1_000_000).unwrap()); + let outcome = analyze_side_bounded( + &RustAdapter::new(), + Some("src/lib.rs"), + SyntaxLanguage::Rust, + "pub fn value() {}".into(), + MAX_CAPTURE_TOTAL_BYTES - 1, + &analysis, + ) + .unwrap(); + let CapturedAnalysis::AggregateExhausted(truncation) = outcome else { + panic!("one remaining capture byte must not retain a syntax document"); + }; + assert_eq!(truncation.reason, TruncationReason::CaptureLimit); + assert_eq!(truncation.limit, Some(MAX_CAPTURE_TOTAL_BYTES)); + assert!(truncation.observed.unwrap() > MAX_CAPTURE_TOTAL_BYTES); + } + + #[test] + fn retained_old_side_is_charged_before_aggregate_capture_halt() { + let analysis = AnalysisControl::new(NonZeroU64::new(1_000_000).unwrap()); + let CapturedAnalysis::Document { + bytes: old_bytes, .. + } = analyze_side_bounded( + &RustAdapter::new(), + Some("src/old.rs"), + SyntaxLanguage::Rust, + "pub fn old_value() {}".into(), + 0, + &analysis, + ) + .unwrap() + else { + panic!("small base document must fit"); + }; + let retained_before_old = MAX_CAPTURE_TOTAL_BYTES.checked_sub(old_bytes).unwrap(); + let mut retained_after_old = retained_before_old; + commit_file_capture(&mut retained_after_old, old_bytes).unwrap(); + assert_eq!(retained_after_old, MAX_CAPTURE_TOTAL_BYTES); + + let CapturedAnalysis::AggregateExhausted(truncation) = analyze_side_bounded( + &RustAdapter::new(), + Some("src/head.rs"), + SyntaxLanguage::Rust, + "pub fn head_value() {}".into(), + retained_after_old, + &analysis, + ) + .unwrap() else { + panic!("head analysis must halt once the retained base fills the request budget"); + }; + assert_eq!(truncation.reason, TruncationReason::CaptureLimit); + assert_eq!(truncation.limit, Some(MAX_CAPTURE_TOTAL_BYTES)); + assert_eq!(truncation.observed, Some(MAX_CAPTURE_TOTAL_BYTES + 1)); + + let outcome = FileBuildOutcome::Halted(OmittedFileReason::FactLimit, truncation); + let mut halted = None; + if let FileBuildOutcome::Halted(reason, truncation) = outcome { + halted = Some((reason, truncation)); + } + let mut later_adapter_calls = 0_u32; + let later_omission = match halted_omission(&halted) { + Some(omission) => omission, + None => { + later_adapter_calls += 1; + ( + OmittedFileReason::FactLimit, + measured_truncation( + TruncationReason::CaptureLimit, + MAX_CAPTURE_TOTAL_BYTES, + MAX_CAPTURE_TOTAL_BYTES + 1, + "unexpected later parse", + ), + ) + } + }; + assert_eq!(later_adapter_calls, 0); + assert_eq!(later_omission.0, OmittedFileReason::FactLimit); + assert_eq!(later_omission.1.reason, TruncationReason::CaptureLimit); + } + + #[test] + fn unsupported_files_do_not_consume_analyzable_slots() { + let unsupported = file(None, Some("notes.txt"), ReviewFileStatus::Added); + for _ in 0..250 { + assert_eq!( + deterministic_omission(&unsupported, true).unwrap().1, + OmittedFileReason::UnsupportedLanguage + ); + } + let mut started = 0; + for _ in 0..MAX_FILES { + assert!(claim_analyzable_slot(&mut started)); + } + assert!(!claim_analyzable_slot(&mut started)); + assert_eq!(started, MAX_FILES); + + let mut submodule = file(None, Some("deps/lib"), ReviewFileStatus::Added); + submodule.new_mode = Some("160000".to_string()); + assert_eq!( + deterministic_omission(&submodule, true).unwrap().1, + OmittedFileReason::Submodule + ); + } + + #[test] + fn cancelled_cpu_loop_stops_at_checkpoint() { + let control = ReviewWorkerControl::default(); + control.cancel(); + let diff = FileDiff { + old_path: Some("src/lib.rs".into()), + new_path: Some("src/lib.rs".into()), + hunks: Vec::new(), + is_binary: false, + lines_added: 0, + lines_removed: 0, + }; + assert!(changed_hunks(&diff, &control).is_err()); + } + + #[test] + fn pending_files_are_counted_as_pending() { + let pending = OmittedFileGroup::new( + 1, + Some(SyntaxLanguage::Rust), + OmittedFileReason::FileLimit, + Some(ReviewTruncation { + reason: TruncationReason::ItemLimit, + limit: Some(1), + observed: Some(2), + detail: Some("files".to_string()), + }), + ) + .unwrap(); + let coverage = coverage_for(&[], &[pending], None).unwrap(); + assert_eq!(coverage.pending_items(), 1); + assert_eq!(coverage.analyzed_items(), 0); + } + + #[test] + fn response_writer_stops_before_allocating_past_the_limit() { + let error = serialize_bounded_with_limit(4, &ReviewWorkerControl::default(), |writer| { + serde_json::to_writer(writer, &"too large") + }) + .unwrap_err(); + assert!(error.contains("4 byte response limit")); + } + + #[test] + fn response_reader_stops_when_reply_closes_during_materialization() { + let control = ReviewWorkerControl::default(); + let bytes = vec![b' '; RESPONSE_CHECKPOINT_BYTES * 2]; + let mut reader = CheckpointReader::new(&bytes, &control, None); + let mut output = vec![0_u8; RESPONSE_CHECKPOINT_BYTES]; + assert_eq!(reader.read(&mut output).unwrap(), RESPONSE_CHECKPOINT_BYTES); + control.cancel(); + assert!(reader.read(&mut output).is_err()); + assert_eq!( + reader.stop_error.as_deref(), + Some("review request cancelled") + ); + } + + #[test] + fn immutable_structure_uses_merge_base_and_ignores_a_moved_ref() { + let repo = TestRepo::new(); + repo.write( + "src/lib.rs", + "pub fn value() -> u32 { 1 }\npub fn stable_a() {}\npub fn stable_b() {}\npub fn stable_c() {}\npub fn stable_d() {}\npub fn stable_e() {}\n", + ); + repo.write( + "src/view.tsx", + "export function View() { return
{oldValue()}
; }\n", + ); + std::fs::write(repo.0.join("src/image.bin"), [0_u8, 159]).unwrap(); + repo.write("notes.txt", "old note\n"); + repo.write("src/mode.rs", "pub fn unchanged() {}\n"); + repo.write("src/deleted.rs", "pub fn removed() {}\n"); + repo.commit_all("base"); + + repo.git(&["checkout", "-b", "feature"]); + repo.git(&["mv", "src/lib.rs", "src/core.rs"]); + repo.write( + "src/core.rs", + "pub fn value(input: u32) -> u32 { input + 1 }\npub fn stable_a() {}\npub fn stable_b() {}\npub fn stable_c() {}\npub fn stable_d() {}\npub fn stable_e() {}\n", + ); + repo.write( + "src/view.tsx", + "export function View() { return
{newValue(1)}
; }\n", + ); + std::fs::write(repo.0.join("src/image.bin"), [0_u8, 160]).unwrap(); + repo.write("notes.txt", "new note\n"); + repo.write("src/added.rs", "pub fn added() {}\n"); + std::fs::remove_file(repo.0.join("src/deleted.rs")).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(repo.0.join("src/mode.rs")) + .unwrap() + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(repo.0.join("src/mode.rs"), permissions).unwrap(); + } + repo.commit_all("feature change"); + let frozen_feature = repo.git(&["rev-parse", "HEAD"]); + + repo.git(&["checkout", "main"]); + repo.write("README.md", "main moved\n"); + repo.commit_all("main moved"); + + let git_control = ReviewGitControl::new(Default::default()); + let resolved = resolve_review_comparison_with_control( + &repo.0, + DiffMode::BranchCompare { + base: "main".to_string(), + head: "feature".to_string(), + }, + &git_control, + ) + .unwrap(); + assert_ne!(resolved.requested_base_oid(), resolved.merge_base_oid()); + assert_eq!( + resolved.requested_head_oid().unwrap().as_str(), + frozen_feature + ); + let immutable = ImmutableResolvedComparison::try_from(resolved.clone()).unwrap(); + let request = ReviewDiffRequest::new(resolved, false).unwrap(); + let exact_diff = + get_exact_review_diff_response_with_control(&repo.0, &request, &git_control).unwrap(); + assert_eq!(exact_diff.comparison(), &immutable); + + repo.git(&["checkout", "feature"]); + repo.write("src/after-resolution.rs", "pub fn later() {}\n"); + repo.commit_all("move feature ref"); + + let worker_control = ReviewWorkerControl::default(); + let structure = build_structure(&repo.0, request, &git_control, &worker_control).unwrap(); + assert_eq!(structure.comparison(), &immutable); + assert_eq!(structure.files().len(), 4); + assert!(structure.files().iter().all(|file| matches!( + file.status(), + FileAnalysisStatus::Parsed | FileAnalysisStatus::Partial + ))); + assert_eq!(structure.coverage().unsupported_items(), 2); + assert_eq!(structure.coverage().analyzed_items(), 4); + assert_eq!( + structure.coverage().total_items(), + if cfg!(unix) { 7 } else { 6 } + ); + assert_eq!( + structure.coverage().skipped_items(), + usize::from(cfg!(unix)) as u64 + ); + assert!( + structure + .files() + .iter() + .any(|file| file.language() == Some(SyntaxLanguage::Rust)) + ); + let renamed_rust = structure + .files() + .iter() + .find(|file| file.new_path() == Some("src/core.rs")) + .unwrap(); + assert_eq!(renamed_rust.old_path(), Some("src/lib.rs")); + let renamed_symbol = renamed_rust + .symbol_changes() + .iter() + .find(|change| { + change + .new_fact() + .is_some_and(|symbol| symbol.key().name() == "value") + }) + .unwrap(); + assert_eq!(renamed_symbol.navigation().side, ComparisonSide::Head); + assert_eq!(renamed_symbol.navigation().path, "src/core.rs"); + let added_file = structure + .files() + .iter() + .find(|file| file.new_path() == Some("src/added.rs")) + .unwrap(); + let added_symbol = added_file.symbol_changes().first().unwrap(); + assert_eq!(added_symbol.navigation().side, ComparisonSide::Head); + assert_eq!(added_symbol.navigation().path, "src/added.rs"); + let deleted_file = structure + .files() + .iter() + .find(|file| file.old_path() == Some("src/deleted.rs")) + .unwrap(); + let deleted_symbol = deleted_file.symbol_changes().first().unwrap(); + assert_eq!(deleted_symbol.navigation().side, ComparisonSide::Base); + assert_eq!(deleted_symbol.navigation().path, "src/deleted.rs"); + assert!( + structure + .files() + .iter() + .any(|file| file.language() == Some(SyntaxLanguage::Tsx)) + ); + let tsx_file = structure + .files() + .iter() + .find(|file| file.language() == Some(SyntaxLanguage::Tsx)) + .unwrap(); + assert!(!tsx_file.call_diff().is_empty()); + assert_eq!(structure.language_coverage().len(), 2); + assert_eq!( + structure + .language_coverage() + .iter() + .find(|entry| entry.language() == SyntaxLanguage::Tsx) + .unwrap() + .coverage() + .analyzed_items(), + 1 + ); + assert!( + structure + .files() + .iter() + .all(|file| file.new_path() != Some("src/after-resolution.rs")) + ); + #[cfg(unix)] + { + let mode_only = structure + .omissions() + .iter() + .find(|omission| omission.reason() == OmittedFileReason::ModeOnly) + .unwrap(); + assert_eq!(mode_only.status(), FileAnalysisStatus::Skipped); + assert_eq!(mode_only.count(), 1); + } + + let inventory_result = execute_review_action( + PreparedReviewAction { + project_path: repo.0.clone(), + request: ReviewRequest::Inventory(DiffMode::BranchCompare { + base: "main".to_string(), + head: "feature".to_string(), + }), + }, + &ReviewGitControl::new(Default::default()), + &ReviewWorkerControl::default(), + ); + let CommandResult::Ok(Some(inventory_value)) = inventory_result else { + panic!("inventory action worker did not return a JSON payload"); + }; + let inventory: ReviewInventory = serde_json::from_value(inventory_value).unwrap(); + assert!( + inventory + .files + .iter() + .all(|file| file.provenance == FactProvenance::Git) + ); + let rust_file = inventory + .files + .iter() + .find(|file| file.new_path.as_deref() == Some("src/core.rs")) + .unwrap(); + assert_eq!(rust_file.classification.role(), FileRole::Implementation); + } + + #[test] + fn whitespace_ignored_diff_is_a_skipped_omission() { + let repo = TestRepo::new(); + repo.write("src/lib.rs", "pub fn value() -> u32 { 1 }\n"); + repo.commit_all("base"); + repo.git(&["checkout", "-b", "feature"]); + repo.write("src/lib.rs", "pub fn value() -> u32 { 1 }\n"); + repo.commit_all("whitespace only"); + + let git_control = ReviewGitControl::new(Default::default()); + let resolved = resolve_review_comparison_with_control( + &repo.0, + DiffMode::BranchCompare { + base: "main".to_string(), + head: "feature".to_string(), + }, + &git_control, + ) + .unwrap(); + let request = ReviewDiffRequest::new(resolved, true).unwrap(); + let structure = build_structure( + &repo.0, + request, + &git_control, + &ReviewWorkerControl::default(), + ) + .unwrap(); + assert!(structure.files().is_empty()); + assert_eq!(structure.omissions().len(), 1); + assert_eq!( + structure.omissions()[0].reason(), + OmittedFileReason::WhitespaceIgnored + ); + assert_eq!(structure.coverage().skipped_items(), 1); + } + + #[test] + fn binary_addition_and_deletion_pair_with_inventory() { + let repo = TestRepo::new(); + std::fs::create_dir_all(repo.0.join("assets")).unwrap(); + std::fs::write(repo.0.join("assets/deleted.png"), [0_u8, 1, 2]).unwrap(); + repo.commit_all("base"); + repo.git(&["checkout", "-b", "feature"]); + std::fs::remove_file(repo.0.join("assets/deleted.png")).unwrap(); + std::fs::write(repo.0.join("assets/added.png"), [0_u8, 3, 4]).unwrap(); + repo.commit_all("replace binary asset"); + + let git_control = ReviewGitControl::new(Default::default()); + let resolved = resolve_review_comparison_with_control( + &repo.0, + DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + }, + &git_control, + ) + .unwrap(); + let request = ReviewDiffRequest::new(resolved, false).unwrap(); + let structure = build_structure( + &repo.0, + request, + &git_control, + &ReviewWorkerControl::default(), + ) + .unwrap(); + + assert!(structure.files().is_empty()); + assert_eq!(structure.omissions().len(), 1); + assert_eq!(structure.omissions()[0].reason(), OmittedFileReason::Binary); + assert_eq!(structure.omissions()[0].count(), 2); + assert_eq!(structure.coverage().unsupported_items(), 2); + } + + #[test] + fn supported_cross_grammar_rename_is_an_explicit_failed_file() { + let repo = TestRepo::new(); + repo.write( + "src/value.rs", + "// stable one\n// stable two\n// stable three\npub fn value() {}\n", + ); + repo.commit_all("base"); + repo.git(&["checkout", "-b", "feature"]); + repo.git(&["mv", "src/value.rs", "src/value.ts"]); + repo.commit_all("rename across grammars"); + + let git_control = ReviewGitControl::new(Default::default()); + let resolved = resolve_review_comparison_with_control( + &repo.0, + DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + }, + &git_control, + ) + .unwrap(); + let request = ReviewDiffRequest::new(resolved, false).unwrap(); + let structure = build_structure( + &repo.0, + request, + &git_control, + &ReviewWorkerControl::default(), + ) + .unwrap(); + + assert!(structure.omissions().is_empty()); + assert_eq!(structure.files().len(), 1); + let file = &structure.files()[0]; + assert_eq!(file.old_path(), Some("src/value.rs")); + assert_eq!(file.new_path(), Some("src/value.ts")); + assert_eq!(file.status(), FileAnalysisStatus::Failed); + assert!( + file.errors() + .iter() + .any(|error| error.stage() == AnalysisStage::Detection) + ); + assert!( + file.errors() + .iter() + .any(|error| error.stage() == AnalysisStage::Comparison) + ); + assert_eq!(structure.coverage().failed_items(), 1); + } + + #[test] + fn changed_submodule_keeps_exact_oids_and_becomes_structure_omission() { + let child = TestRepo::new(); + child.write("src/lib.rs", "pub fn version() -> u32 { 1 }\n"); + child.commit_all("child base"); + let old_oid = child.git(&["rev-parse", "HEAD"]); + + let parent = TestRepo::new(); + let child_path = child.0.to_str().unwrap(); + parent.git(&[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + child_path, + "deps/lib", + ]); + parent.commit_all("add submodule"); + parent.git(&["checkout", "-b", "feature"]); + + child.write("src/lib.rs", "pub fn version() -> u32 { 2 }\n"); + child.commit_all("child feature"); + let new_oid = child.git(&["rev-parse", "HEAD"]); + parent.git(&["-C", "deps/lib", "fetch", "origin"]); + parent.git(&["-C", "deps/lib", "checkout", &new_oid]); + parent.commit_all("update submodule"); + + let git_control = ReviewGitControl::new(Default::default()); + let resolved = resolve_review_comparison_with_control( + &parent.0, + DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + }, + &git_control, + ) + .unwrap(); + let immutable = ImmutableResolvedComparison::try_from(resolved.clone()).unwrap(); + let inventory = + get_review_inventory_with_control(&parent.0, &immutable, &git_control).unwrap(); + let submodule = inventory + .files + .iter() + .find(|file| file.new_path.as_deref() == Some("deps/lib")) + .unwrap(); + assert_eq!(submodule.old_path.as_deref(), Some("deps/lib")); + assert_eq!(submodule.status, ReviewFileStatus::SubmoduleChanged); + let submodule_change = submodule.submodule.as_ref().unwrap(); + assert_eq!(submodule_change.old_oid.as_ref().unwrap().as_str(), old_oid); + assert_eq!(submodule_change.new_oid.as_ref().unwrap().as_str(), new_oid); + + let request = ReviewDiffRequest::new(resolved, false).unwrap(); + let exact = + get_exact_review_diff_response_with_control(&parent.0, &request, &git_control).unwrap(); + let paired_diff = exact + .diff() + .files + .iter() + .find(|file| file.new_path.as_deref() == Some("deps/lib")) + .unwrap(); + assert_eq!(paired_diff.old_path.as_deref(), Some("deps/lib")); + + let structure = build_structure( + &parent.0, + request, + &git_control, + &ReviewWorkerControl::default(), + ) + .unwrap(); + assert!(structure.files().is_empty()); + let omission = structure + .omissions() + .iter() + .find(|omission| omission.reason() == OmittedFileReason::Submodule) + .unwrap(); + assert_eq!(omission.count(), 1); + assert_eq!(structure.coverage().unsupported_items(), 1); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reply_close_cancels_both_controls_and_waits_for_worker() { + let permits = Arc::new(Semaphore::new(1)); + let (reply, receiver) = oneshot::channel(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (finished_tx, finished_rx) = std::sync::mpsc::channel(); + spawn_review_action_with( + fake_action(), + Some(reply), + &tokio::runtime::Handle::current(), + permits.clone(), + move |_, git, control| { + let parser = control.start_analysis(); + started_tx.send(()).unwrap(); + while !git.is_cancelled() + || !control.cancelled.load(Ordering::Acquire) + || !parser.is_cancelled() + { + std::thread::yield_now(); + } + finished_tx.send(()).unwrap(); + CommandResult::Ok(None) + }, + ); + started_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + drop(receiver); + finished_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while permits.available_permits() != 1 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn semaphore_caps_review_workers_at_two() { + let permits = Arc::new(Semaphore::new(MAX_CONCURRENT_REVIEWS)); + let active = Arc::new(AtomicUsize::new(0)); + let maximum = Arc::new(AtomicUsize::new(0)); + let release = Arc::new(AtomicBool::new(false)); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let mut receivers = Vec::new(); + for _ in 0..3 { + let (reply, receiver) = oneshot::channel(); + receivers.push(receiver); + let active = active.clone(); + let maximum = maximum.clone(); + let release = release.clone(); + let started_tx = started_tx.clone(); + spawn_review_action_with( + fake_action(), + Some(reply), + &tokio::runtime::Handle::current(), + permits.clone(), + move |_, _, _| { + let current = active.fetch_add(1, Ordering::SeqCst) + 1; + maximum.fetch_max(current, Ordering::SeqCst); + started_tx.send(()).unwrap(); + while !release.load(Ordering::SeqCst) { + std::thread::yield_now(); + } + active.fetch_sub(1, Ordering::SeqCst); + CommandResult::Ok(None) + }, + ); + } + started_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + started_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + assert!(started_rx.try_recv().is_err()); + assert_eq!(maximum.load(Ordering::SeqCst), 2); + release.store(true, Ordering::SeqCst); + let mut results = Vec::new(); + for receiver in receivers { + results.push( + tokio::time::timeout(std::time::Duration::from_secs(2), receiver) + .await + .unwrap() + .unwrap(), + ); + } + assert!(started_rx.try_recv().is_err()); + assert_eq!( + results + .iter() + .filter(|result| matches!(result, CommandResult::Err(error) if error.contains("executor busy"))) + .count(), + 1 + ); + assert_eq!(maximum.load(Ordering::SeqCst), 2); + } + + #[test] + fn aggregate_budget_omissions_produce_pending_coverage() { + let (source_reason, source_truncation) = source_limit_omission( + ReviewSourceBudgetKind::AggregateSourceBytes, + MAX_SOURCE_TOTAL_BYTES, + 1, + 1, + ) + .unwrap(); + assert_eq!(source_reason, OmittedFileReason::AggregateByteLimit); + let mut omissions = Vec::new(); + add_omission( + &mut omissions, + Some(SyntaxLanguage::Rust), + source_reason, + Some(source_truncation), + ); + add_omission( + &mut omissions, + Some(SyntaxLanguage::Rust), + OmittedFileReason::FactLimit, + Some(measured_truncation( + TruncationReason::CaptureLimit, + 10, + 11, + "facts", + )), + ); + add_omission( + &mut omissions, + Some(SyntaxLanguage::Rust), + OmittedFileReason::ResponseLimit, + Some(measured_truncation( + TruncationReason::ResponseLimit, + 20, + 21, + "response", + )), + ); + let omissions = finish_omissions(omissions).unwrap(); + let coverage = coverage_for(&[], &omissions, None).unwrap(); + assert_eq!(coverage.pending_items(), 3); + assert_eq!( + coverage.truncation().unwrap().reason, + TruncationReason::Other + ); + } +} diff --git a/crates/okena-git/src/diff.rs b/crates/okena-git/src/diff.rs index 4e6ffc81d..50696622b 100644 --- a/crates/okena-git/src/diff.rs +++ b/crates/okena-git/src/diff.rs @@ -149,6 +149,17 @@ pub fn parse_unified_diff(output: &str) -> DiffResult { None => continue, }; + // Binary additions and deletions do not include `---`/`+++` markers. + // Clear the synthetic path inherited from the `diff --git` header. + if line.starts_with("new file mode ") { + file.old_path = None; + continue; + } + if line.starts_with("deleted file mode ") { + file.new_path = None; + continue; + } + // Parse rename/copy headers. A pure rename (100% similarity) emits // `rename from ` / `rename to ` with no `---`/`+++` lines; // copies emit `copy from`/`copy to` analogously. These override the @@ -157,14 +168,14 @@ pub fn parse_unified_diff(output: &str) -> DiffResult { .strip_prefix("rename from ") .or_else(|| line.strip_prefix("copy from ")) { - file.old_path = Some(old.to_string()); + file.old_path = decode_git_path(old).or_else(|| Some(old.to_string())); continue; } if let Some(new) = line .strip_prefix("rename to ") .or_else(|| line.strip_prefix("copy to ")) { - file.new_path = Some(new.to_string()); + file.new_path = decode_git_path(new).or_else(|| Some(new.to_string())); continue; } @@ -172,12 +183,14 @@ pub fn parse_unified_diff(output: &str) -> DiffResult { // `diff --git` header fallback (e.g. /dev/null clears the path for an // added file even though the header carried a fake `a/`). if line.starts_with("--- ") { - let path = line.strip_prefix("--- ").unwrap_or(""); + let raw_path = line.strip_prefix("--- ").unwrap_or(""); + let path = + decode_git_patch_marker_path(raw_path).unwrap_or_else(|| raw_path.to_string()); if path == "/dev/null" { file.old_path = None; } else { // Strip "a/" prefix if present - let path = path.strip_prefix("a/").unwrap_or(path); + let path = path.strip_prefix("a/").unwrap_or(&path); file.old_path = Some(path.to_string()); } continue; @@ -185,12 +198,14 @@ pub fn parse_unified_diff(output: &str) -> DiffResult { // Parse new file path if line.starts_with("+++ ") { - let path = line.strip_prefix("+++ ").unwrap_or(""); + let raw_path = line.strip_prefix("+++ ").unwrap_or(""); + let path = + decode_git_patch_marker_path(raw_path).unwrap_or_else(|| raw_path.to_string()); if path == "/dev/null" { file.new_path = None; } else { // Strip "b/" prefix if present - let path = path.strip_prefix("b/").unwrap_or(path); + let path = path.strip_prefix("b/").unwrap_or(&path); file.new_path = Some(path.to_string()); } continue; @@ -298,20 +313,41 @@ pub fn parse_unified_diff(output: &str) -> DiffResult { /// authoritative paths come from `rename from`/`rename to` or `---`/`+++` /// lines when present, which override this. /// -/// Caveat: when paths contain spaces the `a/… b/…` form is ambiguous and git -/// quotes them or relies on the explicit headers instead, so this helper only -/// reliably handles unquoted, space-free paths. Returns `(None, None)` if the -/// header can't be split unambiguously. fn parse_diff_git_header(line: &str) -> (Option, Option) { let rest = match line.strip_prefix("diff --git ") { Some(r) => r, None => return (None, None), }; - // Quoted paths (contain spaces / special chars) are not handled here; defer - // to the explicit rename/`---`/`+++` headers. if rest.starts_with('"') { - return (None, None); + let Some((old, rest)) = take_quoted_git_path(rest) else { + return (None, None); + }; + let Some(rest) = rest.strip_prefix(' ') else { + return (None, None); + }; + let new = if rest.starts_with('"') { + let Some((new, trailing)) = take_quoted_git_path(rest) else { + return (None, None); + }; + if !trailing.is_empty() { + return (None, None); + } + new + } else { + rest.to_string() + }; + return strip_diff_prefixes(old, new); + } + + // When only the new side is quoted, its opening quote is an unambiguous + // separator because a literal quote in the old path would also be quoted. + if let Some((old, new)) = rest.split_once(" \"b/") { + let quoted_new = format!("\"b/{new}"); + let Some(new) = decode_git_path("ed_new) else { + return (None, None); + }; + return strip_diff_prefixes(old.to_string(), new); } let a = match rest.strip_prefix("a/") { @@ -330,9 +366,113 @@ fn parse_diff_git_header(line: &str) -> (Option, Option) { return (None, None); } + strip_diff_prefixes(format!("a/{old}"), format!("b/{new}")) +} + +fn strip_diff_prefixes(old: String, new: String) -> (Option, Option) { + let Some(old) = old.strip_prefix("a/") else { + return (None, None); + }; + let Some(new) = new.strip_prefix("b/") else { + return (None, None); + }; + if old.is_empty() || new.is_empty() { + return (None, None); + } (Some(old.to_string()), Some(new.to_string())) } +fn take_quoted_git_path(input: &str) -> Option<(String, &str)> { + if !input.starts_with('"') { + return None; + } + let bytes = input.as_bytes(); + let mut escaped = false; + for index in 1..bytes.len() { + match (escaped, bytes[index]) { + (false, b'\\') => escaped = true, + (false, b'"') => { + let quoted = &input[..=index]; + return decode_git_path(quoted).map(|path| (path, &input[index + 1..])); + } + (true, _) => escaped = false, + _ => {} + } + } + None +} + +fn decode_git_patch_marker_path(input: &str) -> Option { + if input.starts_with('"') { + let (path, trailing) = take_quoted_git_path(input)?; + if trailing.is_empty() || trailing.starts_with('\t') { + Some(path) + } else { + None + } + } else { + Some( + input + .split_once('\t') + .map_or(input, |(path, _)| path) + .to_string(), + ) + } +} + +/// Decode Git's `quote.c` double-quoted path representation. +fn decode_git_path(input: &str) -> Option { + if !input.starts_with('"') { + return Some(input.to_string()); + } + let bytes = input.as_bytes(); + if bytes.len() < 2 || bytes.last() != Some(&b'"') { + return None; + } + let mut decoded = Vec::with_capacity(bytes.len() - 2); + let mut index = 1; + while index + 1 < bytes.len() { + if bytes[index] != b'\\' { + decoded.push(bytes[index]); + index += 1; + continue; + } + index += 1; + if index + 1 >= bytes.len() { + return None; + } + let escaped = bytes[index]; + let value = match escaped { + b'a' => 0x07, + b'b' => 0x08, + b't' => b'\t', + b'n' => b'\n', + b'v' => 0x0b, + b'f' => 0x0c, + b'r' => b'\r', + b'\\' => b'\\', + b'"' => b'"', + b'0'..=b'7' => { + let mut octal = escaped - b'0'; + let mut digits = 1; + while digits < 3 + && index + 1 < bytes.len() - 1 + && matches!(bytes[index + 1], b'0'..=b'7') + { + index += 1; + octal = octal.checked_mul(8)?.checked_add(bytes[index] - b'0')?; + digits += 1; + } + octal + } + _ => return None, + }; + decoded.push(value); + index += 1; + } + String::from_utf8(decoded).ok() +} + /// Parse hunk header to extract old and new starting line numbers. fn parse_hunk_header(header: &str) -> (usize, usize) { // Format: @@ -old_start,old_count +new_start,new_count @@ context @@ -780,6 +920,69 @@ mod tests { ); } + #[test] + fn decodes_git_c_quoted_paths() { + assert_eq!( + decode_git_path(r#""caf\303\251\t\"name\\file.rs""#).as_deref(), + Some("café\t\"name\\file.rs") + ); + assert!(decode_git_path(r#""bad\qpath""#).is_none()); + assert!(decode_git_path(r#""unterminated"#).is_none()); + assert_eq!( + decode_git_patch_marker_path("\"a/tab\\tname.rs\"\t").as_deref(), + Some("a/tab\tname.rs") + ); + } + + #[test] + fn parses_quoted_rename_paths_from_all_patch_headers() { + let diff = r#"diff --git "a/old \303\251\t\"name.rs" "b/new \303\251\n\"name.rs" +similarity index 90% +rename from "old \303\251\t\"name.rs" +rename to "new \303\251\n\"name.rs" +--- "a/old \303\251\t\"name.rs" ++++ "b/new \303\251\n\"name.rs" +@@ -1 +1 @@ +-old ++new +"#; + let result = parse_unified_diff(diff); + assert_eq!(result.files.len(), 1); + assert_eq!( + result.files[0].old_path.as_deref(), + Some("old é\t\"name.rs") + ); + assert_eq!( + result.files[0].new_path.as_deref(), + Some("new é\n\"name.rs") + ); + } + + #[test] + fn parses_quoted_paths_from_mode_only_header() { + let diff = r#"diff --git "a/tab\t\303\251.rs" "b/tab\t\303\251.rs" +old mode 100644 +new mode 100755 +"#; + let result = parse_unified_diff(diff); + assert_eq!(result.files.len(), 1); + assert_eq!(result.files[0].old_path.as_deref(), Some("tab\té.rs")); + assert_eq!(result.files[0].new_path.as_deref(), Some("tab\té.rs")); + } + + #[test] + fn parses_c_quoted_copy_headers() { + let diff = r#"diff --git "a/source\t\303\251.rs" "b/copy\n\"name.rs" +similarity index 100% +copy from "source\t\303\251.rs" +copy to "copy\n\"name.rs" +"#; + let result = parse_unified_diff(diff); + assert_eq!(result.files.len(), 1); + assert_eq!(result.files[0].old_path.as_deref(), Some("source\té.rs")); + assert_eq!(result.files[0].new_path.as_deref(), Some("copy\n\"name.rs")); + } + #[test] fn test_parse_unified_diff() { let diff = r#"diff --git a/src/main.rs b/src/main.rs @@ -896,6 +1099,28 @@ Binary files a/image.png and b/image.png differ assert!(result.files[0].hunks.is_empty()); } + #[test] + fn binary_addition_and_deletion_clear_the_absent_side() { + let diff = r#"diff --git a/added.png b/added.png +new file mode 100644 +index 0000000..1234567 +Binary files /dev/null and b/added.png differ +diff --git a/deleted.png b/deleted.png +deleted file mode 100644 +index 7654321..0000000 +Binary files a/deleted.png and /dev/null differ +"#; + + let result = parse_unified_diff(diff); + assert_eq!(result.files.len(), 2); + assert_eq!(result.files[0].old_path, None); + assert_eq!(result.files[0].new_path.as_deref(), Some("added.png")); + assert!(result.files[0].is_binary); + assert_eq!(result.files[1].old_path.as_deref(), Some("deleted.png")); + assert_eq!(result.files[1].new_path, None); + assert!(result.files[1].is_binary); + } + #[test] fn test_parse_empty_diff() { let result = parse_unified_diff(""); @@ -1092,10 +1317,13 @@ rename to src/new.rs parse_diff_git_header("diff --git a/old.rs b/new.rs"), (Some("old.rs".to_string()), Some("new.rs".to_string())) ); - // Quoted (special-char) paths are deferred to explicit headers. + // Quoted paths are decoded even when no explicit path headers follow. assert_eq!( parse_diff_git_header("diff --git \"a/has space.rs\" \"b/has space.rs\""), - (None, None) + ( + Some("has space.rs".to_string()), + Some("has space.rs".to_string()) + ) ); // Non-header input. assert_eq!(parse_diff_git_header("@@ -1 +1 @@"), (None, None)); diff --git a/crates/okena-git/src/error.rs b/crates/okena-git/src/error.rs index 377cf72bf..2c4ff7831 100644 --- a/crates/okena-git/src/error.rs +++ b/crates/okena-git/src/error.rs @@ -1,5 +1,25 @@ +use std::fmt; use std::path::PathBuf; +/// Byte budget that an exact review source request exceeded. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum ReviewSourceBudgetKind { + /// One source side exceeded the maximum blob size. + PerFileSourceBytes, + /// The combined source sides exceeded the request's remaining byte budget. + AggregateSourceBytes, +} + +impl fmt::Display for ReviewSourceBudgetKind { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PerFileSourceBytes => formatter.write_str("per-file byte"), + Self::AggregateSourceBytes => formatter.write_str("aggregate byte"), + } + } +} + /// Structured error type for git operations. #[derive(Debug, thiserror::Error)] pub enum GitError { @@ -38,6 +58,16 @@ pub enum GitError { /// Failed to parse structured output (JSON, etc.). #[error("parse error: {0}")] ParseError(String), + + /// An exact review source request exceeded a caller-owned byte budget. + #[error( + "exact review source {kind} budget exceeded: observed {observed} bytes, limit {limit} bytes" + )] + ReviewSourceBudgetExceeded { + kind: ReviewSourceBudgetKind, + observed: u64, + limit: u64, + }, } /// Convenience alias for `Result`. diff --git a/crates/okena-git/src/lib.rs b/crates/okena-git/src/lib.rs index fe16ec907..c2ce23e13 100644 --- a/crates/okena-git/src/lib.rs +++ b/crates/okena-git/src/lib.rs @@ -7,6 +7,7 @@ pub mod diff; pub mod error; pub(crate) mod gix_helpers; pub mod repository; +pub mod review; pub use blame::{BlameCommit, BlameError, BlameKind, BlameLine, get_blame}; pub use commit_graph::fetch_commit_log; @@ -14,7 +15,7 @@ pub use diff::{ DiffLineType, DiffMode, DiffResult, FileDiff, get_diff_with_options, get_file_contents_for_diff, is_git_repo, }; -pub use error::{GitError, GitResult}; +pub use error::{GitError, GitResult, ReviewSourceBudgetKind}; pub use repository::{ BranchList, HeadSnapshot, VerifiedWorktree, checkout_local_branch, checkout_remote_branch, compute_target_paths, count_ahead_behind, count_unpushed_commits, create_and_checkout_branch, @@ -27,6 +28,15 @@ pub use repository::{ resolve_review_base, stage_file, stash_changes, stash_pop, unstage_file, verify_linked_worktree_fresh, }; +pub use review::{ + ExactReviewDiffResponse, ReviewGitBudget, ReviewGitControl, ReviewSourceBudget, + ReviewSourceContents, get_exact_review_diff, get_exact_review_diff_response, + get_exact_review_diff_response_with_control, get_exact_review_diff_with_control, + get_exact_review_source, get_exact_review_source_response, + get_exact_review_source_response_with_control, get_exact_review_source_with_control, + get_review_inventory, get_review_inventory_with_control, resolve_review_comparison, + resolve_review_comparison_with_control, +}; /// Validate that a git ref (branch name, commit hash, revision) doesn't look /// like a command-line flag. Returns `Ok(name)` for safe values, or an error diff --git a/crates/okena-git/src/review.rs b/crates/okena-git/src/review.rs new file mode 100644 index 000000000..281be123b --- /dev/null +++ b/crates/okena-git/src/review.rs @@ -0,0 +1,2264 @@ +//! Exact Git comparison and deterministic review inventory. +//! +//! Friendly refs are resolved once. Every subsequent operation consumes the +//! effective immutable object IDs stored in the resolved comparison. + +use std::num::NonZeroU64; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use okena_core::process::{ + CommandBus, CommandCancellationHandle, CommandFailure, CommandFailureCause, CommandSpec, Lane, + OutputLimits, +}; +use okena_core::review::{ + ComparisonStrategy, ExactReviewSourceResponse, FactProvenance, FileClassification, FileRole, + GitObjectId, ImmutableResolvedComparison, ResolvedComparison, ReviewChangeTotals, + ReviewCommitFact, ReviewComparisonId, ReviewCoverage, ReviewDiffRequest, ReviewFileFact, + ReviewFileStatus, ReviewInventory, ReviewSnapshot, ReviewSourceRequest, ReviewSubmoduleChange, +}; +use okena_core::types::DiffMode; +use serde::{Deserialize, Serialize}; + +use crate::diff::{DiffResult, parse_unified_diff}; +use crate::error::{GitError, GitResult, ReviewSourceBudgetKind}; + +// Wave 1 records Git facts only; the later classifier replaces this explicit fallback. +const UNCLASSIFIED_RULE_ID: &str = "builtin.unclassified"; + +const DEFAULT_STDOUT_BYTES: u64 = 64 * 1024 * 1024; +const DEFAULT_STDERR_BYTES: u64 = 256 * 1024; +const DEFAULT_ELAPSED_MICROS: u64 = 90 * 1_000_000; + +/// Server-owned limits for one exact review Git request. +/// +/// Output limits apply independently to every Git command. The elapsed limit +/// is shared by every command that uses the same [`ReviewGitControl`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReviewGitBudget { + max_stdout_bytes: NonZeroU64, + max_stderr_bytes: NonZeroU64, + max_elapsed_micros: NonZeroU64, +} + +impl ReviewGitBudget { + pub fn new( + max_stdout_bytes: NonZeroU64, + max_stderr_bytes: NonZeroU64, + max_elapsed_micros: NonZeroU64, + ) -> Self { + Self { + max_stdout_bytes, + max_stderr_bytes, + max_elapsed_micros, + } + } + + pub fn max_stdout_bytes(self) -> NonZeroU64 { + self.max_stdout_bytes + } + + pub fn max_stderr_bytes(self) -> NonZeroU64 { + self.max_stderr_bytes + } + + pub fn max_elapsed_micros(self) -> NonZeroU64 { + self.max_elapsed_micros + } +} + +impl Default for ReviewGitBudget { + fn default() -> Self { + Self::new( + NonZeroU64::new(DEFAULT_STDOUT_BYTES).unwrap_or(NonZeroU64::MIN), + NonZeroU64::new(DEFAULT_STDERR_BYTES).unwrap_or(NonZeroU64::MIN), + NonZeroU64::new(DEFAULT_ELAPSED_MICROS).unwrap_or(NonZeroU64::MIN), + ) + } +} + +#[derive(Debug)] +struct ReviewGitControlInner { + budget: ReviewGitBudget, + started_at: Instant, + deadline: Option, + state: Mutex, + command_gate: Mutex<()>, +} + +#[derive(Debug, Default)] +struct ReviewGitControlState { + cancelled: bool, + active: Option, +} + +/// Runtime-only cooperative cancellation and overall deadline for review Git. +#[derive(Clone, Debug)] +pub struct ReviewGitControl(Arc); + +impl ReviewGitControl { + pub fn new(budget: ReviewGitBudget) -> Self { + let started_at = Instant::now(); + let deadline = + started_at.checked_add(Duration::from_micros(budget.max_elapsed_micros().get())); + Self(Arc::new(ReviewGitControlInner { + budget, + started_at, + deadline, + state: Mutex::new(ReviewGitControlState::default()), + command_gate: Mutex::new(()), + })) + } + + pub fn budget(&self) -> ReviewGitBudget { + self.0.budget + } + + pub fn cancel(&self) { + let active = { + let mut state = self + .0 + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.cancelled = true; + state.active.clone() + }; + if let Some(active) = active { + active.cancel(); + } + } + + pub fn is_cancelled(&self) -> bool { + self.0 + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .cancelled + } + + pub fn elapsed_micros(&self) -> u64 { + u64::try_from(self.0.started_at.elapsed().as_micros()).unwrap_or(u64::MAX) + } + + fn deadline_exceeded(&self, now: Instant) -> bool { + self.0.deadline.map_or_else( + || { + u64::try_from(now.saturating_duration_since(self.0.started_at).as_micros()) + .unwrap_or(u64::MAX) + >= self.0.budget.max_elapsed_micros().get() + }, + |deadline| now >= deadline, + ) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewSourceContents { + pub old_content: Option, + pub new_content: Option, +} + +/// Exact line diff paired with the immutable comparison that produced it. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExactReviewDiffResponse { + comparison: ImmutableResolvedComparison, + diff: DiffResult, +} + +impl ExactReviewDiffResponse { + fn new(request: &ReviewDiffRequest, diff: DiffResult) -> Self { + Self { + comparison: request.comparison.clone(), + diff, + } + } + + pub fn comparison(&self) -> &ImmutableResolvedComparison { + &self.comparison + } + + pub fn diff(&self) -> &DiffResult { + &self.diff + } + + pub fn into_parts(self) -> (ImmutableResolvedComparison, DiffResult) { + (self.comparison, self.diff) + } +} + +/// Allocation limits for loading the two sides of an exact source request. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewSourceBudget { + pub max_file_bytes: u64, + pub max_total_bytes: u64, +} + +impl ReviewSourceBudget { + pub fn new(max_file_bytes: u64, max_total_bytes: u64) -> GitResult { + if max_file_bytes == 0 || max_total_bytes == 0 { + return Err(GitError::ParseError( + "source byte limits must be greater than zero".to_string(), + )); + } + Ok(Self { + max_file_bytes, + max_total_bytes, + }) + } +} + +/// Resolve an immutable review target to full object IDs exactly once. +pub fn resolve_review_comparison(path: &Path, mode: DiffMode) -> GitResult { + resolve_review_comparison_with_control(path, mode, &ReviewGitControl::new(Default::default())) +} + +/// Resolve an immutable target using one request-scoped Git control. +pub fn resolve_review_comparison_with_control( + path: &Path, + mode: DiffMode, + control: &ReviewGitControl, +) -> GitResult { + match &mode { + DiffMode::BranchCompare { base, head } => { + crate::validate_git_ref(base)?; + crate::validate_git_ref(head)?; + let requested_base = resolve_commit_oid_with_control(path, base, control)?; + let requested_head = resolve_commit_oid_with_control(path, head, control)?; + let merge_base = resolve_merge_base(path, &requested_base, &requested_head, control)?; + + let (strategy, effective_base, merge_base_oid, identity) = match merge_base { + Some(merge_base) => ( + ComparisonStrategy::MergeBaseToHead, + merge_base.clone(), + Some(merge_base.clone()), + ReviewComparisonId(format!( + "branch:merge-base:{}:{}:{}", + requested_base, requested_head, merge_base + )), + ), + None => ( + ComparisonStrategy::DirectBaseToHeadWithoutMergeBase, + requested_base.clone(), + None, + ReviewComparisonId(format!( + "branch:direct:{}:{}", + requested_base, requested_head + )), + ), + }; + + resolved( + mode, + Some(requested_base), + Some(requested_head.clone()), + strategy, + ReviewSnapshot::Commit { + oid: effective_base, + }, + ReviewSnapshot::Commit { + oid: requested_head, + }, + merge_base_oid, + identity, + ) + } + DiffMode::Commit(reference) => { + crate::validate_git_ref(reference)?; + let commit = resolve_commit_oid_with_control(path, reference, control)?; + let parents = commit_parent_oids(path, &commit, control)?; + let first_parent = parents.first().cloned(); + match first_parent { + Some(parent) => resolved( + mode, + Some(parent.clone()), + Some(commit.clone()), + ComparisonStrategy::ParentToCommit, + ReviewSnapshot::Commit { oid: parent }, + ReviewSnapshot::Commit { + oid: commit.clone(), + }, + None, + ReviewComparisonId(format!("commit:parent:{commit}")), + ), + None => { + let empty_tree = empty_tree_oid(path, control)?; + resolved( + mode, + None, + Some(commit.clone()), + ComparisonStrategy::EmptyTreeToCommit, + ReviewSnapshot::EmptyTree { + oid: empty_tree.clone(), + }, + ReviewSnapshot::Commit { + oid: commit.clone(), + }, + None, + ReviewComparisonId(format!("commit:root:{empty_tree}:{commit}")), + ) + } + } + } + DiffMode::WorkingTree | DiffMode::Staged => Err(GitError::ParseError( + "mutable review comparison resolution is not implemented".to_string(), + )), + } +} + +/// Produce a line diff from the immutable effective snapshots. +pub fn get_exact_review_diff(path: &Path, request: &ReviewDiffRequest) -> GitResult { + get_exact_review_diff_with_control(path, request, &ReviewGitControl::new(Default::default())) +} + +/// Produce an exact line diff paired with its immutable comparison. +pub fn get_exact_review_diff_response( + path: &Path, + request: &ReviewDiffRequest, +) -> GitResult { + get_exact_review_diff_response_with_control( + path, + request, + &ReviewGitControl::new(Default::default()), + ) +} + +/// Produce a paired exact line diff using one request-scoped Git control. +pub fn get_exact_review_diff_response_with_control( + path: &Path, + request: &ReviewDiffRequest, + control: &ReviewGitControl, +) -> GitResult { + let diff = get_exact_review_diff_with_control(path, request, control)?; + Ok(ExactReviewDiffResponse::new(request, diff)) +} + +/// Produce an exact line diff using one request-scoped Git control. +pub fn get_exact_review_diff_with_control( + path: &Path, + request: &ReviewDiffRequest, + control: &ReviewGitControl, +) -> GitResult { + let comparison = request.comparison.as_resolved(); + let (base, head) = immutable_endpoints(comparison)?; + let mut args = vec![ + "diff", + "--no-color", + "--no-ext-diff", + "--no-textconv", + "--find-renames=50%", + "--find-copies=50%", + "--find-copies-harder", + base.as_str(), + head.as_str(), + ]; + if request.ignore_whitespace { + args.insert(7, "-w"); + } + let output = run_git(path, &args, control)?; + let stdout = String::from_utf8(output) + .map_err(|error| GitError::ParseError(format!("diff output is not UTF-8: {error}")))?; + Ok(parse_unified_diff(&stdout)) +} + +/// Load exact old/new source with distinct paths for additions, deletions, and renames. +pub fn get_exact_review_source( + path: &Path, + request: &ReviewSourceRequest, + budget: ReviewSourceBudget, +) -> GitResult { + get_exact_review_source_with_control( + path, + request, + budget, + &ReviewGitControl::new(Default::default()), + ) +} + +/// Load exact source using allocation limits and one request-scoped Git control. +pub fn get_exact_review_source_with_control( + path: &Path, + request: &ReviewSourceRequest, + budget: ReviewSourceBudget, + control: &ReviewGitControl, +) -> GitResult { + let comparison = request.comparison().as_resolved(); + let old = preflight_snapshot_file(path, comparison.base(), request.old_path(), control)?; + let new = preflight_snapshot_file(path, comparison.head(), request.new_path(), control)?; + enforce_source_budget(old.as_ref(), new.as_ref(), budget)?; + let old_content = read_preflight_file(path, old, control)?; + let new_content = read_preflight_file(path, new, control)?; + Ok(ReviewSourceContents { + old_content, + new_content, + }) +} + +/// Load exact source and pair it with the immutable request that produced it. +pub fn get_exact_review_source_response( + path: &Path, + request: &ReviewSourceRequest, + budget: ReviewSourceBudget, +) -> GitResult { + get_exact_review_source_response_with_control( + path, + request, + budget, + &ReviewGitControl::new(Default::default()), + ) +} + +/// Load a paired exact source response using one request-scoped Git control. +pub fn get_exact_review_source_response_with_control( + path: &Path, + request: &ReviewSourceRequest, + budget: ReviewSourceBudget, + control: &ReviewGitControl, +) -> GitResult { + let contents = get_exact_review_source_with_control(path, request, budget, control)?; + ExactReviewSourceResponse::new(request.clone(), contents.old_content, contents.new_content) + .map_err(|error| { + GitError::ParseError(format!( + "exact source response did not match its request: {error}" + )) + }) +} + +/// Build deterministic facts over one immutable resolved comparison. +pub fn get_review_inventory( + path: &Path, + comparison: &ImmutableResolvedComparison, +) -> GitResult { + get_review_inventory_with_control(path, comparison, &ReviewGitControl::new(Default::default())) +} + +/// Build deterministic inventory using one request-scoped Git control. +pub fn get_review_inventory_with_control( + path: &Path, + comparison: &ImmutableResolvedComparison, + control: &ReviewGitControl, +) -> GitResult { + let (base, head) = immutable_endpoints(comparison.as_resolved())?; + let raw = run_git( + path, + &[ + "diff", + "--raw", + "-z", + "--abbrev=64", + "--no-ext-diff", + "--no-textconv", + "--find-renames=50%", + "--find-copies=50%", + "--find-copies-harder", + base.as_str(), + head.as_str(), + ], + control, + )?; + let numstat = run_git( + path, + &[ + "diff", + "--numstat", + "-z", + "--no-ext-diff", + "--no-textconv", + "--find-renames=50%", + "--find-copies=50%", + "--find-copies-harder", + base.as_str(), + head.as_str(), + ], + control, + )?; + let mut files = parse_raw_diff(&raw)?; + apply_numstat(&mut files, &parse_numstat(&numstat)?)?; + let commits = bounded_commit_ledger(path, comparison.as_resolved(), control)?; + let totals = calculate_totals(&files, commits.len() as u64); + let coverage = ReviewCoverage::new(files.len() as u64, files.len() as u64, 0, 0, 0, 0, None) + .map_err(model_error)?; + + Ok(ReviewInventory { + comparison: comparison.as_resolved().clone(), + totals, + commits, + files, + coverage, + }) +} + +#[allow(clippy::too_many_arguments)] +fn resolved( + requested: DiffMode, + requested_base_oid: Option, + requested_head_oid: Option, + strategy: ComparisonStrategy, + base: ReviewSnapshot, + head: ReviewSnapshot, + merge_base_oid: Option, + identity: ReviewComparisonId, +) -> GitResult { + ResolvedComparison::new( + requested, + requested_base_oid, + requested_head_oid, + strategy, + base, + head, + merge_base_oid, + identity, + ) + .map_err(model_error) +} + +fn model_error(error: impl std::fmt::Display) -> GitError { + GitError::ParseError(error.to_string()) +} + +fn run_git(path: &Path, args: &[&str], control: &ReviewGitControl) -> GitResult> { + let output = run_git_output(path, args, control)?; + if !output.status.success() { + return Err(GitError::GitExitError { + status: output.status.code().unwrap_or(-1), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }); + } + Ok(output.stdout) +} + +fn run_git_output( + path: &Path, + args: &[&str], + control: &ReviewGitControl, +) -> GitResult { + run_review_command(review_git_spec(path, args, control), control) +} + +fn review_git_spec(path: &Path, args: &[&str], control: &ReviewGitControl) -> CommandSpec { + CommandSpec::new("git") + .args(args.iter().copied()) + .current_dir(path) + .lane(Lane::Interactive) + .label("git.review") + .output_limits(OutputLimits::new( + control.budget().max_stdout_bytes(), + control.budget().max_stderr_bytes(), + )) +} + +fn run_review_command( + spec: CommandSpec, + control: &ReviewGitControl, +) -> GitResult { + run_review_command_inner( + spec, + control, + #[cfg(test)] + None, + ) +} + +#[cfg(test)] +#[derive(Clone, Default)] +struct ReviewCommandTestHooks { + after_submit_before_publish: Option>, + after_publish: Option>, + after_wait_before_finish: Option>, + before_success_accept: Option>, +} + +fn run_review_command_inner( + mut spec: CommandSpec, + control: &ReviewGitControl, + #[cfg(test)] hooks: Option<&ReviewCommandTestHooks>, +) -> GitResult { + let _command_guard = control + .0 + .command_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + preflight_control(control)?; + let Some(deadline) = control.0.deadline else { + return Err(GitError::ParseError( + "process_failure: review Git deadline cannot be represented".to_string(), + )); + }; + spec.deadline = Some(deadline); + + let handle = { + let mut state = control + .0 + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.cancelled { + return Err(cancelled_error()); + } + let handle = CommandBus::global().submit(spec); + #[cfg(test)] + if let Some(hook) = hooks.and_then(|hooks| hooks.after_submit_before_publish.as_ref()) { + hook(); + } + let cancellation = handle.cancellation_handle(); + state.active = Some(cancellation); + handle + }; + + #[cfg(test)] + if let Some(hook) = hooks.and_then(|hooks| hooks.after_publish.as_ref()) { + hook(); + } + let result = handle.wait_detailed(); + #[cfg(test)] + if let Some(hook) = hooks.and_then(|hooks| hooks.after_wait_before_finish.as_ref()) { + hook(); + } + let cancelled_before_success = { + let mut state = control + .0 + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.active.take(); + if result.is_ok() { + #[cfg(test)] + if let Some(hook) = hooks.and_then(|hooks| hooks.before_success_accept.as_ref()) { + hook(); + } + state.cancelled + } else { + false + } + }; + + match result { + Ok(_) if cancelled_before_success => Err(cancelled_error()), + Ok(output) => Ok(output), + Err(failure) => Err(map_command_failure(failure, control.budget())), + } +} + +fn preflight_control(control: &ReviewGitControl) -> GitResult<()> { + if control.is_cancelled() { + return Err(cancelled_error()); + } + if control.deadline_exceeded(Instant::now()) { + return Err(time_limit_error(control.budget())); + } + Ok(()) +} + +fn cancelled_error() -> GitError { + GitError::ParseError("cancelled: review Git command was cancelled".to_string()) +} + +fn time_limit_error(budget: ReviewGitBudget) -> GitError { + GitError::ParseError(format!( + "time_limit: review Git request exceeded {} microseconds", + budget.max_elapsed_micros().get() + )) +} + +fn map_command_failure(failure: CommandFailure, budget: ReviewGitBudget) -> GitError { + let cleanup = cleanup_summary(&failure); + let message = match failure.primary { + CommandFailureCause::Cancelled { .. } => { + format!("cancelled: review Git command was cancelled{cleanup}") + } + CommandFailureCause::DeadlineExceeded { .. } => format!( + "time_limit: review Git request exceeded {} microseconds{cleanup}", + budget.max_elapsed_micros().get() + ), + CommandFailureCause::StdoutLimitExceeded { + limit, observed, .. + } => format!( + "stdout_limit: Git stdout exceeded {limit} bytes (observed at least {observed}){cleanup}" + ), + CommandFailureCause::StderrLimitExceeded { + limit, observed, .. + } => format!( + "stderr_limit: Git stderr exceeded {limit} bytes (observed at least {observed}){cleanup}" + ), + CommandFailureCause::Process { + operation, + kind, + message, + .. + } => format!( + "process_failure: Git command {operation} failed ({kind:?}): {}{cleanup}", + truncate_message(&message, 512) + ), + }; + GitError::ParseError(message) +} + +fn cleanup_summary(failure: &CommandFailure) -> String { + if failure.cleanup.is_empty() { + return String::new(); + } + let mut entries = failure + .cleanup + .iter() + .take(4) + .map(|item| format!("{}:{:?}", item.operation, item.kind)) + .collect::>(); + if failure.cleanup.len() > entries.len() { + entries.push(format!("+{} more", failure.cleanup.len() - entries.len())); + } + format!("; cleanup=[{}]", entries.join(", ")) +} + +fn truncate_message(message: &str, max_chars: usize) -> String { + let mut chars = message.chars(); + let prefix = chars.by_ref().take(max_chars).collect::(); + if chars.next().is_some() { + format!("{prefix}…") + } else { + prefix + } +} + +#[cfg(test)] +fn resolve_commit_oid(path: &Path, reference: &str) -> GitResult { + resolve_commit_oid_with_control(path, reference, &ReviewGitControl::new(Default::default())) +} + +fn resolve_commit_oid_with_control( + path: &Path, + reference: &str, + control: &ReviewGitControl, +) -> GitResult { + let revision = format!("{reference}^{{commit}}"); + let output = run_git(path, &["rev-parse", "--verify", &revision], control)?; + parse_oid(trim_ascii(&output), "resolved commit") +} + +fn resolve_merge_base( + path: &Path, + base: &GitObjectId, + head: &GitObjectId, + control: &ReviewGitControl, +) -> GitResult> { + let output = run_git_output(path, &["merge-base", base.as_str(), head.as_str()], control)?; + if output.status.success() { + return parse_oid(trim_ascii(&output.stdout), "merge base").map(Some); + } + if output.status.code() == Some(1) && output.stdout.is_empty() { + return Ok(None); + } + Err(GitError::GitExitError { + status: output.status.code().unwrap_or(-1), + stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), + }) +} + +fn commit_parent_oids( + path: &Path, + commit: &GitObjectId, + control: &ReviewGitControl, +) -> GitResult> { + let output = run_git( + path, + &["rev-list", "--parents", "-n", "1", commit.as_str()], + control, + )?; + let line = std::str::from_utf8(trim_ascii(&output)) + .map_err(|error| GitError::ParseError(format!("commit parents are not UTF-8: {error}")))?; + let mut parts = line.split_ascii_whitespace(); + let Some(returned_commit) = parts.next() else { + return Err(GitError::ParseError( + "missing commit parent record".to_string(), + )); + }; + if returned_commit != commit.as_str() { + return Err(GitError::ParseError( + "commit parent record does not match requested commit".to_string(), + )); + } + parts + .map(|part| parse_oid(part.as_bytes(), "commit parent")) + .collect() +} + +fn empty_tree_oid(path: &Path, control: &ReviewGitControl) -> GitResult { + // A null stdin supplies EOF, so this hashes an empty tree + // without writing an object and works for both SHA-1 and SHA-256 repos. + let output = run_git(path, &["hash-object", "-t", "tree", "--stdin"], control)?; + parse_oid(trim_ascii(&output), "empty tree") +} + +fn parse_oid(bytes: &[u8], context: &str) -> GitResult { + let value = std::str::from_utf8(bytes) + .map_err(|error| GitError::ParseError(format!("{context} is not UTF-8: {error}")))?; + GitObjectId::new(value.to_string()).map_err(model_error) +} + +fn trim_ascii(bytes: &[u8]) -> &[u8] { + let start = bytes + .iter() + .position(|byte| !byte.is_ascii_whitespace()) + .unwrap_or(bytes.len()); + let end = bytes + .iter() + .rposition(|byte| !byte.is_ascii_whitespace()) + .map(|index| index + 1) + .unwrap_or(start); + &bytes[start..end] +} + +fn immutable_endpoints(comparison: &ResolvedComparison) -> GitResult<(&GitObjectId, &GitObjectId)> { + let base = comparison + .base() + .oid() + .ok_or_else(|| GitError::ParseError("comparison base is mutable".to_string()))?; + let head = comparison + .head() + .oid() + .ok_or_else(|| GitError::ParseError("comparison head is mutable".to_string()))?; + Ok((base, head)) +} + +#[derive(Debug)] +struct PreflightBlob { + object: String, + size: u64, +} + +fn preflight_snapshot_file( + path: &Path, + snapshot: &ReviewSnapshot, + file_path: Option<&str>, + control: &ReviewGitControl, +) -> GitResult> { + let Some(file_path) = file_path else { + return Ok(None); + }; + match snapshot { + ReviewSnapshot::EmptyTree { .. } => Ok(None), + ReviewSnapshot::Commit { oid } => { + let object = format!("{}:{file_path}", oid.as_str()); + let output = run_git(path, &["cat-file", "-s", &object], control)?; + let size = std::str::from_utf8(trim_ascii(&output)) + .map_err(|error| GitError::ParseError(format!("blob size is not UTF-8: {error}")))? + .parse::() + .map_err(|error| GitError::ParseError(format!("invalid blob size: {error}")))?; + Ok(Some(PreflightBlob { object, size })) + } + ReviewSnapshot::Index { .. } | ReviewSnapshot::WorkingTree { .. } => Err( + GitError::ParseError("exact source request contains a mutable snapshot".to_string()), + ), + } +} + +fn enforce_source_budget( + old: Option<&PreflightBlob>, + new: Option<&PreflightBlob>, + budget: ReviewSourceBudget, +) -> GitResult<()> { + let budget = ReviewSourceBudget::new(budget.max_file_bytes, budget.max_total_bytes)?; + for blob in [old, new].into_iter().flatten() { + if blob.size > budget.max_file_bytes { + return Err(GitError::ReviewSourceBudgetExceeded { + kind: ReviewSourceBudgetKind::PerFileSourceBytes, + observed: blob.size, + limit: budget.max_file_bytes, + }); + } + } + let total = old + .map_or(0, |blob| blob.size) + .checked_add(new.map_or(0, |blob| blob.size)) + .ok_or_else(|| GitError::ParseError("source byte total overflowed".to_string()))?; + if total > budget.max_total_bytes { + return Err(GitError::ReviewSourceBudgetExceeded { + kind: ReviewSourceBudgetKind::AggregateSourceBytes, + observed: total, + limit: budget.max_total_bytes, + }); + } + Ok(()) +} + +fn read_preflight_file( + path: &Path, + blob: Option, + control: &ReviewGitControl, +) -> GitResult> { + let Some(blob) = blob else { + return Ok(None); + }; + let bytes = run_git(path, &["cat-file", "blob", &blob.object], control)?; + if bytes.len() as u64 != blob.size { + return Err(GitError::ParseError(format!( + "blob size changed between preflight and read: expected {}, received {}", + blob.size, + bytes.len() + ))); + } + String::from_utf8(bytes) + .map(Some) + .map_err(|error| GitError::ParseError(format!("source is not UTF-8: {error}"))) +} + +fn parse_raw_diff(output: &[u8]) -> GitResult> { + let chunks: Vec<&[u8]> = output.split(|byte| *byte == 0).collect(); + let mut files = Vec::new(); + let mut index = 0; + while index < chunks.len() && !chunks[index].is_empty() { + let header = std::str::from_utf8(chunks[index]) + .map_err(|error| GitError::ParseError(format!("raw header is not UTF-8: {error}")))?; + index += 1; + let Some(header) = header.strip_prefix(':') else { + return Err(GitError::ParseError(format!( + "raw diff record does not start with ':': {header:?}" + ))); + }; + let fields: Vec<&str> = header.split_ascii_whitespace().collect(); + if fields.len() != 5 { + return Err(GitError::ParseError(format!( + "raw diff header has {} fields instead of 5", + fields.len() + ))); + } + let old_mode = fields[0].to_string(); + let new_mode = fields[1].to_string(); + let old_oid = fields[2]; + let new_oid = fields[3]; + let status_token = fields[4]; + let status_code = status_token + .as_bytes() + .first() + .copied() + .ok_or_else(|| GitError::ParseError("raw status is empty".to_string()))?; + let first_path = take_path(&chunks, &mut index)?; + let (old_path, new_path) = match status_code { + b'A' => (None, Some(first_path)), + b'D' => (Some(first_path), None), + b'R' | b'C' => { + let second_path = take_path(&chunks, &mut index)?; + (Some(first_path), Some(second_path)) + } + _ => (Some(first_path.clone()), Some(first_path)), + }; + let is_submodule = old_mode == "160000" || new_mode == "160000"; + let similarity = + match status_code { + b'R' | b'C' => Some(status_token[1..].parse::().map_err(|error| { + GitError::ParseError(format!("invalid similarity: {error}")) + })?), + _ => None, + }; + let status = match status_code { + b'A' => ReviewFileStatus::Added, + b'D' => ReviewFileStatus::Deleted, + b'R' => ReviewFileStatus::Renamed, + b'C' => ReviewFileStatus::Copied, + b'T' => ReviewFileStatus::TypeChanged, + b'U' => ReviewFileStatus::Unmerged, + b'M' if is_submodule => ReviewFileStatus::SubmoduleChanged, + b'M' if old_mode != new_mode && old_oid == new_oid => ReviewFileStatus::ModeChanged, + b'M' => ReviewFileStatus::Modified, + _ => ReviewFileStatus::Unknown, + }; + let submodule = if is_submodule { + Some(ReviewSubmoduleChange { + old_oid: if old_mode == "160000" { + nonzero_oid(old_oid, "old submodule")? + } else { + None + }, + new_oid: if new_mode == "160000" { + nonzero_oid(new_oid, "new submodule")? + } else { + None + }, + worktree_dirty: false, + }) + } else { + None + }; + files.push(ReviewFileFact { + old_path, + new_path, + status, + similarity, + old_mode: mode_or_none(&old_mode), + new_mode: mode_or_none(&new_mode), + lines_added: None, + lines_deleted: None, + binary: false, + submodule, + classification: FileClassification::from_rule( + FileRole::Unclassified, + UNCLASSIFIED_RULE_ID, + ) + .map_err(model_error)?, + provenance: FactProvenance::Git, + }); + } + Ok(files) +} + +fn take_path(chunks: &[&[u8]], index: &mut usize) -> GitResult { + let Some(path) = chunks.get(*index) else { + return Err(GitError::ParseError("raw diff path is missing".to_string())); + }; + *index += 1; + String::from_utf8(path.to_vec()) + .map_err(|error| GitError::ParseError(format!("Git path is not UTF-8: {error}"))) +} + +fn nonzero_oid(value: &str, context: &str) -> GitResult> { + if value.bytes().all(|byte| byte == b'0') { + Ok(None) + } else { + parse_oid(value.as_bytes(), context).map(Some) + } +} + +fn mode_or_none(mode: &str) -> Option { + (mode != "000000").then(|| mode.to_string()) +} + +#[derive(Debug)] +enum NumstatPaths { + Single(String), + Pair { old: String, new: String }, +} + +#[derive(Debug)] +struct NumstatEntry { + paths: NumstatPaths, + added: Option, + deleted: Option, +} + +fn parse_numstat(output: &[u8]) -> GitResult> { + let chunks: Vec<&[u8]> = output.split(|byte| *byte == 0).collect(); + let mut entries = Vec::new(); + let mut index = 0; + while index < chunks.len() && !chunks[index].is_empty() { + let record = chunks[index]; + index += 1; + let mut fields = record.splitn(3, |byte| *byte == b'\t'); + let added = fields + .next() + .ok_or_else(|| GitError::ParseError("numstat addition is missing".to_string()))?; + let deleted = fields + .next() + .ok_or_else(|| GitError::ParseError("numstat deletion is missing".to_string()))?; + let path = fields + .next() + .ok_or_else(|| GitError::ParseError("numstat path is missing".to_string()))?; + let paths = if path.is_empty() { + let old = take_path(&chunks, &mut index)?; + let new = take_path(&chunks, &mut index)?; + NumstatPaths::Pair { old, new } + } else { + NumstatPaths::Single(String::from_utf8(path.to_vec()).map_err(|error| { + GitError::ParseError(format!("numstat path is not UTF-8: {error}")) + })?) + }; + let binary = added == b"-" && deleted == b"-"; + let (added, deleted) = if binary { + (None, None) + } else { + (Some(parse_count(added)?), Some(parse_count(deleted)?)) + }; + entries.push(NumstatEntry { + paths, + added, + deleted, + }); + } + Ok(entries) +} + +fn parse_count(value: &[u8]) -> GitResult { + let value = std::str::from_utf8(value) + .map_err(|error| GitError::ParseError(format!("numstat count is not UTF-8: {error}")))?; + value + .parse() + .map_err(|error| GitError::ParseError(format!("invalid numstat count: {error}"))) +} + +fn apply_numstat(files: &mut [ReviewFileFact], entries: &[NumstatEntry]) -> GitResult<()> { + let mut matched = vec![false; files.len()]; + for entry in entries { + let matching: Vec = files + .iter() + .enumerate() + .filter_map(|(index, file)| numstat_matches(file, &entry.paths).then_some(index)) + .collect(); + if matching.len() != 1 { + return Err(GitError::ParseError(format!( + "numstat entry matched {} raw file facts", + matching.len() + ))); + } + let index = matching[0]; + if matched[index] { + return Err(GitError::ParseError( + "multiple numstat entries matched one raw file fact".to_string(), + )); + } + matched[index] = true; + let file = &mut files[index]; + file.lines_added = entry.added; + file.lines_deleted = entry.deleted; + file.binary = entry.added.is_none() && entry.deleted.is_none(); + } + for (index, file) in files.iter_mut().enumerate() { + if matched[index] { + continue; + } + if file.status == ReviewFileStatus::ModeChanged { + file.lines_added = Some(0); + file.lines_deleted = Some(0); + continue; + } + return Err(GitError::ParseError(format!( + "raw file fact for {:?} has no matching numstat entry", + file.new_path.as_deref().or(file.old_path.as_deref()) + ))); + } + Ok(()) +} + +fn numstat_matches(file: &ReviewFileFact, paths: &NumstatPaths) -> bool { + match paths { + NumstatPaths::Single(path) => { + !matches!( + file.status, + ReviewFileStatus::Renamed | ReviewFileStatus::Copied + ) && file.new_path.as_deref().or(file.old_path.as_deref()) == Some(path.as_str()) + } + NumstatPaths::Pair { old, new } => { + file.old_path.as_deref() == Some(old) && file.new_path.as_deref() == Some(new) + } + } +} + +fn bounded_commit_ledger( + path: &Path, + comparison: &ResolvedComparison, + control: &ReviewGitControl, +) -> GitResult> { + let (_, head) = immutable_endpoints(comparison)?; + let range; + let (revision, max_count) = match comparison.requested() { + DiffMode::BranchCompare { .. } => { + let base = comparison.base().oid().ok_or_else(|| { + GitError::ParseError("branch comparison base is mutable".to_string()) + })?; + range = format!("{}..{}", base.as_str(), head.as_str()); + (range.as_str(), None) + } + DiffMode::Commit(_) => (head.as_str(), Some("--max-count=1")), + DiffMode::WorkingTree | DiffMode::Staged => { + return Err(GitError::ParseError( + "mutable comparison has no immutable commit ledger".to_string(), + )); + } + }; + let mut args = vec![ + "log", + "-z", + "--reverse", + "--topo-order", + "--no-decorate", + "--format=%H%x00%P%x00%s%x00%an%x00%ct", + ]; + if let Some(max_count) = max_count { + args.push(max_count); + } + args.push(revision); + let output = run_git(path, &args, control)?; + parse_commit_ledger(&output) +} + +fn parse_commit_ledger(output: &[u8]) -> GitResult> { + if output.is_empty() { + return Ok(Vec::new()); + } + let mut chunks: Vec<&[u8]> = output.split(|byte| *byte == 0).collect(); + if chunks.last().is_some_and(|chunk| chunk.is_empty()) { + chunks.pop(); + } + if !chunks.len().is_multiple_of(5) { + return Err(GitError::ParseError(format!( + "commit ledger has {} fields, not a multiple of 5", + chunks.len() + ))); + } + chunks + .chunks_exact(5) + .map(|record| { + let oid = parse_oid(record[0], "ledger commit")?; + let parents = std::str::from_utf8(record[1]) + .map_err(|error| { + GitError::ParseError(format!("ledger parents are not UTF-8: {error}")) + })? + .split_ascii_whitespace() + .map(|parent| parse_oid(parent.as_bytes(), "ledger parent")) + .collect::>>()?; + let subject = String::from_utf8(record[2].to_vec()).map_err(|error| { + GitError::ParseError(format!("commit subject is not UTF-8: {error}")) + })?; + let author_name = String::from_utf8(record[3].to_vec()).map_err(|error| { + GitError::ParseError(format!("commit author is not UTF-8: {error}")) + })?; + let timestamp = std::str::from_utf8(record[4]) + .map_err(|error| { + GitError::ParseError(format!("commit timestamp is not UTF-8: {error}")) + })? + .parse::() + .map_err(|error| { + GitError::ParseError(format!("invalid commit timestamp: {error}")) + })?; + Ok(ReviewCommitFact { + oid, + parent_oids: parents, + subject, + author_name, + timestamp, + provenance: FactProvenance::Git, + }) + }) + .collect() +} + +fn calculate_totals(files: &[ReviewFileFact], commits: u64) -> ReviewChangeTotals { + let mut totals = ReviewChangeTotals { + commits, + files: files.len() as u64, + files_added: 0, + files_deleted: 0, + files_modified: 0, + files_renamed: 0, + files_copied: 0, + files_type_changed: 0, + files_mode_changed: 0, + submodule_changes: 0, + binary_files: 0, + lines_added: 0, + lines_deleted: 0, + provenance: FactProvenance::Git, + }; + for file in files { + match file.status { + ReviewFileStatus::Added => totals.files_added += 1, + ReviewFileStatus::Deleted => totals.files_deleted += 1, + ReviewFileStatus::Modified => totals.files_modified += 1, + ReviewFileStatus::Renamed => totals.files_renamed += 1, + ReviewFileStatus::Copied => totals.files_copied += 1, + ReviewFileStatus::TypeChanged => totals.files_type_changed += 1, + ReviewFileStatus::ModeChanged => totals.files_mode_changed += 1, + ReviewFileStatus::SubmoduleChanged + | ReviewFileStatus::Unmerged + | ReviewFileStatus::Unknown => {} + } + if file.submodule.is_some() { + totals.submodule_changes += 1; + } + if file.binary { + totals.binary_files += 1; + } + totals.lines_added += file.lines_added.unwrap_or(0); + totals.lines_deleted += file.lines_deleted.unwrap_or(0); + } + totals +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::fs; + use std::num::NonZeroU64; + use std::process::Command; + use std::sync::{Barrier, mpsc}; + use std::time::{Duration, Instant}; + + use okena_core::review::{ComparisonStrategy, ImmutableResolvedComparison}; + + use super::*; + use crate::repository::test_support::{git_in, init_temp_repo}; + + fn immutable(comparison: ResolvedComparison) -> ImmutableResolvedComparison { + comparison.try_into().unwrap() + } + + fn source_budget() -> ReviewSourceBudget { + ReviewSourceBudget::new(1024 * 1024, 2 * 1024 * 1024).unwrap() + } + + fn git_budget(stdout: u64, stderr: u64, elapsed_micros: u64) -> ReviewGitBudget { + ReviewGitBudget::new( + NonZeroU64::new(stdout).unwrap(), + NonZeroU64::new(stderr).unwrap(), + NonZeroU64::new(elapsed_micros).unwrap(), + ) + } + + #[test] + fn command_bus_maps_stdout_overflow() { + let (_temporary, repo) = init_temp_repo(); + let control = ReviewGitControl::new(git_budget(1, 1024 * 1024, 5_000_000)); + let error = run_git(&repo, &["rev-parse", "HEAD"], &control).unwrap_err(); + assert!(error.to_string().contains("stdout_limit:")); + } + + #[test] + fn command_bus_maps_stderr_overflow() { + let (_temporary, repo) = init_temp_repo(); + let control = ReviewGitControl::new(git_budget(1024 * 1024, 1, 5_000_000)); + let error = run_git_output( + &repo, + &["rev-parse", "--verify", "definitely-not-a-ref"], + &control, + ) + .unwrap_err(); + assert!(error.to_string().contains("stderr_limit:")); + } + + #[test] + fn command_bus_preserves_bounded_nonzero_git_exit() { + let (_temporary, repo) = init_temp_repo(); + let control = ReviewGitControl::new(git_budget(1024 * 1024, 1024, 5_000_000)); + let error = run_git( + &repo, + &["rev-parse", "--verify", "definitely-not-a-ref"], + &control, + ) + .unwrap_err(); + assert!(matches!( + error, + GitError::GitExitError { + status: 128, + ref stderr, + } if !stderr.is_empty() && stderr.len() <= 1024 + )); + } + + #[test] + fn command_bus_maps_process_failure() { + let control = ReviewGitControl::new(git_budget(1024, 1024, 5_000_000)); + let error = run_review_command( + CommandSpec::new("okena-command-that-does-not-exist") + .lane(Lane::Interactive) + .label("git.review") + .output_limits(OutputLimits::new( + control.budget().max_stdout_bytes(), + control.budget().max_stderr_bytes(), + )), + &control, + ) + .unwrap_err(); + assert!(error.to_string().contains("process_failure:")); + } + + #[test] + fn cancellation_during_publication_is_forwarded_before_fast_success() { + let temporary = tempfile::tempdir().unwrap(); + let control = ReviewGitControl::new(git_budget(1024, 1024, 5_000_000)); + let at_publication = Arc::new(Barrier::new(2)); + let release_publication = Arc::new(Barrier::new(2)); + let cancellation_finished = Arc::new(Barrier::new(2)); + let hooks = ReviewCommandTestHooks { + after_submit_before_publish: Some({ + let at_publication = at_publication.clone(); + let release_publication = release_publication.clone(); + Arc::new(move || { + at_publication.wait(); + release_publication.wait(); + }) + }), + after_publish: Some({ + let cancellation_finished = cancellation_finished.clone(); + Arc::new(move || { + cancellation_finished.wait(); + }) + }), + ..Default::default() + }; + let runner_control = control.clone(); + let repo = temporary.path().to_path_buf(); + let runner = std::thread::spawn(move || { + run_review_command_inner( + review_git_spec(&repo, &["--version"], &runner_control), + &runner_control, + Some(&hooks), + ) + }); + + at_publication.wait(); + let (cancel_started_tx, cancel_started_rx) = mpsc::sync_channel(1); + let cancelling_control = control.clone(); + let canceller = std::thread::spawn(move || { + cancel_started_tx.send(()).unwrap(); + cancelling_control.cancel(); + cancellation_finished.wait(); + }); + cancel_started_rx.recv().unwrap(); + release_publication.wait(); + + let error = runner.join().unwrap().unwrap_err(); + canceller.join().unwrap(); + assert!(error.to_string().contains("cancelled:")); + } + + #[test] + fn cancellation_after_wait_before_success_acceptance_wins() { + let temporary = tempfile::tempdir().unwrap(); + let control = ReviewGitControl::new(git_budget(1024, 1024, 5_000_000)); + let after_wait = Arc::new(Barrier::new(2)); + let release_finish = Arc::new(Barrier::new(2)); + let hooks = ReviewCommandTestHooks { + after_wait_before_finish: Some({ + let after_wait = after_wait.clone(); + let release_finish = release_finish.clone(); + Arc::new(move || { + after_wait.wait(); + release_finish.wait(); + }) + }), + ..Default::default() + }; + let runner_control = control.clone(); + let repo = temporary.path().to_path_buf(); + let runner = std::thread::spawn(move || { + run_review_command_inner( + review_git_spec(&repo, &["--version"], &runner_control), + &runner_control, + Some(&hooks), + ) + }); + + after_wait.wait(); + control.cancel(); + release_finish.wait(); + + let error = runner.join().unwrap().unwrap_err(); + assert!(error.to_string().contains("cancelled:")); + } + + #[test] + fn success_acceptance_before_cancellation_keeps_current_success() { + let temporary = tempfile::tempdir().unwrap(); + let control = ReviewGitControl::new(git_budget(1024, 1024, 5_000_000)); + let before_accept = Arc::new(Barrier::new(2)); + let release_accept = Arc::new(Barrier::new(2)); + let hooks = ReviewCommandTestHooks { + before_success_accept: Some({ + let before_accept = before_accept.clone(); + let release_accept = release_accept.clone(); + Arc::new(move || { + before_accept.wait(); + release_accept.wait(); + }) + }), + ..Default::default() + }; + let runner_control = control.clone(); + let repo = temporary.path().to_path_buf(); + let runner = std::thread::spawn(move || { + run_review_command_inner( + review_git_spec(&repo, &["--version"], &runner_control), + &runner_control, + Some(&hooks), + ) + }); + + before_accept.wait(); + let (cancel_started_tx, cancel_started_rx) = mpsc::sync_channel(1); + let cancelling_control = control.clone(); + let canceller = std::thread::spawn(move || { + cancel_started_tx.send(()).unwrap(); + cancelling_control.cancel(); + }); + cancel_started_rx.recv().unwrap(); + release_accept.wait(); + + let output = runner.join().unwrap().unwrap(); + canceller.join().unwrap(); + assert!(output.status.success()); + assert!(control.is_cancelled()); + let error = run_git_output(temporary.path(), &["--version"], &control).unwrap_err(); + assert!(error.to_string().contains("cancelled:")); + } + + #[test] + fn cancellation_after_typed_bus_failure_preserves_bus_failure() { + let control = ReviewGitControl::new(git_budget(1024, 1024, 5_000_000)); + let after_wait = Arc::new(Barrier::new(2)); + let release_finish = Arc::new(Barrier::new(2)); + let hooks = ReviewCommandTestHooks { + after_wait_before_finish: Some({ + let after_wait = after_wait.clone(); + let release_finish = release_finish.clone(); + Arc::new(move || { + after_wait.wait(); + release_finish.wait(); + }) + }), + ..Default::default() + }; + let runner_control = control.clone(); + let runner = std::thread::spawn(move || { + run_review_command_inner( + CommandSpec::new("okena-command-that-does-not-exist") + .lane(Lane::Interactive) + .label("git.review") + .output_limits(OutputLimits::new( + runner_control.budget().max_stdout_bytes(), + runner_control.budget().max_stderr_bytes(), + )), + &runner_control, + Some(&hooks), + ) + }); + + after_wait.wait(); + control.cancel(); + release_finish.wait(); + + let error = runner.join().unwrap().unwrap_err(); + assert!(error.to_string().contains("process_failure:")); + assert!(!error.to_string().contains("cancelled:")); + } + + #[test] + fn command_bus_uses_one_deadline_across_sequential_commands() { + let (_temporary, repo) = init_temp_repo(); + let control = ReviewGitControl::new(git_budget(1024 * 1024, 1024, 100_000)); + run_git(&repo, &["rev-parse", "HEAD"], &control).unwrap(); + std::thread::sleep(Duration::from_millis(150)); + let error = run_git(&repo, &["rev-parse", "HEAD"], &control).unwrap_err(); + assert!(error.to_string().contains("time_limit:")); + } + + #[cfg(unix)] + #[test] + fn command_bus_does_not_submit_after_control_was_cancelled() { + let temporary = tempfile::tempdir().unwrap(); + let marker = temporary.path().join("should-not-exist"); + let alias = format!("alias.review-touch=!touch {}", marker.display()); + let control = ReviewGitControl::new(git_budget(1024, 1024, 5_000_000)); + control.cancel(); + + let error = run_git_output(temporary.path(), &["-c", &alias, "review-touch"], &control) + .unwrap_err(); + + assert!(error.to_string().contains("cancelled:")); + assert!(!marker.exists()); + } + + #[cfg(unix)] + #[test] + fn command_bus_observes_active_cancellation_and_reaps_tree() { + let temporary = tempfile::tempdir().unwrap(); + let marker = temporary.path().join("started"); + let control = ReviewGitControl::new(git_budget(1024 * 1024, 1024, 5_000_000)); + let runner_control = control.clone(); + let runner_marker = marker.clone(); + let repo_for_runner = temporary.path().to_path_buf(); + let runner = std::thread::spawn(move || { + let alias = format!( + "alias.review-hang=!echo $$ > {}; sleep 30", + runner_marker.display() + ); + run_git_output( + &repo_for_runner, + &["-c", &alias, "review-hang"], + &runner_control, + ) + }); + + let started = Instant::now(); + while !marker.exists() && started.elapsed() < Duration::from_secs(3) { + std::thread::sleep(Duration::from_millis(5)); + } + assert!(marker.exists(), "helper did not start before cancellation"); + control.cancel(); + let error = runner.join().unwrap().unwrap_err(); + assert!(error.to_string().contains("cancelled:")); + + #[cfg(target_os = "linux")] + { + let pid = fs::read_to_string(marker) + .unwrap() + .trim() + .parse::() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(1); + while okena_core::process::is_process_alive(pid) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + assert!(!okena_core::process::is_process_alive(pid)); + } + } + + #[test] + fn shared_control_runs_normal_exact_review_pipeline() { + let (_temporary, repo) = init_temp_repo(); + let control = ReviewGitControl::new(git_budget(4 * 1024 * 1024, 64 * 1024, 5_000_000)); + let comparison = resolve_review_comparison_with_control( + &repo, + DiffMode::Commit("HEAD".to_string()), + &control, + ) + .unwrap(); + let immutable = immutable(comparison.clone()); + let inventory = get_review_inventory_with_control(&repo, &immutable, &control).unwrap(); + let diff_response = get_exact_review_diff_response_with_control( + &repo, + &ReviewDiffRequest::new(comparison, false).unwrap(), + &control, + ) + .unwrap(); + + assert_eq!(inventory.comparison.identity(), immutable.identity()); + assert_eq!(diff_response.comparison().identity(), immutable.identity()); + assert_eq!(inventory.files.len(), diff_response.diff().files.len()); + assert!(!control.is_cancelled()); + } + + #[test] + fn exact_diff_response_has_stable_json_shape_and_round_trips() { + let (_temporary, repo) = init_temp_repo(); + let comparison = + resolve_review_comparison(&repo, DiffMode::Commit("HEAD".to_string())).unwrap(); + let request = ReviewDiffRequest::new(comparison, false).unwrap(); + let response = get_exact_review_diff_response(&repo, &request).unwrap(); + let comparison_json = serde_json::to_value(&request.comparison).unwrap(); + let diff_json = serde_json::to_value(response.diff()).unwrap(); + let value = serde_json::to_value(&response).unwrap(); + + assert_eq!( + value, + serde_json::json!({ + "comparison": comparison_json, + "diff": diff_json + }) + ); + let decoded: ExactReviewDiffResponse = serde_json::from_value(value).unwrap(); + assert_eq!( + decoded.comparison().identity(), + request.comparison.identity() + ); + assert_eq!(decoded.diff().files.len(), response.diff().files.len()); + } + + fn commit_at(repo: &Path, subject: &str, timestamp: &str) { + let output = Command::new("git") + .args(["-c", "commit.gpgsign=false", "commit", "-m", subject]) + .current_dir(repo) + .env("GIT_AUTHOR_NAME", "test") + .env("GIT_AUTHOR_EMAIL", "test@test") + .env("GIT_COMMITTER_NAME", "test") + .env("GIT_COMMITTER_EMAIL", "test@test") + .env("GIT_AUTHOR_DATE", timestamp) + .env("GIT_COMMITTER_DATE", timestamp) + .output() + .unwrap(); + assert!( + output.status.success(), + "dated commit failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn file<'a>(inventory: &'a ReviewInventory, path: &str) -> &'a ReviewFileFact { + inventory + .files + .iter() + .find(|file| { + file.new_path.as_deref() == Some(path) || file.old_path.as_deref() == Some(path) + }) + .unwrap_or_else(|| panic!("missing file fact for {path:?}")) + } + + #[test] + fn branch_resolution_freezes_refs_and_bounds_commit_ledger() { + let (_tmp, repo) = init_temp_repo(); + let fork = resolve_commit_oid(&repo, "main").unwrap(); + git_in(&repo, &["checkout", "-b", "feature"]); + fs::write(repo.join("file.txt"), "feature one\n").unwrap(); + git_in(&repo, &["add", "file.txt"]); + git_in( + &repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "feature one"], + ); + fs::write(repo.join("file.txt"), "feature two\n").unwrap(); + git_in(&repo, &["add", "file.txt"]); + git_in( + &repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "feature two"], + ); + let frozen_head = resolve_commit_oid(&repo, "feature").unwrap(); + + git_in(&repo, &["checkout", "main"]); + fs::write(repo.join("main-only.txt"), "main\n").unwrap(); + git_in(&repo, &["add", "main-only.txt"]); + git_in( + &repo, + &[ + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "main advanced", + ], + ); + let requested_base = resolve_commit_oid(&repo, "main").unwrap(); + + let comparison = resolve_review_comparison( + &repo, + DiffMode::BranchCompare { + base: "main".to_string(), + head: "feature".to_string(), + }, + ) + .unwrap(); + assert_eq!(comparison.strategy(), ComparisonStrategy::MergeBaseToHead); + assert_eq!(comparison.requested_base_oid(), Some(&requested_base)); + assert_eq!(comparison.requested_head_oid(), Some(&frozen_head)); + assert_eq!(comparison.merge_base_oid(), Some(&fork)); + assert_eq!(comparison.base().oid(), Some(&fork)); + + let request = ReviewDiffRequest::new(comparison.clone(), false).unwrap(); + let before = get_exact_review_diff(&repo, &request).unwrap(); + let source_request = ReviewSourceRequest::new( + comparison.clone(), + Some("file.txt".to_string()), + Some("file.txt".to_string()), + ) + .unwrap(); + let source_before = + get_exact_review_source_response(&repo, &source_request, source_budget()).unwrap(); + + git_in(&repo, &["branch", "-f", "feature", "main"]); + assert_eq!( + serde_json::to_value(get_exact_review_diff(&repo, &request).unwrap()).unwrap(), + serde_json::to_value(before).unwrap() + ); + assert_eq!( + get_exact_review_source_response(&repo, &source_request, source_budget()).unwrap(), + source_before + ); + assert_eq!(source_before.comparison(), source_request.comparison()); + assert_eq!(source_before.old_path(), Some("file.txt")); + assert_eq!(source_before.new_path(), Some("file.txt")); + assert_eq!(source_before.old_content(), Some("x")); + assert_eq!(source_before.new_content(), Some("feature two\n")); + + let inventory = get_review_inventory(&repo, &immutable(comparison)).unwrap(); + assert_eq!(inventory.commits.len(), 2); + assert_eq!(inventory.commits[0].subject, "feature one"); + assert_eq!(inventory.commits[1].subject, "feature two"); + assert!(inventory.commits.iter().all(|commit| { + matches!(commit.oid.as_str().len(), 40 | 64) + && commit + .parent_oids + .iter() + .all(|parent| matches!(parent.as_str().len(), 40 | 64)) + })); + } + + #[test] + fn skewed_diamond_ledger_keeps_every_parent_before_its_child() { + let (_tmp, repo) = init_temp_repo(); + git_in(&repo, &["checkout", "-b", "left"]); + fs::write(repo.join("left-one.txt"), "left one\n").unwrap(); + git_in(&repo, &["add", "left-one.txt"]); + commit_at(&repo, "left one", "2040-01-01T00:00:00Z"); + + git_in(&repo, &["checkout", "-b", "right", "main"]); + fs::write(repo.join("right-one.txt"), "right one\n").unwrap(); + git_in(&repo, &["add", "right-one.txt"]); + commit_at(&repo, "right one", "1990-01-01T00:00:00Z"); + fs::write(repo.join("right-two.txt"), "right two\n").unwrap(); + git_in(&repo, &["add", "right-two.txt"]); + commit_at(&repo, "right two", "2060-01-01T00:00:00Z"); + + git_in(&repo, &["checkout", "left"]); + fs::write(repo.join("left-two.txt"), "left two\n").unwrap(); + git_in(&repo, &["add", "left-two.txt"]); + commit_at(&repo, "left two", "1980-01-01T00:00:00Z"); + git_in(&repo, &["merge", "--no-ff", "--no-commit", "right"]); + commit_at(&repo, "merge right", "1970-01-01T00:00:00Z"); + + let comparison = resolve_review_comparison( + &repo, + DiffMode::BranchCompare { + base: "main".to_string(), + head: "left".to_string(), + }, + ) + .unwrap(); + let inventory = get_review_inventory(&repo, &immutable(comparison)).unwrap(); + assert_eq!(inventory.commits.len(), 5); + + let positions: HashMap<_, _> = inventory + .commits + .iter() + .enumerate() + .map(|(index, commit)| (commit.oid.clone(), index)) + .collect(); + for (child_index, commit) in inventory.commits.iter().enumerate() { + for parent in &commit.parent_oids { + if let Some(parent_index) = positions.get(parent) { + assert!( + parent_index < &child_index, + "parent {parent} followed child {}", + commit.oid + ); + } + } + } + } + + #[test] + fn unrelated_histories_use_explicit_direct_strategy() { + let (_tmp, repo) = init_temp_repo(); + let main = resolve_commit_oid(&repo, "main").unwrap(); + git_in(&repo, &["checkout", "--orphan", "other"]); + git_in(&repo, &["rm", "-f", "file.txt"]); + fs::write(repo.join("other.txt"), "other\n").unwrap(); + git_in(&repo, &["add", "other.txt"]); + git_in( + &repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "other root"], + ); + let other = resolve_commit_oid(&repo, "other").unwrap(); + + let comparison = resolve_review_comparison( + &repo, + DiffMode::BranchCompare { + base: "main".to_string(), + head: "other".to_string(), + }, + ) + .unwrap(); + assert_eq!( + comparison.strategy(), + ComparisonStrategy::DirectBaseToHeadWithoutMergeBase + ); + assert_eq!(comparison.base().oid(), Some(&main)); + assert_eq!(comparison.head().oid(), Some(&other)); + assert_eq!(comparison.merge_base_oid(), None); + } + + #[test] + fn root_commit_uses_empty_tree_and_has_one_ledger_entry() { + let (_tmp, repo) = init_temp_repo(); + let root = resolve_commit_oid(&repo, "main").unwrap(); + let comparison = + resolve_review_comparison(&repo, DiffMode::Commit(root.to_string())).unwrap(); + assert_eq!(comparison.strategy(), ComparisonStrategy::EmptyTreeToCommit); + assert!(matches!( + comparison.base(), + ReviewSnapshot::EmptyTree { .. } + )); + + let diff = get_exact_review_diff( + &repo, + &ReviewDiffRequest::new(comparison.clone(), false).unwrap(), + ) + .unwrap(); + assert_eq!(diff.files.len(), 1); + assert_eq!(diff.files[0].new_path.as_deref(), Some("file.txt")); + + let inventory = get_review_inventory(&repo, &immutable(comparison.clone())).unwrap(); + assert_eq!(inventory.commits.len(), 1); + assert_eq!(inventory.commits[0].oid, root); + assert!(inventory.commits[0].parent_oids.is_empty()); + + let source = get_exact_review_source( + &repo, + &ReviewSourceRequest::new(comparison, None, Some("file.txt".to_string())).unwrap(), + source_budget(), + ) + .unwrap(); + assert_eq!(source.old_content, None); + assert_eq!(source.new_content.as_deref(), Some("x")); + } + + #[test] + fn commit_mode_uses_first_parent_and_only_selected_commit_in_ledger() { + let (_tmp, repo) = init_temp_repo(); + let parent = resolve_commit_oid(&repo, "HEAD").unwrap(); + fs::write(repo.join("file.txt"), "next\n").unwrap(); + git_in(&repo, &["add", "file.txt"]); + git_in( + &repo, + &[ + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "selected commit", + ], + ); + let head = resolve_commit_oid(&repo, "HEAD").unwrap(); + + let comparison = + resolve_review_comparison(&repo, DiffMode::Commit("HEAD".to_string())).unwrap(); + assert_eq!(comparison.strategy(), ComparisonStrategy::ParentToCommit); + assert_eq!(comparison.base().oid(), Some(&parent)); + assert_eq!(comparison.head().oid(), Some(&head)); + + let inventory = get_review_inventory(&repo, &immutable(comparison)).unwrap(); + assert_eq!(inventory.commits.len(), 1); + assert_eq!(inventory.commits[0].oid, head); + assert_eq!(inventory.commits[0].parent_oids, vec![parent]); + } + + #[test] + fn exact_source_preflights_per_file_limits_for_both_sides() { + let (_tmp, repo) = init_temp_repo(); + fs::write(repo.join("file.txt"), "previous").unwrap(); + git_in(&repo, &["add", "file.txt"]); + git_in( + &repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "previous"], + ); + fs::write(repo.join("file.txt"), "next content").unwrap(); + git_in(&repo, &["add", "file.txt"]); + git_in( + &repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "next"], + ); + let comparison = + resolve_review_comparison(&repo, DiffMode::Commit("HEAD".to_string())).unwrap(); + let old_request = + ReviewSourceRequest::new(comparison.clone(), Some("file.txt".to_string()), None) + .unwrap(); + let old_error = get_exact_review_source_response( + &repo, + &old_request, + ReviewSourceBudget::new(7, 64).unwrap(), + ) + .unwrap_err(); + assert!(matches!( + old_error, + GitError::ReviewSourceBudgetExceeded { + kind: ReviewSourceBudgetKind::PerFileSourceBytes, + observed: 8, + limit: 7, + } + )); + + let new_request = + ReviewSourceRequest::new(comparison, None, Some("file.txt".to_string())).unwrap(); + let new_error = get_exact_review_source_response( + &repo, + &new_request, + ReviewSourceBudget::new(11, 64).unwrap(), + ) + .unwrap_err(); + assert!(matches!( + new_error, + GitError::ReviewSourceBudgetExceeded { + kind: ReviewSourceBudgetKind::PerFileSourceBytes, + observed: 12, + limit: 11, + } + )); + assert!(ReviewSourceBudget::new(0, 1).is_err()); + } + + #[test] + fn exact_source_reports_aggregate_remaining_budget_structurally() { + let (_tmp, repo) = init_temp_repo(); + fs::write(repo.join("file.txt"), "previous").unwrap(); + git_in(&repo, &["add", "file.txt"]); + git_in( + &repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "previous"], + ); + fs::write(repo.join("file.txt"), "next content").unwrap(); + git_in(&repo, &["add", "file.txt"]); + git_in( + &repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "next"], + ); + let comparison = + resolve_review_comparison(&repo, DiffMode::Commit("HEAD".to_string())).unwrap(); + let request = ReviewSourceRequest::new( + comparison, + Some("file.txt".to_string()), + Some("file.txt".to_string()), + ) + .unwrap(); + + let error = get_exact_review_source_response( + &repo, + &request, + ReviewSourceBudget::new(64, 19).unwrap(), + ) + .unwrap_err(); + assert!(matches!( + error, + GitError::ReviewSourceBudgetExceeded { + kind: ReviewSourceBudgetKind::AggregateSourceBytes, + observed: 20, + limit: 19, + } + )); + } + + #[test] + fn inventory_reports_file_shapes_and_exact_source_paths() { + let (_tmp, repo) = init_temp_repo(); + let original = (1..=30) + .map(|line| format!("line {line}\n")) + .collect::(); + fs::write(repo.join("old.rs"), &original).unwrap(); + fs::write(repo.join("deleted.txt"), "deleted\n").unwrap(); + fs::write(repo.join("binary.bin"), [0, 1, 2, 3]).unwrap(); + fs::write(repo.join("mode.sh"), "echo ok\n").unwrap(); + fs::write(repo.join("copy-source.txt"), &original).unwrap(); + git_in(&repo, &["add", "."]); + git_in( + &repo, + &[ + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "seed review files", + ], + ); + git_in(&repo, &["checkout", "-b", "feature"]); + + git_in(&repo, &["mv", "old.rs", "new.rs"]); + let changed = original.replace("line 15\n", "line fifteen changed\n"); + fs::write(repo.join("new.rs"), &changed).unwrap(); + fs::remove_file(repo.join("deleted.txt")).unwrap(); + fs::write(repo.join("added.txt"), "added\n").unwrap(); + fs::write(repo.join("binary.bin"), [0, 1, 2, 4]).unwrap(); + fs::copy(repo.join("copy-source.txt"), repo.join("copied.txt")).unwrap(); + fs::write(repo.join("odd name [é].txt"), "odd\n").unwrap(); + #[cfg(unix)] + fs::write(repo.join("tab\tand\nnewline.txt"), "control path\n").unwrap(); + git_in(&repo, &["add", "-A"]); + git_in(&repo, &["update-index", "--chmod=+x", "mode.sh"]); + git_in( + &repo, + &[ + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "mixed file changes", + ], + ); + + let comparison = resolve_review_comparison( + &repo, + DiffMode::BranchCompare { + base: "main".to_string(), + head: "feature".to_string(), + }, + ) + .unwrap(); + let inventory = get_review_inventory(&repo, &immutable(comparison.clone())).unwrap(); + + let renamed = file(&inventory, "new.rs"); + assert_eq!(renamed.status, ReviewFileStatus::Renamed); + assert_eq!(renamed.old_path.as_deref(), Some("old.rs")); + assert!( + renamed + .similarity + .is_some_and(|similarity| similarity < 100) + ); + assert_eq!( + file(&inventory, "deleted.txt").status, + ReviewFileStatus::Deleted + ); + assert_eq!( + file(&inventory, "added.txt").status, + ReviewFileStatus::Added + ); + assert_eq!( + file(&inventory, "copied.txt").status, + ReviewFileStatus::Copied + ); + assert_eq!( + file(&inventory, "mode.sh").status, + ReviewFileStatus::ModeChanged + ); + let binary = file(&inventory, "binary.bin"); + assert!(binary.binary); + assert_eq!(binary.lines_added, None); + assert_eq!(binary.lines_deleted, None); + assert_eq!( + file(&inventory, "odd name [é].txt").new_path.as_deref(), + Some("odd name [é].txt") + ); + #[cfg(unix)] + assert_eq!( + file(&inventory, "tab\tand\nnewline.txt") + .new_path + .as_deref(), + Some("tab\tand\nnewline.txt") + ); + assert!( + inventory + .files + .iter() + .all(|file| file.classification.role() == FileRole::Unclassified) + ); + + let rename_request = ReviewSourceRequest::new( + comparison.clone(), + Some("old.rs".to_string()), + Some("new.rs".to_string()), + ) + .unwrap(); + let renamed_source = + get_exact_review_source_response(&repo, &rename_request, source_budget()).unwrap(); + assert_eq!(renamed_source.comparison(), rename_request.comparison()); + assert_eq!(renamed_source.old_path(), Some("old.rs")); + assert_eq!(renamed_source.new_path(), Some("new.rs")); + assert_eq!(renamed_source.old_content(), Some(original.as_str())); + assert_eq!(renamed_source.new_content(), Some(changed.as_str())); + + let addition_request = + ReviewSourceRequest::new(comparison.clone(), None, Some("added.txt".to_string())) + .unwrap(); + let addition = + get_exact_review_source_response(&repo, &addition_request, source_budget()).unwrap(); + assert_eq!(addition.comparison(), addition_request.comparison()); + assert_eq!(addition.old_path(), None); + assert_eq!(addition.new_path(), Some("added.txt")); + assert_eq!(addition.old_content(), None); + assert_eq!(addition.new_content(), Some("added\n")); + + let deletion_request = + ReviewSourceRequest::new(comparison, Some("deleted.txt".to_string()), None).unwrap(); + let deletion = + get_exact_review_source_response(&repo, &deletion_request, source_budget()).unwrap(); + assert_eq!(deletion.comparison(), deletion_request.comparison()); + assert_eq!(deletion.old_path(), Some("deleted.txt")); + assert_eq!(deletion.new_path(), None); + assert_eq!(deletion.old_content(), Some("deleted\n")); + assert_eq!(deletion.new_content(), None); + } + + #[cfg(unix)] + #[test] + fn exact_patch_and_inventory_agree_on_c_quoted_rename_paths() { + let (_tmp, repo) = init_temp_repo(); + let old_path = "old é\t\"quoted.rs"; + let new_path = "new é\n\"quoted.rs"; + let original = (1..=40) + .map(|line| format!("line {line}\n")) + .collect::(); + fs::write(repo.join(old_path), &original).unwrap(); + git_in(&repo, &["add", old_path]); + git_in( + &repo, + &["-c", "commit.gpgsign=false", "commit", "-m", "odd path"], + ); + git_in(&repo, &["checkout", "-b", "feature"]); + git_in(&repo, &["mv", old_path, new_path]); + let changed = original.replace("line 20\n", "line twenty changed\n"); + fs::write(repo.join(new_path), changed).unwrap(); + git_in(&repo, &["add", "-A"]); + git_in( + &repo, + &[ + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "rename odd path", + ], + ); + + let comparison = resolve_review_comparison( + &repo, + DiffMode::BranchCompare { + base: "main".to_string(), + head: "feature".to_string(), + }, + ) + .unwrap(); + let inventory = get_review_inventory(&repo, &immutable(comparison.clone())).unwrap(); + let inventory_file = file(&inventory, new_path); + assert_eq!(inventory_file.old_path.as_deref(), Some(old_path)); + assert_eq!(inventory_file.new_path.as_deref(), Some(new_path)); + + let diff = + get_exact_review_diff(&repo, &ReviewDiffRequest::new(comparison, false).unwrap()) + .unwrap(); + let patch_file = diff + .files + .iter() + .find(|file| file.new_path.as_deref() == Some(new_path)) + .unwrap_or_else(|| { + panic!( + "quoted rename exists in exact line diff: {:?}", + diff.files + .iter() + .map(|file| (&file.old_path, &file.new_path)) + .collect::>() + ) + }); + assert_eq!(patch_file.old_path, inventory_file.old_path); + assert_eq!(patch_file.new_path, inventory_file.new_path); + assert!(patch_file.hunks.iter().any(|hunk| !hunk.lines.is_empty())); + } + + #[test] + fn raw_submodule_record_preserves_commit_oids() { + let old = "1".repeat(40); + let new = "2".repeat(40); + let raw = format!(":160000 160000 {old} {new} M\0vendor/lib\0"); + let files = parse_raw_diff(raw.as_bytes()).unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].status, ReviewFileStatus::SubmoduleChanged); + let submodule = files[0].submodule.as_ref().unwrap(); + assert_eq!(submodule.old_oid.as_ref().unwrap().as_str(), old); + assert_eq!(submodule.new_oid.as_ref().unwrap().as_str(), new); + } + + #[test] + fn raw_submodule_transition_only_populates_the_submodule_side() { + let regular = "1".repeat(40); + let submodule = "2".repeat(40); + let to_submodule = format!(":100644 160000 {regular} {submodule} T\0vendor/lib\0"); + let files = parse_raw_diff(to_submodule.as_bytes()).unwrap(); + let change = files[0].submodule.as_ref().unwrap(); + assert_eq!(change.old_oid, None); + assert_eq!(change.new_oid.as_ref().unwrap().as_str(), submodule); + + let to_regular = format!(":160000 100644 {submodule} {regular} T\0vendor/lib\0"); + let files = parse_raw_diff(to_regular.as_bytes()).unwrap(); + let change = files[0].submodule.as_ref().unwrap(); + assert_eq!(change.old_oid.as_ref().unwrap().as_str(), submodule); + assert_eq!(change.new_oid, None); + } + + #[test] + fn numstat_correspondence_rejects_missing_duplicate_and_unmatched_records() { + let old = "1".repeat(40); + let new = "2".repeat(40); + let raw = format!(":100644 100644 {old} {new} M\0file.txt\0"); + + let mut missing = parse_raw_diff(raw.as_bytes()).unwrap(); + assert!(apply_numstat(&mut missing, &[]).is_err()); + + let entries = parse_numstat(b"1\t1\tfile.txt\x001\t1\tfile.txt\0").unwrap(); + let mut duplicate = parse_raw_diff(raw.as_bytes()).unwrap(); + assert!(apply_numstat(&mut duplicate, &entries).is_err()); + + let entries = parse_numstat(b"1\t1\tother.txt\0").unwrap(); + let mut unmatched = parse_raw_diff(raw.as_bytes()).unwrap(); + assert!(apply_numstat(&mut unmatched, &entries).is_err()); + } +} diff --git a/crates/okena-review/Cargo.toml b/crates/okena-review/Cargo.toml new file mode 100644 index 000000000..cb6197ab8 --- /dev/null +++ b/crates/okena-review/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "okena-review" +version = "0.1.0" +edition = "2024" +license = "MIT" + +[dependencies] +okena-core = { path = "../okena-core" } +okena-syntax = { path = "../okena-syntax" } +serde = { version = "1.0", features = ["derive"] } + +[dev-dependencies] +serde_json = "1.0" diff --git a/crates/okena-review/src/call_diff/mod.rs b/crates/okena-review/src/call_diff/mod.rs new file mode 100644 index 000000000..e31f8f111 --- /dev/null +++ b/crates/okena-review/src/call_diff/mod.rs @@ -0,0 +1,1250 @@ +//! Deterministic same-file comparison of direct outgoing calls. + +use std::cmp::Ordering; +use std::collections::BTreeMap; +use std::fmt; + +use okena_core::review::{ComparisonSide, ReviewNavigationTarget}; +use okena_syntax::{CallFact, ControlContext, SourceRange, SymbolKey, SyntaxLanguage}; + +use crate::model::{ControlledModelError, checked_stable_sort_by}; +use crate::{CallChangeKind, CallDiffChange, CallPairingEvidence, CallPairingStrategy, ModelError}; + +/// Exact inputs for comparing direct calls in one uniquely matched descriptive symbol. +/// +/// This input does not claim stable symbol identity. The caller selects a same-file old/new symbol +/// match and supplies each side's exact source range. Calls outside those ranges, calls owned by a +/// nested symbol, and calls owned by another same-named symbol are ignored. +#[derive(Clone, Copy, Debug)] +pub struct CallDiffInput<'a> { + old_path: &'a str, + new_path: &'a str, + enclosing_symbol: &'a SymbolKey, + old_enclosing_range: SourceRange, + new_enclosing_range: SourceRange, + old_calls: &'a [CallFact], + new_calls: &'a [CallFact], +} + +/// Borrowed call candidates already indexed for one enclosing symbol. +#[derive(Clone, Copy, Debug)] +pub struct IndexedCallDiffInput<'a> { + old_path: &'a str, + new_path: &'a str, + enclosing_symbol: &'a SymbolKey, + old_enclosing_range: SourceRange, + new_enclosing_range: SourceRange, + old_calls: &'a [&'a CallFact], + new_calls: &'a [&'a CallFact], +} + +impl<'a> IndexedCallDiffInput<'a> { + #[allow(clippy::too_many_arguments)] + pub fn new( + old_path: &'a str, + new_path: &'a str, + enclosing_symbol: &'a SymbolKey, + old_enclosing_range: SourceRange, + new_enclosing_range: SourceRange, + old_calls: &'a [&'a CallFact], + new_calls: &'a [&'a CallFact], + ) -> Result { + validate_paths(old_path, new_path)?; + Ok(Self { + old_path, + new_path, + enclosing_symbol, + old_enclosing_range, + new_enclosing_range, + old_calls, + new_calls, + }) + } +} + +/// Why a controlled deterministic comparison stopped before producing a result. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ComparisonStopReason { + Cancelled, + Deadline, + Disconnected, +} + +impl<'a> CallDiffInput<'a> { + #[allow(clippy::too_many_arguments)] + pub fn new( + old_path: &'a str, + new_path: &'a str, + enclosing_symbol: &'a SymbolKey, + old_enclosing_range: SourceRange, + new_enclosing_range: SourceRange, + old_calls: &'a [CallFact], + new_calls: &'a [CallFact], + ) -> Result { + validate_paths(old_path, new_path)?; + Ok(Self { + old_path, + new_path, + enclosing_symbol, + old_enclosing_range, + new_enclosing_range, + old_calls, + new_calls, + }) + } +} + +#[derive(Debug)] +pub enum CallDiffError { + EmptyPath(ComparisonSide), + InvalidChange(ModelError), +} + +impl fmt::Display for CallDiffError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyPath(ComparisonSide::Base) => { + formatter.write_str("old call-diff path must not be empty") + } + Self::EmptyPath(ComparisonSide::Head) => { + formatter.write_str("new call-diff path must not be empty") + } + Self::InvalidChange(error) => write!(formatter, "invalid call-diff change: {error}"), + } + } +} + +impl std::error::Error for CallDiffError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::InvalidChange(error) => Some(error), + Self::EmptyPath(_) => None, + } + } +} + +/// Error returned only by cooperative CallDiff entry points. +#[derive(Debug)] +pub enum ControlledCallDiffError { + Comparison(CallDiffError), + Stopped(ComparisonStopReason), +} + +impl fmt::Display for ControlledCallDiffError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Comparison(error) => error.fmt(formatter), + Self::Stopped(reason) => write!(formatter, "call-diff comparison stopped: {reason:?}"), + } + } +} + +impl std::error::Error for ControlledCallDiffError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Comparison(error) => Some(error), + Self::Stopped(_) => None, + } + } +} + +impl ControlledCallDiffError { + pub fn stop_reason(&self) -> Option { + match self { + Self::Stopped(reason) => Some(*reason), + Self::Comparison(_) => None, + } + } +} + +impl From for ControlledCallDiffError { + fn from(error: CallDiffError) -> Self { + Self::Comparison(error) + } +} + +impl From for ControlledCallDiffError { + fn from(error: ModelError) -> Self { + Self::Comparison(CallDiffError::InvalidChange(error)) + } +} + +impl From> for ControlledCallDiffError { + fn from(error: ControlledModelError) -> Self { + match error { + ControlledModelError::Invalid(error) => { + Self::Comparison(CallDiffError::InvalidChange(error)) + } + ControlledModelError::Stopped(reason) => Self::Stopped(reason), + } + } +} + +impl From for CallDiffError { + fn from(value: ModelError) -> Self { + Self::InvalidChange(value) + } +} + +#[derive(Default)] +struct Candidates<'a> { + old: Vec<&'a CallFact>, + new: Vec<&'a CallFact>, +} + +/// Compare direct outgoing calls for one selected same-file enclosing symbol. +/// +/// A modified call is emitted only when its exact callee and descriptive enclosing key produce one +/// candidate on each side with identical syntax provenance. Any repetition or provenance mismatch +/// deliberately degrades to removed and added occurrences. +pub fn compare_calls(input: CallDiffInput<'_>) -> Result, CallDiffError> { + match compare_calls_controlled(input, &mut || None) { + Ok(changes) => Ok(changes), + Err(ControlledCallDiffError::Comparison(error)) => Err(error), + Err(ControlledCallDiffError::Stopped(_)) => { + unreachable!("the legacy CallDiff entry point never requests a stop") + } + } +} + +/// Compare direct calls with a cooperative stop checkpoint. +pub fn compare_calls_controlled( + input: CallDiffInput<'_>, + checkpoint: &mut dyn FnMut() -> Option, +) -> Result, ControlledCallDiffError> { + check(checkpoint)?; + let mut old_calls = Vec::new(); + for call in input.old_calls { + check(checkpoint)?; + if call.enclosing_symbol() == Some(input.enclosing_symbol) + && input.old_enclosing_range.contains(call.call_site_range()) + { + old_calls.push(call); + } + } + let mut new_calls = Vec::new(); + for call in input.new_calls { + check(checkpoint)?; + if call.enclosing_symbol() == Some(input.enclosing_symbol) + && input.new_enclosing_range.contains(call.call_site_range()) + { + new_calls.push(call); + } + } + compare_indexed_calls_controlled( + IndexedCallDiffInput::new( + input.old_path, + input.new_path, + input.enclosing_symbol, + input.old_enclosing_range, + input.new_enclosing_range, + &old_calls, + &new_calls, + )?, + checkpoint, + ) +} + +/// Compare pre-indexed borrowed candidates with cooperative stop checkpoints. +pub fn compare_indexed_calls( + input: IndexedCallDiffInput<'_>, +) -> Result, CallDiffError> { + match compare_indexed_calls_controlled(input, &mut || None) { + Ok(changes) => Ok(changes), + Err(ControlledCallDiffError::Comparison(error)) => Err(error), + Err(ControlledCallDiffError::Stopped(_)) => { + unreachable!("the legacy indexed CallDiff entry point never requests a stop") + } + } +} + +/// Compare pre-indexed borrowed candidates with cooperative stop checkpoints. +pub fn compare_indexed_calls_controlled( + input: IndexedCallDiffInput<'_>, + checkpoint: &mut dyn FnMut() -> Option, +) -> Result, ControlledCallDiffError> { + check(checkpoint)?; + let mut candidates = BTreeMap::<&str, Candidates<'_>>::new(); + for &call in input.old_calls { + check(checkpoint)?; + if call.enclosing_symbol() == Some(input.enclosing_symbol) + && input.old_enclosing_range.contains(call.call_site_range()) + { + candidates + .entry(call.callee_text()) + .or_default() + .old + .push(call); + } + } + for &call in input.new_calls { + check(checkpoint)?; + if call.enclosing_symbol() == Some(input.enclosing_symbol) + && input.new_enclosing_range.contains(call.call_site_range()) + { + candidates + .entry(call.callee_text()) + .or_default() + .new + .push(call); + } + } + + let mut changes = Vec::new(); + for group in candidates.values_mut() { + check(checkpoint)?; + for call in &group.old { + check(checkpoint)?; + for _ in call.control_context() { + check(checkpoint)?; + } + } + for call in &group.new { + check(checkpoint)?; + for _ in call.control_context() { + check(checkpoint)?; + } + } + checked_stable_sort_by( + &mut group.old, + |left, right, checkpoint| compare_facts_controlled(left, right, checkpoint), + &mut || check(checkpoint), + )?; + checked_stable_sort_by( + &mut group.new, + |left, right, checkpoint| compare_facts_controlled(left, right, checkpoint), + &mut || check(checkpoint), + )?; + // Repeated callees: an occurrence that reads the same on both sides + // (arguments, control context, provenance) is unchanged whatever its + // ordinal, so it drops out before the uniqueness test below. Nothing + // here pairs by position; the leftovers still degrade to added/removed. + let (old_count, new_count) = (group.old.len(), group.new.len()); + cancel_identical_controlled(&mut group.old, &mut group.new, checkpoint)?; + if let ([old], [new]) = (group.old.as_slice(), group.new.as_slice()) + && old.provenance() == new.provenance() + { + let strategy = if old_count == 1 && new_count == 1 { + CallPairingStrategy::UniqueOccurrenceWithinEnclosingRange + } else { + CallPairingStrategy::UniqueChangedOccurrenceWithinEnclosingRange + }; + let arguments_changed = old.argument_text() != new.argument_text(); + let control_context_changed = !contexts_equal_controlled( + old.control_context(), + new.control_context(), + checkpoint, + )?; + if !arguments_changed && !control_context_changed { + continue; + } + let pairing = CallPairingEvidence::new( + strategy, + old.call_site_range(), + new.call_site_range(), + input.old_enclosing_range, + input.new_enclosing_range, + 1, + 1, + )?; + changes.push(CallDiffChange::new_controlled( + CallChangeKind::Modified, + Some((*old).clone()), + Some((*new).clone()), + arguments_changed, + control_context_changed, + Some(pairing), + navigation(input.new_path, ComparisonSide::Head, new.call_site_range()), + &mut || checkpoint().map_or(Ok(()), Err), + )?); + continue; + } + + for call in &group.old { + check(checkpoint)?; + changes.push(CallDiffChange::new_controlled( + CallChangeKind::Removed, + Some((*call).clone()), + None, + false, + false, + None, + navigation(input.old_path, ComparisonSide::Base, call.call_site_range()), + &mut || checkpoint().map_or(Ok(()), Err), + )?); + } + for call in &group.new { + check(checkpoint)?; + changes.push(CallDiffChange::new_controlled( + CallChangeKind::Added, + None, + Some((*call).clone()), + false, + false, + None, + navigation(input.new_path, ComparisonSide::Head, call.call_site_range()), + &mut || checkpoint().map_or(Ok(()), Err), + )?); + } + } + checked_stable_sort_by(&mut changes, compare_changes_controlled, &mut || { + check(checkpoint) + })?; + Ok(changes) +} + +/// Drop every old/new occurrence pair that is identical in argument text, +/// control context and provenance; each old call cancels at most one new call. +fn cancel_identical_controlled<'a>( + old: &mut Vec<&'a CallFact>, + new: &mut Vec<&'a CallFact>, + checkpoint: &mut dyn FnMut() -> Option, +) -> Result<(), ControlledCallDiffError> { + let mut kept_old = Vec::with_capacity(old.len()); + for candidate in old.drain(..) { + check(checkpoint)?; + let mut matched = None; + for (index, other) in new.iter().enumerate() { + if candidate.argument_text() == other.argument_text() + && candidate.provenance() == other.provenance() + && contexts_equal_controlled( + candidate.control_context(), + other.control_context(), + checkpoint, + )? + { + matched = Some(index); + break; + } + } + match matched { + Some(index) => { + new.remove(index); + } + None => kept_old.push(candidate), + } + } + *old = kept_old; + Ok(()) +} + +fn validate_paths(old_path: &str, new_path: &str) -> Result<(), CallDiffError> { + if old_path.trim().is_empty() { + return Err(CallDiffError::EmptyPath(ComparisonSide::Base)); + } + if new_path.trim().is_empty() { + return Err(CallDiffError::EmptyPath(ComparisonSide::Head)); + } + Ok(()) +} + +fn check( + checkpoint: &mut dyn FnMut() -> Option, +) -> Result<(), ControlledCallDiffError> { + match checkpoint() { + Some(reason) => Err(ControlledCallDiffError::Stopped(reason)), + None => Ok(()), + } +} + +fn navigation(path: &str, side: ComparisonSide, call_range: SourceRange) -> ReviewNavigationTarget { + ReviewNavigationTarget { + path: path.to_string(), + side, + line: call_range.start_line(), + byte_offset: Some(call_range.start_byte()), + symbol_context: None, + } +} + +fn compare_facts_controlled( + left: &CallFact, + right: &CallFact, + checkpoint: &mut dyn FnMut() -> Result<(), E>, +) -> Result { + let ordering = left + .call_site_range() + .start_byte() + .cmp(&right.call_site_range().start_byte()) + .then_with(|| { + left.call_site_range() + .end_byte() + .cmp(&right.call_site_range().end_byte()) + }) + .then_with(|| left.argument_text().cmp(right.argument_text())); + if ordering != Ordering::Equal { + return Ok(ordering); + } + let ordering = + compare_contexts_controlled(left.control_context(), right.control_context(), checkpoint)?; + if ordering != Ordering::Equal { + return Ok(ordering); + } + Ok(language_rank(left.provenance().language()) + .cmp(&language_rank(right.provenance().language())) + .then_with(|| left.provenance().parser().cmp(right.provenance().parser()))) +} + +fn language_rank(language: SyntaxLanguage) -> u8 { + match language { + SyntaxLanguage::Rust => 0, + SyntaxLanguage::TypeScript => 1, + SyntaxLanguage::Tsx => 2, + } +} + +fn compare_contexts_controlled( + left: &[ControlContext], + right: &[ControlContext], + checkpoint: &mut dyn FnMut() -> Result<(), E>, +) -> Result { + for (left, right) in left.iter().zip(right) { + checkpoint()?; + let ordering = + context_rank(left) + .cmp(&context_rank(right)) + .then_with(|| match (left, right) { + (ControlContext::Other(left), ControlContext::Other(right)) => left.cmp(right), + _ => Ordering::Equal, + }); + if ordering != Ordering::Equal { + return Ok(ordering); + } + } + Ok(left.len().cmp(&right.len())) +} + +fn contexts_equal_controlled( + left: &[ControlContext], + right: &[ControlContext], + checkpoint: &mut dyn FnMut() -> Option, +) -> Result { + Ok(compare_contexts_controlled(left, right, &mut || check(checkpoint))? == Ordering::Equal) +} + +fn context_rank(context: &ControlContext) -> u8 { + match context { + ControlContext::Condition => 0, + ControlContext::Loop => 1, + ControlContext::MatchArm => 2, + ControlContext::ErrorBranch => 3, + ControlContext::Callback => 4, + ControlContext::Closure => 5, + ControlContext::Other(_) => 6, + } +} + +fn compare_changes_controlled( + left: &CallDiffChange, + right: &CallDiffChange, + checkpoint: &mut dyn FnMut() -> Result<(), E>, +) -> Result { + let ordering = left + .navigation() + .line + .cmp(&right.navigation().line) + .then_with(|| { + left.navigation() + .byte_offset + .cmp(&right.navigation().byte_offset) + }) + .then_with(|| change_rank(left.kind()).cmp(&change_rank(right.kind()))) + .then_with(|| change_callee(left).cmp(change_callee(right))) + .then_with(|| left.navigation().path.cmp(&right.navigation().path)) + .then_with(|| side_rank(left.navigation().side).cmp(&side_rank(right.navigation().side))); + if ordering != Ordering::Equal { + return Ok(ordering); + } + match (change_fact(left), change_fact(right)) { + (Some(left), Some(right)) => compare_facts_controlled(left, right, checkpoint), + (None, Some(_)) => Ok(Ordering::Less), + (Some(_), None) => Ok(Ordering::Greater), + (None, None) => Ok(Ordering::Equal), + } +} + +fn change_rank(kind: CallChangeKind) -> u8 { + match kind { + CallChangeKind::Removed => 0, + CallChangeKind::Modified => 1, + CallChangeKind::Added => 2, + } +} + +fn side_rank(side: ComparisonSide) -> u8 { + match side { + ComparisonSide::Base => 0, + ComparisonSide::Head => 1, + } +} + +fn change_callee(change: &CallDiffChange) -> &str { + change_fact(change) + .map(CallFact::callee_text) + .unwrap_or_default() +} + +fn change_fact(change: &CallDiffChange) -> Option<&CallFact> { + change.new_fact().or_else(|| change.old()) +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroU32; + + use okena_syntax::{SymbolKind, SyntaxLanguage, SyntaxProvenance}; + + use super::*; + + fn source_range(start: u64, end: u64, line: u32) -> SourceRange { + let line = NonZeroU32::new(line).unwrap(); + SourceRange::new(start, end, line, line).unwrap() + } + + fn enclosing_range() -> SourceRange { + SourceRange::new( + 0, + 10_000, + NonZeroU32::new(1).unwrap(), + NonZeroU32::new(1_000).unwrap(), + ) + .unwrap() + } + + fn key(path: &[&str], name: &str) -> SymbolKey { + SymbolKey::new( + path.iter().map(|segment| (*segment).to_string()).collect(), + SymbolKind::Function, + name, + ) + .unwrap() + } + + fn call( + callee: &str, + arguments: &str, + start: u64, + line: u32, + enclosing: &SymbolKey, + contexts: Vec, + ) -> CallFact { + call_with_provenance( + SyntaxProvenance::tree_sitter(SyntaxLanguage::TypeScript, "call-diff-test").unwrap(), + callee, + arguments, + start, + line, + enclosing, + contexts, + ) + } + + fn call_with_provenance( + provenance: SyntaxProvenance, + callee: &str, + arguments: &str, + start: u64, + line: u32, + enclosing: &SymbolKey, + contexts: Vec, + ) -> CallFact { + let call_range = source_range(start, start.saturating_add(12), line); + CallFact::new( + provenance, + callee, + arguments, + call_range, + call_range, + Some(enclosing.clone()), + contexts, + ) + .unwrap() + } + + fn compare( + enclosing: &SymbolKey, + old_calls: &[CallFact], + new_calls: &[CallFact], + ) -> Vec { + compare_paths("src/old.ts", "src/new.ts", enclosing, old_calls, new_calls) + } + + fn compare_paths( + old_path: &str, + new_path: &str, + enclosing: &SymbolKey, + old_calls: &[CallFact], + new_calls: &[CallFact], + ) -> Vec { + compare_calls( + CallDiffInput::new( + old_path, + new_path, + enclosing, + enclosing_range(), + enclosing_range(), + old_calls, + new_calls, + ) + .unwrap(), + ) + .unwrap() + } + + #[test] + fn unchanged_unique_call_is_omitted() { + let enclosing = key(&[], "review"); + let old = vec![call("load", "(value)", 10, 2, &enclosing, Vec::new())]; + let new = vec![call("load", "(value)", 30, 4, &enclosing, Vec::new())]; + + assert!(compare(&enclosing, &old, &new).is_empty()); + } + + #[test] + fn additions_and_removals_navigate_to_their_own_side_and_path() { + let enclosing = key(&[], "review"); + let old = vec![call("removed", "()", 20, 3, &enclosing, Vec::new())]; + let new = vec![call("added", "()", 40, 5, &enclosing, Vec::new())]; + let changes = compare_paths("old/name.ts", "new/name.ts", &enclosing, &old, &new); + + let removed = changes + .iter() + .find(|change| change.kind() == CallChangeKind::Removed) + .unwrap(); + assert_eq!(removed.navigation().path, "old/name.ts"); + assert_eq!(removed.navigation().side, ComparisonSide::Base); + assert_eq!(removed.navigation().line.get(), 3); + assert_eq!(removed.navigation().byte_offset, Some(20)); + let added = changes + .iter() + .find(|change| change.kind() == CallChangeKind::Added) + .unwrap(); + assert_eq!(added.navigation().path, "new/name.ts"); + assert_eq!(added.navigation().side, ComparisonSide::Head); + assert_eq!(added.navigation().line.get(), 5); + assert_eq!(added.navigation().byte_offset, Some(40)); + } + + #[test] + fn unique_pair_reports_argument_control_and_combined_modifications() { + let enclosing = key(&[], "review"); + for (old_arguments, new_arguments, old_context, new_context, expected) in [ + ("(old)", "(new)", vec![], vec![], (true, false)), + ( + "(same)", + "(same)", + vec![], + vec![ControlContext::Condition], + (false, true), + ), + ( + "(old)", + "(new)", + vec![ControlContext::Loop], + vec![ControlContext::Condition], + (true, true), + ), + ] { + let old = vec![call("load", old_arguments, 10, 2, &enclosing, old_context)]; + let new = vec![call("load", new_arguments, 30, 4, &enclosing, new_context)]; + let changes = compare(&enclosing, &old, &new); + + assert_eq!(changes.len(), 1); + let change = &changes[0]; + assert_eq!(change.kind(), CallChangeKind::Modified); + assert_eq!(change.arguments_changed(), expected.0); + assert_eq!(change.control_context_changed(), expected.1); + assert_eq!(change.navigation().side, ComparisonSide::Head); + assert_eq!(change.navigation().path, "src/new.ts"); + assert_eq!(change.navigation().line.get(), 4); + let evidence = change.pairing().unwrap(); + assert_eq!( + evidence.strategy(), + CallPairingStrategy::UniqueOccurrenceWithinEnclosingRange + ); + assert_eq!(evidence.old_candidate_count(), 1); + assert_eq!(evidence.new_candidate_count(), 1); + assert_eq!(evidence.old_call_range(), old[0].call_site_range()); + assert_eq!(evidence.new_call_range(), new[0].call_site_range()); + assert_eq!(evidence.old_enclosing_range(), enclosing_range()); + assert_eq!(evidence.new_enclosing_range(), enclosing_range()); + } + } + + #[test] + fn repeated_candidates_on_one_or_both_sides_never_pair_by_ordinal() { + let enclosing = key(&[], "review"); + let old = vec![ + call("load", "(first)", 10, 2, &enclosing, Vec::new()), + call("load", "(second)", 30, 4, &enclosing, Vec::new()), + ]; + let one_new = vec![call("load", "(new)", 50, 6, &enclosing, Vec::new())]; + let one_sided = compare(&enclosing, &old, &one_new); + assert_eq!( + one_sided + .iter() + .filter(|change| change.kind() == CallChangeKind::Removed) + .count(), + 2 + ); + assert_eq!( + one_sided + .iter() + .filter(|change| change.kind() == CallChangeKind::Added) + .count(), + 1 + ); + assert!(one_sided.iter().all(|change| change.pairing().is_none())); + + let two_new = vec![ + call("load", "(third)", 50, 6, &enclosing, Vec::new()), + call("load", "(fourth)", 70, 8, &enclosing, Vec::new()), + ]; + let both_sides = compare(&enclosing, &old, &two_new); + assert_eq!(both_sides.len(), 4); + assert!( + both_sides + .iter() + .all(|change| change.kind() != CallChangeKind::Modified) + ); + } + + #[test] + fn identical_repeated_calls_cancel_out_whatever_their_ordinal() { + let enclosing = key(&[], "review"); + let old = vec![ + call("useState", "(false)", 10, 2, &enclosing, Vec::new()), + call("useState", "(true)", 30, 4, &enclosing, Vec::new()), + call("useState", "(false)", 50, 6, &enclosing, Vec::new()), + ]; + // Same three occurrences, moved down by an unrelated edit above them. + let moved = vec![ + call("useState", "(false)", 90, 8, &enclosing, Vec::new()), + call("useState", "(true)", 110, 10, &enclosing, Vec::new()), + call("useState", "(false)", 130, 12, &enclosing, Vec::new()), + ]; + assert!(compare(&enclosing, &old, &moved).is_empty()); + + // One occurrence changed: the identical ones drop out, the rest still + // degrade to added/removed — never a positional Modified pair. + let edited = vec![ + call("useState", "(false)", 90, 8, &enclosing, Vec::new()), + call("useState", "(1)", 110, 10, &enclosing, Vec::new()), + call("useState", "(false)", 130, 12, &enclosing, Vec::new()), + ]; + let changes = compare(&enclosing, &old, &edited); + assert_eq!(changes.len(), 1); + let change = &changes[0]; + assert_eq!(change.kind(), CallChangeKind::Modified); + assert_eq!(change.old().map(CallFact::argument_text), Some("(true)")); + assert_eq!(change.new_fact().map(CallFact::argument_text), Some("(1)")); + assert_eq!( + change.pairing().map(CallPairingEvidence::strategy), + Some(CallPairingStrategy::UniqueChangedOccurrenceWithinEnclosingRange) + ); + + // Two changed occurrences per side stay ambiguous: added and removed. + let two_edited = vec![ + call("useState", "(1)", 90, 8, &enclosing, Vec::new()), + call("useState", "(2)", 110, 10, &enclosing, Vec::new()), + call("useState", "(false)", 130, 12, &enclosing, Vec::new()), + ]; + let changes = compare(&enclosing, &old, &two_edited); + assert_eq!(changes.len(), 4); + assert!(changes.iter().all(|change| change.pairing().is_none())); + + // Control context is part of identity: the same call inside a loop is + // a different occurrence from the one outside it. + let in_loop = vec![ + call( + "useState", + "(false)", + 90, + 8, + &enclosing, + vec![ControlContext::Loop], + ), + call("useState", "(true)", 110, 10, &enclosing, Vec::new()), + call("useState", "(false)", 130, 12, &enclosing, Vec::new()), + ]; + let changes = compare(&enclosing, &old, &in_loop); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].kind(), CallChangeKind::Modified); + assert!(changes[0].control_context_changed()); + } + + #[test] + fn exact_callee_change_degrades_to_added_and_removed() { + let enclosing = key(&[], "review"); + let old = vec![call("load", "(value)", 10, 2, &enclosing, Vec::new())]; + let new = vec![call("loadCached", "(value)", 30, 4, &enclosing, Vec::new())]; + let changes = compare(&enclosing, &old, &new); + + assert_eq!(changes.len(), 2); + assert!( + changes + .iter() + .any(|change| change.kind() == CallChangeKind::Removed) + ); + assert!( + changes + .iter() + .any(|change| change.kind() == CallChangeKind::Added) + ); + } + + #[test] + fn cross_language_provenance_never_pairs_as_modified() { + let enclosing = key(&[], "review"); + let old = vec![call_with_provenance( + SyntaxProvenance::tree_sitter(SyntaxLanguage::TypeScript, "typescript-parser").unwrap(), + "load", + "(old)", + 10, + 2, + &enclosing, + Vec::new(), + )]; + let new = vec![call_with_provenance( + SyntaxProvenance::tree_sitter(SyntaxLanguage::Rust, "rust-parser").unwrap(), + "load", + "(new)", + 30, + 4, + &enclosing, + Vec::new(), + )]; + let changes = compare(&enclosing, &old, &new); + + assert_eq!(changes.len(), 2); + assert!( + changes + .iter() + .any(|change| change.kind() == CallChangeKind::Removed) + ); + assert!( + changes + .iter() + .any(|change| change.kind() == CallChangeKind::Added) + ); + assert!( + changes + .iter() + .all(|change| change.kind() != CallChangeKind::Modified) + ); + } + + #[test] + fn parser_version_mismatch_never_pairs_as_modified() { + let enclosing = key(&[], "review"); + let old = vec![call_with_provenance( + SyntaxProvenance::tree_sitter(SyntaxLanguage::TypeScript, "typescript-parser@1") + .unwrap(), + "load", + "(old)", + 10, + 2, + &enclosing, + Vec::new(), + )]; + let new = vec![call_with_provenance( + SyntaxProvenance::tree_sitter(SyntaxLanguage::TypeScript, "typescript-parser@2") + .unwrap(), + "load", + "(new)", + 30, + 4, + &enclosing, + Vec::new(), + )]; + let changes = compare(&enclosing, &old, &new); + + assert_eq!(changes.len(), 2); + assert!( + changes + .iter() + .all(|change| change.kind() != CallChangeKind::Modified) + ); + } + + #[test] + fn nested_other_and_out_of_range_calls_are_excluded() { + let enclosing = key(&[], "outer"); + let nested = key(&["outer"], "inner"); + let other = key(&[], "other"); + let old = vec![ + call("selected", "(old)", 10, 2, &enclosing, Vec::new()), + call("nested", "()", 20, 3, &nested, Vec::new()), + call("selected", "(other)", 30, 4, &other, Vec::new()), + call("outside", "()", 20_000, 2_000, &enclosing, Vec::new()), + ]; + let new = vec![ + call("selected", "(new)", 40, 5, &enclosing, Vec::new()), + call("nested", "()", 50, 6, &nested, Vec::new()), + call("selected", "(other)", 60, 7, &other, Vec::new()), + ]; + let changes = compare(&enclosing, &old, &new); + + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].kind(), CallChangeKind::Modified); + assert_eq!(changes[0].new_fact().unwrap().callee_text(), "selected"); + } + + #[test] + fn unicode_callee_and_ranges_are_preserved() { + let enclosing = key(&["Nástroje"], "zkontroluj"); + let old = vec![call( + "služba.načti", + "(žlutý)", + 21, + 3, + &enclosing, + Vec::new(), + )]; + let new = vec![call( + "služba.načti", + "(červený)", + 55, + 6, + &enclosing, + Vec::new(), + )]; + let changes = compare(&enclosing, &old, &new); + + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].new_fact().unwrap().callee_text(), "služba.načti"); + assert_eq!(changes[0].navigation().byte_offset, Some(55)); + assert_eq!( + changes[0].pairing().unwrap().old_call_range(), + source_range(21, 33, 3) + ); + } + + #[test] + fn output_order_is_stable_for_reordered_inputs() { + let enclosing = key(&[], "review"); + let old = vec![ + call("zeta", "()", 90, 9, &enclosing, Vec::new()), + call("alpha", "()", 10, 2, &enclosing, Vec::new()), + call("repeat", "(one)", 50, 5, &enclosing, Vec::new()), + call("repeat", "(two)", 70, 7, &enclosing, Vec::new()), + ]; + let new = vec![call("beta", "()", 30, 3, &enclosing, Vec::new())]; + let mut reversed_old = old.clone(); + reversed_old.reverse(); + let mut reversed_new = new.clone(); + reversed_new.reverse(); + + assert_eq!( + compare(&enclosing, &old, &new), + compare(&enclosing, &reversed_old, &reversed_new) + ); + } + + #[test] + fn provenance_tie_breaks_have_stable_serialization_for_reversed_inputs() { + let enclosing = key(&[], "review"); + let make = |language, parser| { + call_with_provenance( + SyntaxProvenance::tree_sitter(language, parser).unwrap(), + "same", + "(value)", + 10, + 2, + &enclosing, + Vec::new(), + ) + }; + let old = vec![ + make(SyntaxLanguage::TypeScript, "parser-z"), + make(SyntaxLanguage::Rust, "parser-rust"), + ]; + let new = vec![ + make(SyntaxLanguage::Tsx, "parser-tsx"), + make(SyntaxLanguage::TypeScript, "parser-a"), + ]; + let mut reversed_old = old.clone(); + reversed_old.reverse(); + let mut reversed_new = new.clone(); + reversed_new.reverse(); + + let forward = serde_json::to_string(&compare(&enclosing, &old, &new)).unwrap(); + let reversed = + serde_json::to_string(&compare(&enclosing, &reversed_old, &reversed_new)).unwrap(); + assert_eq!(forward, reversed); + } + + #[test] + fn empty_paths_are_rejected_even_when_there_are_no_changes() { + let enclosing = key(&[], "review"); + let error = CallDiffInput::new( + "", + "src/new.ts", + &enclosing, + enclosing_range(), + enclosing_range(), + &[], + &[], + ) + .unwrap_err(); + + assert!(matches!( + error, + CallDiffError::EmptyPath(ComparisonSide::Base) + )); + + let legacy_shape = match error { + CallDiffError::EmptyPath(_) => "empty_path", + CallDiffError::InvalidChange(_) => "invalid_change", + }; + assert_eq!(legacy_shape, "empty_path"); + } + + #[test] + fn legacy_entry_point_matches_never_stopped_controlled_output() { + let enclosing = key(&[], "review"); + let old = vec![call("load", "(old)", 10, 2, &enclosing, Vec::new())]; + let new = vec![call("load", "(new)", 30, 4, &enclosing, Vec::new())]; + let input = CallDiffInput::new( + "src/old.ts", + "src/new.ts", + &enclosing, + enclosing_range(), + enclosing_range(), + &old, + &new, + ) + .unwrap(); + + assert_eq!( + compare_calls(input).unwrap(), + compare_calls_controlled(input, &mut || None).unwrap() + ); + } + + #[test] + fn controlled_comparison_stops_during_large_candidate_sort() { + let enclosing = key(&[], "review"); + let calls: Vec<_> = (0_u64..100) + .rev() + .map(|index| { + call( + "repeated", + &format!("({index})"), + index * 20, + u32::try_from(index + 1).unwrap(), + &enclosing, + Vec::new(), + ) + }) + .collect(); + let input = CallDiffInput::new( + "src/old.ts", + "src/new.ts", + &enclosing, + enclosing_range(), + enclosing_range(), + &calls, + &[], + ) + .unwrap(); + let mut checks = 0_u32; + let error = compare_calls_controlled(input, &mut || { + checks += 1; + (checks == 306).then_some(ComparisonStopReason::Deadline) + }) + .unwrap_err(); + + assert_eq!(error.stop_reason(), Some(ComparisonStopReason::Deadline)); + } + + #[test] + fn controlled_comparison_distinguishes_deadline_and_disconnect_stops() { + let enclosing = key(&[], "review"); + let calls: Vec<_> = (0_u64..100) + .map(|index| { + call( + &format!("call_{index}"), + "()", + index * 20, + u32::try_from(index + 1).unwrap(), + &enclosing, + Vec::new(), + ) + }) + .collect(); + let input = CallDiffInput::new( + "src/old.ts", + "src/new.ts", + &enclosing, + enclosing_range(), + enclosing_range(), + &calls, + &calls, + ) + .unwrap(); + let mut checkpoints = 0_u32; + let error = compare_calls_controlled(input, &mut || { + checkpoints += 1; + (checkpoints == 250).then_some(ComparisonStopReason::Deadline) + }) + .unwrap_err(); + assert_eq!(error.stop_reason(), Some(ComparisonStopReason::Deadline)); + + let empty = CallDiffInput::new( + "src/old.ts", + "src/new.ts", + &enclosing, + enclosing_range(), + enclosing_range(), + &[], + &[], + ) + .unwrap(); + let error = + compare_calls_controlled(empty, &mut || Some(ComparisonStopReason::Disconnected)) + .unwrap_err(); + assert_eq!( + error.stop_reason(), + Some(ComparisonStopReason::Disconnected) + ); + } + + #[test] + fn indexed_candidates_are_checkpointed_near_linearly() { + let enclosing = key(&[], "review"); + let calls: Vec<_> = (0_u64..250) + .map(|index| { + call( + &format!("call_{index}"), + "()", + index * 20, + u32::try_from(index + 1).unwrap(), + &enclosing, + Vec::new(), + ) + }) + .collect(); + let references: Vec<_> = calls.iter().collect(); + let input = IndexedCallDiffInput::new( + "src/old.ts", + "src/new.ts", + &enclosing, + enclosing_range(), + enclosing_range(), + &references, + &references, + ) + .unwrap(); + let mut checkpoints = 0_usize; + let changes = compare_indexed_calls_controlled(input, &mut || { + checkpoints += 1; + None + }) + .unwrap(); + + assert!(changes.is_empty()); + assert!(checkpoints >= references.len() * 2); + assert!(checkpoints <= references.len() * 8 + 2); + } +} diff --git a/crates/okena-review/src/classification/mod.rs b/crates/okena-review/src/classification/mod.rs new file mode 100644 index 000000000..66907e96d --- /dev/null +++ b/crates/okena-review/src/classification/mod.rs @@ -0,0 +1,586 @@ +//! Deterministic path-based file classification. + +use std::fmt; + +use okena_core::review::{FileClassification, FileRole, ReviewFileFact, ReviewFileStatus}; + +const GENERATED_RULE: &str = "builtin.path.generated.v1"; +const VENDORED_RULE: &str = "builtin.path.vendored.v1"; +const LOCKFILE_RULE: &str = "builtin.path.lockfile.v1"; +const SNAPSHOT_RULE: &str = "builtin.path.snapshot.v1"; +const FIXTURE_RULE: &str = "builtin.path.fixture.v1"; +const TEST_RULE: &str = "builtin.path.test.v1"; +const DOCUMENTATION_RULE: &str = "builtin.path.documentation.v1"; +const EXAMPLE_RULE: &str = "builtin.path.example.v1"; +const CONFIGURATION_RULE: &str = "builtin.path.configuration.v1"; +const IMPLEMENTATION_RULE: &str = "builtin.path.implementation.v1"; +const UNCLASSIFIED_RULE: &str = "builtin.path.unclassified.v1"; + +/// A path cannot be classified as a repository-relative file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClassificationError(String); + +impl fmt::Display for ClassificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for ClassificationError {} + +/// Classify one raw Git fact without modifying its Git provenance. +/// +/// The head path wins for additions, modifications, copies, and renames. The +/// base path is used for deletions. +pub fn classify_file_fact( + file: &ReviewFileFact, +) -> Result { + validate_optional_path(file.old_path.as_deref())?; + validate_optional_path(file.new_path.as_deref())?; + let selected = match file.status { + ReviewFileStatus::Added => require_path(file.new_path.as_deref(), "added file head")?, + ReviewFileStatus::Deleted => require_path(file.old_path.as_deref(), "deleted file base")?, + ReviewFileStatus::Renamed | ReviewFileStatus::Copied => { + require_path(file.old_path.as_deref(), "renamed/copied file base")?; + require_path(file.new_path.as_deref(), "renamed/copied file head")? + } + ReviewFileStatus::Modified + | ReviewFileStatus::TypeChanged + | ReviewFileStatus::ModeChanged + | ReviewFileStatus::SubmoduleChanged + | ReviewFileStatus::Unmerged + | ReviewFileStatus::Unknown => file + .new_path + .as_deref() + .or(file.old_path.as_deref()) + .ok_or_else(missing_paths)?, + }; + classify_path(selected) +} + +/// Classify an old/new path pair, preferring the head path when present. +/// +/// Rules use this fixed precedence: +/// Generated, Vendored, Lockfile, Snapshot, Fixture, Test, Documentation, +/// Example, Configuration, Implementation, Unclassified. +/// Whether a scope *inside* a file reads as a test scope — a Rust `mod tests`, +/// a `describe` named `spec`. Same vocabulary as the path rules use for +/// directories, so "what counts as a test" has one answer. +pub fn is_test_scope(name: &str) -> bool { + TEST_SEGMENTS.contains(&name.to_ascii_lowercase().as_str()) +} + +pub fn classify_paths( + old_path: Option<&str>, + new_path: Option<&str>, +) -> Result { + validate_optional_path(old_path)?; + validate_optional_path(new_path)?; + let selected = new_path.or(old_path).ok_or_else(missing_paths)?; + classify_path(selected) +} + +fn classify_path(selected: &str) -> Result { + let normalized = normalize_path(selected)?; + let lower = normalized.to_lowercase(); + let segments: Vec<&str> = lower.split('/').collect(); + let basename = segments.last().copied().unwrap_or_default(); + + let (role, rule_id) = if is_generated(&segments, basename) { + (FileRole::Generated, GENERATED_RULE) + } else if has_segment(&segments, VENDORED_SEGMENTS) { + (FileRole::Vendored, VENDORED_RULE) + } else if LOCKFILE_NAMES.contains(&basename) { + (FileRole::Lockfile, LOCKFILE_RULE) + } else if is_snapshot(&segments, basename) { + (FileRole::Snapshot, SNAPSHOT_RULE) + } else if is_fixture(&segments, basename) { + (FileRole::Fixture, FIXTURE_RULE) + } else if is_test(&segments, basename) { + (FileRole::Test, TEST_RULE) + } else if is_documentation(&segments, basename) { + (FileRole::Documentation, DOCUMENTATION_RULE) + } else if has_segment(&segments, EXAMPLE_SEGMENTS) { + (FileRole::Example, EXAMPLE_RULE) + } else if is_configuration(&segments, basename) { + (FileRole::Configuration, CONFIGURATION_RULE) + } else if has_implementation_extension(basename) { + (FileRole::Implementation, IMPLEMENTATION_RULE) + } else { + (FileRole::Unclassified, UNCLASSIFIED_RULE) + }; + + FileClassification::from_rule(role, rule_id) + .map_err(|error| ClassificationError(error.to_string())) +} + +const GENERATED_SEGMENTS: &[&str] = &[ + "generated", + "__generated__", + "gen", + "dist", + "target", + ".next", + ".nuxt", + ".svelte-kit", +]; +const VENDORED_SEGMENTS: &[&str] = &[ + "vendor", + "vendored", + "third_party", + "third-party", + "node_modules", +]; +const SNAPSHOT_SEGMENTS: &[&str] = &["snapshots", "__snapshots__"]; +const FIXTURE_SEGMENTS: &[&str] = &[ + "fixture", + "fixtures", + "__fixtures__", + "testdata", + "test-data", + "golden", +]; +const TEST_SEGMENTS: &[&str] = &["test", "tests", "__tests__", "spec", "specs"]; +const DOCUMENTATION_SEGMENTS: &[&str] = &["doc", "docs", "documentation"]; +const EXAMPLE_SEGMENTS: &[&str] = &["example", "examples", "playground"]; +const CONFIGURATION_SEGMENTS: &[&str] = &["config", "configs", ".cargo", ".github"]; + +const LOCKFILE_NAMES: &[&str] = &[ + "cargo.lock", + "package-lock.json", + "npm-shrinkwrap.json", + "yarn.lock", + "pnpm-lock.yaml", + "bun.lock", + "bun.lockb", + "deno.lock", + "poetry.lock", + "uv.lock", + "pipfile.lock", + "gemfile.lock", + "composer.lock", + "flake.lock", + "go.sum", +]; + +fn validate_optional_path(path: Option<&str>) -> Result<(), ClassificationError> { + if path.is_some_and(|path| path.trim().is_empty()) { + Err(ClassificationError( + "classification paths must not be empty".to_string(), + )) + } else { + Ok(()) + } +} + +fn require_path<'a>(path: Option<&'a str>, context: &str) -> Result<&'a str, ClassificationError> { + path.ok_or_else(|| ClassificationError(format!("classification requires the {context} path"))) +} + +fn missing_paths() -> ClassificationError { + ClassificationError("classification requires an old path or a new path".to_string()) +} + +fn normalize_path(path: &str) -> Result { + if path.contains('\0') { + return Err(ClassificationError( + "classification paths must not contain NUL".to_string(), + )); + } + let slashed = path.replace('\\', "/"); + let bytes = slashed.as_bytes(); + let windows_drive_path = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; + if slashed.starts_with('/') || windows_drive_path { + return Err(ClassificationError( + "classification paths must be repository-relative".to_string(), + )); + } + let mut normalized = Vec::new(); + for segment in slashed.split('/') { + match segment { + "" | "." => {} + ".." => { + return Err(ClassificationError( + "classification paths must not traverse parent directories".to_string(), + )); + } + segment => normalized.push(segment), + } + } + if normalized.is_empty() { + return Err(ClassificationError( + "classification path does not name a file".to_string(), + )); + } + Ok(normalized.join("/")) +} + +fn has_segment(segments: &[&str], candidates: &[&str]) -> bool { + segments.iter().any(|segment| candidates.contains(segment)) +} + +fn is_generated(segments: &[&str], basename: &str) -> bool { + has_segment(segments, GENERATED_SEGMENTS) + || basename.starts_with("generated.") + || basename.contains(".generated.") + || basename.contains(".gen.") + || basename.ends_with("_generated.rs") + || basename.ends_with("-generated.rs") +} + +fn is_snapshot(segments: &[&str], basename: &str) -> bool { + has_segment(segments, SNAPSHOT_SEGMENTS) + || basename.ends_with(".snap") + || basename.ends_with(".snap.new") + || basename.ends_with(".snapshot") +} + +fn is_fixture(segments: &[&str], basename: &str) -> bool { + has_segment(segments, FIXTURE_SEGMENTS) + || basename.contains(".fixture.") + || basename.contains("_fixture.") +} + +fn is_test(segments: &[&str], basename: &str) -> bool { + if has_segment(segments, TEST_SEGMENTS) { + return true; + } + let stem = [".d.ts", ".d.mts", ".d.cts"] + .iter() + .find_map(|suffix| basename.strip_suffix(suffix)) + .unwrap_or_else(|| basename.rsplit_once('.').map_or(basename, |(stem, _)| stem)); + stem == "test" + || stem == "tests" + || stem.ends_with(".test") + || stem.ends_with(".spec") + || stem.ends_with("_test") + || stem.ends_with("_tests") + || stem.ends_with("-test") + || stem.ends_with("-tests") + || stem.starts_with("test_") +} + +fn is_documentation(segments: &[&str], basename: &str) -> bool { + has_segment(segments, DOCUMENTATION_SEGMENTS) + || basename.ends_with(".md") + || basename.ends_with(".mdx") + || basename.ends_with(".rst") + || basename.ends_with(".adoc") + || CANONICAL_DOCUMENTATION_NAMES.contains(&basename) +} + +const CANONICAL_DOCUMENTATION_NAMES: &[&str] = &[ + "readme", + "changelog", + "contributing", + "code_of_conduct", + "license", + "license-mit", + "license-apache", + "copying", + "notice", +]; + +fn is_configuration(segments: &[&str], basename: &str) -> bool { + has_segment(segments, CONFIGURATION_SEGMENTS) + || CONFIGURATION_NAMES.contains(&basename) + || basename.starts_with("tsconfig.") && basename.ends_with(".json") + || basename.starts_with(".eslintrc") + || basename.starts_with(".prettierrc") + || basename == ".env" + || basename.starts_with(".env.") + || CONFIGURATION_STEMS + .iter() + .any(|stem| basename.starts_with(stem)) +} + +const CONFIGURATION_NAMES: &[&str] = &[ + "cargo.toml", + "package.json", + "deno.json", + "deno.jsonc", + "biome.json", + "biome.jsonc", + "turbo.json", + "nx.json", + "rust-toolchain", + "rust-toolchain.toml", + "rustfmt.toml", + "clippy.toml", + "deny.toml", + ".editorconfig", + ".gitignore", + ".gitattributes", + "dockerfile", + "makefile", + "justfile", +]; + +const CONFIGURATION_STEMS: &[&str] = &[ + "eslint.config.", + "prettier.config.", + "vite.config.", + "vitest.config.", + "jest.config.", + "webpack.config.", + "rollup.config.", + "tailwind.config.", + "postcss.config.", + "next.config.", + "nuxt.config.", +]; + +fn has_implementation_extension(basename: &str) -> bool { + const EXTENSIONS: &[&str] = &[ + "rs", "js", "jsx", "ts", "tsx", "mts", "cts", "mjs", "cjs", "css", "scss", "sass", "less", + "html", "vue", "svelte", + ]; + basename + .rsplit_once('.') + .is_some_and(|(_, extension)| EXTENSIONS.contains(&extension)) +} + +#[cfg(test)] +mod tests { + use okena_core::review::{FactProvenance, ReviewFileStatus, ReviewSubmoduleChange}; + + use super::*; + + fn role(path: &str) -> FileRole { + classify_paths(None, Some(path)).unwrap().role() + } + + fn file( + status: ReviewFileStatus, + old_path: Option<&str>, + new_path: Option<&str>, + ) -> ReviewFileFact { + ReviewFileFact { + old_path: old_path.map(str::to_string), + new_path: new_path.map(str::to_string), + status, + similarity: None, + old_mode: Some("100644".to_string()), + new_mode: Some("100644".to_string()), + lines_added: Some(1), + lines_deleted: Some(1), + binary: false, + submodule: None::, + classification: FileClassification::from_rule( + FileRole::Unclassified, + "builtin.unclassified", + ) + .unwrap(), + provenance: FactProvenance::Git, + } + } + + #[test] + fn a_scope_inside_a_file_reads_as_a_test_by_the_same_names() { + for name in ["tests", "Tests", "test", "spec", "__tests__"] { + assert!(is_test_scope(name), "{name}"); + } + for name in ["testing", "attest", "fixtures", "helpers"] { + assert!(!is_test_scope(name), "{name}"); + } + } + + #[test] + fn precedence_protects_generated_vendor_lock_snapshot_and_fixture_roles() { + assert_eq!(role("generated/tests/widget.test.ts"), FileRole::Generated); + assert_eq!(role("src/api.generated.test.ts"), FileRole::Generated); + assert_eq!(role("vendor/tests/widget.test.ts"), FileRole::Vendored); + assert_eq!(role("tests/fixtures/package-lock.json"), FileRole::Lockfile); + assert_eq!(role("src/fixtures/widget.test.ts"), FileRole::Fixture); + assert_eq!( + role("src/fixtures/__snapshots__/widget.test.ts.snap"), + FileRole::Snapshot + ); + } + + #[test] + fn every_adjacent_precedence_boundary_is_explicit() { + let cases = [ + ("vendor/generated/widget.ts", FileRole::Generated), + ("vendor/package-lock.json", FileRole::Vendored), + ("__snapshots__/package-lock.json", FileRole::Lockfile), + ("fixtures/__snapshots__/case.snap", FileRole::Snapshot), + ("tests/fixtures/case.test.ts", FileRole::Fixture), + ("tests/README.md", FileRole::Test), + ("docs/examples/demo.ts", FileRole::Documentation), + ("examples/vite.config.ts", FileRole::Example), + ("config/worker.ts", FileRole::Configuration), + ("src/worker.ts", FileRole::Implementation), + ("assets/logo.png", FileRole::Unclassified), + ]; + for (path, expected) in cases { + assert_eq!(role(path), expected, "{path}"); + } + } + + #[test] + fn path_pairs_prefer_head_and_fall_back_to_base() { + let renamed = classify_paths(Some("src/worker.ts"), Some("tests/worker.test.ts")).unwrap(); + assert_eq!(renamed.role(), FileRole::Test); + assert_eq!( + classify_paths(Some("docs/removed.md"), None) + .unwrap() + .role(), + FileRole::Documentation + ); + } + + #[test] + fn file_fact_status_selects_the_semantically_present_side() { + let added = file( + ReviewFileStatus::Added, + Some("docs/stale.md"), + Some("src/new.ts"), + ); + assert_eq!( + classify_file_fact(&added).unwrap().role(), + FileRole::Implementation + ); + + let deleted = file( + ReviewFileStatus::Deleted, + Some("docs/removed.md"), + Some("src/stale.ts"), + ); + assert_eq!( + classify_file_fact(&deleted).unwrap().role(), + FileRole::Documentation + ); + + let renamed = file( + ReviewFileStatus::Renamed, + Some("src/old.ts"), + Some("examples/new.ts"), + ); + assert_eq!( + classify_file_fact(&renamed).unwrap().role(), + FileRole::Example + ); + + assert!(classify_file_fact(&file(ReviewFileStatus::Added, None, None)).is_err()); + assert!( + classify_file_fact(&file(ReviewFileStatus::Deleted, None, Some("src/stale.ts"))) + .is_err() + ); + assert!( + classify_file_fact(&file(ReviewFileStatus::Renamed, None, Some("src/new.ts"))).is_err() + ); + } + + #[test] + fn recognizes_common_roles_and_stable_rule_ids() { + let cases = [ + ("pnpm-lock.yaml", FileRole::Lockfile, LOCKFILE_RULE), + ("README.md", FileRole::Documentation, DOCUMENTATION_RULE), + ("README.cs.md", FileRole::Documentation, DOCUMENTATION_RULE), + ("examples/basic.ts", FileRole::Example, EXAMPLE_RULE), + ("Cargo.toml", FileRole::Configuration, CONFIGURATION_RULE), + ("src/main.rs", FileRole::Implementation, IMPLEMENTATION_RULE), + ( + "packages/ui/src/Button.tsx", + FileRole::Implementation, + IMPLEMENTATION_RULE, + ), + ("assets/logo.png", FileRole::Unclassified, UNCLASSIFIED_RULE), + ]; + for (path, expected_role, expected_rule) in cases { + let classification = classify_paths(None, Some(path)).unwrap(); + assert_eq!(classification.role(), expected_role, "{path}"); + assert_eq!(classification.rule_id().as_str(), expected_rule, "{path}"); + assert_eq!( + classification.provenance(), + FactProvenance::RuleDerived { + rule_id: expected_rule.to_string() + } + ); + } + } + + #[test] + fn common_test_fixture_example_and_implementation_boundaries_are_conservative() { + let cases = [ + ("src/tests.rs", FileRole::Test), + ("test.js", FileRole::Test), + ("src/foo.test.d.ts", FileRole::Test), + ("src/__fixtures__/case.json", FileRole::Fixture), + ("playground/demo.ts", FileRole::Example), + ("src/component.tsx", FileRole::Implementation), + ("src/build/mod.rs", FileRole::Implementation), + ("crates/coverage/src/lib.rs", FileRole::Implementation), + ("src/license_checker.rs", FileRole::Implementation), + ("src/readme_generator.ts", FileRole::Implementation), + ("src/changelog_parser.ts", FileRole::Implementation), + ("src/readme.generator.ts", FileRole::Implementation), + ("src/changelog.parser.ts", FileRole::Implementation), + ("src/license.checker.rs", FileRole::Implementation), + ("src/notice.service.ts", FileRole::Implementation), + ("src/.envoy.ts", FileRole::Implementation), + ]; + for (path, expected) in cases { + assert_eq!(role(path), expected, "{path}"); + } + } + + #[test] + fn dotenv_names_are_configuration_without_using_a_broad_prefix() { + assert_eq!(role(".env"), FileRole::Configuration); + assert_eq!(role(".env.local"), FileRole::Configuration); + assert_eq!(role(".env.production"), FileRole::Configuration); + assert_eq!(role("src/.envoy.ts"), FileRole::Implementation); + } + + #[test] + fn precedence_boundaries_across_human_authored_roles_are_stable() { + let cases = [ + ("tests/README.md", FileRole::Test), + ("docs/examples/demo.ts", FileRole::Documentation), + ("examples/vite.config.ts", FileRole::Example), + ("config/worker.test.ts", FileRole::Test), + ("config/worker.ts", FileRole::Configuration), + ("src/worker.ts", FileRole::Implementation), + ]; + for (path, expected) in cases { + assert_eq!(role(path), expected, "{path}"); + } + } + + #[test] + fn normalizes_windows_separators_for_matching_only() { + assert_eq!( + role(r"packages\worker\__tests__\run.spec.ts"), + FileRole::Test + ); + assert_eq!(role(r"src\fixtures\case.json"), FileRole::Fixture); + assert_eq!(role(r"SRC\FIXTURES\CASE.JSON"), FileRole::Fixture); + } + + #[test] + fn classifying_a_fact_does_not_replace_git_provenance() { + let fact = file( + ReviewFileStatus::Modified, + Some("src/old.ts"), + Some("tests/new.test.ts"), + ); + let classification = classify_file_fact(&fact).unwrap(); + assert_eq!(classification.role(), FileRole::Test); + assert_eq!(fact.provenance, FactProvenance::Git); + assert_eq!(fact.classification.role(), FileRole::Unclassified); + } + + #[test] + fn rejects_missing_empty_absolute_and_traversing_paths() { + assert!(classify_paths(None, None).is_err()); + assert!(classify_paths(None, Some("")).is_err()); + assert!(classify_paths(Some("src/lib.rs"), Some(" ")).is_err()); + assert!(classify_paths(None, Some("/src/lib.rs")).is_err()); + assert!(classify_paths(None, Some("C:\\src\\lib.rs")).is_err()); + assert!(classify_paths(None, Some("C:src\\lib.rs")).is_err()); + assert!(classify_paths(None, Some("src/../lib.rs")).is_err()); + assert!(classify_paths(None, Some("src/\0lib.rs")).is_err()); + } +} diff --git a/crates/okena-review/src/lib.rs b/crates/okena-review/src/lib.rs new file mode 100644 index 000000000..2c000049d --- /dev/null +++ b/crates/okena-review/src/lib.rs @@ -0,0 +1,21 @@ +#![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] + +//! Pure review comparison and result models. + +pub mod call_diff; +pub mod classification; +mod model; +pub mod structure; + +pub use model::{ + AnalysisError, AnalysisStage, CallChangeKind, CallDiffChange, CallPairingEvidence, + CallPairingStrategy, ChangedHunk, ChangedLineRange, FileAnalysisStatus, LanguageCoverage, + ModelError, OmittedFileGroup, OmittedFileReason, OutlineFact, ReviewStructure, SignatureChange, + StructuralHotspot, StructuralMetric, StructuredFile, SymbolChange, SymbolChangeKind, + SymbolReference, +}; + +pub use okena_core::review::{ + ComparisonSide, ImmutableResolvedComparison, ReviewCoverage, ReviewNavigationTarget, + ReviewTruncation, +}; diff --git a/crates/okena-review/src/model.rs b/crates/okena-review/src/model.rs new file mode 100644 index 000000000..8ff3cf1a7 --- /dev/null +++ b/crates/okena-review/src/model.rs @@ -0,0 +1,3792 @@ +use okena_core::review::{ + ComparisonSide, ImmutableResolvedComparison, ReviewCoverage, ReviewNavigationTarget, + ReviewTruncation, TruncationReason, +}; +use okena_syntax::{ + CallFact, SourceRange, SymbolFact, SymbolKey, SyntaxLanguage, SyntaxProvenance, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::convert::Infallible; +use std::fmt; +use std::num::NonZeroU32; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ModelError(String); + +impl ModelError { + fn new(message: impl Into) -> Self { + Self(message.into()) + } +} +impl fmt::Display for ModelError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} +impl std::error::Error for ModelError {} + +#[derive(Debug)] +pub(crate) enum ControlledModelError { + Invalid(ModelError), + Stopped(E), +} + +impl From for ControlledModelError { + fn from(error: ModelError) -> Self { + Self::Invalid(error) + } +} + +pub(crate) fn checked_stable_sort_by( + items: &mut Vec, + mut compare: impl FnMut(&T, &T, &mut dyn FnMut() -> Result<(), E>) -> Result, + checkpoint: &mut dyn FnMut() -> Result<(), E>, +) -> Result<(), E> { + let len = items.len(); + if len <= 1 { + return checkpoint(); + } + let mut operations = 0_u8; + let mut order = Vec::with_capacity(len); + for index in 0..len { + checked_sort_tick(&mut operations, checkpoint)?; + order.push(index); + } + + let mut width = 1_usize; + while width < len { + let mut merged = Vec::with_capacity(len); + let mut start = 0_usize; + while start < len { + checked_sort_tick(&mut operations, checkpoint)?; + let middle = start.saturating_add(width).min(len); + let end = middle.saturating_add(width).min(len); + let (mut left, mut right) = (start, middle); + while left < middle && right < end { + checked_sort_tick(&mut operations, checkpoint)?; + if compare(&items[order[left]], &items[order[right]], checkpoint)? + != std::cmp::Ordering::Greater + { + merged.push(order[left]); + left += 1; + } else { + merged.push(order[right]); + right += 1; + } + } + while left < middle { + checked_sort_tick(&mut operations, checkpoint)?; + merged.push(order[left]); + left += 1; + } + while right < end { + checked_sort_tick(&mut operations, checkpoint)?; + merged.push(order[right]); + right += 1; + } + start = end; + } + order = merged; + width = width.saturating_mul(2); + } + + let mut slots = Vec::with_capacity(len); + for item in std::mem::take(items) { + checked_sort_tick(&mut operations, checkpoint)?; + slots.push(Some(item)); + } + for index in order { + checked_sort_tick(&mut operations, checkpoint)?; + let Some(item) = slots[index].take() else { + unreachable!("checked sort order contains each source index once"); + }; + items.push(item); + } + checkpoint() +} + +fn checked_sort_tick( + operations: &mut u8, + checkpoint: &mut dyn FnMut() -> Result<(), E>, +) -> Result<(), E> { + *operations = operations.wrapping_add(1); + if *operations % 64 == 1 { + checkpoint() + } else { + Ok(()) + } +} + +/// One-based inclusive changed-line range, distinct from a UTF-8 source range. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] +pub struct ChangedLineRange { + start: NonZeroU32, + end: NonZeroU32, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ChangedLineRangeWire { + start: NonZeroU32, + end: NonZeroU32, +} +impl TryFrom for ChangedLineRange { + type Error = ModelError; + fn try_from(value: ChangedLineRangeWire) -> Result { + Self::new(value.start, value.end) + } +} +impl<'de> Deserialize<'de> for ChangedLineRange { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + ChangedLineRangeWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} +impl ChangedLineRange { + pub fn new(start: NonZeroU32, end: NonZeroU32) -> Result { + if start > end { + return Err(ModelError::new("changed-line range starts after it ends")); + } + Ok(Self { start, end }) + } + pub fn start(self) -> NonZeroU32 { + self.start + } + pub fn end(self) -> NonZeroU32 { + self.end + } + pub fn line_count(self) -> u32 { + self.end.get() - self.start.get() + 1 + } + fn intersects_source(self, source: SourceRange) -> bool { + self.start.get() <= source.end_line().get() && source.start_line().get() <= self.end.get() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] +pub struct ChangedHunk { + old: Option, + new: Option, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ChangedHunkWire { + old: Option, + new: Option, +} +impl TryFrom for ChangedHunk { + type Error = ModelError; + fn try_from(value: ChangedHunkWire) -> Result { + Self::new(value.old, value.new) + } +} +impl<'de> Deserialize<'de> for ChangedHunk { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + ChangedHunkWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} +impl ChangedHunk { + pub fn new( + old: Option, + new: Option, + ) -> Result { + if old.is_none() && new.is_none() { + return Err(ModelError::new("changed hunk requires at least one side")); + } + Ok(Self { old, new }) + } + pub fn old(&self) -> Option { + self.old + } + pub fn new_range(&self) -> Option { + self.new + } +} + +/// A descriptive symbol occurrence. It is a location, not an identity. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct SymbolReference { + side: ComparisonSide, + range: SourceRange, + key: SymbolKey, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SymbolReferenceWire { + side: ComparisonSide, + range: SourceRange, + key: SymbolKey, +} +impl TryFrom for SymbolReference { + type Error = ModelError; + fn try_from(value: SymbolReferenceWire) -> Result { + Ok(Self::new(value.side, value.range, value.key)) + } +} +impl<'de> Deserialize<'de> for SymbolReference { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + SymbolReferenceWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} +impl SymbolReference { + pub fn new(side: ComparisonSide, range: SourceRange, key: SymbolKey) -> Self { + Self { side, range, key } + } + pub fn side(&self) -> ComparisonSide { + self.side + } + pub fn range(&self) -> SourceRange { + self.range + } + pub fn key(&self) -> &SymbolKey { + &self.key + } +} + +/// Compact hierarchy for rendering surrounding structure on either snapshot. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct OutlineFact { + provenance: SyntaxProvenance, + symbol: SymbolReference, + children: Vec, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct OutlineFactWire { + provenance: SyntaxProvenance, + symbol: SymbolReference, + children: Vec, +} +impl TryFrom for OutlineFact { + type Error = ModelError; + fn try_from(value: OutlineFactWire) -> Result { + Self::new(value.provenance, value.symbol, value.children) + } +} +impl<'de> Deserialize<'de> for OutlineFact { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + OutlineFactWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} +impl OutlineFact { + pub fn new( + provenance: SyntaxProvenance, + symbol: SymbolReference, + children: Vec, + ) -> Result { + match Self::new_controlled(provenance, symbol, children, &mut || { + Ok::<(), Infallible>(()) + }) { + Ok(fact) => Ok(fact), + Err(ControlledModelError::Invalid(error)) => Err(error), + Err(ControlledModelError::Stopped(never)) => match never {}, + } + } + + pub(crate) fn new_controlled( + provenance: SyntaxProvenance, + symbol: SymbolReference, + children: Vec, + checkpoint: &mut dyn FnMut() -> Result<(), E>, + ) -> Result> { + checkpoint().map_err(ControlledModelError::Stopped)?; + for child in &children { + checkpoint().map_err(ControlledModelError::Stopped)?; + if child.provenance != provenance + || child.symbol.side() != symbol.side() + || !symbol.range().contains(child.symbol.range()) + { + return Err(ModelError::new( + "outline children must share provenance and be on the same side inside their parent", + ) + .into()); + } + } + Ok(Self { + provenance, + symbol, + children, + }) + } + pub fn provenance(&self) -> &SyntaxProvenance { + &self.provenance + } + pub fn symbol(&self) -> &SymbolReference { + &self.symbol + } + pub fn children(&self) -> &[Self] { + &self.children + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SymbolChangeKind { + Added, + Removed, + Modified, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct SignatureChange { + old_signature: String, + new_signature: String, + old_range: SourceRange, + new_range: SourceRange, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SignatureChangeWire { + old_signature: String, + new_signature: String, + old_range: SourceRange, + new_range: SourceRange, +} +impl TryFrom for SignatureChange { + type Error = ModelError; + fn try_from(value: SignatureChangeWire) -> Result { + Self::new( + value.old_signature, + value.new_signature, + value.old_range, + value.new_range, + ) + } +} +impl<'de> Deserialize<'de> for SignatureChange { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + SignatureChangeWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} +impl SignatureChange { + pub fn new( + old_signature: impl Into, + new_signature: impl Into, + old_range: SourceRange, + new_range: SourceRange, + ) -> Result { + let old_signature = old_signature.into(); + let new_signature = new_signature.into(); + if old_signature.trim().is_empty() + || new_signature.trim().is_empty() + || old_signature == new_signature + { + return Err(ModelError::new( + "signature change requires two distinct non-empty signatures", + )); + } + Ok(Self { + old_signature, + new_signature, + old_range, + new_range, + }) + } + fn validate_facts(&self, old: &SymbolFact, new: &SymbolFact) -> Result<(), ModelError> { + if self.old_signature != old.normalized_signature() + || self.new_signature != new.normalized_signature() + || self.old_range != old.signature_range() + || self.new_range != new.signature_range() + { + return Err(ModelError::new( + "signature change must exactly match its paired symbol facts", + )); + } + Ok(()) + } + pub fn old_signature(&self) -> &str { + &self.old_signature + } + pub fn new_signature(&self) -> &str { + &self.new_signature + } + pub fn old_range(&self) -> SourceRange { + self.old_range + } + pub fn new_range(&self) -> SourceRange { + self.new_range + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct SymbolChange { + kind: SymbolChangeKind, + old: Option, + new: Option, + signature_change: Option, + body_changed: bool, + changed_old_lines: u32, + changed_new_lines: u32, + hunks: Vec, + navigation: ReviewNavigationTarget, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SymbolChangeWire { + kind: SymbolChangeKind, + old: Option, + new: Option, + signature_change: Option, + body_changed: bool, + changed_old_lines: u32, + changed_new_lines: u32, + hunks: Vec, + navigation: ReviewNavigationTarget, +} +impl TryFrom for SymbolChange { + type Error = ModelError; + fn try_from(value: SymbolChangeWire) -> Result { + Self::new_validated( + value.kind, + value.old, + value.new, + value.signature_change, + value.body_changed, + value.hunks, + value.navigation, + Some((value.changed_old_lines, value.changed_new_lines)), + ) + } +} +impl<'de> Deserialize<'de> for SymbolChange { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + SymbolChangeWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} +impl SymbolChange { + #[allow(clippy::too_many_arguments)] + pub fn new( + kind: SymbolChangeKind, + old: Option, + new: Option, + signature_change: Option, + body_changed: bool, + hunks: Vec, + navigation: ReviewNavigationTarget, + ) -> Result { + Self::new_validated( + kind, + old, + new, + signature_change, + body_changed, + hunks, + navigation, + None, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn new_controlled( + kind: SymbolChangeKind, + old: Option, + new: Option, + signature_change: Option, + body_changed: bool, + hunks: Vec, + navigation: ReviewNavigationTarget, + checkpoint: &mut dyn FnMut() -> Result<(), E>, + ) -> Result> { + Self::new_validated_controlled( + kind, + old, + new, + signature_change, + body_changed, + hunks, + navigation, + None, + checkpoint, + ) + } + + #[allow(clippy::too_many_arguments)] + fn new_validated( + kind: SymbolChangeKind, + old: Option, + new: Option, + signature_change: Option, + body_changed: bool, + hunks: Vec, + navigation: ReviewNavigationTarget, + reported_counts: Option<(u32, u32)>, + ) -> Result { + match Self::new_validated_controlled( + kind, + old, + new, + signature_change, + body_changed, + hunks, + navigation, + reported_counts, + &mut || Ok::<(), Infallible>(()), + ) { + Ok(change) => Ok(change), + Err(ControlledModelError::Invalid(error)) => Err(error), + Err(ControlledModelError::Stopped(never)) => match never {}, + } + } + + #[allow(clippy::too_many_arguments)] + fn new_validated_controlled( + kind: SymbolChangeKind, + old: Option, + new: Option, + signature_change: Option, + body_changed: bool, + hunks: Vec, + navigation: ReviewNavigationTarget, + reported_counts: Option<(u32, u32)>, + checkpoint: &mut dyn FnMut() -> Result<(), E>, + ) -> Result> { + checkpoint().map_err(ControlledModelError::Stopped)?; + let valid = match kind { + SymbolChangeKind::Added => { + old.is_none() + && new.is_some() + && signature_change.is_none() + && !body_changed + && navigation.side == ComparisonSide::Head + } + SymbolChangeKind::Removed => { + old.is_some() + && new.is_none() + && signature_change.is_none() + && !body_changed + && navigation.side == ComparisonSide::Base + } + SymbolChangeKind::Modified => { + old.is_some() && new.is_some() && (signature_change.is_some() || body_changed) + } + }; + if !valid { + return Err(ModelError::new("symbol change shape does not match its kind").into()); + } + if let (Some(old), Some(new)) = (&old, &new) { + if !symbol_keys_equal_controlled(old.key(), new.key(), checkpoint)? { + return Err( + ModelError::new("matched symbols must have the same qualified key").into(), + ); + } + if old.provenance().language() != new.provenance().language() { + return Err( + ModelError::new("matched symbols must use the same syntax language").into(), + ); + } + if signature_change.is_none() + && old.normalized_signature() != new.normalized_signature() + { + return Err(ModelError::new( + "body-only changes require an unchanged normalized signature", + ) + .into()); + } + if let Some(signature) = &signature_change { + signature.validate_facts(old, new)?; + } + } + if hunks.is_empty() { + return Err(ModelError::new("symbol changes require changed hunks").into()); + } + let mut unique_hunks = HashSet::with_capacity(hunks.len()); + for hunk in &hunks { + checkpoint().map_err(ControlledModelError::Stopped)?; + if !unique_hunks.insert(hunk) { + return Err(ModelError::new("symbol changes cannot cite duplicate hunks").into()); + } + } + for hunk in &hunks { + checkpoint().map_err(ControlledModelError::Stopped)?; + let intersects_old = old.as_ref().is_some_and(|fact| { + hunk.old() + .is_some_and(|lines| lines.intersects_source(fact.full_range())) + }); + let intersects_new = new.as_ref().is_some_and(|fact| { + hunk.new_range() + .is_some_and(|lines| lines.intersects_source(fact.full_range())) + }); + let old_outside = old.as_ref().is_some_and(|fact| { + hunk.old() + .is_some_and(|lines| !lines.intersects_source(fact.full_range())) + }); + let new_outside = new.as_ref().is_some_and(|fact| { + hunk.new_range() + .is_some_and(|lines| !lines.intersects_source(fact.full_range())) + }); + if old_outside || new_outside || !intersects_old && !intersects_new { + return Err(ModelError::new( + "symbol change hunks must intersect a paired symbol occurrence", + ) + .into()); + } + } + if let (Some(signature), Some(old), Some(new)) = (&signature_change, &old, &new) { + if !side_has_intersection_controlled( + &hunks, + ComparisonSide::Base, + signature.old_range(), + checkpoint, + )? && !side_has_intersection_controlled( + &hunks, + ComparisonSide::Head, + signature.new_range(), + checkpoint, + )? { + return Err(ModelError::new( + "signature changes require hunk evidence on at least one exact signature range", + ) + .into()); + } + signature.validate_facts(old, new)?; + } + if body_changed { + let mut body_has_evidence = false; + for (side, fact) in [ + (ComparisonSide::Base, old.as_ref()), + (ComparisonSide::Head, new.as_ref()), + ] { + checkpoint().map_err(ControlledModelError::Stopped)?; + if let Some(body) = fact.and_then(SymbolFact::body_range) + && side_has_intersection_controlled(&hunks, side, body, checkpoint)? + { + body_has_evidence = true; + break; + } + } + if !body_has_evidence { + return Err(ModelError::new( + "body changes require hunk evidence on at least one body range", + ) + .into()); + } + } + if kind == SymbolChangeKind::Modified { + for hunk in &hunks { + checkpoint().map_err(ControlledModelError::Stopped)?; + for (side, fact) in [ + (ComparisonSide::Base, old.as_ref()), + (ComparisonSide::Head, new.as_ref()), + ] { + checkpoint().map_err(ControlledModelError::Stopped)?; + let Some(lines) = hunk_range(hunk, side) else { + continue; + }; + let signature_relevant = signature_change.as_ref().is_some_and(|signature| { + let range = match side { + ComparisonSide::Base => signature.old_range(), + ComparisonSide::Head => signature.new_range(), + }; + lines.intersects_source(range) + }); + let body_relevant = body_changed + && fact + .and_then(SymbolFact::body_range) + .is_some_and(|body| lines.intersects_source(body)); + if !signature_relevant && !body_relevant { + return Err(ModelError::new( + "every present modified-hunk side must intersect a changed dimension", + ) + .into()); + } + } + } + } + let changed_old_lines = match old.as_ref() { + Some(fact) => changed_line_count_controlled( + &hunks, + ComparisonSide::Base, + fact.full_range(), + checkpoint, + )?, + None => 0, + }; + let changed_new_lines = match new.as_ref() { + Some(fact) => changed_line_count_controlled( + &hunks, + ComparisonSide::Head, + fact.full_range(), + checkpoint, + )?, + None => 0, + }; + if changed_old_lines == 0 && changed_new_lines == 0 { + return Err(ModelError::new("symbol changes require changed-line evidence").into()); + } + if reported_counts.is_some_and(|counts| counts != (changed_old_lines, changed_new_lines)) { + return Err(ModelError::new( + "serialized changed-line counts must equal derived hunk intersections", + ) + .into()); + } + validate_navigation(&navigation)?; + checkpoint().map_err(ControlledModelError::Stopped)?; + Ok(Self { + kind, + old, + new, + signature_change, + body_changed, + changed_old_lines, + changed_new_lines, + hunks, + navigation, + }) + } + pub fn kind(&self) -> SymbolChangeKind { + self.kind + } + pub fn old(&self) -> Option<&SymbolFact> { + self.old.as_ref() + } + pub fn new_fact(&self) -> Option<&SymbolFact> { + self.new.as_ref() + } + pub fn signature_change(&self) -> Option<&SignatureChange> { + self.signature_change.as_ref() + } + pub fn body_changed(&self) -> bool { + self.body_changed + } + pub fn changed_old_lines(&self) -> u32 { + self.changed_old_lines + } + pub fn changed_new_lines(&self) -> u32 { + self.changed_new_lines + } + pub fn hunks(&self) -> &[ChangedHunk] { + &self.hunks + } + pub fn navigation(&self) -> &ReviewNavigationTarget { + &self.navigation + } +} + +fn side_has_intersection_controlled( + hunks: &[ChangedHunk], + side: ComparisonSide, + source: SourceRange, + checkpoint: &mut dyn FnMut() -> Result<(), E>, +) -> Result> { + for hunk in hunks { + checkpoint().map_err(ControlledModelError::Stopped)?; + if hunk_range(hunk, side).is_some_and(|range| range.intersects_source(source)) { + return Ok(true); + } + } + Ok(false) +} + +fn hunk_range(hunk: &ChangedHunk, side: ComparisonSide) -> Option { + match side { + ComparisonSide::Base => hunk.old(), + ComparisonSide::Head => hunk.new_range(), + } +} + +fn changed_line_count_controlled( + hunks: &[ChangedHunk], + side: ComparisonSide, + source: SourceRange, + checkpoint: &mut dyn FnMut() -> Result<(), E>, +) -> Result> { + let mut intersections = Vec::with_capacity(hunks.len()); + for hunk in hunks { + checkpoint().map_err(ControlledModelError::Stopped)?; + if let Some(range) = hunk_range(hunk, side) { + let start = range.start().get().max(source.start_line().get()); + let end = range.end().get().min(source.end_line().get()); + if start <= end { + intersections.push((start, end)); + } + } + } + checked_stable_sort_by( + &mut intersections, + |left, right, _| Ok(left.cmp(right)), + checkpoint, + ) + .map_err(ControlledModelError::Stopped)?; + let mut total = 0_u64; + let mut current: Option<(u32, u32)> = None; + for (start, end) in intersections { + checkpoint().map_err(ControlledModelError::Stopped)?; + match current { + Some((current_start, current_end)) if start <= current_end.saturating_add(1) => { + current = Some((current_start, current_end.max(end))); + } + Some((current_start, current_end)) => { + total += u64::from(current_end - current_start + 1); + current = Some((start, end)); + } + None => current = Some((start, end)), + } + } + if let Some((start, end)) = current { + total += u64::from(end - start + 1); + } + u32::try_from(total) + .map_err(|_| ModelError::new("derived changed-line count overflowed").into()) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "metric", rename_all = "snake_case")] +pub enum StructuralMetric { + FunctionLineCount { lines: u32 }, + ChangedLines { old: u32, new: u32 }, + ParameterCount { parameters: u32 }, + SyntacticNestingDepth { depth: u32 }, + TypeMemberCount { members: u32 }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct StructuralHotspot { + symbol: SymbolReference, + metric: StructuralMetric, + provenance: SyntaxProvenance, + navigation: ReviewNavigationTarget, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct StructuralHotspotWire { + symbol: SymbolReference, + metric: StructuralMetric, + provenance: SyntaxProvenance, + navigation: ReviewNavigationTarget, +} +impl TryFrom for StructuralHotspot { + type Error = ModelError; + fn try_from(value: StructuralHotspotWire) -> Result { + Self::new( + value.symbol, + value.metric, + value.provenance, + value.navigation, + ) + } +} +impl<'de> Deserialize<'de> for StructuralHotspot { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + StructuralHotspotWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} +impl StructuralHotspot { + pub fn new( + symbol: SymbolReference, + metric: StructuralMetric, + provenance: SyntaxProvenance, + navigation: ReviewNavigationTarget, + ) -> Result { + validate_navigation(&navigation)?; + if symbol.side() != navigation.side { + return Err(ModelError::new( + "hotspot location and navigation must use the same side", + )); + } + Ok(Self { + symbol, + metric, + provenance, + navigation, + }) + } + pub fn symbol(&self) -> &SymbolReference { + &self.symbol + } + pub fn metric(&self) -> &StructuralMetric { + &self.metric + } + pub fn provenance(&self) -> &SyntaxProvenance { + &self.provenance + } + pub fn navigation(&self) -> &ReviewNavigationTarget { + &self.navigation + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CallChangeKind { + Added, + Removed, + Modified, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CallPairingStrategy { + /// The comparator found exactly one matching occurrence inside each enclosing range. + UniqueOccurrenceWithinEnclosingRange, + /// The callee repeats, but once the occurrences identical on both sides were cancelled + /// exactly one changed occurrence remained inside each enclosing range. + UniqueChangedOccurrenceWithinEnclosingRange, +} + +/// Evidence for pairing two call occurrences across snapshots. +/// +/// Candidate counts make the comparator's collection-level uniqueness claim explicit. The model +/// requires 1:1, but cannot independently recount the comparator's candidate collection. Repeated +/// or otherwise ambiguous calls deliberately degrade to separate added and removed changes. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct CallPairingEvidence { + strategy: CallPairingStrategy, + old_call_range: SourceRange, + new_call_range: SourceRange, + old_enclosing_range: SourceRange, + new_enclosing_range: SourceRange, + old_candidate_count: u32, + new_candidate_count: u32, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CallPairingEvidenceWire { + strategy: CallPairingStrategy, + old_call_range: SourceRange, + new_call_range: SourceRange, + old_enclosing_range: SourceRange, + new_enclosing_range: SourceRange, + old_candidate_count: u32, + new_candidate_count: u32, +} + +impl TryFrom for CallPairingEvidence { + type Error = ModelError; + fn try_from(value: CallPairingEvidenceWire) -> Result { + Self::new( + value.strategy, + value.old_call_range, + value.new_call_range, + value.old_enclosing_range, + value.new_enclosing_range, + value.old_candidate_count, + value.new_candidate_count, + ) + } +} + +impl<'de> Deserialize<'de> for CallPairingEvidence { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + CallPairingEvidenceWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +impl CallPairingEvidence { + pub fn new( + strategy: CallPairingStrategy, + old_call_range: SourceRange, + new_call_range: SourceRange, + old_enclosing_range: SourceRange, + new_enclosing_range: SourceRange, + old_candidate_count: u32, + new_candidate_count: u32, + ) -> Result { + if !old_enclosing_range.contains(old_call_range) + || !new_enclosing_range.contains(new_call_range) + { + return Err(ModelError::new( + "paired call locations must be inside their enclosing ranges", + )); + } + if old_candidate_count != 1 || new_candidate_count != 1 { + return Err(ModelError::new( + "modified call pairing requires exactly one candidate on each side", + )); + } + Ok(Self { + strategy, + old_call_range, + new_call_range, + old_enclosing_range, + new_enclosing_range, + old_candidate_count, + new_candidate_count, + }) + } + pub fn strategy(&self) -> CallPairingStrategy { + self.strategy + } + pub fn old_call_range(&self) -> SourceRange { + self.old_call_range + } + pub fn new_call_range(&self) -> SourceRange { + self.new_call_range + } + pub fn old_enclosing_range(&self) -> SourceRange { + self.old_enclosing_range + } + pub fn new_enclosing_range(&self) -> SourceRange { + self.new_enclosing_range + } + pub fn old_candidate_count(&self) -> u32 { + self.old_candidate_count + } + pub fn new_candidate_count(&self) -> u32 { + self.new_candidate_count + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct CallDiffChange { + kind: CallChangeKind, + old: Option, + new: Option, + arguments_changed: bool, + control_context_changed: bool, + pairing: Option, + navigation: ReviewNavigationTarget, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CallDiffChangeWire { + kind: CallChangeKind, + old: Option, + new: Option, + arguments_changed: bool, + control_context_changed: bool, + pairing: Option, + navigation: ReviewNavigationTarget, +} +impl TryFrom for CallDiffChange { + type Error = ModelError; + fn try_from(value: CallDiffChangeWire) -> Result { + Self::new( + value.kind, + value.old, + value.new, + value.arguments_changed, + value.control_context_changed, + value.pairing, + value.navigation, + ) + } +} +impl<'de> Deserialize<'de> for CallDiffChange { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + CallDiffChangeWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} +impl CallDiffChange { + pub fn new( + kind: CallChangeKind, + old: Option, + new: Option, + arguments_changed: bool, + control_context_changed: bool, + pairing: Option, + navigation: ReviewNavigationTarget, + ) -> Result { + match Self::new_controlled( + kind, + old, + new, + arguments_changed, + control_context_changed, + pairing, + navigation, + &mut || Ok::<(), Infallible>(()), + ) { + Ok(change) => Ok(change), + Err(ControlledModelError::Invalid(error)) => Err(error), + Err(ControlledModelError::Stopped(never)) => match never {}, + } + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn new_controlled( + kind: CallChangeKind, + old: Option, + new: Option, + arguments_changed: bool, + control_context_changed: bool, + pairing: Option, + navigation: ReviewNavigationTarget, + checkpoint: &mut dyn FnMut() -> Result<(), E>, + ) -> Result> { + checkpoint().map_err(ControlledModelError::Stopped)?; + let valid = match kind { + CallChangeKind::Added => { + old.is_none() + && new.is_some() + && !arguments_changed + && !control_context_changed + && pairing.is_none() + && navigation.side == ComparisonSide::Head + } + CallChangeKind::Removed => { + old.is_some() + && new.is_none() + && !arguments_changed + && !control_context_changed + && pairing.is_none() + && navigation.side == ComparisonSide::Base + } + CallChangeKind::Modified => { + old.is_some() + && new.is_some() + && pairing.is_some() + && (arguments_changed || control_context_changed) + } + }; + if !valid { + return Err(ModelError::new( + "call diff shape does not match its kind or changed dimensions", + ) + .into()); + } + if let (Some(old), Some(new)) = (&old, &new) + && (old.callee_text() != new.callee_text() + || !optional_symbol_keys_equal_controlled( + old.enclosing_symbol(), + new.enclosing_symbol(), + checkpoint, + )?) + { + return Err(ModelError::new( + "paired call modifications require the same callee and enclosing symbol", + ) + .into()); + } + if let (Some(old), Some(new)) = (&old, &new) { + let actual_arguments_changed = old.argument_text() != new.argument_text(); + let actual_control_context_changed = !control_contexts_equal_controlled( + old.control_context(), + new.control_context(), + checkpoint, + )?; + if arguments_changed != actual_arguments_changed + || control_context_changed != actual_control_context_changed + { + return Err(ModelError::new( + "call modification flags must match the paired syntactic facts", + ) + .into()); + } + let evidence = pairing.as_ref().ok_or_else(|| { + ModelError::new("modified calls require explicit pairing evidence") + })?; + if evidence.old_call_range() != old.call_site_range() + || evidence.new_call_range() != new.call_site_range() + { + return Err(ModelError::new( + "call pairing evidence must name the paired call-site locations", + ) + .into()); + } + } + validate_navigation(&navigation)?; + checkpoint().map_err(ControlledModelError::Stopped)?; + Ok(Self { + kind, + old, + new, + arguments_changed, + control_context_changed, + pairing, + navigation, + }) + } + pub fn kind(&self) -> CallChangeKind { + self.kind + } + pub fn old(&self) -> Option<&CallFact> { + self.old.as_ref() + } + pub fn new_fact(&self) -> Option<&CallFact> { + self.new.as_ref() + } + pub fn arguments_changed(&self) -> bool { + self.arguments_changed + } + pub fn control_context_changed(&self) -> bool { + self.control_context_changed + } + pub fn pairing(&self) -> Option<&CallPairingEvidence> { + self.pairing.as_ref() + } + pub fn navigation(&self) -> &ReviewNavigationTarget { + &self.navigation + } +} + +fn control_contexts_equal_controlled( + old: &[okena_syntax::ControlContext], + new: &[okena_syntax::ControlContext], + checkpoint: &mut dyn FnMut() -> Result<(), E>, +) -> Result> { + checkpoint().map_err(ControlledModelError::Stopped)?; + if old.len() != new.len() { + return Ok(false); + } + for (old, new) in old.iter().zip(new) { + checkpoint().map_err(ControlledModelError::Stopped)?; + if old != new { + return Ok(false); + } + } + Ok(true) +} + +fn optional_symbol_keys_equal_controlled( + old: Option<&SymbolKey>, + new: Option<&SymbolKey>, + checkpoint: &mut dyn FnMut() -> Result<(), E>, +) -> Result> { + match (old, new) { + (Some(old), Some(new)) => symbol_keys_equal_controlled(old, new, checkpoint), + (None, None) => Ok(true), + (Some(_), None) | (None, Some(_)) => Ok(false), + } +} + +fn symbol_keys_equal_controlled( + old: &SymbolKey, + new: &SymbolKey, + checkpoint: &mut dyn FnMut() -> Result<(), E>, +) -> Result> { + checkpoint().map_err(ControlledModelError::Stopped)?; + if old.kind() != new.kind() + || old.name() != new.name() + || old.qualified_path().len() != new.qualified_path().len() + { + return Ok(false); + } + for (old, new) in old.qualified_path().iter().zip(new.qualified_path()) { + checkpoint().map_err(ControlledModelError::Stopped)?; + if old != new { + return Ok(false); + } + } + Ok(true) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FileAnalysisStatus { + Parsed, + Partial, + Pending, + Unsupported, + Failed, + Skipped, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AnalysisStage { + Detection, + Parsing, + Comparison, + Budget, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct AnalysisError { + path: Option, + stage: AnalysisStage, + message: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct AnalysisErrorWire { + path: Option, + stage: AnalysisStage, + message: String, +} +impl TryFrom for AnalysisError { + type Error = ModelError; + fn try_from(value: AnalysisErrorWire) -> Result { + Self::new(value.path, value.stage, value.message) + } +} +impl<'de> Deserialize<'de> for AnalysisError { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + AnalysisErrorWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} +impl AnalysisError { + pub fn new( + path: Option, + stage: AnalysisStage, + message: impl Into, + ) -> Result { + let message = message.into(); + if message.trim().is_empty() || path.as_ref().is_some_and(|path| path.trim().is_empty()) { + return Err(ModelError::new( + "analysis error requires a message and a valid optional path", + )); + } + Ok(Self { + path, + stage, + message, + }) + } + pub fn path(&self) -> Option<&str> { + self.path.as_deref() + } + pub fn stage(&self) -> AnalysisStage { + self.stage + } + pub fn message(&self) -> &str { + &self.message + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct StructuredFile { + old_path: Option, + new_path: Option, + language: Option, + old_provenance: Option, + new_provenance: Option, + status: FileAnalysisStatus, + old_outline: Vec, + new_outline: Vec, + symbol_changes: Vec, + hotspots: Vec, + call_diff: Vec, + changed_hunks: Vec, + errors: Vec, + truncation: Option, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct StructuredFileWire { + old_path: Option, + new_path: Option, + language: Option, + old_provenance: Option, + new_provenance: Option, + status: FileAnalysisStatus, + old_outline: Vec, + new_outline: Vec, + symbol_changes: Vec, + hotspots: Vec, + call_diff: Vec, + changed_hunks: Vec, + errors: Vec, + truncation: Option, +} +impl TryFrom for StructuredFile { + type Error = ModelError; + fn try_from(value: StructuredFileWire) -> Result { + Self::new( + value.old_path, + value.new_path, + value.language, + value.old_provenance, + value.new_provenance, + value.status, + value.old_outline, + value.new_outline, + value.symbol_changes, + value.hotspots, + value.call_diff, + value.changed_hunks, + value.errors, + value.truncation, + ) + } +} +impl<'de> Deserialize<'de> for StructuredFile { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + StructuredFileWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} +impl StructuredFile { + #[allow(clippy::too_many_arguments)] + pub fn new( + old_path: Option, + new_path: Option, + language: Option, + old_provenance: Option, + new_provenance: Option, + status: FileAnalysisStatus, + old_outline: Vec, + new_outline: Vec, + symbol_changes: Vec, + hotspots: Vec, + call_diff: Vec, + changed_hunks: Vec, + errors: Vec, + truncation: Option, + ) -> Result { + match Self::new_controlled( + old_path, + new_path, + language, + old_provenance, + new_provenance, + status, + old_outline, + new_outline, + symbol_changes, + hotspots, + call_diff, + changed_hunks, + errors, + truncation, + &mut || Ok::<(), Infallible>(()), + ) { + Ok(file) => Ok(file), + Err(ControlledModelError::Invalid(error)) => Err(error), + Err(ControlledModelError::Stopped(never)) => match never {}, + } + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn new_controlled( + old_path: Option, + new_path: Option, + language: Option, + old_provenance: Option, + new_provenance: Option, + status: FileAnalysisStatus, + old_outline: Vec, + new_outline: Vec, + symbol_changes: Vec, + hotspots: Vec, + call_diff: Vec, + changed_hunks: Vec, + errors: Vec, + truncation: Option, + checkpoint: &mut dyn FnMut() -> Result<(), E>, + ) -> Result> { + checkpoint().map_err(ControlledModelError::Stopped)?; + if old_path.is_none() && new_path.is_none() { + return Err(ModelError::new("structured file requires at least one path").into()); + } + if old_path.as_ref().is_some_and(|path| path.trim().is_empty()) + || new_path.as_ref().is_some_and(|path| path.trim().is_empty()) + { + return Err(ModelError::new("structured file paths must not be empty").into()); + } + let has_facts = !old_outline.is_empty() + || !new_outline.is_empty() + || !symbol_changes.is_empty() + || !hotspots.is_empty() + || !call_diff.is_empty(); + if matches!( + status, + FileAnalysisStatus::Pending + | FileAnalysisStatus::Unsupported + | FileAnalysisStatus::Failed + | FileAnalysisStatus::Skipped + ) && has_facts + { + return Err( + ModelError::new("unsuccessful files cannot contain structured facts").into(), + ); + } + if status == FileAnalysisStatus::Unsupported && language.is_some() { + return Err(ModelError::new("unsupported files cannot claim a syntax language").into()); + } + if status == FileAnalysisStatus::Unsupported + && (old_provenance.is_some() || new_provenance.is_some()) + { + return Err(ModelError::new("unsupported files cannot claim syntax provenance").into()); + } + if status == FileAnalysisStatus::Pending + && (old_provenance.is_some() || new_provenance.is_some()) + { + return Err(ModelError::new("pending files cannot claim syntax provenance").into()); + } + if matches!( + status, + FileAnalysisStatus::Parsed | FileAnalysisStatus::Partial + ) && language.is_none() + { + return Err( + ModelError::new("successful structured files require a syntax language").into(), + ); + } + if matches!( + status, + FileAnalysisStatus::Parsed | FileAnalysisStatus::Partial + ) && (old_path.is_some() != old_provenance.is_some() + || new_path.is_some() != new_provenance.is_some()) + { + return Err(ModelError::new( + "analyzed snapshot paths require matching syntax provenance", + ) + .into()); + } + if status == FileAnalysisStatus::Parsed && (truncation.is_some() || !errors.is_empty()) { + return Err(ModelError::new("parsed files cannot carry errors or truncation").into()); + } + if let Some(truncation) = &truncation { + validate_review_truncation(truncation)?; + } + if status == FileAnalysisStatus::Partial && truncation.is_none() && errors.is_empty() { + return Err(ModelError::new("partial files require errors or truncation").into()); + } + if status == FileAnalysisStatus::Pending { + if truncation.is_none() && errors.is_empty() { + return Err(ModelError::new( + "pending files require budget, cancellation, time, or analysis error evidence", + ) + .into()); + } + for error in &errors { + checkpoint().map_err(ControlledModelError::Stopped)?; + if error.stage() != AnalysisStage::Budget { + return Err(ModelError::new( + "pending files can only carry budget-stage analysis errors", + ) + .into()); + } + } + if truncation + .as_ref() + .is_some_and(|truncation| !is_pending_truncation(truncation.reason)) + { + return Err(ModelError::new( + "pending file truncation must describe a budget, cancellation, or time limit", + ) + .into()); + } + } + if status == FileAnalysisStatus::Failed && errors.is_empty() { + return Err(ModelError::new("failed files require analysis error evidence").into()); + } + let mut unique_file_hunks = HashSet::with_capacity(changed_hunks.len()); + for hunk in &changed_hunks { + checkpoint().map_err(ControlledModelError::Stopped)?; + if !unique_file_hunks.insert(hunk) { + return Err(ModelError::new( + "structured files cannot contain duplicate changed hunks", + ) + .into()); + } + } + for change in &symbol_changes { + checkpoint().map_err(ControlledModelError::Stopped)?; + for hunk in change.hunks() { + checkpoint().map_err(ControlledModelError::Stopped)?; + if !unique_file_hunks.contains(hunk) { + return Err(ModelError::new( + "symbol changes can only cite hunks from their structured file", + ) + .into()); + } + } + } + for fact in old_outline.iter() { + checkpoint().map_err(ControlledModelError::Stopped)?; + if fact.symbol().side() != ComparisonSide::Base + || Some(fact.provenance()) != old_provenance.as_ref() + { + return Err(ModelError::new( + "old outline must use the base side and old document provenance", + ) + .into()); + } + } + for fact in new_outline.iter() { + checkpoint().map_err(ControlledModelError::Stopped)?; + if fact.symbol().side() != ComparisonSide::Head + || Some(fact.provenance()) != new_provenance.as_ref() + { + return Err(ModelError::new( + "new outline must use the head side and new document provenance", + ) + .into()); + } + } + let file = Self { + old_path, + new_path, + language, + old_provenance, + new_provenance, + status, + old_outline, + new_outline, + symbol_changes, + hotspots, + call_diff, + changed_hunks, + errors, + truncation, + }; + if let Some(language) = file.language { + for provenance in file.old_provenance.iter().chain(file.new_provenance.iter()) { + checkpoint().map_err(ControlledModelError::Stopped)?; + if provenance.language() != language { + return Err(ModelError::new( + "document provenance must use the file syntax language", + ) + .into()); + } + } + for change in &file.symbol_changes { + checkpoint().map_err(ControlledModelError::Stopped)?; + for fact in change.old().into_iter().chain(change.new_fact()) { + checkpoint().map_err(ControlledModelError::Stopped)?; + if fact.provenance().language() != language { + return Err(ModelError::new( + "structured facts must use the file syntax language", + ) + .into()); + } + } + } + for hotspot in &file.hotspots { + checkpoint().map_err(ControlledModelError::Stopped)?; + if hotspot.provenance().language() != language { + return Err(ModelError::new( + "structured facts must use the file syntax language", + ) + .into()); + } + } + for change in &file.call_diff { + checkpoint().map_err(ControlledModelError::Stopped)?; + for fact in change.old().into_iter().chain(change.new_fact()) { + checkpoint().map_err(ControlledModelError::Stopped)?; + if fact.provenance().language() != language { + return Err(ModelError::new( + "structured facts must use the file syntax language", + ) + .into()); + } + } + } + } + for navigation in file + .symbol_changes + .iter() + .map(SymbolChange::navigation) + .chain(file.hotspots.iter().map(StructuralHotspot::navigation)) + .chain(file.call_diff.iter().map(CallDiffChange::navigation)) + { + checkpoint().map_err(ControlledModelError::Stopped)?; + let expected = file + .path_on(navigation.side) + .ok_or_else(|| ModelError::new("navigation targets a missing comparison side"))?; + if navigation.path != expected { + return Err(ModelError::new( + "navigation path does not match its structured file side", + ) + .into()); + } + } + checkpoint().map_err(ControlledModelError::Stopped)?; + Ok(file) + } + pub fn path_on(&self, side: ComparisonSide) -> Option<&str> { + match side { + ComparisonSide::Base => self.old_path.as_deref(), + ComparisonSide::Head => self.new_path.as_deref(), + } + } + pub fn old_path(&self) -> Option<&str> { + self.old_path.as_deref() + } + pub fn new_path(&self) -> Option<&str> { + self.new_path.as_deref() + } + pub fn language(&self) -> Option { + self.language + } + pub fn status(&self) -> FileAnalysisStatus { + self.status + } + pub fn old_provenance(&self) -> Option<&SyntaxProvenance> { + self.old_provenance.as_ref() + } + pub fn new_provenance(&self) -> Option<&SyntaxProvenance> { + self.new_provenance.as_ref() + } + pub fn old_outline(&self) -> &[OutlineFact] { + &self.old_outline + } + pub fn new_outline(&self) -> &[OutlineFact] { + &self.new_outline + } + pub fn symbol_changes(&self) -> &[SymbolChange] { + &self.symbol_changes + } + pub fn hotspots(&self) -> &[StructuralHotspot] { + &self.hotspots + } + pub fn call_diff(&self) -> &[CallDiffChange] { + &self.call_diff + } + pub fn changed_hunks(&self) -> &[ChangedHunk] { + &self.changed_hunks + } + pub fn errors(&self) -> &[AnalysisError] { + &self.errors + } + pub fn truncation(&self) -> Option<&ReviewTruncation> { + self.truncation.as_ref() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct LanguageCoverage { + language: SyntaxLanguage, + coverage: ReviewCoverage, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct LanguageCoverageWire { + language: SyntaxLanguage, + coverage: ReviewCoverage, +} +impl TryFrom for LanguageCoverage { + type Error = ModelError; + fn try_from(value: LanguageCoverageWire) -> Result { + Ok(Self::new(value.language, value.coverage)) + } +} +impl<'de> Deserialize<'de> for LanguageCoverage { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + LanguageCoverageWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} +impl LanguageCoverage { + pub fn new(language: SyntaxLanguage, coverage: ReviewCoverage) -> Self { + Self { language, coverage } + } + pub fn language(&self) -> SyntaxLanguage { + self.language + } + pub fn coverage(&self) -> &ReviewCoverage { + &self.coverage + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OmittedFileReason { + UnsupportedLanguage, + Binary, + Submodule, + ModeOnly, + WhitespaceIgnored, + FileLimit, + SourceByteLimit, + AggregateByteLimit, + TimeLimit, + FactLimit, + ResponseLimit, + Cancelled, +} + +impl OmittedFileReason { + pub fn status(self) -> FileAnalysisStatus { + match self { + Self::UnsupportedLanguage | Self::Binary | Self::Submodule => { + FileAnalysisStatus::Unsupported + } + Self::ModeOnly | Self::WhitespaceIgnored => FileAnalysisStatus::Skipped, + Self::FileLimit + | Self::SourceByteLimit + | Self::AggregateByteLimit + | Self::TimeLimit + | Self::FactLimit + | Self::ResponseLimit + | Self::Cancelled => FileAnalysisStatus::Pending, + } + } + + fn truncation_reason(self) -> Option { + match self { + Self::UnsupportedLanguage + | Self::Binary + | Self::Submodule + | Self::ModeOnly + | Self::WhitespaceIgnored => None, + Self::FileLimit => Some(TruncationReason::ItemLimit), + Self::SourceByteLimit | Self::AggregateByteLimit => Some(TruncationReason::ByteLimit), + Self::TimeLimit => Some(TruncationReason::TimeLimit), + Self::FactLimit => Some(TruncationReason::CaptureLimit), + Self::ResponseLimit => Some(TruncationReason::ResponseLimit), + Self::Cancelled => Some(TruncationReason::Cancelled), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct OmittedFileGroup { + count: u64, + language: Option, + reason: OmittedFileReason, + status: FileAnalysisStatus, + truncation: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct OmittedFileGroupWire { + count: u64, + language: Option, + reason: OmittedFileReason, + status: FileAnalysisStatus, + truncation: Option, +} + +impl TryFrom for OmittedFileGroup { + type Error = ModelError; + + fn try_from(value: OmittedFileGroupWire) -> Result { + Self::new_with_status( + value.count, + value.language, + value.reason, + value.status, + value.truncation, + ) + } +} + +impl<'de> Deserialize<'de> for OmittedFileGroup { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + OmittedFileGroupWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +impl OmittedFileGroup { + pub fn new( + count: u64, + language: Option, + reason: OmittedFileReason, + truncation: Option, + ) -> Result { + Self::new_with_status(count, language, reason, reason.status(), truncation) + } + + fn new_with_status( + count: u64, + language: Option, + reason: OmittedFileReason, + status: FileAnalysisStatus, + truncation: Option, + ) -> Result { + if count == 0 { + return Err(ModelError::new("omitted file group count must be positive")); + } + if status != reason.status() { + return Err(ModelError::new( + "omitted file status must match its deterministic reason category", + )); + } + if reason == OmittedFileReason::UnsupportedLanguage && language.is_some() { + return Err(ModelError::new( + "unsupported-language omissions cannot claim a supported syntax language", + )); + } + match (reason.truncation_reason(), truncation.as_ref()) { + (None, None) => {} + (None, Some(_)) => { + return Err(ModelError::new( + "non-pending omission reasons cannot carry truncation evidence", + )); + } + (Some(_), None) => { + return Err(ModelError::new( + "pending omission reasons require truncation evidence", + )); + } + (Some(expected), Some(actual)) if actual.reason != expected => { + return Err(ModelError::new( + "omission truncation reason does not match its omission reason", + )); + } + (Some(_), Some(actual)) => validate_review_truncation(actual)?, + } + Ok(Self { + count, + language, + reason, + status, + truncation, + }) + } + + pub fn count(&self) -> u64 { + self.count + } + + pub fn language(&self) -> Option { + self.language + } + + pub fn reason(&self) -> OmittedFileReason { + self.reason + } + + pub fn status(&self) -> FileAnalysisStatus { + self.status + } + + pub fn truncation(&self) -> Option<&ReviewTruncation> { + self.truncation.as_ref() + } + + fn equivalent_to(&self, other: &Self) -> bool { + self.language == other.language + && self.reason == other.reason + && self.status == other.status + && self.truncation == other.truncation + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct ReviewStructure { + comparison: ImmutableResolvedComparison, + files: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + omissions: Vec, + coverage: ReviewCoverage, + language_coverage: Vec, + errors: Vec, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ReviewStructureWire { + comparison: ImmutableResolvedComparison, + files: Vec, + #[serde(default)] + omissions: Vec, + coverage: ReviewCoverage, + language_coverage: Vec, + errors: Vec, +} +impl TryFrom for ReviewStructure { + type Error = ModelError; + fn try_from(value: ReviewStructureWire) -> Result { + Self::new_with_omissions( + value.comparison, + value.files, + value.omissions, + value.coverage, + value.language_coverage, + value.errors, + ) + } +} +impl<'de> Deserialize<'de> for ReviewStructure { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + ReviewStructureWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} +impl ReviewStructure { + pub fn new( + comparison: ImmutableResolvedComparison, + files: Vec, + coverage: ReviewCoverage, + language_coverage: Vec, + errors: Vec, + ) -> Result { + Self::new_with_omissions( + comparison, + files, + Vec::new(), + coverage, + language_coverage, + errors, + ) + } + + pub fn new_with_omissions( + comparison: ImmutableResolvedComparison, + files: Vec, + mut omissions: Vec, + coverage: ReviewCoverage, + language_coverage: Vec, + errors: Vec, + ) -> Result { + if let Some(truncation) = coverage.truncation() { + validate_review_truncation(truncation)?; + } + if omissions.iter().enumerate().any(|(index, omission)| { + omissions[..index] + .iter() + .any(|earlier| omission.equivalent_to(earlier)) + }) { + return Err(ModelError::new( + "equivalent omitted file groups must be combined", + )); + } + omissions.sort_by(compare_omitted_file_groups); + validate_coverage(&files, &omissions, &coverage, None)?; + let mut seen = HashSet::new(); + for language in &language_coverage { + if !seen.insert(language.language()) { + return Err(ModelError::new("language coverage entries must be unique")); + } + if let Some(truncation) = language.coverage().truncation() { + validate_review_truncation(truncation)?; + } + validate_coverage( + &files, + &omissions, + language.coverage(), + Some(language.language()), + )?; + } + let covered_languages: HashSet<_> = files + .iter() + .filter_map(StructuredFile::language) + .chain(omissions.iter().filter_map(OmittedFileGroup::language)) + .collect(); + if seen != covered_languages { + return Err(ModelError::new( + "language coverage must account for every detected language exactly once", + )); + } + Ok(Self { + comparison, + files, + omissions, + coverage, + language_coverage, + errors, + }) + } + pub fn comparison(&self) -> &ImmutableResolvedComparison { + &self.comparison + } + pub fn files(&self) -> &[StructuredFile] { + &self.files + } + pub fn omissions(&self) -> &[OmittedFileGroup] { + &self.omissions + } + pub fn coverage(&self) -> &ReviewCoverage { + &self.coverage + } + pub fn language_coverage(&self) -> &[LanguageCoverage] { + &self.language_coverage + } + pub fn errors(&self) -> &[AnalysisError] { + &self.errors + } +} + +fn compare_omitted_file_groups( + left: &OmittedFileGroup, + right: &OmittedFileGroup, +) -> std::cmp::Ordering { + optional_language_rank(left.language()) + .cmp(&optional_language_rank(right.language())) + .then_with(|| { + omission_reason_rank(left.reason()).cmp(&omission_reason_rank(right.reason())) + }) + .then_with(|| { + left.truncation() + .and_then(|truncation| truncation.limit) + .cmp(&right.truncation().and_then(|truncation| truncation.limit)) + }) + .then_with(|| { + left.truncation() + .and_then(|truncation| truncation.observed) + .cmp( + &right + .truncation() + .and_then(|truncation| truncation.observed), + ) + }) + .then_with(|| { + left.truncation() + .and_then(|truncation| truncation.detail.as_deref()) + .cmp( + &right + .truncation() + .and_then(|truncation| truncation.detail.as_deref()), + ) + }) + .then_with(|| left.count().cmp(&right.count())) +} + +fn optional_language_rank(language: Option) -> u8 { + match language { + None => 0, + Some(SyntaxLanguage::Rust) => 1, + Some(SyntaxLanguage::TypeScript) => 2, + Some(SyntaxLanguage::Tsx) => 3, + } +} + +fn omission_reason_rank(reason: OmittedFileReason) -> u8 { + match reason { + OmittedFileReason::UnsupportedLanguage => 0, + OmittedFileReason::Binary => 1, + OmittedFileReason::Submodule => 2, + OmittedFileReason::ModeOnly => 3, + OmittedFileReason::WhitespaceIgnored => 4, + OmittedFileReason::FileLimit => 5, + OmittedFileReason::SourceByteLimit => 6, + OmittedFileReason::AggregateByteLimit => 7, + OmittedFileReason::TimeLimit => 8, + OmittedFileReason::FactLimit => 9, + OmittedFileReason::ResponseLimit => 10, + OmittedFileReason::Cancelled => 11, + } +} + +fn validate_navigation(target: &ReviewNavigationTarget) -> Result<(), ModelError> { + if target.path.trim().is_empty() { + Err(ModelError::new("navigation path must not be empty")) + } else { + Ok(()) + } +} + +fn validate_review_truncation(truncation: &ReviewTruncation) -> Result<(), ModelError> { + let measured = match (truncation.limit, truncation.observed) { + (Some(limit), Some(observed)) if limit > 0 && observed >= limit => true, + (None, None) => false, + _ => { + return Err(ModelError::new( + "review truncation measurements must be paired and meet a positive limit", + )); + } + }; + match truncation.reason { + TruncationReason::Cancelled if measured => Err(ModelError::new( + "cancelled review truncation cannot carry numeric measurements", + )), + TruncationReason::Cancelled => Ok(()), + TruncationReason::Other + if truncation + .detail + .as_ref() + .is_some_and(|detail| !detail.trim().is_empty()) => + { + Ok(()) + } + TruncationReason::Other => Err(ModelError::new( + "other review truncation requires non-empty detail", + )), + _ if measured => Ok(()), + _ => Err(ModelError::new( + "bounded review truncation requires limit and observed values", + )), + } +} + +fn is_pending_truncation(reason: TruncationReason) -> bool { + matches!( + reason, + TruncationReason::ItemLimit + | TruncationReason::ByteLimit + | TruncationReason::TimeLimit + | TruncationReason::CaptureLimit + | TruncationReason::ResponseLimit + | TruncationReason::Cancelled + ) +} + +fn validate_coverage( + files: &[StructuredFile], + omissions: &[OmittedFileGroup], + coverage: &ReviewCoverage, + language: Option, +) -> Result<(), ModelError> { + let mut counts: HashMap = HashMap::new(); + for file in files + .iter() + .filter(|file| language.is_none_or(|language| file.language() == Some(language))) + { + add_coverage_count(&mut counts, file.status(), 1)?; + } + for omission in omissions + .iter() + .filter(|omission| language.is_none_or(|language| omission.language() == Some(language))) + { + add_coverage_count(&mut counts, omission.status(), omission.count())?; + } + let analyzed = counts + .get(&FileAnalysisStatus::Parsed) + .copied() + .unwrap_or(0) + .checked_add( + counts + .get(&FileAnalysisStatus::Partial) + .copied() + .unwrap_or(0), + ) + .ok_or_else(|| ModelError::new("coverage count overflow"))?; + let pending = counts + .get(&FileAnalysisStatus::Pending) + .copied() + .unwrap_or(0); + let total = counts.values().try_fold(0_u64, |total, count| { + total + .checked_add(*count) + .ok_or_else(|| ModelError::new("coverage count overflow")) + })?; + let matches = coverage.total_items() == total + && coverage.analyzed_items() == analyzed + && coverage.pending_items() == pending + && coverage.skipped_items() + == counts + .get(&FileAnalysisStatus::Skipped) + .copied() + .unwrap_or(0) + && coverage.unsupported_items() + == counts + .get(&FileAnalysisStatus::Unsupported) + .copied() + .unwrap_or(0) + && coverage.failed_items() + == counts + .get(&FileAnalysisStatus::Failed) + .copied() + .unwrap_or(0); + if matches { + Ok(()) + } else { + Err(ModelError::new( + "coverage does not account for its structured files and omissions", + )) + } +} + +fn add_coverage_count( + counts: &mut HashMap, + status: FileAnalysisStatus, + count: u64, +) -> Result<(), ModelError> { + let current = counts.entry(status).or_default(); + *current = current + .checked_add(count) + .ok_or_else(|| ModelError::new("coverage count overflow"))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + use okena_core::review::{ + ComparisonStrategy, GitObjectId, ResolvedComparison, ReviewComparisonId, ReviewSnapshot, + }; + use okena_core::types::DiffMode; + use okena_syntax::{ControlContext, SymbolKind, SymbolVisibility}; + use serde_json::json; + + fn nz(value: u32) -> NonZeroU32 { + NonZeroU32::new(value).unwrap() + } + fn range(start: u64, end: u64, start_line: u32, end_line: u32) -> SourceRange { + SourceRange::new(start, end, nz(start_line), nz(end_line)).unwrap() + } + fn provenance() -> SyntaxProvenance { + SyntaxProvenance::tree_sitter(SyntaxLanguage::Rust, "tree-sitter-rust@0.24").unwrap() + } + fn symbol(signature: &str) -> SymbolFact { + SymbolFact::new( + provenance(), + SymbolKey::new(vec!["worker".into()], SymbolKind::Function, "run").unwrap(), + SymbolVisibility::Public, + range(0, 30, 1, 3), + range(0, 12, 1, 1), + Some(range(13, 30, 2, 3)), + signature, + 0, + 1, + 0, + ) + .unwrap() + } + fn navigation(side: ComparisonSide) -> ReviewNavigationTarget { + ReviewNavigationTarget { + path: "src/lib.rs".into(), + side, + line: nz(1), + byte_offset: Some(0), + symbol_context: None, + } + } + fn immutable_comparison() -> ImmutableResolvedComparison { + let base = GitObjectId::new("1111111111111111111111111111111111111111").unwrap(); + let head = GitObjectId::new("2222222222222222222222222222222222222222").unwrap(); + ResolvedComparison::new( + DiffMode::BranchCompare { + base: "origin/main".into(), + head: "feature".into(), + }, + Some(GitObjectId::new("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").unwrap()), + Some(head.clone()), + ComparisonStrategy::MergeBaseToHead, + ReviewSnapshot::Commit { oid: base.clone() }, + ReviewSnapshot::Commit { oid: head.clone() }, + Some(base), + ReviewComparisonId("comparison-1".into()), + ) + .unwrap() + .try_into() + .unwrap() + } + fn coverage(total: u64, analyzed: u64) -> ReviewCoverage { + ReviewCoverage::new(total, analyzed, 0, 0, 0, 0, None).unwrap() + } + fn new_hunk() -> ChangedHunk { + ChangedHunk::new(None, Some(ChangedLineRange::new(nz(1), nz(3)).unwrap())).unwrap() + } + fn paired_hunk() -> ChangedHunk { + ChangedHunk::new( + Some(ChangedLineRange::new(nz(1), nz(3)).unwrap()), + Some(ChangedLineRange::new(nz(1), nz(3)).unwrap()), + ) + .unwrap() + } + fn parsed_file() -> StructuredFile { + let change = SymbolChange::new( + SymbolChangeKind::Added, + None, + Some(symbol("pub fn run()")), + None, + false, + vec![new_hunk()], + navigation(ComparisonSide::Head), + ) + .unwrap(); + StructuredFile::new( + None, + Some("src/lib.rs".into()), + Some(SyntaxLanguage::Rust), + None, + Some(provenance()), + FileAnalysisStatus::Parsed, + Vec::new(), + Vec::new(), + vec![change], + Vec::new(), + Vec::new(), + vec![new_hunk()], + Vec::new(), + None, + ) + .unwrap() + } + + fn pending_file( + path: &str, + language: Option, + truncation: Option, + errors: Vec, + ) -> StructuredFile { + StructuredFile::new( + None, + Some(path.into()), + language, + None, + None, + FileAnalysisStatus::Pending, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + errors, + truncation, + ) + .unwrap() + } + + fn measured_truncation( + reason: TruncationReason, + limit: u64, + observed: u64, + ) -> ReviewTruncation { + ReviewTruncation { + reason, + limit: Some(limit), + observed: Some(observed), + detail: None, + } + } + + fn omission( + count: u64, + language: Option, + reason: OmittedFileReason, + ) -> OmittedFileGroup { + let truncation = match reason { + OmittedFileReason::UnsupportedLanguage + | OmittedFileReason::Binary + | OmittedFileReason::Submodule + | OmittedFileReason::ModeOnly + | OmittedFileReason::WhitespaceIgnored => None, + OmittedFileReason::FileLimit => { + Some(measured_truncation(TruncationReason::ItemLimit, 100, 100)) + } + OmittedFileReason::SourceByteLimit | OmittedFileReason::AggregateByteLimit => Some( + measured_truncation(TruncationReason::ByteLimit, 1_000, 1_001), + ), + OmittedFileReason::TimeLimit => Some(measured_truncation( + TruncationReason::TimeLimit, + 50_000, + 50_001, + )), + OmittedFileReason::FactLimit => Some(measured_truncation( + TruncationReason::CaptureLimit, + 500, + 500, + )), + OmittedFileReason::ResponseLimit => Some(measured_truncation( + TruncationReason::ResponseLimit, + 10_000, + 10_001, + )), + OmittedFileReason::Cancelled => Some(ReviewTruncation { + reason: TruncationReason::Cancelled, + limit: None, + observed: None, + detail: None, + }), + }; + OmittedFileGroup::new(count, language, reason, truncation).unwrap() + } + + #[test] + fn invalid_wire_change_shapes_are_rejected() { + assert!(serde_json::from_value::(json!({"start":3,"end":2})).is_err()); + assert!(serde_json::from_value::(json!({"old":null,"new":null})).is_err()); + let mut value = serde_json::to_value( + SymbolChange::new( + SymbolChangeKind::Added, + None, + Some(symbol("pub fn run()")), + None, + false, + vec![new_hunk()], + navigation(ComparisonSide::Head), + ) + .unwrap(), + ) + .unwrap(); + value["old"] = serde_json::to_value(symbol("pub fn run()")).unwrap(); + assert!(serde_json::from_value::(value).is_err()); + } + + #[test] + fn signature_wire_must_match_paired_facts() { + let old = symbol("pub fn run()"); + let new = symbol("pub fn run(value: u32)"); + let signature = SignatureChange::new( + old.normalized_signature(), + new.normalized_signature(), + old.signature_range(), + new.signature_range(), + ) + .unwrap(); + let change = SymbolChange::new( + SymbolChangeKind::Modified, + Some(old), + Some(new), + Some(signature), + true, + vec![paired_hunk()], + navigation(ComparisonSide::Head), + ) + .unwrap(); + let mut value = serde_json::to_value(change).unwrap(); + value["signature_change"]["new_signature"] = json!("wrong"); + assert!(serde_json::from_value::(value).is_err()); + } + + #[test] + fn modified_symbol_can_change_signature_and_body_together() { + let old = symbol("pub fn run()"); + let new = symbol("pub fn run(value: u32)"); + let signature = SignatureChange::new( + old.normalized_signature(), + new.normalized_signature(), + old.signature_range(), + new.signature_range(), + ) + .unwrap(); + let change = SymbolChange::new( + SymbolChangeKind::Modified, + Some(old), + Some(new), + Some(signature), + true, + vec![paired_hunk()], + navigation(ComparisonSide::Head), + ) + .unwrap(); + assert!(change.signature_change().is_some()); + assert!(change.body_changed()); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&change).unwrap()).unwrap(), + change + ); + } + + #[test] + fn modified_symbol_requires_dimension_specific_hunks() { + let old = symbol("pub fn run()"); + let new = symbol("pub fn run(value: u32)"); + let signature = SignatureChange::new( + old.normalized_signature(), + new.normalized_signature(), + old.signature_range(), + new.signature_range(), + ) + .unwrap(); + let signature_only = ChangedHunk::new( + Some(ChangedLineRange::new(nz(1), nz(1)).unwrap()), + Some(ChangedLineRange::new(nz(1), nz(1)).unwrap()), + ) + .unwrap(); + assert!( + SymbolChange::new( + SymbolChangeKind::Modified, + Some(old.clone()), + Some(new.clone()), + Some(signature.clone()), + true, + vec![signature_only], + navigation(ComparisonSide::Head), + ) + .is_err() + ); + + let body_only = ChangedHunk::new( + Some(ChangedLineRange::new(nz(2), nz(3)).unwrap()), + Some(ChangedLineRange::new(nz(2), nz(3)).unwrap()), + ) + .unwrap(); + assert!( + SymbolChange::new( + SymbolChangeKind::Modified, + Some(old), + Some(new), + Some(signature), + false, + vec![body_only], + navigation(ComparisonSide::Head), + ) + .is_err() + ); + } + + #[test] + fn modified_symbol_accepts_one_sided_body_evidence_and_derives_zero_other_count() { + let old = symbol("pub fn run()"); + let new = symbol("pub fn run()"); + let insertion = + ChangedHunk::new(None, Some(ChangedLineRange::new(nz(2), nz(2)).unwrap())).unwrap(); + let inserted = SymbolChange::new( + SymbolChangeKind::Modified, + Some(old.clone()), + Some(new.clone()), + None, + true, + vec![insertion], + navigation(ComparisonSide::Head), + ) + .unwrap(); + assert_eq!(inserted.changed_old_lines(), 0); + assert_eq!(inserted.changed_new_lines(), 1); + + let deletion = + ChangedHunk::new(Some(ChangedLineRange::new(nz(3), nz(3)).unwrap()), None).unwrap(); + let deleted = SymbolChange::new( + SymbolChangeKind::Modified, + Some(old), + Some(new), + None, + true, + vec![deletion], + navigation(ComparisonSide::Head), + ) + .unwrap(); + assert_eq!(deleted.changed_old_lines(), 1); + assert_eq!(deleted.changed_new_lines(), 0); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&deleted).unwrap()) + .unwrap(), + deleted + ); + } + + #[test] + fn modified_signature_accepts_one_sided_evidence_but_rejects_present_unrelated_sides() { + let old = symbol("pub fn run()"); + let new = symbol("pub fn run(value: u32)"); + let signature = SignatureChange::new( + old.normalized_signature(), + new.normalized_signature(), + old.signature_range(), + new.signature_range(), + ) + .unwrap(); + let insertion = + ChangedHunk::new(None, Some(ChangedLineRange::new(nz(1), nz(1)).unwrap())).unwrap(); + let inserted = SymbolChange::new( + SymbolChangeKind::Modified, + Some(old.clone()), + Some(new.clone()), + Some(signature.clone()), + false, + vec![insertion], + navigation(ComparisonSide::Head), + ) + .unwrap(); + assert_eq!(inserted.changed_old_lines(), 0); + assert_eq!(inserted.changed_new_lines(), 1); + + let unrelated_old_side = ChangedHunk::new( + Some(ChangedLineRange::new(nz(2), nz(2)).unwrap()), + Some(ChangedLineRange::new(nz(1), nz(1)).unwrap()), + ) + .unwrap(); + assert!( + SymbolChange::new( + SymbolChangeKind::Modified, + Some(old.clone()), + Some(new.clone()), + Some(signature.clone()), + false, + vec![unrelated_old_side], + navigation(ComparisonSide::Head), + ) + .is_err() + ); + + let deletion = + ChangedHunk::new(Some(ChangedLineRange::new(nz(1), nz(1)).unwrap()), None).unwrap(); + let deleted = SymbolChange::new( + SymbolChangeKind::Modified, + Some(old), + Some(new), + Some(signature), + false, + vec![deletion], + navigation(ComparisonSide::Head), + ) + .unwrap(); + assert_eq!(deleted.changed_old_lines(), 1); + assert_eq!(deleted.changed_new_lines(), 0); + } + + #[test] + fn symbol_hunks_are_unique_and_counts_are_derived() { + assert!( + SymbolChange::new( + SymbolChangeKind::Added, + None, + Some(symbol("pub fn run()")), + None, + false, + vec![new_hunk(), new_hunk()], + navigation(ComparisonSide::Head), + ) + .is_err() + ); + let out_of_symbol = + ChangedHunk::new(None, Some(ChangedLineRange::new(nz(8), nz(9)).unwrap())).unwrap(); + assert!( + SymbolChange::new( + SymbolChangeKind::Added, + None, + Some(symbol("pub fn run()")), + None, + false, + vec![out_of_symbol], + navigation(ComparisonSide::Head), + ) + .is_err() + ); + + let change = SymbolChange::new( + SymbolChangeKind::Added, + None, + Some(symbol("pub fn run()")), + None, + false, + vec![new_hunk()], + navigation(ComparisonSide::Head), + ) + .unwrap(); + assert_eq!(change.changed_old_lines(), 0); + assert_eq!(change.changed_new_lines(), 3); + let mut inflated = serde_json::to_value(change).unwrap(); + inflated["changed_new_lines"] = json!(30); + assert!(serde_json::from_value::(inflated).is_err()); + } + + #[test] + fn structured_file_rejects_fabricated_symbol_hunks() { + let change = SymbolChange::new( + SymbolChangeKind::Added, + None, + Some(symbol("pub fn run()")), + None, + false, + vec![new_hunk()], + navigation(ComparisonSide::Head), + ) + .unwrap(); + let different_file_hunk = + ChangedHunk::new(None, Some(ChangedLineRange::new(nz(1), nz(1)).unwrap())).unwrap(); + assert!( + StructuredFile::new( + None, + Some("src/lib.rs".into()), + Some(SyntaxLanguage::Rust), + None, + Some(provenance()), + FileAnalysisStatus::Parsed, + Vec::new(), + Vec::new(), + vec![change], + Vec::new(), + Vec::new(), + vec![different_file_hunk], + Vec::new(), + None, + ) + .is_err() + ); + } + + #[test] + fn controlled_structured_file_validation_stops_inside_hunk_membership_work() { + let hunks: Vec<_> = (1_u32..=100) + .map(|line| { + ChangedHunk::new( + None, + Some(ChangedLineRange::new(nz(line), nz(line)).unwrap()), + ) + .unwrap() + }) + .collect(); + let mut checks = 0_u32; + let error = StructuredFile::new_controlled( + None, + Some("src/lib.rs".into()), + Some(SyntaxLanguage::Rust), + None, + Some(provenance()), + FileAnalysisStatus::Parsed, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + hunks, + Vec::new(), + None, + &mut || { + checks += 1; + if checks == 40 { Err("stopped") } else { Ok(()) } + }, + ) + .unwrap_err(); + + assert!(matches!(error, ControlledModelError::Stopped("stopped"))); + } + + #[test] + fn checked_stable_sort_can_stop_after_comparisons_begin() { + let comparisons = Cell::new(0_u32); + let mut values: Vec<_> = (0_u32..100).rev().collect(); + let error = checked_stable_sort_by( + &mut values, + |left, right, _| { + comparisons.set(comparisons.get() + 1); + Ok(left.cmp(right)) + }, + &mut || { + if comparisons.get() >= 10 { + Err("stopped") + } else { + Ok(()) + } + }, + ) + .unwrap_err(); + + assert_eq!(error, "stopped"); + assert!(comparisons.get() >= 10); + } + + #[test] + fn modified_calls_require_a_changed_dimension_and_same_context() { + let call = CallFact::new( + provenance(), + "work", + "value", + range(20, 22, 2, 2), + range(16, 23, 2, 2), + Some(symbol("pub fn run()").key().clone()), + Vec::new(), + ) + .unwrap(); + assert!( + CallDiffChange::new( + CallChangeKind::Modified, + Some(call.clone()), + Some(call.clone()), + false, + false, + None, + navigation(ComparisonSide::Head) + ) + .is_err() + ); + let changed = CallFact::new( + provenance(), + "work", + "other", + range(20, 22, 2, 2), + range(16, 23, 2, 2), + Some(symbol("pub fn run()").key().clone()), + Vec::new(), + ) + .unwrap(); + let change = CallDiffChange::new( + CallChangeKind::Modified, + Some(call.clone()), + Some(changed.clone()), + true, + false, + Some( + CallPairingEvidence::new( + CallPairingStrategy::UniqueOccurrenceWithinEnclosingRange, + call.call_site_range(), + changed.call_site_range(), + symbol("pub fn run()").full_range(), + symbol("pub fn run()").full_range(), + 1, + 1, + ) + .unwrap(), + ), + navigation(ComparisonSide::Head), + ) + .unwrap(); + let mut invalid_wire = serde_json::to_value(change).unwrap(); + invalid_wire["arguments_changed"] = json!(false); + assert!(serde_json::from_value::(invalid_wire).is_err()); + } + + #[test] + fn controlled_modified_call_validation_stops_inside_context_comparison() { + let old_contexts: Vec<_> = (0_u32..100) + .map(|index| ControlContext::Other(format!("context-{index}"))) + .collect(); + let mut new_contexts = old_contexts.clone(); + new_contexts[99] = ControlContext::Other("changed".into()); + let enclosing = symbol("pub fn run()"); + let old = CallFact::new( + provenance(), + "work", + "value", + range(20, 22, 2, 2), + range(16, 23, 2, 2), + Some(enclosing.key().clone()), + old_contexts, + ) + .unwrap(); + let new = CallFact::new( + provenance(), + "work", + "value", + range(20, 22, 2, 2), + range(16, 23, 2, 2), + Some(enclosing.key().clone()), + new_contexts, + ) + .unwrap(); + let pairing = CallPairingEvidence::new( + CallPairingStrategy::UniqueOccurrenceWithinEnclosingRange, + old.call_site_range(), + new.call_site_range(), + enclosing.full_range(), + enclosing.full_range(), + 1, + 1, + ) + .unwrap(); + let mut checks = 0_u32; + let error = CallDiffChange::new_controlled( + CallChangeKind::Modified, + Some(old), + Some(new), + false, + true, + Some(pairing), + navigation(ComparisonSide::Head), + &mut || { + checks += 1; + if checks == 50 { Err("stopped") } else { Ok(()) } + }, + ) + .unwrap_err(); + + assert!(matches!(error, ControlledModelError::Stopped("stopped"))); + } + + #[test] + fn ambiguous_repeated_calls_must_remain_unpaired() { + let old = CallFact::new( + provenance(), + "work", + "value", + range(20, 22, 2, 2), + range(16, 23, 2, 2), + Some(symbol("pub fn run()").key().clone()), + Vec::new(), + ) + .unwrap(); + let new = CallFact::new( + provenance(), + "work", + "other", + range(20, 22, 2, 2), + range(16, 23, 2, 2), + Some(symbol("pub fn run()").key().clone()), + Vec::new(), + ) + .unwrap(); + let repeated_old = CallFact::new( + provenance(), + "work", + "value", + range(25, 27, 3, 3), + range(24, 28, 3, 3), + Some(symbol("pub fn run()").key().clone()), + Vec::new(), + ) + .unwrap(); + let old_candidates = [old.clone(), repeated_old]; + assert!( + CallPairingEvidence::new( + CallPairingStrategy::UniqueOccurrenceWithinEnclosingRange, + old.call_site_range(), + new.call_site_range(), + symbol("pub fn run()").full_range(), + symbol("pub fn run()").full_range(), + u32::try_from(old_candidates.len()).unwrap(), + 1, + ) + .is_err() + ); + assert!( + CallDiffChange::new( + CallChangeKind::Modified, + Some(old.clone()), + Some(new.clone()), + true, + false, + None, + navigation(ComparisonSide::Head), + ) + .is_err() + ); + assert!( + CallDiffChange::new( + CallChangeKind::Removed, + Some(old), + None, + false, + false, + None, + navigation(ComparisonSide::Base), + ) + .is_ok() + ); + assert!( + CallDiffChange::new( + CallChangeKind::Added, + None, + Some(new), + false, + false, + None, + navigation(ComparisonSide::Head), + ) + .is_ok() + ); + } + + #[test] + fn failed_structured_file_rejects_successful_facts_on_the_wire() { + let mut value = serde_json::to_value(parsed_file()).unwrap(); + value["status"] = json!("failed"); + assert!(serde_json::from_value::(value).is_err()); + } + + #[test] + fn pending_files_accept_explicit_budget_time_and_cancellation_evidence() { + let cases = [ + measured_truncation(TruncationReason::ItemLimit, 100, 100), + measured_truncation(TruncationReason::ByteLimit, 1_000_000, 1_000_001), + measured_truncation(TruncationReason::TimeLimit, 50_000, 50_000), + ReviewTruncation { + reason: TruncationReason::Cancelled, + limit: None, + observed: None, + detail: Some("daemon request cancelled".into()), + }, + ]; + + for (index, truncation) in cases.into_iter().enumerate() { + let file = pending_file( + &format!("src/pending-{index}.rs"), + Some(SyntaxLanguage::Rust), + Some(truncation), + Vec::new(), + ); + assert_eq!(file.status(), FileAnalysisStatus::Pending); + assert!(file.old_provenance().is_none()); + assert!(file.new_provenance().is_none()); + assert!(file.old_outline().is_empty()); + assert!(file.new_outline().is_empty()); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&file).unwrap()) + .unwrap(), + file + ); + } + + let error = AnalysisError::new( + Some("src/pending-error.rs".into()), + AnalysisStage::Budget, + "analysis slot was not available", + ) + .unwrap(); + let pending = pending_file("src/pending-error.rs", None, None, vec![error]); + assert_eq!(pending.status(), FileAnalysisStatus::Pending); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&pending).unwrap()) + .unwrap(), + pending + ); + } + + #[test] + fn pending_files_reject_successful_facts_provenance_and_invalid_evidence() { + let mut with_facts = serde_json::to_value(parsed_file()).unwrap(); + with_facts["status"] = json!("pending"); + with_facts["truncation"] = + serde_json::to_value(measured_truncation(TruncationReason::ItemLimit, 1, 1)).unwrap(); + assert!(serde_json::from_value::(with_facts).is_err()); + + assert!( + StructuredFile::new( + None, + Some("src/pending.rs".into()), + Some(SyntaxLanguage::Rust), + None, + Some(provenance()), + FileAnalysisStatus::Pending, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Some(measured_truncation(TruncationReason::ByteLimit, 100, 100)), + ) + .is_err() + ); + assert!( + StructuredFile::new( + None, + Some("src/pending.rs".into()), + Some(SyntaxLanguage::Rust), + None, + None, + FileAnalysisStatus::Pending, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ) + .is_err() + ); + assert!( + StructuredFile::new( + None, + Some("src/pending.rs".into()), + None, + None, + None, + FileAnalysisStatus::Pending, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Some(ReviewTruncation { + reason: TruncationReason::Other, + limit: None, + observed: None, + detail: Some("not a scheduling or budget reason".into()), + }), + ) + .is_err() + ); + + for stage in [ + AnalysisStage::Detection, + AnalysisStage::Parsing, + AnalysisStage::Comparison, + ] { + let invalid_error = + AnalysisError::new(Some("src/pending.rs".into()), stage, "not pending evidence") + .unwrap(); + assert!( + StructuredFile::new( + None, + Some("src/pending.rs".into()), + Some(SyntaxLanguage::Rust), + None, + None, + FileAnalysisStatus::Pending, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + vec![invalid_error.clone()], + Some(measured_truncation(TruncationReason::TimeLimit, 50, 50)), + ) + .is_err() + ); + + let budget_error = AnalysisError::new( + Some("src/pending.rs".into()), + AnalysisStage::Budget, + "scheduler deferred analysis", + ) + .unwrap(); + let valid = pending_file( + "src/pending.rs", + None, + Some(measured_truncation(TruncationReason::ByteLimit, 100, 100)), + vec![budget_error], + ); + let mut wire = serde_json::to_value(valid).unwrap(); + wire["errors"] = json!([invalid_error]); + assert!(serde_json::from_value::(wire).is_err()); + } + } + + #[test] + fn partial_and_failed_files_require_explicit_evidence() { + let mut partial = serde_json::to_value(parsed_file()).unwrap(); + partial["status"] = json!("partial"); + assert!(serde_json::from_value::(partial).is_err()); + + let mut failed = serde_json::to_value(parsed_file()).unwrap(); + failed["status"] = json!("failed"); + failed["symbol_changes"] = json!([]); + assert!(serde_json::from_value::(failed).is_err()); + } + + #[test] + fn outlines_must_match_snapshot_provenance() { + let snapshot_provenance = provenance(); + let other_provenance = + SyntaxProvenance::tree_sitter(SyntaxLanguage::Rust, "different-parser").unwrap(); + let outline = OutlineFact::new( + other_provenance, + SymbolReference::new( + ComparisonSide::Head, + symbol("pub fn run()").full_range(), + symbol("pub fn run()").key().clone(), + ), + Vec::new(), + ) + .unwrap(); + assert!( + StructuredFile::new( + None, + Some("src/lib.rs".into()), + Some(SyntaxLanguage::Rust), + None, + Some(snapshot_provenance), + FileAnalysisStatus::Parsed, + Vec::new(), + vec![outline], + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ) + .is_err() + ); + } + + #[test] + fn aggregate_wire_rejects_duplicate_language_coverage() { + let review = ReviewStructure::new( + immutable_comparison(), + vec![parsed_file()], + coverage(1, 1), + vec![LanguageCoverage::new(SyntaxLanguage::Rust, coverage(1, 1))], + Vec::new(), + ) + .unwrap(); + let mut value = serde_json::to_value(review).unwrap(); + let duplicate = value["language_coverage"][0].clone(); + value["language_coverage"] + .as_array_mut() + .unwrap() + .push(duplicate); + assert!(serde_json::from_value::(value).is_err()); + } + + #[test] + fn aggregate_and_language_coverage_count_pending_files_exactly() { + let files = vec![ + parsed_file(), + pending_file( + "src/pending-rust.rs", + Some(SyntaxLanguage::Rust), + Some(measured_truncation(TruncationReason::ByteLimit, 100, 101)), + Vec::new(), + ), + pending_file( + "src/pending-ts.ts", + Some(SyntaxLanguage::TypeScript), + Some(measured_truncation(TruncationReason::TimeLimit, 50, 50)), + Vec::new(), + ), + pending_file( + "src/pending-undetected", + None, + Some(ReviewTruncation { + reason: TruncationReason::Cancelled, + limit: None, + observed: None, + detail: None, + }), + Vec::new(), + ), + ]; + let aggregate = ReviewCoverage::new(4, 1, 3, 0, 0, 0, None).unwrap(); + let rust = ReviewCoverage::new(2, 1, 1, 0, 0, 0, None).unwrap(); + let typescript = ReviewCoverage::new(1, 0, 1, 0, 0, 0, None).unwrap(); + let review = ReviewStructure::new( + immutable_comparison(), + files, + aggregate, + vec![ + LanguageCoverage::new(SyntaxLanguage::Rust, rust), + LanguageCoverage::new(SyntaxLanguage::TypeScript, typescript), + ], + Vec::new(), + ) + .unwrap(); + assert_eq!(review.coverage().pending_items(), 3); + assert_eq!(review.language_coverage()[0].coverage().pending_items(), 1); + assert_eq!(review.language_coverage()[1].coverage().pending_items(), 1); + + let value = serde_json::to_value(&review).unwrap(); + assert_eq!( + serde_json::from_value::(value.clone()).unwrap(), + review + ); + let mut invalid = value; + invalid["language_coverage"][0]["coverage"] = + serde_json::to_value(ReviewCoverage::new(2, 2, 0, 0, 0, 0, None).unwrap()).unwrap(); + assert!(serde_json::from_value::(invalid).is_err()); + } + + #[test] + fn every_omission_reason_has_a_fixed_status_and_evidence_shape() { + let cases = [ + ( + OmittedFileReason::UnsupportedLanguage, + FileAnalysisStatus::Unsupported, + None, + ), + ( + OmittedFileReason::Binary, + FileAnalysisStatus::Unsupported, + None, + ), + ( + OmittedFileReason::Submodule, + FileAnalysisStatus::Unsupported, + None, + ), + ( + OmittedFileReason::ModeOnly, + FileAnalysisStatus::Skipped, + None, + ), + ( + OmittedFileReason::WhitespaceIgnored, + FileAnalysisStatus::Skipped, + None, + ), + ( + OmittedFileReason::FileLimit, + FileAnalysisStatus::Pending, + Some(TruncationReason::ItemLimit), + ), + ( + OmittedFileReason::SourceByteLimit, + FileAnalysisStatus::Pending, + Some(TruncationReason::ByteLimit), + ), + ( + OmittedFileReason::AggregateByteLimit, + FileAnalysisStatus::Pending, + Some(TruncationReason::ByteLimit), + ), + ( + OmittedFileReason::TimeLimit, + FileAnalysisStatus::Pending, + Some(TruncationReason::TimeLimit), + ), + ( + OmittedFileReason::FactLimit, + FileAnalysisStatus::Pending, + Some(TruncationReason::CaptureLimit), + ), + ( + OmittedFileReason::ResponseLimit, + FileAnalysisStatus::Pending, + Some(TruncationReason::ResponseLimit), + ), + ( + OmittedFileReason::Cancelled, + FileAnalysisStatus::Pending, + Some(TruncationReason::Cancelled), + ), + ]; + + for (reason, status, truncation_reason) in cases { + let language = + (reason != OmittedFileReason::UnsupportedLanguage).then_some(SyntaxLanguage::Rust); + let group = omission(3, language, reason); + assert_eq!(group.count(), 3); + assert_eq!(group.reason(), reason); + assert_eq!(group.status(), status); + assert_eq!( + group.truncation().map(|truncation| truncation.reason), + truncation_reason + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&group).unwrap()) + .unwrap(), + group + ); + } + } + + #[test] + fn grouped_omissions_support_huge_counts_without_file_expansion() { + let group = omission(u64::MAX, None, OmittedFileReason::FileLimit); + let review = ReviewStructure::new_with_omissions( + immutable_comparison(), + Vec::new(), + vec![group], + ReviewCoverage::new(u64::MAX, 0, u64::MAX, 0, 0, 0, None).unwrap(), + Vec::new(), + Vec::new(), + ) + .unwrap(); + assert!(review.files().is_empty()); + assert_eq!(review.omissions().len(), 1); + assert_eq!(review.omissions()[0].count(), u64::MAX); + } + + #[test] + fn unknown_language_omissions_only_contribute_to_aggregate_coverage() { + let omissions = vec![ + omission(3, None, OmittedFileReason::FileLimit), + omission( + 2, + Some(SyntaxLanguage::TypeScript), + OmittedFileReason::ModeOnly, + ), + ]; + let review = ReviewStructure::new_with_omissions( + immutable_comparison(), + vec![parsed_file()], + omissions, + ReviewCoverage::new(6, 1, 3, 2, 0, 0, None).unwrap(), + vec![ + LanguageCoverage::new( + SyntaxLanguage::Rust, + ReviewCoverage::new(1, 1, 0, 0, 0, 0, None).unwrap(), + ), + LanguageCoverage::new( + SyntaxLanguage::TypeScript, + ReviewCoverage::new(2, 0, 0, 2, 0, 0, None).unwrap(), + ), + ], + Vec::new(), + ) + .unwrap(); + assert_eq!(review.coverage().total_items(), 6); + assert_eq!(review.language_coverage()[0].coverage().total_items(), 1); + assert_eq!(review.language_coverage()[1].coverage().total_items(), 2); + } + + #[test] + fn invalid_omission_shapes_duplicates_and_coverage_are_rejected() { + assert!(OmittedFileGroup::new(0, None, OmittedFileReason::FileLimit, None).is_err()); + assert!( + OmittedFileGroup::new( + 1, + Some(SyntaxLanguage::Rust), + OmittedFileReason::UnsupportedLanguage, + None, + ) + .is_err() + ); + assert!(OmittedFileGroup::new(1, None, OmittedFileReason::FileLimit, None).is_err()); + assert!( + OmittedFileGroup::new( + 1, + None, + OmittedFileReason::SourceByteLimit, + Some(measured_truncation(TruncationReason::ItemLimit, 1, 1)), + ) + .is_err() + ); + assert!( + OmittedFileGroup::new( + 1, + None, + OmittedFileReason::Binary, + Some(measured_truncation(TruncationReason::ByteLimit, 1, 1)), + ) + .is_err() + ); + + let group = omission(1, None, OmittedFileReason::FileLimit); + let mut invalid_wire = serde_json::to_value(&group).unwrap(); + invalid_wire["count"] = json!(0); + assert!(serde_json::from_value::(invalid_wire).is_err()); + for invalid_status in [ + FileAnalysisStatus::Parsed, + FileAnalysisStatus::Partial, + FileAnalysisStatus::Failed, + FileAnalysisStatus::Skipped, + ] { + let mut invalid_wire = serde_json::to_value(&group).unwrap(); + invalid_wire["status"] = serde_json::to_value(invalid_status).unwrap(); + assert!(serde_json::from_value::(invalid_wire).is_err()); + } + + assert!( + ReviewStructure::new_with_omissions( + immutable_comparison(), + Vec::new(), + vec![group.clone(), group.clone()], + ReviewCoverage::new(2, 0, 2, 0, 0, 0, None).unwrap(), + Vec::new(), + Vec::new(), + ) + .is_err() + ); + assert!( + ReviewStructure::new_with_omissions( + immutable_comparison(), + Vec::new(), + vec![group], + ReviewCoverage::new(2, 0, 2, 0, 0, 0, None).unwrap(), + Vec::new(), + Vec::new(), + ) + .is_err() + ); + } + + #[test] + fn non_empty_omission_response_has_stable_golden_json() { + let pending_coverage = ReviewCoverage::new(3, 0, 3, 0, 0, 0, None).unwrap(); + let review = ReviewStructure::new_with_omissions( + immutable_comparison(), + Vec::new(), + vec![omission( + 3, + Some(SyntaxLanguage::Rust), + OmittedFileReason::TimeLimit, + )], + pending_coverage.clone(), + vec![LanguageCoverage::new( + SyntaxLanguage::Rust, + pending_coverage, + )], + Vec::new(), + ) + .unwrap(); + let value = serde_json::to_value(&review).unwrap(); + assert_eq!( + value, + json!({ + "comparison": { + "requested": { "branch_compare": { "base": "origin/main", "head": "feature" } }, + "requested_base_oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "requested_head_oid": "2222222222222222222222222222222222222222", + "strategy": "merge_base_to_head", + "base": { "kind": "commit", "oid": "1111111111111111111111111111111111111111" }, + "head": { "kind": "commit", "oid": "2222222222222222222222222222222222222222" }, + "merge_base_oid": "1111111111111111111111111111111111111111", + "identity": "comparison-1" + }, + "files": [], + "omissions": [{ + "count": 3, + "language": "rust", + "reason": "time_limit", + "status": "pending", + "truncation": { + "reason": "time_limit", + "limit": 50_000, + "observed": 50_001 + } + }], + "coverage": { + "total_items": 3, + "analyzed_items": 0, + "pending_items": 3, + "skipped_items": 0, + "unsupported_items": 0, + "failed_items": 0 + }, + "language_coverage": [{ + "language": "rust", + "coverage": { + "total_items": 3, + "analyzed_items": 0, + "pending_items": 3, + "skipped_items": 0, + "unsupported_items": 0, + "failed_items": 0 + } + }], + "errors": [] + }) + ); + assert_eq!( + serde_json::from_value::(value).unwrap(), + review + ); + } + + #[test] + fn pending_exact_comparison_response_has_stable_golden_json() { + let truncation = measured_truncation(TruncationReason::ByteLimit, 1_000, 1_250); + let pending = pending_file( + "src/pending.rs", + Some(SyntaxLanguage::Rust), + Some(truncation.clone()), + Vec::new(), + ); + let pending_coverage = ReviewCoverage::new(1, 0, 1, 0, 0, 0, None).unwrap(); + let review = ReviewStructure::new( + immutable_comparison(), + vec![pending], + pending_coverage.clone(), + vec![LanguageCoverage::new( + SyntaxLanguage::Rust, + pending_coverage, + )], + Vec::new(), + ) + .unwrap(); + let value = serde_json::to_value(&review).unwrap(); + assert_eq!( + value, + json!({ + "comparison": { + "requested": { "branch_compare": { "base": "origin/main", "head": "feature" } }, + "requested_base_oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "requested_head_oid": "2222222222222222222222222222222222222222", + "strategy": "merge_base_to_head", + "base": { "kind": "commit", "oid": "1111111111111111111111111111111111111111" }, + "head": { "kind": "commit", "oid": "2222222222222222222222222222222222222222" }, + "merge_base_oid": "1111111111111111111111111111111111111111", + "identity": "comparison-1" + }, + "files": [{ + "old_path": null, + "new_path": "src/pending.rs", + "language": "rust", + "old_provenance": null, + "new_provenance": null, + "status": "pending", + "old_outline": [], + "new_outline": [], + "symbol_changes": [], + "hotspots": [], + "call_diff": [], + "changed_hunks": [], + "errors": [], + "truncation": { + "reason": "byte_limit", + "limit": 1_000, + "observed": 1_250 + } + }], + "coverage": { + "total_items": 1, + "analyzed_items": 0, + "pending_items": 1, + "skipped_items": 0, + "unsupported_items": 0, + "failed_items": 0 + }, + "language_coverage": [{ + "language": "rust", + "coverage": { + "total_items": 1, + "analyzed_items": 0, + "pending_items": 1, + "skipped_items": 0, + "unsupported_items": 0, + "failed_items": 0 + } + }], + "errors": [] + }) + ); + assert_eq!( + serde_json::from_value::(value).unwrap(), + review + ); + } + + #[test] + fn exact_comparison_response_has_stable_golden_json() { + let review = ReviewStructure::new( + immutable_comparison(), + Vec::new(), + coverage(0, 0), + Vec::new(), + Vec::new(), + ) + .unwrap(); + let value = serde_json::to_value(&review).unwrap(); + assert_eq!( + value, + json!({ + "comparison": { + "requested": { "branch_compare": { "base": "origin/main", "head": "feature" } }, + "requested_base_oid": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "requested_head_oid": "2222222222222222222222222222222222222222", + "strategy": "merge_base_to_head", + "base": { "kind": "commit", "oid": "1111111111111111111111111111111111111111" }, + "head": { "kind": "commit", "oid": "2222222222222222222222222222222222222222" }, + "merge_base_oid": "1111111111111111111111111111111111111111", + "identity": "comparison-1" + }, + "files": [], + "coverage": { + "total_items": 0, + "analyzed_items": 0, + "pending_items": 0, + "skipped_items": 0, + "unsupported_items": 0, + "failed_items": 0 + }, + "language_coverage": [], + "errors": [] + }) + ); + assert_eq!( + serde_json::from_value::(value.clone()).unwrap(), + review + ); + let mut legacy = value; + legacy.as_object_mut().unwrap().remove("omissions"); + assert_eq!( + serde_json::from_value::(legacy).unwrap(), + review + ); + } +} diff --git a/crates/okena-review/src/structure/mod.rs b/crates/okena-review/src/structure/mod.rs new file mode 100644 index 000000000..c4b8041ba --- /dev/null +++ b/crates/okena-review/src/structure/mod.rs @@ -0,0 +1,2813 @@ +//! Deterministic structural comparison for one exact file pair. + +use std::cmp::Ordering; +use std::collections::{BTreeSet, HashMap, HashSet}; +use std::fmt; + +use okena_core::review::{ + ComparisonSide, ReviewNavigationTarget, ReviewTruncation, TruncationReason, +}; +use okena_syntax::{ + CallFact, ControlContext, DiagnosticSeverity, DocumentStatus, DocumentStructure, SourceRange, + SymbolFact, SymbolKey, SymbolKind, SyntaxLanguage, SyntaxProvenance, SyntaxTruncation, + SyntaxTruncationReason, +}; + +use crate::call_diff::{ + CallDiffError, ComparisonStopReason, ControlledCallDiffError, IndexedCallDiffInput, + compare_indexed_calls_controlled, +}; +use crate::model::{ControlledModelError, checked_stable_sort_by}; +use crate::{ + AnalysisError, AnalysisStage, CallChangeKind, CallDiffChange, ChangedHunk, ChangedLineRange, + FileAnalysisStatus, ModelError, OutlineFact, SignatureChange, StructuralHotspot, + StructuralMetric, StructuredFile, SymbolChange, SymbolChangeKind, SymbolReference, +}; + +/// Invalid comparator input or a result rejected by the frozen review model. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StructureError { + Invalid(String), + Stopped(ComparisonStopReason), +} + +impl StructureError { + fn invalid(message: impl Into) -> Self { + Self::Invalid(message.into()) + } + + pub fn stop_reason(&self) -> Option { + match self { + Self::Stopped(reason) => Some(*reason), + Self::Invalid(_) => None, + } + } +} + +impl fmt::Display for StructureError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(message) => formatter.write_str(message), + Self::Stopped(reason) => { + write!(formatter, "structural comparison stopped: {reason:?}") + } + } + } +} + +impl std::error::Error for StructureError {} + +impl From for StructureError { + fn from(error: ModelError) -> Self { + Self::Invalid(error.to_string()) + } +} + +impl From for StructureError { + fn from(error: CallDiffError) -> Self { + Self::Invalid(error.to_string()) + } +} + +impl From for StructureError { + fn from(error: ControlledCallDiffError) -> Self { + match error { + ControlledCallDiffError::Comparison(error) => Self::Invalid(error.to_string()), + ControlledCallDiffError::Stopped(reason) => Self::Stopped(reason), + } + } +} + +impl From> for StructureError { + fn from(error: ControlledModelError) -> Self { + match error { + ControlledModelError::Invalid(error) => Self::Invalid(error.to_string()), + ControlledModelError::Stopped(reason) => Self::Stopped(reason), + } + } +} + +/// Compare syntax facts for one exact old/new path pair. +/// +/// A missing document is valid only for a missing comparison side. If a path exists but its +/// document is absent, the result is explicitly skipped. Matching uses a unique `SymbolKey` +/// occurrence and identical syntax provenance; ambiguity degrades to added/removed facts when +/// changed-hunk evidence exists. +pub fn compare_structured_file( + old_path: Option<&str>, + new_path: Option<&str>, + old_document: Option<&DocumentStructure>, + new_document: Option<&DocumentStructure>, + changed_hunks: &[ChangedHunk], +) -> Result { + compare_structured_file_controlled( + old_path, + new_path, + old_document, + new_document, + changed_hunks, + &mut || None, + ) +} + +/// Compare one exact file pair with cooperative cancellation/deadline checkpoints. +pub fn compare_structured_file_controlled( + old_path: Option<&str>, + new_path: Option<&str>, + old_document: Option<&DocumentStructure>, + new_document: Option<&DocumentStructure>, + changed_hunks: &[ChangedHunk], + checkpoint: &mut dyn FnMut() -> Option, +) -> Result { + check(checkpoint)?; + validate_inputs(old_path, new_path, old_document, new_document)?; + + if old_path.is_some() && old_document.is_none() || new_path.is_some() && new_document.is_none() + { + return unsuccessful_file( + old_path, + new_path, + FileAnalysisStatus::Skipped, + changed_hunks, + Vec::new(), + checkpoint, + ); + } + + let documents: Vec<_> = old_document.into_iter().chain(new_document).collect(); + if documents + .iter() + .any(|document| document.status() == DocumentStatus::Failed) + { + let mut errors = document_errors(&documents, true, checkpoint)?; + if errors.is_empty() { + errors.push(AnalysisError::new( + selected_path(old_path, new_path).map(str::to_owned), + AnalysisStage::Parsing, + "syntax analysis failed without a diagnostic", + )?); + } + return unsuccessful_file( + old_path, + new_path, + FileAnalysisStatus::Failed, + changed_hunks, + errors, + checkpoint, + ); + } + if documents + .iter() + .any(|document| document.status() == DocumentStatus::Unsupported) + { + let errors = document_errors(&documents, true, checkpoint)?; + return unsuccessful_file( + old_path, + new_path, + FileAnalysisStatus::Unsupported, + changed_hunks, + errors, + checkpoint, + ); + } + if documents + .iter() + .any(|document| document.status() == DocumentStatus::Skipped) + { + let errors = document_errors(&documents, true, checkpoint)?; + return unsuccessful_file( + old_path, + new_path, + FileAnalysisStatus::Skipped, + changed_hunks, + errors, + checkpoint, + ); + } + + let language = documents + .first() + .map(|document| document.provenance().language()) + .ok_or_else(|| StructureError::invalid("comparison has no analyzable document"))?; + if documents + .iter() + .any(|document| document.provenance().language() != language) + { + return unsuccessful_file( + old_path, + new_path, + FileAnalysisStatus::Failed, + changed_hunks, + vec![AnalysisError::new( + selected_path(old_path, new_path).map(str::to_owned), + AnalysisStage::Comparison, + "old and new syntax languages differ; structural facts were not matched", + )?], + checkpoint, + ); + } + + let old_outline = old_document + .map(|document| build_outline(document, ComparisonSide::Base, checkpoint)) + .transpose()? + .unwrap_or_default(); + let new_outline = new_document + .map(|document| build_outline(document, ComparisonSide::Head, checkpoint)) + .transpose()? + .unwrap_or_default(); + let symbol_changes = compare_symbols( + old_path, + new_path, + old_document + .map(DocumentStructure::symbols) + .unwrap_or_default(), + new_document + .map(DocumentStructure::symbols) + .unwrap_or_default(), + changed_hunks, + checkpoint, + )?; + let call_diff = + compare_matched_calls(old_path, new_path, old_document, new_document, checkpoint)?; + let hotspots = build_hotspots(new_path, new_document, &symbol_changes, checkpoint)?; + + let mut errors = document_errors(&documents, false, checkpoint)?; + let truncations = document_truncations(old_document, new_document); + for (side, document, truncation) in &truncations { + check(checkpoint)?; + errors.push(AnalysisError::new( + Some(document.path().to_owned()), + AnalysisStage::Budget, + format!( + "{} syntax analysis truncated: {:?}", + side_name(*side), + truncation.reason() + ), + )?); + } + let review_truncation = truncations + .first() + .map(|(side, _, truncation)| translate_truncation(*side, truncation)); + let status = if documents + .iter() + .any(|document| document.status() == DocumentStatus::Partial) + || !errors.is_empty() + || review_truncation.is_some() + { + FileAnalysisStatus::Partial + } else { + FileAnalysisStatus::Parsed + }; + + let file = StructuredFile::new_controlled( + old_path.map(str::to_owned), + new_path.map(str::to_owned), + Some(language), + old_document.map(|document| document.provenance().clone()), + new_document.map(|document| document.provenance().clone()), + status, + old_outline, + new_outline, + symbol_changes, + hotspots, + call_diff, + clone_hunks(changed_hunks, checkpoint)?, + errors, + review_truncation, + &mut || checkpoint().map_or(Ok(()), Err), + )?; + Ok(file) +} + +fn validate_inputs( + old_path: Option<&str>, + new_path: Option<&str>, + old_document: Option<&DocumentStructure>, + new_document: Option<&DocumentStructure>, +) -> Result<(), StructureError> { + if old_path.is_none() && new_path.is_none() { + return Err(StructureError::invalid( + "structural comparison requires at least one exact path", + )); + } + for (side, path, document) in [ + ("old", old_path, old_document), + ("new", new_path, new_document), + ] { + if path.is_some_and(|path| path.trim().is_empty()) { + return Err(StructureError::invalid(format!( + "{side} comparison path must not be empty" + ))); + } + if path.is_none() && document.is_some() { + return Err(StructureError::invalid(format!( + "{side} document requires an exact {side} path" + ))); + } + if let (Some(path), Some(document)) = (path, document) + && path != document.path() + { + return Err(StructureError::invalid(format!( + "{side} document path does not match the exact comparison path" + ))); + } + } + Ok(()) +} + +fn unsuccessful_file( + old_path: Option<&str>, + new_path: Option<&str>, + status: FileAnalysisStatus, + changed_hunks: &[ChangedHunk], + errors: Vec, + checkpoint: &mut dyn FnMut() -> Option, +) -> Result { + let file = StructuredFile::new_controlled( + old_path.map(str::to_owned), + new_path.map(str::to_owned), + None, + None, + None, + status, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + clone_hunks(changed_hunks, checkpoint)?, + errors, + None, + &mut || checkpoint().map_or(Ok(()), Err), + )?; + Ok(file) +} + +fn clone_hunks( + hunks: &[ChangedHunk], + checkpoint: &mut dyn FnMut() -> Option, +) -> Result, StructureError> { + let mut cloned = Vec::with_capacity(hunks.len()); + for hunk in hunks { + check(checkpoint)?; + cloned.push(hunk.clone()); + } + Ok(cloned) +} + +fn selected_path<'a>(old_path: Option<&'a str>, new_path: Option<&'a str>) -> Option<&'a str> { + new_path.or(old_path) +} + +fn document_errors( + documents: &[&DocumentStructure], + include_all: bool, + checkpoint: &mut dyn FnMut() -> Option, +) -> Result, StructureError> { + let mut errors = Vec::new(); + for document in documents { + check(checkpoint)?; + for diagnostic in document.diagnostics() { + check(checkpoint)?; + if !include_all && diagnostic.severity() == DiagnosticSeverity::Info { + continue; + } + errors.push(AnalysisError::new( + Some(document.path().to_owned()), + AnalysisStage::Parsing, + format!("{:?}: {}", diagnostic.severity(), diagnostic.message()), + )?); + } + } + Ok(errors) +} + +fn document_truncations<'a>( + old_document: Option<&'a DocumentStructure>, + new_document: Option<&'a DocumentStructure>, +) -> Vec<(ComparisonSide, &'a DocumentStructure, &'a SyntaxTruncation)> { + let mut truncations = Vec::new(); + if let Some(document) = old_document + && let Some(truncation) = document.truncation() + { + truncations.push((ComparisonSide::Base, document, truncation)); + } + if let Some(document) = new_document + && let Some(truncation) = document.truncation() + { + truncations.push((ComparisonSide::Head, document, truncation)); + } + truncations +} + +fn translate_truncation(side: ComparisonSide, truncation: &SyntaxTruncation) -> ReviewTruncation { + let reason = match truncation.reason() { + SyntaxTruncationReason::SourceBytes | SyntaxTruncationReason::CaptureBytes => { + TruncationReason::ByteLimit + } + SyntaxTruncationReason::SymbolCount + | SyntaxTruncationReason::CallCount + | SyntaxTruncationReason::DiagnosticCount => TruncationReason::CaptureLimit, + SyntaxTruncationReason::Time => TruncationReason::TimeLimit, + SyntaxTruncationReason::Cancelled => TruncationReason::Cancelled, + }; + ReviewTruncation { + reason, + limit: truncation.limit(), + observed: truncation.observed(), + detail: Some(format!("{} syntax analysis", side_name(side))), + } +} + +fn side_name(side: ComparisonSide) -> &'static str { + match side { + ComparisonSide::Base => "base", + ComparisonSide::Head => "head", + } +} + +fn build_outline( + document: &DocumentStructure, + side: ComparisonSide, + checkpoint: &mut dyn FnMut() -> Option, +) -> Result, StructureError> { + let symbols = document.symbols(); + let mut order: Vec = (0..symbols.len()).collect(); + checked_stable_sort_by( + &mut order, + |left, right, _| Ok(symbol_source_order(&symbols[*left], &symbols[*right])), + &mut || check(checkpoint), + )?; + + let mut parents = vec![None; symbols.len()]; + let mut stack = Vec::::new(); + for index in order.iter().copied() { + check(checkpoint)?; + while stack + .last() + .is_some_and(|candidate| !is_outline_parent(&symbols[*candidate], &symbols[index])) + { + check(checkpoint)?; + stack.pop(); + } + parents[index] = stack.last().copied(); + stack.push(index); + } + + let mut children = vec![Vec::new(); symbols.len()]; + let mut roots = Vec::new(); + for index in order.iter().copied() { + check(checkpoint)?; + if let Some(parent) = parents[index] { + children[parent].push(index); + } else { + roots.push(index); + } + } + let mut built = vec![None; symbols.len()]; + for index in order.iter().rev().copied() { + check(checkpoint)?; + let mut child_facts = Vec::with_capacity(children[index].len()); + for child in &children[index] { + check(checkpoint)?; + child_facts.push( + built[*child] + .take() + .ok_or_else(|| StructureError::invalid("outline child was not built"))?, + ); + } + let fact = &symbols[index]; + built[index] = Some(OutlineFact::new_controlled( + document.provenance().clone(), + SymbolReference::new(side, fact.full_range(), fact.key().clone()), + child_facts, + &mut || checkpoint().map_or(Ok(()), Err), + )?); + } + let mut outline = Vec::with_capacity(roots.len()); + for root in roots { + check(checkpoint)?; + outline.push( + built[root] + .take() + .ok_or_else(|| StructureError::invalid("outline root was not built"))?, + ); + } + Ok(outline) +} + +fn is_outline_parent(parent: &SymbolFact, child: &SymbolFact) -> bool { + let parent_path = parent.key().qualified_path(); + let child_path = child.key().qualified_path(); + child_path.len() == parent_path.len() + 1 + && child_path.starts_with(parent_path) + && child_path + .last() + .is_some_and(|name| name == parent.key().name()) + && parent.full_range() != child.full_range() + && parent.full_range().contains(child.full_range()) +} + +fn compare_symbols( + old_path: Option<&str>, + new_path: Option<&str>, + old_symbols: &[SymbolFact], + new_symbols: &[SymbolFact], + changed_hunks: &[ChangedHunk], + checkpoint: &mut dyn FnMut() -> Option, +) -> Result, StructureError> { + let old_hunks = attribute_hunks(old_symbols, changed_hunks, ComparisonSide::Base, checkpoint)?; + let new_hunks = attribute_hunks(new_symbols, changed_hunks, ComparisonSide::Head, checkpoint)?; + let mut matched_old = HashSet::new(); + let mut matched_new = HashSet::new(); + let mut changes = Vec::new(); + + for (old_index, new_index) in unique_pair_indices(old_symbols, new_symbols, checkpoint)? { + check(checkpoint)?; + let old = &old_symbols[old_index]; + let new = &new_symbols[new_index]; + match compare_unique_pair( + old_path, + new_path, + old, + new, + old_index, + new_index, + &old_hunks, + &new_hunks, + changed_hunks, + checkpoint, + )? { + UniquePairResult::Unpaired => {} + UniquePairResult::Unchanged => { + matched_old.insert(old_index); + matched_new.insert(new_index); + } + UniquePairResult::Changed(change) => { + matched_old.insert(old_index); + matched_new.insert(new_index); + changes.push(*change); + } + } + } + + for (index, fact) in old_symbols.iter().enumerate() { + check(checkpoint)?; + if matched_old.contains(&index) { + continue; + } + let hunks = hunks_from_indices(changed_hunks, &old_hunks.intersecting[index], checkpoint)?; + if hunks.is_empty() { + continue; + } + changes.push(SymbolChange::new_controlled( + SymbolChangeKind::Removed, + Some(fact.clone()), + None, + None, + false, + hunks, + navigation( + required_path(old_path, "removed symbol requires an old path")?, + ComparisonSide::Base, + fact, + ), + &mut || checkpoint().map_or(Ok(()), Err), + )?); + } + for (index, fact) in new_symbols.iter().enumerate() { + check(checkpoint)?; + if matched_new.contains(&index) { + continue; + } + let hunks = hunks_from_indices(changed_hunks, &new_hunks.intersecting[index], checkpoint)?; + if hunks.is_empty() { + continue; + } + changes.push(SymbolChange::new_controlled( + SymbolChangeKind::Added, + None, + Some(fact.clone()), + None, + false, + hunks, + navigation( + required_path(new_path, "added symbol requires a new path")?, + ComparisonSide::Head, + fact, + ), + &mut || checkpoint().map_or(Ok(()), Err), + )?); + } + checked_stable_sort_by( + &mut changes, + |left, right, _| Ok(symbol_change_order(left, right)), + &mut || check(checkpoint), + )?; + Ok(changes) +} + +fn group_by_key( + symbols: &[SymbolFact], + checkpoint: &mut dyn FnMut() -> Option, +) -> Result>, StructureError> { + let mut grouped = HashMap::>::new(); + for (index, symbol) in symbols.iter().enumerate() { + check(checkpoint)?; + grouped.entry(symbol.key().clone()).or_default().push(index); + } + Ok(grouped) +} + +fn unique_pair_indices( + old_symbols: &[SymbolFact], + new_symbols: &[SymbolFact], + checkpoint: &mut dyn FnMut() -> Option, +) -> Result, StructureError> { + let old_by_key = group_by_key(old_symbols, checkpoint)?; + let new_by_key = group_by_key(new_symbols, checkpoint)?; + let mut pairs = Vec::new(); + for (old_index, old) in old_symbols.iter().enumerate() { + check(checkpoint)?; + let Some(old_occurrences) = old_by_key.get(old.key()) else { + continue; + }; + let Some(new_occurrences) = new_by_key.get(old.key()) else { + continue; + }; + if old_occurrences.len() != 1 || new_occurrences.len() != 1 { + continue; + } + let new_index = new_occurrences[0]; + if old.provenance() == new_symbols[new_index].provenance() { + pairs.push((old_index, new_index)); + } + } + Ok(pairs) +} + +enum UniquePairResult { + Unpaired, + Unchanged, + Changed(Box), +} + +#[allow(clippy::too_many_arguments)] +fn compare_unique_pair( + old_path: Option<&str>, + new_path: Option<&str>, + old: &SymbolFact, + new: &SymbolFact, + old_index: usize, + new_index: usize, + old_hunks: &HunkAttribution, + new_hunks: &HunkAttribution, + changed_hunks: &[ChangedHunk], + checkpoint: &mut dyn FnMut() -> Option, +) -> Result { + let candidate_hunks = pair_hunks( + changed_hunks, + old_index, + new_index, + old_hunks, + new_hunks, + checkpoint, + )?; + let signature_text_changed = old.normalized_signature() != new.normalized_signature(); + let signature_has_evidence = side_has_intersection( + &candidate_hunks, + ComparisonSide::Base, + old.signature_range(), + checkpoint, + )? || side_has_intersection( + &candidate_hunks, + ComparisonSide::Head, + new.signature_range(), + checkpoint, + )?; + if signature_text_changed && !signature_has_evidence { + return Ok(UniquePairResult::Unpaired); + } + let body_changed = body_has_evidence(&candidate_hunks, old, new, checkpoint)?; + let hunks = dimension_hunks( + &candidate_hunks, + old, + new, + signature_text_changed, + body_changed, + checkpoint, + )?; + let signature_has_evidence = side_has_intersection( + &hunks, + ComparisonSide::Base, + old.signature_range(), + checkpoint, + )? || side_has_intersection( + &hunks, + ComparisonSide::Head, + new.signature_range(), + checkpoint, + )?; + if signature_text_changed && !signature_has_evidence { + return Ok(UniquePairResult::Unpaired); + } + let signature_change = signature_text_changed + .then(|| { + SignatureChange::new( + old.normalized_signature(), + new.normalized_signature(), + old.signature_range(), + new.signature_range(), + ) + }) + .transpose()?; + let body_changed = body_has_evidence(&hunks, old, new, checkpoint)?; + if signature_change.is_none() && !body_changed { + return Ok(UniquePairResult::Unchanged); + } + Ok(UniquePairResult::Changed(Box::new( + SymbolChange::new_controlled( + SymbolChangeKind::Modified, + Some(old.clone()), + Some(new.clone()), + signature_change, + body_changed, + hunks, + navigation( + required_path(new_path.or(old_path), "modified symbol requires a path")?, + if new_path.is_some() { + ComparisonSide::Head + } else { + ComparisonSide::Base + }, + if new_path.is_some() { new } else { old }, + ), + &mut || checkpoint().map_or(Ok(()), Err), + )?, + ))) +} + +fn dimension_hunks( + hunks: &[ChangedHunk], + old: &SymbolFact, + new: &SymbolFact, + signature_changed: bool, + body_changed: bool, + checkpoint: &mut dyn FnMut() -> Option, +) -> Result, StructureError> { + let mut relevant = Vec::new(); + for hunk in hunks { + check(checkpoint)?; + let old_relevant = hunk + .old() + .map(|lines| dimension_intersects(lines, old, signature_changed, body_changed)); + let new_relevant = hunk + .new_range() + .map(|lines| dimension_intersects(lines, new, signature_changed, body_changed)); + if old_relevant.is_none_or(|relevant| relevant) + && new_relevant.is_none_or(|relevant| relevant) + && (old_relevant.unwrap_or(false) || new_relevant.unwrap_or(false)) + { + relevant.push(hunk.clone()); + } + } + Ok(relevant) +} + +fn dimension_intersects( + lines: ChangedLineRange, + fact: &SymbolFact, + signature_changed: bool, + body_changed: bool, +) -> bool { + signature_changed && line_range_intersects(lines, fact.signature_range()) + || body_changed + && fact + .body_range() + .is_some_and(|body| line_range_intersects(lines, body)) +} + +fn pair_hunks( + hunks: &[ChangedHunk], + old_index: usize, + new_index: usize, + old_hunks: &HunkAttribution, + new_hunks: &HunkAttribution, + checkpoint: &mut dyn FnMut() -> Option, +) -> Result, StructureError> { + let mut candidates = BTreeSet::new(); + for &hunk_index in &old_hunks.own[old_index] { + check(checkpoint)?; + candidates.insert(hunk_index); + } + for &hunk_index in &new_hunks.own[new_index] { + check(checkpoint)?; + candidates.insert(hunk_index); + } + let mut paired = Vec::new(); + for hunk_index in candidates { + check(checkpoint)?; + let hunk = &hunks[hunk_index]; + let old_valid = hunk.old().is_none() + || old_hunks.intersecting[old_index] + .binary_search(&hunk_index) + .is_ok(); + let new_valid = hunk.new_range().is_none() + || new_hunks.intersecting[new_index] + .binary_search(&hunk_index) + .is_ok(); + let old_own = old_hunks.own[old_index].binary_search(&hunk_index).is_ok(); + let new_own = new_hunks.own[new_index].binary_search(&hunk_index).is_ok(); + if old_valid && new_valid && (old_own || new_own) { + paired.push(hunk.clone()); + } + } + Ok(paired) +} + +struct HunkAttribution { + intersecting: Vec>, + own: Vec>, +} + +fn attribute_hunks( + symbols: &[SymbolFact], + hunks: &[ChangedHunk], + side: ComparisonSide, + checkpoint: &mut dyn FnMut() -> Option, +) -> Result { + let mut symbol_order: Vec<_> = (0..symbols.len()).collect(); + checked_stable_sort_by( + &mut symbol_order, + |left, right, _| { + Ok(symbols[*left] + .full_range() + .start_line() + .cmp(&symbols[*right].full_range().start_line())) + }, + &mut || check(checkpoint), + )?; + + let mut hunk_order = Vec::new(); + for (index, hunk) in hunks.iter().enumerate() { + check(checkpoint)?; + if let Some(lines) = hunk_on_side(hunk, side) { + hunk_order.push((index, lines)); + } + } + checked_stable_sort_by( + &mut hunk_order, + |(_, left), (_, right), _| Ok((left.start(), left.end()).cmp(&(right.start(), right.end()))), + &mut || check(checkpoint), + )?; + + let mut attribution = HunkAttribution { + intersecting: vec![Vec::new(); symbols.len()], + own: vec![Vec::new(); symbols.len()], + }; + let mut next_symbol = 0; + let mut active = Vec::::new(); + for (hunk_index, lines) in hunk_order { + check(checkpoint)?; + while next_symbol < symbol_order.len() + && symbols[symbol_order[next_symbol]] + .full_range() + .start_line() + .get() + <= lines.end().get() + { + check(checkpoint)?; + active.push(symbol_order[next_symbol]); + next_symbol += 1; + } + let mut retained = Vec::with_capacity(active.len()); + for symbol_index in active.drain(..) { + check(checkpoint)?; + if symbols[symbol_index].full_range().end_line().get() >= lines.start().get() { + retained.push(symbol_index); + } + } + active = retained; + for &symbol_index in &active { + check(checkpoint)?; + let fact = &symbols[symbol_index]; + if !line_range_intersects(lines, fact.full_range()) { + continue; + } + attribution.intersecting[symbol_index].push(hunk_index); + let clipped_start = lines + .start() + .get() + .max(fact.full_range().start_line().get()); + let clipped_end = lines.end().get().min(fact.full_range().end_line().get()); + let mut belongs_to_descendant = false; + for &candidate_index in &active { + check(checkpoint)?; + let candidate = &symbols[candidate_index]; + if is_descendant(fact, candidate) + && candidate.full_range().start_line().get() <= clipped_start + && clipped_end <= candidate.full_range().end_line().get() + { + belongs_to_descendant = true; + break; + } + } + if !belongs_to_descendant { + attribution.own[symbol_index].push(hunk_index); + } + } + } + for indexes in attribution + .intersecting + .iter_mut() + .chain(attribution.own.iter_mut()) + { + checked_stable_sort_by(indexes, |left, right, _| Ok(left.cmp(right)), &mut || { + check(checkpoint) + })?; + } + Ok(attribution) +} + +fn is_descendant(parent: &SymbolFact, candidate: &SymbolFact) -> bool { + let parent_path = parent.key().qualified_path(); + let candidate_path = candidate.key().qualified_path(); + candidate_path.len() > parent_path.len() + && candidate_path.starts_with(parent_path) + && candidate_path[parent_path.len()] == parent.key().name() + && parent.full_range() != candidate.full_range() + && parent.full_range().contains(candidate.full_range()) +} + +fn hunks_from_indices( + hunks: &[ChangedHunk], + indices: &[usize], + checkpoint: &mut dyn FnMut() -> Option, +) -> Result, StructureError> { + let mut matching = Vec::with_capacity(indices.len()); + for &index in indices { + check(checkpoint)?; + matching.push(hunks[index].clone()); + } + Ok(matching) +} + +fn body_has_evidence( + hunks: &[ChangedHunk], + old: &SymbolFact, + new: &SymbolFact, + checkpoint: &mut dyn FnMut() -> Option, +) -> Result { + if let Some(range) = old.body_range() + && side_has_intersection(hunks, ComparisonSide::Base, range, checkpoint)? + { + return Ok(true); + } + if let Some(range) = new.body_range() + && side_has_intersection(hunks, ComparisonSide::Head, range, checkpoint)? + { + return Ok(true); + } + Ok(false) +} + +fn side_has_intersection( + hunks: &[ChangedHunk], + side: ComparisonSide, + range: SourceRange, + checkpoint: &mut dyn FnMut() -> Option, +) -> Result { + for hunk in hunks { + check(checkpoint)?; + if hunk_on_side(hunk, side).is_some_and(|lines| line_range_intersects(lines, range)) { + return Ok(true); + } + } + Ok(false) +} + +fn hunk_on_side(hunk: &ChangedHunk, side: ComparisonSide) -> Option { + match side { + ComparisonSide::Base => hunk.old(), + ComparisonSide::Head => hunk.new_range(), + } +} + +fn line_range_intersects(lines: ChangedLineRange, source: SourceRange) -> bool { + lines.start().get() <= source.end_line().get() && source.start_line().get() <= lines.end().get() +} + +fn compare_matched_calls( + old_path: Option<&str>, + new_path: Option<&str>, + old_document: Option<&DocumentStructure>, + new_document: Option<&DocumentStructure>, + checkpoint: &mut dyn FnMut() -> Option, +) -> Result, StructureError> { + let (Some(old_path), Some(new_path), Some(old_document), Some(new_document)) = + (old_path, new_path, old_document, new_document) + else { + return Ok(Vec::new()); + }; + let old_index = index_calls(old_document.calls(), checkpoint)?; + let new_index = index_calls(new_document.calls(), checkpoint)?; + let mut changes = Vec::new(); + for (old_symbol_index, new_symbol_index) in + unique_pair_indices(old_document.symbols(), new_document.symbols(), checkpoint)? + { + check(checkpoint)?; + let old = &old_document.symbols()[old_symbol_index]; + let new = &new_document.symbols()[new_symbol_index]; + if !is_function(old.key().kind()) || !is_function(new.key().kind()) { + continue; + } + let old_calls = old_index + .get(&(old.key(), old.provenance())) + .map(Vec::as_slice) + .unwrap_or_default(); + let new_calls = new_index + .get(&(new.key(), new.provenance())) + .map(Vec::as_slice) + .unwrap_or_default(); + changes.extend(compare_indexed_calls_controlled( + IndexedCallDiffInput::new( + old_path, + new_path, + old.key(), + old.body_range().unwrap_or_else(|| old.full_range()), + new.body_range().unwrap_or_else(|| new.full_range()), + old_calls, + new_calls, + )?, + checkpoint, + )?); + } + for change in &changes { + check(checkpoint)?; + if let Some(fact) = call_change_fact(change) { + for _ in fact.control_context() { + check(checkpoint)?; + } + } + } + checked_stable_sort_by(&mut changes, compare_call_changes_controlled, &mut || { + check(checkpoint) + })?; + Ok(changes) +} + +type CallIndex<'a> = HashMap<(&'a SymbolKey, &'a SyntaxProvenance), Vec<&'a CallFact>>; + +fn index_calls<'a>( + calls: &'a [CallFact], + checkpoint: &mut dyn FnMut() -> Option, +) -> Result, StructureError> { + let mut index = HashMap::new(); + for call in calls { + check(checkpoint)?; + let Some(enclosing) = call.enclosing_symbol() else { + continue; + }; + index + .entry((enclosing, call.provenance())) + .or_insert_with(Vec::new) + .push(call); + } + Ok(index) +} + +fn compare_call_changes_controlled( + left: &CallDiffChange, + right: &CallDiffChange, + checkpoint: &mut dyn FnMut() -> Result<(), E>, +) -> Result { + let ordering = left + .navigation() + .line + .cmp(&right.navigation().line) + .then_with(|| { + left.navigation() + .byte_offset + .cmp(&right.navigation().byte_offset) + }) + .then_with(|| call_change_rank(left.kind()).cmp(&call_change_rank(right.kind()))) + .then_with(|| compare_call_enclosing(left, right)) + .then_with(|| call_callee(left).cmp(call_callee(right))) + .then_with(|| left.navigation().path.cmp(&right.navigation().path)) + .then_with(|| side_rank(left.navigation().side).cmp(&side_rank(right.navigation().side))); + if ordering != Ordering::Equal { + return Ok(ordering); + } + match (call_change_fact(left), call_change_fact(right)) { + (Some(left), Some(right)) => compare_call_facts_controlled(left, right, checkpoint), + (None, Some(_)) => Ok(Ordering::Less), + (Some(_), None) => Ok(Ordering::Greater), + (None, None) => Ok(Ordering::Equal), + } +} + +fn call_change_rank(kind: CallChangeKind) -> u8 { + match kind { + CallChangeKind::Removed => 0, + CallChangeKind::Modified => 1, + CallChangeKind::Added => 2, + } +} + +fn call_change_fact(change: &CallDiffChange) -> Option<&CallFact> { + change.new_fact().or_else(|| change.old()) +} + +fn call_callee(change: &CallDiffChange) -> &str { + call_change_fact(change) + .map(CallFact::callee_text) + .unwrap_or_default() +} + +fn compare_call_enclosing(left: &CallDiffChange, right: &CallDiffChange) -> Ordering { + let left = call_change_fact(left).and_then(CallFact::enclosing_symbol); + let right = call_change_fact(right).and_then(CallFact::enclosing_symbol); + match (left, right) { + (Some(left), Some(right)) => left + .qualified_path() + .cmp(right.qualified_path()) + .then_with(|| left.name().cmp(right.name())) + .then_with(|| symbol_kind_rank(left.kind()).cmp(&symbol_kind_rank(right.kind()))), + (None, Some(_)) => Ordering::Less, + (Some(_), None) => Ordering::Greater, + (None, None) => Ordering::Equal, + } +} + +fn compare_call_facts_controlled( + left: &CallFact, + right: &CallFact, + checkpoint: &mut dyn FnMut() -> Result<(), E>, +) -> Result { + let ordering = left + .call_site_range() + .start_byte() + .cmp(&right.call_site_range().start_byte()) + .then_with(|| { + left.call_site_range() + .end_byte() + .cmp(&right.call_site_range().end_byte()) + }) + .then_with(|| left.argument_text().cmp(right.argument_text())); + if ordering != Ordering::Equal { + return Ok(ordering); + } + let ordering = compare_control_contexts_controlled( + left.control_context(), + right.control_context(), + checkpoint, + )?; + if ordering != Ordering::Equal { + return Ok(ordering); + } + Ok(syntax_language_rank(left.provenance().language()) + .cmp(&syntax_language_rank(right.provenance().language())) + .then_with(|| left.provenance().parser().cmp(right.provenance().parser()))) +} + +fn compare_control_contexts_controlled( + left: &[ControlContext], + right: &[ControlContext], + checkpoint: &mut dyn FnMut() -> Result<(), E>, +) -> Result { + for (left, right) in left.iter().zip(right) { + checkpoint()?; + let ordering = control_context_rank(left) + .cmp(&control_context_rank(right)) + .then_with(|| match (left, right) { + (ControlContext::Other(left), ControlContext::Other(right)) => left.cmp(right), + _ => Ordering::Equal, + }); + if ordering != Ordering::Equal { + return Ok(ordering); + } + } + Ok(left.len().cmp(&right.len())) +} + +fn control_context_rank(context: &ControlContext) -> u8 { + match context { + ControlContext::Condition => 0, + ControlContext::Loop => 1, + ControlContext::MatchArm => 2, + ControlContext::ErrorBranch => 3, + ControlContext::Callback => 4, + ControlContext::Closure => 5, + ControlContext::Other(_) => 6, + } +} + +fn syntax_language_rank(language: SyntaxLanguage) -> u8 { + match language { + SyntaxLanguage::Rust => 0, + SyntaxLanguage::TypeScript => 1, + SyntaxLanguage::Tsx => 2, + } +} + +fn build_hotspots( + new_path: Option<&str>, + new_document: Option<&DocumentStructure>, + changes: &[SymbolChange], + checkpoint: &mut dyn FnMut() -> Option, +) -> Result, StructureError> { + let mut candidates = Vec::new(); + for change in changes { + check(checkpoint)?; + let fact = change.new_fact().or_else(|| change.old()); + let Some(fact) = fact.filter(|fact| is_function(fact.key().kind())) else { + continue; + }; + let side = if change.new_fact().is_some() { + ComparisonSide::Head + } else { + ComparisonSide::Base + }; + let path = match side { + ComparisonSide::Head => new_path, + ComparisonSide::Base => Some(change.navigation().path.as_str()), + } + .ok_or_else(|| StructureError::invalid("changed-function hotspot requires its path"))?; + candidates.push(HotspotCandidate::new( + fact, + side, + path, + StructuralMetric::ChangedLines { + old: change.changed_old_lines(), + new: change.changed_new_lines(), + }, + 0, + u64::from(change.changed_old_lines()) + u64::from(change.changed_new_lines()), + )?); + } + if let (Some(path), Some(document)) = (new_path, new_document) { + for fact in document.symbols() { + check(checkpoint)?; + if is_function(fact.key().kind()) { + candidates.push(HotspotCandidate::new( + fact, + ComparisonSide::Head, + path, + StructuralMetric::FunctionLineCount { + lines: fact.full_range().line_count(), + }, + 1, + u64::from(fact.full_range().line_count()), + )?); + candidates.push(HotspotCandidate::new( + fact, + ComparisonSide::Head, + path, + StructuralMetric::ParameterCount { + parameters: fact.parameter_count(), + }, + 2, + u64::from(fact.parameter_count()), + )?); + candidates.push(HotspotCandidate::new( + fact, + ComparisonSide::Head, + path, + StructuralMetric::SyntacticNestingDepth { + depth: fact.syntactic_nesting_depth(), + }, + 3, + u64::from(fact.syntactic_nesting_depth()), + )?); + } + if is_type(fact.key().kind()) { + candidates.push(HotspotCandidate::new( + fact, + ComparisonSide::Head, + path, + StructuralMetric::TypeMemberCount { + members: fact.type_member_count(), + }, + 4, + u64::from(fact.type_member_count()), + )?); + } + } + } + checked_stable_sort_by( + &mut candidates, + |left, right, _| Ok(HotspotCandidate::compare(left, right)), + &mut || check(checkpoint), + )?; + let mut hotspots = Vec::with_capacity(candidates.len()); + for candidate in candidates { + check(checkpoint)?; + hotspots.push(candidate.hotspot); + } + Ok(hotspots) +} + +struct HotspotCandidate { + hotspot: StructuralHotspot, + metric_rank: u8, + value: u64, + qualified_name: String, + kind_rank: u8, + start_byte: u64, + side_rank: u8, +} + +impl HotspotCandidate { + fn new( + fact: &SymbolFact, + side: ComparisonSide, + path: &str, + metric: StructuralMetric, + metric_rank: u8, + value: u64, + ) -> Result { + Ok(Self { + hotspot: StructuralHotspot::new( + SymbolReference::new(side, fact.full_range(), fact.key().clone()), + metric, + fact.provenance().clone(), + navigation(path, side, fact), + )?, + metric_rank, + value, + qualified_name: fact.key().qualified_name(), + kind_rank: symbol_kind_rank(fact.key().kind()), + start_byte: fact.full_range().start_byte(), + side_rank: side_rank(side), + }) + } + + fn compare(left: &Self, right: &Self) -> Ordering { + left.metric_rank + .cmp(&right.metric_rank) + .then_with(|| right.value.cmp(&left.value)) + .then_with(|| left.qualified_name.cmp(&right.qualified_name)) + .then_with(|| left.kind_rank.cmp(&right.kind_rank)) + .then_with(|| left.start_byte.cmp(&right.start_byte)) + .then_with(|| left.side_rank.cmp(&right.side_rank)) + } +} + +fn check( + checkpoint: &mut dyn FnMut() -> Option, +) -> Result<(), StructureError> { + match checkpoint() { + Some(reason) => Err(StructureError::Stopped(reason)), + None => Ok(()), + } +} + +fn navigation(path: &str, side: ComparisonSide, fact: &SymbolFact) -> ReviewNavigationTarget { + ReviewNavigationTarget { + path: path.to_owned(), + side, + line: fact.full_range().start_line(), + byte_offset: Some(fact.full_range().start_byte()), + symbol_context: None, + } +} + +fn required_path<'a>(path: Option<&'a str>, message: &str) -> Result<&'a str, StructureError> { + path.ok_or_else(|| StructureError::invalid(message)) +} + +fn symbol_change_order(left: &SymbolChange, right: &SymbolChange) -> Ordering { + left.navigation() + .line + .cmp(&right.navigation().line) + .then_with(|| side_rank(left.navigation().side).cmp(&side_rank(right.navigation().side))) + .then_with(|| { + optional_change_fact(left) + .map(|fact| fact.key().qualified_name()) + .cmp(&optional_change_fact(right).map(|fact| fact.key().qualified_name())) + }) + .then_with(|| { + optional_change_fact(left) + .map(|fact| symbol_kind_rank(fact.key().kind())) + .cmp(&optional_change_fact(right).map(|fact| symbol_kind_rank(fact.key().kind()))) + }) +} + +fn optional_change_fact(change: &SymbolChange) -> Option<&SymbolFact> { + change.new_fact().or_else(|| change.old()) +} + +fn symbol_source_order(left: &SymbolFact, right: &SymbolFact) -> Ordering { + left.full_range() + .start_byte() + .cmp(&right.full_range().start_byte()) + .then_with(|| { + right + .full_range() + .end_byte() + .cmp(&left.full_range().end_byte()) + }) + .then_with(|| { + left.key() + .qualified_name() + .cmp(&right.key().qualified_name()) + }) + .then_with(|| { + symbol_kind_rank(left.key().kind()).cmp(&symbol_kind_rank(right.key().kind())) + }) +} + +fn side_rank(side: ComparisonSide) -> u8 { + match side { + ComparisonSide::Base => 0, + ComparisonSide::Head => 1, + } +} + +fn is_function(kind: SymbolKind) -> bool { + matches!(kind, SymbolKind::Function | SymbolKind::Method) +} + +fn is_type(kind: SymbolKind) -> bool { + matches!( + kind, + SymbolKind::Struct + | SymbolKind::Enum + | SymbolKind::Union + | SymbolKind::Trait + | SymbolKind::Impl + | SymbolKind::Class + | SymbolKind::Interface + ) +} + +fn symbol_kind_rank(kind: SymbolKind) -> u8 { + match kind { + SymbolKind::Module => 0, + SymbolKind::Function => 1, + SymbolKind::Method => 2, + SymbolKind::Struct => 3, + SymbolKind::Enum => 4, + SymbolKind::Union => 5, + SymbolKind::Trait => 6, + SymbolKind::Impl => 7, + SymbolKind::Class => 8, + SymbolKind::Interface => 9, + SymbolKind::TypeAlias => 10, + SymbolKind::Constant => 11, + SymbolKind::Static => 12, + SymbolKind::Field => 13, + SymbolKind::Variant => 14, + SymbolKind::Macro => 15, + } +} + +#[cfg(test)] +mod tests { + use std::num::{NonZeroU32, NonZeroU64}; + + use okena_syntax::{ + DocumentStatus, SymbolVisibility, SyntaxDiagnostic, SyntaxLanguage, SyntaxProvenance, + }; + + use super::*; + + fn range(start_byte: u64, end_byte: u64, start_line: u32, end_line: u32) -> SourceRange { + SourceRange::new( + start_byte, + end_byte, + NonZeroU32::new(start_line).unwrap(), + NonZeroU32::new(end_line).unwrap(), + ) + .unwrap() + } + + fn lines(start: u32, end: u32) -> ChangedLineRange { + ChangedLineRange::new( + NonZeroU32::new(start).unwrap(), + NonZeroU32::new(end).unwrap(), + ) + .unwrap() + } + + fn hunk(old: Option<(u32, u32)>, new: Option<(u32, u32)>) -> ChangedHunk { + ChangedHunk::new( + old.map(|(start, end)| lines(start, end)), + new.map(|(start, end)| lines(start, end)), + ) + .unwrap() + } + + fn provenance(language: SyntaxLanguage, parser: &str) -> SyntaxProvenance { + SyntaxProvenance::tree_sitter(language, parser).unwrap() + } + + #[allow(clippy::too_many_arguments)] + fn fact( + provenance: &SyntaxProvenance, + parent: &[&str], + kind: SymbolKind, + name: &str, + full: SourceRange, + signature: SourceRange, + body: Option, + signature_text: &str, + parameters: u32, + nesting: u32, + members: u32, + ) -> SymbolFact { + SymbolFact::new( + provenance.clone(), + SymbolKey::new( + parent.iter().map(|part| (*part).to_owned()).collect(), + kind, + name, + ) + .unwrap(), + SymbolVisibility::Private, + full, + signature, + body, + signature_text, + parameters, + nesting, + members, + ) + .unwrap() + } + + fn document( + path: &str, + provenance: &SyntaxProvenance, + status: DocumentStatus, + symbols: Vec, + diagnostics: Vec, + truncation: Option, + ) -> DocumentStructure { + document_with_calls( + path, + provenance, + status, + symbols, + Vec::new(), + diagnostics, + truncation, + ) + } + + #[allow(clippy::too_many_arguments)] + fn document_with_calls( + path: &str, + provenance: &SyntaxProvenance, + status: DocumentStatus, + symbols: Vec, + calls: Vec, + diagnostics: Vec, + truncation: Option, + ) -> DocumentStructure { + DocumentStructure::new( + path, + provenance.clone(), + status, + symbols, + calls, + diagnostics, + truncation, + ) + .unwrap() + } + + fn function( + provenance: &SyntaxProvenance, + parent: &[&str], + name: &str, + start_line: u32, + signature_text: &str, + ) -> SymbolFact { + let start = u64::from(start_line) * 100; + fact( + provenance, + parent, + SymbolKind::Function, + name, + range(start, start + 90, start_line, start_line + 4), + range(start, start + 20, start_line, start_line), + Some(range( + start + 21, + start + 90, + start_line + 1, + start_line + 4, + )), + signature_text, + 1, + 2, + 0, + ) + } + + fn parsed_with_calls( + path: &str, + provenance: &SyntaxProvenance, + symbols: Vec, + calls: Vec, + ) -> DocumentStructure { + document_with_calls( + path, + provenance, + DocumentStatus::Parsed, + symbols, + calls, + Vec::new(), + None, + ) + } + + fn direct_call( + provenance: &SyntaxProvenance, + enclosing: &SymbolFact, + callee: &str, + arguments: &str, + start_byte: u64, + line: u32, + contexts: Vec, + ) -> CallFact { + let call_range = range(start_byte, start_byte + 12, line, line); + CallFact::new( + provenance.clone(), + callee, + arguments, + range(start_byte + 4, start_byte + 10, line, line), + call_range, + Some(enclosing.key().clone()), + contexts, + ) + .unwrap() + } + + fn parsed( + path: &str, + provenance: &SyntaxProvenance, + symbols: Vec, + ) -> DocumentStructure { + document( + path, + provenance, + DocumentStatus::Parsed, + symbols, + Vec::new(), + None, + ) + } + + fn find_change<'a>(file: &'a StructuredFile, name: &str) -> &'a SymbolChange { + file.symbol_changes() + .iter() + .find(|change| { + optional_change_fact(change).is_some_and(|fact| fact.key().name() == name) + }) + .unwrap() + } + + #[test] + fn compares_added_removed_body_signature_and_combined_changes() { + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let old_symbols = vec![ + function(&rust, &[], "removed", 1, "fn removed()"), + function(&rust, &[], "body", 10, "fn body()"), + function(&rust, &[], "signature", 20, "fn signature(value: u8)"), + function(&rust, &[], "combined", 30, "fn combined(value: u8)"), + ]; + let new_symbols = vec![ + function(&rust, &[], "added", 1, "fn added()"), + function(&rust, &[], "body", 10, "fn body()"), + function(&rust, &[], "signature", 20, "fn signature(value: u16)"), + function(&rust, &[], "combined", 30, "fn combined(value: u16)"), + ]; + let hunks = vec![ + hunk(Some((1, 1)), Some((1, 1))), + hunk(Some((12, 12)), Some((12, 12))), + hunk(Some((20, 20)), Some((20, 20))), + hunk(Some((30, 30)), Some((30, 30))), + hunk(Some((32, 32)), Some((32, 32))), + ]; + let file = compare_structured_file( + Some("old.rs"), + Some("new.rs"), + Some(&parsed("old.rs", &rust, old_symbols)), + Some(&parsed("new.rs", &rust, new_symbols)), + &hunks, + ) + .unwrap(); + + assert_eq!( + find_change(&file, "removed").kind(), + SymbolChangeKind::Removed + ); + assert_eq!(find_change(&file, "added").kind(), SymbolChangeKind::Added); + let body = find_change(&file, "body"); + assert!(body.body_changed()); + assert!(body.signature_change().is_none()); + let signature = find_change(&file, "signature"); + assert!(!signature.body_changed()); + assert!(signature.signature_change().is_some()); + let combined = find_change(&file, "combined"); + assert!(combined.body_changed()); + assert!(combined.signature_change().is_some()); + } + + #[test] + fn compares_one_sided_body_insertions_and_deletions_as_modified() { + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let old = parsed( + "src/lib.rs", + &rust, + vec![function(&rust, &[], "work", 1, "fn work()")], + ); + let new = old.clone(); + + let insertion = compare_structured_file( + Some("src/lib.rs"), + Some("src/lib.rs"), + Some(&old), + Some(&new), + &[hunk(None, Some((3, 3)))], + ) + .unwrap(); + let change = &insertion.symbol_changes()[0]; + assert_eq!(change.kind(), SymbolChangeKind::Modified); + assert!(change.body_changed()); + assert_eq!(change.changed_old_lines(), 0); + assert_eq!(change.changed_new_lines(), 1); + + let deletion = compare_structured_file( + Some("src/lib.rs"), + Some("src/lib.rs"), + Some(&old), + Some(&new), + &[hunk(Some((4, 4)), None)], + ) + .unwrap(); + let change = &deletion.symbol_changes()[0]; + assert_eq!(change.kind(), SymbolChangeKind::Modified); + assert!(change.body_changed()); + assert_eq!(change.changed_old_lines(), 1); + assert_eq!(change.changed_new_lines(), 0); + } + + #[test] + fn compares_multiline_signature_insertions_and_deletions_as_modified() { + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let compact = fact( + &rust, + &[], + SymbolKind::Function, + "work", + range(0, 100, 1, 6), + range(0, 20, 1, 1), + Some(range(21, 100, 2, 6)), + "fn work()", + 0, + 1, + 0, + ); + let multiline = fact( + &rust, + &[], + SymbolKind::Function, + "work", + range(0, 130, 1, 8), + range(0, 50, 1, 3), + Some(range(51, 130, 4, 8)), + "fn work(value: u32)", + 1, + 1, + 0, + ); + let compact_document = parsed("src/lib.rs", &rust, vec![compact]); + let multiline_document = parsed("src/lib.rs", &rust, vec![multiline]); + + let insertion = compare_structured_file( + Some("src/lib.rs"), + Some("src/lib.rs"), + Some(&compact_document), + Some(&multiline_document), + &[hunk(None, Some((2, 2)))], + ) + .unwrap(); + let change = &insertion.symbol_changes()[0]; + assert!(change.signature_change().is_some()); + assert!(!change.body_changed()); + assert_eq!(change.changed_old_lines(), 0); + assert_eq!(change.changed_new_lines(), 1); + + let deletion = compare_structured_file( + Some("src/lib.rs"), + Some("src/lib.rs"), + Some(&multiline_document), + Some(&compact_document), + &[hunk(Some((3, 3)), None)], + ) + .unwrap(); + let change = &deletion.symbol_changes()[0]; + assert!(change.signature_change().is_some()); + assert!(!change.body_changed()); + assert_eq!(change.changed_old_lines(), 1); + assert_eq!(change.changed_new_lines(), 0); + } + + #[test] + fn duplicate_keys_degrade_to_added_and_removed() { + let ts = provenance(SyntaxLanguage::TypeScript, "ts-test"); + let old = parsed( + "old.ts", + &ts, + vec![ + function( + &ts, + &[], + "overload", + 1, + "function overload(x: string): void", + ), + function( + &ts, + &[], + "overload", + 10, + "function overload(x: number): void", + ), + ], + ); + let new = parsed( + "new.ts", + &ts, + vec![ + function( + &ts, + &[], + "overload", + 1, + "function overload(x: string): void", + ), + function( + &ts, + &[], + "overload", + 10, + "function overload(x: number): void", + ), + ], + ); + let file = compare_structured_file( + Some("old.ts"), + Some("new.ts"), + Some(&old), + Some(&new), + &[ + hunk(Some((1, 1)), Some((1, 1))), + hunk(Some((10, 10)), Some((10, 10))), + ], + ) + .unwrap(); + assert_eq!(file.symbol_changes().len(), 4); + assert_eq!( + file.symbol_changes() + .iter() + .filter(|change| change.kind() == SymbolChangeKind::Added) + .count(), + 2 + ); + assert_eq!( + file.symbol_changes() + .iter() + .filter(|change| change.kind() == SymbolChangeKind::Removed) + .count(), + 2 + ); + } + + #[test] + fn preserves_unchanged_parent_in_both_outlines() { + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let parent = |body_end| { + fact( + &rust, + &[], + SymbolKind::Module, + "outer", + range(0, body_end, 1, 20), + range(0, 10, 1, 1), + Some(range(11, body_end, 2, 20)), + "mod outer", + 0, + 0, + 0, + ) + }; + let old = parsed( + "src/lib.rs", + &rust, + vec![ + parent(2_000), + function(&rust, &["outer"], "child", 5, "fn child()"), + ], + ); + let new = parsed( + "src/lib.rs", + &rust, + vec![ + parent(2_000), + function(&rust, &["outer"], "child", 5, "fn child()"), + ], + ); + let file = compare_structured_file( + Some("src/lib.rs"), + Some("src/lib.rs"), + Some(&old), + Some(&new), + &[hunk(Some((7, 7)), Some((7, 7)))], + ) + .unwrap(); + assert_eq!(file.symbol_changes().len(), 1); + assert_eq!(file.old_outline()[0].symbol().key().name(), "outer"); + assert_eq!( + file.old_outline()[0].children()[0].symbol().key().name(), + "child" + ); + assert_eq!( + file.new_outline()[0].children()[0].symbol().key().name(), + "child" + ); + } + + #[test] + fn navigation_uses_new_rename_path_and_old_deleted_path() { + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let old = parsed( + "old.rs", + &rust, + vec![function(&rust, &[], "work", 2, "fn work()")], + ); + let new = parsed( + "new.rs", + &rust, + vec![function(&rust, &[], "work", 2, "fn work()")], + ); + let renamed = compare_structured_file( + Some("old.rs"), + Some("new.rs"), + Some(&old), + Some(&new), + &[hunk(Some((4, 4)), Some((4, 4)))], + ) + .unwrap(); + assert_eq!(renamed.symbol_changes()[0].navigation().path, "new.rs"); + assert_eq!( + renamed.symbol_changes()[0].navigation().side, + ComparisonSide::Head + ); + + let deleted = compare_structured_file( + Some("old.rs"), + None, + Some(&old), + None, + &[hunk(Some((2, 6)), None)], + ) + .unwrap(); + assert_eq!(deleted.symbol_changes()[0].navigation().path, "old.rs"); + assert_eq!( + deleted.symbol_changes()[0].navigation().side, + ComparisonSide::Base + ); + } + + #[test] + fn unicode_offsets_and_inclusive_hunk_boundaries_are_preserved() { + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let source = "// žluť\nfn převeď() {\n work();\n}\n"; + let start = u64::try_from(source.find("fn převeď").unwrap()).unwrap(); + let body_start = u64::try_from(source.find('{').unwrap()).unwrap(); + let end = u64::try_from(source.len()).unwrap(); + let unicode = fact( + &rust, + &[], + SymbolKind::Function, + "převeď", + range(start, end, 2, 4), + range(start, body_start, 2, 2), + Some(range(body_start, end, 2, 4)), + "fn převeď()", + 0, + 1, + 0, + ); + unicode.full_range().validate_source(source).unwrap(); + unicode.signature_range().validate_source(source).unwrap(); + unicode + .body_range() + .unwrap() + .validate_source(source) + .unwrap(); + let old = parsed("unicode.rs", &rust, vec![unicode.clone()]); + let new = parsed("unicode.rs", &rust, vec![unicode]); + let file = compare_structured_file( + Some("unicode.rs"), + Some("unicode.rs"), + Some(&old), + Some(&new), + &[hunk(Some((4, 4)), Some((4, 4)))], + ) + .unwrap(); + let change = &file.symbol_changes()[0]; + assert_eq!(change.navigation().line.get(), 2); + assert_eq!(change.navigation().byte_offset, Some(start)); + assert_eq!(change.changed_old_lines(), 1); + assert_eq!(change.changed_new_lines(), 1); + } + + #[test] + fn hotspot_ties_have_stable_name_order_and_named_metrics() { + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let old = parsed( + "src/lib.rs", + &rust, + vec![ + function(&rust, &[], "beta", 1, "fn beta()"), + function(&rust, &[], "alpha", 10, "fn alpha()"), + fact( + &rust, + &[], + SymbolKind::Struct, + "Container", + range(2_000, 2_090, 20, 24), + range(2_000, 2_020, 20, 20), + Some(range(2_021, 2_090, 21, 24)), + "struct Container", + 0, + 0, + 3, + ), + ], + ); + let new = old.clone(); + let file = compare_structured_file( + Some("src/lib.rs"), + Some("src/lib.rs"), + Some(&old), + Some(&new), + &[ + hunk(Some((3, 3)), Some((3, 3))), + hunk(Some((12, 12)), Some((12, 12))), + ], + ) + .unwrap(); + let changed: Vec<_> = file + .hotspots() + .iter() + .filter(|hotspot| matches!(hotspot.metric(), StructuralMetric::ChangedLines { .. })) + .collect(); + assert_eq!(changed[0].symbol().key().name(), "alpha"); + assert_eq!(changed[1].symbol().key().name(), "beta"); + let largest: Vec<_> = file + .hotspots() + .iter() + .filter(|hotspot| { + matches!(hotspot.metric(), StructuralMetric::FunctionLineCount { .. }) + }) + .collect(); + assert_eq!(largest[0].symbol().key().name(), "alpha"); + assert!(file.hotspots().iter().any(|hotspot| { + matches!( + hotspot.metric(), + StructuralMetric::ParameterCount { parameters: 1 } + ) + })); + assert!(file.hotspots().iter().any(|hotspot| { + matches!( + hotspot.metric(), + StructuralMetric::SyntacticNestingDepth { depth: 2 } + ) + })); + assert!(file.hotspots().iter().any(|hotspot| { + matches!( + hotspot.metric(), + StructuralMetric::TypeMemberCount { members: 3 } + ) + })); + } + + #[test] + fn translates_unsupported_partial_failed_truncated_and_skipped_statuses() { + let ts = provenance(SyntaxLanguage::TypeScript, "ts-test"); + let unsupported = document( + "file.ts", + &ts, + DocumentStatus::Unsupported, + Vec::new(), + Vec::new(), + None, + ); + let file = + compare_structured_file(None, Some("file.ts"), None, Some(&unsupported), &[]).unwrap(); + assert_eq!(file.status(), FileAnalysisStatus::Unsupported); + assert!(file.new_outline().is_empty()); + + let warning = + SyntaxDiagnostic::new(DiagnosticSeverity::Warning, "recovered", None).unwrap(); + let partial = document( + "file.ts", + &ts, + DocumentStatus::Partial, + Vec::new(), + vec![warning], + None, + ); + let file = + compare_structured_file(None, Some("file.ts"), None, Some(&partial), &[]).unwrap(); + assert_eq!(file.status(), FileAnalysisStatus::Partial); + assert_eq!(file.errors().len(), 1); + + let failure = SyntaxDiagnostic::new(DiagnosticSeverity::Error, "failed", None).unwrap(); + let failed = document( + "file.ts", + &ts, + DocumentStatus::Failed, + Vec::new(), + vec![failure], + None, + ); + let file = + compare_structured_file(None, Some("file.ts"), None, Some(&failed), &[]).unwrap(); + assert_eq!(file.status(), FileAnalysisStatus::Failed); + assert!(file.symbol_changes().is_empty()); + + let truncation = + SyntaxTruncation::new(SyntaxTruncationReason::SymbolCount, Some(10), Some(11)).unwrap(); + let truncated = document( + "file.ts", + &ts, + DocumentStatus::Partial, + vec![function(&ts, &[], "available", 1, "function available()")], + Vec::new(), + Some(truncation), + ); + let file = + compare_structured_file(None, Some("file.ts"), None, Some(&truncated), &[]).unwrap(); + assert_eq!(file.status(), FileAnalysisStatus::Partial); + assert_eq!( + file.truncation().unwrap().reason, + TruncationReason::CaptureLimit + ); + assert_eq!(file.new_outline().len(), 1); + + let file = compare_structured_file(None, Some("file.ts"), None, None, &[]).unwrap(); + assert_eq!(file.status(), FileAnalysisStatus::Skipped); + } + + #[test] + fn unchanged_unique_symbol_is_not_reported_as_a_change() { + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let document = parsed( + "src/lib.rs", + &rust, + vec![function(&rust, &[], "unchanged", 1, "fn unchanged()")], + ); + let file = compare_structured_file( + Some("src/lib.rs"), + Some("src/lib.rs"), + Some(&document), + Some(&document), + &[], + ) + .unwrap(); + assert!(file.symbol_changes().is_empty()); + assert_eq!(file.old_outline().len(), 1); + assert_eq!(file.new_outline().len(), 1); + } + + #[test] + fn unique_callable_pairs_report_each_changed_call_dimension() { + let ts = provenance(SyntaxLanguage::TypeScript, "ts-test"); + let arguments = function(&ts, &[], "arguments", 1, "function arguments() {}"); + let control = function(&ts, &[], "control", 10, "function control() {}"); + let combined = function(&ts, &[], "combined", 20, "function combined() {}"); + let symbols = vec![arguments.clone(), control.clone(), combined.clone()]; + let old_calls = vec![ + direct_call(&ts, &arguments, "load", "old", 130, 3, Vec::new()), + direct_call(&ts, &control, "load", "same", 1_030, 12, Vec::new()), + direct_call( + &ts, + &combined, + "load", + "old", + 2_030, + 22, + vec![ControlContext::Loop], + ), + ]; + let new_calls = vec![ + direct_call(&ts, &arguments, "load", "new", 130, 3, Vec::new()), + direct_call( + &ts, + &control, + "load", + "same", + 1_030, + 12, + vec![ControlContext::Condition], + ), + direct_call( + &ts, + &combined, + "load", + "new", + 2_030, + 22, + vec![ControlContext::Condition], + ), + ]; + let file = compare_structured_file( + Some("src/old.ts"), + Some("src/new.ts"), + Some(&parsed_with_calls( + "src/old.ts", + &ts, + symbols.clone(), + old_calls, + )), + Some(&parsed_with_calls("src/new.ts", &ts, symbols, new_calls)), + &[], + ) + .unwrap(); + + assert_eq!(file.call_diff().len(), 3); + let find = |name: &str| { + file.call_diff() + .iter() + .find(|change| { + call_change_fact(change) + .and_then(CallFact::enclosing_symbol) + .is_some_and(|key| key.name() == name) + }) + .unwrap() + }; + let argument_change = find("arguments"); + assert!(argument_change.arguments_changed()); + assert!(!argument_change.control_context_changed()); + let control_change = find("control"); + assert!(!control_change.arguments_changed()); + assert!(control_change.control_context_changed()); + let combined_change = find("combined"); + assert!(combined_change.arguments_changed()); + assert!(combined_change.control_context_changed()); + assert!(file.call_diff().iter().all(|change| { + change.kind() == CallChangeKind::Modified + && change.navigation().path == "src/new.ts" + && change.navigation().side == ComparisonSide::Head + })); + } + + #[test] + fn repeated_calls_degrade_and_duplicate_symbol_keys_suppress_pairing() { + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let run = function(&rust, &[], "run", 1, "fn run() {}"); + let repeated = compare_structured_file( + Some("old.rs"), + Some("new.rs"), + Some(&parsed_with_calls( + "old.rs", + &rust, + vec![run.clone()], + vec![ + direct_call(&rust, &run, "load", "old-a", 130, 3, Vec::new()), + direct_call(&rust, &run, "load", "old-b", 150, 4, Vec::new()), + ], + )), + Some(&parsed_with_calls( + "new.rs", + &rust, + vec![run.clone()], + vec![direct_call(&rust, &run, "load", "new", 130, 3, Vec::new())], + )), + &[], + ) + .unwrap(); + assert_eq!(repeated.call_diff().len(), 3); + assert_eq!( + repeated + .call_diff() + .iter() + .filter(|change| change.kind() == CallChangeKind::Removed) + .count(), + 2 + ); + assert_eq!( + repeated + .call_diff() + .iter() + .filter(|change| change.kind() == CallChangeKind::Added) + .count(), + 1 + ); + assert!( + repeated + .call_diff() + .iter() + .all(|change| change.pairing().is_none()) + ); + + let duplicate_old = vec![run.clone(), function(&rust, &[], "run", 10, "fn run() {}")]; + let duplicate_new = duplicate_old.clone(); + let duplicate = compare_structured_file( + Some("old.rs"), + Some("new.rs"), + Some(&parsed_with_calls( + "old.rs", + &rust, + duplicate_old, + vec![direct_call(&rust, &run, "load", "old", 130, 3, Vec::new())], + )), + Some(&parsed_with_calls( + "new.rs", + &rust, + duplicate_new, + vec![direct_call(&rust, &run, "load", "new", 130, 3, Vec::new())], + )), + &[], + ) + .unwrap(); + assert!(duplicate.call_diff().is_empty()); + } + + #[test] + fn same_callee_is_scoped_to_function_and_method_across_renamed_paths() { + let ts = provenance(SyntaxLanguage::TypeScript, "ts-test"); + let function = function(&ts, &[], "loadPage", 1, "function loadPage() {}"); + let method = fact( + &ts, + &["Store"], + SymbolKind::Method, + "refresh", + range(1_000, 1_090, 10, 14), + range(1_000, 1_020, 10, 10), + Some(range(1_021, 1_090, 11, 14)), + "refresh() {}", + 0, + 1, + 0, + ); + let symbols = vec![function.clone(), method.clone()]; + let old_calls = vec![ + direct_call(&ts, &function, "load", "page-old", 130, 3, Vec::new()), + direct_call(&ts, &method, "load", "store-old", 1_030, 12, Vec::new()), + ]; + let new_calls = vec![ + direct_call(&ts, &function, "load", "page-new", 130, 3, Vec::new()), + direct_call(&ts, &method, "load", "store-new", 1_030, 12, Vec::new()), + ]; + let file = compare_structured_file( + Some("src/before.ts"), + Some("src/after.ts"), + Some(&parsed_with_calls( + "src/before.ts", + &ts, + symbols.clone(), + old_calls, + )), + Some(&parsed_with_calls("src/after.ts", &ts, symbols, new_calls)), + &[], + ) + .unwrap(); + + assert_eq!(file.call_diff().len(), 2); + let enclosing_names: HashSet<_> = file + .call_diff() + .iter() + .map(|change| { + change + .new_fact() + .unwrap() + .enclosing_symbol() + .unwrap() + .qualified_name() + }) + .collect(); + assert_eq!( + enclosing_names, + HashSet::from(["loadPage".to_owned(), "Store::refresh".to_owned()]) + ); + assert!(file.call_diff().iter().all(|change| { + change.navigation().path == "src/after.ts" + && change.navigation().side == ComparisonSide::Head + })); + } + + #[test] + fn parser_mismatch_suppresses_call_diff() { + let old_provenance = provenance(SyntaxLanguage::Rust, "rust-old"); + let new_provenance = provenance(SyntaxLanguage::Rust, "rust-new"); + let old = function(&old_provenance, &[], "run", 1, "fn run() {}"); + let new = function(&new_provenance, &[], "run", 1, "fn run() {}"); + let file = compare_structured_file( + Some("old.rs"), + Some("new.rs"), + Some(&parsed_with_calls( + "old.rs", + &old_provenance, + vec![old.clone()], + vec![direct_call( + &old_provenance, + &old, + "load", + "old", + 130, + 3, + Vec::new(), + )], + )), + Some(&parsed_with_calls( + "new.rs", + &new_provenance, + vec![new.clone()], + vec![direct_call( + &new_provenance, + &new, + "load", + "new", + 130, + 3, + Vec::new(), + )], + )), + &[], + ) + .unwrap(); + assert!(file.call_diff().is_empty()); + } + + #[test] + fn multi_function_call_diff_order_is_stable_for_reversed_inputs() { + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let first = function(&rust, &[], "first", 1, "fn first() {}"); + let second = function(&rust, &[], "second", 10, "fn second() {}"); + let symbols = vec![first.clone(), second.clone()]; + let old_calls = vec![ + direct_call(&rust, &first, "zeta", "old", 150, 4, Vec::new()), + direct_call(&rust, &second, "alpha", "old", 1_030, 12, Vec::new()), + ]; + let new_calls = vec![ + direct_call(&rust, &first, "zeta", "new", 150, 4, Vec::new()), + direct_call(&rust, &second, "alpha", "new", 1_030, 12, Vec::new()), + ]; + let forward = compare_structured_file( + Some("old.rs"), + Some("new.rs"), + Some(&parsed_with_calls( + "old.rs", + &rust, + symbols.clone(), + old_calls.clone(), + )), + Some(&parsed_with_calls( + "new.rs", + &rust, + symbols.clone(), + new_calls.clone(), + )), + &[], + ) + .unwrap(); + let reversed = compare_structured_file( + Some("old.rs"), + Some("new.rs"), + Some(&parsed_with_calls( + "old.rs", + &rust, + symbols.iter().cloned().rev().collect(), + old_calls.iter().cloned().rev().collect(), + )), + Some(&parsed_with_calls( + "new.rs", + &rust, + symbols.into_iter().rev().collect(), + new_calls.into_iter().rev().collect(), + )), + &[], + ) + .unwrap(); + assert_eq!(forward.call_diff(), reversed.call_diff()); + assert_eq!( + forward + .call_diff() + .iter() + .map(|change| change.navigation().line.get()) + .collect::>(), + vec![4, 12] + ); + } + + #[test] + fn language_and_provenance_rules_are_conservative() { + let ts = provenance(SyntaxLanguage::TypeScript, "ts-test"); + let tsx = provenance(SyntaxLanguage::Tsx, "tsx-test"); + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let ts_document = parsed("file.ts", &ts, vec![function(&ts, &[], "work", 1, "work")]); + let tsx_document = parsed( + "file.tsx", + &tsx, + vec![function(&tsx, &[], "work", 1, "work")], + ); + let mismatch = compare_structured_file( + Some("file.ts"), + Some("file.tsx"), + Some(&ts_document), + Some(&tsx_document), + &[hunk(Some((1, 1)), Some((1, 1)))], + ) + .unwrap(); + assert_eq!(mismatch.status(), FileAnalysisStatus::Failed); + assert!(mismatch.symbol_changes().is_empty()); + + let rust_old = parsed( + "old.rs", + &rust, + vec![function(&rust, &[], "work", 1, "fn work()")], + ); + let rust_new_provenance = provenance(SyntaxLanguage::Rust, "rust-new-parser"); + let rust_new = parsed( + "new.rs", + &rust_new_provenance, + vec![function(&rust_new_provenance, &[], "work", 1, "fn work()")], + ); + let file = compare_structured_file( + Some("old.rs"), + Some("new.rs"), + Some(&rust_old), + Some(&rust_new), + &[hunk(Some((1, 1)), Some((1, 1)))], + ) + .unwrap(); + assert_eq!(file.symbol_changes().len(), 2); + assert!( + file.symbol_changes() + .iter() + .all(|change| change.kind() != SymbolChangeKind::Modified) + ); + } + + #[test] + fn exact_document_paths_are_required() { + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let document = parsed("actual.rs", &rust, Vec::new()); + let error = compare_structured_file(None, Some("other.rs"), None, Some(&document), &[]) + .unwrap_err(); + assert!(error.to_string().contains("exact comparison path")); + } + + #[test] + fn cancelled_syntax_truncation_keeps_unmeasured_review_evidence() { + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let cancellation = + SyntaxTruncation::new(SyntaxTruncationReason::Cancelled, None, None).unwrap(); + let document = document( + "src/lib.rs", + &rust, + DocumentStatus::Partial, + Vec::new(), + Vec::new(), + Some(cancellation), + ); + let file = + compare_structured_file(None, Some("src/lib.rs"), None, Some(&document), &[]).unwrap(); + assert_eq!( + file.truncation().unwrap().reason, + TruncationReason::Cancelled + ); + assert_eq!(file.truncation().unwrap().limit, None); + assert_eq!(file.truncation().unwrap().observed, None); + } + + #[test] + fn controlled_checkpoints_stop_outline_symbol_and_hunk_work() { + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let symbols: Vec<_> = (0_u32..100) + .map(|index| { + function( + &rust, + &[], + &format!("function_{index}"), + index * 10 + 1, + &format!("fn function_{index}()"), + ) + }) + .collect(); + let document = parsed("src/lib.rs", &rust, symbols.clone()); + let mut sort_checks = 0_u32; + let error = build_outline(&document, ComparisonSide::Head, &mut || { + sort_checks += 1; + (sort_checks == 3).then_some(ComparisonStopReason::Deadline) + }) + .unwrap_err(); + assert_eq!(error.stop_reason(), Some(ComparisonStopReason::Deadline)); + + let mut outline_checks = 0_u32; + let error = build_outline(&document, ComparisonSide::Head, &mut || { + outline_checks += 1; + (outline_checks == 25).then_some(ComparisonStopReason::Cancelled) + }) + .unwrap_err(); + assert_eq!(error.stop_reason(), Some(ComparisonStopReason::Cancelled)); + + let mut symbol_checks = 0_u32; + let error = compare_symbols( + Some("old.rs"), + Some("new.rs"), + &symbols, + &symbols, + &[], + &mut || { + symbol_checks += 1; + (symbol_checks == 150).then_some(ComparisonStopReason::Deadline) + }, + ) + .unwrap_err(); + assert_eq!(error.stop_reason(), Some(ComparisonStopReason::Deadline)); + + let wide = fact( + &rust, + &[], + SymbolKind::Function, + "wide", + range(0, 50_000, 1, 500), + range(0, 20, 1, 1), + Some(range(21, 50_000, 2, 500)), + "fn wide()", + 0, + 0, + 0, + ); + let hunks: Vec<_> = (2_u32..102) + .map(|line| hunk(Some((line, line)), Some((line, line)))) + .collect(); + let mut hunk_checks = 0_u32; + let error = compare_symbols( + Some("old.rs"), + Some("new.rs"), + std::slice::from_ref(&wide), + std::slice::from_ref(&wide), + &hunks, + &mut || { + hunk_checks += 1; + (hunk_checks == 30).then_some(ComparisonStopReason::Disconnected) + }, + ) + .unwrap_err(); + assert_eq!( + error.stop_reason(), + Some(ComparisonStopReason::Disconnected) + ); + } + + #[test] + fn wide_outline_parent_stops_inside_controlled_child_validation() { + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let parent = fact( + &rust, + &[], + SymbolKind::Module, + "wide", + range(0, 200_000, 1, 2_000), + range(0, 20, 1, 1), + Some(range(21, 200_000, 2, 2_000)), + "mod wide", + 0, + 0, + 100, + ); + let mut symbols = vec![parent]; + symbols.extend((0_u32..100).map(|index| { + function( + &rust, + &["wide"], + &format!("child_{index}"), + index * 10 + 10, + &format!("fn child_{index}()"), + ) + })); + let document = parsed("src/lib.rs", &rust, symbols); + let mut total_checks = 0_u32; + build_outline(&document, ComparisonSide::Head, &mut || { + total_checks += 1; + None + }) + .unwrap(); + let stop_at = total_checks - 50; + let mut checks = 0_u32; + let error = build_outline(&document, ComparisonSide::Head, &mut || { + checks += 1; + (checks == stop_at).then_some(ComparisonStopReason::Cancelled) + }) + .unwrap_err(); + + assert_eq!(error.stop_reason(), Some(ComparisonStopReason::Cancelled)); + } + + #[test] + fn structure_call_index_is_checkpointed_near_linearly() { + let rust = provenance(SyntaxLanguage::Rust, "rust-test"); + let symbols: Vec<_> = (0_u32..100) + .map(|index| { + function( + &rust, + &[], + &format!("function_{index}"), + index * 10 + 1, + &format!("fn function_{index}()"), + ) + }) + .collect(); + let calls: Vec<_> = symbols + .iter() + .enumerate() + .map(|(index, symbol)| { + let start_line = u32::try_from(index).unwrap() * 10 + 3; + direct_call( + &rust, + symbol, + "load", + "same", + u64::from(start_line - 3) * 100 + 130, + start_line, + Vec::new(), + ) + }) + .collect(); + let old = parsed_with_calls("old.rs", &rust, symbols.clone(), calls.clone()); + let new = parsed_with_calls("new.rs", &rust, symbols, calls); + let mut checkpoints = 0_usize; + let file = compare_structured_file_controlled( + Some("old.rs"), + Some("new.rs"), + Some(&old), + Some(&new), + &[], + &mut || { + checkpoints += 1; + None + }, + ) + .unwrap(); + + assert!(file.call_diff().is_empty()); + assert!(checkpoints < 10_000, "checkpoint count was {checkpoints}"); + } + + #[test] + fn controlled_file_comparison_reports_immediate_disconnect() { + let error = compare_structured_file_controlled( + None, + Some("src/lib.rs"), + None, + None, + &[], + &mut || Some(ComparisonStopReason::Disconnected), + ) + .unwrap_err(); + assert_eq!( + error.stop_reason(), + Some(ComparisonStopReason::Disconnected) + ); + } + + #[test] + fn controlled_file_comparison_stops_inside_final_model_validation() { + let hunks: Vec<_> = (1_u32..=100) + .map(|line| hunk(None, Some((line, line)))) + .collect(); + let mut checks = 0_u32; + let error = compare_structured_file_controlled( + None, + Some("src/lib.rs"), + None, + None, + &hunks, + &mut || { + checks += 1; + (checks == 150).then_some(ComparisonStopReason::Disconnected) + }, + ) + .unwrap_err(); + + assert_eq!( + error.stop_reason(), + Some(ComparisonStopReason::Disconnected) + ); + } + + #[test] + fn line_helpers_use_fixed_width_nonzero_values() { + assert_eq!(lines(1, 3).line_count(), 3); + assert_eq!(NonZeroU64::new(1).unwrap().get(), 1); + } +} diff --git a/crates/okena-syntax/Cargo.toml b/crates/okena-syntax/Cargo.toml new file mode 100644 index 000000000..a06648838 --- /dev/null +++ b/crates/okena-syntax/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "okena-syntax" +version = "0.1.0" +edition = "2024" +license = "MIT" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +tree-sitter = "0.26" +tree-sitter-rust = "0.24" +tree-sitter-typescript = "0.23" + +[dev-dependencies] +serde_json = "1.0" diff --git a/crates/okena-syntax/src/language.rs b/crates/okena-syntax/src/language.rs new file mode 100644 index 000000000..3f00393fc --- /dev/null +++ b/crates/okena-syntax/src/language.rs @@ -0,0 +1,89 @@ +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Languages with structured-review support. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SyntaxLanguage { + #[serde(rename = "rust")] + Rust, + #[serde(rename = "typescript")] + TypeScript, + #[serde(rename = "tsx")] + Tsx, +} + +impl SyntaxLanguage { + /// Detect a supported language from a source path. + pub fn from_path(path: &Path) -> Option { + let extension = path.extension()?.to_str()?; + Self::from_extension(extension) + } + + /// Detect a supported language from a file extension, with or without a dot. + pub fn from_extension(extension: &str) -> Option { + let extension = extension.strip_prefix('.').unwrap_or(extension); + if extension.eq_ignore_ascii_case("rs") { + Some(Self::Rust) + } else if ["ts", "mts", "cts"] + .iter() + .any(|candidate| extension.eq_ignore_ascii_case(candidate)) + { + Some(Self::TypeScript) + } else if extension.eq_ignore_ascii_case("tsx") { + Some(Self::Tsx) + } else { + None + } + } + + pub fn display_name(self) -> &'static str { + match self { + Self::Rust => "Rust", + Self::TypeScript => "TypeScript", + Self::Tsx => "TSX", + } + } +} + +#[cfg(test)] +mod tests { + use super::SyntaxLanguage; + use std::path::Path; + + #[test] + fn detects_supported_extensions() { + assert_eq!( + SyntaxLanguage::from_path(Path::new("src/lib.rs")), + Some(SyntaxLanguage::Rust) + ); + assert_eq!( + SyntaxLanguage::from_path(Path::new("src/types.d.ts")), + Some(SyntaxLanguage::TypeScript) + ); + assert_eq!( + SyntaxLanguage::from_path(Path::new("src/module.MTS")), + Some(SyntaxLanguage::TypeScript) + ); + assert_eq!( + SyntaxLanguage::from_path(Path::new("src/view.TSX")), + Some(SyntaxLanguage::Tsx) + ); + } + + #[test] + fn rejects_unsupported_and_non_utf8_free_paths() { + assert_eq!(SyntaxLanguage::from_path(Path::new("README.md")), None); + assert_eq!(SyntaxLanguage::from_path(Path::new("Makefile")), None); + assert_eq!(SyntaxLanguage::from_extension(".jsx"), None); + } + + #[test] + fn language_serde_uses_stable_wire_names() { + let json = serde_json::to_string(&SyntaxLanguage::TypeScript).unwrap(); + assert_eq!(json, "\"typescript\""); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + SyntaxLanguage::TypeScript + ); + } +} diff --git a/crates/okena-syntax/src/lib.rs b/crates/okena-syntax/src/lib.rs new file mode 100644 index 000000000..7190f63ff --- /dev/null +++ b/crates/okena-syntax/src/lib.rs @@ -0,0 +1,18 @@ +#![cfg_attr(not(test), warn(clippy::unwrap_used, clippy::expect_used))] + +//! GPUI-free syntax facts shared by review and navigation features. + +mod language; +mod model; + +// Wave 1 adapters own separate directories so they can be implemented independently. +pub mod rust; +pub mod typescript; + +pub use language::SyntaxLanguage; +pub use model::{ + AnalysisBudget, AnalysisControl, AnalysisInput, CallFact, CaptureByteTracker, ControlContext, + DiagnosticSeverity, DocumentStatus, DocumentStructure, ModelError, SourceRange, SymbolFact, + SymbolKey, SymbolKind, SymbolVisibility, SyntaxAdapter, SyntaxDiagnostic, SyntaxProvenance, + SyntaxTruncation, SyntaxTruncationReason, +}; diff --git a/crates/okena-syntax/src/model.rs b/crates/okena-syntax/src/model.rs new file mode 100644 index 000000000..6b34ab533 --- /dev/null +++ b/crates/okena-syntax/src/model.rs @@ -0,0 +1,1701 @@ +use crate::SyntaxLanguage; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::num::{NonZeroU32, NonZeroU64}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ModelError(String); + +impl ModelError { + fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl fmt::Display for ModelError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for ModelError {} + +/// UTF-8 source range. Bytes are zero-based and end-exclusive. Lines are one-based and inclusive. +/// +/// Tree-sitter rows are zero-based and its end position is exclusive. Adapters must convert an +/// end position at column zero to the preceding inclusive line when the range spans lines. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] +#[serde(try_from = "SourceRangeWire")] +pub struct SourceRange { + start_byte: u64, + end_byte: u64, + start_line: NonZeroU32, + end_line: NonZeroU32, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SourceRangeWire { + start_byte: u64, + end_byte: u64, + start_line: NonZeroU32, + end_line: NonZeroU32, +} + +impl TryFrom for SourceRange { + type Error = ModelError; + + fn try_from(value: SourceRangeWire) -> Result { + Self::new( + value.start_byte, + value.end_byte, + value.start_line, + value.end_line, + ) + } +} + +impl<'de> Deserialize<'de> for SourceRange { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + SourceRangeWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +impl SourceRange { + pub fn new( + start_byte: u64, + end_byte: u64, + start_line: NonZeroU32, + end_line: NonZeroU32, + ) -> Result { + if start_byte > end_byte { + return Err(ModelError::new("source range starts after it ends")); + } + if start_line > end_line { + return Err(ModelError::new("source range start line exceeds end line")); + } + Ok(Self { + start_byte, + end_byte, + start_line, + end_line, + }) + } + + /// Convert zero-based tree-sitter rows and its exclusive end position. + pub fn from_tree_sitter( + start_byte: u64, + end_byte: u64, + start_row: u32, + end_row: u32, + end_column: u32, + ) -> Result { + let start_line = start_row + .checked_add(1) + .and_then(NonZeroU32::new) + .ok_or_else(|| ModelError::new("tree-sitter start row exceeds wire line range"))?; + let inclusive_end = if end_byte > start_byte && end_column == 0 && end_row > start_row { + end_row + } else { + end_row + .checked_add(1) + .ok_or_else(|| ModelError::new("tree-sitter end row exceeds wire line range"))? + }; + let end_line = NonZeroU32::new(inclusive_end) + .ok_or_else(|| ModelError::new("tree-sitter range has no inclusive end line"))?; + Self::new(start_byte, end_byte, start_line, end_line) + } + + pub fn start_byte(self) -> u64 { + self.start_byte + } + + pub fn end_byte(self) -> u64 { + self.end_byte + } + + pub fn start_line(self) -> NonZeroU32 { + self.start_line + } + + pub fn end_line(self) -> NonZeroU32 { + self.end_line + } + + pub fn line_count(self) -> u32 { + self.end_line.get() - self.start_line.get() + 1 + } + + pub fn contains(self, other: Self) -> bool { + self.start_byte <= other.start_byte + && other.end_byte <= self.end_byte + && self.start_line <= other.start_line + && other.end_line <= self.end_line + } + + pub fn validate_source(self, source: &str) -> Result<(), ModelError> { + let start = usize::try_from(self.start_byte) + .map_err(|_| ModelError::new("source range start does not fit this platform"))?; + let end = usize::try_from(self.end_byte) + .map_err(|_| ModelError::new("source range end does not fit this platform"))?; + if end > source.len() { + return Err(ModelError::new("source range exceeds source length")); + } + if !source.is_char_boundary(start) || !source.is_char_boundary(end) { + return Err(ModelError::new("source range splits a UTF-8 code point")); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] +#[serde(try_from = "SyntaxProvenanceWire")] +pub struct SyntaxProvenance { + language: SyntaxLanguage, + parser: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SyntaxProvenanceWire { + language: SyntaxLanguage, + parser: String, +} + +impl TryFrom for SyntaxProvenance { + type Error = ModelError; + + fn try_from(value: SyntaxProvenanceWire) -> Result { + Self::tree_sitter(value.language, value.parser) + } +} + +impl<'de> Deserialize<'de> for SyntaxProvenance { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + SyntaxProvenanceWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +impl SyntaxProvenance { + pub fn tree_sitter( + language: SyntaxLanguage, + parser: impl Into, + ) -> Result { + let parser = parser.into(); + if parser.trim().is_empty() { + return Err(ModelError::new("syntax parser name must not be empty")); + } + Ok(Self { language, parser }) + } + + pub fn language(&self) -> SyntaxLanguage { + self.language + } + + pub fn parser(&self) -> &str { + &self.parser + } + + /// Owned UTF-8 payload bytes retained by this value. + pub fn estimated_owned_bytes(&self) -> u64 { + string_owned_bytes(&self.parser) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SymbolKind { + Module, + Function, + Method, + Struct, + Enum, + Union, + Trait, + Impl, + Class, + Interface, + TypeAlias, + Constant, + Static, + Field, + Variant, + Macro, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SymbolVisibility { + Public, + Restricted, + Private, + Exported, + Unknown, +} + +/// Descriptive symbol key within one file. Duplicate keys are intentionally ambiguous. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] +#[serde(try_from = "SymbolKeyWire")] +pub struct SymbolKey { + qualified_path: Vec, + kind: SymbolKind, + name: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SymbolKeyWire { + qualified_path: Vec, + kind: SymbolKind, + name: String, +} + +impl TryFrom for SymbolKey { + type Error = ModelError; + + fn try_from(value: SymbolKeyWire) -> Result { + Self::new(value.qualified_path, value.kind, value.name) + } +} + +impl<'de> Deserialize<'de> for SymbolKey { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + SymbolKeyWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +impl SymbolKey { + pub fn new( + qualified_path: Vec, + kind: SymbolKind, + name: impl Into, + ) -> Result { + let name = name.into(); + if name.trim().is_empty() { + return Err(ModelError::new("symbol name must not be empty")); + } + if qualified_path.iter().any(|part| part.trim().is_empty()) { + return Err(ModelError::new( + "symbol qualified path must not contain empty segments", + )); + } + Ok(Self { + qualified_path, + kind, + name, + }) + } + + pub fn qualified_path(&self) -> &[String] { + &self.qualified_path + } + + pub fn kind(&self) -> SymbolKind { + self.kind + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn qualified_name(&self) -> String { + self.qualified_path + .iter() + .chain(std::iter::once(&self.name)) + .cloned() + .collect::>() + .join("::") + } + + /// Owned UTF-8 payload bytes, excluding vector and allocator overhead. + pub fn estimated_owned_bytes(&self) -> u64 { + self.qualified_path + .iter() + .map(|part| string_owned_bytes(part)) + .chain(std::iter::once(string_owned_bytes(&self.name))) + .fold(0, u64::saturating_add) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(try_from = "SymbolFactWire")] +pub struct SymbolFact { + provenance: SyntaxProvenance, + key: SymbolKey, + visibility: SymbolVisibility, + full_range: SourceRange, + signature_range: SourceRange, + body_range: Option, + normalized_signature: String, + parameter_count: u32, + syntactic_nesting_depth: u32, + type_member_count: u32, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SymbolFactWire { + provenance: SyntaxProvenance, + key: SymbolKey, + visibility: SymbolVisibility, + full_range: SourceRange, + signature_range: SourceRange, + body_range: Option, + normalized_signature: String, + parameter_count: u32, + syntactic_nesting_depth: u32, + type_member_count: u32, +} + +impl TryFrom for SymbolFact { + type Error = ModelError; + + fn try_from(value: SymbolFactWire) -> Result { + Self::new( + value.provenance, + value.key, + value.visibility, + value.full_range, + value.signature_range, + value.body_range, + value.normalized_signature, + value.parameter_count, + value.syntactic_nesting_depth, + value.type_member_count, + ) + } +} + +impl<'de> Deserialize<'de> for SymbolFact { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + SymbolFactWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +impl SymbolFact { + #[allow(clippy::too_many_arguments)] + pub fn new( + provenance: SyntaxProvenance, + key: SymbolKey, + visibility: SymbolVisibility, + full_range: SourceRange, + signature_range: SourceRange, + body_range: Option, + normalized_signature: impl Into, + parameter_count: u32, + syntactic_nesting_depth: u32, + type_member_count: u32, + ) -> Result { + if !full_range.contains(signature_range) { + return Err(ModelError::new( + "signature range must be inside symbol range", + )); + } + if body_range.is_some_and(|body| !full_range.contains(body)) { + return Err(ModelError::new("body range must be inside symbol range")); + } + let normalized_signature = normalized_signature.into(); + if normalized_signature.trim().is_empty() { + return Err(ModelError::new("normalized signature must not be empty")); + } + Ok(Self { + provenance, + key, + visibility, + full_range, + signature_range, + body_range, + normalized_signature, + parameter_count, + syntactic_nesting_depth, + type_member_count, + }) + } + + pub fn provenance(&self) -> &SyntaxProvenance { + &self.provenance + } + pub fn key(&self) -> &SymbolKey { + &self.key + } + pub fn visibility(&self) -> SymbolVisibility { + self.visibility + } + pub fn full_range(&self) -> SourceRange { + self.full_range + } + pub fn signature_range(&self) -> SourceRange { + self.signature_range + } + pub fn body_range(&self) -> Option { + self.body_range + } + pub fn normalized_signature(&self) -> &str { + &self.normalized_signature + } + pub fn parameter_count(&self) -> u32 { + self.parameter_count + } + pub fn syntactic_nesting_depth(&self) -> u32 { + self.syntactic_nesting_depth + } + pub fn type_member_count(&self) -> u32 { + self.type_member_count + } + + /// Owned UTF-8 payload bytes retained by this fact, including duplicated provenance and key. + pub fn estimated_owned_bytes(&self) -> u64 { + saturating_owned_sum([ + self.provenance.estimated_owned_bytes(), + self.key.estimated_owned_bytes(), + string_owned_bytes(&self.normalized_signature), + ]) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ControlContext { + Condition, + Loop, + MatchArm, + ErrorBranch, + Callback, + Closure, + Other(String), +} + +impl ControlContext { + /// Owned UTF-8 payload bytes retained by this context. + pub fn estimated_owned_bytes(&self) -> u64 { + match self { + Self::Other(value) => string_owned_bytes(value), + _ => 0, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(try_from = "CallFactWire")] +pub struct CallFact { + provenance: SyntaxProvenance, + callee_text: String, + argument_text: String, + argument_range: SourceRange, + call_site_range: SourceRange, + enclosing_symbol: Option, + control_context: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CallFactWire { + provenance: SyntaxProvenance, + callee_text: String, + argument_text: String, + argument_range: SourceRange, + call_site_range: SourceRange, + enclosing_symbol: Option, + control_context: Vec, +} + +impl TryFrom for CallFact { + type Error = ModelError; + fn try_from(value: CallFactWire) -> Result { + Self::new( + value.provenance, + value.callee_text, + value.argument_text, + value.argument_range, + value.call_site_range, + value.enclosing_symbol, + value.control_context, + ) + } +} + +impl<'de> Deserialize<'de> for CallFact { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + CallFactWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +impl CallFact { + pub fn new( + provenance: SyntaxProvenance, + callee_text: impl Into, + argument_text: impl Into, + argument_range: SourceRange, + call_site_range: SourceRange, + enclosing_symbol: Option, + control_context: Vec, + ) -> Result { + let callee_text = callee_text.into(); + let argument_text = argument_text.into(); + if callee_text.trim().is_empty() { + return Err(ModelError::new("callee text must not be empty")); + } + if !call_site_range.contains(argument_range) { + return Err(ModelError::new("argument range must be inside call site")); + } + Ok(Self { + provenance, + callee_text, + argument_text, + argument_range, + call_site_range, + enclosing_symbol, + control_context, + }) + } + pub fn provenance(&self) -> &SyntaxProvenance { + &self.provenance + } + pub fn callee_text(&self) -> &str { + &self.callee_text + } + pub fn argument_text(&self) -> &str { + &self.argument_text + } + pub fn argument_range(&self) -> SourceRange { + self.argument_range + } + pub fn call_site_range(&self) -> SourceRange { + self.call_site_range + } + pub fn enclosing_symbol(&self) -> Option<&SymbolKey> { + self.enclosing_symbol.as_ref() + } + pub fn control_context(&self) -> &[ControlContext] { + &self.control_context + } + + /// Owned UTF-8 payload bytes retained by this fact, including duplicated provenance and key. + pub fn estimated_owned_bytes(&self) -> u64 { + let direct = saturating_owned_sum([ + self.provenance.estimated_owned_bytes(), + string_owned_bytes(&self.callee_text), + string_owned_bytes(&self.argument_text), + self.enclosing_symbol + .as_ref() + .map_or(0, SymbolKey::estimated_owned_bytes), + ]); + self.control_context + .iter() + .map(ControlContext::estimated_owned_bytes) + .fold(direct, u64::saturating_add) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DocumentStatus { + Parsed, + Partial, + Unsupported, + Failed, + Skipped, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DiagnosticSeverity { + Info, + Warning, + Error, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SyntaxTruncationReason { + SourceBytes, + CaptureBytes, + SymbolCount, + CallCount, + DiagnosticCount, + Time, + Cancelled, +} + +/// The concrete bounded resource which stopped syntax analysis. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct SyntaxTruncation { + reason: SyntaxTruncationReason, + limit: Option, + observed: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SyntaxTruncationWire { + reason: SyntaxTruncationReason, + limit: Option, + observed: Option, +} + +impl TryFrom for SyntaxTruncation { + type Error = ModelError; + + fn try_from(value: SyntaxTruncationWire) -> Result { + Self::new(value.reason, value.limit, value.observed) + } +} + +impl<'de> Deserialize<'de> for SyntaxTruncation { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + SyntaxTruncationWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} + +impl SyntaxTruncation { + pub fn new( + reason: SyntaxTruncationReason, + limit: Option, + observed: Option, + ) -> Result { + match reason { + SyntaxTruncationReason::Cancelled if limit.is_some() || observed.is_some() => { + return Err(ModelError::new( + "cancelled truncation cannot carry numeric measurements", + )); + } + SyntaxTruncationReason::Cancelled => {} + SyntaxTruncationReason::CaptureBytes => match (limit, observed) { + (Some(limit), Some(observed)) + if limit > 0 + && (observed > limit || limit == u64::MAX && observed == u64::MAX) => {} + _ => { + return Err(ModelError::new( + "capture-byte truncation requires a positive limit and observed value above it", + )); + } + }, + _ => match (limit, observed) { + (Some(limit), Some(observed)) if limit > 0 && observed >= limit => {} + _ => { + return Err(ModelError::new( + "bounded truncation requires a positive limit and observed value at least equal to it", + )); + } + }, + } + Ok(Self { + reason, + limit, + observed, + }) + } + + pub fn reason(&self) -> SyntaxTruncationReason { + self.reason + } + pub fn limit(&self) -> Option { + self.limit + } + pub fn observed(&self) -> Option { + self.observed + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(try_from = "SyntaxDiagnosticWire")] +pub struct SyntaxDiagnostic { + severity: DiagnosticSeverity, + message: String, + range: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct SyntaxDiagnosticWire { + severity: DiagnosticSeverity, + message: String, + range: Option, +} + +impl TryFrom for SyntaxDiagnostic { + type Error = ModelError; + fn try_from(value: SyntaxDiagnosticWire) -> Result { + Self::new(value.severity, value.message, value.range) + } +} +impl<'de> Deserialize<'de> for SyntaxDiagnostic { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + SyntaxDiagnosticWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} +impl SyntaxDiagnostic { + pub fn new( + severity: DiagnosticSeverity, + message: impl Into, + range: Option, + ) -> Result { + let message = message.into(); + if message.trim().is_empty() { + return Err(ModelError::new("diagnostic must not be empty")); + } + Ok(Self { + severity, + message, + range, + }) + } + pub fn severity(&self) -> DiagnosticSeverity { + self.severity + } + pub fn message(&self) -> &str { + &self.message + } + pub fn range(&self) -> Option { + self.range + } + + /// Owned UTF-8 payload bytes retained by this diagnostic. + pub fn estimated_owned_bytes(&self) -> u64 { + string_owned_bytes(&self.message) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(try_from = "DocumentStructureWire")] +pub struct DocumentStructure { + path: String, + provenance: SyntaxProvenance, + status: DocumentStatus, + symbols: Vec, + calls: Vec, + diagnostics: Vec, + truncation: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct DocumentStructureWire { + path: String, + provenance: SyntaxProvenance, + status: DocumentStatus, + symbols: Vec, + calls: Vec, + diagnostics: Vec, + truncation: Option, +} + +impl TryFrom for DocumentStructure { + type Error = ModelError; + fn try_from(value: DocumentStructureWire) -> Result { + Self::new( + value.path, + value.provenance, + value.status, + value.symbols, + value.calls, + value.diagnostics, + value.truncation, + ) + } +} +impl<'de> Deserialize<'de> for DocumentStructure { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + DocumentStructureWire::deserialize(deserializer)? + .try_into() + .map_err(serde::de::Error::custom) + } +} +impl DocumentStructure { + #[allow(clippy::too_many_arguments)] + pub fn new( + path: impl Into, + provenance: SyntaxProvenance, + status: DocumentStatus, + symbols: Vec, + calls: Vec, + diagnostics: Vec, + truncation: Option, + ) -> Result { + let path = path.into(); + if path.trim().is_empty() { + return Err(ModelError::new("document path must not be empty")); + } + if symbols.iter().any(|fact| fact.provenance() != &provenance) + || calls.iter().any(|fact| fact.provenance() != &provenance) + { + return Err(ModelError::new("document facts must share its provenance")); + } + let no_facts = symbols.is_empty() && calls.is_empty(); + if matches!( + status, + DocumentStatus::Unsupported | DocumentStatus::Failed | DocumentStatus::Skipped + ) && !no_facts + { + return Err(ModelError::new( + "unsuccessful documents cannot contain syntax facts", + )); + } + if status == DocumentStatus::Parsed + && (truncation.is_some() + || diagnostics + .iter() + .any(|d| d.severity() == DiagnosticSeverity::Error)) + { + return Err(ModelError::new( + "parsed status cannot carry truncation or errors", + )); + } + if status == DocumentStatus::Partial + && truncation.is_none() + && !diagnostics.iter().any(|diagnostic| { + matches!( + diagnostic.severity(), + DiagnosticSeverity::Warning | DiagnosticSeverity::Error + ) + }) + { + return Err(ModelError::new( + "partial status requires truncation or warning/error evidence", + )); + } + if status == DocumentStatus::Failed + && !diagnostics + .iter() + .any(|diagnostic| diagnostic.severity() == DiagnosticSeverity::Error) + { + return Err(ModelError::new( + "failed status requires an error diagnostic", + )); + } + Ok(Self { + path, + provenance, + status, + symbols, + calls, + diagnostics, + truncation, + }) + } + pub fn path(&self) -> &str { + &self.path + } + pub fn provenance(&self) -> &SyntaxProvenance { + &self.provenance + } + pub fn status(&self) -> DocumentStatus { + self.status + } + pub fn symbols(&self) -> &[SymbolFact] { + &self.symbols + } + pub fn calls(&self) -> &[CallFact] { + &self.calls + } + pub fn diagnostics(&self) -> &[SyntaxDiagnostic] { + &self.diagnostics + } + pub fn truncation(&self) -> Option<&SyntaxTruncation> { + self.truncation.as_ref() + } + + /// Estimated retained UTF-8 payload bytes in this complete document. + /// + /// Every owned string is counted at each storage location, including cloned provenance and + /// symbol keys. Fixed-size fields, vector capacity, and allocator overhead are excluded. If a + /// sum exceeds the fixed-width wire measurement, the result saturates at `u64::MAX`. + pub fn estimated_owned_bytes(&self) -> u64 { + let base = saturating_owned_sum([ + string_owned_bytes(&self.path), + self.provenance.estimated_owned_bytes(), + ]); + let with_symbols = self + .symbols + .iter() + .map(SymbolFact::estimated_owned_bytes) + .fold(base, u64::saturating_add); + let with_calls = self + .calls + .iter() + .map(CallFact::estimated_owned_bytes) + .fold(with_symbols, u64::saturating_add); + self.diagnostics + .iter() + .map(SyntaxDiagnostic::estimated_owned_bytes) + .fold(with_calls, u64::saturating_add) + } +} + +/// Server-selected bounded analysis limits. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AnalysisBudget { + max_source_bytes: NonZeroU64, + max_capture_bytes: NonZeroU64, + max_symbols: NonZeroU32, + max_calls: NonZeroU32, + max_diagnostics: NonZeroU32, +} + +/// Incremental retained-payload accounting for one syntax document. +/// +/// Adapters initialize this with [`Self::for_document`], then account each candidate with the +/// matching typed method before pushing it into a retained collection. Rejected candidates do not +/// change the retained count. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CaptureByteTracker { + limit: NonZeroU64, + retained: u64, +} + +impl CaptureByteTracker { + fn new(limit: NonZeroU64) -> Self { + Self { limit, retained: 0 } + } + + pub fn for_document( + limit: NonZeroU64, + path: &str, + provenance: &SyntaxProvenance, + ) -> Result { + let mut tracker = Self::new(limit); + tracker.try_account(saturating_owned_sum([ + string_owned_bytes(path), + provenance.estimated_owned_bytes(), + ]))?; + Ok(tracker) + } + + pub fn limit(self) -> NonZeroU64 { + self.limit + } + + pub fn retained_bytes(self) -> u64 { + self.retained + } + + pub fn try_account_symbol(&mut self, candidate: &SymbolFact) -> Result<(), SyntaxTruncation> { + self.try_account(candidate.estimated_owned_bytes()) + } + + pub fn try_account_call(&mut self, candidate: &CallFact) -> Result<(), SyntaxTruncation> { + self.try_account(candidate.estimated_owned_bytes()) + } + + pub fn try_account_diagnostic( + &mut self, + candidate: &SyntaxDiagnostic, + ) -> Result<(), SyntaxTruncation> { + self.try_account(candidate.estimated_owned_bytes()) + } + + /// Account a candidate before retaining it. Exact-limit candidates are accepted. + /// + /// On fixed-width addition overflow, `observed` saturates at `u64::MAX` and the candidate is + /// rejected even when the configured limit is also `u64::MAX`. + fn try_account(&mut self, candidate_bytes: u64) -> Result<(), SyntaxTruncation> { + let observed = match self.retained.checked_add(candidate_bytes) { + Some(observed) => observed, + None => return Err(capture_byte_truncation(self.limit, u64::MAX)), + }; + if observed > self.limit.get() { + return Err(capture_byte_truncation(self.limit, observed)); + } + self.retained = observed; + Ok(()) + } +} + +/// Runtime-only stop control. It is deliberately separate from serializable document facts. +#[derive(Clone, Debug)] +pub struct AnalysisControl { + started_at: Instant, + deadline: Option, + time_limit_micros: NonZeroU64, + cancelled_at: Arc>>, +} + +impl AnalysisControl { + pub fn new(time_limit_micros: NonZeroU64) -> Self { + Self::new_at(Instant::now(), time_limit_micros) + } + + fn new_at(started_at: Instant, time_limit_micros: NonZeroU64) -> Self { + let deadline = started_at.checked_add(Duration::from_micros(time_limit_micros.get())); + Self { + started_at, + deadline, + time_limit_micros, + cancelled_at: Arc::new(Mutex::new(None)), + } + } + + pub fn cancel(&self) { + self.cancel_at(Instant::now()); + } + + fn cancel_at(&self, cancelled_at: Instant) { + let mut stored = match self.cancelled_at.lock() { + Ok(stored) => stored, + Err(poisoned) => poisoned.into_inner(), + }; + if stored.is_none_or(|existing| cancelled_at < existing) { + *stored = Some(cancelled_at); + } + } + + pub fn is_cancelled(&self) -> bool { + self.cancellation_instant().is_some() + } + + fn cancellation_instant(&self) -> Option { + match self.cancelled_at.lock() { + Ok(stored) => *stored, + Err(poisoned) => *poisoned.into_inner(), + } + } + + pub fn time_limit_micros(&self) -> NonZeroU64 { + self.time_limit_micros + } + + pub fn elapsed_micros(&self, now: Instant) -> u64 { + duration_micros(now.saturating_duration_since(self.started_at)) + } + + pub fn deadline_exceeded(&self, now: Instant) -> bool { + time_limit_reached( + self.deadline, + now, + self.elapsed_micros(now), + self.time_limit_micros, + ) + } + + pub fn should_stop(&self, now: Instant) -> bool { + self.cancellation_instant() + .is_some_and(|cancelled_at| cancelled_at <= now) + || self.deadline_exceeded(now) + } + + /// Return inspectable stop evidence in the same microsecond unit as the configured limit. + pub fn stop_truncation(&self, now: Instant) -> Result, ModelError> { + let cancellation = self + .cancellation_instant() + .filter(|cancelled_at| *cancelled_at <= now); + let time_reached = self.deadline_exceeded(now); + let cancellation_first = cancellation.is_some_and(|cancelled_at| match self.deadline { + Some(deadline) => cancelled_at < deadline, + None => self.elapsed_micros(cancelled_at) < self.time_limit_micros.get(), + }); + if cancellation_first || cancellation.is_some() && !time_reached { + return SyntaxTruncation::new(SyntaxTruncationReason::Cancelled, None, None).map(Some); + } + if time_reached { + let limit = self.time_limit_micros.get(); + let observed = self.elapsed_micros(now); + if observed < limit { + return Err(ModelError::new( + "expired analysis control measured less elapsed time than its configured limit", + )); + } + return SyntaxTruncation::new( + SyntaxTruncationReason::Time, + Some(limit), + Some(observed), + ) + .map(Some); + } + Ok(None) + } +} + +fn time_limit_reached( + deadline: Option, + now: Instant, + elapsed_micros: u64, + limit_micros: NonZeroU64, +) -> bool { + deadline.map_or_else( + || elapsed_micros >= limit_micros.get(), + |deadline| now >= deadline, + ) +} + +fn duration_micros(duration: Duration) -> u64 { + u64::try_from(duration.as_micros()).unwrap_or(u64::MAX) +} + +fn string_owned_bytes(value: &str) -> u64 { + u64::try_from(value.len()).unwrap_or(u64::MAX) +} + +fn saturating_owned_sum(values: [u64; N]) -> u64 { + values.into_iter().fold(0, u64::saturating_add) +} + +fn capture_byte_truncation(limit: NonZeroU64, observed: u64) -> SyntaxTruncation { + SyntaxTruncation { + reason: SyntaxTruncationReason::CaptureBytes, + limit: Some(limit.get()), + observed: Some(observed), + } +} + +impl AnalysisBudget { + pub fn new( + max_source_bytes: NonZeroU64, + max_symbols: NonZeroU32, + max_calls: NonZeroU32, + max_diagnostics: NonZeroU32, + ) -> Self { + Self { + max_source_bytes, + max_capture_bytes: max_source_bytes, + max_symbols, + max_calls, + max_diagnostics, + } + } + + /// Override the default capture limit, which equals `max_source_bytes`. + pub fn with_max_capture_bytes(mut self, max_capture_bytes: NonZeroU64) -> Self { + self.max_capture_bytes = max_capture_bytes; + self + } + + pub fn max_source_bytes(self) -> NonZeroU64 { + self.max_source_bytes + } + pub fn max_capture_bytes(self) -> NonZeroU64 { + self.max_capture_bytes + } + pub fn max_symbols(self) -> NonZeroU32 { + self.max_symbols + } + pub fn max_calls(self) -> NonZeroU32 { + self.max_calls + } + pub fn max_diagnostics(self) -> NonZeroU32 { + self.max_diagnostics + } +} + +/// Owned adapter input. The server owns both the source snapshot and analysis budget. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AnalysisInput { + path: String, + language: SyntaxLanguage, + source: String, +} + +impl AnalysisInput { + pub fn new( + path: impl Into, + language: SyntaxLanguage, + source: String, + ) -> Result { + let path = path.into(); + if path.trim().is_empty() { + return Err(ModelError::new("analysis path must not be empty")); + } + Ok(Self { + path, + language, + source, + }) + } + pub fn path(&self) -> &str { + &self.path + } + pub fn language(&self) -> SyntaxLanguage { + self.language + } + pub fn source(&self) -> &str { + &self.source + } +} + +/// Shared adapter seam. Implementations must return explicit partial/failed output. +pub trait SyntaxAdapter: Send + Sync { + fn language(&self) -> SyntaxLanguage; + fn supports(&self, language: SyntaxLanguage) -> bool { + language == self.language() + } + fn analyze( + &self, + input: AnalysisInput, + budget: AnalysisBudget, + control: &AnalysisControl, + ) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn nz(value: u32) -> NonZeroU32 { + NonZeroU32::new(value).unwrap() + } + fn nz64(value: u64) -> NonZeroU64 { + NonZeroU64::new(value).unwrap() + } + fn range(start: u64, end: u64, start_line: u32, end_line: u32) -> SourceRange { + SourceRange::new(start, end, nz(start_line), nz(end_line)).unwrap() + } + fn provenance() -> SyntaxProvenance { + SyntaxProvenance::tree_sitter(SyntaxLanguage::Rust, "tree-sitter-rust@0.24").unwrap() + } + fn symbol() -> SymbolFact { + SymbolFact::new( + provenance(), + SymbolKey::new(vec!["worker".into()], SymbolKind::Function, "run").unwrap(), + SymbolVisibility::Public, + range(0, 30, 1, 3), + range(0, 12, 1, 1), + Some(range(13, 30, 1, 3)), + "pub fn run()", + 0, + 1, + 0, + ) + .unwrap() + } + + fn call(argument_text: impl Into) -> CallFact { + CallFact::new( + provenance(), + "work", + argument_text, + range(4, 96, 1, 1), + range(0, 100, 1, 1), + Some(SymbolKey::new(vec!["worker".into()], SymbolKind::Function, "run").unwrap()), + vec![ + ControlContext::Condition, + ControlContext::Other("guard".into()), + ], + ) + .unwrap() + } + + #[test] + fn validates_utf8_and_wire_ranges() { + assert!(range(1, 3, 1, 1).validate_source("aéz").is_ok()); + assert!(range(2, 3, 1, 1).validate_source("aéz").is_err()); + assert!( + serde_json::from_value::( + json!({"start_byte": 3, "end_byte": 2, "start_line": 1, "end_line": 1}) + ) + .is_err() + ); + assert_eq!( + SourceRange::from_tree_sitter(0, 8, 0, 2, 0) + .unwrap() + .end_line() + .get(), + 2 + ); + assert!( + serde_json::from_value::( + json!({"start_byte": 0, "end_byte": 2, "start_line": 0, "end_line": 1}) + ) + .is_err() + ); + } + + #[test] + fn invalid_nested_wire_models_are_rejected() { + assert!( + serde_json::from_value::(json!({"language":"rust","parser":""})) + .is_err() + ); + assert!( + serde_json::from_value::( + json!({"qualified_path":[""],"kind":"function","name":"run"}) + ) + .is_err() + ); + let mut value = serde_json::to_value(symbol()).unwrap(); + value["signature_range"]["end_byte"] = json!(99); + assert!(serde_json::from_value::(value).is_err()); + assert!( + serde_json::from_value::(json!({ + "severity": "error", + "message": "", + "range": null + })) + .is_err() + ); + } + + #[test] + fn failed_documents_reject_successful_facts_on_the_wire() { + let value = json!({"path":"src/lib.rs","provenance":provenance(),"status":"failed","symbols":[symbol()],"calls":[],"diagnostics":[],"truncation":null}); + assert!(serde_json::from_value::(value).is_err()); + } + + #[test] + fn document_structure_serde_round_trips() { + let document = DocumentStructure::new( + "src/lib.rs", + provenance(), + DocumentStatus::Parsed, + vec![symbol()], + Vec::new(), + Vec::new(), + None, + ) + .unwrap(); + let json = serde_json::to_string(&document).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + document + ); + } + + #[test] + fn truncation_and_status_evidence_are_validated_on_the_wire() { + assert!( + serde_json::from_value::(json!({ + "reason": "source_bytes", "limit": 100, "observed": 99 + })) + .is_err() + ); + assert!( + serde_json::from_value::(json!({ + "reason": "cancelled", "limit": 100, "observed": 100 + })) + .is_err() + ); + + let failed_without_error = json!({ + "path":"src/lib.rs", "provenance":provenance(), "status":"failed", + "symbols":[], "calls":[], "diagnostics":[], "truncation":null + }); + assert!(serde_json::from_value::(failed_without_error).is_err()); + + let partial_with_info = json!({ + "path":"src/lib.rs", "provenance":provenance(), "status":"partial", + "symbols":[], "calls":[], + "diagnostics":[{"severity":"info","message":"note","range":null}], + "truncation":null + }); + assert!(serde_json::from_value::(partial_with_info).is_err()); + + let partial_with_limit = json!({ + "path":"src/lib.rs", "provenance":provenance(), "status":"partial", + "symbols":[], "calls":[], "diagnostics":[], + "truncation":{"reason":"call_count","limit":10,"observed":10} + }); + assert!(serde_json::from_value::(partial_with_limit).is_ok()); + } + + #[test] + fn capture_byte_truncation_has_validated_golden_wire_evidence() { + assert!( + serde_json::from_value::(json!({ + "reason": "capture_bytes", "limit": 100, "observed": 100 + })) + .is_err() + ); + assert!( + serde_json::from_value::(json!({ + "reason": "capture_bytes", "limit": 0, "observed": 1 + })) + .is_err() + ); + let truncation = + SyntaxTruncation::new(SyntaxTruncationReason::CaptureBytes, Some(100), Some(101)) + .unwrap(); + assert_eq!( + serde_json::to_string(&truncation).unwrap(), + r#"{"reason":"capture_bytes","limit":100,"observed":101}"# + ); + assert_eq!( + serde_json::from_str::( + r#"{"reason":"capture_bytes","limit":100,"observed":101}"# + ) + .unwrap(), + truncation + ); + } + + #[test] + fn owned_byte_estimates_count_each_retained_string_copy() { + let provenance = provenance(); + let symbol = symbol(); + let call = call("(nested(value))"); + let diagnostic = + SyntaxDiagnostic::new(DiagnosticSeverity::Info, "parser note", None).unwrap(); + assert_eq!( + provenance.estimated_owned_bytes(), + provenance.parser().len() as u64 + ); + assert_eq!( + symbol.key().estimated_owned_bytes(), + ("worker".len() + "run".len()) as u64 + ); + assert_eq!( + symbol.estimated_owned_bytes(), + (provenance.parser().len() + "worker".len() + "run".len() + "pub fn run()".len()) + as u64 + ); + assert_eq!( + call.estimated_owned_bytes(), + (provenance.parser().len() + + "work".len() + + "(nested(value))".len() + + "worker".len() + + "run".len() + + "guard".len()) as u64 + ); + assert_eq!( + diagnostic.estimated_owned_bytes(), + "parser note".len() as u64 + ); + + let expected = "src/lib.rs".len() as u64 + + provenance.estimated_owned_bytes() + + symbol.estimated_owned_bytes() + + call.estimated_owned_bytes() + + diagnostic.estimated_owned_bytes(); + let mut tracker = + CaptureByteTracker::for_document(nz64(expected), "src/lib.rs", &provenance).unwrap(); + tracker.try_account_symbol(&symbol).unwrap(); + tracker.try_account_call(&call).unwrap(); + tracker.try_account_diagnostic(&diagnostic).unwrap(); + assert_eq!(tracker.retained_bytes(), expected); + let document = DocumentStructure::new( + "src/lib.rs", + provenance, + DocumentStatus::Parsed, + vec![symbol], + vec![call], + vec![diagnostic], + None, + ) + .unwrap(); + assert_eq!(document.estimated_owned_bytes(), expected); + } + + #[test] + fn overlapping_call_arguments_are_bounded_independently_of_fact_count() { + let outer_argument = format!("({})", "nested(".repeat(64)); + let inner_argument = outer_argument[1..].to_string(); + let outer = call(outer_argument.clone()); + let inner = call(inner_argument.clone()); + assert_eq!(outer.argument_text().len(), outer_argument.len()); + assert_eq!(inner.argument_text().len(), inner_argument.len()); + assert!(outer.argument_text().len() + inner.argument_text().len() > outer_argument.len()); + + let provenance = provenance(); + let base = string_owned_bytes("src/lib.rs") + provenance.estimated_owned_bytes(); + let exact = base + outer.estimated_owned_bytes() + inner.estimated_owned_bytes(); + let mut exact_tracker = + CaptureByteTracker::for_document(nz64(exact), "src/lib.rs", &provenance).unwrap(); + exact_tracker.try_account_call(&outer).unwrap(); + exact_tracker.try_account_call(&inner).unwrap(); + assert_eq!(exact_tracker.retained_bytes(), exact); + + let mut short_tracker = + CaptureByteTracker::for_document(nz64(exact - 1), "src/lib.rs", &provenance).unwrap(); + short_tracker.try_account_call(&outer).unwrap(); + let truncation = short_tracker.try_account_call(&inner).unwrap_err(); + assert_eq!(truncation.reason(), SyntaxTruncationReason::CaptureBytes); + assert_eq!(truncation.limit(), Some(exact - 1)); + assert_eq!(truncation.observed(), Some(exact)); + } + + #[test] + fn capture_accounting_saturates_evidence_and_never_accepts_overflow() { + assert_eq!(saturating_owned_sum([u64::MAX, 1]), u64::MAX); + let mut tracker = CaptureByteTracker::new(nz64(u64::MAX)); + tracker.try_account(u64::MAX).unwrap(); + let truncation = tracker.try_account(1).unwrap_err(); + assert_eq!(truncation.limit(), Some(u64::MAX)); + assert_eq!(truncation.observed(), Some(u64::MAX)); + assert_eq!(tracker.retained_bytes(), u64::MAX); + assert!( + SyntaxTruncation::new( + SyntaxTruncationReason::CaptureBytes, + Some(u64::MAX), + Some(u64::MAX), + ) + .is_ok() + ); + } + + #[test] + fn analysis_control_reports_configured_and_elapsed_microseconds() { + assert_eq!( + AnalysisControl::new(nz64(100)).time_limit_micros(), + nz64(100) + ); + let started_at = Instant::now(); + let control = AnalysisControl::new_at(started_at, nz64(100)); + let before_deadline = started_at.checked_add(Duration::from_micros(40)).unwrap(); + + assert_eq!(control.time_limit_micros(), nz64(100)); + assert_eq!(control.elapsed_micros(before_deadline), 40); + assert!(!control.deadline_exceeded(before_deadline)); + assert!(!control.should_stop(before_deadline)); + assert_eq!(control.stop_truncation(before_deadline).unwrap(), None); + } + + #[test] + fn expired_control_reports_truthful_time_truncation() { + let started_at = Instant::now(); + let control = AnalysisControl::new_at(started_at, nz64(100)); + let after_deadline = started_at.checked_add(Duration::from_micros(175)).unwrap(); + + assert!(control.deadline_exceeded(after_deadline)); + assert!(control.should_stop(after_deadline)); + let truncation = control.stop_truncation(after_deadline).unwrap().unwrap(); + assert_eq!(truncation.reason(), SyntaxTruncationReason::Time); + assert_eq!(truncation.limit(), Some(100)); + assert_eq!(truncation.observed(), Some(175)); + } + + #[test] + fn cancellation_before_deadline_is_the_shared_first_stop_cause() { + let started_at = Instant::now(); + let control = AnalysisControl::new_at(started_at, nz64(100)); + let cloned = control.clone(); + let cancelled_at = started_at.checked_add(Duration::from_micros(40)).unwrap(); + let observed_at = started_at.checked_add(Duration::from_micros(175)).unwrap(); + cloned.cancel_at(cancelled_at); + + assert!(control.is_cancelled()); + assert!(control.should_stop(observed_at)); + let truncation = control.stop_truncation(observed_at).unwrap().unwrap(); + assert_eq!( + cloned.stop_truncation(observed_at).unwrap().as_ref(), + Some(&truncation) + ); + assert_eq!(truncation.reason(), SyntaxTruncationReason::Cancelled); + assert_eq!(truncation.limit(), None); + assert_eq!(truncation.observed(), None); + } + + #[test] + fn deadline_before_cancellation_remains_the_first_stop_cause() { + let started_at = Instant::now(); + let control = AnalysisControl::new_at(started_at, nz64(100)); + control.cancel_at(started_at.checked_add(Duration::from_micros(150)).unwrap()); + let observed_at = started_at.checked_add(Duration::from_micros(175)).unwrap(); + + let truncation = control.stop_truncation(observed_at).unwrap().unwrap(); + assert_eq!(truncation.reason(), SyntaxTruncationReason::Time); + assert_eq!(truncation.limit(), Some(100)); + assert_eq!(truncation.observed(), Some(175)); + } + + #[test] + fn cancellation_at_deadline_deterministically_reports_time() { + let started_at = Instant::now(); + let control = AnalysisControl::new_at(started_at, nz64(100)); + let boundary = started_at.checked_add(Duration::from_micros(100)).unwrap(); + control.cancel_at(boundary); + + let truncation = control.stop_truncation(boundary).unwrap().unwrap(); + assert_eq!(truncation.reason(), SyntaxTruncationReason::Time); + assert_eq!(truncation.limit(), Some(100)); + assert_eq!(truncation.observed(), Some(100)); + } + + #[test] + fn analysis_control_tests_overflow_fallback_and_fixed_width_saturation() { + assert_eq!(duration_micros(Duration::MAX), u64::MAX); + let now = Instant::now(); + assert!(!time_limit_reached(None, now, 99, nz64(100))); + assert!(time_limit_reached(None, now, 100, nz64(100))); + } + + #[test] + fn time_truncation_requires_matching_positive_microsecond_evidence() { + assert!(SyntaxTruncation::new(SyntaxTruncationReason::Time, Some(100), Some(100)).is_ok()); + assert!(SyntaxTruncation::new(SyntaxTruncationReason::Time, Some(100), Some(99)).is_err()); + assert!(SyntaxTruncation::new(SyntaxTruncationReason::Time, None, None).is_err()); + assert!( + SyntaxTruncation::new(SyntaxTruncationReason::DiagnosticCount, Some(64), Some(65)) + .is_ok() + ); + assert!( + SyntaxTruncation::new(SyntaxTruncationReason::DiagnosticCount, Some(64), Some(63)) + .is_err() + ); + } + + #[test] + fn analysis_budget_exposes_every_positive_fixed_width_limit() { + let budget = AnalysisBudget::new(nz64(1_000), nz(10), nz(20), nz(30)); + assert_eq!(budget.max_source_bytes(), nz64(1_000)); + assert_eq!(budget.max_capture_bytes(), nz64(1_000)); + assert_eq!(budget.max_symbols(), nz(10)); + assert_eq!(budget.max_calls(), nz(20)); + assert_eq!(budget.max_diagnostics(), nz(30)); + + let overridden = budget.with_max_capture_bytes(nz64(750)); + assert_eq!(overridden.max_source_bytes(), nz64(1_000)); + assert_eq!(overridden.max_capture_bytes(), nz64(750)); + } + + #[test] + fn syntax_adapter_default_support_matches_its_primary_language() { + struct RustOnly; + impl SyntaxAdapter for RustOnly { + fn language(&self) -> SyntaxLanguage { + SyntaxLanguage::Rust + } + + fn analyze( + &self, + _input: AnalysisInput, + _budget: AnalysisBudget, + _control: &AnalysisControl, + ) -> Result { + Err(ModelError::new("not used by this contract test")) + } + } + + assert!(RustOnly.supports(SyntaxLanguage::Rust)); + assert!(!RustOnly.supports(SyntaxLanguage::TypeScript)); + assert!(!RustOnly.supports(SyntaxLanguage::Tsx)); + } +} diff --git a/crates/okena-syntax/src/rust/mod.rs b/crates/okena-syntax/src/rust/mod.rs new file mode 100644 index 000000000..254e15f4e --- /dev/null +++ b/crates/okena-syntax/src/rust/mod.rs @@ -0,0 +1,1533 @@ +//! Rust tree-sitter adapter. + +use crate::{ + AnalysisBudget, AnalysisControl, AnalysisInput, CallFact, CaptureByteTracker, ControlContext, + DiagnosticSeverity, DocumentStatus, DocumentStructure, ModelError, SourceRange, SymbolFact, + SymbolKey, SymbolKind, SymbolVisibility, SyntaxAdapter, SyntaxDiagnostic, SyntaxLanguage, + SyntaxProvenance, SyntaxTruncation, SyntaxTruncationReason, +}; +use std::ops::ControlFlow; +use std::time::Instant; +use tree_sitter::{Node, ParseOptions, Parser, Point}; + +const PARSER_NAME: &str = "tree-sitter-rust@0.24.2"; + +#[derive(Clone, Copy, Debug, Default)] +pub struct RustAdapter; + +impl RustAdapter { + pub fn new() -> Self { + Self + } + + fn failed( + path: &str, + message: &str, + provenance: SyntaxProvenance, + mut capture: CaptureByteTracker, + control: &AnalysisControl, + ) -> Result { + let diagnostic = SyntaxDiagnostic::new(DiagnosticSeverity::Error, message, None)?; + let observed_at = Instant::now(); + let (status, diagnostics, truncation) = + if let Some(truncation) = control.stop_truncation(observed_at)? { + (DocumentStatus::Partial, Vec::new(), Some(truncation)) + } else { + let observed_at = Instant::now(); + match capture.try_account_diagnostic(&diagnostic) { + Ok(()) => (DocumentStatus::Failed, vec![diagnostic], None), + Err(truncation) => ( + DocumentStatus::Partial, + Vec::new(), + Some(first_cause(control, truncation, observed_at)?), + ), + } + }; + let retained_bytes = capture.retained_bytes(); + let document = DocumentStructure::new( + path, + provenance, + status, + Vec::new(), + Vec::new(), + diagnostics, + truncation, + )?; + debug_assert_eq!(document.estimated_owned_bytes(), retained_bytes); + Ok(document) + } +} + +impl SyntaxAdapter for RustAdapter { + fn language(&self) -> SyntaxLanguage { + SyntaxLanguage::Rust + } + + fn analyze( + &self, + input: AnalysisInput, + budget: AnalysisBudget, + control: &AnalysisControl, + ) -> Result { + let provenance = provenance()?; + if let Some(truncation) = control.stop_truncation(Instant::now())? { + return truncated_document(&input, provenance, truncation); + } + let source_bytes = u64::try_from(input.source().len()).unwrap_or(u64::MAX); + let observed_at = Instant::now(); + if source_bytes > budget.max_source_bytes().get() { + let truncation = SyntaxTruncation::new( + SyntaxTruncationReason::SourceBytes, + Some(budget.max_source_bytes().get()), + Some(source_bytes), + )?; + return truncated_document( + &input, + provenance, + first_cause(control, truncation, observed_at)?, + ); + } + let observed_at = Instant::now(); + let capture = match CaptureByteTracker::for_document( + budget.max_capture_bytes(), + input.path(), + &provenance, + ) { + Ok(capture) => capture, + Err(truncation) => { + return truncated_document( + &input, + provenance, + first_cause(control, truncation, observed_at)?, + ); + } + }; + if input.language() != SyntaxLanguage::Rust { + return Self::failed( + input.path(), + "Rust adapter received a non-Rust document", + provenance, + capture, + control, + ); + } + + let mut parser = Parser::new(); + if parser + .set_language(&tree_sitter_rust::LANGUAGE.into()) + .is_err() + { + return Self::failed( + input.path(), + "tree-sitter rejected the Rust grammar", + provenance, + capture, + control, + ); + } + let source = input.source().as_bytes(); + let mut parse_stopped = false; + let mut progress = |_: &tree_sitter::ParseState| { + if control.should_stop(Instant::now()) { + parse_stopped = true; + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }; + let options = ParseOptions::new().progress_callback(&mut progress); + let Some(tree) = parser.parse_with_options( + &mut |offset, _| source.get(offset..).unwrap_or_default(), + None, + Some(options), + ) else { + if let Some(truncation) = control.stop_truncation(Instant::now())? { + return truncated_document(&input, provenance, truncation); + } + return Self::failed( + input.path(), + "tree-sitter did not produce a Rust tree", + provenance, + capture, + control, + ); + }; + if parse_stopped && let Some(truncation) = control.stop_truncation(Instant::now())? { + return truncated_document(&input, provenance, truncation); + } + + let mut engine = Engine { + source: input.source(), + provenance: provenance.clone(), + budget, + control, + capture, + symbols: Vec::new(), + calls: Vec::new(), + truncation: None, + }; + engine.extract(tree.root_node())?; + + let mut diagnostics = Vec::new(); + if engine.truncation.is_none() + && tree.root_node().has_error() + && let Some(error) = engine.first_error(tree.root_node())? + { + let diagnostic = SyntaxDiagnostic::new( + DiagnosticSeverity::Error, + "Rust syntax contains an error or missing token", + node_range(error), + )?; + if !engine.should_stop()? { + let observed_at = Instant::now(); + match engine.capture.try_account_diagnostic(&diagnostic) { + Ok(()) => diagnostics.push(diagnostic), + Err(truncation) => engine.latch_local(truncation, observed_at)?, + } + } + } + let status = if engine.truncation.is_some() || !diagnostics.is_empty() { + DocumentStatus::Partial + } else { + DocumentStatus::Parsed + }; + let retained_bytes = engine.capture.retained_bytes(); + let document = DocumentStructure::new( + input.path(), + provenance, + status, + engine.symbols, + engine.calls, + diagnostics, + engine.truncation, + )?; + debug_assert_eq!(document.estimated_owned_bytes(), retained_bytes); + Ok(document) + } +} + +fn provenance() -> Result { + SyntaxProvenance::tree_sitter(SyntaxLanguage::Rust, PARSER_NAME) +} + +fn truncated_document( + input: &AnalysisInput, + provenance: SyntaxProvenance, + truncation: SyntaxTruncation, +) -> Result { + DocumentStructure::new( + input.path(), + provenance, + DocumentStatus::Partial, + Vec::new(), + Vec::new(), + Vec::new(), + Some(truncation), + ) +} + +fn first_cause( + control: &AnalysisControl, + local: SyntaxTruncation, + observed_at: Instant, +) -> Result { + Ok(control.stop_truncation(observed_at)?.unwrap_or(local)) +} + +#[derive(Clone, Default)] +struct WalkContext { + path: Vec, + enclosing_symbol: Option, + method_parent: bool, + member_context: Option, + controls: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MemberContext { + Trait, + TraitImpl, + InherentImpl, + Inherited, +} + +struct Engine<'a> { + source: &'a str, + provenance: SyntaxProvenance, + budget: AnalysisBudget, + control: &'a AnalysisControl, + capture: CaptureByteTracker, + symbols: Vec, + calls: Vec, + truncation: Option, +} + +impl Engine<'_> { + fn extract(&mut self, root: Node<'_>) -> Result<(), ModelError> { + let mut stack = vec![(root, WalkContext::default())]; + while let Some((node, context)) = stack.pop() { + if self.should_stop()? { + break; + } + + let mut child_context = context.clone(); + if let Some(spec) = symbol_spec(node, &context, self.source) { + if self.symbols.len() >= usize_from_u32(self.budget.max_symbols().get()) { + let observed_at = Instant::now(); + let truncation = SyntaxTruncation::new( + SyntaxTruncationReason::SymbolCount, + Some(u64::from(self.budget.max_symbols().get())), + Some(u64::from(self.budget.max_symbols().get()) + 1), + )?; + self.latch_local(truncation, observed_at)?; + break; + } + let Some(depth) = self.syntactic_nesting_depth(body_node(node).unwrap_or(node))? + else { + break; + }; + let Some(parameters) = self.parameter_count(node)? else { + break; + }; + let Some(members) = self.type_member_count(node)? else { + break; + }; + let metrics = SymbolMetrics { + syntactic_nesting_depth: depth, + parameter_count: parameters, + type_member_count: members, + }; + if let Some(fact) = make_symbol( + node, + &spec, + &context, + self.source, + &self.provenance, + metrics, + )? { + if self.should_stop()? { + break; + } + let observed_at = Instant::now(); + if let Err(truncation) = self.capture.try_account_symbol(&fact) { + self.latch_local(truncation, observed_at)?; + break; + } + child_context.path.push(spec.name.clone()); + child_context.enclosing_symbol = Some(fact.key().clone()); + child_context.method_parent = + matches!(spec.kind, SymbolKind::Trait | SymbolKind::Impl); + child_context.member_context = member_context(node, spec.kind); + self.symbols.push(fact); + } + } + + if let Some(call) = make_call(node, &context, self.source, &self.provenance)? { + if self.calls.len() >= usize_from_u32(self.budget.max_calls().get()) { + let observed_at = Instant::now(); + let truncation = SyntaxTruncation::new( + SyntaxTruncationReason::CallCount, + Some(u64::from(self.budget.max_calls().get())), + Some(u64::from(self.budget.max_calls().get()) + 1), + )?; + self.latch_local(truncation, observed_at)?; + break; + } + if self.should_stop()? { + break; + } + let observed_at = Instant::now(); + if let Err(truncation) = self.capture.try_account_call(&call) { + self.latch_local(truncation, observed_at)?; + break; + } + self.calls.push(call); + } + + for index in (0..node.named_child_count()).rev() { + if self.should_stop()? { + break; + } + let Ok(child_index) = u32::try_from(index) else { + continue; + }; + let Some(child) = node.named_child(child_index) else { + continue; + }; + let mut next = child_context.clone(); + if !self.add_control_context(node, child_index, &mut next.controls)? { + break; + } + stack.push((child, next)); + } + } + Ok(()) + } + + fn parameter_count(&mut self, node: Node<'_>) -> Result, ModelError> { + let Some(parameters) = node.child_by_field_name("parameters") else { + return Ok(Some(0)); + }; + let mut count = 0_u32; + for _ in 0..parameters.named_child_count() { + if self.should_stop()? { + return Ok(None); + } + count = count.saturating_add(1); + } + Ok(Some(count)) + } + + fn type_member_count(&mut self, node: Node<'_>) -> Result, ModelError> { + let Some(body) = body_node(node) else { + return Ok(Some(0)); + }; + let mut count = 0_u32; + for index in 0..body.named_child_count() { + if self.should_stop()? { + return Ok(None); + } + let Ok(index) = u32::try_from(index) else { + continue; + }; + if body.named_child(index).is_some_and(|child| { + matches!( + child.kind(), + "field_declaration" + | "enum_variant" + | "function_item" + | "function_signature_item" + | "const_item" + | "type_item" + ) + }) { + count = count.saturating_add(1); + } + } + Ok(Some(count)) + } + + fn syntactic_nesting_depth(&mut self, root: Node<'_>) -> Result, ModelError> { + let mut maximum = 0_u32; + let mut stack = vec![(root, 0_u32)]; + while let Some((node, depth)) = stack.pop() { + if self.should_stop()? { + return Ok(None); + } + let next_depth = if nesting_node(node) { + depth.saturating_add(1) + } else { + depth + }; + maximum = maximum.max(next_depth); + for index in (0..node.named_child_count()).rev() { + if self.should_stop()? { + return Ok(None); + } + let Ok(index) = u32::try_from(index) else { + continue; + }; + let Some(child) = node.named_child(index) else { + continue; + }; + if symbol_kind_node(child) { + continue; + } + stack.push((child, next_depth)); + } + } + Ok(Some(maximum)) + } + + fn first_error<'tree>(&mut self, root: Node<'tree>) -> Result>, ModelError> { + let mut stack = vec![root]; + while let Some(node) = stack.pop() { + if self.should_stop()? { + return Ok(None); + } + if node.is_error() || node.is_missing() { + return Ok(Some(node)); + } + for index in (0..node.named_child_count()).rev() { + if self.should_stop()? { + return Ok(None); + } + let Ok(index) = u32::try_from(index) else { + continue; + }; + if let Some(child) = node.named_child(index) { + stack.push(child); + } + } + } + Ok(None) + } + + fn add_control_context( + &mut self, + parent: Node<'_>, + child_index: u32, + controls: &mut Vec, + ) -> Result { + let field = parent.field_name_for_named_child(child_index); + match parent.kind() { + "if_expression" if field == Some("condition") => { + controls.push(ControlContext::Condition); + } + "while_expression" => { + controls.push(ControlContext::Loop); + if field == Some("condition") { + controls.push(ControlContext::Condition); + } + } + "for_expression" | "loop_expression" => controls.push(ControlContext::Loop), + "match_arm" => { + controls.push(ControlContext::MatchArm); + let Some(is_error) = self.is_err_match_arm(parent)? else { + return Ok(false); + }; + if is_error { + controls.push(ControlContext::ErrorBranch); + } + } + "closure_expression" => controls.push(ControlContext::Closure), + _ => {} + } + Ok(true) + } + + fn is_err_match_arm(&mut self, arm: Node<'_>) -> Result, ModelError> { + let Some(pattern) = arm.child_by_field_name("pattern") else { + return Ok(Some(false)); + }; + let mut stack = vec![pattern]; + while let Some(node) = stack.pop() { + if self.should_stop()? { + return Ok(None); + } + match node.kind() { + "tuple_struct_pattern" | "struct_pattern" => { + if node + .child_by_field_name("type") + .is_some_and(|node| path_ends_in_err(node, self.source)) + { + return Ok(Some(true)); + } + continue; + } + "identifier" | "scoped_identifier" if path_ends_in_err(node, self.source) => { + return Ok(Some(true)); + } + _ => {} + } + for index in (0..node.named_child_count()).rev() { + if self.should_stop()? { + return Ok(None); + } + let Ok(index) = u32::try_from(index) else { + continue; + }; + if let Some(child) = node.named_child(index) { + stack.push(child); + } + } + } + Ok(Some(false)) + } + + fn should_stop(&mut self) -> Result { + if self.truncation.is_some() { + return Ok(true); + } + if let Some(truncation) = self.control.stop_truncation(Instant::now())? { + self.truncation = Some(truncation); + return Ok(true); + } + Ok(false) + } + + fn latch_local( + &mut self, + truncation: SyntaxTruncation, + observed_at: Instant, + ) -> Result<(), ModelError> { + self.truncation = Some(first_cause(self.control, truncation, observed_at)?); + Ok(()) + } +} + +struct SymbolSpec { + kind: SymbolKind, + name: String, +} + +#[derive(Clone, Copy)] +struct SymbolMetrics { + syntactic_nesting_depth: u32, + parameter_count: u32, + type_member_count: u32, +} + +fn symbol_spec(node: Node<'_>, context: &WalkContext, source: &str) -> Option { + let kind = match node.kind() { + "mod_item" => SymbolKind::Module, + "struct_item" => SymbolKind::Struct, + "enum_item" => SymbolKind::Enum, + "union_item" => SymbolKind::Union, + "trait_item" => SymbolKind::Trait, + "impl_item" => SymbolKind::Impl, + "type_item" => SymbolKind::TypeAlias, + "const_item" => SymbolKind::Constant, + "static_item" => SymbolKind::Static, + "field_declaration" => SymbolKind::Field, + "enum_variant" => SymbolKind::Variant, + "function_item" | "function_signature_item" if context.method_parent => SymbolKind::Method, + "function_item" | "function_signature_item" => SymbolKind::Function, + _ => return None, + }; + let name = if kind == SymbolKind::Impl { + let target = node + .child_by_field_name("type") + .and_then(|node| text(node, source))?; + if let Some(trait_node) = node.child_by_field_name("trait") { + format!( + "impl {} for {}", + text(trait_node, source)?.trim(), + target.trim() + ) + } else { + format!("impl {}", target.trim()) + } + } else { + text(node.child_by_field_name("name")?, source)? + .trim() + .to_owned() + }; + Some(SymbolSpec { kind, name }) +} + +fn make_symbol( + node: Node<'_>, + spec: &SymbolSpec, + context: &WalkContext, + source: &str, + provenance: &SyntaxProvenance, + metrics: SymbolMetrics, +) -> Result, ModelError> { + let Some(full_range) = node_range(node) else { + return Ok(None); + }; + let body = body_node(node); + let body_range = body.and_then(node_range); + let signature_range = body + .and_then(|body| { + range_between( + node.start_byte(), + body.start_byte(), + node.start_position(), + body.start_position(), + ) + }) + .unwrap_or(full_range); + let normalized_signature = text_for_range(signature_range, source) + .map(normalize) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| spec.name.clone()); + let key = SymbolKey::new(context.path.clone(), spec.kind, spec.name.clone())?; + SymbolFact::new( + provenance.clone(), + key, + visibility(node, spec.kind, context.member_context, source), + full_range, + signature_range, + body_range, + normalized_signature, + metrics.parameter_count, + metrics.syntactic_nesting_depth, + metrics.type_member_count, + ) + .map(Some) +} + +fn make_call( + node: Node<'_>, + context: &WalkContext, + source: &str, + provenance: &SyntaxProvenance, +) -> Result, ModelError> { + let (callee, arguments) = match node.kind() { + "call_expression" => ( + node.child_by_field_name("function"), + node.child_by_field_name("arguments"), + ), + "macro_invocation" => ( + node.child_by_field_name("macro"), + first_named_child_of_kind(node, "token_tree"), + ), + _ => return Ok(None), + }; + let (Some(callee), Some(arguments)) = (callee, arguments) else { + return Ok(None); + }; + let Some(argument_range) = node_range(arguments) else { + return Ok(None); + }; + let Some(call_site_range) = node_range(node) else { + return Ok(None); + }; + let Some(mut callee_text) = text(callee, source) else { + return Ok(None); + }; + if node.kind() == "macro_invocation" { + callee_text.push('!'); + } + Ok(Some(CallFact::new( + provenance.clone(), + callee_text, + text(arguments, source).unwrap_or_default(), + argument_range, + call_site_range, + context.enclosing_symbol.clone(), + context.controls.clone(), + )?)) +} + +fn body_node(node: Node<'_>) -> Option> { + node.child_by_field_name("body").or_else(|| { + if matches!(node.kind(), "const_item" | "static_item") { + node.child_by_field_name("value") + } else { + None + } + }) +} + +fn member_context(node: Node<'_>, kind: SymbolKind) -> Option { + match kind { + SymbolKind::Trait => Some(MemberContext::Trait), + SymbolKind::Impl if node.child_by_field_name("trait").is_some() => { + Some(MemberContext::TraitImpl) + } + SymbolKind::Impl => Some(MemberContext::InherentImpl), + SymbolKind::Variant => Some(MemberContext::Inherited), + _ => None, + } +} + +fn visibility( + node: Node<'_>, + kind: SymbolKind, + member_context: Option, + source: &str, +) -> SymbolVisibility { + if matches!(kind, SymbolKind::Impl | SymbolKind::Variant) + || matches!( + member_context, + Some(MemberContext::Trait | MemberContext::TraitImpl | MemberContext::Inherited) + ) + { + return SymbolVisibility::Unknown; + } + let mut cursor = node.walk(); + let modifier = node + .named_children(&mut cursor) + .find(|child| child.kind() == "visibility_modifier") + .and_then(|child| text(child, source)); + match modifier.as_deref().map(str::trim) { + Some("pub") => SymbolVisibility::Public, + Some(_) => SymbolVisibility::Restricted, + None => SymbolVisibility::Private, + } +} + +fn nesting_node(node: Node<'_>) -> bool { + matches!( + node.kind(), + "if_expression" + | "match_expression" + | "while_expression" + | "for_expression" + | "loop_expression" + | "closure_expression" + ) +} + +fn symbol_kind_node(node: Node<'_>) -> bool { + matches!( + node.kind(), + "mod_item" + | "struct_item" + | "enum_item" + | "union_item" + | "trait_item" + | "impl_item" + | "type_item" + | "const_item" + | "static_item" + | "function_item" + | "function_signature_item" + ) +} + +fn path_ends_in_err(mut node: Node<'_>, source: &str) -> bool { + loop { + match node.kind() { + "identifier" => return text(node, source).is_some_and(|name| name == "Err"), + "scoped_identifier" => { + let Some(name) = node.child_by_field_name("name") else { + return false; + }; + node = name; + } + _ => return false, + } + } +} + +fn first_named_child_of_kind<'tree>(node: Node<'tree>, kind: &str) -> Option> { + let mut cursor = node.walk(); + node.named_children(&mut cursor) + .find(|child| child.kind() == kind) +} + +fn text(node: Node<'_>, source: &str) -> Option { + source + .get(node.start_byte()..node.end_byte()) + .map(str::to_owned) +} + +fn text_for_range(range: SourceRange, source: &str) -> Option<&str> { + let start = usize::try_from(range.start_byte()).ok()?; + let end = usize::try_from(range.end_byte()).ok()?; + source.get(start..end) +} + +fn normalize(source: &str) -> String { + source.split_whitespace().collect::>().join(" ") +} + +fn node_range(node: Node<'_>) -> Option { + range_between( + node.start_byte(), + node.end_byte(), + node.start_position(), + node.end_position(), + ) +} + +fn range_between( + start_byte: usize, + end_byte: usize, + start: Point, + end: Point, +) -> Option { + SourceRange::from_tree_sitter( + u64::try_from(start_byte).ok()?, + u64::try_from(end_byte).ok()?, + u32::try_from(start.row).ok()?, + u32::try_from(end.row).ok()?, + u32::try_from(end.column).ok()?, + ) + .ok() +} + +fn usize_from_u32(value: u32) -> usize { + usize::try_from(value).unwrap_or(usize::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::num::{NonZeroU32, NonZeroU64}; + use std::time::Duration; + + fn budget(bytes: u64, symbols: u32, calls: u32) -> AnalysisBudget { + AnalysisBudget::new( + NonZeroU64::new(bytes).unwrap(), + NonZeroU32::new(symbols).unwrap(), + NonZeroU32::new(calls).unwrap(), + NonZeroU32::new(64).unwrap(), + ) + } + + fn analyze(source: &str) -> DocumentStructure { + analyze_with_budget( + source, + budget(100_000, 1_000, 1_000), + &AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()), + ) + } + + fn analyze_with_budget( + source: &str, + budget: AnalysisBudget, + control: &AnalysisControl, + ) -> DocumentStructure { + RustAdapter::new() + .analyze( + AnalysisInput::new("src/lib.rs", SyntaxLanguage::Rust, source.to_owned()).unwrap(), + budget, + control, + ) + .unwrap() + } + + #[test] + fn rust_extracts_hierarchy_signatures_visibility_and_ranges() { + let source = r#" +// Unicode before symbols: žluťoučký +pub(crate) mod outer { + pub struct Boxed + where T: Clone + { + pub value: T, + } + + enum Choice { One, Two(u32) } + union Bits { integer: u32, float: f32 } + type Alias = Option; + const LIMIT: usize = 10; + static ENABLED: bool = true; + + trait Runner { + fn run(&self, value: T) -> Result<(), ()>; + } + + impl Runner for Boxed where T: Clone { + fn run(&self, value: T) -> Result<(), ()> { Ok(()) } + } + + fn host() { fn café(value: u32) { consume(value); } } +} +"#; + let document = analyze(source); + assert_eq!(document.status(), DocumentStatus::Parsed); + for fact in document.symbols() { + fact.full_range().validate_source(source).unwrap(); + fact.signature_range().validate_source(source).unwrap(); + if let Some(body) = fact.body_range() { + body.validate_source(source).unwrap(); + } + } + + let outer = document + .symbols() + .iter() + .find(|fact| fact.key().name() == "outer") + .unwrap(); + assert_eq!(outer.visibility(), SymbolVisibility::Restricted); + let boxed = document + .symbols() + .iter() + .find(|fact| fact.key().name() == "Boxed") + .unwrap(); + assert!(boxed.normalized_signature().contains("where T: Clone")); + assert_eq!(boxed.type_member_count(), 1); + let field = document + .symbols() + .iter() + .find(|fact| fact.key().kind() == SymbolKind::Field) + .unwrap(); + assert_eq!(field.key().qualified_path(), &["outer", "Boxed"]); + assert_eq!(field.visibility(), SymbolVisibility::Public); + + let methods: Vec<_> = document + .symbols() + .iter() + .filter(|fact| fact.key().kind() == SymbolKind::Method && fact.key().name() == "run") + .collect(); + assert_eq!(methods.len(), 2); + assert_ne!( + methods[0].key().qualified_path(), + methods[1].key().qualified_path() + ); + assert_eq!(methods[0].parameter_count(), 2); + let nested = document + .symbols() + .iter() + .find(|fact| fact.key().name() == "café") + .unwrap(); + assert_eq!(nested.key().qualified_path(), &["outer", "host"]); + assert!( + document + .symbols() + .iter() + .any(|fact| fact.key().kind() == SymbolKind::Variant) + ); + for kind in [ + SymbolKind::Module, + SymbolKind::Struct, + SymbolKind::Enum, + SymbolKind::Union, + SymbolKind::Trait, + SymbolKind::Impl, + SymbolKind::TypeAlias, + SymbolKind::Constant, + SymbolKind::Static, + ] { + assert!( + document + .symbols() + .iter() + .any(|fact| fact.key().kind() == kind), + "missing {kind:?}" + ); + } + } + + #[test] + fn rust_extracts_calls_macros_and_conservative_control_contexts() { + let source = r#" +fn review(result: Result, items: Vec) { + if ready() { work!(&items); } + for item in items() { consume(item); } + match result { + Err(error) => report(error), + Ok(value) => use_value(value), + } + let callback = |value| transform(value); +} +"#; + let document = analyze(source); + let review = document + .symbols() + .iter() + .find(|fact| fact.key().name() == "review") + .unwrap(); + assert!(review.syntactic_nesting_depth() >= 1); + let call = |name: &str| { + document + .calls() + .iter() + .find(|call| call.callee_text() == name) + .unwrap() + }; + assert!( + call("ready") + .control_context() + .contains(&ControlContext::Condition) + ); + assert!( + !call("work!") + .control_context() + .contains(&ControlContext::Condition) + ); + assert!( + call("items") + .control_context() + .contains(&ControlContext::Loop) + ); + assert!( + call("report") + .control_context() + .contains(&ControlContext::MatchArm) + ); + assert!( + call("report") + .control_context() + .contains(&ControlContext::ErrorBranch) + ); + assert!( + call("transform") + .control_context() + .contains(&ControlContext::Closure) + ); + assert_eq!(call("work!").argument_text(), "(&items)"); + for call in document.calls() { + call.argument_range().validate_source(source).unwrap(); + call.call_site_range().validate_source(source).unwrap(); + assert_eq!(call.enclosing_symbol().unwrap().name(), "review"); + } + } + + #[test] + fn rust_does_not_invent_visibility_for_inherited_members() { + let source = r#" +pub enum PublicChoice { Item { value: u32 } } +pub trait PublicTrait { fn inherited(&self); } +pub struct Value; +impl PublicTrait for Value { fn inherited(&self) {} } +impl Value { pub fn open(&self) {} fn closed(&self) {} } +"#; + let document = analyze(source); + let find = |kind, name: &str, parent: &str| { + document + .symbols() + .iter() + .find(|fact| { + fact.key().kind() == kind + && fact.key().name() == name + && fact + .key() + .qualified_path() + .last() + .is_some_and(|segment| segment == parent) + }) + .unwrap() + }; + assert_eq!( + find(SymbolKind::Variant, "Item", "PublicChoice").visibility(), + SymbolVisibility::Unknown + ); + assert_eq!( + find(SymbolKind::Field, "value", "Item").visibility(), + SymbolVisibility::Unknown + ); + assert_eq!( + find(SymbolKind::Method, "inherited", "PublicTrait").visibility(), + SymbolVisibility::Unknown + ); + let impls: Vec<_> = document + .symbols() + .iter() + .filter(|fact| fact.key().kind() == SymbolKind::Impl) + .collect(); + assert_eq!(impls.len(), 2); + assert!( + impls + .iter() + .all(|fact| fact.visibility() == SymbolVisibility::Unknown) + ); + assert_eq!( + find( + SymbolKind::Method, + "inherited", + "impl PublicTrait for Value" + ) + .visibility(), + SymbolVisibility::Unknown + ); + assert_eq!( + find(SymbolKind::Method, "open", "impl Value").visibility(), + SymbolVisibility::Public + ); + assert_eq!( + find(SymbolKind::Method, "closed", "impl Value").visibility(), + SymbolVisibility::Private + ); + } + + #[test] + fn rust_error_branch_requires_exact_err_path_segment() { + let source = r#" +fn inspect(value: Value) { + match value { + Err(error) => exact(error), + Result::Err(error) => qualified(error), + Erratic(error) => wrong_one(error), + Errata(error) => wrong_two(error), + } +} +"#; + let document = analyze(source); + let has_error_context = |callee: &str| { + document + .calls() + .iter() + .find(|call| call.callee_text() == callee) + .unwrap() + .control_context() + .contains(&ControlContext::ErrorBranch) + }; + assert!(has_error_context("exact")); + assert!(has_error_context("qualified")); + assert!(!has_error_context("wrong_one")); + assert!(!has_error_context("wrong_two")); + } + + #[test] + fn rust_deep_valid_and_broken_trees_do_not_use_the_call_stack() { + let depth = 2_000; + let mut valid = String::from("fn deep() {"); + valid.push_str(&"if true {".repeat(depth)); + valid.push_str("work();"); + valid.push_str(&"}".repeat(depth + 1)); + let document = analyze(&valid); + assert_eq!(document.status(), DocumentStatus::Parsed); + assert_eq!( + document + .symbols() + .iter() + .find(|symbol| symbol.key().name() == "deep") + .unwrap() + .syntactic_nesting_depth(), + u32::try_from(depth).unwrap() + ); + assert!( + document + .calls() + .iter() + .any(|call| call.callee_text() == "work") + ); + + let mut broken = String::from("fn deep() {"); + broken.push_str(&"if true {".repeat(depth)); + broken.push_str("work();"); + broken.push_str(&"}".repeat(depth)); + let document = analyze(&broken); + assert_eq!(document.status(), DocumentStatus::Partial); + assert!( + document + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.severity() == DiagnosticSeverity::Error) + ); + } + + #[test] + fn rust_engine_checks_stop_control_during_large_heap_walk() { + let mut source = String::from("fn work() {"); + source.push_str(&"call();".repeat(50_000)); + source.push('}'); + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_rust::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(&source, None).unwrap(); + + let cancelled = AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()); + let cancellation_signal = cancelled.clone(); + let canceller = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(1)); + cancellation_signal.cancel(); + }); + let engine_budget = budget(1_000_000, 100_000, 100_000); + let engine_provenance = provenance().unwrap(); + let mut engine = Engine { + source: &source, + provenance: engine_provenance.clone(), + budget: engine_budget, + control: &cancelled, + capture: CaptureByteTracker::for_document( + engine_budget.max_capture_bytes(), + "src/lib.rs", + &engine_provenance, + ) + .unwrap(), + symbols: Vec::new(), + calls: Vec::new(), + truncation: None, + }; + engine.extract(tree.root_node()).unwrap(); + canceller.join().unwrap(); + assert_eq!( + engine.truncation.unwrap().reason(), + SyntaxTruncationReason::Cancelled + ); + + let expired = AnalysisControl::new(NonZeroU64::new(500).unwrap()); + let engine_provenance = provenance().unwrap(); + let mut engine = Engine { + source: &source, + provenance: engine_provenance.clone(), + budget: engine_budget, + control: &expired, + capture: CaptureByteTracker::for_document( + engine_budget.max_capture_bytes(), + "src/lib.rs", + &engine_provenance, + ) + .unwrap(), + symbols: Vec::new(), + calls: Vec::new(), + truncation: None, + }; + engine.extract(tree.root_node()).unwrap(); + assert_eq!( + engine.truncation.unwrap().reason(), + SyntaxTruncationReason::Time + ); + } + + #[test] + fn rust_broken_syntax_is_partial_with_error_evidence() { + let document = analyze("fn broken( { let value = ; }"); + assert_eq!(document.status(), DocumentStatus::Partial); + assert!(document.truncation().is_none()); + assert!( + document + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.severity() == DiagnosticSeverity::Error) + ); + } + + #[test] + fn rust_capture_budget_accepts_exact_limit_and_rejects_the_next_byte() { + let source = "fn work() { perform(value); }"; + let control = AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()); + let generous = + budget(10_000, 100, 100).with_max_capture_bytes(NonZeroU64::new(10_000).unwrap()); + let baseline = analyze_with_budget(source, generous, &control); + assert_eq!(baseline.status(), DocumentStatus::Parsed); + let exact = baseline.estimated_owned_bytes(); + + let exact_document = analyze_with_budget( + source, + budget(10_000, 100, 100).with_max_capture_bytes(NonZeroU64::new(exact).unwrap()), + &control, + ); + assert_eq!(exact_document.status(), DocumentStatus::Parsed); + assert_eq!(exact_document.estimated_owned_bytes(), exact); + + let short_document = analyze_with_budget( + source, + budget(10_000, 100, 100).with_max_capture_bytes(NonZeroU64::new(exact - 1).unwrap()), + &control, + ); + assert_eq!(short_document.status(), DocumentStatus::Partial); + let truncation = short_document.truncation().unwrap(); + assert_eq!(truncation.reason(), SyntaxTruncationReason::CaptureBytes); + assert_eq!(truncation.limit(), Some(exact - 1)); + assert_eq!(truncation.observed(), Some(exact)); + assert!(short_document.calls().is_empty()); + assert!(short_document.estimated_owned_bytes() < exact); + } + + #[test] + fn rust_capture_budget_stops_deep_overlapping_call_arguments_before_count_limit() { + let depth = 256; + let mut expression = "leaf()".to_string(); + for _ in 0..depth { + expression = format!("wrap({expression})"); + } + let source = format!("fn deep() {{ {expression}; }}"); + let source_limit = NonZeroU64::new(source.len() as u64).unwrap(); + let capture_limit = NonZeroU64::new(source.len() as u64 * 3).unwrap(); + let document = analyze_with_budget( + &source, + AnalysisBudget::new( + source_limit, + NonZeroU32::new(1_000).unwrap(), + NonZeroU32::new(1_000).unwrap(), + NonZeroU32::new(64).unwrap(), + ) + .with_max_capture_bytes(capture_limit), + &AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()), + ); + assert_eq!(document.status(), DocumentStatus::Partial); + let truncation = document.truncation().unwrap(); + assert_eq!(truncation.reason(), SyntaxTruncationReason::CaptureBytes); + assert_eq!(truncation.limit(), Some(capture_limit.get())); + assert!(truncation.observed().unwrap() > capture_limit.get()); + assert!(document.calls().len() < 10); + assert!(document.calls().len() < depth); + assert!(document.estimated_owned_bytes() <= capture_limit.get()); + } + + #[test] + fn rust_diagnostics_participate_in_capture_budget() { + let source = "fn broken( { let value = ; }"; + let control = AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()); + let generous = + budget(10_000, 100, 100).with_max_capture_bytes(NonZeroU64::new(10_000).unwrap()); + let baseline = analyze_with_budget(source, generous, &control); + assert_eq!(baseline.status(), DocumentStatus::Partial); + assert!(baseline.truncation().is_none()); + let diagnostic = baseline.diagnostics().first().unwrap(); + let exact = baseline.estimated_owned_bytes(); + let without_diagnostic = exact - diagnostic.estimated_owned_bytes(); + + let exact_document = analyze_with_budget( + source, + budget(10_000, 100, 100).with_max_capture_bytes(NonZeroU64::new(exact).unwrap()), + &control, + ); + assert_eq!(exact_document.diagnostics().len(), 1); + assert!(exact_document.truncation().is_none()); + assert_eq!(exact_document.estimated_owned_bytes(), exact); + + let limited = analyze_with_budget( + source, + budget(10_000, 100, 100) + .with_max_capture_bytes(NonZeroU64::new(without_diagnostic).unwrap()), + &control, + ); + assert!(limited.diagnostics().is_empty()); + assert_eq!(limited.estimated_owned_bytes(), without_diagnostic); + let truncation = limited.truncation().unwrap(); + assert_eq!(truncation.reason(), SyntaxTruncationReason::CaptureBytes); + assert_eq!(truncation.limit(), Some(without_diagnostic)); + assert_eq!(truncation.observed(), Some(exact)); + } + + #[test] + fn rust_honors_source_symbol_and_call_budgets() { + let adapter = RustAdapter::new(); + let control = AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()); + let input = || { + AnalysisInput::new( + "src/lib.rs", + SyntaxLanguage::Rust, + "fn one() { first(); second(); } fn two() {}".to_owned(), + ) + .unwrap() + }; + let source_limited = adapter + .analyze(input(), budget(10, 10, 10), &control) + .unwrap(); + assert_eq!( + source_limited.truncation().unwrap().reason(), + SyntaxTruncationReason::SourceBytes + ); + let symbol_limited = adapter + .analyze(input(), budget(1_000, 1, 10), &control) + .unwrap(); + assert_eq!(symbol_limited.symbols().len(), 1); + assert_eq!( + symbol_limited.truncation().unwrap().reason(), + SyntaxTruncationReason::SymbolCount + ); + let call_limited = adapter + .analyze(input(), budget(1_000, 10, 1), &control) + .unwrap(); + assert_eq!(call_limited.calls().len(), 1); + assert_eq!( + call_limited.truncation().unwrap().reason(), + SyntaxTruncationReason::CallCount + ); + } + + #[test] + fn rust_failure_hook_prefers_control_over_one_remaining_capture_byte() { + let path = "src/lib.rs"; + let message = "failure diagnostic exceeds one byte"; + + let cancelled = AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()); + cancelled.cancel(); + let syntax_provenance = provenance().unwrap(); + let baseline = path.len() as u64 + syntax_provenance.estimated_owned_bytes(); + let capture = CaptureByteTracker::for_document( + NonZeroU64::new(baseline + 1).unwrap(), + path, + &syntax_provenance, + ) + .unwrap(); + let document = + RustAdapter::failed(path, message, syntax_provenance, capture, &cancelled).unwrap(); + assert_eq!(document.status(), DocumentStatus::Partial); + assert!(document.diagnostics().is_empty()); + assert_eq!(document.estimated_owned_bytes(), baseline); + assert_eq!( + document.truncation().unwrap().reason(), + SyntaxTruncationReason::Cancelled + ); + + let expired = AnalysisControl::new(NonZeroU64::new(500).unwrap()); + std::thread::sleep(Duration::from_millis(2)); + let syntax_provenance = provenance().unwrap(); + let capture = CaptureByteTracker::for_document( + NonZeroU64::new(baseline + 1).unwrap(), + path, + &syntax_provenance, + ) + .unwrap(); + let document = + RustAdapter::failed(path, message, syntax_provenance, capture, &expired).unwrap(); + assert_eq!(document.status(), DocumentStatus::Partial); + assert!(document.diagnostics().is_empty()); + assert_eq!(document.estimated_owned_bytes(), baseline); + assert_eq!( + document.truncation().unwrap().reason(), + SyntaxTruncationReason::Time + ); + } + + #[test] + fn rust_capture_observation_preserves_both_race_directions() { + let path = "src/lib.rs"; + let syntax_provenance = provenance().unwrap(); + let baseline = path.len() as u64 + syntax_provenance.estimated_owned_bytes(); + let diagnostic = + SyntaxDiagnostic::new(DiagnosticSeverity::Error, "too large", None).unwrap(); + + let mut capture = CaptureByteTracker::for_document( + NonZeroU64::new(baseline + 1).unwrap(), + path, + &syntax_provenance, + ) + .unwrap(); + let control = AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()); + let observed_at = Instant::now(); + let local = capture.try_account_diagnostic(&diagnostic).unwrap_err(); + control.cancel(); + let chosen = first_cause(&control, local, observed_at).unwrap(); + assert_eq!(chosen.reason(), SyntaxTruncationReason::CaptureBytes); + + let mut capture = CaptureByteTracker::for_document( + NonZeroU64::new(baseline + 1).unwrap(), + path, + &syntax_provenance, + ) + .unwrap(); + let control = AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()); + control.cancel(); + let observed_at = Instant::now(); + let local = capture.try_account_diagnostic(&diagnostic).unwrap_err(); + let chosen = first_cause(&control, local, observed_at).unwrap(); + assert_eq!(chosen.reason(), SyntaxTruncationReason::Cancelled); + } + + #[test] + fn rust_count_observation_uses_the_shared_first_cause() { + let symbol_count = + SyntaxTruncation::new(SyntaxTruncationReason::SymbolCount, Some(1), Some(2)).unwrap(); + let call_count = + SyntaxTruncation::new(SyntaxTruncationReason::CallCount, Some(1), Some(2)).unwrap(); + + let cancelled = AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()); + cancelled.cancel(); + let chosen = first_cause(&cancelled, symbol_count.clone(), Instant::now()).unwrap(); + assert_eq!(chosen.reason(), SyntaxTruncationReason::Cancelled); + + let expired = AnalysisControl::new(NonZeroU64::new(500).unwrap()); + std::thread::sleep(Duration::from_millis(2)); + let chosen = first_cause(&expired, call_count, Instant::now()).unwrap(); + assert_eq!(chosen.reason(), SyntaxTruncationReason::Time); + + let live = AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()); + let chosen = first_cause(&live, symbol_count, Instant::now()).unwrap(); + live.cancel(); + assert_eq!(chosen.reason(), SyntaxTruncationReason::SymbolCount); + } + + #[test] + fn rust_honors_cancellation_and_deadline() { + let adapter = RustAdapter::new(); + let input = || { + AnalysisInput::new( + "src/lib.rs", + SyntaxLanguage::Rust, + "fn work() {}".to_owned(), + ) + .unwrap() + }; + let cancelled = AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()); + cancelled.cancel(); + let document = adapter + .analyze( + input(), + budget(1_000, 10, 10).with_max_capture_bytes(NonZeroU64::new(1).unwrap()), + &cancelled, + ) + .unwrap(); + assert_eq!( + document.truncation().unwrap().reason(), + SyntaxTruncationReason::Cancelled + ); + + let expired = AnalysisControl::new(NonZeroU64::new(1_000).unwrap()); + std::thread::sleep(Duration::from_millis(3)); + let document = adapter + .analyze( + input(), + budget(1_000, 10, 10).with_max_capture_bytes(NonZeroU64::new(1).unwrap()), + &expired, + ) + .unwrap(); + let truncation = document.truncation().unwrap(); + assert_eq!(truncation.reason(), SyntaxTruncationReason::Time); + assert_eq!(truncation.limit(), Some(1_000)); + assert!(truncation.observed().unwrap() >= 1_000); + } +} diff --git a/crates/okena-syntax/src/typescript/fixtures/advanced.ts b/crates/okena-syntax/src/typescript/fixtures/advanced.ts new file mode 100644 index 000000000..97cd08e37 --- /dev/null +++ b/crates/okena-syntax/src/typescript/fixtures/advanced.ts @@ -0,0 +1,30 @@ +function listed(value: string): string { + return value; +} +export { listed as publicListed }; + +export function* generate(value: T): Generator { + yield value; +} + +export const generated = function* named(value: number): Generator { + yield value; +}; + +export enum State { + Idle, + Busy = compute(), +} + +export namespace Tools { + export function parse(value: string): string { + return value; + } +} + +export class SecretBox { + #secret = makeSecret(); + #hide(): void {} + visible = makeVisible(); + method(first: string, /* comment is not a parameter */ second = 1): void {} +} diff --git a/crates/okena-syntax/src/typescript/fixtures/broken.ts b/crates/okena-syntax/src/typescript/fixtures/broken.ts new file mode 100644 index 000000000..2bd8578b6 --- /dev/null +++ b/crates/okena-syntax/src/typescript/fixtures/broken.ts @@ -0,0 +1,3 @@ +export function broken(value: string { + return value. +} diff --git a/crates/okena-syntax/src/typescript/fixtures/component.tsx b/crates/okena-syntax/src/typescript/fixtures/component.tsx new file mode 100644 index 000000000..8d1915261 --- /dev/null +++ b/crates/okena-syntax/src/typescript/fixtures/component.tsx @@ -0,0 +1,3 @@ +export const Card = ({ value }: { value: T }) => { + return
{format(value)}
; +}; diff --git a/crates/okena-syntax/src/typescript/fixtures/review.ts b/crates/okena-syntax/src/typescript/fixtures/review.ts new file mode 100644 index 000000000..1b27346ff --- /dev/null +++ b/crates/okena-syntax/src/typescript/fixtures/review.ts @@ -0,0 +1,21 @@ +export async function run(value: T, service: Service): Promise { + for (const item of [value]) { + if (service.ready(item)) { + service.call(value); + } + } + return value; +} + +export class Worker { + value: T; + execute(value: T): T { + return value; + } +} + +interface Executor { + execute(value: T): T; +} + +export type Result = { value: T }; diff --git a/crates/okena-syntax/src/typescript/mod.rs b/crates/okena-syntax/src/typescript/mod.rs new file mode 100644 index 000000000..5bf352601 --- /dev/null +++ b/crates/okena-syntax/src/typescript/mod.rs @@ -0,0 +1,1870 @@ +//! Deterministic TypeScript and TSX tree-sitter adapter. + +use std::collections::{HashMap, HashSet}; +use std::ops::ControlFlow; +use std::time::Instant; + +use tree_sitter::{Node, Parser}; + +use crate::{ + AnalysisBudget, AnalysisControl, AnalysisInput, CallFact, CaptureByteTracker, ControlContext, + DiagnosticSeverity, DocumentStatus, DocumentStructure, ModelError, SourceRange, SymbolFact, + SymbolKey, SymbolKind, SymbolVisibility, SyntaxAdapter, SyntaxDiagnostic, SyntaxLanguage, + SyntaxProvenance, SyntaxTruncation, SyntaxTruncationReason, +}; + +const TYPESCRIPT_PARSER: &str = "tree-sitter-typescript@0.23.2"; +const TSX_PARSER: &str = "tree-sitter-tsx@0.23.2"; + +/// Syntax adapter for both the TypeScript and TSX grammars. +#[derive(Clone, Copy, Debug, Default)] +pub struct TypeScriptAdapter; + +impl TypeScriptAdapter { + pub fn new() -> Self { + Self + } +} + +impl SyntaxAdapter for TypeScriptAdapter { + fn language(&self) -> SyntaxLanguage { + SyntaxLanguage::TypeScript + } + + fn supports(&self, language: SyntaxLanguage) -> bool { + matches!(language, SyntaxLanguage::TypeScript | SyntaxLanguage::Tsx) + } + + fn analyze( + &self, + input: AnalysisInput, + budget: AnalysisBudget, + control: &AnalysisControl, + ) -> Result { + let language = input.language(); + let parser_name = match language { + SyntaxLanguage::TypeScript => TYPESCRIPT_PARSER, + SyntaxLanguage::Tsx => TSX_PARSER, + SyntaxLanguage::Rust => { + return unsupported_document(&input, "typescript-adapter"); + } + }; + let provenance = SyntaxProvenance::tree_sitter(language, parser_name)?; + + if let Some(truncation) = control.stop_truncation(Instant::now())? { + return partial_document( + &input, + provenance, + Vec::new(), + Vec::new(), + Vec::new(), + truncation, + ); + } + let source_bytes = u64::try_from(input.source().len()).unwrap_or(u64::MAX); + let source_limit = budget.max_source_bytes().get(); + if source_bytes > source_limit { + return partial_document( + &input, + provenance, + Vec::new(), + Vec::new(), + Vec::new(), + SyntaxTruncation::new( + SyntaxTruncationReason::SourceBytes, + Some(source_limit), + Some(source_bytes), + )?, + ); + } + let mut capture = match CaptureByteTracker::for_document( + budget.max_capture_bytes(), + input.path(), + &provenance, + ) { + Ok(capture) => capture, + Err(truncation) => { + return partial_document( + &input, + provenance, + Vec::new(), + Vec::new(), + Vec::new(), + truncation, + ); + } + }; + let mut parser = Parser::new(); + let grammar = match language { + SyntaxLanguage::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + SyntaxLanguage::Tsx => tree_sitter_typescript::LANGUAGE_TSX.into(), + SyntaxLanguage::Rust => { + return unsupported_document(&input, "typescript-adapter"); + } + }; + if let Err(error) = parser.set_language(&grammar) { + return failed_document( + &input, + provenance, + format!("failed to load {parser_name}: {error}"), + &mut capture, + control, + ); + } + + let source = input.source().as_bytes(); + let mut read = |offset: usize, _| source.get(offset..).unwrap_or_default(); + let mut progress = |_: &tree_sitter::ParseState| { + if control.should_stop(Instant::now()) { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }; + let options = tree_sitter::ParseOptions::new().progress_callback(&mut progress); + let Some(tree) = parser.parse_with_options(&mut read, None, Some(options)) else { + if let Some(truncation) = control.stop_truncation(Instant::now())? { + return partial_document( + &input, + provenance, + Vec::new(), + Vec::new(), + Vec::new(), + truncation, + ); + } + return failed_document( + &input, + provenance, + "tree-sitter returned no syntax tree", + &mut capture, + control, + ); + }; + + let (exported_names, export_scan_truncation) = + collect_exported_names(tree.root_node(), input.source(), control)?; + if let Some(truncation) = export_scan_truncation { + return partial_document( + &input, + provenance, + Vec::new(), + Vec::new(), + Vec::new(), + truncation, + ); + } + let mut extractor = Extractor::new( + input.source(), + provenance.clone(), + budget, + control, + exported_names, + capture, + ); + extractor.extract(tree.root_node())?; + + let diagnostics = if extractor.truncation.is_none() { + let (diagnostics, truncation) = parse_diagnostics( + tree.root_node(), + control, + budget.max_diagnostics().get(), + &mut extractor.capture, + )?; + extractor.truncation = truncation; + diagnostics + } else { + Vec::new() + }; + + let truncation = extractor.truncation; + let status = if truncation.is_some() || !diagnostics.is_empty() { + DocumentStatus::Partial + } else { + DocumentStatus::Parsed + }; + let retained_bytes = extractor.capture.retained_bytes(); + let document = DocumentStructure::new( + input.path(), + provenance, + status, + extractor.symbols, + extractor.calls, + diagnostics, + truncation, + )?; + debug_assert_eq!(document.estimated_owned_bytes(), retained_bytes); + Ok(document) + } +} + +fn unsupported_document( + input: &AnalysisInput, + parser: &str, +) -> Result { + DocumentStructure::new( + input.path(), + SyntaxProvenance::tree_sitter(input.language(), parser)?, + DocumentStatus::Unsupported, + Vec::new(), + Vec::new(), + Vec::new(), + None, + ) +} + +fn failed_document( + input: &AnalysisInput, + provenance: SyntaxProvenance, + message: impl Into, + capture: &mut CaptureByteTracker, + control: &AnalysisControl, +) -> Result { + if let Some(truncation) = control.stop_truncation(Instant::now())? { + return partial_document( + input, + provenance, + Vec::new(), + Vec::new(), + Vec::new(), + truncation, + ); + } + let diagnostic = SyntaxDiagnostic::new(DiagnosticSeverity::Error, message, None)?; + if let Err(truncation) = capture.try_account_diagnostic(&diagnostic) { + return partial_document( + input, + provenance, + Vec::new(), + Vec::new(), + Vec::new(), + truncation, + ); + } + let retained_bytes = capture.retained_bytes(); + let document = DocumentStructure::new( + input.path(), + provenance, + DocumentStatus::Failed, + Vec::new(), + Vec::new(), + vec![diagnostic], + None, + )?; + debug_assert_eq!(document.estimated_owned_bytes(), retained_bytes); + Ok(document) +} + +fn partial_document( + input: &AnalysisInput, + provenance: SyntaxProvenance, + symbols: Vec, + calls: Vec, + diagnostics: Vec, + truncation: SyntaxTruncation, +) -> Result { + DocumentStructure::new( + input.path(), + provenance, + DocumentStatus::Partial, + symbols, + calls, + diagnostics, + Some(truncation), + ) +} + +#[derive(Clone)] +struct WorkItem<'tree> { + node: Node<'tree>, + parent_path: Vec, + enclosing_symbol: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct ScopeKey { + start_byte: usize, + end_byte: usize, +} + +impl ScopeKey { + fn new(node: Node<'_>) -> Self { + Self { + start_byte: node.start_byte(), + end_byte: node.end_byte(), + } + } +} + +type ExportedNames = HashMap>; + +struct Extractor<'source, 'control> { + source: &'source str, + provenance: SyntaxProvenance, + budget: AnalysisBudget, + control: &'control AnalysisControl, + exported_names: ExportedNames, + symbols: Vec, + calls: Vec, + truncation: Option, + capture: CaptureByteTracker, +} + +impl<'source, 'control> Extractor<'source, 'control> { + fn new( + source: &'source str, + provenance: SyntaxProvenance, + budget: AnalysisBudget, + control: &'control AnalysisControl, + exported_names: ExportedNames, + capture: CaptureByteTracker, + ) -> Self { + Self { + source, + provenance, + budget, + control, + exported_names, + symbols: Vec::new(), + calls: Vec::new(), + truncation: None, + capture, + } + } + + fn extract(&mut self, root: Node<'_>) -> Result<(), ModelError> { + let mut stack = vec![WorkItem { + node: root, + parent_path: Vec::new(), + enclosing_symbol: None, + }]; + while let Some(item) = stack.pop() { + if self.should_stop()? { + break; + } + + let mut child_path = item.parent_path.clone(); + let mut child_enclosing = item.enclosing_symbol.clone(); + if let Some(spec) = symbol_spec(item.node, self.source, &item.parent_path)? { + let Some(fact) = self.build_symbol(item.node, &spec)? else { + break; + }; + let key = fact.key().clone(); + if !self.push_symbol(fact)? { + break; + } + child_path.push(spec.name); + child_enclosing = Some(key); + } + + if item.node.kind() == "call_expression" + && let Some(call) = self.build_call(item.node, item.enclosing_symbol)? + && !self.push_call(call)? + { + break; + } + if self.truncation.is_some() { + break; + } + + for index in (0..item.node.named_child_count()).rev() { + if self.should_stop()? { + break; + } + let Some(child) = named_child(item.node, index) else { + continue; + }; + stack.push(WorkItem { + node: child, + parent_path: child_path.clone(), + enclosing_symbol: child_enclosing.clone(), + }); + } + } + Ok(()) + } + + fn should_stop(&mut self) -> Result { + if self.truncation.is_some() { + return Ok(true); + } + if let Some(truncation) = self.control.stop_truncation(Instant::now())? { + self.truncation = Some(truncation); + return Ok(true); + } + Ok(false) + } + + fn push_symbol(&mut self, fact: SymbolFact) -> Result { + if self.should_stop()? { + return Ok(false); + } + let limit = usize::try_from(self.budget.max_symbols().get()).unwrap_or(usize::MAX); + if self.symbols.len() >= limit { + let observed = u64::try_from(self.symbols.len()) + .unwrap_or(u64::MAX) + .saturating_add(1); + self.truncation = Some(SyntaxTruncation::new( + SyntaxTruncationReason::SymbolCount, + Some(self.budget.max_symbols().get().into()), + Some(observed), + )?); + return Ok(false); + } + if let Err(truncation) = self.capture.try_account_symbol(&fact) { + self.truncation = Some(truncation); + return Ok(false); + } + self.symbols.push(fact); + Ok(true) + } + + fn push_call(&mut self, fact: CallFact) -> Result { + if self.should_stop()? { + return Ok(false); + } + let limit = usize::try_from(self.budget.max_calls().get()).unwrap_or(usize::MAX); + if self.calls.len() >= limit { + let observed = u64::try_from(self.calls.len()) + .unwrap_or(u64::MAX) + .saturating_add(1); + self.truncation = Some(SyntaxTruncation::new( + SyntaxTruncationReason::CallCount, + Some(self.budget.max_calls().get().into()), + Some(observed), + )?); + return Ok(false); + } + if let Err(truncation) = self.capture.try_account_call(&fact) { + self.truncation = Some(truncation); + return Ok(false); + } + self.calls.push(fact); + Ok(true) + } + + fn build_symbol( + &mut self, + node: Node<'_>, + spec: &SymbolSpec, + ) -> Result, ModelError> { + let Some(full_node) = self.evidence_node(node)? else { + return Ok(None); + }; + let full_range = node_range(full_node)?; + let signature_range = if let Some(body) = spec.body { + range_between(full_node, body)? + } else { + full_range + }; + let signature = source_text(self.source, signature_range)?; + let normalized_signature = normalize_signature(signature); + let body_range = spec.body.map(node_range).transpose()?; + let Some(visibility) = self.visibility(node)? else { + return Ok(None); + }; + let Some(parameter_count) = self.parameter_count(spec.parameters)? else { + return Ok(None); + }; + let Some(syntactic_nesting_depth) = self.max_nesting_depth(spec.body)? else { + return Ok(None); + }; + let Some(type_member_count) = self.type_member_count(spec.body)? else { + return Ok(None); + }; + SymbolFact::new( + self.provenance.clone(), + SymbolKey::new(spec.parent_path.clone(), spec.kind, spec.name.clone())?, + visibility, + full_range, + signature_range, + body_range, + normalized_signature, + parameter_count, + syntactic_nesting_depth, + type_member_count, + ) + .map(Some) + } + + fn build_call( + &mut self, + node: Node<'_>, + enclosing_symbol: Option, + ) -> Result, ModelError> { + if self.should_stop()? { + return Ok(None); + } + let Some(function) = node.child_by_field_name("function") else { + return Ok(None); + }; + let Some(arguments) = node.child_by_field_name("arguments") else { + return Ok(None); + }; + let Some(contexts) = self.call_contexts(node)? else { + return Ok(None); + }; + Ok(Some(CallFact::new( + self.provenance.clone(), + node_text(self.source, function)?.to_string(), + node_text(self.source, arguments)?.to_string(), + node_range(arguments)?, + node_range(node)?, + enclosing_symbol, + contexts, + )?)) + } + + fn evidence_node<'tree>( + &mut self, + node: Node<'tree>, + ) -> Result>, ModelError> { + let mut evidence = node; + if node.kind() == "variable_declarator" + && let Some(declaration) = node.parent().filter(|parent| { + matches!( + parent.kind(), + "lexical_declaration" | "variable_declaration" + ) + }) + { + let mut declarators = 0_u32; + for index in 0..declaration.named_child_count() { + if self.should_stop()? { + return Ok(None); + } + if named_child(declaration, index) + .is_some_and(|child| child.kind() == "variable_declarator") + { + declarators = declarators.saturating_add(1); + } + } + if declarators > 1 { + return Ok(Some(node)); + } + evidence = declaration; + } + while let Some(parent) = evidence.parent() { + if self.should_stop()? { + return Ok(None); + } + if matches!(parent.kind(), "ambient_declaration" | "export_statement") { + evidence = parent; + } else { + break; + } + } + Ok(Some(evidence)) + } + + fn visibility(&mut self, node: Node<'_>) -> Result, ModelError> { + if node + .child_by_field_name("name") + .is_some_and(|name| name.kind() == "private_property_identifier") + { + return Ok(Some(SymbolVisibility::Private)); + } + for index in 0..node.named_child_count() { + if self.should_stop()? { + return Ok(None); + } + let Some(child) = named_child(node, index) else { + continue; + }; + if child.kind() != "accessibility_modifier" { + continue; + } + return Ok(Some(match node_text(self.source, child).ok() { + Some("private") => SymbolVisibility::Private, + Some("protected") => SymbolVisibility::Restricted, + _ => SymbolVisibility::Public, + })); + } + let mut current = node.parent(); + while let Some(candidate) = current { + if self.should_stop()? { + return Ok(None); + } + if candidate.kind() == "export_statement" { + return Ok(Some(SymbolVisibility::Exported)); + } + if is_nested_symbol_boundary(candidate.kind()) { + break; + } + current = candidate.parent(); + } + let declaration_scope = self.declaration_scope(node)?; + if self.truncation.is_some() { + return Ok(None); + } + if let Some(scope) = declaration_scope + && let Some(name) = node + .child_by_field_name("name") + .and_then(|name| node_text(self.source, name).ok()) + && self + .exported_names + .get(&scope) + .is_some_and(|names| names.contains(name)) + { + return Ok(Some(SymbolVisibility::Exported)); + } + Ok(Some(match node.kind() { + "method_definition" + | "method_signature" + | "abstract_method_signature" + | "public_field_definition" + | "property_signature" + | "enum_assignment" => SymbolVisibility::Public, + "property_identifier" | "number" | "string" | "computed_property_name" + if node + .parent() + .is_some_and(|parent| parent.kind() == "enum_body") => + { + SymbolVisibility::Public + } + _ => SymbolVisibility::Private, + })) + } + + fn declaration_scope(&mut self, node: Node<'_>) -> Result, ModelError> { + let mut current = node; + if current.kind() == "variable_declarator" + && let Some(declaration) = current.parent() + && matches!( + declaration.kind(), + "lexical_declaration" | "variable_declaration" + ) + { + current = declaration; + } + while let Some(parent) = current.parent() { + if self.should_stop()? { + return Ok(None); + } + if matches!(parent.kind(), "ambient_declaration" | "export_statement") { + current = parent; + continue; + } + if parent.kind() == "program" { + return Ok(Some(ScopeKey::new(parent))); + } + if parent.kind() == "statement_block" + && let Some(owner) = parent.parent() + && matches!(owner.kind(), "internal_module" | "module") + { + return Ok(Some(ScopeKey::new(owner))); + } + return Ok(None); + } + Ok(None) + } + + fn parameter_count(&mut self, parameters: Option>) -> Result, ModelError> { + let Some(parameters) = parameters else { + return Ok(Some(0)); + }; + if parameters.kind() == "identifier" { + return Ok(Some(1)); + } + let mut count = 0_u32; + for index in 0..parameters.named_child_count() { + if self.should_stop()? { + return Ok(None); + } + if named_child(parameters, index).is_some_and(|parameter| { + matches!( + parameter.kind(), + "required_parameter" | "optional_parameter" + ) + }) { + count = count.saturating_add(1); + } + } + Ok(Some(count)) + } + + fn type_member_count(&mut self, body: Option>) -> Result, ModelError> { + let Some(body) = body else { + return Ok(Some(0)); + }; + if !matches!( + body.kind(), + "class_body" | "interface_body" | "object_type" | "enum_body" + ) { + return Ok(Some(0)); + } + let mut count = 0_u32; + for index in 0..body.named_child_count() { + if self.should_stop()? { + return Ok(None); + } + if named_child(body, index).is_some_and(|child| { + matches!( + child.kind(), + "method_definition" + | "method_signature" + | "abstract_method_signature" + | "public_field_definition" + | "property_signature" + | "call_signature" + | "construct_signature" + | "index_signature" + | "enum_assignment" + | "property_identifier" + | "number" + | "string" + | "computed_property_name" + ) + }) { + count = count.saturating_add(1); + } + } + Ok(Some(count)) + } + + fn max_nesting_depth(&mut self, root: Option>) -> Result, ModelError> { + let Some(root) = root else { + return Ok(Some(0)); + }; + let mut maximum = 0_u32; + let mut stack = vec![(root, 0_u32)]; + while let Some((node, depth)) = stack.pop() { + if self.should_stop()? { + return Ok(None); + } + if node != root && is_nested_symbol_boundary(node.kind()) { + continue; + } + let next_depth = if is_nesting_node(node.kind()) { + depth.saturating_add(1) + } else { + depth + }; + maximum = maximum.max(next_depth); + for index in (0..node.named_child_count()).rev() { + if self.should_stop()? { + return Ok(None); + } + if let Some(child) = named_child(node, index) { + stack.push((child, next_depth)); + } + } + } + Ok(Some(maximum)) + } + + fn call_contexts(&mut self, node: Node<'_>) -> Result>, ModelError> { + let mut condition = false; + let mut loop_context = false; + let mut match_arm = false; + let mut error_branch = false; + let mut callback = false; + let mut closure = false; + let mut child = node; + let mut current = node.parent(); + while let Some(ancestor) = current { + if self.should_stop()? { + return Ok(None); + } + match ancestor.kind() { + "if_statement" | "while_statement" | "do_statement" | "for_statement" + if ancestor + .child_by_field_name("condition") + .is_some_and(|range| range_contains(range, child)) => + { + condition = true; + } + "conditional_expression" + if ancestor + .child_by_field_name("condition") + .is_some_and(|range| range_contains(range, child)) => + { + condition = true; + } + "for_in_statement" | "for_of_statement" => loop_context = true, + "switch_case" | "switch_default" => match_arm = true, + "catch_clause" => error_branch = true, + "arrow_function" | "function_expression" => { + closure = true; + let Some(is_callback) = self.is_callback_function(ancestor)? else { + return Ok(None); + }; + if is_callback { + callback = true; + } + } + _ => {} + } + if matches!(ancestor.kind(), "for_statement") { + loop_context = true; + } + if matches!(ancestor.kind(), "while_statement" | "do_statement") { + loop_context = true; + } + child = ancestor; + current = ancestor.parent(); + } + let mut contexts = Vec::new(); + if condition { + contexts.push(ControlContext::Condition); + } + if loop_context { + contexts.push(ControlContext::Loop); + } + if match_arm { + contexts.push(ControlContext::MatchArm); + } + if error_branch { + contexts.push(ControlContext::ErrorBranch); + } + if callback { + contexts.push(ControlContext::Callback); + } + if closure { + contexts.push(ControlContext::Closure); + } + Ok(Some(contexts)) + } + + fn is_callback_function(&mut self, node: Node<'_>) -> Result, ModelError> { + let mut current = node.parent(); + while let Some(parent) = current { + if self.should_stop()? { + return Ok(None); + } + if parent.kind() == "arguments" { + return Ok(Some( + parent + .parent() + .is_some_and(|node| node.kind() == "call_expression"), + )); + } + if is_nested_symbol_boundary(parent.kind()) || parent.kind() == "statement_block" { + return Ok(Some(false)); + } + current = parent.parent(); + } + Ok(Some(false)) + } +} + +struct SymbolSpec<'tree> { + name: String, + kind: SymbolKind, + parent_path: Vec, + parameters: Option>, + body: Option>, +} + +fn symbol_spec<'tree>( + node: Node<'tree>, + source: &str, + parent_path: &[String], +) -> Result>, ModelError> { + let direct = match node.kind() { + "function_declaration" | "function_signature" | "generator_function_declaration" => { + Some(SymbolKind::Function) + } + "class_declaration" | "abstract_class_declaration" => Some(SymbolKind::Class), + "interface_declaration" => Some(SymbolKind::Interface), + "enum_declaration" => Some(SymbolKind::Enum), + "internal_module" | "module" => Some(SymbolKind::Module), + "method_definition" | "method_signature" | "abstract_method_signature" => { + Some(SymbolKind::Method) + } + "type_alias_declaration" => Some(SymbolKind::TypeAlias), + "public_field_definition" | "property_signature" => Some(SymbolKind::Field), + "enum_assignment" => Some(SymbolKind::Variant), + "property_identifier" | "number" | "string" | "computed_property_name" + if node + .parent() + .is_some_and(|parent| parent.kind() == "enum_body") => + { + Some(SymbolKind::Variant) + } + _ => None, + }; + if let Some(kind) = direct { + let name_node = if kind == SymbolKind::Variant + && node.kind() != "enum_assignment" + && node + .parent() + .is_some_and(|parent| parent.kind() == "enum_body") + { + node + } else { + let Some(name) = node.child_by_field_name("name") else { + return Ok(None); + }; + name + }; + let name = node_text(source, name_node)?.trim().to_string(); + if name.is_empty() { + return Ok(None); + } + return Ok(Some(SymbolSpec { + name, + kind, + parent_path: parent_path.to_vec(), + parameters: node.child_by_field_name("parameters"), + body: match node.kind() { + "public_field_definition" | "enum_assignment" => node.child_by_field_name("value"), + _ => node.child_by_field_name("body"), + }, + })); + } + + if node.kind() != "variable_declarator" { + return Ok(None); + } + let Some(name_node) = node.child_by_field_name("name") else { + return Ok(None); + }; + if name_node.kind() != "identifier" { + return Ok(None); + } + let Some(value) = node.child_by_field_name("value") else { + return Ok(None); + }; + if !matches!( + value.kind(), + "arrow_function" | "function_expression" | "generator_function" + ) { + return Ok(None); + } + let name = node_text(source, name_node)?.to_string(); + let parameters = value + .child_by_field_name("parameters") + .or_else(|| value.child_by_field_name("parameter")); + Ok(Some(SymbolSpec { + name, + kind: SymbolKind::Function, + parent_path: parent_path.to_vec(), + parameters, + body: value.child_by_field_name("body"), + })) +} + +fn collect_exported_names( + root: Node<'_>, + source: &str, + control: &AnalysisControl, +) -> Result<(ExportedNames, Option), ModelError> { + let mut exported = HashMap::>::new(); + let mut stack = vec![(root, ScopeKey::new(root))]; + while let Some((node, scope)) = stack.pop() { + if let Some(truncation) = control.stop_truncation(Instant::now())? { + return Ok((exported, Some(truncation))); + } + if node.kind() == "export_statement" && node.child_by_field_name("source").is_none() { + for index in 0..node.named_child_count() { + if let Some(truncation) = control.stop_truncation(Instant::now())? { + return Ok((exported, Some(truncation))); + } + let Some(clause) = named_child(node, index) else { + continue; + }; + if clause.kind() != "export_clause" { + continue; + } + for specifier_index in 0..clause.named_child_count() { + if let Some(truncation) = control.stop_truncation(Instant::now())? { + return Ok((exported, Some(truncation))); + } + let Some(specifier) = named_child(clause, specifier_index) else { + continue; + }; + if specifier.kind() != "export_specifier" { + continue; + } + if let Some(name) = specifier.child_by_field_name("name") + && name.kind() == "identifier" + && let Ok(name) = node_text(source, name) + { + exported.entry(scope).or_default().insert(name.to_string()); + } + } + } + } + let child_scope = if matches!(node.kind(), "internal_module" | "module") { + ScopeKey::new(node) + } else { + scope + }; + for index in (0..node.named_child_count()).rev() { + if let Some(truncation) = control.stop_truncation(Instant::now())? { + return Ok((exported, Some(truncation))); + } + if let Some(child) = named_child(node, index) { + stack.push((child, child_scope)); + } + } + } + Ok((exported, None)) +} + +fn is_nested_symbol_boundary(kind: &str) -> bool { + matches!( + kind, + "function_declaration" + | "function_expression" + | "function_signature" + | "generator_function_declaration" + | "generator_function" + | "arrow_function" + | "method_definition" + | "method_signature" + | "class_declaration" + | "interface_declaration" + | "enum_declaration" + | "internal_module" + | "module" + ) +} + +fn is_nesting_node(kind: &str) -> bool { + matches!( + kind, + "if_statement" + | "switch_statement" + | "switch_case" + | "switch_default" + | "for_statement" + | "for_in_statement" + | "while_statement" + | "do_statement" + | "try_statement" + | "catch_clause" + | "conditional_expression" + ) +} + +fn range_contains(outer: Node<'_>, inner: Node<'_>) -> bool { + outer.start_byte() <= inner.start_byte() && inner.end_byte() <= outer.end_byte() +} + +fn named_child(node: Node<'_>, index: usize) -> Option> { + u32::try_from(index) + .ok() + .and_then(|index| node.named_child(index)) +} + +fn parse_diagnostics( + root: Node<'_>, + control: &AnalysisControl, + diagnostic_limit: u32, + capture: &mut CaptureByteTracker, +) -> Result<(Vec, Option), ModelError> { + if !root.has_error() { + return Ok((Vec::new(), control.stop_truncation(Instant::now())?)); + } + let mut diagnostics = Vec::new(); + let mut stack = vec![root]; + while let Some(node) = stack.pop() { + if let Some(truncation) = control.stop_truncation(Instant::now())? { + return Ok((diagnostics, Some(truncation))); + } + if node.is_error() || node.is_missing() { + if diagnostics.len() >= usize::try_from(diagnostic_limit).unwrap_or(usize::MAX) { + return Ok(( + diagnostics, + Some(SyntaxTruncation::new( + SyntaxTruncationReason::DiagnosticCount, + Some(u64::from(diagnostic_limit)), + Some(u64::from(diagnostic_limit).saturating_add(1)), + )?), + )); + } + let message = if node.is_missing() { + format!("tree-sitter inserted missing {}", node.kind()) + } else { + "tree-sitter recovered from invalid syntax".to_string() + }; + let diagnostic = SyntaxDiagnostic::new( + DiagnosticSeverity::Warning, + message, + Some(node_range(node)?), + )?; + if let Some(truncation) = control.stop_truncation(Instant::now())? { + return Ok((diagnostics, Some(truncation))); + } + if let Err(truncation) = capture.try_account_diagnostic(&diagnostic) { + return Ok((diagnostics, Some(truncation))); + } + diagnostics.push(diagnostic); + continue; + } + for index in (0..node.named_child_count()).rev() { + if let Some(truncation) = control.stop_truncation(Instant::now())? { + return Ok((diagnostics, Some(truncation))); + } + if let Some(child) = named_child(node, index) { + stack.push(child); + } + } + } + if diagnostics.is_empty() { + if let Some(truncation) = control.stop_truncation(Instant::now())? { + return Ok((diagnostics, Some(truncation))); + } + let diagnostic = SyntaxDiagnostic::new( + DiagnosticSeverity::Warning, + "tree-sitter reported an incomplete syntax tree", + Some(node_range(root)?), + )?; + if let Some(truncation) = control.stop_truncation(Instant::now())? { + return Ok((diagnostics, Some(truncation))); + } + if let Err(truncation) = capture.try_account_diagnostic(&diagnostic) { + return Ok((diagnostics, Some(truncation))); + } + diagnostics.push(diagnostic); + } + Ok((diagnostics, None)) +} + +fn node_range(node: Node<'_>) -> Result { + SourceRange::from_tree_sitter( + u64::try_from(node.start_byte()).unwrap_or(u64::MAX), + u64::try_from(node.end_byte()).unwrap_or(u64::MAX), + u32::try_from(node.start_position().row).unwrap_or(u32::MAX), + u32::try_from(node.end_position().row).unwrap_or(u32::MAX), + u32::try_from(node.end_position().column).unwrap_or(u32::MAX), + ) +} + +fn range_between(start: Node<'_>, end: Node<'_>) -> Result { + SourceRange::from_tree_sitter( + u64::try_from(start.start_byte()).unwrap_or(u64::MAX), + u64::try_from(end.start_byte()).unwrap_or(u64::MAX), + u32::try_from(start.start_position().row).unwrap_or(u32::MAX), + u32::try_from(end.start_position().row).unwrap_or(u32::MAX), + u32::try_from(end.start_position().column).unwrap_or(u32::MAX), + ) +} + +fn node_text<'a>(source: &'a str, node: Node<'_>) -> Result<&'a str, ModelError> { + source_text(source, node_range(node)?) +} + +fn source_text(source: &str, range: SourceRange) -> Result<&str, ModelError> { + range.validate_source(source)?; + let start = usize::try_from(range.start_byte()).unwrap_or(usize::MAX); + let end = usize::try_from(range.end_byte()).unwrap_or(usize::MAX); + Ok(&source[start..end]) +} + +fn normalize_signature(signature: &str) -> String { + signature.split_whitespace().collect::>().join(" ") +} + +#[cfg(test)] +mod tests { + use std::num::{NonZeroU32, NonZeroU64}; + use std::time::Duration; + + use super::*; + + fn analyze(path: &str, language: SyntaxLanguage, source: &str) -> DocumentStructure { + TypeScriptAdapter + .analyze( + AnalysisInput::new(path, language, source.to_string()).unwrap(), + budget(1_000_000, 1_000, 1_000), + &AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()), + ) + .unwrap() + } + + fn budget(source: u64, symbols: u32, calls: u32) -> AnalysisBudget { + budget_with_diagnostics(source, symbols, calls, 1_000) + } + + fn budget_with_diagnostics( + source: u64, + symbols: u32, + calls: u32, + diagnostics: u32, + ) -> AnalysisBudget { + AnalysisBudget::new( + NonZeroU64::new(source).unwrap(), + NonZeroU32::new(symbols).unwrap(), + NonZeroU32::new(calls).unwrap(), + NonZeroU32::new(diagnostics).unwrap(), + ) + } + + fn budget_with_capture( + source: u64, + symbols: u32, + calls: u32, + diagnostics: u32, + capture: u64, + ) -> AnalysisBudget { + budget_with_diagnostics(source, symbols, calls, diagnostics) + .with_max_capture_bytes(NonZeroU64::new(capture).unwrap()) + } + + fn analyze_with_budget( + path: &str, + language: SyntaxLanguage, + source: &str, + budget: AnalysisBudget, + ) -> DocumentStructure { + TypeScriptAdapter + .analyze( + AnalysisInput::new(path, language, source.to_string()).unwrap(), + budget, + &AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()), + ) + .unwrap() + } + + fn symbol<'a>(document: &'a DocumentStructure, name: &str) -> &'a SymbolFact { + document + .symbols() + .iter() + .find(|symbol| symbol.key().name() == name) + .unwrap_or_else(|| panic!("missing symbol {name}")) + } + + #[test] + fn extracts_typescript_declarations_and_owned_calls() { + let document = analyze( + "fixtures/review.mts", + SyntaxLanguage::TypeScript, + include_str!("fixtures/review.ts"), + ); + assert_eq!(document.status(), DocumentStatus::Parsed); + let run = symbol(&document, "run"); + assert_eq!(run.visibility(), SymbolVisibility::Exported); + assert_eq!(run.parameter_count(), 2); + assert!(run.normalized_signature().contains("")); + assert!(run.normalized_signature().contains(": Promise")); + assert!(run.syntactic_nesting_depth() >= 2); + run.full_range() + .validate_source(include_str!("fixtures/review.ts")) + .unwrap(); + + let worker = symbol(&document, "Worker"); + assert_eq!(worker.type_member_count(), 2); + let method = symbol(&document, "execute"); + assert_eq!(method.key().qualified_path(), &["Worker"]); + let interface_method = document + .symbols() + .iter() + .find(|fact| { + fact.key().name() == "execute" && fact.key().qualified_path() == ["Executor"] + }) + .unwrap(); + assert_eq!(interface_method.key().kind(), SymbolKind::Method); + + let call = document + .calls() + .iter() + .find(|call| call.callee_text() == "service.call") + .unwrap(); + assert_eq!(call.argument_text(), "(value)"); + assert_eq!(call.enclosing_symbol().unwrap().name(), "run"); + assert!(call.control_context().contains(&ControlContext::Loop)); + let condition_call = document + .calls() + .iter() + .find(|call| call.callee_text() == "service.ready") + .unwrap(); + assert!( + condition_call + .control_context() + .contains(&ControlContext::Condition) + ); + } + + #[test] + fn extracts_named_arrows_unicode_and_cts_input() { + let source = "export const převeď = (hodnota: T): T => hodnota;\nconst named = function inner(x: number) { return x; };"; + let document = analyze("src/value.cts", SyntaxLanguage::TypeScript, source); + let arrow = symbol(&document, "převeď"); + assert_eq!(arrow.visibility(), SymbolVisibility::Exported); + assert_eq!(arrow.parameter_count(), 1); + assert_eq!(arrow.body_range().unwrap().line_count(), 1); + assert!( + source_text(source, arrow.full_range()) + .unwrap() + .starts_with("export const převeď") + ); + assert!( + arrow + .normalized_signature() + .starts_with("export const převeď") + ); + assert!( + symbol(&document, "named") + .normalized_signature() + .contains("function inner") + ); + assert!( + source_text(source, symbol(&document, "named").full_range()) + .unwrap() + .starts_with("const named") + ); + for fact in document.symbols() { + fact.full_range().validate_source(source).unwrap(); + fact.signature_range().validate_source(source).unwrap(); + } + } + + #[test] + fn multi_declarator_ranges_do_not_claim_sibling_bodies() { + let source = "export const first = () => firstBody(), second = () => secondBody();\n"; + let document = analyze("src/multiple.ts", SyntaxLanguage::TypeScript, source); + let second = symbol(&document, "second"); + let full = source_text(source, second.full_range()).unwrap(); + let signature = source_text(source, second.signature_range()).unwrap(); + + assert_eq!(second.visibility(), SymbolVisibility::Exported); + assert!(full.starts_with("second =")); + assert!(!full.contains("firstBody")); + assert!(!signature.contains("firstBody")); + assert_eq!( + source_text(source, second.body_range().unwrap()).unwrap(), + "secondBody()" + ); + } + + #[test] + fn extracts_overload_signatures_without_inventing_identity() { + let source = r#" +export function convert(value: string): string; +export function convert(value: string): string { return value; } +"#; + let document = analyze("src/overloads.ts", SyntaxLanguage::TypeScript, source); + let overloads: Vec<_> = document + .symbols() + .iter() + .filter(|fact| fact.key().name() == "convert") + .collect(); + assert_eq!(overloads.len(), 2); + assert!( + overloads + .iter() + .all(|fact| fact.key().qualified_path().is_empty()) + ); + assert!( + overloads + .iter() + .all(|fact| fact.visibility() == SymbolVisibility::Exported) + ); + assert!(overloads.iter().any(|fact| fact.body_range().is_none())); + assert!(overloads.iter().any(|fact| fact.body_range().is_some())); + } + + #[test] + fn uses_tsx_grammar_for_components() { + assert!(TypeScriptAdapter.supports(SyntaxLanguage::TypeScript)); + assert!(TypeScriptAdapter.supports(SyntaxLanguage::Tsx)); + assert!(!TypeScriptAdapter.supports(SyntaxLanguage::Rust)); + let document = analyze( + "src/card.tsx", + SyntaxLanguage::Tsx, + include_str!("fixtures/component.tsx"), + ); + assert_eq!(document.status(), DocumentStatus::Parsed); + assert_eq!(document.provenance().language(), SyntaxLanguage::Tsx); + assert_eq!(symbol(&document, "Card").parameter_count(), 1); + assert!( + document + .calls() + .iter() + .any(|call| call.callee_text() == "format") + ); + } + + #[test] + fn extracts_generators_enums_namespaces_private_members_and_initializers() { + let source = include_str!("fixtures/advanced.ts"); + let document = analyze("src/advanced.ts", SyntaxLanguage::TypeScript, source); + assert_eq!(document.status(), DocumentStatus::Parsed); + + assert_eq!( + symbol(&document, "listed").visibility(), + SymbolVisibility::Exported + ); + let generator = symbol(&document, "generate"); + assert_eq!(generator.key().kind(), SymbolKind::Function); + assert!( + generator + .normalized_signature() + .contains("function* generate") + ); + let assigned_generator = symbol(&document, "generated"); + assert!( + assigned_generator + .normalized_signature() + .starts_with("export const generated = function* named") + ); + + let state = symbol(&document, "State"); + assert_eq!(state.key().kind(), SymbolKind::Enum); + assert_eq!(state.type_member_count(), 2); + for variant_name in ["Idle", "Busy"] { + let variant = document + .symbols() + .iter() + .find(|fact| { + fact.key().kind() == SymbolKind::Variant && fact.key().name() == variant_name + }) + .unwrap(); + assert_eq!(variant.key().qualified_path(), &["State"]); + } + + let namespace = symbol(&document, "Tools"); + assert_eq!(namespace.key().kind(), SymbolKind::Module); + let parse = symbol(&document, "parse"); + assert_eq!(parse.key().qualified_path(), &["Tools"]); + + assert_eq!( + symbol(&document, "#secret").visibility(), + SymbolVisibility::Private + ); + assert_eq!( + symbol(&document, "#hide").visibility(), + SymbolVisibility::Private + ); + let visible = symbol(&document, "visible"); + let visible_signature = source_text(source, visible.signature_range()).unwrap(); + assert!(!visible_signature.contains("makeVisible")); + assert_eq!( + source_text(source, visible.body_range().unwrap()).unwrap(), + "makeVisible()" + ); + assert_eq!(symbol(&document, "method").parameter_count(), 2); + } + + #[test] + fn export_lists_are_scoped_to_their_lexical_module() { + let source = r#" +function shared() {} +namespace Local { + function shared() {} + export { shared }; +} +"#; + let document = analyze("src/scoped.ts", SyntaxLanguage::TypeScript, source); + let top_level = document + .symbols() + .iter() + .find(|fact| fact.key().name() == "shared" && fact.key().qualified_path().is_empty()) + .unwrap(); + let namespace_local = document + .symbols() + .iter() + .find(|fact| fact.key().name() == "shared" && fact.key().qualified_path() == ["Local"]) + .unwrap(); + + assert_eq!(top_level.visibility(), SymbolVisibility::Private); + assert_eq!(namespace_local.visibility(), SymbolVisibility::Exported); + } + + #[test] + fn broken_syntax_is_partial_with_diagnostics() { + let document = analyze( + "src/broken.ts", + SyntaxLanguage::TypeScript, + include_str!("fixtures/broken.ts"), + ); + assert_eq!(document.status(), DocumentStatus::Partial); + assert!(!document.diagnostics().is_empty()); + } + + #[test] + fn diagnostic_budget_is_explicit_and_truncated() { + let source = "const first = ;\nconst second = ;\nconst third = ;\n"; + let document = TypeScriptAdapter + .analyze( + AnalysisInput::new( + "src/many-errors.ts", + SyntaxLanguage::TypeScript, + source.to_string(), + ) + .unwrap(), + budget_with_diagnostics(1_000, 100, 100, 1), + &AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()), + ) + .unwrap(); + assert_eq!(document.status(), DocumentStatus::Partial); + assert_eq!(document.diagnostics().len(), 1); + let truncation = document.truncation().unwrap(); + assert_eq!(truncation.reason(), SyntaxTruncationReason::DiagnosticCount); + assert_eq!(truncation.limit(), Some(1)); + assert_eq!(truncation.observed(), Some(2)); + } + + #[test] + fn retained_capture_exact_limit_succeeds_and_one_byte_under_is_partial() { + let source = "export function run(value: string) { return work(value); }"; + let path = "src/capture.ts"; + let generous = analyze_with_budget( + path, + SyntaxLanguage::TypeScript, + source, + budget_with_capture(10_000, 100, 100, 100, 10_000), + ); + assert_eq!(generous.status(), DocumentStatus::Parsed); + let exact = generous.estimated_owned_bytes(); + + let at_limit = analyze_with_budget( + path, + SyntaxLanguage::TypeScript, + source, + budget_with_capture(10_000, 100, 100, 100, exact), + ); + assert_eq!(at_limit.status(), DocumentStatus::Parsed); + assert_eq!(at_limit.estimated_owned_bytes(), exact); + + let below = analyze_with_budget( + path, + SyntaxLanguage::TypeScript, + source, + budget_with_capture(10_000, 100, 100, 100, exact - 1), + ); + assert_eq!(below.status(), DocumentStatus::Partial); + assert_eq!( + below.truncation().unwrap().reason(), + SyntaxTruncationReason::CaptureBytes + ); + assert_eq!(below.truncation().unwrap().limit(), Some(exact - 1)); + assert_eq!(below.truncation().unwrap().observed(), Some(exact)); + assert!(below.estimated_owned_bytes() < exact); + } + + #[test] + fn overlapping_nested_call_arguments_stop_on_capture_bytes_before_count() { + let source = format!( + "export function run() {{ return outer(inner(deep(\"{}\"))); }}", + "payload".repeat(64) + ); + let path = "src/nested-capture.ts"; + let generous = analyze_with_budget( + path, + SyntaxLanguage::TypeScript, + &source, + budget_with_capture(100_000, 100, 100, 100, 100_000), + ); + assert_eq!(generous.status(), DocumentStatus::Parsed); + assert_eq!(generous.calls().len(), 3); + assert!( + generous.calls()[0] + .argument_text() + .contains(generous.calls()[1].argument_text()) + ); + assert!( + generous.calls()[0].argument_text().len() + generous.calls()[1].argument_text().len() + > generous.calls()[0].argument_text().len() + ); + + let calls_bytes: u64 = generous + .calls() + .iter() + .map(CallFact::estimated_owned_bytes) + .sum(); + let without_calls = generous.estimated_owned_bytes() - calls_bytes; + let first_two = generous.calls()[0].estimated_owned_bytes() + + generous.calls()[1].estimated_owned_bytes(); + let limit = without_calls + first_two - 1; + let limited = analyze_with_budget( + path, + SyntaxLanguage::TypeScript, + &source, + budget_with_capture(100_000, 100, 100, 100, limit), + ); + assert_eq!(limited.status(), DocumentStatus::Partial); + assert_eq!(limited.calls().len(), 1); + assert_eq!( + limited.truncation().unwrap().reason(), + SyntaxTruncationReason::CaptureBytes + ); + assert_eq!(limited.truncation().unwrap().limit(), Some(limit)); + assert_eq!(limited.truncation().unwrap().observed(), Some(limit + 1)); + assert_eq!( + limited.estimated_owned_bytes(), + without_calls + generous.calls()[0].estimated_owned_bytes() + ); + } + + #[test] + fn retained_diagnostics_are_capture_accounted() { + let source = "const first = ;\nconst second = ;\nconst third = ;\n"; + let path = "src/capture-errors.ts"; + let generous = analyze_with_budget( + path, + SyntaxLanguage::TypeScript, + source, + budget_with_capture(10_000, 100, 100, 100, 10_000), + ); + assert_eq!(generous.status(), DocumentStatus::Partial); + assert!(generous.truncation().is_none()); + assert!(!generous.diagnostics().is_empty()); + let exact = generous.estimated_owned_bytes(); + + let at_limit = analyze_with_budget( + path, + SyntaxLanguage::TypeScript, + source, + budget_with_capture(10_000, 100, 100, 100, exact), + ); + assert_eq!(at_limit.estimated_owned_bytes(), exact); + assert!(at_limit.truncation().is_none()); + + let limited = analyze_with_budget( + path, + SyntaxLanguage::TypeScript, + source, + budget_with_capture(10_000, 100, 100, 100, exact - 1), + ); + assert_eq!(limited.status(), DocumentStatus::Partial); + assert_eq!( + limited.truncation().unwrap().reason(), + SyntaxTruncationReason::CaptureBytes + ); + assert!(limited.diagnostics().len() < generous.diagnostics().len()); + assert!(limited.estimated_owned_bytes() < exact); + } + + #[test] + fn records_callback_closure_and_error_contexts_conservatively() { + let source = r#" +export function run(items: string[]) { + try { items.map((item) => transform(item)); } + catch (error) { report(error); } + switch (items.length) { case 1: notify(); } +} +"#; + let document = analyze("src/contexts.ts", SyntaxLanguage::TypeScript, source); + let transform = document + .calls() + .iter() + .find(|call| call.callee_text() == "transform") + .unwrap(); + assert!( + transform + .control_context() + .contains(&ControlContext::Callback) + ); + assert!( + transform + .control_context() + .contains(&ControlContext::Closure) + ); + let report = document + .calls() + .iter() + .find(|call| call.callee_text() == "report") + .unwrap(); + assert!( + report + .control_context() + .contains(&ControlContext::ErrorBranch) + ); + let notify = document + .calls() + .iter() + .find(|call| call.callee_text() == "notify") + .unwrap(); + assert!(notify.control_context().contains(&ControlContext::MatchArm)); + } + + #[test] + fn deep_valid_and_broken_trees_use_bounded_heap_walks() { + let depth = 2_000; + let mut valid = String::from("function deep() {"); + valid.push_str(&"if (true) {".repeat(depth)); + valid.push_str("work();"); + valid.push_str(&"}".repeat(depth + 1)); + let document = analyze("src/deep.ts", SyntaxLanguage::TypeScript, &valid); + assert_eq!(document.status(), DocumentStatus::Parsed); + assert_eq!( + symbol(&document, "deep").syntactic_nesting_depth(), + u32::try_from(depth).unwrap() + ); + + let mut broken = String::from("function deep() {"); + broken.push_str(&"if (true) {".repeat(depth)); + broken.push_str("work();"); + broken.push_str(&"}".repeat(depth)); + let document = analyze("src/deep-broken.ts", SyntaxLanguage::TypeScript, &broken); + assert_eq!(document.status(), DocumentStatus::Partial); + assert!(!document.diagnostics().is_empty()); + } + + #[test] + fn extractor_checks_cancellation_and_deadline_inside_large_walks() { + let mut source = String::from("function work() {"); + source.push_str(&"call();".repeat(100_000)); + source.push('}'); + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()) + .unwrap(); + let tree = parser.parse(&source, None).unwrap(); + + let cancelled = AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()); + let cancellation_signal = cancelled.clone(); + let canceller = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(1)); + cancellation_signal.cancel(); + }); + let cancelled_provenance = + SyntaxProvenance::tree_sitter(SyntaxLanguage::TypeScript, TYPESCRIPT_PARSER).unwrap(); + let cancelled_budget = budget(10_000_000, 200_000, 200_000); + let cancelled_capture = CaptureByteTracker::for_document( + cancelled_budget.max_capture_bytes(), + "src/large.ts", + &cancelled_provenance, + ) + .unwrap(); + let mut extractor = Extractor::new( + &source, + cancelled_provenance, + cancelled_budget, + &cancelled, + ExportedNames::new(), + cancelled_capture, + ); + extractor.extract(tree.root_node()).unwrap(); + canceller.join().unwrap(); + assert_eq!( + extractor.truncation.unwrap().reason(), + SyntaxTruncationReason::Cancelled + ); + + let expired = AnalysisControl::new(NonZeroU64::new(500).unwrap()); + let expired_provenance = + SyntaxProvenance::tree_sitter(SyntaxLanguage::TypeScript, TYPESCRIPT_PARSER).unwrap(); + let expired_budget = budget(10_000_000, 200_000, 200_000); + let expired_capture = CaptureByteTracker::for_document( + expired_budget.max_capture_bytes(), + "src/large.ts", + &expired_provenance, + ) + .unwrap(); + let mut extractor = Extractor::new( + &source, + expired_provenance, + expired_budget, + &expired, + ExportedNames::new(), + expired_capture, + ); + extractor.extract(tree.root_node()).unwrap(); + assert_eq!( + extractor.truncation.unwrap().reason(), + SyntaxTruncationReason::Time + ); + } + + #[test] + fn reports_each_budget_and_stop_path() { + let source = "const one = () => first(); const two = () => second();"; + let input = || { + AnalysisInput::new( + "src/budget.ts", + SyntaxLanguage::TypeScript, + source.to_string(), + ) + .unwrap() + }; + let future = || AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()); + + let source_limited = TypeScriptAdapter + .analyze(input(), budget(1, 10, 10), &future()) + .unwrap(); + assert_eq!( + source_limited.truncation().unwrap().reason(), + SyntaxTruncationReason::SourceBytes + ); + + let symbol_limited = TypeScriptAdapter + .analyze(input(), budget(1_000, 1, 10), &future()) + .unwrap(); + assert_eq!(symbol_limited.symbols().len(), 1); + assert_eq!( + symbol_limited.truncation().unwrap().reason(), + SyntaxTruncationReason::SymbolCount + ); + + let call_limited = TypeScriptAdapter + .analyze(input(), budget(1_000, 10, 1), &future()) + .unwrap(); + assert_eq!(call_limited.calls().len(), 1); + assert_eq!( + call_limited.truncation().unwrap().reason(), + SyntaxTruncationReason::CallCount + ); + + let cancelled = future(); + cancelled.cancel(); + let cancelled_document = TypeScriptAdapter + .analyze( + input(), + budget_with_capture(1_000, 10, 10, 10, 1), + &cancelled, + ) + .unwrap(); + assert_eq!( + cancelled_document.truncation().unwrap().reason(), + SyntaxTruncationReason::Cancelled + ); + + let expired = AnalysisControl::new(NonZeroU64::new(1).unwrap()); + while !expired.deadline_exceeded(Instant::now()) { + std::hint::spin_loop(); + } + let timed_out = TypeScriptAdapter + .analyze(input(), budget_with_capture(1_000, 10, 10, 10, 1), &expired) + .unwrap(); + assert_eq!( + timed_out.truncation().unwrap().reason(), + SyntaxTruncationReason::Time + ); + assert_eq!(timed_out.truncation().unwrap().limit(), Some(1)); + assert!(timed_out.truncation().unwrap().observed().unwrap() >= 1); + } + + #[test] + fn grammar_load_failure_preserves_control_stop_precedence() { + let input = AnalysisInput::new("src/grammar.ts", SyntaxLanguage::TypeScript, String::new()) + .unwrap(); + let provenance = + SyntaxProvenance::tree_sitter(SyntaxLanguage::TypeScript, TYPESCRIPT_PARSER).unwrap(); + let mut capture = CaptureByteTracker::for_document( + NonZeroU64::new(1_000).unwrap(), + input.path(), + &provenance, + ) + .unwrap(); + let cancelled = AnalysisControl::new(NonZeroU64::new(5_000_000).unwrap()); + cancelled.cancel(); + + let document = failed_document( + &input, + provenance, + "failed to load grammar", + &mut capture, + &cancelled, + ) + .unwrap(); + + assert_eq!(document.status(), DocumentStatus::Partial); + assert!(document.diagnostics().is_empty()); + assert_eq!( + document.truncation().unwrap().reason(), + SyntaxTruncationReason::Cancelled + ); + } + + #[test] + fn rejects_rust_without_parsing_it_as_typescript() { + let document = analyze("src/lib.rs", SyntaxLanguage::Rust, "fn main() {}\n"); + assert_eq!(document.status(), DocumentStatus::Unsupported); + assert!(document.symbols().is_empty()); + } +} diff --git a/crates/okena-transport/src/remote_action.rs b/crates/okena-transport/src/remote_action.rs index a2fafd41f..cebf7c5b3 100644 --- a/crates/okena-transport/src/remote_action.rs +++ b/crates/okena-transport/src/remote_action.rs @@ -21,6 +21,9 @@ const BYTES_TIMEOUT_SECS: u64 = 90; /// actions, these may need to walk and inspect an entire large checkout. const SEARCH_TIMEOUT_SECS: u64 = 90; +/// Total request timeout for deterministic review inventory, diff, and structure jobs. +const REVIEW_TIMEOUT_SECS: u64 = 90; + /// Total request timeout for synchronous filesystem mutations. Direct /// worktree removal may run two sequential five-minute close hooks before Git. const LONG_MUTATION_TIMEOUT_SECS: u64 = 11 * 60; @@ -40,6 +43,7 @@ enum ActionClientKind { Fast, Bytes, Search, + Review, LongMutation, } @@ -51,6 +55,10 @@ fn client_kind_for(action: &ActionRequest) -> ActionClientKind { ActionRequest::SearchContent { .. } | ActionRequest::SearchPathContent { .. } => { ActionClientKind::Search } + ActionRequest::ReviewInventory { .. } + | ActionRequest::ReviewDiff { .. } + | ActionRequest::ReviewSource { .. } + | ActionRequest::ReviewStructure { .. } => ActionClientKind::Review, ActionRequest::RemoveWorktreeProject { .. } | ActionRequest::RenameProjectDirectory { .. } => ActionClientKind::LongMutation, _ => ActionClientKind::Fast, @@ -62,6 +70,7 @@ fn timeout_for(action: &ActionRequest) -> u64 { ActionClientKind::Fast => FAST_TIMEOUT_SECS, ActionClientKind::Bytes => BYTES_TIMEOUT_SECS, ActionClientKind::Search => SEARCH_TIMEOUT_SECS, + ActionClientKind::Review => REVIEW_TIMEOUT_SECS, ActionClientKind::LongMutation => LONG_MUTATION_TIMEOUT_SECS, } } @@ -83,6 +92,7 @@ struct RemoteActionClientInner { fast: OnceLock>, bytes: OnceLock>, search: OnceLock>, + review: OnceLock>, long_mutation: OnceLock>, download: OnceLock>, #[cfg(feature = "cancellable-http")] @@ -108,6 +118,7 @@ impl RemoteActionClient { fast: OnceLock::new(), bytes: OnceLock::new(), search: OnceLock::new(), + review: OnceLock::new(), long_mutation: OnceLock::new(), download: OnceLock::new(), #[cfg(feature = "cancellable-http")] @@ -122,6 +133,7 @@ impl RemoteActionClient { ActionClientKind::Fast => &self.inner.fast, ActionClientKind::Bytes => &self.inner.bytes, ActionClientKind::Search => &self.inner.search, + ActionClientKind::Review => &self.inner.review, ActionClientKind::LongMutation => &self.inner.long_mutation, }; let timeout = std::time::Duration::from_secs(timeout_for(&action)); @@ -467,6 +479,60 @@ mod tests { } } + fn review_actions() -> Vec { + let requested_base = "1".repeat(40); + let merge_base = "2".repeat(40); + let head = "3".repeat(40); + let identity = format!("branch:merge-base:{requested_base}:{head}:{merge_base}"); + let request: okena_core::review::ReviewDiffRequest = + serde_json::from_value(serde_json::json!({ + "comparison": { + "requested": { + "branch_compare": { + "base": "origin/main", + "head": "feature/review" + } + }, + "requested_base_oid": requested_base, + "requested_head_oid": head, + "strategy": "merge_base_to_head", + "base": { "kind": "commit", "oid": merge_base }, + "head": { "kind": "commit", "oid": head }, + "merge_base_oid": merge_base, + "identity": identity + }, + "ignore_whitespace": false + })) + .unwrap(); + let source_request = okena_core::review::ReviewSourceRequest::new( + request.comparison.as_resolved().clone(), + Some("src/old.rs".to_string()), + Some("src/new.rs".to_string()), + ) + .unwrap(); + vec![ + ActionRequest::ReviewInventory { + project_id: "project".to_string(), + mode: okena_core::types::DiffMode::BranchCompare { + base: "origin/main".to_string(), + head: "feature/review".to_string(), + }, + }, + ActionRequest::ReviewDiff { + project_id: "project".to_string(), + request: request.clone(), + }, + ActionRequest::ReviewSource { + project_id: "project".to_string(), + request: Box::new(source_request), + }, + ActionRequest::ReviewStructure { + project_id: "project".to_string(), + request, + }, + ] + } + #[cfg(feature = "cancellable-http")] fn remote_config(port: u16) -> RemoteConnectionConfig { RemoteConnectionConfig { @@ -488,6 +554,15 @@ mod tests { assert_eq!(SEARCH_TIMEOUT_SECS, 90); } + #[test] + fn review_actions_use_dedicated_clients_and_timeout() { + for action in review_actions() { + assert_eq!(client_kind_for(&action), ActionClientKind::Review); + assert_eq!(timeout_for(&action), REVIEW_TIMEOUT_SECS); + } + assert_eq!(REVIEW_TIMEOUT_SECS, 90); + } + #[test] fn synchronous_long_mutations_use_dedicated_clients() { for action in [remove_worktree_action(), rename_project_directory_action()] { diff --git a/crates/okena-views-git/Cargo.toml b/crates/okena-views-git/Cargo.toml index 38bbbba86..435eae04c 100644 --- a/crates/okena-views-git/Cargo.toml +++ b/crates/okena-views-git/Cargo.toml @@ -9,6 +9,8 @@ okena-core = { path = "../okena-core" } okena-transport = { path = "../okena-transport", features = ["blocking-http"] } okena-extensions = { path = "../okena-extensions" } okena-git = { path = "../okena-git" } +okena-review = { path = "../okena-review" } +okena-syntax = { path = "../okena-syntax" } okena-files = { path = "../okena-files" } okena-ui = { path = "../okena-ui" } okena-workspace = { path = "../okena-workspace" } diff --git a/crates/okena-views-git/src/diff_viewer/data.rs b/crates/okena-views-git/src/diff_viewer/data.rs index b01f0764e..c8c0d93ff 100644 --- a/crates/okena-views-git/src/diff_viewer/data.rs +++ b/crates/okena-views-git/src/diff_viewer/data.rs @@ -2,9 +2,15 @@ //! syntax-highlighting the selected file, and (re-)building the file tree. use super::DiffViewer; +use super::review::{ + LoadState, ReviewEpoch, ReviewFileKey, adjacent_inventory_file, derived_requests, + is_smart_mode, theme_requires_rehighlight, +}; +use super::review_ui::ContentView; use super::syntax::process_file; use super::types::{DiffDisplayFile, DisplayItem, FileStats, FileTreeNode}; +use okena_core::review::{ImmutableResolvedComparison, ReviewSourceRequest}; use okena_files::file_tree::build_file_tree; use okena_git::{DiffMode, DiffResult}; @@ -18,9 +24,19 @@ impl DiffViewer { select_file: Option, cx: &mut Context, ) { + self.review_navigation.invalidate(); + if is_smart_mode(&mode) { + self.load_smart_review_async(mode, select_file, cx); + } else { + self.load_legacy_diff_async(mode, select_file, cx); + } + } + + fn reset_diff_display(&mut self, mode: DiffMode) { self.diff_mode = mode.clone(); self.loading = true; self.error_message = None; + self.file_error_active = false; self.raw_files.clear(); self.file_stats.clear(); self.current_file = None; @@ -33,6 +49,16 @@ impl DiffViewer { self.side_by_side_lines.clear(); self.scroll_x = 0.0; self.max_line_chars = 0; + } + + fn load_legacy_diff_async( + &mut self, + mode: DiffMode, + select_file: Option, + cx: &mut Context, + ) { + let epoch = self.smart_review.disable(); + self.reset_diff_display(mode.clone()); cx.notify(); let provider = self.provider.clone(); @@ -43,6 +69,9 @@ impl DiffViewer { let result = smol::unblock(move || provider.get_diff(mode, ignore_whitespace)).await; let _ = this.update(cx, |this, cx| { + if !this.smart_review.is_current(epoch) || this.diff_mode != mode_for_fallback { + return; + } this.loading = false; match result { Ok(diff_result) => { @@ -81,6 +110,209 @@ impl DiffViewer { .detach(); } + fn load_smart_review_async( + &mut self, + mode: DiffMode, + select_file: Option, + cx: &mut Context, + ) { + let explicit_selection = select_file.is_some(); + let selected = select_file.or_else(|| { + self.file_stats + .get(self.selected_file_index) + .map(|file| file.path.clone()) + }); + if let Some((epoch, comparison)) = self.smart_review.begin_derived_reload(&mode) { + self.reset_diff_display(mode.clone()); + self.start_smart_derived(epoch, mode, comparison, selected, cx); + cx.notify(); + return; + } + + let epoch = self.smart_review.begin(mode.clone()); + self.review_reset_for_comparison(); + if explicit_selection { + // The caller already chose the file, so the small-change auto-open must not. + self.review_ui.content = ContentView::File; + self.review_ui.small_change_applied = true; + } + self.reset_diff_display(mode.clone()); + cx.notify(); + let provider = self.provider.clone(); + cx.spawn(async move |this, cx| { + let result = provider.get_review_inventory(mode.clone()).await; + let _ = this.update(cx, |this, cx| { + if !this.smart_review.accepts(epoch, &mode) { + return; + } + match result { + Ok(inventory) => { + if inventory.comparison.requested() != &mode { + this.smart_review.inventory = LoadState::Failed( + "Review inventory returned a different requested comparison" + .to_string(), + ); + this.loading = false; + this.smart_review.changed(); + this.review_rebuild_model(cx); + this.error_message = Some( + "Review inventory returned a different comparison".to_string(), + ); + cx.notify(); + return; + } + let comparison = match ImmutableResolvedComparison::try_from( + inventory.comparison.clone(), + ) { + Ok(comparison) => comparison, + Err(error) => { + let message = format!( + "Review inventory comparison is not immutable: {error}" + ); + this.smart_review.inventory = LoadState::Failed(message.clone()); + this.loading = false; + this.smart_review.changed(); + this.review_rebuild_model(cx); + this.error_message = Some(message); + cx.notify(); + return; + } + }; + this.smart_review + .set_inventory(inventory, selected.as_deref()); + this.smart_review.diff = LoadState::Loading; + this.smart_review.structure = LoadState::Loading; + this.smart_review.changed(); + this.review_rebuild_model(cx); + this.start_smart_derived(epoch, mode, comparison, selected, cx); + } + Err(error) => { + this.smart_review.inventory = LoadState::Failed(error.clone()); + this.loading = false; + this.smart_review.changed(); + this.review_rebuild_model(cx); + this.error_message = Some(error); + } + } + cx.notify(); + }); + }) + .detach(); + } + + fn start_smart_derived( + &mut self, + epoch: ReviewEpoch, + mode: DiffMode, + comparison: ImmutableResolvedComparison, + _select_file: Option, + cx: &mut Context, + ) { + let (diff_request, structure_request) = + derived_requests(&comparison, self.ignore_whitespace); + + let diff_provider = self.provider.clone(); + let diff_mode = mode.clone(); + let expected_diff_comparison = comparison.clone(); + cx.spawn(async move |this, cx| { + let result = diff_provider.get_review_diff(diff_request).await; + let _ = this.update(cx, |this, cx| { + match this.smart_review.accept_diff( + epoch, + &diff_mode, + &expected_diff_comparison, + result, + ) { + Some(Ok(files)) => { + let diff = DiffResult { files }; + this.loading = false; + if !diff.is_empty() { + this.store_diff_result(diff); + this.build_file_tree(); + if !this.review_navigation.has_pending() { + this.reconcile_smart_selection(cx); + } + } else if !this.review_navigation.has_pending() { + this.smart_review.file.clear(); + } + } + Some(Err(error)) => { + this.loading = false; + debug_assert_eq!(this.smart_review.diff.error(), Some(error.as_str())); + } + None => return, + } + this.review_rebuild_model(cx); + this.resume_review_navigation(cx); + cx.notify(); + }); + }) + .detach(); + + let structure_provider = self.provider.clone(); + let structure_mode = mode; + let expected_structure_comparison = comparison; + cx.spawn(async move |this, cx| { + let result = structure_provider + .get_review_structure(structure_request) + .await; + let _ = this.update(cx, |this, cx| { + if this + .smart_review + .accept_structure( + epoch, + &structure_mode, + &expected_structure_comparison, + result, + ) + .is_some() + { + this.review_rebuild_model(cx); + } + cx.notify(); + }); + }) + .detach(); + } + + pub(super) fn select_smart_file(&mut self, key: ReviewFileKey, cx: &mut Context) { + // Choosing a file always lands in the file view — spec §2. + self.review_ui.content = ContentView::File; + self.review_navigation.invalidate(); + self.smart_review.set_selected_file(key); + self.reconcile_smart_selection(cx); + cx.notify(); + } + + pub(super) fn select_adjacent_smart_file(&mut self, forward: bool, cx: &mut Context) { + let Some(inventory) = self.smart_review.inventory.ready() else { + return; + }; + let Some(key) = + adjacent_inventory_file(inventory, self.smart_review.selected_file.as_ref(), forward) + else { + return; + }; + self.select_smart_file(key, cx); + } + + pub(super) fn reconcile_smart_selection(&mut self, cx: &mut Context) { + let Some(key) = self.smart_review.selected_file.clone() else { + self.smart_review.file.clear(); + return; + }; + let Some(index) = self + .file_stats + .iter() + .position(|file| file.old_path == key.old_path && file.new_path == key.new_path) + else { + self.smart_review.file.clear(); + return; + }; + self.selected_file_index = index; + self.process_current_file_async(cx); + } + /// Store raw diff data and extract lightweight stats (no syntax highlighting). fn store_diff_result(&mut self, result: DiffResult) { let mut files = result.files; @@ -93,19 +325,130 @@ impl DiffViewer { /// Process the currently selected file with syntax highlighting (async). pub(super) fn process_current_file_async(&mut self, cx: &mut Context) { + self.process_current_file_for_generation(None, cx); + } + + pub(super) fn process_current_file_for_generation( + &mut self, + requested: Option<(super::review::FileGeneration, ReviewFileKey)>, + cx: &mut Context, + ) { + self.current_file = None; + self.current_file_old_content = None; + self.current_file_new_content = None; + if self.file_error_active { + self.error_message = None; + self.file_error_active = false; + } let Some(raw_file) = self.raw_files.get(self.selected_file_index).cloned() else { - self.current_file = None; - self.current_file_old_content = None; - self.current_file_new_content = None; + self.smart_review.file.clear(); return; }; + let key = ReviewFileKey::from_diff(&raw_file); + let generation = match requested { + Some((generation, requested_key)) + if requested_key == key + && self.smart_review.file.accepts(generation, &requested_key) => + { + generation + } + Some(_) | None => self.smart_review.file.begin(key.clone()), + }; let provider = self.provider.clone(); - let file_path = raw_file.display_name().to_string(); - let diff_mode = self.diff_mode.clone(); let syntax_set = self.syntax_set.clone(); let is_dark = self.is_dark; + if is_smart_mode(&self.diff_mode) { + let Some(comparison) = self.smart_review.comparison() else { + self.smart_review.file.source = + LoadState::Failed("Exact review comparison is not ready".to_string()); + self.resume_review_navigation(cx); + return; + }; + let request = match ReviewSourceRequest::new( + comparison.as_resolved().clone(), + key.old_path.clone(), + key.new_path.clone(), + ) { + Ok(request) => request, + Err(error) => { + self.smart_review.file.source = LoadState::Failed(error.to_string()); + self.resume_review_navigation(cx); + return; + } + }; + cx.spawn(async move |this, cx| { + let result = provider.get_review_source(request).await; + let result = match result { + Ok(source) + if source.comparison() == &comparison && key.matches_source(&source) => + { + let old_content = source.old_content().map(str::to_owned); + let new_content = source.new_content().map(str::to_owned); + let processing_old = old_content.clone(); + let processing_new = new_content.clone(); + let processed = smol::unblock(move || { + let mut max_line_num = 0usize; + let display_file = process_file( + &raw_file, + &mut max_line_num, + &syntax_set, + processing_old, + processing_new, + is_dark, + ); + (display_file, max_line_num) + }) + .await; + Ok((source, old_content, new_content, processed.0, processed.1)) + } + Ok(_) => Err("Exact review source did not match the selected file".to_string()), + Err(error) => Err(error), + }; + + let _ = this.update(cx, |this, cx| { + if !this.smart_review.file.accepts(generation, &key) { + return; + } + match result { + Ok((source, old_content, new_content, display_file, max_line_num)) => { + this.smart_review.file.source = LoadState::Ready(source); + this.smart_review.file.mark_cache_ready(generation, &key); + if this.file_error_active { + this.error_message = None; + this.file_error_active = false; + } + if theme_requires_rehighlight(is_dark, this.is_dark) { + this.current_file_old_content = old_content; + this.current_file_new_content = new_content; + this.rehighlight_current_file(); + this.update_side_by_side_cache(); + } else { + this.install_processed_file( + old_content, + new_content, + display_file, + max_line_num, + ); + } + } + Err(error) => { + this.smart_review.file.source = LoadState::Failed(error); + this.current_file = None; + } + } + this.resume_review_navigation(cx); + cx.notify(); + }); + }) + .detach(); + return; + } + + let file_path = raw_file.display_name().to_string(); + let diff_mode = self.diff_mode.clone(); + cx.spawn(async move |this, cx| { let result = smol::unblock(move || { let (old_content, new_content) = @@ -124,18 +467,31 @@ impl DiffViewer { .await; let _ = this.update(cx, |this, cx| { + if !this.smart_review.file.accepts(generation, &key) { + return; + } match result { Ok((old_content, new_content, display_file, max_line_num)) => { - this.current_file_old_content = old_content; - this.current_file_new_content = new_content; - this.line_num_width = max_line_num.to_string().len().max(3); - this.max_line_chars = Self::calc_max_line_chars(&display_file); - this.current_file = Some(display_file); - this.update_side_by_side_cache(); + this.smart_review.file.source = LoadState::Idle; + this.smart_review.file.mark_cache_ready(generation, &key); + if theme_requires_rehighlight(is_dark, this.is_dark) { + this.current_file_old_content = old_content; + this.current_file_new_content = new_content; + this.rehighlight_current_file(); + this.update_side_by_side_cache(); + } else { + this.install_processed_file( + old_content, + new_content, + display_file, + max_line_num, + ); + } } Err(error) => { this.current_file = None; this.error_message = Some(error); + this.file_error_active = true; } } cx.notify(); @@ -144,11 +500,34 @@ impl DiffViewer { .detach(); } + fn install_processed_file( + &mut self, + old_content: Option, + new_content: Option, + display_file: DiffDisplayFile, + max_line_num: usize, + ) { + self.current_file_old_content = old_content; + self.current_file_new_content = new_content; + self.line_num_width = max_line_num.to_string().len().max(3); + self.max_line_chars = Self::calc_max_line_chars(&display_file); + self.current_file = Some(display_file); + self.update_side_by_side_cache(); + } + /// Re-highlight current file using cached content (for theme changes). pub(super) fn rehighlight_current_file(&mut self) { let Some(raw_file) = self.raw_files.get(self.selected_file_index) else { return; }; + let key = ReviewFileKey::from_diff(raw_file); + if !self + .smart_review + .file + .has_ready_cache(&key, is_smart_mode(&self.diff_mode)) + { + return; + } let mut max_line_num = 0usize; let display_file = process_file( diff --git a/crates/okena-views-git/src/diff_viewer/line_render.rs b/crates/okena-views-git/src/diff_viewer/line_render.rs index 501bca46e..42917bce6 100644 --- a/crates/okena-views-git/src/diff_viewer/line_render.rs +++ b/crates/okena-views-git/src/diff_viewer/line_render.rs @@ -8,6 +8,7 @@ use super::types::{DisplayItem, DisplayLine, ExpanderRow, HighlightedSpan}; use gpui::prelude::*; use gpui::*; use gpui_component::h_flex; +use okena_core::review::ComparisonSide; use okena_core::theme::ThemeColors; use okena_files::code_view::{ build_styled_text_with_backgrounds, find_word_boundaries, selection_bg_ranges, @@ -276,7 +277,17 @@ impl DiffViewer { .map(|n| format!("{:>width$}", n, width = self.line_num_width)) .unwrap_or_else(|| " ".repeat(self.line_num_width)); - let (line_bg, _, accent_color) = self.line_colors(line.line_type, t); + let (line_bg, _, mut accent_color) = self.line_colors(line.line_type, t); + let semantic_highlight = line + .old_line_num + .is_some_and(|line| self.semantic_highlight_matches(ComparisonSide::Base, line)) + || line + .new_line_num + .is_some_and(|line| self.semantic_highlight_matches(ComparisonSide::Head, line)); + if semantic_highlight { + // The selected symbol claims the accent bar for as long as it stays selected. + accent_color = Some(rgba(t.border_active, 1.0)); + } let mut bg_ranges = selection_bg_ranges(&self.selection, line_index, line.plain_text.len()); // In-page search highlights (cell id = item index in unified view). @@ -299,6 +310,7 @@ impl DiffViewer { .text_size(px(font_size)) .font_family("monospace") .when_some(line_bg, |d, bg| d.bg(bg)) + .when(semantic_highlight, |d| d.bg(rgba(t.term_yellow, 0.2))) .on_mouse_down(MouseButton::Left, { let text_layout = text_layout.clone(); let plain_text = plain_text.clone(); diff --git a/crates/okena-views-git/src/diff_viewer/mod.rs b/crates/okena-views-git/src/diff_viewer/mod.rs index d307aa00a..c06907cf7 100644 --- a/crates/okena-views-git/src/diff_viewer/mod.rs +++ b/crates/okena-views-git/src/diff_viewer/mod.rs @@ -9,6 +9,9 @@ mod line_render; mod nav; pub mod provider; mod render; +pub(crate) mod review; +mod review_nav; +mod review_ui; mod scrollbar; mod search; mod selection_ops; @@ -84,6 +87,8 @@ pub struct DiffViewer { /// Width and active resize gesture for the file tree sidebar. pub(super) sidebar_resize: ResizableSidebarState, pub(super) error_message: Option, + /// Whether `error_message` belongs only to the selected legacy file load. + pub(super) file_error_active: bool, pub(super) line_num_width: usize, pub(super) syntax_set: std::sync::Arc, pub(super) scrollbar_drag: Option, @@ -131,6 +136,11 @@ pub struct DiffViewer { pub(super) search: Option, /// See [`DiffSearchSig`]. pub(super) search_sig: Option, + /// Independently loaded immutable review datasets and exact file source. + pub(super) smart_review: review::SmartReviewState, + /// Review workspace UI state (navigator, filters, derived model). + pub(super) review_ui: review_ui::state::ReviewUiState, + pub(super) review_navigation: review_nav::ReviewNavigationState, } impl DiffViewer { @@ -150,6 +160,7 @@ impl DiffViewer { let view_mode = gs.diff_view_mode; let ignore_whitespace = gs.diff_ignore_whitespace; let is_dark = gs.is_dark; + let review_ui = review_ui::state::ReviewUiState::new(cx); let mut viewer = Self { focus_handle, @@ -169,6 +180,7 @@ impl DiffViewer { tree_scroll_handle: ScrollHandle::new(), sidebar_resize: ResizableSidebarState::default(), error_message: None, + file_error_active: false, line_num_width: 4, syntax_set: load_syntax_set(), scrollbar_drag: None, @@ -194,6 +206,9 @@ impl DiffViewer { selection_context_menu: None, search: None, search_sig: None, + smart_review: review::SmartReviewState::default(), + review_ui, + review_navigation: review_nav::ReviewNavigationState::default(), }; if !provider.is_git_repo() { diff --git a/crates/okena-views-git/src/diff_viewer/nav.rs b/crates/okena-views-git/src/diff_viewer/nav.rs index 06b9cfefd..987e5de72 100644 --- a/crates/okena-views-git/src/diff_viewer/nav.rs +++ b/crates/okena-views-git/src/diff_viewer/nav.rs @@ -3,6 +3,7 @@ use super::DiffViewer; use super::DiffViewerEvent; +use super::review::is_smart_mode; use super::side_by_side; use crate::settings::{git_settings, set_git_settings}; @@ -29,11 +30,15 @@ impl DiffViewer { } pub(super) fn toggle_mode(&mut self, cx: &mut Context) { + if is_smart_mode(&self.diff_mode) { + return; + } let new_mode = self.diff_mode.toggle(); self.load_diff_async(new_mode, None, cx); } pub(super) fn toggle_view_mode(&mut self, cx: &mut Context) { + self.review_navigation.invalidate(); self.view_mode = self.view_mode.toggle(); self.selection.clear(); self.selection_side = None; @@ -74,6 +79,16 @@ impl DiffViewer { if index == self.selected_file_index && self.current_file.is_some() { return; } + self.review_navigation.invalidate(); + if is_smart_mode(&self.diff_mode) + && let Some(file) = self.file_stats.get(index) + { + self.smart_review + .set_selected_file(super::review::ReviewFileKey { + old_path: file.old_path.clone(), + new_path: file.new_path.clone(), + }); + } self.selected_file_index = index; self.selection.clear(); self.selection_side = None; diff --git a/crates/okena-views-git/src/diff_viewer/provider.rs b/crates/okena-views-git/src/diff_viewer/provider.rs index 133aef8a5..99743c6f0 100644 --- a/crates/okena-views-git/src/diff_viewer/provider.rs +++ b/crates/okena-views-git/src/diff_viewer/provider.rs @@ -1,7 +1,16 @@ //! GitProvider trait and the remote-server (HTTP) implementation. +use futures::future::BoxFuture; +use okena_core::api::ActionRequest; +use okena_core::review::{ + ExactReviewSourceResponse, ImmutableResolvedComparison, ReviewComparisonId, ReviewDiffRequest, + ReviewInventory, ReviewSourceRequest, +}; +use okena_git::ExactReviewDiffResponse; use okena_git::{BranchList, CommitLogEntry, DiffMode, DiffResult, FileDiffSummary}; +use okena_review::ReviewStructure; use serde::de::DeserializeOwned; +use std::hash::{Hash, Hasher}; /// Provides git data from either local git commands or a remote server. pub trait GitProvider: Send + Sync + 'static { @@ -13,6 +22,30 @@ pub trait GitProvider: Send + Sync + 'static { true } fn get_diff(&self, mode: DiffMode, ignore_whitespace: bool) -> Result; + fn get_review_inventory( + &self, + _mode: DiffMode, + ) -> BoxFuture<'static, Result> { + Box::pin(async { Err("Review inventory is not supported by this provider".to_string()) }) + } + fn get_review_diff( + &self, + _request: ReviewDiffRequest, + ) -> BoxFuture<'static, Result> { + Box::pin(async { Err("Exact review diff is not supported by this provider".to_string()) }) + } + fn get_review_source( + &self, + _request: ReviewSourceRequest, + ) -> BoxFuture<'static, Result> { + Box::pin(async { Err("Exact review source is not supported by this provider".to_string()) }) + } + fn get_review_structure( + &self, + _request: ReviewDiffRequest, + ) -> BoxFuture<'static, Result> { + Box::pin(async { Err("Structured review is not supported by this provider".to_string()) }) + } fn get_file_contents( &self, file_path: &str, @@ -86,28 +119,108 @@ impl RemoteGitProvider { } } - fn post_action( - &self, - action: okena_core::api::ActionRequest, - ) -> Result, String> { + fn post_action(&self, action: ActionRequest) -> Result, String> { self.client.post_action(action) } - fn post_json(&self, action: okena_core::api::ActionRequest, label: &str) -> Result + fn post_json(&self, action: ActionRequest, label: &str) -> Result where T: DeserializeOwned, { - let value = self - .post_action(action)? - .ok_or_else(|| format!("Missing {label} response"))?; - serde_json::from_value(value).map_err(|error| format!("Invalid {label} response: {error}")) + decode_json_response(self.post_action(action)?, label) + } + + fn post_json_async( + &self, + action: ActionRequest, + label: &'static str, + ) -> BoxFuture<'static, Result> + where + T: DeserializeOwned + Send + 'static, + { + let client = self.client.clone(); + Box::pin(smol::unblock(move || { + decode_json_response(client.post_action(action)?, label) + })) } - fn post_unit(&self, action: okena_core::api::ActionRequest) -> Result<(), String> { + fn post_unit(&self, action: ActionRequest) -> Result<(), String> { self.post_action(action).map(|_| ()) } } +fn decode_json_response(value: Option, label: &str) -> Result +where + T: DeserializeOwned, +{ + let value = value.ok_or_else(|| format!("Missing {label} response"))?; + serde_json::from_value(value).map_err(|error| format!("Invalid {label} response: {error}")) +} + +fn review_inventory_action(project_id: &str, mode: DiffMode) -> ActionRequest { + ActionRequest::ReviewInventory { + project_id: project_id.to_string(), + mode, + } +} + +fn review_diff_action(project_id: &str, request: ReviewDiffRequest) -> ActionRequest { + ActionRequest::ReviewDiff { + project_id: project_id.to_string(), + request, + } +} + +fn review_source_action(project_id: &str, request: ReviewSourceRequest) -> ActionRequest { + ActionRequest::ReviewSource { + project_id: project_id.to_string(), + request: Box::new(request), + } +} + +fn review_structure_action(project_id: &str, request: ReviewDiffRequest) -> ActionRequest { + ActionRequest::ReviewStructure { + project_id: project_id.to_string(), + request, + } +} + +fn require_exact_source_match( + expected_comparison: &ImmutableResolvedComparison, + expected_old_path: Option<&str>, + expected_new_path: Option<&str>, + response: &ExactReviewSourceResponse, +) -> Result<(), String> { + if response.comparison() == expected_comparison + && response.old_path() == expected_old_path + && response.new_path() == expected_new_path + { + return Ok(()); + } + Err("Exact review source response did not match the requested comparison and paths".to_string()) +} + +fn require_comparison_identity( + expected: &ReviewComparisonId, + received: &ReviewComparisonId, + label: &str, +) -> Result<(), String> { + if expected == received { + return Ok(()); + } + Err(format!( + "{label} response comparison mismatch (expected tag {:016x}, received tag {:016x})", + comparison_identity_tag(expected), + comparison_identity_tag(received) + )) +} + +fn comparison_identity_tag(identity: &ReviewComparisonId) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + identity.hash(&mut hasher); + hasher.finish() +} + impl GitProvider for RemoteGitProvider { fn is_git_repo(&self) -> bool { true @@ -118,7 +231,7 @@ impl GitProvider for RemoteGitProvider { } fn get_diff(&self, mode: DiffMode, ignore_whitespace: bool) -> Result { - let action = okena_core::api::ActionRequest::GitDiff { + let action = ActionRequest::GitDiff { project_id: self.project_id.clone(), mode, ignore_whitespace, @@ -126,12 +239,85 @@ impl GitProvider for RemoteGitProvider { self.post_json(action, "diff") } + fn get_review_inventory( + &self, + mode: DiffMode, + ) -> BoxFuture<'static, Result> { + self.post_json_async( + review_inventory_action(&self.project_id, mode), + "review inventory", + ) + } + + fn get_review_diff( + &self, + request: ReviewDiffRequest, + ) -> BoxFuture<'static, Result> { + let expected = request.comparison.identity().clone(); + let response = self.post_json_async::( + review_diff_action(&self.project_id, request), + "exact review diff", + ); + Box::pin(async move { + let response = response.await?; + require_comparison_identity( + &expected, + response.comparison().identity(), + "Exact review diff", + )?; + Ok(response) + }) + } + + fn get_review_source( + &self, + request: ReviewSourceRequest, + ) -> BoxFuture<'static, Result> { + let expected_comparison = request.comparison().clone(); + let expected_old_path = request.old_path().map(str::to_owned); + let expected_new_path = request.new_path().map(str::to_owned); + let response = self.post_json_async::( + review_source_action(&self.project_id, request), + "exact review source", + ); + Box::pin(async move { + let response = response.await?; + require_exact_source_match( + &expected_comparison, + expected_old_path.as_deref(), + expected_new_path.as_deref(), + &response, + )?; + Ok(response) + }) + } + + fn get_review_structure( + &self, + request: ReviewDiffRequest, + ) -> BoxFuture<'static, Result> { + let expected = request.comparison.identity().clone(); + let response = self.post_json_async::( + review_structure_action(&self.project_id, request), + "review structure", + ); + Box::pin(async move { + let response = response.await?; + require_comparison_identity( + &expected, + response.comparison().identity(), + "Review structure", + )?; + Ok(response) + }) + } + fn get_file_contents( &self, file_path: &str, mode: DiffMode, ) -> Result<(Option, Option), String> { - let action = okena_core::api::ActionRequest::GitFileContents { + let action = ActionRequest::GitFileContents { project_id: self.project_id.clone(), file_path: file_path.to_string(), mode, @@ -151,7 +337,7 @@ impl GitProvider for RemoteGitProvider { } fn get_diff_file_summary(&self) -> Result, String> { - let action = okena_core::api::ActionRequest::GitDiffSummary { + let action = ActionRequest::GitDiffSummary { project_id: self.project_id.clone(), }; self.post_json(action, "diff summary") @@ -162,7 +348,7 @@ impl GitProvider for RemoteGitProvider { count: usize, branch: Option<&str>, ) -> Result, String> { - let action = okena_core::api::ActionRequest::GitCommitGraph { + let action = ActionRequest::GitCommitGraph { project_id: self.project_id.clone(), count, branch: branch.map(String::from), @@ -171,21 +357,21 @@ impl GitProvider for RemoteGitProvider { } fn list_branches(&self) -> Result, String> { - let action = okena_core::api::ActionRequest::GitListBranches { + let action = ActionRequest::GitListBranches { project_id: self.project_id.clone(), }; self.post_json(action, "branch list") } fn list_branches_classified(&self) -> Result { - let action = okena_core::api::ActionRequest::GitListBranchesClassified { + let action = ActionRequest::GitListBranchesClassified { project_id: self.project_id.clone(), }; self.post_json(action, "classified branch list") } fn stage_file(&self, file_path: &str) -> Result<(), String> { - let action = okena_core::api::ActionRequest::GitStageFile { + let action = ActionRequest::GitStageFile { project_id: self.project_id.clone(), file_path: file_path.to_string(), }; @@ -193,7 +379,7 @@ impl GitProvider for RemoteGitProvider { } fn unstage_file(&self, file_path: &str) -> Result<(), String> { - let action = okena_core::api::ActionRequest::GitUnstageFile { + let action = ActionRequest::GitUnstageFile { project_id: self.project_id.clone(), file_path: file_path.to_string(), }; @@ -201,7 +387,7 @@ impl GitProvider for RemoteGitProvider { } fn discard_file(&self, file_path: &str) -> Result<(), String> { - let action = okena_core::api::ActionRequest::GitDiscardFile { + let action = ActionRequest::GitDiscardFile { project_id: self.project_id.clone(), file_path: file_path.to_string(), }; @@ -209,7 +395,7 @@ impl GitProvider for RemoteGitProvider { } fn delete_file(&self, file_path: &str) -> Result<(), String> { - let action = okena_core::api::ActionRequest::DeleteFile { + let action = ActionRequest::DeleteFile { project_id: self.project_id.clone(), relative_path: file_path.to_string(), }; @@ -217,7 +403,7 @@ impl GitProvider for RemoteGitProvider { } fn checkout_local_branch(&self, branch: &str) -> Result<(), String> { - let action = okena_core::api::ActionRequest::GitCheckoutLocalBranch { + let action = ActionRequest::GitCheckoutLocalBranch { project_id: self.project_id.clone(), branch: branch.to_string(), }; @@ -225,7 +411,7 @@ impl GitProvider for RemoteGitProvider { } fn checkout_remote_branch(&self, remote_branch: &str) -> Result<(), String> { - let action = okena_core::api::ActionRequest::GitCheckoutRemoteBranch { + let action = ActionRequest::GitCheckoutRemoteBranch { project_id: self.project_id.clone(), remote_branch: remote_branch.to_string(), }; @@ -237,7 +423,7 @@ impl GitProvider for RemoteGitProvider { new_name: &str, start_point: Option<&str>, ) -> Result<(), String> { - let action = okena_core::api::ActionRequest::GitCreateAndCheckoutBranch { + let action = ActionRequest::GitCreateAndCheckoutBranch { project_id: self.project_id.clone(), new_name: new_name.to_string(), start_point: start_point.map(String::from), @@ -253,3 +439,492 @@ impl GitProvider for RemoteGitProvider { Some(format!("{}/{}", base, file_path)) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + fn comparison_json() -> Value { + comparison_json_for('1', '2', '3') + } + + fn comparison_json_for(base_digit: char, merge_base_digit: char, head_digit: char) -> Value { + let requested_base = base_digit.to_string().repeat(40); + let merge_base = merge_base_digit.to_string().repeat(40); + let head = head_digit.to_string().repeat(40); + let identity = format!("branch:merge-base:{requested_base}:{head}:{merge_base}"); + json!({ + "requested": { + "branch_compare": { + "base": "origin/main", + "head": "feature/review" + } + }, + "requested_base_oid": requested_base, + "requested_head_oid": head, + "strategy": "merge_base_to_head", + "base": { "kind": "commit", "oid": merge_base }, + "head": { "kind": "commit", "oid": head }, + "merge_base_oid": merge_base, + "identity": identity + }) + } + + fn coverage_json() -> Value { + json!({ + "total_items": 0, + "analyzed_items": 0, + "pending_items": 0, + "skipped_items": 0, + "unsupported_items": 0, + "failed_items": 0 + }) + } + + fn request() -> ReviewDiffRequest { + serde_json::from_value(json!({ + "comparison": comparison_json(), + "ignore_whitespace": false + })) + .expect("valid review request") + } + + fn source_request(old_path: Option<&str>, new_path: Option<&str>) -> ReviewSourceRequest { + ReviewSourceRequest::new( + request().comparison.into_resolved(), + old_path.map(str::to_owned), + new_path.map(str::to_owned), + ) + .expect("valid exact source request") + } + + #[test] + fn review_actions_keep_project_and_exact_request() { + let mode = DiffMode::BranchCompare { + base: "origin/main".to_string(), + head: "feature/review".to_string(), + }; + let request = request(); + + assert_eq!( + serde_json::to_value(review_inventory_action("project-1", mode)).unwrap(), + json!({ + "action": "review_inventory", + "project_id": "project-1", + "mode": { + "branch_compare": { + "base": "origin/main", + "head": "feature/review" + } + } + }) + ); + assert_eq!( + serde_json::to_value(review_source_action( + "project-1", + source_request(Some("src/old.rs"), Some("src/new.rs")), + )) + .unwrap(), + json!({ + "action": "review_source", + "project_id": "project-1", + "request": { + "comparison": comparison_json(), + "old_path": "src/old.rs", + "new_path": "src/new.rs" + } + }) + ); + for (action, name) in [ + ( + review_diff_action("project-1", request.clone()), + "review_diff", + ), + ( + review_structure_action("project-1", request.clone()), + "review_structure", + ), + ] { + assert_eq!( + serde_json::to_value(action).unwrap(), + json!({ + "action": name, + "project_id": "project-1", + "request": { + "comparison": comparison_json(), + "ignore_whitespace": false + } + }) + ); + } + } + + #[test] + fn typed_review_responses_decode_without_empty_fallbacks() { + let inventory: ReviewInventory = decode_json_response( + Some(json!({ + "comparison": comparison_json(), + "totals": { + "commits": 0, + "files": 0, + "files_added": 0, + "files_deleted": 0, + "files_modified": 0, + "files_renamed": 0, + "files_copied": 0, + "files_type_changed": 0, + "files_mode_changed": 0, + "submodule_changes": 0, + "binary_files": 0, + "lines_added": 0, + "lines_deleted": 0, + "provenance": { "source": "git" } + }, + "commits": [], + "files": [], + "coverage": coverage_json() + })), + "review inventory", + ) + .expect("typed inventory"); + assert_eq!(inventory.comparison.identity().0, comparison_identity()); + + let diff: ExactReviewDiffResponse = decode_json_response( + Some(json!({ + "comparison": comparison_json(), + "diff": { "files": [] } + })), + "exact review diff", + ) + .expect("typed exact diff"); + assert_eq!(diff.comparison().identity().0, comparison_identity()); + assert!(diff.diff().is_empty()); + + let source: ExactReviewSourceResponse = decode_json_response( + Some(json!({ + "comparison": comparison_json(), + "old_path": "src/old.rs", + "new_path": "src/new.rs", + "old_content": "old source\n", + "new_content": "new source\n" + })), + "exact review source", + ) + .expect("typed exact source"); + assert_eq!(source.comparison().identity().0, comparison_identity()); + assert_eq!(source.old_path(), Some("src/old.rs")); + assert_eq!(source.new_path(), Some("src/new.rs")); + + let structure: ReviewStructure = + decode_json_response(Some(valid_structure_json()), "review structure") + .expect("typed review structure"); + assert_eq!(structure.comparison().identity().0, comparison_identity()); + assert!(structure.files().is_empty()); + } + + #[test] + fn missing_or_malformed_review_responses_are_errors() { + let missing = decode_json_response::(None, "review inventory") + .expect_err("missing response must fail"); + assert_eq!(missing, "Missing review inventory response"); + + let mut malformed = valid_structure_json(); + malformed["coverage"]["total_items"] = json!(1); + let error = decode_json_response::(Some(malformed), "review structure") + .expect_err("invalid coverage must fail"); + assert!(error.starts_with("Invalid review structure response:")); + + let missing = + decode_json_response::(None, "exact review source") + .expect_err("missing exact source response must fail"); + assert_eq!(missing, "Missing exact review source response"); + + let malformed = decode_json_response::( + Some(json!({ + "comparison": comparison_json(), + "old_path": "src/old.rs" + })), + "exact review source", + ) + .expect_err("source path without content must fail"); + assert!(malformed.starts_with("Invalid exact review source response:")); + } + + #[test] + fn exact_source_responses_match_full_comparison_and_paths() { + for (old_path, new_path, old_content, new_content) in [ + ( + Some("src/old.rs"), + Some("src/new.rs"), + Some("old source\n"), + Some("new source\n"), + ), + (None, Some("src/added.rs"), None, Some("")), + (Some("src/deleted.rs"), None, Some("deleted\n"), None), + ] { + let expected = source_request(old_path, new_path); + let response = ExactReviewSourceResponse::new( + expected.clone(), + old_content.map(str::to_owned), + new_content.map(str::to_owned), + ) + .expect("valid exact source response"); + require_exact_source_match( + expected.comparison(), + expected.old_path(), + expected.new_path(), + &response, + ) + .expect("matching exact source response"); + } + } + + #[test] + fn exact_source_rejects_same_identity_with_different_comparison() { + let expected = source_request(Some("src/old.rs"), Some("src/new.rs")); + let mut different_comparison = comparison_json_for('4', '5', '6'); + different_comparison["identity"] = json!(comparison_identity()); + let different_request: ReviewSourceRequest = serde_json::from_value(json!({ + "comparison": different_comparison, + "old_path": "src/old.rs", + "new_path": "src/new.rs" + })) + .expect("comparison identity is opaque rather than recomputed by the model"); + let response = ExactReviewSourceResponse::new( + different_request, + Some("old source\n".to_string()), + Some("new source\n".to_string()), + ) + .expect("valid exact source response"); + assert_eq!( + expected.comparison().identity(), + response.comparison().identity() + ); + assert_ne!(expected.comparison(), response.comparison()); + + let error = require_exact_source_match( + expected.comparison(), + expected.old_path(), + expected.new_path(), + &response, + ) + .expect_err("matching opaque identity must not hide a different comparison"); + assert_source_mismatch_is_bounded_and_redacted(&error); + } + + #[test] + fn exact_source_rejects_path_mismatch() { + let expected = source_request(Some("src/old.rs"), Some("src/new.rs")); + let response = ExactReviewSourceResponse::new( + source_request(Some("src/other.rs"), Some("src/new.rs")), + Some("old source\n".to_string()), + Some("new source\n".to_string()), + ) + .expect("valid response with a different old path"); + let error = require_exact_source_match( + expected.comparison(), + expected.old_path(), + expected.new_path(), + &response, + ) + .expect_err("different response path must fail"); + assert_source_mismatch_is_bounded_and_redacted(&error); + } + + fn assert_source_mismatch_is_bounded_and_redacted(error: &str) { + assert_eq!( + error, + "Exact review source response did not match the requested comparison and paths" + ); + assert!(error.len() < 128); + for sensitive in [ + "1".repeat(40), + "4".repeat(40), + "origin/main".to_string(), + "feature/review".to_string(), + "src/old.rs".to_string(), + "src/other.rs".to_string(), + "old source".to_string(), + ] { + assert!(!error.contains(&sensitive)); + } + } + + #[test] + fn exact_review_responses_must_match_the_requested_identity() { + let expected = request().comparison.identity().clone(); + let matching_diff: ExactReviewDiffResponse = decode_json_response( + Some(json!({ + "comparison": comparison_json(), + "diff": { "files": [] } + })), + "exact review diff", + ) + .expect("matching diff response"); + require_comparison_identity( + &expected, + matching_diff.comparison().identity(), + "Exact review diff", + ) + .expect("matching diff identity"); + + let mismatched_diff: ExactReviewDiffResponse = decode_json_response( + Some(json!({ + "comparison": comparison_json_for('4', '5', '6'), + "diff": { "files": [] } + })), + "exact review diff", + ) + .expect("valid mismatched diff response"); + let diff_error = require_comparison_identity( + &expected, + mismatched_diff.comparison().identity(), + "Exact review diff", + ) + .expect_err("mismatched diff identity"); + assert_mismatch_is_bounded_and_redacted(&diff_error, &expected); + + let matching_structure: ReviewStructure = + decode_json_response(Some(valid_structure_json()), "review structure") + .expect("matching structure response"); + require_comparison_identity( + &expected, + matching_structure.comparison().identity(), + "Review structure", + ) + .expect("matching structure identity"); + + let mismatched_structure: ReviewStructure = decode_json_response( + Some(structure_json(comparison_json_for('4', '5', '6'))), + "review structure", + ) + .expect("valid mismatched structure response"); + let structure_error = require_comparison_identity( + &expected, + mismatched_structure.comparison().identity(), + "Review structure", + ) + .expect_err("mismatched structure identity"); + assert_mismatch_is_bounded_and_redacted(&structure_error, &expected); + } + + fn assert_mismatch_is_bounded_and_redacted(error: &str, expected: &ReviewComparisonId) { + assert!(error.contains("response comparison mismatch")); + assert!(error.len() < 160, "unbounded mismatch error: {error}"); + assert!( + !error.contains(&expected.0), + "mismatch error exposed the raw identity" + ); + } + + fn valid_structure_json() -> Value { + structure_json(comparison_json()) + } + + fn structure_json(comparison: Value) -> Value { + json!({ + "comparison": comparison, + "files": [], + "coverage": coverage_json(), + "language_coverage": [], + "errors": [] + }) + } + + fn comparison_identity() -> String { + format!( + "branch:merge-base:{}:{}:{}", + "1".repeat(40), + "3".repeat(40), + "2".repeat(40) + ) + } + + struct UnsupportedProvider; + + impl GitProvider for UnsupportedProvider { + fn is_git_repo(&self) -> bool { + false + } + + fn get_diff( + &self, + _mode: DiffMode, + _ignore_whitespace: bool, + ) -> Result { + Err("unsupported".to_string()) + } + + fn get_file_contents( + &self, + _file_path: &str, + _mode: DiffMode, + ) -> Result<(Option, Option), String> { + Err("unsupported".to_string()) + } + + fn get_diff_file_summary(&self) -> Result, String> { + Err("unsupported".to_string()) + } + + fn get_commit_graph( + &self, + _count: usize, + _branch: Option<&str>, + ) -> Result, String> { + Err("unsupported".to_string()) + } + + fn list_branches(&self) -> Result, String> { + Err("unsupported".to_string()) + } + + fn stage_file(&self, _file_path: &str) -> Result<(), String> { + Err("unsupported".to_string()) + } + + fn unstage_file(&self, _file_path: &str) -> Result<(), String> { + Err("unsupported".to_string()) + } + + fn discard_file(&self, _file_path: &str) -> Result<(), String> { + Err("unsupported".to_string()) + } + + fn delete_file(&self, _file_path: &str) -> Result<(), String> { + Err("unsupported".to_string()) + } + + fn absolute_file_path(&self, _file_path: &str) -> Option { + None + } + } + + #[test] + fn default_review_methods_fail_explicitly() { + let provider = UnsupportedProvider; + assert_eq!( + smol::block_on(provider.get_review_inventory(DiffMode::WorkingTree)) + .expect_err("unsupported inventory"), + "Review inventory is not supported by this provider" + ); + assert_eq!( + smol::block_on(provider.get_review_diff(request())) + .expect_err("unsupported exact diff"), + "Exact review diff is not supported by this provider" + ); + assert_eq!( + smol::block_on( + provider.get_review_source(source_request(Some("src/old.rs"), Some("src/new.rs"),)) + ) + .expect_err("unsupported exact source"), + "Exact review source is not supported by this provider" + ); + assert_eq!( + smol::block_on(provider.get_review_structure(request())) + .expect_err("unsupported structure"), + "Structured review is not supported by this provider" + ); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/render.rs b/crates/okena-views-git/src/diff_viewer/render.rs index cc517fea8..acfd40523 100644 --- a/crates/okena-views-git/src/diff_viewer/render.rs +++ b/crates/okena-views-git/src/diff_viewer/render.rs @@ -1,5 +1,8 @@ //! Render trait impl and helper methods for the diff viewer. +use super::review::is_smart_mode; +use super::review_ui::DiffPaneArgs; +use super::review_ui::labels::short_sha; use super::types::DiffViewMode; use super::{Cancel, DiffViewer}; use gpui::prelude::*; @@ -35,14 +38,18 @@ impl DiffViewer { needs_controls: bool, is_maximized: bool, cx: &mut Context, - ) -> impl IntoElement { + ) -> AnyElement { let is_working = *diff_mode == DiffMode::WorkingTree; let hide_mode_toggle = matches!( diff_mode, DiffMode::Commit(_) | DiffMode::BranchCompare { .. } ); let is_unified = self.view_mode == DiffViewMode::Unified; + let smart = is_smart_mode(diff_mode); + let show_view_toggle = !smart || self.review_show_split_toggle(); let detached = self.is_detached; + let merge_base = smart.then(|| self.render_merge_base(t, cx)).flatten(); + let status_pill = smart.then(|| self.render_status_pill(t, cx)); div() .px(px(20.0)) @@ -161,13 +168,16 @@ impl DiffViewer { .child(format!("-{}", total_removed)), ), ) - }), + }) + .children(merge_base), ) // Drag-to-move spacer (only when detached) .child(window_drag_spacer(detached)) .child( h_flex() .gap(px(8.0)) + // Analysis status pill sits with the controls so its popover anchors right + .children(status_pill) // Whitespace toggle .child( div() @@ -198,19 +208,23 @@ impl DiffViewer { .child("Whitespace"), ), ) - // Separator - .child(div().w(px(1.0)).h(px(20.0)).bg(rgb(t.border)).mx(px(4.0))) - // View mode toggle - .child( - div() - .id("view-mode-toggle") - .on_click(cx.listener(|this, _, _window, cx| this.toggle_view_mode(cx))) - .child(segmented_toggle( - &[("Unified", is_unified), ("Split", !is_unified)], - t, - cx, - )), - ) + .when(show_view_toggle, |d| { + d.child(div().w(px(1.0)).h(px(20.0)).bg(rgb(t.border)).mx(px(4.0))) + .child( + div() + .id("view-mode-toggle") + .on_click( + cx.listener(|this, _, _window, cx| { + this.toggle_view_mode(cx) + }), + ) + .child(segmented_toggle( + &[("Unified", is_unified), ("Split", !is_unified)], + t, + cx, + )), + ) + }) // Diff mode toggle (hidden for commit/branch compare diffs) .when(!hide_mode_toggle, |d| { d.child( @@ -279,6 +293,31 @@ impl DiffViewer { ), ), ) + .into_any_element() + } + + /// `merge-base `, with the resolved OIDs on hover. + fn render_merge_base(&self, t: &ThemeColors, cx: &mut Context) -> Option { + let comparison = self.smart_review.comparison()?; + let merge_base = comparison.merge_base_oid()?.as_str().to_string(); + let dot = "\u{00B7}"; + let detail = format!( + "base {} {dot} head {} {dot} merge-base {merge_base}", + snapshot_oid(comparison.base()), + snapshot_oid(comparison.head()), + ); + Some( + div() + .id("review-merge-base") + .text_size(ui_text_md(cx)) + .font_family("monospace") + .text_color(rgb(t.text_muted)) + .child(format!("merge-base {}", short_sha(&merge_base))) + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(detail.clone()).build(window, cx) + }) + .into_any_element(), + ) } /// Commit navigation bar: prev/next arrows, author, date, hash, position indicator. @@ -570,6 +609,8 @@ impl DiffViewer { false }; let max_scroll = self.max_scroll_x(); + // The review file header already states the path once. + let show_path_header = !is_smart_mode(&self.diff_mode); div() .flex_1() @@ -577,18 +618,20 @@ impl DiffViewer { .flex_col() .min_w_0() .min_h_0() - .child( - div() - .px(px(16.0)) - .py(px(10.0)) - .border_b_1() - .border_color(rgb(t.border)) - .bg(rgb(t.bg_header)) - .text_size(ui_text_md(cx)) - .font_family("monospace") - .text_color(rgb(t.text_secondary)) - .child(file_path), - ) + .when(show_path_header, |d| { + d.child( + div() + .px(px(16.0)) + .py(px(10.0)) + .border_b_1() + .border_color(rgb(t.border)) + .bg(rgb(t.bg_header)) + .text_size(ui_text_md(cx)) + .font_family("monospace") + .text_color(rgb(t.text_secondary)) + .child(file_path), + ) + }) // In-page search bar (Cmd/Ctrl+F) .children(search_bar) .when(is_binary, |d| { @@ -794,6 +837,8 @@ impl DiffViewer { pub(super) fn render_footer(&self, t: &ThemeColors, cx: &App) -> impl IntoElement { let has_commits = self.has_commits(); + let smart = is_smart_mode(&self.diff_mode); + let show_split = !smart || self.review_show_split_toggle(); div() .px(px(16.0)) .py(px(8.0)) @@ -806,10 +851,12 @@ impl DiffViewer { h_flex() .gap(px(20.0)) .child(self.render_hint("Esc", "close", t, cx)) - .when(!has_commits, |d| { + .when(!smart && !has_commits, |d| { d.child(self.render_hint("Tab", "staged/unstaged", t, cx)) }) - .child(self.render_hint("S", "split", t, cx)) + .when(show_split, |d| { + d.child(self.render_hint("S", "split", t, cx)) + }) .child(self.render_hint("\u{2191}\u{2193}", "files", t, cx)) .when(has_commits, |d| { d.child(self.render_hint("[ ]", "commits", t, cx)) @@ -987,6 +1034,7 @@ impl Render for DiffViewer { let has_error = self.error_message.is_some(); let error_message = self.error_message.clone(); let diff_mode = self.diff_mode.clone(); + let is_smart = is_smart_mode(&diff_mode); let has_files = !self.file_stats.is_empty(); // Gutter: two number columns + separator, matching render_line layout let char_width = self.char_width(); @@ -1032,6 +1080,9 @@ impl Render for DiffViewer { .track_focus(&focus_handle) .key_context("DiffViewer") .on_action(cx.listener(|this, _: &Cancel, window, cx| { + if is_smart_mode(&this.diff_mode) && this.handle_review_cancel(window, cx) { + return; + } if this.search.is_some() { this.close_search(window, cx); return; @@ -1048,6 +1099,10 @@ impl Render for DiffViewer { } })) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + if is_smart_mode(&this.diff_mode) && this.handle_review_key(event, window, cx) { + cx.stop_propagation(); + return; + } let key = event.keystroke.key.as_str(); let modifiers = &event.keystroke.modifiers; @@ -1055,9 +1110,17 @@ impl Render for DiffViewer { "f" if modifiers.platform || modifiers.control => { this.open_search(window, cx); } - "tab" => this.toggle_mode(cx), - "s" => this.toggle_view_mode(cx), + "tab" if !is_smart_mode(&this.diff_mode) => this.toggle_mode(cx), + "s" if !is_smart_mode(&this.diff_mode) || this.review_show_split_toggle() => { + this.toggle_view_mode(cx) + } "w" => this.toggle_ignore_whitespace(cx), + "up" if is_smart_mode(&this.diff_mode) => { + this.select_adjacent_smart_file(false, cx) + } + "down" if is_smart_mode(&this.diff_mode) => { + this.select_adjacent_smart_file(true, cx) + } "up" => this.prev_file(cx), "down" => this.next_file(cx), "left" => { @@ -1129,22 +1192,46 @@ impl Render for DiffViewer { .when(self.has_commits(), |d| { d.child(self.render_commit_info_bar(&t, cx)) }) - .child(self.render_content( - &t, - self.loading, - has_error, - error_message, - has_files, - is_binary, - file_path, - line_count, - gutter_width, - tree_elements, - theme_colors, - cx, - )) - .child(self.render_footer(&t, cx)) + .child(if is_smart { + // The Overview reflows below 1000 px, so the shell needs its own width. + self.review_ui.content_width = (f32::from(window.viewport_size().width) + - self.sidebar_resize.width()) + .max(0.0); + self.render_review_shell( + &t, + DiffPaneArgs { + is_binary, + file_path, + line_count, + gutter_width, + theme_colors, + }, + cx, + ) + } else { + self.render_content( + &t, + self.loading, + has_error, + error_message, + has_files, + is_binary, + file_path, + line_count, + gutter_width, + tree_elements, + theme_colors, + cx, + ) + .into_any_element() + }) + .child(if is_smart { + self.render_review_footer(&t, cx) + } else { + self.render_footer(&t, cx).into_any_element() + }) .children(self.render_context_overlays(&t, cx)) + .into_any_element() } } @@ -1153,3 +1240,11 @@ impl Focusable for DiffViewer { self.focus_handle.clone() } } + +/// The resolved OID of one side, or a dash when the side has none. +fn snapshot_oid(snapshot: &okena_core::review::ReviewSnapshot) -> String { + snapshot + .oid() + .map(|oid| oid.as_str().to_string()) + .unwrap_or_else(|| "\u{2014}".to_string()) +} diff --git a/crates/okena-views-git/src/diff_viewer/review.rs b/crates/okena-views-git/src/diff_viewer/review.rs new file mode 100644 index 000000000..c1eff8cfb --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review.rs @@ -0,0 +1,657 @@ +//! Pure state for coordinating immutable smart-review datasets. + +use okena_core::review::{ + ComparisonSide, ExactReviewSourceResponse, ImmutableResolvedComparison, ReviewDiffRequest, + ReviewInventory, +}; +use okena_git::{DiffMode, ExactReviewDiffResponse, FileDiff}; +use okena_review::ReviewStructure; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct ReviewFileKey { + pub(crate) old_path: Option, + pub(crate) new_path: Option, +} + +impl ReviewFileKey { + pub(crate) fn from_diff(file: &FileDiff) -> Self { + Self { + old_path: file.old_path.clone(), + new_path: file.new_path.clone(), + } + } + + pub(crate) fn from_inventory(file: &okena_core::review::ReviewFileFact) -> Self { + Self { + old_path: file.old_path.clone(), + new_path: file.new_path.clone(), + } + } + + pub(crate) fn display(&self) -> String { + match (&self.old_path, &self.new_path) { + (Some(old), Some(new)) if old != new => format!("{old} → {new}"), + (_, Some(new)) => new.clone(), + (Some(old), None) => old.clone(), + (None, None) => "(no file)".into(), + } + } + + pub(crate) fn path(&self, side: ComparisonSide) -> Option<&str> { + match side { + ComparisonSide::Base => self.old_path.as_deref(), + ComparisonSide::Head => self.new_path.as_deref(), + } + } + + pub(crate) fn matches_inventory(&self, file: &okena_core::review::ReviewFileFact) -> bool { + self.old_path == file.old_path && self.new_path == file.new_path + } + + pub(crate) fn matches_source(&self, source: &ExactReviewSourceResponse) -> bool { + self.old_path.as_deref() == source.old_path() + && self.new_path.as_deref() == source.new_path() + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct ReviewEpoch(u64); + +impl ReviewEpoch { + fn next(&mut self) -> Self { + self.0 = self.0.wrapping_add(1); + if self.0 == 0 { + self.0 = 1; + } + *self + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct FileGeneration(u64); + +impl FileGeneration { + fn next(&mut self) -> Self { + self.0 = self.0.wrapping_add(1); + if self.0 == 0 { + self.0 = 1; + } + *self + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) enum LoadState { + #[default] + Idle, + Loading, + Ready(T), + Failed(String), +} + +impl LoadState { + pub(crate) fn ready(&self) -> Option<&T> { + match self { + Self::Ready(value) => Some(value), + Self::Idle | Self::Loading | Self::Failed(_) => None, + } + } + + pub(crate) fn error(&self) -> Option<&str> { + match self { + Self::Failed(error) => Some(error), + Self::Idle | Self::Loading | Self::Ready(_) => None, + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct DiffDataset { + pub(crate) comparison: ImmutableResolvedComparison, + pub(crate) files: Vec, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct FileViewState { + generation: FileGeneration, + pub(crate) key: Option, + pub(crate) source: LoadState, + cache_ready: bool, +} + +impl FileViewState { + pub(crate) fn begin(&mut self, key: ReviewFileKey) -> FileGeneration { + let generation = self.generation.next(); + self.key = Some(key); + self.source = LoadState::Loading; + self.cache_ready = false; + generation + } + + pub(crate) fn clear(&mut self) { + self.generation.next(); + self.key = None; + self.source = LoadState::Idle; + self.cache_ready = false; + } + + pub(crate) fn accepts(&self, generation: FileGeneration, key: &ReviewFileKey) -> bool { + self.generation == generation && self.key.as_ref() == Some(key) + } + + /// The generation the loaded file belongs to; a navigation inside the + /// same loaded file keeps it instead of starting a reload. + pub(crate) fn generation(&self) -> FileGeneration { + self.generation + } + + pub(crate) fn mark_cache_ready( + &mut self, + generation: FileGeneration, + key: &ReviewFileKey, + ) -> bool { + if !self.accepts(generation, key) { + return false; + } + self.cache_ready = true; + true + } + + pub(crate) fn has_ready_cache(&self, key: &ReviewFileKey, smart: bool) -> bool { + if !self.cache_ready || self.key.as_ref() != Some(key) { + return false; + } + !smart + || matches!( + &self.source, + LoadState::Ready(source) if key.matches_source(source) + ) + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct SmartReviewState { + epoch: ReviewEpoch, + content_revision: u64, + selection_revision: u64, + pub(crate) mode: Option, + pub(crate) selected_file: Option, + pub(crate) inventory: LoadState, + pub(crate) diff: LoadState, + pub(crate) structure: LoadState, + pub(crate) file: FileViewState, +} + +impl SmartReviewState { + fn bump_content_revision(&mut self) { + self.content_revision = self.content_revision.wrapping_add(1); + if self.content_revision == 0 { + self.content_revision = 1; + } + } + + fn bump_selection_revision(&mut self) { + self.selection_revision = self.selection_revision.wrapping_add(1); + if self.selection_revision == 0 { + self.selection_revision = 1; + } + } + + /// Cache scope for everything derived from the datasets. + #[allow(dead_code)] + pub(crate) fn content_revision(&self) -> u64 { + self.content_revision + } + + /// Cache scope for everything derived from the selected file. + #[allow(dead_code)] + pub(crate) fn selection_revision(&self) -> u64 { + self.selection_revision + } + + pub(crate) fn changed(&mut self) { + self.bump_content_revision(); + } + + pub(crate) fn begin(&mut self, mode: DiffMode) -> ReviewEpoch { + let epoch = self.epoch.next(); + self.mode = Some(mode); + self.selected_file = None; + self.inventory = LoadState::Loading; + self.diff = LoadState::Idle; + self.structure = LoadState::Idle; + self.file.clear(); + self.bump_content_revision(); + self.bump_selection_revision(); + epoch + } + + pub(crate) fn disable(&mut self) -> ReviewEpoch { + let epoch = self.epoch.next(); + self.mode = None; + self.selected_file = None; + self.inventory = LoadState::Idle; + self.diff = LoadState::Idle; + self.structure = LoadState::Idle; + self.file.clear(); + self.bump_content_revision(); + self.bump_selection_revision(); + epoch + } + + pub(crate) fn begin_derived_reload( + &mut self, + mode: &DiffMode, + ) -> Option<(ReviewEpoch, ImmutableResolvedComparison)> { + if self.mode.as_ref() != Some(mode) { + return None; + } + let inventory = self.inventory.ready()?.clone(); + let comparison = + ImmutableResolvedComparison::try_from(inventory.comparison.clone()).ok()?; + let epoch = self.epoch.next(); + self.inventory = LoadState::Ready(inventory); + self.diff = LoadState::Loading; + self.structure = LoadState::Loading; + self.file.clear(); + self.bump_content_revision(); + Some((epoch, comparison)) + } + + pub(crate) fn accepts(&self, epoch: ReviewEpoch, mode: &DiffMode) -> bool { + self.epoch == epoch && self.mode.as_ref() == Some(mode) + } + + pub(crate) fn is_current(&self, epoch: ReviewEpoch) -> bool { + self.epoch == epoch + } + + pub(crate) fn set_inventory( + &mut self, + inventory: ReviewInventory, + preferred_path: Option<&str>, + ) { + let retained = self.selected_file.as_ref().filter(|selected| { + inventory + .files + .iter() + .any(|file| selected.matches_inventory(file)) + }); + let selected = retained.cloned().or_else(|| { + preferred_path + .and_then(|path| { + inventory.files.iter().find(|file| { + file.old_path.as_deref() == Some(path) + || file.new_path.as_deref() == Some(path) + }) + }) + .or_else(|| inventory.files.first()) + .map(ReviewFileKey::from_inventory) + }); + self.selected_file = selected; + self.inventory = LoadState::Ready(inventory); + self.bump_content_revision(); + self.bump_selection_revision(); + } + + pub(crate) fn set_selected_file(&mut self, key: ReviewFileKey) { + if self.selected_file.as_ref() != Some(&key) { + self.selected_file = Some(key); + self.file.clear(); + self.bump_selection_revision(); + } + } + + pub(crate) fn comparison(&self) -> Option { + if let Some(dataset) = self.diff.ready() { + return Some(dataset.comparison.clone()); + } + self.inventory.ready().and_then(|inventory| { + ImmutableResolvedComparison::try_from(inventory.comparison.clone()).ok() + }) + } + + pub(crate) fn accept_diff( + &mut self, + epoch: ReviewEpoch, + mode: &DiffMode, + expected: &ImmutableResolvedComparison, + result: Result, + ) -> Option, String>> { + if !self.accepts(epoch, mode) { + return None; + } + let response = match result { + Ok(response) => response, + Err(error) => { + self.diff = LoadState::Failed(error.clone()); + self.bump_content_revision(); + return Some(Err(error)); + } + }; + if response.comparison() != expected { + let error = "Exact review diff returned a different comparison".to_string(); + self.diff = LoadState::Failed(error.clone()); + self.bump_content_revision(); + return Some(Err(error)); + } + let (comparison, diff) = response.into_parts(); + let files = diff.files; + self.diff = LoadState::Ready(DiffDataset { + comparison, + files: files.clone(), + }); + self.bump_content_revision(); + Some(Ok(self + .diff + .ready() + .map_or(files, |dataset| dataset.files.clone()))) + } + + pub(crate) fn accept_structure( + &mut self, + epoch: ReviewEpoch, + mode: &DiffMode, + expected: &ImmutableResolvedComparison, + result: Result, + ) -> Option> { + if !self.accepts(epoch, mode) { + return None; + } + let structure = match result { + Ok(structure) => structure, + Err(error) => { + self.structure = LoadState::Failed(error.clone()); + self.bump_content_revision(); + return Some(Err(error)); + } + }; + if structure.comparison() != expected { + let error = "Structured review returned a different comparison".to_string(); + self.structure = LoadState::Failed(error.clone()); + self.bump_content_revision(); + return Some(Err(error)); + } + self.structure = LoadState::Ready(structure); + self.bump_content_revision(); + Some(Ok(())) + } +} + +pub(crate) fn is_smart_mode(mode: &DiffMode) -> bool { + matches!(mode, DiffMode::BranchCompare { .. } | DiffMode::Commit(_)) +} + +pub(crate) fn derived_requests( + comparison: &ImmutableResolvedComparison, + ignore_whitespace: bool, +) -> (ReviewDiffRequest, ReviewDiffRequest) { + let request = ReviewDiffRequest { + comparison: comparison.clone(), + ignore_whitespace, + }; + (request.clone(), request) +} + +pub(crate) fn theme_requires_rehighlight(requested_dark: bool, current_dark: bool) -> bool { + requested_dark != current_dark +} + +pub(crate) fn adjacent_inventory_file( + inventory: &ReviewInventory, + selected: Option<&ReviewFileKey>, + forward: bool, +) -> Option { + let index = selected + .and_then(|selected| { + inventory + .files + .iter() + .position(|file| selected.matches_inventory(file)) + }) + .unwrap_or(0); + let next = if forward { + index + .saturating_add(1) + .min(inventory.files.len().saturating_sub(1)) + } else { + index.saturating_sub(1) + }; + inventory.files.get(next).map(ReviewFileKey::from_inventory) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::diff_viewer::review_ui::fixtures::{ + empty_inventory, exact_diff, inventory, structure, + }; + + #[test] + fn stale_mode_and_file_generations_are_rejected() { + let mut state = SmartReviewState::default(); + let old_epoch = state.begin(DiffMode::Commit("old".into())); + let new_mode = DiffMode::Commit("new".into()); + let new_epoch = state.begin(new_mode.clone()); + assert!(!state.accepts(old_epoch, &DiffMode::Commit("old".into()))); + assert!(state.accepts(new_epoch, &new_mode)); + + let a = ReviewFileKey { + old_path: Some("a.rs".into()), + new_path: Some("a.rs".into()), + }; + let b = ReviewFileKey { + old_path: Some("b.rs".into()), + new_path: Some("b.rs".into()), + }; + let generation_a = state.file.begin(a.clone()); + let generation_b = state.file.begin(b.clone()); + assert!(!state.file.accepts(generation_a, &a)); + assert!(state.file.accepts(generation_b, &b)); + } + + #[test] + fn ordinary_modes_are_not_smart_review_modes() { + assert!(!is_smart_mode(&DiffMode::WorkingTree)); + assert!(!is_smart_mode(&DiffMode::Staged)); + assert!(is_smart_mode(&DiffMode::Commit("abc".into()))); + assert!(is_smart_mode(&DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + })); + } + + #[test] + fn diff_and_structure_accept_in_either_order_for_one_full_comparison() { + let mode = DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + }; + let expected = ImmutableResolvedComparison::try_from(empty_inventory().comparison).unwrap(); + for structure_first in [false, true] { + let mut state = SmartReviewState::default(); + let epoch = state.begin(mode.clone()); + if structure_first { + assert_eq!( + state.accept_structure(epoch, &mode, &expected, Ok(structure())), + Some(Ok(())) + ); + assert!( + state + .accept_diff(epoch, &mode, &expected, Ok(exact_diff())) + .unwrap() + .is_ok() + ); + } else { + assert!( + state + .accept_diff(epoch, &mode, &expected, Ok(exact_diff())) + .unwrap() + .is_ok() + ); + assert_eq!( + state.accept_structure(epoch, &mode, &expected, Ok(structure())), + Some(Ok(())) + ); + } + assert!(matches!(state.diff, LoadState::Ready(_))); + assert!(matches!(state.structure, LoadState::Ready(_))); + } + } + + #[test] + fn derived_requests_share_the_full_comparison_and_whitespace_flag() { + let comparison = + ImmutableResolvedComparison::try_from(empty_inventory().comparison).unwrap(); + let (diff, structure) = derived_requests(&comparison, true); + assert_eq!(diff, structure); + assert_eq!(diff.comparison, comparison); + assert!(diff.ignore_whitespace); + } + + #[test] + fn whitespace_reload_retains_the_inventory_and_reloads_the_derived_datasets() { + let mode = DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + }; + let inventory = empty_inventory(); + let mut state = SmartReviewState::default(); + state.begin(mode.clone()); + state.inventory = LoadState::Ready(inventory.clone()); + + let (epoch, comparison) = state.begin_derived_reload(&mode).unwrap(); + assert!(state.accepts(epoch, &mode)); + assert_eq!(state.inventory.ready().unwrap(), &inventory); + assert_eq!(comparison.as_resolved(), &inventory.comparison); + assert!(matches!(state.diff, LoadState::Loading)); + assert!(matches!(state.structure, LoadState::Loading)); + } + + #[test] + fn inventory_selection_survives_diff_failure_and_derived_reload() { + let mode = DiffMode::Commit("abc".into()); + let inventory = inventory(); + let comparison = + ImmutableResolvedComparison::try_from(inventory.comparison.clone()).unwrap(); + let mut state = SmartReviewState::default(); + let epoch = state.begin(mode.clone()); + state.set_inventory(inventory, None); + assert_eq!( + state.selected_file.as_ref().unwrap().new_path.as_deref(), + Some("src/lib.rs") + ); + + state.set_selected_file(ReviewFileKey { + old_path: Some("tests/lib.rs".into()), + new_path: Some("tests/lib.rs".into()), + }); + assert!( + state + .accept_diff(epoch, &mode, &comparison, Err("diff failed".into())) + .unwrap() + .is_err() + ); + assert_eq!( + state.selected_file.as_ref().unwrap().new_path.as_deref(), + Some("tests/lib.rs") + ); + + state.begin_derived_reload(&mode).unwrap(); + assert_eq!( + state.selected_file.as_ref().unwrap().new_path.as_deref(), + Some("tests/lib.rs") + ); + } + + #[test] + fn content_and_selection_revisions_invalidate_independent_cache_scopes() { + let mut state = SmartReviewState::default(); + state.begin(DiffMode::Commit("abc".into())); + let initial_content = state.content_revision(); + let initial_selection = state.selection_revision(); + state.set_inventory(inventory(), None); + let after_data_content = state.content_revision(); + let after_data_selection = state.selection_revision(); + assert!(after_data_content > initial_content); + assert!(after_data_selection > initial_selection); + state.set_selected_file(ReviewFileKey { + old_path: Some("tests/lib.rs".into()), + new_path: Some("tests/lib.rs".into()), + }); + assert_eq!(state.content_revision(), after_data_content); + assert!(state.selection_revision() > after_data_selection); + } + + #[test] + fn keyboard_adjacency_returns_canonical_inventory_pairs() { + let inventory = inventory(); + let first = adjacent_inventory_file(&inventory, None, false).unwrap(); + assert_eq!(first.new_path.as_deref(), Some("src/lib.rs")); + let second = adjacent_inventory_file(&inventory, Some(&first), true).unwrap(); + assert_eq!(second.old_path.as_deref(), Some("tests/lib.rs")); + assert_eq!(second.new_path.as_deref(), Some("tests/lib.rs")); + assert_eq!( + adjacent_inventory_file(&inventory, Some(&second), false), + Some(first) + ); + } + + #[test] + fn file_cache_and_theme_guards_reject_stale_or_failed_source() { + let mut file = FileViewState::default(); + let a = ReviewFileKey { + old_path: Some("a.rs".into()), + new_path: Some("a.rs".into()), + }; + let b = ReviewFileKey { + old_path: Some("b.rs".into()), + new_path: Some("b.rs".into()), + }; + let generation_a = file.begin(a.clone()); + let generation_b = file.begin(b.clone()); + assert!(!file.mark_cache_ready(generation_a, &a)); + assert!(file.mark_cache_ready(generation_b, &b)); + assert!(file.has_ready_cache(&b, false)); + assert!(!file.has_ready_cache(&a, false)); + + file.source = LoadState::Failed("source failed".into()); + assert!(!file.has_ready_cache(&b, true)); + assert_eq!(file.source.error(), Some("source failed")); + assert!(theme_requires_rehighlight(false, true)); + assert!(!theme_requires_rehighlight(true, true)); + } + + #[test] + fn smart_dataset_failure_is_local_and_can_recover() { + let mode = DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + }; + let expected = ImmutableResolvedComparison::try_from(empty_inventory().comparison).unwrap(); + let mut state = SmartReviewState::default(); + let epoch = state.begin(mode.clone()); + + assert!( + state + .accept_diff(epoch, &mode, &expected, Err("diff failed".into())) + .unwrap() + .is_err() + ); + assert_eq!(state.diff.error(), Some("diff failed")); + assert_eq!( + state.accept_structure(epoch, &mode, &expected, Ok(structure())), + Some(Ok(())) + ); + assert!(matches!(state.structure, LoadState::Ready(_))); + + assert!( + state + .accept_diff(epoch, &mode, &expected, Ok(exact_diff())) + .unwrap() + .is_ok() + ); + assert!(matches!(state.diff, LoadState::Ready(_))); + assert!(matches!(state.structure, LoadState::Ready(_))); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_nav.rs b/crates/okena-views-git/src/diff_viewer/review_nav.rs new file mode 100644 index 000000000..52d1b52f7 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_nav.rs @@ -0,0 +1,909 @@ +//! Exact evidence navigation and pure diff-row mapping. + +use super::DiffViewer; +use super::review::{FileGeneration, ReviewFileKey, SmartReviewState}; +use super::review_ui::ContentView; +use super::types::{DisplayItem, ExpanderRow, SideBySideLine}; +use gpui::{Context, ScrollStrategy, UniformListScrollHandle}; +use okena_core::review::{ComparisonSide, ReviewNavigationTarget}; +use okena_core::types::DiffViewMode; +use okena_git::FileDiff; +use std::fmt; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct NavigationToken(u64); + +impl NavigationToken { + fn next(&mut self) -> Self { + self.0 = self.0.wrapping_add(1); + if self.0 == 0 { + self.0 = 1; + } + *self + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PendingNavigation { + pub(crate) token: NavigationToken, + pub(crate) generation: FileGeneration, + pub(crate) target: EvidenceTarget, + pub(crate) source_started: bool, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ReviewNavigationState { + next_token: NavigationToken, + pub(crate) pending: Option, + pub(crate) unavailable: Option, +} + +impl ReviewNavigationState { + pub(crate) fn begin( + &mut self, + generation: FileGeneration, + target: EvidenceTarget, + ) -> NavigationToken { + let token = self.next_token.next(); + self.pending = Some(PendingNavigation { + token, + generation, + target, + source_started: false, + }); + self.unavailable = None; + token + } + + pub(crate) fn invalidate(&mut self) { + self.next_token.next(); + self.pending = None; + self.unavailable = None; + } + + pub(crate) fn accepts( + &self, + token: NavigationToken, + generation: FileGeneration, + key: &ReviewFileKey, + ) -> bool { + self.pending.as_ref().is_some_and(|pending| { + pending.token == token + && pending.generation == generation + && &pending.target.file == key + }) + } + + pub(crate) fn fail_current(&mut self, error: NavigationUnavailable) { + self.pending = None; + self.unavailable = Some(error); + } + + /// The navigation landed; the row marker now comes from the selected symbol. + pub(crate) fn finish(&mut self) { + self.pending = None; + self.unavailable = None; + } + + pub(crate) fn has_pending(&self) -> bool { + self.pending.is_some() + } +} + +impl DiffViewer { + pub(super) fn navigate_to_evidence(&mut self, target: EvidenceTarget, cx: &mut Context) { + // Evidence always lands in the diff, so the content area follows it. + self.review_ui.content = ContentView::File; + if preflight_evidence_navigation( + &mut self.smart_review, + &mut self.review_navigation, + &target, + ) + .is_err() + { + cx.notify(); + return; + } + // The same file, already loaded and displayed: map straight to the + // row. Only another file (or a stale load) starts a fresh source load. + let loaded = self.smart_review.file.has_ready_cache(&target.file, true) + && self.current_file.is_some(); + let generation = if loaded { + self.smart_review.file.generation() + } else { + self.smart_review.file.begin(target.file.clone()) + }; + self.review_navigation.begin(generation, target); + if loaded && let Some(pending) = self.review_navigation.pending.as_mut() { + pending.source_started = true; + } + self.resume_review_navigation(cx); + cx.notify(); + } + + pub(super) fn resume_review_navigation(&mut self, cx: &mut Context) { + let Some(pending) = self.review_navigation.pending.clone() else { + return; + }; + if !self + .review_navigation + .accepts(pending.token, pending.generation, &pending.target.file) + { + return; + } + if !self + .smart_review + .file + .accepts(pending.generation, &pending.target.file) + { + return; + } + let dataset = match &self.smart_review.diff { + super::review::LoadState::Idle | super::review::LoadState::Loading => return, + super::review::LoadState::Failed(error) => { + self.review_navigation + .fail_current(NavigationUnavailable::DiffFailed(error.clone())); + return; + } + super::review::LoadState::Ready(dataset) => dataset, + }; + let dataset_index = match find_exact_file_pair(&dataset.files, &pending.target.file) { + Ok(index) => index, + Err(error) => { + self.review_navigation.fail_current(error); + return; + } + }; + let exact_file = dataset.files[dataset_index].clone(); + let index = match find_exact_file_pair(&self.raw_files, &pending.target.file) { + Ok(index) => index, + Err(error) => { + self.review_navigation.fail_current(error); + return; + } + }; + self.selected_file_index = index; + + if !pending.source_started { + if let Some(current) = self.review_navigation.pending.as_mut() { + current.source_started = true; + } + self.process_current_file_for_generation( + Some((pending.generation, pending.target.file.clone())), + cx, + ); + return; + } + match &self.smart_review.file.source { + super::review::LoadState::Idle | super::review::LoadState::Loading => return, + super::review::LoadState::Failed(error) => { + self.review_navigation + .fail_current(NavigationUnavailable::SourceFailed(error.clone())); + return; + } + super::review::LoadState::Ready(_) => {} + } + if !self + .smart_review + .file + .has_ready_cache(&pending.target.file, true) + { + return; + } + let Some(file) = self.current_file.as_ref() else { + return; + }; + let requested = match self.view_mode { + DiffViewMode::Unified => EvidenceView::Unified, + DiffViewMode::SideBySide => EvidenceView::Split, + }; + let view = evidence_view(requested, &exact_file); + let side = pending.target.navigation.side; + let line = pending.target.navigation.line.get(); + let mapped = match view { + EvidenceView::Unified => map_unified_row( + &file.items, + side, + line, + file.old_line_count, + file.new_line_count, + ), + EvidenceView::Split => map_split_row( + &self.side_by_side_lines, + side, + line, + file.old_line_count, + file.new_line_count, + ), + }; + let mapped = match mapped { + Ok(EvidenceRow::Hidden { + old_range, + new_range, + }) => { + if let Err(error) = self.expand_context_by_range_checked(old_range, new_range, cx) { + self.review_navigation.fail_current(error); + return; + } + let Some(file) = self.current_file.as_ref() else { + self.review_navigation + .fail_current(NavigationUnavailable::MissingCurrentFile); + return; + }; + match view { + EvidenceView::Unified => map_unified_row( + &file.items, + side, + line, + file.old_line_count, + file.new_line_count, + ), + EvidenceView::Split => map_split_row( + &self.side_by_side_lines, + side, + line, + file.old_line_count, + file.new_line_count, + ), + } + } + other => other, + }; + let row = match mapped { + Ok(EvidenceRow::Visible { row, .. }) => row, + Ok(EvidenceRow::Hidden { .. }) => { + self.review_navigation + .fail_current(NavigationUnavailable::LineUnrepresented { side, line }); + return; + } + Err(error) => { + self.review_navigation.fail_current(error); + return; + } + }; + request_strict_center(&self.scroll_handle, row); + self.review_navigation.finish(); + } + + /// The selected symbol's marker; it stays until another symbol is selected. + pub(super) fn semantic_highlight_matches(&self, side: ComparisonSide, line: usize) -> bool { + self.review_marker_matches(side, line) + } +} + +fn preflight_evidence_navigation( + smart_review: &mut SmartReviewState, + navigation: &mut ReviewNavigationState, + target: &EvidenceTarget, +) -> Result<(), NavigationUnavailable> { + navigation.invalidate(); + smart_review.set_selected_file(target.file.clone()); + validate_evidence_target(target).inspect_err(|error| { + navigation.unavailable = Some(error.clone()); + }) +} + +fn request_strict_center(handle: &UniformListScrollHandle, row: usize) { + handle.scroll_to_item_strict(row, ScrollStrategy::Center); +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct EvidenceTarget { + pub(crate) file: ReviewFileKey, + pub(crate) navigation: ReviewNavigationTarget, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum NavigationUnavailable { + MissingSide { side: ComparisonSide }, + PathMismatch, + DiffFailed(String), + SourceFailed(String), + MissingFilePair, + DuplicateFilePair, + MissingCurrentFile, + MissingExpander, + DuplicateExpander, + NotAnExpander, + InvalidExpander, + AsymmetricExpander, + SourceRangeUnavailable, + LineOutOfRange { side: ComparisonSide, line: u32 }, + LineUnrepresented { side: ComparisonSide, line: u32 }, + DuplicateLine { side: ComparisonSide, line: u32 }, +} + +impl fmt::Display for NavigationUnavailable { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingSide { side } => write!(formatter, "{side:?} side is absent"), + Self::PathMismatch => { + formatter.write_str("evidence path does not match the exact pair") + } + Self::DiffFailed(error) => write!(formatter, "exact diff failed: {error}"), + Self::SourceFailed(error) => write!(formatter, "exact source failed: {error}"), + Self::MissingFilePair => formatter.write_str("exact file pair is absent from the diff"), + Self::DuplicateFilePair => { + formatter.write_str("exact file pair is ambiguous in the diff") + } + Self::MissingCurrentFile => formatter.write_str("exact file display is not ready"), + Self::MissingExpander => formatter.write_str("hidden context expander is absent"), + Self::DuplicateExpander => formatter.write_str("hidden context expander is ambiguous"), + Self::NotAnExpander => formatter.write_str("the selected row is not hidden context"), + Self::InvalidExpander => formatter.write_str("hidden context has an invalid range"), + Self::AsymmetricExpander => { + formatter.write_str("hidden context has asymmetric old/new ranges") + } + Self::SourceRangeUnavailable => { + formatter.write_str("hidden context exceeds exact source contents") + } + Self::LineOutOfRange { side, line } => { + write!(formatter, "{side:?} line {line} is outside exact source") + } + Self::LineUnrepresented { side, line } => { + write!( + formatter, + "{side:?} line {line} is not represented in this diff" + ) + } + Self::DuplicateLine { side, line } => { + write!(formatter, "{side:?} line {line} occurs more than once") + } + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum EvidenceRow { + Visible { + row: usize, + side: ComparisonSide, + }, + Hidden { + old_range: (usize, usize), + new_range: (usize, usize), + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EvidenceView { + Unified, + Split, +} + +pub(crate) fn validate_evidence_target( + target: &EvidenceTarget, +) -> Result<(), NavigationUnavailable> { + let expected = match target.navigation.side { + ComparisonSide::Base => { + target + .file + .old_path + .as_deref() + .ok_or(NavigationUnavailable::MissingSide { + side: ComparisonSide::Base, + })? + } + ComparisonSide::Head => { + target + .file + .new_path + .as_deref() + .ok_or(NavigationUnavailable::MissingSide { + side: ComparisonSide::Head, + })? + } + }; + if expected != target.navigation.path { + return Err(NavigationUnavailable::PathMismatch); + } + Ok(()) +} + +pub(crate) fn find_exact_file_pair( + files: &[FileDiff], + key: &ReviewFileKey, +) -> Result { + let mut matches = files + .iter() + .enumerate() + .filter(|(_, file)| file.old_path == key.old_path && file.new_path == key.new_path) + .map(|(index, _)| index); + let first = matches + .next() + .ok_or(NavigationUnavailable::MissingFilePair)?; + if matches.next().is_some() { + return Err(NavigationUnavailable::DuplicateFilePair); + } + Ok(first) +} + +pub(crate) fn evidence_view(requested: EvidenceView, file: &FileDiff) -> EvidenceView { + if file.old_path.is_none() || file.new_path.is_none() { + EvidenceView::Unified + } else { + requested + } +} + +pub(crate) fn map_unified_row( + items: &[DisplayItem], + side: ComparisonSide, + line: u32, + old_line_count: usize, + new_line_count: usize, +) -> Result { + validate_line_range(side, line, old_line_count, new_line_count)?; + let line = + usize::try_from(line).map_err(|_| NavigationUnavailable::LineOutOfRange { side, line })?; + let mut visible = None; + let mut hidden = None; + for (row, item) in items.iter().enumerate() { + match item { + DisplayItem::Line(item) if line_on_side(item, side) == Some(line) => { + if visible.replace(row).is_some() { + return Err(NavigationUnavailable::DuplicateLine { + side, + line: u32::try_from(line).unwrap_or(u32::MAX), + }); + } + } + DisplayItem::Expander(expander) if expander_contains(expander, side, line) => { + if hidden.replace(expander.clone()).is_some() { + return Err(NavigationUnavailable::DuplicateLine { + side, + line: u32::try_from(line).unwrap_or(u32::MAX), + }); + } + } + DisplayItem::Line(_) | DisplayItem::Expander(_) => {} + } + } + match (visible, hidden) { + (Some(row), None) => Ok(EvidenceRow::Visible { row, side }), + (None, Some(expander)) => Ok(EvidenceRow::Hidden { + old_range: expander.old_range, + new_range: expander.new_range, + }), + (Some(_), Some(_)) => Err(NavigationUnavailable::DuplicateLine { + side, + line: u32::try_from(line).unwrap_or(u32::MAX), + }), + (None, None) => Err(NavigationUnavailable::LineUnrepresented { + side, + line: u32::try_from(line).unwrap_or(u32::MAX), + }), + } +} + +pub(crate) fn map_split_row( + rows: &[SideBySideLine], + side: ComparisonSide, + line: u32, + old_line_count: usize, + new_line_count: usize, +) -> Result { + validate_line_range(side, line, old_line_count, new_line_count)?; + let line = + usize::try_from(line).map_err(|_| NavigationUnavailable::LineOutOfRange { side, line })?; + let mut visible = None; + let mut hidden = None; + for (row, item) in rows.iter().enumerate() { + let content = match side { + ComparisonSide::Base => item.left.as_ref(), + ComparisonSide::Head => item.right.as_ref(), + }; + if content.is_some_and(|content| content.line_num == line) && visible.replace(row).is_some() + { + return Err(NavigationUnavailable::DuplicateLine { + side, + line: u32::try_from(line).unwrap_or(u32::MAX), + }); + } + if let Some(expander) = item + .expander + .as_ref() + .filter(|expander| expander_contains(expander, side, line)) + && hidden.replace(expander.clone()).is_some() + { + return Err(NavigationUnavailable::DuplicateLine { + side, + line: u32::try_from(line).unwrap_or(u32::MAX), + }); + } + } + match (visible, hidden) { + (Some(row), None) => Ok(EvidenceRow::Visible { row, side }), + (None, Some(expander)) => Ok(EvidenceRow::Hidden { + old_range: expander.old_range, + new_range: expander.new_range, + }), + (Some(_), Some(_)) => Err(NavigationUnavailable::DuplicateLine { + side, + line: u32::try_from(line).unwrap_or(u32::MAX), + }), + (None, None) => Err(NavigationUnavailable::LineUnrepresented { + side, + line: u32::try_from(line).unwrap_or(u32::MAX), + }), + } +} + +pub(crate) fn validate_expander( + expander: &ExpanderRow, + old_line_count: usize, + new_line_count: usize, + old_source_lines: usize, + new_source_lines: usize, +) -> Result { + let (old_start, old_end) = expander.old_range; + let (new_start, new_end) = expander.new_range; + if old_start == 0 || new_start == 0 || old_end < old_start || new_end < new_start { + return Err(NavigationUnavailable::InvalidExpander); + } + let old_len = old_end + .checked_sub(old_start) + .and_then(|length| length.checked_add(1)) + .ok_or(NavigationUnavailable::InvalidExpander)?; + let new_len = new_end + .checked_sub(new_start) + .and_then(|length| length.checked_add(1)) + .ok_or(NavigationUnavailable::InvalidExpander)?; + if old_len != new_len { + return Err(NavigationUnavailable::AsymmetricExpander); + } + if old_end > old_line_count + || new_end > new_line_count + || old_end > old_source_lines + || new_end > new_source_lines + { + return Err(NavigationUnavailable::SourceRangeUnavailable); + } + Ok(old_len) +} + +fn validate_line_range( + side: ComparisonSide, + line: u32, + old_line_count: usize, + new_line_count: usize, +) -> Result<(), NavigationUnavailable> { + let count = match side { + ComparisonSide::Base => old_line_count, + ComparisonSide::Head => new_line_count, + }; + if line == 0 || usize::try_from(line).map_or(true, |line| line > count) { + return Err(NavigationUnavailable::LineOutOfRange { side, line }); + } + Ok(()) +} + +fn line_on_side(line: &super::types::DisplayLine, side: ComparisonSide) -> Option { + match side { + ComparisonSide::Base => line.old_line_num, + ComparisonSide::Head => line.new_line_num, + } +} + +fn expander_contains(expander: &ExpanderRow, side: ComparisonSide, line: usize) -> bool { + let (start, end) = match side { + ComparisonSide::Base => expander.old_range, + ComparisonSide::Head => expander.new_range, + }; + start <= line && line <= end +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::diff_viewer::side_by_side::to_side_by_side; + use crate::diff_viewer::types::DisplayLine; + use okena_git::DiffLineType; + use std::num::NonZeroU32; + + fn line(kind: DiffLineType, old: Option, new: Option) -> DisplayItem { + DisplayItem::Line(DisplayLine { + line_type: kind, + old_line_num: old, + new_line_num: new, + spans: Vec::new(), + plain_text: String::new(), + }) + } + + fn target(key: ReviewFileKey, side: ComparisonSide, path: &str, line: u32) -> EvidenceTarget { + EvidenceTarget { + file: key, + navigation: ReviewNavigationTarget { + path: path.into(), + side, + line: NonZeroU32::new(line).unwrap(), + byte_offset: None, + symbol_context: None, + }, + } + } + + #[test] + fn unified_maps_removed_added_and_context_by_exact_side() { + let items = vec![ + line(DiffLineType::Removed, Some(2), None), + line(DiffLineType::Added, None, Some(2)), + line(DiffLineType::Context, Some(3), Some(3)), + ]; + assert_eq!( + map_unified_row(&items, ComparisonSide::Base, 2, 3, 3).unwrap(), + EvidenceRow::Visible { + row: 0, + side: ComparisonSide::Base + } + ); + assert_eq!( + map_unified_row(&items, ComparisonSide::Head, 2, 3, 3).unwrap(), + EvidenceRow::Visible { + row: 1, + side: ComparisonSide::Head + } + ); + assert_eq!( + map_unified_row(&items, ComparisonSide::Head, 3, 3, 3).unwrap(), + EvidenceRow::Visible { + row: 2, + side: ComparisonSide::Head + } + ); + } + + #[test] + fn split_scans_left_and_right_rows_directly() { + let items = vec![ + line(DiffLineType::Removed, Some(2), None), + line(DiffLineType::Added, None, Some(2)), + ]; + let rows = to_side_by_side(&items); + assert_eq!( + map_split_row(&rows, ComparisonSide::Base, 2, 2, 2).unwrap(), + EvidenceRow::Visible { + row: 0, + side: ComparisonSide::Base + } + ); + assert_eq!( + map_split_row(&rows, ComparisonSide::Head, 2, 2, 2).unwrap(), + EvidenceRow::Visible { + row: 0, + side: ComparisonSide::Head + } + ); + } + + #[test] + fn hidden_rows_and_invalid_ranges_are_explicit() { + let expander = ExpanderRow { + old_range: (4, 6), + new_range: (5, 7), + }; + let items = vec![DisplayItem::Expander(expander.clone())]; + assert_eq!( + map_unified_row(&items, ComparisonSide::Head, 6, 10, 10).unwrap(), + EvidenceRow::Hidden { + old_range: (4, 6), + new_range: (5, 7) + } + ); + assert_eq!(validate_expander(&expander, 10, 10, 10, 10), Ok(3)); + assert_eq!( + validate_expander( + &ExpanderRow { + old_range: (1, 2), + new_range: (1, 3) + }, + 10, + 10, + 10, + 10 + ), + Err(NavigationUnavailable::AsymmetricExpander) + ); + assert_eq!( + validate_expander( + &ExpanderRow { + old_range: (0, 2), + new_range: (1, 3) + }, + 10, + 10, + 10, + 10 + ), + Err(NavigationUnavailable::InvalidExpander) + ); + } + + #[test] + fn add_delete_force_unified_and_exact_pairs_disambiguate() { + let added = FileDiff { + old_path: None, + new_path: Some("new.rs".into()), + hunks: Vec::new(), + is_binary: false, + lines_added: 1, + lines_removed: 0, + }; + assert_eq!( + evidence_view(EvidenceView::Split, &added), + EvidenceView::Unified + ); + let files = vec![added.clone(), added]; + let key = ReviewFileKey { + old_path: None, + new_path: Some("new.rs".into()), + }; + assert_eq!( + find_exact_file_pair(&files, &key), + Err(NavigationUnavailable::DuplicateFilePair) + ); + assert_eq!( + find_exact_file_pair(&[], &key), + Err(NavigationUnavailable::MissingFilePair) + ); + } + + #[test] + fn renamed_targets_validate_the_exact_side_path() { + let key = ReviewFileKey { + old_path: Some("old.rs".into()), + new_path: Some("new.rs".into()), + }; + assert_eq!( + validate_evidence_target(&target(key.clone(), ComparisonSide::Base, "old.rs", 2)), + Ok(()) + ); + assert_eq!( + validate_evidence_target(&target(key.clone(), ComparisonSide::Head, "new.rs", 3)), + Ok(()) + ); + assert_eq!( + validate_evidence_target(&target(key, ComparisonSide::Base, "new.rs", 2)), + Err(NavigationUnavailable::PathMismatch) + ); + assert_eq!( + validate_evidence_target(&target( + ReviewFileKey { + old_path: None, + new_path: Some("new.rs".into()), + }, + ComparisonSide::Base, + "new.rs", + 1 + )), + Err(NavigationUnavailable::MissingSide { + side: ComparisonSide::Base + }) + ); + } + + #[test] + fn invalid_preflight_keeps_the_exact_pair_visible() { + let key = ReviewFileKey { + old_path: None, + new_path: Some("new.rs".into()), + }; + let invalid = target(key.clone(), ComparisonSide::Base, "new.rs", 1); + let mut smart_review = SmartReviewState::default(); + let mut navigation = ReviewNavigationState::default(); + + assert_eq!( + preflight_evidence_navigation(&mut smart_review, &mut navigation, &invalid), + Err(NavigationUnavailable::MissingSide { + side: ComparisonSide::Base, + }) + ); + assert_eq!(smart_review.selected_file.as_ref(), Some(&key)); + assert_eq!( + navigation.unavailable, + Some(NavigationUnavailable::MissingSide { + side: ComparisonSide::Base, + }) + ); + } + + #[test] + fn center_request_is_strict_and_deferred() { + let handle = UniformListScrollHandle::new(); + request_strict_center(&handle, 17); + let state = handle.0.borrow(); + let request = state.deferred_scroll_to_item.as_ref().unwrap(); + assert_eq!(request.item_index, 17); + assert_eq!(request.strategy, ScrollStrategy::Center); + assert!(request.scroll_strict); + } + + #[test] + fn hidden_rows_work_before_between_and_after_hunks() { + let items = vec![ + DisplayItem::Expander(ExpanderRow { + old_range: (1, 2), + new_range: (1, 2), + }), + line(DiffLineType::Context, Some(3), Some(3)), + DisplayItem::Expander(ExpanderRow { + old_range: (4, 5), + new_range: (4, 5), + }), + line(DiffLineType::Context, Some(6), Some(6)), + DisplayItem::Expander(ExpanderRow { + old_range: (7, 8), + new_range: (7, 8), + }), + ]; + for (line, range) in [(1, (1, 2)), (5, (4, 5)), (8, (7, 8))] { + assert_eq!( + map_unified_row(&items, ComparisonSide::Head, line, 8, 8), + Ok(EvidenceRow::Hidden { + old_range: range, + new_range: range, + }) + ); + } + assert_eq!( + map_unified_row(&items, ComparisonSide::Head, 9, 8, 8), + Err(NavigationUnavailable::LineOutOfRange { + side: ComparisonSide::Head, + line: 9, + }) + ); + } + + #[test] + fn unrepresented_and_source_overflow_are_not_guessed() { + assert_eq!( + map_unified_row(&[], ComparisonSide::Base, 2, 3, 3), + Err(NavigationUnavailable::LineUnrepresented { + side: ComparisonSide::Base, + line: 2, + }) + ); + assert_eq!( + validate_expander( + &ExpanderRow { + old_range: (2, 4), + new_range: (2, 4), + }, + 4, + 4, + 3, + 4, + ), + Err(NavigationUnavailable::SourceRangeUnavailable) + ); + } + + #[test] + fn newer_tokens_and_generations_retire_the_pending_navigation() { + let key = ReviewFileKey { + old_path: Some("a.rs".into()), + new_path: Some("a.rs".into()), + }; + let mut file = super::super::review::FileViewState::default(); + let generation = file.begin(key.clone()); + let mut state = ReviewNavigationState::default(); + let first = state.begin( + generation, + target(key.clone(), ComparisonSide::Head, "a.rs", 2), + ); + assert!(state.accepts(first, generation, &key)); + + let second_generation = file.begin(key.clone()); + let second = state.begin( + second_generation, + target(key.clone(), ComparisonSide::Base, "a.rs", 3), + ); + assert!(!state.accepts(first, generation, &key)); + assert!(state.accepts(second, second_generation, &key)); + + state.finish(); + assert!(!state.has_pending()); + assert!(!state.accepts(second, second_generation, &key)); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/actions.rs b/crates/okena-views-git/src/diff_viewer/review_ui/actions.rs new file mode 100644 index 000000000..8eba69de0 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/actions.rs @@ -0,0 +1,640 @@ +//! Every review interaction goes through one of these; the views only call them. +// Frozen surface: the wave-1 view units call these. +#![allow(dead_code)] + +use super::super::DiffViewer; +use super::super::review::{LoadState, ReviewFileKey}; +use super::super::review_nav::EvidenceTarget; +use super::model::{AttentionTarget, ReasonKind}; +use super::ranking::{ModelInputs, StructureLoad, build_review_model}; +use super::state::{ + ContentView, FocusRegion, MarkerSpan, NavRowId, NavigatorMode, RoleFilter, RolePreset, + SymbolRef, +}; +use gpui::{App, ClipboardItem, Context, ScrollStrategy, Window}; +use okena_core::review::{ComparisonSide, FileRole}; +use std::sync::Arc; + +impl DiffViewer { + /// Rebuild the pure model. Cheap enough to run whenever a dataset lands. + pub(crate) fn review_rebuild_model(&mut self, cx: &mut Context) { + let structure_state = match &self.smart_review.structure { + LoadState::Idle => StructureLoad::NotStarted, + LoadState::Loading => StructureLoad::Loading, + LoadState::Failed(error) => StructureLoad::Failed(error.clone()), + LoadState::Ready(_) => StructureLoad::Ready, + }; + let model = Arc::new(build_review_model(ModelInputs { + inventory: self.smart_review.inventory.ready(), + inventory_error: self.smart_review.inventory.error(), + structure: self.smart_review.structure.ready(), + structure_state, + diff_mode: &self.diff_mode, + })); + self.review_ui.model = Some(Arc::clone(&model)); + + // Small comparisons skip the Overview and open the first ranked file. + if model.small_change + && !self.review_ui.small_change_applied + && !self.review_navigation.has_pending() + && let Some(item) = model.attention.first() + { + self.review_ui.small_change_applied = true; + self.review_open_item(item.target.clone(), cx); + } + cx.notify(); + } + + /// Drop everything derived from the previous comparison. + pub(crate) fn review_reset_for_comparison(&mut self) { + let state = &mut self.review_ui; + state.model = None; + state.content = ContentView::Overview; + state.selected_symbol = None; + state.queue_target = None; + state.marker = None; + state.nav_cursor = None; + state.nav_reveal = None; + state.small_change_applied = false; + state.expanded_dirs.clear(); + state.expanded_initialized = false; + state.roles_menu_open = false; + state.status_popover_open = false; + state.outline_open = false; + state.help_open = false; + state.ledger_open = false; + } + + /// Indices into `ReviewModel::files` that pass the role filter and the text filter. + pub(crate) fn review_visible_files(&self) -> Vec { + let Some(model) = self.review_ui.model.as_ref() else { + return Vec::new(); + }; + let needle = self.review_ui.filter_text.to_lowercase(); + model + .files + .iter() + .enumerate() + .filter(|(_, entry)| self.review_ui.role_filter.allows(entry)) + .filter(|(_, entry)| matches_filter(&entry.display_path, &needle)) + .map(|(index, _)| index) + .collect() + } + + /// Indices into `ReviewModel::attention` that pass every navigator filter. + pub(crate) fn review_visible_attention(&self) -> Vec { + let Some(model) = self.review_ui.model.as_ref() else { + return Vec::new(); + }; + let filter = &self.review_ui.attention_filter; + let needle = self.review_ui.filter_text.to_lowercase(); + model + .attention + .iter() + .enumerate() + .filter(|(_, item)| { + filter.kinds.is_empty() + || item + .reasons + .iter() + .any(|reason| filter.kinds.contains(&reason.kind)) + }) + .filter(|(_, item)| filter.include_tests || !item.is_test) + .filter(|(_, item)| match item.target.file() { + Some(key) => model + .file_index(key) + .and_then(|index| model.files.get(index)) + .is_some_and(|entry| self.review_ui.role_filter.allows(entry)), + None => true, + }) + .filter(|(_, item)| { + matches_filter(&item.path, &needle) || matches_filter(&item.name, &needle) + }) + .map(|(index, _)| index) + .collect() + } + + pub(crate) fn review_set_navigator(&mut self, mode: NavigatorMode, cx: &mut Context) { + self.review_ui.navigator = mode; + match mode { + NavigatorMode::Attention => self.review_reveal_selected_in_attention(), + NavigatorMode::Files => self.review_reveal_selected_in_files(), + } + cx.notify(); + } + + /// Put the cursor on the open file's first Attention row and scroll to it + /// once; with nothing open the list stays where it was. + fn review_reveal_selected_in_attention(&mut self) { + if self.review_ui.content != ContentView::File { + return; + } + let Some(model) = self.review_ui.model.as_ref() else { + return; + }; + let Some(key) = self.smart_review.selected_file.as_ref() else { + return; + }; + let Some(index) = model.first_attention_for_file(key) else { + return; + }; + let target = model.attention[index].target.clone(); + self.review_ui.nav_cursor = Some(NavRowId::Item(target)); + self.review_ui.nav_reveal = Some(ScrollStrategy::Center); + } + + /// Same for the tree: expand down to the open file, park the cursor on it. + fn review_reveal_selected_in_files(&mut self) { + self.review_expand_to_selected_file(); + if self.review_ui.content != ContentView::File { + return; + } + let Some(key) = self.smart_review.selected_file.clone() else { + return; + }; + self.review_ui.nav_cursor = Some(NavRowId::File(key)); + self.review_ui.nav_reveal = Some(ScrollStrategy::Center); + } + + /// Expand every directory above the open file so its tree row is reachable. + fn review_expand_to_selected_file(&mut self) { + let Some(key) = self.smart_review.selected_file.clone() else { + return; + }; + let Some(path) = key.new_path.clone().or_else(|| key.old_path.clone()) else { + return; + }; + let segments: Vec<&str> = path.split('/').collect(); + for depth in 0..segments.len().saturating_sub(1) { + self.review_ui + .expanded_dirs + .insert(segments[..=depth].join("/")); + } + } + + pub(crate) fn review_toggle_dir(&mut self, path: &str, cx: &mut Context) { + if !self.review_ui.expanded_dirs.remove(path) { + self.review_ui.expanded_dirs.insert(path.to_string()); + } + cx.notify(); + } + + pub(crate) fn review_set_flatten(&mut self, flatten: bool, cx: &mut Context) { + self.review_ui.flatten = flatten; + cx.notify(); + } + + /// Inline every file's changed symbols, or fold them all away again. + pub(crate) fn review_set_outline(&mut self, outline: bool, cx: &mut Context) { + self.review_ui.outline_inline = outline; + if !outline { + // The cursor may be sitting on a symbol row that just disappeared. + if let Some(NavRowId::Item(AttentionTarget::Symbol { file, .. })) = + self.review_ui.nav_cursor.clone() + { + self.review_ui.nav_cursor = Some(NavRowId::File(file)); + } + } + self.review_ui.nav_reveal = Some(ScrollStrategy::Nearest); + cx.notify(); + } + + pub(crate) fn review_set_role_filter(&mut self, filter: RoleFilter, cx: &mut Context) { + self.review_ui.role_filter = filter; + cx.notify(); + } + + pub(crate) fn review_toggle_role(&mut self, role: FileRole, cx: &mut Context) { + self.review_ui.role_filter.toggle(role); + cx.notify(); + } + + pub(crate) fn review_apply_preset(&mut self, preset: RolePreset, cx: &mut Context) { + self.review_ui.role_filter = RoleFilter::preset(preset); + cx.notify(); + } + + pub(crate) fn review_set_saved_filter( + &mut self, + likely_mechanical: Option, + not_analyzed: Option, + cx: &mut Context, + ) { + if let Some(value) = likely_mechanical { + self.review_ui.role_filter.likely_mechanical_only = value; + } + if let Some(value) = not_analyzed { + self.review_ui.role_filter.not_analyzed_only = value; + } + cx.notify(); + } + + pub(crate) fn review_toggle_reason_filter(&mut self, kind: ReasonKind, cx: &mut Context) { + if !self.review_ui.attention_filter.kinds.remove(&kind) { + self.review_ui.attention_filter.kinds.insert(kind); + } + cx.notify(); + } + + pub(crate) fn review_toggle_include_tests(&mut self, cx: &mut Context) { + let filter = &mut self.review_ui.attention_filter; + filter.include_tests = !filter.include_tests; + cx.notify(); + } + + pub(crate) fn review_toggle_group_by_file(&mut self, cx: &mut Context) { + let filter = &mut self.review_ui.attention_filter; + filter.grouped_by_file = !filter.grouped_by_file; + cx.notify(); + } + + pub(crate) fn review_open_overview(&mut self, cx: &mut Context) { + self.review_ui.outline_open = false; + self.review_ui.content = ContentView::Overview; + self.review_ui.selected_symbol = None; + self.review_ui.marker = None; + cx.notify(); + } + + pub(crate) fn review_open_file(&mut self, key: ReviewFileKey, cx: &mut Context) { + self.review_ui.outline_open = false; + self.review_ui.content = ContentView::File; + self.review_ui.selected_symbol = None; + self.review_ui.marker = None; + self.review_ui.queue_target = self.review_queue_target_for(&key); + if self.review_file_is_loaded(&key) { + // Already on screen: re-selecting must not reload or lose the scroll. + self.review_navigation.invalidate(); + cx.notify(); + return; + } + self.select_smart_file(key, cx); + } + + /// The open file's source and diff are ready and displayed. + fn review_file_is_loaded(&self, key: &ReviewFileKey) -> bool { + self.smart_review.selected_file.as_ref() == Some(key) + && self.smart_review.file.has_ready_cache(key, true) + && self.current_file.is_some() + } + + /// The file's own queue entry when it has one, else its first symbol entry. + fn review_queue_target_for(&self, key: &ReviewFileKey) -> Option { + let model = self.review_ui.model.as_ref()?; + let file_target = AttentionTarget::File(key.clone()); + if model.attention_index(&file_target).is_some() { + return Some(file_target); + } + let index = model.first_attention_for_file(key)?; + model.attention.get(index).map(|item| item.target.clone()) + } + + pub(crate) fn review_open_symbol(&mut self, symbol: SymbolRef, cx: &mut Context) { + let Some(model) = self.review_ui.model.clone() else { + return; + }; + let Some(entry) = model + .file_index(&symbol.file) + .and_then(|index| model.files.get(index)) + else { + return; + }; + let Some(found) = entry + .symbols + .iter() + .find(|candidate| candidate.change_index == symbol.change_index) + else { + return; + }; + let marker = MarkerSpan { + file: symbol.file.clone(), + old: found.old_hunks.clone(), + new: found.new_hunks.clone(), + }; + let navigation = found.navigation.clone(); + + self.review_open_file(symbol.file.clone(), cx); + self.review_ui.queue_target = Some(AttentionTarget::Symbol { + file: symbol.file.clone(), + change_index: symbol.change_index, + }); + self.review_ui.selected_symbol = Some(symbol.clone()); + self.review_ui.marker = Some(marker); + self.navigate_to_evidence( + EvidenceTarget { + file: symbol.file, + navigation, + }, + cx, + ); + } + + pub(crate) fn review_open_item(&mut self, target: AttentionTarget, cx: &mut Context) { + match target { + AttentionTarget::Symbol { file, change_index } => { + self.review_open_symbol(SymbolRef { file, change_index }, cx); + } + AttentionTarget::File(key) => self.review_open_file(key, cx), + AttentionTarget::Directory(path) => { + self.review_ui.expanded_dirs.insert(path.clone()); + let key = self.review_ui.model.as_ref().and_then(|model| { + model + .first_file_under(&path) + .and_then(|index| model.files.get(index)) + .map(|entry| entry.key.clone()) + }); + if let Some(key) = key { + self.review_open_file(key, cx); + } else { + cx.notify(); + } + } + } + } + + /// Move along the visible Attention order; clamps at both ends. + pub(crate) fn review_step_queue(&mut self, delta: i32, cx: &mut Context) { + let visible = self.review_visible_attention(); + let Some(model) = self.review_ui.model.clone() else { + return; + }; + let current = self + .review_ui + .queue_target + .as_ref() + .and_then(|target| model.attention_index(target)) + .and_then(|index| visible.iter().position(|visible| *visible == index)); + let Some(row) = step_index(visible.len(), current, delta) else { + return; + }; + let Some(target) = visible + .get(row) + .and_then(|index| model.attention.get(*index)) + .map(|item| item.target.clone()) + else { + return; + }; + self.review_follow_queue_target(&target); + self.review_open_item(target, cx); + } + + /// `]` `[` keep the navigator's cursor on the item they land on, so the + /// list scrolls along and `↑` `↓` continue from there. + fn review_follow_queue_target(&mut self, target: &AttentionTarget) { + let cursor = match self.review_ui.navigator { + NavigatorMode::Attention => NavRowId::Item(target.clone()), + NavigatorMode::Files => match target { + AttentionTarget::Symbol { file, .. } | AttentionTarget::File(file) => { + NavRowId::File(file.clone()) + } + AttentionTarget::Directory(path) => NavRowId::Dir(path.clone()), + }, + }; + self.review_ui.nav_cursor = Some(cursor); + self.review_ui.nav_reveal = Some(ScrollStrategy::Nearest); + } + + /// Move along the open file's changed symbols in source order; clamps. + pub(crate) fn review_step_symbol(&mut self, delta: i32, cx: &mut Context) { + let Some(model) = self.review_ui.model.clone() else { + return; + }; + let Some(key) = self.smart_review.selected_file.clone() else { + return; + }; + let Some(entry) = model + .file_index(&key) + .and_then(|index| model.files.get(index)) + else { + return; + }; + // Start from the symbol the bar shows (selected, or the one in view), + // so `}` and the "k of n" counter agree. + let current = self.review_current_symbol_index(); + let Some(position) = step_index(entry.symbols.len(), current, delta) else { + return; + }; + let Some(change_index) = entry + .symbols + .get(position) + .map(|symbol| symbol.change_index) + else { + return; + }; + self.review_open_symbol( + SymbolRef { + file: key, + change_index, + }, + cx, + ); + } + + pub(crate) fn review_set_focus_region(&mut self, region: FocusRegion, cx: &mut Context) { + self.review_ui.focus_region = region; + cx.notify(); + } + + pub(crate) fn review_toggle_details(&mut self, cx: &mut Context) { + self.review_ui.details_expanded = !self.review_ui.details_expanded; + cx.notify(); + } + + pub(crate) fn review_toggle_roles_menu(&mut self, cx: &mut Context) { + self.review_ui.roles_menu_open = !self.review_ui.roles_menu_open; + cx.notify(); + } + + pub(crate) fn review_toggle_status_popover(&mut self, cx: &mut Context) { + self.review_ui.status_popover_open = !self.review_ui.status_popover_open; + cx.notify(); + } + + pub(crate) fn review_toggle_outline(&mut self, cx: &mut Context) { + self.review_ui.outline_open = !self.review_ui.outline_open; + cx.notify(); + } + + pub(crate) fn review_toggle_help(&mut self, cx: &mut Context) { + self.review_ui.help_open = !self.review_ui.help_open; + cx.notify(); + } + + pub(crate) fn review_toggle_commit_ledger(&mut self, cx: &mut Context) { + self.review_ui.ledger_open = !self.review_ui.ledger_open; + cx.notify(); + } + + /// Close any open menu, popover or overlay. True when something closed. + pub(crate) fn review_dismiss_transient(&mut self, cx: &mut Context) -> bool { + let state = &mut self.review_ui; + let open = state.roles_menu_open + || state.status_popover_open + || state.outline_open + || state.help_open; + if !open { + return false; + } + state.roles_menu_open = false; + state.status_popover_open = false; + state.outline_open = false; + state.help_open = false; + cx.notify(); + true + } + + pub(crate) fn review_set_filter_text(&mut self, text: String, cx: &mut Context) { + self.review_ui.filter_text = text.clone(); + self.review_ui + .filter_input + .update(cx, |input, cx| input.set_value(text, cx)); + cx.notify(); + } + + pub(crate) fn review_focus_filter(&mut self, window: &mut Window, cx: &mut Context) { + self.review_ui.filter_input.update(cx, |input, cx| { + input.select_all(cx); + input.focus(window, cx); + }); + cx.notify(); + } + + pub(crate) fn review_clear_filter(&mut self, cx: &mut Context) { + self.review_set_filter_text(String::new(), cx); + } + + pub(crate) fn review_filter_focused(&self, window: &Window, cx: &App) -> bool { + self.review_ui + .filter_input + .read(cx) + .focus_handle(cx) + .is_focused(window) + } + + /// Copy `path:line` of the selected symbol, else the open file's path. + pub(crate) fn review_copy_path_line(&mut self, cx: &mut Context) { + let Some(text) = self.review_path_line() else { + return; + }; + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + + fn review_path_line(&self) -> Option { + let model = self.review_ui.model.as_ref()?; + if let Some(symbol) = self.review_ui.selected_symbol.as_ref() + && let Some(entry) = model + .file_index(&symbol.file) + .and_then(|index| model.files.get(index)) + && let Some(found) = entry + .symbols + .iter() + .find(|candidate| candidate.change_index == symbol.change_index) + { + let navigation = &found.navigation; + return Some(format!("{}:{}", navigation.path, navigation.line)); + } + let key = self.smart_review.selected_file.as_ref()?; + key.path(ComparisonSide::Head) + .or_else(|| key.path(ComparisonSide::Base)) + .map(str::to_owned) + } + + /// Whether the diff line painters should mark this row. + pub(crate) fn review_marker_matches(&self, side: ComparisonSide, line: usize) -> bool { + let Some(marker) = self.review_ui.marker.as_ref() else { + return false; + }; + self.smart_review.selected_file.as_ref() == Some(&marker.file) && marker.matches(side, line) + } + + /// The unified/split toggle only applies while a diff is on screen. + pub(crate) fn review_show_split_toggle(&self) -> bool { + self.review_ui.content == ContentView::File + } +} + +fn matches_filter(haystack: &str, lowercase_needle: &str) -> bool { + lowercase_needle.is_empty() || haystack.to_lowercase().contains(lowercase_needle) +} + +/// Clamped step within `len` rows; no selection starts at the first row. +fn step_index(len: usize, current: Option, delta: i32) -> Option { + if len == 0 { + return None; + } + let Some(current) = current else { + return Some(0); + }; + let current = i64::try_from(current).ok()?; + let last = i64::try_from(len.saturating_sub(1)).ok()?; + let next = current.saturating_add(i64::from(delta)).clamp(0, last); + usize::try_from(next).ok() +} + +#[cfg(test)] +mod tests { + use super::super::fixtures; + use super::super::ranking::{ModelInputs, StructureLoad, build_review_model}; + use super::super::state::{RoleFilter, RolePreset}; + use super::{matches_filter, step_index}; + use okena_git::DiffMode; + + #[test] + fn visible_files_intersect_the_role_filter_with_the_text_filter() { + let inventory = fixtures::inventory(); + let model = build_review_model(ModelInputs { + inventory: Some(&inventory), + inventory_error: None, + structure: None, + structure_state: StructureLoad::Loading, + diff_mode: &DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + }, + }); + let visible = |filter: &RoleFilter, needle: &str| -> Vec { + model + .files + .iter() + .filter(|entry| filter.allows(entry)) + .filter(|entry| matches_filter(&entry.display_path, needle)) + .map(|entry| entry.display_path.clone()) + .collect() + }; + + let everything = RoleFilter::everything(); + assert_eq!( + visible(&everything, "lib"), + ["src/lib.rs", "tests/lib.rs"], + "the text filter alone keeps both roles" + ); + assert_eq!( + visible(&RoleFilter::preset(RolePreset::ReviewCode), "lib"), + ["src/lib.rs"], + "the role filter drops the test file" + ); + assert!( + visible(&everything, "").len() >= 7, + "an empty filter keeps every inventory file" + ); + } + + #[test] + fn steps_clamp_at_both_ends_and_start_at_the_first_row() { + assert_eq!(step_index(0, None, 1), None); + assert_eq!(step_index(3, None, 1), Some(0)); + assert_eq!(step_index(3, None, -1), Some(0)); + assert_eq!(step_index(3, Some(0), 1), Some(1)); + assert_eq!(step_index(3, Some(2), 1), Some(2)); + assert_eq!(step_index(3, Some(0), -1), Some(0)); + assert_eq!(step_index(3, Some(1), 5), Some(2)); + assert_eq!(step_index(3, Some(1), -5), Some(0)); + } + + #[test] + fn the_text_filter_is_case_insensitive_and_empty_means_everything() { + assert!(matches_filter("src/Lib.rs", "")); + assert!(matches_filter("src/Lib.rs", "lib")); + assert!(!matches_filter("src/Lib.rs", "tests")); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/diff_state.rs b/crates/okena-views-git/src/diff_viewer/review_ui/diff_state.rs new file mode 100644 index 000000000..3d378ae9a --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/diff_state.rs @@ -0,0 +1,274 @@ +//! Whether the exact diff of the selected file can be shown, and what to say +//! while it cannot. + +use super::super::DiffViewer; +use super::super::review::{LoadState, ReviewFileKey}; +use gpui::prelude::*; +use gpui::*; +use okena_core::theme::ThemeColors; +use okena_ui::tokens::{ui_text_ms, ui_text_sm}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum SmartDiffViewState { + Idle, + Loading, + Failed(String), + Empty, + NoSelection, + SourceIdle, + SourceLoading, + SourceFailed(String), + DisplayLoading, + Ready, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum DiffPhase { + Idle, + Loading, + Failed(String), + Empty, + Files, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum SourcePhase { + Idle, + Loading, + Failed(String), + Ready, +} + +fn classify_smart_diff_state( + diff: DiffPhase, + selected_in_diff: bool, + source: SourcePhase, + display_ready: bool, +) -> SmartDiffViewState { + match diff { + DiffPhase::Idle => SmartDiffViewState::Idle, + DiffPhase::Loading => SmartDiffViewState::Loading, + DiffPhase::Failed(error) => SmartDiffViewState::Failed(error), + DiffPhase::Empty => SmartDiffViewState::Empty, + DiffPhase::Files if !selected_in_diff => SmartDiffViewState::NoSelection, + DiffPhase::Files => match source { + SourcePhase::Idle => SmartDiffViewState::SourceIdle, + SourcePhase::Loading => SmartDiffViewState::SourceLoading, + SourcePhase::Failed(error) => SmartDiffViewState::SourceFailed(error), + SourcePhase::Ready if !display_ready => SmartDiffViewState::DisplayLoading, + SourcePhase::Ready => SmartDiffViewState::Ready, + }, + } +} + +fn exact_display_ready( + canonical: Option<&ReviewFileKey>, + file_key: Option<&ReviewFileKey>, + source_matches: bool, + cache_ready: bool, + display_ready: bool, +) -> bool { + canonical.is_some() && canonical == file_key && source_matches && cache_ready && display_ready +} + +impl DiffViewer { + pub(crate) fn smart_diff_view_state(&self) -> SmartDiffViewState { + let (phase, dataset) = match &self.smart_review.diff { + LoadState::Idle => (DiffPhase::Idle, None), + LoadState::Loading => (DiffPhase::Loading, None), + LoadState::Failed(error) => (DiffPhase::Failed(error.clone()), None), + LoadState::Ready(dataset) if dataset.files.is_empty() => (DiffPhase::Empty, None), + LoadState::Ready(dataset) => (DiffPhase::Files, Some(dataset)), + }; + let selected_in_diff = dataset.is_some_and(|dataset| { + self.smart_review.selected_file.as_ref().is_some_and(|key| { + dataset + .files + .iter() + .any(|file| file.old_path == key.old_path && file.new_path == key.new_path) + }) + }); + let canonical = self.smart_review.selected_file.as_ref(); + let source = match &self.smart_review.file.source { + LoadState::Idle => SourcePhase::Idle, + LoadState::Loading => SourcePhase::Loading, + LoadState::Failed(error) => SourcePhase::Failed(error.clone()), + LoadState::Ready(source) + if canonical.is_none_or(|key| { + self.smart_review.file.key.as_ref() != Some(key) || !key.matches_source(source) + }) => + { + SourcePhase::Failed("Exact source does not match canonical selection".into()) + } + LoadState::Ready(_) => SourcePhase::Ready, + }; + let source_matches = matches!( + &self.smart_review.file.source, + LoadState::Ready(source) + if canonical.is_some_and(|key| key.matches_source(source)) + ); + let cache_ready = + canonical.is_some_and(|key| self.smart_review.file.has_ready_cache(key, true)); + let display_ready = exact_display_ready( + canonical, + self.smart_review.file.key.as_ref(), + source_matches, + cache_ready, + self.current_file.is_some(), + ); + classify_smart_diff_state(phase, selected_in_diff, source, display_ready) + } + + pub(crate) fn render_smart_diff_state( + &self, + state: SmartDiffViewState, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let (title, detail) = match state { + SmartDiffViewState::Idle => ("Diff is not loaded", None), + SmartDiffViewState::Loading => ("Loading exact diff\u{2026}", None), + SmartDiffViewState::Failed(error) => ("Exact diff failed", Some(error)), + SmartDiffViewState::Empty => ("No changed files", None), + SmartDiffViewState::NoSelection => ("No exact file selected", None), + SmartDiffViewState::SourceIdle => ("Exact source is not loaded", None), + SmartDiffViewState::SourceLoading => ("Loading exact source\u{2026}", None), + SmartDiffViewState::SourceFailed(error) => ("Exact source failed", Some(error)), + SmartDiffViewState::DisplayLoading => ("Preparing diff display\u{2026}", None), + SmartDiffViewState::Ready => return div().into_any_element(), + }; + render_review_state(title, detail.as_deref(), t, cx) + } + + pub(crate) fn render_navigation_unavailable( + &self, + t: &ThemeColors, + cx: &mut Context, + ) -> Option { + self.review_navigation.unavailable.as_ref().map(|error| { + div() + .h(px(30.0)) + .px(px(16.0)) + .flex() + .items_center() + .border_b_1() + .border_color(rgb(t.border)) + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.term_yellow)) + .child(format!("Evidence unavailable: {error}")) + .into_any_element() + }) + } +} + +fn render_review_state( + title: &str, + detail: Option<&str>, + t: &ThemeColors, + cx: &mut Context, +) -> AnyElement { + div() + .flex_1() + .flex() + .items_center() + .justify_center() + .child( + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(title.to_string()) + .when_some(detail, |d, detail| { + d.child( + div() + .mt(px(4.0)) + .text_size(ui_text_ms(cx)) + .child(detail.to_string()), + ) + }), + ) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use super::{ + DiffPhase, ReviewFileKey, SmartDiffViewState, SourcePhase, classify_smart_diff_state, + exact_display_ready, + }; + + #[test] + fn smart_diff_local_states_cover_dataset_source_and_display() { + assert_eq!( + classify_smart_diff_state(DiffPhase::Idle, false, SourcePhase::Idle, false), + SmartDiffViewState::Idle + ); + assert_eq!( + classify_smart_diff_state(DiffPhase::Loading, false, SourcePhase::Idle, false), + SmartDiffViewState::Loading + ); + assert_eq!( + classify_smart_diff_state( + DiffPhase::Failed("diff".into()), + false, + SourcePhase::Idle, + false + ), + SmartDiffViewState::Failed("diff".into()) + ); + assert_eq!( + classify_smart_diff_state(DiffPhase::Empty, false, SourcePhase::Idle, false), + SmartDiffViewState::Empty + ); + assert_eq!( + classify_smart_diff_state(DiffPhase::Files, false, SourcePhase::Idle, false), + SmartDiffViewState::NoSelection + ); + assert_eq!( + classify_smart_diff_state(DiffPhase::Files, true, SourcePhase::Loading, false), + SmartDiffViewState::SourceLoading + ); + assert_eq!( + classify_smart_diff_state( + DiffPhase::Files, + true, + SourcePhase::Failed("source".into()), + false + ), + SmartDiffViewState::SourceFailed("source".into()) + ); + assert_eq!( + classify_smart_diff_state(DiffPhase::Files, true, SourcePhase::Ready, false), + SmartDiffViewState::DisplayLoading + ); + assert_eq!( + classify_smart_diff_state(DiffPhase::Files, true, SourcePhase::Ready, true), + SmartDiffViewState::Ready + ); + } + + #[test] + fn exact_diff_ready_rejects_a_stale_file_key() { + let canonical = ReviewFileKey { + old_path: Some("a.rs".into()), + new_path: Some("a.rs".into()), + }; + let stale = ReviewFileKey { + old_path: Some("b.rs".into()), + new_path: Some("b.rs".into()), + }; + assert!(!exact_display_ready( + Some(&canonical), + Some(&stale), + true, + true, + true + )); + assert!(exact_display_ready( + Some(&canonical), + Some(&canonical), + true, + true, + true + )); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/file_view/bar.rs b/crates/okena-views-git/src/diff_viewer/review_ui/file_view/bar.rs new file mode 100644 index 000000000..04642a217 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/file_view/bar.rs @@ -0,0 +1,383 @@ +//! The 32 px symbol bar and the details block under it — spec §9. + +use super::super::super::DiffViewer; +use super::super::super::line_render::{WORD_BG_ALPHA, rgba as tint}; +use super::super::labels; +use super::super::labels::reasons as words; +use super::super::model::{CallRow, ReasonKind, SymbolEntry}; +use super::text; +use super::token_diff::{Segment, SegmentKind, token_diff}; +use super::{chip, churn, word}; +use gpui::prelude::*; +use gpui::*; +use gpui_component::h_flex; +use gpui_component::v_flex; +use okena_core::theme::ThemeColors; +use okena_review::CallChangeKind; +use okena_ui::tokens::{ui_text_ms, ui_text_sm}; + +const BAR_CHIPS: usize = 3; +/// A narrow column keeps the name and the first two chips — spec §12. +const NARROW_CHIPS: usize = 2; +const NEXT_HINT: &str = "} next"; +const DETAILS_WORD: &str = " details"; +const COLLAPSED_ARROW: &str = "\u{25B8}"; +const EXPANDED_ARROW: &str = "\u{25BE}"; +const REASONS_TITLE: &str = "Reasons"; +const LINES_TITLE: &str = "Lines"; +const SIGNATURE_TITLE: &str = "Signature"; +const CALLS_TITLE: &str = "Calls"; +const CALLS_CAVEAT: &str = "same file, syntactic \u{00B7} callers are not tracked"; +const COMPLEXITY_TITLE: &str = "Complexity"; +/// The details label column. +const LABEL_WIDTH: Pixels = px(72.0); +/// Calls listed before `… n more`; the diff underneath has the rest. +const MAX_CALL_ROWS: usize = 8; + +pub(super) fn render( + view: &DiffViewer, + t: &ThemeColors, + cx: &mut Context, +) -> Option { + let entry = view.review_open_entry()?; + let index = view.review_current_symbol_index()?; + let symbol = entry.symbols.get(index)?; + let narrow = view.review_content_is_narrow(); + let expanded = view.review_ui.details_expanded; + // A narrow column keeps the name and two chips; the counter and the churn + // repeat what the header and the diff already say — spec §12. + let chip_limit = if narrow { NARROW_CHIPS } else { BAR_CHIPS }; + let counter = (!narrow).then(|| { + format!( + "{}{}{NEXT_HINT}", + text::symbol_counter(index, entry.symbols.len()), + text::DOT + ) + }); + + let bar = h_flex() + .h(px(32.0)) + .px(px(16.0)) + .gap(px(8.0)) + .flex_none() + .items_center() + .bg(rgb(t.bg_secondary)) + .border_b_1() + .border_color(rgb(t.border)) + .child(word(labels::glyph(symbol.glyph), t.text_muted, cx)) + .child( + div() + .flex_none() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_primary)) + .child(symbol.name.clone()), + ) + .children( + symbol + .reasons + .iter() + .take(chip_limit) + .map(|reason| chip(reason, t, cx)), + ) + .when(!narrow, |d| { + d.children(churn( + u64::from(symbol.lines_added), + u64::from(symbol.lines_deleted), + t, + cx, + )) + }) + .child(div().flex_1()) + .when_some(counter, |d, counter| { + d.child(word(counter, t.text_muted, cx)) + }) + .child(details_toggle(expanded, narrow, t, cx)); + + if !expanded { + return Some(bar.into_any_element()); + } + Some( + v_flex() + .flex_none() + .child(bar) + .child(details(symbol, chip_limit, t, cx)) + .into_any_element(), + ) +} + +/// The arrow alone in a narrow column; the word is the first thing to go — §12. +fn details_toggle( + expanded: bool, + narrow: bool, + t: &ThemeColors, + cx: &mut Context, +) -> AnyElement { + let arrow = if expanded { + EXPANDED_ARROW + } else { + COLLAPSED_ARROW + }; + let label = if narrow { + arrow.to_string() + } else { + format!("{arrow}{DETAILS_WORD}") + }; + div() + .id("review-symbol-details") + .flex_none() + .cursor_pointer() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.term_blue)) + .hover(|style| style.text_color(rgb(t.text_primary))) + .on_click(cx.listener(|this, _, _window, cx| this.review_toggle_details(cx))) + .child(label) + .into_any_element() +} + +/// The details block: a label column and one row per fact the symbol has. +/// It always has at least the line span, so opening it never shows nothing. +fn details(symbol: &SymbolEntry, shown_chips: usize, t: &ThemeColors, cx: &App) -> AnyElement { + let complex = symbol + .reasons + .iter() + .any(|reason| reason.kind == ReasonKind::Complex); + v_flex() + .flex_none() + .min_w_0() + .px(px(16.0)) + .py(px(8.0)) + .gap(px(6.0)) + .overflow_hidden() + .bg(rgb(t.bg_secondary)) + .border_b_1() + .border_color(rgb(t.border)) + // Every reason, when the bar could not fit them all. + .when(symbol.reasons.len() > shown_chips, |d| { + d.child(detail_row( + REASONS_TITLE, + h_flex() + .flex_wrap() + .gap(px(4.0)) + .children(symbol.reasons.iter().map(|reason| chip(reason, t, cx))) + .into_any_element(), + t, + cx, + )) + }) + .child(detail_row( + LINES_TITLE, + plain( + text::line_span(&symbol.old_hunks, &symbol.new_hunks), + t.text_secondary, + cx, + ), + t, + cx, + )) + .when_some(symbol.signature.as_ref(), |d, (old, new)| { + d.child(detail_row( + SIGNATURE_TITLE, + signature_block(old, new, t, cx), + t, + cx, + )) + }) + .when(!symbol.calls.is_empty(), |d| { + d.child(detail_row( + CALLS_TITLE, + calls_block(&symbol.calls, t, cx), + t, + cx, + )) + }) + .when(complex, |d| { + d.children( + metrics_text(symbol).map(|line| { + detail_row(COMPLEXITY_TITLE, plain(line, t.text_secondary, cx), t, cx) + }), + ) + }) + .into_any_element() +} + +/// `label content` — the label column keeps every row aligned. +fn detail_row(title: &'static str, content: AnyElement, t: &ThemeColors, cx: &App) -> AnyElement { + h_flex() + .items_start() + .gap(px(12.0)) + .min_w_0() + .child( + div() + .flex_none() + .w(LABEL_WIDTH) + .pt(px(1.0)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(title), + ) + .child(div().flex_1().min_w_0().child(content)) + .into_any_element() +} + +fn plain(text: String, color: u32, cx: &App) -> AnyElement { + div() + .min_w_0() + .text_size(ui_text_ms(cx)) + .text_color(rgb(color)) + .child(text) + .into_any_element() +} + +fn signature_block(old: &str, new: &str, t: &ThemeColors, cx: &App) -> AnyElement { + let segments = token_diff(old, new); + v_flex() + .min_w_0() + .gap(px(2.0)) + .overflow_hidden() + .child(signature_line( + "\u{2212}", + t.diff_removed_fg, + &segments, + SegmentKind::Removed, + t, + cx, + )) + .child(signature_line( + "+", + t.diff_added_fg, + &segments, + SegmentKind::Added, + t, + cx, + )) + .into_any_element() +} + +fn signature_line( + marker: &'static str, + tone: u32, + segments: &[Segment], + changed: SegmentKind, + t: &ThemeColors, + cx: &App, +) -> AnyElement { + h_flex() + .min_w_0() + .gap(px(8.0)) + .items_start() + .overflow_hidden() + .font_family("monospace") + .text_size(ui_text_ms(cx)) + .child( + div() + .flex_none() + .w(px(8.0)) + .text_color(rgb(tone)) + .child(marker), + ) + .child( + h_flex() + .min_w_0() + .flex_wrap() + .text_color(rgb(t.text_secondary)) + .children( + segments + .iter() + .filter(|segment| segment.on_side(changed)) + .map(|segment| { + div() + .flex_none() + .when(segment.kind == changed, |d| { + d.bg(tint(tone, WORD_BG_ALPHA)).text_color(rgb(tone)) + }) + .child(segment.text.clone()) + .into_any_element() + }), + ), + ) + .into_any_element() +} + +fn calls_block(calls: &[CallRow], t: &ThemeColors, cx: &App) -> AnyElement { + let rows = text::call_lines(calls, MAX_CALL_ROWS); + v_flex() + .min_w_0() + .gap(px(2.0)) + .overflow_hidden() + .children(rows.shown.iter().map(|row| call_line(row, t, cx))) + .when_some(rows.hidden_note(), |d, note| { + d.child( + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(note), + ) + }) + .child( + div() + .pt(px(2.0)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(CALLS_CAVEAT), + ) + .into_any_element() +} + +/// `− parse(x) in loop` — marker, one-line call text, context. +fn call_line(row: &text::CallLine, t: &ThemeColors, cx: &App) -> AnyElement { + let tone = match row.change { + CallChangeKind::Added => t.diff_added_fg, + CallChangeKind::Removed => t.diff_removed_fg, + CallChangeKind::Modified => t.warning, + }; + h_flex() + .min_w_0() + .gap(px(8.0)) + .items_center() + .child( + div() + .flex_none() + .w(px(8.0)) + .font_family("monospace") + .text_size(ui_text_ms(cx)) + .text_color(rgb(tone)) + .child(text::call_marker(row.change)), + ) + .child( + div() + .min_w_0() + .truncate() + .font_family("monospace") + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_secondary)) + .child(row.text_with_count()), + ) + .when_some(row.context.clone(), |d, context| { + d.child( + div() + .flex_none() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(context), + ) + }) + .into_any_element() +} + +/// What made the symbol complex; only shown when complexity is a reason — §9. +fn metrics_text(symbol: &SymbolEntry) -> Option { + let mut parts = Vec::new(); + if let Some(depth) = symbol.metrics.depth { + parts.push(words::nesting_label(depth)); + } + if let Some(params) = symbol.metrics.params { + parts.push(words::params_label(params)); + } + if let Some(lines) = symbol.metrics.lines { + parts.push(words::lines_label(lines)); + } + if let Some(members) = symbol.metrics.members { + parts.push(words::members_label(members)); + } + (!parts.is_empty()).then(|| parts.join(text::DOT)) +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/file_view/header.rs b/crates/okena-views-git/src/diff_viewer/review_ui/file_view/header.rs new file mode 100644 index 000000000..bc9c40f53 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/file_view/header.rs @@ -0,0 +1,264 @@ +//! The 40 px file header — spec §9: what the open file is, how big it is, why +//! it is ranked where it is, and where it sits in the queue. + +use super::super::super::DiffViewer; +use super::super::super::review::ReviewFileKey; +use super::super::labels; +use super::super::labels::reasons as words; +use super::super::model::FileEntry; +use super::text; +use super::{chip, churn, word}; +use gpui::prelude::*; +use gpui::*; +use gpui_component::h_flex; +use gpui_component::tooltip::Tooltip; +use gpui_component::v_flex; +use okena_core::theme::ThemeColors; +use okena_ui::tokens::{ui_text_md, ui_text_ms, ui_text_sm}; + +/// The header states the file's strongest reasons; the navigator has the rest. +const HEADER_CHIPS: usize = 3; +const ROW_HEIGHT: Pixels = px(40.0); +const SUMMARY_HEIGHT: Pixels = px(22.0); +const OUTLINE_LINK: &str = "outline"; +const PREVIOUS: &str = "\u{2039}"; +const NEXT: &str = "\u{203A}"; +const NO_FILE: &str = "No file selected"; + +pub(super) fn render( + view: &DiffViewer, + t: &ThemeColors, + cx: &mut Context, +) -> AnyElement { + let Some(entry) = view.review_open_entry() else { + return placeholder(view.smart_review.selected_file.as_ref(), t, cx); + }; + let narrow = view.review_content_is_narrow(); + let visible = view.review_visible_attention(); + let summary = view + .review_ui + .model + .as_ref() + .and_then(|model| text::header_summary(model)); + let queue = + view.review_ui.model.as_ref().and_then(|model| { + text::queue_label(&visible, model, view.review_ui.queue_target.as_ref()) + }); + let has_outline = view.review_open_outline().is_some(); + + let row = h_flex() + .h(ROW_HEIGHT) + .px(px(16.0)) + .gap(px(8.0)) + .flex_none() + .items_center() + .border_b_1() + .border_color(rgb(t.border)) + .child(path_element(entry, t, cx)) + .child(role_badge(entry, narrow, t, cx)) + .child(word(labels::status_label(entry.status), t.text_muted, cx)) + .children(churn(entry.lines_added, entry.lines_deleted, t, cx)) + .children( + entry + .reasons + .iter() + .take(HEADER_CHIPS) + .map(|reason| chip(reason, t, cx)), + ) + .child(div().flex_1()) + .when(!narrow, |d| { + d.child(word(text::analysis_label(entry), t.text_muted, cx)) + }) + .when(has_outline, |d| d.child(outline_link(t, cx))) + .when_some(queue, |d, queue| d.child(queue_group(queue, t, cx))); + + match summary { + Some(summary) => v_flex() + .flex_none() + .child(summary_line(summary, t, cx)) + .child(row) + .into_any_element(), + None => row.into_any_element(), + } +} + +/// `src/build/compile.ts`, or `old → new · moved 98 %` for a rename. +fn path_element(entry: &FileEntry, t: &ThemeColors, cx: &App) -> AnyElement { + let renamed = match (entry.old_path.as_deref(), entry.new_path.as_deref()) { + (Some(old), Some(new)) if old != new => Some((old, new)), + _ => None, + }; + let mut row = h_flex() + .min_w_0() + .gap(px(6.0)) + .items_center() + .overflow_hidden(); + match renamed { + Some((old, new)) => { + row = row + .child(path_text(old, t, cx)) + .child(word(text::ARROW, t.text_muted, cx)) + .child(path_text(new, t, cx)); + if let Some(similarity) = entry.similarity { + let moved = format!("\u{00B7} {}", words::moved_label(similarity)); + row = row.child(word(moved, t.text_muted, cx)); + } + } + None => row = row.child(path_text(&entry.display_path, t, cx)), + } + row.into_any_element() +} + +fn path_text(path: &str, t: &ThemeColors, cx: &App) -> AnyElement { + let (directory, base) = text::split_path(path); + h_flex() + .min_w_0() + .overflow_hidden() + .text_size(ui_text_md(cx)) + .when(!directory.is_empty(), |d| { + d.child( + div() + .flex_none() + .text_color(rgb(t.text_muted)) + .child(directory.to_string()), + ) + }) + .child( + div() + .min_w_0() + .truncate() + .text_color(rgb(t.text_primary)) + .child(base.to_string()), + ) + .into_any_element() +} + +/// The role, with the rule that classified it on hover — spec §9. +fn role_badge(entry: &FileEntry, narrow: bool, t: &ThemeColors, cx: &App) -> AnyElement { + let label = if narrow { + labels::role_short(entry.role) + } else { + labels::role_label(entry.role) + }; + // Narrow columns drop the language line, so the badge carries it instead. + let tooltip = if narrow { + format!( + "{}{}{}", + labels::rule_sentence(&entry.rule_id), + text::DOT, + text::analysis_label(entry) + ) + } else { + labels::rule_sentence(&entry.rule_id) + }; + div() + .id("review-file-role") + .flex_none() + .px(px(5.0)) + .rounded(px(3.0)) + .bg(rgb(t.bg_secondary)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_secondary)) + .child(label) + .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) + .into_any_element() +} + +fn outline_link(t: &ThemeColors, cx: &mut Context) -> AnyElement { + div() + .id("review-file-outline") + .flex_none() + .cursor_pointer() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.term_blue)) + .hover(|style| style.text_color(rgb(t.text_primary))) + .on_click(cx.listener(|this, _, _window, cx| this.review_toggle_outline(cx))) + .child(OUTLINE_LINK) + .into_any_element() +} + +/// `3 of 236` with the two steps through the Attention order. +fn queue_group(label: String, t: &ThemeColors, cx: &mut Context) -> AnyElement { + h_flex() + .flex_none() + .gap(px(4.0)) + .items_center() + .child(word(label, t.text_muted, cx)) + .child(step_button("review-queue-prev", PREVIOUS, -1, t, cx)) + .child(step_button("review-queue-next", NEXT, 1, t, cx)) + .into_any_element() +} + +fn step_button( + id: &'static str, + glyph: &'static str, + delta: i32, + t: &ThemeColors, + cx: &mut Context, +) -> AnyElement { + div() + .id(id) + .flex_none() + .w(px(18.0)) + .h(px(18.0)) + .flex() + .items_center() + .justify_center() + .rounded(px(3.0)) + .bg(rgb(t.bg_secondary)) + .border_1() + .border_color(rgb(t.border)) + .cursor_pointer() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_secondary)) + .hover(|style| style.bg(rgb(t.bg_hover))) + .on_click(cx.listener(move |this, _, _window, cx| this.review_step_queue(delta, cx))) + .child(glyph) + .into_any_element() +} + +/// How tall the header is, so overlays anchored under it know where it ends. +pub(super) fn height(view: &DiffViewer) -> Pixels { + let summary = view + .review_ui + .model + .as_ref() + .is_some_and(|model| text::header_summary(model).is_some()); + if summary && view.review_open_entry().is_some() { + ROW_HEIGHT + SUMMARY_HEIGHT + } else { + ROW_HEIGHT + } +} + +/// The line a small comparison gets instead of the Overview — spec §12. +fn summary_line(summary: String, t: &ThemeColors, cx: &App) -> AnyElement { + div() + .flex_none() + .h(SUMMARY_HEIGHT) + .px(px(16.0)) + .flex() + .items_center() + .border_b_1() + .border_color(rgb(t.border)) + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .child(summary) + .into_any_element() +} + +fn placeholder(key: Option<&ReviewFileKey>, t: &ThemeColors, cx: &App) -> AnyElement { + let path = key.map_or_else(|| NO_FILE.to_string(), ReviewFileKey::display); + div() + .h(ROW_HEIGHT) + .px(px(16.0)) + .flex_none() + .flex() + .items_center() + .border_b_1() + .border_color(rgb(t.border)) + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_secondary)) + .child(path) + .into_any_element() +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/file_view/mod.rs b/crates/okena-views-git/src/diff_viewer/review_ui/file_view/mod.rs new file mode 100644 index 000000000..a9c0718b4 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/file_view/mod.rs @@ -0,0 +1,226 @@ +//! File view: header, symbol bar, details, outline — spec §9. + +mod bar; +mod header; +mod outline; +mod structure; +mod text; +mod token_diff; + +use super::super::DiffViewer; +use super::super::line_render::{WORD_BG_ALPHA, rgba as tint}; +use super::super::types::DiffViewMode; +use super::labels::format_signed; +use super::model::{FileEntry, Reason, ReasonKind}; +use gpui::prelude::*; +use gpui::*; +use okena_core::theme::ThemeColors; +use okena_review::{OutlineFact, StructuredFile}; +use okena_ui::tokens::{ui_text_ms, ui_text_sm}; + +/// Under this width the header hides the language line and the symbol bar keeps +/// two chips — spec §12. +const NARROW_CONTENT: f32 = 1_000.0; + +impl DiffViewer { + pub(crate) fn render_file_header(&self, t: &ThemeColors, cx: &mut Context) -> AnyElement { + header::render(self, t, cx) + } + + pub(crate) fn render_symbol_bar( + &mut self, + t: &ThemeColors, + cx: &mut Context, + ) -> Option { + bar::render(self, t, cx) + } + + pub(crate) fn render_outline_popover( + &self, + t: &ThemeColors, + cx: &mut Context, + ) -> Option { + outline::render(self, t, cx) + } + + /// The open file's entry, once the model knows about it. + pub(super) fn review_open_entry(&self) -> Option<&FileEntry> { + let model = self.review_ui.model.as_ref()?; + let key = self.smart_review.selected_file.as_ref()?; + model + .file_index(key) + .and_then(|index| model.files.get(index)) + } + + /// The open file as structure analysis saw it. + pub(super) fn review_open_structured_file(&self) -> Option<&StructuredFile> { + let index = self.review_open_entry()?.structure_index?; + self.smart_review.structure.ready()?.files().get(index) + } + + /// Base and head outlines of the open file; `None` when both are empty. + pub(super) fn review_open_outline(&self) -> Option<(&[OutlineFact], &[OutlineFact])> { + let file = self.review_open_structured_file()?; + let outlines = (file.old_outline(), file.new_outline()); + (!outlines.0.is_empty() || !outlines.1.is_empty()).then_some(outlines) + } + + /// The changed symbol the bar names: the selection while it still holds, + /// else the one the viewport is looking at — spec §9. + pub(super) fn review_current_symbol_index(&self) -> Option { + let entry = self.review_open_entry()?; + if entry.symbols.is_empty() { + return None; + } + let selected = self + .review_ui + .selected_symbol + .as_ref() + .filter(|symbol| symbol.file == entry.key) + .and_then(|symbol| { + entry + .symbols + .iter() + .position(|candidate| candidate.change_index == symbol.change_index) + }); + let viewport = self.review_viewport(); + structure::followed_symbol(&entry.symbols, selected, &viewport) + } + + /// The rows the diff list shows right now, as base/head lines. + fn review_viewport(&self) -> structure::Viewport { + let top = self.review_viewport_top(); + let bottom = self.review_viewport_bottom(top); + structure::Viewport { + top: self.review_row_lines(top), + bottom: bottom.map(|row| self.review_row_lines(row)), + } + } + + fn review_row_lines(&self, row: usize) -> (Option, Option) { + let items = self + .current_file + .as_ref() + .map_or(&[][..], |file| file.items.as_slice()); + structure::top_row_lines( + items, + &self.side_by_side_lines, + self.effective_view_mode(), + row, + ) + } + + /// Index of the first row the diff list shows. + fn review_viewport_top(&self) -> usize { + let item_count = self.review_diff_item_count(); + let state = self.scroll_handle.0.borrow(); + // A pending scroll is where the list is about to be, which is what the + // bar should already name. + if let Some(deferred) = state.deferred_scroll_to_item { + return deferred.item_index.min(item_count.saturating_sub(1)); + } + let Some(size) = state.last_item_size else { + return 0; + }; + structure::top_item_index( + -f32::from(state.base_handle.offset().y), + f32::from(size.contents.height), + item_count, + ) + } + + /// Index of the last row the diff list shows; `None` before the list has + /// been laid out (or while a scroll is pending and the top is a guess). + fn review_viewport_bottom(&self, top: usize) -> Option { + let item_count = self.review_diff_item_count(); + let state = self.scroll_handle.0.borrow(); + if state.deferred_scroll_to_item.is_some() { + return None; + } + let size = state.last_item_size?; + let visible = structure::visible_rows( + f32::from(state.base_handle.bounds().size.height), + f32::from(size.contents.height), + item_count, + ); + Some( + top.saturating_add(visible.saturating_sub(1)) + .min(item_count.checked_sub(1)?), + ) + } + + /// How many rows the diff list renders in the mode currently on screen. + fn review_diff_item_count(&self) -> usize { + match self.effective_view_mode() { + DiffViewMode::Unified => self + .current_file + .as_ref() + .map_or(0, |file| file.items.len()), + DiffViewMode::SideBySide => self.side_by_side_lines.len(), + } + } + + /// Spec §12: a narrow content column drops the labels that repeat elsewhere. + pub(super) fn review_content_is_narrow(&self) -> bool { + let width = self.review_ui.content_width; + width > 0.0 && width < NARROW_CONTENT + } +} + +/// Chip tint per reason kind — spec §6 wording, spec §7 chip style. +pub(super) fn reason_tone(kind: ReasonKind, t: &ThemeColors) -> u32 { + match kind { + ReasonKind::PublicRemoved | ReasonKind::Removed | ReasonKind::DeletedImpl => { + t.diff_removed_fg + } + ReasonKind::PublicSignature | ReasonKind::ExportedSignature => t.term_blue, + ReasonKind::Calls => t.term_cyan, + ReasonKind::New | ReasonKind::NewPublic => t.diff_added_fg, + ReasonKind::Moved => t.term_magenta, + ReasonKind::NoTestChanges | ReasonKind::Complex => t.warning, + ReasonKind::LargeChurn => t.term_yellow, + ReasonKind::Body + | ReasonKind::CiConfig + | ReasonKind::Lockfile + | ReasonKind::Submodule + | ReasonKind::Binary + | ReasonKind::NotAnalyzed => t.text_muted, + } +} + +/// A reason chip: small, rounded, tinted by kind. +pub(super) fn chip(reason: &Reason, t: &ThemeColors, cx: &App) -> AnyElement { + let tone = reason_tone(reason.kind, t); + div() + .flex_none() + .px(px(5.0)) + .rounded(px(3.0)) + .bg(tint(tone, WORD_BG_ALPHA)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(tone)) + .child(reason.label.clone()) + .into_any_element() +} + +/// `+388` and `−41`; the side that changed nothing is left out — spec §2. +pub(super) fn churn(added: u64, deleted: u64, t: &ThemeColors, cx: &App) -> Vec { + let (plus, minus) = format_signed(added, deleted); + let mut out = Vec::new(); + if added > 0 { + out.push(word(plus, t.diff_added_fg, cx)); + } + if deleted > 0 { + out.push(word(minus, t.diff_removed_fg, cx)); + } + out +} + +/// One piece of metadata text on a header or bar row. +pub(super) fn word(text: impl Into, color: u32, cx: &App) -> AnyElement { + div() + .flex_none() + .text_size(ui_text_ms(cx)) + .text_color(rgb(color)) + .child(text.into()) + .into_any_element() +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/file_view/outline.rs b/crates/okena-views-git/src/diff_viewer/review_ui/file_view/outline.rs new file mode 100644 index 000000000..d9a9ce7ef --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/file_view/outline.rs @@ -0,0 +1,224 @@ +//! The outline popover: base and head outlines side by side — spec §9. + +use super::super::super::DiffViewer; +use super::super::super::review::ReviewFileKey; +use super::super::labels; +use super::super::state::{ContentView, SymbolRef}; +use super::header; +use super::structure::{OutlineRow, outline_rows}; +use gpui::prelude::*; +use gpui::*; +use gpui_component::h_flex; +use gpui_component::v_flex; +use okena_core::theme::ThemeColors; +use okena_review::StructuredFile; +use okena_syntax::SymbolKey; +use okena_ui::popover::popover_panel; +use okena_ui::tokens::{ui_text_ms, ui_text_sm}; +use std::collections::HashMap; + +const PANEL_WIDTH: Pixels = px(520.0); +/// Gap between the file header and the panel hanging off the `outline` link. +const PANEL_GAP: Pixels = px(6.0); +const PANEL_HEIGHT: Pixels = px(520.0); +/// One nesting step, in pixels; deeper nesting stops moving right. +const INDENT: f32 = 12.0; +const MAX_INDENT_DEPTH: usize = 8; +const BASE_TITLE: &str = "Base"; +const HEAD_TITLE: &str = "Head"; +const CHANGED_MARK: &str = "\u{25CF}"; + +pub(super) fn render( + view: &DiffViewer, + t: &ThemeColors, + cx: &mut Context, +) -> Option { + // The link that opens it lives on the file header, so the Overview has none. + if !view.review_ui.outline_open || view.review_ui.content != ContentView::File { + return None; + } + let entry = view.review_open_entry()?; + let (old, new) = view.review_open_outline()?; + let changed = changed_symbols(view.review_open_structured_file()?); + let base = outline_rows(old, &changed); + let head = outline_rows(new, &changed); + let key = entry.key.clone(); + + let panel = popover_panel("review-outline-popover", t) + .absolute() + .top(header::height(view) + PANEL_GAP) + .right(px(16.0)) + .w(PANEL_WIDTH) + .max_h(PANEL_HEIGHT) + .flex() + // The panel only dismisses on a click outside it, either button. + .on_mouse_down(MouseButton::Right, |_, _, cx| cx.stop_propagation()) + .child( + // Both outlines scroll together, so a symbol stays level with its + // counterpart on the other side. + div() + .id("review-outline-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .child( + h_flex() + .w_full() + .gap(px(12.0)) + .items_start() + .child(column("review-outline-base", BASE_TITLE, base, None, t, cx)) + .child(column( + "review-outline-head", + HEAD_TITLE, + head, + Some(key), + t, + cx, + )), + ), + ); + + // Occludes, so the dismissing click never also lands on the diff underneath. + Some( + div() + .id("review-outline-backdrop") + .occlude() + .absolute() + .inset_0() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| this.review_toggle_outline(cx)), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, _, _window, cx| this.review_toggle_outline(cx)), + ) + .child(panel) + .into_any_element(), + ) +} + +/// Every changed symbol of the open file, keyed the way the outline names it. +/// A removed symbol only exists on the base side, an added one only on the head. +fn changed_symbols(file: &StructuredFile) -> HashMap { + file.symbol_changes() + .iter() + .enumerate() + .filter_map(|(index, change)| { + let fact = change.new_fact().or_else(|| change.old())?; + Some((fact.key().clone(), index)) + }) + .collect() +} + +/// One snapshot's outline. `file` is set only for the head column, whose changed +/// symbols open in the diff. +fn column( + id: &'static str, + title: &'static str, + rows: Vec, + file: Option, + t: &ThemeColors, + cx: &mut Context, +) -> AnyElement { + v_flex() + .flex_1() + .min_w_0() + .gap(px(1.0)) + .child( + div() + .pb(px(4.0)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(title), + ) + .children( + rows.into_iter() + .enumerate() + .map(|(index, row)| outline_row(id, index, &row, file.clone(), t, cx)), + ) + .into_any_element() +} + +fn outline_row( + id: &'static str, + index: usize, + row: &OutlineRow, + file: Option, + t: &ThemeColors, + cx: &mut Context, +) -> AnyElement { + let changed = row.change_index.is_some(); + let name_color = if changed { + t.text_primary + } else { + t.text_secondary + }; + let content = h_flex() + .gap(px(6.0)) + .items_center() + .pl(indent(row.depth)) + .pr(px(4.0)) + .text_size(ui_text_ms(cx)) + .child( + div() + .flex_none() + .text_color(rgb(t.text_muted)) + .child(labels::glyph(row.glyph)), + ) + .child( + div() + .min_w_0() + .truncate() + .text_color(rgb(name_color)) + .child(row.name.clone()), + ) + .when(changed, |d| { + d.child( + div() + .flex_none() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.warning)) + .child(CHANGED_MARK), + ) + }); + + match file.zip(row.change_index) { + Some((file, change_index)) => content + .id((id, index)) + .cursor_pointer() + .rounded(px(3.0)) + .hover(|style| style.bg(rgb(t.bg_hover))) + .on_click(cx.listener(move |this, _, _window, cx| { + this.review_open_symbol( + SymbolRef { + file: file.clone(), + change_index, + }, + cx, + ); + this.review_toggle_outline(cx); + })) + .into_any_element(), + None => content.into_any_element(), + } +} + +fn indent(depth: usize) -> Pixels { + let steps = u16::try_from(depth.min(MAX_INDENT_DEPTH)).unwrap_or(0); + px(f32::from(steps) * INDENT) +} + +#[cfg(test)] +mod tests { + use super::{INDENT, MAX_INDENT_DEPTH, indent}; + use gpui::px; + + #[test] + fn indentation_grows_per_level_and_stops_at_the_cap() { + assert_eq!(indent(0), px(0.0)); + assert_eq!(indent(2), px(2.0 * INDENT)); + assert_eq!(indent(MAX_INDENT_DEPTH), px(8.0 * INDENT)); + assert_eq!(indent(MAX_INDENT_DEPTH + 5), indent(MAX_INDENT_DEPTH)); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/file_view/structure.rs b/crates/okena-views-git/src/diff_viewer/review_ui/file_view/structure.rs new file mode 100644 index 000000000..1b6acd996 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/file_view/structure.rs @@ -0,0 +1,707 @@ +//! Where the diff viewport is, which changed symbol it shows, and the outline +//! as flat rows. Pure; no GPUI element building. + +use super::super::super::types::{DiffViewMode, DisplayItem, SideBySideLine}; +use super::super::labels::symbol_glyph; +use super::super::model::{KindGlyph, SymbolEntry}; +use okena_review::OutlineFact; +use okena_syntax::SymbolKey; +use std::collections::HashMap; + +/// Hunk headers carry no line numbers, so the top row may need a look ahead. +const LOOKAHEAD_ROWS: usize = 16; + +/// Index of the topmost visible row of a `uniform_list`. +/// +/// The list never fills `ScrollHandle::child_bounds`, so `logical_scroll_top` +/// stays at zero; the rows are uniform, so the index is `offset / row height`. +/// A binary search does that division without a float-to-integer cast. +pub(super) fn top_item_index(scroll_y: f32, contents_height: f32, item_count: usize) -> usize { + if item_count == 0 + || !contents_height.is_finite() + || contents_height <= 0.0 + || !scroll_y.is_finite() + || scroll_y <= 0.0 + { + return 0; + } + let contents = f64::from(contents_height); + // row height = contents / count, so `row * contents <= offset * count`. + let limit = f64::from(scroll_y) * as_f64(item_count); + let mut low = 0; + let mut high = item_count - 1; + while low < high { + let middle = low + (high - low).div_ceil(2); + if as_f64(middle) * contents <= limit { + low = middle; + } else { + high = middle - 1; + } + } + low +} + +/// How many rows fit in a list of `list_height`; rows are `contents / count` +/// tall. Zero when nothing is measured yet. +pub(super) fn visible_rows(list_height: f32, contents_height: f32, item_count: usize) -> usize { + if item_count == 0 + || !contents_height.is_finite() + || contents_height <= 0.0 + || !list_height.is_finite() + || list_height <= 0.0 + { + return 0; + } + let row_height = f64::from(contents_height) / as_f64(item_count); + if row_height <= 0.0 { + return 0; + } + let rows = (f64::from(list_height) / row_height).ceil(); + // Row counts are small; a huge quotient just means "everything". + if rows >= as_f64(item_count) { + item_count + } else { + rows as usize + } +} + +fn as_f64(value: usize) -> f64 { + f64::from(u32::try_from(value).unwrap_or(u32::MAX)) +} + +/// Base and head line of the diff row at `top`, in the mode the pane renders. +pub(super) fn top_row_lines( + items: &[DisplayItem], + side_by_side_lines: &[SideBySideLine], + mode: DiffViewMode, + top: usize, +) -> (Option, Option) { + let count = match mode { + DiffViewMode::Unified => items.len(), + DiffViewMode::SideBySide => side_by_side_lines.len(), + }; + let last = count.min(top.saturating_add(LOOKAHEAD_ROWS)); + for row in top..last { + let lines = match mode { + DiffViewMode::Unified => items.get(row).map(unified_lines), + DiffViewMode::SideBySide => side_by_side_lines.get(row).map(split_lines), + }; + if let Some((old, new)) = lines + && (old.is_some() || new.is_some()) + { + return (old, new); + } + } + (None, None) +} + +fn unified_lines(item: &DisplayItem) -> (Option, Option) { + match item { + DisplayItem::Line(line) => ( + line_number(line.old_line_num), + line_number(line.new_line_num), + ), + DisplayItem::Expander(expander) => ( + line_number(Some(expander.old_range.0)), + line_number(Some(expander.new_range.0)), + ), + } +} + +fn split_lines(line: &SideBySideLine) -> (Option, Option) { + if let Some(expander) = line.expander.as_ref() { + return ( + line_number(Some(expander.old_range.0)), + line_number(Some(expander.new_range.0)), + ); + } + ( + line_number(line.left.as_ref().map(|side| side.line_num)), + line_number(line.right.as_ref().map(|side| side.line_num)), + ) +} + +fn line_number(value: Option) -> Option { + value.and_then(|line| u32::try_from(line).ok()) +} + +/// The changed symbol the viewport is looking at — spec §9: the symbol around +/// the top row, else the next one below it, else the last one above it. +pub(super) fn viewport_symbol( + entries: &[SymbolEntry], + top_row_old: Option, + top_row_new: Option, +) -> Option { + enclosing(entries, top_row_old, top_row_new) + .or_else(|| nearest_following(entries, top_row_old, top_row_new)) + .or_else(|| nearest_preceding(entries, top_row_old, top_row_new)) +} + +/// The diff rows on screen, as `(base, head)` lines. `bottom` is `None` until +/// the list has been measured, in which case only the top row is known. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct Viewport { + pub top: (Option, Option), + pub bottom: Option<(Option, Option)>, +} + +/// What the symbol bar names. An explicit selection holds while any of it is +/// on screen, or the viewport top sits just above it; once the view has moved +/// past it the bar follows the view again. +pub(super) fn followed_symbol( + entries: &[SymbolEntry], + selected: Option, + viewport: &Viewport, +) -> Option { + let (top_row_old, top_row_new) = viewport.top; + let Some(selected) = selected.filter(|index| *index < entries.len()) else { + return viewport_symbol(entries, top_row_old, top_row_new); + }; + if top_row_old.is_none() && top_row_new.is_none() { + return Some(selected); + } + let holds = entries + .get(selected) + .is_some_and(|entry| covers(entry, top_row_old, top_row_new) || on_screen(entry, viewport)) + || nearest_following(entries, top_row_old, top_row_new) == Some(selected); + if holds { + return Some(selected); + } + viewport_symbol(entries, top_row_old, top_row_new).or(Some(selected)) +} + +/// Any hunk of the symbol intersects the rows between the viewport's top and +/// bottom, on the side those rows are measured on. +fn on_screen(entry: &SymbolEntry, viewport: &Viewport) -> bool { + let Some((bottom_old, bottom_new)) = viewport.bottom else { + return false; + }; + let (top_old, top_new) = viewport.top; + let intersects = |top: Option, bottom: Option, hunks: &[(u32, u32)]| { + let (Some(top), Some(bottom)) = (top, bottom) else { + return false; + }; + hunks + .iter() + .any(|(start, end)| *start <= bottom && *end >= top) + }; + intersects(top_old, bottom_old, &entry.old_hunks) + || intersects(top_new, bottom_new, &entry.new_hunks) +} + +/// The viewport row of one snapshot with the symbol's hunks on that same side. +type SideRows<'a> = (Option, &'a [(u32, u32)]); + +/// One symbol's hunks paired with the viewport row of the same snapshot, so a +/// base line number is never measured against a head one. +fn sides( + entry: &SymbolEntry, + top_row_old: Option, + top_row_new: Option, +) -> [SideRows<'_>; 2] { + [ + (top_row_old, entry.old_hunks.as_slice()), + (top_row_new, entry.new_hunks.as_slice()), + ] +} + +fn covers(entry: &SymbolEntry, top_row_old: Option, top_row_new: Option) -> bool { + sides(entry, top_row_old, top_row_new) + .into_iter() + .any(|(row, hunks)| { + row.is_some_and(|row| { + hunks + .iter() + .any(|(start, end)| *start <= row && row <= *end) + }) + }) +} + +/// Nested symbols both cover the row; the deeper qualified path is the specific +/// one, so it wins — spec §9. +fn enclosing( + entries: &[SymbolEntry], + top_row_old: Option, + top_row_new: Option, +) -> Option { + let mut best: Option<(usize, usize)> = None; + for (index, entry) in entries.iter().enumerate() { + if !covers(entry, top_row_old, top_row_new) { + continue; + } + let depth = qualified_depth(entry); + if best.is_none_or(|(deepest, _)| depth > deepest) { + best = Some((depth, index)); + } + } + best.map(|(_, index)| index) +} + +fn qualified_depth(entry: &SymbolEntry) -> usize { + entry.qualified.matches("::").count() +} + +fn nearest_following( + entries: &[SymbolEntry], + top_row_old: Option, + top_row_new: Option, +) -> Option { + nearest(entries, top_row_old, top_row_new, |row, hunks| { + hunks + .iter() + .filter(|(start, _)| *start > row) + .map(|(start, _)| start.saturating_sub(row)) + .min() + }) +} + +fn nearest_preceding( + entries: &[SymbolEntry], + top_row_old: Option, + top_row_new: Option, +) -> Option { + nearest(entries, top_row_old, top_row_new, |row, hunks| { + hunks + .iter() + .filter(|(_, end)| *end < row) + .map(|(_, end)| row.saturating_sub(*end)) + .min() + }) +} + +/// The smallest distance wins; distances are line counts within one snapshot, +/// so the two sides stay comparable. A tie keeps the earlier symbol. +fn nearest( + entries: &[SymbolEntry], + top_row_old: Option, + top_row_new: Option, + distance: impl Fn(u32, &[(u32, u32)]) -> Option, +) -> Option { + let mut best: Option<(u32, usize)> = None; + for (index, entry) in entries.iter().enumerate() { + for (row, hunks) in sides(entry, top_row_old, top_row_new) { + let Some(row) = row else { + continue; + }; + let Some(found) = distance(row, hunks) else { + continue; + }; + if best.is_none_or(|(closest, _)| found < closest) { + best = Some((found, index)); + } + } + } + best.map(|(_, index)| index) +} + +/// One outline entry, already flattened to a row with its nesting depth. +pub(super) struct OutlineRow { + pub depth: usize, + pub glyph: KindGlyph, + pub name: String, + /// Index into the file's `symbol_changes` when this symbol changed. + pub change_index: Option, +} + +/// `changed` is keyed by the full [`SymbolKey`], so a type and a function of the +/// same qualified name never mark each other — spec §9. +pub(super) fn outline_rows( + outline: &[OutlineFact], + changed: &HashMap, +) -> Vec { + let mut rows = Vec::new(); + collect(outline, 0, changed, &mut rows); + rows +} + +fn collect( + facts: &[OutlineFact], + depth: usize, + changed: &HashMap, + rows: &mut Vec, +) { + for fact in facts { + let key = fact.symbol().key(); + rows.push(OutlineRow { + depth, + glyph: symbol_glyph(&key.kind()), + name: key.name().to_string(), + change_index: changed.get(key).copied(), + }); + collect(fact.children(), depth.saturating_add(1), changed, rows); + } +} + +#[cfg(test)] +mod tests { + use super::super::super::fixtures; + use super::super::super::model::{KindGlyph, SymbolEntry}; + use super::{ + Viewport, followed_symbol, outline_rows, top_item_index, top_row_lines, viewport_symbol, + visible_rows, + }; + use crate::diff_viewer::types::{ + DiffViewMode, DisplayItem, DisplayLine, ExpanderRow, SideBySideLine, SideContent, + }; + use okena_git::DiffLineType; + use okena_review::{ComparisonSide, OutlineFact, SymbolReference}; + use okena_syntax::{SourceRange, SymbolKey, SymbolKind, SyntaxLanguage, SyntaxProvenance}; + use std::collections::HashMap; + use std::num::NonZeroU32; + + fn symbols() -> Vec { + fixtures::model() + .files + .iter() + .find(|entry| entry.display_path == "src/engine.rs") + .expect("the analyzed fixture file") + .symbols + .clone() + } + + fn named(entries: &[SymbolEntry], index: Option) -> Option<&str> { + index + .and_then(|index| entries.get(index)) + .map(|entry| entry.name.as_str()) + } + + fn position(entries: &[SymbolEntry], name: &str) -> usize { + entries + .iter() + .position(|entry| entry.name == name) + .expect("fixture symbol") + } + + fn key(path: &[&str], kind: SymbolKind, name: &str) -> SymbolKey { + SymbolKey::new( + path.iter().map(|part| (*part).to_string()).collect(), + kind, + name, + ) + .expect("symbol key") + } + + fn range(start: u32, end: u32) -> SourceRange { + SourceRange::new( + u64::from(start) * 100, + u64::from(end) * 100 + 99, + NonZeroU32::new(start).expect("one-based"), + NonZeroU32::new(end).expect("one-based"), + ) + .expect("source range") + } + + fn outline( + path: &[&str], + name: &str, + kind: SymbolKind, + lines: (u32, u32), + children: Vec, + ) -> OutlineFact { + let provenance = SyntaxProvenance::tree_sitter(SyntaxLanguage::Rust, "tree-sitter-rust") + .expect("provenance"); + OutlineFact::new( + provenance, + SymbolReference::new( + ComparisonSide::Head, + range(lines.0, lines.1), + key(path, kind, name), + ), + children, + ) + .expect("outline fact") + } + + fn line(old: Option, new: Option, line_type: DiffLineType) -> DisplayItem { + DisplayItem::Line(DisplayLine { + line_type, + old_line_num: old, + new_line_num: new, + spans: Vec::new(), + plain_text: String::new(), + }) + } + + fn side(line_num: usize) -> SideContent { + SideContent { + line_num, + line_type: DiffLineType::Context, + spans: Vec::new(), + plain_text: String::new(), + changed_ranges: Vec::new(), + } + } + + fn split(left: Option, right: Option, is_header: bool) -> SideBySideLine { + SideBySideLine { + left: left.map(side), + right: right.map(side), + is_header, + header_text: String::new(), + expander: None, + } + } + + #[test] + fn the_top_index_divides_the_scroll_offset_by_one_row() { + // 10 rows, 200 px of content, so each row is 20 px tall. + assert_eq!(top_item_index(0.0, 200.0, 10), 0); + assert_eq!(top_item_index(19.0, 200.0, 10), 0); + assert_eq!(top_item_index(20.0, 200.0, 10), 1); + assert_eq!(top_item_index(105.0, 200.0, 10), 5); + assert_eq!(top_item_index(10_000.0, 200.0, 10), 9); + } + + #[test] + fn an_unmeasured_or_impossible_list_stays_at_the_first_row() { + assert_eq!(top_item_index(50.0, 200.0, 0), 0); + assert_eq!(top_item_index(50.0, 0.0, 10), 0); + assert_eq!(top_item_index(-50.0, 200.0, 10), 0); + assert_eq!(top_item_index(f32::NAN, 200.0, 10), 0); + assert_eq!(top_item_index(50.0, f32::NAN, 10), 0); + assert_eq!(top_item_index(50.0, 200.0, 1), 0); + } + + #[test] + fn unified_rows_report_both_line_numbers_and_skip_hunk_headers() { + let items = [ + line(None, None, DiffLineType::Header), + line(Some(18), Some(18), DiffLineType::Context), + line(Some(19), None, DiffLineType::Removed), + DisplayItem::Expander(ExpanderRow { + old_range: (30, 40), + new_range: (31, 41), + }), + ]; + let mode = DiffViewMode::Unified; + assert_eq!( + top_row_lines(&items, &[], mode, 0), + (Some(18), Some(18)), + "the header has no numbers, so the next row answers" + ); + assert_eq!(top_row_lines(&items, &[], mode, 2), (Some(19), None)); + assert_eq!(top_row_lines(&items, &[], mode, 3), (Some(30), Some(31))); + assert_eq!(top_row_lines(&items, &[], mode, 9), (None, None)); + assert_eq!(top_row_lines(&[], &[], mode, 0), (None, None)); + } + + #[test] + fn split_rows_come_from_the_side_by_side_list_not_the_unified_items() { + let items = [line(Some(1), Some(1), DiffLineType::Context)]; + let rows = [ + split(None, None, true), + split(Some(18), Some(20), false), + split(None, Some(21), false), + SideBySideLine { + left: None, + right: None, + is_header: false, + header_text: String::new(), + expander: Some(ExpanderRow { + old_range: (50, 60), + new_range: (52, 62), + }), + }, + ]; + let mode = DiffViewMode::SideBySide; + assert_eq!(top_row_lines(&items, &rows, mode, 0), (Some(18), Some(20))); + assert_eq!(top_row_lines(&items, &rows, mode, 2), (None, Some(21))); + assert_eq!(top_row_lines(&items, &rows, mode, 3), (Some(50), Some(52))); + assert_eq!(top_row_lines(&items, &[], mode, 0), (None, None)); + } + + #[test] + fn the_symbol_under_the_top_row_wins_on_either_side() { + let entries = symbols(); + assert_eq!( + named(&entries, viewport_symbol(&entries, Some(22), Some(22))), + Some("run") + ); + assert_eq!( + named(&entries, viewport_symbol(&entries, Some(60), None)), + Some("legacy_run") + ); + assert_eq!( + named(&entries, viewport_symbol(&entries, None, Some(700))), + Some("orchestrate") + ); + } + + #[test] + fn a_row_between_symbols_falls_back_to_the_nearest_one_below() { + let entries = symbols(); + assert_eq!( + named(&entries, viewport_symbol(&entries, Some(100), Some(100))), + Some("dispatch") + ); + assert_eq!( + named(&entries, viewport_symbol(&entries, Some(1), Some(1))), + Some("run") + ); + } + + #[test] + fn a_row_past_the_last_symbol_falls_back_to_the_one_above_it() { + let entries = symbols(); + assert_eq!( + named( + &entries, + viewport_symbol(&entries, Some(9_000), Some(9_000)) + ), + Some("orchestrate"), + "scrolling past the end never jumps back to the first symbol" + ); + assert_eq!(viewport_symbol(&entries, None, None), None); + assert_eq!(viewport_symbol(&[], Some(1), Some(1)), None); + } + + #[test] + fn the_deepest_qualified_path_wins_when_two_symbols_cover_the_row() { + let mut entries = symbols(); + entries.truncate(2); + entries[0].qualified = "orchestrate".into(); + entries[0].old_hunks = vec![(10, 200)]; + entries[0].new_hunks = vec![(10, 200)]; + entries[1].qualified = "Engine::run".into(); + entries[1].old_hunks = vec![(10, 200)]; + entries[1].new_hunks = vec![(10, 200)]; + assert_eq!(viewport_symbol(&entries, Some(50), Some(50)), Some(1)); + + entries[1].qualified = "run".into(); + assert_eq!( + viewport_symbol(&entries, Some(50), Some(50)), + Some(0), + "equal depth keeps the earlier symbol" + ); + } + + #[test] + fn the_selection_holds_inside_and_just_above_it_then_lets_the_view_lead() { + let entries = symbols(); + let configure = position(&entries, "configure"); + let selected = Some(configure); + + let at = |row: u32| Viewport { + top: (Some(row), Some(row)), + bottom: None, + }; + + assert_eq!( + followed_symbol(&entries, selected, &at(75)), + selected, + "the top row sits inside the selected symbol" + ); + assert_eq!( + followed_symbol(&entries, selected, &at(72)), + selected, + "the top row is just above it, with nothing in between" + ); + assert_eq!( + named(&entries, followed_symbol(&entries, selected, &at(410))), + Some("normalize"), + "once the view moves on, the bar follows it" + ); + assert_eq!( + followed_symbol(&entries, selected, &Viewport::default()), + selected, + "an unmeasured viewport keeps the selection" + ); + assert_eq!( + named(&entries, followed_symbol(&entries, None, &at(22))), + Some("run"), + "without a selection the view alone decides" + ); + } + + #[test] + fn the_selection_holds_while_any_of_it_is_on_screen() { + let entries = symbols(); + let configure = position(&entries, "configure"); + let selected = Some(configure); + let (start, _) = entries[configure].new_hunks[0]; + // Centered on the symbol: the top row is inside the symbol above it, + // but the symbol itself is on screen, so the bar keeps naming it. + let centered = Viewport { + top: (Some(start - 30), Some(start - 30)), + bottom: Some((Some(start + 10), Some(start + 10))), + }; + assert_eq!(followed_symbol(&entries, selected, ¢ered), selected); + // Scrolled so it left the screen entirely: the view leads again. + let past = Viewport { + top: (Some(start - 60), Some(start - 60)), + bottom: Some((Some(start - 31), Some(start - 31))), + }; + assert_ne!(followed_symbol(&entries, selected, &past), selected); + } + + #[test] + fn visible_rows_come_from_the_list_height_over_the_row_height() { + assert_eq!(visible_rows(200.0, 2000.0, 100), 10); + assert_eq!(visible_rows(205.0, 2000.0, 100), 11); + assert_eq!(visible_rows(5000.0, 2000.0, 100), 100); + assert_eq!(visible_rows(200.0, 0.0, 100), 0); + assert_eq!(visible_rows(200.0, 2000.0, 0), 0); + } + + #[test] + fn the_outline_flattens_depth_first_and_marks_changed_symbols() { + let tree = vec![ + outline( + &[], + "Engine", + SymbolKind::Struct, + (10, 90), + vec![ + outline(&["Engine"], "run", SymbolKind::Method, (20, 40), Vec::new()), + outline( + &["Engine"], + "stop", + SymbolKind::Method, + (50, 60), + Vec::new(), + ), + ], + ), + outline( + &[], + "normalize", + SymbolKind::Function, + (100, 120), + Vec::new(), + ), + ]; + let changed = HashMap::from([(key(&["Engine"], SymbolKind::Method, "run"), 3)]); + + let rows = outline_rows(&tree, &changed); + let shape: Vec<(usize, &str, Option)> = rows + .iter() + .map(|row| (row.depth, row.name.as_str(), row.change_index)) + .collect(); + assert_eq!( + shape, + [ + (0, "Engine", None), + (1, "run", Some(3)), + (1, "stop", None), + (0, "normalize", None), + ] + ); + assert_eq!(rows[0].glyph, KindGlyph::Class); + assert_eq!(rows[1].glyph, KindGlyph::Method); + assert_eq!(rows[3].glyph, KindGlyph::Function); + assert!(outline_rows(&[], &changed).is_empty()); + } + + #[test] + fn a_same_named_symbol_of_another_kind_is_not_marked_changed() { + let tree = vec![outline( + &[], + "normalize", + SymbolKind::TypeAlias, + (10, 20), + Vec::new(), + )]; + let changed = HashMap::from([(key(&[], SymbolKind::Function, "normalize"), 0)]); + assert_eq!(outline_rows(&tree, &changed)[0].change_index, None); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/file_view/text.rs b/crates/okena-views-git/src/diff_viewer/review_ui/file_view/text.rs new file mode 100644 index 000000000..23f1d0126 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/file_view/text.rs @@ -0,0 +1,345 @@ +//! File-view wording and arithmetic — spec §9 and §12. Pure; no GPUI, and every +//! enum reaches the screen through `labels`, never through `{:?}`. + +use super::super::labels::{format_signed, language_from_path}; +use super::super::model::{AttentionTarget, FileAnalysis, FileEntry, PublicApiFact, ReviewModel}; + +pub(super) const DOT: &str = " \u{00B7} "; +pub(super) const ARROW: &str = "\u{2192}"; +pub(super) const AT_LEAST: &str = "\u{2265} "; +/// Stands in for the position when the queue target is filtered out of view. +const UNPLACED: &str = "\u{2014}"; +const BINARY: &str = "binary"; + +/// Directory (with its trailing slash) and basename; the header dims the first. +pub(super) fn split_path(path: &str) -> (&str, &str) { + match path.rfind('/') { + Some(index) => path.split_at(index + 1), + None => ("", path), + } +} + +/// `TypeScript · parsed`, `JavaScript · not analyzed`, `binary`. +pub(super) fn analysis_label(entry: &FileEntry) -> String { + if entry.binary { + return BINARY.to_string(); + } + let path = entry + .new_path + .as_deref() + .or(entry.old_path.as_deref()) + .unwrap_or_default(); + let language = entry + .analysis + .language() + .or_else(|| language_from_path(path)); + let state = match &entry.analysis { + FileAnalysis::Parsed { .. } => "parsed", + FileAnalysis::Partial { .. } => "partly analyzed", + FileAnalysis::Failed => "failed to parse", + FileAnalysis::NotInStructure + | FileAnalysis::Pending + | FileAnalysis::Unsupported + | FileAnalysis::Skipped => "not analyzed", + }; + match language { + Some(language) => format!("{language}{DOT}{state}"), + None => state.to_string(), + } +} + +/// The one-line summary a small comparison gets instead of the Overview — §12. +pub(super) fn header_summary(model: &ReviewModel) -> Option { + if !model.small_change { + return None; + } + let mut parts = vec![file_count(model.files.len())]; + let added: u64 = model.files.iter().map(|entry| entry.lines_added).sum(); + let deleted: u64 = model.files.iter().map(|entry| entry.lines_deleted).sum(); + if let Some(churn) = churn_words(added, deleted) { + parts.push(churn); + } + if let Some(clause) = public_api_clause(model.facts.public_api.as_ref()) { + parts.push(clause); + } + Some(parts.join(DOT)) +} + +/// `+40 −12`; a side that changed nothing is left out — spec §2. +pub(super) fn churn_words(added: u64, deleted: u64) -> Option { + let (plus, minus) = format_signed(added, deleted); + match (added > 0, deleted > 0) { + (true, true) => Some(format!("{plus} {minus}")), + (true, false) => Some(plus), + (false, true) => Some(minus), + (false, false) => None, + } +} + +/// The public-API half of the small-change summary; the strongest fact wins. +fn public_api_clause(fact: Option<&PublicApiFact>) -> Option { + let fact = fact?; + let bound = if fact.lower_bound { AT_LEAST } else { "" }; + if fact.signatures > 0 { + return Some(format!("{bound}{} changed", signatures(fact.signatures))); + } + if fact.removed > 0 { + return Some(format!("{bound}{} removed", public_symbols(fact.removed))); + } + if fact.added > 0 { + return Some(format!("{bound}{} added", public_symbols(fact.added))); + } + None +} + +fn file_count(files: usize) -> String { + if files == 1 { + "1 file".to_string() + } else { + format!("{files} files") + } +} + +fn signatures(count: u64) -> String { + if count == 1 { + "1 public signature".to_string() + } else { + format!("{count} public signatures") + } +} + +fn public_symbols(count: u64) -> String { + if count == 1 { + "1 public symbol".to_string() + } else { + format!("{count} public symbols") + } +} + +/// One-based position of the queue target in the visible Attention order. +pub(super) fn queue_position( + visible: &[usize], + model: &ReviewModel, + target: Option<&AttentionTarget>, +) -> Option<(usize, usize)> { + let index = model.attention_index(target?)?; + let row = visible.iter().position(|candidate| *candidate == index)?; + Some((row + 1, visible.len())) +} + +/// `3 of 236`, or `— of 236` when a filter hides the target. The steps stay +/// usable either way, so the label is only missing when there is nothing to step. +pub(super) fn queue_label( + visible: &[usize], + model: &ReviewModel, + target: Option<&AttentionTarget>, +) -> Option { + if visible.is_empty() { + return None; + } + Some(match queue_position(visible, model, target) { + Some((position, total)) => format!("{position} of {total}"), + None => format!("{UNPLACED} of {}", visible.len()), + }) +} + +/// The call wording the details bar prints lives in `labels::calls`; the +/// navigator's inline outline reads the same functions. +pub(super) use super::super::labels::calls::{CallLine, call_lines, call_marker}; + +/// `changed symbol 1 of 4`. +pub(super) fn symbol_counter(index: usize, total: usize) -> String { + format!("changed symbol {} of {total}", index.saturating_add(1)) +} + +/// `base 120–168 · head 120–190` — the outermost lines the symbol's hunks +/// touch on each side; a side without hunks is left out. +pub(super) fn line_span(old: &[(u32, u32)], new: &[(u32, u32)]) -> String { + let span = |ranges: &[(u32, u32)]| { + let start = ranges.iter().map(|(start, _)| *start).min()?; + let end = ranges.iter().map(|(_, end)| *end).max()?; + Some(if start == end { + start.to_string() + } else { + format!("{start}\u{2013}{end}") + }) + }; + let mut parts = Vec::new(); + if let Some(span) = span(old) { + parts.push(format!("base {span}")); + } + if let Some(span) = span(new) { + parts.push(format!("head {span}")); + } + if parts.is_empty() { + return UNPLACED.to_string(); + } + parts.join(DOT) +} + +#[cfg(test)] +mod tests { + use super::super::super::fixtures; + use super::super::super::model::{AttentionTarget, ReviewModel}; + use super::super::super::ranking::{ModelInputs, StructureLoad, build_review_model}; + use super::{ + analysis_label, churn_words, header_summary, line_span, queue_label, queue_position, + split_path, symbol_counter, + }; + use okena_git::DiffMode; + + fn mode() -> DiffMode { + DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + } + } + + fn small_model() -> ReviewModel { + let inventory = fixtures::inventory_small(); + build_review_model(ModelInputs { + inventory: Some(&inventory), + inventory_error: None, + structure: None, + structure_state: StructureLoad::Loading, + diff_mode: &mode(), + }) + } + + fn entry<'a>(model: &'a ReviewModel, path: &str) -> &'a super::FileEntry { + model + .files + .iter() + .find(|entry| entry.display_path == path) + .expect("fixture file") + } + + #[test] + fn paths_split_at_the_last_directory_boundary() { + assert_eq!( + split_path("src/build/compile.ts"), + ("src/build/", "compile.ts") + ); + assert_eq!(split_path("Cargo.toml"), ("", "Cargo.toml")); + assert_eq!(split_path("src/"), ("src/", "")); + } + + #[test] + fn the_language_line_states_how_far_analysis_got() { + let model = fixtures::model(); + assert_eq!( + analysis_label(entry(&model, "src/engine.rs")), + "Rust \u{00B7} parsed" + ); + assert_eq!( + analysis_label(entry(&model, "src/app.js")), + "JavaScript \u{00B7} not analyzed" + ); + assert_eq!( + analysis_label(entry(&model, "worker/handler.rs")), + "Rust \u{00B7} failed to parse" + ); + assert_eq!( + analysis_label(entry(&model, "README.md")), + "Markdown \u{00B7} not analyzed" + ); + assert_eq!(analysis_label(entry(&model, "assets/logo.png")), "binary"); + } + + #[test] + fn only_a_small_comparison_carries_a_header_summary() { + assert_eq!(header_summary(&fixtures::model()), None); + assert_eq!( + header_summary(&small_model()), + Some("3 files \u{00B7} +16 \u{2212}4".to_string()) + ); + } + + #[test] + fn churn_words_drop_the_side_that_did_not_change() { + assert_eq!(churn_words(0, 0), None); + assert_eq!(churn_words(388, 0), Some("+388".to_string())); + assert_eq!(churn_words(0, 41), Some("\u{2212}41".to_string())); + assert_eq!( + churn_words(1_388, 41), + Some("+1\u{2009}388 \u{2212}41".to_string()) + ); + } + + #[test] + fn the_queue_position_counts_visible_rows_and_starts_at_one() { + let model = fixtures::model(); + let visible: Vec = (0..model.attention.len()).collect(); + let first = model + .attention + .first() + .expect("ranked items") + .target + .clone(); + assert_eq!( + queue_position(&visible, &model, Some(&first)), + Some((1, visible.len())) + ); + + let third = model.attention.get(2).expect("ranked items").target.clone(); + let narrowed = vec![2usize, 0]; + assert_eq!( + queue_position(&narrowed, &model, Some(&third)), + Some((1, 2)) + ); + assert_eq!(queue_position(&visible, &model, None), None); + assert_eq!( + queue_position( + &visible, + &model, + Some(&AttentionTarget::Directory("nowhere".into())) + ), + None + ); + } + + #[test] + fn a_filtered_out_target_still_counts_the_rows_it_could_step_through() { + let model = fixtures::model(); + let visible: Vec = (0..model.attention.len()).collect(); + let first = model + .attention + .first() + .expect("ranked items") + .target + .clone(); + assert_eq!( + queue_label(&visible, &model, Some(&first)), + Some(format!("1 of {}", visible.len())) + ); + assert_eq!( + queue_label(&visible, &model, None), + Some(format!("\u{2014} of {}", visible.len())) + ); + assert_eq!( + queue_label( + &visible, + &model, + Some(&AttentionTarget::Directory("nowhere".into())) + ), + Some(format!("\u{2014} of {}", visible.len())) + ); + assert_eq!(queue_label(&[], &model, Some(&first)), None); + } + + #[test] + fn the_symbol_counter_is_one_based() { + assert_eq!(symbol_counter(0, 4), "changed symbol 1 of 4"); + assert_eq!(symbol_counter(3, 4), "changed symbol 4 of 4"); + } + + #[test] + fn line_span_covers_the_outermost_hunk_lines_per_side() { + assert_eq!( + line_span(&[(120, 130), (150, 168)], &[(120, 190)]), + "base 120\u{2013}168 \u{00B7} head 120\u{2013}190" + ); + assert_eq!(line_span(&[], &[(7, 7)]), "head 7"); + assert_eq!(line_span(&[], &[]), "\u{2014}"); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/file_view/token_diff.rs b/crates/okena-views-git/src/diff_viewer/review_ui/file_view/token_diff.rs new file mode 100644 index 000000000..5cdb9a29b --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/file_view/token_diff.rs @@ -0,0 +1,214 @@ +//! Token diff of the two normalized signatures — spec §9. Pure; the details +//! block only paints the result. +//! +//! The shared prefix and suffix stay `Same`, so each line is rebuilt by taking +//! the segments that belong to its side, in order. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum SegmentKind { + Same, + Removed, + Added, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct Segment { + pub text: String, + pub kind: SegmentKind, +} + +impl Segment { + /// Whether this segment belongs on the line whose change kind is `changed`. + pub(super) fn on_side(&self, changed: SegmentKind) -> bool { + self.kind == SegmentKind::Same || self.kind == changed + } +} + +/// Longest common prefix and suffix; whatever is left in the middle changed. +pub(super) fn token_diff(old: &str, new: &str) -> Vec { + let old_tokens = tokenize(old); + let new_tokens = tokenize(new); + let shortest = old_tokens.len().min(new_tokens.len()); + let prefix = (0..shortest) + .take_while(|index| old_tokens[*index] == new_tokens[*index]) + .count(); + let suffix = (0..shortest - prefix) + .take_while(|index| { + old_tokens[old_tokens.len() - 1 - index] == new_tokens[new_tokens.len() - 1 - index] + }) + .count(); + let old_end = old_tokens.len() - suffix; + let new_end = new_tokens.len() - suffix; + + let mut segments = Vec::new(); + push(&mut segments, SegmentKind::Same, &old_tokens[..prefix]); + push( + &mut segments, + SegmentKind::Removed, + &old_tokens[prefix..old_end], + ); + push( + &mut segments, + SegmentKind::Added, + &new_tokens[prefix..new_end], + ); + push(&mut segments, SegmentKind::Same, &old_tokens[old_end..]); + segments +} + +fn push(segments: &mut Vec, kind: SegmentKind, tokens: &[&str]) { + if tokens.is_empty() { + return; + } + segments.push(Segment { + text: tokens.concat(), + kind, + }); +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Class { + Word, + Space, + Punctuation, +} + +fn class_of(character: char) -> Class { + if character.is_alphanumeric() || character == '_' { + Class::Word + } else if character.is_whitespace() { + Class::Space + } else { + Class::Punctuation + } +} + +/// Identifier and whitespace runs stay together; punctuation is one token each, +/// so `(`, `,` and `:` can anchor the diff. +fn tokenize(text: &str) -> Vec<&str> { + let mut tokens = Vec::new(); + let mut start = 0; + let mut previous: Option = None; + for (index, character) in text.char_indices() { + let class = class_of(character); + let continues = matches!( + (previous, class), + (Some(Class::Word), Class::Word) | (Some(Class::Space), Class::Space) + ); + if !continues { + if index > start { + tokens.push(&text[start..index]); + } + start = index; + } + previous = Some(class); + } + if start < text.len() { + tokens.push(&text[start..]); + } + tokens +} + +#[cfg(test)] +mod tests { + use super::{Segment, SegmentKind, token_diff, tokenize}; + + fn side(segments: &[Segment], changed: SegmentKind) -> String { + segments + .iter() + .filter(|segment| segment.on_side(changed)) + .map(|segment| segment.text.as_str()) + .collect() + } + + fn only(segments: &[Segment], kind: SegmentKind) -> Vec<&str> { + segments + .iter() + .filter(|segment| segment.kind == kind) + .map(|segment| segment.text.as_str()) + .collect() + } + + #[test] + fn tokens_split_identifiers_whitespace_and_single_punctuation() { + assert_eq!( + tokenize("fn run(&self) -> u32"), + [ + "fn", " ", "run", "(", "&", "self", ")", " ", "-", ">", " ", "u32" + ] + ); + assert_eq!(tokenize(""), Vec::<&str>::new()); + assert_eq!(tokenize(" "), [" "]); + } + + #[test] + fn an_added_parameter_is_the_only_highlighted_span() { + let old = "pub fn run(&self, input: &str) -> Result<()>"; + let new = "pub fn run(&self, input: &str, retries: u32) -> Result<()>"; + let segments = token_diff(old, new); + assert_eq!(only(&segments, SegmentKind::Added), [", retries: u32"]); + assert!(only(&segments, SegmentKind::Removed).is_empty()); + assert_eq!(side(&segments, SegmentKind::Removed), old); + assert_eq!(side(&segments, SegmentKind::Added), new); + } + + #[test] + fn the_new_parameter_of_a_typescript_signature_is_highlighted() { + let old = "export async function compileProject(project: Project): Promise"; + let new = + "export async function compileProject(project: Project, host: Host): Promise"; + let segments = token_diff(old, new); + let added = only(&segments, SegmentKind::Added); + assert_eq!(added.len(), 1); + assert!(added[0].contains("host: Host"), "{added:?}"); + assert!(!added[0].contains("project"), "{added:?}"); + assert_eq!(side(&segments, SegmentKind::Added), new); + } + + #[test] + fn a_replaced_span_is_removed_on_one_line_and_added_on_the_other() { + let old = "fn f(a: A)"; + let new = "fn f(b: B)"; + let segments = token_diff(old, new); + assert_eq!(only(&segments, SegmentKind::Removed), ["a: A"]); + assert_eq!(only(&segments, SegmentKind::Added), ["b: B"]); + assert_eq!(side(&segments, SegmentKind::Removed), old); + assert_eq!(side(&segments, SegmentKind::Added), new); + } + + #[test] + fn a_dropped_parameter_is_highlighted_on_the_old_line_only() { + let old = "fn f(a, b)"; + let new = "fn f(a)"; + let segments = token_diff(old, new); + assert_eq!(only(&segments, SegmentKind::Removed), [", b"]); + assert!(only(&segments, SegmentKind::Added).is_empty()); + assert_eq!(side(&segments, SegmentKind::Removed), old); + assert_eq!(side(&segments, SegmentKind::Added), new); + } + + #[test] + fn equal_signatures_produce_one_shared_segment() { + let segments = token_diff("fn f()", "fn f()"); + assert_eq!(segments.len(), 1); + assert_eq!(segments[0].kind, SegmentKind::Same); + assert_eq!(segments[0].text, "fn f()"); + } + + #[test] + fn every_pair_rebuilds_both_lines_exactly() { + let pairs = [ + ("", ""), + ("", "fn f()"), + ("fn f()", ""), + ("a", "b"), + ("fn f(a: A) -> B", "fn g(a: A) -> B"), + ("pub fn run()", "fn run()"), + ]; + for (old, new) in pairs { + let segments = token_diff(old, new); + assert_eq!(side(&segments, SegmentKind::Removed), old, "{old} / {new}"); + assert_eq!(side(&segments, SegmentKind::Added), new, "{old} / {new}"); + } + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/fixtures.rs b/crates/okena-views-git/src/diff_viewer/review_ui/fixtures.rs new file mode 100644 index 000000000..e456e75b6 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/fixtures.rs @@ -0,0 +1,1142 @@ +//! The one review dataset every test in this crate builds on. +//! +//! `inventory()` + `structure()` describe the same comparison, and `model()` is +//! the ranking over both. Keep the builder names stable — other units' tests +//! read them. + +use super::model::ReviewModel; +use super::ranking::{ModelInputs, StructureLoad, build_review_model}; +use okena_core::review::{ReviewInventory, TruncationReason}; +use okena_git::{DiffMode, ExactReviewDiffResponse}; +use okena_review::{ + AnalysisError, AnalysisStage, CallChangeKind, CallDiffChange, CallPairingEvidence, + CallPairingStrategy, ChangedHunk, ChangedLineRange, ComparisonSide, FileAnalysisStatus, + ImmutableResolvedComparison, LanguageCoverage, OmittedFileGroup, OmittedFileReason, + ReviewCoverage, ReviewNavigationTarget, ReviewStructure, ReviewTruncation, SignatureChange, + StructuralHotspot, StructuralMetric, StructuredFile, SymbolChange, SymbolChangeKind, + SymbolReference, +}; +use okena_syntax::{ + CallFact, ControlContext, SourceRange, SymbolFact, SymbolKey, SymbolKind, SymbolVisibility, + SyntaxLanguage, SyntaxProvenance, +}; +use serde_json::{Value, json}; +use std::num::NonZeroU32; + +/// The only file the fixture analyses; everything else stays a git fact. +const ENGINE: &str = "src/engine.rs"; +const DELETED: &str = "src/legacy.rs"; +const COUNTER: &str = "src/counter.rs"; +const MOVED_OLD: &str = "src/motion_old.rs"; +const MOVED_NEW: &str = "src/motion_new.rs"; +const UNSUPPORTED: &str = "src/app.js"; +const FAILED: &str = "worker/handler.rs"; + +const RUN_OLD: &str = "pub fn run(&self, input: &str) -> Result<()>"; +const RUN_NEW: &str = "pub fn run(&self, input: &str, retries: u32) -> Result<()>"; +const CONFIGURE_OLD: &str = "pub fn configure(&self, options: Options)"; +const CONFIGURE_NEW: &str = "pub fn configure(&self, options: Options, strict: bool)"; +const DISPATCH: &str = "fn dispatch(&self, event: Event)"; +const NORMALIZE: &str = "fn normalize(value: &str) -> String"; +const RENDER: &str = "fn render(input: &str) -> String"; +const STEPS: &str = "fn steps(count: u32) -> Vec"; +const ORCHESTRATE: &str = "pub fn orchestrate(config: Config, hooks: &Hooks, retries: u32, \ + timeout: u64, tags: &[&str], tracing: bool, budget: Budget, sink: &mut Sink) -> Result<()>"; + +pub(crate) fn comparison_json() -> Value { + let base = "1".repeat(40); + let merge_base = "2".repeat(40); + let head = "3".repeat(40); + json!({ + "requested": { "branch_compare": { "base": "main", "head": "feature" } }, + "requested_base_oid": base, + "requested_head_oid": head, + "strategy": "merge_base_to_head", + "base": { "kind": "commit", "oid": merge_base }, + "head": { "kind": "commit", "oid": head }, + "merge_base_oid": merge_base, + "identity": format!("branch:merge-base:{base}:{head}:{merge_base}") + }) +} + +pub(crate) fn coverage_json(total: u64, analyzed: u64, unsupported: u64) -> Value { + json!({ + "total_items": total, + "analyzed_items": analyzed, + "pending_items": 0, + "skipped_items": 0, + "unsupported_items": unsupported, + "failed_items": 0 + }) +} + +fn totals_json() -> Value { + json!({ + "commits": 0, + "files": 0, + "files_added": 0, + "files_deleted": 0, + "files_modified": 0, + "files_renamed": 0, + "files_copied": 0, + "files_type_changed": 0, + "files_mode_changed": 0, + "submodule_changes": 0, + "binary_files": 0, + "lines_added": 0, + "lines_deleted": 0, + "provenance": { "source": "git" } + }) +} + +fn implementation() -> Value { + json!({ "role": "implementation", "rule_id": "builtin.path.implementation.v1" }) +} + +fn git() -> Value { + json!({ "source": "git" }) +} + +/// A comparison that resolved but changed nothing. +pub(crate) fn empty_inventory() -> ReviewInventory { + serde_json::from_value(json!({ + "comparison": comparison_json(), + "totals": totals_json(), + "commits": [], + "files": [], + "coverage": coverage_json(0, 0, 0) + })) + .expect("empty inventory fixture") +} + +/// Thirteen files covering every ranking input: renames on both sides of the +/// residual boundary, a deleted and an added implementation file, a binary, a +/// lockfile, a config file, one implementation directory with test changes next +/// to it and one without, and an unsupported language. `src/lib.rs` and +/// `tests/lib.rs` come first, in that order. +pub(crate) fn inventory() -> ReviewInventory { + let mut totals = totals_json(); + totals["commits"] = json!(2); + totals["files"] = json!(13); + totals["files_added"] = json!(2); + totals["files_deleted"] = json!(1); + totals["files_modified"] = json!(8); + totals["files_renamed"] = json!(2); + totals["binary_files"] = json!(1); + totals["lines_added"] = json!(471); + totals["lines_deleted"] = json!(295); + serde_json::from_value(json!({ + "comparison": comparison_json(), + "totals": totals, + "commits": [ + { "oid": "a".repeat(40), "parent_oids": [], "subject": "first", + "author_name": "Ada", "timestamp": 1, "provenance": git() }, + { "oid": "b".repeat(40), "parent_oids": ["a".repeat(40), "c".repeat(40)], + "subject": "merge second", "author_name": "Bob", "timestamp": 2, + "provenance": git() } + ], + "files": [ + { "new_path": "src/lib.rs", "status": "added", "lines_added": 10, + "lines_deleted": 0, "binary": false, + "classification": implementation(), "provenance": git() }, + { "old_path": "tests/lib.rs", "new_path": "tests/lib.rs", "status": "modified", + "lines_added": 1, "lines_deleted": 3, "binary": false, + "classification": { "role": "test", "rule_id": "builtin.path.test.v1" }, + "provenance": git() }, + { "old_path": "src/old.rs", "new_path": "src/new.rs", "status": "renamed", + "similarity": 98, "lines_added": 2, "lines_deleted": 1, "binary": false, + "classification": implementation(), "provenance": git() }, + { "old_path": "README.md", "new_path": "README.md", "status": "modified", + "lines_added": 5, "lines_deleted": 1, "binary": false, + "classification": { "role": "documentation", + "rule_id": "builtin.path.documentation.v1" }, + "provenance": git() }, + { "old_path": "Cargo.toml", "new_path": "Cargo.toml", "status": "modified", + "lines_added": 3, "lines_deleted": 0, "binary": false, + "classification": { "role": "configuration", + "rule_id": "builtin.path.configuration.v1" }, + "provenance": git() }, + { "old_path": "pnpm-lock.yaml", "new_path": "pnpm-lock.yaml", "status": "modified", + "lines_added": 120, "lines_deleted": 40, "binary": false, + "classification": { "role": "lockfile", "rule_id": "builtin.path.lockfile.v1" }, + "provenance": git() }, + { "new_path": "assets/logo.png", "status": "added", "binary": true, + "classification": { "role": "unclassified", + "rule_id": "builtin.path.unclassified.v1" }, + "provenance": git() }, + { "old_path": ENGINE, "new_path": ENGINE, "status": "modified", + "lines_added": 60, "lines_deleted": 30, "binary": false, + "classification": implementation(), "provenance": git() }, + { "old_path": "src/legacy.rs", "status": "deleted", "lines_added": 0, + "lines_deleted": 120, "binary": false, + "classification": implementation(), "provenance": git() }, + { "old_path": "src/motion_old.rs", "new_path": "src/motion_new.rs", + "status": "renamed", "similarity": 91, "lines_added": 50, "lines_deleted": 36, + "binary": false, "classification": implementation(), "provenance": git() }, + { "old_path": UNSUPPORTED, "new_path": UNSUPPORTED, "status": "modified", + "lines_added": 12, "lines_deleted": 4, "binary": false, + "classification": implementation(), "provenance": git() }, + { "old_path": FAILED, "new_path": FAILED, "status": "modified", + "lines_added": 200, "lines_deleted": 60, "binary": false, + "classification": implementation(), "provenance": git() }, + { "old_path": "worker/handler_test.rs", "new_path": "worker/handler_test.rs", + "status": "modified", "lines_added": 8, "lines_deleted": 0, "binary": false, + "classification": { "role": "test", "rule_id": "builtin.path.test.v1" }, + "provenance": git() } + ], + "coverage": coverage_json(13, 13, 0) + })) + .expect("inventory fixture") +} + +/// Three files, under both small-comparison bounds — spec §12. +pub(crate) fn inventory_small() -> ReviewInventory { + let mut totals = totals_json(); + totals["files"] = json!(3); + totals["files_added"] = json!(1); + totals["files_modified"] = json!(2); + totals["lines_added"] = json!(16); + totals["lines_deleted"] = json!(4); + serde_json::from_value(json!({ + "comparison": comparison_json(), + "totals": totals, + "commits": [], + "files": [ + { "new_path": "src/lib.rs", "status": "added", "lines_added": 10, + "lines_deleted": 0, "binary": false, + "classification": implementation(), "provenance": git() }, + { "old_path": "tests/lib.rs", "new_path": "tests/lib.rs", "status": "modified", + "lines_added": 1, "lines_deleted": 3, "binary": false, + "classification": { "role": "test", "rule_id": "builtin.path.test.v1" }, + "provenance": git() }, + { "old_path": "README.md", "new_path": "README.md", "status": "modified", + "lines_added": 5, "lines_deleted": 1, "binary": false, + "classification": { "role": "documentation", + "rule_id": "builtin.path.documentation.v1" }, + "provenance": git() } + ], + "coverage": coverage_json(3, 3, 0) + })) + .expect("small inventory fixture") +} + +/// Nothing structure analysis can parse — spec §12. +pub(crate) fn inventory_all_unsupported() -> ReviewInventory { + let mut totals = totals_json(); + totals["files"] = json!(3); + totals["files_modified"] = json!(3); + totals["lines_added"] = json!(25); + totals["lines_deleted"] = json!(6); + serde_json::from_value(json!({ + "comparison": comparison_json(), + "totals": totals, + "commits": [], + "files": [ + { "old_path": "src/app.js", "new_path": "src/app.js", "status": "modified", + "lines_added": 12, "lines_deleted": 4, "binary": false, + "classification": implementation(), "provenance": git() }, + { "old_path": "web/page.astro", "new_path": "web/page.astro", "status": "modified", + "lines_added": 9, "lines_deleted": 2, "binary": false, + "classification": implementation(), "provenance": git() }, + { "old_path": "src/util.js", "new_path": "src/util.js", "status": "modified", + "lines_added": 4, "lines_deleted": 0, "binary": false, + "classification": implementation(), "provenance": git() } + ], + "coverage": coverage_json(3, 0, 3) + })) + .expect("unsupported inventory fixture") +} + +/// Only binary files, so nothing has a line count — spec §12. +pub(crate) fn inventory_binary_only() -> ReviewInventory { + let mut totals = totals_json(); + totals["files"] = json!(2); + totals["files_added"] = json!(2); + totals["binary_files"] = json!(2); + serde_json::from_value(json!({ + "comparison": comparison_json(), + "totals": totals, + "commits": [], + "files": [ + { "new_path": "assets/logo.png", "status": "added", "binary": true, + "classification": { "role": "unclassified", + "rule_id": "builtin.path.unclassified.v1" }, + "provenance": git() }, + { "new_path": "assets/hero.jpg", "status": "added", "binary": true, + "classification": { "role": "unclassified", + "rule_id": "builtin.path.unclassified.v1" }, + "provenance": git() } + ], + "coverage": coverage_json(2, 0, 2) + })) + .expect("binary inventory fixture") +} + +pub(crate) fn exact_diff() -> ExactReviewDiffResponse { + serde_json::from_value(json!({ + "comparison": comparison_json(), + "diff": { "files": [] } + })) + .expect("exact diff fixture") +} + +// -- structure --------------------------------------------------------------- + +fn comparison() -> ImmutableResolvedComparison { + ImmutableResolvedComparison::try_from(empty_inventory().comparison) + .expect("the fixture comparison is immutable") +} + +fn at(value: u32) -> NonZeroU32 { + NonZeroU32::new(value).expect("fixture line numbers are one-based") +} + +/// A line-shaped range; a hundred bytes per line keeps containment obvious. +fn span(start: u32, end: u32) -> SourceRange { + SourceRange::new( + u64::from(start) * 100, + u64::from(end) * 100 + 99, + at(start), + at(end), + ) + .expect("fixture source range") +} + +fn rust() -> SyntaxProvenance { + SyntaxProvenance::tree_sitter(SyntaxLanguage::Rust, "tree-sitter-rust") + .expect("fixture provenance") +} + +fn hunk(old: Option<(u32, u32)>, new: Option<(u32, u32)>) -> ChangedHunk { + let range = |(start, end): (u32, u32)| { + ChangedLineRange::new(at(start), at(end)).expect("fixture changed-line range") + }; + ChangedHunk::new(old.map(range), new.map(range)).expect("fixture hunk") +} + +fn nav_at(path: &str, side: ComparisonSide, line: u32) -> ReviewNavigationTarget { + ReviewNavigationTarget { + path: path.to_string(), + side, + line: at(line), + byte_offset: None, + symbol_context: None, + } +} + +fn nav(side: ComparisonSide, line: u32) -> ReviewNavigationTarget { + nav_at(ENGINE, side, line) +} + +fn key(path: &[&str], kind: SymbolKind, name: &str) -> SymbolKey { + SymbolKey::new( + path.iter().map(|part| (*part).to_string()).collect(), + kind, + name, + ) + .expect("fixture symbol key") +} + +struct FactSpec<'a> { + key: &'a SymbolKey, + visibility: SymbolVisibility, + full: (u32, u32), + signature_line: u32, + body: (u32, u32), + signature: &'a str, + params: u32, + depth: u32, +} + +fn fact(spec: FactSpec<'_>) -> SymbolFact { + SymbolFact::new( + rust(), + spec.key.clone(), + spec.visibility, + span(spec.full.0, spec.full.1), + span(spec.signature_line, spec.signature_line), + Some(span(spec.body.0, spec.body.1)), + spec.signature, + spec.params, + spec.depth, + 0, + ) + .expect("fixture symbol fact") +} + +fn call( + callee: &str, + arguments: &str, + line: u32, + enclosing: &SymbolKey, + context: Vec, +) -> CallFact { + CallFact::new( + rust(), + callee, + arguments, + span(line, line), + span(line, line), + Some(enclosing.clone()), + context, + ) + .expect("fixture call fact") +} + +/// A `ChangedLines` hotspot on the side the change actually has. +fn changed_lines( + symbol: &SymbolKey, + range: (u32, u32), + old: u32, + new: u32, + side: ComparisonSide, + line: u32, +) -> StructuralHotspot { + StructuralHotspot::new( + SymbolReference::new(side, span(range.0, range.1), symbol.clone()), + StructuralMetric::ChangedLines { old, new }, + rust(), + nav(side, line), + ) + .expect("fixture changed-lines hotspot") +} + +/// A removed function is measured on the base side, where it still exists. +fn base_changed_lines( + symbol: &SymbolKey, + range: (u32, u32), + old: u32, + new: u32, + line: u32, +) -> StructuralHotspot { + changed_lines(symbol, range, old, new, ComparisonSide::Base, line) +} + +fn hotspot( + symbol: &SymbolKey, + range: (u32, u32), + metric: StructuralMetric, + line: u32, +) -> StructuralHotspot { + StructuralHotspot::new( + SymbolReference::new(ComparisonSide::Head, span(range.0, range.1), symbol.clone()), + metric, + rust(), + nav(ComparisonSide::Head, line), + ) + .expect("fixture hotspot") +} + +/// `src/engine.rs`: six changed symbols, one untouched hotspot, four call changes. +fn engine_file() -> StructuredFile { + let run = key(&["Engine"], SymbolKind::Method, "run"); + let legacy_run = key(&["Engine"], SymbolKind::Method, "legacy_run"); + let configure = key(&["Engine"], SymbolKind::Method, "configure"); + let dispatch = key(&["Engine"], SymbolKind::Method, "dispatch"); + let normalize = key(&[], SymbolKind::Function, "normalize"); + let orchestrate = key(&[], SymbolKind::Function, "orchestrate"); + let helper = key(&["Engine"], SymbolKind::Method, "helper"); + + let run_change = SymbolChange::new( + SymbolChangeKind::Modified, + Some(fact(FactSpec { + key: &run, + visibility: SymbolVisibility::Public, + full: (10, 40), + signature_line: 10, + body: (11, 40), + signature: RUN_OLD, + params: 2, + depth: 3, + })), + Some(fact(FactSpec { + key: &run, + visibility: SymbolVisibility::Public, + full: (10, 45), + signature_line: 10, + body: (11, 45), + signature: RUN_NEW, + params: 3, + depth: 3, + })), + Some( + SignatureChange::new(RUN_OLD, RUN_NEW, span(10, 10), span(10, 10)) + .expect("fixture signature change"), + ), + true, + vec![ + hunk(Some((10, 10)), Some((10, 10))), + hunk(Some((20, 24)), Some((20, 26))), + ], + nav(ComparisonSide::Head, 10), + ) + .expect("fixture run change"); + + let legacy_change = SymbolChange::new( + SymbolChangeKind::Removed, + Some(fact(FactSpec { + key: &legacy_run, + visibility: SymbolVisibility::Public, + full: (50, 70), + signature_line: 50, + body: (51, 70), + signature: "pub fn legacy_run(&self) -> Result<()>", + params: 0, + depth: 1, + })), + None, + None, + false, + vec![hunk(Some((50, 70)), None)], + nav(ComparisonSide::Base, 50), + ) + .expect("fixture legacy change"); + + let configure_change = SymbolChange::new( + SymbolChangeKind::Modified, + Some(fact(FactSpec { + key: &configure, + visibility: SymbolVisibility::Public, + full: (75, 95), + signature_line: 75, + body: (76, 95), + signature: CONFIGURE_OLD, + params: 1, + depth: 1, + })), + Some(fact(FactSpec { + key: &configure, + visibility: SymbolVisibility::Public, + full: (75, 95), + signature_line: 75, + body: (76, 95), + signature: CONFIGURE_NEW, + params: 2, + depth: 1, + })), + Some( + SignatureChange::new(CONFIGURE_OLD, CONFIGURE_NEW, span(75, 75), span(75, 75)) + .expect("fixture signature change"), + ), + false, + vec![hunk(Some((75, 75)), Some((75, 75)))], + nav(ComparisonSide::Head, 75), + ) + .expect("fixture configure change"); + + let dispatch_change = SymbolChange::new( + SymbolChangeKind::Modified, + Some(fact(FactSpec { + key: &dispatch, + visibility: SymbolVisibility::Private, + full: (200, 240), + signature_line: 200, + body: (201, 240), + signature: DISPATCH, + params: 1, + depth: 2, + })), + Some(fact(FactSpec { + key: &dispatch, + visibility: SymbolVisibility::Private, + full: (200, 244), + signature_line: 200, + body: (201, 244), + signature: DISPATCH, + params: 1, + depth: 2, + })), + None, + true, + vec![hunk(Some((210, 212)), Some((210, 214)))], + nav(ComparisonSide::Head, 200), + ) + .expect("fixture dispatch change"); + + let normalize_change = SymbolChange::new( + SymbolChangeKind::Modified, + Some(fact(FactSpec { + key: &normalize, + visibility: SymbolVisibility::Private, + full: (400, 430), + signature_line: 400, + body: (401, 430), + signature: NORMALIZE, + params: 1, + depth: 2, + })), + Some(fact(FactSpec { + key: &normalize, + visibility: SymbolVisibility::Private, + full: (400, 436), + signature_line: 400, + body: (401, 436), + signature: NORMALIZE, + params: 1, + depth: 2, + })), + None, + true, + vec![hunk(Some((410, 415)), Some((410, 421)))], + nav(ComparisonSide::Head, 400), + ) + .expect("fixture normalize change"); + + let orchestrate_change = SymbolChange::new( + SymbolChangeKind::Added, + None, + Some(fact(FactSpec { + key: &orchestrate, + visibility: SymbolVisibility::Public, + full: (600, 839), + signature_line: 600, + body: (601, 839), + signature: ORCHESTRATE, + params: 8, + depth: 6, + })), + None, + false, + vec![hunk(None, Some((600, 839)))], + nav(ComparisonSide::Head, 600), + ) + .expect("fixture orchestrate change"); + + let pairing = CallPairingEvidence::new( + CallPairingStrategy::UniqueOccurrenceWithinEnclosingRange, + span(30, 30), + span(32, 32), + span(10, 40), + span(10, 45), + 1, + 1, + ) + .expect("fixture call pairing"); + + let call_diff = vec![ + CallDiffChange::new( + CallChangeKind::Removed, + Some(call( + "validate", + "(input)", + 22, + &run, + vec![ControlContext::ErrorBranch], + )), + None, + false, + false, + None, + nav(ComparisonSide::Base, 22), + ) + .expect("fixture removed call"), + CallDiffChange::new( + CallChangeKind::Modified, + Some(call( + "retry", + "(3)", + 30, + &run, + vec![ControlContext::Condition], + )), + Some(call( + "retry", + "(retries)", + 32, + &run, + vec![ControlContext::Condition], + )), + true, + false, + Some(pairing), + nav(ComparisonSide::Head, 32), + ) + .expect("fixture modified call"), + CallDiffChange::new( + CallChangeKind::Removed, + Some(call( + "log_error", + "(event)", + 215, + &dispatch, + vec![ControlContext::MatchArm], + )), + None, + false, + false, + None, + nav(ComparisonSide::Base, 215), + ) + .expect("fixture dispatch call"), + CallDiffChange::new( + CallChangeKind::Added, + None, + Some(call("emit", "(value)", 412, &normalize, Vec::new())), + false, + false, + None, + nav(ComparisonSide::Head, 412), + ) + .expect("fixture added call"), + // A brand-new function is full of new calls; that is not behaviour. + CallDiffChange::new( + CallChangeKind::Added, + None, + Some(call( + "spawn", + "(config)", + 640, + &orchestrate, + vec![ControlContext::ErrorBranch], + )), + false, + false, + None, + nav(ComparisonSide::Head, 640), + ) + .expect("fixture new-function call"), + ]; + + // The producer emits `ChangedLines` for *every* changed function and the + // head-side metric triple for every head-side function, changed or not. + let hotspots = vec![ + changed_lines(&run, (10, 45), 6, 8, ComparisonSide::Head, 10), + base_changed_lines(&legacy_run, (50, 70), 21, 0, 50), + changed_lines(&configure, (75, 95), 1, 1, ComparisonSide::Head, 75), + changed_lines(&dispatch, (200, 244), 3, 5, ComparisonSide::Head, 200), + changed_lines(&normalize, (400, 436), 6, 12, ComparisonSide::Head, 400), + changed_lines(&orchestrate, (600, 839), 0, 240, ComparisonSide::Head, 600), + hotspot( + &run, + (10, 45), + StructuralMetric::FunctionLineCount { lines: 36 }, + 10, + ), + hotspot( + &run, + (10, 45), + StructuralMetric::ParameterCount { parameters: 3 }, + 10, + ), + hotspot( + &run, + (10, 45), + StructuralMetric::SyntacticNestingDepth { depth: 3 }, + 10, + ), + hotspot( + &orchestrate, + (600, 839), + StructuralMetric::FunctionLineCount { lines: 240 }, + 600, + ), + hotspot( + &orchestrate, + (600, 839), + StructuralMetric::SyntacticNestingDepth { depth: 6 }, + 600, + ), + hotspot( + &orchestrate, + (600, 839), + StructuralMetric::ParameterCount { parameters: 8 }, + 600, + ), + // Hotspots on a symbol that did not change; the ranking must skip them. + hotspot( + &helper, + (900, 989), + StructuralMetric::FunctionLineCount { lines: 90 }, + 900, + ), + hotspot( + &helper, + (900, 989), + StructuralMetric::SyntacticNestingDepth { depth: 7 }, + 900, + ), + ]; + + StructuredFile::new( + Some(ENGINE.to_string()), + Some(ENGINE.to_string()), + Some(SyntaxLanguage::Rust), + Some(rust()), + Some(rust()), + FileAnalysisStatus::Parsed, + Vec::new(), + Vec::new(), + vec![ + run_change, + legacy_change, + configure_change, + dispatch_change, + normalize_change, + orchestrate_change, + ], + hotspots, + call_diff, + vec![ + hunk(Some((10, 10)), Some((10, 10))), + hunk(Some((20, 24)), Some((20, 26))), + hunk(Some((50, 70)), None), + hunk(Some((75, 75)), Some((75, 75))), + hunk(Some((210, 212)), Some((210, 214))), + hunk(Some((410, 415)), Some((410, 421))), + hunk(None, Some((600, 839))), + ], + Vec::new(), + None, + ) + .expect("fixture structured file") +} + +fn unsupported_file() -> StructuredFile { + StructuredFile::new( + Some(UNSUPPORTED.to_string()), + Some(UNSUPPORTED.to_string()), + None, + None, + None, + FileAnalysisStatus::Unsupported, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ) + .expect("fixture unsupported file") +} + +fn failed_file() -> StructuredFile { + StructuredFile::new( + Some(FAILED.to_string()), + Some(FAILED.to_string()), + None, + None, + None, + FileAnalysisStatus::Failed, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + vec![ + AnalysisError::new( + Some(FAILED.to_string()), + AnalysisStage::Parsing, + "unexpected token", + ) + .expect("fixture analysis error"), + ], + None, + ) + .expect("fixture failed file") +} + +fn rust_coverage(files: u64) -> Vec { + vec![LanguageCoverage::new( + SyntaxLanguage::Rust, + ReviewCoverage::new(files, files, 0, 0, 0, 0, None).expect("fixture language coverage"), + )] +} + +/// `src/legacy.rs`: analysed even though it is gone, so its removed symbol and +/// its `deleted implementation file` row both appear. +fn deleted_file() -> StructuredFile { + let render = key(&[], SymbolKind::Function, "render"); + let change = SymbolChange::new( + SymbolChangeKind::Removed, + Some(fact(FactSpec { + key: &render, + visibility: SymbolVisibility::Private, + full: (5, 60), + signature_line: 5, + body: (6, 60), + signature: RENDER, + params: 1, + depth: 2, + })), + None, + None, + false, + vec![hunk(Some((5, 60)), None)], + nav_at(DELETED, ComparisonSide::Base, 5), + ) + .expect("fixture render change"); + StructuredFile::new( + Some(DELETED.to_string()), + None, + Some(SyntaxLanguage::Rust), + Some(rust()), + None, + FileAnalysisStatus::Parsed, + Vec::new(), + Vec::new(), + vec![change], + vec![ + StructuralHotspot::new( + SymbolReference::new(ComparisonSide::Base, span(5, 60), render.clone()), + StructuralMetric::ChangedLines { old: 56, new: 0 }, + rust(), + nav_at(DELETED, ComparisonSide::Base, 5), + ) + .expect("fixture deleted hotspot"), + ], + Vec::new(), + vec![hunk(Some((5, 60)), None)], + Vec::new(), + None, + ) + .expect("fixture deleted file") +} + +/// `src/motion_old.rs` → `src/motion_new.rs`: a rename with 86 residual lines, +/// analysed, so its `moved` row must survive next to its symbol row. +fn moved_file() -> StructuredFile { + let steps = key(&[], SymbolKind::Function, "steps"); + let head = |line: u32| nav_at(MOVED_NEW, ComparisonSide::Head, line); + let change = SymbolChange::new( + SymbolChangeKind::Modified, + Some(fact(FactSpec { + key: &steps, + visibility: SymbolVisibility::Private, + full: (10, 60), + signature_line: 10, + body: (11, 60), + signature: STEPS, + params: 1, + depth: 2, + })), + Some(fact(FactSpec { + key: &steps, + visibility: SymbolVisibility::Private, + full: (10, 74), + signature_line: 10, + body: (11, 74), + signature: STEPS, + params: 1, + depth: 2, + })), + None, + true, + vec![hunk(Some((20, 55)), Some((20, 69)))], + head(10), + ) + .expect("fixture steps change"); + StructuredFile::new( + Some(MOVED_OLD.to_string()), + Some(MOVED_NEW.to_string()), + Some(SyntaxLanguage::Rust), + Some(rust()), + Some(rust()), + FileAnalysisStatus::Parsed, + Vec::new(), + Vec::new(), + vec![change], + vec![ + StructuralHotspot::new( + SymbolReference::new(ComparisonSide::Head, span(10, 74), steps.clone()), + StructuralMetric::ChangedLines { old: 36, new: 50 }, + rust(), + head(10), + ) + .expect("fixture moved hotspot"), + StructuralHotspot::new( + SymbolReference::new(ComparisonSide::Head, span(10, 74), steps.clone()), + StructuralMetric::FunctionLineCount { lines: 65 }, + rust(), + head(10), + ) + .expect("fixture moved size hotspot"), + ], + Vec::new(), + vec![hunk(Some((20, 55)), Some((20, 69)))], + Vec::new(), + None, + ) + .expect("fixture moved file") +} + +/// Three parsed files, one unsupported, one failed, and a file-limit omission. +pub(crate) fn structure() -> ReviewStructure { + ReviewStructure::new_with_omissions( + comparison(), + vec![ + engine_file(), + deleted_file(), + moved_file(), + unsupported_file(), + failed_file(), + ], + vec![ + OmittedFileGroup::new( + 2, + None, + OmittedFileReason::FileLimit, + Some(ReviewTruncation { + reason: TruncationReason::ItemLimit, + limit: Some(200), + observed: Some(205), + detail: None, + }), + ) + .expect("fixture omission group"), + ], + ReviewCoverage::new(7, 3, 2, 0, 1, 1, None).expect("fixture structure coverage"), + rust_coverage(3), + Vec::new(), + ) + .expect("structure fixture") +} + +/// Complete except for one file the parser could not read. +pub(crate) fn structure_with_failure() -> ReviewStructure { + ReviewStructure::new( + comparison(), + vec![engine_file(), failed_file()], + ReviewCoverage::new(2, 1, 0, 0, 0, 1, None).expect("fixture failure coverage"), + rust_coverage(1), + Vec::new(), + ) + .expect("failure structure fixture") +} + +/// Structure that reached no file at all. +pub(crate) fn structure_empty() -> ReviewStructure { + ReviewStructure::new( + comparison(), + Vec::new(), + ReviewCoverage::new(0, 0, 0, 0, 0, 0, None).expect("fixture empty coverage"), + Vec::new(), + Vec::new(), + ) + .expect("empty structure fixture") +} + +/// One implementation file with its tests inside it, the way Rust writes them: +/// `mod tests` and one test function in it — spec §5 on inline tests. +pub(crate) fn inventory_inline_tests() -> ReviewInventory { + let mut totals = totals_json(); + totals["files"] = json!(1); + totals["files_modified"] = json!(1); + totals["lines_added"] = json!(52); + totals["lines_deleted"] = json!(2); + serde_json::from_value(json!({ + "comparison": comparison_json(), + "totals": totals, + "commits": [], + "files": [ + { "old_path": COUNTER, "new_path": COUNTER, "status": "modified", + "lines_added": 52, "lines_deleted": 2, "binary": false, + "classification": implementation(), "provenance": git() } + ], + "coverage": coverage_json(1, 1, 0) + })) + .expect("inline-tests inventory fixture") +} + +pub(crate) fn structure_inline_tests() -> ReviewStructure { + ReviewStructure::new( + comparison(), + vec![counter_file()], + ReviewCoverage::new(1, 1, 0, 0, 0, 0, None).expect("fixture inline coverage"), + rust_coverage(1), + Vec::new(), + ) + .expect("inline-tests structure fixture") +} + +/// `src/counter.rs`: one changed function, and a `mod tests` whose own change +/// covers the test function inside it. +fn counter_file() -> StructuredFile { + let bump = key(&[], SymbolKind::Function, "bump"); + let tests_mod = key(&[], SymbolKind::Module, "tests"); + let case = key(&["tests"], SymbolKind::Function, "bumps_once"); + let nav_counter = |line: u32| nav_at(COUNTER, ComparisonSide::Head, line); + fn spec<'a>(key: &'a SymbolKey, full: (u32, u32), signature: &'a str) -> FactSpec<'a> { + FactSpec { + key, + visibility: SymbolVisibility::Private, + full, + signature_line: full.0, + body: (full.0.saturating_add(1), full.1), + signature, + params: 0, + depth: 1, + } + } + let bump_change = SymbolChange::new( + SymbolChangeKind::Modified, + Some(fact(spec(&bump, (5, 6), "fn bump()"))), + Some(fact(spec(&bump, (5, 14), "fn bump()"))), + None, + true, + vec![hunk(Some((5, 6)), Some((5, 14)))], + nav_counter(5), + ) + .expect("fixture bump change"); + let tests_change = SymbolChange::new( + SymbolChangeKind::Added, + None, + Some(fact(spec(&tests_mod, (100, 129), "mod tests"))), + None, + false, + vec![hunk(None, Some((100, 129)))], + nav_counter(100), + ) + .expect("fixture tests module change"); + let case_change = SymbolChange::new( + SymbolChangeKind::Added, + None, + Some(fact(spec(&case, (110, 121), "fn bumps_once()"))), + None, + false, + // The whole module arrived in one hunk; the symbol's own span is what + // makes its 12 lines out of the module's 30. + vec![hunk(None, Some((100, 129)))], + nav_counter(110), + ) + .expect("fixture test case change"); + StructuredFile::new( + Some(COUNTER.to_string()), + Some(COUNTER.to_string()), + Some(SyntaxLanguage::Rust), + Some(rust()), + Some(rust()), + FileAnalysisStatus::Parsed, + Vec::new(), + Vec::new(), + vec![bump_change, tests_change, case_change], + Vec::new(), + Vec::new(), + vec![ + hunk(Some((5, 6)), Some((5, 14))), + hunk(None, Some((100, 129))), + ], + Vec::new(), + None, + ) + .expect("fixture counter file") +} + +/// The ranking over [`inventory`] and [`structure`] — the shared golden model. +pub(crate) fn model() -> ReviewModel { + let inventory = inventory(); + let structure = structure(); + let mode = DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + }; + build_review_model(ModelInputs { + inventory: Some(&inventory), + inventory_error: None, + structure: Some(&structure), + structure_state: StructureLoad::Ready, + diff_mode: &mode, + }) +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/keys/footer.rs b/crates/okena-views-git/src/diff_viewer/review_ui/keys/footer.rs new file mode 100644 index 000000000..d89b9637c --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/keys/footer.rs @@ -0,0 +1,281 @@ +//! Footer hints — only keys that work on the current screen — spec §11. + +use super::super::super::DiffViewer; +use super::super::state::ContentView; +use super::KeyContext; +use gpui::prelude::*; +use gpui::*; +use gpui_component::h_flex; +use okena_core::theme::ThemeColors; +use okena_ui::tokens::ui_text_ms; + +/// Spec §3: the footer is 28 px and uses both halves. +const FOOTER_HEIGHT: f32 = 28.0; + +const fn platform_key(mac: &'static str, other: &'static str) -> &'static str { + if cfg!(target_os = "macos") { + mac + } else { + other + } +} + +pub(super) const FIND_KEY: &str = platform_key("\u{2318}F", "Ctrl+F"); +pub(super) const COPY_KEY: &str = platform_key("\u{2318}C", "Ctrl+C"); +pub(super) const UP: &str = "\u{2191}"; +pub(super) const DOWN: &str = "\u{2193}"; +pub(super) const ENTER: &str = "\u{21B5}"; + +/// One footer entry: the key chips that trigger it and what it does. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Hint { + pub keys: &'static [&'static str], + pub action: &'static str, +} + +/// The hints for the current screen, left half then right half. +pub(crate) fn footer_hints(ctx: KeyContext) -> (Vec, Vec) { + match ctx.screen { + ContentView::Overview => overview_hints(), + ContentView::File => file_hints(ctx), + } +} + +fn overview_hints() -> (Vec, Vec) { + ( + vec![ + Hint { + keys: &[UP, DOWN], + action: "navigate", + }, + Hint { + keys: &[ENTER], + action: "open", + }, + Hint { + keys: &["1", "2"], + action: "files \u{00B7} attention", + }, + Hint { + keys: &["/"], + action: "filter", + }, + Hint { + keys: &["r"], + action: "roles", + }, + Hint { + keys: &["?"], + action: "keys", + }, + ], + vec![Hint { + keys: &["Esc"], + action: "close", + }], + ) +} + +fn file_hints(ctx: KeyContext) -> (Vec, Vec) { + let mut left = Vec::new(); + if ctx.has_symbols { + left.push(Hint { + keys: &["}", "{"], + action: "next / prev symbol", + }); + } + left.push(Hint { + keys: &["]", "["], + action: if ctx.has_commits { + "next / prev commit" + } else { + "next / prev in queue" + }, + }); + left.push(Hint { + keys: &["d"], + action: "details", + }); + left.push(Hint { + keys: &["o"], + action: "overview", + }); + if ctx.split_available { + left.push(Hint { + keys: &["s"], + action: "split", + }); + } + left.push(Hint { + keys: &["w"], + action: "whitespace", + }); + left.push(Hint { + keys: &[FIND_KEY], + action: "find", + }); + + let right = vec![ + Hint { + keys: &["y"], + action: "copy path:line", + }, + Hint { + keys: &[COPY_KEY], + action: "copy", + }, + Hint { + keys: &["Esc"], + action: "back", + }, + ]; + (left, right) +} + +impl DiffViewer { + pub(crate) fn render_review_footer( + &self, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let (left, right) = footer_hints(self.review_screen_context()); + div() + .h(px(FOOTER_HEIGHT)) + .flex_shrink_0() + .px(px(12.0)) + .border_t_1() + .border_color(rgb(t.border)) + .flex() + .items_center() + .justify_between() + .child(hint_row(&left, t, cx)) + .child(hint_row(&right, t, cx)) + .into_any_element() + } +} + +fn hint_row(hints: &[Hint], t: &ThemeColors, cx: &App) -> AnyElement { + h_flex() + .gap(px(14.0)) + .children(hints.iter().map(|hint| render_hint_item(*hint, t, cx))) + .into_any_element() +} + +fn render_hint_item(hint: Hint, t: &ThemeColors, cx: &App) -> AnyElement { + h_flex() + .gap(px(6.0)) + .child( + h_flex() + .gap(px(3.0)) + .children(hint.keys.iter().map(|key| key_chip(key, t, cx))), + ) + .child( + div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .child(hint.action.to_string()), + ) + .into_any_element() +} + +/// A keycap. Shared with the help overlay so both spell keys the same way. +pub(super) fn key_chip(key: &str, t: &ThemeColors, cx: &App) -> AnyElement { + div() + .px(px(5.0)) + .rounded(px(4.0)) + .bg(rgb(t.bg_secondary)) + .border_1() + .border_color(rgb(t.border)) + .text_size(ui_text_ms(cx)) + .font_weight(FontWeight::MEDIUM) + .text_color(rgb(t.text_muted)) + .child(key.to_string()) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use super::super::super::state::ContentView; + use super::super::KeyContext; + use super::{FIND_KEY, Hint, footer_hints}; + + fn actions(hints: &[Hint]) -> Vec<&'static str> { + hints.iter().map(|hint| hint.action).collect() + } + + #[test] + fn the_overview_lists_the_navigator_keys_and_closes_on_the_right() { + let (left, right) = footer_hints(KeyContext::default()); + assert_eq!( + actions(&left), + [ + "navigate", + "open", + "files \u{00B7} attention", + "filter", + "roles", + "keys" + ] + ); + assert_eq!(actions(&right), ["close"]); + } + + #[test] + fn the_file_screen_lists_the_reading_keys_and_goes_back_on_the_right() { + let ctx = KeyContext { + screen: ContentView::File, + has_symbols: true, + split_available: true, + ..KeyContext::default() + }; + let (left, right) = footer_hints(ctx); + assert_eq!( + actions(&left), + [ + "next / prev symbol", + "next / prev in queue", + "details", + "overview", + "split", + "whitespace", + "find" + ] + ); + assert_eq!(actions(&right), ["copy path:line", "copy", "back"]); + assert!( + left.iter().any(|hint| hint.keys == [FIND_KEY]), + "find carries the platform accelerator" + ); + } + + #[test] + fn hints_whose_action_is_unavailable_are_dropped() { + let ctx = KeyContext { + screen: ContentView::File, + ..KeyContext::default() + }; + let (left, _) = footer_hints(ctx); + assert_eq!( + actions(&left), + [ + "next / prev in queue", + "details", + "overview", + "whitespace", + "find" + ] + ); + } + + #[test] + fn a_commit_list_renames_the_bracket_hint() { + let ctx = KeyContext { + screen: ContentView::File, + has_commits: true, + ..KeyContext::default() + }; + let (left, _) = footer_hints(ctx); + assert!(actions(&left).contains(&"next / prev commit")); + assert!(!actions(&left).contains(&"next / prev in queue")); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/keys/help.rs b/crates/okena-views-git/src/diff_viewer/review_ui/keys/help.rs new file mode 100644 index 000000000..031992530 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/keys/help.rs @@ -0,0 +1,346 @@ +//! Shortcut help overlay (`?`) — spec §11. + +use super::super::super::DiffViewer; +use super::footer::{COPY_KEY, DOWN, ENTER, FIND_KEY, UP, key_chip}; +use gpui::prelude::*; +use gpui::*; +use gpui_component::{h_flex, v_flex}; +use okena_core::theme::ThemeColors; +use okena_ui::modal::{modal_backdrop, modal_content}; +use okena_ui::tokens::{ui_text, ui_text_ms}; + +/// One line of the spec §11 table. +struct HelpRow { + keys: &'static [&'static str], + action: &'static str, +} + +/// Every binding the review answers to, in spec order. +const HELP_ROWS: &[HelpRow] = &[ + HelpRow { + keys: &[UP, DOWN], + action: "Move in the navigator; the row opens in the content area", + }, + HelpRow { + keys: &[ENTER], + action: "Open the row and move focus to the content", + }, + HelpRow { + keys: &["\u{2190}", "\u{2192}", "Space"], + action: "Collapse / expand / toggle a tree node", + }, + HelpRow { + keys: &["Home", "End"], + action: "Jump to the first / last row", + }, + HelpRow { + keys: &["1", "2"], + action: "Navigator mode: files / attention", + }, + HelpRow { + keys: &["/"], + action: "Focus the filter box", + }, + HelpRow { + keys: &["r"], + action: "Roles menu", + }, + HelpRow { + keys: &["e"], + action: "Outline: changed symbols and their changes, inline in the file tree", + }, + HelpRow { + keys: &["o"], + action: "Back to the overview", + }, + HelpRow { + keys: &["]", "["], + action: "Next / previous item in the attention order", + }, + HelpRow { + keys: &["}", "{"], + action: "Next / previous changed symbol in the open file", + }, + HelpRow { + keys: &["d"], + action: "Expand / collapse symbol details", + }, + HelpRow { + keys: &["s"], + action: "Split / unified diff", + }, + HelpRow { + keys: &["w"], + action: "Ignore whitespace", + }, + HelpRow { + keys: &[FIND_KEY], + action: "Find in the displayed diff; on the overview it focuses the filter", + }, + HelpRow { + keys: &["n", "N"], + action: "Next / previous search match", + }, + HelpRow { + keys: &["y"], + action: "Copy path:line of the current symbol", + }, + HelpRow { + keys: &[COPY_KEY], + action: "Copy the diff selection, or the navigator row when it has focus", + }, + HelpRow { + keys: &["F6"], + action: "Switch between the navigator and the content", + }, + HelpRow { + keys: &["?"], + action: "This help", + }, + HelpRow { + keys: &["Esc"], + action: "Close find, then back to the overview, then close the review", + }, +]; + +impl DiffViewer { + pub(crate) fn render_help_overlay( + &self, + t: &ThemeColors, + cx: &mut Context, + ) -> Option { + if !self.review_ui.help_open { + return None; + } + Some( + modal_backdrop("review-help-backdrop", t) + .items_center() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| this.review_toggle_help(cx)), + ) + .child( + modal_content("review-help", t) + .w(px(560.0)) + .max_h(px(560.0)) + .p(px(16.0)) + .gap(px(12.0)) + .child( + v_flex() + .flex_shrink_0() + .gap(px(2.0)) + .child( + div() + .text_size(ui_text(15.0, cx)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(t.text_primary)) + .child("Keyboard"), + ) + .child( + div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .child("Esc closes this"), + ), + ) + // The table is taller than the card on a short window. + .child( + div() + .id("review-help-rows") + .min_h_0() + .overflow_y_scroll() + .child(v_flex().gap(px(6.0)).children( + HELP_ROWS.iter().map(|row| render_help_row(row, t, cx)), + )), + ), + ) + .into_any_element(), + ) + } +} + +fn render_help_row(row: &HelpRow, t: &ThemeColors, cx: &App) -> AnyElement { + h_flex() + .gap(px(10.0)) + .items_start() + .child( + h_flex() + .w(px(150.0)) + .flex_shrink_0() + .gap(px(3.0)) + .children(row.keys.iter().map(|key| key_chip(key, t, cx))), + ) + .child( + div() + .flex_1() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_secondary)) + .child(row.action.to_string()), + ) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use super::super::super::state::{ContentView, FocusRegion}; + use super::super::footer::{COPY_KEY, DOWN, ENTER, FIND_KEY, UP, footer_hints}; + use super::super::{KeyContext, ReviewCommand, dispatch, normalize_key}; + use super::HELP_ROWS; + use gpui::Modifiers; + use std::collections::BTreeSet; + + /// Everything a user can press, so the sweep below misses nothing. + const KEY_CORPUS: &[&str] = &[ + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", + "s", "t", "u", "v", "w", "x", "y", "z", "1", "2", "3", "/", "?", "[", "]", "{", "}", "up", + "down", "left", "right", "home", "end", "enter", "space", "escape", "tab", "f6", + ]; + + /// How the help table and the footer spell the keystroke. + fn label(key: &str, modifiers: Modifiers) -> String { + if key == "f6" { + return "F6".to_string(); + } + if modifiers.platform || modifiers.control { + return match key { + "f" => FIND_KEY, + "c" => COPY_KEY, + other => other, + } + .to_string(); + } + let (key, _) = normalize_key(key, modifiers.shift); + match key { + "up" => UP, + "down" => DOWN, + "enter" => ENTER, + "left" => "\u{2190}", + "right" => "\u{2192}", + "space" => "Space", + "home" => "Home", + "end" => "End", + "f6" => "F6", + other => other, + } + .to_string() + } + + fn documented() -> BTreeSet { + HELP_ROWS + .iter() + .flat_map(|row| row.keys.iter().map(|key| (*key).to_string())) + .collect() + } + + /// Every keystroke that resolves to a command, over every screen state. + fn reachable() -> BTreeSet { + let contexts = [ + KeyContext::default(), + KeyContext { + focus: FocusRegion::Content, + ..KeyContext::default() + }, + KeyContext { + screen: ContentView::File, + has_symbols: true, + split_available: true, + search_open: true, + ..KeyContext::default() + }, + KeyContext { + screen: ContentView::File, + focus: FocusRegion::Content, + has_symbols: true, + split_available: true, + search_open: true, + ..KeyContext::default() + }, + KeyContext { + screen: ContentView::File, + has_commits: true, + ..KeyContext::default() + }, + ]; + let modifier_sets = [ + Modifiers::default(), + Modifiers { + shift: true, + ..Modifiers::default() + }, + Modifiers { + control: true, + ..Modifiers::default() + }, + Modifiers { + platform: true, + ..Modifiers::default() + }, + Modifiers { + alt: true, + ..Modifiers::default() + }, + Modifiers { + function: true, + ..Modifiers::default() + }, + ]; + // Esc is bound through the Cancel action, not the key table. + let mut found = BTreeSet::from(["Esc".to_string()]); + for base in contexts { + for modifiers in modifier_sets { + let ctx = KeyContext { modifiers, ..base }; + for key in KEY_CORPUS { + match dispatch(ctx, key) { + Some(ReviewCommand::Swallow) | None => {} + Some(_) => { + found.insert(label(key, modifiers)); + } + } + } + } + } + found + } + + #[test] + fn the_help_table_lists_exactly_what_is_bound() { + assert_eq!( + documented(), + reachable(), + "the help overlay must document every binding and nothing else" + ); + } + + #[test] + fn every_footer_key_is_documented_in_the_help_table() { + let documented = documented(); + let screens = [ + KeyContext::default(), + KeyContext { + screen: ContentView::File, + has_symbols: true, + split_available: true, + ..KeyContext::default() + }, + ]; + for ctx in screens { + let (left, right) = footer_hints(ctx); + for hint in left.iter().chain(right.iter()) { + for key in hint.keys { + assert!( + documented.contains(*key), + "the footer offers {key} but the help table never explains it" + ); + } + } + } + } + + #[test] + fn no_help_row_is_empty() { + for row in HELP_ROWS { + assert!(!row.keys.is_empty(), "{} has no key", row.action); + assert!(!row.action.is_empty()); + } + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/keys/mod.rs b/crates/okena-views-git/src/diff_viewer/review_ui/keys/mod.rs new file mode 100644 index 000000000..90aa08293 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/keys/mod.rs @@ -0,0 +1,1126 @@ +//! Keyboard handling for the review workspace — spec §11. +//! +//! [`dispatch`] and [`cancel_step`] are pure: the key table and the Esc ladder +//! are decided without touching GPUI, and the `impl` below only runs the result. + +mod footer; +mod help; + +use super::super::DiffViewer; +use super::super::review::ReviewFileKey; +use super::model::{AttentionTarget, ReviewModel}; +use super::state::{ContentView, FocusRegion, NavRowId, NavigatorMode}; +use gpui::{App, ClipboardItem, Context, KeyDownEvent, Modifiers, ScrollStrategy, Window}; +use okena_core::review::ComparisonSide; + +/// Everything the key table branches on, gathered once per event. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct KeyContext { + pub screen: ContentView, + pub focus: FocusRegion, + /// The navigator filter or the in-page search field has focus. + pub input_focused: bool, + pub search_open: bool, + /// The open file has at least one changed symbol. + pub has_symbols: bool, + pub split_available: bool, + /// A commit list is open, so `[` / `]` belong to the legacy commit bar (§3). + pub has_commits: bool, + pub modifiers: Modifiers, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CursorMove { + Prev, + Next, + First, + Last, +} + +/// One resolved keystroke. Every review action the keyboard can reach is here. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ReviewCommand { + /// Handled on purpose but does nothing — keeps the key out of the legacy path. + Swallow, + SetNavigator(NavigatorMode), + FocusFilter, + ToggleRoles, + ToggleOutline, + OpenOverview, + StepQueue(i32), + StepSymbol(i32), + PrevCommit, + NextCommit, + ToggleDetails, + ToggleSplit, + ToggleWhitespace, + OpenSearch, + SearchNext, + SearchPrev, + CopyPathLine, + CopyNavigatorRow, + CopySelection, + ToggleHelp, + CycleRegion, + MoveCursor(CursorMove), + ExpandCursor, + CollapseCursor, + ToggleCursorNode, + ActivateCursor, +} + +/// Keys the review owns. A modified variant of one of them is unbound and must +/// not reach the legacy diff shortcuts, which match on the key alone. +const REVIEW_KEYS: &[&str] = &[ + "1", "2", "/", "?", "r", "e", "o", "d", "w", "y", "s", "n", "N", "[", "]", "{", "}", +]; + +/// Layouts report a shifted character either as the character itself or as the +/// unshifted key plus `shift`. Returns the canonical key and whether `shift` +/// was consumed by the mapping. +fn normalize_key(key: &str, shift: bool) -> (&str, bool) { + if !shift { + return (key, false); + } + match key { + "/" => ("?", true), + "]" => ("}", true), + "[" => ("{", true), + "n" => ("N", true), + other => (other, false), + } +} + +/// The key table of spec §11. `None` means the legacy diff handler may run. +pub(crate) fn dispatch(ctx: KeyContext, key: &str) -> Option { + let modifiers = ctx.modifiers; + + // The only region switch the review owns — `Ctrl+1` / `Ctrl+2` are global + // app bindings. It answers from inside a field and under any modifier. + if key == "f6" { + return Some(ReviewCommand::CycleRegion); + } + // A focused field owns the key; swallowing stops the legacy single-letter + // shortcuts from firing while the user types. + if ctx.input_focused { + return Some(ReviewCommand::Swallow); + } + // Accelerators resolve first, so `Cmd+?` / `Cmd+}` never hit the key table. + if modifiers.platform || modifiers.control { + return accelerator_key(ctx, key); + } + + let (key, shift_used) = normalize_key(key, modifiers.shift); + let modified = modifiers.alt || modifiers.function || (modifiers.shift && !shift_used); + if !modified { + if let Some(command) = single_key(ctx, key) { + return Some(command); + } + } else if REVIEW_KEYS.contains(&key) { + return Some(ReviewCommand::Swallow); + } + navigator_key(ctx, key) +} + +fn accelerator_key(ctx: KeyContext, key: &str) -> Option { + match key { + "f" => Some(if ctx.screen == ContentView::File { + ReviewCommand::OpenSearch + } else { + ReviewCommand::FocusFilter + }), + "c" => Some(if ctx.focus == FocusRegion::Navigator { + ReviewCommand::CopyNavigatorRow + } else { + ReviewCommand::CopySelection + }), + _ => None, + } +} + +/// The single-key table; keys that act on the open file stay on the file screen. +fn single_key(ctx: KeyContext, key: &str) -> Option { + let on_file = ctx.screen == ContentView::File; + match key { + "1" => Some(ReviewCommand::SetNavigator(NavigatorMode::Files)), + "2" => Some(ReviewCommand::SetNavigator(NavigatorMode::Attention)), + "/" => Some(ReviewCommand::FocusFilter), + "?" => Some(ReviewCommand::ToggleHelp), + "r" => Some(ReviewCommand::ToggleRoles), + "e" => Some(ReviewCommand::ToggleOutline), + "o" => Some(ReviewCommand::OpenOverview), + "w" => Some(ReviewCommand::ToggleWhitespace), + "d" => on_file.then_some(ReviewCommand::ToggleDetails), + "y" => on_file.then_some(ReviewCommand::CopyPathLine), + "s" => ctx.split_available.then_some(ReviewCommand::ToggleSplit), + "}" => (on_file && ctx.has_symbols).then_some(ReviewCommand::StepSymbol(1)), + "{" => (on_file && ctx.has_symbols).then_some(ReviewCommand::StepSymbol(-1)), + "n" => ctx.search_open.then_some(ReviewCommand::SearchNext), + "N" => ctx.search_open.then_some(ReviewCommand::SearchPrev), + "]" => Some(if ctx.has_commits { + ReviewCommand::NextCommit + } else { + ReviewCommand::StepQueue(1) + }), + "[" => Some(if ctx.has_commits { + ReviewCommand::PrevCommit + } else { + ReviewCommand::StepQueue(-1) + }), + _ => None, + } +} + +/// Arrows belong to the navigator; in the content they keep scrolling the diff. +fn navigator_key(ctx: KeyContext, key: &str) -> Option { + let command = match key { + "up" => ReviewCommand::MoveCursor(CursorMove::Prev), + "down" => ReviewCommand::MoveCursor(CursorMove::Next), + "home" => ReviewCommand::MoveCursor(CursorMove::First), + "end" => ReviewCommand::MoveCursor(CursorMove::Last), + "left" => ReviewCommand::CollapseCursor, + "right" => ReviewCommand::ExpandCursor, + "space" => ReviewCommand::ToggleCursorNode, + "enter" => ReviewCommand::ActivateCursor, + _ => return None, + }; + // `Alt+↑` / `Alt+↓` are reserved for hunk stepping, which has no helper yet. + // Swallowing keeps them from stepping files through the legacy handler. + if ctx.modifiers.alt { + return Some(ReviewCommand::Swallow); + } + if ctx.focus != FocusRegion::Navigator { + return None; + } + Some(command) +} + +/// What `Esc` is currently for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CancelStep { + CloseHelp, + DismissMenu, + ClearFilter, + CloseSearch, + DismissLegacy, + BackToOverview, + Unhandled, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct CancelFlags { + pub help_open: bool, + /// A review menu or popover is open (roles, status, outline). + pub menu_open: bool, + pub filter_focused: bool, + pub search_open: bool, + /// A legacy context menu or confirm dialog is open. + pub legacy_transient: bool, + pub content_is_file: bool, +} + +/// The Esc ladder of spec §11 — never leaves an input without clearing it first. +pub(crate) fn cancel_step(flags: CancelFlags) -> CancelStep { + if flags.help_open { + CancelStep::CloseHelp + } else if flags.menu_open { + CancelStep::DismissMenu + } else if flags.filter_focused { + CancelStep::ClearFilter + } else if flags.search_open { + CancelStep::CloseSearch + } else if flags.legacy_transient { + CancelStep::DismissLegacy + } else if flags.content_is_file { + CancelStep::BackToOverview + } else { + CancelStep::Unhandled + } +} + +/// Mirrors the set [`DiffViewer::dismiss_transient_ui`] closes; kept separate so +/// the ladder input is testable without a viewer. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct LegacyTransients { + pub delete_confirm: bool, + pub discard_confirm: bool, + pub context_menu: bool, + pub commit_hash_menu: bool, + pub selection_context_menu: bool, +} + +impl LegacyTransients { + pub(crate) fn any_open(self) -> bool { + self.delete_confirm + || self.discard_confirm + || self.context_menu + || self.commit_hash_menu + || self.selection_context_menu + } +} + +/// Clamped cursor step within `len` rows; no cursor starts at the first row. +fn next_cursor_index(len: usize, current: Option, movement: CursorMove) -> Option { + if len == 0 { + return None; + } + let last = len.saturating_sub(1); + Some(match movement { + CursorMove::First => 0, + CursorMove::Last => last, + CursorMove::Prev => current.map_or(0, |index| index.saturating_sub(1)), + CursorMove::Next => current.map_or(0, |index| index.saturating_add(1).min(last)), + }) +} + +/// The row the cursor lands on. A cursor the rows no longer contain restarts. +fn next_cursor( + rows: &[NavRowId], + cursor: Option<&NavRowId>, + movement: CursorMove, +) -> Option { + let current = cursor.and_then(|cursor| rows.iter().position(|row| row == cursor)); + let index = next_cursor_index(rows.len(), current, movement)?; + rows.get(index).cloned() +} + +/// What `Ctrl+C` copies from a navigator row: a path, or a qualified symbol. +fn cursor_row_text(cursor: &NavRowId, model: Option<&ReviewModel>) -> Option { + match cursor { + NavRowId::Dir(path) | NavRowId::Item(AttentionTarget::Directory(path)) => { + Some(path.clone()) + } + NavRowId::File(key) | NavRowId::Item(AttentionTarget::File(key)) => file_path(key), + NavRowId::Item(AttentionTarget::Symbol { file, change_index }) => { + let model = model?; + let entry = model + .file_index(file) + .and_then(|index| model.files.get(index))?; + entry + .symbols + .iter() + .find(|symbol| symbol.change_index == *change_index) + .map(|symbol| symbol.qualified.clone()) + } + } +} + +/// The side that still exists; renames copy as the head path. +fn file_path(key: &ReviewFileKey) -> Option { + key.path(ComparisonSide::Head) + .or_else(|| key.path(ComparisonSide::Base)) + .map(str::to_owned) +} + +impl DiffViewer { + /// Returns true when the review handled the key and the legacy path must not run. + pub(crate) fn handle_review_key( + &mut self, + event: &KeyDownEvent, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let ctx = self.review_key_context(event.keystroke.modifiers, window, cx); + let Some(command) = dispatch(ctx, event.keystroke.key.as_str()) else { + return false; + }; + self.run_review_command(command, window, cx); + true + } + + /// The Esc ladder. Returns true when it consumed the key. + pub(crate) fn handle_review_cancel( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let flags = CancelFlags { + help_open: self.review_ui.help_open, + menu_open: self.review_ui.roles_menu_open + || self.review_ui.status_popover_open + || self.review_ui.outline_open, + filter_focused: self.review_filter_focused(window, cx), + search_open: self.search.is_some(), + legacy_transient: self.legacy_transients().any_open(), + content_is_file: self.review_ui.content == ContentView::File, + }; + match cancel_step(flags) { + CancelStep::CloseHelp => { + self.review_toggle_help(cx); + true + } + CancelStep::DismissMenu => self.review_dismiss_transient(cx), + CancelStep::ClearFilter => { + self.review_clear_filter(cx); + window.focus(&self.focus_handle, cx); + true + } + CancelStep::CloseSearch => { + self.close_search(window, cx); + true + } + CancelStep::DismissLegacy => self.dismiss_transient_ui(cx), + CancelStep::BackToOverview => { + self.review_return_to_overview(cx); + true + } + CancelStep::Unhandled => false, + } + } + + fn review_key_context(&self, modifiers: Modifiers, window: &Window, cx: &App) -> KeyContext { + KeyContext { + screen: self.review_ui.content, + focus: self.review_ui.focus_region, + input_focused: self.review_input_focused(window, cx), + search_open: self.search.is_some(), + has_symbols: self.review_open_file_has_symbols(), + split_available: self.review_show_split_toggle(), + has_commits: self.has_commits(), + modifiers, + } + } + + /// The context the footer describes; no event, so no modifiers. + fn review_screen_context(&self) -> KeyContext { + KeyContext { + screen: self.review_ui.content, + focus: self.review_ui.focus_region, + input_focused: false, + search_open: self.search.is_some(), + has_symbols: self.review_open_file_has_symbols(), + split_available: self.review_show_split_toggle(), + has_commits: self.has_commits(), + modifiers: Modifiers::default(), + } + } + + fn review_input_focused(&self, window: &Window, cx: &App) -> bool { + if self.review_filter_focused(window, cx) { + return true; + } + self.search + .as_ref() + .is_some_and(|search| search.input.read(cx).focus_handle(cx).is_focused(window)) + } + + fn review_open_file_has_symbols(&self) -> bool { + let Some(model) = self.review_ui.model.as_ref() else { + return false; + }; + let Some(key) = self.smart_review.selected_file.as_ref() else { + return false; + }; + model + .file_index(key) + .and_then(|index| model.files.get(index)) + .is_some_and(|entry| !entry.symbols.is_empty()) + } + + fn legacy_transients(&self) -> LegacyTransients { + LegacyTransients { + delete_confirm: self.delete_confirm.is_some(), + discard_confirm: self.discard_confirm.is_some(), + context_menu: self.context_menu.is_some(), + commit_hash_menu: self.commit_hash_menu.is_some(), + selection_context_menu: self.selection_context_menu.is_some(), + } + } + + fn run_review_command( + &mut self, + command: ReviewCommand, + window: &mut Window, + cx: &mut Context, + ) { + match command { + ReviewCommand::Swallow => {} + ReviewCommand::SetNavigator(mode) => self.review_set_navigator(mode, cx), + ReviewCommand::FocusFilter => self.review_focus_filter(window, cx), + ReviewCommand::ToggleRoles => self.review_toggle_roles_menu(cx), + ReviewCommand::ToggleOutline => { + let outline = self.review_ui.outline_inline; + self.review_set_outline(!outline, cx); + } + ReviewCommand::OpenOverview => self.review_return_to_overview(cx), + ReviewCommand::StepQueue(delta) => self.review_step_queue(delta, cx), + ReviewCommand::StepSymbol(delta) => self.review_step_symbol(delta, cx), + ReviewCommand::PrevCommit => self.prev_commit(cx), + ReviewCommand::NextCommit => self.next_commit(cx), + ReviewCommand::ToggleDetails => self.review_toggle_details(cx), + ReviewCommand::ToggleSplit => self.toggle_view_mode(cx), + ReviewCommand::ToggleWhitespace => self.toggle_ignore_whitespace(cx), + ReviewCommand::OpenSearch => self.open_search(window, cx), + ReviewCommand::SearchNext => self.next_search_match(cx), + ReviewCommand::SearchPrev => self.prev_search_match(cx), + ReviewCommand::CopyPathLine => self.review_copy_path_line(cx), + ReviewCommand::CopyNavigatorRow => self.review_copy_cursor_row(cx), + ReviewCommand::CopySelection => self.copy_selection(cx), + ReviewCommand::ToggleHelp => self.review_toggle_help(cx), + ReviewCommand::CycleRegion => { + let next = match self.review_ui.focus_region { + FocusRegion::Navigator => FocusRegion::Content, + FocusRegion::Content => FocusRegion::Navigator, + }; + self.review_set_focus_region(next, cx); + } + ReviewCommand::MoveCursor(movement) => self.review_move_cursor(movement, cx), + ReviewCommand::ExpandCursor => self.review_set_cursor_dir(true, cx), + ReviewCommand::CollapseCursor => self.review_set_cursor_dir(false, cx), + ReviewCommand::ToggleCursorNode => self.review_toggle_cursor_dir(cx), + ReviewCommand::ActivateCursor => self.review_activate_cursor(cx), + } + } + + /// The overview is navigator-driven, so focus comes back with it. + fn review_return_to_overview(&mut self, cx: &mut Context) { + self.review_open_overview(cx); + self.review_set_focus_region(FocusRegion::Navigator, cx); + } + + /// Move the navigator cursor and open whatever it lands on. + fn review_move_cursor(&mut self, movement: CursorMove, cx: &mut Context) { + let rows = self.navigator_row_ids(); + let Some(row) = next_cursor(&rows, self.review_ui.nav_cursor.as_ref(), movement) else { + return; + }; + self.review_ui.nav_cursor = Some(row.clone()); + self.review_ui.nav_reveal = Some(ScrollStrategy::Nearest); + self.review_open_row(row, cx); + } + + /// Directories only move the cursor; files and items open in the content. + fn review_open_row(&mut self, row: NavRowId, cx: &mut Context) { + match row { + NavRowId::Dir(_) => cx.notify(), + NavRowId::File(key) => self.review_open_file(key, cx), + NavRowId::Item(target) => self.review_open_item(target, cx), + } + } + + fn review_activate_cursor(&mut self, cx: &mut Context) { + let Some(row) = self.review_ui.nav_cursor.clone() else { + return; + }; + // A tree folder has nothing to show, so `↵` behaves like `Space`. + if let NavRowId::Dir(path) = row { + self.review_toggle_dir(&path, cx); + return; + } + self.review_open_row(row, cx); + self.review_set_focus_region(FocusRegion::Content, cx); + } + + fn review_set_cursor_dir(&mut self, expand: bool, cx: &mut Context) { + let Some(NavRowId::Dir(path)) = self.review_ui.nav_cursor.clone() else { + return; + }; + let changed = if expand { + self.review_ui.expanded_dirs.insert(path) + } else { + self.review_ui.expanded_dirs.remove(&path) + }; + if changed { + cx.notify(); + } + } + + fn review_toggle_cursor_dir(&mut self, cx: &mut Context) { + let Some(NavRowId::Dir(path)) = self.review_ui.nav_cursor.clone() else { + return; + }; + self.review_toggle_dir(&path, cx); + } + + fn review_copy_cursor_row(&mut self, cx: &mut Context) { + let text = self + .review_ui + .nav_cursor + .as_ref() + .and_then(|cursor| cursor_row_text(cursor, self.review_ui.model.as_deref())); + let Some(text) = text else { + return; + }; + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } +} + +#[cfg(test)] +mod tests { + use super::super::fixtures; + use super::super::model::AttentionTarget; + use super::super::state::NavRowId; + use super::{ + CancelFlags, CancelStep, ContentView, CursorMove, FocusRegion, KeyContext, + LegacyTransients, NavigatorMode, ReviewCommand, ReviewFileKey, cancel_step, + cursor_row_text, dispatch, next_cursor, next_cursor_index, + }; + use gpui::Modifiers; + + fn overview() -> KeyContext { + KeyContext::default() + } + + fn file_screen() -> KeyContext { + KeyContext { + screen: ContentView::File, + has_symbols: true, + split_available: true, + ..KeyContext::default() + } + } + + fn with(modifiers: Modifiers, ctx: KeyContext) -> KeyContext { + KeyContext { modifiers, ..ctx } + } + + fn control() -> Modifiers { + Modifiers { + control: true, + ..Modifiers::default() + } + } + + fn platform() -> Modifiers { + Modifiers { + platform: true, + ..Modifiers::default() + } + } + + fn shift() -> Modifiers { + Modifiers { + shift: true, + ..Modifiers::default() + } + } + + fn alt() -> Modifiers { + Modifiers { + alt: true, + ..Modifiers::default() + } + } + + fn function() -> Modifiers { + Modifiers { + function: true, + ..Modifiers::default() + } + } + + fn key(path: &str) -> ReviewFileKey { + ReviewFileKey { + old_path: Some(path.to_string()), + new_path: Some(path.to_string()), + } + } + + #[test] + fn the_overview_keys_reach_the_navigator_and_the_filters() { + let ctx = overview(); + assert_eq!( + dispatch(ctx, "1"), + Some(ReviewCommand::SetNavigator(NavigatorMode::Files)) + ); + assert_eq!( + dispatch(ctx, "2"), + Some(ReviewCommand::SetNavigator(NavigatorMode::Attention)) + ); + assert_eq!(dispatch(ctx, "/"), Some(ReviewCommand::FocusFilter)); + assert_eq!(dispatch(ctx, "r"), Some(ReviewCommand::ToggleRoles)); + assert_eq!(dispatch(ctx, "o"), Some(ReviewCommand::OpenOverview)); + assert_eq!(dispatch(ctx, "?"), Some(ReviewCommand::ToggleHelp)); + assert_eq!(dispatch(ctx, "w"), Some(ReviewCommand::ToggleWhitespace)); + } + + #[test] + fn the_file_screen_steps_symbols_and_the_attention_queue() { + let ctx = file_screen(); + assert_eq!(dispatch(ctx, "}"), Some(ReviewCommand::StepSymbol(1))); + assert_eq!(dispatch(ctx, "{"), Some(ReviewCommand::StepSymbol(-1))); + assert_eq!(dispatch(ctx, "]"), Some(ReviewCommand::StepQueue(1))); + assert_eq!(dispatch(ctx, "["), Some(ReviewCommand::StepQueue(-1))); + assert_eq!(dispatch(ctx, "s"), Some(ReviewCommand::ToggleSplit)); + assert_eq!(dispatch(ctx, "d"), Some(ReviewCommand::ToggleDetails)); + assert_eq!(dispatch(ctx, "y"), Some(ReviewCommand::CopyPathLine)); + } + + #[test] + fn keys_that_act_on_the_open_file_stay_on_the_file_screen() { + // The overview must never act on whatever file was open last. + let ctx = KeyContext { + has_symbols: true, + ..overview() + }; + for pressed in ["d", "y", "}", "{"] { + assert_eq!( + dispatch(ctx, pressed), + None, + "{pressed} has no meaning on the overview" + ); + } + } + + #[test] + fn a_commit_list_takes_the_brackets_back_for_the_commit_bar() { + let ctx = KeyContext { + has_commits: true, + ..file_screen() + }; + assert_eq!(dispatch(ctx, "]"), Some(ReviewCommand::NextCommit)); + assert_eq!(dispatch(ctx, "["), Some(ReviewCommand::PrevCommit)); + assert_eq!( + dispatch(ctx, "}"), + Some(ReviewCommand::StepSymbol(1)), + "the braces still step symbols" + ); + } + + #[test] + fn shifted_punctuation_is_accepted_in_both_reported_forms() { + let ctx = with(shift(), file_screen()); + assert_eq!(dispatch(ctx, "]"), Some(ReviewCommand::StepSymbol(1))); + assert_eq!(dispatch(ctx, "["), Some(ReviewCommand::StepSymbol(-1))); + assert_eq!(dispatch(ctx, "/"), Some(ReviewCommand::ToggleHelp)); + } + + #[test] + fn unavailable_actions_are_never_dispatched() { + let ctx = KeyContext { + screen: ContentView::File, + ..KeyContext::default() + }; + assert_eq!(dispatch(ctx, "}"), None, "no symbols in the open file"); + assert_eq!(dispatch(ctx, "{"), None); + assert_eq!(dispatch(ctx, "s"), None, "no split toggle on this screen"); + assert_eq!(dispatch(ctx, "n"), None, "search is closed"); + } + + #[test] + fn search_stepping_needs_an_open_search() { + let open = KeyContext { + search_open: true, + ..file_screen() + }; + assert_eq!(dispatch(open, "n"), Some(ReviewCommand::SearchNext)); + assert_eq!( + dispatch(with(shift(), open), "n"), + Some(ReviewCommand::SearchPrev) + ); + } + + #[test] + fn find_opens_the_diff_search_only_on_the_file_screen() { + assert_eq!( + dispatch(with(control(), file_screen()), "f"), + Some(ReviewCommand::OpenSearch) + ); + assert_eq!( + dispatch(with(platform(), file_screen()), "f"), + Some(ReviewCommand::OpenSearch) + ); + assert_eq!( + dispatch(with(control(), overview()), "f"), + Some(ReviewCommand::FocusFilter) + ); + } + + #[test] + fn copy_follows_the_focused_region() { + let navigator = with(control(), file_screen()); + assert_eq!( + dispatch(navigator, "c"), + Some(ReviewCommand::CopyNavigatorRow) + ); + let content = KeyContext { + focus: FocusRegion::Content, + ..navigator + }; + assert_eq!(dispatch(content, "c"), Some(ReviewCommand::CopySelection)); + } + + #[test] + fn an_accelerator_never_triggers_a_plain_review_shortcut() { + // `Cmd+?` and `Cmd+}` used to fall into the punctuation block. + for modifiers in [control(), platform()] { + let ctx = with(modifiers, file_screen()); + for pressed in [ + "?", "}", "{", "/", "]", "[", "1", "2", "r", "o", "d", "w", "y", "s", + ] { + assert_eq!( + dispatch(ctx, pressed), + None, + "an accelerator plus {pressed} is not a review binding" + ); + } + } + } + + #[test] + fn alt_and_function_variants_of_review_keys_do_nothing() { + for modifiers in [alt(), function()] { + let ctx = with(modifiers, file_screen()); + for pressed in [ + "w", "s", "]", "[", "}", "{", "1", "2", "r", "o", "d", "y", "/", + ] { + assert_eq!( + dispatch(ctx, pressed), + Some(ReviewCommand::Swallow), + "{pressed} must not reach the legacy shortcut" + ); + } + } + } + + #[test] + fn alt_arrows_are_reserved_and_never_move_the_cursor() { + for focus in [FocusRegion::Navigator, FocusRegion::Content] { + let ctx = KeyContext { + focus, + modifiers: alt(), + ..file_screen() + }; + for pressed in ["up", "down", "left", "right"] { + assert_eq!( + dispatch(ctx, pressed), + Some(ReviewCommand::Swallow), + "{pressed} with alt must not step files either" + ); + } + } + } + + #[test] + fn shifted_letters_are_not_plain_shortcuts() { + let ctx = with(shift(), file_screen()); + for pressed in ["w", "s", "d", "y", "r", "o", "1", "2"] { + assert_eq!( + dispatch(ctx, pressed), + Some(ReviewCommand::Swallow), + "shift plus {pressed} is unbound" + ); + } + } + + #[test] + fn arrows_move_the_cursor_only_while_the_navigator_has_focus() { + let ctx = overview(); + assert_eq!( + dispatch(ctx, "up"), + Some(ReviewCommand::MoveCursor(CursorMove::Prev)) + ); + assert_eq!( + dispatch(ctx, "down"), + Some(ReviewCommand::MoveCursor(CursorMove::Next)) + ); + assert_eq!( + dispatch(ctx, "home"), + Some(ReviewCommand::MoveCursor(CursorMove::First)) + ); + assert_eq!( + dispatch(ctx, "end"), + Some(ReviewCommand::MoveCursor(CursorMove::Last)) + ); + assert_eq!(dispatch(ctx, "left"), Some(ReviewCommand::CollapseCursor)); + assert_eq!(dispatch(ctx, "right"), Some(ReviewCommand::ExpandCursor)); + assert_eq!( + dispatch(ctx, "space"), + Some(ReviewCommand::ToggleCursorNode) + ); + assert_eq!(dispatch(ctx, "enter"), Some(ReviewCommand::ActivateCursor)); + + let content = KeyContext { + focus: FocusRegion::Content, + ..ctx + }; + for pressed in ["up", "down", "left", "right", "home", "end", "enter"] { + assert_eq!( + dispatch(content, pressed), + None, + "{pressed} must fall through to the diff pane" + ); + } + } + + #[test] + fn a_focused_field_makes_every_shortcut_inert() { + let ctx = KeyContext { + input_focused: true, + search_open: true, + ..file_screen() + }; + for pressed in [ + "1", "2", "r", "o", "d", "w", "s", "y", "n", "/", "?", "[", "]", "{", "}", "up", + "down", "left", "right", "space", "enter", + ] { + assert_eq!( + dispatch(ctx, pressed), + Some(ReviewCommand::Swallow), + "{pressed} must not act while a field has focus" + ); + } + assert_eq!( + dispatch(with(control(), ctx), "f"), + Some(ReviewCommand::Swallow), + "even find stays out of a focused field" + ); + } + + #[test] + fn f6_is_the_only_region_switch_that_reaches_the_review() { + for base in [overview(), file_screen()] { + for input_focused in [false, true] { + let ctx = KeyContext { + input_focused, + ..base + }; + assert_eq!( + dispatch(ctx, "f6"), + Some(ReviewCommand::CycleRegion), + "F6 works even from inside a field" + ); + // `Ctrl+1` / `Ctrl+2` are global app bindings; the review must + // not claim them. A focused field still swallows them first. + let expected = if input_focused { + Some(ReviewCommand::Swallow) + } else { + None + }; + assert_eq!(dispatch(with(control(), ctx), "1"), expected); + assert_eq!(dispatch(with(control(), ctx), "2"), expected); + } + } + } + + #[test] + fn the_navigator_modes_are_always_handled() { + for screen in [ContentView::Overview, ContentView::File] { + for focus in [FocusRegion::Navigator, FocusRegion::Content] { + for input_focused in [false, true] { + let ctx = KeyContext { + screen, + focus, + input_focused, + ..KeyContext::default() + }; + assert!(dispatch(ctx, "1").is_some(), "1 must never reach the app"); + assert!(dispatch(ctx, "2").is_some(), "2 must never reach the app"); + } + } + } + } + + #[test] + fn unknown_keys_leave_the_legacy_path_alone() { + let ctx = file_screen(); + assert_eq!(dispatch(ctx, "tab"), None); + assert_eq!(dispatch(ctx, "escape"), None); + assert_eq!(dispatch(ctx, "q"), None); + assert_eq!( + dispatch(with(control(), ctx), "a"), + None, + "select-all stays with the diff pane" + ); + } + + #[test] + fn the_esc_ladder_runs_in_order() { + let all = CancelFlags { + help_open: true, + menu_open: true, + filter_focused: true, + search_open: true, + legacy_transient: true, + content_is_file: true, + }; + assert_eq!(cancel_step(all), CancelStep::CloseHelp); + let no_help = CancelFlags { + help_open: false, + ..all + }; + assert_eq!(cancel_step(no_help), CancelStep::DismissMenu); + let no_menu = CancelFlags { + menu_open: false, + ..no_help + }; + assert_eq!(cancel_step(no_menu), CancelStep::ClearFilter); + let blurred = CancelFlags { + filter_focused: false, + ..no_menu + }; + assert_eq!(cancel_step(blurred), CancelStep::CloseSearch); + let no_search = CancelFlags { + search_open: false, + ..blurred + }; + assert_eq!(cancel_step(no_search), CancelStep::DismissLegacy); + let no_legacy = CancelFlags { + legacy_transient: false, + ..no_search + }; + assert_eq!(cancel_step(no_legacy), CancelStep::BackToOverview); + let on_overview = CancelFlags { + content_is_file: false, + ..no_legacy + }; + assert_eq!(cancel_step(on_overview), CancelStep::Unhandled); + } + + #[test] + fn esc_clears_a_focused_filter_before_it_closes_anything() { + let flags = CancelFlags { + filter_focused: true, + search_open: true, + content_is_file: true, + ..CancelFlags::default() + }; + assert_eq!(cancel_step(flags), CancelStep::ClearFilter); + } + + #[test] + fn every_legacy_transient_reaches_the_ladder() { + assert!(!LegacyTransients::default().any_open()); + let each = [ + LegacyTransients { + delete_confirm: true, + ..LegacyTransients::default() + }, + LegacyTransients { + discard_confirm: true, + ..LegacyTransients::default() + }, + LegacyTransients { + context_menu: true, + ..LegacyTransients::default() + }, + LegacyTransients { + commit_hash_menu: true, + ..LegacyTransients::default() + }, + LegacyTransients { + selection_context_menu: true, + ..LegacyTransients::default() + }, + ]; + for transient in each { + assert!(transient.any_open(), "{transient:?} must stop Esc"); + assert_eq!( + cancel_step(CancelFlags { + legacy_transient: transient.any_open(), + ..CancelFlags::default() + }), + CancelStep::DismissLegacy + ); + } + } + + #[test] + fn cursor_steps_clamp_at_both_ends() { + assert_eq!(next_cursor_index(0, None, CursorMove::Next), None); + assert_eq!(next_cursor_index(3, None, CursorMove::Next), Some(0)); + assert_eq!(next_cursor_index(3, None, CursorMove::Prev), Some(0)); + assert_eq!(next_cursor_index(3, Some(0), CursorMove::Prev), Some(0)); + assert_eq!(next_cursor_index(3, Some(1), CursorMove::Next), Some(2)); + assert_eq!(next_cursor_index(3, Some(2), CursorMove::Next), Some(2)); + assert_eq!(next_cursor_index(3, Some(1), CursorMove::First), Some(0)); + assert_eq!(next_cursor_index(3, Some(1), CursorMove::Last), Some(2)); + } + + #[test] + fn a_cursor_the_rows_no_longer_hold_restarts_at_the_top() { + let rows = vec![ + NavRowId::Dir("src".into()), + NavRowId::File(key("src/a.rs")), + NavRowId::File(key("src/b.rs")), + ]; + let gone = NavRowId::File(key("dropped.rs")); + assert_eq!( + next_cursor(&rows, Some(&gone), CursorMove::Next), + Some(rows[0].clone()), + "a filtered-out cursor must not silently jump to the last row" + ); + assert_eq!( + next_cursor(&rows, Some(&gone), CursorMove::Prev), + Some(rows[0].clone()) + ); + assert_eq!( + next_cursor(&rows, Some(&rows[1]), CursorMove::Next), + Some(rows[2].clone()) + ); + assert_eq!(next_cursor(&[], None, CursorMove::Next), None); + } + + #[test] + fn a_cursor_row_copies_its_path_or_its_qualified_symbol() { + assert_eq!( + cursor_row_text(&NavRowId::Dir("packages/core".into()), None).as_deref(), + Some("packages/core") + ); + assert_eq!( + cursor_row_text( + &NavRowId::Item(AttentionTarget::Directory("packages/core".into())), + None + ) + .as_deref(), + Some("packages/core") + ); + assert_eq!( + cursor_row_text(&NavRowId::File(key("src/lib.rs")), None).as_deref(), + Some("src/lib.rs"), + "the head path, not the `old → new` label" + ); + let deleted = ReviewFileKey { + old_path: Some("src/gone.rs".into()), + new_path: None, + }; + assert_eq!( + cursor_row_text(&NavRowId::Item(AttentionTarget::File(deleted)), None).as_deref(), + Some("src/gone.rs"), + "a deletion falls back to the base side" + ); + assert_eq!( + cursor_row_text( + &NavRowId::Item(AttentionTarget::Symbol { + file: key("src/lib.rs"), + change_index: 0, + }), + None + ), + None, + "a symbol needs the model to name it" + ); + } + + #[test] + fn a_symbol_row_copies_the_name_the_model_qualified() { + let model = fixtures::model(); + let (file, symbol) = model + .files + .iter() + .find_map(|entry| entry.symbols.first().map(|symbol| (entry, symbol))) + .expect("the fixture reaches structure for at least one file"); + assert_eq!( + cursor_row_text( + &NavRowId::Item(AttentionTarget::Symbol { + file: file.key.clone(), + change_index: symbol.change_index, + }), + Some(&model) + ) + .as_deref(), + Some(symbol.qualified.as_str()) + ); + assert_eq!( + cursor_row_text( + &NavRowId::Item(AttentionTarget::Symbol { + file: file.key.clone(), + change_index: usize::MAX, + }), + Some(&model) + ), + None, + "an index the file no longer has copies nothing" + ); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/labels/calls.rs b/crates/okena-views-git/src/diff_viewer/review_ui/labels/calls.rs new file mode 100644 index 000000000..5e915788d --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/labels/calls.rs @@ -0,0 +1,288 @@ +//! Call-change and signature wording — spec §9. Shared by the file view's +//! details bar and the navigator's inline outline, so both read the same. +//! Pure; no GPUI. + +use super::super::model::CallRow; +use okena_review::CallChangeKind; + +const DOT: &str = " \u{00B7} "; +const ARROW: &str = "\u{2192}"; +/// A call outside every branch, when the other side of a move had one. +const TOP_LEVEL: &str = "top level"; + +/// `+`, `−` or `~` — which way the call went. +pub(crate) fn call_marker(change: CallChangeKind) -> &'static str { + match change { + CallChangeKind::Added => "+", + CallChangeKind::Removed => "\u{2212}", + CallChangeKind::Modified => "~", + } +} + +/// `retry(3) → (retries)` for a modified call whose arguments changed, +/// `callee(args)` otherwise — a call that only moved between branches keeps +/// its one text, and `call_context` tells the move. +pub(crate) fn call_text(row: &CallRow) -> String { + let old = row.old_args.as_deref().unwrap_or_default(); + let new = row.new_args.as_deref().unwrap_or_default(); + match row.change { + CallChangeKind::Added => format!("{}{new}", row.callee), + CallChangeKind::Removed => format!("{}{old}", row.callee), + CallChangeKind::Modified if old == new => format!("{}{new}", row.callee), + CallChangeKind::Modified => format!("{}{old} {ARROW} {new}", row.callee), + } +} + +/// `in condition` — the branch the call sits in, outermost first; a call that +/// moved reads `in loop → loop · closure`. Top level on both sides says nothing. +pub(crate) fn call_context(row: &CallRow) -> Option { + let stack = |context: &[String]| { + if context.is_empty() { + TOP_LEVEL.to_string() + } else { + context.join(DOT) + } + }; + match row.old_context.as_deref() { + Some(old) => Some(format!("in {} {ARROW} {}", stack(old), stack(&row.context))), + None if row.context.is_empty() => None, + None => Some(format!("in {}", stack(&row.context))), + } +} + +/// Widest a call reads before it is cut. +pub(crate) const CALL_TEXT_CHARS: usize = 96; +const ELLIPSIS: char = '\u{2026}'; + +/// One call as the details block lists it: one line, whatever the source did. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CallLine { + pub change: CallChangeKind, + pub text: String, + pub context: Option, + /// How many identical occurrences this one line stands for. + pub count: usize, +} + +impl CallLine { + /// `emit(value) ×4` — the count only when the call repeats. + pub(crate) fn text_with_count(&self) -> String { + if self.count <= 1 { + return self.text.clone(); + } + format!("{} \u{00D7}{}", self.text, self.count) + } +} + +/// The lines the details block shows, and how many it left out. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CallLines { + pub shown: Vec, + pub hidden: usize, +} + +impl CallLines { + /// `… 12 more`, or nothing when every call is listed. + pub(crate) fn hidden_note(&self) -> Option { + (self.hidden > 0).then(|| format!("{ELLIPSIS} {} more", self.hidden)) + } +} + +/// Removed and modified calls first — they are what changed behaviour; then +/// added, each side in source order. Occurrences that read identically share +/// one line with a count, so a callee used four times is one line and not +/// four. At most `limit` lines; `hidden` counts the lines left out. +pub(crate) fn call_lines(calls: &[CallRow], limit: usize) -> CallLines { + let mut ordered: Vec<&CallRow> = calls.iter().collect(); + ordered.sort_by_key(|row| match row.change { + CallChangeKind::Removed => 0, + CallChangeKind::Modified => 1, + CallChangeKind::Added => 2, + }); + let mut lines: Vec = Vec::new(); + for row in ordered { + let line = CallLine { + change: row.change, + text: one_line(&call_text(row), CALL_TEXT_CHARS), + context: call_context(row), + count: 1, + }; + match lines.iter_mut().find(|kept| { + kept.change == line.change && kept.text == line.text && kept.context == line.context + }) { + Some(kept) => kept.count = kept.count.saturating_add(1), + None => lines.push(line), + } + } + let hidden = lines.len().saturating_sub(limit); + lines.truncate(limit); + CallLines { + shown: lines, + hidden, + } +} + +/// Collapse every whitespace run to one space and cut at `max_chars`. +fn one_line(text: &str, max_chars: usize) -> String { + let joined = text.split_whitespace().collect::>().join(" "); + if joined.chars().count() <= max_chars { + return joined; + } + let mut out: String = joined.chars().take(max_chars.saturating_sub(1)).collect(); + out.push(ELLIPSIS); + out +} + +/// `(&self, t) → (&self, t, cx)` — the signature pair on one line, for a +/// column too narrow for the two-line token diff. +pub(crate) fn signature_pair(old: &str, new: &str, max_chars: usize) -> String { + one_line(&format!("{old} {ARROW} {new}"), max_chars) +} + +#[cfg(test)] +mod tests { + use super::super::super::model::CallRow; + use super::{call_context, call_lines, call_marker, call_text, one_line, signature_pair}; + use okena_review::CallChangeKind; + + #[test] + fn call_rows_read_as_signed_lines_with_their_branch() { + let row = |change, old: Option<&str>, new: Option<&str>, context: Vec| CallRow { + change, + callee: "retry".into(), + old_args: old.map(str::to_string), + new_args: new.map(str::to_string), + context, + old_context: None, + }; + + let added = row(CallChangeKind::Added, None, Some("(value)"), Vec::new()); + assert_eq!(call_marker(added.change), "+"); + assert_eq!(call_text(&added), "retry(value)"); + assert_eq!(call_context(&added), None); + + let removed = row( + CallChangeKind::Removed, + Some("(input)"), + None, + vec!["error branch".into()], + ); + assert_eq!(call_marker(removed.change), "\u{2212}"); + assert_eq!(call_text(&removed), "retry(input)"); + assert_eq!(call_context(&removed), Some("in error branch".to_string())); + + let modified = row( + CallChangeKind::Modified, + Some("(3)"), + Some("(retries)"), + vec!["condition".into(), "loop".into()], + ); + assert_eq!(call_marker(modified.change), "~"); + assert_eq!(call_text(&modified), "retry(3) \u{2192} (retries)"); + assert_eq!( + call_context(&modified), + Some("in condition \u{00B7} loop".to_string()) + ); + + // Same arguments, moved into a closure: one text, the move in the context. + let mut moved = row( + CallChangeKind::Modified, + Some("(3)"), + Some("(3)"), + vec!["loop".into(), "closure".into()], + ); + moved.old_context = Some(vec!["loop".into()]); + assert_eq!(call_text(&moved), "retry(3)"); + assert_eq!( + call_context(&moved), + Some("in loop \u{2192} loop \u{00B7} closure".to_string()) + ); + moved.old_context = Some(Vec::new()); + assert_eq!( + call_context(&moved), + Some("in top level \u{2192} loop \u{00B7} closure".to_string()) + ); + } + + #[test] + fn call_lines_read_one_line_each_and_list_what_changed_first() { + let row = |change: CallChangeKind, callee: &str, args: &str| CallRow { + change, + callee: callee.to_string(), + old_args: Some(args.to_string()), + new_args: Some(args.to_string()), + context: Vec::new(), + old_context: None, + }; + let calls = vec![ + row(CallChangeKind::Added, "log", "(a)"), + row( + CallChangeKind::Removed, + "result.set", + "(name, {\n fields: [],\n})", + ), + row(CallChangeKind::Added, "warn", "(b)"), + row(CallChangeKind::Modified, "retry", "(3)"), + ]; + let lines = call_lines(&calls, 3); + assert_eq!(lines.hidden, 1); + assert_eq!(lines.hidden_note().as_deref(), Some("\u{2026} 1 more")); + assert_eq!( + lines + .shown + .iter() + .map(|line| line.change) + .collect::>(), + vec![ + CallChangeKind::Removed, + CallChangeKind::Modified, + CallChangeKind::Added + ] + ); + assert_eq!(lines.shown[0].text, "result.set(name, { fields: [], })"); + assert_eq!(lines.shown[2].text, "log(a)"); + assert_eq!(call_lines(&calls, 8).hidden_note(), None); + } + + #[test] + fn one_line_collapses_whitespace_and_cuts_with_an_ellipsis() { + assert_eq!(one_line("a b\n\t c", 10), "a b c"); + assert_eq!(one_line("abcdefghij", 10), "abcdefghij"); + assert_eq!(one_line("abcdefghijk", 10), "abcdefghi\u{2026}"); + } + + #[test] + fn identical_occurrences_share_one_line_with_a_count() { + let call = |callee: &str| CallRow { + change: CallChangeKind::Added, + callee: callee.to_string(), + old_args: None, + new_args: Some("(&project_id)".to_string()), + context: vec!["match arm".into()], + old_context: None, + }; + let calls = vec![call("strip"), call("strip"), call("strip"), call("keep")]; + let lines = call_lines(&calls, 6); + assert_eq!(lines.shown.len(), 2, "three of them are the same call"); + assert_eq!(lines.shown[0].count, 3); + assert_eq!( + lines.shown[0].text_with_count(), + "strip(&project_id) \u{00D7}3" + ); + assert_eq!(lines.shown[1].text_with_count(), "keep(&project_id)"); + assert_eq!(lines.hidden, 0); + + // The limit counts lines, not occurrences. + let one = call_lines(&calls, 1); + assert_eq!(one.shown.len(), 1); + assert_eq!(one.hidden, 1); + } + + #[test] + fn a_signature_pair_reads_on_one_line() { + assert_eq!( + signature_pair("fn f(&self, t)", "fn f(&self,\n t, cx)", 96), + "fn f(&self, t) \u{2192} fn f(&self, t, cx)" + ); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/labels/facts.rs b/crates/okena-views-git/src/diff_viewer/review_ui/labels/facts.rs new file mode 100644 index 000000000..ef082098e --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/labels/facts.rs @@ -0,0 +1,726 @@ +//! Overview fact sentences — unit O. Spec §8: every number the Overview shows is +//! worded here, so the render pass only paints finished strings. + +use super::super::model::{AlsoFact, CommitsFact, MovesFact, PublicApiFact, TestsFact}; +use super::super::state::MECHANICAL_RESIDUAL_LINES; +use super::format_lines; +use super::reasons::short_language; +use super::status::files_phrase; + +const DOT: &str = " \u{00B7} "; +const DASH: &str = " \u{2014} "; +/// Counts are lower bounds while coverage is partial — spec §2. +const AT_LEAST: &str = "\u{2265} "; + +pub(crate) const GLANCE_HEADER: &str = "CHANGE AT A GLANCE"; +pub(crate) const GLANCE_HINT: &str = "changed lines = added + deleted"; +pub(crate) const START_HERE_HEADER: &str = "START HERE"; +pub(crate) const START_HERE_HINT: &str = "one ordered list \u{00B7} every row names its reasons"; +pub(crate) const TIERS_FOOTER: &str = "Tiers: contract \u{2192} behaviour \u{2192} volume \ + \u{2192} git facts \u{2192} everything else. Same order in the navigator's Attention \ + mode; ] steps through it from any file."; +/// Shown instead of the headline when the comparison changed nothing. +pub(crate) const NOTHING_CHANGED: &str = "No files changed"; + +pub(crate) const PUBLIC_API: &str = "Public API"; +pub(crate) const TESTS: &str = "Tests"; +pub(crate) const MOVES: &str = "Moves"; +pub(crate) const COMMITS: &str = "Commits"; +pub(crate) const ALSO: &str = "Also"; + +pub(crate) const ATTENTION_LINK: &str = "\u{2192} Attention"; +pub(crate) const SHOW_LINK: &str = "show"; +pub(crate) const FILTER_LINK: &str = "filter"; +const SHOW_LEDGER_LINK: &str = "show ledger"; +const HIDE_LEDGER_LINK: &str = "hide ledger"; +/// Marks a commit with more than one parent in the ledger. +pub(crate) const MERGE_BADGE: &str = "merge"; +pub(crate) const NO_SUPPORTED_LANGUAGE: &str = "no supported language in this comparison"; +/// A minus in front of `added + deleted` would misread as a net, so say it. +const MOSTLY_DELETIONS: &str = "mostly deletions"; +/// A directory row with no path of its own — the comparison touched the root. +const REPOSITORY_ROOT: &str = "repository root"; + +/// `12 files` from a `usize` count, without a lossy cast. +fn count_files(files: usize) -> String { + files_phrase(u64::try_from(files).unwrap_or(u64::MAX)) +} + +/// A count grouped in threes, without a lossy cast. +fn format_count(count: usize) -> String { + format_lines(u64::try_from(count).unwrap_or(u64::MAX)) +} + +/// `1 line` / `6 118 lines`. +pub(crate) fn lines_phrase(lines: u64) -> String { + if lines == 1 { + "1 line".to_string() + } else { + format!("{} lines", format_lines(lines)) + } +} + +/// `Implementation 15 692 lines`, or `… · mostly deletions` when the role lost +/// more lines than it gained. The number stays the changed-line total. +pub(crate) fn headline_lines(role: &str, lines: u64, deletion_heavy: bool) -> String { + let unit = if lines == 1 { "line" } else { "lines" }; + let headline = format!("{role} {} {unit}", format_lines(lines)); + if deletion_heavy { + format!("{headline}{DOT}{MOSTLY_DELETIONS}") + } else { + headline + } +} + +/// `Implementation 12 files` — the fallback when nothing has a line count. +pub(crate) fn headline_files(role: &str, files: usize) -> String { + format!("{role} {}", count_files(files)) +} + +/// `45 % of 34 640 · 97 files`. +pub(crate) fn headline_share_of_lines(percent: f32, total_lines: u64, files: usize) -> String { + format!( + "{} of {}{DOT}{}", + rounded_percent(percent), + format_lines(total_lines), + count_files(files) + ) +} + +/// `40 % of 30 files`. +pub(crate) fn headline_share_of_files(percent: f32, total_files: usize) -> String { + format!( + "{} of {}", + rounded_percent(percent), + count_files(total_files) + ) +} + +/// Headline share, without the decimal the legend carries. +fn rounded_percent(percent: f32) -> String { + format!("{percent:.0} %") +} + +/// `45.3 %` — one decimal. A share too small to round to a tenth says so, and a +/// role with no share at all keeps an empty cell — spec §2, no zero cells. +pub(crate) fn percent_label(percent: f32) -> String { + if percent <= 0.0 { + return String::new(); + } + if percent < 0.05 { + return "< 0.1 %".to_string(); + } + format!("{percent:.1} %") +} + +/// Legend count column: `97 files`. +pub(crate) fn legend_files(files: usize) -> String { + count_files(files) +} + +/// The legend's file cell. Lines that came from inside files of another role — +/// tests written in the file they test — say so rather than claiming files: +/// `in 12 files`, or `4 files + 12` when the role also has files of its own. +pub(crate) fn legend_file_cell(files: usize, inline_files: usize) -> String { + match (files, inline_files) { + (_, 0) => count_files(files), + (0, inline) => format!("in {}", count_files(inline)), + (_, inline) => format!("{} + {}", count_files(files), format_count(inline)), + } +} + +/// `≥ 3 removed · ≥ 12 signatures changed · ≥ 34 added — analyzed subset, TS/TSX`. +/// Empty when the fact carries nothing worth a line. +pub(crate) fn public_api_sentence(fact: &PublicApiFact) -> String { + if fact.no_supported_language { + return NO_SUPPORTED_LANGUAGE.to_string(); + } + let at_least = if fact.lower_bound { AT_LEAST } else { "" }; + let mut parts: Vec = Vec::with_capacity(3); + if fact.removed > 0 { + parts.push(format!("{at_least}{} removed", format_lines(fact.removed))); + } + if fact.signatures > 0 { + let unit = if fact.signatures == 1 { + "signature" + } else { + "signatures" + }; + parts.push(format!( + "{at_least}{} {unit} changed", + format_lines(fact.signatures) + )); + } + if fact.added > 0 { + parts.push(format!("{at_least}{} added", format_lines(fact.added))); + } + if parts.is_empty() { + return String::new(); + } + let counts = parts.join(DOT); + if !fact.lower_bound { + return counts; + } + let subset = language_slashes(&fact.languages); + if subset.is_empty() { + format!("{counts}{DASH}analyzed subset") + } else { + format!("{counts}{DASH}analyzed subset, {subset}") + } +} + +/// `TS/TSX` — chip-sized language names, the way the analyzed subset is named. +fn language_slashes(languages: &[String]) -> String { + languages + .iter() + .map(|language| short_language(language)) + .collect::>() + .join("/") +} + +/// `Tests changed next to 4 of 6 implementation directories · none next to +/// packages/workers/src (26 files, 6 118 lines)`. +/// +/// "tests" and not "test files": in Rust they usually live in the file they +/// test, and those count here too. +pub(crate) fn tests_sentence(fact: &TestsFact) -> String { + let unit = if fact.impl_dirs == 1 { + "directory" + } else { + "directories" + }; + // `0 of 3` is a zero cell; when nothing has tests, say that instead. + let none_at_all = fact.with_tests == 0; + let head = if none_at_all { + format!( + "no tests changed next to any of the {} implementation {unit}", + format_count(fact.impl_dirs) + ) + } else { + format!( + "Tests changed next to {} of {} implementation {unit}", + format_count(fact.with_tests), + format_count(fact.impl_dirs) + ) + }; + let Some(first) = fact.without.first() else { + return head; + }; + let named = format!( + "{} ({}, {})", + directory_name(&first.path), + count_files(first.files), + lines_phrase(first.lines) + ); + if none_at_all { + // "any of the N" already covers the rest, so only the biggest is named. + return format!("{head}{DOT}largest {named}"); + } + let mut sentence = format!("{head}{DOT}none next to {named}"); + let rest = fact.without.len().saturating_sub(1); + if rest > 0 { + sentence.push_str(&format!(" and {} more", format_count(rest))); + } + sentence +} + +fn directory_name(path: &str) -> &str { + if path.is_empty() { + REPOSITORY_ROOT + } else { + path + } +} + +/// `21 high-similarity moves · 17 likely mechanical (≤ 20 residual lines) · +/// 4 with edits, ranked below`. +pub(crate) fn moves_sentence(fact: &MovesFact) -> String { + let unit = if fact.total == 1 { "move" } else { "moves" }; + let mut parts = vec![format!( + "{} high-similarity {unit}", + format_count(fact.total) + )]; + if fact.likely_mechanical > 0 { + parts.push(format!( + "{} likely mechanical (\u{2264} {MECHANICAL_RESIDUAL_LINES} residual lines)", + format_count(fact.likely_mechanical) + )); + } + if fact.with_edits > 0 { + parts.push(format!( + "{} with edits, ranked below", + format_count(fact.with_edits) + )); + } + parts.join(DOT) +} + +/// `14 · 1 merge · Ada, Bob · 6 days · 305b0f0 … 9a7be3f`. The label supplies the noun. +pub(crate) fn commits_sentence(fact: &CommitsFact) -> String { + let mut parts = vec![format_count(fact.count)]; + if fact.merges > 0 { + let unit = if fact.merges == 1 { "merge" } else { "merges" }; + parts.push(format!("{} {unit}", format_count(fact.merges))); + } + let authors = authors_phrase(&fact.authors); + if !authors.is_empty() { + parts.push(authors); + } + let span = span_phrase(fact.span_secs); + if !span.is_empty() { + parts.push(span); + } + let range = sha_range(&fact.first_sha, &fact.last_sha); + if !range.is_empty() { + parts.push(range); + } + parts.join(DOT) +} + +/// Three names at most; the rest become `+2`. +fn authors_phrase(authors: &[String]) -> String { + const SHOWN: usize = 3; + if authors.is_empty() { + return String::new(); + } + let named = authors + .iter() + .take(SHOWN) + .cloned() + .collect::>() + .join(", "); + let rest = authors.len().saturating_sub(SHOWN); + if rest == 0 { + named + } else { + format!("{named} +{}", format_count(rest)) + } +} + +/// How long the branch took, in the coarsest unit that still says something. +fn span_phrase(seconds: i64) -> String { + const MINUTE: i64 = 60; + const HOUR: i64 = 60 * MINUTE; + const DAY: i64 = 24 * HOUR; + if seconds <= 0 { + return String::new(); + } + if seconds < MINUTE { + return "under a minute".to_string(); + } + let (value, singular, plural): (i64, &str, &str) = if seconds < HOUR { + (seconds / MINUTE, "minute", "minutes") + } else if seconds < DAY { + (seconds / HOUR, "hour", "hours") + } else { + (seconds / DAY, "day", "days") + }; + let unit = if value == 1 { singular } else { plural }; + let value = u64::try_from(value).unwrap_or(0); + format!("{} {unit}", format_lines(value)) +} + +/// `305b0f0 … 9a7be3f`; a single commit shows one sha. +fn sha_range(first: &str, last: &str) -> String { + if first.is_empty() && last.is_empty() { + return String::new(); + } + if first == last || last.is_empty() { + return first.to_string(); + } + if first.is_empty() { + return last.to_string(); + } + format!("{first} \u{2026} {last}") +} + +/// `2 lockfiles · 1 submodule pointer · 3 binary files · 1 deleted implementation file`. +pub(crate) fn also_sentence(fact: &AlsoFact) -> String { + let mut parts: Vec = Vec::with_capacity(4); + if fact.lockfiles > 0 { + parts.push(counted(fact.lockfiles, "lockfile", "lockfiles")); + } + if fact.submodules > 0 { + parts.push(counted( + fact.submodules, + "submodule pointer", + "submodule pointers", + )); + } + if fact.binaries > 0 { + parts.push(counted(fact.binaries, "binary file", "binary files")); + } + if fact.deleted_impl > 0 { + parts.push(counted( + fact.deleted_impl, + "deleted implementation file", + "deleted implementation files", + )); + } + parts.join(DOT) +} + +fn counted(count: usize, singular: &str, plural: &str) -> String { + let unit = if count == 1 { singular } else { plural }; + format!("{} {unit}", format_count(count)) +} + +/// `structure reached 63 of 97 implementation files (first 200 in path order) +/// — the rest ranked from git facts`. +pub(crate) fn caveat_sentence( + reached: u64, + total: u64, + implementation: bool, + path_order_first: Option, +) -> String { + let noun = if implementation { + "implementation files" + } else { + "files" + }; + let bias = match path_order_first { + Some(first) if first > 0 => format!(" (first {} in path order)", format_lines(first)), + _ => String::new(), + }; + format!( + "structure reached {} of {} {noun}{bias}{DASH}the rest ranked from git facts", + format_lines(reached), + format_lines(total) + ) +} + +/// The commit-ledger link says what the click does next. +pub(crate) fn ledger_link(open: bool) -> &'static str { + if open { + HIDE_LEDGER_LINK + } else { + SHOW_LEDGER_LINK + } +} + +/// `all 236 → Attention` — the link to the whole ordered list. +pub(crate) fn all_attention(count: usize) -> String { + format!("all {} {ATTENTION_LINK}", format_count(count)) +} + +#[cfg(test)] +mod tests { + use super::super::super::model::{ + AlsoFact, CommitsFact, DirRef, MovesFact, PublicApiFact, TestsFact, + }; + use super::{ + all_attention, also_sentence, caveat_sentence, commits_sentence, headline_files, + headline_lines, headline_share_of_files, headline_share_of_lines, ledger_link, + moves_sentence, percent_label, public_api_sentence, tests_sentence, + }; + + fn public_api(removed: u64, signatures: u64, added: u64, lower_bound: bool) -> PublicApiFact { + PublicApiFact { + removed, + signatures, + added, + lower_bound, + languages: languages(), + no_supported_language: false, + } + } + + /// The same counts without a language list, so the subset clause drops out. + fn without_languages(fact: &PublicApiFact) -> PublicApiFact { + PublicApiFact { + languages: Vec::new(), + ..fact.clone() + } + } + + fn languages() -> Vec { + vec!["TypeScript".to_string(), "TSX".to_string()] + } + + #[test] + fn the_headline_names_the_role_then_its_share() { + assert_eq!( + headline_lines("Implementation", 15_692, false), + "Implementation 15\u{2009}692 lines" + ); + assert_eq!( + headline_share_of_lines(45.3, 34_640, 97), + "45 % of 34\u{2009}640 \u{00B7} 97 files" + ); + assert_eq!( + headline_lines("Documentation", 1, false), + "Documentation 1 line" + ); + } + + #[test] + fn a_deletion_heavy_role_says_so_instead_of_signing_a_sum() { + let headline = headline_lines("Implementation", 16_000, true); + assert_eq!( + headline, + "Implementation 16\u{2009}000 lines \u{00B7} mostly deletions" + ); + assert!( + !headline.contains('\u{2212}') && !headline.contains('-'), + "the number is added + deleted, so it carries no sign: {headline}" + ); + } + + #[test] + fn a_comparison_without_line_counts_falls_back_to_files() { + assert_eq!( + headline_files("Implementation", 12), + "Implementation 12 files" + ); + assert_eq!(headline_files("Unclassified", 1), "Unclassified 1 file"); + assert_eq!(headline_share_of_files(40.0, 30), "40 % of 30 files"); + } + + #[test] + fn legend_percentages_keep_one_decimal_and_never_read_zero() { + assert_eq!(percent_label(45.3), "45.3 %"); + assert_eq!(percent_label(0.7), "0.7 %"); + assert_eq!(percent_label(0.1), "0.1 %"); + assert_eq!( + percent_label(0.04), + "< 0.1 %", + "a share too small to round is still a share" + ); + assert_eq!( + percent_label(0.0), + "", + "a role with no share keeps an empty cell" + ); + } + + #[test] + fn public_api_counts_are_lower_bounds_only_while_coverage_is_partial() { + assert_eq!( + public_api_sentence(&public_api(3, 12, 34, true)), + "\u{2265} 3 removed \u{00B7} \u{2265} 12 signatures changed \u{00B7} \u{2265} 34 \ + added \u{2014} analyzed subset, TS/TSX" + ); + assert_eq!( + public_api_sentence(&public_api(3, 12, 34, false)), + "3 removed \u{00B7} 12 signatures changed \u{00B7} 34 added" + ); + } + + #[test] + fn public_api_never_prints_a_zero_or_a_plural_of_one() { + assert_eq!( + public_api_sentence(&without_languages(&public_api(0, 1, 0, false))), + "1 signature changed" + ); + assert_eq!( + public_api_sentence(&without_languages(&public_api(0, 0, 0, false))), + "" + ); + } + + #[test] + fn a_comparison_without_a_supported_language_says_so() { + let mut fact = public_api(0, 0, 0, true); + fact.no_supported_language = true; + assert_eq!( + public_api_sentence(&fact), + "no supported language in this comparison" + ); + } + + #[test] + fn the_tests_fact_names_the_largest_directory_without_test_changes() { + let fact = TestsFact { + impl_dirs: 6, + with_tests: 4, + without: vec![ + DirRef { + path: "packages/workers/src".to_string(), + files: 26, + lines: 6_118, + }, + DirRef { + path: "packages/core/src".to_string(), + files: 2, + lines: 30, + }, + ], + }; + assert_eq!( + tests_sentence(&fact), + "Tests changed next to 4 of 6 implementation directories \u{00B7} none next to \ + packages/workers/src (26 files, 6\u{2009}118 lines) and 1 more" + ); + } + + #[test] + fn no_tests_anywhere_never_reads_as_zero_of_three() { + let fact = TestsFact { + impl_dirs: 3, + with_tests: 0, + without: vec![ + DirRef { + path: "packages/workers/src".to_string(), + files: 26, + lines: 6_118, + }, + DirRef { + path: "packages/core/src".to_string(), + files: 2, + lines: 30, + }, + ], + }; + let sentence = tests_sentence(&fact); + assert_eq!( + sentence, + "no tests changed next to any of the 3 implementation directories \u{00B7} \ + largest packages/workers/src (26 files, 6\u{2009}118 lines)" + ); + assert!(!sentence.contains("0 of "), "{sentence}"); + } + + #[test] + fn every_implementation_directory_with_tests_needs_no_second_clause() { + let fact = TestsFact { + impl_dirs: 1, + with_tests: 1, + without: Vec::new(), + }; + assert_eq!( + tests_sentence(&fact), + "Tests changed next to 1 of 1 implementation directory" + ); + } + + #[test] + fn the_moves_fact_splits_mechanical_moves_from_moves_with_edits() { + let fact = MovesFact { + total: 21, + likely_mechanical: 17, + with_edits: 4, + avg_similarity: 94, + residual_lines: 400, + }; + assert_eq!( + moves_sentence(&fact), + "21 high-similarity moves \u{00B7} 17 likely mechanical (\u{2264} 20 residual lines) \ + \u{00B7} 4 with edits, ranked below" + ); + + let only_mechanical = MovesFact { + total: 1, + likely_mechanical: 1, + with_edits: 0, + avg_similarity: 98, + residual_lines: 3, + }; + assert_eq!( + moves_sentence(&only_mechanical), + "1 high-similarity move \u{00B7} 1 likely mechanical (\u{2264} 20 residual lines)" + ); + } + + #[test] + fn the_commits_fact_reads_count_merges_authors_span_and_range() { + let fact = CommitsFact { + count: 14, + merges: 1, + authors: vec!["David Matejka".to_string()], + span_secs: 6 * 86_400, + first_sha: "305b0f0".to_string(), + last_sha: "9a7be3f".to_string(), + }; + assert_eq!( + commits_sentence(&fact), + "14 \u{00B7} 1 merge \u{00B7} David Matejka \u{00B7} 6 days \u{00B7} 305b0f0 \ + \u{2026} 9a7be3f" + ); + } + + #[test] + fn a_single_commit_has_no_merge_no_span_and_one_sha() { + let fact = CommitsFact { + count: 1, + merges: 0, + authors: vec!["Ada".to_string()], + span_secs: 0, + first_sha: "abc1234".to_string(), + last_sha: "abc1234".to_string(), + }; + assert_eq!(commits_sentence(&fact), "1 \u{00B7} Ada \u{00B7} abc1234"); + } + + #[test] + fn long_author_lists_and_short_spans_stay_readable() { + let fact = CommitsFact { + count: 9, + merges: 2, + authors: vec![ + "Ada".to_string(), + "Bob".to_string(), + "Cy".to_string(), + "Dee".to_string(), + "Eve".to_string(), + ], + span_secs: 7_200, + first_sha: "1111111".to_string(), + last_sha: "2222222".to_string(), + }; + assert_eq!( + commits_sentence(&fact), + "9 \u{00B7} 2 merges \u{00B7} Ada, Bob, Cy +2 \u{00B7} 2 hours \u{00B7} 1111111 \ + \u{2026} 2222222" + ); + } + + #[test] + fn the_also_fact_omits_every_kind_that_did_not_change() { + let fact = AlsoFact { + lockfiles: 2, + submodules: 1, + binaries: 3, + deleted_impl: 0, + }; + assert_eq!( + also_sentence(&fact), + "2 lockfiles \u{00B7} 1 submodule pointer \u{00B7} 3 binary files" + ); + + let one_each = AlsoFact { + lockfiles: 1, + submodules: 0, + binaries: 0, + deleted_impl: 1, + }; + assert_eq!( + also_sentence(&one_each), + "1 lockfile \u{00B7} 1 deleted implementation file" + ); + } + + #[test] + fn the_caveat_names_the_reach_and_the_selection_bias() { + assert_eq!( + caveat_sentence(63, 97, true, Some(200)), + "structure reached 63 of 97 implementation files (first 200 in path order) \ + \u{2014} the rest ranked from git facts" + ); + assert_eq!( + caveat_sentence(3, 12, false, None), + "structure reached 3 of 12 files \u{2014} the rest ranked from git facts" + ); + } + + #[test] + fn the_attention_link_counts_the_whole_list() { + assert_eq!(all_attention(236), "all 236 \u{2192} Attention"); + assert_eq!(all_attention(1_236), "all 1\u{2009}236 \u{2192} Attention"); + } + + #[test] + fn the_ledger_link_names_the_next_state() { + assert_eq!(ledger_link(false), "show ledger"); + assert_eq!(ledger_link(true), "hide ledger"); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/labels/mod.rs b/crates/okena-views-git/src/diff_viewer/review_ui/labels/mod.rs new file mode 100644 index 000000000..9ecbb4a8c --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/labels/mod.rs @@ -0,0 +1,240 @@ +//! The review UI's shared vocabulary. Every user-visible word for an enum comes +//! from here — `{:?}` never reaches the screen. +// Frozen surface: the wave-1 view units call these. +#![allow(dead_code)] + +pub(crate) mod calls; +pub(crate) mod facts; +pub(crate) mod nav; +pub(crate) mod reasons; +pub(crate) mod status; + +use super::model::KindGlyph; +use okena_core::review::{FileRole, ReviewFileStatus}; +use okena_syntax::{ControlContext, SymbolKind, SyntaxLanguage}; + +/// Thin space; groups digits without the width of a full space. +const DIGIT_GROUP_SEPARATOR: char = '\u{2009}'; + +pub(crate) fn role_label(role: FileRole) -> &'static str { + match role { + FileRole::Implementation => "Implementation", + FileRole::Test => "Tests", + FileRole::Fixture => "Fixtures", + FileRole::Snapshot => "Snapshots", + FileRole::Example => "Examples", + FileRole::Documentation => "Documentation", + FileRole::Generated => "Generated", + FileRole::Vendored => "Vendored", + FileRole::Lockfile => "Lockfiles", + FileRole::Configuration => "Configuration", + FileRole::Unclassified => "Unclassified", + } +} + +/// Badge-sized role name. +pub(crate) fn role_short(role: FileRole) -> &'static str { + match role { + FileRole::Implementation => "Impl", + FileRole::Test => "Tests", + FileRole::Fixture => "Fixtures", + FileRole::Snapshot => "Snapshots", + FileRole::Example => "Examples", + FileRole::Documentation => "Docs", + FileRole::Generated => "Generated", + FileRole::Vendored => "Vendored", + FileRole::Lockfile => "Lockfiles", + FileRole::Configuration => "Config", + FileRole::Unclassified => "Unclassified", + } +} + +pub(crate) fn status_label(status: ReviewFileStatus) -> &'static str { + match status { + ReviewFileStatus::Added => "added", + ReviewFileStatus::Deleted => "deleted", + ReviewFileStatus::Modified => "modified", + ReviewFileStatus::Renamed => "renamed", + ReviewFileStatus::Copied => "copied", + ReviewFileStatus::TypeChanged => "type changed", + ReviewFileStatus::ModeChanged => "mode changed", + ReviewFileStatus::SubmoduleChanged => "submodule changed", + ReviewFileStatus::Unmerged => "unmerged", + ReviewFileStatus::Unknown => "unknown", + } +} + +pub(crate) fn glyph(kind: KindGlyph) -> &'static str { + match kind { + KindGlyph::Function => "\u{0192}", + KindGlyph::Method => "m", + KindGlyph::Class => "C", + KindGlyph::Type => "T", + KindGlyph::Module => "M", + KindGlyph::File => "\u{2261}", + KindGlyph::Directory => "\u{25B8}", + } +} + +/// Members (constants, fields, variants) share the type glyph — the spec has no +/// glyph of their own. +pub(crate) fn symbol_glyph(kind: &SymbolKind) -> KindGlyph { + match kind { + SymbolKind::Module => KindGlyph::Module, + SymbolKind::Function | SymbolKind::Macro => KindGlyph::Function, + SymbolKind::Method => KindGlyph::Method, + SymbolKind::Struct | SymbolKind::Class | SymbolKind::Impl | SymbolKind::Union => { + KindGlyph::Class + } + SymbolKind::Enum + | SymbolKind::Trait + | SymbolKind::Interface + | SymbolKind::TypeAlias + | SymbolKind::Constant + | SymbolKind::Static + | SymbolKind::Field + | SymbolKind::Variant => KindGlyph::Type, + } +} + +pub(crate) fn language_label(language: &SyntaxLanguage) -> &'static str { + language.display_name() +} + +/// Language name for a path, including languages structure analysis cannot parse. +pub(crate) fn language_from_path(path: &str) -> Option<&'static str> { + let extension = path.rsplit('/').next()?.rsplit_once('.')?.1.to_lowercase(); + let label = match extension.as_str() { + "js" | "jsx" | "mjs" | "cjs" => "JavaScript", + "ts" => "TypeScript", + "tsx" => "TSX", + "rs" => "Rust", + "astro" => "Astro", + "py" => "Python", + "go" => "Go", + "md" => "Markdown", + "json" => "JSON", + "yml" | "yaml" => "YAML", + "toml" => "TOML", + "css" | "scss" => "CSS", + "html" => "HTML", + _ => return None, + }; + Some(label) +} + +/// Digits grouped in threes, e.g. `15 692`. +pub(crate) fn format_lines(value: u64) -> String { + let digits = value.to_string(); + let mut out = String::with_capacity(digits.len() + digits.len() / 3); + let leading = digits.len() % 3; + for (index, digit) in digits.chars().enumerate() { + if index > 0 && index % 3 == leading { + out.push(DIGIT_GROUP_SEPARATOR); + } + out.push(digit); + } + out +} + +/// The `+A` / `−D` pair; both are always produced, callers hide the zero side. +pub(crate) fn format_signed(added: u64, deleted: u64) -> (String, String) { + ( + format!("+{}", format_lines(added)), + format!("\u{2212}{}", format_lines(deleted)), + ) +} + +pub(crate) fn relative_time(timestamp: i64) -> String { + okena_git::format_relative_time(timestamp) +} + +pub(crate) fn short_sha(sha: &str) -> String { + sha.chars().take(7).collect() +} + +pub(crate) fn control_context_word(context: &ControlContext) -> String { + match context { + ControlContext::Condition => "condition".to_string(), + ControlContext::Loop => "loop".to_string(), + ControlContext::MatchArm => "match arm".to_string(), + ControlContext::ErrorBranch => "error branch".to_string(), + ControlContext::Callback => "callback".to_string(), + ControlContext::Closure => "closure".to_string(), + ControlContext::Other(word) => word.clone(), + } +} + +/// Why a file carries its role, in words. Unknown ids keep their identity. +pub(crate) fn rule_sentence(rule_id: &str) -> String { + let what = match rule_id { + "builtin.path.generated.v1" => "generated output", + "builtin.path.vendored.v1" => "vendored dependencies", + "builtin.path.lockfile.v1" => "a lockfile name", + "builtin.path.snapshot.v1" => "snapshot files", + "builtin.path.fixture.v1" => "fixture files", + "builtin.path.test.v1" => "test paths", + "builtin.path.documentation.v1" => "documentation files", + "builtin.path.example.v1" => "example paths", + "builtin.path.configuration.v1" => "configuration files", + "builtin.path.implementation.v1" => "a source file extension", + "builtin.path.unclassified.v1" => "nothing more specific", + _ => return format!("matched by rule {rule_id}"), + }; + format!("matched by path rule: {what}") +} + +#[cfg(test)] +mod tests { + use super::{format_lines, format_signed, language_from_path, relative_time, rule_sentence}; + + #[test] + fn line_counts_group_digits_in_threes() { + assert_eq!(format_lines(0), "0"); + assert_eq!(format_lines(999), "999"); + assert_eq!(format_lines(1_000), "1\u{2009}000"); + assert_eq!(format_lines(15_692), "15\u{2009}692"); + assert_eq!(format_lines(1_234_567), "1\u{2009}234\u{2009}567"); + } + + #[test] + fn signed_pairs_carry_their_own_sign() { + let (added, deleted) = format_signed(30_925, 3_715); + assert_eq!(added, "+30\u{2009}925"); + assert_eq!(deleted, "\u{2212}3\u{2009}715"); + } + + #[test] + fn relative_time_reads_the_distance_from_now() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| i64::try_from(elapsed.as_secs()).unwrap_or(0)) + .unwrap_or(0); + assert_eq!(relative_time(now), "just now"); + assert_eq!(relative_time(now - 3_600), "1h ago"); + assert_eq!(relative_time(now - 6 * 86_400), "6d ago"); + } + + #[test] + fn languages_come_from_the_extension_not_the_parser() { + assert_eq!(language_from_path("src/app.tsx"), Some("TSX")); + assert_eq!(language_from_path("src/app.MJS"), Some("JavaScript")); + assert_eq!(language_from_path("a/b/Cargo.toml"), Some("TOML")); + assert_eq!(language_from_path("docs/readme.md"), Some("Markdown")); + assert_eq!(language_from_path("Makefile"), None); + assert_eq!(language_from_path("src.dir/Makefile"), None); + } + + #[test] + fn rule_ids_are_spelled_out_and_unknown_ids_keep_their_identity() { + assert_eq!( + rule_sentence("builtin.path.test.v1"), + "matched by path rule: test paths" + ); + assert_eq!( + rule_sentence("builtin.path.implementation.v1"), + "matched by path rule: a source file extension" + ); + assert_eq!(rule_sentence("custom.rule"), "matched by rule custom.rule"); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/labels/nav.rs b/crates/okena-views-git/src/diff_viewer/review_ui/labels/nav.rs new file mode 100644 index 000000000..2bad95a7c --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/labels/nav.rs @@ -0,0 +1,413 @@ +//! Navigator row wording — unit N. Spec §7: the segmented control, the filter +//! box, the Roles button, the row markers and the footer sentence. + +use super::super::model::{ReasonKind, Tier}; +use super::{format_lines, reasons}; + +const DOT: &str = " \u{00B7} "; + +pub(crate) const FILES_TAB: &str = "Files"; +pub(crate) const ATTENTION_TAB: &str = "Attention"; +pub(crate) const FILTER_PLACEHOLDER_FILES: &str = "Filter files\u{2026}"; +pub(crate) const FILTER_PLACEHOLDER_ITEMS: &str = "Filter\u{2026}"; +/// The key that focuses the filter box, shown inside it — spec §11. +pub(crate) const FILTER_KEY_HINT: &str = "/"; +pub(crate) const ROLES: &str = "Roles"; +pub(crate) const CLEAR_GLYPH: &str = "\u{2715}"; +pub(crate) const CHEVRON_DOWN: &str = "\u{25BE}"; +pub(crate) const FLATTEN: &str = "flatten"; +pub(crate) const OUTLINE: &str = "outline"; +pub(crate) const OUTLINE_HINT: &str = + "Changed symbols and what changed in them, inline under every file"; +/// The label on a detail line that states a signature change. +pub(crate) const SIGNATURE_LINE: &str = "sig"; +pub(crate) const SHOW_ALL: &str = "show all"; +pub(crate) const GROUP_BY_FILE: &str = "group by file"; +pub(crate) const ORDERED_LIST: &str = "ordered list"; +pub(crate) const TESTS_EXCLUDED: &str = "tests excluded"; +pub(crate) const TESTS_CHIP: &str = "tests"; +pub(crate) const NO_FILE_MATCH: &str = "No file matches the filter"; +pub(crate) const NO_ITEM_MATCH: &str = "No item matches the filters"; +/// The way out of an empty list, next to the sentence that explains it. +pub(crate) const CLEAR: &str = "clear"; +pub(crate) const NO_TESTS_MARKER: &str = "no tests"; + +pub(crate) const PRESETS_TITLE: &str = "PRESETS"; +pub(crate) const ROLES_TITLE: &str = "ROLES \u{00B7} CLICK TOGGLES \u{00B7} OR"; +pub(crate) const ALSO_TITLE: &str = "ALSO"; +pub(crate) const LIKELY_MECHANICAL: &str = "Likely mechanical only"; +pub(crate) const NOT_ANALYZED_ONLY: &str = "Not analyzed only"; + +/// The tier separators of the Attention list — spec §7. +pub(crate) fn tier_label(tier: Tier) -> &'static str { + match tier { + Tier::Contract => "CONTRACT", + Tier::Behaviour => "BEHAVIOUR", + Tier::Volume => "VOLUME", + Tier::GitFacts => "GIT FACTS", + Tier::Rest => "REST", + } +} + +/// `Roles · all 11`; the caller adds the ✕ when a filter is active. +pub(crate) fn roles_button(filter_label: &str) -> String { + format!("{ROLES}{DOT}{filter_label}") +} + +/// `sig` / `sig 2` — how many signatures changed in the file. +pub(crate) fn signature_marker(count: usize) -> String { + if count <= 1 { + "sig".to_string() + } else { + format!("sig {count}") + } +} + +/// The marker word for a file row; `None` for reasons a row never shows. +/// +/// `signatures` is the number of signature reasons in the whole file, so the +/// `sig N` marker counts them even though only one of them produced it. +pub(crate) fn file_marker(kind: ReasonKind, label: &str, signatures: usize) -> Option { + match kind { + // Dimming already says it; a badge would read as an error state. + ReasonKind::NotAnalyzed => None, + // Every edited function has a changed body; the churn cell says as much. + ReasonKind::Body => None, + ReasonKind::PublicSignature | ReasonKind::ExportedSignature => { + Some(signature_marker(signatures)) + } + ReasonKind::Calls => Some("calls".to_string()), + ReasonKind::New | ReasonKind::NewPublic => Some("new".to_string()), + ReasonKind::PublicRemoved | ReasonKind::Removed | ReasonKind::DeletedImpl => { + Some("removed".to_string()) + } + _ => Some(short_chip(label).to_string()), + } +} + +/// The marker word for a symbol row in the outline; `None` when the detail +/// lines under the row already state it. +pub(crate) fn symbol_marker(kind: ReasonKind, label: &str) -> Option { + match kind { + // The lines under the row are the calls and the signature themselves. + ReasonKind::Calls => None, + // Every edited symbol has a changed body; the churn cell says as much. + ReasonKind::Body => None, + ReasonKind::NotAnalyzed => None, + // The signature line shows the change but not who depends on it. + ReasonKind::PublicSignature => Some("public".to_string()), + ReasonKind::ExportedSignature => Some("exported".to_string()), + _ => Some(short_chip(label).to_string()), + } +} + +/// Priority of a reason as a file marker — spec §7 keeps the two loudest. +pub(crate) fn marker_rank(kind: ReasonKind) -> u8 { + match kind { + ReasonKind::PublicRemoved => 0, + ReasonKind::PublicSignature | ReasonKind::ExportedSignature => 1, + ReasonKind::Calls => 2, + ReasonKind::Removed | ReasonKind::New | ReasonKind::NewPublic | ReasonKind::DeletedImpl => { + 3 + } + ReasonKind::Moved => 4, + _ => 5, + } +} + +/// The navigator is one column wide, so the sentences the Overview spells out +/// are shortened here. Everything else keeps the wording it already has. +pub(crate) fn short_chip(label: &str) -> &str { + match label { + reasons::PUBLIC_REMOVED => "removed", + reasons::PUBLIC_SIGNATURE | reasons::EXPORTED_SIGNATURE => "sig", + reasons::NEW_PUBLIC | reasons::NEW_IMPLEMENTATION_FILE => "new", + reasons::DELETED_IMPLEMENTATION_FILE => "deleted", + reasons::NO_TEST_CHANGES => NO_TESTS_MARKER, + other => other, + } +} + +/// `…/collection.ts → …/content/collection.ts` — the directories both paths +/// share are elided, the basenames always survive. +pub(crate) fn rename_display(old: &str, new: &str) -> String { + let old_parts: Vec<&str> = old.split('/').collect(); + let new_parts: Vec<&str> = new.split('/').collect(); + // The basename is never elided, so the last segment is out of reach. + let limit = old_parts.len().min(new_parts.len()).saturating_sub(1); + let shared = (0..limit) + .take_while(|index| old_parts[*index] == new_parts[*index]) + .count(); + format!( + "{} \u{2192} {}", + elided(&old_parts, shared), + elided(&new_parts, shared) + ) +} + +fn elided(parts: &[&str], shared: usize) -> String { + let tail = parts[shared..].join("/"); + if shared == 0 { + tail + } else { + format!("\u{2026}/{tail}") + } +} + +/// Row counts are `usize`; the shared formatter speaks `u64`. +fn wide(count: usize) -> u64 { + u64::try_from(count).unwrap_or(u64::MAX) +} + +/// `385 files` / `1 file`. +pub(crate) fn files_phrase(count: usize) -> String { + match count { + 1 => "1 file".to_string(), + other => format!("{} files", format_lines(wide(other))), + } +} + +/// `312 changed symbols` / `1 changed symbol`. +pub(crate) fn symbols_phrase(count: usize) -> String { + match count { + 1 => "1 changed symbol".to_string(), + other => format!("{} changed symbols", format_lines(wide(other))), + } +} + +/// `236 items` / `1 item`. +pub(crate) fn items_phrase(count: usize) -> String { + match count { + 1 => "1 item".to_string(), + other => format!("{} items", format_lines(wide(other))), + } +} + +/// The sidebar footer: what the list currently shows, plus the link that undoes it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FooterLine { + pub text: String, + /// `show all`, present only while a filter narrows the list. + pub action: Option<&'static str>, +} + +/// `385 files · 312 changed symbols · dimmed = not analyzed (185)` / +/// `113 of 385 files · Review code`. `symbols` is `None` unless the outline +/// is on, and then it says how long the list the user scrolls really is. +pub(crate) fn files_footer( + visible: usize, + total: usize, + role_label: Option<&str>, + not_analyzed: usize, + symbols: Option, +) -> FooterLine { + let narrowed = visible != total || role_label.is_some(); + if !narrowed { + let mut text = files_phrase(total); + if let Some(symbols) = symbols { + text.push_str(DOT); + text.push_str(&symbols_phrase(symbols)); + } + if not_analyzed > 0 { + text.push_str(DOT); + text.push_str(&format!( + "dimmed = not analyzed ({})", + format_lines(wide(not_analyzed)) + )); + } + return FooterLine { text, action: None }; + } + let mut text = format!("{} of {}", format_lines(wide(visible)), files_phrase(total)); + if let Some(symbols) = symbols { + text.push_str(DOT); + text.push_str(&symbols_phrase(symbols)); + } + if let Some(role_label) = role_label { + text.push_str(DOT); + text.push_str(role_label); + } + FooterLine { + text, + action: Some(SHOW_ALL), + } +} + +/// `236 items · tests excluded` / `26 of 236 items · sig, removed`. +pub(crate) fn attention_footer( + visible: usize, + total: usize, + chips: &[&str], + tests_excluded: bool, +) -> FooterLine { + let mut text = if visible == total { + items_phrase(total) + } else { + format!("{} of {}", format_lines(wide(visible)), items_phrase(total)) + }; + if !chips.is_empty() { + text.push_str(DOT); + text.push_str(&chips.join(", ")); + } + if tests_excluded { + text.push_str(DOT); + text.push_str(TESTS_EXCLUDED); + } + FooterLine { text, action: None } +} + +#[cfg(test)] +mod tests { + use super::super::super::model::{ReasonKind, Tier}; + use super::{ + attention_footer, file_marker, files_footer, rename_display, short_chip, signature_marker, + symbol_marker, tier_label, + }; + + #[test] + fn tiers_are_named_in_words_not_debug() { + assert_eq!(tier_label(Tier::Contract), "CONTRACT"); + assert_eq!(tier_label(Tier::GitFacts), "GIT FACTS"); + assert_eq!(tier_label(Tier::Rest), "REST"); + } + + #[test] + fn the_signature_marker_counts_only_when_there_is_more_than_one() { + assert_eq!(signature_marker(0), "sig"); + assert_eq!(signature_marker(1), "sig"); + assert_eq!(signature_marker(2), "sig 2"); + } + + #[test] + fn not_analyzed_never_becomes_a_marker() { + assert_eq!( + file_marker(ReasonKind::NotAnalyzed, "not analyzed \u{00B7} JS", 0), + None + ); + } + + #[test] + fn markers_use_the_short_word_for_the_row() { + assert_eq!( + file_marker(ReasonKind::PublicRemoved, "public symbol removed", 0), + Some("removed".to_string()) + ); + assert_eq!( + file_marker(ReasonKind::ExportedSignature, "exported signature", 2), + Some("sig 2".to_string()) + ); + assert_eq!( + file_marker(ReasonKind::Calls, "2 calls \u{00B7} error branch", 0), + Some("calls".to_string()) + ); + assert_eq!( + file_marker(ReasonKind::New, "new implementation file", 0), + Some("new".to_string()) + ); + assert_eq!( + file_marker(ReasonKind::Moved, "moved 98 %", 0), + Some("moved 98 %".to_string()), + "the similarity is the whole point of the marker" + ); + assert_eq!( + file_marker(ReasonKind::CiConfig, "CI config", 0), + Some("CI config".to_string()) + ); + } + + #[test] + fn chips_shorten_the_sentences_and_leave_the_measurements_alone() { + assert_eq!(short_chip("public symbol removed"), "removed"); + assert_eq!(short_chip("public signature"), "sig"); + assert_eq!(short_chip("exported signature"), "sig"); + assert_eq!(short_chip("new \u{00B7} exported"), "new"); + assert_eq!(short_chip("no tests changed next to it"), "no tests"); + assert_eq!(short_chip("240 lines"), "240 lines"); + assert_eq!( + short_chip("2 calls \u{00B7} error branch"), + "2 calls \u{00B7} error branch" + ); + } + + #[test] + fn renames_keep_the_basenames_and_elide_the_shared_directories() { + assert_eq!( + rename_display("core/src/collection.ts", "core/src/content/collection.ts"), + "\u{2026}/collection.ts \u{2192} \u{2026}/content/collection.ts" + ); + assert_eq!( + rename_display("pletivo/src/render.ts", "core/src/render.ts"), + "pletivo/src/render.ts \u{2192} core/src/render.ts", + "nothing is shared, so nothing is elided" + ); + assert_eq!( + rename_display("src/old.rs", "src/new.rs"), + "\u{2026}/old.rs \u{2192} \u{2026}/new.rs" + ); + assert_eq!( + rename_display("old.rs", "new.rs"), + "old.rs \u{2192} new.rs", + "a basename is never elided" + ); + } + + #[test] + fn the_files_footer_names_the_filter_and_offers_the_way_back() { + let plain = files_footer(385, 385, None, 185, None); + assert_eq!(plain.text, "385 files \u{00B7} dimmed = not analyzed (185)"); + assert_eq!(plain.action, None); + + let analyzed = files_footer(12, 12, None, 0, None); + assert_eq!(analyzed.text, "12 files"); + assert_eq!(analyzed.action, None); + + let filtered = files_footer(113, 385, Some("Review code"), 185, None); + assert_eq!(filtered.text, "113 of 385 files \u{00B7} Review code"); + assert_eq!(filtered.action, Some("show all")); + + let text_only = files_footer(3, 385, None, 0, None); + assert_eq!(text_only.text, "3 of 385 files"); + assert_eq!(text_only.action, Some("show all")); + } + + #[test] + fn a_symbol_marker_never_repeats_the_lines_below_it() { + // The call and signature lines under the row state these themselves. + assert_eq!(symbol_marker(ReasonKind::Calls, "6 calls"), None); + assert_eq!(symbol_marker(ReasonKind::Body, "body"), None); + assert_eq!( + symbol_marker(ReasonKind::PublicSignature, "public signature").as_deref(), + Some("public") + ); + assert_eq!( + symbol_marker(ReasonKind::NewPublic, "new \u{00B7} exported").as_deref(), + Some("new") + ); + } + + #[test] + fn the_outline_footer_says_how_long_the_list_really_is() { + let outlined = files_footer(88, 88, None, 8, Some(312)); + assert_eq!( + outlined.text, + "88 files \u{00B7} 312 changed symbols \u{00B7} dimmed = not analyzed (8)" + ); + + let one = files_footer(1, 88, None, 0, Some(1)); + assert_eq!(one.text, "1 of 88 files \u{00B7} 1 changed symbol"); + assert_eq!(one.action, Some("show all")); + } + + #[test] + fn the_attention_footer_counts_items_and_names_the_active_chips() { + let plain = attention_footer(236, 236, &[], false); + assert_eq!(plain.text, "236 items"); + + let excluded = attention_footer(236, 236, &[], true); + assert_eq!(excluded.text, "236 items \u{00B7} tests excluded"); + + let chipped = attention_footer(26, 236, &["sig", "removed"], false); + assert_eq!(chipped.text, "26 of 236 items \u{00B7} sig, removed"); + + assert_eq!(attention_footer(1, 1, &[], false).text, "1 item"); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/labels/reasons.rs b/crates/okena-views-git/src/diff_viewer/review_ui/labels/reasons.rs new file mode 100644 index 000000000..5920b7b2c --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/labels/reasons.rs @@ -0,0 +1,339 @@ +//! Reason chip wording — unit R. Spec §6: every chip and every omission sentence +//! is already worded here, so no `{:?}` enum name ever reaches the screen. + +use super::{control_context_word, format_lines}; +use okena_review::{AnalysisStage, FileAnalysisStatus, OmittedFileReason}; +use okena_syntax::ControlContext; + +const DOT: &str = " \u{00B7} "; +const DASH: &str = " \u{2014} "; + +pub(crate) const PUBLIC_REMOVED: &str = "public symbol removed"; +pub(crate) const PUBLIC_SIGNATURE: &str = "public signature"; +pub(crate) const EXPORTED_SIGNATURE: &str = "exported signature"; +pub(crate) const BODY: &str = "body"; +pub(crate) const REMOVED: &str = "removed"; +pub(crate) const NEW_PUBLIC: &str = "new \u{00B7} exported"; +pub(crate) const NEW_IMPLEMENTATION_FILE: &str = "new implementation file"; +pub(crate) const DELETED_IMPLEMENTATION_FILE: &str = "deleted implementation file"; +pub(crate) const NO_TEST_CHANGES: &str = "no tests changed next to it"; +pub(crate) const CI_CONFIG: &str = "CI config"; +pub(crate) const LOCKFILE: &str = "lockfile"; +pub(crate) const SUBMODULE: &str = "submodule"; +pub(crate) const BINARY: &str = "binary"; +pub(crate) const LARGE_CHURN: &str = "large change"; +pub(crate) const NOT_ANALYZED: &str = "not analyzed"; +pub(crate) const FAILED_TO_PARSE: &str = "Failed to parse"; + +/// `2 calls · error branch`; the context is dropped when no call sits in one. +pub(crate) fn calls_label(count: usize, context: Option<&str>) -> String { + let calls = if count == 1 { + "1 call".to_string() + } else { + format!("{count} calls") + }; + match context { + Some(context) if !context.is_empty() => format!("{calls}{DOT}{context}"), + _ => calls, + } +} + +/// `240 lines` — the size of a new function. +pub(crate) fn lines_label(lines: u32) -> String { + if lines == 1 { + "1 line".to_string() + } else { + format!("{} lines", format_lines(u64::from(lines))) + } +} + +/// `11 members` — the size of a new type. +pub(crate) fn members_label(members: u32) -> String { + if members == 1 { + "1 member".to_string() + } else { + format!("{} members", format_lines(u64::from(members))) + } +} + +/// `moved 98 %` — how much of the file survived the rename. +pub(crate) fn moved_label(similarity: u8) -> String { + format!("moved {similarity} %") +} + +/// `86 residual lines` — what the rename changed on top of the move. +pub(crate) fn residual_label(lines: u64) -> String { + if lines == 1 { + "1 residual line".to_string() + } else { + format!("{} residual lines", format_lines(lines)) + } +} + +/// `nesting 6` — changed code in an already deeply nested function. +pub(crate) fn nesting_label(depth: u32) -> String { + format!("nesting {depth}") +} + +/// `8 params` — changed code in an already wide signature. +pub(crate) fn params_label(params: u32) -> String { + format!("{params} params") +} + +/// `not analyzed · JS`; the language drops out when the path does not name one. +pub(crate) fn not_analyzed_label(language: Option<&str>) -> String { + match language { + Some(language) => format!("{NOT_ANALYZED}{DOT}{}", short_language(language)), + None => NOT_ANALYZED.to_string(), + } +} + +/// `26 implementation files` — what a directory row stands for. +pub(crate) fn implementation_files(count: usize) -> String { + if count == 1 { + "1 implementation file".to_string() + } else { + format!("{count} implementation files") + } +} + +/// Chip-sized language name; unknown names keep their full spelling. +pub(crate) fn short_language(language: &str) -> &str { + match language { + "JavaScript" => "JS", + "TypeScript" => "TS", + "Markdown" => "MD", + "Python" => "Py", + other => other, + } +} + +/// The context worth naming: the innermost kind of branch a call sits in. +pub(crate) fn most_severe_context<'a>( + contexts: impl IntoIterator, +) -> Option { + contexts + .into_iter() + .filter_map(|context| severity(context).map(|rank| (rank, context))) + .min_by_key(|(rank, _)| *rank) + .map(|(_, context)| control_context_word(context)) +} + +/// Lower ranks first. `Other` is a parser escape hatch, never a headline. +fn severity(context: &ControlContext) -> Option { + Some(match context { + ControlContext::ErrorBranch => 0, + ControlContext::Condition => 1, + ControlContext::Loop => 2, + ControlContext::MatchArm => 3, + ControlContext::Callback => 4, + ControlContext::Closure => 5, + ControlContext::Other(_) => return None, + }) +} + +/// One omission group in words — spec §10. Never the reason's Debug name. +pub(crate) fn omission_sentence(reason: OmittedFileReason, limit: Option) -> String { + match reason { + OmittedFileReason::UnsupportedLanguage => "Unsupported language".to_string(), + OmittedFileReason::Binary => "Binary content".to_string(), + OmittedFileReason::Submodule => "Submodule pointer".to_string(), + OmittedFileReason::ModeOnly => format!("Skipped{DASH}mode-only change"), + OmittedFileReason::WhitespaceIgnored => format!("Skipped{DASH}whitespace-only"), + OmittedFileReason::FileLimit => format!( + "Not analyzed{DASH}file limit{}, taken in path order", + bracketed(limit) + ), + OmittedFileReason::SourceByteLimit => { + format!("Not analyzed{DASH}file size limit{}", bracketed(limit)) + } + OmittedFileReason::AggregateByteLimit => { + format!("Not analyzed{DASH}total size limit{}", bracketed(limit)) + } + OmittedFileReason::TimeLimit => { + format!("Not analyzed{DASH}time limit{}", bracketed(limit)) + } + OmittedFileReason::FactLimit => { + format!("Not analyzed{DASH}fact limit{}", bracketed(limit)) + } + OmittedFileReason::ResponseLimit => { + format!("Not analyzed{DASH}response size limit{}", bracketed(limit)) + } + OmittedFileReason::Cancelled => format!("Not analyzed{DASH}analysis cancelled"), + } +} + +/// A truncation the analysis hit is worth an amber row; a language it never +/// supported is not. +pub(crate) fn omission_warns(reason: OmittedFileReason) -> bool { + reason.status() == FileAnalysisStatus::Pending +} + +fn bracketed(limit: Option) -> String { + limit + .map(|limit| format!(" ({})", format_lines(limit))) + .unwrap_or_default() +} + +/// `parsing: unexpected token` — the right column of a failed-parse row. +pub(crate) fn failure_detail(stage: AnalysisStage, message: &str) -> String { + format!("{}: {message}", stage_word(stage)) +} + +fn stage_word(stage: AnalysisStage) -> &'static str { + match stage { + AnalysisStage::Detection => "detection", + AnalysisStage::Parsing => "parsing", + AnalysisStage::Comparison => "comparison", + AnalysisStage::Budget => "budget", + } +} + +/// `.astro 14, .js 5` — what the unsupported group actually contained. +pub(crate) fn extension_summary(counts: &[(String, u64)]) -> String { + counts + .iter() + .map(|(extension, count)| format!("{extension} {count}")) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::{ + calls_label, extension_summary, failure_detail, implementation_files, lines_label, + members_label, most_severe_context, moved_label, not_analyzed_label, omission_sentence, + omission_warns, residual_label, + }; + use okena_review::{AnalysisStage, OmittedFileReason}; + use okena_syntax::ControlContext; + + #[test] + fn counted_chips_stay_singular_at_one() { + assert_eq!(calls_label(1, None), "1 call"); + assert_eq!( + calls_label(2, Some("error branch")), + "2 calls \u{00B7} error branch" + ); + assert_eq!(calls_label(2, Some("")), "2 calls"); + assert_eq!(lines_label(1), "1 line"); + assert_eq!(lines_label(240), "240 lines"); + assert_eq!(lines_label(1_240), "1\u{2009}240 lines"); + assert_eq!(members_label(11), "11 members"); + assert_eq!(residual_label(1), "1 residual line"); + assert_eq!(residual_label(86), "86 residual lines"); + assert_eq!(implementation_files(1), "1 implementation file"); + assert_eq!(implementation_files(26), "26 implementation files"); + assert_eq!(moved_label(98), "moved 98 %"); + } + + #[test] + fn the_named_context_is_the_most_severe_one_and_parser_escapes_never_win() { + let contexts = [ + ControlContext::Closure, + ControlContext::ErrorBranch, + ControlContext::Condition, + ]; + assert_eq!(most_severe_context(&contexts), Some("error branch".into())); + + let softer = [ControlContext::MatchArm, ControlContext::Loop]; + assert_eq!(most_severe_context(&softer), Some("loop".into())); + + let escapes = [ControlContext::Other("guard".into())]; + assert_eq!(most_severe_context(&escapes), None); + assert_eq!(most_severe_context(&[]), None); + } + + #[test] + fn languages_are_shortened_for_chips_and_omitted_when_unknown() { + assert_eq!( + not_analyzed_label(Some("JavaScript")), + "not analyzed \u{00B7} JS" + ); + assert_eq!( + not_analyzed_label(Some("Rust")), + "not analyzed \u{00B7} Rust" + ); + assert_eq!(not_analyzed_label(None), "not analyzed"); + } + + #[test] + fn omission_sentences_carry_their_limit_and_never_a_debug_name() { + assert_eq!( + omission_sentence(OmittedFileReason::FileLimit, Some(200)), + "Not analyzed \u{2014} file limit (200), taken in path order" + ); + assert_eq!( + omission_sentence(OmittedFileReason::FileLimit, None), + "Not analyzed \u{2014} file limit, taken in path order" + ); + assert_eq!( + omission_sentence(OmittedFileReason::ModeOnly, None), + "Skipped \u{2014} mode-only change" + ); + assert_eq!( + omission_sentence(OmittedFileReason::UnsupportedLanguage, None), + "Unsupported language" + ); + assert_eq!( + omission_sentence(OmittedFileReason::SourceByteLimit, Some(1_048_576)), + "Not analyzed \u{2014} file size limit (1\u{2009}048\u{2009}576)" + ); + + // Single-word variants spell ordinary English; only the run-together + // names would betray a `{:?}`. + let debug_names = [ + "UnsupportedLanguage", + "ModeOnly", + "WhitespaceIgnored", + "FileLimit", + "SourceByteLimit", + "AggregateByteLimit", + "TimeLimit", + "FactLimit", + "ResponseLimit", + ]; + let reasons = [ + OmittedFileReason::UnsupportedLanguage, + OmittedFileReason::Binary, + OmittedFileReason::Submodule, + OmittedFileReason::ModeOnly, + OmittedFileReason::WhitespaceIgnored, + OmittedFileReason::FileLimit, + OmittedFileReason::SourceByteLimit, + OmittedFileReason::AggregateByteLimit, + OmittedFileReason::TimeLimit, + OmittedFileReason::FactLimit, + OmittedFileReason::ResponseLimit, + OmittedFileReason::Cancelled, + ]; + for reason in reasons { + let sentence = omission_sentence(reason, Some(7)); + assert_ne!(sentence, format!("{reason:?}"), "a sentence, not a name"); + for name in debug_names { + assert!(!sentence.contains(name), "{sentence} leaks {name}"); + } + } + } + + #[test] + fn only_the_reasons_that_stopped_the_analysis_warn() { + assert!(omission_warns(OmittedFileReason::FileLimit)); + assert!(omission_warns(OmittedFileReason::TimeLimit)); + assert!(!omission_warns(OmittedFileReason::UnsupportedLanguage)); + assert!(!omission_warns(OmittedFileReason::ModeOnly)); + } + + #[test] + fn failure_and_extension_details_read_as_words() { + assert_eq!( + failure_detail(AnalysisStage::Parsing, "unexpected token"), + "parsing: unexpected token" + ); + assert_eq!( + extension_summary(&[(".astro".into(), 14), (".js".into(), 5)]), + ".astro 14, .js 5" + ); + assert_eq!(extension_summary(&[]), ""); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/labels/status.rs b/crates/okena-views-git/src/diff_viewer/review_ui/labels/status.rs new file mode 100644 index 000000000..0ec2c1934 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/labels/status.rs @@ -0,0 +1,189 @@ +//! Analysis-status wording — unit S. Spec §10: the pill and its details popover +//! say everything in words, so `{:?}` never reaches the screen. + +use super::super::model::CoverageSummary; +use super::{format_lines, short_sha}; + +pub(crate) const LOADING_INVENTORY: &str = "Loading inventory\u{2026}"; +/// No count: the structure request is one-shot, there is no progress channel. +pub(crate) const ANALYZING_STRUCTURE: &str = "Analyzing structure\u{2026}"; +pub(crate) const UNAVAILABLE: &str = "Structure unavailable \u{00B7} diff still works"; +pub(crate) const DETAILS_LINK: &str = "details"; +/// Spec §10 wording, already in the caps the popover header wears. +pub(crate) const POPOVER_TITLE: &str = "STRUCTURE ANALYSIS \u{00B7} THIS COMPARISON"; +pub(crate) const ANALYZED_ROW: &str = "Analyzed"; +/// Same noun as the pill: one state is never named two ways. +pub(crate) const UNAVAILABLE_ROW: &str = "Structure unavailable"; +pub(crate) const FOOTER: &str = "Not analyzed files stay in the tree (dimmed), open as a plain \ + diff, and are ranked from git facts."; + +const DOT: &str = " \u{00B7} "; + +/// Joins the parts with ` · `, dropping the ones that have nothing to say. +fn join_dots(parts: &[&str]) -> String { + parts + .iter() + .filter(|part| !part.is_empty()) + .copied() + .collect::>() + .join(DOT) +} + +/// `385 files` / `1 file`. +pub(crate) fn files_phrase(count: u64) -> String { + if count == 1 { + "1 file".to_string() + } else { + format!("{} files", format_lines(count)) + } +} + +/// `TS, TSX, Rust`; empty when structure parsed nothing. +pub(crate) fn language_list(languages: &[String]) -> String { + languages.join(", ") +} + +/// `Structure ready · 385 files · TS, TSX, Rust`. +pub(crate) fn ready_sentence(files: u64, languages: &[String]) -> String { + join_dots(&[ + "Structure ready", + &files_phrase(files), + &language_list(languages), + ]) +} + +/// `Structure limited · 200 of 385 files` — a capped run is limited, never complete. +pub(crate) fn limited_sentence(analyzed: u64, total: u64) -> String { + format!( + "Structure limited{DOT}{} of {}", + format_lines(analyzed), + files_phrase(total) + ) +} + +/// `Structure ready · 3 files failed to parse`. +pub(crate) fn failures_sentence(failed: u64) -> String { + format!( + "Structure ready{DOT}{} failed to parse", + files_phrase(failed) + ) +} + +/// Right column of the analyzed row: `200 files · TypeScript, TSX`. +pub(crate) fn analyzed_detail(files: u64, languages: &[String]) -> String { + join_dots(&[&files_phrase(files), &language_list(languages)]) +} + +/// Right column of an omission row: `21 files · .astro 14, .js 5`. +pub(crate) fn omission_detail(count: u64, detail: &str) -> String { + if count == 0 { + return detail.to_string(); + } + join_dots(&[&files_phrase(count), detail]) +} + +/// `Base 8f2c1a0 · head 3e91d7c · merge-base 8f2c1a0`; unresolved sides drop out. +pub(crate) fn oid_line(coverage: &CoverageSummary) -> String { + let base = named_oid("Base", &coverage.base_oid); + let head = named_oid("head", &coverage.head_oid); + let merge_base = coverage + .merge_base_oid + .as_deref() + .map(|oid| named_oid("merge-base", oid)) + .unwrap_or_default(); + join_dots(&[&base, &head, &merge_base]) +} + +fn named_oid(name: &str, oid: &str) -> String { + if oid.is_empty() { + String::new() + } else { + format!("{name} {}", short_sha(oid)) + } +} + +#[cfg(test)] +mod tests { + use super::super::super::model::CoverageSummary; + use super::{ + FOOTER, analyzed_detail, failures_sentence, files_phrase, limited_sentence, oid_line, + omission_detail, ready_sentence, + }; + + fn languages() -> Vec { + vec!["TS".to_string(), "TSX".to_string(), "Rust".to_string()] + } + + #[test] + fn file_counts_are_singular_at_one_and_grouped_above_a_thousand() { + assert_eq!(files_phrase(0), "0 files"); + assert_eq!(files_phrase(1), "1 file"); + assert_eq!(files_phrase(385), "385 files"); + assert_eq!(files_phrase(1_200), "1\u{2009}200 files"); + } + + #[test] + fn pill_sentences_match_the_spec_wording() { + assert_eq!( + ready_sentence(385, &languages()), + "Structure ready \u{00B7} 385 files \u{00B7} TS, TSX, Rust" + ); + assert_eq!( + limited_sentence(200, 385), + "Structure limited \u{00B7} 200 of 385 files" + ); + assert_eq!( + failures_sentence(3), + "Structure ready \u{00B7} 3 files failed to parse" + ); + assert_eq!( + failures_sentence(1), + "Structure ready \u{00B7} 1 file failed to parse" + ); + } + + #[test] + fn a_missing_language_list_does_not_leave_a_dangling_separator() { + assert_eq!(ready_sentence(12, &[]), "Structure ready \u{00B7} 12 files"); + assert_eq!(analyzed_detail(12, &[]), "12 files"); + } + + #[test] + fn omission_details_pair_the_count_with_the_wording() { + assert_eq!( + omission_detail(21, ".astro 14, .js 5"), + "21 files \u{00B7} .astro 14, .js 5" + ); + assert_eq!(omission_detail(9, ""), "9 files"); + assert_eq!(omission_detail(0, "nothing skipped"), "nothing skipped"); + } + + /// The literal is wrapped with a line continuation; guard the joined result. + #[test] + fn the_footer_reads_as_one_sentence() { + assert!(FOOTER.starts_with("Not analyzed files stay in the tree (dimmed), ")); + assert!(FOOTER.ends_with("open as a plain diff, and are ranked from git facts.")); + assert!(!FOOTER.contains(" "), "{FOOTER}"); + } + + #[test] + fn resolved_oids_are_shortened_and_unresolved_sides_drop_out() { + let coverage = CoverageSummary { + base_oid: "8f2c1a0abcdef".to_string(), + head_oid: "3e91d7cabcdef".to_string(), + merge_base_oid: Some("8f2c1a0abcdef".to_string()), + ..CoverageSummary::default() + }; + assert_eq!( + oid_line(&coverage), + "Base 8f2c1a0 \u{00B7} head 3e91d7c \u{00B7} merge-base 8f2c1a0" + ); + + let no_merge_base = CoverageSummary { + head_oid: "3e91d7cabcdef".to_string(), + ..CoverageSummary::default() + }; + assert_eq!(oid_line(&no_merge_base), "head 3e91d7c"); + assert_eq!(oid_line(&CoverageSummary::default()), ""); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/mod.rs b/crates/okena-views-git/src/diff_viewer/review_ui/mod.rs new file mode 100644 index 000000000..d206b7253 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/mod.rs @@ -0,0 +1,34 @@ +//! Review workspace UI — see `docs/review-workspace-ui-spec.md`. +//! +//! `state` / `model` / `ranking` / `labels` / `actions` are the shared surface; +//! the remaining modules render one screen region each. + +pub(crate) mod state; + +pub(crate) mod model; + +pub(crate) mod ranking; + +pub(crate) mod labels; + +pub(crate) mod actions; + +pub(crate) mod shell; + +pub(crate) mod diff_state; + +#[cfg(test)] +pub(crate) mod fixtures; + +pub(crate) mod status; + +pub(crate) mod navigator; + +pub(crate) mod overview; + +pub(crate) mod file_view; + +pub(crate) mod keys; + +pub(crate) use shell::DiffPaneArgs; +pub(crate) use state::ContentView; diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/model.rs b/crates/okena-views-git/src/diff_viewer/review_ui/model.rs new file mode 100644 index 000000000..055d6e0ee --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/model.rs @@ -0,0 +1,412 @@ +//! Pure review model — spec §5 / §6. Derived from inventory + structure only, +//! never from the filters; no GPUI types live here. +// Frozen surface: the wave-1 view units read these fields. +#![allow(dead_code)] + +use super::super::review::ReviewFileKey; +use okena_core::review::{FileRole, ReviewFileStatus, ReviewNavigationTarget}; +use okena_review::{CallChangeKind, SymbolChangeKind}; + +/// Everything the review screens read, in one immutable value. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ReviewModel { + /// Inventory order — *not* the diff pane's `file_stats` order. + pub files: Vec, + pub root: DirNode, + pub volume: Vec, + pub total_changed_lines: u64, + pub facts: Facts, + pub attention: Vec, + pub status: AnalysisStatus, + pub omissions: Vec, + pub commits: Vec, + pub coverage: CoverageSummary, + pub small_change: bool, +} + +impl ReviewModel { + pub(crate) fn file_index(&self, key: &ReviewFileKey) -> Option { + self.files.iter().position(|entry| &entry.key == key) + } + + pub(crate) fn attention_index(&self, target: &AttentionTarget) -> Option { + self.attention + .iter() + .position(|item| &item.target == target) + } + + pub(crate) fn first_attention_for_file(&self, key: &ReviewFileKey) -> Option { + self.attention + .iter() + .position(|item| item.target.file() == Some(key)) + } + + /// First file under `dir_path`, in attention order; model order as fallback. + pub(crate) fn first_file_under(&self, dir_path: &str) -> Option { + let under = |index: usize| { + self.files + .get(index) + .is_some_and(|entry| is_under(&entry.display_path, dir_path)) + }; + self.attention + .iter() + .filter_map(|item| item.target.file()) + .filter_map(|key| self.file_index(key)) + .find(|index| under(*index)) + .or_else(|| (0..self.files.len()).find(|index| under(*index))) + } +} + +/// Whether `path` sits inside `dir_path` (`dir_path` empty = repository root). +pub(crate) fn is_under(path: &str, dir_path: &str) -> bool { + if dir_path.is_empty() { + return true; + } + path.strip_prefix(dir_path) + .is_some_and(|rest| rest.starts_with('/')) +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct FileEntry { + pub key: ReviewFileKey, + /// `old → new` for renames, otherwise the single path. + pub display_path: String, + pub old_path: Option, + pub new_path: Option, + pub status: ReviewFileStatus, + pub role: FileRole, + pub rule_id: String, + pub similarity: Option, + pub lines_added: u64, + pub lines_deleted: u64, + pub binary: bool, + pub analysis: FileAnalysis, + pub reasons: Vec, + pub tier: Tier, + /// The whole file is a test file, by its path. + pub is_test: bool, + /// Tests changed here: the file is one, or a test scope inside it changed — + /// in Rust the tests usually live in the file they test. + pub has_test_changes: bool, + /// Changed lines that sit inside a test scope of an otherwise + /// non-test file; counted once, on the outermost test scope. + pub inline_test_lines: u64, + pub symbols: Vec, + /// Index into `ReviewStructure::files` when structure reached this file. + pub structure_index: Option, +} + +impl FileEntry { + pub(crate) fn changed_lines(&self) -> u64 { + self.lines_added.saturating_add(self.lines_deleted) + } +} + +/// How far structure analysis got with one file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum FileAnalysis { + NotInStructure, + Parsed { language: String }, + Partial { language: String }, + Pending, + Unsupported, + Failed, + Skipped, +} + +impl FileAnalysis { + pub(crate) fn is_analyzed(&self) -> bool { + matches!(self, Self::Parsed { .. } | Self::Partial { .. }) + } + + pub(crate) fn language(&self) -> Option<&str> { + match self { + Self::Parsed { language } | Self::Partial { language } => Some(language), + Self::NotInStructure + | Self::Pending + | Self::Unsupported + | Self::Failed + | Self::Skipped => None, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct SymbolEntry { + /// Index into the file's `StructuredFile::symbol_changes`. + pub change_index: usize, + pub name: String, + pub qualified: String, + pub glyph: KindGlyph, + pub change: SymbolChangeKind, + pub public: bool, + /// The symbol is a test scope or sits in one (`mod tests`, `describe`). + pub in_test_scope: bool, + /// Normalized `(old, new)` signature pair when the signature changed. + pub signature: Option<(String, String)>, + pub body_changed: bool, + pub lines_added: u32, + pub lines_deleted: u32, + pub calls: Vec, + pub reasons: Vec, + pub tier: Tier, + pub navigation: ReviewNavigationTarget, + /// 1-based inclusive line ranges, base side. + pub old_hunks: Vec<(u32, u32)>, + /// 1-based inclusive line ranges, head side. + pub new_hunks: Vec<(u32, u32)>, + pub metrics: SymbolMetrics, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct SymbolMetrics { + pub lines: Option, + pub params: Option, + pub depth: Option, + pub members: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum KindGlyph { + Function, + Method, + Class, + Type, + Module, + File, + Directory, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CallRow { + pub change: CallChangeKind, + pub callee: String, + pub old_args: Option, + pub new_args: Option, + /// Control-context stack, outermost first, already worded — head side, + /// or base side for a removed call. + pub context: Vec, + /// The base-side stack when a modified call moved between contexts. + pub old_context: Option>, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct DirNode { + /// Display name; joined single-child chains keep the whole `a/b/c`. + pub name: String, + pub path: String, + pub children: Vec, + /// Indices into `ReviewModel::files`, this directory only. + pub files: Vec, + /// Files in the whole subtree. + pub file_count: usize, + pub lines_added: u64, + pub lines_deleted: u64, + pub is_implementation_dir: bool, + pub no_test_changes: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct VolumeRow { + pub role: FileRole, + pub files: usize, + /// Files that gave this role lines without carrying it — inline tests in + /// implementation files. They are counted in their own role's `files` too. + pub inline_files: usize, + pub lines: u64, + pub percent: f32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Tier { + Contract, + Behaviour, + Volume, + GitFacts, + #[default] + Rest, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum ReasonKind { + PublicRemoved, + PublicSignature, + ExportedSignature, + Body, + Calls, + New, + NewPublic, + Removed, + Moved, + NoTestChanges, + CiConfig, + Lockfile, + Submodule, + Binary, + Complex, + NotAnalyzed, + LargeChurn, + DeletedImpl, +} + +/// One measured reason plus its already-worded chip text. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Reason { + pub kind: ReasonKind, + pub label: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) enum AttentionTarget { + Symbol { + file: ReviewFileKey, + change_index: usize, + }, + File(ReviewFileKey), + Directory(String), +} + +impl AttentionTarget { + pub(crate) fn file(&self) -> Option<&ReviewFileKey> { + match self { + Self::Symbol { file, .. } | Self::File(file) => Some(file), + Self::Directory(_) => None, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct AttentionItem { + pub target: AttentionTarget, + pub tier: Tier, + pub reasons: Vec, + pub name: String, + pub path: String, + pub glyph: KindGlyph, + pub lines_added: u64, + pub lines_deleted: u64, + /// Ranked from git facts only (structure never reached it). + pub dimmed: bool, + pub is_test: bool, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Facts { + pub public_api: Option, + pub tests: Option, + pub moves: Option, + pub commits: Option, + pub also: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PublicApiFact { + pub removed: u64, + pub signatures: u64, + pub added: u64, + /// Coverage is partial, so the counts are lower bounds (`≥`). + pub lower_bound: bool, + pub languages: Vec, + pub no_supported_language: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct TestsFact { + pub impl_dirs: usize, + pub with_tests: usize, + pub without: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DirRef { + pub path: String, + pub files: usize, + pub lines: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct MovesFact { + pub total: usize, + pub likely_mechanical: usize, + pub with_edits: usize, + pub avg_similarity: u8, + pub residual_lines: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CommitsFact { + pub count: usize, + pub merges: usize, + pub authors: Vec, + pub span_secs: i64, + pub first_sha: String, + pub last_sha: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct AlsoFact { + pub lockfiles: usize, + pub submodules: usize, + pub binaries: usize, + pub deleted_impl: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct CommitRow { + pub sha: String, + pub short_sha: String, + pub subject: String, + pub author: String, + pub timestamp: i64, + pub is_merge: bool, +} + +/// Header pill state — spec §10. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum AnalysisStatus { + LoadingInventory, + AnalyzingStructure, + Ready { files: u64, languages: Vec }, + Limited { analyzed: u64, total: u64 }, + ReadyWithFailures { failed: u64 }, + Unavailable { message: String }, +} + +/// One omission group, already worded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct OmissionRow { + pub sentence: String, + pub count: u64, + pub detail: String, + pub warn: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct CoverageSummary { + pub analyzed_files: u64, + pub total_files: u64, + pub impl_analyzed: usize, + pub impl_total: usize, + /// Analysis took files in path order, so the reached subset is biased. + pub path_order_bias: bool, + pub languages: Vec, + pub partial: bool, + pub failed: u64, + pub base_oid: String, + pub head_oid: String, + pub merge_base_oid: Option, +} + +#[cfg(test)] +mod tests { + use super::is_under; + + #[test] + fn subtree_membership_requires_a_directory_boundary() { + assert!(is_under("src/lib.rs", "src")); + assert!(is_under("src/a/b.rs", "src/a")); + assert!(!is_under("src2/lib.rs", "src")); + assert!(!is_under("src", "src")); + assert!(is_under("anything", "")); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/navigator/attention.rs b/crates/okena-views-git/src/diff_viewer/review_ui/navigator/attention.rs new file mode 100644 index 000000000..6a7785af5 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/navigator/attention.rs @@ -0,0 +1,329 @@ +//! Attention mode: the ordered list — spec §7. + +use super::super::super::DiffViewer; +use super::super::labels::{glyph, nav as words}; +use super::items::{self, AttentionRow, AttentionRowKind, ChipView, GroupRow, ItemRow}; +use super::rows::basename; +use super::{ITEM_ROW_HEIGHT, TREE_ROW_HEIGHT, chip, chip_tone, churn_cell, selection_bar}; +use gpui::prelude::*; +use gpui::*; +use gpui_component::{h_flex, v_flex}; +use okena_core::theme::ThemeColors; +use okena_ui::file_icon::file_icon; +use okena_ui::tokens::{RADIUS_STD, ui_text_ms, ui_text_sm}; +use std::sync::Arc; + +/// How far an item indents under its file header in the grouped variant. +const GROUP_INDENT: f32 = 12.0; +const DOT: &str = "\u{00B7}"; + +impl DiffViewer { + pub(crate) fn render_attention_list( + &mut self, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let Some(model) = self.review_ui.model.clone() else { + return div().flex_1().into_any_element(); + }; + let state = &self.review_ui; + let chips = items::reason_chips(&model, &state.attention_filter); + let rows = Arc::new(items::attention_rows( + &model, + &state.attention_filter, + &state.role_filter, + &state.filter_text, + )); + let ids: Vec> = rows.iter().map(|row| row.id.clone()).collect(); + let scroll = self.review_ui.attention_scroll.clone(); + self.review_reveal_cursor(&ids, &scroll); + + let colors = *t; + let view = cx.entity().clone(); + let count = rows.len(); + let body = if count == 0 { + self.render_attention_empty(t, cx) + } else { + uniform_list("review-attention-list", count, move |range, _window, cx| { + view.update(cx, |this, cx| { + range + .filter_map(|index| rows.get(index)) + .map(|row| this.render_attention_row(row, &colors, cx)) + .collect::>() + }) + }) + .flex_1() + .min_h_0() + .track_scroll(&self.review_ui.attention_scroll) + .into_any_element() + }; + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + // The chips stay on screen; one of them is usually what emptied the list. + .child(self.render_reason_chips(&chips, t, cx)) + .child(body) + .into_any_element() + } + + fn render_attention_empty(&self, t: &ThemeColors, cx: &mut Context) -> AnyElement { + super::empty_state(words::NO_ITEM_MATCH, t, cx) + .child(DOT) + .child( + div() + .id("review-attention-clear") + .cursor_pointer() + .text_color(rgb(t.term_blue)) + .hover(|s| s.text_color(rgb(t.text_primary))) + .child(words::CLEAR) + .on_click(cx.listener(|this, _, _window, cx| { + this.review_clear_attention_filters(cx); + })), + ) + .into_any_element() + } + + /// The OR filters over the ranked list, plus the tests toggle — spec §7. + fn render_reason_chips( + &self, + chips: &[ChipView], + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let include_tests = self.review_ui.attention_filter.include_tests; + h_flex() + .flex_wrap() + .flex_shrink_0() + .px(px(super::COLUMN_PADDING)) + .pb(px(6.0)) + .gap(px(4.0)) + .children(chips.iter().map(|view| { + let kinds = items::chip_toggle_kinds(view, &self.review_ui.attention_filter); + filter_chip( + ElementId::Name(format!("review-chip-{}", view.word).into()), + view.label.clone(), + view.active, + t, + cx, + ) + .on_click(cx.listener(move |this, _, _window, cx| { + for kind in &kinds { + this.review_toggle_reason_filter(*kind, cx); + } + })) + .into_any_element() + })) + .child( + filter_chip( + ElementId::Name("review-chip-tests".into()), + words::TESTS_CHIP.to_string(), + include_tests, + t, + cx, + ) + .on_click(cx.listener(|this, _, _window, cx| { + this.review_toggle_include_tests(cx); + })), + ) + .into_any_element() + } + + fn render_attention_row( + &self, + row: &AttentionRow, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + match &row.kind { + AttentionRowKind::Tier(label) => tier_separator(label, t, cx), + AttentionRowKind::Group(group) => self.render_group_row(group, t, cx), + AttentionRowKind::Item(item) => self.render_item_row(row, item, t, cx), + } + } + + /// A header *is* its file: it paints and opens like a tree file row. + fn render_group_row( + &self, + group: &GroupRow, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let id = super::NavRowId::File(group.key.clone()); + let cursor = self.review_ui.nav_cursor.as_ref() == Some(&id); + let open = self.smart_review.selected_file.as_ref() == Some(&group.key); + let for_click = group.key.clone(); + h_flex() + .id(super::nav_element_id("review-group", &id)) + .h(px(ITEM_ROW_HEIGHT)) + .w_full() + .items_center() + .gap(px(4.0)) + .cursor_pointer() + .when(open, |d| d.bg(rgb(t.bg_selection))) + .when(cursor && !open, |d| d.bg(rgb(t.bg_hover))) + .hover(|s| s.bg(rgb(t.bg_hover))) + .child(selection_bar(cursor, t)) + .child(div().w(px(4.0)).flex_shrink_0()) + .child(file_icon(basename(&group.path), t, cx).flex_shrink_0()) + .child( + div() + .min_w_0() + .truncate() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_secondary)) + .child(group.path.clone()), + ) + .child(div().flex_1()) + .child( + div() + .flex_shrink_0() + .pr(px(super::COLUMN_PADDING)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(group.count.to_string()), + ) + .on_click(cx.listener(move |this, _, _window, cx| { + // The pointer moves the cursor too, so `↑` `↓` carry on from here. + this.review_ui.nav_cursor = Some(super::NavRowId::File(for_click.clone())); + this.review_open_file(for_click.clone(), cx); + })) + .into_any_element() + } + + fn render_item_row( + &self, + row: &AttentionRow, + item: &ItemRow, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + // The stripe is the keyboard cursor; the fill is what the content shows. + let cursor = row + .id + .as_ref() + .is_some_and(|id| self.review_ui.nav_cursor.as_ref() == Some(id)); + let open = self.review_ui.queue_target.as_ref() == Some(&item.target); + let name_color = if item.dimmed { + t.text_muted + } else { + t.text_primary + }; + let indent = if item.nested { GROUP_INDENT } else { 0.0 }; + let target = item.target.clone(); + h_flex() + .id(super::nav_element_id( + "review-item", + &super::NavRowId::Item(item.target.clone()), + )) + .h(px(ITEM_ROW_HEIGHT)) + .w_full() + .items_center() + .gap(px(4.0)) + .cursor_pointer() + .when(open, |d| d.bg(rgb(t.bg_selection))) + .when(cursor && !open, |d| d.bg(rgb(t.bg_hover))) + .hover(|s| s.bg(rgb(t.bg_hover))) + .child(selection_bar(cursor, t)) + .child(div().w(px(indent)).flex_shrink_0()) + .child( + div() + .w(px(14.0)) + .flex_shrink_0() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .child(glyph(item.glyph)), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap(px(2.0)) + .pr(px(super::COLUMN_PADDING)) + .child( + h_flex() + .gap(px(6.0)) + .items_center() + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(ui_text_ms(cx)) + .text_color(rgb(name_color)) + .child(item.name.clone()), + ) + .child(churn_cell(item.added, item.deleted, t, cx)), + ) + .child( + h_flex() + .gap(px(4.0)) + .items_center() + .children(item.chips.iter().map(|reason| { + chip(reason.label.clone(), chip_tone(reason.kind), t, cx) + .into_any_element() + })) + .child( + div() + .min_w_0() + .truncate() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(item.path.clone()), + ), + ), + ) + .on_click(cx.listener(move |this, _, _window, cx| { + // The pointer moves the cursor too, so `↑` `↓` carry on from here. + this.review_ui.nav_cursor = Some(super::NavRowId::Item(target.clone())); + this.review_open_item(target.clone(), cx); + })) + .into_any_element() + } +} + +fn tier_separator(label: &'static str, t: &ThemeColors, cx: &App) -> AnyElement { + h_flex() + .h(px(ITEM_ROW_HEIGHT)) + .w_full() + .items_end() + .px(px(super::COLUMN_PADDING)) + .pb(px(4.0)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(label) + .into_any_element() +} + +fn filter_chip( + id: ElementId, + label: String, + active: bool, + t: &ThemeColors, + cx: &App, +) -> Stateful
{ + div() + .id(id) + .cursor_pointer() + .h(px(TREE_ROW_HEIGHT)) + .px(px(6.0)) + .flex() + .items_center() + .rounded(RADIUS_STD) + .bg(rgb(if active { + t.bg_selection + } else { + t.bg_secondary + })) + .border_1() + .border_color(rgb(if active { t.border_active } else { t.border })) + .hover(|s| s.bg(rgb(t.bg_hover))) + .text_size(ui_text_sm(cx)) + .text_color(rgb(if active { + t.text_primary + } else { + t.text_secondary + })) + .child(label) +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/navigator/files.rs b/crates/okena-views-git/src/diff_viewer/review_ui/navigator/files.rs new file mode 100644 index 000000000..2b26f602e --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/navigator/files.rs @@ -0,0 +1,378 @@ +//! Files mode: the directory tree — spec §7. + +use super::super::super::DiffViewer; +use super::super::labels::nav as words; +use super::super::labels::{self as labels, glyph}; +use super::super::model::AttentionTarget; +use super::super::state::SymbolRef; +use super::rows::{self, DetailKind, DetailRow, DirRow, FileRow, NavRow, NavRowKind, SymbolRow}; +use super::{TREE_ROW_HEIGHT, chip, chip_tone, churn_cell, selection_bar}; +use gpui::prelude::*; +use gpui::*; +use gpui_component::h_flex; +use gpui_component::tooltip::Tooltip; +use okena_core::theme::ThemeColors; +use okena_review::CallChangeKind; +use okena_ui::file_icon::file_icon; +use okena_ui::tokens::{ICON_SM, RADIUS_MD, ui_text_ms, ui_text_sm}; +use std::sync::Arc; + +/// How far one tree level indents. +const INDENT: f32 = 12.0; +/// The kind glyph column of a symbol row; its detail lines start after it. +const GLYPH_WIDTH: f32 = 13.0; +/// The marker column of a detail line — `sig` is the widest thing in it, and +/// every line's text has to start at the same x. +const MARKER_WIDTH: f32 = 20.0; + +impl DiffViewer { + pub(crate) fn render_files_tree( + &mut self, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let Some(model) = self.review_ui.model.clone() else { + return div().flex_1().into_any_element(); + }; + let state = &self.review_ui; + let tree = Arc::new(rows::nav_rows(&model, &rows::TreeArgs::of(state))); + if tree.is_empty() { + return super::empty_state(words::NO_FILE_MATCH, t, cx).into_any_element(); + } + let ids: Vec> = tree.iter().map(|row| row.id.clone()).collect(); + let scroll = self.review_ui.tree_scroll.clone(); + self.review_reveal_cursor(&ids, &scroll); + + let colors = *t; + let view = cx.entity().clone(); + let count = tree.len(); + div() + .flex_1() + .min_h_0() + .child( + uniform_list("review-files-tree", count, move |range, _window, cx| { + view.update(cx, |this, cx| { + range + .filter_map(|index| tree.get(index)) + .map(|row| this.render_tree_row(row, &colors, cx)) + .collect::>() + }) + }) + .size_full() + .track_scroll(&self.review_ui.tree_scroll), + ) + .into_any_element() + } + + fn render_tree_row(&self, row: &NavRow, t: &ThemeColors, cx: &mut Context) -> AnyElement { + match &row.kind { + NavRowKind::Dir(dir) => self.render_dir_row(row, dir, t, cx), + NavRowKind::File(file) => self.render_file_row(row, file, t, cx), + NavRowKind::Symbol(symbol) => self.render_symbol_row(row, symbol, t, cx), + NavRowKind::Detail(detail) => self.render_detail_row(row, detail, t, cx), + } + } + + fn render_dir_row( + &self, + row: &NavRow, + dir: &DirRow, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let Some(id @ super::NavRowId::Dir(path)) = &row.id else { + return div().into_any_element(); + }; + let cursor = self.review_ui.nav_cursor.as_ref() == Some(id); + let for_click = path.clone(); + tree_row( + super::nav_element_id("review-row", id), + row.depth, + cursor, + false, + t, + ) + .child( + svg() + .path(if dir.expanded { + "icons/chevron-down.svg" + } else { + "icons/chevron-right.svg" + }) + .size(ICON_SM) + .flex_shrink_0() + .text_color(rgb(t.text_muted)), + ) + .child( + svg() + .path("icons/folder.svg") + .size(px(14.0)) + .flex_shrink_0() + .text_color(rgb(t.text_secondary)), + ) + .child( + div() + .min_w_0() + .truncate() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_primary)) + .child(dir.name.clone()), + ) + .when(dir.no_tests, |d| { + d.child(chip(words::NO_TESTS_MARKER, super::ChipTone::Warn, t, cx)) + }) + .when_some(dir.role_badge, |d, badge| d.child(role_badge(badge, t, cx))) + .child(div().flex_1()) + .child( + div() + .flex_shrink_0() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(dir.file_count.to_string()), + ) + .child(churn_cell(dir.added, dir.deleted, t, cx)) + .on_click(cx.listener(move |this, _, _window, cx| { + // The pointer moves the cursor too, so `↑` `↓` carry on from here. + this.review_ui.nav_cursor = Some(super::NavRowId::Dir(for_click.clone())); + this.review_toggle_dir(&for_click, cx); + })) + .into_any_element() + } + + fn render_file_row( + &self, + row: &NavRow, + file: &FileRow, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let Some(id @ super::NavRowId::File(key)) = &row.id else { + return div().into_any_element(); + }; + let cursor = self.review_ui.nav_cursor.as_ref() == Some(id); + let open = self.smart_review.selected_file.as_ref() == Some(key); + let name_color = if file.dimmed { + t.text_muted + } else { + t.text_primary + }; + let for_click = key.clone(); + let tooltip = file.tooltip.clone(); + tree_row( + super::nav_element_id("review-row", id), + row.depth, + cursor, + open, + t, + ) + // The block below belongs to this row, so the row reads as its header. + // A fill and not a rule: a border would make this row taller than the + // rest, and the virtualized list measures one height for all of them. + .when(file.outlined && !open && !cursor, |d| { + d.bg(rgb(t.bg_secondary)) + }) + .child(div().w(ICON_SM).flex_shrink_0()) + .child(file_icon(&file.icon_name, t, cx).flex_shrink_0()) + .child( + div() + .min_w_0() + .truncate() + .text_size(ui_text_ms(cx)) + .text_color(rgb(name_color)) + .child(file.name_display.clone()), + ) + .children(file.markers.iter().map(|marker| { + chip(marker.label.clone(), chip_tone(marker.kind), t, cx).into_any_element() + })) + .when_some(file.role_badge, |d, badge| { + d.child(role_badge(badge, t, cx)) + }) + .child(div().flex_1()) + .child(churn_cell(file.added, file.deleted, t, cx)) + .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) + .on_click(cx.listener(move |this, _, _window, cx| { + this.review_ui.nav_cursor = Some(super::NavRowId::File(for_click.clone())); + this.review_open_file(for_click.clone(), cx); + })) + .into_any_element() + } + + /// One changed symbol under its file — spec §7. The fill marks the symbol + /// the content area currently shows. + fn render_symbol_row( + &self, + row: &NavRow, + symbol: &SymbolRow, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let Some(id) = row.id.as_ref() else { + return div().into_any_element(); + }; + let cursor = self.review_ui.nav_cursor.as_ref() == Some(id); + let open = matches!(&symbol.target, AttentionTarget::Symbol { file, change_index } + if self.review_ui.selected_symbol + == Some(SymbolRef { file: file.clone(), change_index: *change_index })); + let tooltip = SharedString::from(symbol.tooltip.clone()); + let target = symbol.target.clone(); + tree_row( + super::nav_element_id("review-symbol", id), + row.depth, + cursor, + open, + t, + ) + .child( + div() + .w(px(GLYPH_WIDTH)) + .flex_shrink_0() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(glyph(symbol.glyph)), + ) + .child( + div() + .min_w_0() + .truncate() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_primary)) + .child(symbol.name.clone()), + ) + .children(symbol.markers.iter().map(|marker| { + chip(marker.label.clone(), chip_tone(marker.kind), t, cx).into_any_element() + })) + .when_some(symbol.role_badge, |d, badge| { + d.child(role_badge(badge, t, cx)) + }) + .child(div().flex_1()) + .child(churn_cell(symbol.added, symbol.deleted, t, cx)) + .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) + .on_click(cx.listener(move |this, _, _window, cx| { + this.review_ui.nav_cursor = Some(super::NavRowId::Item(target.clone())); + this.review_open_item(target.clone(), cx); + })) + .into_any_element() + } + + /// One line of what changed inside a symbol: the signature pair, or a call. + /// It opens the symbol like the row above it, but `↑` `↓` step over it. + fn render_detail_row( + &self, + row: &NavRow, + detail: &DetailRow, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let tooltip = SharedString::from(detail.text.clone()); + let target = detail.target.clone(); + tree_row( + super::detail_element_id(&detail.target, detail.position), + row.depth, + false, + false, + t, + ) + .child(detail_marker(detail.kind, t, cx)) + .child( + div() + .min_w_0() + .truncate() + .when(detail.kind != DetailKind::More, |d| { + d.font_family("monospace") + }) + .text_size(ui_text_sm(cx)) + .text_color(rgb(if detail.kind == DetailKind::More { + t.text_muted + } else { + t.text_secondary + })) + .child(detail.text.clone()), + ) + .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) + .on_click(cx.listener(move |this, _, _window, cx| { + this.review_ui.nav_cursor = Some(super::NavRowId::Item(target.clone())); + this.review_open_item(target.clone(), cx); + })) + .into_any_element() + } +} + +/// `sig` for a signature change, `+` `−` `~` for a call; nothing for the +/// `… 4 more` line, whose own words say what it is. +fn detail_marker(kind: DetailKind, t: &ThemeColors, cx: &App) -> Div { + let (text, color) = match kind { + DetailKind::Signature => (words::SIGNATURE_LINE, t.warning), + DetailKind::Call(CallChangeKind::Added) => ( + labels::calls::call_marker(CallChangeKind::Added), + t.diff_added_fg, + ), + DetailKind::Call(CallChangeKind::Removed) => ( + labels::calls::call_marker(CallChangeKind::Removed), + t.diff_removed_fg, + ), + DetailKind::Call(CallChangeKind::Modified) => ( + labels::calls::call_marker(CallChangeKind::Modified), + t.term_blue, + ), + DetailKind::More => ("", t.text_muted), + }; + div() + .w(px(MARKER_WIDTH)) + .flex_shrink_0() + .font_family("monospace") + .text_size(ui_text_sm(cx)) + .text_color(rgb(color)) + .child(text) +} + +/// `Tests` / `Docs` … — outlined, so it reads as a label and not a reason. +fn role_badge(badge: &'static str, t: &ThemeColors, cx: &App) -> Div { + div() + .flex_shrink_0() + .px(px(4.0)) + .rounded(RADIUS_MD) + .border_1() + .border_color(rgb(t.border)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(badge) +} + +/// One row of the tree: the accent stripe, the indent rail, then the content. +/// The stripe marks the keyboard cursor, the fill the file that is open. +fn tree_row( + id: ElementId, + depth: usize, + cursor: bool, + open: bool, + t: &ThemeColors, +) -> Stateful
{ + h_flex() + .id(id) + .h(px(TREE_ROW_HEIGHT)) + .w_full() + .items_center() + .gap(px(4.0)) + .cursor_pointer() + .when(open, |d| d.bg(rgb(t.bg_selection))) + .when(cursor && !open, |d| d.bg(rgb(t.bg_hover))) + .hover(|s| s.bg(rgb(t.bg_hover))) + .child(selection_bar(cursor, t)) + .child(indent_rail(depth, t)) +} + +/// The indent, drawn rather than left blank: one hairline per level the row +/// hangs under, so five levels of tree read as five levels and not as text at +/// five x positions. +fn indent_rail(depth: usize, t: &ThemeColors) -> Div { + h_flex() + .flex_shrink_0() + .h_full() + .children((0..depth).map(|_| { + div() + .w(px(INDENT)) + .h_full() + .border_l_1() + .border_color(rgb(t.border)) + })) +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/navigator/items.rs b/crates/okena-views-git/src/diff_viewer/review_ui/navigator/items.rs new file mode 100644 index 000000000..a1c7912e4 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/navigator/items.rs @@ -0,0 +1,670 @@ +//! Attention-mode row model — spec §7. +//! +//! Pure. The ordered list is `ReviewModel::attention` narrowed by the navigator +//! filters; the grouped variant re-buckets the same rows by file without +//! reordering them. + +use super::super::super::review::ReviewFileKey; +use super::super::labels::nav as words; +use super::super::model::{AttentionTarget, KindGlyph, Reason, ReasonKind, ReviewModel, Tier}; +use super::super::state::{AttentionFilter, NavRowId, RoleFilter}; +use super::rows::matches_filter; + +/// A two-line row has room for two chips — spec §7. +const MAX_CHIPS: usize = 2; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct AttentionRow { + /// Tier separators are not navigable, so they carry no id. + pub id: Option, + pub kind: AttentionRowKind, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum AttentionRowKind { + Tier(&'static str), + Group(GroupRow), + Item(ItemRow), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct GroupRow { + /// The header opens its file, so it carries the file's identity. + pub key: ReviewFileKey, + pub path: String, + pub count: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ItemRow { + pub target: AttentionTarget, + pub glyph: KindGlyph, + pub name: String, + /// The file the item lives in, or what a directory row counts. + pub path: String, + pub added: u64, + pub deleted: u64, + /// At most [`MAX_CHIPS`], already shortened for the column. + pub chips: Vec, + /// Ranked from git facts only — structure never reached it. + pub dimmed: bool, + /// Indented under a file header in the grouped variant. + pub nested: bool, +} + +/// Indices into `ReviewModel::attention` that pass every navigator filter. +/// +/// Mirrors `DiffViewer::review_visible_attention`; `]` `[` walk that one and +/// `↑` `↓` walk this one, so the two predicates must stay identical. +pub(crate) fn visible_attention( + model: &ReviewModel, + filter: &AttentionFilter, + role_filter: &RoleFilter, + filter_text: &str, +) -> Vec { + let needle = filter_text.to_lowercase(); + model + .attention + .iter() + .enumerate() + .filter(|(_, item)| { + filter.kinds.is_empty() + || item + .reasons + .iter() + .any(|reason| filter.kinds.contains(&reason.kind)) + }) + .filter(|(_, item)| filter.include_tests || !item.is_test) + .filter(|(_, item)| match item.target.file() { + Some(key) => model + .file_index(key) + .and_then(|index| model.files.get(index)) + .is_some_and(|entry| role_filter.allows(entry)), + None => true, + }) + .filter(|(_, item)| { + matches_filter(&item.path, &needle) || matches_filter(&item.name, &needle) + }) + .map(|(index, _)| index) + .collect() +} + +/// Every visible row of the Attention list, in display order. +pub(crate) fn attention_rows( + model: &ReviewModel, + attention_filter: &AttentionFilter, + role_filter: &RoleFilter, + filter_text: &str, +) -> Vec { + let visible = visible_attention(model, attention_filter, role_filter, filter_text); + if attention_filter.grouped_by_file { + grouped_rows(model, &visible) + } else { + ordered_rows(model, &visible) + } +} + +/// The ranked list with one separator per tier that has rows — spec §7. +fn ordered_rows(model: &ReviewModel, visible: &[usize]) -> Vec { + let mut out = Vec::new(); + let mut tier: Option = None; + for index in visible { + let Some(item) = model.attention.get(*index) else { + continue; + }; + if tier != Some(item.tier) { + tier = Some(item.tier); + out.push(AttentionRow { + id: None, + kind: AttentionRowKind::Tier(words::tier_label(item.tier)), + }); + } + out.extend(item_row(model, *index, false)); + } + out +} + +/// The same rows bucketed by file; a file keeps the rank of its first item. +fn grouped_rows(model: &ReviewModel, visible: &[usize]) -> Vec { + let mut groups: Vec<(ReviewFileKey, String, Vec)> = Vec::new(); + let mut loose: Vec = Vec::new(); + for index in visible { + let Some(item) = model.attention.get(*index) else { + continue; + }; + // Directory rows belong to no file, so they stay in the flat order. + let Some(key) = item.target.file() else { + loose.push(*index); + continue; + }; + match groups.iter_mut().find(|(candidate, _, _)| candidate == key) { + Some((_, _, members)) => members.push(*index), + None => groups.push((key.clone(), item.path.clone(), vec![*index])), + } + } + let mut out = Vec::new(); + for (key, path, members) in groups { + out.push(AttentionRow { + // A header is its file: `↑` `↓` and `↵` open it like a tree row. + id: Some(NavRowId::File(key.clone())), + kind: AttentionRowKind::Group(GroupRow { + key, + path, + count: members.len(), + }), + }); + out.extend( + members + .iter() + .filter_map(|index| item_row(model, *index, true)), + ); + } + out.extend( + loose + .iter() + .filter_map(|index| item_row(model, *index, false)), + ); + out +} + +fn item_row(model: &ReviewModel, index: usize, nested: bool) -> Option { + let item = model.attention.get(index)?; + Some(AttentionRow { + id: Some(NavRowId::Item(item.target.clone())), + kind: AttentionRowKind::Item(ItemRow { + target: item.target.clone(), + glyph: item.glyph, + name: item.name.clone(), + path: item.path.clone(), + added: item.lines_added, + deleted: item.lines_deleted, + chips: item_chips(&item.reasons), + dimmed: item.dimmed, + nested, + }), + }) +} + +/// The ids `↑` `↓` walk: every row except the tier separators, in order. +pub(crate) fn row_ids(rows: &[AttentionRow]) -> Vec { + rows.iter().filter_map(|row| row.id.clone()).collect() +} + +/// The two chips that say the most; `body` never displaces a measurement. +fn item_chips(reasons: &[Reason]) -> Vec { + let mut ordered: Vec<&Reason> = reasons.iter().collect(); + ordered.sort_by_key(|reason| u8::from(reason.kind == ReasonKind::Body)); + let mut out: Vec = Vec::with_capacity(MAX_CHIPS); + for reason in ordered { + let label = words::short_chip(&reason.label).to_string(); + if out.iter().any(|kept| kept.label == label) { + continue; + } + out.push(Reason { + kind: reason.kind, + label, + }); + if out.len() == MAX_CHIPS { + break; + } + } + out +} + +// -- reason filter chips ----------------------------------------------------- + +/// One OR filter over a group of reason kinds — spec §7. +pub(crate) struct ChipSpec { + pub word: &'static str, + pub kinds: &'static [ReasonKind], +} + +pub(crate) const REASON_CHIPS: [ChipSpec; 6] = [ + ChipSpec { + word: "sig", + kinds: &[ReasonKind::PublicSignature, ReasonKind::ExportedSignature], + }, + ChipSpec { + word: "removed", + kinds: &[ + ReasonKind::PublicRemoved, + ReasonKind::Removed, + ReasonKind::DeletedImpl, + ], + }, + ChipSpec { + word: "calls", + kinds: &[ReasonKind::Calls], + }, + ChipSpec { + word: "new", + kinds: &[ReasonKind::New, ReasonKind::NewPublic], + }, + ChipSpec { + word: words::NO_TESTS_MARKER, + kinds: &[ReasonKind::NoTestChanges], + }, + ChipSpec { + word: "git facts", + kinds: &[ + ReasonKind::CiConfig, + ReasonKind::Lockfile, + ReasonKind::Submodule, + ReasonKind::Binary, + ReasonKind::Moved, + ReasonKind::LargeChurn, + ], + }, +]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ChipView { + pub word: &'static str, + pub label: String, + pub count: usize, + pub active: bool, + /// The kinds one click has to flip. + pub kinds: &'static [ReasonKind], +} + +/// The chip row, counted over the *unfiltered* list so the numbers never move +/// under the pointer. Chips nothing matches are dropped — spec §2, no zeros. +pub(crate) fn reason_chips(model: &ReviewModel, filter: &AttentionFilter) -> Vec { + REASON_CHIPS + .iter() + .filter_map(|spec| { + let count = model + .attention + .iter() + .filter(|item| { + item.reasons + .iter() + .any(|reason| spec.kinds.contains(&reason.kind)) + }) + .count(); + (count > 0).then(|| ChipView { + word: spec.word, + label: format!("{} {count}", spec.word), + count, + active: spec.kinds.iter().any(|kind| filter.kinds.contains(kind)), + kinds: spec.kinds, + }) + }) + .collect() +} + +/// The kinds one chip click has to hand to `review_toggle_reason_filter`. +pub(crate) fn chip_toggle_kinds(chip: &ChipView, filter: &AttentionFilter) -> Vec { + chip.kinds + .iter() + .copied() + .filter(|kind| filter.kinds.contains(kind) == chip.active) + .collect() +} + +/// The active chip words, for the footer sentence. +pub(crate) fn active_chip_words(chips: &[ChipView]) -> Vec<&'static str> { + chips + .iter() + .filter(|chip| chip.active) + .map(|chip| chip.word) + .collect() +} + +#[cfg(test)] +mod tests { + use super::super::super::fixtures; + use super::super::super::model::{AttentionTarget, ReasonKind, Tier}; + use super::super::super::state::{AttentionFilter, NavRowId, RoleFilter, RolePreset}; + use super::{ + AttentionRow, AttentionRowKind, active_chip_words, attention_rows, chip_toggle_kinds, + reason_chips, row_ids, visible_attention, + }; + use std::collections::BTreeSet; + + fn labels(rows: &[AttentionRow]) -> Vec { + rows.iter() + .map(|row| match &row.kind { + AttentionRowKind::Tier(label) => (*label).to_string(), + AttentionRowKind::Group(group) => format!("[{}] {}", group.count, group.path), + AttentionRowKind::Item(item) => item.name.clone(), + }) + .collect() + } + + #[test] + fn a_separator_opens_every_tier_that_has_rows() { + let model = fixtures::model(); + let rows = attention_rows( + &model, + &AttentionFilter::default(), + &RoleFilter::everything(), + "", + ); + let separators: Vec = rows + .iter() + .filter_map(|row| match &row.kind { + AttentionRowKind::Tier(label) => Some((*label).to_string()), + _ => None, + }) + .collect(); + assert_eq!( + separators, + ["CONTRACT", "BEHAVIOUR", "VOLUME", "GIT FACTS", "REST"] + ); + + // Every separator sits directly above a row of its own tier. + let mut tier = None; + for row in &rows { + match &row.kind { + AttentionRowKind::Tier(label) => tier = Some(*label), + AttentionRowKind::Item(item) => { + let expected = model + .attention + .iter() + .find(|candidate| candidate.target == item.target) + .map(|candidate| candidate.tier) + .unwrap_or(Tier::Rest); + assert_eq!(tier, Some(super::words::tier_label(expected))); + } + AttentionRowKind::Group(_) => panic!("the ordered list has no groups"), + } + } + } + + #[test] + fn separators_are_never_navigable_but_every_item_is() { + let model = fixtures::model(); + let rows = attention_rows( + &model, + &AttentionFilter::default(), + &RoleFilter::everything(), + "", + ); + for row in &rows { + match &row.kind { + AttentionRowKind::Tier(_) => assert!(row.id.is_none()), + _ => assert!(row.id.is_some()), + } + } + } + + #[test] + fn the_reason_filter_keeps_only_items_that_carry_one_of_its_kinds() { + let model = fixtures::model(); + let filter = AttentionFilter { + kinds: BTreeSet::from([ReasonKind::PublicRemoved]), + ..AttentionFilter::default() + }; + let visible = visible_attention(&model, &filter, &RoleFilter::everything(), ""); + assert!(!visible.is_empty()); + for index in visible { + assert!( + model.attention[index] + .reasons + .iter() + .any(|reason| reason.kind == ReasonKind::PublicRemoved) + ); + } + } + + #[test] + fn tests_stay_out_of_the_list_until_the_toggle_asks_for_them() { + let model = fixtures::model(); + let without = visible_attention( + &model, + &AttentionFilter::default(), + &RoleFilter::everything(), + "", + ); + assert!(without.iter().all(|index| !model.attention[*index].is_test)); + + let filter = AttentionFilter { + include_tests: true, + ..AttentionFilter::default() + }; + let with = visible_attention(&model, &filter, &RoleFilter::everything(), ""); + assert!(with.len() > without.len()); + assert!(with.iter().any(|index| model.attention[*index].is_test)); + } + + #[test] + fn the_role_filter_and_the_text_filter_narrow_the_same_list() { + let model = fixtures::model(); + let role_filter = RoleFilter::preset(RolePreset::Supporting); + let supporting = visible_attention( + &model, + &AttentionFilter { + include_tests: true, + ..AttentionFilter::default() + }, + &role_filter, + "", + ); + assert!(!supporting.is_empty()); + assert!(supporting.iter().all(|index| { + match model.attention[*index].target.file() { + // A directory row belongs to no file, so no role excludes it. + None => true, + Some(key) => model + .file_index(key) + .and_then(|file| model.files.get(file)) + .is_some_and(|entry| role_filter.allows(entry)), + } + })); + + let typed = visible_attention( + &model, + &AttentionFilter::default(), + &RoleFilter::everything(), + "engine", + ); + assert!(!typed.is_empty()); + assert!(typed.iter().all(|index| { + let item = &model.attention[*index]; + item.path.contains("engine") || item.name.contains("engine") + })); + } + + #[test] + fn grouping_puts_a_header_over_every_file_and_keeps_the_ranked_order() { + let model = fixtures::model(); + let filter = AttentionFilter { + grouped_by_file: true, + ..AttentionFilter::default() + }; + let rows = attention_rows(&model, &filter, &RoleFilter::everything(), ""); + assert!( + !rows + .iter() + .any(|row| matches!(row.kind, AttentionRowKind::Tier(_))), + "the grouped variant drops the tier separators" + ); + let first = labels(&rows) + .into_iter() + .next() + .expect("the fixture has rows"); + assert!(first.starts_with('['), "a header opens the list: {first}"); + + // Every header counts exactly the rows nested under it. + let mut counted = 0usize; + let mut expected = 0usize; + for row in &rows { + match &row.kind { + AttentionRowKind::Group(group) => { + assert_eq!(counted, expected, "the previous header miscounted"); + counted = 0; + expected = group.count; + assert!(matches!(row.id, Some(NavRowId::File(_)))); + } + AttentionRowKind::Item(item) if item.nested => counted += 1, + _ => {} + } + } + assert_eq!(counted, expected); + } + + #[test] + fn chips_count_the_unfiltered_list_and_drop_the_empty_ones() { + let model = fixtures::model(); + let chips = reason_chips(&model, &AttentionFilter::default()); + let words: Vec<&str> = chips.iter().map(|chip| chip.word).collect(); + assert_eq!( + words, + ["sig", "removed", "calls", "new", "no tests", "git facts"] + ); + assert!(chips.iter().all(|chip| chip.count > 0)); + + let sig = chips + .iter() + .find(|chip| chip.word == "sig") + .expect("the fixture changes signatures"); + assert_eq!(sig.label, format!("sig {}", sig.count)); + assert!(!sig.active); + + let filter = AttentionFilter { + kinds: BTreeSet::from([ReasonKind::PublicSignature]), + ..AttentionFilter::default() + }; + let chips = reason_chips(&model, &filter); + assert_eq!(active_chip_words(&chips), ["sig"]); + + // Turning the chip off has to clear both signature kinds. + let sig = chips + .iter() + .find(|chip| chip.word == "sig") + .expect("the sig chip"); + assert_eq!( + chip_toggle_kinds(sig, &filter), + [ReasonKind::PublicSignature] + ); + + let off = AttentionFilter::default(); + let chips = reason_chips(&model, &off); + let sig = chips + .iter() + .find(|chip| chip.word == "sig") + .expect("the sig chip"); + assert_eq!( + chip_toggle_kinds(sig, &off), + [ReasonKind::PublicSignature, ReasonKind::ExportedSignature] + ); + } + + #[test] + fn a_row_shows_two_chips_and_body_is_the_first_to_go() { + let model = fixtures::model(); + let rows = attention_rows( + &model, + &AttentionFilter::default(), + &RoleFilter::everything(), + "", + ); + let item = rows + .iter() + .find_map(|row| match &row.kind { + AttentionRowKind::Item(item) if item.name.ends_with("run") => Some(item), + _ => None, + }) + .expect("Engine::run is in the fixture"); + let chips: Vec<&str> = item.chips.iter().map(|chip| chip.label.as_str()).collect(); + assert_eq!(chips, ["sig", "2 calls \u{00B7} error branch"]); + + assert!(rows.iter().all(|row| match &row.kind { + AttentionRowKind::Item(item) => item.chips.len() <= 2, + _ => true, + })); + } + + #[test] + fn a_directory_row_names_what_it_counts() { + let model = fixtures::model(); + let rows = attention_rows( + &model, + &AttentionFilter::default(), + &RoleFilter::everything(), + "", + ); + let directory = rows + .iter() + .find_map(|row| match &row.kind { + AttentionRowKind::Item(item) + if matches!(item.target, AttentionTarget::Directory(_)) => + { + Some(item) + } + _ => None, + }) + .expect("src/ changed no tests"); + assert_eq!( + directory + .chips + .iter() + .map(|chip| chip.label.as_str()) + .collect::>(), + ["no tests"] + ); + assert!(directory.path.contains("implementation files")); + } + + #[test] + fn a_file_header_is_its_file_so_the_keys_open_it() { + let model = fixtures::model(); + let filter = AttentionFilter { + grouped_by_file: true, + ..AttentionFilter::default() + }; + let rows = attention_rows(&model, &filter, &RoleFilter::everything(), ""); + let (id, group) = rows + .iter() + .find_map(|row| match &row.kind { + AttentionRowKind::Group(group) => Some((row.id.clone(), group)), + _ => None, + }) + .expect("the grouped list opens with a header"); + assert_eq!( + id, + Some(NavRowId::File(group.key.clone())), + "the header id is the file id, not a directory path" + ); + + // The key is the one the items under it carry. + let first_item = rows + .iter() + .find_map(|row| match &row.kind { + AttentionRowKind::Item(item) if item.nested => Some(item), + _ => None, + }) + .expect("the header has rows under it"); + assert_eq!(first_item.target.file(), Some(&group.key)); + } + + #[test] + fn the_cursor_walks_every_row_except_the_tier_separators() { + let model = fixtures::model(); + for grouped in [false, true] { + let filter = AttentionFilter { + grouped_by_file: grouped, + ..AttentionFilter::default() + }; + let rows = attention_rows(&model, &filter, &RoleFilter::everything(), ""); + let expected: Vec = rows + .iter() + .filter(|row| !matches!(row.kind, AttentionRowKind::Tier(_))) + .map(|row| row.id.clone().expect("only separators lack an id")) + .collect(); + assert_eq!(row_ids(&rows), expected); + assert_eq!( + row_ids(&rows).len() + separators(&rows), + rows.len(), + "grouped={grouped}" + ); + } + } + + fn separators(rows: &[AttentionRow]) -> usize { + rows.iter() + .filter(|row| matches!(row.kind, AttentionRowKind::Tier(_))) + .count() + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/navigator/mod.rs b/crates/okena-views-git/src/diff_viewer/review_ui/navigator/mod.rs new file mode 100644 index 000000000..58d8242af --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/navigator/mod.rs @@ -0,0 +1,641 @@ +//! Navigator column: Files tree and Attention list — spec §7. +//! +//! `mod.rs` owns the column chrome (segmented control, filter box, Roles button, +//! footer) and the pieces both lists share; `rows` / `items` / `roles` hold the +//! pure view models the two lists render. + +mod attention; +mod files; +pub(super) mod items; +mod roles; +mod roles_menu; +mod rows; + +use super::super::DiffViewer; +use super::super::review::ReviewFileKey; +use super::labels::nav as words; +use super::model::{AttentionTarget, ReasonKind}; +use super::state::{NavRowId, NavigatorMode, RolePreset}; +use gpui::prelude::*; +use gpui::*; +use gpui_component::h_flex; +use gpui_component::tooltip::Tooltip; +use okena_core::theme::ThemeColors; +use okena_ui::simple_input::SimpleInput; +use okena_ui::tokens::{RADIUS_MD, RADIUS_STD, ui_text_ms, ui_text_sm}; + +/// One tree row; the list is virtualized, so every row is the same height. +const TREE_ROW_HEIGHT: f32 = 22.0; +/// Attention rows carry two lines of text. +const ITEM_ROW_HEIGHT: f32 = 40.0; +const COLUMN_PADDING: f32 = 8.0; +/// The accent stripe that marks the selected row. +const SELECTION_BAR: f32 = 2.0; + +impl DiffViewer { + pub(crate) fn render_navigator( + &mut self, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + self.review_init_expanded_dirs(); + let mode = self.review_ui.navigator; + let (files, items) = self.review_visible_counts(); + let placeholder = match mode { + NavigatorMode::Files => words::FILTER_PLACEHOLDER_FILES, + NavigatorMode::Attention => words::FILTER_PLACEHOLDER_ITEMS, + }; + self.review_ui + .filter_input + .update(cx, |input, _cx| input.set_placeholder(placeholder)); + + let tabs = self.render_navigator_tabs(files, items, t, cx); + let filter = self.render_navigator_filter(t, cx); + let roles = self.render_navigator_roles_row(t, cx); + let body = match mode { + NavigatorMode::Files => self.render_files_tree(t, cx), + NavigatorMode::Attention => self.render_attention_list(t, cx), + }; + let footer = self.render_navigator_footer(files, items, t, cx); + + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .child(tabs) + .child(filter) + .child(roles) + .child(body) + .child(footer) + .into_any_element() + } + + /// Visible rows of the current navigator mode, in display order. + pub(crate) fn navigator_row_ids(&self) -> Vec { + let Some(model) = self.review_ui.model.as_deref() else { + return Vec::new(); + }; + let state = &self.review_ui; + match state.navigator { + NavigatorMode::Files => { + rows::row_ids(&rows::nav_rows(model, &rows::TreeArgs::of(state))) + } + NavigatorMode::Attention => items::row_ids(&items::attention_rows( + model, + &state.attention_filter, + &state.role_filter, + &state.filter_text, + )), + } + } + + /// Seed `expanded_dirs` once so the tree the user first sees follows the + /// default rule and every later toggle is theirs alone — spec §7. + fn review_init_expanded_dirs(&mut self) { + if self.review_ui.expanded_initialized { + return; + } + let Some(model) = self.review_ui.model.clone() else { + return; + }; + let defaults = rows::default_expanded_dirs( + &model, + &self.review_ui.role_filter, + &self.review_ui.filter_text, + ); + self.review_ui.expanded_dirs.extend(defaults); + self.review_ui.expanded_initialized = true; + } + + /// `(visible files, visible attention items)` — the segmented control counts. + fn review_visible_counts(&self) -> (usize, usize) { + let Some(model) = self.review_ui.model.as_deref() else { + return (0, 0); + }; + let state = &self.review_ui; + ( + rows::visible_files(model, &state.role_filter, &state.filter_text).len(), + items::visible_attention( + model, + &state.attention_filter, + &state.role_filter, + &state.filter_text, + ) + .len(), + ) + } + + fn render_navigator_tabs( + &self, + files: usize, + items: usize, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let mode = self.review_ui.navigator; + h_flex() + .p(px(COLUMN_PADDING)) + .gap(px(4.0)) + .child( + nav_tab( + "review-nav-files", + words::FILES_TAB, + files, + mode == NavigatorMode::Files, + t, + cx, + ) + .on_click(cx.listener(|this, _, _window, cx| { + this.review_set_navigator(NavigatorMode::Files, cx); + })), + ) + .child( + nav_tab( + "review-nav-attention", + words::ATTENTION_TAB, + items, + mode == NavigatorMode::Attention, + t, + cx, + ) + .on_click(cx.listener(|this, _, _window, cx| { + this.review_set_navigator(NavigatorMode::Attention, cx); + })), + ) + .into_any_element() + } + + fn render_navigator_filter(&self, t: &ThemeColors, cx: &mut Context) -> AnyElement { + h_flex() + .px(px(COLUMN_PADDING)) + .pb(px(6.0)) + .child( + h_flex() + .flex_1() + .min_w_0() + .h(px(24.0)) + .px(px(6.0)) + .gap(px(6.0)) + .items_center() + .rounded(RADIUS_STD) + .bg(rgb(t.bg_secondary)) + .border_1() + .border_color(rgb(t.border)) + .child(div().flex_1().min_w_0().child( + SimpleInput::new(&self.review_ui.filter_input).text_size(ui_text_ms(cx)), + )) + .child( + div() + .px(px(4.0)) + .rounded(RADIUS_MD) + .bg(rgb(t.bg_primary)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(words::FILTER_KEY_HINT), + ), + ) + .into_any_element() + } + + /// The one role control, plus `flatten` while the tree is on screen. + fn render_navigator_roles_row(&self, t: &ThemeColors, cx: &mut Context) -> AnyElement { + let filter = self.review_ui.role_filter; + let active = !filter.is_everything(); + let flatten = self.review_ui.flatten; + h_flex() + .px(px(COLUMN_PADDING)) + .pb(px(6.0)) + .gap(px(6.0)) + .items_center() + .justify_between() + .child( + h_flex() + .min_w_0() + .gap(px(2.0)) + .child( + h_flex() + .id("review-roles-button") + .cursor_pointer() + .h(px(20.0)) + .px(px(6.0)) + .gap(px(4.0)) + .items_center() + .rounded(RADIUS_STD) + .bg(rgb(if active { + t.bg_selection + } else { + t.bg_secondary + })) + .border_1() + .border_color(rgb(if active { t.border_active } else { t.border })) + .hover(|s| s.bg(rgb(t.bg_hover))) + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_primary)) + .child(words::roles_button(&filter.label())) + .child( + div() + .text_color(rgb(t.text_muted)) + .child(words::CHEVRON_DOWN), + ) + .on_click(cx.listener(|this, _, _window, cx| { + this.review_toggle_roles_menu(cx); + })), + ) + .when(active, |d| { + d.child( + div() + .id("review-roles-clear") + .cursor_pointer() + .px(px(4.0)) + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .hover(|s| s.text_color(rgb(t.text_primary))) + .child(words::CLEAR_GLYPH) + .on_click(cx.listener(|this, _, _window, cx| { + this.review_clear_role_filter(cx); + })), + ) + }), + ) + .when(self.review_ui.navigator == NavigatorMode::Files, |d| { + let outline = self.review_ui.outline_inline; + d.child( + h_flex() + .flex_shrink_0() + .gap(px(10.0)) + .child( + tree_switch("review-outline", words::OUTLINE, outline, t, cx) + .tooltip(|window, cx| { + Tooltip::new(words::OUTLINE_HINT).build(window, cx) + }) + .on_click(cx.listener(move |this, _, _window, cx| { + this.review_set_outline(!outline, cx); + })), + ) + .child( + tree_switch("review-flatten", words::FLATTEN, flatten, t, cx).on_click( + cx.listener(move |this, _, _window, cx| { + this.review_set_flatten(!flatten, cx); + }), + ), + ), + ) + }) + .into_any_element() + } + + fn render_navigator_footer( + &self, + files: usize, + items: usize, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let mode = self.review_ui.navigator; + let filter = self.review_ui.role_filter; + let role_label = (!filter.is_everything()).then(|| filter.label()); + let (line, right) = match mode { + NavigatorMode::Files => ( + words::files_footer( + files, + self.review_file_total(), + role_label.as_deref(), + self.review_not_analyzed_total(), + self.review_ui + .outline_inline + .then(|| self.review_visible_symbols()), + ), + None, + ), + NavigatorMode::Attention => { + let chips = self.review_active_chip_words(); + ( + words::attention_footer( + items, + self.review_attention_total(), + &chips, + !self.review_ui.attention_filter.include_tests, + ), + Some(if self.review_ui.attention_filter.grouped_by_file { + words::ORDERED_LIST + } else { + words::GROUP_BY_FILE + }), + ) + } + }; + + h_flex() + .h(px(24.0)) + .flex_shrink_0() + .px(px(COLUMN_PADDING)) + .gap(px(8.0)) + .items_center() + .justify_between() + .border_t_1() + .border_color(rgb(t.border)) + .child( + div() + .min_w_0() + .truncate() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(line.text), + ) + .when_some(line.action, |d, action| { + d.child( + div() + .id("review-nav-show-all") + .flex_shrink_0() + .cursor_pointer() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.term_blue)) + .hover(|s| s.text_color(rgb(t.text_primary))) + .child(action) + .on_click(cx.listener(|this, _, _window, cx| this.review_show_all(cx))), + ) + }) + .when_some(right, |d, label| { + d.child( + div() + .id("review-nav-group-toggle") + .flex_shrink_0() + .cursor_pointer() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.term_blue)) + .hover(|s| s.text_color(rgb(t.text_primary))) + .child(label) + .on_click(cx.listener(|this, _, _window, cx| { + this.review_toggle_group_by_file(cx); + })), + ) + }) + .into_any_element() + } + + /// Changed symbols the outline currently lists — how long the scroll is. + fn review_visible_symbols(&self) -> usize { + let Some(model) = self.review_ui.model.as_deref() else { + return 0; + }; + let state = &self.review_ui; + rows::visible_files(model, &state.role_filter, &state.filter_text) + .iter() + .filter_map(|index| model.files.get(*index)) + .map(|entry| entry.symbols.len()) + .sum() + } + + fn review_file_total(&self) -> usize { + self.review_ui + .model + .as_ref() + .map_or(0, |model| model.files.len()) + } + + fn review_attention_total(&self) -> usize { + self.review_ui + .model + .as_ref() + .map_or(0, |model| model.attention.len()) + } + + /// Files structure never reached; the footer explains why rows are dim. + fn review_not_analyzed_total(&self) -> usize { + self.review_ui + .model + .as_ref() + .map_or(0, |model| rows::not_analyzed_count(model)) + } + + fn review_active_chip_words(&self) -> Vec<&'static str> { + self.review_ui + .model + .as_ref() + .map_or_else(Vec::new, |model| { + items::active_chip_words(&items::reason_chips( + model, + &self.review_ui.attention_filter, + )) + }) + } + + /// `show all` — spec §7 puts every filter behind one way back. + fn review_show_all(&mut self, cx: &mut Context) { + self.review_clear_filter(cx); + self.review_apply_preset(RolePreset::Everything, cx); + } + + fn review_clear_role_filter(&mut self, cx: &mut Context) { + self.review_apply_preset(RolePreset::Everything, cx); + } + + /// The empty Attention list's way out: drop every filter that hides rows. + /// The `tests` chip stays as it is — spec §6 excludes tests by default. + fn review_clear_attention_filters(&mut self, cx: &mut Context) { + let kinds: Vec = self + .review_ui + .attention_filter + .kinds + .iter() + .copied() + .collect(); + for kind in kinds { + self.review_toggle_reason_filter(kind, cx); + } + self.review_show_all(cx); + } + + /// Scroll the cursor row into view once, when something moved it. + /// + /// Edge-triggered on purpose: re-asserting it every render would undo the + /// wheel the moment the cursor left the viewport. + fn review_reveal_cursor( + &mut self, + rows: &[Option], + scroll: &UniformListScrollHandle, + ) { + let Some(strategy) = self.review_ui.nav_reveal else { + return; + }; + let Some(cursor) = self.review_ui.nav_cursor.as_ref() else { + self.review_ui.nav_reveal = None; + return; + }; + if let Some(index) = rows + .iter() + .position(|row| row.as_ref().is_some_and(|id| id == cursor)) + { + scroll.scroll_to_item(index, strategy); + } + self.review_ui.nav_reveal = None; + } +} + +/// A text switch over the tree — `outline`, `flatten`; on reads in the accent. +fn tree_switch( + id: &'static str, + label: &'static str, + on: bool, + t: &ThemeColors, + cx: &App, +) -> Stateful
{ + div() + .id(id) + .cursor_pointer() + .flex_shrink_0() + .text_size(ui_text_ms(cx)) + .text_color(rgb(if on { t.term_blue } else { t.text_muted })) + .hover(|s| s.text_color(rgb(t.text_primary))) + .child(label) +} + +fn nav_tab( + id: &'static str, + label: &'static str, + count: usize, + active: bool, + t: &ThemeColors, + cx: &App, +) -> Stateful
{ + h_flex() + .id(id) + .flex_1() + .h(px(24.0)) + .gap(px(6.0)) + .items_center() + .justify_center() + .rounded(RADIUS_STD) + .cursor_pointer() + .bg(rgb(if active { t.bg_header } else { t.bg_secondary })) + .border_1() + .border_color(rgb(if active { t.border_active } else { t.border })) + .hover(|s| s.bg(rgb(t.bg_hover))) + .text_size(ui_text_ms(cx)) + .text_color(rgb(if active { t.text_primary } else { t.text_muted })) + .child(label) + .child( + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(count.to_string()), + ) +} + +/// How loud a reason chip reads. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ChipTone { + Neutral, + Added, + Removed, + Warn, +} + +fn chip_tone(kind: ReasonKind) -> ChipTone { + match kind { + ReasonKind::PublicRemoved | ReasonKind::Removed | ReasonKind::DeletedImpl => { + ChipTone::Removed + } + ReasonKind::New | ReasonKind::NewPublic => ChipTone::Added, + ReasonKind::PublicSignature | ReasonKind::ExportedSignature | ReasonKind::NoTestChanges => { + ChipTone::Warn + } + _ => ChipTone::Neutral, + } +} + +fn chip(label: impl Into, tone: ChipTone, t: &ThemeColors, cx: &App) -> Div { + let color = match tone { + ChipTone::Neutral => t.text_secondary, + ChipTone::Added => t.diff_added_fg, + ChipTone::Removed => t.diff_removed_fg, + ChipTone::Warn => t.warning, + }; + div() + .flex_shrink_0() + .px(px(4.0)) + .rounded(RADIUS_MD) + .bg(rgb(t.bg_secondary)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(color)) + .child(label.into()) +} + +/// `+A −D`; a zero side is left out — spec §2 has no zero cells. +fn churn_cell(added: u64, deleted: u64, t: &ThemeColors, cx: &App) -> Div { + let (plus, minus) = super::labels::format_signed(added, deleted); + h_flex() + .flex_shrink_0() + .gap(px(4.0)) + .text_size(ui_text_sm(cx)) + .when(added > 0, |d| { + d.child(div().text_color(rgb(t.diff_added_fg)).child(plus)) + }) + .when(deleted > 0, |d| { + d.child(div().text_color(rgb(t.diff_removed_fg)).child(minus)) + }) +} + +/// Rows are keyed on what they open, never on the text they display: a +/// renamed or re-filtered row keeps its element id, and its scroll state. +fn nav_element_id(prefix: &str, id: &NavRowId) -> ElementId { + ElementId::Name(format!("{prefix}-{}", row_key(id)).into()) +} + +/// A detail line has no `NavRowId`; its element id is the symbol's plus the +/// line's position, so it stays stable across re-renders. +fn detail_element_id(target: &AttentionTarget, position: usize) -> ElementId { + ElementId::Name( + format!( + "review-detail-{}-{position}", + row_key(&NavRowId::Item(target.clone())) + ) + .into(), + ) +} + +fn row_key(id: &NavRowId) -> String { + match id { + NavRowId::Dir(path) => format!("d:{path}"), + NavRowId::File(key) => format!("f:{}", file_key(key)), + NavRowId::Item(target) => match target { + AttentionTarget::Symbol { file, change_index } => { + format!("s:{}#{change_index}", file_key(file)) + } + AttentionTarget::File(key) => format!("i:{}", file_key(key)), + AttentionTarget::Directory(path) => format!("t:{path}"), + }, + } +} + +fn file_key(key: &ReviewFileKey) -> String { + format!( + "{}>{}", + key.old_path.as_deref().unwrap_or_default(), + key.new_path.as_deref().unwrap_or_default() + ) +} + +/// What a list says when every row is filtered away. +fn empty_state(message: &'static str, t: &ThemeColors, cx: &App) -> Div { + h_flex() + .flex_1() + .min_h_0() + .items_start() + .gap(px(6.0)) + .px(px(COLUMN_PADDING)) + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .child(message) +} + +/// The selection accent is its own child, never a border: a later +/// `border_color` on the row would repaint a left border away. +fn selection_bar(selected: bool, t: &ThemeColors) -> Div { + div() + .w(px(SELECTION_BAR)) + .flex_shrink_0() + .h_full() + .when(selected, |d| d.bg(rgb(t.border_active))) +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/navigator/roles.rs b/crates/okena-views-git/src/diff_viewer/review_ui/navigator/roles.rs new file mode 100644 index 000000000..f8a305874 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/navigator/roles.rs @@ -0,0 +1,214 @@ +//! Roles-menu model — spec §7: presets first, then all eleven roles with +//! counts, then the two saved filters. Pure; the menu only renders this. + +use super::super::labels::{role_label, role_short}; +use super::super::model::ReviewModel; +use super::super::state::{ALL_ROLES, RoleFilter, RolePreset, is_likely_mechanical}; +use okena_core::review::FileRole; + +/// Presets in the order the menu offers them. +const PRESETS: [RolePreset; 3] = [ + RolePreset::ReviewCode, + RolePreset::Supporting, + RolePreset::Everything, +]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RolesMenu { + pub presets: Vec, + pub roles: Vec, + pub saved: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PresetRow { + pub preset: RolePreset, + pub label: &'static str, + /// The roles the preset stands for, so the name is never the only clue. + pub hint: String, + pub active: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct RoleRow { + pub role: FileRole, + pub label: &'static str, + pub count: usize, + pub checked: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SavedFilter { + LikelyMechanical, + NotAnalyzed, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SavedRow { + pub filter: SavedFilter, + pub label: &'static str, + /// `17 moves` / `185` — what turning it on would leave. + pub note: String, + pub checked: bool, +} + +pub(crate) fn roles_menu(model: &ReviewModel, filter: &RoleFilter) -> RolesMenu { + RolesMenu { + presets: PRESETS + .into_iter() + .map(|preset| PresetRow { + preset, + label: preset.label(), + hint: preset_hint(preset), + active: filter.preset == preset, + }) + .collect(), + roles: ALL_ROLES + .into_iter() + .map(|role| RoleRow { + role, + label: role_label(role), + count: model + .files + .iter() + .filter(|entry| entry.role == role) + .count(), + checked: filter.roles.contains(role), + }) + .collect(), + saved: vec![ + SavedRow { + filter: SavedFilter::LikelyMechanical, + label: super::super::labels::nav::LIKELY_MECHANICAL, + note: moves_note(model), + checked: filter.likely_mechanical_only, + }, + SavedRow { + filter: SavedFilter::NotAnalyzed, + label: super::super::labels::nav::NOT_ANALYZED_ONLY, + note: super::rows::not_analyzed_count(model).to_string(), + checked: filter.not_analyzed_only, + }, + ], + } +} + +/// `Impl + Config + Unclassified`; *Everything* names its own size instead. +fn preset_hint(preset: RolePreset) -> String { + match preset.roles() { + Some(roles) if preset == RolePreset::Everything => format!("all {}", roles.len()), + Some(roles) => roles.iter().map(role_short).collect::>().join(" + "), + None => String::new(), + } +} + +fn moves_note(model: &ReviewModel) -> String { + let moves = model + .files + .iter() + .filter(|entry| is_likely_mechanical(entry)) + .count(); + if moves == 1 { + "1 move".to_string() + } else { + format!("{moves} moves") + } +} + +#[cfg(test)] +mod tests { + use super::super::super::fixtures; + use super::super::super::state::{RoleFilter, RolePreset}; + use super::{SavedFilter, roles_menu}; + use okena_core::review::FileRole; + + #[test] + fn presets_come_first_and_spell_out_the_roles_they_stand_for() { + let model = fixtures::model(); + let menu = roles_menu(&model, &RoleFilter::everything()); + let labels: Vec<&str> = menu.presets.iter().map(|row| row.label).collect(); + assert_eq!(labels, ["Review code", "Supporting", "Everything"]); + assert_eq!(menu.presets[0].hint, "Impl + Config + Unclassified"); + assert_eq!( + menu.presets[1].hint, + "Tests + Fixtures + Snapshots + Examples + Docs" + ); + assert_eq!(menu.presets[2].hint, "all 11"); + assert!(menu.presets[2].active, "Everything is the default"); + assert!(!menu.presets[0].active); + } + + #[test] + fn every_role_is_listed_with_the_files_it_holds() { + let model = fixtures::model(); + let menu = roles_menu(&model, &RoleFilter::preset(RolePreset::ReviewCode)); + assert_eq!(menu.roles.len(), 11, "no role may vanish from the menu"); + assert!(menu.roles.iter().all(|row| !row.label.is_empty())); + + let count = |role: FileRole| { + menu.roles + .iter() + .find(|row| row.role == role) + .map(|row| row.count) + .expect("every role is listed") + }; + assert_eq!(count(FileRole::Implementation), 7); + assert_eq!(count(FileRole::Test), 2); + assert_eq!(count(FileRole::Documentation), 1); + assert_eq!(count(FileRole::Configuration), 1); + assert_eq!(count(FileRole::Lockfile), 1); + assert_eq!(count(FileRole::Unclassified), 1); + assert_eq!(count(FileRole::Generated), 0, "zero is still listed"); + + let checked: Vec = menu + .roles + .iter() + .filter(|row| row.checked) + .map(|row| row.role) + .collect(); + assert_eq!( + checked, + [ + FileRole::Implementation, + FileRole::Configuration, + FileRole::Unclassified + ] + ); + } + + #[test] + fn the_saved_filters_carry_their_own_counts() { + let model = fixtures::model(); + let mut filter = RoleFilter::everything(); + filter.not_analyzed_only = true; + let menu = roles_menu(&model, &filter); + + // Each note promises exactly what turning that filter on would leave. + let leaves = |narrow: &RoleFilter| { + model + .files + .iter() + .filter(|entry| narrow.allows(entry)) + .count() + }; + let mut mechanical_only = RoleFilter::everything(); + mechanical_only.likely_mechanical_only = true; + let mut analyzed_only = RoleFilter::everything(); + analyzed_only.not_analyzed_only = true; + + let mechanical = &menu.saved[0]; + assert_eq!(mechanical.filter, SavedFilter::LikelyMechanical); + assert_eq!(leaves(&mechanical_only), 1, "src/old.rs → src/new.rs"); + assert_eq!(mechanical.note, "1 move"); + assert!(!mechanical.checked); + + let analyzed = &menu.saved[1]; + assert_eq!(analyzed.filter, SavedFilter::NotAnalyzed); + assert_eq!(analyzed.note, leaves(&analyzed_only).to_string()); + assert!( + leaves(&analyzed_only) > leaves(&mechanical_only), + "structure reaches only a few fixture files" + ); + assert!(analyzed.checked); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/navigator/roles_menu.rs b/crates/okena-views-git/src/diff_viewer/review_ui/navigator/roles_menu.rs new file mode 100644 index 000000000..202fab155 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/navigator/roles_menu.rs @@ -0,0 +1,225 @@ +//! Roles menu: presets, the 11 roles, and the saved filters — spec §7. + +use super::super::super::DiffViewer; +use super::super::labels::nav as words; +use super::roles::{self, PresetRow, RoleRow, SavedFilter, SavedRow}; +use gpui::prelude::*; +use gpui::*; +use gpui_component::h_flex; +use okena_core::theme::ThemeColors; +use okena_ui::popover::popover_panel; +use okena_ui::tokens::{RADIUS_MD, ui_text_ms, ui_text_sm}; + +const PANEL_WIDTH: Pixels = px(260.0); +/// Clears the segmented control, the filter box and the Roles button above it. +const PANEL_TOP: Pixels = px(96.0); +const CHECKBOX: Pixels = px(12.0); + +impl DiffViewer { + pub(crate) fn render_roles_menu( + &self, + t: &ThemeColors, + cx: &mut Context, + ) -> Option { + if !self.review_ui.roles_menu_open { + return None; + } + let model = self.review_ui.model.as_ref()?; + let menu = roles::roles_menu(model, &self.review_ui.role_filter); + + let panel = popover_panel("review-roles-menu", t) + .absolute() + .left(px(8.0)) + .top(PANEL_TOP) + .w(PANEL_WIDTH) + .flex() + .flex_col() + .gap(px(2.0)) + .child(section_title(words::PRESETS_TITLE, t, cx)) + .children( + menu.presets + .iter() + .map(|preset| self.render_preset_row(preset, t, cx)), + ) + .child(section_title(words::ROLES_TITLE, t, cx)) + .children( + menu.roles + .iter() + .map(|role| self.render_role_row(role, t, cx)), + ) + .child(section_title(words::ALSO_TITLE, t, cx)) + .children( + menu.saved + .iter() + .map(|saved| self.render_saved_row(saved, t, cx)), + ); + + // Occludes, so the dismissing click never also lands on a row underneath. + Some( + div() + .id("review-roles-backdrop") + .occlude() + .absolute() + .inset_0() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| { + this.review_toggle_roles_menu(cx); + }), + ) + .child(panel) + .into_any_element(), + ) + } + + fn render_preset_row( + &self, + preset: &PresetRow, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let target = preset.preset; + h_flex() + .id(ElementId::Name( + format!("review-preset-{}", preset.label).into(), + )) + .cursor_pointer() + .py(px(3.0)) + .px(px(4.0)) + .gap(px(8.0)) + .items_baseline() + .rounded(RADIUS_MD) + .hover(|s| s.bg(rgb(t.bg_hover))) + .child( + div() + .flex_shrink_0() + .text_size(ui_text_ms(cx)) + .text_color(rgb(if preset.active { + t.text_primary + } else { + t.term_blue + })) + .child(preset.label), + ) + .child( + div() + .min_w_0() + .truncate() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(preset.hint.clone()), + ) + .on_click(cx.listener(move |this, _, _window, cx| { + this.review_apply_preset(target, cx); + })) + .into_any_element() + } + + fn render_role_row( + &self, + role: &RoleRow, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let target = role.role; + menu_row( + ElementId::Name(format!("review-role-{}", role.label).into()), + role.checked, + role.label, + &role.count.to_string(), + t, + cx, + ) + .on_click(cx.listener(move |this, _, _window, cx| { + this.review_toggle_role(target, cx); + })) + .into_any_element() + } + + fn render_saved_row( + &self, + saved: &SavedRow, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let filter = saved.filter; + let next = !saved.checked; + menu_row( + ElementId::Name(format!("review-saved-{}", saved.label).into()), + saved.checked, + saved.label, + &saved.note, + t, + cx, + ) + .on_click(cx.listener(move |this, _, _window, cx| match filter { + SavedFilter::LikelyMechanical => this.review_set_saved_filter(Some(next), None, cx), + SavedFilter::NotAnalyzed => this.review_set_saved_filter(None, Some(next), cx), + })) + .into_any_element() + } +} + +fn section_title(title: &'static str, t: &ThemeColors, cx: &App) -> Div { + div() + .pt(px(8.0)) + .pb(px(2.0)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(title) +} + +/// A checkbox row: the box, the name, and the number it stands for. +fn menu_row( + id: ElementId, + checked: bool, + label: &'static str, + trailing: &str, + t: &ThemeColors, + cx: &App, +) -> Stateful
{ + h_flex() + .id(id) + .cursor_pointer() + .py(px(3.0)) + .px(px(4.0)) + .gap(px(8.0)) + .items_center() + .rounded(RADIUS_MD) + .hover(|s| s.bg(rgb(t.bg_hover))) + .child( + div() + .size(CHECKBOX) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .rounded(px(2.0)) + .border_1() + .border_color(rgb(if checked { t.border_active } else { t.border })) + .when(checked, |d| { + d.bg(rgb(t.border_active)).child( + svg() + .path("icons/check.svg") + .size(px(10.0)) + .text_color(rgb(t.selection_fg)), + ) + }), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_primary)) + .child(label), + ) + .child( + div() + .flex_shrink_0() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(trailing.to_string()), + ) +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/navigator/rows.rs b/crates/okena-views-git/src/diff_viewer/review_ui/navigator/rows.rs new file mode 100644 index 000000000..d8d84eabc --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/navigator/rows.rs @@ -0,0 +1,1428 @@ +//! Files-mode row model — spec §7. +//! +//! Pure. The tree is rebuilt over the *visible* subset, so a directory row never +//! sums files the filter hides. Two flags decide how a directory opens: +//! `expanded_initialized` says whether the user's set is authoritative yet, and +//! until it is, [`default_expanded`] answers instead. The render pass seeds +//! `expanded_dirs` from [`default_expanded_dirs`] and flips the flag, so both +//! answers agree for the row the user first sees. + +use super::super::labels::calls::{self, CALL_TEXT_CHARS}; +use super::super::labels::nav as words; +use super::super::labels::role_short; +use super::super::model::{ + AttentionTarget, DirNode, FileEntry, KindGlyph, Reason, ReasonKind, ReviewModel, SymbolEntry, +}; +use super::super::state::{NavRowId, ReviewUiState, RoleFilter}; +use okena_core::review::{FileRole, ReviewFileStatus}; +use okena_review::{CallChangeKind, SymbolChangeKind}; +use std::collections::{BTreeMap, HashSet}; + +/// Visible files at or below which the whole tree opens — spec §7. +pub(crate) const EXPAND_ALL_LIMIT: usize = 40; + +/// A file row shows the two loudest reasons and no more — spec §7. +const MAX_MARKERS: usize = 2; + +/// Call lines one symbol contributes to the outline; the rest are counted. +const MAX_OUTLINE_CALLS: usize = 6; + +/// What `SymbolKey::qualified_name` puts between a symbol and its scope. +const QUALIFIER: &str = "::"; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct NavRow { + /// `None` on a detail line: it is read, not walked by `↑` `↓`. + pub id: Option, + pub depth: usize, + pub kind: NavRowKind, +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum NavRowKind { + Dir(DirRow), + File(FileRow), + Symbol(SymbolRow), + Detail(DetailRow), +} + +/// One changed symbol, inlined under its file — spec §7. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SymbolRow { + pub name: String, + pub glyph: KindGlyph, + pub added: u64, + pub deleted: u64, + /// At most [`MAX_MARKERS`]; the detail lines below say the rest. + pub markers: Vec, + /// The qualified name — the row itself has one line for the short one. + pub tooltip: String, + /// `Tests` on the outermost test scope, so inline tests are visible for + /// what they are without badging every case inside them. + pub role_badge: Option<&'static str>, + pub target: AttentionTarget, +} + +/// What one detail line under a symbol states. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DetailKind { + Signature, + Call(CallChangeKind), + /// `… 4 more` — the calls the outline left out; the details bar has them. + More, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DetailRow { + pub kind: DetailKind, + /// One line: the signature pair, or the call and the branch it sits in. + pub text: String, + /// The symbol the line belongs to; a click opens it. + pub target: AttentionTarget, + /// Position under the symbol, so the element id stays unique. + pub position: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct DirRow { + /// Joined single-child chains keep the whole `src/build`. + pub name: String, + pub file_count: usize, + pub added: u64, + pub deleted: u64, + pub expanded: bool, + pub no_tests: bool, + /// Every file under it shares one role worth naming (`Tests`, `Docs`…), + /// so the directory carries the badge once and its files carry none. + pub role_badge: Option<&'static str>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FileRow { + /// Basename, the elided rename pair, or the whole path while flattened. + pub name_display: String, + /// The name the file-type icon is chosen from. + pub icon_name: String, + pub added: u64, + pub deleted: u64, + /// At most [`MAX_MARKERS`], loudest first. + pub markers: Vec, + pub role_badge: Option<&'static str>, + /// Structure never reached the file — spec §7 dims instead of badging. + pub dimmed: bool, + /// The outline is on and this file has symbols under it, so the row is the + /// header of a block and not just another row. + pub outlined: bool, + pub is_rename: bool, + /// Full path(s), plus why the row is dimmed. + pub tooltip: String, +} + +/// Indices into `ReviewModel::files` the navigator currently shows. +/// +/// Mirrors `DiffViewer::review_visible_files`; the two must stay identical or +/// `↑` `↓` and the tree would disagree about what is on screen. +pub(crate) fn visible_files( + model: &ReviewModel, + role_filter: &RoleFilter, + filter_text: &str, +) -> Vec { + let needle = filter_text.to_lowercase(); + model + .files + .iter() + .enumerate() + .filter(|(_, entry)| role_filter.allows(entry)) + .filter(|(_, entry)| matches_filter(&entry.display_path, &needle)) + .map(|(index, _)| index) + .collect() +} + +pub(crate) fn matches_filter(haystack: &str, lowercase_needle: &str) -> bool { + lowercase_needle.is_empty() || haystack.to_lowercase().contains(lowercase_needle) +} + +/// The navigator's one reading of "not analyzed": structure landed and did not +/// reach this file. The ranking already gates that on structure being present, +/// so its `NotAnalyzed` reason is the authority — while structure is still +/// loading no file is dim and nothing is counted. +pub(crate) fn not_analyzed(entry: &FileEntry) -> bool { + entry + .reasons + .iter() + .any(|reason| reason.kind == ReasonKind::NotAnalyzed) +} + +pub(crate) fn not_analyzed_count(model: &ReviewModel) -> usize { + model + .files + .iter() + .filter(|entry| not_analyzed(entry)) + .count() +} + +/// The ids `↑` `↓` walk; a detail line has none, so the cursor skips it. +pub(crate) fn row_ids(rows: &[NavRow]) -> Vec { + rows.iter().filter_map(|row| row.id.clone()).collect() +} + +/// What the tree is built from; every field is client state, never the model. +#[derive(Clone, Copy)] +pub(crate) struct TreeArgs<'a> { + pub role_filter: &'a RoleFilter, + pub filter_text: &'a str, + pub expanded_dirs: &'a HashSet, + pub flatten: bool, + pub expanded_initialized: bool, + /// Inline every file's changed symbols and what changed in them. + pub outline: bool, +} + +impl<'a> TreeArgs<'a> { + pub(crate) fn of(state: &'a ReviewUiState) -> Self { + Self { + role_filter: &state.role_filter, + filter_text: &state.filter_text, + expanded_dirs: &state.expanded_dirs, + flatten: state.flatten, + expanded_initialized: state.expanded_initialized, + outline: state.outline_inline, + } + } +} + +/// Every visible row of the Files tree, in display order. +pub(crate) fn nav_rows(model: &ReviewModel, args: &TreeArgs<'_>) -> Vec { + let visible = visible_files(model, args.role_filter, args.filter_text); + if args.flatten { + return flat_rows(model, &visible, args.outline); + } + let root = build_tree(model, &visible); + let untested = untested_dirs(&model.root); + let mut out = Vec::new(); + emit(Emit { + dir: &root, + depth: 0, + model, + untested: &untested, + expanded_dirs: args.expanded_dirs, + expanded_initialized: args.expanded_initialized, + total: visible.len(), + badged: false, + outline: args.outline, + out: &mut out, + }); + out +} + +/// Directory paths the tree opens with, before the user touches it. +pub(crate) fn default_expanded_dirs( + model: &ReviewModel, + role_filter: &RoleFilter, + filter_text: &str, +) -> Vec { + let visible = visible_files(model, role_filter, filter_text); + let root = build_tree(model, &visible); + let mut out = Vec::new(); + collect_default_expanded(&root, 0, visible.len(), &mut out); + out +} + +/// Spec §7: under the limit everything opens, above it only the top level closes. +pub(crate) fn default_expanded(visible_files: usize, depth: usize) -> bool { + visible_files <= EXPAND_ALL_LIMIT || depth > 0 +} + +fn collect_default_expanded(dir: &Dir, depth: usize, total: usize, out: &mut Vec) { + for child in &dir.dirs { + if default_expanded(total, depth) { + out.push(child.path.clone()); + } + collect_default_expanded(child, depth.saturating_add(1), total, out); + } +} + +// -- tree -------------------------------------------------------------------- + +/// A directory over the visible subset only. +#[derive(Debug, Default)] +struct Dir { + name: String, + path: String, + dirs: Vec, + /// Indices into `ReviewModel::files`, this directory only. + files: Vec, + file_count: usize, + added: u64, + deleted: u64, + /// The one role every file under this directory has, when there is one. + uniform_role: Option, +} + +#[derive(Debug, Default)] +struct DirBuilder { + name: String, + path: String, + children: BTreeMap, + files: Vec, +} + +/// The path a file occupies in the tree — head side when it has one. +fn tree_path(entry: &FileEntry) -> &str { + entry + .new_path + .as_deref() + .or(entry.old_path.as_deref()) + .unwrap_or_default() +} + +fn build_tree(model: &ReviewModel, visible: &[usize]) -> Dir { + let mut root = DirBuilder::default(); + for index in visible { + let Some(entry) = model.files.get(*index) else { + continue; + }; + let path = tree_path(entry); + let segments: Vec<&str> = path.split('/').collect(); + let mut node = &mut root; + for segment in segments.iter().take(segments.len().saturating_sub(1)) { + let child_path = if node.path.is_empty() { + (*segment).to_string() + } else { + format!("{}/{segment}", node.path) + }; + node = node + .children + .entry((*segment).to_string()) + .or_insert_with(|| DirBuilder { + name: (*segment).to_string(), + path: child_path, + ..DirBuilder::default() + }); + } + node.files.push(*index); + } + finish(root, model) +} + +fn finish(builder: DirBuilder, model: &ReviewModel) -> Dir { + let dirs: Vec = builder + .children + .into_values() + .map(|child| join_chain(finish(child, model))) + .collect(); + let mut files = builder.files; + files.sort_by(|left, right| { + let name = |index: &usize| { + model + .files + .get(*index) + .map(|entry| basename(tree_path(entry)).to_lowercase()) + .unwrap_or_default() + }; + name(left).cmp(&name(right)) + }); + let mut file_count = files.len(); + let mut added = 0u64; + let mut deleted = 0u64; + for index in &files { + if let Some(entry) = model.files.get(*index) { + added = added.saturating_add(entry.lines_added); + deleted = deleted.saturating_add(entry.lines_deleted); + } + } + for child in &dirs { + file_count = file_count.saturating_add(child.file_count); + added = added.saturating_add(child.added); + deleted = deleted.saturating_add(child.deleted); + } + let uniform_role = uniform_role( + files + .iter() + .filter_map(|index| model.files.get(*index)) + .map(|entry| Some(entry.role)) + .chain(dirs.iter().map(|child| child.uniform_role)), + ); + Dir { + name: builder.name, + path: builder.path, + dirs, + files, + file_count, + added, + deleted, + uniform_role, + } +} + +/// `Some(role)` when every member agrees on one; a `None` member (a mixed +/// child directory) or two different roles make the whole mixed. +fn uniform_role(mut members: impl Iterator>) -> Option { + let first = members.next()??; + members.all(|member| member == Some(first)).then_some(first) +} + +/// Collapse `a` → `b` → `c` into one `a/b/c` row. +fn join_chain(mut node: Dir) -> Dir { + while node.files.is_empty() && node.dirs.len() == 1 { + let child = node.dirs.remove(0); + node.name = format!("{}/{}", node.name, child.name); + node.path = child.path; + node.dirs = child.dirs; + node.files = child.files; + } + node +} + +/// Implementation directories with no test change, top-most only — spec §6. +fn untested_dirs(root: &DirNode) -> HashSet { + let mut out = HashSet::new(); + collect_untested(root, &mut out); + out +} + +/// A joined chain stands for several directories at once, so the flag may sit +/// on any of them — the visible tree joins differently than the model's does. +fn covers_untested(name: &str, path: &str, untested: &HashSet) -> bool { + let joined = name.split('/').count(); + let segments: Vec<&str> = path.split('/').collect(); + let first = segments.len().saturating_sub(joined); + (first..segments.len()).any(|last| untested.contains(&segments[..=last].join("/"))) +} + +fn collect_untested(node: &DirNode, out: &mut HashSet) { + if node.no_test_changes { + out.insert(node.path.clone()); + return; + } + for child in &node.children { + collect_untested(child, out); + } +} + +struct Emit<'a> { + dir: &'a Dir, + depth: usize, + model: &'a ReviewModel, + untested: &'a HashSet, + expanded_dirs: &'a HashSet, + expanded_initialized: bool, + total: usize, + /// A directory above already carries the role badge. + badged: bool, + outline: bool, + out: &'a mut Vec, +} + +/// Directories first, then the files that sit in them; both alphabetical. +fn emit(args: Emit<'_>) { + let Emit { + dir, + depth, + model, + untested, + expanded_dirs, + expanded_initialized, + total, + badged, + outline, + out, + } = args; + for child in &dir.dirs { + let expanded = if expanded_initialized { + expanded_dirs.contains(&child.path) + } else { + default_expanded(total, depth) + }; + let role_badge = if badged { + None + } else { + child.uniform_role.and_then(role_badge) + }; + out.push(NavRow { + id: Some(NavRowId::Dir(child.path.clone())), + depth, + kind: NavRowKind::Dir(DirRow { + name: child.name.clone(), + file_count: child.file_count, + added: child.added, + deleted: child.deleted, + expanded, + no_tests: covers_untested(&child.name, &child.path, untested), + role_badge, + }), + }); + if expanded { + emit(Emit { + dir: child, + depth: depth.saturating_add(1), + model, + untested, + expanded_dirs, + expanded_initialized, + total, + badged: badged || role_badge.is_some(), + outline, + out, + }); + } + } + for index in &dir.files { + if let Some(entry) = model.files.get(*index) { + let outlined = outline && !entry.symbols.is_empty(); + out.push(file_nav_row(entry, depth, false, badged, outlined)); + if outlined { + push_outline(entry, depth.saturating_add(1), out); + } + } + } +} + +fn flat_rows(model: &ReviewModel, visible: &[usize], outline: bool) -> Vec { + let mut indices = visible.to_vec(); + indices.sort_by(|left, right| { + let path = |index: &usize| { + model + .files + .get(*index) + .map(|entry| tree_path(entry).to_string()) + .unwrap_or_default() + }; + path(left).cmp(&path(right)) + }); + let mut out = Vec::new(); + for entry in indices.iter().filter_map(|index| model.files.get(*index)) { + let outlined = outline && !entry.symbols.is_empty(); + out.push(file_nav_row(entry, 0, true, false, outlined)); + if outlined { + push_outline(entry, 1, &mut out); + } + } + out +} + +// -- outline rows ------------------------------------------------------------ + +/// Every changed symbol of one file, each followed by what changed in it. +/// Source order, the order the file itself reads in; a member sits under the +/// type it belongs to, so a class reads as its own outline. +fn push_outline(entry: &FileEntry, depth: usize, out: &mut Vec) { + let scopes = Scopes::of(&entry.symbols); + for symbol in &entry.symbols { + let Some(level) = scopes.level(&symbol.qualified) else { + continue; + }; + let depth = depth.saturating_add(level); + let badge = symbol.in_test_scope && !scopes.under_test(&symbol.qualified); + let target = AttentionTarget::Symbol { + file: entry.key.clone(), + change_index: symbol.change_index, + }; + out.push(NavRow { + id: Some(NavRowId::Item(target.clone())), + depth, + kind: NavRowKind::Symbol(symbol_row(symbol, badge, target.clone())), + }); + for detail in detail_rows(symbol, &target) { + out.push(NavRow { + id: None, + // One level under the symbol, so the guide of the block runs + // down the symbol's own glyph column. + depth: depth.saturating_add(1), + kind: NavRowKind::Detail(detail), + }); + } + } +} + +/// The changed symbols of one file, by qualified name — what a row needs to +/// know about the symbols enclosing it. +struct Scopes<'a> { + present: HashSet<&'a str>, + removed: HashSet<&'a str>, + tests: HashSet<&'a str>, +} + +impl<'a> Scopes<'a> { + fn of(symbols: &'a [SymbolEntry]) -> Self { + let names = |keep: fn(&SymbolEntry) -> bool| -> HashSet<&'a str> { + symbols + .iter() + .filter(|symbol| keep(symbol)) + .map(|symbol| symbol.qualified.as_str()) + .collect() + }; + Self { + present: names(|_| true), + removed: names(|symbol| symbol.change == SymbolChangeKind::Removed), + tests: names(|symbol| symbol.in_test_scope), + } + } + + /// How far under its file a symbol sits: one level per enclosing symbol + /// that changed too. `None` when one of those was removed whole — the + /// member went with it, and its own row would only repeat that. + fn level(&self, qualified: &str) -> Option { + let mut level: usize = 0; + for (index, _) in qualified.match_indices(QUALIFIER) { + let ancestor = &qualified[..index]; + if self.removed.contains(ancestor) { + return None; + } + if self.present.contains(ancestor) { + level = level.saturating_add(1); + } + } + Some(level) + } + + /// A test scope above this one already carries the badge. + fn under_test(&self, qualified: &str) -> bool { + qualified + .match_indices(QUALIFIER) + .any(|(index, _)| self.tests.contains(&qualified[..index])) + } +} + +fn symbol_row(symbol: &SymbolEntry, badge: bool, target: AttentionTarget) -> SymbolRow { + let candidates = symbol.reasons.iter().filter_map(|reason| { + words::symbol_marker(reason.kind, &reason.label).map(|label| { + ( + words::marker_rank(reason.kind), + Reason { + kind: reason.kind, + label, + }, + ) + }) + }); + SymbolRow { + name: symbol.name.clone(), + glyph: symbol.glyph, + added: u64::from(symbol.lines_added), + deleted: u64::from(symbol.lines_deleted), + markers: top_markers(candidates), + tooltip: symbol.qualified.clone(), + role_badge: badge.then(|| role_short(FileRole::Test)), + target, + } +} + +/// The signature change first — it is the contract — then the calls. +fn detail_rows(symbol: &SymbolEntry, target: &AttentionTarget) -> Vec { + let mut lines: Vec<(DetailKind, String)> = Vec::new(); + if let Some((old, new)) = symbol.signature.as_ref() { + lines.push(( + DetailKind::Signature, + calls::signature_pair(old, new, CALL_TEXT_CHARS), + )); + } + let calls = calls::call_lines(&symbol.calls, MAX_OUTLINE_CALLS); + for line in &calls.shown { + lines.push((DetailKind::Call(line.change), call_line_text(line))); + } + if let Some(note) = calls.hidden_note() { + lines.push((DetailKind::More, note)); + } + lines + .into_iter() + .enumerate() + .map(|(position, (kind, text))| DetailRow { + kind, + text, + target: target.clone(), + position, + }) + .collect() +} + +/// `cx.notify() in error branch` — one line, so the column truncates the +/// branch before the callee. +fn call_line_text(line: &calls::CallLine) -> String { + let text = line.text_with_count(); + match line.context.as_deref() { + Some(context) => format!("{text} {context}"), + None => text, + } +} + +// -- file rows --------------------------------------------------------------- + +fn file_nav_row( + entry: &FileEntry, + depth: usize, + flatten: bool, + badged: bool, + outlined: bool, +) -> NavRow { + let is_rename = entry.status == ReviewFileStatus::Renamed + || matches!((&entry.old_path, &entry.new_path), (Some(old), Some(new)) if old != new); + let name_display = match (&entry.old_path, &entry.new_path) { + (Some(old), Some(new)) if is_rename => words::rename_display(old, new), + _ if flatten => tree_path(entry).to_string(), + _ => basename(tree_path(entry)).to_string(), + }; + let unreached = entry + .reasons + .iter() + .find(|reason| reason.kind == ReasonKind::NotAnalyzed); + let tooltip = match unreached { + Some(reason) => format!("{} \u{00B7} {}", entry.display_path, reason.label), + None => entry.display_path.clone(), + }; + NavRow { + id: Some(NavRowId::File(entry.key.clone())), + depth, + kind: NavRowKind::File(FileRow { + name_display, + icon_name: basename(tree_path(entry)).to_string(), + added: entry.lines_added, + deleted: entry.lines_deleted, + markers: markers(entry), + role_badge: role_badge(entry.role).filter(|_| !badged && !markers_name_the_role(entry)), + dimmed: not_analyzed(entry), + outlined, + is_rename, + tooltip, + }), + } +} + +/// A `lockfile` / `CI config` / `submodule` chip already says what the role +/// badge would say; showing both reads as two facts. +fn markers_name_the_role(entry: &FileEntry) -> bool { + markers(entry).iter().any(|reason| { + matches!( + reason.kind, + ReasonKind::Lockfile | ReasonKind::CiConfig | ReasonKind::Submodule + ) + }) +} + +/// Implementation is the default reading; Unclassified has nothing to say. +fn role_badge(role: FileRole) -> Option<&'static str> { + match role { + FileRole::Implementation | FileRole::Unclassified => None, + other => Some(role_short(other)), + } +} + +/// The loudest two reasons of the file and of the symbols inside it — spec §7. +/// +/// A new file's symbols are all new, so their reasons add nothing to `new`; +/// only the file's own reasons mark it. +fn markers(entry: &FileEntry) -> Vec { + let is_new = entry + .reasons + .iter() + .any(|reason| reason.kind == ReasonKind::New); + let symbol_reasons = entry + .symbols + .iter() + .filter(|_| !is_new) + .flat_map(|symbol| symbol.reasons.iter()); + let signatures = entry + .symbols + .iter() + .flat_map(|symbol| symbol.reasons.iter()) + .filter(|reason| { + matches!( + reason.kind, + ReasonKind::PublicSignature | ReasonKind::ExportedSignature + ) + }) + .count(); + let candidates = entry + .reasons + .iter() + .chain(symbol_reasons) + .filter_map(|reason| { + words::file_marker(reason.kind, &reason.label, signatures).map(|label| { + ( + words::marker_rank(reason.kind), + Reason { + kind: reason.kind, + label, + }, + ) + }) + }); + top_markers(candidates) +} + +/// The loudest [`MAX_MARKERS`] distinct markers of a row. +fn top_markers(candidates: impl Iterator) -> Vec { + let mut candidates: Vec<(u8, Reason)> = candidates.collect(); + candidates.sort_by_key(|(rank, _)| *rank); + let mut out: Vec = Vec::with_capacity(MAX_MARKERS); + for (_, reason) in candidates { + if out.iter().any(|kept| kept.label == reason.label) { + continue; + } + out.push(reason); + if out.len() == MAX_MARKERS { + break; + } + } + out +} + +pub(crate) fn basename(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +#[cfg(test)] +mod tests { + use super::super::super::fixtures; + use super::super::super::model::{FileEntry, ReasonKind}; + use super::super::super::ranking::{ModelInputs, StructureLoad, build_review_model}; + use super::super::super::state::{NavRowId, RoleFilter, RolePreset}; + use super::{ + AttentionTarget, CallChangeKind, DetailKind, DetailRow, DirRow, FileRow, NavRow, + NavRowKind, Scopes, SymbolRow, TreeArgs, covers_untested, default_expanded, nav_rows, + not_analyzed, not_analyzed_count, row_ids, visible_files, + }; + use std::collections::HashSet; + + fn args<'a>( + filter: &'a RoleFilter, + text: &'a str, + expanded: &'a HashSet, + flatten: bool, + ) -> TreeArgs<'a> { + TreeArgs { + role_filter: filter, + filter_text: text, + expanded_dirs: expanded, + flatten, + expanded_initialized: false, + outline: false, + } + } + + fn tree( + filter: &RoleFilter, + text: &str, + expanded: &HashSet, + flatten: bool, + ) -> Vec { + let model = fixtures::model(); + nav_rows(&model, &args(filter, text, expanded, flatten)) + } + + /// The same tree with every file's changed symbols inlined. + fn outlined(flatten: bool) -> Vec { + let model = fixtures::model(); + let filter = RoleFilter::everything(); + let expanded = HashSet::new(); + nav_rows( + &model, + &TreeArgs { + outline: true, + ..args(&filter, "", &expanded, flatten) + }, + ) + } + + fn dir<'a>(rows: &'a [NavRow], path: &str) -> &'a DirRow { + rows.iter() + .find_map(|row| match (&row.id, &row.kind) { + (Some(NavRowId::Dir(candidate)), NavRowKind::Dir(dir)) if candidate == path => { + Some(dir) + } + _ => None, + }) + .unwrap_or_else(|| panic!("no directory row for {path}")) + } + + fn file<'a>(rows: &'a [NavRow], name: &str) -> &'a FileRow { + rows.iter() + .find_map(|row| match &row.kind { + NavRowKind::File(file) if file.name_display == name => Some(file), + _ => None, + }) + .unwrap_or_else(|| panic!("no file row named {name}")) + } + + fn names(rows: &[NavRow]) -> Vec { + rows.iter() + .map(|row| match &row.kind { + NavRowKind::Dir(dir) => dir.name.clone(), + NavRowKind::File(file) => file.name_display.clone(), + NavRowKind::Symbol(symbol) => symbol.name.clone(), + NavRowKind::Detail(detail) => detail.text.clone(), + }) + .collect() + } + + #[test] + fn the_tree_opens_fully_under_the_limit_and_closes_the_top_level_above_it() { + assert!(default_expanded(40, 0)); + assert!(!default_expanded(41, 0)); + assert!( + default_expanded(41, 1), + "only the top level closes above the limit" + ); + } + + #[test] + fn directory_rows_sum_only_the_visible_files() { + let everything = tree(&RoleFilter::everything(), "", &HashSet::new(), false); + let src = dir(&everything, "src"); + assert_eq!(src.file_count, 6, "six fixture files live under src/"); + let sum = |pick: fn(&FileEntry) -> u64| -> u64 { + let model = fixtures::model(); + model + .files + .iter() + .filter(|entry| { + entry + .new_path + .as_deref() + .or(entry.old_path.as_deref()) + .is_some_and(|path| path.starts_with("src/")) + }) + .map(pick) + .sum() + }; + assert_eq!(src.added, sum(|entry| entry.lines_added)); + assert_eq!(src.deleted, sum(|entry| entry.lines_deleted)); + assert!(src.added > 0 && src.deleted > 0); + + // The worker directory holds one implementation file and one test file. + let worker = dir(&everything, "worker"); + assert_eq!(worker.file_count, 2); + assert_eq!(worker.added, 208); + + let review_code = tree( + &RoleFilter::preset(RolePreset::ReviewCode), + "", + &HashSet::new(), + false, + ); + let worker = dir(&review_code, "worker"); + assert_eq!(worker.file_count, 1, "the test file is filtered out"); + assert_eq!(worker.added, 200); + assert_eq!(worker.deleted, 60); + } + + #[test] + fn single_child_chains_are_joined_into_one_row() { + let rows = tree(&RoleFilter::everything(), "handler", &HashSet::new(), false); + assert_eq!( + names(&rows), + ["worker", "handler.rs", "handler_test.rs"], + "worker holds two files, so nothing joins" + ); + + let rows = tree(&RoleFilter::everything(), "logo", &HashSet::new(), false); + assert_eq!(names(&rows), ["assets", "logo.png"]); + + // One visible file under a two-deep chain joins the whole chain. + let rows = tree( + &RoleFilter::everything(), + "handler_test", + &HashSet::new(), + false, + ); + assert_eq!(names(&rows), ["worker", "handler_test.rs"]); + } + + #[test] + fn markers_keep_the_two_loudest_reasons_in_priority_order() { + let rows = tree(&RoleFilter::everything(), "engine", &HashSet::new(), false); + let engine = file(&rows, "engine.rs"); + let labels: Vec<&str> = engine + .markers + .iter() + .map(|marker| marker.label.as_str()) + .collect(); + assert_eq!( + labels, + ["removed", "sig 2"], + "a removed public symbol outranks the two changed signatures" + ); + assert!(engine.markers.len() <= 2); + } + + #[test] + fn a_rename_shows_its_similarity_and_a_new_file_shows_new() { + let rows = tree(&RoleFilter::everything(), "motion", &HashSet::new(), false); + let moved = file( + &rows, + "\u{2026}/motion_old.rs \u{2192} \u{2026}/motion_new.rs", + ); + assert!(moved.is_rename); + assert_eq!( + moved + .markers + .iter() + .map(|marker| marker.label.as_str()) + .collect::>(), + ["moved 91 %", "86 residual lines"] + ); + + let rows = tree(&RoleFilter::everything(), "src/lib", &HashSet::new(), false); + let new = file(&rows, "lib.rs"); + assert_eq!( + new.markers + .iter() + .map(|marker| marker.label.as_str()) + .collect::>(), + ["new"] + ); + } + + #[test] + fn files_structure_never_reached_are_dimmed_and_never_badged() { + let rows = tree(&RoleFilter::everything(), "app.js", &HashSet::new(), false); + let unsupported = file(&rows, "app.js"); + assert!(unsupported.dimmed); + assert!( + !unsupported + .markers + .iter() + .any(|marker| marker.kind == ReasonKind::NotAnalyzed), + "the dim is the whole signal" + ); + assert!( + unsupported.tooltip.contains("not analyzed"), + "the reason is on hover instead: {}", + unsupported.tooltip + ); + + let rows = tree(&RoleFilter::everything(), "engine", &HashSet::new(), false); + assert!(!file(&rows, "engine.rs").dimmed); + } + + #[test] + fn role_badges_appear_only_when_the_role_is_worth_naming() { + let rows = tree(&RoleFilter::everything(), "", &HashSet::new(), false); + assert_eq!(file(&rows, "lib.rs").role_badge, None); + assert_eq!(file(&rows, "logo.png").role_badge, None); + assert_eq!(file(&rows, "README.md").role_badge, Some("Docs")); + // Its `CI config` chip already names the role; no second badge. + assert_eq!(file(&rows, "Cargo.toml").role_badge, None); + assert!( + file(&rows, "Cargo.toml") + .markers + .iter() + .any(|reason| reason.kind == ReasonKind::CiConfig) + ); + assert_eq!(file(&rows, "handler_test.rs").role_badge, Some("Tests")); + } + + #[test] + fn a_directory_of_one_role_carries_the_badge_for_its_files() { + let rows = tree(&RoleFilter::everything(), "", &HashSet::new(), false); + assert_eq!(dir(&rows, "tests").role_badge, Some("Tests")); + // The tree lists `tests/lib.rs` right after its directory row. + let tests_row = rows + .iter() + .position(|row| row.id == Some(NavRowId::Dir("tests".into()))) + .unwrap(); + let NavRowKind::File(inner) = &rows[tests_row + 1].kind else { + panic!("expected the file under tests/"); + }; + assert_eq!(inner.name_display, "lib.rs"); + assert_eq!(inner.role_badge, None); + // Mixed directories badge nothing; their files keep their own. + assert_eq!(dir(&rows, "src").role_badge, None); + assert_eq!(dir(&rows, "worker").role_badge, None); + // Flattened, there is no directory to carry it. + let flat = tree(&RoleFilter::everything(), "", &HashSet::new(), true); + assert_eq!(file(&flat, "tests/lib.rs").role_badge, Some("Tests")); + } + + #[test] + fn an_untested_implementation_directory_is_marked_once() { + let rows = tree(&RoleFilter::everything(), "", &HashSet::new(), false); + assert!(dir(&rows, "src").no_tests, "src/ changed no test file"); + assert!( + !dir(&rows, "worker").no_tests, + "worker/ changed a test next to its implementation" + ); + } + + #[test] + fn the_fixture_tree_opens_fully_and_lists_directories_before_files() { + let rows = tree(&RoleFilter::everything(), "", &HashSet::new(), false); + assert_eq!( + names(&rows), + [ + "assets", + "logo.png", + "src", + "app.js", + "engine.rs", + "legacy.rs", + "lib.rs", + "\u{2026}/motion_old.rs \u{2192} \u{2026}/motion_new.rs", + "\u{2026}/old.rs \u{2192} \u{2026}/new.rs", + "tests", + "lib.rs", + "worker", + "handler.rs", + "handler_test.rs", + "Cargo.toml", + "pnpm-lock.yaml", + "README.md" + ] + ); + } + + #[test] + fn a_collapsed_directory_hides_its_files() { + let model = fixtures::model(); + let mut expanded: HashSet = HashSet::new(); + expanded.insert("assets".to_string()); + expanded.insert("tests".to_string()); + expanded.insert("worker".to_string()); + let rows = nav_rows( + &model, + &TreeArgs { + expanded_initialized: true, + ..args(&RoleFilter::everything(), "", &expanded, false) + }, + ); + assert!( + !names(&rows).contains(&"engine.rs".to_string()), + "src/ is not in the expanded set" + ); + assert!(names(&rows).contains(&"handler.rs".to_string())); + } + + #[test] + fn flatten_drops_the_directories_and_shows_whole_paths() { + let rows = tree(&RoleFilter::everything(), "", &HashSet::new(), true); + assert!( + rows.iter() + .all(|row| matches!(row.kind, NavRowKind::File(_))), + "a flat list has no directory rows" + ); + assert!(names(&rows).contains(&"src/engine.rs".to_string())); + assert!(names(&rows).contains(&"tests/lib.rs".to_string())); + } + + #[test] + fn the_visible_set_intersects_the_role_filter_with_the_text_filter() { + let model = fixtures::model(); + let paths = |filter: &RoleFilter, text: &str| -> Vec { + visible_files(&model, filter, text) + .iter() + .filter_map(|index| model.files.get(*index)) + .map(|entry| entry.display_path.clone()) + .collect() + }; + assert_eq!( + paths(&RoleFilter::everything(), "lib"), + ["src/lib.rs", "tests/lib.rs"] + ); + assert_eq!( + paths(&RoleFilter::preset(RolePreset::ReviewCode), "lib"), + ["src/lib.rs"] + ); + assert_eq!(paths(&RoleFilter::everything(), "").len(), 13); + } + + #[test] + fn every_tree_row_is_reachable_from_the_cursor() { + let model = fixtures::model(); + let filter = RoleFilter::everything(); + let expanded = HashSet::new(); + for flatten in [false, true] { + let rows = nav_rows(&model, &args(&filter, "", &expanded, flatten)); + assert!(!rows.is_empty()); + assert_eq!( + row_ids(&rows), + rows.iter() + .filter_map(|row| row.id.clone()) + .collect::>(), + "the Files tree has no separator rows, so no row is skipped" + ); + } + } + + /// The symbol rows the outline emits directly under one file row. + fn symbols_under<'a>(rows: &'a [NavRow], file: &str) -> Vec<&'a SymbolRow> { + let start = rows + .iter() + .position( + |row| matches!(&row.kind, NavRowKind::File(entry) if entry.name_display == file), + ) + .unwrap_or_else(|| panic!("no file row named {file}")); + let depth = rows[start].depth; + rows[start.saturating_add(1)..] + .iter() + .take_while(|row| row.depth > depth) + .filter_map(|row| match &row.kind { + NavRowKind::Symbol(symbol) => Some(symbol), + _ => None, + }) + .collect() + } + + /// The detail lines that follow one symbol row. + fn details_of<'a>(rows: &'a [NavRow], symbol: &str) -> Vec<&'a DetailRow> { + let start = rows + .iter() + .position(|row| matches!(&row.kind, NavRowKind::Symbol(entry) if entry.name == symbol)) + .unwrap_or_else(|| panic!("no symbol row named {symbol}")); + rows[start.saturating_add(1)..] + .iter() + .map_while(|row| match &row.kind { + NavRowKind::Detail(detail) => Some(detail), + _ => None, + }) + .collect() + } + + #[test] + fn the_outline_inlines_every_changed_symbol_under_its_file() { + let plain = tree(&RoleFilter::everything(), "", &HashSet::new(), false); + assert!( + !plain + .iter() + .any(|row| matches!(row.kind, NavRowKind::Symbol(_))), + "the outline is off by default" + ); + + let model = fixtures::model(); + let entry = model + .files + .iter() + .find(|entry| entry.display_path == "src/engine.rs") + .expect("the fixture analyses src/engine.rs"); + let expected: Vec<&str> = entry + .symbols + .iter() + .map(|symbol| symbol.name.as_str()) + .collect(); + + for flatten in [false, true] { + let rows = outlined(flatten); + let name = if flatten { + "src/engine.rs" + } else { + "engine.rs" + }; + let names: Vec<&str> = symbols_under(&rows, name) + .iter() + .map(|symbol| symbol.name.as_str()) + .collect(); + assert_eq!(names, expected, "source order, the order the file reads in"); + } + } + + #[test] + fn a_symbol_row_carries_its_churn_and_only_the_markers_the_lines_lack() { + let rows = outlined(false); + let run = symbols_under(&rows, "engine.rs") + .into_iter() + .find(|symbol| symbol.name == "run") + .expect("Engine::run changed"); + assert_eq!(run.tooltip, "Engine::run"); + assert!(run.added > 0 || run.deleted > 0); + let labels: Vec<&str> = run + .markers + .iter() + .map(|marker| marker.label.as_str()) + .collect(); + assert!(labels.contains(&"public"), "{labels:?}"); + assert!( + !labels.iter().any(|label| label.contains("call")), + "the call lines below say it: {labels:?}" + ); + } + + #[test] + fn detail_lines_state_the_signature_change_and_then_the_calls() { + let rows = outlined(false); + let details = details_of(&rows, "run"); + assert_eq!( + details.iter().map(|detail| detail.kind).collect::>(), + vec![ + DetailKind::Signature, + DetailKind::Call(CallChangeKind::Removed), + DetailKind::Call(CallChangeKind::Modified), + ], + "the contract first, then what changed behind it" + ); + assert!( + details[0].text.contains('\u{2192}'), + "the signature reads old → new: {}", + details[0].text + ); + assert!( + details[1].text.starts_with("validate(input)"), + "{}", + details[1].text + ); + assert!( + details[1].text.ends_with("in error branch"), + "the branch comes last, so a narrow column cuts it first: {}", + details[1].text + ); + } + + #[test] + fn a_member_sits_under_the_type_it_changed_with() { + let scopes = Scopes { + present: HashSet::from(["Registry", "Registry::add", "free", "Gone"]), + removed: HashSet::from(["Gone"]), + tests: HashSet::new(), + }; + let level = |qualified| scopes.level(qualified); + + assert_eq!(level("Registry"), Some(0)); + assert_eq!( + level("Registry::add"), + Some(1), + "a member of a changed type" + ); + assert_eq!( + level("Registry::add::inner"), + Some(2), + "one level per enclosing symbol that changed too" + ); + assert_eq!( + level("Untouched::method"), + Some(0), + "a type that did not change is not a level" + ); + assert_eq!(level("free"), Some(0)); + } + + #[test] + fn a_member_of_a_removed_type_is_left_out() { + let scopes = Scopes { + present: HashSet::from(["Gone", "Gone::field", "Gone::Inner", "Gone::Inner::x"]), + removed: HashSet::from(["Gone"]), + tests: HashSet::new(), + }; + let level = |qualified| scopes.level(qualified); + + assert_eq!(level("Gone"), Some(0), "the removal itself is the news"); + assert_eq!(level("Gone::field"), None); + assert_eq!(level("Gone::Inner::x"), None, "however deep it sits"); + } + + #[test] + fn only_the_outermost_test_scope_carries_the_badge() { + let scopes = Scopes { + present: HashSet::from(["tests", "tests::case", "bump"]), + removed: HashSet::new(), + tests: HashSet::from(["tests", "tests::case"]), + }; + assert!(!scopes.under_test("tests"), "the module is the outermost"); + assert!(scopes.under_test("tests::case")); + assert!(!scopes.under_test("bump")); + } + + #[test] + fn the_cursor_walks_the_symbols_and_steps_over_their_detail_lines() { + let rows = outlined(false); + assert!( + rows.iter() + .any(|row| matches!(row.kind, NavRowKind::Detail(_))) + ); + for row in &rows { + let walkable = !matches!(row.kind, NavRowKind::Detail(_)); + assert_eq!(row.id.is_some(), walkable); + } + let ids = row_ids(&rows); + assert!( + ids.iter() + .any(|id| matches!(id, NavRowId::Item(AttentionTarget::Symbol { .. }))), + "a symbol row opens the symbol" + ); + } + + #[test] + fn a_joined_chain_carries_the_flag_of_every_directory_it_swallowed() { + let untested: HashSet = HashSet::from(["packages/workers".to_string()]); + assert!( + covers_untested("workers/src", "packages/workers/src", &untested), + "the joined row stands for packages/workers too" + ); + assert!(covers_untested( + "packages/workers", + "packages/workers", + &untested + )); + assert!( + !covers_untested("src", "packages/workers/src", &untested), + "an unjoined child never borrows its parent's flag" + ); + assert!(!covers_untested( + "packages/core", + "packages/core", + &untested + )); + } + + #[test] + fn the_no_tests_flag_reads_the_comparison_not_the_filter() { + // Hiding the test files must not turn every directory into an untested one. + let review_code = tree( + &RoleFilter::preset(RolePreset::ReviewCode), + "", + &HashSet::new(), + false, + ); + assert!(dir(&review_code, "src").no_tests); + assert!( + !dir(&review_code, "worker").no_tests, + "worker/ changed a test next to its implementation, filtered or not" + ); + } + + #[test] + fn not_analyzed_means_structure_landed_and_missed_the_file() { + let model = fixtures::model(); + for entry in &model.files { + assert_eq!( + not_analyzed(entry), + !entry.analysis.is_analyzed(), + "{} disagrees with the saved filter", + entry.display_path + ); + } + let mut narrow = RoleFilter::everything(); + narrow.not_analyzed_only = true; + assert_eq!( + not_analyzed_count(&model), + model + .files + .iter() + .filter(|entry| narrow.allows(entry)) + .count() + ); + assert!(not_analyzed_count(&model) > 0); + } + + #[test] + fn nothing_is_dim_while_structure_is_still_loading() { + let inventory = fixtures::inventory(); + let loading = build_review_model(ModelInputs { + inventory: Some(&inventory), + inventory_error: None, + structure: None, + structure_state: StructureLoad::Loading, + diff_mode: &okena_git::DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + }, + }); + assert_eq!(not_analyzed_count(&loading), 0); + let rows = nav_rows( + &loading, + &args(&RoleFilter::everything(), "", &HashSet::new(), true), + ); + assert!(!rows.is_empty()); + assert!(rows.iter().all(|row| match &row.kind { + NavRowKind::File(file) => !file.dimmed, + _ => true, + })); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/overview/facts.rs b/crates/okena-views-git/src/diff_viewer/review_ui/overview/facts.rs new file mode 100644 index 000000000..3c8d862d0 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/overview/facts.rs @@ -0,0 +1,345 @@ +//! The five Overview facts, as finished sentences plus the one thing each links +//! to — spec §8. Pure; the render pass turns a link into a click. + +use super::super::labels::facts as words; +use super::super::model::{CommitRow, Facts, FileEntry, ReasonKind, ReviewModel}; +use super::super::state::RoleSet; + +/// One fact line: label, sentence, and the link that ends it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FactLine { + pub label: &'static str, + pub text: String, + pub link: Option, +} + +/// Where a fact's link goes. Every one of these lands somewhere — spec §2. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum FactLink { + /// The ordered list, in the navigator. + Attention, + /// The directory item that has no test changes next to it. + Directory(String), + /// Narrow the file filter to the moves that only moved. + MechanicalMoves, + /// The commit ledger, inline under the facts. + CommitLedger, + /// The files this comparison also touched, by role. + Also, +} + +impl FactLink { + /// Link text. Only the ledger has two states, so only it reads `ledger_open`. + pub(crate) fn label(&self, ledger_open: bool) -> &'static str { + match self { + Self::Attention => words::ATTENTION_LINK, + Self::MechanicalMoves => words::FILTER_LINK, + Self::CommitLedger => words::ledger_link(ledger_open), + Self::Directory(_) | Self::Also => words::SHOW_LINK, + } + } +} + +/// One line per fact that has something to say; empty facts never reach the screen. +/// Every sentence reads off the fact itself — the coverage caveat lives in §8's +/// second block, not here. +pub(crate) fn fact_sentences(facts: &Facts) -> Vec { + let mut lines: Vec = Vec::with_capacity(5); + + if let Some(fact) = facts.public_api.as_ref() { + let text = words::public_api_sentence(fact); + if !text.is_empty() { + // Nothing to rank when no language was analyzed, so no link either. + let link = (!fact.no_supported_language).then_some(FactLink::Attention); + lines.push(FactLine { + label: words::PUBLIC_API, + text, + link, + }); + } + } + + if let Some(fact) = facts.tests.as_ref() { + let link = fact + .without + .first() + .map(|dir| FactLink::Directory(dir.path.clone())); + lines.push(FactLine { + label: words::TESTS, + text: words::tests_sentence(fact), + link, + }); + } + + if let Some(fact) = facts.moves.as_ref() { + let link = (fact.likely_mechanical > 0).then_some(FactLink::MechanicalMoves); + lines.push(FactLine { + label: words::MOVES, + text: words::moves_sentence(fact), + link, + }); + } + + if let Some(fact) = facts.commits.as_ref() { + lines.push(FactLine { + label: words::COMMITS, + text: words::commits_sentence(fact), + link: Some(FactLink::CommitLedger), + }); + } + + if let Some(fact) = facts.also.as_ref() { + let text = words::also_sentence(fact); + if !text.is_empty() { + // Deleted implementation files are named but not linked: their role is + // Implementation, and filtering to it would show the whole change. + let linkable = fact.lockfiles > 0 || fact.submodules > 0 || fact.binaries > 0; + lines.push(FactLine { + label: words::ALSO, + text, + link: linkable.then_some(FactLink::Also), + }); + } + } + + lines +} + +/// The ledger, oldest commit first — the order the branch was written in. +pub(crate) fn ledger_rows(commits: &[CommitRow]) -> Vec<&CommitRow> { + let mut rows: Vec<&CommitRow> = commits.iter().collect(); + // Stable, so commits that share a timestamp keep their inventory order. + rows.sort_by_key(|commit| commit.timestamp); + rows +} + +/// A file the "Also" link can narrow to. Deleted implementation files are left +/// out on purpose — their role is Implementation, so filtering to it would widen +/// the view to the whole change instead of narrowing it. +fn is_also_file(entry: &FileEntry) -> bool { + entry.binary + || entry + .reasons + .iter() + .any(|reason| matches!(reason.kind, ReasonKind::Lockfile | ReasonKind::Submodule)) +} + +/// Roles of the files "Also" counts. The role filter is the narrowest filter the +/// navigator has, so the shown set is these roles, not only these files. +pub(crate) fn also_roles(model: &ReviewModel) -> RoleSet { + model + .files + .iter() + .filter(|entry| is_also_file(entry)) + .fold(RoleSet::empty(), |roles, entry| roles.with(entry.role)) +} + +#[cfg(test)] +mod tests { + use super::super::super::fixtures; + use super::super::super::model::{AlsoFact, Facts, ReasonKind, ReviewModel}; + use super::super::super::ranking::{ModelInputs, StructureLoad, build_review_model}; + use super::{FactLine, FactLink, also_roles, fact_sentences, is_also_file, ledger_rows}; + use okena_core::review::{FileRole, ReviewInventory}; + use okena_git::DiffMode; + + fn model_of(inventory: &ReviewInventory) -> ReviewModel { + build_review_model(ModelInputs { + inventory: Some(inventory), + inventory_error: None, + structure: None, + structure_state: StructureLoad::NotStarted, + diff_mode: &DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + }, + }) + } + + fn line<'a>(lines: &'a [FactLine], label: &str) -> &'a FactLine { + lines + .iter() + .find(|line| line.label == label) + .unwrap_or_else(|| panic!("no {label} line in {lines:?}")) + } + + #[test] + fn the_fixture_comparison_says_every_fact_it_has() { + let model = fixtures::model(); + let lines = fact_sentences(&model.facts); + let labels: Vec<&str> = lines.iter().map(|line| line.label).collect(); + assert_eq!(labels, ["Public API", "Tests", "Moves", "Commits", "Also"]); + assert_eq!(line(&lines, "Public API").link, Some(FactLink::Attention)); + assert_eq!(line(&lines, "Moves").link, Some(FactLink::MechanicalMoves)); + assert_eq!(line(&lines, "Also").link, Some(FactLink::Also)); + assert_eq!(line(&lines, "Commits").link, Some(FactLink::CommitLedger)); + assert!( + matches!(line(&lines, "Tests").link, Some(FactLink::Directory(_))), + "the tests link opens the directory it names" + ); + } + + #[test] + fn an_empty_fact_set_produces_no_lines() { + assert!(fact_sentences(&Facts::default()).is_empty()); + } + + #[test] + fn every_link_carries_the_wording_the_spec_gives_it() { + assert_eq!(FactLink::Attention.label(false), "\u{2192} Attention"); + assert_eq!(FactLink::MechanicalMoves.label(false), "filter"); + assert_eq!(FactLink::Also.label(false), "show"); + assert_eq!(FactLink::Directory("src".into()).label(false), "show"); + } + + #[test] + fn only_the_ledger_link_changes_with_the_open_state() { + assert_eq!(FactLink::CommitLedger.label(false), "show ledger"); + assert_eq!(FactLink::CommitLedger.label(true), "hide ledger"); + assert_eq!(FactLink::Also.label(true), "show"); + } + + #[test] + fn the_ledger_runs_oldest_first() { + let model = fixtures::model(); + let rows = ledger_rows(&model.commits); + assert_eq!(rows.len(), model.commits.len()); + assert!( + rows.windows(2) + .all(|pair| pair[0].timestamp <= pair[1].timestamp), + "the ledger reads in the order the branch was written" + ); + assert!( + rows.iter().any(|commit| commit.is_merge), + "the fixture has a merge commit to mark" + ); + } + + #[test] + fn a_comparison_without_a_supported_language_keeps_the_fact_but_drops_the_link() { + let inventory = fixtures::inventory_all_unsupported(); + let structure = fixtures::structure_empty(); + let model = build_review_model(ModelInputs { + inventory: Some(&inventory), + inventory_error: None, + structure: Some(&structure), + structure_state: StructureLoad::Ready, + diff_mode: &DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + }, + }); + let lines = fact_sentences(&model.facts); + let public_api = line(&lines, "Public API"); + assert_eq!(public_api.text, "no supported language in this comparison"); + assert_eq!(public_api.link, None); + } + + #[test] + fn a_small_comparison_has_no_moves_and_no_also_line() { + let inventory = fixtures::inventory_small(); + let model = model_of(&inventory); + let labels: Vec<&str> = fact_sentences(&model.facts) + .iter() + .map(|line| line.label) + .collect(); + assert!(!labels.contains(&"Moves"), "{labels:?}"); + assert!(!labels.contains(&"Also"), "{labels:?}"); + assert!(!labels.contains(&"Commits"), "the fixture has no commits"); + } + + #[test] + fn the_also_link_narrows_to_the_roles_those_files_carry() { + let model = fixtures::model(); + let roles = also_roles(&model); + assert!(roles.contains(FileRole::Lockfile), "pnpm-lock.yaml"); + assert!(roles.contains(FileRole::Unclassified), "assets/logo.png"); + assert!( + !roles.contains(FileRole::Implementation), + "a deleted implementation file must not widen the link to the whole change" + ); + assert!(!roles.contains(FileRole::Documentation)); + } + + /// Drift guard: the link predicate must keep counting the same files the + /// ranking's `also_fact` counts, minus the deleted implementation files. + #[test] + fn the_also_predicate_tracks_the_fact_it_links_from() { + let model = fixtures::model(); + let fact = model + .facts + .also + .as_ref() + .expect("the fixture has an Also fact"); + let counted = model + .files + .iter() + .filter(|entry| is_also_file(entry)) + .count(); + assert_eq!( + counted, + fact.lockfiles + fact.submodules + fact.binaries, + "one file per lockfile, submodule and binary the fact counts" + ); + assert!( + fact.deleted_impl > 0, + "the fixture deletes an implementation file, so the split is exercised" + ); + assert_eq!( + counted + fact.deleted_impl, + model + .files + .iter() + .filter(|entry| { + is_also_file(entry) + || entry + .reasons + .iter() + .any(|reason| reason.kind == ReasonKind::DeletedImpl) + }) + .count(), + "the sentence still names the deleted files the link leaves out" + ); + } + + #[test] + fn a_deleted_implementation_file_alone_leaves_the_also_line_unlinked() { + let facts = Facts { + also: Some(AlsoFact { + lockfiles: 0, + submodules: 0, + binaries: 0, + deleted_impl: 2, + }), + ..Facts::default() + }; + let lines = fact_sentences(&facts); + let also = line(&lines, "Also"); + assert_eq!(also.text, "2 deleted implementation files"); + assert_eq!(also.link, None, "nothing to narrow to"); + } + + #[test] + fn the_tests_link_opens_the_directory_the_sentence_names() { + let model = fixtures::model(); + let lines = fact_sentences(&model.facts); + let tests = line(&lines, "Tests"); + let Some(FactLink::Directory(path)) = tests.link.as_ref() else { + panic!("the tests fact links to a directory: {tests:?}"); + }; + assert!( + tests.text.contains(path.as_str()), + "the link goes where the sentence points: {} vs {path}", + tests.text + ); + } + + #[test] + fn a_binary_only_comparison_still_has_an_also_line() { + let inventory = fixtures::inventory_binary_only(); + let model = model_of(&inventory); + let lines = fact_sentences(&model.facts); + assert_eq!(line(&lines, "Also").text, "2 binary files"); + assert!(also_roles(&model).contains(FileRole::Unclassified)); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/overview/glance.rs b/crates/okena-views-git/src/diff_viewer/review_ui/overview/glance.rs new file mode 100644 index 000000000..6f8f39d6e --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/overview/glance.rs @@ -0,0 +1,223 @@ +//! "Change at a glance" — the headline and the volume legend. Pure; the render +//! pass only paints what these return. + +use super::super::labels::facts as words; +use super::super::labels::role_label; +use super::super::model::{ReviewModel, VolumeRow}; +use okena_core::review::FileRole; + +/// Below this the Overview stacks its two columns — spec §12. +const NARROW_WIDTH: f32 = 1_000.0; + +/// The one number the Overview leads with. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Headline { + pub main: String, + pub sub: String, +} + +pub(crate) fn is_narrow(width: f32) -> bool { + width < NARROW_WIDTH +} + +/// Implementation volume, or — when nothing implements anything — the largest role. +pub(crate) fn headline(model: &ReviewModel) -> Headline { + let Some(row) = headline_row(model) else { + return Headline { + main: words::NOTHING_CHANGED.to_string(), + sub: String::new(), + }; + }; + let role = role_label(row.role); + // Binary-only comparisons have no lines to share out — spec §12. + if model.total_changed_lines == 0 { + return Headline { + main: words::headline_files(role, row.files), + sub: words::headline_share_of_files(row.percent, model.files.len()), + }; + } + Headline { + main: words::headline_lines(role, row.lines, deletions_dominate(model, row.role)), + sub: words::headline_share_of_lines(row.percent, model.total_changed_lines, row.files), + } +} + +/// Implementation when it changed something the headline can name, else the role +/// that changed the most. A headline never reads `Implementation 0 lines`. +fn headline_row(model: &ReviewModel) -> Option<&VolumeRow> { + let counts_lines = model.total_changed_lines > 0; + let implementation = model.volume.iter().find(|row| { + row.role == FileRole::Implementation && row.files > 0 && (!counts_lines || row.lines > 0) + }); + if implementation.is_some() { + return implementation; + } + model + .volume + .iter() + .filter(|row| row.files > 0) + .fold(None, |best: Option<&VolumeRow>, row| match best { + // Ties keep the earlier role, so the order stays the one the menu uses. + Some(best) if (best.lines, best.files) >= (row.lines, row.files) => Some(best), + _ => Some(row), + }) +} + +fn deletions_dominate(model: &ReviewModel, role: FileRole) -> bool { + let (added, deleted) = model.files.iter().filter(|entry| entry.role == role).fold( + (0u64, 0u64), + |(added, deleted), entry| { + ( + added.saturating_add(entry.lines_added), + deleted.saturating_add(entry.lines_deleted), + ) + }, + ); + deleted > added +} + +/// The legend rows worth a line: the model keeps all 11 roles, display drops +/// the ones nothing touched — spec §5. A role can have lines without files of +/// its own: tests written inside the file they test. +pub(crate) fn legend_rows(model: &ReviewModel) -> Vec<&VolumeRow> { + model + .volume + .iter() + .filter(|row| row.files > 0 || row.lines > 0) + .collect() +} + +#[cfg(test)] +mod tests { + use super::super::super::fixtures; + use super::super::super::model::ReviewModel; + use super::super::super::ranking::{ModelInputs, StructureLoad, build_review_model}; + use super::{headline, is_narrow, legend_rows}; + use okena_core::review::{FileRole, ReviewInventory}; + use okena_git::DiffMode; + + fn model_of(inventory: &ReviewInventory) -> ReviewModel { + build_review_model(ModelInputs { + inventory: Some(inventory), + inventory_error: None, + structure: None, + structure_state: StructureLoad::NotStarted, + diff_mode: &DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + }, + }) + } + + #[test] + fn the_headline_leads_with_implementation_lines() { + let model = fixtures::model(); + let headline = headline(&model); + assert!( + headline.main.starts_with("Implementation "), + "{}", + headline.main + ); + assert!(headline.main.ends_with(" lines"), "{}", headline.main); + assert!( + headline.sub.contains(" % of "), + "the share names the comparison total: {}", + headline.sub + ); + } + + #[test] + fn a_deletion_heavy_role_carries_the_sign() { + let mut model = fixtures::model(); + for entry in &mut model.files { + if entry.role == FileRole::Implementation { + std::mem::swap(&mut entry.lines_added, &mut entry.lines_deleted); + } + } + assert!( + headline(&model).main.ends_with("mostly deletions"), + "{}", + headline(&model).main + ); + } + + #[test] + fn implementation_without_changed_lines_never_leads_the_headline() { + let mut model = fixtures::model(); + for row in &mut model.volume { + if row.role == FileRole::Implementation { + row.lines = 0; + row.percent = 0.0; + } + } + let headline = headline(&model); + assert!( + !headline.main.starts_with("Implementation"), + "the largest role takes over: {}", + headline.main + ); + assert!(!headline.main.contains(" 0 lines"), "{}", headline.main); + assert!(!headline.sub.starts_with("0 %"), "{}", headline.sub); + } + + #[test] + fn a_binary_only_comparison_counts_files_instead_of_lines() { + let inventory = fixtures::inventory_binary_only(); + let model = model_of(&inventory); + let headline = headline(&model); + assert_eq!(headline.main, "Unclassified 2 files"); + assert_eq!(headline.sub, "100 % of 2 files"); + } + + #[test] + fn an_empty_comparison_says_nothing_changed() { + let inventory = fixtures::empty_inventory(); + let model = model_of(&inventory); + assert_eq!(headline(&model).main, "No files changed"); + assert!(headline(&model).sub.is_empty()); + } + + #[test] + fn the_legend_drops_the_roles_nothing_touched() { + let model = fixtures::model(); + let rows = legend_rows(&model); + assert!(!rows.is_empty()); + assert!( + rows.iter().all(|row| row.files > 0 || row.lines > 0), + "a role with neither files nor lines has nothing to show" + ); + assert!( + rows.len() < model.volume.len(), + "the model keeps all 11 roles" + ); + } + + #[test] + fn a_role_with_lines_but_no_files_of_its_own_keeps_its_row() { + let inventory = fixtures::inventory_inline_tests(); + let structure = fixtures::structure_inline_tests(); + let model = build_review_model(ModelInputs { + inventory: Some(&inventory), + inventory_error: None, + structure: Some(&structure), + structure_state: StructureLoad::Ready, + diff_mode: &DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + }, + }); + let tests = legend_rows(&model) + .into_iter() + .find(|row| row.role == FileRole::Test) + .expect("the inline tests earn a legend row"); + assert_eq!(tests.files, 0); + assert!(tests.lines > 0, "and the lines are what earned it"); + } + + #[test] + fn the_overview_stacks_below_a_thousand_pixels() { + assert!(is_narrow(999.0)); + assert!(!is_narrow(1_000.0)); + assert!(!is_narrow(1_400.0)); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/overview/mod.rs b/crates/okena-views-git/src/diff_viewer/review_ui/overview/mod.rs new file mode 100644 index 000000000..7382df777 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/overview/mod.rs @@ -0,0 +1,744 @@ +//! Overview: change at a glance, the facts, and "Start here" — spec §8. + +mod facts; +mod glance; +mod start_here; + +use self::facts::{FactLine, FactLink, also_roles, fact_sentences, ledger_rows}; +use self::glance::{Headline, headline, is_narrow, legend_rows}; +use self::start_here::{ChipTone, caveat, chip_tone, row_tone, start_here}; +use super::super::DiffViewer; +use super::labels::facts as words; +use super::labels::status as status_words; +use super::labels::{format_lines, format_signed, glyph, relative_time, role_label}; +use super::model::{AttentionItem, AttentionTarget, CommitRow, Reason, ReviewModel, VolumeRow}; +use super::state::{NavigatorMode, RoleFilter, RolePreset, RoleSet}; +use gpui::prelude::*; +use gpui::*; +use gpui_component::tooltip::Tooltip; +use gpui_component::{h_flex, v_flex}; +use okena_core::review::FileRole; +use okena_core::theme::ThemeColors; +use okena_ui::tokens::{ui_text, ui_text_ms, ui_text_sm}; + +/// The page stops growing here; a wider window gets margin, not longer rows. +const CONTENT_WIDTH: Pixels = px(1080.0); +/// The sidebar holding the composition and the facts; the list takes the rest. +const SIDE_WIDTH: Pixels = px(380.0); +/// The legend stops here even when the block is wider: a role and its numbers +/// belong to each other, and a full-width row pulls them apart. +const LEGEND_WIDTH: Pixels = px(420.0); +const HEADLINE_SIZE: f32 = 17.0; + +impl DiffViewer { + pub(crate) fn render_overview( + &mut self, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let Some(model) = self.review_ui.model.clone() else { + return div() + .flex_1() + .min_h_0() + .p(px(28.0)) + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .child(status_words::LOADING_INVENTORY) + .into_any_element(); + }; + let glance = self.render_glance(&model, t, cx); + let start_here = self.render_start_here(&model, t, cx); + // The ordered list is the page and reads down the left; the composition + // is its sidebar. Too narrow for both and the sidebar goes on top. + let body = if is_narrow(self.review_ui.content_width) { + v_flex().gap(px(26.0)).child(glance).child(start_here) + } else { + h_flex() + .items_start() + .gap(px(36.0)) + .child(start_here.flex_1().min_w_0()) + .child(glance.w(SIDE_WIDTH).flex_shrink_0()) + }; + div() + .id("review-overview") + .flex_1() + .min_w_0() + .min_h_0() + .overflow_x_hidden() + .overflow_y_scroll() + .child( + v_flex() + // A wide window does not stretch the page: past this the + // rows would only grow their empty middle. + .max_w(CONTENT_WIDTH) + .px(px(28.0)) + .py(px(18.0)) + .child(body), + ) + .into_any_element() + } + + /// The sidebar: the headline number, the stacked bar, the legend, the facts. + fn render_glance(&self, model: &ReviewModel, t: &ThemeColors, cx: &mut Context) -> Div { + v_flex() + .gap(px(14.0)) + .child(stacked_header( + words::GLANCE_HEADER, + words::GLANCE_HINT, + t, + cx, + )) + .child(render_headline(&headline(model), t, cx)) + .child(render_bar(model, t)) + .child( + v_flex().gap(px(1.0)).max_w(LEGEND_WIDTH).children( + legend_rows(model) + .into_iter() + .enumerate() + .map(|(index, row)| self.render_legend_row(index, row, t, cx)), + ), + ) + // A rule, so the facts read as facts and not as more legend. + .children( + self.render_facts(model, t, cx) + .map(|facts| facts.pt(px(14.0)).border_t_1().border_color(rgb(t.border))), + ) + } + + /// One legend row; clicking it narrows the navigator to that role alone. + fn render_legend_row( + &self, + index: usize, + row: &VolumeRow, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let role = row.role; + // Binary-only comparisons have no lines, and a zero cell says nothing. + let lines = if row.lines > 0 { + format_lines(row.lines) + } else { + String::new() + }; + h_flex() + .id(SharedString::from(format!("review-legend-{index}"))) + .cursor_pointer() + .items_center() + .gap(px(8.0)) + .px(px(4.0)) + .py(px(2.0)) + .rounded(px(3.0)) + .hover(|style| style.bg(rgb(t.bg_hover))) + .on_click(cx.listener(move |this, _, _window, cx| { + this.review_set_role_filter(single_role(role), cx); + })) + .child( + div() + .w(px(8.0)) + .h(px(8.0)) + .flex_shrink_0() + .rounded(px(2.0)) + .bg(rgb(role_color(role, t))), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_secondary)) + .child(role_label(role)), + ) + .child(numeric( + words::legend_file_cell(row.files, row.inline_files), + 64.0, + t.text_muted, + cx, + )) + .child(numeric(lines, 60.0, t.text_secondary, cx)) + .child(numeric( + words::percent_label(row.percent), + 48.0, + t.text_muted, + cx, + )) + .into_any_element() + } + + /// The right column, or nothing at all when this comparison states no facts. + fn render_facts( + &self, + model: &ReviewModel, + t: &ThemeColors, + cx: &mut Context, + ) -> Option
{ + let lines = fact_sentences(&model.facts); + if lines.is_empty() { + return None; + } + Some( + v_flex() + .gap(px(10.0)) + .children( + lines + .into_iter() + .enumerate() + .map(|(index, line)| self.render_fact_line(index, line, t, cx)), + ) + .children(self.render_ledger(model, t, cx)), + ) + } + + /// The commit ledger, inline under the facts while `show ledger` is on. + fn render_ledger( + &self, + model: &ReviewModel, + t: &ThemeColors, + cx: &mut Context, + ) -> Option
{ + if !self.review_ui.ledger_open || model.commits.is_empty() { + return None; + } + Some( + v_flex() + .pt(px(4.0)) + .gap(px(3.0)) + .border_t_1() + .border_color(rgb(t.border)) + .children( + ledger_rows(&model.commits) + .into_iter() + .map(|commit| render_ledger_row(commit, t, cx)), + ), + ) + } + + fn render_fact_line( + &self, + index: usize, + line: FactLine, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let link = line + .link + .map(|link| self.render_fact_link(index, link, t, cx)); + h_flex() + .items_start() + .gap(px(12.0)) + .child( + div() + .w(px(76.0)) + .flex_shrink_0() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(line.label), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_wrap() + .gap(px(5.0)) + .child( + div() + .min_w_0() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_secondary)) + .child(line.text), + ) + .children(link), + ) + .into_any_element() + } + + fn render_fact_link( + &self, + index: usize, + link: FactLink, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + div() + .id(SharedString::from(format!("review-fact-link-{index}"))) + .cursor_pointer() + .flex_shrink_0() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.term_blue)) + .hover(|style| style.text_color(rgb(t.text_primary))) + .child(link.label(self.review_ui.ledger_open)) + .on_click(cx.listener(move |this, _, _window, cx| { + this.review_follow_fact_link(&link, cx); + })) + .into_any_element() + } + + /// Every fact link lands somewhere — spec §2. + fn review_follow_fact_link(&mut self, link: &FactLink, cx: &mut Context) { + match link { + FactLink::Attention => self.review_set_navigator(NavigatorMode::Attention, cx), + FactLink::Directory(path) => { + self.review_open_item(AttentionTarget::Directory(path.clone()), cx); + } + FactLink::MechanicalMoves => { + // Replace the filter rather than layer onto it, as "Also" does. + self.review_set_navigator(NavigatorMode::Files, cx); + self.review_set_role_filter(RoleFilter::everything(), cx); + self.review_set_saved_filter(Some(true), Some(false), cx); + } + FactLink::CommitLedger => self.review_toggle_commit_ledger(cx), + FactLink::Also => { + let roles = self + .review_ui + .model + .as_ref() + .map(|model| also_roles(model)) + .unwrap_or_else(RoleSet::empty); + if roles.is_empty() { + return; + } + self.review_set_navigator(NavigatorMode::Files, cx); + self.review_set_role_filter(role_set_filter(roles), cx); + } + } + } + + /// The page's own column: the ordered list, two lines to a row. + fn render_start_here( + &self, + model: &ReviewModel, + t: &ThemeColors, + cx: &mut Context, + ) -> Div { + let all = div() + .id("review-start-here-all") + .cursor_pointer() + .flex_shrink_0() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.term_blue)) + .hover(|style| style.text_color(rgb(t.text_primary))) + .child(words::all_attention(model.attention.len())) + .on_click(cx.listener(|this, _, _window, cx| { + this.review_set_navigator(NavigatorMode::Attention, cx); + })) + .into_any_element(); + let caveat_line = caveat(&model.coverage).map(|sentence| { + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(sentence) + }); + let items = start_here(model); + let last = items.len().saturating_sub(1); + let rows: Vec = items + .iter() + .enumerate() + .map(|(index, item)| self.render_start_row(index, item, index == last, t, cx)) + .collect(); + + v_flex() + .gap(px(10.0)) + .child(section_header( + words::START_HERE_HEADER, + words::START_HERE_HINT, + Some(all), + t, + cx, + )) + .children(caveat_line) + .child(v_flex().children(rows)) + .child( + div() + .pt(px(6.0)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(words::TIERS_FOOTER), + ) + } + + /// A row is two lines: what changed and by how much, then where it lives + /// and why it is here. + fn render_start_row( + &self, + index: usize, + item: &AttentionItem, + last: bool, + t: &ThemeColors, + cx: &mut Context, + ) -> AnyElement { + let target = item.target.clone(); + // Rows structure never reached stay dimmed — spec §8. + let name_color = if item.dimmed { + t.text_muted + } else { + t.text_primary + }; + let (added, deleted) = format_signed(item.lines_added, item.lines_deleted); + let full_path = SharedString::from(item.path.clone()); + let where_text = short_path(&item.path, &item.target); + h_flex() + .id(SharedString::from(format!("review-start-here-{index}"))) + .cursor_pointer() + .items_start() + .gap(px(10.0)) + .px(px(6.0)) + .py(px(8.0)) + // A hairline, so two-line rows do not run into each other. + .when(!last, |row| row.border_b_1().border_color(rgb(t.border))) + .hover(|style| style.bg(rgb(t.bg_hover))) + .on_click(cx.listener(move |this, _, _window, cx| { + this.review_open_item(target.clone(), cx); + })) + .tooltip(move |window, cx| Tooltip::new(full_path.clone()).build(window, cx)) + .child( + div() + .w(px(16.0)) + .flex_shrink_0() + .text_right() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(format!("{}", index.saturating_add(1))), + ) + .child( + div() + .w(px(12.0)) + .flex_shrink_0() + .text_size(ui_text_ms(cx)) + .text_color(rgb(tone_color(row_tone(item), t))) + .child(glyph(item.glyph)), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap(px(4.0)) + .child( + h_flex() + .items_baseline() + .gap(px(10.0)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(ui_text_ms(cx)) + .text_color(rgb(name_color)) + .child(item.name.clone()), + ) + .child( + h_flex() + .flex_shrink_0() + .gap(px(6.0)) + .when(item.lines_added > 0, |row| { + row.child(signed_count(added, t.diff_added_fg, cx)) + }) + .when(item.lines_deleted > 0, |row| { + row.child(signed_count(deleted, t.diff_removed_fg, cx)) + }), + ), + ) + .child( + h_flex() + .flex_wrap() + .items_center() + .gap(px(6.0)) + .when(!where_text.is_empty(), |line| { + line.child( + div() + .min_w_0() + .truncate() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(where_text), + ) + }) + .children(item.reasons.iter().map(|reason| render_chip(reason, t, cx))), + ), + ) + .into_any_element() + } +} + +/// What the muted column after the name says about where the row lives. +/// +/// A symbol row: its file, cut to `…/dir/file.rs`. A file row: its directory, +/// cut the same way — the name is the basename already. A directory row's +/// `path` is its "n implementation files" text and stays whole. Every row's +/// tooltip carries the full path. +fn short_path(path: &str, target: &AttentionTarget) -> String { + match target { + AttentionTarget::Symbol { .. } => tail(path, 2), + AttentionTarget::File(_) => match path.rfind('/') { + Some(index) => tail(&path[..index], 2), + None => String::new(), + }, + AttentionTarget::Directory(_) => path.to_string(), + } +} + +/// The last `keep` segments of a path, `…/` marking what was cut. +fn tail(path: &str, keep: usize) -> String { + let segments: Vec<&str> = path.split('/').collect(); + if segments.len() <= keep { + return path.to_string(); + } + format!("\u{2026}/{}", segments[segments.len() - keep..].join("/")) +} + +fn render_headline(head: &Headline, t: &ThemeColors, cx: &App) -> AnyElement { + h_flex() + .items_baseline() + .flex_wrap() + .gap(px(10.0)) + .child( + div() + .text_size(ui_text(HEADLINE_SIZE, cx)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(t.term_bright_blue)) + .child(head.main.clone()), + ) + .when(!head.sub.is_empty(), |row| { + row.child( + div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .child(head.sub.clone()), + ) + }) + .into_any_element() +} + +/// One segment per role that changed something; the shares already add up to 100 %. +fn render_bar(model: &ReviewModel, t: &ThemeColors) -> AnyElement { + let rows = legend_rows(model); + let total: f32 = rows.iter().map(|row| row.percent).sum(); + let bar = h_flex() + .h(px(8.0)) + .w_full() + .rounded(px(2.0)) + .overflow_hidden() + .bg(rgb(t.bg_secondary)); + if total <= 0.0 { + return bar.into_any_element(); + } + bar.children(rows.into_iter().map(|row| { + div() + .h_full() + .w(relative(row.percent / total)) + .bg(rgb(role_color(row.role, t))) + })) + .into_any_element() +} + +fn section_header( + title: &'static str, + hint: &'static str, + right: Option, + t: &ThemeColors, + cx: &App, +) -> Div { + h_flex() + .items_center() + .gap(px(10.0)) + .child( + div() + .flex_shrink_0() + .text_size(ui_text_sm(cx)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(t.text_secondary)) + .child(title), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(hint), + ) + .children(right) +} + +/// The sidebar has no room for a title and its hint side by side. +fn stacked_header(title: &'static str, hint: &'static str, t: &ThemeColors, cx: &App) -> Div { + v_flex() + .gap(px(2.0)) + .child( + div() + .text_size(ui_text_sm(cx)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(rgb(t.text_secondary)) + .child(title), + ) + .child( + div() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(hint), + ) +} + +/// A right-aligned number column; the digits line up across rows. +fn numeric(text: String, width: f32, color: u32, cx: &App) -> Div { + div() + .w(px(width)) + .flex_shrink_0() + .text_right() + .font_family("monospace") + .text_size(ui_text_sm(cx)) + .text_color(rgb(color)) + .child(text) +} + +fn signed_count(text: String, color: u32, cx: &App) -> Div { + div() + .font_family("monospace") + .text_size(ui_text_sm(cx)) + .text_color(rgb(color)) + .child(text) +} + +/// `a1b2c3d · subject · Ada · 6d ago`; no per-commit diff to open yet. +fn render_ledger_row(commit: &CommitRow, t: &ThemeColors, cx: &App) -> AnyElement { + h_flex() + .items_center() + .gap(px(8.0)) + .child( + div() + .flex_shrink_0() + .font_family("monospace") + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(commit.short_sha.clone()), + ) + .when(commit.is_merge, |row| { + row.child( + div() + .flex_shrink_0() + .px(px(4.0)) + .rounded(px(3.0)) + .bg(rgb(t.bg_secondary)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(words::MERGE_BADGE), + ) + }) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_secondary)) + .child(commit.subject.clone()), + ) + .child( + div() + .flex_shrink_0() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(commit.author.clone()), + ) + .child( + div() + .flex_shrink_0() + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(relative_time(commit.timestamp)), + ) + .into_any_element() +} + +fn render_chip(reason: &Reason, t: &ThemeColors, cx: &App) -> AnyElement { + div() + .px(px(5.0)) + .py(px(1.0)) + .rounded(px(3.0)) + .bg(rgb(t.bg_secondary)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(tone_color(chip_tone(reason.kind), t))) + .child(reason.label.clone()) + .into_any_element() +} + +fn tone_color(tone: ChipTone, t: &ThemeColors) -> u32 { + match tone { + ChipTone::Contract => t.error, + ChipTone::Behaviour => t.term_blue, + ChipTone::Addition => t.success, + ChipTone::Caution => t.warning, + ChipTone::Muted => t.text_muted, + } +} + +/// Roles keep one colour across the bar and the legend — spec §8. +fn role_color(role: FileRole, t: &ThemeColors) -> u32 { + match role { + FileRole::Implementation => t.term_bright_blue, + FileRole::Test => t.success, + FileRole::Fixture | FileRole::Snapshot | FileRole::Example => t.text_secondary, + FileRole::Documentation => t.term_magenta, + FileRole::Configuration => t.warning, + FileRole::Lockfile | FileRole::Generated | FileRole::Vendored | FileRole::Unclassified => { + t.term_bright_black + } + } +} + +fn single_role(role: FileRole) -> RoleFilter { + role_set_filter(RoleSet::from_roles([role])) +} + +fn role_set_filter(roles: RoleSet) -> RoleFilter { + RoleFilter { + roles, + preset: RolePreset::Custom, + likely_mechanical_only: false, + not_analyzed_only: false, + } +} + +#[cfg(test)] +mod tests { + use super::super::super::review::ReviewFileKey; + use super::super::model::AttentionTarget; + use super::{short_path, tail}; + + #[test] + fn paths_keep_their_tail_and_mark_the_cut() { + assert_eq!(tail("a/b/c/d.rs", 2), "\u{2026}/c/d.rs"); + assert_eq!(tail("c/d.rs", 2), "c/d.rs"); + assert_eq!(tail("d.rs", 2), "d.rs"); + } + + #[test] + fn the_where_column_depends_on_what_the_row_is() { + let key = ReviewFileKey { + old_path: None, + new_path: Some("x".into()), + }; + let symbol = AttentionTarget::Symbol { + file: key.clone(), + change_index: 0, + }; + assert_eq!( + short_path("packages/worker/src/storage/repo.ts", &symbol), + "\u{2026}/storage/repo.ts" + ); + assert_eq!( + short_path( + "packages/worker/src/storage/repo.ts", + &AttentionTarget::File(key) + ), + "\u{2026}/src/storage" + ); + assert_eq!( + short_path( + "26 implementation files", + &AttentionTarget::Directory("a".into()) + ), + "26 implementation files" + ); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/overview/start_here.rs b/crates/okena-views-git/src/diff_viewer/review_ui/overview/start_here.rs new file mode 100644 index 000000000..3f33b6e21 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/overview/start_here.rs @@ -0,0 +1,273 @@ +//! "Start here" — the first rows of the ordered list, the coverage caveat, and +//! the tone each reason chip wears. Pure; spec §8. + +use super::super::labels::facts as words; +use super::super::model::{AttentionItem, CoverageSummary, ReasonKind, ReviewModel, Tier}; + +/// How many rows the Overview shows before "all N → Attention" takes over. +pub(crate) const START_HERE_ROWS: usize = 10; + +/// The first ten items. The list is tier-ordered, so rest-tier rows only appear +/// once the higher tiers run out — spec §6. +pub(crate) fn start_here(model: &ReviewModel) -> &[AttentionItem] { + let end = model.attention.len().min(START_HERE_ROWS); + &model.attention[..end] +} + +/// How far structure reached, in words — only when it did not reach everything. +pub(crate) fn caveat(coverage: &CoverageSummary) -> Option { + if !coverage.partial { + return None; + } + if coverage.impl_total == 0 { + // Without implementation files the sentence already counts every file, so + // "(first N in path order)" would only repeat the number next to it. + return Some(words::caveat_sentence( + coverage.analyzed_files, + coverage.total_files, + false, + None, + )); + } + let reached = u64::try_from(coverage.impl_analyzed).unwrap_or(u64::MAX); + let total = u64::try_from(coverage.impl_total).unwrap_or(u64::MAX); + if reached >= total { + // Every implementation file was reached; only supporting files were + // left out, and those never carry structural reasons anyway. + return None; + } + // The bias clause earns its place only when it names a different number. + let path_order = coverage + .path_order_bias + .then_some(coverage.analyzed_files) + .filter(|first| *first != reached); + Some(words::caveat_sentence(reached, total, true, path_order)) +} + +/// How loud a reason chip is. Colours come from the theme, not from here. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ChipTone { + /// Something left the public surface. + Contract, + /// The code behind the surface moved. + Behaviour, + /// New surface. + Addition, + /// A git fact worth a second look. + Caution, + /// Context, not a finding. + Muted, +} + +pub(crate) fn chip_tone(kind: ReasonKind) -> ChipTone { + match kind { + ReasonKind::PublicRemoved | ReasonKind::Removed | ReasonKind::DeletedImpl => { + ChipTone::Contract + } + ReasonKind::PublicSignature + | ReasonKind::ExportedSignature + | ReasonKind::Body + | ReasonKind::Calls => ChipTone::Behaviour, + ReasonKind::New | ReasonKind::NewPublic => ChipTone::Addition, + ReasonKind::NoTestChanges + | ReasonKind::CiConfig + | ReasonKind::Lockfile + | ReasonKind::Submodule + | ReasonKind::Binary + | ReasonKind::Complex + | ReasonKind::LargeChurn => ChipTone::Caution, + ReasonKind::Moved | ReasonKind::NotAnalyzed => ChipTone::Muted, + } +} + +/// Tier of the loudest reason on a row, for the row's own glyph colour. +pub(crate) fn row_tone(item: &AttentionItem) -> ChipTone { + match item.tier { + Tier::Contract => ChipTone::Contract, + Tier::Behaviour => ChipTone::Behaviour, + Tier::Volume | Tier::GitFacts => ChipTone::Caution, + Tier::Rest => ChipTone::Muted, + } +} + +#[cfg(test)] +mod tests { + use super::super::super::fixtures; + use super::super::super::model::{CoverageSummary, ReasonKind, ReviewModel, Tier}; + use super::super::super::ranking::{ModelInputs, StructureLoad, build_review_model}; + use super::{ChipTone, START_HERE_ROWS, caveat, chip_tone, row_tone, start_here}; + use okena_core::review::ReviewInventory; + use okena_git::DiffMode; + + fn model_of(inventory: &ReviewInventory) -> ReviewModel { + build_review_model(ModelInputs { + inventory: Some(inventory), + inventory_error: None, + structure: None, + structure_state: StructureLoad::NotStarted, + diff_mode: &DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + }, + }) + } + + #[test] + fn start_here_takes_the_top_of_the_ordered_list() { + let model = fixtures::model(); + let rows = start_here(&model); + assert_eq!(rows.len(), START_HERE_ROWS.min(model.attention.len())); + assert_eq!(rows.first(), model.attention.first()); + assert!( + rows.windows(2).all(|pair| pair[0].tier <= pair[1].tier), + "the rows keep the ranking order" + ); + } + + #[test] + fn rest_tier_rows_only_appear_when_the_higher_tiers_run_out() { + let model = fixtures::model(); + let higher = model + .attention + .iter() + .filter(|item| item.tier < Tier::Rest) + .count(); + let shown_rest = start_here(&model) + .iter() + .filter(|item| item.tier == Tier::Rest) + .count(); + if higher >= START_HERE_ROWS { + assert_eq!(shown_rest, 0); + } else { + assert_eq!( + shown_rest, + START_HERE_ROWS + .saturating_sub(higher) + .min(model.attention.len().saturating_sub(higher)) + ); + } + } + + #[test] + fn a_short_list_is_shown_whole() { + let inventory = fixtures::inventory_small(); + let model = model_of(&inventory); + assert!(model.attention.len() < START_HERE_ROWS); + assert_eq!(start_here(&model).len(), model.attention.len()); + } + + #[test] + fn complete_coverage_has_no_caveat() { + assert_eq!(caveat(&CoverageSummary::default()), None); + } + + #[test] + fn a_partial_run_names_the_reach_and_only_then_the_bias() { + let coverage = CoverageSummary { + analyzed_files: 200, + total_files: 385, + impl_analyzed: 63, + impl_total: 97, + path_order_bias: true, + partial: true, + ..CoverageSummary::default() + }; + assert_eq!( + caveat(&coverage).as_deref(), + Some( + "structure reached 63 of 97 implementation files (first 200 in path order) \ + \u{2014} the rest ranked from git facts" + ) + ); + + let unbiased = CoverageSummary { + path_order_bias: false, + ..coverage + }; + assert_eq!( + caveat(&unbiased).as_deref(), + Some( + "structure reached 63 of 97 implementation files \u{2014} the rest ranked \ + from git facts" + ) + ); + } + + #[test] + fn a_comparison_without_implementation_files_counts_files_instead() { + let coverage = CoverageSummary { + analyzed_files: 0, + total_files: 2, + partial: true, + ..CoverageSummary::default() + }; + assert_eq!( + caveat(&coverage).as_deref(), + Some("structure reached 0 of 2 files \u{2014} the rest ranked from git facts") + ); + } + + #[test] + fn the_bias_clause_is_dropped_when_it_would_only_repeat_a_number() { + let no_implementation = CoverageSummary { + analyzed_files: 1, + total_files: 3, + impl_analyzed: 0, + impl_total: 0, + path_order_bias: true, + partial: true, + ..CoverageSummary::default() + }; + let sentence = caveat(&no_implementation).expect("a partial run has a caveat"); + assert_eq!( + sentence, + "structure reached 1 of 3 files \u{2014} the rest ranked from git facts" + ); + assert!(!sentence.contains("path order"), "{sentence}"); + + let same_number = CoverageSummary { + analyzed_files: 63, + total_files: 200, + impl_analyzed: 63, + impl_total: 97, + path_order_bias: true, + partial: true, + ..CoverageSummary::default() + }; + let sentence = caveat(&same_number).expect("a partial run has a caveat"); + assert_eq!( + sentence, + "structure reached 63 of 97 implementation files \u{2014} the rest ranked from \ + git facts" + ); + assert!(!sentence.contains("path order"), "{sentence}"); + } + + #[test] + fn a_rows_tone_follows_the_tier_that_placed_it() { + let model = fixtures::model(); + for item in &model.attention { + let expected = match item.tier { + Tier::Contract => ChipTone::Contract, + Tier::Behaviour => ChipTone::Behaviour, + Tier::Volume | Tier::GitFacts => ChipTone::Caution, + Tier::Rest => ChipTone::Muted, + }; + assert_eq!(row_tone(item), expected, "{} | {:?}", item.name, item.tier); + } + let contract = model + .attention + .first() + .expect("the fixture ranks something first"); + assert_eq!(row_tone(contract), ChipTone::Contract); + } + + #[test] + fn chip_tones_separate_the_contract_from_the_context() { + assert_eq!(chip_tone(ReasonKind::PublicRemoved), ChipTone::Contract); + assert_eq!(chip_tone(ReasonKind::Body), ChipTone::Behaviour); + assert_eq!(chip_tone(ReasonKind::NewPublic), ChipTone::Addition); + assert_eq!(chip_tone(ReasonKind::Lockfile), ChipTone::Caution); + assert_eq!(chip_tone(ReasonKind::NotAnalyzed), ChipTone::Muted); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/ranking.rs b/crates/okena-views-git/src/diff_viewer/review_ui/ranking.rs new file mode 100644 index 000000000..680ba14f9 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/ranking.rs @@ -0,0 +1,2485 @@ +//! Builds the review model from the loaded datasets — spec §5 / §6. +//! +//! Everything here is deterministic and filter-independent: the same inventory +//! and structure always produce the same order, and no filter is ever consulted. + +use super::super::review::ReviewFileKey; +use super::labels::reasons as words; +use super::labels::{control_context_word, language_from_path, language_label, symbol_glyph}; +use super::model::{ + AlsoFact, AnalysisStatus, AttentionItem, AttentionTarget, CallRow, CommitRow, CommitsFact, + CoverageSummary, DirNode, DirRef, Facts, FileAnalysis, FileEntry, KindGlyph, MovesFact, + OmissionRow, PublicApiFact, Reason, ReasonKind, ReviewModel, SymbolEntry, SymbolMetrics, + TestsFact, Tier, VolumeRow, +}; +use super::state::{ALL_ROLES, MECHANICAL_RESIDUAL_LINES, is_likely_mechanical}; +use okena_core::review::{ + ComparisonSide, FileRole, ResolvedComparison, ReviewCoverage, ReviewFileFact, ReviewFileStatus, + ReviewInventory, +}; +use okena_git::DiffMode; +use okena_review::classification; +use okena_review::{ + CallChangeKind, CallDiffChange, FileAnalysisStatus, OmittedFileGroup, OmittedFileReason, + ReviewStructure, StructuralHotspot, StructuralMetric, StructuredFile, SymbolChange, + SymbolChangeKind, +}; +use okena_syntax::{ControlContext, SymbolKey, SymbolKind, SymbolVisibility}; +use std::collections::{BTreeMap, HashMap, HashSet}; + +/// Comparisons at or below either bound skip the Overview — spec §12. +const SMALL_CHANGE_FILES: usize = 10; +const SMALL_CHANGE_LINES: u64 = 500; + +/// Complexity worth a chip on a symbol that changed anyway — spec §6. +const COMPLEX_DEPTH: u32 = 5; +const COMPLEX_PARAMS: u32 = 6; + +/// Churn share that counts as "one of the largest changes" — spec §6 tier 4. +const CHURN_DECILE: usize = 10; + +/// Extensions named in the unsupported-language omission row. +const OMISSION_EXTENSIONS: usize = 4; + +/// How far the structure request got, independent of the inventory. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum StructureLoad { + NotStarted, + Loading, + Failed(String), + Ready, +} + +pub(crate) struct ModelInputs<'a> { + pub inventory: Option<&'a ReviewInventory>, + pub inventory_error: Option<&'a str>, + pub structure: Option<&'a ReviewStructure>, + pub structure_state: StructureLoad, + pub diff_mode: &'a DiffMode, +} + +/// Filter-independent. Rebuilt only when inventory / structure land or fail. Files +/// in inventory order, keyed by `ReviewFileKey` — NOT the diff pane's `file_stats` +/// order. +pub(crate) fn build_review_model(inputs: ModelInputs<'_>) -> ReviewModel { + let Some(inventory) = inputs.inventory else { + return empty_model(match inputs.inventory_error { + Some(error) => AnalysisStatus::Unavailable { + message: error.to_string(), + }, + None => AnalysisStatus::LoadingInventory, + }); + }; + + let structure = inputs.structure; + let index = StructureIndex::new(structure); + let mut files: Vec = inventory + .files + .iter() + .map(|fact| file_entry(fact, &index)) + .collect(); + apply_large_churn(&mut files); + + let total_changed_lines = files.iter().fold(0u64, |total, entry| { + total.saturating_add(entry.changed_lines()) + }); + let root = directory_tree(&files); + let implementation_counts = implementation_counts(&root, &files); + let volume = volume_rows(&files, total_changed_lines); + let attention = attention_items(&files, &root, &implementation_counts); + // A single commit target has no commit ledger to show — spec §12. + let commits = match inputs.diff_mode { + DiffMode::Commit(_) => Vec::new(), + _ => commit_rows(inventory), + }; + let status = analysis_status(inventory, structure, &inputs.structure_state); + let coverage = coverage_summary(inventory, structure, &files); + let facts = Facts { + public_api: public_api_fact(&files, structure, &coverage), + tests: tests_fact(&root, &implementation_counts), + moves: moves_fact(&files), + commits: commits_fact(&commits), + also: also_fact(&files), + }; + let omissions = omission_rows(structure, &files); + let small_change = + files.len() <= SMALL_CHANGE_FILES || total_changed_lines <= SMALL_CHANGE_LINES; + + ReviewModel { + files, + root, + volume, + total_changed_lines, + facts, + attention, + status, + omissions, + commits, + coverage, + small_change, + } +} + +fn empty_model(status: AnalysisStatus) -> ReviewModel { + ReviewModel { + files: Vec::new(), + root: DirNode::default(), + volume: Vec::new(), + total_changed_lines: 0, + facts: Facts::default(), + attention: Vec::new(), + status, + omissions: Vec::new(), + commits: Vec::new(), + coverage: CoverageSummary::default(), + small_change: true, + } +} + +/// A reason plus the tier it argues for. Annotations (`Complex`, `NotAnalyzed`) +/// carry no tier — spec §6 keeps them off the ranking. +struct Scored { + reason: Reason, + tier: Option, +} + +fn scored(kind: ReasonKind, label: impl Into, tier: Tier) -> Scored { + Scored { + reason: Reason { + kind, + label: label.into(), + }, + tier: Some(tier), + } +} + +fn annotation(kind: ReasonKind, label: impl Into) -> Scored { + Scored { + reason: Reason { + kind, + label: label.into(), + }, + tier: None, + } +} + +fn tier_of(scored: &[Scored]) -> Tier { + scored + .iter() + .filter_map(|entry| entry.tier) + .min() + .unwrap_or_default() +} + +fn chips(scored: Vec) -> Vec { + scored.into_iter().map(|entry| entry.reason).collect() +} + +/// Unclassified files are reviewed like implementation files — spec §6. +fn is_implementation_like(role: FileRole) -> bool { + matches!(role, FileRole::Implementation | FileRole::Unclassified) +} + +/// What makes a directory an implementation directory. A binary blob never +/// does: nobody writes a test next to a PNG. +fn counts_as_implementation_dir(entry: &FileEntry) -> bool { + match entry.role { + FileRole::Implementation => true, + FileRole::Unclassified => !entry.binary, + _ => false, + } +} + +fn is_function(glyph: KindGlyph) -> bool { + matches!(glyph, KindGlyph::Function | KindGlyph::Method) +} + +fn is_type(glyph: KindGlyph) -> bool { + matches!(glyph, KindGlyph::Class | KindGlyph::Type) +} + +// -- files ------------------------------------------------------------------- + +/// Structure files by their exact `(old, new)` path pair, resolved once. +struct StructureIndex<'a> { + structure: Option<&'a ReviewStructure>, + by_paths: HashMap<(Option<&'a str>, Option<&'a str>), usize>, +} + +impl<'a> StructureIndex<'a> { + fn new(structure: Option<&'a ReviewStructure>) -> Self { + let by_paths = structure + .map(|structure| { + structure + .files() + .iter() + .enumerate() + .map(|(index, file)| ((file.old_path(), file.new_path()), index)) + .collect() + }) + .unwrap_or_default(); + Self { + structure, + by_paths, + } + } + + fn is_loaded(&self) -> bool { + self.structure.is_some() + } + + fn find(&self, fact: &ReviewFileFact) -> Option<(usize, &'a StructuredFile)> { + let index = *self + .by_paths + .get(&(fact.old_path.as_deref(), fact.new_path.as_deref()))?; + Some((index, self.structure?.files().get(index)?)) + } +} + +fn file_entry(fact: &ReviewFileFact, index: &StructureIndex<'_>) -> FileEntry { + let key = ReviewFileKey::from_inventory(fact); + let role = fact.classification.role(); + let found = index.find(fact); + let structure_index = found.map(|(index, _)| index); + let structured = found.map(|(_, file)| file); + let path = fact + .new_path + .as_deref() + .or(fact.old_path.as_deref()) + .unwrap_or_default(); + let analysis = file_analysis(structured, path); + let symbols = structured.map(symbol_entries).unwrap_or_default(); + let reasons = file_reasons(fact, role, &analysis, index.is_loaded(), path); + let is_test = role == FileRole::Test; + // A test file's lines are already test lines; only an implementation file + // hides tests inside it. + let inline_test_lines = if is_test { + 0 + } else { + inline_test_lines(&symbols) + }; + + FileEntry { + display_path: key.display(), + key, + old_path: fact.old_path.clone(), + new_path: fact.new_path.clone(), + status: fact.status, + role, + rule_id: fact.classification.rule_id().as_str().to_string(), + similarity: fact.similarity, + lines_added: fact.lines_added.unwrap_or(0), + lines_deleted: fact.lines_deleted.unwrap_or(0), + binary: fact.binary, + analysis, + tier: tier_of(&reasons), + reasons: chips(reasons), + is_test, + has_test_changes: is_test || symbols.iter().any(|symbol| symbol.in_test_scope), + inline_test_lines, + symbols, + structure_index, + } +} + +/// Changed lines inside the file's test scopes, counted once: a `mod tests` +/// change already covers the tests inside it, so a nested symbol whose scope +/// changed too must not be added a second time. +fn inline_test_lines(symbols: &[SymbolEntry]) -> u64 { + let scopes: HashSet<&str> = symbols + .iter() + .filter(|symbol| symbol.in_test_scope) + .map(|symbol| symbol.qualified.as_str()) + .collect(); + symbols + .iter() + .filter(|symbol| symbol.in_test_scope) + .filter(|symbol| !enclosed_by(&symbol.qualified, &scopes)) + .fold(0u64, |lines, symbol| { + lines + .saturating_add(u64::from(symbol.lines_added)) + .saturating_add(u64::from(symbol.lines_deleted)) + }) +} + +/// Whether an enclosing symbol of `qualified` is in `scopes`. +fn enclosed_by(qualified: &str, scopes: &HashSet<&str>) -> bool { + qualified + .match_indices("::") + .any(|(index, _)| scopes.contains(&qualified[..index])) +} + +fn file_analysis(structured: Option<&StructuredFile>, path: &str) -> FileAnalysis { + let Some(file) = structured else { + return FileAnalysis::NotInStructure; + }; + let language = file + .language() + .map(|language| language_label(&language).to_string()) + .or_else(|| language_from_path(path).map(str::to_string)) + .unwrap_or_default(); + match file.status() { + FileAnalysisStatus::Parsed => FileAnalysis::Parsed { language }, + FileAnalysisStatus::Partial => FileAnalysis::Partial { language }, + FileAnalysisStatus::Pending => FileAnalysis::Pending, + FileAnalysisStatus::Unsupported => FileAnalysis::Unsupported, + FileAnalysisStatus::Failed => FileAnalysis::Failed, + FileAnalysisStatus::Skipped => FileAnalysis::Skipped, + } +} + +fn file_reasons( + fact: &ReviewFileFact, + role: FileRole, + analysis: &FileAnalysis, + structure_present: bool, + path: &str, +) -> Vec { + let mut out = Vec::new(); + let implementation = is_implementation_like(role); + let residual = fact + .lines_added + .unwrap_or(0) + .saturating_add(fact.lines_deleted.unwrap_or(0)); + + if implementation && fact.status == ReviewFileStatus::Deleted { + out.push(scored( + ReasonKind::DeletedImpl, + words::DELETED_IMPLEMENTATION_FILE, + Tier::Contract, + )); + } + if role == FileRole::Configuration { + out.push(scored( + ReasonKind::CiConfig, + words::CI_CONFIG, + Tier::GitFacts, + )); + } + if role == FileRole::Lockfile { + out.push(scored( + ReasonKind::Lockfile, + words::LOCKFILE, + Tier::GitFacts, + )); + } + if fact.submodule.is_some() || fact.status == ReviewFileStatus::SubmoduleChanged { + out.push(scored( + ReasonKind::Submodule, + words::SUBMODULE, + Tier::GitFacts, + )); + } + if fact.binary && implementation { + out.push(scored(ReasonKind::Binary, words::BINARY, Tier::GitFacts)); + } + if implementation && !fact.binary && fact.status == ReviewFileStatus::Added { + out.push(scored( + ReasonKind::New, + words::NEW_IMPLEMENTATION_FILE, + Tier::GitFacts, + )); + } + if fact.status == ReviewFileStatus::Renamed { + // Renames are judged by residual lines, not by similarity — spec §6. + let with_edits = residual > MECHANICAL_RESIDUAL_LINES; + if let Some(similarity) = fact.similarity { + out.push(Scored { + reason: Reason { + kind: ReasonKind::Moved, + label: words::moved_label(similarity), + }, + tier: with_edits.then_some(Tier::GitFacts), + }); + } + if with_edits { + out.push(scored( + ReasonKind::Moved, + words::residual_label(residual), + Tier::GitFacts, + )); + } + } + if structure_present && !analysis.is_analyzed() { + out.push(annotation( + ReasonKind::NotAnalyzed, + words::not_analyzed_label(analysis.language().or_else(|| language_from_path(path))), + )); + } + out +} + +/// The biggest implementation changes nothing else already explains — spec §6. +fn apply_large_churn(files: &mut [FileEntry]) { + let Some(threshold) = churn_threshold(files) else { + return; + }; + for entry in files.iter_mut() { + // Symbol reasons explain the file too, so its churn adds nothing. + let unexplained = entry.tier == Tier::Rest + && entry + .reasons + .iter() + .all(|reason| reason.kind == ReasonKind::NotAnalyzed) + && entry.symbols.iter().all(|symbol| symbol.reasons.is_empty()); + if is_implementation_like(entry.role) && unexplained && entry.changed_lines() >= threshold { + entry.reasons.push(Reason { + kind: ReasonKind::LargeChurn, + label: words::LARGE_CHURN.to_string(), + }); + entry.tier = Tier::GitFacts; + } + } +} + +/// Smallest churn still inside the top decile of implementation files. +fn churn_threshold(files: &[FileEntry]) -> Option { + let mut churn: Vec = files + .iter() + .filter(|entry| is_implementation_like(entry.role) && entry.changed_lines() > 0) + .map(FileEntry::changed_lines) + .collect(); + if churn.len() < 2 { + return None; + } + churn.sort_unstable_by(|left, right| right.cmp(left)); + let decile = churn.len().div_ceil(CHURN_DECILE).max(1); + churn.get(decile.saturating_sub(1)).copied() +} + +// -- symbols ----------------------------------------------------------------- + +/// Calls and hotspots bucketed by the symbol they belong to, built once per file. +#[derive(Default)] +struct SymbolFacetIndex<'a> { + calls: HashMap<&'a SymbolKey, Vec<&'a CallDiffChange>>, + hotspots: HashMap<&'a SymbolKey, Vec<&'a StructuralHotspot>>, +} + +impl<'a> SymbolFacetIndex<'a> { + fn new(file: &'a StructuredFile) -> Self { + let mut index = Self::default(); + for change in file.call_diff() { + let mut keys: Vec<&SymbolKey> = [change.old(), change.new_fact()] + .into_iter() + .flatten() + .filter_map(|fact| fact.enclosing_symbol()) + .collect(); + keys.dedup(); + for key in keys { + index.calls.entry(key).or_default().push(change); + } + } + for hotspot in file.hotspots() { + // Head-side only: base-side metrics describe code that is gone. + if hotspot.symbol().side() == ComparisonSide::Head { + index + .hotspots + .entry(hotspot.symbol().key()) + .or_default() + .push(hotspot); + } + } + index + } + + fn calls(&self, key: &SymbolKey) -> &[&'a CallDiffChange] { + self.calls.get(key).map_or(&[], Vec::as_slice) + } + + fn hotspots(&self, key: &SymbolKey) -> &[&'a StructuralHotspot] { + self.hotspots.get(key).map_or(&[], Vec::as_slice) + } +} + +fn symbol_entries(file: &StructuredFile) -> Vec { + let index = SymbolFacetIndex::new(file); + file.symbol_changes() + .iter() + .enumerate() + .filter_map(|(change_index, change)| symbol_entry(&index, change_index, change)) + .collect() +} + +fn symbol_entry( + index: &SymbolFacetIndex<'_>, + change_index: usize, + change: &SymbolChange, +) -> Option { + let fact = change.new_fact().or_else(|| change.old())?; + let key = fact.key(); + let visibility = fact.visibility(); + let public = [change.old(), change.new_fact()] + .into_iter() + .flatten() + .any(|fact| { + matches!( + fact.visibility(), + SymbolVisibility::Public | SymbolVisibility::Exported + ) + }); + let glyph = symbol_glyph(&key.kind()); + let in_test_scope = key + .qualified_path() + .iter() + .any(|scope| classification::is_test_scope(scope)) + || (key.kind() == SymbolKind::Module && classification::is_test_scope(key.name())); + let changes = index.calls(key); + let calls = call_rows(changes); + let metrics = symbol_metrics(index.hotspots(key)); + let reasons = symbol_reasons(SymbolFacts { + change, + glyph, + public, + visibility, + calls: &calls, + contexts: &call_contexts(changes), + metrics, + }); + let (old_hunks, new_hunks) = hunk_ranges(change); + + Some(SymbolEntry { + change_index, + name: key.name().to_string(), + qualified: key.qualified_name(), + glyph, + change: change.kind(), + public, + in_test_scope, + signature: change.signature_change().map(|signature| { + ( + signature.old_signature().to_string(), + signature.new_signature().to_string(), + ) + }), + body_changed: change.body_changed(), + lines_added: change.changed_new_lines(), + lines_deleted: change.changed_old_lines(), + calls, + tier: tier_of(&reasons), + reasons: chips(reasons), + navigation: change.navigation().clone(), + old_hunks, + new_hunks, + metrics, + }) +} + +struct SymbolFacts<'a> { + change: &'a SymbolChange, + glyph: KindGlyph, + public: bool, + visibility: SymbolVisibility, + calls: &'a [CallRow], + contexts: &'a [&'a ControlContext], + metrics: SymbolMetrics, +} + +fn symbol_reasons(facts: SymbolFacts<'_>) -> Vec { + let mut out = Vec::new(); + let change = facts.change; + let mut body_named = false; + + // Tier 1 — the contract other code depends on. + match change.kind() { + SymbolChangeKind::Removed if facts.public => { + out.push(scored( + ReasonKind::PublicRemoved, + words::PUBLIC_REMOVED, + Tier::Contract, + )); + } + SymbolChangeKind::Removed => out.push(annotation(ReasonKind::Removed, words::REMOVED)), + SymbolChangeKind::Added | SymbolChangeKind::Modified => {} + } + if change.signature_change().is_some() && facts.public { + let (kind, label) = if facts.visibility == SymbolVisibility::Exported { + (ReasonKind::ExportedSignature, words::EXPORTED_SIGNATURE) + } else { + (ReasonKind::PublicSignature, words::PUBLIC_SIGNATURE) + }; + out.push(scored(kind, label, Tier::Contract)); + if change.body_changed() { + // Signature *and* body outranks signature only inside the tier. + out.push(scored(ReasonKind::Body, words::BODY, Tier::Contract)); + body_named = true; + } + } + + // Tier 2 — behaviour, read through the calls an existing function makes. + // A new function's calls are all new, so they say nothing about behaviour. + if change.kind() == SymbolChangeKind::Modified + && is_function(facts.glyph) + && !facts.calls.is_empty() + { + let context = words::most_severe_context(facts.contexts.iter().copied()); + out.push(scored( + ReasonKind::Calls, + words::calls_label(facts.calls.len(), context.as_deref()), + Tier::Behaviour, + )); + } + + // Tier 3 — volume, measured separately for edits and for new code. + match change.kind() { + // The producer measures `ChangedLines` for every changed function, so + // only `body_changed` says the body itself moved; the measurement is + // magnitude, and the comparator already sorts by it. + SymbolChangeKind::Modified + if is_function(facts.glyph) && change.body_changed() && !body_named => + { + out.push(scored(ReasonKind::Body, words::BODY, Tier::Volume)); + } + SymbolChangeKind::Added => { + if let Some(lines) = facts.metrics.lines.filter(|_| is_function(facts.glyph)) { + out.push(scored( + ReasonKind::New, + words::lines_label(lines), + Tier::Volume, + )); + } + if let Some(members) = facts.metrics.members.filter(|_| is_type(facts.glyph)) { + out.push(scored( + ReasonKind::New, + words::members_label(members), + Tier::Volume, + )); + } + if facts.public { + out.push(scored( + ReasonKind::NewPublic, + words::NEW_PUBLIC, + Tier::Volume, + )); + } + } + SymbolChangeKind::Modified | SymbolChangeKind::Removed => {} + } + + // Complexity never ranks on its own; it explains code that changed anyway. + if let Some(depth) = facts.metrics.depth.filter(|depth| *depth >= COMPLEX_DEPTH) { + out.push(annotation(ReasonKind::Complex, words::nesting_label(depth))); + } + if let Some(params) = facts + .metrics + .params + .filter(|params| *params >= COMPLEX_PARAMS) + { + out.push(annotation(ReasonKind::Complex, words::params_label(params))); + } + out +} + +/// One-based inclusive line ranges on a single side. +type LineRanges = Vec<(u32, u32)>; + +fn hunk_ranges(change: &SymbolChange) -> (LineRanges, LineRanges) { + let mut old = Vec::new(); + let mut new = Vec::new(); + for hunk in change.hunks() { + if let Some(range) = hunk.old() { + old.push((range.start().get(), range.end().get())); + } + if let Some(range) = hunk.new_range() { + new.push((range.start().get(), range.end().get())); + } + } + (old, new) +} + +fn call_rows(call_diff: &[&CallDiffChange]) -> Vec { + call_diff + .iter() + .map(|change| { + let fact = change.new_fact().or_else(|| change.old()); + CallRow { + change: change.kind(), + callee: fact + .map(|fact| fact.callee_text().to_string()) + .unwrap_or_default(), + old_args: change.old().map(|fact| fact.argument_text().to_string()), + new_args: change + .new_fact() + .map(|fact| fact.argument_text().to_string()), + context: fact + .map(|fact| { + fact.control_context() + .iter() + .map(control_context_word) + .collect() + }) + .unwrap_or_default(), + old_context: change + .old() + .filter(|_| change.control_context_changed()) + .map(|old| { + old.control_context() + .iter() + .map(control_context_word) + .collect() + }), + } + }) + .collect() +} + +fn call_contexts<'a>(call_diff: &[&'a CallDiffChange]) -> Vec<&'a ControlContext> { + call_diff + .iter() + .flat_map(|change| { + [change.old(), change.new_fact()] + .into_iter() + .flatten() + .flat_map(|fact| fact.control_context()) + }) + .collect() +} + +/// Hotspots exist for every head-side symbol; only changed ones reach the model. +fn symbol_metrics(hotspots: &[&StructuralHotspot]) -> SymbolMetrics { + let mut metrics = SymbolMetrics::default(); + for hotspot in hotspots { + match hotspot.metric() { + StructuralMetric::FunctionLineCount { lines } => metrics.lines = Some(*lines), + StructuralMetric::ParameterCount { parameters } => metrics.params = Some(*parameters), + StructuralMetric::SyntacticNestingDepth { depth } => metrics.depth = Some(*depth), + StructuralMetric::TypeMemberCount { members } => metrics.members = Some(*members), + StructuralMetric::ChangedLines { .. } => {} + } + } + metrics +} + +// -- directories ------------------------------------------------------------- + +/// The path a file occupies in the tree — head side when it has one. +fn tree_path(entry: &FileEntry) -> &str { + entry + .new_path + .as_deref() + .or(entry.old_path.as_deref()) + .unwrap_or_default() +} + +struct DirBuilder { + name: String, + path: String, + children: BTreeMap, + files: Vec, +} + +impl DirBuilder { + fn new(name: String, path: String) -> Self { + Self { + name, + path, + children: BTreeMap::new(), + files: Vec::new(), + } + } +} + +fn directory_tree(files: &[FileEntry]) -> DirNode { + let mut root = DirBuilder::new(String::new(), String::new()); + for (index, entry) in files.iter().enumerate() { + let path = tree_path(entry); + let segments: Vec<&str> = path.split('/').collect(); + let mut node = &mut root; + for segment in segments.iter().take(segments.len().saturating_sub(1)) { + let child_path = if node.path.is_empty() { + (*segment).to_string() + } else { + format!("{}/{segment}", node.path) + }; + node = node + .children + .entry((*segment).to_string()) + .or_insert_with(|| DirBuilder::new((*segment).to_string(), child_path)); + } + node.files.push(index); + } + let mut root = finish_dir(root, files); + mark_test_coverage(&mut root, files, false); + root +} + +fn finish_dir(builder: DirBuilder, files: &[FileEntry]) -> DirNode { + let children: Vec = builder + .children + .into_values() + .map(|child| join_chain(finish_dir(child, files))) + .collect(); + let mut lines_added = 0u64; + let mut lines_deleted = 0u64; + let mut file_count = builder.files.len(); + let mut is_implementation_dir = false; + for index in &builder.files { + let Some(entry) = files.get(*index) else { + continue; + }; + lines_added = lines_added.saturating_add(entry.lines_added); + lines_deleted = lines_deleted.saturating_add(entry.lines_deleted); + is_implementation_dir |= counts_as_implementation_dir(entry); + } + for child in &children { + file_count = file_count.saturating_add(child.file_count); + lines_added = lines_added.saturating_add(child.lines_added); + lines_deleted = lines_deleted.saturating_add(child.lines_deleted); + } + DirNode { + name: builder.name, + path: builder.path, + children, + files: builder.files, + file_count, + lines_added, + lines_deleted, + is_implementation_dir, + no_test_changes: false, + } +} + +/// Collapse `a` → `b` → `c` into one `a/b/c` row. +fn join_chain(mut node: DirNode) -> DirNode { + while node.files.is_empty() && node.children.len() == 1 { + let child = node.children.remove(0); + node.name = format!("{}/{}", node.name, child.name); + node.path = child.path; + node.children = child.children; + node.files = child.files; + node.is_implementation_dir = child.is_implementation_dir; + } + node +} + +/// Marks the top-most untested implementation directory of each branch and +/// returns whether the subtree contains a test file — spec §5 / §6 tier 4. +/// `nested` says an ancestor is already an implementation directory, so this +/// one is a child of the row the reader will see. +fn mark_test_coverage(node: &mut DirNode, files: &[FileEntry], nested: bool) -> bool { + let mut has_test = node.files.iter().any(|index| { + files + .get(*index) + .is_some_and(|entry| entry.has_test_changes) + }); + let inside = nested || node.is_implementation_dir; + for child in &mut node.children { + has_test |= mark_test_coverage(child, files, inside); + } + node.no_test_changes = node.is_implementation_dir && !nested && !has_test; + has_test +} + +/// The directories that carry the marker; by construction none is a descendant +/// of another. +fn untested_dirs<'a>(node: &'a DirNode, out: &mut Vec<&'a DirNode>) { + if node.no_test_changes { + out.push(node); + } + for child in &node.children { + untested_dirs(child, out); + } +} + +/// Top-most implementation directories — the denominator of the Tests fact. +fn implementation_dirs<'a>(node: &'a DirNode, out: &mut Vec<&'a DirNode>) { + if node.is_implementation_dir { + out.push(node); + return; + } + for child in &node.children { + implementation_dirs(child, out); + } +} + +/// Implementation-like files per directory path, summed bottom-up in one pass. +fn implementation_counts(root: &DirNode, files: &[FileEntry]) -> HashMap { + let mut counts = HashMap::new(); + count_implementation(root, files, &mut counts); + counts +} + +fn count_implementation( + node: &DirNode, + files: &[FileEntry], + counts: &mut HashMap, +) -> usize { + let mut total = node + .files + .iter() + .filter(|index| files.get(**index).is_some_and(counts_as_implementation_dir)) + .count(); + for child in &node.children { + total = total.saturating_add(count_implementation(child, files, counts)); + } + counts.insert(node.path.clone(), total); + total +} + +// -- volume ------------------------------------------------------------------ + +fn volume_rows(files: &[FileEntry], total_changed_lines: u64) -> Vec { + // Tests written inside the file they test belong to the Tests row, not to + // the role of the file that happens to hold them — spec §8. + let inline_tests: u64 = files.iter().fold(0u64, |lines, entry| { + lines.saturating_add(entry.inline_test_lines) + }); + let inline_files = files + .iter() + .filter(|entry| entry.inline_test_lines > 0) + .count(); + let mut counts = Vec::with_capacity(ALL_ROLES.len()); + for role in ALL_ROLES { + let mut role_files = 0usize; + let mut lines = 0u64; + for entry in files.iter().filter(|entry| entry.role == role) { + role_files += 1; + lines = lines.saturating_add(entry.changed_lines()); + lines = lines.saturating_sub(entry.inline_test_lines); + } + if role == FileRole::Test { + lines = lines.saturating_add(inline_tests); + } + counts.push((role, role_files, lines)); + } + // Binary-only comparisons have no lines to share out, so files carry the bar. + let parts: Vec = if total_changed_lines > 0 { + counts.iter().map(|(_, _, lines)| *lines).collect() + } else { + counts + .iter() + .map(|(_, role_files, _)| u64::try_from(*role_files).unwrap_or(u64::MAX)) + .collect() + }; + let total = parts + .iter() + .fold(0u64, |total, part| total.saturating_add(*part)); + let permille = apportion(&parts, total); + counts + .into_iter() + .zip(permille) + .map(|((role, role_files, lines), permille)| VolumeRow { + role, + files: role_files, + inline_files: if role == FileRole::Test { + inline_files + } else { + 0 + }, + lines, + percent: f32::from(permille) / 10.0, + }) + .collect() +} + +/// Largest-remainder split into permille, so the shares always add up to 100 %. +fn apportion(parts: &[u64], total: u64) -> Vec { + if total == 0 { + return vec![0; parts.len()]; + } + let mut shares: Vec = parts + .iter() + .map(|part| part.saturating_mul(1_000) / total) + .collect(); + let assigned = shares + .iter() + .fold(0u64, |sum, share| sum.saturating_add(*share)); + let mut spare = 1_000u64.saturating_sub(assigned); + let mut order: Vec = (0..parts.len()).collect(); + order.sort_by(|left, right| { + let left_rest = parts[*left].saturating_mul(1_000) % total; + let right_rest = parts[*right].saturating_mul(1_000) % total; + right_rest.cmp(&left_rest).then(left.cmp(right)) + }); + for index in order { + if spare == 0 { + break; + } + if parts[index] == 0 { + continue; + } + shares[index] = shares[index].saturating_add(1); + spare -= 1; + } + shares + .into_iter() + .map(|share| u16::try_from(share.min(1_000)).unwrap_or(1_000)) + .collect() +} + +// -- attention --------------------------------------------------------------- + +/// One row plus the keys that place it. `sub_rank` carries the two orderings +/// spec §6 states inside a tier (signature+body first, mechanical moves last). +struct Ranked { + item: AttentionItem, + sub_rank: u8, + implementation: bool, + sort_path: String, + source: usize, +} + +fn attention_items( + files: &[FileEntry], + root: &DirNode, + counts: &HashMap, +) -> Vec { + let mut ranked: Vec = Vec::new(); + let mut source = 0usize; + for entry in files { + if entry.symbols.is_empty() || keeps_its_own_row(entry) { + ranked.push(file_row(entry, source)); + source += 1; + } + for symbol in &entry.symbols { + ranked.push(symbol_row(entry, symbol, source)); + source += 1; + } + } + let mut untested = Vec::new(); + untested_dirs(root, &mut untested); + for node in untested { + ranked.push(directory_row(node, counts, source)); + source += 1; + } + + ranked.sort_by(|left, right| { + left.item + .tier + .cmp(&right.item.tier) + .then_with(|| left.sub_rank.cmp(&right.sub_rank)) + .then_with(|| right.implementation.cmp(&left.implementation)) + .then_with(|| right.item.reasons.len().cmp(&left.item.reasons.len())) + .then_with(|| churn(&right.item).cmp(&churn(&left.item))) + .then_with(|| left.sort_path.cmp(&right.sort_path)) + .then_with(|| left.source.cmp(&right.source)) + }); + ranked.into_iter().map(|ranked| ranked.item).collect() +} + +/// Reasons about the file as a whole. A deleted implementation file or a +/// residual-heavy rename keeps its own row even when its symbols also rank. +fn keeps_its_own_row(entry: &FileEntry) -> bool { + entry.reasons.iter().any(|reason| { + matches!( + reason.kind, + ReasonKind::DeletedImpl + | ReasonKind::Moved + | ReasonKind::CiConfig + | ReasonKind::Lockfile + | ReasonKind::Submodule + | ReasonKind::Binary + ) + }) +} + +fn churn(item: &AttentionItem) -> u64 { + item.lines_added.saturating_add(item.lines_deleted) +} + +fn symbol_row(entry: &FileEntry, symbol: &SymbolEntry, source: usize) -> Ranked { + Ranked { + item: AttentionItem { + target: AttentionTarget::Symbol { + file: entry.key.clone(), + change_index: symbol.change_index, + }, + tier: symbol.tier, + reasons: symbol.reasons.clone(), + name: symbol.qualified.clone(), + path: entry.display_path.clone(), + glyph: symbol.glyph, + lines_added: u64::from(symbol.lines_added), + lines_deleted: u64::from(symbol.lines_deleted), + dimmed: false, + is_test: entry.is_test || symbol.in_test_scope, + }, + sub_rank: symbol_sub_rank(symbol), + implementation: is_implementation_like(entry.role), + sort_path: entry.display_path.clone(), + source, + } +} + +fn symbol_sub_rank(symbol: &SymbolEntry) -> u8 { + match symbol.tier { + // Signature *and* body outranks signature only — read off the change, + // not off the chips. + Tier::Contract => u8::from(symbol.signature.is_some() && !symbol.body_changed), + // Calls that vanished or changed shape come before calls merely added. + Tier::Behaviour => u8::from(!symbol.calls.iter().any(|call| { + matches!( + call.change, + CallChangeKind::Removed | CallChangeKind::Modified + ) + })), + Tier::Volume | Tier::GitFacts | Tier::Rest => 0, + } +} + +fn file_row(entry: &FileEntry, source: usize) -> Ranked { + // Inside the rest tier the leftover files follow the leftover symbols, and a + // move that only moved comes last of all — spec §6 tier 5. + let sub_rank = match (entry.tier, is_likely_mechanical(entry)) { + (Tier::Rest, true) => 2, + (Tier::Rest, false) => 1, + _ => 0, + }; + Ranked { + item: AttentionItem { + target: AttentionTarget::File(entry.key.clone()), + tier: entry.tier, + reasons: entry.reasons.clone(), + name: basename(&entry.display_path).to_string(), + path: entry.display_path.clone(), + glyph: KindGlyph::File, + lines_added: entry.lines_added, + lines_deleted: entry.lines_deleted, + dimmed: entry + .reasons + .iter() + .any(|reason| reason.kind == ReasonKind::NotAnalyzed), + is_test: entry.is_test, + }, + sub_rank, + implementation: is_implementation_like(entry.role), + sort_path: entry.display_path.clone(), + source, + } +} + +fn directory_row(node: &DirNode, counts: &HashMap, source: usize) -> Ranked { + let name = if node.path.is_empty() { + "repository root".to_string() + } else { + node.path.clone() + }; + Ranked { + item: AttentionItem { + target: AttentionTarget::Directory(node.path.clone()), + tier: Tier::GitFacts, + reasons: vec![Reason { + kind: ReasonKind::NoTestChanges, + label: words::NO_TEST_CHANGES.to_string(), + }], + name, + path: words::implementation_files(counts.get(&node.path).copied().unwrap_or_default()), + glyph: KindGlyph::Directory, + lines_added: node.lines_added, + lines_deleted: node.lines_deleted, + dimmed: false, + is_test: false, + }, + sub_rank: 0, + implementation: true, + sort_path: node.path.clone(), + source, + } +} + +fn basename(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +// -- facts ------------------------------------------------------------------- + +fn public_api_fact( + files: &[FileEntry], + structure: Option<&ReviewStructure>, + coverage: &CoverageSummary, +) -> Option { + let structure = structure?; + let mut removed = 0u64; + let mut signatures = 0u64; + let mut added = 0u64; + for symbol in files + .iter() + .flat_map(|entry| entry.symbols.iter()) + .filter(|symbol| symbol.public) + { + match symbol.change { + SymbolChangeKind::Removed => removed += 1, + SymbolChangeKind::Added => added += 1, + SymbolChangeKind::Modified => {} + } + if symbol.signature.is_some() { + signatures += 1; + } + } + let no_supported_language = structure.language_coverage().is_empty(); + if removed == 0 && signatures == 0 && added == 0 && !no_supported_language { + return None; + } + Some(PublicApiFact { + removed, + signatures, + added, + lower_bound: coverage.partial, + languages: coverage.languages.clone(), + no_supported_language, + }) +} + +fn tests_fact(root: &DirNode, counts: &HashMap) -> Option { + let mut dirs = Vec::new(); + implementation_dirs(root, &mut dirs); + if dirs.is_empty() { + return None; + } + let mut without: Vec = dirs + .iter() + .filter(|node| node.no_test_changes) + .map(|node| DirRef { + path: node.path.clone(), + // The same number the attention row shows. + files: counts.get(&node.path).copied().unwrap_or_default(), + lines: node.lines_added.saturating_add(node.lines_deleted), + }) + .collect(); + without.sort_by(|left, right| { + right + .lines + .cmp(&left.lines) + .then_with(|| left.path.cmp(&right.path)) + }); + Some(TestsFact { + impl_dirs: dirs.len(), + with_tests: dirs.len().saturating_sub(without.len()), + without, + }) +} + +fn moves_fact(files: &[FileEntry]) -> Option { + let renames: Vec<&FileEntry> = files + .iter() + .filter(|entry| entry.status == ReviewFileStatus::Renamed) + .collect(); + if renames.is_empty() { + return None; + } + let likely_mechanical = renames + .iter() + .filter(|entry| is_likely_mechanical(entry)) + .count(); + let similarities: Vec = renames + .iter() + .filter_map(|entry| entry.similarity.map(u64::from)) + .collect(); + let average = if similarities.is_empty() { + 0 + } else { + let total = similarities + .iter() + .fold(0u64, |sum, value| sum.saturating_add(*value)); + let count = u64::try_from(similarities.len()).unwrap_or(1); + u8::try_from(total / count.max(1)).unwrap_or(u8::MAX) + }; + Some(MovesFact { + total: renames.len(), + likely_mechanical, + with_edits: renames.len().saturating_sub(likely_mechanical), + avg_similarity: average, + // Residual lines across every rename; the split above says how they land. + residual_lines: renames.iter().fold(0u64, |total, entry| { + total.saturating_add(entry.changed_lines()) + }), + }) +} + +fn commits_fact(commits: &[CommitRow]) -> Option { + let first = commits.first()?; + let last = commits.last()?; + let mut seen: HashSet<&str> = HashSet::with_capacity(commits.len()); + let mut authors: Vec = Vec::new(); + for commit in commits { + if seen.insert(commit.author.as_str()) { + authors.push(commit.author.clone()); + } + } + let oldest = commits.iter().map(|commit| commit.timestamp).min()?; + let newest = commits.iter().map(|commit| commit.timestamp).max()?; + Some(CommitsFact { + count: commits.len(), + merges: commits.iter().filter(|commit| commit.is_merge).count(), + authors, + span_secs: newest.saturating_sub(oldest), + first_sha: first.short_sha.clone(), + last_sha: last.short_sha.clone(), + }) +} + +fn also_fact(files: &[FileEntry]) -> Option { + let count = |kind: ReasonKind| { + files + .iter() + .filter(|entry| entry.reasons.iter().any(|reason| reason.kind == kind)) + .count() + }; + let fact = AlsoFact { + lockfiles: count(ReasonKind::Lockfile), + submodules: count(ReasonKind::Submodule), + binaries: files.iter().filter(|entry| entry.binary).count(), + deleted_impl: count(ReasonKind::DeletedImpl), + }; + let empty = + fact.lockfiles == 0 && fact.submodules == 0 && fact.binaries == 0 && fact.deleted_impl == 0; + (!empty).then_some(fact) +} + +fn commit_rows(inventory: &ReviewInventory) -> Vec { + inventory + .commits + .iter() + .map(|commit| CommitRow { + sha: commit.oid.as_str().to_string(), + short_sha: super::labels::short_sha(commit.oid.as_str()), + subject: commit.subject.clone(), + author: commit.author_name.clone(), + timestamp: commit.timestamp, + is_merge: commit.parent_oids.len() > 1, + }) + .collect() +} + +// -- status, omissions, coverage --------------------------------------------- + +fn analysis_status( + inventory: &ReviewInventory, + structure: Option<&ReviewStructure>, + state: &StructureLoad, +) -> AnalysisStatus { + match state { + StructureLoad::NotStarted | StructureLoad::Loading => AnalysisStatus::AnalyzingStructure, + StructureLoad::Failed(error) => AnalysisStatus::Unavailable { + message: error.clone(), + }, + StructureLoad::Ready => { + let coverage = structure.map_or(&inventory.coverage, ReviewStructure::coverage); + // A capped run is limited even when the rest parsed cleanly. + if coverage.pending_items() > 0 || coverage.truncation().is_some() { + AnalysisStatus::Limited { + analyzed: coverage.analyzed_items(), + total: coverage.total_items(), + } + } else if coverage.failed_items() > 0 { + AnalysisStatus::ReadyWithFailures { + failed: coverage.failed_items(), + } + } else { + AnalysisStatus::Ready { + files: coverage.analyzed_items(), + languages: structure_languages(structure), + } + } + } + } +} + +fn structure_languages(structure: Option<&ReviewStructure>) -> Vec { + structure + .map(|structure| { + structure + .language_coverage() + .iter() + .map(|coverage| language_label(&coverage.language()).to_string()) + .collect() + }) + .unwrap_or_default() +} + +/// One merged row per omission reason, plus the parse failures — spec §10. +fn omission_rows(structure: Option<&ReviewStructure>, files: &[FileEntry]) -> Vec { + let Some(structure) = structure else { + return Vec::new(); + }; + let mut merged: Vec<(OmittedFileReason, MergedOmission)> = Vec::new(); + for group in structure.omissions() { + let slot = match merged + .iter_mut() + .find(|(reason, _)| *reason == group.reason()) + { + Some((_, slot)) => slot, + None => { + merged.push((group.reason(), MergedOmission::default())); + match merged.last_mut() { + Some((_, slot)) => slot, + None => continue, + } + } + }; + slot.absorb(group); + } + let mut rows: Vec = merged + .into_iter() + .map(|(reason, merged)| OmissionRow { + sentence: words::omission_sentence(reason, merged.limit), + count: merged.count, + detail: merged.detail(reason, files), + warn: words::omission_warns(reason), + }) + .collect(); + if let Some(row) = failure_row(structure, files) { + rows.push(row); + } + rows +} + +#[derive(Default)] +struct MergedOmission { + count: u64, + limit: Option, + detail: Option, + languages: Vec<(String, u64)>, +} + +impl MergedOmission { + fn absorb(&mut self, group: &OmittedFileGroup) { + self.count = self.count.saturating_add(group.count()); + if let Some(truncation) = group.truncation() { + self.limit = self.limit.or(truncation.limit); + self.detail = self.detail.take().or_else(|| truncation.detail.clone()); + } + if let Some(language) = group.language() { + let name = language_label(&language).to_string(); + match self + .languages + .iter_mut() + .find(|(existing, _)| *existing == name) + { + Some((_, count)) => *count = count.saturating_add(group.count()), + None => self.languages.push((name, group.count())), + } + } + } + + fn detail(&self, reason: OmittedFileReason, files: &[FileEntry]) -> String { + if !self.languages.is_empty() { + return words::extension_summary(&self.languages); + } + if reason == OmittedFileReason::UnsupportedLanguage { + return words::extension_summary(&unsupported_extensions(files)); + } + self.detail.clone().unwrap_or_default() + } +} + +/// What the unsupported group actually held, read back from the file list. +fn unsupported_extensions(files: &[FileEntry]) -> Vec<(String, u64)> { + let mut counts: BTreeMap = BTreeMap::new(); + for entry in files + .iter() + .filter(|entry| entry.analysis == FileAnalysis::Unsupported) + { + let Some(extension) = extension_of(tree_path(entry)) else { + continue; + }; + *counts.entry(format!(".{extension}")).or_default() += 1; + } + let mut ordered: Vec<(String, u64)> = counts.into_iter().collect(); + ordered.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0))); + ordered.truncate(OMISSION_EXTENSIONS); + ordered +} + +fn extension_of(path: &str) -> Option { + Some(path.rsplit('/').next()?.rsplit_once('.')?.1.to_lowercase()) +} + +/// Only files that actually failed get a row; a run-wide error belongs to the +/// status pill, not to a "0 files" line. +fn failure_row(structure: &ReviewStructure, files: &[FileEntry]) -> Option { + let failed = files + .iter() + .filter(|entry| entry.analysis == FileAnalysis::Failed) + .count(); + if failed == 0 { + return None; + } + let error = structure + .files() + .iter() + .flat_map(StructuredFile::errors) + .chain(structure.errors()) + .next(); + Some(OmissionRow { + sentence: words::FAILED_TO_PARSE.to_string(), + count: u64::try_from(failed).unwrap_or(0), + detail: error + .map(|error| words::failure_detail(error.stage(), error.message())) + .unwrap_or_default(), + warn: true, + }) +} + +fn coverage_summary( + inventory: &ReviewInventory, + structure: Option<&ReviewStructure>, + files: &[FileEntry], +) -> CoverageSummary { + let coverage: &ReviewCoverage = + structure.map_or(&inventory.coverage, ReviewStructure::coverage); + let implementation = files + .iter() + .filter(|entry| entry.role == FileRole::Implementation); + let impl_total = implementation.clone().count(); + let impl_analyzed = implementation + .filter(|entry| entry.analysis.is_analyzed()) + .count(); + CoverageSummary { + analyzed_files: coverage.analyzed_items(), + total_files: coverage.total_items(), + impl_analyzed, + impl_total, + path_order_bias: structure.is_some_and(|structure| { + structure + .omissions() + .iter() + .any(|group| group.reason() == OmittedFileReason::FileLimit) + }), + languages: structure_languages(structure), + partial: !coverage.is_complete(), + failed: coverage.failed_items(), + base_oid: snapshot_oid(&inventory.comparison, true), + head_oid: snapshot_oid(&inventory.comparison, false), + merge_base_oid: inventory + .comparison + .merge_base_oid() + .map(|oid| oid.as_str().to_string()), + } +} + +fn snapshot_oid(comparison: &ResolvedComparison, base: bool) -> String { + let snapshot = if base { + comparison.base() + } else { + comparison.head() + }; + snapshot + .oid() + .map(|oid| oid.as_str().to_string()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::super::fixtures; + use super::super::model::{ + AnalysisStatus, AttentionTarget, DirNode, KindGlyph, ReasonKind, ReviewModel, Tier, + }; + use super::{ModelInputs, StructureLoad, apportion, build_review_model}; + use okena_core::review::{FileRole, ReviewInventory}; + use okena_git::DiffMode; + use okena_review::ReviewStructure; + use serde_json::{Value, json}; + + fn branch() -> DiffMode { + DiffMode::BranchCompare { + base: "main".into(), + head: "feature".into(), + } + } + + fn model_of( + inventory: &ReviewInventory, + structure: Option<&ReviewStructure>, + state: StructureLoad, + ) -> ReviewModel { + let mode = branch(); + build_review_model(ModelInputs { + inventory: Some(inventory), + inventory_error: None, + structure, + structure_state: state, + diff_mode: &mode, + }) + } + + /// The model over the file that keeps its tests inside it. + fn inline_tests_model() -> ReviewModel { + let inventory = fixtures::inventory_inline_tests(); + let structure = fixtures::structure_inline_tests(); + model_of(&inventory, Some(&structure), StructureLoad::Ready) + } + + #[test] + fn tests_written_inside_the_file_they_test_count_as_tests() { + let model = inline_tests_model(); + let entry = model.files.first().expect("one file changed"); + assert!(!entry.is_test, "the file itself is implementation"); + assert!( + entry.has_test_changes, + "its `mod tests` changed, so tests changed here" + ); + // The module's own 30 lines cover the test function inside it; counting + // both would claim 42 test lines out of 54 changed. + assert_eq!(entry.inline_test_lines, 30); + } + + #[test] + fn inline_test_lines_move_from_implementation_to_tests() { + let model = inline_tests_model(); + let row = |role: FileRole| { + *model + .volume + .iter() + .find(|row| row.role == role) + .expect("every role has a row") + }; + let implementation = row(FileRole::Implementation); + let tests = row(FileRole::Test); + assert_eq!(implementation.files, 1); + assert_eq!(implementation.lines, 24, "54 changed, 30 of them tests"); + assert_eq!(tests.files, 0, "no file is a test file"); + assert_eq!(tests.lines, 30); + assert_eq!( + tests.inline_files, 1, + "the row is one implementation file's inline tests" + ); + assert!( + (tests.percent - 55.6).abs() < 0.2, + "shares follow the lines: {}", + tests.percent + ); + } + + #[test] + fn an_implementation_directory_with_inline_tests_is_not_untested() { + let model = inline_tests_model(); + assert!( + model.facts.tests.is_none() + || model.root.children.iter().all(|dir| !dir.no_test_changes), + "src/ changed its own tests" + ); + assert!( + !model.attention.iter().any(|item| item + .reasons + .iter() + .any(|reason| reason.kind == ReasonKind::NoTestChanges)), + "nothing may claim the tests are missing" + ); + } + + #[test] + fn a_test_symbol_follows_the_tests_filter() { + let model = inline_tests_model(); + let item = model + .attention + .iter() + .find(|item| item.name.contains("bumps_once")) + .expect("the test function is ranked"); + assert!(item.is_test, "a symbol in `mod tests` is a test"); + let bump = model + .attention + .iter() + .find(|item| item.name == "bump") + .expect("the implementation function is ranked"); + assert!(!bump.is_test); + } + + /// Every attention row as `name | tier | reason kinds`. + fn attention_rows(model: &ReviewModel) -> Vec { + model + .attention + .iter() + .map(|item| { + let kinds: Vec = item + .reasons + .iter() + .map(|reason| format!("{:?}", reason.kind)) + .collect(); + format!("{} | {:?} | {}", item.name, item.tier, kinds.join("+")) + }) + .collect() + } + + fn find(model: &ReviewModel, name: &str) -> usize { + model + .attention + .iter() + .position(|item| item.name == name) + .unwrap_or_else(|| panic!("{name} is missing from the attention list")) + } + + #[test] + fn the_attention_list_is_the_whole_ranking_in_order() { + let model = fixtures::model(); + assert_eq!( + attention_rows(&model), + [ + "Engine::run | Contract | PublicSignature+Body+Calls", + "legacy.rs | Contract | DeletedImpl", + "Engine::legacy_run | Contract | PublicRemoved", + "Engine::configure | Contract | PublicSignature", + "Engine::dispatch | Behaviour | Calls+Body", + "normalize | Behaviour | Calls+Body", + "orchestrate | Volume | New+NewPublic+Complex+Complex", + "steps | Volume | Body", + "handler.rs | GitFacts | NotAnalyzed+LargeChurn", + "motion_new.rs | GitFacts | Moved+Moved", + "lib.rs | GitFacts | New+NotAnalyzed", + "logo.png | GitFacts | Binary+NotAnalyzed", + "src | GitFacts | NoTestChanges", + "pnpm-lock.yaml | GitFacts | Lockfile+NotAnalyzed", + "Cargo.toml | GitFacts | CiConfig+NotAnalyzed", + "render | Rest | Removed", + "app.js | Rest | NotAnalyzed", + "handler_test.rs | Rest | NotAnalyzed", + "README.md | Rest | NotAnalyzed", + "lib.rs | Rest | NotAnalyzed", + "new.rs | Rest | Moved+NotAnalyzed", + ] + ); + } + + #[test] + fn signature_and_body_outranks_signature_only_inside_the_contract_tier() { + let model = fixtures::model(); + assert!(find(&model, "Engine::run") < find(&model, "Engine::configure")); + let contract: Vec<&str> = model + .attention + .iter() + .filter(|item| item.tier == Tier::Contract) + .map(|item| item.name.as_str()) + .collect(); + assert_eq!( + contract.last(), + Some(&"Engine::configure"), + "the signature-only change closes the tier" + ); + } + + #[test] + fn calls_that_vanished_outrank_calls_merely_added() { + let model = fixtures::model(); + // `normalize` carries more reasons, so only the sub-rank can order these. + assert!(find(&model, "Engine::dispatch") < find(&model, "normalize")); + } + + #[test] + fn every_symbol_and_file_appears_exactly_once() { + let model = fixtures::model(); + let mut targets: Vec<&AttentionTarget> = + model.attention.iter().map(|item| &item.target).collect(); + let before = targets.len(); + targets.sort_by_key(|target| format!("{target:?}")); + targets.dedup_by_key(|target| format!("{target:?}")); + assert_eq!(targets.len(), before, "no target is listed twice"); + + for entry in &model.files { + assert!( + model + .attention + .iter() + .any(|item| item.target.file() == Some(&entry.key)), + "{} is missing from the list", + entry.display_path + ); + } + let symbols: usize = model.files.iter().map(|entry| entry.symbols.len()).sum(); + assert_eq!( + model + .attention + .iter() + .filter(|item| matches!(item.target, AttentionTarget::Symbol { .. })) + .count(), + symbols, + "one row per changed symbol" + ); + } + + #[test] + fn structural_file_reasons_keep_their_row_even_when_the_file_has_symbols() { + let model = fixtures::model(); + for (path, kind, tier) in [ + ("src/legacy.rs", ReasonKind::DeletedImpl, Tier::Contract), + ( + "src/motion_old.rs \u{2192} src/motion_new.rs", + ReasonKind::Moved, + Tier::GitFacts, + ), + ] { + let entry = model + .files + .iter() + .find(|entry| entry.display_path == path) + .unwrap_or_else(|| panic!("{path} is in the fixture")); + assert!(!entry.symbols.is_empty(), "{path} was analysed"); + let row = model + .attention + .iter() + .find(|item| item.target == AttentionTarget::File(entry.key.clone())) + .unwrap_or_else(|| panic!("{path} keeps its own row")); + assert_eq!(row.tier, tier); + assert!(row.reasons.iter().any(|reason| reason.kind == kind)); + assert!( + model.attention.iter().any(|item| matches!( + &item.target, + AttentionTarget::Symbol { file, .. } if file == &entry.key + )), + "and its symbols are still listed" + ); + } + // A file that only carries annotations still folds into its symbols. + let engine = model + .files + .iter() + .find(|entry| entry.display_path == "src/engine.rs") + .expect("the fixture analyses src/engine.rs"); + assert!(engine.reasons.is_empty()); + assert!( + !model + .attention + .iter() + .any(|item| item.target == AttentionTarget::File(engine.key.clone())) + ); + } + + #[test] + fn a_new_function_full_of_new_calls_still_ranks_by_volume() { + let model = fixtures::model(); + let engine = model + .files + .iter() + .find(|entry| entry.display_path == "src/engine.rs") + .expect("the fixture analyses src/engine.rs"); + let orchestrate = engine + .symbols + .iter() + .find(|symbol| symbol.name == "orchestrate") + .expect("the fixture adds a public function"); + assert_eq!(orchestrate.calls.len(), 1, "the new function does call out"); + assert_eq!(orchestrate.tier, Tier::Volume); + assert!( + !orchestrate + .reasons + .iter() + .any(|reason| reason.kind == ReasonKind::Calls), + "new calls in new code are not a behaviour signal" + ); + } + + #[test] + fn a_signature_only_change_never_claims_a_changed_body() { + let model = fixtures::model(); + let engine = model + .files + .iter() + .find(|entry| entry.display_path == "src/engine.rs") + .expect("the fixture analyses src/engine.rs"); + // The producer measures ChangedLines for every changed function, so the + // chip must come from `body_changed`, not from the measurement. + let configure = engine + .symbols + .iter() + .find(|symbol| symbol.name == "configure") + .expect("the fixture changes a signature only"); + assert!(!configure.body_changed); + assert!( + !configure + .reasons + .iter() + .any(|reason| reason.kind == ReasonKind::Body) + ); + let run = engine + .symbols + .iter() + .find(|symbol| symbol.name == "run") + .expect("the fixture changes a signature and a body"); + assert!(run.body_changed); + assert!( + run.reasons + .iter() + .any(|reason| reason.kind == ReasonKind::Body) + ); + } + + #[test] + fn complexity_and_not_analyzed_never_lift_an_item_out_of_the_rest_tier() { + let model = fixtures::model(); + for item in &model.attention { + let ranking = item + .reasons + .iter() + .filter(|reason| { + !matches!(reason.kind, ReasonKind::Complex | ReasonKind::NotAnalyzed) + }) + .count(); + assert!( + item.tier == Tier::Rest || ranking > 0, + "{} was ranked by an annotation alone", + item.name + ); + } + let orchestrate = &model.attention[find(&model, "orchestrate")]; + assert_eq!(orchestrate.tier, Tier::Volume); + assert!( + orchestrate + .reasons + .iter() + .any(|reason| reason.label == "nesting 6") + ); + assert!( + orchestrate + .reasons + .iter() + .any(|reason| reason.label == "8 params") + ); + } + + #[test] + fn hotspots_for_symbols_that_did_not_change_are_ignored() { + let model = fixtures::model(); + assert!( + !model + .attention + .iter() + .any(|item| item.name.contains("helper")), + "an untouched hotspot must not become a row" + ); + let engine = model + .files + .iter() + .find(|entry| entry.display_path == "src/engine.rs") + .expect("the fixture analyses src/engine.rs"); + assert_eq!(engine.symbols.len(), 6); + assert!(!engine.symbols.iter().any(|symbol| symbol.name == "helper")); + } + + #[test] + fn the_untested_directory_marker_lands_on_the_top_most_directory_only() { + let model = fixtures::model(); + let dirs: Vec<&str> = model + .attention + .iter() + .filter(|item| item.glyph == KindGlyph::Directory) + .map(|item| item.name.as_str()) + .collect(); + assert_eq!( + dirs, + ["src"], + "worker changed tests next to its code, and a lone binary is not a \ + directory anyone writes tests for" + ); + let row = &model.attention[find(&model, "src")]; + assert_eq!(row.path, "6 implementation files"); + assert_eq!(row.reasons[0].label, "no tests changed next to it"); + assert!( + !model + .root + .children + .iter() + .any(|child| child.path == "assets" && child.is_implementation_dir), + "a directory holding only a binary is not an implementation directory" + ); + } + + #[test] + fn the_untested_marker_never_repeats_on_a_nested_directory() { + let bare = wrap(vec![ + file_json("packages/core/src/a.rs", 10, 0), + file_json("packages/core/src/nested/b.rs", 5, 0), + ]); + let model = model_of(&bare, None, StructureLoad::Loading); + let dirs: Vec<&str> = model + .attention + .iter() + .filter(|item| item.glyph == KindGlyph::Directory) + .map(|item| item.name.as_str()) + .collect(); + assert_eq!(dirs, ["packages/core/src"], "only the top-most row"); + let nested = dir_at(&model.root, "packages/core/src/nested") + .expect("the nested directory is in the tree"); + assert!(nested.is_implementation_dir); + assert!(!nested.no_test_changes, "the parent already says it"); + + let tests = model.facts.tests.clone().expect("one implementation tree"); + assert_eq!(tests.impl_dirs, 1, "top-most directories only"); + assert_eq!(tests.with_tests, 0); + assert_eq!(tests.without.len(), 1); + assert_eq!(tests.without[0].path, "packages/core/src"); + assert_eq!(tests.without[0].files, 2, "the same count as the row"); + + // A test next to the parent covers the nested directory as well. + let covered = wrap(vec![ + file_json("packages/core/src/a.rs", 10, 0), + file_json("packages/core/src/nested/b.rs", 5, 0), + test_json("packages/core/src/a_test.rs", 4), + ]); + let model = model_of(&covered, None, StructureLoad::Loading); + assert!( + !model + .attention + .iter() + .any(|item| item.glyph == KindGlyph::Directory) + ); + let tests = model.facts.tests.expect("one implementation tree"); + assert_eq!((tests.impl_dirs, tests.with_tests), (1, 1)); + assert!(tests.without.is_empty()); + } + + #[test] + fn renames_split_at_twenty_residual_lines() { + let inventory = renames_inventory(); + let model = model_of(&inventory, None, StructureLoad::Loading); + let mechanical = &model.files[0]; + let edited = &model.files[1]; + assert_eq!(mechanical.changed_lines(), 20); + assert_eq!(mechanical.tier, Tier::Rest); + assert_eq!( + mechanical + .reasons + .iter() + .map(|reason| reason.label.as_str()) + .collect::>(), + ["moved 99 %"] + ); + assert_eq!(edited.changed_lines(), 21); + assert_eq!(edited.tier, Tier::GitFacts); + assert_eq!( + edited + .reasons + .iter() + .map(|reason| reason.label.as_str()) + .collect::>(), + ["moved 91 %", "21 residual lines"] + ); + + let moves = model.facts.moves.expect("two renames make a moves fact"); + assert_eq!(moves.total, 2); + assert_eq!(moves.likely_mechanical, 1); + assert_eq!(moves.with_edits, 1); + assert_eq!(moves.avg_similarity, 95); + assert_eq!(moves.residual_lines, 41); + } + + #[test] + fn public_api_counts_are_lower_bounds_exactly_when_coverage_is_partial() { + let model = fixtures::model(); + let fact = model + .facts + .public_api + .clone() + .expect("the fixture changes public symbols"); + assert_eq!((fact.removed, fact.signatures, fact.added), (1, 2, 1)); + assert!(model.coverage.partial); + assert!(fact.lower_bound); + assert!(!fact.no_supported_language); + assert_eq!(fact.languages, ["Rust"]); + + let unsupported = model_of( + &fixtures::inventory_all_unsupported(), + Some(&fixtures::structure_empty()), + StructureLoad::Ready, + ); + let fact = unsupported + .facts + .public_api + .expect("an all-unsupported comparison still says so"); + assert!(!unsupported.coverage.partial); + assert!(!fact.lower_bound); + assert!(fact.no_supported_language); + assert_eq!( + unsupported + .attention + .iter() + .filter(|item| item.glyph == KindGlyph::File) + .count(), + 3, + "nothing is empty; every file is ranked from git facts" + ); + } + + #[test] + fn the_status_matrix_follows_coverage_and_the_load_state() { + let inventory = fixtures::inventory(); + assert_eq!( + build_review_model(ModelInputs { + inventory: None, + inventory_error: None, + structure: None, + structure_state: StructureLoad::NotStarted, + diff_mode: &branch(), + }) + .status, + AnalysisStatus::LoadingInventory + ); + assert_eq!( + build_review_model(ModelInputs { + inventory: None, + inventory_error: Some("git exited 128"), + structure: None, + structure_state: StructureLoad::NotStarted, + diff_mode: &branch(), + }) + .status, + AnalysisStatus::Unavailable { + message: "git exited 128".into() + } + ); + assert_eq!( + model_of(&inventory, None, StructureLoad::Loading).status, + AnalysisStatus::AnalyzingStructure + ); + assert_eq!( + model_of( + &inventory, + None, + StructureLoad::Failed("worker exited".into()) + ) + .status, + AnalysisStatus::Unavailable { + message: "worker exited".into() + } + ); + assert_eq!( + model_of( + &inventory, + Some(&fixtures::structure()), + StructureLoad::Ready + ) + .status, + AnalysisStatus::Limited { + analyzed: 3, + total: 7 + }, + "a pending group outranks the parse failure next to it" + ); + assert_eq!( + model_of( + &inventory, + Some(&fixtures::structure_with_failure()), + StructureLoad::Ready + ) + .status, + AnalysisStatus::ReadyWithFailures { failed: 1 } + ); + assert_eq!( + model_of( + &inventory, + Some(&fixtures::structure_empty()), + StructureLoad::Ready + ) + .status, + AnalysisStatus::Ready { + files: 0, + languages: Vec::new() + } + ); + } + + #[test] + fn omission_rows_are_sentences_and_never_debug_names() { + let model = fixtures::model(); + let sentences: Vec<&str> = model + .omissions + .iter() + .map(|row| row.sentence.as_str()) + .collect(); + assert_eq!( + sentences, + [ + "Not analyzed \u{2014} file limit (200), taken in path order", + "Failed to parse" + ] + ); + assert_eq!(model.omissions[0].count, 2); + assert!(model.omissions[0].warn); + assert_eq!(model.omissions[1].count, 1); + assert_eq!(model.omissions[1].detail, "parsing: unexpected token"); + assert!( + model.omissions.iter().all(|row| row.count > 0), + "no row ever counts nothing" + ); + assert!( + model_of( + &fixtures::inventory(), + Some(&fixtures::structure_empty()), + StructureLoad::Ready + ) + .omissions + .is_empty(), + "a clean run has nothing to report" + ); + + for row in &model.omissions { + for name in [ + "SourceByteLimit", + "ByteLimit", + "ItemLimit", + "FileLimit", + "UnsupportedLanguage", + "Parsing", + ] { + assert!( + !row.sentence.contains(name), + "{} leaks {name}", + row.sentence + ); + assert!(!row.detail.contains(name), "{} leaks {name}", row.detail); + } + } + } + + #[test] + fn volume_shares_add_up_to_one_hundred_percent() { + for model in [ + fixtures::model(), + model_of(&fixtures::inventory_small(), None, StructureLoad::Loading), + model_of( + &fixtures::inventory_binary_only(), + None, + StructureLoad::Loading, + ), + ] { + let total: f32 = model.volume.iter().map(|row| row.percent).sum(); + assert!( + (total - 100.0).abs() < 0.1, + "shares add up to {total}, not 100" + ); + assert_eq!(model.volume.len(), 11, "every role stays in the model"); + } + // Binary-only comparisons have no lines, so the bar is built from files. + let binary = model_of( + &fixtures::inventory_binary_only(), + None, + StructureLoad::Loading, + ); + assert_eq!(binary.total_changed_lines, 0); + let unclassified = binary + .volume + .iter() + .find(|row| row.files == 2) + .expect("both binaries are unclassified"); + assert!((unclassified.percent - 100.0).abs() < 0.1); + } + + #[test] + fn apportioned_shares_always_spend_the_whole_thousand() { + assert_eq!(apportion(&[1, 1, 1], 3), [334, 333, 333]); + assert_eq!(apportion(&[0, 0], 0), [0, 0]); + assert_eq!(apportion(&[7], 7), [1_000]); + let shares = apportion(&[585, 12, 6, 3, 160, 0], 766); + assert_eq!( + shares.iter().map(|share| u64::from(*share)).sum::(), + 1_000 + ); + assert_eq!(shares[5], 0, "a role with nothing changed stays at zero"); + } + + #[test] + fn small_comparisons_are_bounded_by_files_or_by_lines() { + assert!(model_of(&fixtures::inventory_small(), None, StructureLoad::Loading).small_change); + assert!(!fixtures::model().small_change); + // Both bounds are inclusive, and both must fail before a comparison is big. + let ten = model_of(&sized_inventory(10, 100), None, StructureLoad::Loading); + assert_eq!((ten.files.len(), ten.total_changed_lines), (10, 1_000)); + assert!(ten.small_change, "exactly ten files is still small"); + let eleven = model_of(&sized_inventory(11, 100), None, StructureLoad::Loading); + assert!(!eleven.small_change, "one file more is not"); + + let five_hundred = model_of(&sized_inventory(20, 25), None, StructureLoad::Loading); + assert_eq!(five_hundred.total_changed_lines, 500); + assert!( + five_hundred.small_change, + "exactly 500 lines is still small" + ); + let big = model_of(&sized_inventory(20, 26), None, StructureLoad::Loading); + assert_eq!(big.total_changed_lines, 520); + assert!(!big.small_change); + } + + #[test] + fn files_structure_never_reached_are_dimmed_and_say_why() { + let model = fixtures::model(); + let javascript = &model.attention[find(&model, "app.js")]; + assert!(javascript.dimmed); + assert_eq!( + javascript + .reasons + .iter() + .map(|reason| reason.label.as_str()) + .collect::>(), + ["not analyzed \u{00B7} JS"] + ); + let engine = &model.attention[find(&model, "Engine::run")]; + assert!(!engine.dimmed, "analysed symbols are never dimmed"); + + // While structure is still loading nothing is claimed about analysis. + let loading = model_of(&fixtures::inventory(), None, StructureLoad::Loading); + assert!(loading.attention.iter().all(|item| !item.dimmed)); + assert!(loading.files.iter().all(|entry| { + entry + .reasons + .iter() + .all(|reason| reason.kind != ReasonKind::NotAnalyzed) + })); + } + + #[test] + fn the_supporting_facts_read_off_the_same_files() { + let model = fixtures::model(); + let tests = model.facts.tests.clone().expect("two implementation dirs"); + assert_eq!(tests.impl_dirs, 2); + assert_eq!(tests.with_tests, 1); + assert_eq!( + tests + .without + .iter() + .map(|dir| dir.path.as_str()) + .collect::>(), + ["src"] + ); + assert_eq!(tests.without[0].files, 6); + + let also = model + .facts + .also + .expect("the fixture touches supporting files"); + assert_eq!( + ( + also.lockfiles, + also.submodules, + also.binaries, + also.deleted_impl + ), + (1, 0, 1, 1) + ); + + let commits = model.facts.commits.clone().expect("the ledger has commits"); + assert_eq!(commits.count, 2); + assert_eq!(commits.merges, 1); + assert_eq!(commits.authors, ["Ada", "Bob"]); + assert_eq!(commits.span_secs, 1); + assert_eq!(commits.first_sha, "aaaaaaa"); + assert_eq!(commits.last_sha, "bbbbbbb"); + + let mode = DiffMode::Commit("abc".into()); + let inventory = fixtures::inventory(); + let single = build_review_model(ModelInputs { + inventory: Some(&inventory), + inventory_error: None, + structure: None, + structure_state: StructureLoad::Loading, + diff_mode: &mode, + }); + assert!(single.commits.is_empty()); + assert!(single.facts.commits.is_none(), "spec §12 hides the fact"); + } + + #[test] + fn coverage_reports_the_implementation_subset_and_the_path_order_bias() { + let model = fixtures::model(); + assert_eq!(model.coverage.analyzed_files, 3); + assert_eq!(model.coverage.total_files, 7); + assert_eq!(model.coverage.impl_total, 7); + assert_eq!(model.coverage.impl_analyzed, 3); + assert!(model.coverage.path_order_bias); + assert_eq!(model.coverage.failed, 1); + assert_eq!(model.coverage.merge_base_oid, Some("2".repeat(40))); + } + + #[test] + fn a_file_whose_symbols_already_rank_never_gets_a_churn_reason() { + let mut inventory = fixtures::inventory(); + for file in &mut inventory.files { + if file.new_path.as_deref() == Some("src/engine.rs") { + file.lines_added = Some(5_000); + } + } + let model = model_of( + &inventory, + Some(&fixtures::structure()), + StructureLoad::Ready, + ); + let engine = model + .files + .iter() + .find(|entry| entry.display_path == "src/engine.rs") + .expect("the fixture analyses src/engine.rs"); + assert!(engine.changed_lines() > 5_000, "it is the top decile now"); + assert!(engine.reasons.is_empty(), "its symbols already explain it"); + assert!( + !model.attention.iter().any(|item| item + .reasons + .iter() + .any(|reason| reason.kind == ReasonKind::LargeChurn)), + "and nothing else reaches the decile" + ); + } + + fn dir_at<'a>(node: &'a DirNode, path: &str) -> Option<&'a DirNode> { + if node.path == path { + return Some(node); + } + node.children.iter().find_map(|child| dir_at(child, path)) + } + + fn test_json(path: &str, added: u64) -> Value { + json!({ + "old_path": path, "new_path": path, "status": "modified", + "lines_added": added, "lines_deleted": 0, "binary": false, + "classification": { "role": "test", "rule_id": "builtin.path.test.v1" }, + "provenance": { "source": "git" } + }) + } + + fn file_json(path: &str, added: u64, deleted: u64) -> Value { + json!({ + "old_path": path, "new_path": path, "status": "modified", + "lines_added": added, "lines_deleted": deleted, "binary": false, + "classification": { "role": "implementation", + "rule_id": "builtin.path.implementation.v1" }, + "provenance": { "source": "git" } + }) + } + + fn wrap(files: Vec) -> ReviewInventory { + let count = files.len(); + let totals = json!({ + "commits": 0, "files": count, "files_added": 0, "files_deleted": 0, + "files_modified": count, "files_renamed": 0, "files_copied": 0, + "files_type_changed": 0, "files_mode_changed": 0, "submodule_changes": 0, + "binary_files": 0, "lines_added": 0, "lines_deleted": 0, + "provenance": { "source": "git" } + }); + serde_json::from_value(json!({ + "comparison": fixtures::comparison_json(), + "totals": totals, + "commits": [], + "files": files, + "coverage": fixtures::coverage_json( + u64::try_from(count).unwrap_or(0), + u64::try_from(count).unwrap_or(0), + 0, + ) + })) + .expect("inline inventory") + } + + fn sized_inventory(files: usize, churn: u64) -> ReviewInventory { + wrap( + (0..files) + .map(|index| file_json(&format!("src/f{index}.rs"), churn, 0)) + .collect(), + ) + } + + fn renames_inventory() -> ReviewInventory { + wrap(vec![ + json!({ + "old_path": "src/a_old.rs", "new_path": "src/a_new.rs", "status": "renamed", + "similarity": 99, "lines_added": 12, "lines_deleted": 8, "binary": false, + "classification": { "role": "implementation", + "rule_id": "builtin.path.implementation.v1" }, + "provenance": { "source": "git" } + }), + json!({ + "old_path": "src/b_old.rs", "new_path": "src/b_new.rs", "status": "renamed", + "similarity": 91, "lines_added": 13, "lines_deleted": 8, "binary": false, + "classification": { "role": "implementation", + "rule_id": "builtin.path.implementation.v1" }, + "provenance": { "source": "git" } + }), + ]) + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/shell.rs b/crates/okena-views-git/src/diff_viewer/review_ui/shell.rs new file mode 100644 index 000000000..fef18cee8 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/shell.rs @@ -0,0 +1,128 @@ +//! Composes the review workspace: navigator column, content column, overlays. + +use super::super::DiffViewer; +use super::diff_state::SmartDiffViewState; +use super::state::ContentView; +use gpui::prelude::*; +use gpui::*; +use okena_core::theme::ThemeColors; +use okena_ui::resizable_sidebar::resizable_sidebar; +use std::sync::Arc; + +/// What `render_diff_pane` needs; the render pass computes all of it. +pub(crate) struct DiffPaneArgs { + pub is_binary: bool, + pub file_path: String, + pub line_count: usize, + pub gutter_width: f32, + pub theme_colors: Arc, +} + +impl DiffViewer { + pub(crate) fn render_review_shell( + &mut self, + t: &ThemeColors, + diff_pane: DiffPaneArgs, + cx: &mut Context, + ) -> AnyElement { + let navigator = self.render_navigator(t, cx); + let sidebar = self.render_navigator_column(t, navigator, cx); + let content = match self.review_ui.content { + ContentView::Overview => self.render_overview(t, cx), + ContentView::File => self.render_file_content(t, diff_pane, cx), + }; + let status_popover = self.render_status_popover(t, cx); + let roles_menu = self.render_roles_menu(t, cx); + let outline = self.render_outline_popover(t, cx); + let help = self.render_help_overlay(t, cx); + + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + // Overlays position themselves against this box. + .relative() + .child( + div() + .flex_1() + .min_h_0() + .flex() + .child(sidebar) + .child(content), + ) + .children(status_popover) + .children(roles_menu) + .children(outline) + .children(help) + .into_any_element() + } + + fn render_navigator_column( + &self, + t: &ThemeColors, + navigator: AnyElement, + cx: &mut Context, + ) -> AnyElement { + let entity = cx.entity().downgrade(); + let entity_for_end = entity.clone(); + resizable_sidebar( + self.sidebar_resize.width(), + t.bg_primary, + t.border, + t.border_active, + vec![navigator], + move |mouse_pos, cx| { + if let Some(entity) = entity.upgrade() { + entity.update(cx, |this, _| { + this.sidebar_resize.start_resize(f32::from(mouse_pos.x)); + }); + } + }, + move |cx| { + if let Some(entity) = entity_for_end.upgrade() { + entity.update(cx, |this, _| this.sidebar_resize.end_resize()); + } + }, + ) + .into_any_element() + } + + fn render_file_content( + &mut self, + t: &ThemeColors, + diff_pane: DiffPaneArgs, + cx: &mut Context, + ) -> AnyElement { + let header = self.render_file_header(t, cx); + let symbol_bar = self.render_symbol_bar(t, cx); + let unavailable = self.render_navigation_unavailable(t, cx); + let state = self.smart_diff_view_state(); + let body = if state == SmartDiffViewState::Ready { + self.render_diff_pane( + t, + diff_pane.is_binary, + diff_pane.file_path, + diff_pane.line_count, + diff_pane.gutter_width, + diff_pane.theme_colors, + cx, + ) + .into_any_element() + } else { + self.render_smart_diff_state(state, t, cx) + }; + + div() + .flex_1() + .min_w_0() + .min_h_0() + .flex() + .flex_col() + .child(header) + .children(symbol_bar) + .children(unavailable) + .child(body) + .into_any_element() + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/state.rs b/crates/okena-views-git/src/diff_viewer/review_ui/state.rs new file mode 100644 index 000000000..11d716331 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/state.rs @@ -0,0 +1,551 @@ +//! Client-side review UI state — spec §4. Filters live here, never in the model. +// Frozen surface: the wave-1 view units read these fields. +#![allow(dead_code)] + +use super::super::DiffViewer; +use super::super::review::ReviewFileKey; +use super::labels::role_short; +use super::model::{AttentionTarget, FileEntry, ReasonKind, ReviewModel}; +use gpui::{AppContext as _, Context, Entity, ScrollStrategy, UniformListScrollHandle}; +use okena_core::review::{ComparisonSide, FileRole, ReviewFileStatus}; +use okena_ui::simple_input::{InputChangedEvent, SimpleInputState}; +use std::collections::{BTreeSet, HashSet}; +use std::sync::Arc; + +/// Residual lines at or below which a rename is treated as a mechanical move. +pub(crate) const MECHANICAL_RESIDUAL_LINES: u64 = 20; + +/// All roles in the order they are offered in the Roles menu. +pub(crate) const ALL_ROLES: [FileRole; 11] = [ + FileRole::Implementation, + FileRole::Test, + FileRole::Fixture, + FileRole::Snapshot, + FileRole::Example, + FileRole::Documentation, + FileRole::Configuration, + FileRole::Lockfile, + FileRole::Generated, + FileRole::Vendored, + FileRole::Unclassified, +]; + +fn role_bit(role: FileRole) -> u16 { + let index = ALL_ROLES + .iter() + .position(|candidate| *candidate == role) + .unwrap_or(0); + 1u16 << index +} + +/// Set of [`FileRole`]s. A bit set, because `FileRole` is neither `Ord` nor `Hash`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct RoleSet(u16); + +impl RoleSet { + pub(crate) fn empty() -> Self { + Self(0) + } + + pub(crate) fn all() -> Self { + Self::from_roles(ALL_ROLES) + } + + pub(crate) fn from_roles(roles: impl IntoIterator) -> Self { + roles + .into_iter() + .fold(Self::empty(), |set, role| set.with(role)) + } + + pub(crate) fn with(self, role: FileRole) -> Self { + Self(self.0 | role_bit(role)) + } + + pub(crate) fn without(self, role: FileRole) -> Self { + Self(self.0 & !role_bit(role)) + } + + pub(crate) fn contains(self, role: FileRole) -> bool { + self.0 & role_bit(role) != 0 + } + + pub(crate) fn toggled(self, role: FileRole) -> Self { + if self.contains(role) { + self.without(role) + } else { + self.with(role) + } + } + + pub(crate) fn is_empty(self) -> bool { + self.0 == 0 + } + + pub(crate) fn len(self) -> usize { + usize::try_from(self.0.count_ones()).unwrap_or(0) + } + + /// Members in [`ALL_ROLES`] order. + pub(crate) fn iter(self) -> impl Iterator { + ALL_ROLES + .into_iter() + .filter(move |role| self.contains(*role)) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum NavigatorMode { + #[default] + Files, + Attention, +} + +/// The open file is `SmartReviewState::selected_file`; this only picks the screen. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum ContentView { + #[default] + Overview, + File, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum FocusRegion { + #[default] + Navigator, + Content, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum RolePreset { + #[default] + Everything, + ReviewCode, + Supporting, + Custom, +} + +impl RolePreset { + /// The role set a preset stands for; `Custom` has none. + pub(crate) fn roles(self) -> Option { + match self { + Self::Everything => Some(RoleSet::all()), + Self::ReviewCode => Some(RoleSet::from_roles([ + FileRole::Implementation, + FileRole::Configuration, + FileRole::Unclassified, + ])), + Self::Supporting => Some(RoleSet::from_roles([ + FileRole::Test, + FileRole::Fixture, + FileRole::Snapshot, + FileRole::Example, + FileRole::Documentation, + ])), + Self::Custom => None, + } + } + + pub(crate) fn label(self) -> &'static str { + match self { + Self::Everything => "Everything", + Self::ReviewCode => "Review code", + Self::Supporting => "Supporting", + Self::Custom => "Custom", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct RoleFilter { + pub roles: RoleSet, + pub preset: RolePreset, + pub likely_mechanical_only: bool, + pub not_analyzed_only: bool, +} + +impl Default for RoleFilter { + fn default() -> Self { + Self::everything() + } +} + +impl RoleFilter { + pub(crate) fn everything() -> Self { + Self::preset(RolePreset::Everything) + } + + pub(crate) fn preset(preset: RolePreset) -> Self { + Self { + roles: preset.roles().unwrap_or_else(RoleSet::all), + preset, + likely_mechanical_only: false, + not_analyzed_only: false, + } + } + + pub(crate) fn is_everything(&self) -> bool { + self.preset == RolePreset::Everything + && !self.likely_mechanical_only + && !self.not_analyzed_only + } + + /// Flip one role; the preset follows the resulting set. + pub(crate) fn toggle(&mut self, role: FileRole) { + self.roles = self.roles.toggled(role); + self.preset = preset_for(self.roles); + } + + pub(crate) fn allows(&self, entry: &FileEntry) -> bool { + if !self.roles.contains(entry.role) { + return false; + } + if self.likely_mechanical_only && !is_likely_mechanical(entry) { + return false; + } + if self.not_analyzed_only && entry.analysis.is_analyzed() { + return false; + } + true + } + + /// Roles-button text: the preset name, or up to three short role names. + pub(crate) fn label(&self) -> String { + match self.preset { + RolePreset::Everything => format!("all {}", ALL_ROLES.len()), + RolePreset::ReviewCode | RolePreset::Supporting => self.preset.label().to_string(), + RolePreset::Custom => { + if self.roles.is_empty() { + return "none".to_string(); + } + let shown: Vec<&str> = self.roles.iter().take(3).map(role_short).collect(); + let rest = self.roles.len().saturating_sub(shown.len()); + let joined = shown.join(" + "); + if rest == 0 { + joined + } else { + format!("{joined} +{rest}") + } + } + } + } +} + +fn preset_for(roles: RoleSet) -> RolePreset { + [ + RolePreset::Everything, + RolePreset::ReviewCode, + RolePreset::Supporting, + ] + .into_iter() + .find(|preset| preset.roles() == Some(roles)) + .unwrap_or(RolePreset::Custom) +} + +/// A rename small enough that the move itself is the whole change. +pub(crate) fn is_likely_mechanical(entry: &FileEntry) -> bool { + entry.status == ReviewFileStatus::Renamed && entry.changed_lines() <= MECHANICAL_RESIDUAL_LINES +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct AttentionFilter { + /// Empty means every reason kind. + pub kinds: BTreeSet, + pub include_tests: bool, + pub grouped_by_file: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct SymbolRef { + pub file: ReviewFileKey, + pub change_index: usize, +} + +/// Identity of one navigator row, stable across re-renders. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) enum NavRowId { + Dir(String), + File(ReviewFileKey), + Item(AttentionTarget), +} + +/// Lines the selected symbol occupies; 1-based, inclusive on both ends. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct MarkerSpan { + pub file: ReviewFileKey, + pub old: Vec<(u32, u32)>, + pub new: Vec<(u32, u32)>, +} + +impl MarkerSpan { + pub(crate) fn matches(&self, side: ComparisonSide, line: usize) -> bool { + let Ok(line) = u32::try_from(line) else { + return false; + }; + let ranges = match side { + ComparisonSide::Base => &self.old, + ComparisonSide::Head => &self.new, + }; + ranges + .iter() + .any(|(start, end)| *start <= line && line <= *end) + } +} + +pub(crate) struct ReviewUiState { + pub navigator: NavigatorMode, + pub content: ContentView, + pub focus_region: FocusRegion, + pub nav_cursor: Option, + /// Scroll the cursor row into view on the next render, then forget it — + /// re-asserting every frame would fight the wheel. + pub nav_reveal: Option, + pub selected_symbol: Option, + /// Position in the Attention queue, kept by identity rather than index. + pub queue_target: Option, + pub role_filter: RoleFilter, + pub attention_filter: AttentionFilter, + pub expanded_dirs: HashSet, + pub expanded_initialized: bool, + pub flatten: bool, + pub details_expanded: bool, + pub filter_input: Entity, + pub filter_text: String, + pub roles_menu_open: bool, + pub status_popover_open: bool, + /// The outline popover over the open file. + pub outline_open: bool, + /// Files mode inlines every file's changed symbols and what changed in them. + pub outline_inline: bool, + pub help_open: bool, + /// Overview: commit ledger expanded under the Commits fact. + pub ledger_open: bool, + pub model: Option>, + pub small_change_applied: bool, + pub marker: Option, + pub content_width: f32, + pub tree_scroll: UniformListScrollHandle, + pub attention_scroll: UniformListScrollHandle, +} + +impl ReviewUiState { + pub(crate) fn new(cx: &mut Context) -> Self { + let filter_input = + cx.new(|cx| SimpleInputState::new(cx).placeholder("Filter files\u{2026}")); + cx.subscribe( + &filter_input, + |this: &mut DiffViewer, input, _: &InputChangedEvent, cx| { + this.review_ui.filter_text = input.read(cx).value().to_string(); + cx.notify(); + }, + ) + .detach(); + Self { + navigator: NavigatorMode::default(), + content: ContentView::default(), + focus_region: FocusRegion::default(), + nav_cursor: None, + nav_reveal: None, + selected_symbol: None, + queue_target: None, + role_filter: RoleFilter::everything(), + attention_filter: AttentionFilter::default(), + expanded_dirs: HashSet::new(), + expanded_initialized: false, + flatten: false, + details_expanded: false, + filter_input, + filter_text: String::new(), + roles_menu_open: false, + status_popover_open: false, + outline_open: false, + outline_inline: false, + help_open: false, + ledger_open: false, + model: None, + small_change_applied: false, + marker: None, + content_width: 0.0, + tree_scroll: UniformListScrollHandle::new(), + attention_scroll: UniformListScrollHandle::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::super::model::{FileAnalysis, FileEntry, Tier}; + use super::{ALL_ROLES, ComparisonSide, FileRole, MarkerSpan, RoleFilter, RolePreset, RoleSet}; + use crate::diff_viewer::review::ReviewFileKey; + use okena_core::review::ReviewFileStatus; + + fn entry(role: FileRole, status: ReviewFileStatus, churn: u64, analyzed: bool) -> FileEntry { + FileEntry { + key: ReviewFileKey { + old_path: Some("a.rs".into()), + new_path: Some("a.rs".into()), + }, + display_path: "a.rs".into(), + old_path: Some("a.rs".into()), + new_path: Some("a.rs".into()), + status, + role, + rule_id: "builtin.path.implementation.v1".into(), + similarity: None, + lines_added: churn, + lines_deleted: 0, + binary: false, + analysis: if analyzed { + FileAnalysis::Parsed { + language: "Rust".into(), + } + } else { + FileAnalysis::NotInStructure + }, + reasons: Vec::new(), + tier: Tier::Rest, + is_test: role == FileRole::Test, + has_test_changes: role == FileRole::Test, + inline_test_lines: 0, + symbols: Vec::new(), + structure_index: None, + } + } + + #[test] + fn everything_admits_every_role() { + let filter = RoleFilter::everything(); + assert!(filter.is_everything()); + for role in ALL_ROLES { + assert!(filter.allows(&entry(role, ReviewFileStatus::Modified, 1, true))); + } + } + + #[test] + fn presets_split_review_code_from_supporting() { + let review = RoleFilter::preset(RolePreset::ReviewCode); + assert!(review.allows(&entry( + FileRole::Implementation, + ReviewFileStatus::Modified, + 1, + true + ))); + assert!(!review.allows(&entry(FileRole::Test, ReviewFileStatus::Modified, 1, true))); + + let supporting = RoleFilter::preset(RolePreset::Supporting); + assert!(supporting.allows(&entry(FileRole::Test, ReviewFileStatus::Modified, 1, true))); + assert!(!supporting.allows(&entry( + FileRole::Implementation, + ReviewFileStatus::Modified, + 1, + true + ))); + } + + #[test] + fn saved_filters_narrow_by_move_size_and_analysis() { + let mut filter = RoleFilter::everything(); + filter.likely_mechanical_only = true; + assert!(filter.allows(&entry( + FileRole::Implementation, + ReviewFileStatus::Renamed, + 20, + true + ))); + assert!(!filter.allows(&entry( + FileRole::Implementation, + ReviewFileStatus::Renamed, + 21, + true + ))); + assert!(!filter.allows(&entry( + FileRole::Implementation, + ReviewFileStatus::Modified, + 1, + true + ))); + + let mut filter = RoleFilter::everything(); + filter.not_analyzed_only = true; + assert!(filter.allows(&entry( + FileRole::Implementation, + ReviewFileStatus::Modified, + 1, + false + ))); + assert!(!filter.allows(&entry( + FileRole::Implementation, + ReviewFileStatus::Modified, + 1, + true + ))); + } + + #[test] + fn toggling_roles_moves_between_custom_and_the_matching_preset() { + let mut filter = RoleFilter::everything(); + filter.toggle(FileRole::Test); + assert_eq!(filter.preset, RolePreset::Custom); + assert!(!filter.roles.contains(FileRole::Test)); + + filter.toggle(FileRole::Test); + assert_eq!(filter.preset, RolePreset::Everything); + + let mut filter = RoleFilter { + roles: RoleSet::from_roles([FileRole::Implementation, FileRole::Configuration]), + preset: RolePreset::Custom, + likely_mechanical_only: false, + not_analyzed_only: false, + }; + filter.toggle(FileRole::Unclassified); + assert_eq!(filter.preset, RolePreset::ReviewCode); + } + + #[test] + fn labels_name_the_preset_or_the_chosen_roles() { + assert_eq!(RoleFilter::everything().label(), "all 11"); + assert_eq!( + RoleFilter::preset(RolePreset::ReviewCode).label(), + "Review code" + ); + assert_eq!( + RoleFilter::preset(RolePreset::Supporting).label(), + "Supporting" + ); + + let mut filter = RoleFilter::everything(); + filter.roles = RoleSet::from_roles([FileRole::Implementation, FileRole::Configuration]); + filter.preset = RolePreset::Custom; + assert_eq!(filter.label(), "Impl + Config"); + + filter.roles = RoleSet::from_roles([ + FileRole::Implementation, + FileRole::Test, + FileRole::Documentation, + FileRole::Configuration, + FileRole::Lockfile, + ]); + assert_eq!(filter.label(), "Impl + Tests + Docs +2"); + + filter.roles = RoleSet::empty(); + assert_eq!(filter.label(), "none"); + } + + #[test] + fn marker_covers_every_range_on_the_matching_side() { + let marker = MarkerSpan { + file: ReviewFileKey { + old_path: Some("a.rs".into()), + new_path: Some("a.rs".into()), + }, + old: vec![(3, 5), (10, 10)], + new: vec![(4, 6), (20, 22)], + }; + assert!(marker.matches(ComparisonSide::Base, 3)); + assert!(marker.matches(ComparisonSide::Base, 5)); + assert!(marker.matches(ComparisonSide::Base, 10)); + assert!(!marker.matches(ComparisonSide::Base, 6)); + assert!(marker.matches(ComparisonSide::Head, 6)); + assert!(marker.matches(ComparisonSide::Head, 21)); + assert!(!marker.matches(ComparisonSide::Head, 10)); + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/status/mod.rs b/crates/okena-views-git/src/diff_viewer/review_ui/status/mod.rs new file mode 100644 index 000000000..b0310b77d --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/status/mod.rs @@ -0,0 +1,198 @@ +//! Analysis status pill and its details popover — spec §10. + +mod pill; +mod popover; + +use super::super::DiffViewer; +use super::labels::status as words; +use super::model::AnalysisStatus; +use gpui::prelude::*; +use gpui::*; +use gpui_component::h_flex; +use gpui_component::tooltip::Tooltip; +use okena_core::theme::ThemeColors; +use okena_ui::popover::popover_panel; +use okena_ui::tokens::{ui_text_ms, ui_text_sm}; +use pill::{PillTone, pill_view}; +use popover::{PopoverRow, popover_rows}; + +/// One spinner frame; the app draws no animations. +const SPINNER: &str = "\u{25D0}"; + +const PANEL_WIDTH: Pixels = px(360.0); + +impl DiffViewer { + pub(crate) fn render_status_pill(&self, t: &ThemeColors, cx: &mut Context) -> AnyElement { + let status = self.review_status(); + let view = pill_view(&status); + let message = match &status { + AnalysisStatus::Unavailable { message } => Some(message.clone()), + _ => None, + }; + h_flex() + .id("review-status-pill") + .h(px(22.0)) + .px(px(8.0)) + .gap(px(6.0)) + .items_center() + .rounded_full() + .bg(rgb(t.bg_secondary)) + .border_1() + .border_color(rgb(t.border)) + .child(tone_marker(view.tone, t, cx)) + .child( + div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_secondary)) + .child(view.text), + ) + .when(view.has_details, |d| { + d.child( + div() + .id("review-status-details") + .cursor_pointer() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.term_blue)) + .hover(|s| s.text_color(rgb(t.text_primary))) + .on_click(cx.listener(|this, _, _window, cx| { + this.review_toggle_status_popover(cx); + })) + .child(words::DETAILS_LINK), + ) + }) + .when_some(message, |d, message| { + d.tooltip(move |window, cx| Tooltip::new(message.clone()).build(window, cx)) + }) + .into_any_element() + } + + pub(crate) fn render_status_popover( + &self, + t: &ThemeColors, + cx: &mut Context, + ) -> Option { + if !self.review_ui.status_popover_open { + return None; + } + // Only the `details` link opens this; states without one have nothing to add. + if !pill_view(&self.review_status()).has_details { + return None; + } + let model = self.review_ui.model.as_ref()?; + let rows: Vec = popover_rows(model) + .into_iter() + .map(|row| render_row(&row, t, cx)) + .collect(); + let oids = words::oid_line(&model.coverage); + + let panel = popover_panel("review-status-popover", t) + .absolute() + .top(px(6.0)) + .right(px(16.0)) + .w(PANEL_WIDTH) + .flex() + .flex_col() + .gap(px(2.0)) + .child( + div() + .pb(px(4.0)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(words::POPOVER_TITLE), + ) + .children(rows) + .child( + div() + .pt(px(6.0)) + .text_size(ui_text_sm(cx)) + .text_color(rgb(t.text_muted)) + .child(words::FOOTER) + .when(!oids.is_empty(), |d| { + d.child(div().pt(px(4.0)).font_family("monospace").child(oids)) + }), + ); + + // Occludes, so the dismissing click never also lands on the row underneath. + Some( + div() + .id("review-status-backdrop") + .occlude() + .absolute() + .inset_0() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, _window, cx| this.review_toggle_status_popover(cx)), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(|this, _, _window, cx| this.review_toggle_status_popover(cx)), + ) + .child(panel) + .into_any_element(), + ) + } + + /// The pill renders before the first dataset lands, so default to loading. + fn review_status(&self) -> AnalysisStatus { + self.review_ui + .model + .as_ref() + .map_or(AnalysisStatus::LoadingInventory, |model| { + model.status.clone() + }) + } +} + +fn tone_marker(tone: PillTone, t: &ThemeColors, cx: &App) -> AnyElement { + let color = match tone { + PillTone::Green => t.success, + PillTone::Amber => t.warning, + PillTone::Red => t.error, + PillTone::Busy => { + return div() + .text_size(ui_text_ms(cx)) + .text_color(rgb(t.text_muted)) + .child(SPINNER) + .into_any_element(); + } + }; + div() + .w(px(6.0)) + .h(px(6.0)) + .rounded_full() + .bg(rgb(color)) + .into_any_element() +} + +fn render_row(row: &PopoverRow, t: &ThemeColors, cx: &App) -> AnyElement { + let color = if row.warn { + t.warning + } else { + t.text_secondary + }; + let detail_color = if row.warn { t.warning } else { t.text_muted }; + h_flex() + .py(px(4.0)) + .gap(px(12.0)) + .items_start() + .justify_between() + .border_b_1() + .border_color(rgb(t.border)) + .child( + div() + .flex_1() + .min_w_0() + .text_size(ui_text_ms(cx)) + .text_color(rgb(color)) + .child(row.sentence.clone()), + ) + .child( + div() + .min_w_0() + .truncate() + .text_size(ui_text_ms(cx)) + .text_color(rgb(detail_color)) + .child(row.detail.clone()), + ) + .into_any_element() +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/status/pill.rs b/crates/okena-views-git/src/diff_viewer/review_ui/status/pill.rs new file mode 100644 index 000000000..6a731602c --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/status/pill.rs @@ -0,0 +1,176 @@ +//! Header pill tone and wording — spec §10. Pure; the render pass only paints it. + +use super::super::labels::status as words; +use super::super::model::AnalysisStatus; + +/// Dot colour of the pill. `Busy` draws a spinner glyph instead of a dot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PillTone { + Green, + Amber, + Red, + Busy, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PillView { + pub tone: PillTone, + pub text: String, + /// A `details` link that opens the popover. + pub has_details: bool, +} + +/// Anything short of a full clean run stays amber or red — never green. +pub(crate) fn pill_view(status: &AnalysisStatus) -> PillView { + match status { + AnalysisStatus::LoadingInventory => PillView { + tone: PillTone::Busy, + text: words::LOADING_INVENTORY.to_string(), + has_details: false, + }, + AnalysisStatus::AnalyzingStructure => PillView { + tone: PillTone::Busy, + text: words::ANALYZING_STRUCTURE.to_string(), + has_details: false, + }, + AnalysisStatus::Ready { files, languages } => PillView { + tone: PillTone::Green, + text: words::ready_sentence(*files, languages), + has_details: false, + }, + AnalysisStatus::Limited { analyzed, total } => PillView { + tone: PillTone::Amber, + text: words::limited_sentence(*analyzed, *total), + has_details: true, + }, + AnalysisStatus::ReadyWithFailures { failed } => PillView { + tone: PillTone::Amber, + text: words::failures_sentence(*failed), + has_details: true, + }, + // The message is too long for the header; the hover tooltip carries it. + AnalysisStatus::Unavailable { .. } => PillView { + tone: PillTone::Red, + text: words::UNAVAILABLE.to_string(), + has_details: false, + }, + } +} + +#[cfg(test)] +mod tests { + use super::super::super::model::AnalysisStatus; + use super::{PillTone, pill_view}; + + /// Every `AnalysisStatus` variant name; none of them may reach the screen. + const DEBUG_NAMES: [&str; 6] = [ + "LoadingInventory", + "AnalyzingStructure", + "Ready", + "Limited", + "ReadyWithFailures", + "Unavailable", + ]; + + fn all_states() -> Vec { + vec![ + AnalysisStatus::LoadingInventory, + AnalysisStatus::AnalyzingStructure, + AnalysisStatus::Ready { + files: 385, + languages: vec!["TS".into(), "TSX".into(), "Rust".into()], + }, + AnalysisStatus::Limited { + analyzed: 200, + total: 385, + }, + AnalysisStatus::ReadyWithFailures { failed: 3 }, + AnalysisStatus::Unavailable { + message: "tree-sitter query failed".into(), + }, + ] + } + + #[test] + fn every_state_has_its_spec_tone_text_and_details_link() { + let expected = [ + (PillTone::Busy, "Loading inventory\u{2026}", false), + (PillTone::Busy, "Analyzing structure\u{2026}", false), + ( + PillTone::Green, + "Structure ready \u{00B7} 385 files \u{00B7} TS, TSX, Rust", + false, + ), + ( + PillTone::Amber, + "Structure limited \u{00B7} 200 of 385 files", + true, + ), + ( + PillTone::Amber, + "Structure ready \u{00B7} 3 files failed to parse", + true, + ), + ( + PillTone::Red, + "Structure unavailable \u{00B7} diff still works", + false, + ), + ]; + for (status, (tone, text, has_details)) in all_states().iter().zip(expected) { + let view = pill_view(status); + assert_eq!(view.tone, tone, "tone for {status:?}"); + assert_eq!(view.text, text, "text for {status:?}"); + assert_eq!(view.has_details, has_details, "details for {status:?}"); + } + } + + #[test] + fn a_capped_or_failed_run_is_never_green() { + for analyzed in [0_u64, 1, 199, 384] { + let view = pill_view(&AnalysisStatus::Limited { + analyzed, + total: 385, + }); + assert_eq!(view.tone, PillTone::Amber); + assert!(view.has_details); + } + for failed in [1_u64, 3, 4_200] { + let view = pill_view(&AnalysisStatus::ReadyWithFailures { failed }); + assert_eq!(view.tone, PillTone::Amber); + assert!(view.has_details); + } + } + + #[test] + fn languages_are_joined_with_commas() { + let view = pill_view(&AnalysisStatus::Ready { + files: 2, + languages: vec!["Rust".into(), "TSX".into()], + }); + assert!(view.text.ends_with("Rust, TSX"), "{}", view.text); + } + + #[test] + fn the_failure_message_stays_out_of_the_header() { + let view = pill_view(&AnalysisStatus::Unavailable { + message: "tree-sitter query failed".into(), + }); + assert!( + !view.text.contains("tree-sitter query failed"), + "{}", + view.text + ); + assert_eq!(view.text, "Structure unavailable \u{00B7} diff still works"); + } + + #[test] + fn no_state_leaks_a_debug_enum_name() { + for status in all_states() { + let text = pill_view(&status).text; + for name in DEBUG_NAMES { + assert!(!text.contains(name), "{text} leaks {name}"); + } + } + } +} diff --git a/crates/okena-views-git/src/diff_viewer/review_ui/status/popover.rs b/crates/okena-views-git/src/diff_viewer/review_ui/status/popover.rs new file mode 100644 index 000000000..224851345 --- /dev/null +++ b/crates/okena-views-git/src/diff_viewer/review_ui/status/popover.rs @@ -0,0 +1,183 @@ +//! Rows of the details popover — spec §10. Pure; the render pass only paints them. + +use super::super::labels::status as words; +use super::super::model::{AnalysisStatus, OmissionRow, ReviewModel}; + +/// One popover line: the sentence on the left, count and detail on the right. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct PopoverRow { + pub sentence: String, + pub detail: String, + pub warn: bool, +} + +/// What was reached first, then what needs attention, then the rest. +pub(crate) fn popover_rows(model: &ReviewModel) -> Vec { + let coverage = &model.coverage; + let mut rows = Vec::with_capacity(model.omissions.len() + 2); + if coverage.analyzed_files > 0 { + rows.push(PopoverRow { + sentence: words::ANALYZED_ROW.to_string(), + detail: words::analyzed_detail(coverage.analyzed_files, &coverage.languages), + warn: false, + }); + } + // The pill only says "diff still works"; the reason lives here. + if let AnalysisStatus::Unavailable { message } = &model.status { + rows.push(PopoverRow { + sentence: words::UNAVAILABLE_ROW.to_string(), + detail: message.clone(), + warn: true, + }); + } + rows.extend(model.omissions.iter().filter(|row| row.warn).map(omission)); + rows.extend(model.omissions.iter().filter(|row| !row.warn).map(omission)); + rows +} + +fn omission(row: &OmissionRow) -> PopoverRow { + PopoverRow { + sentence: row.sentence.clone(), + detail: words::omission_detail(row.count, &row.detail), + warn: row.warn, + } +} + +#[cfg(test)] +mod tests { + use super::super::super::model::{ + AnalysisStatus, CoverageSummary, DirNode, Facts, OmissionRow, ReviewModel, + }; + use super::popover_rows; + + /// Every `AnalysisStatus` variant name; none of them may reach the screen. + const DEBUG_NAMES: [&str; 6] = [ + "LoadingInventory", + "AnalyzingStructure", + "Ready", + "Limited", + "ReadyWithFailures", + "Unavailable", + ]; + + fn model(status: AnalysisStatus, omissions: Vec) -> ReviewModel { + ReviewModel { + files: Vec::new(), + root: DirNode::default(), + volume: Vec::new(), + total_changed_lines: 0, + facts: Facts::default(), + attention: Vec::new(), + status, + omissions, + commits: Vec::new(), + coverage: CoverageSummary { + analyzed_files: 200, + total_files: 385, + languages: vec!["TypeScript".into(), "TSX".into()], + partial: true, + ..CoverageSummary::default() + }, + small_change: false, + } + } + + fn omission(sentence: &str, count: u64, detail: &str, warn: bool) -> OmissionRow { + OmissionRow { + sentence: sentence.to_string(), + count, + detail: detail.to_string(), + warn, + } + } + + #[test] + fn analyzed_leads_then_the_rows_that_need_attention() { + let rows = popover_rows(&model( + AnalysisStatus::Limited { + analyzed: 200, + total: 385, + }, + vec![ + omission("Skipped \u{2014} mode-only change", 9, "", false), + omission("Not analyzed \u{2014} file limit (200)", 152, "", true), + omission("Unsupported language", 21, ".astro 14, .js 5", false), + omission("Failed to parse", 3, "parsing: unexpected token", true), + ], + )); + let sentences: Vec<&str> = rows.iter().map(|row| row.sentence.as_str()).collect(); + assert_eq!( + sentences, + [ + "Analyzed", + "Not analyzed \u{2014} file limit (200)", + "Failed to parse", + "Skipped \u{2014} mode-only change", + "Unsupported language", + ] + ); + assert_eq!(rows[0].detail, "200 files \u{00B7} TypeScript, TSX"); + assert!(!rows[0].warn); + assert!(rows[1].warn && rows[2].warn); + assert_eq!( + rows[2].detail, "3 files \u{00B7} parsing: unexpected token", + "failure rows keep their stage and message" + ); + assert!(!rows[3].warn && !rows[4].warn); + } + + #[test] + fn an_unavailable_run_shows_its_message_as_a_warn_row() { + let mut model = model( + AnalysisStatus::Unavailable { + message: "tree-sitter query failed".into(), + }, + Vec::new(), + ); + model.coverage.analyzed_files = 0; + let rows = popover_rows(&model); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].sentence, "Structure unavailable"); + assert_eq!(rows[0].detail, "tree-sitter query failed"); + assert!(rows[0].warn); + } + + #[test] + fn nothing_analyzed_means_no_analyzed_row() { + let mut model = model(AnalysisStatus::AnalyzingStructure, Vec::new()); + model.coverage.analyzed_files = 0; + assert!(popover_rows(&model).is_empty()); + } + + #[test] + fn no_row_leaks_a_debug_enum_name() { + let states = [ + AnalysisStatus::LoadingInventory, + AnalysisStatus::AnalyzingStructure, + AnalysisStatus::Ready { + files: 385, + languages: vec!["Rust".into()], + }, + AnalysisStatus::Limited { + analyzed: 200, + total: 385, + }, + AnalysisStatus::ReadyWithFailures { failed: 3 }, + AnalysisStatus::Unavailable { + message: "worker exited".into(), + }, + ]; + for status in states { + for row in popover_rows(&model(status, Vec::new())) { + for name in DEBUG_NAMES { + assert!( + !row.sentence.contains(name), + "{} leaks {name}", + row.sentence + ); + assert!(!row.detail.contains(name), "{} leaks {name}", row.detail); + } + } + } + } +} diff --git a/crates/okena-views-git/src/diff_viewer/selection_ops.rs b/crates/okena-views-git/src/diff_viewer/selection_ops.rs index 392494112..906c44d40 100644 --- a/crates/okena-views-git/src/diff_viewer/selection_ops.rs +++ b/crates/okena-views-git/src/diff_viewer/selection_ops.rs @@ -1,6 +1,7 @@ //! Context expansion and text selection ops for the diff viewer. use super::DiffViewer; +use super::review_nav::{NavigationUnavailable, validate_expander}; use super::types::{self, DisplayItem, SideBySideSide}; use okena_core::types::DiffViewMode; @@ -17,40 +18,50 @@ impl DiffViewer { new_range: (usize, usize), cx: &mut Context, ) { - let file = match self.current_file.as_ref() { - Some(f) => f, - None => return, - }; - let item_index = file.items.iter().position(|item| { - matches!(item, DisplayItem::Expander(e) if e.old_range == old_range && e.new_range == new_range) + let _ = self.expand_context_by_range_checked(old_range, new_range, cx); + } + + pub(super) fn expand_context_by_range_checked( + &mut self, + old_range: (usize, usize), + new_range: (usize, usize), + cx: &mut Context, + ) -> Result<(), NavigationUnavailable> { + let file = self + .current_file + .as_ref() + .ok_or(NavigationUnavailable::MissingCurrentFile)?; + let mut matches = file.items.iter().enumerate().filter_map(|(index, item)| { + matches!(item, DisplayItem::Expander(expander) + if expander.old_range == old_range && expander.new_range == new_range) + .then_some(index) }); - if let Some(idx) = item_index { - self.expand_context(idx, cx); + let index = matches + .next() + .ok_or(NavigationUnavailable::MissingExpander)?; + if matches.next().is_some() { + return Err(NavigationUnavailable::DuplicateExpander); } + self.expand_context_checked(index, cx) } - /// Expand all hidden context lines at the given item index. - pub(super) fn expand_context(&mut self, item_index: usize, cx: &mut Context) { - let file = match self.current_file.as_mut() { - Some(f) => f, - None => return, - }; - - let expander = match &file.items[item_index] { - DisplayItem::Expander(e) => e.clone(), - _ => return, + pub(super) fn expand_context_checked( + &mut self, + item_index: usize, + cx: &mut Context, + ) -> Result<(), NavigationUnavailable> { + let file = self + .current_file + .as_ref() + .ok_or(NavigationUnavailable::MissingCurrentFile)?; + let expander = match file.items.get(item_index) { + Some(DisplayItem::Expander(expander)) => expander.clone(), + Some(DisplayItem::Line(_)) => return Err(NavigationUnavailable::NotAnExpander), + None => return Err(NavigationUnavailable::MissingExpander), }; - let (old_start, old_end) = expander.old_range; - let (new_start, new_end) = expander.new_range; - - // Validate ranges - if new_start == 0 || new_end < new_start || old_end < old_start { - return; - } - - self.selection.clear(); - self.selection_side = None; + let (old_start, _) = expander.old_range; + let (new_start, _) = expander.new_range; let old_lines: Vec<&str> = self .current_file_old_content @@ -62,14 +73,24 @@ impl DiffViewer { .as_deref() .map(|c| c.lines().collect()) .unwrap_or_default(); - - let count = new_end - new_start + 1; + let count = validate_expander( + &expander, + file.old_line_count, + file.new_line_count, + old_lines.len(), + new_lines.len(), + )?; let mut new_items: Vec = Vec::with_capacity(count); for i in 0..count { let new_ln = new_start + i; let old_ln = old_start + i; + let plain_text = new_lines + .get(new_ln - 1) + .or_else(|| old_lines.get(old_ln - 1)) + .ok_or(NavigationUnavailable::SourceRangeUnavailable)? + .replace('\t', " "); let spans = file .new_highlighted .get(&new_ln) @@ -77,37 +98,26 @@ impl DiffViewer { .cloned() .unwrap_or_default(); - let plain_text = new_lines - .get(new_ln - 1) - .or_else(|| old_lines.get(old_ln - 1)) - .unwrap_or(&"") - .replace('\t', " "); - new_items.push(DisplayItem::Line(types::DisplayLine { line_type: okena_git::DiffLineType::Context, - old_line_num: if old_ln >= 1 && old_ln <= file.old_line_count { - Some(old_ln) - } else { - None - }, - new_line_num: if new_ln >= 1 && new_ln <= file.new_line_count { - Some(new_ln) - } else { - None - }, + old_line_num: Some(old_ln), + new_line_num: Some(new_ln), spans, plain_text, })); } let Some(file) = self.current_file.as_mut() else { - return; + return Err(NavigationUnavailable::MissingCurrentFile); }; + self.selection.clear(); + self.selection_side = None; file.items.splice(item_index..=item_index, new_items); self.max_line_chars = Self::calc_max_line_chars(file); self.update_side_by_side_cache(); cx.notify(); + Ok(()) } pub(super) fn get_selected_text(&self) -> Option { diff --git a/crates/okena-views-git/src/diff_viewer/side_by_side.rs b/crates/okena-views-git/src/diff_viewer/side_by_side.rs index 4750626c6..4a664459c 100644 --- a/crates/okena-views-git/src/diff_viewer/side_by_side.rs +++ b/crates/okena-views-git/src/diff_viewer/side_by_side.rs @@ -7,6 +7,7 @@ use super::types::{ }; use gpui::prelude::*; use gpui::*; +use okena_core::review::ComparisonSide; use okena_core::theme::ThemeColors; use okena_files::code_view::{find_word_boundaries, selection_bg_ranges}; use okena_files::selection::{Selection2DExtension, Selection2DNonEmpty}; @@ -307,7 +308,17 @@ impl DiffViewer { match content { Some(c) => { - let (line_bg, word_bg, accent_color) = self.line_colors(c.line_type, t); + let (line_bg, word_bg, mut accent_color) = self.line_colors(c.line_type, t); + let comparison_side = match side { + SideBySideSide::Left => ComparisonSide::Base, + SideBySideSide::Right => ComparisonSide::Head, + }; + let semantic_highlight = + self.semantic_highlight_matches(comparison_side, c.line_num); + if semantic_highlight { + // The selected symbol claims the accent bar for as long as it stays selected. + accent_color = Some(rgba(t.border_active, 1.0)); + } // Format line number - show empty for 0 let line_num = if c.line_num > 0 { @@ -414,6 +425,9 @@ impl DiffViewer { if let Some(bg) = line_bg { column = column.bg(bg); } + if semantic_highlight { + column = column.bg(rgba(t.term_yellow, 0.2)); + } // Left accent bar (fixed width child, always present for alignment) let accent = div() diff --git a/crates/okena-views-git/src/diff_viewer/types.rs b/crates/okena-views-git/src/diff_viewer/types.rs index 29dfa2442..9969b722d 100644 --- a/crates/okena-views-git/src/diff_viewer/types.rs +++ b/crates/okena-views-git/src/diff_viewer/types.rs @@ -18,6 +18,8 @@ pub enum SideBySideSide { /// Lightweight file stats for sidebar display (no syntax highlighting). pub struct FileStats { pub path: String, + pub old_path: Option, + pub new_path: Option, pub added: usize, pub removed: usize, pub is_binary: bool, @@ -29,6 +31,8 @@ impl From<&FileDiff> for FileStats { fn from(file: &FileDiff) -> Self { Self { path: file.display_name().to_string(), + old_path: file.old_path.clone(), + new_path: file.new_path.clone(), added: file.lines_added, removed: file.lines_removed, is_binary: file.is_binary, diff --git a/docs/review-workspace-product-plan.md b/docs/review-workspace-product-plan.md new file mode 100644 index 000000000..1b16fc999 --- /dev/null +++ b/docs/review-workspace-product-plan.md @@ -0,0 +1,687 @@ +# Review Workspace Product Plan + +## Status + +- **Stage:** Product definition +- **Primary goal:** Help a developer understand what changed in a large pull request or local branch, decide where to spend attention, and send grounded feedback back to an agent. +- **Scope boundary:** Review and navigation only. Editing, refactoring, merging, and conflict resolution are out of scope. +- **Reference branch:** `pletivo/feat/workers-host` against `origin/main` — 14 commits, 385 changed files, 33,045 additions, and 3,365 removals. + +## Executive summary + +Okena should present a large change as a review workspace, not only as a flat file diff. +The workspace should answer four questions: + +1. What is the shape of this change? +2. Which behavior and contracts changed? +3. What evidence supports those changes? +4. What has the reviewer checked, and what should be sent back to the agent? + +The product should have three trust layers: + +1. **Deterministic inventory** built from Git and visible local rules. +2. **Structured review** built from tree-sitter and language-specific extractors. +3. **AI-assisted interpretation** that groups facts into a change story, with every claim linked back to evidence. + +The ordinary line diff remains the final source of detail. The new layers help the reviewer decide which diffs to open and why. + +## Product decision + +Build the review experience as progressive lenses over one exact comparison: + +```text +Review target + ↓ +Deterministic inventory + ↓ +Structured code facts + ↓ +AI-assisted story + ↓ +Line diff + local notes + agent handoff +``` + +Do not make tree-sitter highlighting migration, a persistent workspace index, or a full semantic resolver prerequisites for the review product. + +This approach is best when Okena must remain useful without AI, must explain where every displayed fact came from, and must support local and remote repositories consistently. + +It would be the wrong approach if reviewers consistently cannot orient themselves without a resolved cross-file call graph. In that case, call resolution would need to move earlier than the proposed structured-review phases. + +### Decision axes + +The product options differ on these axes, in priority order: + +1. **Trust and inspectability:** Can a reviewer verify why an item appears? +2. **Orientation value:** Does the view reduce the time needed to form a correct mental model? +3. **Graceful availability:** Does the review still work without AI, GitHub, or language support? +4. **Remote parity:** Can the same product work when the repository lives behind an Okena daemon? +5. **Expansion cost:** Can structure, CallDiff, search, and more languages be added without replacing the review model? +6. **Operational cost:** How much indexing, invalidation, caching, and protocol surface is required before users receive value? + +### Alternatives considered + +#### Deterministic inventory only + +Build Git facts, path rules, commit chronology, and the ordinary diff, but no syntax or AI layers. + +**Best when:** Okena primarily needs a trustworthy large-diff browser and users do not need symbol-level orientation. + +This is the cheapest and most available option. It cannot answer which contracts, functions, or calls changed, so reviewers still reconstruct code shape manually. + +#### Structure-first review without AI + +Build deterministic inventory plus tree-sitter outline, signatures, hotspots, and CallDiff. Do not add inferred chapters or intent. + +**Best when:** Review trust is more important than narrative orientation, or AI availability and latency cannot be assumed. + +This option provides most code-review value and remains inspectable. It may still leave a large coherent branch feeling like several disconnected structural changes. + +#### Layered review workspace — recommended + +Build deterministic inventory first, structured review second, and AI-assisted interpretation as an optional final layer. + +**Best when:** Okena wants the strongest orientation experience without making AI a source of code truth or a hard dependency. + +This has the highest product ceiling and preserves graceful fallback. It costs more product and UI design because provenance, partial coverage, and transitions between lenses must remain coherent. + +### Recommendation confidence and reversibility + +**Confidence:** High for the layered product model; medium for the order of CallDiff and AI-assisted chapters. + +The decision is reversible because each lens consumes versioned review facts and the ordinary diff remains the fallback. AI can be disabled, language adapters can be added independently, and persistent indexing can remain absent until measured demand justifies it. + +The recommendation is wrong if representative reviewers cannot identify important review locations from Inventory, Structure, and signatures, and require cross-file call paths before those lenses become useful. + +## The problem + +Git answers which lines changed. It does not explain the role those lines play in the change. + +The Pletivo reference branch illustrates the problem: + +| File role | Files | Changed lines | Share | +|---|---:|---:|---:| +| Implementation | 97 | 16,506 | 45.3% | +| Tests | 155 | 12,294 | 33.8% | +| Fixtures and examples | 105 | 3,928 | 10.7% | +| Documentation | 17 | 3,100 | 8.5% | +| CI and configuration | 11 | 582 | 1.7% | + +Less than half of the volume is implementation. Twenty-one files are detected renames with an average similarity of 98.3%. A flat file tree presents implementation, supporting evidence, documentation, generated content, and mechanical moves as equivalent work. + +A reviewer currently has to reconstruct the product story manually by reading commit messages, opening large files, identifying public seams, locating tests, and remembering what has already been checked. + +## Target user and job + +The primary user is a senior developer opening a large branch or AI-generated pull request after the implementation work is complete. + +The user needs to: + +- form an accurate mental model quickly; +- separate behavior from supporting volume; +- find changed contracts and high-impact code; +- inspect the evidence for important claims; +- track review coverage; +- attach questions to stable code context; +- send a concise, grounded correction bundle back to an agent. + +The desired feel is a dense forensic workbench: calm, explicit about uncertainty, and optimized for scanning rather than presentation. + +## Review targets + +A review target is a dynamic user choice that resolves to one exact comparison. + +Supported targets should include: + +- working tree against the index or `HEAD`; +- staged changes against `HEAD`; +- one commit against its parent; +- a local branch against a selected base; +- a remote branch against a selected base; +- a GitHub pull request resolved to exact base and head object IDs. + +The UI may show friendly branch or PR names, but every opened review must record the exact object IDs used for analysis. A moved ref should mark the review stale; it must not silently change the comparison under the reviewer. + +Three-dot branch and PR comparisons must use the actual merge-base snapshot consistently for both line and structured comparisons. + +## Trust and provenance model + +Deterministic does not always mean exact. The product must distinguish source and derivation instead of presenting one confidence score. + +| Source class | Examples | UI treatment | +|---|---|---| +| **Git fact** | object IDs, paths, statuses, line counts, commits, rename similarity | Verified source stamp | +| **Rule-derived** | file role from a path pattern, Conventional Commit parsing | Heuristic source stamp with the matching rule | +| **Syntax-derived** | symbols, signatures, enclosing function, call expressions | Language and parser source stamp; partial coverage visible | +| **External context** | PR description, labels, checks, reviews | Shown verbatim as author or GitHub context | +| **AI-inferred** | chapters, intent, causal explanation, suggested review order | Distinct inferred marker and clickable evidence | +| **Reviewer-authored** | notes, reviewed state, manual grouping | Local user state | + +The product must never collapse these classes into one opaque “risk” or “confidence” number. + +## Product vocabulary + +- **Review target:** The branch, commit, working tree, or PR being reviewed. +- **Resolved comparison:** Exact base, head, and merge-base identities used by all lenses. +- **Inventory:** Deterministic facts about files, commits, and volume. +- **File role:** Implementation, test, fixture, snapshot, example, documentation, generated, vendor, lockfile, configuration, or unclassified. +- **Structure:** Packages, modules, files, symbols, signatures, and syntactic relationships. +- **Change chapter:** An AI-inferred or reviewer-edited group of related changes. +- **Evidence:** Tests, fixtures, snapshots, documentation, checks, and measurements that support a behavior change. +- **Attention item:** An explainable reason to inspect a specific change. It is not a risk score. +- **Anchor:** The exact context attached to a note or review state. +- **Coverage:** What the system could analyze and what the reviewer has explicitly checked. + +## Workspace layout + +The review workspace should keep one selected location synchronized across all lenses. + +### Persistent regions + +- **Target header:** Comparison, resolved identities, stale state, commit and file totals. +- **Primary navigation:** Inventory, Structure, Diff, Evidence, Commits, and optional Story. +- **Context sidebar:** Files, symbols, or chapters depending on the active lens. +- **Review notebook:** Local annotations, review state, and the agent handoff bundle. + +Switching lenses should preserve the selected file, symbol, chapter, and nearest hunk whenever possible. + +## Lens 1: Deterministic inventory + +This lens must work without tree-sitter, GitHub, or AI. + +### Change facts + +- exact base, head, and merge-base identities; +- commit count and chronological commit ledger; +- added, removed, modified, renamed, copied, binary, mode-only, and submodule changes; +- lines added and removed per file; +- rename similarity; +- largest additions, removals, and total churn; +- directory and package aggregation; +- Conventional Commit type and scope aggregation when messages match the format. + +### File-role classification + +Classify changed files using ordered, inspectable path rules: + +- implementation; +- test; +- fixture; +- snapshot; +- example or playground; +- documentation; +- generated; +- vendored; +- lockfile; +- CI or configuration; +- unclassified. + +The UI must show the matching rule and allow the reviewer to inspect classification coverage. A deterministic heuristic must not look like a Git fact. + +### Mechanical-change handling + +- collapse high-similarity renames into one move statement; +- expose residual edits inside moved files; +- group generated, vendor, snapshot, and lockfile changes without hiding them; +- show exact collapsed counts and keep them in review coverage; +- allow one-click expansion into the ordinary file diff. + +### GitHub context + +When the comparison maps to a GitHub pull request, add: + +- PR title and description; +- author, labels, milestone, and linked issues; +- exact GitHub base and head object IDs; +- checks and their current state; +- requested and completed reviews; +- unresolved review threads; +- PR commit list. + +GitHub text is author-provided context. It must not be presented as a verified statement about the code. A mismatch between GitHub object IDs and the analyzed repository must be visible. + +## Lens 2: Structured review + +This lens uses tree-sitter plus language-specific extraction and comparison rules. It is syntax-aware, not automatically semantic. + +### Hierarchical outline + +Present the change at multiple levels: + +```text +package + module + file + type / class / trait + function / method +``` + +At every level show: + +- added, removed, modified, and moved items; +- added and removed lines inside the item; +- related hunks; +- analysis coverage and parser errors; +- supporting files attached to the same scope where a deterministic relationship exists. + +### Symbol changes + +For supported languages, identify: + +- added and removed symbols; +- modified symbol bodies; +- moved symbols when matching is unambiguous; +- changed visibility or export status; +- changed parameters, return types, generic parameters, bounds, and modifiers; +- changed fields, variants, properties, and implemented interfaces or traits; +- the enclosing symbol for every changed hunk. + +Ambiguous matches must remain add/remove or explicitly ambiguous. The product must not invent stable symbol identity across revisions. + +### Signature lens + +Provide a compact list of changed signatures before the reviewer opens full diffs. + +Each row should show: + +- old and new signatures; +- the changed portion emphasized; +- public or private visibility; +- callers or references only when the available analysis can support the claim; +- direct navigation to the relevant hunk; +- related tests when a deterministic or clearly labeled heuristic relationship exists. + +### Function and type hotspots + +Support explainable, sortable structural metrics: + +- largest new functions and methods; +- largest modified functions by total size; +- functions with the most changed lines; +- functions with the most parameters; +- deepest syntactic nesting; +- largest types by fields, variants, or methods; +- files and modules containing the most changed symbols; +- new or modified public surface; +- changed symbols without linked test evidence. + +These are measurements, not risk scores. The UI should state the sorting metric directly. + +### Structure-aware diff + +Offer a structure-aware alternative to the line diff: + +- unchanged symbols collapsed; +- symbols shown in source order or grouped by change kind; +- moved symbols separated from body edits; +- signature changes separated from implementation changes; +- comment-only and formatting-only changes labeled when detection is reliable; +- direct fallback to the exact line diff for every item. + +The product should call this “structure-aware” or “syntax-aware” unless semantic name resolution is actually available. + +## Lens 3: CallDiff and call-flow changes + +CallDiff should help a reviewer understand how changed functions interact without claiming a complete call graph. + +The product direction is inspired by [calldiff](https://github.com/tanishqkancharla/calldiff): compare expanded call trees across two Git states, allow a symbol or file to act as the entrypoint, preserve call-site locations, and keep machine-readable output suitable for agents. Okena should integrate the same class of information into the review workspace rather than expose it only as a separate command output. + +### Per-function CallDiff + +For a selected function or method, show: + +- calls added to its body; +- calls removed from its body; +- unchanged calls whose arguments changed; +- constructor, method, macro, and callback registration calls when supported by the language adapter; +- the surrounding control context, such as a condition, loop, error branch, or callback; +- navigation to the call expression and enclosing diff hunk. + +### Call-flow view + +Present a bounded graph or linear flow centered on selected changed symbols: + +- changed symbol as the root; +- outgoing calls before and after; +- incoming references only when resolved or explicitly labeled as textual matches; +- cross-file edges with provenance and confidence class; +- filters for changed-only, same-file, same-package, and resolved-only edges; +- cycle and fan-out visualization where useful. + +Version one may be syntactic and intra-file. Cross-file claims must distinguish: + +- resolved edge; +- probable textual edge; +- ambiguous edge; +- unresolved callee. + +The UI must not label textual callee matching as a semantic call graph. + +A later reachability action may show every known path from one selected symbol to another. As with the rest of CallDiff, unresolved dynamic calls and ambiguous dispatch must remain visible limitations. + +## Lens 4: Evidence + +Evidence should be visible as support for behavior, not as equivalent review volume. + +### Evidence inventory + +- tests and named test cases changed by the review; +- fixtures, snapshots, examples, and playgrounds; +- documentation and architecture notes; +- CI and packaging checks; +- GitHub checks and review state when available. + +### Evidence relationships + +Attach evidence to implementation using progressively weaker mechanisms: + +1. explicit repository metadata; +2. same symbol or imported symbol; +3. test target or module relationship; +4. file-name and path convention; +5. same commit; +6. AI-inferred relationship. + +The relationship source must remain visible. Unlinked evidence should remain in a separate group rather than being hidden. + +### Reviewer workflow + +- open an implementation symbol and its related evidence side by side; +- mark evidence as inspected independently from implementation; +- find changed implementation without evidence; +- find large evidence changes supporting only small behavior changes; +- collapse snapshots and fixtures while preserving their counts and review state. + +## Lens 5: AI-assisted Change Spine + +The Change Spine is an optional interpretation over deterministic and structured facts. + +It should: + +- group commits, files, and symbols into a small number of causal chapters; +- propose a concise intent for each chapter; +- distinguish behavior, contracts, evidence, and mechanical changes; +- suggest an explainable review order; +- show the facts supporting every claim; +- allow the reviewer to rename, split, merge, or reject chapters; +- preserve a deterministic fallback when AI is unavailable. + +AI must not invent code facts. It may interpret observed facts and external context. Every inferred statement must be visually distinct and traceable to source files, symbols, commits, PR text, or documentation. + +For the Pletivo reference branch, a useful proposed spine is: + +1. Extract the host-independent runtime and core. +2. Establish the Worker rendering path. +3. Define isolation and deployable artifacts. +4. Build the live workspace and harden contracts. +5. Qualify and document the Workers host. + +This grouping is a product hypothesis, not repository truth. + +## Review state and annotations + +Annotations are local user data in the first version. + +### Supported anchors + +- review target or chapter; +- file; +- symbol; +- signature change; +- CallDiff edge; +- hunk; +- free line range. + +An anchor should retain path, comparison side, line hint, byte range where available, excerpt, and surrounding context. Re-anchoring after refresh must be best-effort and visible. An uncertain note becomes orphaned; it must never silently move. + +### Review states + +Keep these states separate: + +- **Seen:** The reviewer opened the item. +- **Reviewed:** The reviewer explicitly completed it. +- **Noted:** One or more annotations are attached. +- **Stale:** The underlying comparison changed. + +Review coverage should be based on explicit reviewed items, not scroll position or files opened. + +### Agent handoff + +“Send to agent” should produce a review bundle containing: + +- reviewer notes; +- exact resolved comparison; +- anchor evidence and current snippet; +- relevant line diff; +- enclosing symbol and signature change; +- related CallDiff changes; +- related tests and fixtures; +- chapter intent when selected, labeled as inferred; +- provenance for all included facts. + +The agent should receive enough context to act without repeating the entire repository scan. + +## Search and navigation extensions + +The syntax foundation can later support navigation outside the active review: + +- workspace symbol search; +- jump to symbol; +- file outline and breadcrumbs; +- changed-symbol search; +- search by signature or symbol kind; +- navigation from a review item to unchanged surrounding code. + +These capabilities should reuse normalized syntax facts, but they should not force a persistent workspace index into the first review release. Review analysis only needs the changed comparison plus explicitly opened context. + +## Language coverage + +The product must expose coverage rather than imply completeness. + +The first useful language set should cover both primary dogfood repositories: + +- Rust for Okena; +- TypeScript and TSX for Pletivo; +- JavaScript where it follows the same adapter; +- Astro evaluated separately based on changed-file coverage and grammar quality. + +Every language adapter needs contract fixtures for: + +- broken and incomplete syntax; +- Unicode byte ranges; +- nested symbols; +- overloads or duplicate names; +- signatures; +- imports and call expressions; +- macros or generated syntax where relevant; +- changed-symbol matching across two revisions. + +Unsupported files remain fully available in Inventory and Diff. + +## Syntax highlighting relationship + +Tree-sitter highlighting is a separate migration. + +The structured-review foundation should produce normalized source ranges and facts without GPUI colors or theme types. Highlighting may later reuse the same language registry and parse result, but structured review must not wait for the current syntect highlighting paths to move. + +Syntect can remain the fallback for languages without structured support. + +## Performance and failure behavior + +The review workspace must remain useful on large changes. + +- show deterministic inventory before structured analysis finishes; +- analyze changed files before unrelated repository files; +- run source and Git analysis where the repository lives; +- keep remote transfers to compact review facts where possible; +- use bounded file-size, file-count, parser-time, and query-capture budgets; +- support cancellation and discard stale generations; +- never delay the first ordinary diff paint for structured analysis; +- show parsed, pending, skipped, unsupported, and failed counts; +- keep the ordinary diff available after every analysis failure. + +Partial output must be explicit. Empty structured output must not mean “no structural changes” when analysis was unsupported or incomplete. + +## Product phases + +### Phase 0 — Exact comparison contract + +- resolve every review target to exact object IDs; +- make merge-base semantics consistent between displayed diff and source snapshots; +- detect stale branch and PR targets; +- return coverage and provenance with review results. + +**User value:** The review is trustworthy and reproducible. + +### Phase 1 — Deterministic inventory + +- change totals and status inventory; +- commit ledger; +- path and package aggregation; +- rename detection and residual edits; +- file-role rules; +- mechanical and supporting-change groups; +- provenance ledger; +- optional GitHub PR context. + +**User value:** A large branch becomes scannable without AI or language parsing. + +### Phase 2 — Structured outline and signatures + +- language capability reporting; +- Rust and TypeScript/TSX outlines; +- changed enclosing symbols; +- signature and public-surface changes; +- largest and most-changed functions and types; +- structure-aware navigation to line diffs. + +**User value:** The reviewer can inspect contracts and code shape before reading files linearly. + +### Phase 3 — Review notebook and evidence + +- local annotations; +- Seen and Reviewed states; +- symbol, signature, hunk, and range anchors; +- evidence inventory and deterministic relationships; +- agent handoff bundle. + +**User value:** Review becomes a stateful workflow rather than a sequence of file opens. + +### Phase 4 — CallDiff + +- per-function added and removed calls; +- argument and control-context changes; +- bounded intra-file call flow; +- explicitly classified cross-file edges where available. + +**User value:** Reviewers can understand changed interactions without reconstructing every call manually. + +### Phase 5 — AI-assisted Change Spine + +- inferred chapters and intent; +- evidence-backed attention suggestions; +- reviewer-editable grouping; +- provenance-preserving summaries. + +**User value:** Large coherent changes become a short navigable story without sacrificing access to facts. + +### Phase 6 — Broader navigation + +- workspace symbol search; +- breadcrumbs and outline outside the review; +- optional background workspace index if measured demand justifies it; +- additional languages based on observed coverage gaps. + +**User value:** The review syntax foundation improves everyday code navigation. + +## Explicit non-goals for the first release + +- editing, refactoring, merging, or conflict resolution; +- automatic approval or merge recommendation; +- an opaque risk score; +- a complete semantic call graph; +- rename claims when symbol matching is ambiguous; +- persistent whole-workspace indexing; +- go-to-definition and references across every language; +- cross-device annotation sync; +- GitHub comment publishing; +- tree-sitter highlighting migration; +- stable symbol identity across arbitrary revisions; +- hiding unsupported or unparsed files. + +## Success criteria + +### Reference-branch outcomes + +On the Pletivo reference branch, a reviewer should be able to: + +- see that implementation is less than half of total line volume; +- recognize the runtime and core extraction as moves rather than unrelated churn; +- identify the largest Worker implementation files; +- inspect the commit progression without opening 385 files; +- find changed public seams and large functions in supported languages; +- inspect related tests and fixtures without letting them dominate navigation; +- attach a note to a symbol or hunk and send it with sufficient evidence to an agent. + +### Quality gates + +- deterministic values match Git for a fixture matrix of working tree, staged, commit, branch, rename, delete, binary, and shallow-history comparisons; +- every displayed fact has a provenance class; +- structured navigation lands on the correct symbol and hunk; +- parser failure and unsupported coverage are visible; +- no AI-generated claim appears without source evidence; +- a stale comparison cannot silently retain Reviewed state; +- ordinary diff remains usable when every optional analysis layer fails. + +### Product validation + +Dogfood the workspace on large AI-generated changes in Okena and Pletivo. Observe: + +- which lens reviewers open first; +- whether Inventory or Structure provides the first useful mental model; +- which hotspot measurements lead to real findings; +- whether CallDiff changes review decisions; +- whether reviewers naturally anchor notes to chapters, symbols, signatures, calls, or hunks; +- how often AI chapters are accepted, edited, or rejected; +- which unsupported languages materially block review. + +## Risks and mitigations + +| Risk | Mitigation | +|---|---| +| AI summaries create false confidence | Keep deterministic fallback, inferred markers, and clickable evidence | +| Tree-sitter output is described as semantic | Use syntax-aware terminology and classify unresolved edges | +| Symbol matching invents moves or renames | Preserve ambiguity; fall back to add/remove | +| Large reviews block the UI | Background analysis, budgets, cancellation, progressive results | +| Remote repositories transfer too much source | Analyze where repository data lives and return compact facts | +| Path rules misclassify files | Visible rules, unclassified state, local override | +| GitHub context becomes stale | Compare exact object IDs and surface mismatch | +| Notes move to the wrong code after refresh | Evidence-based re-anchoring and explicit orphan state | +| Supporting files are hidden too aggressively | Always show collapsed counts and include them in coverage | +| Early workspace indexing creates operational burden | Analyze changed files on demand; defer persistent indexing | + +## Decision-relevant unknowns + +- Do reviewers orient faster from deterministic areas and commits, structured symbols, or AI chapters? +- Which structural metrics predict useful review attention without becoming noise? +- Is intra-file CallDiff sufficient for the first useful release? +- How reliable are TypeScript/TSX symbol and call matchers on representative Pletivo changes? +- Does Astro coverage materially limit the Pletivo review experience? +- Which annotation anchor is most natural in practice? +- How often does a GitHub PR description contain useful intent that commits and code do not? + +These questions should be answered through dogfooding before expanding into a persistent index or full cross-file resolution. + +## Recommended next product slice + +Build one vertical review slice over an exact local branch comparison: + +1. Deterministic Inventory with provenance. +2. Structured outline and signature changes for Rust and TypeScript/TSX. +3. Largest and most-changed function lists. +4. Direct navigation from every structured fact to the ordinary line diff. +5. Local notes anchored to a symbol or hunk. +6. Agent bundle export for selected notes. + +Keep the AI Change Spine and cross-file CallDiff behind later experiments. The slice validates whether deterministic structure materially improves review before committing to broader indexing or AI orchestration. + +The recommendation should be reconsidered if reviewers reach relevant symbols but still cannot understand impact without cross-file call paths. In that case, move a bounded resolved CallDiff experiment ahead of AI-assisted chapters. diff --git a/docs/review-workspace-ui-spec.md b/docs/review-workspace-ui-spec.md new file mode 100644 index 000000000..bf47b4bcb --- /dev/null +++ b/docs/review-workspace-ui-spec.md @@ -0,0 +1,305 @@ +# Review Workspace — UI specification (v2) + +Status: approved design, being implemented. Product background: `review-workspace-product-plan.md`. +This document is the source of truth for the review UI. Where the product plan and this +document differ on presentation, this document wins. + +## 1. Goal + +When a reviewer opens a branch comparison the view must answer, in order: + +1. **How big is it really?** Implementation volume vs. supporting volume (tests, fixtures, docs, config). +2. **Where do I start?** One ordered list; every row states the reasons that put it there. +3. **Show me.** The line diff, with the changed symbol's signature and call changes one keystroke away. + +Non-goals for this iteration: notes / reviewed state, evidence links, AI summaries, PR targets, +mutable (working tree / staged) targets, per-commit file lists. The shell reserves space for them. + +## 2. Principles + +- **Rank from git facts first.** The ordered list is built from the inventory (roles, paths, churn, + status, rename similarity) the moment it loads — for 100 % of files, every language. Structure + analysis (tree-sitter) then promotes and annotates the rows it reached. The list is never empty + because tree-sitter did not run. +- **Honest coverage.** Structure-derived counts are lower bounds when analysis is partial and are + shown as `≥ N`. A capped run is “limited”, never “complete”. Debug enum names never reach the screen. +- **One number per fact.** No repeated totals, no zero-valued cells. +- **Pipeline is status, not content.** One status pill in the header; details in a popover; one + caveat line next to the ranked list. No coverage strips. +- **Navigator, not tabs.** Two navigator modes — *Files* (tree) and *Attention* (ordered list). No + Inventory / Structure / CallDiff tabs. Call changes belong to the selected function. +- **Measurements, not scores.** Deterministic tiers with the reason spelled out. No opaque number, no AI. +- **Selection always lands somewhere.** Every row opens something. +- **Small change, small screen.** ≤ 10 files or ≤ 500 changed lines → skip the Overview and open the + first ranked file. Content width < 1 000 px → Overview reflows to one column. + +## 3. Shell + +``` +┌ header: base → head · merge-base │ N files · +A −D · C commits │ [status pill] [Whitespace] [Unified/Split] [⧉] [✕] ┐ +├────────────────────────┬─────────────────────────────────────────────────────────────────────────────┤ +│ navigator (resizable) │ content: Overview | File view │ +│ [Files N] [Attention] │ │ +│ filter box (/) │ │ +│ [Roles · all 11 ▾] │ │ +│ tree | ordered list │ │ +│ footer: filter state │ │ +├────────────────────────┴─────────────────────────────────────────────────────────────────────────────┤ +│ footer: keys that work on THIS screen (both halves used) │ +└──────────────────────────────────────────────────────────────────────────────────────────────────────┘ +``` + +- Header title: `origin/main → feat/workers-host`; for `DiffMode::Commit` the commit subject. Merge-base + short SHA with the exact base/head/merge-base OIDs on hover. Totals from the exact diff when loaded, + else from inventory totals. Whitespace toggle unchanged (`w`). +- The content area shows the Overview until a file / symbol / directory is selected. `o` returns to it. +- Legacy commit-info bar (`[`/`]` between commits) stays only for `DiffMode::Commit` with a commit list. + +## 4. State (client-side) + +Held on the diff viewer next to `SmartReviewState`: + +- `navigator: Files | Attention` +- `content: Overview | File` — the open file is `SmartReviewState::selected_file` (single source of truth); + `selected_symbol: Option`; `queue_target: + Option` (stable identity; the position is derived from the visible Attention order) +- `role_filter: RoleFilter { roles: set of FileRole, preset }` — presets: *Everything* (default), + *Review code* = Implementation + Configuration + Unclassified, *Supporting* = Test, Fixture, + Snapshot, Example, Documentation. Extra saved filters: *likely mechanical only*, *not analyzed only*. +- `attention_filter: set of ReasonKind` (OR) + `include_tests: bool` (default false) +- `expanded_dirs`, `flatten: bool`, `attention_grouped_by_file: bool` +- `details_expanded: bool` (symbol details), remembered for the session +- transient: roles menu open, status popover open, outline popover open + +## 5. Review model (pure, derived from inventory + structure + coverage) + +Built client-side whenever inventory or structure lands (or fails). **Filter-independent**: role filter, +filter text, `include_tests` and grouping are applied by pure view functions over the model, never by +rebuilding it. Files are keyed by `ReviewFileKey`; model order is inventory order and is **not** the diff +pane's `file_stats` order. No GPUI types. + +- **FileEntry** per inventory file: key, path(s), status, role + rule id, similarity, lines added/deleted, + binary, analysis status (Parsed/Partial/Pending/Unsupported/Failed/Skipped/not-in-structure), + reasons (see §6), tier, changed symbols (from structure), churn. +- **Directory aggregation**: tree of directories with file count and summed +/− over all files (the navigator + recomputes totals over the visible subset); + single-child chains joined; `no tests changed next to it` flag on implementation directories + (an implementation directory = contains ≥ 1 implementation-role file; “next to it” = a test-role + file under the same directory subtree **or a test scope inside one of its files**; computed on the + top-most directory that has both kinds where applicable — see §6 tier 4). +- **Inline tests**: a symbol whose scope chain contains a test scope (`mod tests`, `describe`, by the + same names the path rules use) is a test change even though its file is implementation — the usual + shape in Rust. Its lines count once, on the outermost such scope, so a changed `mod tests` does not + count its cases twice. A file carries `has_test_changes` and `inline_test_lines`. +- **Volume by role**: files and changed lines (added + deleted) per role, percentage of total changed + lines; inline test lines are subtracted from the file's own role and added to Tests, so a role may + have lines without files of its own (`in 47 files`); all 11 roles listed, roles with neither files + nor lines omitted from display but present in the model. +- **Facts**: Public API (`removed`, `signatures changed`, `added` counts of Public/Exported symbols; + `lower_bound: bool` when coverage < total), Tests (implementation directories with / without test + changes), Moves (renames split into likely mechanical = residual ≤ 20 lines vs with edits), Commits + (count, merge count = commits with > 1 parent, authors, span, first/last SHA), Also (lockfiles, + submodule pointers, binary files, deleted implementation files). +- **Attention list**: ordered `AttentionItem { target: Symbol{file, symbol_change_index} | File(key) | + Directory(path), tier, reasons: Vec, lines_added, lines_deleted, navigation: Option }`, + deduplicated (a symbol or file appears once with all reasons). “Start here” = first 10 items. +- **Analysis status** for the pill: `LoadingInventory | AnalyzingStructure | Ready{files, languages} | + Limited{analyzed, total} | ReadyWithFailures{failed} | Unavailable`. Any active truncation + (FileLimit / FactLimit / ResponseLimit / …) or ≥ 1 parse failure ⇒ amber, never green. +- **Omission groups** in words: one sentence per `OmittedFileReason` (e.g. “Not analyzed — file limit + (200), taken in path order”, “Unsupported language”, “Skipped — mode-only change”, “Failed to parse”) + with counts, languages/extensions, and the resolved OIDs. + +## 6. Ranking — tiers and reasons + +Deterministic. Every item shows the reasons that placed it. Structure signals apply only to files that +were analyzed; unanalyzed files rank from git facts and carry `not analyzed · `. + +| Tier | Rows | Source | +|---|---|---| +| 1 Contract | Public/Exported symbol removed · deleted implementation file · public signature changed (signature **and** body ranks above signature only) | `SymbolChange` + `visibility()`; `ReviewFileFact.status` | +| 2 Behaviour | Changed functions with call changes — removed or modified calls first; control context (`ErrorBranch`, `Condition`, `Loop`, …) named in the reason | `CallDiffChange` + control context | +| 3 Volume | Most-edited existing implementation functions (changed lines) · largest new implementation functions (line count) and types (member count) — separate measures | `ChangedLines`, `FunctionLineCount`, `TypeMemberCount` **intersected with `SymbolChange`** (hotspots are emitted for every head-side symbol) | +| 4 Git facts | Implementation directory with no test-file changes next to it · CI / config / lockfile / submodule touches · new implementation files by size (any language) · renames with residual > 20 lines · binary implementation files | roles, paths, status, similarity, churn | +| 5 Rest | Every other changed symbol, then remaining files by churn; likely-mechanical moves last | — | + +- Within a tier: implementation role first, then number of reasons, then changed lines desc, then path. +- Unclassified counts as implementation for ranking. +- Complexity (`SyntacticNestingDepth ≥ 5`, `ParameterCount ≥ 6`) is never a tier; it is an extra reason on + a changed symbol worded “changed code in an already complex function”. +- Renames judged by residual lines (`lines_added + lines_deleted`), not similarity. +- Test-role items carry `is_test`; the visible list hides them unless `include_tests` (tiers are unaffected). +- Reason kinds (used as chips and filters): `PublicRemoved`, `PublicSignature`, `ExportedSignature`, + `Body`, `Calls{n, context}`, `New{lines}`, `NewPublic`, `Removed`, `Moved{similarity, residual}`, + `NoTestChanges`, `CiConfig`, `Lockfile`, `Submodule`, `Binary`, `Complex{depth|params}`, + `NotAnalyzed{lang}`, `LargeChurn`. +- Chip wording: `public symbol removed`, `public signature`, `exported signature`, `body`, + `2 calls · error branch`, `new · exported`, `240 lines`, `nesting 6`, `moved 98 %`, `86 residual lines`, + `no tests changed next to it`, `CI config`, `not analyzed · JS`. + +## 7. Navigator + +Segmented control `Files N · Attention N`. Filter box (`/`) applies to both modes. One **Roles** +button opens a menu: presets first, then all 11 roles with counts (checkbox, OR), then the two saved +filters. When a filter is active the button reads the preset name or role names with a clear ✕ and the +sidebar footer says “113 of 385 files · Review code · show all”. Overview clicks (legend rows, facts) +set the same filter. + +**Files mode** — real tree (reuse `okena_files::file_tree` helpers): every row draws one indent guide +per level it hangs under, so depth is visible and not inferred; directory rows show file count and +summed +/− of the visible subset; single-child chains joined; under ~40 files everything is expanded, +above that top-level directories collapsed; `flatten` shows a plain list; virtualized. File rows: icon, +name (rename rows `…/old.ts → …/new.ts` keeping basenames, full paths on hover), at most two reason +markers (`sig N`, `calls`, `removed`, `new`, `moved N %`), +/− right-aligned; role badge only when the +role is not implementation; directory-level `no tests` marker; **not-analyzed files are dimmed** (not +badged), reason on hover. Selection highlight must actually paint (accent left border + selection bg). + +**Outline** (`outline` switch next to `flatten`, key `e`) — one switch inlines every visible file's +changed symbols and, under each of them, what changed inside: no per-file clicking, the whole change +reads by scrolling. Symbol rows: kind glyph, name (qualified on hover), the markers the lines below do +*not* already state (`new`, `removed`, `public`, `moved N %` — never `body` or `calls`), +/−; clicking +one opens the symbol. Detail lines, one line each and truncated with the full text on hover: `sig +` first, then the calls (`+` `−` `~`, the call text, then the branch it sits in, so a +narrow column cuts the branch first), at most six of them and then `… N more`. Identical occurrences +share one line with `×4`. A file with a block under it is filled like a header, its symbols sit one +level in and their detail lines one level further, under the symbol's own glyph column. The outermost +symbol of a test scope carries the `Tests` badge, so inline tests read as tests. A member whose +enclosing symbol changed too sits one level under it, so a class reads as its own outline; a member of +a symbol that was *removed* whole is left out, since the parent's row already says it went. Detail lines +open their symbol on click but `↑` `↓` step over them. The +footer counts what the switch added: `89 files · 4 122 changed symbols · dimmed = not analyzed (8)`. + +**Attention mode** — the full ordered list (Start here is its top). Reason chips at the top act as OR +filters (`sig 12`, `removed 14`, `calls 18`, `new 89`, `no tests 2`, `git facts 24`, `tests` toggle). +Tier separators (`CONTRACT`, `BEHAVIOUR`, `VOLUME`, `GIT FACTS`, `REST`). Two-line rows: kind glyph + +name + churn; then reason chips + path. Footer toggles *ordered list* ↔ *group by file*. Directory and +file items sit in the same list with a different glyph. + +Kind glyphs: `ƒ` function, `m` method, `C` class/struct, `T` type/interface/enum, `M` module, `≡` file, `▸` directory. + +Switching modes keeps the selection: a symbol selected in Attention highlights its file in Files; a +file selected in Files scrolls Attention to its first item. + +## 8. Overview + +Two blocks, then nothing else. Side by side: *Start here* is the page and takes the left column, *Change +at a glance* and the facts are its sidebar on the right (380 px). Content width < 1 000 px stacks them, +the sidebar on top. + +**Change at a glance** — headline `Implementation 15 692 lines · 45 % of 34 640 · 97 files` (hint: +“changed lines = added + deleted”); stacked bar by role; legend rows (swatch, role, files, lines, %), +clickable → role filter. For binary-only / no line totals the headline uses file counts; for +deletion-heavy comparisons the sign is visible. + +Facts (one line each, omitted when empty, never zeros): +- **Public API** — `≥ 3 removed · ≥ 12 signatures changed · ≥ 34 added — analyzed subset, TS/TSX → Attention` + (`≥` only when coverage is partial; “no supported language in this comparison” when applicable). +- **Tests** — `Tests changed next to 4 of 6 implementation directories · none next to packages/workers/src (26 files, +6 118) → show`. “Tests”, not “test files”: a `mod tests` inside an implementation file counts. +- **Moves** — `21 high-similarity moves · 17 likely mechanical (≤ 20 residual lines) · 4 with edits, ranked below → filter`. +- **Commits** — `14 · 1 merge · · 6 days · → show ledger` (ledger: relative + dates via `okena_git::format_relative_time`, SHA, subject, author). “Open commit diff” per row needs an + app-level overlay request and is deferred with the backend items. +- **Also** — `2 lockfiles · 1 submodule pointer · 3 binary files → show`. + +**Start here** — header `Start here · one ordered list · every row names its reasons`, right link +`all N → Attention`, caveat line under it: `structure reached 63 of 97 implementation files (first 200 in +path order) — the rest ranked from git facts` (only when partial). Ten rows of two lines, hairline +separated: index and kind glyph in the gutter, then name with +/− right-aligned, then the dimmed path +with the reason chips after it. Unanalyzed rows dimmed. Footer sentence: tiers and `]` steps through it. + +While structure is loading, git-fact rows are shown immediately (no skeleton); symbol rows are inserted +when structure lands. If structure fails, the list stays and the pill turns red. + +## 9. File view + +Opens on file / symbol / directory selection (a directory item opens its first ranked file and expands the +directory in the tree). + +- **File header** (40 px): path with directory dimmed; role badge (hover: the classifying rule in words); + status; +/−; the file's reason chips; language + parse status; `outline` link (popover with base and + head outlines from `StructuredFile`); queue position `3 of 236` with ‹ › (previous / next in Attention + order). Renames: `old → new · moved 98 %`. Unsupported / unanalyzed: `JavaScript · not analyzed`, no + symbol bar, plain diff, git-fact reasons still shown. Binary: a binary state instead of a diff. +- **Symbol bar** (32 px, sticky at the top of the diff, only when the file has changed symbols): follows + the changed symbol currently in view (hunk → symbol via `SymbolChange.hunks`; deepest enclosing + changed symbol wins; on explicit selection it shows the selected symbol). Kind glyph, name, reason + chips, +/− within the symbol, `changed symbol 1 of 4 · } next`, `▸ details` toggle. Collapsed by + default; `d` or click expands; state remembered per session. +- **Details** (expanded): *Signature (normalized)* block only when `signature_change` exists — old line + with `−`, new line with `+`, the differing span highlighted (token diff of the two normalized strings). + *Calls changed in this function — same file, syntactic; callers are not tracked* only when `call_diff` + is non-empty: `+ callee(args)`, `− callee(args)`, `~ callee(old) → (new)`, control-context stack as a + muted suffix (“in condition”, “in error branch”). Complexity metrics only if they are a reason. +- **Persistent marker**: the selected symbol's hunks keep a left accent marker until another symbol is + selected (replace the 2 s auto-clearing highlight in `review_nav`). +- Diff pane itself is unchanged (unified / split, search, selection, context menus). + +## 10. Analysis status + +Header pill states: `Loading inventory…` (spinner) · `Analyzing structure…` (spinner, no count — the +request is one-shot) · `Structure ready · 385 files · TS, TSX, Rust` (green) · `Structure limited · 200 +of 385 files · details` (amber) · `Structure ready · 3 files failed to parse · details` (amber) · +`Structure unavailable · diff still works` (red). Details popover: one row per omission group in words +with counts and languages/extensions, failed-parse stage + message, resolved OIDs, and the sentence “Not +analyzed files stay in the tree (dimmed), open as a plain diff, and are ranked from git facts.” + +## 11. Keyboard + +Two regions (navigator, content). `Tab` traverses controls inside a region; `F6` / `Ctrl+1` / `Ctrl+2` +switch region. Single-letter shortcuts are inert while a text field has focus. `1`/`2` are swallowed by +the overlay. All bindings rebindable; `?` shows the map. Footer hints only for keys that work on the +current screen. + +| Key | Action | +|---|---| +| `↑` `↓` | move in navigator (list keeps selection visible; moving the selection opens the row in the content area, `↵` additionally moves focus to content) | +| `←` `→` `Space` `Home` `End` | collapse / expand / toggle tree node; jump | +| `1` `2` | navigator mode Files / Attention | +| `/` | focus filter box (`Esc` clears and returns) | +| `r` | Roles menu | +| `e` | outline: changed symbols and their changes, inline in the file tree | +| `o` | Overview | +| `]` `[` | next / previous item in Attention order (from any file; keeps queue position) | +| `}` `{` | next / previous changed symbol in the open file | +| `Alt+↓` `Alt+↑` | next / previous hunk | +| `d` | expand / collapse symbol details | +| `s` `w` | split / unified · ignore whitespace (content focused, as today) | +| `Ctrl+F` `n` `N` | find in the displayed diff, next / previous match; on the Overview `Ctrl+F` focuses the navigator filter | +| `y` | copy `path:line` of the current symbol / hunk | +| `Ctrl+C` | copy diff selection, or the selected navigator row (path / qualified symbol) when the navigator is focused | +| `?` | shortcut help | +| `Esc` | close find → back to Overview → close the review; never closes from inside an input without clearing it first | + +## 12. Edge cases + +| Case | Behaviour | +|---|---| +| ≤ 10 files or ≤ 500 changed lines | open on the first ranked file with a one-line summary in the file header; Overview on `o`; navigator fully expanded | +| 2 000 files | tree virtualized, top-level directories collapsed, flatten available; Attention built from git facts for all files; caveat states structure reach | +| all-unsupported languages | Public API fact says “no supported language in this comparison”; Start here = git-fact ranking; nothing empty | +| structure fails after inventory | pill red; git-fact rows stay; symbol markers / symbol bar / details absent all at once; diff unaffected | +| binary-only / deletion-heavy | headline in file counts / sign visible; binary files get a binary state | +| single commit target | header subtitle = commit subject; Commits fact hidden; merge-base hidden | +| content width < 1 000 px | Overview one column; file header drops language and role labels behind the badge tooltip; symbol bar keeps name + first two chips | +| whitespace toggle | reloads diff + structure only; header +/− tagged “ignoring whitespace” | + +## 13. Data mapping + +Client-side from existing types: volume by role, directory aggregation, tests fact, moves split, Public +API counts (`SymbolChange` + `visibility()`), tiers and reasons (`SymbolChange`, `CallDiffChange`, +hotspots ∩ `SymbolChange`, inventory facts), signature token diff (normalized strings), symbol bar +following the viewport (`SymbolChange.hunks`), outline popover (`StructuredFile.old_outline / +new_outline`), commits summary (`ReviewCommitFact.parent_oids`, `timestamp` + `format_relative_time`), +status pill / popover (`ReviewCoverage`, `OmittedFileGroup`, `LanguageCoverage`, `AnalysisError`, +`StructuredFile.status`), rule id → label map (11 rules). + +Backend (out of scope here, tracked separately): per-commit file list (Commits navigator mode), analysis +selection policy (implementation + churn first instead of path order), stale-target check, file lists per +omission group (“show files”), progress events, retry action. + +## 14. Removed from the current UI + +Lens tab bar · both stat strips and per-lens summary strips · the unlabeled selected-path line · the +flat section stream · Structure tab's Outline / File errors / Language coverage / Aggregate omissions / +Aggregate errors sections · CallDiff tab · raw rule ids, provenance words, Debug enum names, unix epochs +in text · footer hints for keys that do nothing on the current screen.