From ad9d1346680075fd03a4c5836e859d49310ab8d7 Mon Sep 17 00:00:00 2001 From: Adolfo Usier Date: Thu, 26 Mar 2026 21:00:59 +0000 Subject: [PATCH] chore: fmt and clippy cleanup across all crates Run cargo fmt --all and fix all clippy warnings: - Replace map_or(false, ...) with is_some_and(...) - Replace map_or(true, ...) with is_none_or(...) - Replace filter().next_back() with rfind() - Replace match with single arm to if let - Add #[derive(Default)] with #[default] on enums - Remove dead code (unused variables, unused imports) - Simplify format!("{}", x) to x.to_string() - Replace .clamp() for min/max chains - Fix needless borrows and other minor lints --- Cargo.lock | 16 +- crates/opencli-rs-ai/src/cascade.rs | 17 +- crates/opencli-rs-ai/src/explore.rs | 171 ++++---- crates/opencli-rs-ai/src/generate.rs | 47 ++- crates/opencli-rs-ai/src/lib.rs | 22 +- crates/opencli-rs-ai/src/synthesize.rs | 145 ++++--- crates/opencli-rs-ai/src/types.rs | 133 ++++++- crates/opencli-rs-browser/src/bridge.rs | 12 +- crates/opencli-rs-browser/src/cdp.rs | 21 +- crates/opencli-rs-browser/src/daemon.rs | 11 +- .../opencli-rs-browser/src/daemon_client.rs | 4 +- crates/opencli-rs-browser/src/lib.rs | 12 +- crates/opencli-rs-browser/src/page.rs | 8 +- crates/opencli-rs-cli/src/args.rs | 4 +- crates/opencli-rs-cli/src/commands/doctor.rs | 3 +- crates/opencli-rs-cli/src/commands/mod.rs | 2 +- crates/opencli-rs-cli/src/execution.rs | 11 +- crates/opencli-rs-cli/src/main.rs | 150 +++++-- crates/opencli-rs-core/src/args.rs | 9 +- crates/opencli-rs-core/src/command.rs | 12 +- crates/opencli-rs-core/src/lib.rs | 8 +- crates/opencli-rs-core/src/page.rs | 14 +- crates/opencli-rs-core/src/registry.rs | 3 +- crates/opencli-rs-core/src/strategy.rs | 9 +- crates/opencli-rs-discovery/build.rs | 5 +- crates/opencli-rs-discovery/src/user.rs | 5 +- .../opencli-rs-discovery/src/yaml_parser.rs | 14 +- crates/opencli-rs-external/src/executor.rs | 15 +- crates/opencli-rs-external/src/lib.rs | 8 +- crates/opencli-rs-output/src/csv_out.rs | 15 +- crates/opencli-rs-output/src/format.rs | 9 +- crates/opencli-rs-output/src/lib.rs | 6 +- crates/opencli-rs-output/src/table.rs | 2 +- crates/opencli-rs-pipeline/src/executor.rs | 16 +- .../opencli-rs-pipeline/src/steps/browser.rs | 74 ++-- .../opencli-rs-pipeline/src/steps/download.rs | 371 +++++++++++++----- crates/opencli-rs-pipeline/src/steps/fetch.rs | 108 ++--- .../src/steps/intercept.rs | 12 +- crates/opencli-rs-pipeline/src/steps/tap.rs | 11 +- .../src/steps/transform.rs | 21 +- .../src/template/evaluator.rs | 38 +- .../src/template/filters.rs | 55 ++- .../opencli-rs-pipeline/src/template/mod.rs | 66 +--- .../src/template/parser.rs | 34 +- 44 files changed, 1031 insertions(+), 698 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3b58bb3..36766ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -954,7 +954,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "opencli-rs" -version = "0.1.0" +version = "0.1.1" dependencies = [ "clap", "clap_complete", @@ -975,7 +975,7 @@ dependencies = [ [[package]] name = "opencli-rs-ai" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-trait", "opencli-rs-browser", @@ -991,7 +991,7 @@ dependencies = [ [[package]] name = "opencli-rs-browser" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-trait", "axum", @@ -1009,7 +1009,7 @@ dependencies = [ [[package]] name = "opencli-rs-core" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-trait", "serde", @@ -1020,7 +1020,7 @@ dependencies = [ [[package]] name = "opencli-rs-discovery" -version = "0.1.0" +version = "0.1.1" dependencies = [ "opencli-rs-core", "opencli-rs-pipeline", @@ -1033,7 +1033,7 @@ dependencies = [ [[package]] name = "opencli-rs-external" -version = "0.1.0" +version = "0.1.1" dependencies = [ "opencli-rs-core", "serde", @@ -1046,7 +1046,7 @@ dependencies = [ [[package]] name = "opencli-rs-output" -version = "0.1.0" +version = "0.1.1" dependencies = [ "colored", "comfy-table", @@ -1059,7 +1059,7 @@ dependencies = [ [[package]] name = "opencli-rs-pipeline" -version = "0.1.0" +version = "0.1.1" dependencies = [ "async-trait", "base64", diff --git a/crates/opencli-rs-ai/src/cascade.rs b/crates/opencli-rs-ai/src/cascade.rs index 561ed56..952a580 100644 --- a/crates/opencli-rs-ai/src/cascade.rs +++ b/crates/opencli-rs-ai/src/cascade.rs @@ -88,11 +88,7 @@ fn build_fetch_probe_js(url: &str, credentials: bool, extract_csrf: bool) -> Str /// Probe an endpoint with a specific strategy. /// Returns whether the probe succeeded and basic response info. -pub async fn probe_endpoint( - page: &dyn IPage, - url: &str, - strategy: Strategy, -) -> StrategyTestResult { +pub async fn probe_endpoint(page: &dyn IPage, url: &str, strategy: Strategy) -> StrategyTestResult { let result = match strategy { Strategy::Public => { // PUBLIC: plain fetch, no credentials @@ -163,10 +159,7 @@ async fn eval_probe(page: &dyn IPage, js: &str, strategy: Strategy) -> StrategyT /// /// Confidence: 1.0 for PUBLIC, 0.9 for COOKIE, 0.8 for HEADER (simpler = more confident). /// If none works, defaults to COOKIE with 0.3 confidence. -pub async fn cascade( - page: &dyn IPage, - api_url: &str, -) -> Result { +pub async fn cascade(page: &dyn IPage, api_url: &str) -> Result { // Don't auto-try INTERCEPT/UI -- stop at HEADER let max_idx = CASCADE_ORDER .iter() @@ -267,7 +260,11 @@ mod tests { #[test] fn test_probe_js_url_escaping() { - let js = build_fetch_probe_js("https://api.example.com/data?q=hello&limit=10", false, false); + let js = build_fetch_probe_js( + "https://api.example.com/data?q=hello&limit=10", + false, + false, + ); assert!(js.contains("api.example.com")); } diff --git a/crates/opencli-rs-ai/src/explore.rs b/crates/opencli-rs-ai/src/explore.rs index 66d8f78..7480ddd 100644 --- a/crates/opencli-rs-ai/src/explore.rs +++ b/crates/opencli-rs-ai/src/explore.rs @@ -10,10 +10,9 @@ use serde_json::Value; use tracing::debug; use crate::types::{ - DiscoveredEndpoint, ExploreManifest, ExploreOptions, ExploreResult, - FieldInfo, InferredCapability, RecommendedArg, ResponseAnalysis, - StoreHint, StoreInfo, FIELD_ROLES, KNOWN_SITE_ALIASES, LIMIT_PARAMS, - PAGINATION_PARAMS, SEARCH_PARAMS, VOLATILE_PARAMS, + DiscoveredEndpoint, ExploreManifest, ExploreOptions, ExploreResult, FieldInfo, + InferredCapability, RecommendedArg, ResponseAnalysis, StoreHint, StoreInfo, FIELD_ROLES, + KNOWN_SITE_ALIASES, LIMIT_PARAMS, PAGINATION_PARAMS, SEARCH_PARAMS, VOLATILE_PARAMS, }; // ── JavaScript snippets ───────────────────────────────────────────────────── @@ -116,7 +115,8 @@ pub async fn explore( // Step 1: Navigate to URL page.goto(url, None).await?; - page.wait_for_timeout((wait_seconds * 1000.0) as u64).await?; + page.wait_for_timeout((wait_seconds * 1000.0) as u64) + .await?; // Step 2: Auto-scroll to trigger lazy loading let max_scrolls = options.max_scrolls.unwrap_or(5); @@ -132,7 +132,8 @@ pub async fn explore( if options.auto_fuzz.unwrap_or(false) { // Targeted clicks by label for label in &options.click_labels { - let safe_label = serde_json::to_string(label).unwrap_or_else(|_| format!("\"{}\"", label)); + let safe_label = + serde_json::to_string(label).unwrap_or_else(|_| format!("\"{}\"", label)); let click_js = format!( r#" (() => {{ @@ -187,16 +188,19 @@ pub async fn explore( }; // Step 6.7: If goal is "search" and no search endpoints found, try discovering search - let is_search_goal = options.goal.as_deref().map_or(false, |g| g == "search"); + let is_search_goal = options.goal.as_deref() == Some("search"); if is_search_goal { let has_search = network.iter().any(|r| { - SEARCH_PARAMS.iter().any(|p| r.url.contains(&format!("{}=", p))) + SEARCH_PARAMS + .iter() + .any(|p| r.url.contains(&format!("{}=", p))) }); if !has_search { debug!("Goal is 'search' but no search endpoints found, trying search discovery"); // Try common search paths let base = url::Url::parse(url).ok(); - let origin = base.as_ref() + let origin = base + .as_ref() .map(|u| format!("{}://{}", u.scheme(), u.host_str().unwrap_or(""))) .unwrap_or_default(); @@ -209,14 +213,16 @@ pub async fn explore( for search_url in &search_urls { debug!("Trying search URL: {}", search_url); - if let Ok(_) = page.goto(search_url, None).await { + if page.goto(search_url, None).await.is_ok() { let _ = page.wait_for_timeout(3000).await; // Capture new network requests let mut new_network = page.get_network_requests().await.unwrap_or_default(); re_fetch_missing_bodies(page, &mut new_network).await; // Check if any new requests have search params let found_search = new_network.iter().any(|r| { - SEARCH_PARAMS.iter().any(|p| r.url.contains(&format!("{}=", p))) + SEARCH_PARAMS + .iter() + .any(|p| r.url.contains(&format!("{}=", p))) }); if found_search { debug!("Found search endpoints via {}", search_url); @@ -277,7 +283,8 @@ pub async fn explore_full( // Step 1: Navigate page.goto(url, None).await?; - page.wait_for_timeout((wait_seconds * 1000.0) as u64).await?; + page.wait_for_timeout((wait_seconds * 1000.0) as u64) + .await?; // Step 2: Auto-scroll let max_scrolls = options.max_scrolls.unwrap_or(5); @@ -292,7 +299,8 @@ pub async fn explore_full( // Step 2.5: Interactive fuzzing if options.auto_fuzz.unwrap_or(false) { for label in &options.click_labels { - let safe_label = serde_json::to_string(label).unwrap_or_else(|_| format!("\"{}\"", label)); + let safe_label = + serde_json::to_string(label).unwrap_or_else(|_| format!("\"{}\"", label)); let click_js = format!( r#" (() => {{ @@ -347,8 +355,8 @@ pub async fn explore_full( url, ); - let site = site_name_opt - .unwrap_or_else(|| detect_site_name(metadata.url.as_deref().unwrap_or(url))); + let site = + site_name_opt.unwrap_or_else(|| detect_site_name(metadata.url.as_deref().unwrap_or(url))); Ok(ExploreResult { site, @@ -445,7 +453,7 @@ async fn probe_initial_state(page: &dyn IPage, _url: &str, network: &mut Vec 100 { debug!("Found __INITIAL_STATE__ data ({} bytes)", body.len()); network.push(NetworkRequest { - url: format!("__INITIAL_STATE__"), + url: "__INITIAL_STATE__".to_string(), method: "SSR".to_string(), status: Some(200), headers: { @@ -480,7 +488,7 @@ async fn re_fetch_missing_bodies(page: &dyn IPage, network: &mut [NetworkRequest || entry.url.contains("/x/") || entry.url.ends_with(".json"); if entry.method == "GET" - && entry.status.map_or(true, |s| s == 200) // Performance API returns null status + && entry.status.is_none_or(|s| s == 200) // Performance API returns null status && inferred_json && entry.response_body.is_none() { @@ -497,20 +505,19 @@ async fn re_fetch_missing_bodies(page: &dyn IPage, network: &mut [NetworkRequest }})()"#, url = url_json, ); - match page.evaluate(&js).await { - Ok(val) => { - if !val.is_null() { - if let Ok(s) = serde_json::to_string(&val) { - entry.response_body = Some(s); - // Also populate status and content_type since Performance API doesn't provide them - entry.status = Some(200); - if entry.headers.is_empty() { - entry.headers.insert("content-type".to_string(), "application/json".to_string()); - } + if let Ok(val) = page.evaluate(&js).await { + if !val.is_null() { + if let Ok(s) = serde_json::to_string(&val) { + entry.response_body = Some(s); + // Also populate status and content_type since Performance API doesn't provide them + entry.status = Some(200); + if entry.headers.is_empty() { + entry + .headers + .insert("content-type".to_string(), "application/json".to_string()); } } } - Err(_) => {} } fetched += 1; } @@ -521,9 +528,7 @@ async fn re_fetch_missing_bodies(page: &dyn IPage, network: &mut [NetworkRequest /// Filter, deduplicate, and score network endpoints. /// Returns (analyzed endpoints sorted by score desc, total unique count). -pub(crate) fn analyze_endpoints( - requests: &[NetworkRequest], -) -> (Vec, usize) { +pub(crate) fn analyze_endpoints(requests: &[NetworkRequest]) -> (Vec, usize) { let mut seen: HashMap = HashMap::new(); for req in requests { @@ -572,21 +577,27 @@ pub(crate) fn analyze_endpoints( // Parse query parameters let query_params = extract_query_params(&req.url); - let has_search = query_params.iter().any(|p| SEARCH_PARAMS.contains(&p.as_str())); - let has_pagination = query_params.iter().any(|p| PAGINATION_PARAMS.contains(&p.as_str())); - let has_limit = query_params.iter().any(|p| LIMIT_PARAMS.contains(&p.as_str())); + let has_search = query_params + .iter() + .any(|p| SEARCH_PARAMS.contains(&p.as_str())); + let has_pagination = query_params + .iter() + .any(|p| PAGINATION_PARAMS.contains(&p.as_str())); + let has_limit = query_params + .iter() + .any(|p| LIMIT_PARAMS.contains(&p.as_str())); // Detect auth indicators from request headers let auth_indicators = detect_auth_indicators(&req.headers); // Analyze response body - let (response_analysis, fields, sample_response) = - if let Some(ref body) = req.response_body { - let (ra, flds, sample) = analyze_response_body(body); - (ra, flds, sample) - } else { - (None, vec![], None) - }; + let (response_analysis, fields, sample_response) = if let Some(ref body) = req.response_body + { + let (ra, flds, sample) = analyze_response_body(body); + (ra, flds, sample) + } else { + (None, vec![], None) + }; // Score the endpoint let score = score_endpoint( @@ -599,7 +610,7 @@ pub(crate) fn analyze_endpoints( &response_analysis, ); - let confidence = (score as f64 / 20.0).min(1.0).max(0.0); + let confidence = (score as f64 / 20.0).clamp(0.0, 1.0); let auth_level = infer_strategy(&auth_indicators); seen.insert( @@ -625,10 +636,7 @@ pub(crate) fn analyze_endpoints( } let total_count = seen.len(); - let mut analyzed: Vec<_> = seen - .into_values() - .filter(|ep| ep.score >= 5) - .collect(); + let mut analyzed: Vec<_> = seen.into_values().filter(|ep| ep.score >= 5).collect(); analyzed.sort_by(|a, b| b.score.cmp(&a.score)); (analyzed, total_count) @@ -657,9 +665,7 @@ fn analyze_response_body(body: &str) -> (Option, Vec (Option, Vec(value: &'a Value, path: &str, depth: usize) -> Vec<(String, Vec<&'a Value>)> { +fn find_item_arrays<'a>( + value: &'a Value, + path: &str, + depth: usize, +) -> Vec<(String, Vec<&'a Value>)> { if depth > 4 { return vec![]; } @@ -763,7 +773,10 @@ fn detect_field_roles(sample_fields: &[String]) -> HashMap { } /// Build legacy `FieldInfo` vec from a sample object and detected roles. -fn build_field_infos(sample: Option<&Value>, detected_fields: &HashMap) -> Vec { +fn build_field_infos( + sample: Option<&Value>, + detected_fields: &HashMap, +) -> Vec { let obj = match sample.and_then(|v| v.as_object()) { Some(o) => o, None => return vec![], @@ -909,8 +922,7 @@ fn infer_capabilities_from_endpoints( let suffix = ep .pattern .split('/') - .filter(|s| !s.is_empty() && !s.starts_with('{') && !s.contains('.')) - .last() + .rfind(|s| !s.is_empty() && !s.starts_with('{') && !s.contains('.')) .map(|s| s.to_string()); cap_name = if let Some(s) = suffix { format!("{}_{}", cap_name, s) @@ -976,17 +988,13 @@ fn infer_capabilities_from_endpoints( ep_strategy_str }; - let site_display = site_name.unwrap_or_else(|| { - // Can't call detect_site_name here without allocating. - // Use a fallback; the caller typically provides site_name. - "site" - }); + let site_display = site_name.unwrap_or("site"); capabilities.push(InferredCapability { name: cap_name.clone(), description: format!("{} {}", site_display, cap_name), strategy: strategy_str, - confidence: (ep.score as f64 / 20.0).min(1.0).max(0.0), + confidence: (ep.score as f64 / 20.0).clamp(0.0, 1.0), endpoint: ep.pattern.clone(), item_path: ep .response_analysis @@ -1311,9 +1319,9 @@ pub(crate) fn infer_capability_name(url: &str, goal: Option<&str>) -> String { .into_iter() .flatten() .filter(|s| { - !s.is_empty() - && !s.chars().all(|c| c.is_ascii_digit()) - && !(s.len() >= 8 && s.chars().all(|c| c.is_ascii_hexdigit())) + !(s.is_empty() + || s.chars().all(|c| c.is_ascii_digit()) + || s.len() >= 8 && s.chars().all(|c| c.is_ascii_hexdigit())) }) .collect(); if let Some(last) = segs.last() { @@ -1335,7 +1343,14 @@ pub fn render_explore_summary(result: &ExploreResult) -> String { "opencli probe: OK".to_string(), format!("Site: {}", result.site), format!("URL: {}", result.target_url), - format!("Title: {}", if result.title.is_empty() { "(none)" } else { &result.title }), + format!( + "Title: {}", + if result.title.is_empty() { + "(none)" + } else { + &result.title + } + ), format!("Strategy: {}", result.top_strategy), format!( "Endpoints: {} total, {} API", @@ -1417,7 +1432,10 @@ mod tests { #[test] fn test_detect_site_name_known_alias() { - assert_eq!(detect_site_name("https://news.ycombinator.com"), "hackernews"); + assert_eq!( + detect_site_name("https://news.ycombinator.com"), + "hackernews" + ); assert_eq!(detect_site_name("https://x.com/home"), "twitter"); assert_eq!(detect_site_name("https://www.bilibili.com/hot"), "bilibili"); } @@ -1444,9 +1462,18 @@ mod tests { #[test] fn test_infer_capability_name_from_url() { - assert_eq!(infer_capability_name("https://example.com/api/hot", None), "hot"); - assert_eq!(infer_capability_name("https://example.com/api/search", None), "search"); - assert_eq!(infer_capability_name("https://example.com/api/feed", None), "feed"); + assert_eq!( + infer_capability_name("https://example.com/api/hot", None), + "hot" + ); + assert_eq!( + infer_capability_name("https://example.com/api/search", None), + "search" + ); + assert_eq!( + infer_capability_name("https://example.com/api/feed", None), + "feed" + ); } #[test] @@ -1540,7 +1567,15 @@ mod tests { }, sample_fields: vec!["title".to_string(), "url".to_string()], }); - let s = score_endpoint("application/json", "example.com/api/data", Some(200), false, false, false, &ra); + let s = score_endpoint( + "application/json", + "example.com/api/data", + Some(200), + false, + false, + false, + &ra, + ); // 10 (json) + 5 (has analysis) + 5 (item_count capped) + 4 (2 fields * 2) + 3 (/api/) + 2 (200) = 29 assert_eq!(s, 29); } diff --git a/crates/opencli-rs-ai/src/generate.rs b/crates/opencli-rs-ai/src/generate.rs index c3fc17d..bafe065 100644 --- a/crates/opencli-rs-ai/src/generate.rs +++ b/crates/opencli-rs-ai/src/generate.rs @@ -18,14 +18,20 @@ use crate::types::{AdapterCandidate, ExploreOptions, SynthesizeOptions}; /// Uses a Vec to preserve insertion order (matching TS object iteration order). fn capability_aliases() -> Vec<(&'static str, Vec<&'static str>)> { vec![ - ("search", vec!["search", "搜索", "查找", "query", "keyword"]), - ("hot", vec!["hot", "热门", "热榜", "热搜", "popular", "top", "ranking"]), + ("search", vec!["search", "搜索", "查找", "query", "keyword"]), + ( + "hot", + vec!["hot", "热门", "热榜", "热搜", "popular", "top", "ranking"], + ), ("trending", vec!["trending", "趋势", "流行", "discover"]), - ("feed", vec!["feed", "动态", "关注", "时间线", "timeline", "following"]), - ("me", vec!["profile", "me", "个人信息", "myinfo", "账号"]), - ("detail", vec!["detail", "详情", "video", "article", "view"]), + ( + "feed", + vec!["feed", "动态", "关注", "时间线", "timeline", "following"], + ), + ("me", vec!["profile", "me", "个人信息", "myinfo", "账号"]), + ("detail", vec!["detail", "详情", "video", "article", "view"]), ("comments", vec!["comments", "评论", "回复", "reply"]), - ("history", vec!["history", "历史", "记录"]), + ("history", vec!["history", "历史", "记录"]), ("favorite", vec!["favorite", "收藏", "bookmark", "collect"]), ] } @@ -115,9 +121,9 @@ fn select_candidate<'a>( // Try partial match let lower = goal_str.trim().to_lowercase(); - let partial = candidates.iter().find(|c| { - c.name.to_lowercase().contains(&lower) || lower.contains(&c.name.to_lowercase()) - }); + let partial = candidates + .iter() + .find(|c| c.name.to_lowercase().contains(&lower) || lower.contains(&c.name.to_lowercase())); partial.or_else(|| candidates.first()) } @@ -162,14 +168,12 @@ pub async fn generate( let goal_for_select = if goal.is_empty() { None } else { Some(goal) }; let selected = select_candidate(&candidates, goal_for_select); - selected - .cloned() - .ok_or_else(|| { - CliError::empty_result(format!( - "Could not generate adapter for {} with goal '{}'", - url, goal - )) - }) + selected.cloned().ok_or_else(|| { + CliError::empty_result(format!( + "Could not generate adapter for {} with goal '{}'", + url, goal + )) + }) } /// Full generate with structured result (for programmatic use). @@ -194,9 +198,7 @@ pub async fn generate_full( // Step 2: Normalize goal let normalized_goal = normalize_goal(opts.goal.as_deref()); - let effective_goal = normalized_goal - .as_deref() - .or(opts.goal.as_deref()); + let effective_goal = normalized_goal.as_deref().or(opts.goal.as_deref()); // Step 3: Synthesize candidates let synth_options = SynthesizeOptions { @@ -301,7 +303,10 @@ mod tests { fn test_normalize_goal_english() { assert_eq!(normalize_goal(Some("search")), Some("search".to_string())); assert_eq!(normalize_goal(Some("hot")), Some("hot".to_string())); - assert_eq!(normalize_goal(Some("trending")), Some("trending".to_string())); + assert_eq!( + normalize_goal(Some("trending")), + Some("trending".to_string()) + ); assert_eq!(normalize_goal(Some("popular")), Some("hot".to_string())); assert_eq!(normalize_goal(Some("top")), Some("hot".to_string())); assert_eq!(normalize_goal(Some("ranking")), Some("hot".to_string())); diff --git a/crates/opencli-rs-ai/src/lib.rs b/crates/opencli-rs-ai/src/lib.rs index 24926f0..44403e8 100644 --- a/crates/opencli-rs-ai/src/lib.rs +++ b/crates/opencli-rs-ai/src/lib.rs @@ -1,18 +1,20 @@ -pub mod types; -pub mod explore; -pub mod synthesize; pub mod cascade; +pub mod explore; pub mod generate; +pub mod synthesize; +pub mod types; -pub use explore::explore; -pub use synthesize::{synthesize, render_synthesize_summary, SynthesizeCandidateSummary, SynthesizeResult}; pub use cascade::{cascade, probe_endpoint, render_cascade_result, CascadeResult}; +pub use explore::explore; pub use generate::{ - generate, generate_full, normalize_goal, render_generate_summary, - GenerateOptions, GenerateResult, GenerateExploreStats, GenerateSynthesizeStats, + generate, generate_full, normalize_goal, render_generate_summary, GenerateExploreStats, + GenerateOptions, GenerateResult, GenerateSynthesizeStats, +}; +pub use synthesize::{ + render_synthesize_summary, synthesize, SynthesizeCandidateSummary, SynthesizeResult, }; pub use types::{ - AdapterCandidate, DiscoveredEndpoint, ExploreManifest, ExploreOptions, - ExploreResult, FieldInfo, InferredCapability, RecommendedArg, ResponseAnalysis, - StoreHint, StoreInfo, StrategyTestResult, SynthesizeOptions, + AdapterCandidate, DiscoveredEndpoint, ExploreManifest, ExploreOptions, ExploreResult, + FieldInfo, InferredCapability, RecommendedArg, ResponseAnalysis, StoreHint, StoreInfo, + StrategyTestResult, SynthesizeOptions, }; diff --git a/crates/opencli-rs-ai/src/synthesize.rs b/crates/opencli-rs-ai/src/synthesize.rs index db65fa7..79eb1b8 100644 --- a/crates/opencli-rs-ai/src/synthesize.rs +++ b/crates/opencli-rs-ai/src/synthesize.rs @@ -1,16 +1,15 @@ //! Synthesize candidate CLIs from explore artifacts. //! Generates evaluate-based YAML pipelines (matching hand-written adapter patterns). -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use opencli_rs_core::{CliError, Strategy}; use tracing::debug; use crate::explore::{detect_site_name, infer_capability_name}; use crate::types::{ - AdapterCandidate, DiscoveredEndpoint, ExploreManifest, FieldInfo, - RecommendedArg, StoreHint, SynthesizeOptions, - LIMIT_PARAMS, PAGINATION_PARAMS, SEARCH_PARAMS, VOLATILE_PARAMS, + AdapterCandidate, DiscoveredEndpoint, ExploreManifest, FieldInfo, RecommendedArg, StoreHint, + SynthesizeOptions, LIMIT_PARAMS, PAGINATION_PARAMS, SEARCH_PARAMS, VOLATILE_PARAMS, }; /// Internal capability representation used during synthesis. @@ -59,10 +58,7 @@ pub fn synthesize( manifest: &ExploreManifest, options: SynthesizeOptions, ) -> Result, CliError> { - let site = options - .site - .as_deref() - .unwrap_or_else(|| manifest.url.as_str()); + let site = options.site.as_deref().unwrap_or(manifest.url.as_str()); let site_name = detect_site_name(site); // Build capabilities from endpoints @@ -76,7 +72,7 @@ pub fn synthesize( let mut used_names = HashSet::new(); for cap in &top_caps { - let endpoint = choose_endpoint(&cap, &manifest.endpoints); + let endpoint = choose_endpoint(cap, &manifest.endpoints); let endpoint = match endpoint { Some(ep) => ep, None => continue, @@ -93,7 +89,7 @@ pub fn synthesize( } used_names.insert(cap_name.clone()); - let yaml = build_candidate_yaml(&site_name, manifest, &cap, endpoint); + let yaml = build_candidate_yaml(&site_name, manifest, cap, endpoint); let description = format!( "{} (auto-generated)", if cap.description.is_empty() { @@ -155,7 +151,7 @@ fn build_capabilities_from_endpoints( ) -> Vec { let mut endpoints = manifest.endpoints.clone(); // When goal is "search", boost endpoints with search params - let is_search_goal = goal.map_or(false, |g| g == "search"); + let is_search_goal = goal == Some("search"); endpoints.sort_by(|a, b| { if is_search_goal { // Prioritize endpoints with search params @@ -197,13 +193,15 @@ fn build_capabilities_from_endpoints( }; // Detect item_path from response_analysis or sample response - let item_path: Option = ep.response_analysis.as_ref() + let item_path: Option = ep + .response_analysis + .as_ref() .and_then(|ra| ra.item_path.clone()) .or_else(|| detect_item_path(&ep.sample_response)); caps.push(SynthesizeCapability { name: cap_name.clone(), - description: format!("{}", cap_name), + description: cap_name.to_string(), strategy: ep.auth_level, confidence: ep.confidence, endpoint: Some(ep.url.clone()), @@ -240,13 +238,11 @@ fn choose_endpoint<'a>( } } // Fallback: highest scoring endpoint - endpoints - .iter() - .max_by(|a, b| { - a.confidence - .partial_cmp(&b.confidence) - .unwrap_or(std::cmp::Ordering::Equal) - }) + endpoints.iter().max_by(|a, b| { + a.confidence + .partial_cmp(&b.confidence) + .unwrap_or(std::cmp::Ordering::Equal) + }) } // ── URL templating ────────────────────────────────────────────────────────── @@ -302,10 +298,7 @@ fn build_evaluate_script( _columns: &[String], _detected_fields: &std::collections::HashMap, ) -> String { - let path_chain: String = item_path - .split('.') - .map(|p| format!("?.{}", p)) - .collect(); + let path_chain: String = item_path.split('.').map(|p| format!("?.{}", p)).collect(); // Don't do .map() in evaluate — let the pipeline map step handle field mapping. // evaluate only extracts the items array from the response. @@ -336,14 +329,8 @@ fn build_candidate_yaml( endpoint: &DiscoveredEndpoint, ) -> String { let needs_browser = cap.strategy.requires_browser(); - let has_keyword = cap - .recommended_args - .iter() - .any(|a| a.name == "keyword"); - let templated_url = build_templated_url( - &endpoint.url, - has_keyword, - ); + let has_keyword = cap.recommended_args.iter().any(|a| a.name == "keyword"); + let templated_url = build_templated_url(&endpoint.url, has_keyword); let mut domain = String::new(); if let Ok(parsed) = url::Url::parse(&manifest.url) { @@ -361,9 +348,8 @@ fn build_candidate_yaml( // Build pipeline steps let mut pipeline_lines = Vec::new(); - if cap.strategy == Strategy::Intercept && cap.store_hint.is_some() { + if let (Strategy::Intercept, Some(hint)) = (cap.strategy, cap.store_hint.as_ref()) { // Store-action: navigate + wait + tap (declarative, clean) - let hint = cap.store_hint.as_ref().unwrap(); pipeline_lines.push(format!(" - navigate: \"{}\"", manifest.url)); pipeline_lines.push(" - wait: 3".to_string()); @@ -381,15 +367,12 @@ fn build_candidate_yaml( .flatten() .filter(|p| !p.is_empty()) .collect(); - let capture_part = path_parts - .iter() - .filter(|p| { - let re_version = p.len() <= 3 - && p.starts_with('v') - && p[1..].chars().all(|c| c.is_ascii_digit()); - !re_version - }) - .last(); + let capture_part = path_parts.iter().rfind(|p| { + let re_version = p.len() <= 3 + && p.starts_with('v') + && p[1..].chars().all(|c| c.is_ascii_digit()); + !re_version + }); if let Some(cp) = capture_part { tap_parts.push(format!(" capture: {}", cp)); } @@ -404,13 +387,23 @@ fn build_candidate_yaml( // Browser-based: navigate + evaluate (like bilibili/hot.yaml, twitter/trending.yaml) pipeline_lines.push(format!(" - navigate: \"{}\"", manifest.url)); let item_path = cap.item_path.as_deref().unwrap_or("data"); - let detected = endpoint.response_analysis.as_ref() + let detected = endpoint + .response_analysis + .as_ref() .map(|ra| &ra.detected_fields) .cloned() .unwrap_or_default(); - let eval_script = - build_evaluate_script(&templated_url, item_path, &endpoint.fields, &columns, &detected); - pipeline_lines.push(format!(" - evaluate: |\n {}", eval_script.replace('\n', "\n "))); + let eval_script = build_evaluate_script( + &templated_url, + item_path, + &endpoint.fields, + &columns, + &detected, + ); + pipeline_lines.push(format!( + " - evaluate: |\n {}", + eval_script.replace('\n', "\n ") + )); } else { // Public API: direct fetch (like hackernews/top.yaml) pipeline_lines.push(format!(" - fetch:\n url: \"{}\"", templated_url)); @@ -424,7 +417,9 @@ fn build_candidate_yaml( if !has_keyword { map_entries.push(" rank: \"${{ index + 1 }}\"".to_string()); } - let detected = endpoint.response_analysis.as_ref() + let detected = endpoint + .response_analysis + .as_ref() .map(|ra| &ra.detected_fields) .cloned() .unwrap_or_default(); @@ -433,10 +428,13 @@ fn build_candidate_yaml( // Priority: 1) detected_fields mapping (role → actual path) // 2) FieldInfo with matching role // 3) column name as-is - let field_path = detected.get(col.as_str()) + let field_path = detected + .get(col.as_str()) .cloned() .or_else(|| { - endpoint.fields.iter() + endpoint + .fields + .iter() .find(|f| f.role.as_deref() == Some(col.as_str())) .map(|f| f.name.clone()) }) @@ -503,10 +501,14 @@ fn build_candidate_yaml( // ── Args building ─────────────────────────────────────────────────────────── /// Build recommended args from URL query params and field analysis. -fn build_recommended_args(url: &str, _fields: &[FieldInfo], goal: Option<&str>) -> Vec { +fn build_recommended_args( + url: &str, + _fields: &[FieldInfo], + goal: Option<&str>, +) -> Vec { let qp = extract_query_param_names(url); - let has_search = qp.iter().any(|p| SEARCH_PARAMS.contains(&p.as_str())) - || goal.map_or(false, |g| g == "search"); + let has_search = + qp.iter().any(|p| SEARCH_PARAMS.contains(&p.as_str())) || goal == Some("search"); let has_pagination = qp.iter().any(|p| PAGINATION_PARAMS.contains(&p.as_str())); let mut args = Vec::new(); @@ -575,7 +577,9 @@ fn infer_columns_from_analysis( ra: &crate::types::ResponseAnalysis, fields: &[FieldInfo], ) -> Vec { - let preferred_order = ["title", "url", "author", "score", "time", "id", "cover", "category"]; + let preferred_order = [ + "title", "url", "author", "score", "time", "id", "cover", "category", + ]; let mut cols = Vec::new(); // First add columns from detected_fields (role-based, in preferred order) @@ -589,13 +593,21 @@ fn infer_columns_from_analysis( if cols.len() < 3 { let skip = ["_", "id", "mid", "uid", "cid", "oid", "rid"]; for field in &ra.sample_fields { - if cols.len() >= 6 { break; } + if cols.len() >= 6 { + break; + } let lower = field.to_lowercase(); // Skip internal/id fields and already-added ones - if skip.iter().any(|s| lower == *s) { continue; } - if cols.iter().any(|c| c == field) { continue; } + if skip.iter().any(|s| lower == *s) { + continue; + } + if cols.iter().any(|c| c == field) { + continue; + } // Skip nested paths (keep top-level only) - if field.contains('.') { continue; } + if field.contains('.') { + continue; + } cols.push(field.clone()); } } @@ -653,7 +665,13 @@ fn find_item_path(value: &serde_json::Value, prefix: &str, depth: usize) -> Opti // Recurse into nested objects if let Some(nested) = find_item_path(val, &path, depth + 1) { // Prefer deeper paths (more specific) - if best_path.is_none() || nested.matches('.').count() > best_path.as_ref().map(|p| p.matches('.').count()).unwrap_or(0) { + if best_path.is_none() + || nested.matches('.').count() + > best_path + .as_ref() + .map(|p| p.matches('.').count()) + .unwrap_or(0) + { best_path = Some(nested); } } @@ -687,12 +705,11 @@ fn url_last_segment(url: &str) -> Option { let parsed = url::Url::parse(url).ok()?; parsed .path_segments()? - .filter(|s| { - !s.is_empty() - && !s.chars().all(|c| c.is_ascii_digit()) - && !(s.len() >= 8 && s.chars().all(|c| c.is_ascii_hexdigit())) + .rfind(|s: &&str| { + !(s.is_empty() + || s.chars().all(|c| c.is_ascii_digit()) + || s.len() >= 8 && s.chars().all(|c| c.is_ascii_hexdigit())) }) - .last() .map(|s| { s.chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) diff --git a/crates/opencli-rs-ai/src/types.rs b/crates/opencli-rs-ai/src/types.rs index a18bef0..a4b7db9 100644 --- a/crates/opencli-rs-ai/src/types.rs +++ b/crates/opencli-rs-ai/src/types.rs @@ -259,14 +259,39 @@ pub struct StrategyTestResult { /// URL parameters that should be ignored when normalizing endpoints (volatile/tracking). pub(crate) const VOLATILE_PARAMS: &[&str] = &[ - "_", "t", "ts", "timestamp", "cb", "callback", "nonce", "rand", "random", - "spm_id_from", "vd_source", "from_spmid", "seid", "rt", "mid", - "web_location", "platform", "w_rid", "wts", "sign", + "_", + "t", + "ts", + "timestamp", + "cb", + "callback", + "nonce", + "rand", + "random", + "spm_id_from", + "vd_source", + "from_spmid", + "seid", + "rt", + "mid", + "web_location", + "platform", + "w_rid", + "wts", + "sign", ]; /// Parameters that indicate search capability. pub(crate) const SEARCH_PARAMS: &[&str] = &[ - "q", "query", "keyword", "keywords", "search", "search_query", "w", "wd", "kw", + "q", + "query", + "keyword", + "keywords", + "search", + "search_query", + "w", + "wd", + "kw", ]; /// Parameters that indicate pagination. @@ -276,19 +301,101 @@ pub(crate) const PAGINATION_PARAMS: &[&str] = &[ /// Parameters that indicate limit/page-size. pub(crate) const LIMIT_PARAMS: &[&str] = &[ - "limit", "ps", "size", "pageSize", "page_size", "count", "num", "per_page", + "limit", + "ps", + "size", + "pageSize", + "page_size", + "count", + "num", + "per_page", ]; /// Well-known field roles and their common aliases. pub(crate) const FIELD_ROLES: &[(&str, &[&str])] = &[ - ("title", &["title", "name", "text", "content", "desc", "description", "headline", "subject"]), - ("url", &["url", "uri", "link", "href", "permalink", "jump_url", "web_url", "share_url"]), - ("author", &["author", "username", "user_name", "nickname", "nick", "owner", "creator", "up_name", "uname"]), - ("score", &["score", "hot", "heat", "likes", "like_count", "view_count", "views", "play", "favorite_count", "reply_count"]), - ("time", &["time", "created_at", "publish_time", "pub_time", "date", "ctime", "mtime", "pubdate", "created"]), - ("id", &["id", "aid", "bvid", "mid", "uid", "oid", "note_id", "item_id"]), - ("cover", &["cover", "pic", "image", "thumbnail", "poster", "avatar"]), - ("category", &["category", "tag", "type", "tname", "channel", "section"]), + ( + "title", + &[ + "title", + "name", + "text", + "content", + "desc", + "description", + "headline", + "subject", + ], + ), + ( + "url", + &[ + "url", + "uri", + "link", + "href", + "permalink", + "jump_url", + "web_url", + "share_url", + ], + ), + ( + "author", + &[ + "author", + "username", + "user_name", + "nickname", + "nick", + "owner", + "creator", + "up_name", + "uname", + ], + ), + ( + "score", + &[ + "score", + "hot", + "heat", + "likes", + "like_count", + "view_count", + "views", + "play", + "favorite_count", + "reply_count", + ], + ), + ( + "time", + &[ + "time", + "created_at", + "publish_time", + "pub_time", + "date", + "ctime", + "mtime", + "pubdate", + "created", + ], + ), + ( + "id", + &[ + "id", "aid", "bvid", "mid", "uid", "oid", "note_id", "item_id", + ], + ), + ( + "cover", + &["cover", "pic", "image", "thumbnail", "poster", "avatar"], + ), + ( + "category", + &["category", "tag", "type", "tname", "channel", "section"], + ), ]; /// Known site hostname aliases. diff --git a/crates/opencli-rs-browser/src/bridge.rs b/crates/opencli-rs-browser/src/bridge.rs index e6c438d..0b3ef86 100644 --- a/crates/opencli-rs-browser/src/bridge.rs +++ b/crates/opencli-rs-browser/src/bridge.rs @@ -55,7 +55,10 @@ impl BrowserBridge { } // Step 3: Wait up to 5s for extension to connect - if self.poll_extension(&client, EXTENSION_INITIAL_WAIT, false).await { + if self + .poll_extension(&client, EXTENSION_INITIAL_WAIT, false) + .await + { let page = DaemonPage::new(client, "default"); return Ok(Arc::new(page)); } @@ -66,7 +69,10 @@ impl BrowserBridge { wake_chrome(); // Step 5: Wait remaining 25s with progress - if self.poll_extension(&client, EXTENSION_REMAINING_WAIT, true).await { + if self + .poll_extension(&client, EXTENSION_REMAINING_WAIT, true) + .await + { let page = DaemonPage::new(client, "default"); return Ok(Arc::new(page)); } @@ -130,7 +136,7 @@ impl BrowserBridge { if elapsed >= 1 && !printed { eprint!("Waiting for Chrome extension to connect"); printed = true; - } else if printed && elapsed % 3 == 0 { + } else if printed && elapsed.is_multiple_of(3) { eprint!("."); } } diff --git a/crates/opencli-rs-browser/src/cdp.rs b/crates/opencli-rs-browser/src/cdp.rs index aaeeabf..393946a 100644 --- a/crates/opencli-rs-browser/src/cdp.rs +++ b/crates/opencli-rs-browser/src/cdp.rs @@ -15,8 +15,10 @@ use tracing::{debug, error}; use crate::dom_helpers; -type WsSink = - futures::stream::SplitSink>, Message>; +type WsSink = futures::stream::SplitSink< + tokio_tungstenite::WebSocketStream>, + Message, +>; /// Direct Chrome DevTools Protocol page client via WebSocket. /// @@ -46,9 +48,7 @@ impl CdpPage { Ok(Message::Text(text)) => { if let Ok(json) = serde_json::from_str::(&text) { if let Some(id) = json.get("id").and_then(|v| v.as_u64()) { - if let Some(tx) = - reader_pending.write().await.remove(&id) - { + if let Some(tx) = reader_pending.write().await.remove(&id) { let _ = tx.send(json); } } else { @@ -90,7 +90,7 @@ impl CdpPage { { let mut ws = self.ws_write.lock().await; - ws.send(Message::Text(msg.to_string().into())) + ws.send(Message::Text(msg.to_string())) .await .map_err(|e| CliError::browser_connect(format!("CDP send error: {e}")))?; } @@ -215,9 +215,7 @@ impl IPage for CdpPage { } async fn cookies(&self, _options: Option) -> Result, CliError> { - let result = self - .send_cdp("Network.getCookies", json!({})) - .await?; + let result = self.send_cdp("Network.getCookies", json!({})).await?; let cookies_val = result.get("cookies").cloned().unwrap_or(json!([])); let cookies: Vec = serde_json::from_value(cookies_val).unwrap_or_default(); Ok(cookies) @@ -267,10 +265,7 @@ impl IPage for CdpPage { async fn tabs(&self) -> Result, CliError> { let result = self.send_cdp("Target.getTargets", json!({})).await?; - let targets = result - .get("targetInfos") - .cloned() - .unwrap_or(json!([])); + let targets = result.get("targetInfos").cloned().unwrap_or(json!([])); let mut tabs = Vec::new(); if let Some(arr) = targets.as_array() { for t in arr { diff --git a/crates/opencli-rs-browser/src/daemon.rs b/crates/opencli-rs-browser/src/daemon.rs index 6239c1a..9ed1d8f 100644 --- a/crates/opencli-rs-browser/src/daemon.rs +++ b/crates/opencli-rs-browser/src/daemon.rs @@ -172,7 +172,11 @@ async fn command_handler( // Create a oneshot channel for the result let (tx, rx) = oneshot::channel::(); - state.pending_commands.write().await.insert(cmd_id.clone(), tx); + state + .pending_commands + .write() + .await + .insert(cmd_id.clone(), tx); // Forward command to extension via WebSocket { @@ -203,7 +207,10 @@ async fn command_handler( } else { StatusCode::UNPROCESSABLE_ENTITY }; - (status, Json(serde_json::to_value(result).unwrap_or(json!({})))) + ( + status, + Json(serde_json::to_value(result).unwrap_or(json!({}))), + ) } Ok(Err(_)) => ( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/crates/opencli-rs-browser/src/daemon_client.rs b/crates/opencli-rs-browser/src/daemon_client.rs index b47da72..f60fb00 100644 --- a/crates/opencli-rs-browser/src/daemon_client.rs +++ b/crates/opencli-rs-browser/src/daemon_client.rs @@ -51,7 +51,9 @@ impl DaemonClient { if status.is_success() { let daemon_result: DaemonResult = resp.json().await.map_err(|e| { - CliError::browser_connect(format!("Failed to parse daemon response: {e}")) + CliError::browser_connect(format!( + "Failed to parse daemon response: {e}" + )) })?; if daemon_result.ok { return Ok(daemon_result.data.unwrap_or(Value::Null)); diff --git a/crates/opencli-rs-browser/src/lib.rs b/crates/opencli-rs-browser/src/lib.rs index f918e47..9b75f8a 100644 --- a/crates/opencli-rs-browser/src/lib.rs +++ b/crates/opencli-rs-browser/src/lib.rs @@ -1,15 +1,15 @@ -pub mod types; +pub mod bridge; +pub mod cdp; +pub mod daemon; pub mod daemon_client; -pub mod page; pub mod dom_helpers; +pub mod page; pub mod stealth; -pub mod daemon; -pub mod bridge; -pub mod cdp; +pub mod types; pub use bridge::BrowserBridge; -pub use page::DaemonPage; pub use cdp::CdpPage; pub use daemon::Daemon; pub use daemon_client::DaemonClient; +pub use page::DaemonPage; pub use types::{DaemonCommand, DaemonResult}; diff --git a/crates/opencli-rs-browser/src/page.rs b/crates/opencli-rs-browser/src/page.rs index bc318e7..e65984d 100644 --- a/crates/opencli-rs-browser/src/page.rs +++ b/crates/opencli-rs-browser/src/page.rs @@ -152,8 +152,7 @@ impl IPage for DaemonPage { async fn snapshot(&self, options: Option) -> Result { let opts = options.unwrap_or_default(); - let js = - dom_helpers::snapshot_js(opts.selector.as_deref(), opts.include_hidden); + let js = dom_helpers::snapshot_js(opts.selector.as_deref(), opts.include_hidden); self.eval_js(&js).await } @@ -244,7 +243,10 @@ pub(crate) fn base64_decode_simple(input: &str) -> Vec { } let _ = TABLE; // suppress unused warning - let bytes: Vec = input.bytes().filter(|&b| b != b'=' && b != b'\n' && b != b'\r').collect(); + let bytes: Vec = input + .bytes() + .filter(|&b| b != b'=' && b != b'\n' && b != b'\r') + .collect(); let mut out = Vec::with_capacity(bytes.len() * 3 / 4); for chunk in bytes.chunks(4) { let n = chunk.len(); diff --git a/crates/opencli-rs-cli/src/args.rs b/crates/opencli-rs-cli/src/args.rs index 58ce258..a24cb40 100644 --- a/crates/opencli-rs-cli/src/args.rs +++ b/crates/opencli-rs-cli/src/args.rs @@ -58,9 +58,7 @@ fn coerce_value(raw: &str, arg_type: &ArgType, name: &str) -> Result raw .parse::() .map(|n| Value::Number(serde_json::Number::from_f64(n).unwrap_or(0.into()))) - .map_err(|_| { - CliError::argument(format!("'{}' expects a number, got: {}", name, raw)) - }), + .map_err(|_| CliError::argument(format!("'{}' expects a number, got: {}", name, raw))), ArgType::Bool | ArgType::Boolean => match raw.to_lowercase().as_str() { "true" | "1" | "yes" => Ok(Value::Bool(true)), "false" | "0" | "no" => Ok(Value::Bool(false)), diff --git a/crates/opencli-rs-cli/src/commands/doctor.rs b/crates/opencli-rs-cli/src/commands/doctor.rs index e8dd892..994606a 100644 --- a/crates/opencli-rs-cli/src/commands/doctor.rs +++ b/crates/opencli-rs-cli/src/commands/doctor.rs @@ -13,7 +13,8 @@ pub async fn run_doctor() { || std::path::Path::new("/Applications/Google Chrome.app").exists() } else if cfg!(target_os = "windows") { std::path::Path::new(r"C:\Program Files\Google\Chrome\Application\chrome.exe").exists() - || std::path::Path::new(r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe").exists() + || std::path::Path::new(r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe") + .exists() || is_binary_installed("chrome") } else { // Linux diff --git a/crates/opencli-rs-cli/src/commands/mod.rs b/crates/opencli-rs-cli/src/commands/mod.rs index eea47d1..2c4f27d 100644 --- a/crates/opencli-rs-cli/src/commands/mod.rs +++ b/crates/opencli-rs-cli/src/commands/mod.rs @@ -1,2 +1,2 @@ -pub mod doctor; pub mod completion; +pub mod doctor; diff --git a/crates/opencli-rs-cli/src/execution.rs b/crates/opencli-rs-cli/src/execution.rs index afd4ffe..df2b2de 100644 --- a/crates/opencli-rs-cli/src/execution.rs +++ b/crates/opencli-rs-cli/src/execution.rs @@ -1,9 +1,9 @@ +use opencli_rs_browser::BrowserBridge; use opencli_rs_core::{CliCommand, CliError, IPage}; use opencli_rs_pipeline::{execute_pipeline, steps::register_all_steps, StepRegistry}; -use opencli_rs_browser::BrowserBridge; use serde_json::Value; -use std::sync::Arc; use std::collections::HashMap; +use std::sync::Arc; /// Get daemon port from env or default fn daemon_port() -> u16 { @@ -61,10 +61,12 @@ async fn execute_command_inner( // Pre-navigate to domain if set, but ONLY if the pipeline doesn't // start with its own navigate step (to avoid double navigation). - let pipeline_starts_with_navigate = cmd.pipeline.as_ref() + let pipeline_starts_with_navigate = cmd + .pipeline + .as_ref() .and_then(|steps| steps.first()) .and_then(|step| step.as_object()) - .map_or(false, |obj| obj.contains_key("navigate")); + .is_some_and(|obj| obj.contains_key("navigate")); if !pipeline_starts_with_navigate { if let Some(domain) = &cmd.domain { @@ -95,7 +97,6 @@ async fn execute_command_inner( } } - async fn run_command( cmd: &CliCommand, page: Option>, diff --git a/crates/opencli-rs-cli/src/main.rs b/crates/opencli-rs-cli/src/main.rs index 620cf0a..6511536 100644 --- a/crates/opencli-rs-cli/src/main.rs +++ b/crates/opencli-rs-cli/src/main.rs @@ -5,11 +5,11 @@ mod execution; use clap::{Arg, ArgAction, Command}; use clap_complete::Shell; use opencli_rs_core::Registry; -use serde_json::Value; use opencli_rs_discovery::{discover_builtin_adapters, discover_user_adapters}; use opencli_rs_external::{load_external_clis, ExternalCli}; use opencli_rs_output::format::{OutputFormat, RenderOptions}; use opencli_rs_output::render; +use serde_json::Value; use std::collections::HashMap; use std::str::FromStr; use tracing_subscriber::EnvFilter; @@ -101,21 +101,51 @@ fn build_cli(registry: &Registry, external_clis: &[ExternalCli]) -> Command { .about("Explore a website's API surface and discover endpoints") .arg(Arg::new("url").required(true).help("URL to explore")) .arg(Arg::new("site").long("site").help("Override site name")) - .arg(Arg::new("goal").long("goal").help("Hint for capability naming (e.g. search, hot)")) - .arg(Arg::new("wait").long("wait").default_value("3").help("Initial wait seconds")) - .arg(Arg::new("auto").long("auto").action(ArgAction::SetTrue).help("Enable interactive fuzzing (click buttons/tabs to trigger hidden APIs)")) - .arg(Arg::new("click").long("click").help("Comma-separated labels to click before fuzzing (e.g. 'Comments,CC,字幕')")), + .arg( + Arg::new("goal") + .long("goal") + .help("Hint for capability naming (e.g. search, hot)"), + ) + .arg( + Arg::new("wait") + .long("wait") + .default_value("3") + .help("Initial wait seconds"), + ) + .arg( + Arg::new("auto") + .long("auto") + .action(ArgAction::SetTrue) + .help( + "Enable interactive fuzzing (click buttons/tabs to trigger hidden APIs)", + ), + ) + .arg(Arg::new("click").long("click").help( + "Comma-separated labels to click before fuzzing (e.g. 'Comments,CC,字幕')", + )), ) .subcommand( Command::new("cascade") .about("Auto-detect authentication strategy for an API endpoint") - .arg(Arg::new("url").required(true).help("API endpoint URL to probe")), + .arg( + Arg::new("url") + .required(true) + .help("API endpoint URL to probe"), + ), ) .subcommand( Command::new("generate") .about("One-shot: explore + synthesize + select best adapter") - .arg(Arg::new("url").required(true).help("URL to generate adapter for")) - .arg(Arg::new("goal").long("goal").help("What you want (e.g. hot, search, trending)")) + .arg( + Arg::new("url") + .required(true) + .help("URL to generate adapter for"), + ) + .arg( + Arg::new("goal") + .long("goal") + .help("What you want (e.g. hot, search, trending)"), + ) .arg(Arg::new("site").long("site").help("Override site name")), ); @@ -137,15 +167,13 @@ fn print_error(err: &opencli_rs_core::CliError) { async fn main() { // 1. Initialize tracing tracing_subscriber::fmt() - .with_env_filter( - EnvFilter::try_from_env("RUST_LOG").unwrap_or_else(|_| { - if std::env::var("OPENCLI_VERBOSE").is_ok() { - EnvFilter::new("debug") - } else { - EnvFilter::new("warn") - } - }), - ) + .with_env_filter(EnvFilter::try_from_env("RUST_LOG").unwrap_or_else(|_| { + if std::env::var("OPENCLI_VERBOSE").is_ok() { + EnvFilter::new("debug") + } else { + EnvFilter::new("warn") + } + })) .init(); // Check for --daemon flag (used by BrowserBridge to spawn daemon as subprocess) @@ -230,16 +258,21 @@ async fn main() { let url = site_matches.get_one::("url").unwrap(); let site = site_matches.get_one::("site").cloned(); let goal = site_matches.get_one::("goal").cloned(); - let wait: u64 = site_matches.get_one::("wait") - .and_then(|s| s.parse().ok()).unwrap_or(3); + let wait: u64 = site_matches + .get_one::("wait") + .and_then(|s| s.parse().ok()) + .unwrap_or(3); let auto_fuzz = site_matches.get_flag("auto"); - let click_labels: Vec = site_matches.get_one::("click") + let click_labels: Vec = site_matches + .get_one::("click") .map(|s| s.split(',').map(|l| l.trim().to_string()).collect()) .unwrap_or_default(); let mut bridge = opencli_rs_browser::BrowserBridge::new( - std::env::var("OPENCLI_DAEMON_PORT").ok() - .and_then(|s| s.parse().ok()).unwrap_or(19825), + std::env::var("OPENCLI_DAEMON_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(19825), ); match bridge.connect().await { Ok(page) => { @@ -257,13 +290,20 @@ async fn main() { let _ = page.close().await; match result { Ok(manifest) => { - let output = serde_json::to_string_pretty(&manifest).unwrap_or_default(); + let output = + serde_json::to_string_pretty(&manifest).unwrap_or_default(); println!("{}", output); } - Err(e) => { print_error(&e); std::process::exit(1); } + Err(e) => { + print_error(&e); + std::process::exit(1); + } } } - Err(e) => { print_error(&e); std::process::exit(1); } + Err(e) => { + print_error(&e); + std::process::exit(1); + } } return; } @@ -271,8 +311,10 @@ async fn main() { let url = site_matches.get_one::("url").unwrap(); let mut bridge = opencli_rs_browser::BrowserBridge::new( - std::env::var("OPENCLI_DAEMON_PORT").ok() - .and_then(|s| s.parse().ok()).unwrap_or(19825), + std::env::var("OPENCLI_DAEMON_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(19825), ); match bridge.connect().await { Ok(page) => { @@ -283,25 +325,36 @@ async fn main() { let output = serde_json::to_string_pretty(&r).unwrap_or_default(); println!("{}", output); } - Err(e) => { print_error(&e); std::process::exit(1); } + Err(e) => { + print_error(&e); + std::process::exit(1); + } } } - Err(e) => { print_error(&e); std::process::exit(1); } + Err(e) => { + print_error(&e); + std::process::exit(1); + } } return; } "generate" => { let url = site_matches.get_one::("url").unwrap(); let goal = site_matches.get_one::("goal").cloned(); - let site = site_matches.get_one::("site").cloned(); - let mut bridge = opencli_rs_browser::BrowserBridge::new( - std::env::var("OPENCLI_DAEMON_PORT").ok() - .and_then(|s| s.parse().ok()).unwrap_or(19825), + std::env::var("OPENCLI_DAEMON_PORT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(19825), ); match bridge.connect().await { Ok(page) => { - let gen_result = opencli_rs_ai::generate(page.as_ref(), url, goal.as_deref().unwrap_or("")).await; + let gen_result = opencli_rs_ai::generate( + page.as_ref(), + url, + goal.as_deref().unwrap_or(""), + ) + .await; let _ = page.close().await; match gen_result { Ok(candidate) => { @@ -317,12 +370,22 @@ async fn main() { let path = dir.join(format!("{}.yaml", candidate.name)); match std::fs::write(&path, &candidate.yaml) { Ok(_) => { - eprintln!("✅ Generated adapter: {} {}", candidate.site, candidate.name); - eprintln!(" Strategy: {:?}, Confidence: {:.0}%", candidate.strategy, candidate.confidence * 100.0); + eprintln!( + "✅ Generated adapter: {} {}", + candidate.site, candidate.name + ); + eprintln!( + " Strategy: {:?}, Confidence: {:.0}%", + candidate.strategy, + candidate.confidence * 100.0 + ); eprintln!(" Saved to: {}", path.display()); eprintln!(); eprintln!(" Run it now:"); - eprintln!(" opencli-rs {} {}", candidate.site, candidate.name); + eprintln!( + " opencli-rs {} {}", + candidate.site, candidate.name + ); } Err(e) => { eprintln!("Generated adapter but failed to save: {}", e); @@ -331,10 +394,16 @@ async fn main() { } } } - Err(e) => { print_error(&e); std::process::exit(1); } + Err(e) => { + print_error(&e); + std::process::exit(1); + } } } - Err(e) => { print_error(&e); std::process::exit(1); } + Err(e) => { + print_error(&e); + std::process::exit(1); + } } return; } @@ -355,8 +424,7 @@ async fn main() { None => vec![], }; - match opencli_rs_external::execute_external_cli(&ext.name, &ext.binary, &ext_args) - .await + match opencli_rs_external::execute_external_cli(&ext.name, &ext.binary, &ext_args).await { Ok(status) => { std::process::exit(status.code().unwrap_or(1)); diff --git a/crates/opencli-rs-core/src/args.rs b/crates/opencli-rs-core/src/args.rs index cfa983f..837726f 100644 --- a/crates/opencli-rs-core/src/args.rs +++ b/crates/opencli-rs-core/src/args.rs @@ -1,9 +1,10 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ArgType { + #[default] Str, Int, Number, @@ -12,12 +13,6 @@ pub enum ArgType { Boolean, } -impl Default for ArgType { - fn default() -> Self { - Self::Str - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ArgDef { pub name: String, diff --git a/crates/opencli-rs-core/src/command.rs b/crates/opencli-rs-core/src/command.rs index 58bc44f..1a6c28b 100644 --- a/crates/opencli-rs-core/src/command.rs +++ b/crates/opencli-rs-core/src/command.rs @@ -58,8 +58,16 @@ impl CliCommand { // Check if pipeline contains browser steps if let Some(ref pipeline) = self.pipeline { const BROWSER_STEPS: &[&str] = &[ - "navigate", "click", "type", "wait", "press", - "evaluate", "snapshot", "screenshot", "intercept", "tap", + "navigate", + "click", + "type", + "wait", + "press", + "evaluate", + "snapshot", + "screenshot", + "intercept", + "tap", ]; for step in pipeline { if let Some(obj) = step.as_object() { diff --git a/crates/opencli-rs-core/src/lib.rs b/crates/opencli-rs-core/src/lib.rs index 7db418b..ec312eb 100644 --- a/crates/opencli-rs-core/src/lib.rs +++ b/crates/opencli-rs-core/src/lib.rs @@ -1,18 +1,18 @@ -mod strategy; mod args; mod command; -mod registry; mod error; mod page; +mod registry; +mod strategy; mod value_ext; -pub use strategy::Strategy; pub use args::{ArgDef, ArgType}; pub use command::{AdapterFunc, CliCommand, CommandArgs, NavigateBefore}; -pub use registry::Registry; pub use error::CliError; pub use page::{ AutoScrollOptions, Cookie, CookieOptions, GotoOptions, IPage, InterceptedRequest, NetworkRequest, ScreenshotOptions, ScrollDirection, SnapshotOptions, TabInfo, WaitOptions, }; +pub use registry::Registry; +pub use strategy::Strategy; pub use value_ext::ValueExt; diff --git a/crates/opencli-rs-core/src/page.rs b/crates/opencli-rs-core/src/page.rs index 6168d58..ee68f53 100644 --- a/crates/opencli-rs-core/src/page.rs +++ b/crates/opencli-rs-core/src/page.rs @@ -47,19 +47,14 @@ pub struct SnapshotOptions { pub include_hidden: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ScrollDirection { + #[default] Down, Up, } -impl Default for ScrollDirection { - fn default() -> Self { - Self::Down - } -} - #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct AutoScrollOptions { #[serde(default)] @@ -165,10 +160,7 @@ pub trait IPage: Send + Sync { async fn set_cookies(&self, cookies: Vec) -> Result<(), CliError>; /// Take a screenshot - async fn screenshot( - &self, - options: Option, - ) -> Result, CliError>; + async fn screenshot(&self, options: Option) -> Result, CliError>; /// Get an accessibility snapshot of the page async fn snapshot(&self, options: Option) -> Result; diff --git a/crates/opencli-rs-core/src/registry.rs b/crates/opencli-rs-core/src/registry.rs index 6d9c6b9..4b5c038 100644 --- a/crates/opencli-rs-core/src/registry.rs +++ b/crates/opencli-rs-core/src/registry.rs @@ -40,8 +40,7 @@ impl Registry { } pub fn all_commands(&self) -> Vec<&CliCommand> { - let mut cmds: Vec<&CliCommand> = - self.commands.values().flat_map(|s| s.values()).collect(); + let mut cmds: Vec<&CliCommand> = self.commands.values().flat_map(|s| s.values()).collect(); cmds.sort_by(|a, b| (&a.site, &a.name).cmp(&(&b.site, &b.name))); cmds } diff --git a/crates/opencli-rs-core/src/strategy.rs b/crates/opencli-rs-core/src/strategy.rs index 4087ef7..0246a8f 100644 --- a/crates/opencli-rs-core/src/strategy.rs +++ b/crates/opencli-rs-core/src/strategy.rs @@ -1,8 +1,9 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Strategy { + #[default] Public, Cookie, Header, @@ -10,12 +11,6 @@ pub enum Strategy { Ui, } -impl Default for Strategy { - fn default() -> Self { - Self::Public - } -} - impl Strategy { pub fn requires_browser(&self) -> bool { !matches!(self, Self::Public) diff --git a/crates/opencli-rs-discovery/build.rs b/crates/opencli-rs-discovery/build.rs index 84d8e2a..704652c 100644 --- a/crates/opencli-rs-discovery/build.rs +++ b/crates/opencli-rs-discovery/build.rs @@ -49,10 +49,7 @@ fn collect_yaml_files(base: &Path, dir: &Path, entries: &mut Vec<(String, String let path = entry.path(); if path.is_dir() { collect_yaml_files(base, &path, entries); - } else if path - .extension() - .map_or(false, |e| e == "yaml" || e == "yml") - { + } else if path.extension().is_some_and(|e| e == "yaml" || e == "yml") { let rel = path .strip_prefix(base) .unwrap() diff --git a/crates/opencli-rs-discovery/src/user.rs b/crates/opencli-rs-discovery/src/user.rs index 6e6ce5e..6252f9f 100644 --- a/crates/opencli-rs-discovery/src/user.rs +++ b/crates/opencli-rs-discovery/src/user.rs @@ -29,10 +29,7 @@ fn scan_yaml_dir( let path = entry.path(); if path.is_dir() { scan_yaml_dir(&path, registry, count)?; - } else if path - .extension() - .map_or(false, |e| e == "yaml" || e == "yml") - { + } else if path.extension().is_some_and(|e| e == "yaml" || e == "yml") { let content = std::fs::read_to_string(&path)?; match parse_yaml_adapter(&content) { Ok(cmd) => { diff --git a/crates/opencli-rs-discovery/src/yaml_parser.rs b/crates/opencli-rs-discovery/src/yaml_parser.rs index 2ada5be..469ba15 100644 --- a/crates/opencli-rs-discovery/src/yaml_parser.rs +++ b/crates/opencli-rs-discovery/src/yaml_parser.rs @@ -31,9 +31,7 @@ pub fn parse_yaml_adapter(content: &str) -> Result { // Parse strategy (default: public) let strategy = match raw.get("strategy").and_then(|v| v.as_str()) { - Some(s) => { - serde_json::from_value(Value::String(s.to_string())).unwrap_or(Strategy::Public) - } + Some(s) => serde_json::from_value(Value::String(s.to_string())).unwrap_or(Strategy::Public), None => Strategy::Public, }; @@ -52,10 +50,7 @@ pub fn parse_yaml_adapter(content: &str) -> Result { .unwrap_or_default(); // Pipeline is stored as-is (Vec) - let pipeline = raw - .get("pipeline") - .and_then(|v| v.as_array()) - .cloned(); + let pipeline = raw.get("pipeline").and_then(|v| v.as_array()).cloned(); Ok(CliCommand { site, @@ -65,10 +60,7 @@ pub fn parse_yaml_adapter(content: &str) -> Result { .and_then(|v| v.as_str()) .unwrap_or("") .to_string(), - domain: raw - .get("domain") - .and_then(|v| v.as_str()) - .map(String::from), + domain: raw.get("domain").and_then(|v| v.as_str()).map(String::from), strategy, browser: raw .get("browser") diff --git a/crates/opencli-rs-external/src/executor.rs b/crates/opencli-rs-external/src/executor.rs index 6590ffa..de2fcd3 100644 --- a/crates/opencli-rs-external/src/executor.rs +++ b/crates/opencli-rs-external/src/executor.rs @@ -33,7 +33,8 @@ pub fn validate_args(args: &[String]) -> Result<(), CliError> { ), suggestions: vec![ "Shell operators are not allowed in external CLI arguments".to_string(), - "If you need piping, run the external CLI directly in your shell".to_string(), + "If you need piping, run the external CLI directly in your shell" + .to_string(), ], }); } @@ -55,10 +56,14 @@ pub async fn execute_external_cli( if !is_binary_installed(binary) { return Err(CliError::CommandExecution { - message: format!("External CLI '{}' not found: binary '{}' is not installed", name, binary), - suggestions: vec![ - format!("Install '{}' and make sure it is on your PATH", binary), - ], + message: format!( + "External CLI '{}' not found: binary '{}' is not installed", + name, binary + ), + suggestions: vec![format!( + "Install '{}' and make sure it is on your PATH", + binary + )], source: None, }); } diff --git a/crates/opencli-rs-external/src/lib.rs b/crates/opencli-rs-external/src/lib.rs index 36da14f..7c75d90 100644 --- a/crates/opencli-rs-external/src/lib.rs +++ b/crates/opencli-rs-external/src/lib.rs @@ -1,7 +1,7 @@ -pub mod types; -pub mod loader; pub mod executor; +pub mod loader; +pub mod types; -pub use types::ExternalCli; -pub use loader::load_external_clis; pub use executor::{execute_external_cli, is_binary_installed}; +pub use loader::load_external_clis; +pub use types::ExternalCli; diff --git a/crates/opencli-rs-output/src/csv_out.rs b/crates/opencli-rs-output/src/csv_out.rs index cf0b7d9..a1c9bd7 100644 --- a/crates/opencli-rs-output/src/csv_out.rs +++ b/crates/opencli-rs-output/src/csv_out.rs @@ -38,13 +38,12 @@ pub fn render_csv(data: &Value, columns: Option<&[String]>) -> String { if cols.is_empty() { // Array of scalars let mut wtr = csv::WriterBuilder::new().from_writer(vec![]); - wtr.write_record(&["value"]).ok(); + wtr.write_record(["value"]).ok(); for item in arr { wtr.write_record(&[value_to_field(item)]).ok(); } wtr.flush().ok(); - String::from_utf8(wtr.into_inner().unwrap_or_default()) - .unwrap_or_default() + String::from_utf8(wtr.into_inner().unwrap_or_default()).unwrap_or_default() } else { let mut wtr = csv::WriterBuilder::new().from_writer(vec![]); wtr.write_record(&cols).ok(); @@ -56,21 +55,19 @@ pub fn render_csv(data: &Value, columns: Option<&[String]>) -> String { wtr.write_record(&row).ok(); } wtr.flush().ok(); - String::from_utf8(wtr.into_inner().unwrap_or_default()) - .unwrap_or_default() + String::from_utf8(wtr.into_inner().unwrap_or_default()).unwrap_or_default() } } Value::Object(obj) => { let cols = resolve_columns(data, columns); let mut wtr = csv::WriterBuilder::new().from_writer(vec![]); - wtr.write_record(&["key", "value"]).ok(); + wtr.write_record(["key", "value"]).ok(); for key in &cols { let v = obj.get(key).unwrap_or(&Value::Null); - wtr.write_record(&[key.as_str(), &value_to_field(v)]).ok(); + wtr.write_record([key.as_str(), &value_to_field(v)]).ok(); } wtr.flush().ok(); - String::from_utf8(wtr.into_inner().unwrap_or_default()) - .unwrap_or_default() + String::from_utf8(wtr.into_inner().unwrap_or_default()).unwrap_or_default() } scalar => value_to_field(scalar), } diff --git a/crates/opencli-rs-output/src/format.rs b/crates/opencli-rs-output/src/format.rs index 967c93e..5f82e16 100644 --- a/crates/opencli-rs-output/src/format.rs +++ b/crates/opencli-rs-output/src/format.rs @@ -3,8 +3,9 @@ use std::str::FromStr; use std::time::Duration; /// Supported output formats. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum OutputFormat { + #[default] Table, Json, Yaml, @@ -12,12 +13,6 @@ pub enum OutputFormat { Markdown, } -impl Default for OutputFormat { - fn default() -> Self { - Self::Table - } -} - impl fmt::Display for OutputFormat { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { diff --git a/crates/opencli-rs-output/src/lib.rs b/crates/opencli-rs-output/src/lib.rs index a477713..55f6bb6 100644 --- a/crates/opencli-rs-output/src/lib.rs +++ b/crates/opencli-rs-output/src/lib.rs @@ -1,10 +1,10 @@ +pub mod csv_out; pub mod format; -pub mod table; pub mod json; -pub mod yaml; -pub mod csv_out; pub mod markdown; pub mod render; +pub mod table; +pub mod yaml; pub use format::{OutputFormat, RenderOptions}; pub use render::render; diff --git a/crates/opencli-rs-output/src/table.rs b/crates/opencli-rs-output/src/table.rs index b43f595..bfe68f0 100644 --- a/crates/opencli-rs-output/src/table.rs +++ b/crates/opencli-rs-output/src/table.rs @@ -48,7 +48,7 @@ pub fn render_table(data: &Value, columns: Option<&[String]>) -> String { return table.to_string(); } let mut table = Table::new(); - table.set_header(cols.iter().map(|c| Cell::new(c))); + table.set_header(cols.iter().map(Cell::new)); for item in arr { let row: Vec = cols .iter() diff --git a/crates/opencli-rs-pipeline/src/executor.rs b/crates/opencli-rs-pipeline/src/executor.rs index 1fab9cd..4b6c05d 100644 --- a/crates/opencli-rs-pipeline/src/executor.rs +++ b/crates/opencli-rs-pipeline/src/executor.rs @@ -24,9 +24,9 @@ pub async fn execute_pipeline( let mut data = Value::Null; for (i, step) in pipeline.iter().enumerate() { - let obj = step.as_object().ok_or_else(|| { - CliError::pipeline(format!("Step {i} is not an object: {step}")) - })?; + let obj = step + .as_object() + .ok_or_else(|| CliError::pipeline(format!("Step {i} is not an object: {step}")))?; if obj.len() != 1 { return Err(CliError::pipeline(format!( @@ -45,10 +45,7 @@ pub async fn execute_pipeline( let mut last_error: Option = None; for attempt in 0..if is_browser { MAX_BROWSER_ATTEMPTS } else { 1 } { - match handler - .execute(page.clone(), params, &data, args) - .await - { + match handler.execute(page.clone(), params, &data, args).await { Ok(result) => { data = result; last_error = None; @@ -221,7 +218,10 @@ mod tests { .await .unwrap_err(); let msg = err.to_string(); - assert!(msg.contains("nonexistent"), "Error should mention the step name: {msg}"); + assert!( + msg.contains("nonexistent"), + "Error should mention the step name: {msg}" + ); } #[tokio::test] diff --git a/crates/opencli-rs-pipeline/src/steps/browser.rs b/crates/opencli-rs-pipeline/src/steps/browser.rs index 0e909a4..91a12dc 100644 --- a/crates/opencli-rs-pipeline/src/steps/browser.rs +++ b/crates/opencli-rs-pipeline/src/steps/browser.rs @@ -77,16 +77,22 @@ impl StepHandler for NavigateStep { } // navigate: { url: "...", settleMs: 2000 } Value::Object(obj) => { - let url_val = obj.get("url") + let url_val = obj + .get("url") .ok_or_else(|| CliError::pipeline("navigate object requires 'url' field"))?; - let url_str = url_val.as_str() + let url_str = url_val + .as_str() .ok_or_else(|| CliError::pipeline("navigate 'url' must be a string"))?; let rendered = render_template_str(url_str, &ctx)?; let url = rendered.as_str().unwrap_or("").to_string(); let settle = obj.get("settleMs").and_then(|v| v.as_u64()); (url, settle) } - _ => return Err(CliError::pipeline("navigate expects a string URL or {url, settleMs} object")), + _ => { + return Err(CliError::pipeline( + "navigate expects a string URL or {url, settleMs} object", + )) + } }; pg.goto(&url, None).await?; @@ -171,14 +177,20 @@ impl StepHandler for TypeStep { let txt = render_template_str(text_raw, &ctx)?; ( sel.as_str() - .ok_or_else(|| CliError::pipeline("type: rendered selector is not a string"))? + .ok_or_else(|| { + CliError::pipeline("type: rendered selector is not a string") + })? .to_string(), txt.as_str() .ok_or_else(|| CliError::pipeline("type: rendered text is not a string"))? .to_string(), ) } - _ => return Err(CliError::pipeline("type: params must be an object with 'selector' and 'text'")), + _ => { + return Err(CliError::pipeline( + "type: params must be an object with 'selector' and 'text'", + )) + } }; pg.type_text(&selector, &text).await?; @@ -256,7 +268,11 @@ impl StepHandler for WaitStep { )); } } - _ => return Err(CliError::pipeline("wait: params must be a number or object")), + _ => { + return Err(CliError::pipeline( + "wait: params must be a number or object", + )) + } } Ok(data.clone()) @@ -367,7 +383,10 @@ impl StepHandler for SnapshotStep { let opts = match params { Value::Object(obj) => { - let selector = obj.get("selector").and_then(|v| v.as_str()).map(String::from); + let selector = obj + .get("selector") + .and_then(|v| v.as_str()) + .map(String::from); let include_hidden = obj .get("include_hidden") .and_then(|v| v.as_bool()) @@ -421,7 +440,10 @@ impl StepHandler for ScreenshotStep { .get("full_page") .and_then(|v| v.as_bool()) .unwrap_or(false); - let selector = obj.get("selector").and_then(|v| v.as_str()).map(String::from); + let selector = obj + .get("selector") + .and_then(|v| v.as_str()) + .map(String::from); let path = obj.get("path").and_then(|v| v.as_str()).map(String::from); Some(ScreenshotOptions { path, @@ -478,14 +500,8 @@ impl StepHandler for ScrollStep { } // scroll: { direction: "down", count: 5, delay: 500 } Value::Object(obj) => { - let count = obj - .get("count") - .and_then(|v| v.as_u64()) - .unwrap_or(3) as u32; - let delay = obj - .get("delay") - .and_then(|v| v.as_u64()) - .unwrap_or(300); + let count = obj.get("count").and_then(|v| v.as_u64()).unwrap_or(3) as u32; + let delay = obj.get("delay").and_then(|v| v.as_u64()).unwrap_or(300); pg.auto_scroll(Some(opencli_rs_core::AutoScrollOptions { max_scrolls: Some(count), delay_ms: Some(delay), @@ -496,11 +512,11 @@ impl StepHandler for ScrollStep { // scroll: "down" or template string Value::String(_) => { let ctx = default_ctx(data, args); - let rendered = render_template_str( - params.as_str().unwrap_or("3"), - &ctx, - )?; - let count = rendered.as_u64().or_else(|| rendered.as_str().and_then(|s| s.parse().ok())).unwrap_or(3) as u32; + let rendered = render_template_str(params.as_str().unwrap_or("3"), &ctx)?; + let count = rendered + .as_u64() + .or_else(|| rendered.as_str().and_then(|s| s.parse().ok())) + .unwrap_or(3) as u32; pg.auto_scroll(Some(opencli_rs_core::AutoScrollOptions { max_scrolls: Some(count), delay_ms: Some(300), @@ -552,7 +568,9 @@ impl StepHandler for CollectStep { let parse_fn = params .get("parse") .and_then(|v| v.as_str()) - .ok_or_else(|| CliError::pipeline("collect step requires a 'parse' field with a JS function"))?; + .ok_or_else(|| { + CliError::pipeline("collect step requires a 'parse' field with a JS function") + })?; // Get intercepted data directly from browser (raw JSON, not typed structs) // and run the parse function on it — all in one evaluate call. @@ -646,10 +664,7 @@ mod tests { ) -> Result<(), CliError> { Ok(()) } - async fn wait_for_navigation( - &self, - _options: Option, - ) -> Result<(), CliError> { + async fn wait_for_navigation(&self, _options: Option) -> Result<(), CliError> { Ok(()) } async fn wait_for_timeout(&self, _ms: u64) -> Result<(), CliError> { @@ -766,7 +781,12 @@ mod tests { async fn test_browser_step_requires_page() { let step = NavigateStep; let result = step - .execute(None, &json!("https://example.com"), &json!(null), &empty_args()) + .execute( + None, + &json!("https://example.com"), + &json!(null), + &empty_args(), + ) .await; assert!(result.is_err()); } diff --git a/crates/opencli-rs-pipeline/src/steps/download.rs b/crates/opencli-rs-pipeline/src/steps/download.rs index 528ddb6..770d5d2 100644 --- a/crates/opencli-rs-pipeline/src/steps/download.rs +++ b/crates/opencli-rs-pipeline/src/steps/download.rs @@ -61,11 +61,17 @@ impl StepHandler for DownloadStep { .unwrap_or("media"); if download_type == "article" { - return execute_article_download(obj.unwrap_or(&serde_json::Map::new()), &ctx, data).await; + return execute_article_download(obj.unwrap_or(&serde_json::Map::new()), &ctx, data) + .await; } if download_type == "twitter-media" || download_type == "media-batch" { - return execute_media_batch_download(obj.unwrap_or(&serde_json::Map::new()), &ctx, data).await; + return execute_media_batch_download( + obj.unwrap_or(&serde_json::Map::new()), + &ctx, + data, + ) + .await; } // Default: metadata-only download (extract URLs and annotate) @@ -81,14 +87,28 @@ impl StepHandler for DownloadStep { _ => serde_json::Map::new(), }; - result.insert("download_type".to_string(), Value::String(download_type.to_string())); + result.insert( + "download_type".to_string(), + Value::String(download_type.to_string()), + ); if let Some(ref u) = url { - let filename = u.rsplit('/').next().unwrap_or("download") - .split('?').next().unwrap_or("download"); + let filename = u + .rsplit('/') + .next() + .unwrap_or("download") + .split('?') + .next() + .unwrap_or("download"); result.insert("download_url".to_string(), Value::String(u.clone())); - result.insert("download_path".to_string(), Value::String(filename.to_string())); + result.insert( + "download_path".to_string(), + Value::String(filename.to_string()), + ); } - result.insert("download_status".to_string(), Value::String("pending".to_string())); + result.insert( + "download_status".to_string(), + Value::String("pending".to_string()), + ); Ok(Value::Object(result)) } @@ -100,27 +120,59 @@ async fn execute_article_download( ctx: &TemplateContext, data: &Value, ) -> Result { - let title = params.get("title") + let title = params + .get("title") .and_then(|v| v.as_str()) - .map(|s| render_template_str(s, ctx).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_else(|| s.to_string())) + .map(|s| { + render_template_str(s, ctx) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| s.to_string()) + }) .or_else(|| data.get("title").and_then(|v| v.as_str()).map(String::from)) .unwrap_or_else(|| "article".to_string()); - let output_dir = params.get("output") + let output_dir = params + .get("output") .and_then(|v| v.as_str()) - .map(|s| render_template_str(s, ctx).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_else(|| s.to_string())) + .map(|s| { + render_template_str(s, ctx) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| s.to_string()) + }) .unwrap_or_else(|| "./articles".to_string()); - let filename = params.get("filename") + let filename = params + .get("filename") .and_then(|v| v.as_str()) - .map(|s| render_template_str(s, ctx).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_else(|| s.to_string())) - .or_else(|| data.get("filename").and_then(|v| v.as_str()).map(String::from)) + .map(|s| { + render_template_str(s, ctx) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| s.to_string()) + }) + .or_else(|| { + data.get("filename") + .and_then(|v| v.as_str()) + .map(String::from) + }) .unwrap_or_else(|| "article.md".to_string()); - let mut content = params.get("content") + let mut content = params + .get("content") .and_then(|v| v.as_str()) - .map(|s| render_template_str(s, ctx).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_else(|| s.to_string())) - .or_else(|| data.get("content").and_then(|v| v.as_str()).map(String::from)) + .map(|s| { + render_template_str(s, ctx) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| s.to_string()) + }) + .or_else(|| { + data.get("content") + .and_then(|v| v.as_str()) + .map(String::from) + }) .unwrap_or_default(); if content.is_empty() { @@ -133,16 +185,32 @@ async fn execute_article_download( } // Create article directory (output/safe_title/) - let safe_title: String = title.chars() - .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' { c } else { '_' }) - .collect::().trim().chars().take(80).collect(); + let safe_title: String = title + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' || c == ' ' { + c + } else { + '_' + } + }) + .collect::() + .trim() + .chars() + .take(80) + .collect(); let article_dir = format!("{}/{}", output_dir, safe_title); let _ = std::fs::create_dir_all(&article_dir); // Download images if present in data - let image_urls: Vec = data.get("imageUrls") + let image_urls: Vec = data + .get("imageUrls") .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) .unwrap_or_default(); if !image_urls.is_empty() { @@ -160,21 +228,32 @@ async fn execute_article_download( let mut img_index = 0; for raw_url in &image_urls { - if seen.contains(raw_url.as_str()) { continue; } + if seen.contains(raw_url.as_str()) { + continue; + } seen.insert(raw_url.as_str()); img_index += 1; let mut img_url = raw_url.clone(); - if img_url.starts_with("//") { img_url = format!("https:{}", img_url); } + if img_url.starts_with("//") { + img_url = format!("https:{}", img_url); + } // Detect extension let ext = if let Some(m) = img_url.find("wx_fmt=") { - img_url[m + 7..].split(&['&', '?', ' '][..]).next().unwrap_or("png").to_string() + img_url[m + 7..] + .split(&['&', '?', ' '][..]) + .next() + .unwrap_or("png") + .to_string() } else { - img_url.rsplit('.').next() + img_url + .rsplit('.') + .next() .and_then(|e| e.split(&['?', '#', '&'][..]).next()) .filter(|e| e.len() <= 5 && e.chars().all(|c| c.is_alphanumeric())) - .unwrap_or("jpg").to_string() + .unwrap_or("jpg") + .to_string() }; let img_filename = format!("img_{:03}.{}", img_index, ext); @@ -209,9 +288,13 @@ async fn execute_article_download( match std::fs::write(&file_path, &content) { Ok(_) => { let size = content.len(); - let size_str = if size > 1_000_000 { format!("{:.1} MB", size as f64 / 1e6) } - else if size > 1000 { format!("{:.1} KB", size as f64 / 1e3) } - else { format!("{} bytes", size) }; + let size_str = if size > 1_000_000 { + format!("{:.1} MB", size as f64 / 1e6) + } else if size > 1000 { + format!("{:.1} KB", size as f64 / 1e3) + } else { + format!("{} bytes", size) + }; info!(title = %title, path = %file_path, size = %size_str, "Article saved"); let author = data.get("author").and_then(|v| v.as_str()).unwrap_or("-"); @@ -225,14 +308,12 @@ async fn execute_article_download( "images": image_urls.len(), }])) } - Err(e) => { - Ok(serde_json::json!([{ - "title": title, - "author": "-", - "status": "failed", - "size": format!("Write error: {}", e) - }])) - } + Err(e) => Ok(serde_json::json!([{ + "title": title, + "author": "-", + "status": "failed", + "size": format!("Write error: {}", e) + }])), } } @@ -242,17 +323,30 @@ async fn execute_media_batch_download( ctx: &TemplateContext, data: &Value, ) -> Result { - let output_dir = params.get("output") + let output_dir = params + .get("output") .and_then(|v| v.as_str()) - .map(|s| render_template_str(s, ctx).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_else(|| s.to_string())) + .map(|s| { + render_template_str(s, ctx) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| s.to_string()) + }) .unwrap_or_else(|| "./downloads".to_string()); - let prefix = params.get("username") + let prefix = params + .get("username") .and_then(|v| v.as_str()) - .map(|s| render_template_str(s, ctx).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_else(|| s.to_string())) + .map(|s| { + render_template_str(s, ctx) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| s.to_string()) + }) .unwrap_or_else(|| "media".to_string()); - let items = data.get("items") + let items = data + .get("items") .and_then(|v| v.as_array()) .cloned() .unwrap_or_default(); @@ -262,7 +356,9 @@ async fn execute_media_batch_download( if data.is_array() { return Ok(data.clone()); } - return Ok(serde_json::json!([{ "index": 0, "type": "-", "status": "failed", "size": "No media items" }])); + return Ok( + serde_json::json!([{ "index": 0, "type": "-", "status": "failed", "size": "No media items" }]), + ); } let _ = std::fs::create_dir_all(&output_dir); @@ -276,37 +372,42 @@ async fn execute_media_batch_download( let mut results = Vec::new(); for (i, item) in items.iter().enumerate() { - let media_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("unknown"); + let media_type = item + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); let url = item.get("url").and_then(|v| v.as_str()).unwrap_or(""); - if url.is_empty() { continue; } + if url.is_empty() { + continue; + } let idx = i + 1; if media_type == "image" { // Direct image download - let ext = if url.contains("format=png") { "png" } - else if url.contains("format=webp") { "webp" } - else { "jpg" }; + let ext = if url.contains("format=png") { + "png" + } else if url.contains("format=webp") { + "webp" + } else { + "jpg" + }; let filename = format!("{}_{:03}.{}", prefix, idx, ext); let filepath = format!("{}/{}", output_dir, filename); - match client.get(url).send().await { - Ok(resp) if resp.status().is_success() => { - match resp.bytes().await { - Ok(bytes) => { - if std::fs::write(&filepath, &bytes).is_ok() { - let size = format_size(bytes.len()); - results.push(serde_json::json!({ - "index": idx, "type": "image", "status": "ok", "size": size - })); - continue; - } + if let Ok(resp) = client.get(url).send().await { + if resp.status().is_success() { + if let Ok(bytes) = resp.bytes().await { + if std::fs::write(&filepath, &bytes).is_ok() { + let size = format_size(bytes.len()); + results.push(serde_json::json!({ + "index": idx, "type": "image", "status": "ok", "size": size + })); + continue; } - _ => {} } } - _ => {} } results.push(serde_json::json!({ "index": idx, "type": "image", "status": "failed", "size": "-" @@ -316,22 +417,18 @@ async fn execute_media_batch_download( let filename = format!("{}_{:03}.mp4", prefix, idx); let filepath = format!("{}/{}", output_dir, filename); - match client.get(url).send().await { - Ok(resp) if resp.status().is_success() => { - match resp.bytes().await { - Ok(bytes) => { - if std::fs::write(&filepath, &bytes).is_ok() { - let size = format_size(bytes.len()); - results.push(serde_json::json!({ - "index": idx, "type": "video", "status": "ok", "size": size - })); - continue; - } + if let Ok(resp) = client.get(url).send().await { + if resp.status().is_success() { + if let Ok(bytes) = resp.bytes().await { + if std::fs::write(&filepath, &bytes).is_ok() { + let size = format_size(bytes.len()); + results.push(serde_json::json!({ + "index": idx, "type": "video", "status": "ok", "size": size + })); + continue; } - _ => {} } } - _ => {} } results.push(serde_json::json!({ "index": idx, "type": "video", "status": "failed", "size": "-" @@ -342,7 +439,15 @@ async fn execute_media_batch_download( let filepath = format!("{}/{}", output_dir, filename); let status = tokio::process::Command::new("yt-dlp") - .args(["-f", "best[ext=mp4]/best", "--merge-output-format", "mp4", "-o", &filepath, url]) + .args([ + "-f", + "best[ext=mp4]/best", + "--merge-output-format", + "mp4", + "-o", + &filepath, + url, + ]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() @@ -367,7 +472,9 @@ async fn execute_media_batch_download( } if results.is_empty() { - return Ok(serde_json::json!([{ "index": 0, "type": "-", "status": "no media", "size": "-" }])); + return Ok( + serde_json::json!([{ "index": 0, "type": "-", "status": "no media", "size": "-" }]), + ); } info!(count = results.len(), dir = %output_dir, "Media batch download complete"); @@ -375,10 +482,15 @@ async fn execute_media_batch_download( } fn format_size(bytes: usize) -> String { - if bytes > 1_000_000_000 { format!("{:.1} GB", bytes as f64 / 1e9) } - else if bytes > 1_000_000 { format!("{:.1} MB", bytes as f64 / 1e6) } - else if bytes > 1000 { format!("{:.1} KB", bytes as f64 / 1e3) } - else { format!("{} bytes", bytes) } + if bytes > 1_000_000_000 { + format!("{:.1} GB", bytes as f64 / 1e9) + } else if bytes > 1_000_000 { + format!("{:.1} MB", bytes as f64 / 1e6) + } else if bytes > 1000 { + format!("{:.1} KB", bytes as f64 / 1e3) + } else { + format!("{} bytes", bytes) + } } /// Execute yt-dlp download @@ -388,14 +500,18 @@ async fn execute_ytdlp( data: &Value, ) -> Result { // Check if yt-dlp is installed - let ytdlp_ok = tokio::process::Command::new(if cfg!(target_os = "windows") { "where" } else { "which" }) - .arg("yt-dlp") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .await - .map(|s| s.success()) - .unwrap_or(false); + let ytdlp_ok = tokio::process::Command::new(if cfg!(target_os = "windows") { + "where" + } else { + "which" + }) + .arg("yt-dlp") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false); if !ytdlp_ok { return Ok(serde_json::json!([{ @@ -405,39 +521,68 @@ async fn execute_ytdlp( } // Render template params - let url = params.get("url") + let url = params + .get("url") .and_then(|v| v.as_str()) - .map(|s| render_template_str(s, ctx).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_else(|| s.to_string())) + .map(|s| { + render_template_str(s, ctx) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| s.to_string()) + }) .or_else(|| data.get("url").and_then(|v| v.as_str()).map(String::from)) .ok_or_else(|| CliError::pipeline("download: missing url"))?; - let title = params.get("title") + let title = params + .get("title") .and_then(|v| v.as_str()) - .map(|s| render_template_str(s, ctx).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_else(|| s.to_string())) + .map(|s| { + render_template_str(s, ctx) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| s.to_string()) + }) .or_else(|| data.get("title").and_then(|v| v.as_str()).map(String::from)) .unwrap_or_else(|| "video".to_string()); - let output_dir = params.get("output") + let output_dir = params + .get("output") .and_then(|v| v.as_str()) - .map(|s| render_template_str(s, ctx).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_else(|| s.to_string())) + .map(|s| { + render_template_str(s, ctx) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| s.to_string()) + }) .unwrap_or_else(|| "./downloads".to_string()); - let quality = params.get("quality") + let quality = params + .get("quality") .and_then(|v| v.as_str()) - .map(|s| render_template_str(s, ctx).ok().and_then(|v| v.as_str().map(String::from)).unwrap_or_else(|| s.to_string())) + .map(|s| { + render_template_str(s, ctx) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| s.to_string()) + }) .unwrap_or_else(|| "best".to_string()); // Extract cookies from data (set by evaluate step from document.cookie) - let cookies_str = data.get("cookies") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let cookies_str = data.get("cookies").and_then(|v| v.as_str()).unwrap_or(""); // Create output directory let _ = std::fs::create_dir_all(&output_dir); // Sanitize title for filename - let safe_title: String = title.chars() - .map(|c| if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == ' ' { c } else { '_' }) + let safe_title: String = title + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == ' ' { + c + } else { + '_' + } + }) .collect::() .trim() .chars() @@ -459,7 +604,8 @@ async fn execute_ytdlp( // Write cookies to Netscape format temp file for yt-dlp let cookie_file = if !cookies_str.is_empty() { let cookie_path = format!("{}/.ytdlp_cookies.txt", output_dir); - let domain = url.strip_prefix("https://") + let domain = url + .strip_prefix("https://") .or_else(|| url.strip_prefix("http://")) .and_then(|s| s.split('/').next()) .map(|host| { @@ -491,10 +637,13 @@ async fn execute_ytdlp( }; let mut cmd = tokio::process::Command::new("yt-dlp"); - cmd.arg("-f").arg(format) - .arg("--merge-output-format").arg("mp4") + cmd.arg("-f") + .arg(format) + .arg("--merge-output-format") + .arg("mp4") .arg("--embed-thumbnail") - .arg("-o").arg(&output_path); + .arg("-o") + .arg(&output_path); if let Some(ref cf) = cookie_file { cmd.arg("--cookies").arg(cf); @@ -512,9 +661,13 @@ async fn execute_ytdlp( let file_size = std::fs::metadata(&output_path) .map(|m| { let bytes = m.len(); - if bytes > 1_000_000_000 { format!("{:.1} GB", bytes as f64 / 1e9) } - else if bytes > 1_000_000 { format!("{:.1} MB", bytes as f64 / 1e6) } - else { format!("{:.0} KB", bytes as f64 / 1e3) } + if bytes > 1_000_000_000 { + format!("{:.1} GB", bytes as f64 / 1e9) + } else if bytes > 1_000_000 { + format!("{:.1} MB", bytes as f64 / 1e6) + } else { + format!("{:.0} KB", bytes as f64 / 1e3) + } }) .unwrap_or_else(|_| "-".to_string()); diff --git a/crates/opencli-rs-pipeline/src/steps/fetch.rs b/crates/opencli-rs-pipeline/src/steps/fetch.rs index 5232b3f..a3cd8a9 100644 --- a/crates/opencli-rs-pipeline/src/steps/fetch.rs +++ b/crates/opencli-rs-pipeline/src/steps/fetch.rs @@ -138,7 +138,6 @@ impl FetchStep { .unwrap_or_default(), } } - } impl Default for FetchStep { @@ -169,7 +168,9 @@ impl StepHandler for FetchStep { let url = obj .get("url") .and_then(|v| v.as_str()) - .ok_or_else(|| CliError::pipeline("fetch: object params must have 'url' field"))? + .ok_or_else(|| { + CliError::pipeline("fetch: object params must have 'url' field") + })? .to_string(); let method = obj .get("method") @@ -180,7 +181,11 @@ impl StepHandler for FetchStep { let body = obj.get("body").cloned(); (url, method, headers, body) } - _ => return Err(CliError::pipeline("fetch: params must be a string URL or an object")), + _ => { + return Err(CliError::pipeline( + "fetch: params must be a string URL or an object", + )) + } }; // Check for per-item mode: data is array AND url references item @@ -191,53 +196,56 @@ impl StepHandler for FetchStep { // Mode 3: per-item concurrent fetch let items = data.as_array().unwrap(); let client = self.client.clone(); - let results: Vec> = stream::iter(items.iter().cloned().enumerate()) - .map(|(index, item)| { - let url_tmpl = url_template.clone(); - let method = method.clone(); - let headers_tmpl = headers_template.clone(); - let body_tmpl = body_template.clone(); - let args = args.clone(); - let data = data.clone(); - let client = client.clone(); - async move { - let ctx = TemplateContext { - args, - data, - item, - index, - }; - let rendered_url = render_template_str(&url_tmpl, &ctx)?; - let url_str = rendered_url - .as_str() - .ok_or_else(|| CliError::pipeline("fetch: rendered URL is not a string"))? - .to_string(); - - // Render headers if present - let rendered_headers = match &headers_tmpl { - Some(h) => Some(render_template(h, &ctx)?), - None => None, - }; - - // Render body if present - let rendered_body = match &body_tmpl { - Some(b) => Some(render_template(b, &ctx)?), - None => None, - }; - - do_request_with_client( - &client, - &url_str, - &method, - rendered_headers.as_ref(), - rendered_body.as_ref(), - ) - .await - } - }) - .buffer_unordered(10) - .collect() - .await; + let results: Vec> = + stream::iter(items.iter().cloned().enumerate()) + .map(|(index, item)| { + let url_tmpl = url_template.clone(); + let method = method.clone(); + let headers_tmpl = headers_template.clone(); + let body_tmpl = body_template.clone(); + let args = args.clone(); + let data = data.clone(); + let client = client.clone(); + async move { + let ctx = TemplateContext { + args, + data, + item, + index, + }; + let rendered_url = render_template_str(&url_tmpl, &ctx)?; + let url_str = rendered_url + .as_str() + .ok_or_else(|| { + CliError::pipeline("fetch: rendered URL is not a string") + })? + .to_string(); + + // Render headers if present + let rendered_headers = match &headers_tmpl { + Some(h) => Some(render_template(h, &ctx)?), + None => None, + }; + + // Render body if present + let rendered_body = match &body_tmpl { + Some(b) => Some(render_template(b, &ctx)?), + None => None, + }; + + do_request_with_client( + &client, + &url_str, + &method, + rendered_headers.as_ref(), + rendered_body.as_ref(), + ) + .await + } + }) + .buffer_unordered(10) + .collect() + .await; // Collect results, propagating errors let mut output = Vec::with_capacity(results.len()); diff --git a/crates/opencli-rs-pipeline/src/steps/intercept.rs b/crates/opencli-rs-pipeline/src/steps/intercept.rs index 08bd8af..4aab04a 100644 --- a/crates/opencli-rs-pipeline/src/steps/intercept.rs +++ b/crates/opencli-rs-pipeline/src/steps/intercept.rs @@ -47,7 +47,9 @@ impl StepHandler for InterceptStep { let rendered = render_template_str(s, &ctx)?; let pat = rendered .as_str() - .ok_or_else(|| CliError::pipeline("intercept: pattern must resolve to a string"))? + .ok_or_else(|| { + CliError::pipeline("intercept: pattern must resolve to a string") + })? .to_string(); (pat, 5000u64, false) } @@ -59,7 +61,9 @@ impl StepHandler for InterceptStep { let rendered = render_template_str(pat_raw, &ctx)?; let pat = rendered .as_str() - .ok_or_else(|| CliError::pipeline("intercept: pattern must resolve to a string"))? + .ok_or_else(|| { + CliError::pipeline("intercept: pattern must resolve to a string") + })? .to_string(); let wait = obj .get("wait") @@ -96,9 +100,7 @@ impl StepHandler for InterceptStep { let requests = pg.get_intercepted_requests().await?; let result: Vec = requests .into_iter() - .map(|r| { - serde_json::to_value(&r).unwrap_or(Value::Null) - }) + .map(|r| serde_json::to_value(&r).unwrap_or(Value::Null)) .collect(); Ok(Value::Array(result)) diff --git a/crates/opencli-rs-pipeline/src/steps/tap.rs b/crates/opencli-rs-pipeline/src/steps/tap.rs index 06c5ce5..a50e742 100644 --- a/crates/opencli-rs-pipeline/src/steps/tap.rs +++ b/crates/opencli-rs-pipeline/src/steps/tap.rs @@ -111,7 +111,6 @@ impl StepHandler for TapStep { // Extract action args (optional) let action_args = obj.get("args").cloned().unwrap_or(Value::Array(vec![])); - let action_args_json = serde_json::to_string(&action_args).unwrap_or("[]".to_string()); let store_name_json = serde_json::to_string(&store_name).unwrap_or("\"\"".to_string()); let action_name_json = serde_json::to_string(&action_name).unwrap_or("\"\"".to_string()); @@ -127,10 +126,7 @@ impl StepHandler for TapStep { .iter() .map(|a| serde_json::to_string(a).unwrap_or("null".to_string())) .collect(); - format!( - "store[{action_name_json}]({})", - rendered_args.join(", ") - ) + format!("store[{action_name_json}]({})", rendered_args.join(", ")) }; // Generate self-contained JS that does everything in the browser @@ -226,10 +222,7 @@ impl StepHandler for TapStep { // Check if the result is an error object from the JS if let Some(error) = result.get("error").and_then(|v| v.as_str()) { - let hint = result - .get("hint") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let hint = result.get("hint").and_then(|v| v.as_str()).unwrap_or(""); return Err(CliError::command_execution(format!( "tap: {} {}", error, diff --git a/crates/opencli-rs-pipeline/src/steps/transform.rs b/crates/opencli-rs-pipeline/src/steps/transform.rs index 781dfac..f05ccf9 100644 --- a/crates/opencli-rs-pipeline/src/steps/transform.rs +++ b/crates/opencli-rs-pipeline/src/steps/transform.rs @@ -34,14 +34,8 @@ impl StepHandler for SelectStep { let mut current = data.clone(); for segment in parse_path_segments(path) { current = match segment { - PathSegment::Key(key) => current - .get(&key) - .cloned() - .unwrap_or(Value::Null), - PathSegment::Index(idx) => current - .get(idx) - .cloned() - .unwrap_or(Value::Null), + PathSegment::Key(key) => current.get(&key).cloned().unwrap_or(Value::Null), + PathSegment::Index(idx) => current.get(idx).cloned().unwrap_or(Value::Null), }; } @@ -328,10 +322,15 @@ impl StepHandler for LimitStep { let val = render_template_str(s, &ctx)?; val.as_u64() .or_else(|| val.as_str().and_then(|s| s.parse::().ok())) - .ok_or_else(|| CliError::pipeline("limit: template did not resolve to a number"))? - as usize + .ok_or_else(|| { + CliError::pipeline("limit: template did not resolve to a number") + })? as usize + } + _ => { + return Err(CliError::pipeline( + "limit: params must be a number or template string", + )) } - _ => return Err(CliError::pipeline("limit: params must be a number or template string")), }; let truncated: Vec = arr.iter().take(n).cloned().collect(); diff --git a/crates/opencli-rs-pipeline/src/template/evaluator.rs b/crates/opencli-rs-pipeline/src/template/evaluator.rs index 8edee8f..a8c51e8 100644 --- a/crates/opencli-rs-pipeline/src/template/evaluator.rs +++ b/crates/opencli-rs-pipeline/src/template/evaluator.rs @@ -99,11 +99,7 @@ pub fn evaluate(expr: &Expr, ctx: &TemplateContext) -> Result { } } - Expr::Pipe { - expr, - filter, - args, - } => { + Expr::Pipe { expr, filter, args } => { let val = evaluate(expr, ctx)?; let eval_args: Vec = args .iter() @@ -137,12 +133,8 @@ fn resolve_ident(name: &str, ctx: &TemplateContext) -> Result { fn access_field(val: &Value, field: &str) -> Value { match val { Value::Object(map) => map.get(field).cloned().unwrap_or(Value::Null), - Value::Array(arr) if field == "length" => { - Value::Number(arr.len().into()) - } - Value::String(s) if field == "length" => { - Value::Number(s.len().into()) - } + Value::Array(arr) if field == "length" => Value::Number(arr.len().into()), + Value::String(s) if field == "length" => Value::Number(s.len().into()), _ => Value::Null, } } @@ -217,22 +209,14 @@ fn eval_binop(op: &BinOpKind, left: &Value, right: &Value) -> Result arith(left, right, |a, b| a - b), BinOpKind::Mul => arith(left, right, |a, b| a * b), - BinOpKind::Div => arith(left, right, |a, b| { - if b == 0.0 { - f64::NAN - } else { - a / b - } - }), - BinOpKind::Mod => arith(left, right, |a, b| { - if b == 0.0 { - f64::NAN - } else { - a % b - } - }), - BinOpKind::Gt => Ok(Value::Bool(cmp_values(left, right) == Some(std::cmp::Ordering::Greater))), - BinOpKind::Lt => Ok(Value::Bool(cmp_values(left, right) == Some(std::cmp::Ordering::Less))), + BinOpKind::Div => arith(left, right, |a, b| if b == 0.0 { f64::NAN } else { a / b }), + BinOpKind::Mod => arith(left, right, |a, b| if b == 0.0 { f64::NAN } else { a % b }), + BinOpKind::Gt => Ok(Value::Bool( + cmp_values(left, right) == Some(std::cmp::Ordering::Greater), + )), + BinOpKind::Lt => Ok(Value::Bool( + cmp_values(left, right) == Some(std::cmp::Ordering::Less), + )), BinOpKind::Gte => Ok(Value::Bool(matches!( cmp_values(left, right), Some(std::cmp::Ordering::Greater | std::cmp::Ordering::Equal) diff --git a/crates/opencli-rs-pipeline/src/template/filters.rs b/crates/opencli-rs-pipeline/src/template/filters.rs index 0d81745..028cb74 100644 --- a/crates/opencli-rs-pipeline/src/template/filters.rs +++ b/crates/opencli-rs-pipeline/src/template/filters.rs @@ -2,11 +2,7 @@ use serde_json::Value; use opencli_rs_core::CliError; -pub fn apply_filter( - name: &str, - input: Value, - args: &[Value], -) -> Result { +pub fn apply_filter(name: &str, input: Value, args: &[Value]) -> Result { match name { "default" => filter_default(input, args), "join" => filter_join(input, args), @@ -50,10 +46,7 @@ fn filter_default(input: Value, args: &[Value]) -> Result { } fn filter_join(input: Value, args: &[Value]) -> Result { - let sep = args - .first() - .and_then(|v| v.as_str()) - .unwrap_or(","); + let sep = args.first().and_then(|v| v.as_str()).unwrap_or(","); match input { Value::Array(arr) => { let parts: Vec = arr @@ -91,10 +84,7 @@ fn filter_trim(input: Value) -> Result { } fn filter_truncate(input: Value, args: &[Value]) -> Result { - let n = args - .first() - .and_then(|v| v.as_u64()) - .unwrap_or(50) as usize; + let n = args.first().and_then(|v| v.as_u64()).unwrap_or(50) as usize; Ok(match input { Value::String(s) => { if s.chars().count() > n { @@ -119,9 +109,7 @@ fn filter_replace(input: Value, args: &[Value]) -> Result { fn filter_keys(input: Value) -> Result { Ok(match input { - Value::Object(map) => { - Value::Array(map.keys().map(|k| Value::String(k.clone())).collect()) - } + Value::Object(map) => Value::Array(map.keys().map(|k| Value::String(k.clone())).collect()), _ => Value::Array(vec![]), }) } @@ -151,7 +139,9 @@ fn filter_last(input: Value) -> Result { } fn filter_json(input: Value) -> Result { - Ok(Value::String(serde_json::to_string(&input).unwrap_or_default())) + Ok(Value::String( + serde_json::to_string(&input).unwrap_or_default(), + )) } fn filter_slugify(input: Value) -> Result { @@ -228,10 +218,7 @@ fn filter_ext(input: Value) -> Result { fn filter_basename(input: Value) -> Result { Ok(match input { Value::String(s) => { - let name = s - .rsplit('/') - .next() - .unwrap_or(&s); + let name = s.rsplit('/').next().unwrap_or(&s); Value::String(name.to_string()) } other => other, @@ -244,12 +231,15 @@ fn filter_urlencode(input: Value) -> Result { other => other.to_string(), }; // Percent-encode all non-unreserved characters per RFC 3986 - let encoded: String = s.bytes().map(|b| match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - format!("{}", b as char) - } - _ => format!("%{:02X}", b), - }).collect(); + let encoded: String = s + .bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + format!("{}", b as char) + } + _ => format!("%{:02X}", b), + }) + .collect(); Ok(Value::String(encoded)) } @@ -263,9 +253,7 @@ fn filter_urldecode(input: Value) -> Result { let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' && i + 2 < bytes.len() { - if let Ok(val) = u8::from_str_radix( - &s[i + 1..i + 3], 16 - ) { + if let Ok(val) = u8::from_str_radix(&s[i + 1..i + 3], 16) { result.push(val); i += 3; continue; @@ -357,7 +345,7 @@ fn filter_int(input: Value) -> Result { fn filter_float(input: Value) -> Result { Ok(match &input { - Value::Number(n) => input.clone(), + Value::Number(_) => input.clone(), Value::String(s) => { let f: f64 = s.parse().unwrap_or(0.0); Value::Number(serde_json::Number::from_f64(f).unwrap_or(0.into())) @@ -368,7 +356,10 @@ fn filter_float(input: Value) -> Result { fn filter_reverse(input: Value) -> Result { Ok(match input { - Value::Array(mut arr) => { arr.reverse(); Value::Array(arr) } + Value::Array(mut arr) => { + arr.reverse(); + Value::Array(arr) + } Value::String(s) => Value::String(s.chars().rev().collect()), other => other, }) diff --git a/crates/opencli-rs-pipeline/src/template/mod.rs b/crates/opencli-rs-pipeline/src/template/mod.rs index dd8ce37..c9c605b 100644 --- a/crates/opencli-rs-pipeline/src/template/mod.rs +++ b/crates/opencli-rs-pipeline/src/template/mod.rs @@ -10,9 +10,6 @@ use serde_json::Value; use evaluator::evaluate; use parser::parse_expression; -/// Regex-like pattern for `${{ expr }}`. -/// We use a simple manual scanner instead of pulling in regex. - /// Render a Value that may contain template strings. /// If Value is a String containing `${{ }}`, evaluate it. /// If Value is an Object/Array, recursively render all string values. @@ -201,43 +198,35 @@ mod tests { #[test] fn test_fallback() { let ctx = make_ctx(); - let result = - render_template_str("${{ item.subtitle || \"N/A\" }}", &ctx).unwrap(); + let result = render_template_str("${{ item.subtitle || \"N/A\" }}", &ctx).unwrap(); assert_eq!(result, Value::String("N/A".to_string())); } #[test] fn test_partial_interpolation() { let ctx = make_ctx(); - let result = - render_template_str("https://api.com/${{ item.id }}.json", &ctx).unwrap(); - assert_eq!( - result, - Value::String("https://api.com/42.json".to_string()) - ); + let result = render_template_str("https://api.com/${{ item.id }}.json", &ctx).unwrap(); + assert_eq!(result, Value::String("https://api.com/42.json".to_string())); } #[test] fn test_ternary() { let ctx = make_ctx(); - let result = - render_template_str("${{ item.active ? \"yes\" : \"no\" }}", &ctx).unwrap(); + let result = render_template_str("${{ item.active ? \"yes\" : \"no\" }}", &ctx).unwrap(); assert_eq!(result, Value::String("yes".to_string())); } #[test] fn test_filter_chain() { let ctx = make_ctx(); - let result = - render_template_str("${{ item.name | lower | trim }}", &ctx).unwrap(); + let result = render_template_str("${{ item.name | lower | trim }}", &ctx).unwrap(); assert_eq!(result, Value::String("hello world".to_string())); } #[test] fn test_math_min() { let ctx = make_ctx(); - let result = - render_template_str("${{ Math.min(args.limit + 10, 50) }}", &ctx).unwrap(); + let result = render_template_str("${{ Math.min(args.limit + 10, 50) }}", &ctx).unwrap(); assert_eq!(result, Value::Number(30.into())); } @@ -251,8 +240,7 @@ mod tests { #[test] fn test_logical_and() { let ctx = make_ctx(); - let result = - render_template_str("${{ item.title && !item.deleted }}", &ctx).unwrap(); + let result = render_template_str("${{ item.title && !item.deleted }}", &ctx).unwrap(); assert_eq!(result, Value::Bool(true)); } @@ -289,8 +277,7 @@ mod tests { #[test] fn test_filter_join() { let ctx = make_ctx(); - let result = - render_template_str("${{ item.tags | join(\", \") }}", &ctx).unwrap(); + let result = render_template_str("${{ item.tags | join(\", \") }}", &ctx).unwrap(); assert_eq!(result, Value::String("rust, cli".to_string())); } @@ -323,10 +310,7 @@ mod tests { fn test_filter_json() { let ctx = make_ctx(); let result = render_template_str("${{ item.author | json }}", &ctx).unwrap(); - assert_eq!( - result, - Value::String("{\"name\":\"Alice\"}".to_string()) - ); + assert_eq!(result, Value::String("{\"name\":\"Alice\"}".to_string())); } #[test] @@ -360,30 +344,23 @@ mod tests { #[test] fn test_filter_replace() { let ctx = make_ctx(); - let result = render_template_str( - "${{ item.title | replace(\"World\", \"Rust\") }}", - &ctx, - ) - .unwrap(); + let result = + render_template_str("${{ item.title | replace(\"World\", \"Rust\") }}", &ctx).unwrap(); assert_eq!(result, Value::String("Hello Rust".to_string())); } #[test] fn test_filter_default() { let ctx = make_ctx(); - let result = render_template_str( - "${{ item.subtitle | default(\"fallback\") }}", - &ctx, - ) - .unwrap(); + let result = + render_template_str("${{ item.subtitle | default(\"fallback\") }}", &ctx).unwrap(); assert_eq!(result, Value::String("fallback".to_string())); } #[test] fn test_math_max() { let ctx = make_ctx(); - let result = - render_template_str("${{ Math.max(5, 10) }}", &ctx).unwrap(); + let result = render_template_str("${{ Math.max(5, 10) }}", &ctx).unwrap(); assert_eq!(result, Value::Number(10.into())); } @@ -404,8 +381,7 @@ mod tests { #[test] fn test_parentheses() { let ctx = make_ctx(); - let result = - render_template_str("${{ (index + 1) * 2 }}", &ctx).unwrap(); + let result = render_template_str("${{ (index + 1) * 2 }}", &ctx).unwrap(); assert_eq!(result, Value::Number(2.into())); } @@ -433,15 +409,9 @@ mod tests { #[test] fn test_multiple_interpolations() { let ctx = make_ctx(); - let result = render_template_str( - "${{ item.title }} by ${{ item.author.name }}", - &ctx, - ) - .unwrap(); - assert_eq!( - result, - Value::String("Hello World by Alice".to_string()) - ); + let result = + render_template_str("${{ item.title }} by ${{ item.author.name }}", &ctx).unwrap(); + assert_eq!(result, Value::String("Hello World by Alice".to_string())); } #[test] diff --git a/crates/opencli-rs-pipeline/src/template/parser.rs b/crates/opencli-rs-pipeline/src/template/parser.rs index 78fcc95..db60556 100644 --- a/crates/opencli-rs-pipeline/src/template/parser.rs +++ b/crates/opencli-rs-pipeline/src/template/parser.rs @@ -58,9 +58,8 @@ pub enum BinOpKind { } pub fn parse_expression(input: &str) -> Result { - let pairs = ExprParser::parse(Rule::expression, input).map_err(|e| { - CliError::pipeline(format!("Template parse error: {e}")) - })?; + let pairs = ExprParser::parse(Rule::expression, input) + .map_err(|e| CliError::pipeline(format!("Template parse error: {e}")))?; let expr_pair = pairs .into_iter() @@ -93,7 +92,10 @@ fn parse_any(pair: Pair<'_, Rule>) -> Result { parse_any(inner) } Rule::expression => { - let inner = pair.into_inner().find(|p| p.as_rule() == Rule::pipe_expr).unwrap(); + let inner = pair + .into_inner() + .find(|p| p.as_rule() == Rule::pipe_expr) + .unwrap(); parse_pipe_expr(inner) } _ => Err(CliError::pipeline(format!( @@ -278,8 +280,7 @@ fn parse_postfix_expr(pair: Pair<'_, Rule>) -> Result { expr = Expr::DotAccess(Box::new(expr), field); } Rule::bracket_access => { - let index_expr = - parse_any(suffix.into_inner().next().unwrap())?; + let index_expr = parse_any(suffix.into_inner().next().unwrap())?; expr = Expr::BracketAccess(Box::new(expr), Box::new(index_expr)); } _ => {} @@ -293,26 +294,23 @@ fn parse_primary(pair: Pair<'_, Rule>) -> Result { match inner.as_rule() { Rule::float_lit => { - let val: f64 = inner.as_str().parse().map_err(|_| { - CliError::pipeline(format!("Invalid float: {}", inner.as_str())) - })?; + let val: f64 = inner + .as_str() + .parse() + .map_err(|_| CliError::pipeline(format!("Invalid float: {}", inner.as_str())))?; Ok(Expr::FloatLit(val)) } Rule::int_lit => { - let val: i64 = inner.as_str().parse().map_err(|_| { - CliError::pipeline(format!("Invalid integer: {}", inner.as_str())) - })?; + let val: i64 = inner + .as_str() + .parse() + .map_err(|_| CliError::pipeline(format!("Invalid integer: {}", inner.as_str())))?; Ok(Expr::IntLit(val)) } Rule::bool_lit => Ok(Expr::BoolLit(inner.as_str() == "true")), Rule::null_lit => Ok(Expr::NullLit), Rule::string_lit => { - let s = inner - .into_inner() - .next() - .unwrap() - .as_str() - .to_string(); + let s = inner.into_inner().next().unwrap().as_str().to_string(); Ok(Expr::StringLit(s)) } Rule::func_call => {