From 7881bbb735ba7093357e5d02829ffb6c630fd321 Mon Sep 17 00:00:00 2001 From: messica Date: Fri, 1 May 2026 16:24:02 +0800 Subject: [PATCH 1/8] =?UTF-8?q?refactor:=20phase=201=20cleanup=20=E2=80=94?= =?UTF-8?q?=20Protocol=20FromStr/Display,=20IndexMap=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement std FromStr and Display for Protocol; remove custom from_str and the thin SourceProtocol/OutputProtocol aliases - Drop cli::OutputFormat: a no-arg enum that was only used to double-dispatch what Protocol::default_output_format already encodes - Registry uses IndexMap so iteration order matches registration order (deterministic logging and format-detection fallback) - Introduce FORMAT_SUBSCRIPTION/FORMAT_PLAIN constants; route detect.rs and loader.rs through them and Protocol::as_format_str to kill scattered "clash"/"singbox"/"v2ray" literals - TemplateEngine auto-numbers unnamed sources (source_N) instead of collapsing them to a shared "default" key --- src/commands/cli.rs | 8 --- src/commands/convert.rs | 59 ++++++-------------- src/core/source.rs | 77 ++++++++++++++++++++------- src/protocols/detect.rs | 56 +++++++------------ src/protocols/mod.rs | 22 +++++--- src/utils/source/loader.rs | 23 ++++---- src/utils/template/template_engine.rs | 13 +++-- 7 files changed, 124 insertions(+), 134 deletions(-) diff --git a/src/commands/cli.rs b/src/commands/cli.rs index d809f56..067da69 100644 --- a/src/commands/cli.rs +++ b/src/commands/cli.rs @@ -83,14 +83,6 @@ pub enum Commands { Version, } -#[derive(ValueEnum, Clone, Debug)] -pub enum OutputFormat { - /// JSON format - Json, - /// YAML format - Yaml, -} - #[derive(ValueEnum, Clone, Copy, Debug)] pub enum LogLevel { /// Error information diff --git a/src/commands/convert.rs b/src/commands/convert.rs index 6524ff0..9db31c3 100644 --- a/src/commands/convert.rs +++ b/src/commands/convert.rs @@ -6,9 +6,7 @@ use crate::core::source::{Protocol, SourceMeta}; use crate::protocols; use crate::protocols::ProtocolRegistry; use crate::utils::{source, template::template_engine}; - -/// Backward-compatible alias so `use crate::commands::convert::OutputProtocol` still works. -pub type OutputProtocol = Protocol; +use std::str::FromStr; /// Convert command handler pub struct ConvertCommand; @@ -54,12 +52,8 @@ impl ConvertCommand { Self::generate_default_config(&template_engine, output_protocol, registry)? }; - // Get output format and filename based on protocol - let output_format = match output_protocol.default_output_format() { - "yaml" => cli::OutputFormat::Yaml, - _ => cli::OutputFormat::Json, - }; - let formatted_result = Self::format_output(&result, &output_format)?; + // Serialize to the protocol's native format (json or yaml). + let formatted_result = Self::format_output(&result, output_protocol)?; // output result let output_path = Self::resolve_output_path(output, output_protocol)?; // Ensure parent directory exists @@ -137,12 +131,7 @@ impl ConvertCommand { "Query must include type param. Example: url?type=clash&name=my&flag=clash".to_string(), ) })?; - let source_type = Protocol::from_str(&source_type_str).ok_or_else(|| { - ConvertError::ConfigValidationError(format!( - "Unsupported type: {}, supported: clash, sing-box(singbox), v2ray", - source_type_str - )) - })?; + let source_type = Protocol::from_str(&source_type_str)?; // Keep full string (path|url + all query params); type/name/flag are parsed out but remain in source Ok(SourceMeta { name: name.filter(|s| !s.is_empty()), @@ -174,23 +163,13 @@ impl ConvertCommand { template_engine.process_template(&template_str, registry) } - /// Format output based on the specified format - fn format_output(content: &str, format: &cli::OutputFormat) -> Result { - match format { - cli::OutputFormat::Json => { - // Parse and re-serialize as pretty JSON (formatted) - let parsed: serde_json::Value = - serde_json::from_str(content).map_err(|e| ConvertError::JsonParseError(e))?; - Ok(serde_json::to_string_pretty(&parsed) - .map_err(|e| ConvertError::JsonParseError(e))?) - } - cli::OutputFormat::Yaml => { - // Parse JSON and convert to YAML - let parsed: serde_json::Value = - serde_json::from_str(content).map_err(|e| ConvertError::JsonParseError(e))?; - Ok(serde_yaml::to_string(&parsed) - .map_err(|e| ConvertError::ConfigValidationError(e.to_string()))?) - } + /// Serialize the intermediate JSON string to the protocol's native format. + fn format_output(content: &str, protocol: &Protocol) -> Result { + let parsed: serde_json::Value = serde_json::from_str(content)?; + match protocol.default_output_format() { + "yaml" => serde_yaml::to_string(&parsed) + .map_err(|e| ConvertError::ConfigValidationError(e.to_string())), + _ => Ok(serde_json::to_string_pretty(&parsed)?), } } } @@ -234,12 +213,7 @@ pub async fn handle_convert( .map(|s| s.as_str()) .unwrap_or_else(|| config.output_protocol.as_str()); - let output_protocol = Protocol::from_str(output_protocol_str).ok_or_else(|| { - ConvertError::ConfigValidationError(format!( - "Unsupported output protocol: {}, supported protocols: sing-box(singbox), clash, v2ray", - output_protocol_str - )) - })?; + let output_protocol = Protocol::from_str(output_protocol_str)?; // Merge output: CLI > config > default let final_output: Option = output @@ -257,16 +231,13 @@ pub async fn handle_convert( tracing::info!("Starting conversion"); for (i, m) in final_sources.iter().enumerate() { - let type_str = m.source_type.as_format_str(); - let name_str = m.name.as_deref().unwrap_or("(none)"); - let flag_str = m.flag.as_deref().unwrap_or("(default)"); tracing::info!( "Input source [{}]: {} type={} name={} flag={}", i + 1, m.source, - type_str, - name_str, - flag_str + m.source_type, + m.name.as_deref().unwrap_or("(none)"), + m.flag.as_deref().unwrap_or("(default)"), ); } tracing::info!( diff --git a/src/core/source.rs b/src/core/source.rs index 5a4108f..93bc73c 100644 --- a/src/core/source.rs +++ b/src/core/source.rs @@ -3,13 +3,18 @@ //! Used by commands (to build from CLI) and by utils/protocols (to load and parse). //! Kept in core to avoid utils depending on commands. +use std::fmt; +use std::str::FromStr; + +use crate::core::error::ConvertError; + /// Source metadata: path/url + query params (type, name, flag). #[derive(Debug, Clone)] pub struct SourceMeta { /// Optional display name for multi-source distinction pub name: Option, /// Protocol type of this source (clash, sing-box, v2ray) - pub source_type: SourceProtocol, + pub source_type: Protocol, /// Full source string: ?type=...&name=...&flag=... pub source: String, /// Explicit format override; if None, derived from source_type @@ -19,28 +24,16 @@ pub struct SourceMeta { } /// Supported protocol types (unified: source + output). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Protocol { Clash, SingBox, V2Ray, } -/// Backward-compatible alias. -pub type SourceProtocol = Protocol; - impl Protocol { - pub fn from_str(s: &str) -> Option { - match s.to_lowercase().as_str() { - "clash" => Some(Protocol::Clash), - "sing-box" | "singbox" => Some(Protocol::SingBox), - "v2ray" => Some(Protocol::V2Ray), - _ => None, - } - } - - /// Format name for registry/parsing (e.g. "clash", "singbox", "v2ray"). - pub fn as_format_str(&self) -> &'static str { + /// Canonical format key used across the registry and detection. + pub const fn as_format_str(self) -> &'static str { match self { Protocol::Clash => "clash", Protocol::SingBox => "singbox", @@ -48,8 +41,8 @@ impl Protocol { } } - /// Get the default output format string for this protocol. - pub fn default_output_format(&self) -> &'static str { + /// Default output format (`json` or `yaml`). + pub const fn default_output_format(self) -> &'static str { match self { Protocol::SingBox => "json", Protocol::Clash => "yaml", @@ -57,8 +50,8 @@ impl Protocol { } } - /// Get the default filename for this protocol. - pub fn default_filename(&self) -> &'static str { + /// Default filename. + pub const fn default_filename(self) -> &'static str { match self { Protocol::SingBox => "config.json", Protocol::Clash => "config.yaml", @@ -66,3 +59,47 @@ impl Protocol { } } } + +impl fmt::Display for Protocol { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_format_str()) + } +} + +impl FromStr for Protocol { + type Err = ConvertError; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "clash" => Ok(Protocol::Clash), + "sing-box" | "singbox" => Ok(Protocol::SingBox), + "v2ray" => Ok(Protocol::V2Ray), + other => Err(ConvertError::ConfigValidationError(format!( + "Unsupported protocol: {}, supported: clash, sing-box(singbox), v2ray", + other + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_roundtrip() { + assert_eq!("clash".parse::().unwrap(), Protocol::Clash); + assert_eq!("singbox".parse::().unwrap(), Protocol::SingBox); + assert_eq!("sing-box".parse::().unwrap(), Protocol::SingBox); + assert_eq!("SING-BOX".parse::().unwrap(), Protocol::SingBox); + assert_eq!("v2ray".parse::().unwrap(), Protocol::V2Ray); + assert!("quic".parse::().is_err()); + } + + #[test] + fn display_is_canonical_key() { + assert_eq!(Protocol::Clash.to_string(), "clash"); + assert_eq!(Protocol::SingBox.to_string(), "singbox"); + assert_eq!(Protocol::V2Ray.to_string(), "v2ray"); + } +} diff --git a/src/protocols/detect.rs b/src/protocols/detect.rs index f61c597..e31ee6f 100644 --- a/src/protocols/detect.rs +++ b/src/protocols/detect.rs @@ -4,64 +4,48 @@ //! without parsing full content. Used by ProtocolRegistry and TemplateEngine. use crate::core::error::Result; +use crate::core::source::Protocol; +use crate::protocols::{FORMAT_PLAIN, FORMAT_SUBSCRIPTION}; use serde_json::Value as JsonValue; /// Detect content format. Returns (format_key, description) or None if unknown. pub fn detect_format(content: &str) -> Result> { let content = content.trim(); - if let Ok(json_value) = serde_json::from_str::(content) { - if is_clash_format(&json_value) { - return Ok(Some(( - "clash".to_string(), - "Clash configuration".to_string(), - ))); - } - if is_singbox_format(&json_value) { - return Ok(Some(( - "singbox".to_string(), - "Sing-box configuration".to_string(), - ))); + let structured_hit = |value: &JsonValue, suffix: &str| -> Option<(String, String)> { + if is_clash_format(value) { + Some((Protocol::Clash.as_format_str().to_string(), format!("Clash configuration{suffix}"))) + } else if is_singbox_format(value) { + Some((Protocol::SingBox.as_format_str().to_string(), format!("Sing-box configuration{suffix}"))) + } else if is_v2ray_format(value) { + Some((Protocol::V2Ray.as_format_str().to_string(), format!("V2Ray configuration{suffix}"))) + } else { + None } - if is_v2ray_format(&json_value) { - return Ok(Some(( - "v2ray".to_string(), - "V2Ray configuration".to_string(), - ))); + }; + + if let Ok(json_value) = serde_json::from_str::(content) { + if let Some(hit) = structured_hit(&json_value, "") { + return Ok(Some(hit)); } } if let Ok(yaml_value) = serde_yaml::from_str::(content) { - if is_clash_format(&yaml_value) { - return Ok(Some(( - "clash".to_string(), - "Clash configuration (YAML)".to_string(), - ))); - } - if is_singbox_format(&yaml_value) { - return Ok(Some(( - "singbox".to_string(), - "Sing-box configuration (YAML)".to_string(), - ))); - } - if is_v2ray_format(&yaml_value) { - return Ok(Some(( - "v2ray".to_string(), - "V2Ray configuration (YAML)".to_string(), - ))); + if let Some(hit) = structured_hit(&yaml_value, " (YAML)") { + return Ok(Some(hit)); } } if is_subscription_format(content) { return Ok(Some(( - "subscription".to_string(), + FORMAT_SUBSCRIPTION.to_string(), "Subscription format (base64)".to_string(), ))); } if is_plain_text_format(content) { return Ok(Some(( - "plain".to_string(), + FORMAT_PLAIN.to_string(), "Plain text format".to_string(), ))); } diff --git a/src/protocols/mod.rs b/src/protocols/mod.rs index b05d458..6d40766 100644 --- a/src/protocols/mod.rs +++ b/src/protocols/mod.rs @@ -20,6 +20,10 @@ use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +/// Canonical format keys used by detect/loader for non-protocol content. +pub const FORMAT_SUBSCRIPTION: &str = "subscription"; +pub const FORMAT_PLAIN: &str = "plain"; + /// Typed proxy protocol parameters #[derive(Debug, Clone, PartialEq)] pub enum ProxyParams { @@ -138,17 +142,20 @@ pub trait ProtocolProcessor: Send + Sync { } /// Protocol converter registry: format detection, parsing, and processor lookup. +/// +/// Uses `IndexMap` so iteration order matches registration order — makes +/// logging and format-detection fallbacks deterministic. pub struct ProtocolRegistry { - processors: HashMap>, - formats: HashMap>, + processors: IndexMap>, + formats: IndexMap>, } impl ProtocolRegistry { /// Create new empty registry (for tests or custom setup). pub fn new() -> Self { Self { - processors: HashMap::new(), - formats: HashMap::new(), + processors: IndexMap::new(), + formats: IndexMap::new(), } } @@ -190,11 +197,12 @@ impl ProtocolRegistry { /// Initialize protocol registry with built-in processors and format descriptors. pub fn init() -> Self { + use crate::core::source::Protocol; let mut registry = Self::new(); // Processors (used by TemplateEngine) - registry.register("clash", Box::new(clash::template_processor::ClashProcessor)); - registry.register("singbox", Box::new(singbox::template_processor::SingboxProcessor)); - registry.register("v2ray", Box::new(v2ray::template_processor::V2RayProcessor)); + registry.register(Protocol::Clash.as_format_str(), Box::new(clash::template_processor::ClashProcessor)); + registry.register(Protocol::SingBox.as_format_str(), Box::new(singbox::template_processor::SingboxProcessor)); + registry.register(Protocol::V2Ray.as_format_str(), Box::new(v2ray::template_processor::V2RayProcessor)); // Format descriptors (validate, parse, default template, metadata) registry.register_format(Box::new(singbox::format::SingboxFormat)); registry.register_format(Box::new(clash::format::ClashFormat)); diff --git a/src/utils/source/loader.rs b/src/utils/source/loader.rs index 5b71d0e..92d648d 100644 --- a/src/utils/source/loader.rs +++ b/src/utils/source/loader.rs @@ -3,7 +3,7 @@ use crate::core::source::{Protocol, SourceMeta}; use crate::core::config::AppConfig; use crate::core::error::{ConvertError, Result}; -use crate::protocols::ProtocolRegistry; +use crate::protocols::{ProtocolRegistry, FORMAT_PLAIN, FORMAT_SUBSCRIPTION}; use crate::utils::source::parser::{Config, Source}; use std::path::Path; @@ -20,15 +20,10 @@ impl SourceLoader { let content = Self::load_content_from_source(source_meta, config).await?; // Determine the format - let detected_format = if let Some(fmt) = &source_meta.format { - fmt.clone() - } else { - match &source_meta.source_type { - Protocol::Clash => "clash".to_string(), - Protocol::SingBox => "singbox".to_string(), - Protocol::V2Ray => "v2ray".to_string(), - } - }; + let detected_format = source_meta + .format + .clone() + .unwrap_or_else(|| source_meta.source_type.as_format_str().to_string()); // Parse configuration based on detected format let parsed_config = Self::parse_config(&content, &detected_format, registry)?; @@ -90,12 +85,12 @@ impl SourceLoader { source_type: &Protocol, flag_override: Option<&str>, ) -> String { + // For the URL flag parameter, panels commonly expect "sing-box" (hyphenated). let flag_value = match flag_override { Some(s) => s.to_string(), None => match source_type { - Protocol::Clash => "clash".to_string(), Protocol::SingBox => "sing-box".to_string(), - Protocol::V2Ray => "v2ray".to_string(), + other => other.as_format_str().to_string(), }, }; @@ -224,11 +219,11 @@ impl SourceLoader { /// are dispatched through the registry's `ProtocolFormat` trait. fn parse_config(content: &str, format: &str, registry: &ProtocolRegistry) -> Result { match format.to_lowercase().as_str() { - "subscription" => { + FORMAT_SUBSCRIPTION => { let servers = registry.parse_subscription_to_servers(content)?; Ok(Config::Subscription(servers)) } - "plain" => { + FORMAT_PLAIN => { let servers = registry.parse_plain_text_to_servers(content)?; Ok(Config::Plain(servers)) } diff --git a/src/utils/template/template_engine.rs b/src/utils/template/template_engine.rs index 9e71761..118c77f 100644 --- a/src/utils/template/template_engine.rs +++ b/src/utils/template/template_engine.rs @@ -26,12 +26,15 @@ impl TemplateEngine { } } - /// Add source with complete source information + /// Add source with complete source information. + /// Unnamed sources get `source_N` (1-based) so multiple unnamed sources + /// don't silently overwrite each other under a shared "default" key. pub fn add_source(&mut self, source: Source) { - self.sources.insert( - source.meta.name.as_deref().unwrap_or("default").to_string(), - source, - ); + let key = match source.meta.name.as_deref() { + Some(name) if !name.is_empty() => name.to_string(), + _ => format!("source_{}", self.sources.len() + 1), + }; + self.sources.insert(key, source); } /// Process template interpolation using the given registry for format detection and processor lookup. From fc9694e2cd8466c1f7d43ded6e90334272c09694 Mon Sep 17 00:00:00 2001 From: messica Date: Fri, 1 May 2026 16:29:07 +0800 Subject: [PATCH 2/8] refactor: phase 2 command thinning + url-based flag handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commands: - Each subcommand becomes a dedicated Args struct (ConvertArgs, ValidateArgs, TemplateArgs) via clap's #[derive(Args)]. Handlers receive typed args directly instead of pattern-matching the whole Commands enum and returning "Expected X" errors that can't actually fire — main.rs has already dispatched. - AppConfig::merge_cli_params collapses into merge_convert_args (only the Convert subcommand has overridable fields). URL handling: - Drop the hand-rolled append_flag_to_url / get_flag_param_value / update_flag_param trio. url::Url does it correctly including fragment preservation and proper percent-encoding. - New unit tests cover add/replace/override/fragment cases. Sing-box DNS normalization: - Move the legacy-DNS fallback out of SourceLoader and into SingboxFormat::parse_config where it belongs. The loader no longer knows about singbox internals, removing a backwards-pointing cross-module call. --- src/commands/cli.rs | 113 +++++----- src/commands/convert.rs | 69 ++---- src/commands/template.rs | 52 ++--- src/commands/validate.rs | 39 ++-- src/core/config.rs | 82 ++----- src/main.rs | 63 +++--- src/protocols/singbox/format.rs | 80 ++++++- src/utils/source/loader.rs | 369 ++++++++++++-------------------- 8 files changed, 367 insertions(+), 500 deletions(-) diff --git a/src/commands/cli.rs b/src/commands/cli.rs index 067da69..c323ad4 100644 --- a/src/commands/cli.rs +++ b/src/commands/cli.rs @@ -1,4 +1,4 @@ -use clap::{Parser, Subcommand, ValueEnum}; +use clap::{Args, Parser, Subcommand, ValueEnum}; use std::path::PathBuf; #[derive(Parser, Debug)] @@ -23,66 +23,75 @@ pub struct Cli { #[derive(Subcommand, Debug)] pub enum Commands { /// Convert subscription configuration - Convert { - /// Input sources: ?type=clash&name=...&flag=... (type required in query) - #[arg(long = "source", value_name = "SOURCE")] - sources: Vec, - - /// Template file path - #[arg(short, long, value_name = "PATH")] - template: Option, - - /// Output file path - #[arg(short, long, value_name = "PATH")] - output: Option, - - /// Target output protocol (sing-box, clash, v2ray). - /// The output format is determined by the protocol: - /// - sing-box: JSON only - /// - clash: YAML only - /// - v2ray: JSON only - #[arg(long = "output-protocol", value_name = "PROTOCOL")] - output_protocol: Option, - - /// Log level - #[arg(short, long, value_enum, default_value_t = LogLevel::Info)] - log_level: LogLevel, - - /// Whether to show detailed information - #[arg(short, long)] - verbose: bool, - - /// HTTP request timeout in seconds - #[arg(long, value_name = "SECONDS")] - timeout: Option, - }, + Convert(ConvertArgs), /// Validate configuration file - Validate { - /// Configuration file path to validate - #[arg(value_name = "PATH")] - file: PathBuf, - - /// Target protocol (sing-box, clash, v2ray). Default: sing-box - #[arg(short, long, value_name = "PROTOCOL", default_value = "singbox")] - protocol: String, - }, + Validate(ValidateArgs), /// Generate default template - Template { - /// Output path - #[arg(short, long, value_name = "PATH")] - output: Option, - - /// Target protocol (sing-box, clash, v2ray). Default: sing-box - #[arg(short, long, value_name = "PROTOCOL", default_value = "singbox")] - protocol: String, - }, + Template(TemplateArgs), /// Display version information Version, } +#[derive(Args, Debug)] +pub struct ConvertArgs { + /// Input sources: ?type=clash&name=...&flag=... (type required in query) + #[arg(long = "source", value_name = "SOURCE")] + pub sources: Vec, + + /// Template file path + #[arg(short, long, value_name = "PATH")] + pub template: Option, + + /// Output file path + #[arg(short, long, value_name = "PATH")] + pub output: Option, + + /// Target output protocol (sing-box, clash, v2ray). + /// The output format is determined by the protocol: + /// - sing-box: JSON only + /// - clash: YAML only + /// - v2ray: JSON only + #[arg(long = "output-protocol", value_name = "PROTOCOL")] + pub output_protocol: Option, + + /// Log level + #[arg(short, long, value_enum, default_value_t = LogLevel::Info)] + pub log_level: LogLevel, + + /// Whether to show detailed information + #[arg(short, long)] + pub verbose: bool, + + /// HTTP request timeout in seconds + #[arg(long, value_name = "SECONDS")] + pub timeout: Option, +} + +#[derive(Args, Debug)] +pub struct ValidateArgs { + /// Configuration file path to validate + #[arg(value_name = "PATH")] + pub file: PathBuf, + + /// Target protocol (sing-box, clash, v2ray). Default: sing-box + #[arg(short, long, value_name = "PROTOCOL", default_value = "singbox")] + pub protocol: String, +} + +#[derive(Args, Debug)] +pub struct TemplateArgs { + /// Output path + #[arg(short, long, value_name = "PATH")] + pub output: Option, + + /// Target protocol (sing-box, clash, v2ray). Default: sing-box + #[arg(short, long, value_name = "PROTOCOL", default_value = "singbox")] + pub protocol: String, +} + #[derive(ValueEnum, Clone, Copy, Debug)] pub enum LogLevel { /// Error information diff --git a/src/commands/convert.rs b/src/commands/convert.rs index 9db31c3..1636e40 100644 --- a/src/commands/convert.rs +++ b/src/commands/convert.rs @@ -176,28 +176,13 @@ impl ConvertCommand { /// Handle convert command pub async fn handle_convert( - convert_cmd: &cli::Commands, + args: &cli::ConvertArgs, config: &crate::core::config::AppConfig, registry: &protocols::ProtocolRegistry, ) -> Result<()> { - // Extract Convert command args - let (sources, output, template, output_protocol_str) = match convert_cmd { - cli::Commands::Convert { - sources, - output, - template, - output_protocol, - .. - } => (sources, output, template, output_protocol), - _ => { - return Err(ConvertError::ConfigValidationError( - "Expected Convert command".to_string(), - )) - } - }; - // Parse each source (CLI + config): ?type=...&name=...&flag=... - let mut final_sources: Vec = sources + let mut final_sources: Vec = args + .sources .iter() .map(|raw| ConvertCommand::parse_source_string(raw)) .collect::>>()?; @@ -207,27 +192,12 @@ pub async fn handle_convert( } } - // Output protocol: CLI > config > default (sing-box) - let output_protocol_str = output_protocol_str - .as_ref() - .map(|s| s.as_str()) - .unwrap_or_else(|| config.output_protocol.as_str()); + // Output protocol: CLI > config > default. Config has already been merged + // with CLI in main, so we just read from config here. + let output_protocol = Protocol::from_str(&config.output_protocol)?; - let output_protocol = Protocol::from_str(output_protocol_str)?; - - // Merge output: CLI > config > default - let final_output: Option = output - .as_ref() - .and_then(|p| p.to_str()) - .map(|s| s.to_string()) - .or_else(|| config.output.clone()); - - // Merge template: CLI > config > None (in-memory default) - let final_template: Option = template - .as_ref() - .and_then(|p| p.to_str()) - .map(|s| s.to_string()) - .or_else(|| config.template.clone()); + let final_output: Option = config.output.clone(); + let final_template: Option = config.template.clone(); tracing::info!("Starting conversion"); for (i, m) in final_sources.iter().enumerate() { @@ -240,28 +210,15 @@ pub async fn handle_convert( m.flag.as_deref().unwrap_or("(default)"), ); } - tracing::info!( - "Template: {}", - final_template.as_deref().unwrap_or("(default)") - ); - tracing::info!( - "Output: {}", - final_output.as_deref().unwrap_or("(default)") - ); - tracing::info!("Output protocol: {}", output_protocol_str); - tracing::info!( - "Output format: {}", - match output_protocol.default_output_format() { - "yaml" => "YAML", - _ => "JSON", - } - ); + tracing::info!("Template: {}", final_template.as_deref().unwrap_or("(default)")); + tracing::info!("Output: {}", final_output.as_deref().unwrap_or("(default)")); + tracing::info!("Output protocol: {}", output_protocol); + tracing::info!("Output format: {}", output_protocol.default_output_format().to_uppercase()); tracing::info!("Using timeout: {} seconds", config.timeout_seconds); - // Run conversion ConvertCommand::start_convert( &final_sources, - None, // input_format + None, &output_protocol, final_output.as_deref(), final_template.as_deref(), diff --git a/src/commands/template.rs b/src/commands/template.rs index acc3db0..db63f02 100644 --- a/src/commands/template.rs +++ b/src/commands/template.rs @@ -1,51 +1,39 @@ //! Template command module +use crate::commands::cli::TemplateArgs; +use crate::core::config::AppConfig; use crate::core::error::{ConvertError, Result}; +use crate::protocols::ProtocolRegistry; use tracing::info; /// Handle template generation command pub async fn handle_template( - template_cmd: &crate::commands::cli::Commands, - _config: &crate::core::config::AppConfig, - registry: &crate::protocols::ProtocolRegistry, + args: &TemplateArgs, + _config: &AppConfig, + registry: &ProtocolRegistry, ) -> Result<()> { - // Extract Template command args - let (output, protocol) = match template_cmd { - crate::commands::cli::Commands::Template { output, protocol } => (output, protocol), - _ => { - return Err(ConvertError::ConfigValidationError( - "Expected Template command".to_string(), + let format = registry + .get_format(&args.protocol.to_lowercase()) + .ok_or_else(|| { + ConvertError::ConfigValidationError(format!( + "Unsupported protocol: {}. Supported: singbox, clash, v2ray", + args.protocol )) - } - }; - - // Resolve protocol via registry - let protocol_lower = protocol.to_lowercase(); - let format = registry.get_format(&protocol_lower).ok_or_else(|| { - ConvertError::ConfigValidationError(format!( - "Unsupported protocol: {}. Supported: singbox, clash, v2ray", - protocol - )) - })?; + })?; let protocol_name = format.name(); let default_ext = format.config_ext(); let template_content = format.default_template(); - info!( - "Starting template generation for protocol: {}", - protocol_name - ); + info!("Starting template generation for protocol: {}", protocol_name); - // Output path - let output_path = if let Some(path) = output { - path.to_string_lossy().to_string() - } else { - format!("template.{}", default_ext) - }; + let output_path = args + .output + .as_ref() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|| format!("template.{}", default_ext)); - // Write template - std::fs::write(&output_path, &template_content).map_err(|e| ConvertError::IoError(e))?; + std::fs::write(&output_path, &template_content)?; info!("Template generated: {}", output_path); info!("Protocol: {}", protocol_name); diff --git a/src/commands/validate.rs b/src/commands/validate.rs index 6eb0b0f..a1c2cfe 100644 --- a/src/commands/validate.rs +++ b/src/commands/validate.rs @@ -1,49 +1,36 @@ //! Validate command module +use crate::commands::cli::ValidateArgs; use crate::core::config::AppConfig; -use crate::protocols::ProtocolRegistry; use crate::core::error::{ConvertError, Result}; +use crate::protocols::ProtocolRegistry; use tracing::info; /// Handle validate command pub async fn handle_validate( - validate_cmd: &crate::commands::cli::Commands, + args: &ValidateArgs, _config: &AppConfig, registry: &ProtocolRegistry, ) -> Result<()> { - // Extract Validate command args - let (file, protocol) = match validate_cmd { - crate::commands::cli::Commands::Validate { file, protocol } => (file, protocol), - _ => { - return Err(ConvertError::ConfigValidationError( - "Expected Validate command".to_string(), + let format = registry + .get_format(&args.protocol.to_lowercase()) + .ok_or_else(|| { + ConvertError::ConfigValidationError(format!( + "Unsupported protocol: {}. Supported: singbox, clash, v2ray", + args.protocol )) - } - }; - - // Resolve protocol via registry - let protocol_lower = protocol.to_lowercase(); - let format = registry.get_format(&protocol_lower).ok_or_else(|| { - ConvertError::ConfigValidationError(format!( - "Unsupported protocol: {}. Supported: singbox, clash, v2ray", - protocol - )) - })?; + })?; let protocol_name = format.name(); - let file_path = file.to_string_lossy(); + let file_path = args.file.to_string_lossy(); info!("Validating configuration file: {}", file_path); info!("Protocol: {}", protocol_name); - // Check file exists - if !file.exists() { + if !args.file.exists() { return Err(ConvertError::file_not_found(&file_path)); } - // Read config file - let content = std::fs::read_to_string(&*file_path).map_err(|e| ConvertError::IoError(e))?; - - // Validate via the ProtocolFormat trait + let content = std::fs::read_to_string(&args.file)?; format.validate(&content)?; info!("Validation passed: {} (protocol: {})", file_path, protocol_name); diff --git a/src/core/config.rs b/src/core/config.rs index d05ab8c..c1c1c28 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -137,67 +137,29 @@ impl AppConfig { paths } - /// Merge CLI parameters into config (CLI parameters take precedence) - pub fn merge_cli_params(&mut self, cli: &crate::commands::cli::Commands) -> Result<()> { - if let crate::commands::cli::Commands::Convert { - timeout, - output_protocol, - verbose, - log_level, - template, - output, - .. - } = cli - { - // Merge timeout - if let Some(timeout_val) = timeout { - self.timeout_seconds = *timeout_val; - tracing::debug!( - "CLI timeout parameter overrides config: {} seconds", - timeout_val - ); - } - - // Merge output protocol - if let Some(protocol) = output_protocol { - self.output_protocol = protocol.clone(); - tracing::debug!( - "CLI output protocol parameter overrides config: {}", - protocol - ); - } - - // Merge verbose - if *verbose { - self.verbose = true; - tracing::debug!("CLI verbose parameter overrides config: true"); - } - - // Merge log level - let cli_log_level = format!("{:?}", log_level).to_lowercase(); - if cli_log_level != "info" { - // Override only when CLI specifies non-default - self.log_level = cli_log_level.clone(); - tracing::debug!( - "CLI log_level parameter overrides config: {}", - cli_log_level - ); - } - - // Merge template - if let Some(tpl) = template { - self.template = Some(tpl.to_string_lossy().to_string()); - tracing::debug!("CLI template parameter overrides config: {:?}", tpl); - } - - // Merge output path (only when CLI specifies) - if let Some(out) = output { - let output_str = out.to_string_lossy().to_string(); - self.output = Some(output_str.clone()); - tracing::debug!("CLI output parameter overrides config: {}", output_str); - } + /// Merge `ConvertArgs` into config. CLI fields override whatever came from + /// the config file / env vars; CLI default values (e.g. LogLevel::Info) are + /// treated as "not specified" so they don't overwrite user config. + pub fn merge_convert_args(&mut self, args: &crate::commands::cli::ConvertArgs) { + if let Some(t) = args.timeout { + self.timeout_seconds = t; + } + if let Some(p) = &args.output_protocol { + self.output_protocol = p.clone(); + } + if args.verbose { + self.verbose = true; + } + let cli_log_level = format!("{:?}", args.log_level).to_lowercase(); + if cli_log_level != "info" { + self.log_level = cli_log_level; + } + if let Some(tpl) = &args.template { + self.template = Some(tpl.to_string_lossy().into_owned()); + } + if let Some(out) = &args.output { + self.output = Some(out.to_string_lossy().into_owned()); } - Ok(()) } /// Load application configuration. diff --git a/src/main.rs b/src/main.rs index aadc737..85de026 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,49 +19,36 @@ async fn main() { } async fn run() -> error::Result<()> { - // Parse command line arguments let cli = Cli::parse(); - // Handle commands - match cli.command { - Commands::Version => { - version::handle_version(); - Ok(()) - } - _ => { - // Load configuration from specified path or default locations - let config_path = cli.config.as_ref().and_then(|p| p.to_str()); - let mut config = AppConfig::load_from_path(config_path)?; + // Version is standalone — no config load, no logging setup needed. + if matches!(cli.command, Commands::Version) { + version::handle_version(); + return Ok(()); + } - // Merge CLI parameters into config (CLI parameters take precedence) - config.merge_cli_params(&cli.command)?; + let config_path = cli.config.as_ref().and_then(|p| p.to_str()); + let mut config = AppConfig::load_from_path(config_path)?; - // Initialize logging system with config log level - let log_level = match config.log_level.to_lowercase().as_str() { - "error" => Level::ERROR, - "warn" => Level::WARN, - "debug" => Level::DEBUG, - "trace" => Level::TRACE, - _ => Level::INFO, - }; - logging::init_logging(log_level)?; + if let Commands::Convert(args) = &cli.command { + config.merge_convert_args(args); + } - // Initialize protocol registry - let registry = proxy_convert::protocols::ProtocolRegistry::init(); + let log_level = match config.log_level.to_lowercase().as_str() { + "error" => Level::ERROR, + "warn" => Level::WARN, + "debug" => Level::DEBUG, + "trace" => Level::TRACE, + _ => Level::INFO, + }; + logging::init_logging(log_level)?; - // Handle other commands - match cli.command { - Commands::Convert { .. } => { - convert::handle_convert(&cli.command, &config, ®istry).await - } - Commands::Validate { .. } => { - validate::handle_validate(&cli.command, &config, ®istry).await - } - Commands::Template { .. } => { - template::handle_template(&cli.command, &config, ®istry).await - } - Commands::Version => unreachable!(), - } - } + let registry = proxy_convert::protocols::ProtocolRegistry::init(); + + match cli.command { + Commands::Convert(args) => convert::handle_convert(&args, &config, ®istry).await, + Commands::Validate(args) => validate::handle_validate(&args, &config, ®istry).await, + Commands::Template(args) => template::handle_template(&args, &config, ®istry).await, + Commands::Version => unreachable!("handled above"), } } diff --git a/src/protocols/singbox/format.rs b/src/protocols/singbox/format.rs index 8155faa..4163fc8 100644 --- a/src/protocols/singbox/format.rs +++ b/src/protocols/singbox/format.rs @@ -29,22 +29,88 @@ impl ProtocolFormat for SingboxFormat { } fn validate(&self, content: &str) -> Result<()> { - let config: serde_json::Value = - serde_json::from_str(content).map_err(|e| ConvertError::JsonParseError(e))?; - + let config: serde_json::Value = serde_json::from_str(content)?; if config.get("outbounds").is_none() { return Err(ConvertError::ConfigValidationError( "Missing required field 'outbounds' for Sing-box config".to_string(), )); } - tracing::info!("Sing-box config structure is valid"); Ok(()) } fn parse_config(&self, content: &str) -> Result { - let config = - crate::utils::source::loader::SourceLoader::parse_singbox_config(content)?; - Ok(Config::SingBox(config)) + Ok(Config::SingBox(parse_singbox_config(content)?)) + } +} + +/// Parse a sing-box config; normalizes legacy DNS servers that carry +/// `address` without a `type` field (older configs / Eternal Network style) +/// so they deserialize as `Server::Legacy`. +fn parse_singbox_config(content: &str) -> Result { + if let Ok(config) = + crate::utils::parse_helpers::from_json_or_yaml::(content) + { + return Ok(config); + } + if let Ok(mut value) = serde_json::from_str::(content) { + if let Some(dns) = value.get_mut("dns") { + if let Some(servers) = dns.get_mut("servers").and_then(|s| s.as_array_mut()) { + for server in servers { + if let Some(obj) = server.as_object_mut() { + if obj.contains_key("address") && !obj.contains_key("type") { + obj.insert("type".to_string(), serde_json::Value::String(String::new())); + } + } + } + } + } + if let Ok(config) = serde_json::from_value::(value) { + return Ok(config); + } + } + Err(ConvertError::ConfigValidationError( + "Failed to parse Sing-box configuration".to_string(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocols::singbox::dns::Server as DnsServer; + + #[test] + fn parse_legacy_dns_without_type() { + let json = r#"{ + "dns": { + "servers": [ + {"address": "1.1.1.1", "detour": "proxy", "tag": "remote"}, + {"address": "https://223.5.5.5/dns-query", "detour": "direct", "tag": "local"}, + {"address": "rcode://refused", "tag": "block"} + ], + "final": "remote" + }, + "inbounds": [], + "outbounds": [{"type": "direct", "tag": "direct"}] + }"#; + let config = parse_singbox_config(json).unwrap(); + let dns = config.dns.as_ref().unwrap(); + assert_eq!(dns.servers.len(), 3); + match &dns.servers[0] { + DnsServer::Legacy(l) => { + assert_eq!(l.address, "1.1.1.1"); + assert_eq!(l.tag.as_deref(), Some("remote")); + assert_eq!(l.detour.as_deref(), Some("proxy")); + } + _ => panic!("first server should be Legacy"), + } + match &dns.servers[1] { + DnsServer::Legacy(l) => assert_eq!(l.address, "https://223.5.5.5/dns-query"), + _ => panic!("second server should be Legacy"), + } + match &dns.servers[2] { + DnsServer::Legacy(l) => assert_eq!(l.address, "rcode://refused"), + _ => panic!("third server should be Legacy"), + } } } diff --git a/src/utils/source/loader.rs b/src/utils/source/loader.rs index 92d648d..6c657fa 100644 --- a/src/utils/source/loader.rs +++ b/src/utils/source/loader.rs @@ -1,17 +1,18 @@ -//! Source loader for loading and parsing configurations +//! Source loader for loading and parsing configurations. -use crate::core::source::{Protocol, SourceMeta}; use crate::core::config::AppConfig; use crate::core::error::{ConvertError, Result}; +use crate::core::source::{Protocol, SourceMeta}; use crate::protocols::{ProtocolRegistry, FORMAT_PLAIN, FORMAT_SUBSCRIPTION}; use crate::utils::source::parser::{Config, Source}; use std::path::Path; +use std::str::FromStr; +use url::Url; -/// Source loader for loading and parsing configurations pub struct SourceLoader; impl SourceLoader { - /// Load and parse a source configuration + /// Load and parse a source configuration. pub async fn load_source( source_meta: &SourceMeta, registry: &ProtocolRegistry, @@ -19,162 +20,38 @@ impl SourceLoader { ) -> Result { let content = Self::load_content_from_source(source_meta, config).await?; - // Determine the format let detected_format = source_meta .format .clone() .unwrap_or_else(|| source_meta.source_type.as_format_str().to_string()); - // Parse configuration based on detected format let parsed_config = Self::parse_config(&content, &detected_format, registry)?; Ok(Source::new(source_meta.clone(), parsed_config)) } - /// Load content from source (URL or local file) async fn load_content_from_source( source_meta: &SourceMeta, config: &AppConfig, ) -> Result { let source = &source_meta.source; if source.starts_with("http://") || source.starts_with("https://") { - // Use source flag if set (empty = &flag=), else protocol default - let url_with_flag = - Self::append_flag_to_url(source, &source_meta.source_type, source_meta.flag.as_deref()); - // Pick a User-Agent that subscription panels recognize. - // Why: v2board/xboard/sspanel-style panels route responses by UA; - // the default reqwest UA is rejected or silently dropped, surfacing - // as "address unreachable" even when the host is reachable. - let ua = Self::effective_user_agent(&source_meta.source_type, source_meta.flag.as_deref(), config); + let url_with_flag = with_flag_param( + source, + source_meta.source_type, + source_meta.flag.as_deref(), + )?; + // Subscription panels (v2board/xboard/sspanel) route responses by UA. + // Pick a protocol-matched default so the request isn't silently dropped. + let ua = effective_user_agent(source_meta.source_type, source_meta.flag.as_deref(), config); Self::load_from_url(&url_with_flag, &ua, config).await } else { - // File path: use only the part before ? (query params are kept in source string for reference) + // File path: drop any query string that was kept on `source` for reference. let path = source.find('?').map(|i| &source[..i]).unwrap_or(source.as_str()); Self::load_from_file(path) } } - /// Choose the User-Agent to send with subscription requests. - /// Precedence: explicit `config.user_agent` (non-empty) > protocol-matched default. - fn effective_user_agent( - source_type: &Protocol, - flag_override: Option<&str>, - config: &AppConfig, - ) -> String { - let ua = config.user_agent.trim(); - if !ua.is_empty() { - return ua.to_string(); - } - // Derive from flag if the user overrode it, otherwise from source_type. - let kind = flag_override - .and_then(Protocol::from_str) - .unwrap_or(*source_type); - // Name-only; subscription panels typically match on the keyword, not the version. - // Users who hit a version-strict panel can override via config.user_agent. - match kind { - Protocol::SingBox => "sing-box".to_string(), - Protocol::Clash => "mihomo".to_string(), - Protocol::V2Ray => "v2rayN".to_string(), - } - } - - /// Append or update flag query parameter to URL. - /// Use flag_override if set (empty string = &flag=), else source_type default. - fn append_flag_to_url( - url: &str, - source_type: &Protocol, - flag_override: Option<&str>, - ) -> String { - // For the URL flag parameter, panels commonly expect "sing-box" (hyphenated). - let flag_value = match flag_override { - Some(s) => s.to_string(), - None => match source_type { - Protocol::SingBox => "sing-box".to_string(), - other => other.as_format_str().to_string(), - }, - }; - - // Check if flag parameter already exists and get its value - if let Some(current_flag_value) = Self::get_flag_param_value(url) { - if current_flag_value == flag_value { - url.to_string() - } else { - Self::update_flag_param(url, &flag_value) - } - } else { - if url.contains('?') { - format!("{}&flag={}", url, flag_value) - } else { - format!("{}?flag={}", url, flag_value) - } - } - } - - /// Get the value of the flag parameter from URL, if it exists - fn get_flag_param_value(url: &str) -> Option { - // Find the query string part (after ?) - if let Some(query_start) = url.find('?') { - let query_part = &url[query_start + 1..]; - // Also check for fragment separator - let query_end = query_part.find('#').unwrap_or(query_part.len()); - let query = &query_part[..query_end]; - - // Find flag parameter in query string - for param in query.split('&') { - let param = param.trim_start_matches('?'); - if let Some(flag_start) = param.find("flag=") { - let value = ¶m[flag_start + 5..]; - // Get value up to next & or end of string - let value_end = value.find('&').unwrap_or(value.len()); - return Some(value[..value_end].to_string()); - } - } - } - None - } - - /// Update the flag parameter value in URL - fn update_flag_param(url: &str, new_flag_value: &str) -> String { - // Find the query string part - if let Some(query_start) = url.find('?') { - let base_url = &url[..query_start + 1]; - let query_part = &url[query_start + 1..]; - - // Check for fragment - let (query, fragment) = if let Some(frag_start) = query_part.find('#') { - (&query_part[..frag_start], Some(&query_part[frag_start..])) - } else { - (query_part, None) - }; - - // Split query parameters and update flag - let params: Vec = query - .split('&') - .map(|p| { - if p.trim_start_matches('?').starts_with("flag=") { - format!("flag={}", new_flag_value) - } else { - p.to_string() - } - }) - .collect(); - - // Reconstruct URL - let mut result = base_url.to_string(); - if !params.is_empty() { - result.push_str(¶ms.join("&")); - } - if let Some(frag) = fragment { - result.push_str(frag); - } - result - } else { - // No query string, just add flag parameter - format!("{}?flag={}", url, new_flag_value) - } - } - - /// Load content from URL (uses NetworkError for fetch failures). async fn load_from_url(url: &str, user_agent: &str, config: &AppConfig) -> Result { tracing::info!("Fetching URL: {} (UA: {})", url, user_agent); @@ -182,136 +59,170 @@ impl SourceLoader { .timeout(std::time::Duration::from_secs(config.timeout_seconds)) .user_agent(user_agent) .build() - .map_err(|e| ConvertError::network_error(e.to_string().as_str()))?; + .map_err(|e| ConvertError::network_error(&e.to_string()))?; let response = client .get(url) .send() .await - .map_err(|e| ConvertError::network_error(e.to_string().as_str()))?; + .map_err(|e| ConvertError::network_error(&e.to_string()))?; if !response.status().is_success() { - return Err(ConvertError::network_error( - &format!("Failed to fetch URL: {} - Status: {}", url, response.status()), - )); + return Err(ConvertError::network_error(&format!( + "Failed to fetch URL: {} - Status: {}", + url, + response.status() + ))); } - let content = response + response .text() .await - .map_err(|e| ConvertError::network_error(e.to_string().as_str()))?; - - Ok(content) + .map_err(|e| ConvertError::network_error(&e.to_string())) } - /// Load content from local file fn load_from_file(file_path: &str) -> Result { let path = Path::new(file_path); if !path.exists() { return Err(ConvertError::file_not_found(file_path)); } - - std::fs::read_to_string(path).map_err(|e| ConvertError::IoError(e)) + Ok(std::fs::read_to_string(path)?) } - /// Parse configuration based on format (strongly typed). - /// Subscription and plain formats are handled directly; protocol formats - /// are dispatched through the registry's `ProtocolFormat` trait. fn parse_config(content: &str, format: &str, registry: &ProtocolRegistry) -> Result { match format.to_lowercase().as_str() { - FORMAT_SUBSCRIPTION => { - let servers = registry.parse_subscription_to_servers(content)?; - Ok(Config::Subscription(servers)) - } - FORMAT_PLAIN => { - let servers = registry.parse_plain_text_to_servers(content)?; - Ok(Config::Plain(servers)) - } + FORMAT_SUBSCRIPTION => Ok(Config::Subscription( + registry.parse_subscription_to_servers(content)?, + )), + FORMAT_PLAIN => Ok(Config::Plain(registry.parse_plain_text_to_servers(content)?)), other => { let fmt = registry.get_format(other).ok_or_else(|| { - ConvertError::ConfigValidationError(format!( - "Unsupported format: {}", - other - )) + ConvertError::ConfigValidationError(format!("Unsupported format: {}", other)) })?; fmt.parse_config(content) } } } +} - /// Parse Sing-box configuration (strongly typed). - /// Normalize legacy DNS servers: when "address" exists but "type" is missing, - /// set "type": "" so they deserialize as Server::Legacy. - /// - /// Kept public so `SingboxFormat::parse_config` can reuse the normalization logic. - pub(crate) fn parse_singbox_config(content: &str) -> Result { - // Try direct parse (JSON then YAML) - if let Ok(config) = crate::utils::parse_helpers::from_json_or_yaml::(content) { - return Ok(config); - } - // Normalize legacy DNS format (see sing-box docs: type empty = legacy, uses "address" only) - if let Ok(mut value) = serde_json::from_str::(content) { - if let Some(dns) = value.get_mut("dns") { - if let Some(servers) = dns.get_mut("servers").and_then(|s| s.as_array_mut()) { - for server in servers { - if let Some(obj) = server.as_object_mut() { - // Legacy format has "address" but no "type"; official docs: type empty = legacy - if obj.contains_key("address") && !obj.contains_key("type") { - obj.insert("type".to_string(), serde_json::Value::String(String::new())); - } - } - } - } - } - if let Ok(config) = serde_json::from_value::(value) { - return Ok(config); - } - } - Err(ConvertError::ConfigValidationError( - "Failed to parse Sing-box configuration".to_string(), - )) +/// Choose the User-Agent to send with subscription requests. +/// Precedence: explicit `config.user_agent` (non-empty) > protocol-matched default. +fn effective_user_agent(source_type: Protocol, flag_override: Option<&str>, config: &AppConfig) -> String { + let ua = config.user_agent.trim(); + if !ua.is_empty() { + return ua.to_string(); + } + // Derive from flag if the user overrode it, otherwise from source_type. + let kind = flag_override + .and_then(|s| Protocol::from_str(s).ok()) + .unwrap_or(source_type); + // Name-only; subscription panels typically match on the keyword, not the version. + // Users who hit a version-strict panel can override via config.user_agent. + match kind { + Protocol::SingBox => "sing-box".to_string(), + Protocol::Clash => "mihomo".to_string(), + Protocol::V2Ray => "v2rayN".to_string(), + } +} + +/// Compute the panel's `flag` query param: explicit override wins; otherwise +/// derive from the source protocol (sing-box uses the hyphenated form). +fn default_flag_for(protocol: Protocol) -> &'static str { + match protocol { + Protocol::SingBox => "sing-box", + Protocol::Clash => "clash", + Protocol::V2Ray => "v2ray", + } +} + +/// Ensure the URL carries `flag=`, replacing any existing value. +/// Uses `url::Url` so encoding, fragments, and other params round-trip cleanly. +fn with_flag_param( + raw_url: &str, + protocol: Protocol, + flag_override: Option<&str>, +) -> Result { + let flag_value = flag_override + .map(|s| s.to_string()) + .unwrap_or_else(|| default_flag_for(protocol).to_string()); + + let mut url = Url::parse(raw_url) + .map_err(|e| ConvertError::ConfigValidationError(format!("Invalid URL {}: {}", raw_url, e)))?; + + // Preserve every param except `flag`, then append the new flag. + let preserved: Vec<(String, String)> = url + .query_pairs() + .filter(|(k, _)| k != "flag") + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + url.query_pairs_mut().clear(); + for (k, v) in preserved { + url.query_pairs_mut().append_pair(&k, &v); } + url.query_pairs_mut().append_pair("flag", &flag_value); + + Ok(url.into()) } #[cfg(test)] mod tests { use super::*; - use crate::protocols::singbox::dns::Server as DnsServer; #[test] - fn test_parse_singbox_config_legacy_dns() { - // Minimal sing-box config with legacy DNS (address only, no type) like Eternal Network - let json = r#"{ - "dns": { - "servers": [ - {"address": "1.1.1.1", "detour": "proxy", "tag": "remote"}, - {"address": "https://223.5.5.5/dns-query", "detour": "direct", "tag": "local"}, - {"address": "rcode://refused", "tag": "block"} - ], - "final": "remote" - }, - "inbounds": [], - "outbounds": [{"type": "direct", "tag": "direct"}] - }"#; - let config = SourceLoader::parse_singbox_config(json).unwrap(); - let dns = config.dns.as_ref().unwrap(); - assert_eq!(dns.servers.len(), 3); + fn adds_flag_when_absent() { + let out = with_flag_param("https://example.com/sub", Protocol::SingBox, None).unwrap(); + assert!(out.contains("flag=sing-box")); + } - match &dns.servers[0] { - DnsServer::Legacy(l) => { - assert_eq!(l.address, "1.1.1.1"); - assert_eq!(l.tag.as_deref(), Some("remote")); - assert_eq!(l.detour.as_deref(), Some("proxy")); - } - _ => panic!("first server should be Legacy"), - } - match &dns.servers[1] { - DnsServer::Legacy(l) => assert_eq!(l.address, "https://223.5.5.5/dns-query"), - _ => panic!("second server should be Legacy"), - } - match &dns.servers[2] { - DnsServer::Legacy(l) => assert_eq!(l.address, "rcode://refused"), - _ => panic!("third server should be Legacy"), - } + #[test] + fn replaces_existing_flag() { + let out = with_flag_param( + "https://example.com/sub?token=abc&flag=clash", + Protocol::SingBox, + None, + ) + .unwrap(); + assert!(out.contains("token=abc")); + assert!(out.contains("flag=sing-box")); + assert!(!out.contains("flag=clash")); + } + + #[test] + fn override_wins_over_protocol_default() { + let out = with_flag_param("https://example.com/sub", Protocol::Clash, Some("custom")) + .unwrap(); + assert!(out.contains("flag=custom")); + } + + #[test] + fn preserves_fragment() { + let out = with_flag_param("https://example.com/sub#frag", Protocol::V2Ray, None).unwrap(); + assert!(out.contains("flag=v2ray")); + assert!(out.ends_with("#frag")); + } + + #[test] + fn effective_ua_respects_config_override() { + let mut cfg = AppConfig::default(); + cfg.user_agent = "custom/1.0".to_string(); + assert_eq!(effective_user_agent(Protocol::Clash, None, &cfg), "custom/1.0"); + } + + #[test] + fn effective_ua_falls_back_to_protocol_default() { + let cfg = AppConfig::default(); + assert_eq!(effective_user_agent(Protocol::Clash, None, &cfg), "mihomo"); + assert_eq!(effective_user_agent(Protocol::SingBox, None, &cfg), "sing-box"); + assert_eq!(effective_user_agent(Protocol::V2Ray, None, &cfg), "v2rayN"); + } + + #[test] + fn effective_ua_uses_flag_override_for_protocol_derivation() { + let cfg = AppConfig::default(); + // Source is SingBox but flag override says v2ray → UA matches flag. + assert_eq!( + effective_user_agent(Protocol::SingBox, Some("v2ray"), &cfg), + "v2rayN" + ); } } From 688ec283f1b7114a1baa24437dccfd8f8d6987ee Mon Sep 17 00:00:00 2001 From: messica Date: Fri, 1 May 2026 16:34:20 +0800 Subject: [PATCH 3/8] refactor: phase 3a move Source/Config into protocols layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the parsed-source model (Source, Config enum, ProxyServer extraction) from utils/source/parser.rs to protocols/source.rs. The Config variants wrap protocol-specific configs (clash::Config, singbox::Config, v2ray::Config) and the extraction code reaches deep into protocol types — this is protocol-layer logic, not a generic util. Keeping it in utils forced `utils -> protocols` edges throughout the codebase (utils becomes protocol-aware, inverting the dependency arrow). Now utils stays genuinely generic (parse_helpers only in the source subtree; the loader remains as IO/orchestration). No behavior change: type paths update from `utils::source::parser::{Source,Config}` to `protocols::source::...` (re-exported at `protocols::{Source,Config}`), and the file moves via `git mv` preserving history. All 114 tests pass. --- src/protocols/clash/format.rs | 2 +- src/protocols/clash/template_processor.rs | 2 +- src/protocols/mod.rs | 4 +++- src/protocols/protocol_format.rs | 2 +- src/protocols/shared_resolver.rs | 2 +- src/protocols/singbox/format.rs | 2 +- src/protocols/singbox/template_processor.rs | 4 ++-- src/{utils/source/parser.rs => protocols/source.rs} | 10 +++++++--- src/protocols/v2ray/format.rs | 2 +- src/protocols/v2ray/template_processor.rs | 2 +- src/utils/source/loader.rs | 2 +- src/utils/source/mod.rs | 4 ++-- src/utils/template/template_engine.rs | 2 +- tests/parser_test.rs | 2 +- 14 files changed, 24 insertions(+), 18 deletions(-) rename src/{utils/source/parser.rs => protocols/source.rs} (98%) diff --git a/src/protocols/clash/format.rs b/src/protocols/clash/format.rs index 3f10106..4447c17 100644 --- a/src/protocols/clash/format.rs +++ b/src/protocols/clash/format.rs @@ -2,7 +2,7 @@ use crate::core::error::{ConvertError, Result}; use crate::protocols::protocol_format::ProtocolFormat; -use crate::utils::source::parser::Config; +use crate::protocols::source::Config; /// Clash format descriptor. pub struct ClashFormat; diff --git a/src/protocols/clash/template_processor.rs b/src/protocols/clash/template_processor.rs index 9832f97..e431f1b 100644 --- a/src/protocols/clash/template_processor.rs +++ b/src/protocols/clash/template_processor.rs @@ -3,7 +3,7 @@ use crate::protocols::{ProtocolProcessor, ProxyServer}; use crate::protocols::shared_resolver::SharedNodeResolver; use crate::core::error::Result; -use crate::utils::source::parser::Source; +use crate::protocols::source::Source; use crate::utils::template::interpolation_parser::InterpolationRule; use indexmap::IndexMap; use serde_json; diff --git a/src/protocols/mod.rs b/src/protocols/mod.rs index 6d40766..9c31760 100644 --- a/src/protocols/mod.rs +++ b/src/protocols/mod.rs @@ -9,12 +9,14 @@ pub mod detect; pub mod protocol_format; pub mod shared_resolver; pub mod singbox; +pub mod source; pub mod subscription; pub mod v2ray; pub mod transport_converter; +pub use source::{Config, Source}; + use crate::core::error::Result; -use crate::utils::source::parser::Source; use crate::utils::template::interpolation_parser::InterpolationRule; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; diff --git a/src/protocols/protocol_format.rs b/src/protocols/protocol_format.rs index d176006..39d75a8 100644 --- a/src/protocols/protocol_format.rs +++ b/src/protocols/protocol_format.rs @@ -4,7 +4,7 @@ //! `ProtocolRegistry::init()` is all that is needed to add a new format. use crate::core::error::Result; -use crate::utils::source::parser::Config; +use crate::protocols::source::Config; /// Everything needed to support a proxy protocol format. pub trait ProtocolFormat: Send + Sync { diff --git a/src/protocols/shared_resolver.rs b/src/protocols/shared_resolver.rs index 0abad5d..af8ddf9 100644 --- a/src/protocols/shared_resolver.rs +++ b/src/protocols/shared_resolver.rs @@ -2,7 +2,7 @@ use crate::protocols::ProxyServer; use crate::core::error::Result; -use crate::utils::source::parser::Source; +use crate::protocols::source::Source; use crate::utils::template::interpolation_parser::InterpolationRule; use indexmap::IndexMap; use serde_json; diff --git a/src/protocols/singbox/format.rs b/src/protocols/singbox/format.rs index 4163fc8..bceb48f 100644 --- a/src/protocols/singbox/format.rs +++ b/src/protocols/singbox/format.rs @@ -2,7 +2,7 @@ use crate::core::error::{ConvertError, Result}; use crate::protocols::protocol_format::ProtocolFormat; -use crate::utils::source::parser::Config; +use crate::protocols::source::Config; /// Sing-box format descriptor. pub struct SingboxFormat; diff --git a/src/protocols/singbox/template_processor.rs b/src/protocols/singbox/template_processor.rs index 05a6210..8ecaf32 100644 --- a/src/protocols/singbox/template_processor.rs +++ b/src/protocols/singbox/template_processor.rs @@ -3,7 +3,7 @@ use crate::protocols::{ProtocolProcessor, ProxyServer}; use crate::protocols::shared_resolver::SharedNodeResolver; use crate::core::error::Result; -use crate::utils::source::parser::Source; +use crate::protocols::source::Source; use crate::utils::template::interpolation_parser::InterpolationRule; use indexmap::IndexMap; use serde_json; @@ -281,7 +281,7 @@ impl ProtocolProcessor for SingboxProcessor { mod tests { use super::*; use crate::core::source::{Protocol, SourceMeta}; - use crate::utils::source::parser::{Config, Source}; + use crate::protocols::source::{Config, Source}; use std::collections::HashMap; use crate::protocols::{clash, singbox, ProxyParams}; diff --git a/src/utils/source/parser.rs b/src/protocols/source.rs similarity index 98% rename from src/utils/source/parser.rs rename to src/protocols/source.rs index dbea4cb..4fe5dd9 100644 --- a/src/utils/source/parser.rs +++ b/src/protocols/source.rs @@ -1,8 +1,12 @@ -//! Source configuration parser +//! Parsed source model: typed `Config` variants per protocol and +//! the cross-protocol `ProxyServer` extraction logic. +//! +//! Lives under `protocols` because its variants wrap protocol-specific +//! configs — keeping it here avoids a backwards `utils -> protocols` edge. -use crate::core::source::SourceMeta; +use super::{clash, singbox, v2ray, ProxyParams, ProxyServer, TlsParams, TransportParams}; use crate::core::error::Result; -use crate::protocols::{clash, singbox, v2ray, ProxyParams, ProxyServer, TlsParams, TransportParams}; +use crate::core::source::SourceMeta; use std::collections::HashMap; /// Configuration for different protocols (strongly typed) diff --git a/src/protocols/v2ray/format.rs b/src/protocols/v2ray/format.rs index 20e14af..6a0836f 100644 --- a/src/protocols/v2ray/format.rs +++ b/src/protocols/v2ray/format.rs @@ -2,7 +2,7 @@ use crate::core::error::{ConvertError, Result}; use crate::protocols::protocol_format::ProtocolFormat; -use crate::utils::source::parser::Config; +use crate::protocols::source::Config; /// V2Ray format descriptor. pub struct V2RayFormat; diff --git a/src/protocols/v2ray/template_processor.rs b/src/protocols/v2ray/template_processor.rs index f95a8fc..f08afda 100644 --- a/src/protocols/v2ray/template_processor.rs +++ b/src/protocols/v2ray/template_processor.rs @@ -3,7 +3,7 @@ use crate::protocols::{ProtocolProcessor, ProxyServer}; use crate::protocols::shared_resolver::SharedNodeResolver; use crate::core::error::Result; -use crate::utils::source::parser::Source; +use crate::protocols::source::Source; use crate::utils::template::interpolation_parser::InterpolationRule; use indexmap::IndexMap; diff --git a/src/utils/source/loader.rs b/src/utils/source/loader.rs index 6c657fa..d8ffdb8 100644 --- a/src/utils/source/loader.rs +++ b/src/utils/source/loader.rs @@ -4,7 +4,7 @@ use crate::core::config::AppConfig; use crate::core::error::{ConvertError, Result}; use crate::core::source::{Protocol, SourceMeta}; use crate::protocols::{ProtocolRegistry, FORMAT_PLAIN, FORMAT_SUBSCRIPTION}; -use crate::utils::source::parser::{Config, Source}; +use crate::protocols::source::{Config, Source}; use std::path::Path; use std::str::FromStr; use url::Url; diff --git a/src/utils/source/mod.rs b/src/utils/source/mod.rs index 451fbe7..2997567 100644 --- a/src/utils/source/mod.rs +++ b/src/utils/source/mod.rs @@ -1,6 +1,6 @@ -//! Source configuration parsing and loading module +//! Source IO: remote/file fetching and orchestration. Domain types +//! (Source, Config) live in `crate::protocols::source`. pub mod loader; -pub mod parser; pub use loader::SourceLoader; diff --git a/src/utils/template/template_engine.rs b/src/utils/template/template_engine.rs index 118c77f..d24f75d 100644 --- a/src/utils/template/template_engine.rs +++ b/src/utils/template/template_engine.rs @@ -9,7 +9,7 @@ use super::interpolation_parser::InterpolationParser; use crate::protocols::{ProtocolProcessor, ProtocolRegistry, ProxyServer}; use crate::core::error::{ConvertError, Result}; -use crate::utils::source::parser::Source; +use crate::protocols::source::Source; use indexmap::IndexMap; /// Template engine diff --git a/tests/parser_test.rs b/tests/parser_test.rs index f814958..966b79b 100644 --- a/tests/parser_test.rs +++ b/tests/parser_test.rs @@ -2,7 +2,7 @@ use proxy_convert::core::source::Protocol; use proxy_convert::protocols::{clash, singbox, ProxyParams}; -use proxy_convert::utils::source::parser::{Config, Source}; +use proxy_convert::protocols::source::{Config, Source}; fn make_source(config: Config, source_type: Protocol) -> Source { use proxy_convert::core::source::SourceMeta; From ca6b800ad567157bac9f8437248229040474e52e Mon Sep 17 00:00:00 2001 From: messica Date: Fri, 1 May 2026 16:38:39 +0800 Subject: [PATCH 4/8] refactor: phase 3b merge ProtocolFormat and ProtocolProcessor ProtocolFormat gains a processor() method returning a 'static ProtocolProcessor reference. Each format (Clash/Singbox/V2Ray) holds its processor as a module-level static so the trait method can hand it out without heap allocation. ProtocolRegistry drops its second map. One registration call per protocol now covers descriptor metadata + parse_config + validate + default template + template processor. Forgetting to register the processor half is no longer possible. --- src/protocols/clash/format.rs | 9 ++++++++ src/protocols/mod.rs | 36 +++++++++++--------------------- src/protocols/protocol_format.rs | 21 ++++++++++++------- src/protocols/singbox/format.rs | 9 ++++++++ src/protocols/v2ray/format.rs | 9 ++++++++ 5 files changed, 52 insertions(+), 32 deletions(-) diff --git a/src/protocols/clash/format.rs b/src/protocols/clash/format.rs index 4447c17..087879e 100644 --- a/src/protocols/clash/format.rs +++ b/src/protocols/clash/format.rs @@ -3,10 +3,15 @@ use crate::core::error::{ConvertError, Result}; use crate::protocols::protocol_format::ProtocolFormat; use crate::protocols::source::Config; +use crate::protocols::ProtocolProcessor; + +use super::template_processor::ClashProcessor; /// Clash format descriptor. pub struct ClashFormat; +static PROCESSOR: ClashProcessor = ClashProcessor; + impl ProtocolFormat for ClashFormat { fn name(&self) -> &'static str { "clash" @@ -45,4 +50,8 @@ impl ProtocolFormat for ClashFormat { crate::utils::parse_helpers::from_json_or_yaml(content)?; Ok(Config::Clash(config)) } + + fn processor(&self) -> &'static dyn ProtocolProcessor { + &PROCESSOR + } } diff --git a/src/protocols/mod.rs b/src/protocols/mod.rs index 9c31760..403b241 100644 --- a/src/protocols/mod.rs +++ b/src/protocols/mod.rs @@ -143,12 +143,12 @@ pub trait ProtocolProcessor: Send + Sync { fn create_node_config(&self, node: &ProxyServer) -> String; } -/// Protocol converter registry: format detection, parsing, and processor lookup. +/// Protocol converter registry: a single table of `ProtocolFormat`s that +/// yields descriptor info, parsers, and template processors. /// /// Uses `IndexMap` so iteration order matches registration order — makes /// logging and format-detection fallbacks deterministic. pub struct ProtocolRegistry { - processors: IndexMap>, formats: IndexMap>, } @@ -156,20 +156,15 @@ impl ProtocolRegistry { /// Create new empty registry (for tests or custom setup). pub fn new() -> Self { Self { - processors: IndexMap::new(), formats: IndexMap::new(), } } - /// Register a processor for a format name (e.g. "clash", "singbox", "v2ray"). - pub fn register(&mut self, format: &str, processor: Box) { - self.processors.insert(format.to_lowercase(), processor); - } - - /// Register a `ProtocolFormat` descriptor. - pub fn register_format(&mut self, format: Box) { - let name = format.name().to_string(); - self.formats.insert(name.to_lowercase(), format); + /// Register a protocol. One call per protocol covers both format + /// descriptor and template processor. + pub fn register(&mut self, format: Box) { + let name = format.name().to_lowercase(); + self.formats.insert(name, format); } /// Look up a `ProtocolFormat` by canonical name or alias. @@ -179,7 +174,6 @@ impl ProtocolRegistry { .get(&lower) .map(|b| b.as_ref()) .or_else(|| { - // Fall back to alias search self.formats .values() .find(|f| f.aliases().iter().any(|a| a.to_lowercase() == lower)) @@ -189,7 +183,7 @@ impl ProtocolRegistry { /// Get processor by format name. Used by TemplateEngine. pub fn get_processor(&self, format: &str) -> Option<&dyn ProtocolProcessor> { - self.processors.get(&format.to_lowercase()).map(|b| b.as_ref()) + self.get_format(format).map(|f| f.processor()) } /// Auto-detect input format (delegates to detect module). @@ -197,18 +191,12 @@ impl ProtocolRegistry { detect::detect_format(content) } - /// Initialize protocol registry with built-in processors and format descriptors. + /// Initialize protocol registry with the built-in protocols. pub fn init() -> Self { - use crate::core::source::Protocol; let mut registry = Self::new(); - // Processors (used by TemplateEngine) - registry.register(Protocol::Clash.as_format_str(), Box::new(clash::template_processor::ClashProcessor)); - registry.register(Protocol::SingBox.as_format_str(), Box::new(singbox::template_processor::SingboxProcessor)); - registry.register(Protocol::V2Ray.as_format_str(), Box::new(v2ray::template_processor::V2RayProcessor)); - // Format descriptors (validate, parse, default template, metadata) - registry.register_format(Box::new(singbox::format::SingboxFormat)); - registry.register_format(Box::new(clash::format::ClashFormat)); - registry.register_format(Box::new(v2ray::format::V2RayFormat)); + registry.register(Box::new(singbox::format::SingboxFormat)); + registry.register(Box::new(clash::format::ClashFormat)); + registry.register(Box::new(v2ray::format::V2RayFormat)); tracing::info!("Protocol registry initialized successfully"); registry } diff --git a/src/protocols/protocol_format.rs b/src/protocols/protocol_format.rs index 39d75a8..8f75ee8 100644 --- a/src/protocols/protocol_format.rs +++ b/src/protocols/protocol_format.rs @@ -1,25 +1,25 @@ -//! ProtocolFormat trait -- everything needed to support a proxy protocol format. +//! Single protocol trait — descriptor + parser + template processor in one. //! -//! Implementing this trait for a new protocol and registering it in -//! `ProtocolRegistry::init()` is all that is needed to add a new format. +//! Implementing this trait and registering it in `ProtocolRegistry::init()` +//! is all that is needed to add a new format. use crate::core::error::Result; use crate::protocols::source::Config; +use crate::protocols::ProtocolProcessor; -/// Everything needed to support a proxy protocol format. pub trait ProtocolFormat: Send + Sync { - /// Canonical format name used as registry key ("clash", "singbox", "v2ray"). + /// Canonical format name used as registry key (`"clash"`, `"singbox"`, `"v2ray"`). fn name(&self) -> &'static str; - /// Alternate names for matching (e.g., "sing-box" for "singbox"). + /// Alternate names for matching (e.g. `"sing-box"` for `"singbox"`). fn aliases(&self) -> &'static [&'static str] { &[] } - /// File extension for output ("json", "yaml"). + /// File extension for output (`"json"`, `"yaml"`). fn config_ext(&self) -> &'static str; - /// Default output filename (e.g. "config.json"). + /// Default output filename (e.g. `"config.json"`). fn default_filename(&self) -> &'static str; /// Generate a default template string for this protocol. @@ -30,4 +30,9 @@ pub trait ProtocolFormat: Send + Sync { /// Parse a raw config string into the strongly-typed `Config` enum. fn parse_config(&self, content: &str) -> Result; + + /// Template processor for this format (rule expansion, node injection). + /// Returned as a `'static` reference so the Registry doesn't need a + /// separate map to keep processors alive. + fn processor(&self) -> &'static dyn ProtocolProcessor; } diff --git a/src/protocols/singbox/format.rs b/src/protocols/singbox/format.rs index bceb48f..b89277a 100644 --- a/src/protocols/singbox/format.rs +++ b/src/protocols/singbox/format.rs @@ -3,10 +3,15 @@ use crate::core::error::{ConvertError, Result}; use crate::protocols::protocol_format::ProtocolFormat; use crate::protocols::source::Config; +use crate::protocols::ProtocolProcessor; + +use super::template_processor::SingboxProcessor; /// Sing-box format descriptor. pub struct SingboxFormat; +static PROCESSOR: SingboxProcessor = SingboxProcessor; + impl ProtocolFormat for SingboxFormat { fn name(&self) -> &'static str { "singbox" @@ -42,6 +47,10 @@ impl ProtocolFormat for SingboxFormat { fn parse_config(&self, content: &str) -> Result { Ok(Config::SingBox(parse_singbox_config(content)?)) } + + fn processor(&self) -> &'static dyn ProtocolProcessor { + &PROCESSOR + } } /// Parse a sing-box config; normalizes legacy DNS servers that carry diff --git a/src/protocols/v2ray/format.rs b/src/protocols/v2ray/format.rs index 6a0836f..3deb97e 100644 --- a/src/protocols/v2ray/format.rs +++ b/src/protocols/v2ray/format.rs @@ -3,10 +3,15 @@ use crate::core::error::{ConvertError, Result}; use crate::protocols::protocol_format::ProtocolFormat; use crate::protocols::source::Config; +use crate::protocols::ProtocolProcessor; + +use super::template_processor::V2RayProcessor; /// V2Ray format descriptor. pub struct V2RayFormat; +static PROCESSOR: V2RayProcessor = V2RayProcessor; + impl ProtocolFormat for V2RayFormat { fn name(&self) -> &'static str { "v2ray" @@ -43,4 +48,8 @@ impl ProtocolFormat for V2RayFormat { crate::utils::parse_helpers::from_json_or_yaml(content)?; Ok(Config::V2Ray(config)) } + + fn processor(&self) -> &'static dyn ProtocolProcessor { + &PROCESSOR + } } From d3d65144725504adb26ba4588305093f06d4bbdf Mon Sep 17 00:00:00 2001 From: messica Date: Fri, 1 May 2026 16:49:55 +0800 Subject: [PATCH 5/8] refactor: phase 3c delete ProxyServer.parameters The flat `parameters: HashMap` field was a serde-exposed bag of protocol-specific JSON that duplicated the typed `params: ProxyParams` enum. Two sources of truth, always at risk of drifting apart. Collapse into one: each ProxyParams variant carries its own `extras: HashMap` for raw leftover fields specific to that protocol. ProxyServer::extras() forwards to the variant. - ProxyServer loses the flat parameters field - Each ProxyParams variant (Shadowsocks/Vmess/Trojan/Vless/ Hysteria2/Generic) gains `extras` - Every construction site (source.rs, subscription.rs, tests) now populates extras inside the variant instead of the flat map - Every read site in the three template processors reads through `node.extras()`; pattern-matching downstream on ProxyParams adds `..` to tolerate the new field --- src/protocols/clash/template_processor.rs | 6 +- src/protocols/mod.rs | 60 ++++++++++---- src/protocols/singbox/template_processor.rs | 30 +++---- src/protocols/source.rs | 88 ++++++++++----------- src/protocols/subscription.rs | 30 ++----- src/protocols/v2ray/template_processor.rs | 26 +++--- tests/subscription_test.rs | 7 +- 7 files changed, 128 insertions(+), 119 deletions(-) diff --git a/src/protocols/clash/template_processor.rs b/src/protocols/clash/template_processor.rs index e431f1b..058feb7 100644 --- a/src/protocols/clash/template_processor.rs +++ b/src/protocols/clash/template_processor.rs @@ -127,7 +127,7 @@ impl ProtocolProcessor for ClashProcessor { "tag", "method", ]; - for (key, value) in &node.parameters { + for (key, value) in node.extras() { if !(is_shadowsocks && key == "udp") && !skip_keys.contains(&key.as_str()) { config.insert(key.clone(), value.clone()); } @@ -149,7 +149,7 @@ impl ClashProcessor { config: &mut serde_json::Map, node: &ProxyServer, ) { - let params = &node.parameters; + let params = &node.extras(); // UUID if let Some(uuid) = params.get("uuid") { @@ -206,7 +206,7 @@ impl ClashProcessor { config: &mut serde_json::Map, node: &ProxyServer, ) { - let params = &node.parameters; + let params = &node.extras(); // Password if let Some(password) = &node.password { diff --git a/src/protocols/mod.rs b/src/protocols/mod.rs index 403b241..237e99c 100644 --- a/src/protocols/mod.rs +++ b/src/protocols/mod.rs @@ -26,7 +26,14 @@ use std::collections::HashMap; pub const FORMAT_SUBSCRIPTION: &str = "subscription"; pub const FORMAT_PLAIN: &str = "plain"; -/// Typed proxy protocol parameters +/// Typed proxy-protocol parameters. +/// +/// Each variant carries the typed fields that processors need, plus an +/// `extras` map for protocol-specific fields that haven't been typed yet +/// (e.g. obscure transport tweaks). Previously these lived in a flat +/// `ProxyServer.parameters` HashMap, which duplicated the typed data and +/// made it unclear which field was canonical — `extras` makes the scope +/// explicit: "raw leftovers for *this* protocol". #[derive(Debug, Clone, PartialEq)] pub enum ProxyParams { Shadowsocks { @@ -34,6 +41,7 @@ pub enum ProxyParams { udp: Option, plugin: Option, plugin_opts: Option, + extras: HashMap, }, Vmess { uuid: String, @@ -41,28 +49,50 @@ pub enum ProxyParams { security: Option, tls: Option, transport: Option, + extras: HashMap, }, Trojan { tls: Option, transport: Option, + extras: HashMap, }, Vless { uuid: String, flow: Option, tls: Option, transport: Option, + extras: HashMap, }, Hysteria2 { obfs_password: Option, tls: Option, + extras: HashMap, + }, + /// Fallback for protocols not yet fully typed. + Generic { + extras: HashMap, }, - /// Fallback for protocols not yet fully typed - Generic, } impl Default for ProxyParams { fn default() -> Self { - ProxyParams::Generic + ProxyParams::Generic { extras: HashMap::new() } + } +} + +impl ProxyParams { + /// Raw leftover fields for this protocol (e.g. unknown options preserved + /// verbatim for pass-through). Typed fields should be read from the + /// variant's named fields, not from here. + pub fn extras(&self) -> &HashMap { + match self { + ProxyParams::Shadowsocks { extras, .. } + | ProxyParams::Vmess { extras, .. } + | ProxyParams::Trojan { extras, .. } + | ProxyParams::Vless { extras, .. } + | ProxyParams::Hysteria2 { extras, .. } + | ProxyParams::Generic { extras } => extras, + } } } @@ -87,28 +117,30 @@ pub struct TransportParams { pub early_data_header_name: Option, } -/// Proxy server information +/// Proxy server information. +/// +/// Protocol-specific fields live inside `params` (and, for not-yet-typed +/// fields, `params.extras()`). There is no flat fallback HashMap on the +/// server itself — read through the typed variant. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ProxyServer { - /// Server name pub name: String, - /// Server type pub protocol: String, - /// Server address pub server: String, - /// Server port pub port: u16, - /// Password (if needed) pub password: Option, - /// Encryption method (if needed) pub method: Option, - /// Additional parameters (legacy HashMap, kept for backward compatibility) - pub parameters: HashMap, - /// Typed parameters (new, preferred for reading protocol-specific data) #[serde(skip)] pub params: ProxyParams, } +impl ProxyServer { + /// Convenience — raw pass-through fields for this server's protocol. + pub fn extras(&self) -> &HashMap { + self.params.extras() + } +} + /// Protocol processor trait - each protocol implements this for template processing. pub trait ProtocolProcessor: Send + Sync { /// Process interpolation rules for this protocol diff --git a/src/protocols/singbox/template_processor.rs b/src/protocols/singbox/template_processor.rs index 8ecaf32..b10a80a 100644 --- a/src/protocols/singbox/template_processor.rs +++ b/src/protocols/singbox/template_processor.rs @@ -255,14 +255,14 @@ impl ProtocolProcessor for SingboxProcessor { } // VMess specific handling if is_vmess { - Self::convert_vmess_params_to_singbox(&mut config, &node.parameters); + Self::convert_vmess_params_to_singbox(&mut config, &node.extras()); } else if is_trojan { - Self::convert_trojan_params_to_singbox(&mut config, &node.parameters); + Self::convert_trojan_params_to_singbox(&mut config, &node.extras()); } else { // Generic parameter handling // Skip fields that are already handled or not needed in sing-box let skip_keys = ["cipher", "udp", "name", "type", "server", "port"]; - for (key, value) in &node.parameters { + for (key, value) in node.extras() { if !skip_keys.contains(&key.as_str()) && !(is_shadowsocks && key == "udp") { config.insert(key.clone(), value.clone()); } @@ -418,8 +418,8 @@ mod tests { port: 443, password: None, method: None, - parameters: HashMap::new(), - params: ProxyParams::Generic, + + params: ProxyParams::Generic { extras: HashMap::new() }, }, ProxyServer { name: "Node-02".to_string(), @@ -428,8 +428,8 @@ mod tests { port: 443, password: None, method: None, - parameters: HashMap::new(), - params: ProxyParams::Generic, + + params: ProxyParams::Generic { extras: HashMap::new() }, }, ]; @@ -447,8 +447,8 @@ mod tests { port: 443, password: Some("test-password".to_string()), method: Some("aes-256-gcm".to_string()), - parameters: HashMap::new(), - params: ProxyParams::Generic, + + params: ProxyParams::Generic { extras: HashMap::new() }, }; let config = processor.create_node_config(&server); @@ -475,8 +475,8 @@ mod tests { port: 443, password: Some("test".to_string()), method: Some("aes-256-gcm".to_string()), - parameters: HashMap::new(), - params: ProxyParams::Generic, + + params: ProxyParams::Generic { extras: HashMap::new() }, }; let config = processor.create_node_config(&server_with_prefix); @@ -499,8 +499,8 @@ mod tests { port: 443, password: Some("test".to_string()), method: Some("aes-256-gcm".to_string()), - parameters: HashMap::new(), - params: ProxyParams::Generic, + + params: ProxyParams::Generic { extras: HashMap::new() }, }, ProxyServer { name: "singbox1@US-Node-01".to_string(), @@ -509,8 +509,8 @@ mod tests { port: 443, password: None, method: None, - parameters: HashMap::new(), - params: ProxyParams::Generic, + + params: ProxyParams::Generic { extras: HashMap::new() }, }, ]; diff --git a/src/protocols/source.rs b/src/protocols/source.rs index 4fe5dd9..c09099f 100644 --- a/src/protocols/source.rs +++ b/src/protocols/source.rs @@ -69,6 +69,20 @@ impl Source { } }; + // Build per-variant `extras` from the JSON dump minus fields already + // covered by ProxyServer's typed top-level fields. + let skip_keys: &[&str] = &[ + "name", "type", "server", "port", "password", "cipher", "method", + ]; + let mut extras_map: HashMap = HashMap::new(); + if let Some(obj) = proxy_json.as_object() { + for (key, value) in obj { + if !skip_keys.contains(&key.as_str()) { + extras_map.insert(key.clone(), value.clone()); + } + } + } + // Extract typed params directly from the strong type let (params, protocol, password, method) = match proxy { clash::proxy::Proxy::Ss(ss) => ( @@ -77,6 +91,7 @@ impl Source { udp: ss.udp, plugin: None, plugin_opts: None, + extras: extras_map, }, "shadowsocks".to_string(), Some(ss.password.clone()), @@ -122,6 +137,7 @@ impl Source { security: vmess.cipher.clone(), tls, transport, + extras: extras_map, }, "vmess".to_string(), None, @@ -158,14 +174,14 @@ impl Source { tp }); ( - ProxyParams::Trojan { tls, transport }, + ProxyParams::Trojan { tls, transport, extras: extras_map }, "trojan".to_string(), trojan.password.clone(), None, ) } _ => ( - ProxyParams::Generic, + ProxyParams::Generic { extras: extras_map }, proxy_json.get("type")?.as_str()?.to_string(), None, None, @@ -179,23 +195,10 @@ impl Source { protocol }; - // Build the old-style parameters HashMap from JSON (backward compat) let name = proxy.name().to_string(); let server = proxy_json.get("server")?.as_str()?.to_string(); let port = proxy_json.get("port")?.as_u64()? as u16; - let skip_keys = [ - "name", "type", "server", "port", "password", "cipher", "method", - ]; - let mut parameters = HashMap::new(); - if let Some(obj) = proxy_json.as_object() { - for (key, value) in obj { - if !skip_keys.contains(&key.as_str()) { - parameters.insert(key.clone(), value.clone()); - } - } - } - Some(ProxyServer { name, protocol, @@ -203,7 +206,6 @@ impl Source { port, password, method, - parameters, params, }) } @@ -327,13 +329,26 @@ impl Source { .and_then(|v| v.as_str()) .map(String::from); - // Extract typed params from the strong outbound type + // Leftover JSON fields, mapped to the variant's `extras`. + let skip_keys: &[&str] = &[ + "type", "tag", "server", "server_port", "port", "password", "method", + ]; + let mut extras_map: HashMap = HashMap::new(); + if let Some(obj) = outbound_json.as_object() { + for (key, value) in obj { + if !skip_keys.contains(&key.as_str()) { + extras_map.insert(key.clone(), value.clone()); + } + } + } + let params = match outbound { singbox::outbound::Outbound::Shadowsocks(ss) => ProxyParams::Shadowsocks { cipher: ss.method.clone(), udp: None, plugin: ss.plugin.clone(), plugin_opts: ss.plugin_opts.clone(), + extras: extras_map, }, singbox::outbound::Outbound::Vmess(vmess) => ProxyParams::Vmess { uuid: vmess.uuid.clone(), @@ -341,49 +356,32 @@ impl Source { security: vmess.security.clone(), tls: Self::extract_singbox_tls_params(&vmess.tls), transport: Self::extract_singbox_transport_params(&vmess.transport), + extras: extras_map, }, singbox::outbound::Outbound::Trojan(t) => ProxyParams::Trojan { tls: Self::extract_singbox_tls_params(&t.tls), transport: Self::extract_singbox_transport_params(&t.transport), + extras: extras_map, }, singbox::outbound::Outbound::Vless(v) => ProxyParams::Vless { uuid: v.uuid.clone(), flow: v.flow.clone(), tls: Self::extract_singbox_tls_params(&v.tls), transport: Self::extract_singbox_transport_params(&v.transport), + extras: extras_map, }, singbox::outbound::Outbound::Hysteria2(h2) => ProxyParams::Hysteria2 { obfs_password: h2.obfs.as_ref().and_then(|o| { - // Serialize Obfs to get the password field serde_json::to_value(o) .ok() .and_then(|v| v.get("password").and_then(|p| p.as_str()).map(String::from)) }), tls: Self::extract_singbox_tls_params(&h2.tls), + extras: extras_map, }, - _ => ProxyParams::Generic, + _ => ProxyParams::Generic { extras: extras_map }, }; - // Collect additional parameters (backward compat) - let mut parameters = HashMap::new(); - let skip_keys = [ - "type", - "tag", - "server", - "server_port", - "port", - "password", - "method", - ]; - - if let Some(obj) = outbound_json.as_object() { - for (key, value) in obj { - if !skip_keys.contains(&key.as_str()) { - parameters.insert(key.clone(), value.clone()); - } - } - } - Some(ProxyServer { name: tag, protocol: outbound_type.to_string(), @@ -391,7 +389,6 @@ impl Source { port, password, method, - parameters, params, }) } @@ -427,11 +424,9 @@ impl Source { let password = Self::extract_v2ray_password(outbound, protocol); let method = Self::extract_v2ray_method(outbound, protocol); - // Collect additional parameters from extra - let mut parameters = HashMap::new(); - for (key, value) in &outbound.extra { - parameters.insert(key.clone(), value.clone()); - } + // V2Ray source isn't fully typed yet — route the extras through + // ProxyParams::Generic so processors that need pass-through can read them. + let extras = outbound.extra.clone().into_iter().collect(); Some(ProxyServer { name: tag, @@ -440,8 +435,7 @@ impl Source { port, password, method, - parameters, - params: ProxyParams::Generic, + params: ProxyParams::Generic { extras }, }) } diff --git a/src/protocols/subscription.rs b/src/protocols/subscription.rs index b03bd01..2d4813e 100644 --- a/src/protocols/subscription.rs +++ b/src/protocols/subscription.rs @@ -76,8 +76,6 @@ fn parse_vmess_url(url: &str) -> Result> { }; let server = &server_port[..colon_pos]; let port = server_port[colon_pos + 1..].parse::().unwrap_or(0); - let mut parameters = HashMap::new(); - parameters.insert("uuid".to_string(), serde_json::Value::String(uuid.to_string())); Ok(Some(ProxyServer { name: name.to_string(), protocol: "vmess".to_string(), @@ -85,13 +83,13 @@ fn parse_vmess_url(url: &str) -> Result> { port, password: None, method: None, - parameters, params: ProxyParams::Vmess { uuid: uuid.to_string(), alter_id: None, security: None, tls: None, transport: None, + extras: HashMap::new(), }, })) } @@ -114,11 +112,6 @@ fn parse_trojan_url(url: &str) -> Result> { }; let server = &server_port[..colon_pos]; let port = server_port[colon_pos + 1..].parse::().unwrap_or(0); - let mut parameters = HashMap::new(); - parameters.insert( - "password".to_string(), - serde_json::Value::String(password.to_string()), - ); Ok(Some(ProxyServer { name: name.to_string(), protocol: "trojan".to_string(), @@ -126,10 +119,10 @@ fn parse_trojan_url(url: &str) -> Result> { port, password: Some(password.to_string()), method: None, - parameters, params: ProxyParams::Trojan { tls: None, transport: None, + extras: HashMap::new(), }, })) } @@ -185,12 +178,12 @@ fn parse_shadowsocks_url(url: &str) -> Result> { port, password: Some(password.to_string()), method: Some(method.to_string()), - parameters: HashMap::new(), params: ProxyParams::Shadowsocks { cipher: method.to_string(), udp: None, plugin: None, plugin_opts: None, + extras: HashMap::new(), }, })); } @@ -218,12 +211,12 @@ fn parse_shadowsocks_url(url: &str) -> Result> { port, password: Some(password.to_string()), method: Some(method.to_string()), - parameters: HashMap::new(), params: ProxyParams::Shadowsocks { cipher: method.to_string(), udp: None, plugin: None, plugin_opts: None, + extras: HashMap::new(), }, })); } @@ -309,15 +302,6 @@ fn parse_vless_url(url: &str) -> Result> { }) }); - let mut parameters = HashMap::new(); - parameters.insert( - "uuid".to_string(), - serde_json::Value::String(uuid.to_string()), - ); - if let Some(ref f) = flow { - parameters.insert("flow".to_string(), serde_json::Value::String(f.clone())); - } - Ok(Some(ProxyServer { name, protocol: "vless".to_string(), @@ -325,12 +309,12 @@ fn parse_vless_url(url: &str) -> Result> { port, password: None, method: None, - parameters, params: ProxyParams::Vless { uuid: uuid.to_string(), flow, tls, transport, + extras: HashMap::new(), }, })) } @@ -393,8 +377,6 @@ fn parse_hysteria2_url(url: &str) -> Result> { alpn: None, }); - let parameters = HashMap::new(); - Ok(Some(ProxyServer { name, protocol: "hysteria2".to_string(), @@ -402,10 +384,10 @@ fn parse_hysteria2_url(url: &str) -> Result> { port, password: Some(password.to_string()), method: None, - parameters, params: ProxyParams::Hysteria2 { obfs_password, tls, + extras: HashMap::new(), }, })) } diff --git a/src/protocols/v2ray/template_processor.rs b/src/protocols/v2ray/template_processor.rs index f08afda..b2427c2 100644 --- a/src/protocols/v2ray/template_processor.rs +++ b/src/protocols/v2ray/template_processor.rs @@ -82,16 +82,16 @@ impl ProtocolProcessor for V2RayProcessor { serde_json::Value::String("vmess".to_string()), ); - let uuid = node.parameters.get("uuid") + let uuid = node.extras().get("uuid") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); - let alter_id = node.parameters.get("alterId") - .or(node.parameters.get("alter_id")) + let alter_id = node.extras().get("alterId") + .or(node.extras().get("alter_id")) .cloned() .unwrap_or(serde_json::Value::Number(0.into())); let security = node.method.clone() - .or_else(|| node.parameters.get("security").and_then(|v| v.as_str()).map(|s| s.to_string())) + .or_else(|| node.extras().get("security").and_then(|v| v.as_str()).map(|s| s.to_string())) .unwrap_or_else(|| "auto".to_string()); let mut user = serde_json::Map::new(); @@ -147,7 +147,7 @@ impl ProtocolProcessor for V2RayProcessor { "protocol".to_string(), serde_json::Value::String(node.protocol.clone()), ); - for (key, value) in &node.parameters { + for (key, value) in node.extras() { config.insert(key.clone(), value.clone()); } } @@ -155,7 +155,7 @@ impl ProtocolProcessor for V2RayProcessor { // Add streamSettings if tls or transport parameters are present let mut stream_settings = serde_json::Map::new(); - if let Some(tls) = node.parameters.get("tls") { + if let Some(tls) = node.extras().get("tls") { if let Some(tls_obj) = tls.as_object() { let enabled = tls_obj.get("enabled") .and_then(|v| v.as_bool()) @@ -182,7 +182,7 @@ impl ProtocolProcessor for V2RayProcessor { } } - if let Some(transport) = node.parameters.get("transport") { + if let Some(transport) = node.extras().get("transport") { if let Some(transport_obj) = transport.as_object() { if let Some(transport_type) = transport_obj.get("type").and_then(|v| v.as_str()) { stream_settings.insert( @@ -262,9 +262,9 @@ mod tests { #[test] fn test_create_vmess_node_config() { let processor = V2RayProcessor; - let mut params = HashMap::new(); - params.insert("uuid".to_string(), serde_json::Value::String("test-uuid".to_string())); - params.insert("alterId".to_string(), serde_json::Value::Number(0.into())); + let mut extras = HashMap::new(); + extras.insert("uuid".to_string(), serde_json::Value::String("test-uuid".to_string())); + extras.insert("alterId".to_string(), serde_json::Value::Number(0.into())); let server = ProxyServer { name: "test".to_string(), protocol: "vmess".to_string(), @@ -272,8 +272,7 @@ mod tests { port: 443, password: None, method: None, - parameters: params, - params: ProxyParams::Generic, + params: ProxyParams::Generic { extras }, }; let config = processor.create_node_config(&server); let parsed: serde_json::Value = serde_json::from_str(&config).unwrap(); @@ -293,8 +292,7 @@ mod tests { port: 8443, password: Some("my-pass".to_string()), method: None, - parameters: HashMap::new(), - params: ProxyParams::Generic, + params: ProxyParams::Generic { extras: HashMap::new() }, }; let config = processor.create_node_config(&server); let parsed: serde_json::Value = serde_json::from_str(&config).unwrap(); diff --git a/tests/subscription_test.rs b/tests/subscription_test.rs index 79cbbd7..8b41679 100644 --- a/tests/subscription_test.rs +++ b/tests/subscription_test.rs @@ -82,6 +82,7 @@ fn test_parse_vmess_preserves_uuid_in_params() { security, tls, transport, + .. } => { assert_eq!(uuid, "550e8400-e29b-41d4-a716-446655440000"); assert_eq!(*alter_id, None); @@ -100,7 +101,7 @@ fn test_parse_trojan_params_have_no_tls() { subscription::parse_plain_text("trojan://secret@host.com:443#TJ\n").unwrap(); assert_eq!(servers.len(), 1); match &servers[0].params { - ProxyParams::Trojan { tls, transport } => { + ProxyParams::Trojan { tls, transport, .. } => { assert!(tls.is_none()); assert!(transport.is_none()); } @@ -121,6 +122,7 @@ fn test_parse_ss_params_have_cipher() { udp, plugin, plugin_opts, + .. } => { assert_eq!(cipher, "chacha20-ietf-poly1305"); assert_eq!(*udp, None); @@ -148,6 +150,7 @@ fn test_parse_vless_url() { flow, tls, transport, + .. } => { assert_eq!(uuid, "uuid-123"); assert!(flow.is_none()); @@ -192,7 +195,7 @@ fn test_parse_hysteria2_url() { assert_eq!(servers[0].port, 443); assert_eq!(servers[0].password.as_deref(), Some("mypassword")); match &servers[0].params { - ProxyParams::Hysteria2 { tls, obfs_password } => { + ProxyParams::Hysteria2 { tls, obfs_password, .. } => { assert!(obfs_password.is_none()); let tls = tls.as_ref().unwrap(); assert!(tls.enabled); From ebf8d427cdd6fcefa7912ebcfffd00ca52344c24 Mon Sep 17 00:00:00 2001 From: messica Date: Fri, 1 May 2026 16:57:14 +0800 Subject: [PATCH 6/8] refactor: phase 3d structured SourceMeta.location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously SourceMeta.source held the raw user input (e.g. "https://x/y?type=clash&token=..&flag=..") and downstream code re-parsed it twice: loader used starts_with("http") to branch, then sliced at '?' for file paths; URL flag manipulation parsed the same string again. Now SourceMeta carries a typed SourceLocation { Url(url::Url) | File(PathBuf) }, computed once in parse_source_string. The parser also splits synthetic keys (type/name/flag) from the user's own URL query params — our keys never reach the remote server, and with_flag_param operates on url::Url directly without re-parsing. The raw `source` string is kept purely for log/display output (tests still assert on it). --- src/commands/convert.rs | 30 ++++++- src/core/source.rs | 27 ++++++- src/protocols/singbox/template_processor.rs | 2 + src/utils/source/loader.rs | 88 +++++++++------------ tests/parser_test.rs | 3 +- 5 files changed, 93 insertions(+), 57 deletions(-) diff --git a/src/commands/convert.rs b/src/commands/convert.rs index 1636e40..10c6805 100644 --- a/src/commands/convert.rs +++ b/src/commands/convert.rs @@ -105,7 +105,14 @@ impl ConvertCommand { } /// Parse source string: only URL form ?type=...&name=...&flag=... (type required in query). + /// + /// Extracts our synthetic query keys (`type`/`name`/`flag`) and builds a + /// typed `SourceLocation` so downstream code doesn't have to re-parse the + /// raw string. Any other query params belong to the remote URL and are + /// preserved when the source is an HTTP(S) URL. pub fn parse_source_string(raw: &str) -> Result { + use crate::core::source::SourceLocation; + let raw = raw.trim(); let pos = raw.find('?').ok_or_else(|| { ConvertError::ConfigValidationError(format!( @@ -113,17 +120,18 @@ impl ConvertCommand { raw )) })?; - let (_base, query_str) = raw.split_at(pos); + let (base, query_str) = raw.split_at(pos); let query_str = query_str.trim_start_matches('?'); let mut name: Option = None; let mut type_param: Option = None; let mut flag: Option = None; + let mut external_query: Vec<(String, String)> = Vec::new(); for (k, v) in url::form_urlencoded::parse(query_str.as_bytes()) { match k.as_ref() { "name" => name = Some(v.into_owned()), "type" => type_param = Some(v.into_owned()), "flag" => flag = Some(v.into_owned()), - _ => {} + _ => external_query.push((k.into_owned(), v.into_owned())), } } let source_type_str = type_param.ok_or_else(|| { @@ -132,10 +140,26 @@ impl ConvertCommand { ) })?; let source_type = Protocol::from_str(&source_type_str)?; - // Keep full string (path|url + all query params); type/name/flag are parsed out but remain in source + + let location = if base.starts_with("http://") || base.starts_with("https://") { + let mut u = url::Url::parse(base).map_err(|e| { + ConvertError::ConfigValidationError(format!("Invalid URL {}: {}", base, e)) + })?; + if !external_query.is_empty() { + u.query_pairs_mut().clear(); + for (k, v) in &external_query { + u.query_pairs_mut().append_pair(k, v); + } + } + SourceLocation::Url(u) + } else { + SourceLocation::File(std::path::PathBuf::from(base)) + }; + Ok(SourceMeta { name: name.filter(|s| !s.is_empty()), source_type, + location, source: raw.to_string(), format: None, flag, diff --git a/src/core/source.rs b/src/core/source.rs index 93bc73c..0535345 100644 --- a/src/core/source.rs +++ b/src/core/source.rs @@ -4,18 +4,41 @@ //! Kept in core to avoid utils depending on commands. use std::fmt; +use std::path::PathBuf; use std::str::FromStr; use crate::core::error::ConvertError; -/// Source metadata: path/url + query params (type, name, flag). +/// Where this source lives. Computed once at parse time so downstream +/// code never has to re-inspect the raw `source` string. +#[derive(Debug, Clone)] +pub enum SourceLocation { + /// Remote URL. Our synthetic query keys (`type`/`name`/`flag`) are + /// stripped; the user's own query params (tokens, etc.) stay intact. + Url(url::Url), + /// Local file path (query portion stripped). + File(PathBuf), +} + +impl fmt::Display for SourceLocation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SourceLocation::Url(u) => f.write_str(u.as_str()), + SourceLocation::File(p) => write!(f, "{}", p.display()), + } + } +} + +/// Source metadata: typed location + structured query params. #[derive(Debug, Clone)] pub struct SourceMeta { /// Optional display name for multi-source distinction pub name: Option, /// Protocol type of this source (clash, sing-box, v2ray) pub source_type: Protocol, - /// Full source string: ?type=...&name=...&flag=... + /// Typed URL or file path — downstream reads from here. + pub location: SourceLocation, + /// Raw source string as the user typed it (kept verbatim for logs). pub source: String, /// Explicit format override; if None, derived from source_type pub format: Option, diff --git a/src/protocols/singbox/template_processor.rs b/src/protocols/singbox/template_processor.rs index b10a80a..e904d24 100644 --- a/src/protocols/singbox/template_processor.rs +++ b/src/protocols/singbox/template_processor.rs @@ -305,6 +305,7 @@ mod tests { meta: SourceMeta { name: Some("clash1".to_string()), source_type: Protocol::Clash, + location: crate::core::source::SourceLocation::File("./clash.yaml".into()), source: "./clash.yaml".to_string(), format: None, flag: None, @@ -330,6 +331,7 @@ mod tests { meta: SourceMeta { name: Some("singbox1".to_string()), source_type: Protocol::SingBox, + location: crate::core::source::SourceLocation::File("./singbox.json".into()), source: "./singbox.json".to_string(), format: None, flag: None, diff --git a/src/utils/source/loader.rs b/src/utils/source/loader.rs index d8ffdb8..2a07349 100644 --- a/src/utils/source/loader.rs +++ b/src/utils/source/loader.rs @@ -2,10 +2,9 @@ use crate::core::config::AppConfig; use crate::core::error::{ConvertError, Result}; -use crate::core::source::{Protocol, SourceMeta}; -use crate::protocols::{ProtocolRegistry, FORMAT_PLAIN, FORMAT_SUBSCRIPTION}; +use crate::core::source::{Protocol, SourceLocation, SourceMeta}; use crate::protocols::source::{Config, Source}; -use std::path::Path; +use crate::protocols::{ProtocolRegistry, FORMAT_PLAIN, FORMAT_SUBSCRIPTION}; use std::str::FromStr; use url::Url; @@ -34,21 +33,19 @@ impl SourceLoader { source_meta: &SourceMeta, config: &AppConfig, ) -> Result { - let source = &source_meta.source; - if source.starts_with("http://") || source.starts_with("https://") { - let url_with_flag = with_flag_param( - source, - source_meta.source_type, - source_meta.flag.as_deref(), - )?; - // Subscription panels (v2board/xboard/sspanel) route responses by UA. - // Pick a protocol-matched default so the request isn't silently dropped. - let ua = effective_user_agent(source_meta.source_type, source_meta.flag.as_deref(), config); - Self::load_from_url(&url_with_flag, &ua, config).await - } else { - // File path: drop any query string that was kept on `source` for reference. - let path = source.find('?').map(|i| &source[..i]).unwrap_or(source.as_str()); - Self::load_from_file(path) + match &source_meta.location { + SourceLocation::Url(url) => { + let url_with_flag = with_flag_param( + url.clone(), + source_meta.source_type, + source_meta.flag.as_deref(), + ); + // Subscription panels (v2board/xboard/sspanel) route responses by UA. + // Pick a protocol-matched default so the request isn't silently dropped. + let ua = effective_user_agent(source_meta.source_type, source_meta.flag.as_deref(), config); + Self::load_from_url(url_with_flag.as_str(), &ua, config).await + } + SourceLocation::File(path) => Self::load_from_file(path), } } @@ -81,10 +78,9 @@ impl SourceLoader { .map_err(|e| ConvertError::network_error(&e.to_string())) } - fn load_from_file(file_path: &str) -> Result { - let path = Path::new(file_path); + fn load_from_file(path: &std::path::Path) -> Result { if !path.exists() { - return Err(ConvertError::file_not_found(file_path)); + return Err(ConvertError::file_not_found(&path.display().to_string())); } Ok(std::fs::read_to_string(path)?) } @@ -112,12 +108,9 @@ fn effective_user_agent(source_type: Protocol, flag_override: Option<&str>, conf if !ua.is_empty() { return ua.to_string(); } - // Derive from flag if the user overrode it, otherwise from source_type. let kind = flag_override .and_then(|s| Protocol::from_str(s).ok()) .unwrap_or(source_type); - // Name-only; subscription panels typically match on the keyword, not the version. - // Users who hit a version-strict panel can override via config.user_agent. match kind { Protocol::SingBox => "sing-box".to_string(), Protocol::Clash => "mihomo".to_string(), @@ -136,20 +129,11 @@ fn default_flag_for(protocol: Protocol) -> &'static str { } /// Ensure the URL carries `flag=`, replacing any existing value. -/// Uses `url::Url` so encoding, fragments, and other params round-trip cleanly. -fn with_flag_param( - raw_url: &str, - protocol: Protocol, - flag_override: Option<&str>, -) -> Result { +fn with_flag_param(mut url: Url, protocol: Protocol, flag_override: Option<&str>) -> Url { let flag_value = flag_override .map(|s| s.to_string()) .unwrap_or_else(|| default_flag_for(protocol).to_string()); - let mut url = Url::parse(raw_url) - .map_err(|e| ConvertError::ConfigValidationError(format!("Invalid URL {}: {}", raw_url, e)))?; - - // Preserve every param except `flag`, then append the new flag. let preserved: Vec<(String, String)> = url .query_pairs() .filter(|(k, _)| k != "flag") @@ -160,45 +144,48 @@ fn with_flag_param( url.query_pairs_mut().append_pair(&k, &v); } url.query_pairs_mut().append_pair("flag", &flag_value); - - Ok(url.into()) + url } #[cfg(test)] mod tests { use super::*; + fn parse(u: &str) -> Url { + Url::parse(u).unwrap() + } + #[test] fn adds_flag_when_absent() { - let out = with_flag_param("https://example.com/sub", Protocol::SingBox, None).unwrap(); - assert!(out.contains("flag=sing-box")); + let out = with_flag_param(parse("https://example.com/sub"), Protocol::SingBox, None); + assert!(out.as_str().contains("flag=sing-box")); } #[test] fn replaces_existing_flag() { let out = with_flag_param( - "https://example.com/sub?token=abc&flag=clash", + parse("https://example.com/sub?token=abc&flag=clash"), Protocol::SingBox, None, - ) - .unwrap(); - assert!(out.contains("token=abc")); - assert!(out.contains("flag=sing-box")); - assert!(!out.contains("flag=clash")); + ); + let s = out.as_str(); + assert!(s.contains("token=abc")); + assert!(s.contains("flag=sing-box")); + assert!(!s.contains("flag=clash")); } #[test] fn override_wins_over_protocol_default() { - let out = with_flag_param("https://example.com/sub", Protocol::Clash, Some("custom")) - .unwrap(); - assert!(out.contains("flag=custom")); + let out = with_flag_param(parse("https://example.com/sub"), Protocol::Clash, Some("custom")); + assert!(out.as_str().contains("flag=custom")); } #[test] fn preserves_fragment() { - let out = with_flag_param("https://example.com/sub#frag", Protocol::V2Ray, None).unwrap(); - assert!(out.contains("flag=v2ray")); - assert!(out.ends_with("#frag")); + let out = with_flag_param(parse("https://example.com/sub#frag"), Protocol::V2Ray, None); + let s = out.as_str(); + assert!(s.contains("flag=v2ray")); + assert!(s.ends_with("#frag")); } #[test] @@ -219,7 +206,6 @@ mod tests { #[test] fn effective_ua_uses_flag_override_for_protocol_derivation() { let cfg = AppConfig::default(); - // Source is SingBox but flag override says v2ray → UA matches flag. assert_eq!( effective_user_agent(Protocol::SingBox, Some("v2ray"), &cfg), "v2rayN" diff --git a/tests/parser_test.rs b/tests/parser_test.rs index 966b79b..bf9f9bd 100644 --- a/tests/parser_test.rs +++ b/tests/parser_test.rs @@ -5,11 +5,12 @@ use proxy_convert::protocols::{clash, singbox, ProxyParams}; use proxy_convert::protocols::source::{Config, Source}; fn make_source(config: Config, source_type: Protocol) -> Source { - use proxy_convert::core::source::SourceMeta; + use proxy_convert::core::source::{SourceLocation, SourceMeta}; Source { meta: SourceMeta { name: Some("test".into()), source_type, + location: SourceLocation::File("test".into()), source: "test".into(), format: None, flag: None, From 04d3fce49c7146afe03948384619d8d480947532 Mon Sep 17 00:00:00 2001 From: messica Date: Fri, 1 May 2026 17:02:48 +0800 Subject: [PATCH 7/8] refactor: phase 4 classify network errors + implement retry NetworkError grows from a single String bag into a structured variant { kind: NetworkErrorKind, url: Option, detail: String }: - Timeout, Connect, Status(u16), Tls, Other - is_retryable() marks transient failures (timeout, connect, 5xx, 429) - format_error hints are kind-specific: a 403/401 now points at User-Agent filtering (the exact failure mode we saw on the subscription panel) instead of the generic "check your network" SourceLoader::load_from_url now honors config.retry_count (previously declared-but-unused) with exponential backoff (250ms, 500ms, 1s, capped at 4s). 4xx/DNS errors skip the retry loop. Add wiremock integration tests (tests/url_fetch_test.rs): - happy-path HTTP fetch + parse - 403 surfaces as Status(403) variant (regression guard for the UA-filtering scenario) - transient 503 is retried and succeeds on the second attempt - 404 is NOT retried (expect(1) asserts the hit count) --- src/core/error.rs | 119 +++++++++++++++++++++++++++------ src/utils/source/loader.rs | 87 +++++++++++++++++------- tests/url_fetch_test.rs | 133 +++++++++++++++++++++++++++++++++++++ 3 files changed, 294 insertions(+), 45 deletions(-) create mode 100644 tests/url_fetch_test.rs diff --git a/src/core/error.rs b/src/core/error.rs index 86168a7..c1e26ad 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -2,6 +2,37 @@ use thiserror::Error; +/// Why a network fetch failed. Kept structured so retry logic can classify +/// transient vs permanent failures and so user-facing messages can be +/// targeted (e.g. a 403 on a subscription URL suggests User-Agent filtering, +/// not an unreachable host). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NetworkErrorKind { + /// HTTP request timed out (connect or read timeout). + Timeout, + /// Couldn't open a connection (DNS failure, refused, unreachable). + Connect, + /// Server returned a non-2xx HTTP status code. + Status(u16), + /// TLS handshake / certificate problem. + Tls, + /// Everything else (SDK-level decode errors etc.). + Other, +} + +impl NetworkErrorKind { + /// Transient failures are retryable with backoff; permanent ones aren't. + pub fn is_retryable(&self) -> bool { + match self { + NetworkErrorKind::Timeout | NetworkErrorKind::Connect => true, + // 5xx = server-side / gateway issue, worth retrying. + // 429 = rate-limited — retry with backoff. + NetworkErrorKind::Status(code) => *code >= 500 || *code == 429, + NetworkErrorKind::Tls | NetworkErrorKind::Other => false, + } + } +} + #[derive(Error, Debug)] pub enum ConvertError { #[error("JSON parse error: {0}")] @@ -19,8 +50,12 @@ pub enum ConvertError { #[error("Source error: {0}")] SourceError(String), - #[error("Network error: {0}")] - NetworkError(String), + #[error("Network error: {detail}")] + NetworkError { + kind: NetworkErrorKind, + url: Option, + detail: String, + }, #[error("Config error: {0}")] ConfigValidationError(String), @@ -41,34 +76,55 @@ impl ConvertError { Self::SourceError(msg.to_string()) } + /// Convenience for unclassified network errors (back-compat entry point). pub fn network_error(msg: &str) -> Self { - Self::NetworkError(msg.to_string()) + Self::NetworkError { + kind: NetworkErrorKind::Other, + url: None, + detail: msg.to_string(), + } + } + + pub fn network(kind: NetworkErrorKind, url: Option, detail: impl Into) -> Self { + Self::NetworkError { + kind, + url, + detail: detail.into(), + } } - /// Format error for display + /// Format error for display with user-facing hints. pub fn format_error(&self) -> String { match self { ConvertError::FileNotFound(path) => { format!("File not found: {}\n Please check if the file path is correct.", path) } - ConvertError::IoError(e) => { - format!("IO error: {}", e) - } + ConvertError::IoError(e) => format!("IO error: {}", e), ConvertError::JsonParseError(e) => { format!("JSON parse error: {}\n Please check if the file format is valid JSON.", e) } - ConvertError::TemplateError(msg) => { - format!("Template error: {}", msg) - } - ConvertError::SourceError(msg) => { - format!("Source error: {}", msg) - } - ConvertError::NetworkError(msg) => { - format!("Network error: {}\n Please check your network connection.", msg) - } - ConvertError::ConfigValidationError(msg) => { - format!("Config error: {}", msg) + ConvertError::TemplateError(msg) => format!("Template error: {}", msg), + ConvertError::SourceError(msg) => format!("Source error: {}", msg), + ConvertError::NetworkError { kind, url, detail } => { + let hint = match kind { + NetworkErrorKind::Timeout => "The request took too long. Try increasing `timeout_seconds` in config, or check that the URL is reachable.", + NetworkErrorKind::Connect => "Could not reach the server. Check DNS resolution, proxy settings, and firewall rules.", + NetworkErrorKind::Status(403) | NetworkErrorKind::Status(401) => { + "The server rejected the request. Subscription panels often filter by User-Agent — try setting `user_agent` in config." + } + NetworkErrorKind::Status(404) => "Subscription not found. Double-check the URL path.", + NetworkErrorKind::Status(429) => "Rate limited. The tool will back off automatically; retry later if this persists.", + NetworkErrorKind::Status(code) if *code >= 500 => "The server returned a 5xx error. Often transient — try again shortly.", + NetworkErrorKind::Status(_) => "Unexpected HTTP status.", + NetworkErrorKind::Tls => "TLS handshake failed. The server's certificate may be invalid or your system CA bundle may be outdated.", + NetworkErrorKind::Other => "Please check your network connection.", + }; + match url { + Some(u) => format!("Network error: {}\n URL: {}\n {}", detail, u, hint), + None => format!("Network error: {}\n {}", detail, hint), + } } + ConvertError::ConfigValidationError(msg) => format!("Config error: {}", msg), } } } @@ -104,7 +160,6 @@ mod tests { fn test_convert_error_from_serde_json() { let json_error = serde_json::from_str::("invalid json"); assert!(json_error.is_err()); - if let Err(json_err) = json_error { let convert_error: ConvertError = json_err.into(); match convert_error { @@ -118,7 +173,6 @@ mod tests { fn test_convert_error_from_io() { let io_error = std::fs::read_to_string("/nonexistent/file"); assert!(io_error.is_err()); - if let Err(io_err) = io_error { let convert_error: ConvertError = io_err.into(); match convert_error { @@ -133,11 +187,9 @@ mod tests { fn test_function() -> Result { Ok("test".to_string()) } - fn test_error_function() -> Result { Err(ConvertError::ConfigValidationError("test error".to_string())) } - assert!(test_function().is_ok()); assert!(test_error_function().is_err()); } @@ -149,4 +201,27 @@ mod tests { assert!(formatted.contains("File not found")); assert!(formatted.contains("test.json")); } + + #[test] + fn network_retry_classification() { + assert!(NetworkErrorKind::Timeout.is_retryable()); + assert!(NetworkErrorKind::Connect.is_retryable()); + assert!(NetworkErrorKind::Status(503).is_retryable()); + assert!(NetworkErrorKind::Status(429).is_retryable()); + assert!(!NetworkErrorKind::Status(404).is_retryable()); + assert!(!NetworkErrorKind::Status(403).is_retryable()); + assert!(!NetworkErrorKind::Tls.is_retryable()); + } + + #[test] + fn status_403_hint_mentions_user_agent() { + let err = ConvertError::network( + NetworkErrorKind::Status(403), + Some("https://example.com".into()), + "Forbidden", + ); + let msg = err.format_error(); + assert!(msg.contains("User-Agent"), "403 hint should mention User-Agent filtering, got: {msg}"); + assert!(msg.contains("https://example.com")); + } } diff --git a/src/utils/source/loader.rs b/src/utils/source/loader.rs index 2a07349..f8dddc5 100644 --- a/src/utils/source/loader.rs +++ b/src/utils/source/loader.rs @@ -1,11 +1,12 @@ //! Source loader for loading and parsing configurations. use crate::core::config::AppConfig; -use crate::core::error::{ConvertError, Result}; +use crate::core::error::{ConvertError, NetworkErrorKind, Result}; use crate::core::source::{Protocol, SourceLocation, SourceMeta}; use crate::protocols::source::{Config, Source}; use crate::protocols::{ProtocolRegistry, FORMAT_PLAIN, FORMAT_SUBSCRIPTION}; use std::str::FromStr; +use std::time::Duration; use url::Url; pub struct SourceLoader; @@ -50,32 +51,42 @@ impl SourceLoader { } async fn load_from_url(url: &str, user_agent: &str, config: &AppConfig) -> Result { - tracing::info!("Fetching URL: {} (UA: {})", url, user_agent); - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(config.timeout_seconds)) + .timeout(Duration::from_secs(config.timeout_seconds)) .user_agent(user_agent) .build() - .map_err(|e| ConvertError::network_error(&e.to_string()))?; - - let response = client - .get(url) - .send() - .await - .map_err(|e| ConvertError::network_error(&e.to_string()))?; - - if !response.status().is_success() { - return Err(ConvertError::network_error(&format!( - "Failed to fetch URL: {} - Status: {}", - url, - response.status() - ))); + .map_err(|e| ConvertError::network(NetworkErrorKind::Other, Some(url.into()), e.to_string()))?; + + // Retry transient failures (timeout / connect / 5xx / 429). Attempts + // cap at `config.retry_count + 1` total (first try + N retries). + let mut attempt: u32 = 0; + let max_attempts = config.retry_count.saturating_add(1); + loop { + attempt += 1; + tracing::info!( + "Fetching URL (attempt {}/{}): {} (UA: {})", + attempt, max_attempts, url, user_agent + ); + match fetch_once(&client, url).await { + Ok(body) => return Ok(body), + Err(err) => { + let retryable = matches!(&err, + ConvertError::NetworkError { kind, .. } if kind.is_retryable()); + if !retryable || attempt >= max_attempts { + return Err(err); + } + // Exponential backoff: 250ms, 500ms, 1000ms, capped at 4s. + let backoff = Duration::from_millis( + (250u64 * (1u64 << (attempt - 1).min(4))).min(4000), + ); + tracing::warn!( + "Fetch attempt {} failed ({}), retrying after {:?}", + attempt, err, backoff + ); + tokio::time::sleep(backoff).await; + } + } } - - response - .text() - .await - .map_err(|e| ConvertError::network_error(&e.to_string())) } fn load_from_file(path: &std::path::Path) -> Result { @@ -101,6 +112,36 @@ impl SourceLoader { } } +/// Perform a single HTTP GET, translating reqwest errors into a classified +/// `ConvertError::NetworkError` so retry logic and user-facing hints can act +/// on the kind. +async fn fetch_once(client: &reqwest::Client, url: &str) -> Result { + let response = client.get(url).send().await.map_err(|e| { + let kind = if e.is_timeout() { + NetworkErrorKind::Timeout + } else if e.is_connect() { + NetworkErrorKind::Connect + } else { + NetworkErrorKind::Other + }; + ConvertError::network(kind, Some(url.into()), e.to_string()) + })?; + + let status = response.status(); + if !status.is_success() { + return Err(ConvertError::network( + NetworkErrorKind::Status(status.as_u16()), + Some(url.into()), + format!("HTTP {}", status), + )); + } + + response + .text() + .await + .map_err(|e| ConvertError::network(NetworkErrorKind::Other, Some(url.into()), e.to_string())) +} + /// Choose the User-Agent to send with subscription requests. /// Precedence: explicit `config.user_agent` (non-empty) > protocol-matched default. fn effective_user_agent(source_type: Protocol, flag_override: Option<&str>, config: &AppConfig) -> String { diff --git a/tests/url_fetch_test.rs b/tests/url_fetch_test.rs new file mode 100644 index 0000000..a27d911 --- /dev/null +++ b/tests/url_fetch_test.rs @@ -0,0 +1,133 @@ +//! Integration tests for URL-based source fetching using wiremock. +//! +//! Covers the scenarios that historically caused production issues: +//! - panels that filter by User-Agent (403 without the right UA) +//! - retry behavior on transient 5xx +//! - error classification / message hints + +use proxy_convert::commands::convert::ConvertCommand; +use proxy_convert::core::config::AppConfig; +use proxy_convert::core::error::{ConvertError, NetworkErrorKind}; +use proxy_convert::protocols::ProtocolRegistry; +use proxy_convert::utils::source::SourceLoader; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn config_with(timeout: u64, retries: u32) -> AppConfig { + AppConfig { + timeout_seconds: timeout, + retry_count: retries, + ..AppConfig::default() + } +} + +fn minimal_singbox_body() -> &'static str { + r#"{ + "inbounds": [], + "outbounds": [ + {"type": "direct", "tag": "direct"}, + {"type": "shadowsocks", "tag": "ss1", "server": "1.1.1.1", "server_port": 443, "method": "aes-256-gcm", "password": "secret"} + ] + }"# +} + +#[tokio::test] +async fn fetches_http_subscription_successfully() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/sub")) + .respond_with(ResponseTemplate::new(200).set_body_string(minimal_singbox_body())) + .mount(&server) + .await; + + let source_str = format!("{}/sub?type=singbox", server.uri()); + let meta = ConvertCommand::parse_source_string(&source_str).unwrap(); + let registry = ProtocolRegistry::init(); + let config = config_with(5, 0); + + let source = SourceLoader::load_source(&meta, ®istry, &config).await.unwrap(); + let servers = source.extract_servers().unwrap(); + assert!(servers.iter().any(|s| s.protocol == "shadowsocks")); +} + +#[tokio::test] +async fn status_403_reports_as_status_kind() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/blocked")) + .respond_with(ResponseTemplate::new(403)) + .mount(&server) + .await; + + let source_str = format!("{}/blocked?type=singbox", server.uri()); + let meta = ConvertCommand::parse_source_string(&source_str).unwrap(); + let registry = ProtocolRegistry::init(); + let config = config_with(5, 0); + + let err = SourceLoader::load_source(&meta, ®istry, &config) + .await + .expect_err("403 should error"); + + match err { + ConvertError::NetworkError { kind, .. } => { + assert_eq!(kind, NetworkErrorKind::Status(403)); + } + other => panic!("expected NetworkError, got {:?}", other), + } +} + +#[tokio::test] +async fn retries_transient_5xx_then_succeeds() { + let server = MockServer::start().await; + // First call → 503, second call → 200. `expect` bounds ensure both are hit. + Mock::given(method("GET")) + .and(path("/sub")) + .respond_with(ResponseTemplate::new(503)) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/sub")) + .respond_with(ResponseTemplate::new(200).set_body_string(minimal_singbox_body())) + .expect(1) + .mount(&server) + .await; + + let source_str = format!("{}/sub?type=singbox", server.uri()); + let meta = ConvertCommand::parse_source_string(&source_str).unwrap(); + let registry = ProtocolRegistry::init(); + // 1 retry is enough — first attempt hits the 503 mock, retry hits the 200. + let config = config_with(5, 1); + + let source = SourceLoader::load_source(&meta, ®istry, &config) + .await + .expect("retry should succeed on second try"); + assert!(!source.extract_servers().unwrap().is_empty()); +} + +#[tokio::test] +async fn does_not_retry_client_errors() { + let server = MockServer::start().await; + // 404 is permanent — even with retries=3, we should only see 1 request. + Mock::given(method("GET")) + .and(path("/missing")) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&server) + .await; + + let source_str = format!("{}/missing?type=singbox", server.uri()); + let meta = ConvertCommand::parse_source_string(&source_str).unwrap(); + let registry = ProtocolRegistry::init(); + let config = config_with(5, 3); + + let err = SourceLoader::load_source(&meta, ®istry, &config) + .await + .expect_err("404 should error"); + match err { + ConvertError::NetworkError { kind: NetworkErrorKind::Status(404), .. } => {} + other => panic!("expected Status(404), got {:?}", other), + } + // Mock's `expect(1)` is verified on drop — wiremock will panic if hit count differs. +} From 6b2dcd211addcfad1f6c49157ddbf0f30bc9a039 Mon Sep 17 00:00:00 2001 From: messica Date: Fri, 1 May 2026 17:37:47 +0800 Subject: [PATCH 8/8] ci: bump actions to Node 24 runtimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub deprecated Node 20 actions — runners will force Node 24 from June 2nd 2026 and remove Node 20 entirely on September 16th 2026. Move every flagged action to its Node-24 release line: - actions/checkout v4 -> v6 - Swatinem/rust-cache v2.8.2 -> v2.9.1 - actions/upload-artifact v4 -> v7 - actions/download-artifact v4 -> v7 dtolnay/rust-toolchain@v1 and softprops/action-gh-release@v2 were not flagged and stay as-is. Note: upload/download-artifact v5+ require Actions Runner 2.327.1 or newer. GitHub-hosted runners already satisfy this; self-hosted ones need to be up-to-date. --- .github/workflows/build.yml | 4 ++-- .github/workflows/release.yml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6871253..de5472e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,7 +36,7 @@ jobs: runner: windows-11-arm steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@v1 @@ -45,7 +45,7 @@ jobs: targets: ${{ matrix.target }} - name: Cache cargo - uses: Swatinem/rust-cache@v2.8.2 + uses: Swatinem/rust-cache@v2.9.1 with: key: ${{ matrix.target }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0451d61..927bbc0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@v1 @@ -47,7 +47,7 @@ jobs: targets: ${{ matrix.target }} - name: Cache cargo - uses: Swatinem/rust-cache@v2.8.2 + uses: Swatinem/rust-cache@v2.9.1 with: key: ${{ matrix.target }} shared-key: release @@ -120,7 +120,7 @@ jobs: echo "PACKAGE_PATH=release/$out" >> $GITHUB_ENV - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.target }} path: ${{ env.PACKAGE_PATH }} @@ -134,7 +134,7 @@ jobs: steps: - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: path: artifacts