diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 2b1ed0e9..04aa1aa0 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -886,9 +886,15 @@ dependencies = [ "ignore", "lithe-ai", "lithe-core-rules", + "lithe-diff", "lithe-git-host", + "lithe-github", "lithe-ide-host", + "lithe-network", + "lithe-process", + "lithe-protocol", "lithe-remote", + "lithe-terminal", "quick-xml", "rand", "regex", @@ -896,9 +902,7 @@ dependencies = [ "serde_json", "serde_yaml_ng", "sha2", - "similar", "toml", - "unicode-normalization", "url", "zip", ] @@ -909,6 +913,17 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "unicode-normalization", +] + +[[package]] +name = "lithe-diff" +version = "0.1.0" +dependencies = [ + "lithe-protocol", + "serde", + "serde_json", + "similar", ] [[package]] @@ -922,6 +937,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "lithe-github" +version = "0.1.0" +dependencies = [ + "lithe-protocol", + "serde", + "serde_json", +] + [[package]] name = "lithe-ide-host" version = "0.1.0" @@ -937,6 +961,30 @@ dependencies = [ "uuid", ] +[[package]] +name = "lithe-network" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "lithe-process" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "lithe-protocol" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "lithe-remote" version = "0.1.0" @@ -949,6 +997,14 @@ dependencies = [ "trash", ] +[[package]] +name = "lithe-terminal" +version = "0.1.0" +dependencies = [ + "lithe-protocol", + "serde", +] + [[package]] name = "lock_api" version = "0.4.14" diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index 58d4a623..f9fa8cab 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -119,7 +119,8 @@ an IPC round trip per key event. The maximum table has 4096 outcomes. and returns `{matches: boolean}`. It evaluates the same precompiled table, rejecting oversized, noncanonical, duplicate-key or incorrectly sized plans with `invalid_request`; it never reparses source. Parsed tables are portable -data, not authorization tokens. Neither command retains global state. +data, not authorization tokens. Neither command retains global state. Both live +in the pure crate `rust/crates/core-rules`. Windows validates imports before changing the preset/store and validates additions/updates before persistence. Hydrated/default/extension bindings stay @@ -147,6 +148,7 @@ is the double-tap Shift gesture. Parse failures are listed in `invalid` with `em `multiple_keys`, `too_long` or `too_many_steps` and never take part in conflicts; more than 20000 entries is `invalid_request`. Hosts reject saving a binding when a conflict contains the edited command. Native key-event conversion and the synchronous matching path stay in the host. +The analysis lives in the pure crate `rust/crates/core-rules`. See `rust/fixtures/keymap/binding-conflicts-v1.json`. `keymap.modifierGestureStep` accepts `{state?, event, timestamp}` and returns `{state, triggered}`. It is the @@ -238,7 +240,8 @@ release response strings with `lithe_core_free_string`. alongside their current interface translations. Plugin-owned titles stay verbatim. Core performs case-insensitive, NFC-normalized Unicode subsequence matching after removing query whitespace, preserving input order and returning zero-based candidate offsets. -It retains no catalog, performs no workspace I/O, and never executes actions. +It retains no catalog, performs no workspace I/O, and never executes actions. The +matcher lives in the pure crate `rust/crates/core-rules`. The request is limited to 4096 candidates, 16 fields per candidate, 2 MiB of text and a 4096-byte query; excess input returns `invalid_request`. [Action search fixtures](../fixtures/search/actions-v1.json) cover both languages, @@ -473,7 +476,7 @@ or removing a command fails until the table changes in the same commit. | `workspace.searchIndex.warm` | Build or reuses the workspace search index | | `workspace.searchIndex.update` | Apply changed paths to the search index | | `workspace.searchIndex.invalidate` | Drop a workspace's cached search index | -| `search.actions` | Match host-localized action text without workspace I/O | +| `search.actions` | Match host-localized action text without workspace I/O (from `lithe-core-rules`) | | `workspace.search` | Search visible file names and UTF-8 text files | | `workspace.searchEverywhere` | Search file paths, contents, and symbols | | `workspace.replacePreview` | Return deterministic replacement lines and complete replacement text | @@ -504,9 +507,9 @@ or removing a command fails until the table changes in the same commit. | `processLifecycle.inventory` | Group a process snapshot of one Lithe instance into terminals, Agent tasks and background services | | `processLifecycle.settle` | Turn confirmation answers into what ends, what is kept and the updated settings | | `markdown.render` | Render and sanitizes shared Markdown | -| `keymap.parseContext` | Compile a bounded boolean shortcut context | -| `keymap.evaluateContext` | Evaluate a precompiled shortcut context | -| `keymap.analyzeConflicts` | Find conflicts among the final layered shortcut bindings | +| `keymap.parseContext` | Compile a bounded boolean shortcut context (from `lithe-core-rules`) | +| `keymap.evaluateContext` | Evaluate a precompiled shortcut context (from `lithe-core-rules`) | +| `keymap.analyzeConflicts` | Find conflicts among the final layered shortcut bindings (from `lithe-core-rules`) | | `keymap.modifierGestureStep` | Advance the double-Shift gesture recognizer by one event. The rule itself lives in `lithe-core-rules` so the Windows frontend can run it synchronously | | `editor.workbenchState.reduce` | Apply one editor-group state transition: open, close, move, pin, preview, merge or normalize. The rule lives in `lithe-core-rules` so the Windows frontend can run it synchronously inside store updates | | `workspace.classifyVisibility` | Classify a batch of workspace-relative paths as hidden or visible. The rule lives in `lithe-core-rules` so the Windows frontend can run it synchronously | @@ -549,7 +552,7 @@ or removing a command fails until the table changes in the same commit. | `editor.preferences.resolve` | Resolve persisted editor presentation preferences | | `editor.recent.reduce` | Reduce and queries bounded recent-file history | | `editor.recovery.decide` | Decide how a native checkpoint should recover | -| `keymap.schemes.reduce` | Reduce named shortcut schemes | +| `keymap.schemes.reduce` | Reduce named shortcut schemes (from `lithe-core-rules`) | | `editor.planTextEdits` | Validate and order a plugin document edit batch into UTF-16 offsets with the document's line ending | | `editor.parseNavigationInput` | Parse a bounded line or line:column input into zero-based editor coordinates | | `editor.navigationHistory.reduce` | Reduce one bounded back/forward navigation history event and return the next state plus the location to open | @@ -5815,6 +5818,7 @@ characters without controls and are case-insensitively unique; IDs are ASCII alp hyphen or underscore, up to 64 bytes. Bindings use existing explicit-modifier key syntax. Failures use `invalid_request` plus stable details such as `invalid_name`, `duplicate_name`, `too_many_schemes`, `missing_scheme`, `default_is_fixed` and `invalid_binding`. -No third-party keymap import or plugin protocol change is introduced. +No third-party keymap import or plugin protocol change is introduced. The reducer +lives in the pure crate `rust/crates/core-rules`. Executable vectors: `rust/fixtures/editor/ide-essentials-v1.json` (C ABI tests). diff --git a/rust/crates/core-rules/Cargo.toml b/rust/crates/core-rules/Cargo.toml index f11f11d2..27fbb34f 100644 --- a/rust/crates/core-rules/Cargo.toml +++ b/rust/crates/core-rules/Cargo.toml @@ -8,3 +8,4 @@ description = "Pure synchronous rules linked into Lithe Core" [dependencies] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +unicode-normalization = "0.1" diff --git a/rust/crates/core/src/action_search/mod.rs b/rust/crates/core-rules/src/action_search.rs similarity index 81% rename from rust/crates/core/src/action_search/mod.rs rename to rust/crates/core-rules/src/action_search.rs index 2864edec..eb8d859a 100644 --- a/rust/crates/core/src/action_search/mod.rs +++ b/rust/crates/core-rules/src/action_search.rs @@ -1,7 +1,8 @@ //! Bounded, stateless matching of host-localized action catalog snapshots. -use crate::protocol::{CoreError, ErrorCode}; +use crate::{RuleError, RuleErrorCode}; use serde::Deserialize; +use serde_json::{json, Value}; use unicode_normalization::UnicodeNormalization; const MAX_CANDIDATES: usize = 4096; @@ -11,20 +12,20 @@ const MAX_FIELDS: usize = 16; /// Caller-owned action text; position in the request is its opaque identity. #[derive(Deserialize)] -pub(crate) struct Candidate { +struct Candidate { /// Original and localized presentation fields, in their display order. texts: Vec, } /// One small action catalog; matching never scans workspace files or retains data. #[derive(Deserialize)] -pub(crate) struct Request { +struct Request { query: String, candidates: Vec, } /// Returns matching candidate offsets in input order, preserving native actions. -pub(crate) fn search(request: Request) -> Result, CoreError> { +fn search(request: Request) -> Result, RuleError> { let bytes = request.candidates.iter().try_fold(0usize, |total, item| { item.texts .iter() @@ -38,8 +39,8 @@ pub(crate) fn search(request: Request) -> Result, CoreError> { .any(|candidate| candidate.texts.len() > MAX_FIELDS) || bytes.is_none_or(|count| count > MAX_TEXT_BYTES) { - return Err(CoreError::new( - ErrorCode::InvalidRequest, + return Err(RuleError::new( + RuleErrorCode::InvalidRequest, "Action search exceeds its input budget", )); } @@ -75,6 +76,21 @@ pub(crate) fn search(request: Request) -> Result, CoreError> { .collect()) } +/// Command name of [`search_actions_json`]. +pub const SEARCH_ACTIONS: &str = "search.actions"; + +/// Matches a JSON action catalog and returns `{"indices": [...]}` in input order. +pub fn search_actions_json(payload: Value) -> Result { + let request: Request = serde_json::from_value(payload).map_err(|error| { + RuleError::new( + RuleErrorCode::InvalidRequest, + "Invalid action search request", + ) + .with_details(error.to_string()) + })?; + search(request).map(|indices| json!({ "indices": indices })) +} + #[cfg(test)] mod tests { use super::*; @@ -104,7 +120,7 @@ mod tests { }; assert!(matches!( search(request).unwrap_err().code, - ErrorCode::InvalidRequest + RuleErrorCode::InvalidRequest )); let request = Request { query: "x".into(), diff --git a/rust/crates/core-rules/src/dispatch.rs b/rust/crates/core-rules/src/dispatch.rs index 656e5c3f..2bbfe546 100644 --- a/rust/crates/core-rules/src/dispatch.rs +++ b/rust/crates/core-rules/src/dispatch.rs @@ -1,11 +1,16 @@ //! Command routing for the rules `lithe-core` forwards to this crate. -use crate::{editor, keymap, visibility, workbench, RuleError, RuleErrorCode}; +use crate::{action_search, editor, keymap, visibility, workbench, RuleError, RuleErrorCode}; use serde_json::Value; /// Every command name this crate owns. `lithe-core` routes exactly these names here. pub const COMMANDS: &[&str] = &[ + action_search::SEARCH_ACTIONS, + keymap::ANALYZE_CONFLICTS, + keymap::EVALUATE_CONTEXT, keymap::MODIFIER_GESTURE_STEP, + keymap::PARSE_CONTEXT, + keymap::REDUCE_SCHEMES, editor::REDUCE_WORKBENCH_STATE, visibility::CLASSIFY_VISIBILITY, visibility::VISIBILITY_DEFAULTS, @@ -15,7 +20,12 @@ pub const COMMANDS: &[&str] = &[ /// Runs one rule on an already decoded payload. pub fn execute(command: &str, payload: Value) -> Result { match command { + action_search::SEARCH_ACTIONS => action_search::search_actions_json(payload), + keymap::ANALYZE_CONFLICTS => keymap::analyze_conflicts_json(payload), + keymap::EVALUATE_CONTEXT => keymap::evaluate_context_json(payload), keymap::MODIFIER_GESTURE_STEP => keymap::modifier_gesture_step_json(payload), + keymap::PARSE_CONTEXT => keymap::parse_context_json(payload), + keymap::REDUCE_SCHEMES => keymap::reduce_schemes_json(payload), editor::REDUCE_WORKBENCH_STATE => editor::reduce_json(payload), visibility::CLASSIFY_VISIBILITY => visibility::classify_visibility_json(payload), visibility::VISIBILITY_DEFAULTS => visibility::visibility_defaults_json(payload), diff --git a/rust/crates/core/src/keymap/bindings.rs b/rust/crates/core-rules/src/keymap/bindings.rs similarity index 92% rename from rust/crates/core/src/keymap/bindings.rs rename to rust/crates/core-rules/src/keymap/bindings.rs index ab1d4a2d..fef5f405 100644 --- a/rust/crates/core/src/keymap/bindings.rs +++ b/rust/crates/core-rules/src/keymap/bindings.rs @@ -4,7 +4,9 @@ //! Core owns what a binding string means, which bindings are in force after //! defaults, presets and user overrides are layered, and which of them collide. +use crate::{RuleError, RuleErrorCode}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use std::collections::BTreeMap; const MAX_ENTRIES: usize = 20_000; @@ -199,15 +201,15 @@ fn normalize_when(when: &Option) -> Option { /// Layers bindings per command and reports every collision among the result. pub fn analyze_conflicts( request: AnalyzeConflictsRequest, -) -> Result { +) -> Result { let total: usize = request .layers .iter() .map(|layer| layer.entries.len() + layer.disabled_commands.len()) .sum(); if total > MAX_ENTRIES { - return Err(crate::CoreError::new( - crate::ErrorCode::InvalidRequest, + return Err(RuleError::new( + RuleErrorCode::InvalidRequest, "Too many keymap bindings", )); } @@ -267,6 +269,20 @@ pub fn analyze_conflicts( Ok(AnalyzeConflictsResponse { conflicts, invalid }) } +/// Command name of [`analyze_conflicts_json`]. +pub const ANALYZE_CONFLICTS: &str = "keymap.analyzeConflicts"; + +/// Runs `analyze_conflicts` on a JSON payload. +pub fn analyze_conflicts_json(payload: Value) -> Result { + let request: AnalyzeConflictsRequest = serde_json::from_value(payload).map_err(|_| { + RuleError::new( + RuleErrorCode::InvalidRequest, + "Invalid keymap conflict request", + ) + })?; + Ok(serde_json::to_value(analyze_conflicts(request)?).expect("conflict result should encode")) +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/crates/core/src/keymap/mod.rs b/rust/crates/core-rules/src/keymap/context.rs similarity index 90% rename from rust/crates/core/src/keymap/mod.rs rename to rust/crates/core-rules/src/keymap/context.rs index 8ba3136b..5c93ab40 100644 --- a/rust/crates/core/src/keymap/mod.rs +++ b/rust/crates/core-rules/src/keymap/context.rs @@ -1,10 +1,6 @@ //! Bounded context parsing and Core-owned truth tables for synchronous key dispatch. -mod bindings; -pub(crate) mod schemes; - -pub use bindings::{analyze_conflicts, AnalyzeConflictsRequest}; - +use crate::{RuleError, RuleErrorCode}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -346,7 +342,7 @@ pub fn parse_context(request: ParseContextRequest) -> ParseContextResponse { /// Evaluates a portable precompiled table, rejecting malformed or oversized tables. pub fn evaluate_context( request: EvaluateContextRequest, -) -> Result { +) -> Result { let parsed = request.parsed; if parsed.keys.len() > MAX_CONTEXT_KEYS || parsed.values.len() != 1usize << parsed.keys.len() @@ -355,8 +351,8 @@ pub fn evaluate_context( key.is_empty() || key.len() > MAX_SOURCE_BYTES || !key.chars().all(key_character) }) { - return Err(crate::CoreError::new( - crate::ErrorCode::InvalidRequest, + return Err(RuleError::new( + RuleErrorCode::InvalidRequest, "Invalid compiled keymap context", )); } @@ -368,6 +364,35 @@ pub fn evaluate_context( }) } +/// Command name of [`parse_context_json`]. +pub const PARSE_CONTEXT: &str = "keymap.parseContext"; + +/// Command name of [`evaluate_context_json`]. +pub const EVALUATE_CONTEXT: &str = "keymap.evaluateContext"; + +/// Runs `parse_context` on a JSON payload. Syntax errors in the expression are part of the +/// successful response; only an undecodable payload fails. +pub fn parse_context_json(payload: serde_json::Value) -> Result { + let request: ParseContextRequest = serde_json::from_value(payload).map_err(|_| { + RuleError::new( + RuleErrorCode::InvalidRequest, + "Invalid keymap parse request", + ) + })?; + Ok(serde_json::to_value(parse_context(request)).expect("context plan should encode")) +} + +/// Runs `evaluate_context` on a JSON payload. +pub fn evaluate_context_json(payload: serde_json::Value) -> Result { + let request: EvaluateContextRequest = serde_json::from_value(payload).map_err(|_| { + RuleError::new( + RuleErrorCode::InvalidRequest, + "Invalid keymap evaluation request", + ) + })?; + Ok(serde_json::to_value(evaluate_context(request)?).expect("context result should encode")) +} + #[cfg(test)] mod tests { use super::*; diff --git a/rust/crates/core-rules/src/keymap/mod.rs b/rust/crates/core-rules/src/keymap/mod.rs index bb668b5c..fe4795e5 100644 --- a/rust/crates/core-rules/src/keymap/mod.rs +++ b/rust/crates/core-rules/src/keymap/mod.rs @@ -1,8 +1,15 @@ -//! Keymap rules that must run synchronously on native key events. +//! Keymap rules: binding grammar and conflicts, `when` contexts, saved schemes and the modifier +//! gesture recognizer that runs on every native key event. +mod bindings; +mod context; mod modifier_gesture; +mod schemes; +pub use bindings::{analyze_conflicts_json, ANALYZE_CONFLICTS}; +pub use context::{evaluate_context_json, parse_context_json, EVALUATE_CONTEXT, PARSE_CONTEXT}; pub use modifier_gesture::{ modifier_gesture_step, modifier_gesture_step_json, GestureEvent, GestureState, GestureStep, MODIFIER_GESTURE_STEP, THRESHOLD_SECONDS, }; +pub use schemes::{reduce_schemes_json, REDUCE_SCHEMES}; diff --git a/rust/crates/core/src/keymap/schemes.rs b/rust/crates/core-rules/src/keymap/schemes.rs similarity index 81% rename from rust/crates/core/src/keymap/schemes.rs rename to rust/crates/core-rules/src/keymap/schemes.rs index 8e752495..74b14eee 100644 --- a/rust/crates/core/src/keymap/schemes.rs +++ b/rust/crates/core-rules/src/keymap/schemes.rs @@ -1,7 +1,7 @@ //! Named shortcut schemes with independent overrides and an undeletable default. use super::bindings::{parse_binding, BindingPlatform}; -use crate::protocol::{CoreError, ErrorCode}; +use crate::{RuleError, RuleErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; @@ -46,9 +46,9 @@ struct Request { bindings: Bindings, } -fn rejected(reason: &str) -> CoreError { - CoreError::new( - ErrorCode::InvalidRequest, +fn rejected(reason: &str) -> RuleError { + RuleError::new( + RuleErrorCode::InvalidRequest, "Shortcut scheme operation was rejected", ) .with_details(reason) @@ -62,7 +62,7 @@ fn valid_id(value: &str) -> bool { .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } -fn validate_bindings(bindings: &Bindings) -> Result<(), CoreError> { +fn validate_bindings(bindings: &Bindings) -> Result<(), RuleError> { if bindings.len() > 4096 { return Err(rejected("too_many_bindings")); } @@ -81,7 +81,13 @@ fn validate_bindings(bindings: &Bindings) -> Result<(), CoreError> { Ok(()) } -pub(crate) fn reduce_command(payload: Value) -> Result { +/// Command name of [`reduce_schemes_json`]. +pub const REDUCE_SCHEMES: &str = "keymap.schemes.reduce"; + +/// Applies one scheme operation (`update`, `select`, `saveAs`, `rename` or `delete`; empty only +/// normalizes) to the saved schemes, creating the undeletable default from the current bindings +/// on first use. +pub fn reduce_schemes_json(payload: Value) -> Result { if payload.to_string().len() > 1024 * 1024 { return Err(rejected("too_large")); } @@ -200,31 +206,32 @@ mod tests { use super::*; #[test] fn migration_copy_switch_and_delete_preserve_independent_overrides() { - let default = reduce_command(json!({"current":{"search.files":["meta+f"]}})).unwrap(); - let copied = reduce_command( + let default = reduce_schemes_json(json!({"current":{"search.files":["meta+f"]}})).unwrap(); + let copied = reduce_schemes_json( json!({"state":default["state"],"operation":"saveAs","id":"mine","name":"My keys"}), ) .unwrap(); - let edited = reduce_command(json!({"state":copied["state"],"operation":"update","bindings":{"search.files":["meta+g"]}})).unwrap(); - let switched = - reduce_command(json!({"state":edited["state"],"operation":"select","id":"default"})) - .unwrap(); + let edited = reduce_schemes_json(json!({"state":copied["state"],"operation":"update","bindings":{"search.files":["meta+g"]}})).unwrap(); + let switched = reduce_schemes_json( + json!({"state":edited["state"],"operation":"select","id":"default"}), + ) + .unwrap(); assert_eq!(switched["bindings"]["search.files"], json!(["meta+f"])); let deleted = - reduce_command(json!({"state":edited["state"],"operation":"delete","id":"mine"})) + reduce_schemes_json(json!({"state":edited["state"],"operation":"delete","id":"mine"})) .unwrap(); assert_eq!(deleted["state"]["active"], "default"); assert_eq!(deleted["bindings"], default["bindings"]); } #[test] fn invalid_names_duplicate_names_and_default_deletion_are_rejected() { - assert!(reduce_command(json!({"operation":"saveAs","id":"a","name":" \n"})).is_err()); - assert!(reduce_command(json!({"operation":"delete","id":"default"})).is_err()); - let state = reduce_command(json!({"operation":"saveAs","id":"a","name":"Mine"})).unwrap() - ["state"] + assert!(reduce_schemes_json(json!({"operation":"saveAs","id":"a","name":" \n"})).is_err()); + assert!(reduce_schemes_json(json!({"operation":"delete","id":"default"})).is_err()); + let state = reduce_schemes_json(json!({"operation":"saveAs","id":"a","name":"Mine"})) + .unwrap()["state"] .clone(); assert_eq!( - reduce_command(json!({"state":state,"operation":"saveAs","id":"b","name":"mine"})) + reduce_schemes_json(json!({"state":state,"operation":"saveAs","id":"b","name":"mine"})) .unwrap_err() .details .as_deref(), diff --git a/rust/crates/core-rules/src/lib.rs b/rust/crates/core-rules/src/lib.rs index a438b298..d3f3416a 100644 --- a/rust/crates/core-rules/src/lib.rs +++ b/rust/crates/core-rules/src/lib.rs @@ -8,6 +8,7 @@ //! command dispatch, so hosts reach the rules over the usual Core C ABI. Rule failures use //! [`RuleError`], which `lithe-core` maps one to one onto its stable error codes. +pub mod action_search; mod dispatch; pub mod editor; mod error; diff --git a/rust/crates/core/Cargo.toml b/rust/crates/core/Cargo.toml index 51561e34..8f934347 100644 --- a/rust/crates/core/Cargo.toml +++ b/rust/crates/core/Cargo.toml @@ -16,8 +16,14 @@ plugin-sdk-e2e = [] [dependencies] lithe-ai = { path = "../ai" } lithe-core-rules = { path = "../core-rules" } +lithe-diff = { path = "../diff" } lithe-git-host = { path = "../git-host" } +lithe-github = { path = "../github" } lithe-ide-host = { path = "../ide-host" } +lithe-network = { path = "../network" } +lithe-process = { path = "../process" } +lithe-protocol = { path = "../protocol" } +lithe-terminal = { path = "../terminal" } lithe-remote = { path = "../remote", default-features = false, features = ["client"] } ammonia = "4.1.4" base64 = "0.22" @@ -32,10 +38,8 @@ serde_json = { version = "1.0", features = ["preserve_order"] } sha2 = "0.10" toml = { version = "1.1.4", default-features = false, features = ["parse", "serde"] } serde_yaml_ng = "0.10.0" -similar = { version = "3.2", default-features = false, features = ["std"] } zip = { version = "2.4", default-features = false, features = ["deflate"] } url = "2.5" -unicode-normalization = "0.1" ignore = "=0.4.33" [target.'cfg(unix)'.dev-dependencies] diff --git a/rust/crates/core/src/git/github_checkout.rs b/rust/crates/core/src/git/github_checkout.rs index 764b9bac..947b100b 100644 --- a/rust/crates/core/src/git/github_checkout.rs +++ b/rust/crates/core/src/git/github_checkout.rs @@ -3,7 +3,7 @@ //! Note: .agents/notes/implemented/bug-fix/2026-11-04-github-pull-request-checkout-safety.md use super::*; -use crate::github::{ +use lithe_github::{ parse_remote, pull_request_checkout_plan, GitHubRepository, ParseRemoteRequest, PullRequestCheckoutPlan, PullRequestCheckoutPlanRequest, }; diff --git a/rust/crates/core/src/git/mod.rs b/rust/crates/core/src/git/mod.rs index 6dd5b08d..798a9c6b 100644 --- a/rust/crates/core/src/git/mod.rs +++ b/rust/crates/core/src/git/mod.rs @@ -8209,7 +8209,7 @@ fn flush_diff_changes( ) { let removed_text: Vec<&str> = removed.iter().map(|entry| entry.text.as_str()).collect(); let added_text: Vec<&str> = added.iter().map(|entry| entry.text.as_str()).collect(); - for (left_index, right_index) in crate::diff::pair_lines(&removed_text, &added_text) { + for (left_index, right_index) in lithe_diff::pair_lines(&removed_text, &added_text) { let left = left_index.map(|index| &removed[index]); let right = right_index.map(|index| &added[index]); let kind = match (left.is_some(), right.is_some()) { @@ -8224,7 +8224,7 @@ fn flush_diff_changes( }); let (left_changes, right_changes) = match left.zip(right) { Some((left, right)) if is_invisible_change.is_none() => { - match crate::diff::inline_changes( + match lithe_diff::inline_changes( without_one_trailing_cr(&left.text), without_one_trailing_cr(&right.text), ) { diff --git a/rust/crates/core/src/lib.rs b/rust/crates/core/src/lib.rs index 318230fe..27808af3 100644 --- a/rust/crates/core/src/lib.rs +++ b/rust/crates/core/src/lib.rs @@ -1,25 +1,18 @@ //! Deterministic application services shared by the macOS and Windows hosts. -mod action_search; mod build; mod debug; pub mod diagnostics; -mod diff; mod editor; pub mod execution; mod git; -mod github; -mod keymap; mod lsp; -mod network; pub mod plugins; -pub mod process; mod project; mod project_generators; mod protocol; mod remote; mod runtime; -mod terminal; /// Shared toolchain policy, independent of plugin transport and native discovery. pub mod toolchain; mod vcs; diff --git a/rust/crates/core/src/protocol/contracts.rs b/rust/crates/core/src/protocol/contracts.rs index f1452269..abe16a0f 100644 --- a/rust/crates/core/src/protocol/contracts.rs +++ b/rust/crates/core/src/protocol/contracts.rs @@ -1,59 +1,6 @@ //! Serializable response models shared across every host boundary. -use crate::protocol::CoreError; use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -/// Stable success-or-failure envelope returned across the JSON and C boundaries. -pub struct CoreResponse { - /// Correlation identifier copied from the request, when supplied. - pub id: Option, - /// Discriminator that determines whether `data` or `error` is present. - pub ok: bool, - /// Successful command payload. It is omitted for failures. - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, - /// Structured failure. It is omitted for successful responses. - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Debug, Serialize)] -#[serde(untagged)] -/// Payload wrapper that preserves each command's existing JSON shape. -pub enum ResponseData { - /// A command-specific JSON value serialized without an additional tag. - Json(Value), -} - -impl CoreResponse { - /// Reports whether the response carries successful data. - pub fn is_success(&self) -> bool { - self.ok - } - - /// Builds a successful response while preserving the caller's identifier. - pub fn success(id: Option, data: impl Into) -> Self { - Self { - id, - ok: true, - data: Some(ResponseData::Json(data.into())), - error: None, - } - } - - /// Builds a failed response with no partially successful data attached. - pub fn failure(id: Option, error: CoreError) -> Self { - Self { - id, - ok: false, - data: None, - error: Some(error), - } - } -} #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/rust/crates/core/src/protocol/mod.rs b/rust/crates/core/src/protocol/mod.rs index 1c5186ce..8dcf468d 100644 --- a/rust/crates/core/src/protocol/mod.rs +++ b/rust/crates/core/src/protocol/mod.rs @@ -1,13 +1,36 @@ -//! Stable command, response, error, event, and cancellation contracts. +//! Request parsing, domain response models and events on top of `lithe-protocol`. +//! +//! The envelope, error categories and cancellation come from `lithe-protocol` and are re-exported +//! here, so every `crate::protocol::...` path names the same type a domain crate returns. -pub(crate) mod cancellation; mod command; mod contracts; -mod error; mod event; -pub(crate) mod json_document; pub use command::*; pub use contracts::*; -pub use error::*; pub use event::*; +pub(crate) use lithe_protocol::{cancellation, json_document}; +pub use lithe_protocol::{invalid_relative_path, CoreError, CoreResponse, ErrorCode, ResponseData}; + +/// Maps an `ai.*` failure onto the Core error of the same category name. +pub(crate) fn ai_error(error: lithe_ai::AiError) -> CoreError { + let code = match error.code { + lithe_ai::AiErrorCode::InvalidRequest => ErrorCode::InvalidRequest, + lithe_ai::AiErrorCode::NotSupported => ErrorCode::NotSupported, + }; + CoreError::new(code, error.message) +} + +/// Maps a `lithe-core-rules` failure onto the Core error of the same category name. +pub(crate) fn rule_error(error: lithe_core_rules::RuleError) -> CoreError { + let code = match error.code { + lithe_core_rules::RuleErrorCode::InvalidRequest => ErrorCode::InvalidRequest, + lithe_core_rules::RuleErrorCode::NotSupported => ErrorCode::NotSupported, + }; + let converted = CoreError::new(code, error.message); + match error.details { + Some(details) => converted.with_details(details), + None => converted, + } +} diff --git a/rust/crates/core/src/runtime/dispatcher.rs b/rust/crates/core/src/runtime/dispatcher.rs index 8d6b8f9a..8199186e 100644 --- a/rust/crates/core/src/runtime/dispatcher.rs +++ b/rust/crates/core/src/runtime/dispatcher.rs @@ -14,7 +14,6 @@ use crate::git::{ GitWorktreesRequest, GitWriteRequest, PatchApplyRequest, PatchExportRequest, PatchPreviewRequest, WorkspaceRepositoriesRequest, }; -use crate::github::{NormalizeResponseRequest, ParseRemoteRequest, RequestPlanRequest}; use crate::project::MarkdownRenderRequest; use crate::project::{ self, DirectoryListingRequest, DocumentLifecycleRequest, FileReadRequest, FileWriteRequest, @@ -29,6 +28,7 @@ use crate::project::{ use crate::protocol::CoreResponse; use crate::protocol::{CoreCommand, CoreRequest}; use crate::protocol::{CoreError, ErrorCode}; +use lithe_github::{NormalizeResponseRequest, ParseRemoteRequest, RequestPlanRequest}; use serde_json::json; pub fn execute_json(request: &str) -> String { @@ -66,27 +66,15 @@ fn plugin_response( fn ai_response(id: Option, command: &str, payload: serde_json::Value) -> CoreResponse { match lithe_ai::execute(command, payload) { Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error.into()), + Err(error) => CoreResponse::failure(id, crate::protocol::ai_error(error)), } } -fn agent_response( - id: Option, - payload: serde_json::Value, - operation: fn(T) -> Result, -) -> CoreResponse { - match serde_json::from_value::(payload) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid agent management request", - ) - .with_details(error.to_string()) - }) - .and_then(operation) - { +/// Runs one rule in `lithe-core-rules`. +fn rules_response(id: Option, command: &str, payload: serde_json::Value) -> CoreResponse { + match lithe_core_rules::execute(command, payload) { Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error), + Err(error) => CoreResponse::failure(id, crate::protocol::rule_error(error)), } } @@ -875,13 +863,7 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon Err(error) => CoreResponse::failure(id, error), } } - CoreCommand::SearchActions => agent_response( - id, - parsed.payload, - |request: crate::action_search::Request| { - crate::action_search::search(request).map(|indices| json!({"indices": indices})) - }, - ), + CoreCommand::SearchActions => rules_response(id, "search.actions", parsed.payload), CoreCommand::WorkspaceSearch => { match serde_json::from_value::(parsed.payload) .map_err(|error| { @@ -1198,7 +1180,7 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon } } CoreCommand::DiffCompareTexts => { - plugin_response(id, parsed.payload, crate::diff::compare_texts_command) + plugin_response(id, parsed.payload, lithe_diff::compare_texts_command) } CoreCommand::ExecutionResolveSearchPath => { match serde_json::from_value::(parsed.payload) { @@ -1233,12 +1215,12 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon } } CoreCommand::ProcessLifecycleResolve => { - match serde_json::from_value::( + match serde_json::from_value::( parsed.payload, ) { Ok(request) => CoreResponse::success( id, - serde_json::to_value(crate::process::lifecycle::resolve(&request)) + serde_json::to_value(lithe_process::lifecycle::resolve(&request)) .expect("process lifecycle plan should encode"), ), Err(error) => CoreResponse::failure( @@ -1252,12 +1234,12 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon } } CoreCommand::ProcessLifecycleInventory => { - match serde_json::from_value::( + match serde_json::from_value::( parsed.payload, ) { Ok(request) => CoreResponse::success( id, - serde_json::to_value(crate::process::inventory::classify(&request)) + serde_json::to_value(lithe_process::inventory::classify(&request)) .expect("process inventory should encode"), ), Err(error) => CoreResponse::failure( @@ -1271,11 +1253,11 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon } } CoreCommand::ProcessLifecycleSettle => { - match serde_json::from_value::(parsed.payload) + match serde_json::from_value::(parsed.payload) { Ok(request) => CoreResponse::success( id, - serde_json::to_value(crate::process::lifecycle::settle(&request)) + serde_json::to_value(lithe_process::lifecycle::settle(&request)) .expect("process lifecycle settlement should encode"), ), Err(error) => CoreResponse::failure( @@ -1289,88 +1271,36 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon } } CoreCommand::KeymapParseContext => { - match serde_json::from_value::(parsed.payload) { - Ok(request) => CoreResponse::success( - id, - serde_json::to_value(crate::keymap::parse_context(request)) - .expect("context plan should encode"), - ), - Err(_) => CoreResponse::failure( - id, - CoreError::new(ErrorCode::InvalidRequest, "Invalid keymap parse request"), - ), - } + rules_response(id, "keymap.parseContext", parsed.payload) } CoreCommand::KeymapEvaluateContext => { - let result = - serde_json::from_value::(parsed.payload) - .map_err(|_| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid keymap evaluation request", - ) - }) - .and_then(crate::keymap::evaluate_context); - match result { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("context result should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } + rules_response(id, "keymap.evaluateContext", parsed.payload) } CoreCommand::KeymapAnalyzeConflicts => { - let result = - serde_json::from_value::(parsed.payload) - .map_err(|_| { - CoreError::new(ErrorCode::InvalidRequest, "Invalid keymap conflict request") - }) - .and_then(crate::keymap::analyze_conflicts); - match result { - Ok(data) => CoreResponse::success( - id, - serde_json::to_value(data).expect("conflict result should encode"), - ), - Err(error) => CoreResponse::failure(id, error), - } + rules_response(id, "keymap.analyzeConflicts", parsed.payload) } CoreCommand::KeymapModifierGestureStep => { - match lithe_core_rules::execute("keymap.modifierGestureStep", parsed.payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error.into()), - } + rules_response(id, "keymap.modifierGestureStep", parsed.payload) } CoreCommand::EditorWorkbenchStateReduce => { - match lithe_core_rules::execute("editor.workbenchState.reduce", parsed.payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error.into()), - } + rules_response(id, "editor.workbenchState.reduce", parsed.payload) } CoreCommand::WorkspaceClassifyVisibility => { - match lithe_core_rules::execute("workspace.classifyVisibility", parsed.payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error.into()), - } + rules_response(id, "workspace.classifyVisibility", parsed.payload) } CoreCommand::WorkspaceVisibilityDefaults => { - match lithe_core_rules::execute("workspace.visibilityDefaults", parsed.payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error.into()), - } + rules_response(id, "workspace.visibilityDefaults", parsed.payload) } CoreCommand::WorkbenchChromeLayoutReduce => { - match lithe_core_rules::execute("workbench.chromeLayout.reduce", parsed.payload) { - Ok(data) => CoreResponse::success(id, data), - Err(error) => CoreResponse::failure(id, error.into()), - } + rules_response(id, "workbench.chromeLayout.reduce", parsed.payload) } CoreCommand::TerminalParseLinkTarget => { let result = - serde_json::from_value::(parsed.payload) + serde_json::from_value::(parsed.payload) .map_err(|_| { CoreError::new(ErrorCode::InvalidRequest, "Invalid terminal link request") }) - .and_then(crate::terminal::parse_link_targets); + .and_then(lithe_terminal::parse_link_targets); match result { Ok(data) => CoreResponse::success( id, @@ -1833,7 +1763,7 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon plugin_response(id, parsed.payload, crate::editor::recovery::decide_command) } CoreCommand::KeymapSchemesReduce => { - plugin_response(id, parsed.payload, crate::keymap::schemes::reduce_command) + rules_response(id, "keymap.schemes.reduce", parsed.payload) } CoreCommand::EditorPlanTextEdits => plugin_response( id, @@ -1887,17 +1817,15 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon } } CoreCommand::NetworkProxyPolicy => { - match serde_json::from_value::( - parsed.payload, - ) - .map_err(|error| { - CoreError::new( - ErrorCode::InvalidRequest, - "Invalid network proxy policy request", - ) - .with_details(error.to_string()) - }) - .map(crate::network::proxy_policy) + match serde_json::from_value::(parsed.payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid network proxy policy request", + ) + .with_details(error.to_string()) + }) + .map(lithe_network::proxy_policy) { Ok(data) => CoreResponse::success( id, @@ -3631,7 +3559,7 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon CoreError::new(ErrorCode::InvalidRequest, "Invalid GitHub remote request") .with_details(error.to_string()) }) - .and_then(crate::github::parse_remote) + .and_then(lithe_github::parse_remote) { Ok(data) => CoreResponse::success( id, @@ -3646,7 +3574,7 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon CoreError::new(ErrorCode::InvalidRequest, "Invalid GitHub request plan") .with_details(error.to_string()) }) - .and_then(crate::github::request_plan) + .and_then(lithe_github::request_plan) { Ok(data) => CoreResponse::success( id, @@ -3690,7 +3618,7 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon } } CoreCommand::GitHubPullRequestCheckoutPlan => { - match serde_json::from_value::( + match serde_json::from_value::( parsed.payload, ) .map_err(|error| { @@ -3700,7 +3628,7 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon ) .with_details(error.to_string()) }) - .and_then(crate::github::pull_request_checkout_plan) + .and_then(lithe_github::pull_request_checkout_plan) { Ok(data) => CoreResponse::success( id, @@ -3710,7 +3638,7 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon } } CoreCommand::GitHubDeviceAuthorizationTransition => { - match serde_json::from_value::( + match serde_json::from_value::( parsed.payload, ) .map_err(|error| { @@ -3720,7 +3648,7 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon ) .with_details(error.to_string()) }) - .and_then(crate::github::device_authorization_transition) + .and_then(lithe_github::device_authorization_transition) { Ok(data) => CoreResponse::success( id, @@ -3735,7 +3663,7 @@ fn execute_parsed(mut parsed: CoreRequest, document: Option<&str>) -> CoreRespon CoreError::new(ErrorCode::InvalidRequest, "Invalid GitHub response") .with_details(error.to_string()) }) - .and_then(crate::github::normalize_response) + .and_then(lithe_github::normalize_response) { Ok(data) => CoreResponse::success(id, data), Err(error) => CoreResponse::failure(id, error), diff --git a/rust/crates/diff/Cargo.toml b/rust/crates/diff/Cargo.toml new file mode 100644 index 00000000..7b433967 --- /dev/null +++ b/rust/crates/diff/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lithe-diff" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Line comparison and removed/added line pairing for every Lithe diff view" + +[dependencies] +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +similar = { version = "3.2", default-features = false, features = ["std"] } diff --git a/rust/crates/core/src/diff/mod.rs b/rust/crates/diff/src/lib.rs similarity index 95% rename from rust/crates/core/src/diff/mod.rs rename to rust/crates/diff/src/lib.rs index 3a012363..c671ec54 100644 --- a/rust/crates/core/src/diff/mod.rs +++ b/rust/crates/diff/src/lib.rs @@ -5,7 +5,7 @@ //! presentation detail; Core owns line splitting, the diff algorithm, and how a //! block of removals is aligned against a block of additions. -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::Value; use similar::{capture_diff_slices, Algorithm, DiffOp}; @@ -52,13 +52,16 @@ pub enum TextDiffRowKind { #[serde(rename_all = "camelCase")] /// One aligned side-by-side row with one-based line numbers. pub struct TextDiffRow { + /// One-based line in the old text; absent for an addition. pub old_line: Option, + /// One-based line in the new text; absent for a removal. pub new_line: Option, /// Old-side text; for `context` rows it is also the new-side text. pub left: Option, /// New-side text; omitted for `context` rows, whose sides are identical. #[serde(skip_serializing_if = "Option::is_none")] pub right: Option, + /// Rendering category of the row. pub kind: TextDiffRowKind, /// For a `changed` row, the stretches of `left` that differ from `right`, as /// half-open UTF-16 column ranges; see `inline_changes`. @@ -73,8 +76,11 @@ pub struct TextDiffRow { #[serde(rename_all = "camelCase")] /// Unified rendering category of one line. pub enum TextDiffLineKind { + /// A line present in both texts. Context, + /// A line only present in the old text. Removed, + /// A line only present in the new text. Added, } @@ -83,9 +89,13 @@ pub enum TextDiffLineKind { /// One unified line in original order: each changed block lists its removals /// before its additions, matching `git diff` output. pub struct TextDiffLine { + /// Rendering category of the line. pub kind: TextDiffLineKind, + /// Line text without its terminator. pub content: String, + /// One-based line in the old text; absent for an added line. pub old_line: Option, + /// One-based line in the new text; absent for a removed line. pub new_line: Option, } @@ -93,18 +103,22 @@ pub struct TextDiffLine { #[serde(rename_all = "camelCase")] /// Complete line comparison projected both side by side and unified. pub struct CompareTextsResponse { + /// Side-by-side projection. pub rows: Vec, + /// Unified projection. pub lines: Vec, /// Logical line count of the old text after line-ending normalization. pub old_line_count: usize, /// Logical line count of the new text after line-ending normalization. pub new_line_count: usize, + /// Number of added lines. pub additions: usize, + /// Number of removed lines. pub deletions: usize, } /// Command adapter for `diff.compareTexts`. -pub(crate) fn compare_texts_command(payload: Value) -> Result { +pub fn compare_texts_command(payload: Value) -> Result { let request: CompareTextsRequest = serde_json::from_value(payload).map_err(|error| { CoreError::new(ErrorCode::InvalidRequest, "Invalid text comparison request") .with_details(error.to_string()) @@ -340,10 +354,7 @@ fn inline_tokens(text: &str) -> Vec<&str> { /// Lines that are equal give two empty lists; lines over `MAX_INLINE_CHARS` /// give `None`, and so do lines with nothing in common, which read better as a /// whole changed line than as one range covering it. -pub(crate) fn inline_changes( - left: &str, - right: &str, -) -> Option<(Vec<[usize; 2]>, Vec<[usize; 2]>)> { +pub fn inline_changes(left: &str, right: &str) -> Option<(Vec<[usize; 2]>, Vec<[usize; 2]>)> { if left.chars().count() > MAX_INLINE_CHARS || right.chars().count() > MAX_INLINE_CHARS { return None; } @@ -467,7 +478,7 @@ pub(crate) fn line_similarity(left: &str, right: &str) -> f32 { /// "changed" rows. This aligns the two blocks by similarity instead, keeping the /// matching non-crossing so line numbers stay monotonic in the rendered list. /// Each returned pair indexes into `removed` and `added`. -pub(crate) fn pair_lines(removed: &[&str], added: &[&str]) -> Vec<(Option, Option)> { +pub fn pair_lines(removed: &[&str], added: &[&str]) -> Vec<(Option, Option)> { let rows = removed.len(); let columns = added.len(); diff --git a/rust/crates/core/src/diff/tests.rs b/rust/crates/diff/src/tests.rs similarity index 99% rename from rust/crates/core/src/diff/tests.rs rename to rust/crates/diff/src/tests.rs index ec0c15d3..f005340d 100644 --- a/rust/crates/core/src/diff/tests.rs +++ b/rust/crates/diff/src/tests.rs @@ -2,7 +2,7 @@ use super::{ compare_texts, compare_texts_command, line_similarity, pair_lines, CompareTextsRequest, MAX_ALIGNMENT_CELLS, MAX_TEXT_BYTES, }; -use crate::protocol::ErrorCode; +use lithe_protocol::ErrorCode; use serde_json::{json, Value}; fn fixture() -> Value { diff --git a/rust/crates/github/Cargo.toml b/rust/crates/github/Cargo.toml new file mode 100644 index 00000000..d0e95932 --- /dev/null +++ b/rust/crates/github/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lithe-github" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "GitHub request planning and response normalization for Lithe" + +[dependencies] +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/rust/crates/core/src/github/mod.rs b/rust/crates/github/src/lib.rs similarity index 99% rename from rust/crates/core/src/github/mod.rs rename to rust/crates/github/src/lib.rs index 5c801e85..330a4048 100644 --- a/rust/crates/core/src/github/mod.rs +++ b/rust/crates/github/src/lib.rs @@ -6,7 +6,7 @@ //! //! Device polling: .agents/notes/implemented/bug-fix/2026-11-04-github-device-authorization-deadline.md -use crate::protocol::{CoreError, ErrorCode}; +use lithe_protocol::{CoreError, ErrorCode}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::BTreeMap; @@ -32,7 +32,9 @@ pub struct PullRequestCheckoutPlanRequest { #[serde(rename_all = "camelCase")] /// Actions shown before fetching and creating a local branch. pub struct PullRequestCheckoutPlan { + /// Whether the checkout can proceed; false while `conflict` is set. pub allowed: bool, + /// Local branch to create, `pr/-`. pub branch: String, /// `branchExists` requires the existing local ref to be resolved by the user. pub conflict: Option<&'static str>, diff --git a/rust/crates/network/Cargo.toml b/rust/crates/network/Cargo.toml new file mode 100644 index 00000000..d01ca78b --- /dev/null +++ b/rust/crates/network/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lithe-network" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Proxy policy normalization shared by every Lithe host" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } + +[dev-dependencies] +serde_json = "1.0" diff --git a/rust/crates/core/src/network/mod.rs b/rust/crates/network/src/lib.rs similarity index 72% rename from rust/crates/core/src/network/mod.rs rename to rust/crates/network/src/lib.rs index 153267b1..8f350d55 100644 --- a/rust/crates/core/src/network/mod.rs +++ b/rust/crates/network/src/lib.rs @@ -6,9 +6,12 @@ //! network stack: `connectionProxyDictionary` plus an authentication delegate //! on macOS, and the HTTP client configuration on Windows. -pub(crate) mod proxy; +mod proxy; -pub(crate) use proxy::{proxy_policy, NetworkProxyPolicyRequest}; +pub use proxy::{ + proxy_policy, NetworkProxyIdentity, NetworkProxyMode, NetworkProxyPolicy, + NetworkProxyPolicyKind, NetworkProxyPolicyRequest, NetworkProxyType, +}; #[cfg(test)] mod tests; diff --git a/rust/crates/core/src/network/proxy.rs b/rust/crates/network/src/proxy.rs similarity index 84% rename from rust/crates/core/src/network/proxy.rs rename to rust/crates/network/src/proxy.rs index 6caa70b9..dcf3aeea 100644 --- a/rust/crates/core/src/network/proxy.rs +++ b/rust/crates/network/src/proxy.rs @@ -38,21 +38,27 @@ pub enum NetworkProxyType { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NetworkProxyPolicyRequest { + /// Mode the user selected. pub mode: NetworkProxyMode, /// Proxy protocol for the custom mode; defaults to HTTP when absent. #[serde(default)] pub proxy_type: Option, + /// Custom proxy host; surrounding whitespace is ignored. #[serde(default)] pub host: String, + /// Custom proxy port; zero means not configured. #[serde(default)] pub port: u16, + /// Proxy account; surrounding whitespace is ignored and empty means none. #[serde(default)] pub username: String, + /// Proxy password, forwarded only when `password_identity` matches the server. #[serde(default)] pub password: String, /// Identity stored with a restored password; absent for an explicit draft. #[serde(default)] pub password_identity: Option, + /// Hosts that bypass the proxy; blank entries are dropped. #[serde(default)] pub exclusions: Vec, } @@ -62,9 +68,13 @@ pub struct NetworkProxyPolicyRequest { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct NetworkProxyIdentity { + /// Proxy protocol the password was saved for. pub proxy_type: NetworkProxyType, + /// Proxy host the password was saved for, compared case-insensitively. pub host: String, + /// Proxy port the password was saved for. pub port: u16, + /// Account the password belongs to. pub username: String, } @@ -87,12 +97,19 @@ pub enum NetworkProxyPolicyKind { #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct NetworkProxyPolicy { + /// Policy the network stack applies. pub policy: NetworkProxyPolicyKind, + /// Protocol of the custom server; HTTP when the request named none. pub proxy_type: NetworkProxyType, + /// Trimmed custom host; empty unless `policy` is custom. pub host: String, + /// Custom port; zero unless `policy` is custom. pub port: u16, + /// Trimmed account; empty unless `policy` is custom. pub username: String, + /// Password; empty unless `policy` is custom and the saved identity matches the server. pub password: String, + /// Trimmed, non-empty bypass hosts; empty unless `policy` is custom. pub exclusions: Vec, /// True when the caller-selected mode could actually be honored; false /// for an incomplete custom server that normalized to the system policy. diff --git a/rust/crates/core/src/network/tests.rs b/rust/crates/network/src/tests.rs similarity index 98% rename from rust/crates/core/src/network/tests.rs rename to rust/crates/network/src/tests.rs index 2c7a122b..502ac448 100644 --- a/rust/crates/core/src/network/tests.rs +++ b/rust/crates/network/src/tests.rs @@ -2,7 +2,7 @@ use serde_json::{json, Value}; -use crate::network::{proxy_policy, NetworkProxyPolicyRequest}; +use crate::{proxy_policy, NetworkProxyPolicyRequest}; fn policy_for(request: Value) -> Value { let request: NetworkProxyPolicyRequest = diff --git a/rust/crates/process/Cargo.toml b/rust/crates/process/Cargo.toml new file mode 100644 index 00000000..e077134d --- /dev/null +++ b/rust/crates/process/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lithe-process" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Child-process lifecycle and inventory decisions shared by every Lithe host" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } + +[dev-dependencies] +serde_json = "1.0" diff --git a/rust/crates/core/src/process/inventory.rs b/rust/crates/process/src/inventory.rs similarity index 98% rename from rust/crates/core/src/process/inventory.rs rename to rust/crates/process/src/inventory.rs index 163ce5ea..e136eeb6 100644 --- a/rust/crates/core/src/process/inventory.rs +++ b/rust/crates/process/src/inventory.rs @@ -18,7 +18,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap}; use serde::{Deserialize, Serialize}; -use crate::process::lifecycle::{ +use crate::lifecycle::{ resolve, LifecycleContext, LifecyclePlan, LifecycleRecord, LifecycleRequest, Ownership, RecordKind, TerminationSettings, }; @@ -278,8 +278,7 @@ pub fn classify(request: &InventoryRequest) -> Inventory { if request.application_session_id <= 0 && item.category == ItemCategory::Other { // The scope proves ownership, but the missing original SID // cannot distinguish an ordinary tool from a kept daemon. - record.declared_policy = - Some(crate::process::lifecycle::ExitPolicy::AskOnExit); + record.declared_policy = Some(crate::lifecycle::ExitPolicy::AskOnExit); } record }) @@ -582,7 +581,7 @@ mod tests { #[test] fn shared_fixture_inventories_match() { let fixture: Fixture = serde_json::from_str(include_str!( - "../../../../fixtures/process-lifecycle/inventory-v1.json" + "../../../fixtures/process-lifecycle/inventory-v1.json" )) .expect("fixture should parse"); assert_eq!(fixture.schema_version, 1); diff --git a/rust/crates/core/src/process/mod.rs b/rust/crates/process/src/lib.rs similarity index 100% rename from rust/crates/core/src/process/mod.rs rename to rust/crates/process/src/lib.rs diff --git a/rust/crates/core/src/process/lifecycle.rs b/rust/crates/process/src/lifecycle.rs similarity index 99% rename from rust/crates/core/src/process/lifecycle.rs rename to rust/crates/process/src/lifecycle.rs index 75437971..b94245db 100644 --- a/rust/crates/core/src/process/lifecycle.rs +++ b/rust/crates/process/src/lifecycle.rs @@ -636,7 +636,7 @@ mod tests { #[test] fn shared_fixture_decisions_match() { let fixture: Fixture = serde_json::from_str(include_str!( - "../../../../fixtures/process-lifecycle/exit-plan-v1.json" + "../../../fixtures/process-lifecycle/exit-plan-v1.json" )) .expect("fixture should parse"); assert_eq!(fixture.schema_version, 1); @@ -651,7 +651,7 @@ mod tests { #[test] fn shared_fixture_settlements_match() { let fixture: Fixture = serde_json::from_str(include_str!( - "../../../../fixtures/process-lifecycle/settle-v1.json" + "../../../fixtures/process-lifecycle/settle-v1.json" )) .expect("fixture should parse"); assert_eq!(fixture.schema_version, 1); diff --git a/rust/crates/protocol/Cargo.toml b/rust/crates/protocol/Cargo.toml new file mode 100644 index 00000000..1f412133 --- /dev/null +++ b/rust/crates/protocol/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "lithe-protocol" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Response envelope, error codes and cancellation shared by every Lithe Core crate" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/rust/crates/core/src/protocol/cancellation.rs b/rust/crates/protocol/src/cancellation.rs similarity index 95% rename from rust/crates/core/src/protocol/cancellation.rs rename to rust/crates/protocol/src/cancellation.rs index 0d7e4d51..d69e82f0 100644 --- a/rust/crates/core/src/protocol/cancellation.rs +++ b/rust/crates/protocol/src/cancellation.rs @@ -1,6 +1,6 @@ //! Cooperative operation cancellation and per-thread deadline tracking. -use crate::protocol::{CoreError, ErrorCode}; +use crate::{CoreError, ErrorCode}; use std::cell::RefCell; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; @@ -80,6 +80,10 @@ impl Drop for Scope { } } +/// Cancels the innermost live scope registered under `operation_id`. +/// +/// Returns `false` when no scope with that identifier is running, so a late or +/// repeated cancellation is harmless. pub fn cancel(operation_id: &str) -> bool { registry() .lock() @@ -123,7 +127,7 @@ pub fn check() -> Result<(), CoreError> { /// /// Cleanup must determine whether a ref already moved without inheriting the /// cancelled request token. The original thread state is restored on every exit. -pub(crate) fn with_cleanup_deadline(timeout: Duration, operation: impl FnOnce() -> T) -> T { +pub fn with_cleanup_deadline(timeout: Duration, operation: impl FnOnce() -> T) -> T { struct RestoreState(Option); impl Drop for RestoreState { fn drop(&mut self) { @@ -144,10 +148,7 @@ pub(crate) fn with_cleanup_deadline(timeout: Duration, operation: impl FnOnce /// /// Active requests retain their absolute deadline and cancellation token. Only /// an already cancelled or timed-out request needs an independent cleanup scope. -pub(crate) fn with_mutation_inspection( - cleanup_timeout: Duration, - operation: impl FnOnce() -> T, -) -> T { +pub fn with_mutation_inspection(cleanup_timeout: Duration, operation: impl FnOnce() -> T) -> T { if check().is_ok() { operation() } else { @@ -162,7 +163,7 @@ fn registry() -> &'static Mutex { #[cfg(test)] mod tests { use super::{cancel, check, with_mutation_inspection, Scope, CURRENT}; - use crate::protocol::ErrorCode; + use crate::ErrorCode; use std::thread; use std::time::{Duration, Instant}; diff --git a/rust/crates/core/src/protocol/error.rs b/rust/crates/protocol/src/error.rs similarity index 83% rename from rust/crates/core/src/protocol/error.rs rename to rust/crates/protocol/src/error.rs index 40e29e6a..14c58b76 100644 --- a/rust/crates/core/src/protocol/error.rs +++ b/rust/crates/protocol/src/error.rs @@ -119,29 +119,3 @@ pub fn invalid_relative_path(value: &str) -> bool { || normalized.contains('\0') || normalized.split('/').any(|component| component == "..") } - -impl From for CoreError { - /// AI errors already use Core's category names, so the mapping is one to one. - fn from(error: lithe_ai::AiError) -> Self { - let code = match error.code { - lithe_ai::AiErrorCode::InvalidRequest => ErrorCode::InvalidRequest, - lithe_ai::AiErrorCode::NotSupported => ErrorCode::NotSupported, - }; - Self::new(code, error.message) - } -} - -impl From for CoreError { - /// Rule errors already use Core's category names, so the mapping is one to one. - fn from(error: lithe_core_rules::RuleError) -> Self { - let code = match error.code { - lithe_core_rules::RuleErrorCode::InvalidRequest => ErrorCode::InvalidRequest, - lithe_core_rules::RuleErrorCode::NotSupported => ErrorCode::NotSupported, - }; - let converted = Self::new(code, error.message); - match error.details { - Some(details) => converted.with_details(details), - None => converted, - } - } -} diff --git a/rust/crates/core/src/protocol/json_document.rs b/rust/crates/protocol/src/json_document.rs similarity index 96% rename from rust/crates/core/src/protocol/json_document.rs rename to rust/crates/protocol/src/json_document.rs index e2c9cb52..f989c7dd 100644 --- a/rust/crates/core/src/protocol/json_document.rs +++ b/rust/crates/protocol/src/json_document.rs @@ -9,7 +9,7 @@ use std::{collections::BTreeMap, fmt, marker::PhantomData}; /// An ordered object that rejects duplicate keys at deserialization. #[derive(Debug, PartialEq, Serialize)] -pub(crate) struct UniqueMap(pub(crate) BTreeMap); +pub struct UniqueMap(pub BTreeMap); impl Default for UniqueMap { fn default() -> Self { @@ -102,6 +102,6 @@ impl<'de> Deserialize<'de> for DuplicateCheckedValue { /// Parses JSON without losing duplicate keys, including nested objects and /// escaped spellings. Callers own byte budgets and schema-specific failures. -pub(crate) fn parse_unique_json(content: &str) -> Result { +pub fn parse_unique_json(content: &str) -> Result { serde_json::from_str::(content).map(|value| value.0) } diff --git a/rust/crates/protocol/src/lib.rs b/rust/crates/protocol/src/lib.rs new file mode 100644 index 00000000..f0524ad7 --- /dev/null +++ b/rust/crates/protocol/src/lib.rs @@ -0,0 +1,14 @@ +//! Cross-boundary contract shared by every Lithe Core crate. +//! +//! The response envelope ([`CoreResponse`]), the stable error categories ([`ErrorCode`], +//! [`CoreError`]) and per-request cancellation live here so a domain crate can report results and +//! failures without depending on `lithe-core`. `lithe-core` re-exports these items unchanged; the +//! JSON shape and error code names are part of the C ABI contract. + +pub mod cancellation; +mod error; +pub mod json_document; +mod response; + +pub use error::{invalid_relative_path, CoreError, ErrorCode}; +pub use response::{CoreResponse, ResponseData}; diff --git a/rust/crates/protocol/src/response.rs b/rust/crates/protocol/src/response.rs new file mode 100644 index 00000000..9d3ca3d3 --- /dev/null +++ b/rust/crates/protocol/src/response.rs @@ -0,0 +1,56 @@ +//! Success-or-failure envelope returned across the JSON and C boundaries. + +use crate::CoreError; +use serde::Serialize; +use serde_json::Value; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +/// Stable success-or-failure envelope returned across the JSON and C boundaries. +pub struct CoreResponse { + /// Correlation identifier copied from the request, when supplied. + pub id: Option, + /// Discriminator that determines whether `data` or `error` is present. + pub ok: bool, + /// Successful command payload. It is omitted for failures. + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + /// Structured failure. It is omitted for successful responses. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +/// Payload wrapper that preserves each command's existing JSON shape. +pub enum ResponseData { + /// A command-specific JSON value serialized without an additional tag. + Json(Value), +} + +impl CoreResponse { + /// Reports whether the response carries successful data. + pub fn is_success(&self) -> bool { + self.ok + } + + /// Builds a successful response while preserving the caller's identifier. + pub fn success(id: Option, data: impl Into) -> Self { + Self { + id, + ok: true, + data: Some(ResponseData::Json(data.into())), + error: None, + } + } + + /// Builds a failed response with no partially successful data attached. + pub fn failure(id: Option, error: CoreError) -> Self { + Self { + id, + ok: false, + data: None, + error: Some(error), + } + } +} diff --git a/rust/crates/terminal/Cargo.toml b/rust/crates/terminal/Cargo.toml new file mode 100644 index 00000000..2d0d043e --- /dev/null +++ b/rust/crates/terminal/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "lithe-terminal" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Terminal output link detection shared by every Lithe host" + +[dependencies] +lithe-protocol = { path = "../protocol" } +serde = { version = "1.0", features = ["derive"] } diff --git a/rust/crates/core/src/terminal/mod.rs b/rust/crates/terminal/src/lib.rs similarity index 100% rename from rust/crates/core/src/terminal/mod.rs rename to rust/crates/terminal/src/lib.rs diff --git a/rust/crates/core/src/terminal/link.rs b/rust/crates/terminal/src/link.rs similarity index 97% rename from rust/crates/core/src/terminal/link.rs rename to rust/crates/terminal/src/link.rs index 7a255ed4..45faf15c 100644 --- a/rust/crates/core/src/terminal/link.rs +++ b/rust/crates/terminal/src/link.rs @@ -14,6 +14,7 @@ const MAX_TEXT_BYTES: usize = 4096; #[serde(rename_all = "camelCase", deny_unknown_fields)] /// Candidate link texts plus the facts needed to resolve them. pub struct ParseLinkTargetsRequest { + /// Link texts detected in terminal output, each resolved independently. pub texts: Vec, /// Working directory relative paths are resolved against. #[serde(default)] @@ -247,10 +248,10 @@ fn parse_one(text: &str, request: &ParseLinkTargetsRequest) -> Option Result { +) -> Result { if request.texts.len() > MAX_TEXTS { - return Err(crate::CoreError::new( - crate::ErrorCode::InvalidRequest, + return Err(lithe_protocol::CoreError::new( + lithe_protocol::ErrorCode::InvalidRequest, "Too many terminal link candidates", )); }