diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0af8d86..6950d92 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,6 +48,7 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable with: + toolchain: stable targets: ${{ matrix.target }} - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b7fd29d..b926e7f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,7 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable with: + toolchain: stable components: rustfmt - run: cargo fmt --all -- --check @@ -31,6 +32,7 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable with: + toolchain: stable components: clippy - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - run: cargo clippy --all-targets -- -D warnings @@ -44,6 +46,8 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable + with: + toolchain: stable - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - run: cargo test --all-targets diff --git a/Cargo.lock b/Cargo.lock index 779d956..51d2358 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1714,9 +1714,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -1775,7 +1775,7 @@ dependencies = [ [[package]] name = "semrush-rs" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 3271d18..621eb08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" rust-version = "1.80.0" description = "A high-performance, agent-friendly CLI for the Semrush API" license = "MIT" +default-run = "semrush" [lib] name = "semrush" diff --git a/README.md b/README.md index 16d0ebc..3880a53 100644 --- a/README.md +++ b/README.md @@ -52,31 +52,31 @@ enabled = true ```bash # Domain overview -semrush domain overview --domain example.com +semrush domain overview example.com # Organic keywords for a domain -semrush domain organic --domain example.com --limit 20 +semrush domain organic example.com --limit 20 # Keyword research -semrush keyword overview --phrase "rust programming" +semrush keyword overview "rust programming" # Related keywords -semrush keyword related --phrase "machine learning" --limit 50 +semrush keyword related "machine learning" --limit 50 # Backlink profile -semrush backlink overview --target example.com +semrush backlink overview example.com # Traffic trends -semrush trends summary --targets "example.com,competitor.com" +semrush trends summary example.com competitor.com # Different regional database -semrush domain overview --domain example.de --database de +semrush domain overview example.de --database de # CSV output for spreadsheets -semrush domain organic --domain example.com --output csv > keywords.csv +semrush domain organic example.com --output csv > keywords.csv # Dry run -- estimate API cost without making the call -semrush domain organic --domain example.com --dry-run +semrush domain organic example.com --dry-run ``` ## AI Agent Integration @@ -89,16 +89,16 @@ Output auto-detects: **table** for terminals, **JSON** for pipes. Force a format ```bash # JSON with metadata envelope (default when piped) -semrush domain overview --domain example.com | jq '.data[0].organic_keywords' +semrush domain overview example.com | jq '.data[0].organic_keywords' # JSON Lines for streaming processing -semrush domain organic --domain example.com --output jsonl +semrush domain organic example.com --output jsonl # CSV for data pipelines -semrush domain organic --domain example.com --output csv +semrush domain organic example.com --output csv # Table for human reading -semrush domain overview --domain example.com --output table +semrush domain overview example.com --output table ``` JSON output includes a `_meta` envelope with timing, cache status, and cost: @@ -131,16 +131,16 @@ JSON output includes a `_meta` envelope with timing, cache status, and cost: ```bash # Step 1: Get domain overview -OVERVIEW=$(semrush domain overview --domain competitor.com --output json) +OVERVIEW=$(semrush domain overview competitor.com --output json) # Step 2: Get their top organic keywords -KEYWORDS=$(semrush domain organic --domain competitor.com --limit 50 --output json) +KEYWORDS=$(semrush domain organic competitor.com --limit 50 --output json) # Step 3: Check difficulty of a specific keyword -DIFFICULTY=$(semrush keyword difficulty --phrase "target keyword") +DIFFICULTY=$(semrush keyword difficulty "target keyword") # Step 4: Analyze backlink profile -BACKLINKS=$(semrush backlink overview --target competitor.com) +BACKLINKS=$(semrush backlink overview competitor.com) ``` ### Batch recipes @@ -171,10 +171,10 @@ limit = 50 ```bash # Run the recipe -semrush batch run --file competitor-audit.toml --var domain=example.com --var database=us +semrush batch run competitor-audit.toml --var domain=example.com --var database=us # Estimate cost first -semrush batch estimate --file competitor-audit.toml --var domain=example.com +semrush batch estimate competitor-audit.toml --var domain=example.com ``` ## Commands @@ -182,68 +182,68 @@ semrush batch estimate --file competitor-audit.toml --var domain=example.com ### Domain Analytics ```bash -semrush domain overview --domain # Traffic, rank, keywords count -semrush domain organic --domain # Organic keyword positions -semrush domain paid --domain # Paid search keywords -semrush domain competitors organic --domain # Organic competitors -semrush domain ads-copies --domain # Ad copy texts -semrush domain ad-history --domain # Historical ad data -semrush domain pla-keywords --domain # Product listing ad keywords -semrush domain pla-copies --domain # PLA ad copies -semrush domain pla-competitors --domain # PLA competitors -semrush domain pages --domain # Top pages by traffic -semrush domain subdomains --domain # Subdomain breakdown -semrush domain compare --domains # Compare domains +semrush domain overview # Traffic, rank, keywords count +semrush domain organic # Organic keyword positions +semrush domain paid # Paid search keywords +semrush domain competitors organic # Organic competitors +semrush domain ads-copies # Ad copy texts +semrush domain ad-history # Historical ad data +semrush domain pla-keywords # Product listing ad keywords +semrush domain pla-copies # PLA ad copies +semrush domain pla-competitors # PLA competitors +semrush domain pages # Top pages by traffic +semrush domain subdomains # Subdomain breakdown +semrush domain compare # Compare domains ``` ### Keyword Research ```bash -semrush keyword overview --phrase # Volume, CPC, difficulty -semrush keyword batch --phrases # Bulk keyword lookup -semrush keyword organic --phrase # Domains ranking for keyword -semrush keyword paid --phrase # Paid results for keyword -semrush keyword related --phrase # Related keywords -semrush keyword broad-match --phrase # Broad match keywords -semrush keyword questions --phrase # Question-based keywords -semrush keyword difficulty --phrase # Difficulty score (0-100) -semrush keyword ad-history --phrase # Historical ad data +semrush keyword overview # Volume, CPC, difficulty +semrush keyword batch # Bulk keyword lookup +semrush keyword organic # Domains ranking for keyword +semrush keyword paid # Paid results for keyword +semrush keyword related # Related keywords +semrush keyword broad-match # Broad match keywords +semrush keyword questions # Question-based keywords +semrush keyword difficulty # Difficulty score (0-100) +semrush keyword ad-history # Historical ad data ``` ### Backlink Analytics ```bash -semrush backlink overview --target # Total backlinks, authority -semrush backlink list --target # Individual backlinks -semrush backlink referring-domains --target # Referring domains -semrush backlink referring-ips --target # Referring IPs -semrush backlink anchors --target # Anchor text distribution -semrush backlink tld-distribution --target # TLD breakdown -semrush backlink geo --target # Geographic distribution -semrush backlink indexed-pages --target # Indexed pages -semrush backlink competitors --target # Backlink competitors -semrush backlink compare --targets # Compare targets -semrush backlink batch --targets # Bulk overview -semrush backlink new --target # New backlinks -semrush backlink lost --target # Lost backlinks -semrush backlink categories --target # Category distribution -semrush backlink history --target # Historical data +semrush backlink overview # Total backlinks, authority +semrush backlink list # Individual backlinks +semrush backlink referring-domains # Referring domains +semrush backlink referring-ips # Referring IPs +semrush backlink anchors # Anchor text distribution +semrush backlink tld-distribution # TLD breakdown +semrush backlink geo # Geographic distribution +semrush backlink indexed-pages # Indexed pages +semrush backlink competitors # Backlink competitors +semrush backlink compare # Compare targets +semrush backlink batch # Bulk overview +semrush backlink authority-score # Authority score +semrush backlink categories # Category distribution +semrush backlink category-profile # Category profile +semrush backlink history # Historical data ``` ### Traffic Trends ```bash -semrush trends summary --targets # Visits, bounce rate, pages/visit -semrush trends daily --targets # Daily traffic data -semrush trends weekly --targets # Weekly traffic data -semrush trends sources --target # Traffic source breakdown -semrush trends destinations --target # Outgoing traffic destinations -semrush trends geo --target # Geographic distribution -semrush trends subdomains --target # Subdomain traffic -semrush trends top-pages --target # Top pages by traffic -semrush trends rank --target # Traffic rank -semrush trends categories --target # Category breakdown -semrush trends conversion --target # Conversion data +semrush trends summary [DOMAIN2] # Visits, bounce rate, pages/visit +semrush trends daily # Daily traffic data +semrush trends weekly # Weekly traffic data +semrush trends sources # Traffic source breakdown +semrush trends destinations # Outgoing traffic destinations +semrush trends geo # Geographic distribution +semrush trends subdomains # Subdomain traffic +semrush trends top-pages # Top pages by traffic +semrush trends rank # Traffic rank +semrush trends categories # Category breakdown +semrush trends conversion # Conversion data ``` ### Project Management (v4 API) @@ -252,21 +252,21 @@ Requires OAuth2 token (`SEMRUSH_OAUTH_TOKEN` env var): ```bash semrush project list -semrush project get --id +semrush project get semrush project create --name "My Project" --domain example.com -semrush project update --id --name "New Name" -semrush project delete --id +semrush project update --name "New Name" +semrush project delete ``` ### Local SEO (v4 API) ```bash semrush local listing list -semrush local listing get --id +semrush local listing get semrush local listing create --json '{"name": "Business"}' semrush local map-rank campaigns -semrush local map-rank keywords --campaign-id -semrush local map-rank heatmap --campaign-id --keyword-id +semrush local map-rank keywords +semrush local map-rank heatmap ``` ### Utility diff --git a/src/batch/recipe.rs b/src/batch/recipe.rs index b0e979b..fef8b3c 100644 --- a/src/batch/recipe.rs +++ b/src/batch/recipe.rs @@ -128,7 +128,8 @@ async fn execute_step( .unwrap_or(default) }; - let report_type_key = command_to_report_type_key(&step.command); + let normalized_command = normalize_command(&step.command); + let report_type_key = command_to_report_type_key(&normalized_command); let cache_key = format!("batch|{}|{:?}", report_type_key, args); // Check cache @@ -144,7 +145,7 @@ async fn execute_step( let limit = get_u32("limit", 100); let offset = get_u32("offset", 0); - let data: Vec = match step.command.as_str() { + let data: Vec = match normalized_command.as_str() { "domain overview" => { let domain = get_str("domain").ok_or(AppError::InvalidParams { message: "Missing 'domain' arg in step".to_string(), @@ -241,9 +242,9 @@ async fn execute_step( crate::api::v3_trends::summary(client, &targets, country.as_deref(), None, None, limit) .await? } - other => { + _ => { return Err(AppError::InvalidParams { - message: format!("Unknown batch command: '{other}'. Supported: domain overview, domain organic, domain paid, domain competitors organic/paid, keyword overview, keyword related, backlink overview, backlink list, trends summary"), + message: format!("Unknown batch command: '{}'. Supported: domain overview, domain organic, domain paid, domain competitors organic/paid, keyword overview, keyword related, backlink overview, backlink list, trends summary", step.command), }); } }; @@ -262,7 +263,7 @@ async fn execute_step( /// Map batch command strings to report type keys for cost estimation. fn command_to_report_type_key(command: &str) -> String { - match command { + match normalize_command(command).as_str() { "domain overview" => "domain_overview", "domain organic" => "domain_organic", "domain paid" => "domain_paid", @@ -277,3 +278,33 @@ fn command_to_report_type_key(command: &str) -> String { } .to_string() } + +fn normalize_command(command: &str) -> String { + command + .trim() + .replace('_', " ") + .split_whitespace() + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn command_to_report_type_key_accepts_readme_style_names() { + assert_eq!( + command_to_report_type_key("domain_overview"), + "domain_overview" + ); + assert_eq!( + command_to_report_type_key("domain organic"), + "domain_organic" + ); + assert_eq!( + command_to_report_type_key("domain_competitors_organic"), + "domain_competitors_organic" + ); + } +} diff --git a/src/config/settings.rs b/src/config/settings.rs index 6890876..739550c 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -82,7 +82,7 @@ fn default_database() -> String { "us".to_string() } fn default_output() -> String { - "json".to_string() + "auto".to_string() } fn default_limit() -> u32 { 100 diff --git a/src/error.rs b/src/error.rs index 363b071..b33babd 100644 --- a/src/error.rs +++ b/src/error.rs @@ -33,10 +33,14 @@ pub enum AppError { impl AppError { pub fn exit_code(&self) -> i32 { match self { - AppError::InvalidParams { .. } => 2, - AppError::AuthFailed { .. } => 3, - AppError::InsufficientUnits { .. } => 4, - _ => 1, + AppError::AuthFailed { .. } => 1, + AppError::RateLimited { .. } => 2, + AppError::InvalidParams { .. } => 3, + AppError::InsufficientUnits { .. } + | AppError::ApiError { .. } + | AppError::ParseError { .. } + | AppError::CacheError { .. } + | AppError::NetworkError { .. } => 4, } } @@ -103,3 +107,41 @@ impl From for AppError { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exit_codes_match_documented_contract() { + assert_eq!( + AppError::AuthFailed { + message: String::new() + } + .exit_code(), + 1 + ); + assert_eq!( + AppError::RateLimited { + retry_after_ms: 1000, + api_status_code: 429 + } + .exit_code(), + 2 + ); + assert_eq!( + AppError::InvalidParams { + message: String::new() + } + .exit_code(), + 3 + ); + assert_eq!( + AppError::NetworkError { + message: String::new() + } + .exit_code(), + 4 + ); + } +} diff --git a/src/main.rs b/src/main.rs index 7eea0fc..75473d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,6 +69,11 @@ async fn main() { return; } Commands::Batch { command } => { + if matches!(command, cli::batch::BatchCommand::Estimate { .. }) { + handle_batch_estimate(command); + return; + } + let api_key = match config.resolve_api_key(cli.api_key.as_deref()) { Some(key) => key, None => { @@ -88,21 +93,10 @@ async fn main() { _ => {} } - // All remaining commands need an API key - let api_key = match config.resolve_api_key(cli.api_key.as_deref()) { - Some(key) => key, - None => { - AppError::AuthFailed { - message: "No API key provided. Set SEMRUSH_API_KEY, use --api-key, or run `semrush account auth setup`.".to_string(), - } - .print_and_exit(); - } - }; - // Resolve the report type for this command let report_type_key = resolve_report_type_key(&cli); - // Handle --dry-run: estimate cost and exit + // Handle --dry-run before auth: cost estimation does not call the API. if cli.dry_run { let report_type = api::cost::report_type_for_command(&report_type_key); let estimate = api::cost::estimate(report_type, cli.limit); @@ -110,6 +104,17 @@ async fn main() { return; } + // All remaining executable API commands need an API key + let api_key = match config.resolve_api_key(cli.api_key.as_deref()) { + Some(key) => key, + None => { + AppError::AuthFailed { + message: "No API key provided. Set SEMRUSH_API_KEY, use --api-key, or run `semrush account auth setup`.".to_string(), + } + .print_and_exit(); + } + }; + let client = api::client::SemrushClient::new(api_key, config.rate_limit.requests_per_second); // Set up cache @@ -993,31 +998,39 @@ async fn handle_batch( Err(e) => e.print_and_exit(), } } - BatchCommand::Estimate { recipe, vars } => { - let var_map = cli::batch::parse_vars(vars); - let mut rec = match batch::Recipe::load(recipe) { - Ok(r) => r, - Err(e) => e.print_and_exit(), - }; - rec.substitute_vars(&var_map); - let estimates = rec.estimate(); - let mut total = 0u64; - for (i, est) in estimates.iter().enumerate() { - println!( - "Step {} ({}): {} units ({})", - i + 1, - est.command, - est.estimated_units, - est.description - ); - total += est.estimated_units; - } - println!("{}", "─".repeat(50)); - println!("Total estimated cost: {total} units"); + BatchCommand::Estimate { .. } => { + handle_batch_estimate(command); } } } +fn handle_batch_estimate(command: &cli::batch::BatchCommand) { + let cli::batch::BatchCommand::Estimate { recipe, vars } = command else { + return; + }; + + let var_map = cli::batch::parse_vars(vars); + let mut rec = match batch::Recipe::load(recipe) { + Ok(r) => r, + Err(e) => e.print_and_exit(), + }; + rec.substitute_vars(&var_map); + let estimates = rec.estimate(); + let mut total = 0u64; + for (i, est) in estimates.iter().enumerate() { + println!( + "Step {} ({}): {} units ({})", + i + 1, + est.command, + est.estimated_units, + est.description + ); + total += est.estimated_units; + } + println!("{}", "-".repeat(50)); + println!("Total estimated cost: {total} units"); +} + async fn handle_account(command: &cli::account::AccountCommand, _cli: &Cli, config: &Config) { use cli::account::{AccountCommand, AuthCommand}; diff --git a/src/output/mod.rs b/src/output/mod.rs index ee004ad..a57453a 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -20,8 +20,7 @@ impl OutputFormat { Some("table") => OutputFormat::Table, Some("csv") => OutputFormat::Csv, Some("jsonl") => OutputFormat::Jsonl, - Some(_) => OutputFormat::Json, - None => { + Some("auto") | None => { // Auto-detect: table for TTY, JSON for pipes if std::io::stdout().is_terminal() { OutputFormat::Table @@ -29,6 +28,7 @@ impl OutputFormat { OutputFormat::Json } } + Some(_) => OutputFormat::Json, } } } diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..76bfbd9 --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,57 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use assert_cmd::Command; +use predicates::str::contains; + +fn semrush_cmd() -> Command { + let mut cmd = cargo_bin_cmd!("semrush"); + cmd.env_remove("SEMRUSH_API_KEY") + .env_remove("SEMRUSH_CONFIG") + .env_remove("SEMRUSH_OUTPUT") + .env_remove("SEMRUSH_DATABASE"); + cmd +} + +#[test] +fn dry_run_estimates_without_api_key() { + semrush_cmd() + .args(["domain", "overview", "example.com", "--dry-run"]) + .assert() + .success() + .stdout(contains("Estimated cost")); +} + +#[test] +fn api_commands_fail_with_documented_auth_exit_code_without_api_key() { + semrush_cmd() + .args(["domain", "overview", "example.com"]) + .assert() + .code(1) + .stderr(contains("AUTH_FAILED")); +} + +#[test] +fn batch_estimate_accepts_readme_style_command_names_without_api_key() { + let dir = tempfile::tempdir().expect("tempdir"); + let recipe_path = dir.path().join("recipe.toml"); + std::fs::write( + &recipe_path, + r#" +[meta] +name = "Smoke" + +[[steps]] +command = "domain_overview" +output_key = "overview" +[steps.args] +domain = "example.com" +"#, + ) + .expect("write recipe"); + + semrush_cmd() + .args(["batch", "estimate", recipe_path.to_str().unwrap()]) + .assert() + .success() + .stdout(contains("Step 1 (domain_overview)")) + .stdout(contains("Total estimated cost")); +}