From 97b0f92642b3169f83d13cf42b51f05cddeb7f51 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:30:07 -0800 Subject: [PATCH 1/2] cli: add positional args for users search and messages send --- cli/src/main.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 86010568..5da4ec2d 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -335,7 +335,7 @@ struct ChatsDeleteArgs { #[derive(Subcommand)] enum UsersCommand { - #[command(about = "List users that appear in your chats")] + #[command(about = "List users that appear in your chats", alias = "search", alias = "find")] List(UsersListArgs), #[command(about = "Fetch a user by id from the chat list payload")] Get(UserGetArgs), @@ -343,7 +343,7 @@ enum UsersCommand { #[derive(Args)] struct UsersListArgs { - #[arg(long, help = "Filter users by name, username, email, or phone")] + #[arg(help = "Filter users by name, username, email, or phone")] filter: Option, } @@ -469,6 +469,10 @@ struct MessagesSendArgs { #[arg(long, help = "Message text (used as caption for attachments)")] text: Option, + /// Message text as trailing positional args (alternative to --text) + #[arg(trailing_var_arg = true, allow_hyphen_values = true, hide = true)] + text_positional: Vec, + #[arg(long, help = "Reply to message id")] reply_to: Option, @@ -1527,7 +1531,14 @@ async fn run() -> Result<(), Box> { let token = require_token(&auth_store)?; let peer = input_peer_from_args(args.chat_id, args.user_id)?; let reply_to = args.reply_to; - let caption = resolve_message_caption(args.text, args.stdin)?; + let text = args.text.or_else(|| { + if args.text_positional.is_empty() { + None + } else { + Some(args.text_positional.join(" ")) + } + }); + let caption = resolve_message_caption(text, args.stdin)?; let attachments = prepare_attachments( &args.attachments, &config.data_dir, From 8d5de8309ece52700fb55bd160afb18188ac6df6 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:00:24 -0800 Subject: [PATCH 2/2] cli: add alias-aware query path normalizer --- cli/README.md | 81 ++ cli/build.rs | 7 +- cli/skill/SKILL.md | 67 ++ cli/src/api.rs | 22 +- cli/src/config.rs | 24 +- cli/src/main.rs | 310 +++++--- cli/src/output.rs | 44 +- cli/src/query.rs | 1017 ++++++++++++++++++++++++ cli/src/realtime.rs | 25 +- cli/src/update.rs | 46 +- cli/tests/query_aliases_integration.rs | 38 + 11 files changed, 1519 insertions(+), 162 deletions(-) create mode 100644 cli/src/query.rs create mode 100644 cli/tests/query_aliases_integration.rs diff --git a/cli/README.md b/cli/README.md index ba95729b..2c7c6334 100644 --- a/cli/README.md +++ b/cli/README.md @@ -28,6 +28,87 @@ and paste the markdown. inline auth login ``` +## Alias-Aware JSON Querying + +The CLI now supports alias-aware query/path transforms on JSON output: + +- `--query-path `: select value(s) by dot/bracket path (repeatable) +- `--field `: project paths from each item in an array (repeatable) +- `--jsonpath `: JSONPath-like dot/bracket selector (repeatable) +- `--sort-path `: sort current JSON array by a path +- `--sort-desc`: descending sort order (with `--sort-path`) +- `--jq `: apply jq filter (requires `jq` in PATH) + +Rules: + +- Aliases are rewritten only in selector/filter strings, never in API payload JSON keys. +- Long-form canonical keys still work exactly as before. +- Mixed-case tokens are not rewritten. +- Quoted bracket keys are treated as literals and are not rewritten (for example: `users["fn"]`). + +## Before/After Examples + +```bash +# Canonical path +inline doctor --json --query-path config.apiBaseUrl + +# Short alias path (same result) +inline doctor --json --query-path cfg.apiBaseUrl + +# Canonical user projection +inline users list --json --query-path users --sort-path first_name --field id --field first_name + +# Short alias projection (same result) +inline users list --json --query-path u --sort-path fn --field id --field fn + +# Preserve a literal key via quoted brackets (no alias rewrite of "fn") +inline users list --json --query-path 'u["fn"]' +``` + +## Query Key Alias Table + +| Alias | Canonical key | +| --- | --- | +| `au` | `auth` | +| `at` | `attachments` | +| `c` | `chats` | +| `cfg` | `config` | +| `cid` | `chat_id` | +| `d` | `dialogs` | +| `dn` | `display_name` | +| `em` | `email` | +| `fid` | `from_id` | +| `fn` | `first_name` | +| `it` | `items` | +| `lm` | `last_message` | +| `lmd` | `last_message_relative_date` | +| `lml` | `last_message_line` | +| `ln` | `last_name` | +| `m` | `message` | +| `mb` | `member` | +| `mbs` | `members` | +| `md` | `media` | +| `mid` | `message_id` | +| `ms` | `messages` | +| `par` | `participant` | +| `ph` | `phone_number` | +| `pid` | `peer_id` | +| `ps` | `participants` | +| `pt` | `peer_type` | +| `pth` | `paths` | +| `rd` | `relative_date` | +| `rmi` | `read_max_id` | +| `s` | `spaces` | +| `sid` | `space_id` | +| `sn` | `sender_name` | +| `sys` | `system` | +| `ti` | `title` | +| `u` | `users` | +| `uc` | `unread_count` | +| `uid` | `user_id` | +| `um` | `unread_mark` | +| `un` | `username` | + ## Notes The CLI is still early and may have bugs. diff --git a/cli/build.rs b/cli/build.rs index 6b9f3eab..659308c7 100644 --- a/cli/build.rs +++ b/cli/build.rs @@ -1,7 +1,8 @@ use std::path::PathBuf; fn main() { - let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let manifest_dir = + PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); let proto_dir = manifest_dir.join("..").join("proto"); let protos = [ @@ -19,5 +20,7 @@ fn main() { let mut config = prost_build::Config::new(); config.type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]"); - config.compile_protos(&proto_paths, &include_paths).expect("compile protos"); + config + .compile_protos(&proto_paths, &include_paths) + .expect("compile protos"); } diff --git a/cli/skill/SKILL.md b/cli/skill/SKILL.md index 89d27781..a1e79eb2 100644 --- a/cli/skill/SKILL.md +++ b/cli/skill/SKILL.md @@ -10,6 +10,64 @@ description: Explain and use the Inline CLI (`inline`) for authentication, chats - `--json`: Output JSON instead of human tables/details (available on all commands). This greatly increases the verbosity and information you can get. Most of the data is either not included or truncated/redacted in the default human readable mode. Use JSON mode when you need exact details of a chat, message, etc. You can start with default mode and switch to json mode for more details and form your response. - `--pretty`: Pretty-print JSON output (default). - `--compact`: Compact JSON output (no whitespace). +- `--query-path `: Select value(s) via dot/bracket path (repeatable, alias-aware). +- `--field `: Project field paths from each item in the current JSON array (repeatable, alias-aware). +- `--jsonpath `: Select value(s) via JSONPath-like dot/bracket path (repeatable, alias-aware). +- `--sort-path `: Sort current JSON array by the provided path (alias-aware). +- `--sort-desc`: Sort descending when using `--sort-path`. +- `--jq `: Apply jq filter to JSON output (requires `jq` installed; alias-aware for path tokens). + +## Query alias rules + +- Alias rewriting happens only in query/path strings (`--query-path`, `--field`, `--jsonpath`, `--sort-path`, `--jq`). +- API payload JSON keys are never mutated. +- Canonical long-form keys always remain valid. +- Mixed-case tokens are not rewritten. +- Quoted bracket keys are treated as literals and not rewritten (for example: `users["fn"]`). + +## Query key aliases + +| Alias | Canonical key | +| --- | --- | +| `au` | `auth` | +| `at` | `attachments` | +| `c` | `chats` | +| `cfg` | `config` | +| `cid` | `chat_id` | +| `d` | `dialogs` | +| `dn` | `display_name` | +| `em` | `email` | +| `fid` | `from_id` | +| `fn` | `first_name` | +| `it` | `items` | +| `lm` | `last_message` | +| `lmd` | `last_message_relative_date` | +| `lml` | `last_message_line` | +| `ln` | `last_name` | +| `m` | `message` | +| `mb` | `member` | +| `mbs` | `members` | +| `md` | `media` | +| `mid` | `message_id` | +| `ms` | `messages` | +| `par` | `participant` | +| `ph` | `phone_number` | +| `pid` | `peer_id` | +| `ps` | `participants` | +| `pt` | `peer_type` | +| `pth` | `paths` | +| `rd` | `relative_date` | +| `rmi` | `read_max_id` | +| `s` | `spaces` | +| `sid` | `space_id` | +| `sn` | `sender_name` | +| `sys` | `system` | +| `ti` | `title` | +| `u` | `users` | +| `uc` | `unread_count` | +| `uid` | `user_id` | +| `um` | `unread_mark` | +| `un` | `username` | ## Subcommands @@ -124,6 +182,15 @@ description: Explain and use the Inline CLI (`inline`) for authentication, chats - `inline auth me` - Check diagnostics: - `inline doctor` +- Query-path canonical vs alias (same result): + - `inline doctor --json --query-path config.apiBaseUrl` + - `inline doctor --json --query-path cfg.apiBaseUrl` +- Project and sort with canonical keys: + - `inline users list --json --query-path users --sort-path first_name --field id --field first_name` +- Project and sort with short aliases: + - `inline users list --json --query-path u --sort-path fn --field id --field fn` +- Preserve literal keys using quoted bracket syntax: + - `inline users list --json --query-path 'u["fn"]'` - Search messages in a chat: - `inline messages search --chat-id 123 --query "design review"` - JSON: `inline messages search --chat-id 123 --query "design review" --json` diff --git a/cli/src/api.rs b/cli/src/api.rs index f9a3c33c..4e08ab27 100644 --- a/cli/src/api.rs +++ b/cli/src/api.rs @@ -1,9 +1,9 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; -use thiserror::Error; +use serde_json::{Value, json}; use std::fs; use std::path::PathBuf; +use thiserror::Error; #[derive(Debug, Error)] pub enum ApiError { @@ -85,7 +85,11 @@ impl ApiClient { self.post(url, payload).await } - pub async fn upload_file(&self, token: &str, input: UploadFileInput) -> Result { + pub async fn upload_file( + &self, + token: &str, + input: UploadFileInput, + ) -> Result { let url = format!("{}/uploadFile", self.base_url); let mut form = reqwest::multipart::Form::new().text("type", input.file_type.as_str()); let bytes = fs::read(&input.path)?; @@ -118,9 +122,7 @@ impl ApiClient { match api_response { ApiResponse::Ok { result, .. } => Ok(result), ApiResponse::Err { - error, - description, - .. + error, description, .. } => Err(ApiError::Api { error, description: description.unwrap_or_else(|| "Unknown error".to_string()), @@ -221,9 +223,7 @@ impl ApiClient { match api_response { ApiResponse::Ok { result, .. } => Ok(result), ApiResponse::Err { - error, - description, - .. + error, description, .. } => Err(ApiError::Api { error, description: description.unwrap_or_else(|| "Unknown error".to_string()), @@ -252,9 +252,7 @@ impl ApiClient { match api_response { ApiResponse::Ok { result, .. } => Ok(result), ApiResponse::Err { - error, - description, - .. + error, description, .. } => Err(ApiError::Api { error, description: description.unwrap_or_else(|| "Unknown error".to_string()), diff --git a/cli/src/config.rs b/cli/src/config.rs index 9d77012d..c765fafd 100644 --- a/cli/src/config.rs +++ b/cli/src/config.rs @@ -45,14 +45,24 @@ impl Config { let release_base_url = env::var("INLINE_RELEASE_BASE_URL") .ok() - .or_else(|| if debug { None } else { Some(DEFAULT_RELEASE_BASE_URL.to_string()) }) + .or_else(|| { + if debug { + None + } else { + Some(DEFAULT_RELEASE_BASE_URL.to_string()) + } + }) .map(|url| url.trim_end_matches('/').to_string()); - let release_manifest_url = env::var("INLINE_RELEASE_MANIFEST_URL") - .ok() - .or_else(|| release_base_url.as_ref().map(|base| format!("{base}/manifest.json"))); - let release_install_url = env::var("INLINE_RELEASE_INSTALL_URL") - .ok() - .or_else(|| release_base_url.as_ref().map(|base| format!("{base}/install.sh"))); + let release_manifest_url = env::var("INLINE_RELEASE_MANIFEST_URL").ok().or_else(|| { + release_base_url + .as_ref() + .map(|base| format!("{base}/manifest.json")) + }); + let release_install_url = env::var("INLINE_RELEASE_INSTALL_URL").ok().or_else(|| { + release_base_url + .as_ref() + .map(|base| format!("{base}/install.sh")) + }); Self { api_base_url, diff --git a/cli/src/main.rs b/cli/src/main.rs index 5da4ec2d..48b9554f 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -4,6 +4,7 @@ mod config; mod dates; mod output; mod protocol; +mod query; mod realtime; mod state; mod update; @@ -34,6 +35,7 @@ use crate::output::{ UserSummary, }; use crate::protocol::proto; +use crate::query::JsonQueryOptions; use crate::realtime::RealtimeClient; use crate::state::LocalDb; @@ -95,6 +97,21 @@ JQ examples: inline chats list --json | jq -r '.chats[] | "\(.id)\t\(.title // "")\tspace:\(if .space_id == null then "dm" else (.space_id | tostring) end)"' inline chats list --json | jq -r '.dialogs[] | select(.unread_count > 0) | "\(.chat_id)\tunread:\(.unread_count)"' inline messages list --chat-id 123 --json | jq -r '.messages[] | "\(.id)\t\(.from_id)\t\((.message // "") | gsub("\n"; " ") | .[0:80])"' + +Alias-aware query/path examples: + inline doctor --json --query-path config.apiBaseUrl + inline doctor --json --query-path cfg.apiBaseUrl + inline doctor --json --query-path 'cfg["apiBaseUrl"]' + inline users list --json --query-path 'u[].fn' + inline users list --json --query-path u --sort-path fn --field id --field fn + +Key aliases (query/path only; payload keys stay canonical): + au=auth at=attachments c=chats cfg=config cid=chat_id d=dialogs dn=display_name em=email + fid=from_id fn=first_name it=items lm=last_message lmd=last_message_relative_date lml=last_message_line + ln=last_name m=message mb=member mbs=members md=media mid=message_id ms=messages + par=participant ph=phone_number pid=peer_id ps=participants pt=peer_type pth=paths + rd=relative_date rmi=read_max_id s=spaces sid=space_id sn=sender_name sys=system + ti=title u=users uc=unread_count uid=user_id um=unread_mark un=username "# )] struct Cli { @@ -126,6 +143,63 @@ struct Cli { conflicts_with = "pretty" )] compact: bool, + + #[arg( + long = "query-path", + global = true, + value_name = "PATH", + num_args = 1.., + action = ArgAction::Append, + conflicts_with = "jsonpath", + help = "Select value(s) with a dot/bracket path (repeatable, alias-aware)" + )] + query_paths: Vec, + + #[arg( + long = "field", + global = true, + value_name = "PATH", + num_args = 1.., + action = ArgAction::Append, + help = "Project field paths from each item in an array (repeatable, alias-aware)" + )] + fields: Vec, + + #[arg( + long, + global = true, + value_name = "PATH", + num_args = 1.., + action = ArgAction::Append, + conflicts_with = "query_paths", + help = "Select value(s) using JSONPath-like dot/bracket expressions (repeatable, alias-aware)" + )] + jsonpath: Vec, + + #[arg( + long = "sort-path", + global = true, + value_name = "PATH", + help = "Sort current JSON array by the given path (alias-aware)" + )] + sort_path: Option, + + #[arg( + long, + global = true, + requires = "sort_path", + help = "Sort descending (use with --sort-path)" + )] + sort_desc: bool, + + #[arg( + long, + global = true, + value_name = "FILTER", + conflicts_with_all = ["query_paths", "fields", "jsonpath", "sort_path"], + help = "Apply jq filter to JSON output (requires jq binary; alias-aware for path tokens)" + )] + jq: Option, } #[derive(Subcommand)] @@ -335,7 +409,11 @@ struct ChatsDeleteArgs { #[derive(Subcommand)] enum UsersCommand { - #[command(about = "List users that appear in your chats", alias = "search", alias = "find")] + #[command( + about = "List users that appear in your chats", + alias = "search", + alias = "find" + )] List(UsersListArgs), #[command(about = "Fetch a user by id from the chat list payload")] Get(UserGetArgs), @@ -838,6 +916,15 @@ fn is_broken_pipe_panic(info: &std::panic::PanicHookInfo<'_>) -> bool { async fn run() -> Result<(), Box> { let cli = Cli::parse(); let json_format = output::resolve_json_format(cli.pretty, cli.compact); + let json_query_options = JsonQueryOptions { + jq_filter: cli.jq.clone(), + query_paths: cli.query_paths.clone(), + fields: cli.fields.clone(), + jsonpaths: cli.jsonpath.clone(), + sort_path: cli.sort_path.clone(), + sort_desc: cli.sort_desc, + }; + let json_output = cli.json || json_query_options.has_transforms(); let config = Config::load(); let auth_store = AuthStore::new(config.secrets_path.clone(), config.api_base_url.clone()); let local_db = LocalDb::new(config.state_path.clone(), config.api_base_url.clone()); @@ -852,7 +939,7 @@ async fn run() -> Result<(), Box> { let update_handle = if skip_update_check { None } else { - update::spawn_update_check(&config, &local_db, cli.json) + update::spawn_update_check(&config, &local_db, json_output) }; let result = async { @@ -867,8 +954,8 @@ async fn run() -> Result<(), Box> { RealtimeClient::connect(&config.realtime_url, &token).await?; let me = fetch_me(&mut realtime).await?; local_db.set_current_user(me.clone())?; - if cli.json { - output::print_json(&me, json_format)?; + if json_output { + print_json_with_query(&me, json_format, &json_query_options)?; } else { print_auth_user(&me); } @@ -880,12 +967,12 @@ async fn run() -> Result<(), Box> { } }, Command::Update => { - update::run_update(&config, cli.json).await?; + update::run_update(&config, json_output).await?; } Command::Doctor => { let output = build_doctor_output(&config, &auth_store, &local_db); - if cli.json { - output::print_json(&output, json_format)?; + if json_output { + print_json_with_query(&output, json_format, &json_query_options)?; } else { print_doctor(&output); } @@ -904,13 +991,21 @@ async fn run() -> Result<(), Box> { match result { proto::rpc_result::Result::GetChats(payload) => { - if cli.json { + if json_output { if args.limit.is_some() || args.offset.is_some() { let payload = apply_chat_list_limits(payload, args.limit, args.offset); - output::print_json(&payload, json_format)?; + print_json_with_query( + &payload, + json_format, + &json_query_options, + )?; } else { - output::print_json(&payload, json_format)?; + print_json_with_query( + &payload, + json_format, + &json_query_options, + )?; } } else { let current_user = local_db.load()?.current_user; @@ -946,8 +1041,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::GetChat(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else if let Some(chat) = payload.chat.as_ref() { print_chat_details(chat, payload.dialog.as_ref()); } else { @@ -972,8 +1067,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::GetChatParticipants(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { let output = build_chat_participants_output(payload); output::print_chat_participants(&output, false, json_format)?; @@ -998,8 +1093,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::AddChatParticipant(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { println!("Added user {} to chat {}.", args.user_id, args.chat_id); } @@ -1023,8 +1118,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::RemoveChatParticipant(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { println!( "Removed user {} from chat {}.", @@ -1048,7 +1143,9 @@ async fn run() -> Result<(), Box> { return Err("Public home threads are not supported yet.".into()); } if args.participants.is_empty() { - return Err("Provide at least one --participant for a home thread.".into()); + return Err( + "Provide at least one --participant for a home thread.".into() + ); } } let token = require_token(&auth_store)?; @@ -1091,8 +1188,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::CreateChat(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { if let Some(chat) = payload.chat.as_ref() { println!("Created chat {}.", chat.id); @@ -1107,8 +1204,8 @@ async fn run() -> Result<(), Box> { ChatsCommand::CreateDm(args) => { let token = require_token(&auth_store)?; let payload = api.create_private_chat(&token, args.user_id).await?; - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { let chat_id = payload.chat.get("id").and_then(|value| value.as_i64()); if let Some(chat_id) = chat_id { @@ -1150,8 +1247,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::UpdateChatVisibility(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { let label = if args.public { "public" } else { "private" }; if let Some(chat) = payload.chat.as_ref() { @@ -1180,8 +1277,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::MarkAsUnread(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { println!("Marked as unread (updates: {}).", payload.updates.len()); } @@ -1199,8 +1296,8 @@ async fn run() -> Result<(), Box> { max_id: args.max_id, }; let payload = api.read_messages(&token, input).await?; - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else if let Some(max_id) = args.max_id { println!("Marked {label} as read (max id {max_id})."); } else { @@ -1228,8 +1325,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::DeleteChat(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { println!("Deleted chat {}.", args.chat_id); } @@ -1254,8 +1351,8 @@ async fn run() -> Result<(), Box> { proto::rpc_result::Result::GetChats(payload) => { let mut output = build_user_list(&payload); filter_users_output(&mut output, args.filter.as_deref()); - if cli.json { - output::print_json(&output, json_format)?; + if json_output { + print_json_with_query(&output, json_format, &json_query_options)?; } else { output::print_users(&output, false, json_format)?; } @@ -1278,11 +1375,11 @@ async fn run() -> Result<(), Box> { match result { proto::rpc_result::Result::GetChats(payload) => { - if cli.json { + if json_output { if let Some(user) = payload.users.iter().find(|user| user.id == args.id) { - output::print_json(user, json_format)?; + print_json_with_query(user, json_format, &json_query_options)?; } else { return Err("User not found in getChats users list".into()); } @@ -1339,8 +1436,8 @@ async fn run() -> Result<(), Box> { )?; filter_messages_by_time(&mut payload.messages, since_ts, until_ts); - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { let translation_language = args .translate @@ -1432,8 +1529,8 @@ async fn run() -> Result<(), Box> { )?; filter_messages_by_time(&mut payload.messages, since_ts, until_ts); - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { let chats_result = realtime .call_rpc( @@ -1482,8 +1579,8 @@ async fn run() -> Result<(), Box> { RealtimeClient::connect(&config.realtime_url, &token).await?; let message = fetch_message_by_id(&mut realtime, &peer, args.message_id).await?; - if cli.json { - output::print_json(&message, json_format)?; + if json_output { + print_json_with_query(&message, json_format, &json_query_options)?; } else { let translation_language = args .translate @@ -1543,7 +1640,7 @@ async fn run() -> Result<(), Box> { &args.attachments, &config.data_dir, args.force_file, - cli.json, + json_output, )?; let mention_entities = parse_mention_entities(&args.mentions)?; @@ -1565,8 +1662,8 @@ async fn run() -> Result<(), Box> { mention_entities, ) .await?; - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { println!("Message sent (updates: {}).", payload.updates.len()); } @@ -1582,11 +1679,11 @@ async fn run() -> Result<(), Box> { mention_entities, attachments, peer_summary, - cli.json, + json_output, ) .await?; - if cli.json { - output::print_json(&output, json_format)?; + if json_output { + print_json_with_query(&output, json_format, &json_query_options)?; } } } @@ -1623,14 +1720,14 @@ async fn run() -> Result<(), Box> { fs::write(&output_path, payload_text.as_bytes())?; let message_count = payload.messages.len(); let bytes = payload_text.as_bytes().len(); - if cli.json { + if json_output { let output = ExportOutput { path: output_path.display().to_string(), format: "json".to_string(), messages: message_count, bytes, }; - output::print_json(&output, json_format)?; + print_json_with_query(&output, json_format, &json_query_options)?; } else { println!( "Exported {} message(s) to {}.", @@ -1654,12 +1751,12 @@ async fn run() -> Result<(), Box> { fetch_message_by_id(&mut realtime, &peer, args.message_id).await?; let output_path = resolve_download_path(&message, args.output, args.dir)?; let bytes = download_message_media(&message, &output_path).await?; - if cli.json { + if json_output { let output = DownloadOutput { path: output_path.display().to_string(), bytes, }; - output::print_json(&output, json_format)?; + print_json_with_query(&output, json_format, &json_query_options)?; } else { println!("Downloaded to {}", output_path.display()); } @@ -1691,8 +1788,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::DeleteMessages(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { println!( "Deleted {} message(s) (updates: {}).", @@ -1725,8 +1822,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::EditMessage(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { println!("Message edited (updates: {}).", payload.updates.len()); } @@ -1756,8 +1853,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::AddReaction(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { println!("Reaction added (updates: {}).", payload.updates.len()); } @@ -1787,8 +1884,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::DeleteReaction(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { println!("Reaction deleted (updates: {}).", payload.updates.len()); } @@ -1811,8 +1908,8 @@ async fn run() -> Result<(), Box> { match result { proto::rpc_result::Result::GetChats(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { let output = build_space_list(&payload); output::print_spaces(&output, false, json_format)?; @@ -1838,8 +1935,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::GetSpaceMembers(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { let output = build_space_members_output(payload); output::print_space_members(&output, false, json_format)?; @@ -1867,8 +1964,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::InviteToSpace(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { let name = payload .user @@ -1903,8 +2000,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::DeleteMember(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { println!("Member removed (updates: {}).", payload.updates.len()); } @@ -1931,8 +2028,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::UpdateMemberAccess(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { println!( "Updated member access (updates: {}).", @@ -1952,15 +2049,13 @@ async fn run() -> Result<(), Box> { let result = realtime .call_rpc( proto::Method::GetUserSettings, - proto::rpc_call::Input::GetUserSettings( - proto::GetUserSettingsInput {}, - ), + proto::rpc_call::Input::GetUserSettings(proto::GetUserSettingsInput {}), ) .await?; match result { proto::rpc_result::Result::GetUserSettings(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { print_notification_settings(payload.user_settings.as_ref()); } @@ -1970,9 +2065,7 @@ async fn run() -> Result<(), Box> { } NotificationsCommand::Set(args) => { if args.mode.is_none() && !args.silent && !args.sound { - return Err( - "Provide at least one of --mode, --silent, or --sound".into(), - ); + return Err("Provide at least one of --mode, --silent, or --sound".into()); } let token = require_token(&auth_store)?; let mut realtime = @@ -2016,8 +2109,8 @@ async fn run() -> Result<(), Box> { .await?; match result { proto::rpc_result::Result::UpdateUserSettings(payload) => { - if cli.json { - output::print_json(&payload, json_format)?; + if json_output { + print_json_with_query(&payload, json_format, &json_query_options)?; } else { println!( "Notification settings updated (updates: {}).", @@ -2083,8 +2176,8 @@ async fn run() -> Result<(), Box> { let result = api.create_linear_issue(&token, api_input).await?; - if cli.json { - output::print_json(&result, json_format)?; + if json_output { + print_json_with_query(&result, json_format, &json_query_options)?; } else if let Some(link) = result.link { println!("Created Linear issue: {}", link); } else { @@ -2104,8 +2197,8 @@ async fn run() -> Result<(), Box> { let result = api.create_notion_task(&token, api_input).await?; - if cli.json { - output::print_json(&result, json_format)?; + if json_output { + print_json_with_query(&result, json_format, &json_query_options)?; } else { let title_display = result .task_title @@ -2125,6 +2218,22 @@ async fn run() -> Result<(), Box> { result } +fn print_json_with_query( + value: &T, + json_format: output::JsonFormat, + options: &JsonQueryOptions, +) -> Result<(), Box> { + if !options.has_transforms() { + output::print_json(value, json_format)?; + return Ok(()); + } + + let payload = serde_json::to_value(value)?; + let transformed = query::apply_json_transforms(payload, options).map_err(io::Error::other)?; + output::print_json(&transformed, json_format)?; + Ok(()) +} + async fn handle_login( args: AuthLoginArgs, api: &ApiClient, @@ -2886,17 +2995,13 @@ fn notification_settings_values( } } -fn notification_mode_from_arg( - mode: NotificationModeArg, -) -> proto::notification_settings::Mode { +fn notification_mode_from_arg(mode: NotificationModeArg) -> proto::notification_settings::Mode { match mode { NotificationModeArg::All => proto::notification_settings::Mode::All, NotificationModeArg::None => proto::notification_settings::Mode::None, NotificationModeArg::Mentions => proto::notification_settings::Mode::Mentions, NotificationModeArg::OnlyMentions => proto::notification_settings::Mode::OnlyMentions, - NotificationModeArg::ImportantOnly => { - proto::notification_settings::Mode::ImportantOnly - } + NotificationModeArg::ImportantOnly => proto::notification_settings::Mode::ImportantOnly, } } @@ -2920,15 +3025,27 @@ fn print_notification_settings(settings: Option<&proto::UserSettings>) { println!(" silent: {}", if values.silent { "yes" } else { "no" }); println!( " disable dm notifications: {}", - if values.disable_dm_notifications { "yes" } else { "no" } + if values.disable_dm_notifications { + "yes" + } else { + "no" + } ); println!( " zen requires mention: {}", - if values.zen_requires_mention { "yes" } else { "no" } + if values.zen_requires_mention { + "yes" + } else { + "no" + } ); println!( " zen uses default rules: {}", - if values.zen_uses_default_rules { "yes" } else { "no" } + if values.zen_uses_default_rules { + "yes" + } else { + "no" + } ); if values.zen_custom_rules.is_empty() { println!(" zen custom rules: -"); @@ -3530,7 +3647,9 @@ fn filter_users_output(output: &mut UserListOutput, filter: Option<&str>) { return; }; let needle = filter.to_lowercase(); - output.users.retain(|user| user_matches_filter(user, &needle)); + output + .users + .retain(|user| user_matches_filter(user, &needle)); } fn user_matches_filter(user: &UserSummary, needle: &str) -> bool { @@ -3691,10 +3810,7 @@ fn print_chat_details(chat: &proto::Chat, dialog: Option<&proto::Dialog>) { println!(" read max id: {}", read_max_id); } if let Some(unread_mark) = dialog.unread_mark { - println!( - " unread mark: {}", - if unread_mark { "yes" } else { "no" } - ); + println!(" unread mark: {}", if unread_mark { "yes" } else { "no" }); } } } diff --git a/cli/src/output.rs b/cli/src/output.rs index 2d8bd1e1..67012db6 100644 --- a/cli/src/output.rs +++ b/cli/src/output.rs @@ -159,7 +159,10 @@ pub fn resolve_json_format(pretty: bool, compact: bool) -> JsonFormat { } } -pub fn json_string(value: &T, format: JsonFormat) -> Result { +pub fn json_string( + value: &T, + format: JsonFormat, +) -> Result { let payload = match format { JsonFormat::Pretty => serde_json::to_string_pretty(value)?, JsonFormat::Compact => serde_json::to_string(value)?, @@ -208,15 +211,15 @@ pub fn print_chat_list( ); for item in &output.items { - let preview = item - .last_message_line - .as_deref() - .unwrap_or(""); + let preview = item.last_message_line.as_deref().unwrap_or(""); let space = item.space_name.as_deref().unwrap_or("-"); println!( "{} {} {} {} {}", pad_left(&item.chat.id.to_string(), 6), - pad_right(&truncate_display(&item.display_name, name_width), name_width), + pad_right( + &truncate_display(&item.display_name, name_width), + name_width + ), pad_right(&truncate_display(space, space_width), space_width), pad_left(&item.unread_count.unwrap_or(0).to_string(), 6), pad_right(&truncate_display(preview, last_width), last_width), @@ -262,7 +265,10 @@ pub fn print_users( println!( "{} {} {} {} {} {}", pad_left(&user.user.id.to_string(), 6), - pad_right(&truncate_display(&user.display_name, name_width), name_width), + pad_right( + &truncate_display(&user.display_name, name_width), + name_width + ), pad_right(&truncate_display(username, username_width), username_width), pad_right(&truncate_display(email, 22), 22), pad_right(&truncate_display(phone, 16), 16), @@ -297,7 +303,10 @@ pub fn print_spaces( println!( "{} {} {}", pad_left(&space.space.id.to_string(), 6), - pad_right(&truncate_display(&space.display_name, name_width), name_width), + pad_right( + &truncate_display(&space.display_name, name_width), + name_width + ), pad_right(if space.space.creator { "yes" } else { "no" }, 7), ); } @@ -335,9 +344,19 @@ pub fn print_space_members( "{} {} {} {} {}", pad_left(&member.member.user_id.to_string(), 6), pad_left(&member.member.id.to_string(), 6), - pad_right(&truncate_display(&member.display_name, name_width), name_width), + pad_right( + &truncate_display(&member.display_name, name_width), + name_width + ), pad_right(&truncate_display(&member.role, role_width), role_width), - pad_right(if member.can_access_public_chats { "yes" } else { "no" }, 6), + pad_right( + if member.can_access_public_chats { + "yes" + } else { + "no" + }, + 6 + ), ); } Ok(()) @@ -392,7 +411,10 @@ pub fn print_messages( if let Some(peer_name) = &output.peer_name { if let Some(peer) = &output.peer { - println!("Messages for {} ({} {})", peer_name, peer.peer_type, peer.id); + println!( + "Messages for {} ({} {})", + peer_name, peer.peer_type, peer.id + ); } else { println!("Messages for {}", peer_name); } diff --git a/cli/src/query.rs b/cli/src/query.rs new file mode 100644 index 00000000..c3511f7e --- /dev/null +++ b/cli/src/query.rs @@ -0,0 +1,1017 @@ +use serde_json::{Map, Value}; +use std::cmp::Ordering; +use std::collections::HashMap; +use std::io::Write; +use std::process::{Command, Stdio}; +use std::sync::LazyLock; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct KeyAlias { + pub alias: &'static str, + pub canonical: &'static str, +} + +// Centralized alias table for query/path contexts only. +// Alias keys are lowercase and collision-free. +pub const KEY_ALIASES: &[KeyAlias] = &[ + KeyAlias { + alias: "au", + canonical: "auth", + }, + KeyAlias { + alias: "at", + canonical: "attachments", + }, + KeyAlias { + alias: "c", + canonical: "chats", + }, + KeyAlias { + alias: "cfg", + canonical: "config", + }, + KeyAlias { + alias: "cid", + canonical: "chat_id", + }, + KeyAlias { + alias: "d", + canonical: "dialogs", + }, + KeyAlias { + alias: "dn", + canonical: "display_name", + }, + KeyAlias { + alias: "em", + canonical: "email", + }, + KeyAlias { + alias: "fid", + canonical: "from_id", + }, + KeyAlias { + alias: "fn", + canonical: "first_name", + }, + KeyAlias { + alias: "it", + canonical: "items", + }, + KeyAlias { + alias: "lm", + canonical: "last_message", + }, + KeyAlias { + alias: "lmd", + canonical: "last_message_relative_date", + }, + KeyAlias { + alias: "lml", + canonical: "last_message_line", + }, + KeyAlias { + alias: "ln", + canonical: "last_name", + }, + KeyAlias { + alias: "m", + canonical: "message", + }, + KeyAlias { + alias: "mb", + canonical: "member", + }, + KeyAlias { + alias: "mbs", + canonical: "members", + }, + KeyAlias { + alias: "md", + canonical: "media", + }, + KeyAlias { + alias: "mid", + canonical: "message_id", + }, + KeyAlias { + alias: "ms", + canonical: "messages", + }, + KeyAlias { + alias: "par", + canonical: "participant", + }, + KeyAlias { + alias: "ph", + canonical: "phone_number", + }, + KeyAlias { + alias: "pth", + canonical: "paths", + }, + KeyAlias { + alias: "pid", + canonical: "peer_id", + }, + KeyAlias { + alias: "ps", + canonical: "participants", + }, + KeyAlias { + alias: "pt", + canonical: "peer_type", + }, + KeyAlias { + alias: "rd", + canonical: "relative_date", + }, + KeyAlias { + alias: "rmi", + canonical: "read_max_id", + }, + KeyAlias { + alias: "s", + canonical: "spaces", + }, + KeyAlias { + alias: "sid", + canonical: "space_id", + }, + KeyAlias { + alias: "sn", + canonical: "sender_name", + }, + KeyAlias { + alias: "sys", + canonical: "system", + }, + KeyAlias { + alias: "ti", + canonical: "title", + }, + KeyAlias { + alias: "u", + canonical: "users", + }, + KeyAlias { + alias: "uc", + canonical: "unread_count", + }, + KeyAlias { + alias: "uid", + canonical: "user_id", + }, + KeyAlias { + alias: "um", + canonical: "unread_mark", + }, + KeyAlias { + alias: "un", + canonical: "username", + }, +]; + +static ALIAS_MAP: LazyLock> = LazyLock::new(|| { + KEY_ALIASES + .iter() + .map(|entry| (entry.alias, entry.canonical)) + .collect() +}); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum QueryContext { + Jq, + QueryPath, + FieldProjection, + JsonPath, + SortPath, +} + +#[derive(Clone, Debug, Default)] +pub struct JsonQueryOptions { + pub jq_filter: Option, + pub query_paths: Vec, + pub fields: Vec, + pub jsonpaths: Vec, + pub sort_path: Option, + pub sort_desc: bool, +} + +impl JsonQueryOptions { + pub fn has_transforms(&self) -> bool { + self.jq_filter.is_some() + || !self.query_paths.is_empty() + || !self.fields.is_empty() + || !self.jsonpaths.is_empty() + || self.sort_path.is_some() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum PathSegment { + Key(String), + Index(usize), + Wildcard, +} + +pub fn normalize_aliases(input: &str, context: QueryContext) -> String { + match context { + QueryContext::Jq => normalize_expression(input, false), + QueryContext::JsonPath => normalize_expression(input, true), + QueryContext::QueryPath | QueryContext::FieldProjection | QueryContext::SortPath => { + normalize_path(input) + } + } +} + +pub fn apply_json_transforms( + mut value: Value, + options: &JsonQueryOptions, +) -> Result { + if !options.query_paths.is_empty() { + let normalized = options + .query_paths + .iter() + .map(|path| normalize_aliases(path, QueryContext::QueryPath)) + .collect::>(); + value = apply_query_paths(&value, &normalized)?; + } + + if !options.jsonpaths.is_empty() { + let normalized = options + .jsonpaths + .iter() + .map(|path| normalize_aliases(path, QueryContext::JsonPath)) + .collect::>(); + value = apply_jsonpaths(&value, &normalized)?; + } + + if let Some(path) = &options.sort_path { + let normalized = normalize_aliases(path, QueryContext::SortPath); + sort_array_by_path(&mut value, &normalized, options.sort_desc)?; + } + + if !options.fields.is_empty() { + let normalized = options + .fields + .iter() + .map(|path| normalize_aliases(path, QueryContext::FieldProjection)) + .collect::>(); + value = project_fields(value, &normalized)?; + } + + if let Some(filter) = &options.jq_filter { + let normalized = normalize_aliases(filter, QueryContext::Jq); + value = apply_jq_filter(&value, &normalized)?; + } + + Ok(value) +} + +fn normalize_expression(input: &str, rewrite_root_tokens: bool) -> String { + let bytes = input.as_bytes(); + let mut out = String::with_capacity(input.len()); + let mut i = 0; + + let mut in_single = false; + let mut in_double = false; + let mut in_comment = false; + let mut root_ok = rewrite_root_tokens; + + while i < bytes.len() { + let ch = bytes[i] as char; + + if in_comment { + out.push(ch); + i += 1; + if ch == '\n' { + in_comment = false; + root_ok = rewrite_root_tokens; + } + continue; + } + + if in_single { + out.push(ch); + i += 1; + if ch == '\\' && i < bytes.len() { + out.push(bytes[i] as char); + i += 1; + continue; + } + if ch == '\'' { + in_single = false; + } + continue; + } + + if in_double { + out.push(ch); + i += 1; + if ch == '\\' && i < bytes.len() { + out.push(bytes[i] as char); + i += 1; + continue; + } + if ch == '"' { + in_double = false; + } + continue; + } + + match ch { + '#' => { + in_comment = true; + out.push(ch); + i += 1; + } + '\'' => { + in_single = true; + out.push(ch); + i += 1; + } + '"' => { + in_double = true; + out.push(ch); + i += 1; + } + '.' => { + out.push(ch); + i += 1; + if i < bytes.len() && is_ident_start(bytes[i]) { + let start = i; + i = read_identifier_end(bytes, i); + let token = &input[start..i]; + out.push_str(rewrite_token(token)); + } + root_ok = rewrite_root_tokens; + } + '$' => { + out.push(ch); + i += 1; + root_ok = rewrite_root_tokens; + } + _ if rewrite_root_tokens && root_ok && is_ident_start(bytes[i]) => { + let start = i; + i = read_identifier_end(bytes, i); + let token = &input[start..i]; + out.push_str(rewrite_token(token)); + root_ok = false; + } + _ => { + out.push(ch); + i += 1; + root_ok = rewrite_root_tokens && is_root_separator(ch); + } + } + } + + out +} + +fn normalize_path(input: &str) -> String { + let bytes = input.as_bytes(); + let mut out = String::with_capacity(input.len()); + let mut i = 0; + let mut root_ok = true; + + while i < bytes.len() { + let ch = bytes[i] as char; + match ch { + '$' => { + out.push(ch); + i += 1; + root_ok = true; + } + '.' => { + out.push(ch); + i += 1; + root_ok = true; + if i < bytes.len() && is_ident_start(bytes[i]) { + let start = i; + i = read_identifier_end(bytes, i); + let token = &input[start..i]; + out.push_str(rewrite_token(token)); + root_ok = false; + } + } + '[' => { + i = rewrite_bracket_content(input, bytes, i, &mut out); + root_ok = false; + } + _ if root_ok && is_ident_start(bytes[i]) => { + let start = i; + i = read_identifier_end(bytes, i); + let token = &input[start..i]; + out.push_str(rewrite_token(token)); + root_ok = false; + } + _ => { + out.push(ch); + i += 1; + root_ok = ch.is_whitespace() || ch == '|' || ch == ','; + } + } + } + + out +} + +fn rewrite_bracket_content(input: &str, bytes: &[u8], mut i: usize, out: &mut String) -> usize { + out.push('['); + i += 1; + + if i >= bytes.len() { + return i; + } + + let first = bytes[i] as char; + if first == '\'' || first == '"' { + let quote = first; + out.push(quote); + i += 1; + while i < bytes.len() { + let ch = bytes[i] as char; + out.push(ch); + i += 1; + if ch == '\\' && i < bytes.len() { + out.push(bytes[i] as char); + i += 1; + continue; + } + if ch == quote { + break; + } + } + if i < bytes.len() && bytes[i] as char == ']' { + out.push(']'); + i += 1; + } + return i; + } + + let start = i; + while i < bytes.len() && bytes[i] as char != ']' { + i += 1; + } + let raw = &input[start..i]; + let rewritten = rewrite_bracket_token(raw); + out.push_str(&rewritten); + + if i < bytes.len() && bytes[i] as char == ']' { + out.push(']'); + i += 1; + } + + i +} + +fn rewrite_bracket_token(raw: &str) -> String { + let leading = raw.len() - raw.trim_start().len(); + let trailing = raw.len() - raw.trim_end().len(); + let core_end = raw.len().saturating_sub(trailing); + let core = &raw[leading..core_end]; + + let rewritten_core = if core.is_empty() + || core == "*" + || core.chars().all(|ch| ch.is_ascii_digit()) + || !is_identifier_token(core) + { + core.to_string() + } else { + rewrite_token(core).to_string() + }; + + format!("{}{}{}", &raw[..leading], rewritten_core, &raw[core_end..]) +} + +fn rewrite_token(token: &str) -> &str { + if !is_rewrite_candidate(token) { + return token; + } + ALIAS_MAP.get(token).copied().unwrap_or(token) +} + +fn is_rewrite_candidate(token: &str) -> bool { + !token.is_empty() + && token + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') +} + +fn is_ident_start(byte: u8) -> bool { + byte.is_ascii_alphabetic() || byte == b'_' +} + +fn is_ident_char(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +fn is_identifier_token(token: &str) -> bool { + let bytes = token.as_bytes(); + if bytes.is_empty() { + return false; + } + if !is_ident_start(bytes[0]) { + return false; + } + bytes[1..].iter().all(|byte| is_ident_char(*byte)) +} + +fn read_identifier_end(bytes: &[u8], mut idx: usize) -> usize { + while idx < bytes.len() && is_ident_char(bytes[idx]) { + idx += 1; + } + idx +} + +fn is_root_separator(ch: char) -> bool { + ch.is_whitespace() || matches!(ch, '|' | ',' | ';' | '(' | ')' | '{' | '}' | '[') +} + +fn apply_query_paths(value: &Value, paths: &[String]) -> Result { + if paths.len() == 1 { + return select_path_value(value, &paths[0]); + } + + let mut out = Map::new(); + for path in paths { + out.insert(path.clone(), select_path_value(value, path)?); + } + Ok(Value::Object(out)) +} + +fn apply_jsonpaths(value: &Value, paths: &[String]) -> Result { + let mut all = Vec::new(); + for path in paths { + let values = select_path_values(value, path)?; + all.extend(values); + } + Ok(Value::Array(all)) +} + +fn select_path_value(value: &Value, path: &str) -> Result { + let matches = select_path_values(value, path)?; + Ok(match matches.len() { + 0 => Value::Null, + 1 => matches.into_iter().next().unwrap_or(Value::Null), + _ => Value::Array(matches), + }) +} + +fn select_path_values(value: &Value, path: &str) -> Result, String> { + let segments = parse_path(path)?; + let mut current = vec![value.clone()]; + + for segment in segments { + let mut next = Vec::new(); + for item in current { + match segment { + PathSegment::Key(ref key) => { + if let Value::Object(object) = &item + && let Some(found) = object.get(key) + { + next.push(found.clone()); + } + } + PathSegment::Index(idx) => { + if let Value::Array(array) = &item + && let Some(found) = array.get(idx) + { + next.push(found.clone()); + } + } + PathSegment::Wildcard => match &item { + Value::Array(array) => { + next.extend(array.iter().cloned()); + } + Value::Object(object) => { + next.extend(object.values().cloned()); + } + _ => {} + }, + } + } + current = next; + if current.is_empty() { + break; + } + } + + Ok(current) +} + +fn parse_path(path: &str) -> Result, String> { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err("path cannot be empty".to_string()); + } + + let chars: Vec = trimmed.chars().collect(); + let mut idx = 0; + let mut segments = Vec::new(); + + if chars.get(idx) == Some(&'$') { + idx += 1; + } + + while idx < chars.len() { + match chars[idx] { + '.' => { + idx += 1; + } + '[' => { + let (segment, next) = parse_bracket_segment(&chars, idx)?; + segments.push(segment); + idx = next; + } + ch if is_ident_start(ch as u8) => { + let start = idx; + idx += 1; + while idx < chars.len() && is_ident_char(chars[idx] as u8) { + idx += 1; + } + let key: String = chars[start..idx].iter().collect(); + segments.push(PathSegment::Key(key)); + } + ch if ch.is_whitespace() => { + idx += 1; + } + ch => { + return Err(format!("unsupported token '{ch}' in path '{trimmed}'")); + } + } + } + + Ok(segments) +} + +fn parse_bracket_segment(chars: &[char], start: usize) -> Result<(PathSegment, usize), String> { + let mut idx = start + 1; + while idx < chars.len() && chars[idx].is_whitespace() { + idx += 1; + } + + if idx >= chars.len() { + return Err("unterminated bracket segment".to_string()); + } + + if chars[idx] == '\'' || chars[idx] == '"' { + let quote = chars[idx]; + idx += 1; + let value_start = idx; + while idx < chars.len() { + if chars[idx] == '\\' { + idx += 2; + continue; + } + if chars[idx] == quote { + break; + } + idx += 1; + } + if idx >= chars.len() || chars[idx] != quote { + return Err("unterminated quoted key in bracket segment".to_string()); + } + let key: String = chars[value_start..idx].iter().collect(); + idx += 1; + while idx < chars.len() && chars[idx].is_whitespace() { + idx += 1; + } + if idx >= chars.len() || chars[idx] != ']' { + return Err("missing closing ] for bracket segment".to_string()); + } + return Ok((PathSegment::Key(key), idx + 1)); + } + + let value_start = idx; + while idx < chars.len() && chars[idx] != ']' { + idx += 1; + } + if idx >= chars.len() { + return Err("unterminated bracket segment".to_string()); + } + let raw: String = chars[value_start..idx].iter().collect(); + let token = raw.trim(); + let segment = if token.is_empty() || token == "*" { + PathSegment::Wildcard + } else if token.chars().all(|ch| ch.is_ascii_digit()) { + let parsed = token + .parse::() + .map_err(|_| format!("invalid index '{token}'"))?; + PathSegment::Index(parsed) + } else if is_identifier_token(token) { + PathSegment::Key(token.to_string()) + } else { + return Err(format!( + "unsupported bracket expression '{token}' (use dot paths, indexes, *, or quoted keys)" + )); + }; + + Ok((segment, idx + 1)) +} + +fn sort_array_by_path(value: &mut Value, path: &str, descending: bool) -> Result<(), String> { + let segments = parse_path(path)?; + let Value::Array(items) = value else { + return Err("--sort-path requires the current JSON value to be an array".to_string()); + }; + + items.sort_by(|left, right| compare_path_values(left, right, &segments)); + if descending { + items.reverse(); + } + Ok(()) +} + +fn compare_path_values(left: &Value, right: &Value, segments: &[PathSegment]) -> Ordering { + let left_value = first_match(left, segments); + let right_value = first_match(right, segments); + compare_optional_values(left_value, right_value) +} + +fn first_match<'a>(value: &'a Value, segments: &[PathSegment]) -> Option<&'a Value> { + let mut current = value; + for segment in segments { + match segment { + PathSegment::Key(key) => { + let Value::Object(object) = current else { + return None; + }; + current = object.get(key)?; + } + PathSegment::Index(idx) => { + let Value::Array(array) = current else { + return None; + }; + current = array.get(*idx)?; + } + PathSegment::Wildcard => { + return None; + } + } + } + Some(current) +} + +fn compare_optional_values(left: Option<&Value>, right: Option<&Value>) -> Ordering { + match (left, right) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(left), Some(right)) => compare_values(left, right), + } +} + +fn compare_values(left: &Value, right: &Value) -> Ordering { + match (left, right) { + (Value::Number(left), Value::Number(right)) => compare_numbers(left, right), + (Value::String(left), Value::String(right)) => left.cmp(right), + (Value::Bool(left), Value::Bool(right)) => left.cmp(right), + (Value::Null, Value::Null) => Ordering::Equal, + _ => { + let left_rank = value_rank(left); + let right_rank = value_rank(right); + left_rank + .cmp(&right_rank) + .then_with(|| left.to_string().cmp(&right.to_string())) + } + } +} + +fn compare_numbers(left: &serde_json::Number, right: &serde_json::Number) -> Ordering { + let left_f = left.as_f64().unwrap_or_default(); + let right_f = right.as_f64().unwrap_or_default(); + left_f.partial_cmp(&right_f).unwrap_or(Ordering::Equal) +} + +fn value_rank(value: &Value) -> usize { + match value { + Value::Null => 0, + Value::Bool(_) => 1, + Value::Number(_) => 2, + Value::String(_) => 3, + Value::Array(_) => 4, + Value::Object(_) => 5, + } +} + +fn project_fields(value: Value, fields: &[String]) -> Result { + let Value::Array(items) = value else { + return Err("--field requires the current JSON value to be an array".to_string()); + }; + + let mut projected = Vec::with_capacity(items.len()); + let parsed_fields = fields + .iter() + .map(|path| { + let segments = parse_path(path)?; + let label = projection_label(path, &segments); + Ok((label, segments)) + }) + .collect::, String>>()?; + + for item in items { + let mut object = Map::new(); + for (label, segments) in &parsed_fields { + let value = first_match(&item, segments).cloned().unwrap_or(Value::Null); + object.insert(label.clone(), value); + } + projected.push(Value::Object(object)); + } + + Ok(Value::Array(projected)) +} + +fn projection_label(path: &str, segments: &[PathSegment]) -> String { + for segment in segments.iter().rev() { + if let PathSegment::Key(key) = segment { + return key.clone(); + } + } + path.to_string() +} + +fn apply_jq_filter(value: &Value, filter: &str) -> Result { + let mut child = Command::new("jq") + .arg("-c") + .arg(filter) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("failed to run jq: {error}"))?; + + let payload = + serde_json::to_vec(value).map_err(|error| format!("failed to encode json: {error}"))?; + { + let stdin = child + .stdin + .as_mut() + .ok_or_else(|| "failed to open jq stdin".to_string())?; + stdin + .write_all(&payload) + .map_err(|error| format!("failed to write jq stdin: {error}"))?; + } + + let output = child + .wait_with_output() + .map_err(|error| format!("failed to wait for jq: {error}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let message = stderr.trim(); + return Err(if message.is_empty() { + "jq filter failed".to_string() + } else { + format!("jq filter failed: {message}") + }); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let lines = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>(); + + if lines.is_empty() { + return Ok(Value::Null); + } + + if lines.len() == 1 { + return serde_json::from_str(lines[0]) + .map_err(|error| format!("invalid jq output: {error}")); + } + + let mut values = Vec::with_capacity(lines.len()); + for line in lines { + let parsed = serde_json::from_str(line) + .map_err(|error| format!("invalid jq output line: {error}"))?; + values.push(parsed); + } + Ok(Value::Array(values)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn rewrites_aliases_in_jq_field_paths() { + let input = ".u[] | .fn + \" \" + (.ln // \"\")"; + let output = normalize_aliases(input, QueryContext::Jq); + assert_eq!( + output, + ".users[] | .first_name + \" \" + (.last_name // \"\")" + ); + } + + #[test] + fn does_not_rewrite_strings_comments_or_mixed_case_tokens() { + let input = ".u[] | .Fn # .fn should stay in comment\n| \".fn\" | .fn"; + let output = normalize_aliases(input, QueryContext::Jq); + assert_eq!( + output, + ".users[] | .Fn # .fn should stay in comment\n| \".fn\" | .first_name" + ); + } + + #[test] + fn preserves_quoted_bracket_keys() { + let input = "users[\"fn\"].ln"; + let output = normalize_aliases(input, QueryContext::QueryPath); + assert_eq!(output, "users[\"fn\"].last_name"); + } + + #[test] + fn rewrites_root_and_nested_path_segments() { + let input = "u[].fn"; + let output = normalize_aliases(input, QueryContext::QueryPath); + assert_eq!(output, "users[].first_name"); + } + + #[test] + fn keeps_long_form_paths_backward_compatible() { + let input = "users[].first_name"; + let output = normalize_aliases(input, QueryContext::QueryPath); + assert_eq!(output, "users[].first_name"); + } + + #[test] + fn query_paths_extract_values() { + let value = json!({"users": [{"first_name": "Ada"}]}); + let out = apply_query_paths(&value, &["users[0].first_name".to_string()]) + .expect("query should succeed"); + assert_eq!(out, json!("Ada")); + } + + #[test] + fn fields_project_array_objects() { + let value = json!([ + {"id": 2, "first_name": "Bea", "last_name": "Lee"}, + {"id": 1, "first_name": "Ada", "last_name": "Chen"} + ]); + let out = project_fields(value, &["id".to_string(), "first_name".to_string()]) + .expect("projection should succeed"); + assert_eq!( + out, + json!([ + {"id": 2, "first_name": "Bea"}, + {"id": 1, "first_name": "Ada"} + ]) + ); + } + + #[test] + fn sort_path_orders_array() { + let mut value = json!([ + {"id": 2, "display_name": "Bea"}, + {"id": 1, "display_name": "Ada"} + ]); + sort_array_by_path(&mut value, "display_name", false).expect("sort should succeed"); + assert_eq!( + value, + json!([ + {"id": 1, "display_name": "Ada"}, + {"id": 2, "display_name": "Bea"} + ]) + ); + } + + #[test] + fn pipeline_uses_aliases_without_mutating_payload_keys() { + let original = json!({ + "users": [ + {"id": 1, "first_name": "Ada"}, + {"id": 2, "first_name": "Bea"} + ] + }); + let options = JsonQueryOptions { + query_paths: vec!["u".to_string()], + sort_path: Some("fn".to_string()), + fields: vec!["id".to_string(), "fn".to_string()], + ..Default::default() + }; + + let out = + apply_json_transforms(original.clone(), &options).expect("pipeline should succeed"); + assert_eq!( + out, + json!([ + {"id": 1, "first_name": "Ada"}, + {"id": 2, "first_name": "Bea"} + ]) + ); + + assert!( + original + .get("users") + .and_then(|value| value.as_array()) + .and_then(|array| array.first()) + .and_then(|item| item.get("first_name")) + .is_some() + ); + } +} diff --git a/cli/src/realtime.rs b/cli/src/realtime.rs index 2cdc6c71..f0878e4e 100644 --- a/cli/src/realtime.rs +++ b/cli/src/realtime.rs @@ -30,7 +30,9 @@ pub enum RealtimeError { } pub struct RealtimeClient { - ws: tokio_tungstenite::WebSocketStream>, + ws: tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, seq: u32, id_gen: IdGenerator, } @@ -89,7 +91,7 @@ impl RealtimeClient { }); } Some(proto::server_protocol_message::Body::ConnectionError(_)) => { - return Err(RealtimeError::ConnectionError) + return Err(RealtimeError::ConnectionError); } _ => {} } @@ -147,7 +149,7 @@ impl RealtimeClient { }); } Some(proto::server_protocol_message::Body::ConnectionError(_)) => { - return Err(RealtimeError::ConnectionError) + return Err(RealtimeError::ConnectionError); } _ => {} } @@ -183,14 +185,17 @@ impl RealtimeClient { match message.body { Some(proto::server_protocol_message::Body::ConnectionOpen(_)) => return Ok(()), Some(proto::server_protocol_message::Body::ConnectionError(_)) => { - return Err(RealtimeError::ConnectionError) + return Err(RealtimeError::ConnectionError); } _ => {} } } } - async fn send_client_message(&mut self, message: proto::ClientMessage) -> Result<(), RealtimeError> { + async fn send_client_message( + &mut self, + message: proto::ClientMessage, + ) -> Result<(), RealtimeError> { let bytes = message.encode_to_vec(); self.ws.send(WsMessage::Binary(bytes)).await?; Ok(()) @@ -198,9 +203,15 @@ impl RealtimeClient { async fn read_server_message(&mut self) -> Result { loop { - let message = self.ws.next().await.ok_or(RealtimeError::ConnectionError)??; + let message = self + .ws + .next() + .await + .ok_or(RealtimeError::ConnectionError)??; match message { - WsMessage::Binary(data) => return Ok(proto::ServerProtocolMessage::decode(&*data)?), + WsMessage::Binary(data) => { + return Ok(proto::ServerProtocolMessage::decode(&*data)?); + } WsMessage::Text(_) => continue, WsMessage::Close(_) => return Err(RealtimeError::ConnectionError), WsMessage::Ping(_) | WsMessage::Pong(_) => continue, diff --git a/cli/src/update.rs b/cli/src/update.rs index fe91f1ad..95dbc911 100644 --- a/cli/src/update.rs +++ b/cli/src/update.rs @@ -150,21 +150,19 @@ async fn run_update_inner( Ok(()) } -pub fn spawn_update_check(config: &Config, local_db: &LocalDb, json: bool) -> Option> { +pub fn spawn_update_check( + config: &Config, + local_db: &LocalDb, + json: bool, +) -> Option> { let manifest_url = config.release_manifest_url.clone()?; let install_url = config.release_install_url.clone(); let local_db = local_db.clone(); let current_version = env!("CARGO_PKG_VERSION").to_string(); Some(tokio::spawn(async move { - if let Err(error) = check_for_update( - manifest_url, - install_url, - local_db, - current_version, - json, - ) - .await + if let Err(error) = + check_for_update(manifest_url, install_url, local_db, current_version, json).await { if cfg!(debug_assertions) { eprintln!("update check failed: {error}"); @@ -227,7 +225,12 @@ async fn check_for_update( .unwrap_or(true); if should_notify { let install_url = manifest.install_url.clone().or(install_url); - print_update_notice(¤t_version, &manifest.version, install_url.as_deref(), json); + print_update_notice( + ¤t_version, + &manifest.version, + install_url.as_deref(), + json, + ); state.last_update_notified_version = Some(manifest.version.clone()); } } @@ -297,7 +300,9 @@ async fn download_file(url: &str, path: &Path) -> Result<(), UpdateError> { } fn create_temp_dir() -> Result { - let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); let dir = std::env::temp_dir().join(format!("inline-update-{}", timestamp.as_secs())); fs::create_dir_all(&dir)?; Ok(dir) @@ -365,9 +370,7 @@ fn install_binary(staged_path: &Path, install_path: &Path) -> Result Result<( if status.success() { return Ok(()); } - return Err(io::Error::new( - io::ErrorKind::Other, - "sudo install failed", - )); + return Err(io::Error::new(io::ErrorKind::Other, "sudo install failed")); } let status = Command::new("sudo") @@ -433,10 +433,7 @@ fn install_binary_with_sudo(staged_path: &Path, install_path: &Path) -> Result<( .arg(install_path) .status()?; if !status.success() { - return Err(io::Error::new( - io::ErrorKind::Other, - "sudo copy failed", - )); + return Err(io::Error::new(io::ErrorKind::Other, "sudo copy failed")); } let status = Command::new("sudo") .arg("chmod") @@ -444,10 +441,7 @@ fn install_binary_with_sudo(staged_path: &Path, install_path: &Path) -> Result<( .arg(install_path) .status()?; if !status.success() { - return Err(io::Error::new( - io::ErrorKind::Other, - "sudo chmod failed", - )); + return Err(io::Error::new(io::ErrorKind::Other, "sudo chmod failed")); } Ok(()) } diff --git a/cli/tests/query_aliases_integration.rs b/cli/tests/query_aliases_integration.rs new file mode 100644 index 00000000..d7944b12 --- /dev/null +++ b/cli/tests/query_aliases_integration.rs @@ -0,0 +1,38 @@ +use serde_json::Value; +use std::process::Command; + +fn run_inline(args: &[&str]) -> Value { + let output = Command::new(env!("CARGO_BIN_EXE_inline")) + .args(args) + .output() + .expect("failed to execute inline binary"); + + assert!( + output.status.success(), + "inline failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + serde_json::from_slice(&output.stdout).expect("stdout should be valid json") +} + +#[test] +fn query_path_aliases_work_on_command_output() { + let value = run_inline(&["doctor", "--json", "--query-path", "cfg.apiBaseUrl"]); + let Some(url) = value.as_str() else { + panic!("expected string url, got {value}"); + }; + assert!(url.starts_with("http")); +} + +#[test] +fn quoted_bracket_keys_are_not_rewritten() { + let value = run_inline(&["doctor", "--json", "--query-path", "cfg[\"apiBaseUrl\"]"]); + assert!(value.as_str().is_some()); +} + +#[test] +fn mixed_case_tokens_are_not_rewritten() { + let value = run_inline(&["doctor", "--json", "--query-path", "cfg.ApiBaseUrl"]); + assert!(value.is_null()); +}