Initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/target
|
||||
template.json
|
1787
Cargo.lock
generated
Normal file
1787
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
Normal file
13
Cargo.toml
Normal file
@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "sb-ruleset-sync"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
futures = "0.3"
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
tokio = { version = "1.45", features = ["full"] }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream", "blocking"] }
|
37
src/cli.rs
Normal file
37
src/cli.rs
Normal file
@ -0,0 +1,37 @@
|
||||
use anyhow::{Result, bail};
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
pub struct Args {
|
||||
/// Path to the input JSON configuration file.
|
||||
#[arg(short, long)]
|
||||
pub input: PathBuf,
|
||||
|
||||
/// Path to the output directory for downloaded rule files.
|
||||
#[arg(short, long)]
|
||||
pub output: PathBuf,
|
||||
|
||||
/// Number of concurrent download operations.
|
||||
#[arg(long, default_value_t = 10)]
|
||||
pub concurrency: usize,
|
||||
|
||||
/// The new domain to use for rewriting rule URLs.
|
||||
#[arg(long)]
|
||||
pub domain: Option<String>,
|
||||
|
||||
/// The path on the domain for the rewritten rule URLs.
|
||||
#[arg(long, name = "rule-path")]
|
||||
pub rule_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
/// Validates that domain and rule_path are either both present or both absent.
|
||||
pub fn validate_domain_and_rule_path(&self) -> Result<()> {
|
||||
if self.domain.is_some() != self.rule_path.is_some() {
|
||||
bail!("--domain and --rule-path must be used together.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
75
src/config.rs
Normal file
75
src/config.rs
Normal file
@ -0,0 +1,75 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Represents a single rule, capturing the URL, tag, and any other fields.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Rule {
|
||||
pub url: String,
|
||||
pub tag: String,
|
||||
// Capture any other fields in the rule object
|
||||
#[serde(flatten)]
|
||||
pub other_fields: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
/// Represents the "route" object in the config.
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
struct Route {
|
||||
#[serde(default, rename = "rule_set")]
|
||||
rule_set: Vec<Rule>,
|
||||
}
|
||||
|
||||
/// Represents the top-level structure of the configuration file.
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct Config {
|
||||
route: Route,
|
||||
// Capture any other top-level fields
|
||||
#[serde(flatten)]
|
||||
other_fields: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Loads and parses the config file from a given path.
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read config file from '{}'", path.display()))?;
|
||||
let config: Config = serde_json::from_str(&content)
|
||||
.context("Failed to parse JSON. Please check the config file structure.")?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Saves the current config state back to a file, prettified.
|
||||
pub fn save(&self, path: &Path) -> Result<()> {
|
||||
let new_content = serde_json::to_string_pretty(self)
|
||||
.context("Failed to serialize modified config to JSON.")?;
|
||||
fs::write(path, new_content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Extracts all URLs from the rule_set.
|
||||
pub fn extract_urls(&self) -> Vec<String> {
|
||||
self.route
|
||||
.rule_set
|
||||
.iter()
|
||||
.map(|rule| rule.url.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Rewrites the URLs in the config based on the provided domain and path.
|
||||
pub fn rewrite_urls(&mut self, domain: &str, rule_path: &str) -> Result<()> {
|
||||
let clean_domain = domain.trim_matches('/');
|
||||
let clean_rule_path = format!("/{}/", rule_path.trim_matches('/'));
|
||||
|
||||
for rule in &mut self.route.rule_set {
|
||||
if let Some(filename) = rule.url.split('/').last().filter(|s| !s.is_empty()) {
|
||||
let new_url = format!("https://{}{}{}", clean_domain, clean_rule_path, filename);
|
||||
println!(" '{}' -> {}", rule.tag, new_url);
|
||||
rule.url = new_url;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
82
src/downloader.rs
Normal file
82
src/downloader.rs
Normal file
@ -0,0 +1,82 @@
|
||||
use anyhow::{Context, Result};
|
||||
use futures::{StreamExt, stream};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A report summarizing the outcome of the download process.
|
||||
#[derive(Debug)]
|
||||
pub struct DownloadReport {
|
||||
pub successful: usize,
|
||||
pub failed: usize,
|
||||
}
|
||||
|
||||
/// Downloads a list of URLs concurrently, with a specified limit.
|
||||
pub async fn download_all_rules(
|
||||
urls: &[String],
|
||||
output_dir: &Path,
|
||||
concurrency: usize,
|
||||
) -> Result<DownloadReport> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut successful = 0;
|
||||
let mut failed = 0;
|
||||
|
||||
let fetches = stream::iter(urls)
|
||||
.map(|url| {
|
||||
let client = client.clone();
|
||||
let output_path = output_dir.to_path_buf();
|
||||
async move {
|
||||
match download_rule(&client, url, &output_path).await {
|
||||
Ok(path) => {
|
||||
println!("[OK] Saved {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[FAIL] Download error for {}: {:?}", url, e);
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.buffer_unordered(concurrency)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
for result in fetches {
|
||||
if result.is_ok() {
|
||||
successful += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(DownloadReport { successful, failed })
|
||||
}
|
||||
|
||||
/// Downloads a single file from a URL to a destination directory.
|
||||
/// Uses a temporary file to ensure atomic writes.
|
||||
async fn download_rule(client: &reqwest::Client, url: &str, output_dir: &Path) -> Result<PathBuf> {
|
||||
let file_name = url
|
||||
.split('/')
|
||||
.last()
|
||||
.filter(|s| !s.is_empty())
|
||||
.with_context(|| format!("Could not determine filename for URL '{}'", url))?;
|
||||
|
||||
println!("[DOWNLOAD] from {}", url);
|
||||
|
||||
let final_path = output_dir.join(file_name);
|
||||
let tmp_path = output_dir.join(format!("{}.tmp", file_name));
|
||||
|
||||
// Perform the download
|
||||
let response = client.get(url).send().await?.error_for_status()?;
|
||||
let content = response.bytes().await?;
|
||||
|
||||
// Write to a temporary file first
|
||||
fs::write(&tmp_path, &content)
|
||||
.with_context(|| format!("Failed to write temporary file {}", tmp_path.display()))?;
|
||||
|
||||
// Atomically rename the temporary file to the final destination
|
||||
fs::rename(&tmp_path, &final_path)
|
||||
.with_context(|| format!("Failed to rename temp file to {}", final_path.display()))?;
|
||||
|
||||
Ok(final_path)
|
||||
}
|
82
src/lib.rs
Normal file
82
src/lib.rs
Normal file
@ -0,0 +1,82 @@
|
||||
use anyhow::{Context, Result, bail};
|
||||
use std::fs;
|
||||
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod downloader;
|
||||
|
||||
use cli::Args;
|
||||
use config::Config;
|
||||
|
||||
/// The main application logic.
|
||||
pub async fn run(args: Args) -> Result<()> {
|
||||
println!("▶️ Starting ruleset processor...");
|
||||
println!(" Config file: {}", args.input.display());
|
||||
println!(" Output directory: {}", args.output.display());
|
||||
println!(" Concurrency level: {}", args.concurrency);
|
||||
|
||||
// Validate that --domain and --rule-path are used together.
|
||||
args.validate_domain_and_rule_path()?;
|
||||
|
||||
// Load and parse the configuration file into strongly-typed structs.
|
||||
let mut config = Config::load(&args.input)
|
||||
.with_context(|| format!("Failed to load config from {}", args.input.display()))?;
|
||||
|
||||
let urls_to_download = config.extract_urls();
|
||||
if urls_to_download.is_empty() {
|
||||
println!("✔️ No rule sets with URLs found in the configuration. Nothing to do.");
|
||||
return Ok(());
|
||||
}
|
||||
println!(
|
||||
"✔️ Found {} rule sets to download.",
|
||||
urls_to_download.len()
|
||||
);
|
||||
|
||||
// Ensure the output directory exists.
|
||||
fs::create_dir_all(&args.output).with_context(|| {
|
||||
format!(
|
||||
"Failed to create output directory '{}'",
|
||||
args.output.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
// Download all files concurrently.
|
||||
let download_report =
|
||||
downloader::download_all_rules(&urls_to_download, &args.output, args.concurrency).await?;
|
||||
|
||||
println!("\n✅ Download process finished.");
|
||||
println!(
|
||||
" {} successful, {} failed.",
|
||||
download_report.successful, download_report.failed
|
||||
);
|
||||
|
||||
// If any downloads failed, abort before modifying the config file.
|
||||
if download_report.failed > 0 {
|
||||
bail!(
|
||||
"Aborting due to {} download failures. The configuration file was NOT modified.",
|
||||
download_report.failed
|
||||
);
|
||||
}
|
||||
|
||||
// Rewrite and save the config file only if all downloads were successful
|
||||
// and the domain/rule_path arguments were provided.
|
||||
if let (Some(domain), Some(rule_path)) = (args.domain, args.rule_path) {
|
||||
println!("\n▶️ All downloads successful. Rewriting configuration file...");
|
||||
|
||||
config.rewrite_urls(&domain, &rule_path)?;
|
||||
config.save(&args.input).with_context(|| {
|
||||
format!("Failed to write updated config to {}", args.input.display())
|
||||
})?;
|
||||
|
||||
println!(
|
||||
"✔️ Configuration file {} updated successfully.",
|
||||
args.input.display()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"\n✔️ Downloads complete. No domain/rule-path specified; config file not modified."
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
17
src/main.rs
Normal file
17
src/main.rs
Normal file
@ -0,0 +1,17 @@
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use sb_ruleset_sync::cli::Args;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// 1. Parse command-line arguments.
|
||||
let args = Args::parse();
|
||||
|
||||
// 2. Execute the main application logic from the library.
|
||||
if let Err(e) = sb_ruleset_sync::run(args).await {
|
||||
eprintln!("\nError: {e:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
Reference in New Issue
Block a user