mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
feat(config): add core utilities for config merge and difference extraction
Add comprehensive config merge utilities for both Rust backend and TypeScript frontend to support the new common config architecture where customConfig overrides commonConfig. Backend (Rust): - Add config_merge.rs module with JSON and TOML merge functions - Support deep merge where source overrides target for nested objects - Add extract_difference functions to identify custom-only keys - Include compute_final_*_config functions for runtime merge calculation - Register module in lib.rs Frontend (TypeScript): - Add configMerge.ts with isPlainObject, deepClone, deepEqual, deepMerge utilities - Implement computeFinalConfig for merging common + custom configs - Add extractDifference to identify keys unique to live config - Add tomlConfigMerge.ts with TOML-specific merge/extract functions - Use smol-toml for parsing and serialization These utilities form the foundation for the refactored common config system where providers store only custom config and merge with shared templates at runtime.
This commit is contained in:
@@ -0,0 +1,674 @@
|
||||
//! Configuration merge utilities for the common config redesign.
|
||||
//!
|
||||
//! This module provides functions for:
|
||||
//! - `compute_final_config`: Merges common config (base) with custom config (override)
|
||||
//! - `extract_difference`: Extracts custom parts from live config by comparing with common config
|
||||
//!
|
||||
//! Supports JSON (Claude, Gemini) and TOML (Codex) formats.
|
||||
|
||||
// Allow dead code as this is a utility module with functions available for future use
|
||||
#![allow(dead_code)]
|
||||
|
||||
use serde_json::{Map, Value as JsonValue};
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
// ============================================================================
|
||||
// JSON Configuration Merge Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Deep merge two JSON objects where `source` overrides `target`.
|
||||
///
|
||||
/// Merge rules:
|
||||
/// - Nested objects: Recursive merge
|
||||
/// - Arrays: Source completely replaces target (no element-level merge)
|
||||
/// - Primitives: Source overrides target
|
||||
/// - Null values: Do not override
|
||||
fn deep_merge_json(target: &mut JsonValue, source: &JsonValue) {
|
||||
// First check if both are objects without destructuring
|
||||
let both_objects =
|
||||
matches!(target, JsonValue::Object(_)) && matches!(source, JsonValue::Object(_));
|
||||
|
||||
if both_objects {
|
||||
// Safe to destructure now since we know both are objects
|
||||
if let (JsonValue::Object(target_map), JsonValue::Object(source_map)) = (target, source) {
|
||||
for (key, source_value) in source_map {
|
||||
if source_value.is_null() {
|
||||
// Null doesn't override
|
||||
continue;
|
||||
}
|
||||
match target_map.get_mut(key) {
|
||||
Some(target_value) if target_value.is_object() && source_value.is_object() => {
|
||||
// Nested object: recursive merge
|
||||
deep_merge_json(target_value, source_value);
|
||||
}
|
||||
_ => {
|
||||
// Other cases: source overrides
|
||||
target_map.insert(key.clone(), source_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-object: source overrides
|
||||
*target = source.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute final JSON config.
|
||||
///
|
||||
/// Common config as base, custom config overrides (custom takes priority).
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `custom_config` - Provider's custom configuration
|
||||
/// * `common_config` - Common configuration snippet
|
||||
/// * `enabled` - Whether common config is enabled
|
||||
///
|
||||
/// # Returns
|
||||
/// The merged final configuration as JSON value
|
||||
pub fn compute_final_json_config(
|
||||
custom_config: &JsonValue,
|
||||
common_config: &JsonValue,
|
||||
enabled: bool,
|
||||
) -> JsonValue {
|
||||
if !enabled {
|
||||
return custom_config.clone();
|
||||
}
|
||||
|
||||
// Validate both are objects
|
||||
let common_obj = match common_config {
|
||||
JsonValue::Object(m) if !m.is_empty() => m,
|
||||
_ => return custom_config.clone(),
|
||||
};
|
||||
|
||||
let custom_obj = match custom_config {
|
||||
JsonValue::Object(_) => custom_config,
|
||||
_ => return custom_config.clone(),
|
||||
};
|
||||
|
||||
// Start with common config as base
|
||||
let mut result = JsonValue::Object(common_obj.clone());
|
||||
|
||||
// Merge custom config on top (custom overrides common)
|
||||
deep_merge_json(&mut result, custom_obj);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Compute final JSON config from strings.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `custom_config_json` - Custom configuration as JSON string
|
||||
/// * `common_config_json` - Common configuration as JSON string
|
||||
/// * `enabled` - Whether common config is enabled
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (final_config_json, error_message)
|
||||
pub fn compute_final_json_config_str(
|
||||
custom_config_json: &str,
|
||||
common_config_json: &str,
|
||||
enabled: bool,
|
||||
) -> (String, Option<String>) {
|
||||
let custom_config: JsonValue = match serde_json::from_str(custom_config_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return (
|
||||
custom_config_json.to_string(),
|
||||
Some("Failed to parse custom config JSON".to_string()),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let common_config: JsonValue = if common_config_json.trim().is_empty() {
|
||||
JsonValue::Object(Map::new())
|
||||
} else {
|
||||
match serde_json::from_str(common_config_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return (
|
||||
custom_config_json.to_string(),
|
||||
Some("Failed to parse common config JSON".to_string()),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let final_config = compute_final_json_config(&custom_config, &common_config, enabled);
|
||||
|
||||
match serde_json::to_string_pretty(&final_config) {
|
||||
Ok(s) => (s, None),
|
||||
Err(e) => (
|
||||
custom_config_json.to_string(),
|
||||
Some(format!("Failed to serialize: {e}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if two JSON values are deeply equal.
|
||||
fn json_deep_equal(a: &JsonValue, b: &JsonValue) -> bool {
|
||||
match (a, b) {
|
||||
(JsonValue::Null, JsonValue::Null) => true,
|
||||
(JsonValue::Bool(a), JsonValue::Bool(b)) => a == b,
|
||||
(JsonValue::Number(a), JsonValue::Number(b)) => a == b,
|
||||
(JsonValue::String(a), JsonValue::String(b)) => a == b,
|
||||
(JsonValue::Array(a), JsonValue::Array(b)) => {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter().zip(b.iter()).all(|(x, y)| json_deep_equal(x, y))
|
||||
}
|
||||
(JsonValue::Object(a), JsonValue::Object(b)) => {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter()
|
||||
.all(|(k, v)| b.get(k).is_some_and(|bv| json_deep_equal(v, bv)))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract difference between live config and common config.
|
||||
///
|
||||
/// Extraction rules:
|
||||
/// - Keys not in common config → include in custom config
|
||||
/// - Keys in common config but with different values → include in custom config (user override)
|
||||
/// - Keys in common config with same values → skip (avoid redundant storage)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `live_config` - Configuration read from live file
|
||||
/// * `common_config` - Common configuration snippet
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (custom_config, has_common_keys)
|
||||
pub fn extract_json_difference(
|
||||
live_config: &JsonValue,
|
||||
common_config: &JsonValue,
|
||||
) -> (JsonValue, bool) {
|
||||
let live_obj = match live_config {
|
||||
JsonValue::Object(m) => m,
|
||||
_ => return (live_config.clone(), false),
|
||||
};
|
||||
|
||||
let common_obj = match common_config {
|
||||
JsonValue::Object(m) => m,
|
||||
_ => return (live_config.clone(), false),
|
||||
};
|
||||
|
||||
let mut custom_config = Map::new();
|
||||
let mut has_common_keys = false;
|
||||
|
||||
fn extract_recursive(
|
||||
live: &Map<String, JsonValue>,
|
||||
common: &Map<String, JsonValue>,
|
||||
target: &mut Map<String, JsonValue>,
|
||||
has_common: &mut bool,
|
||||
) {
|
||||
for (key, live_value) in live {
|
||||
match common.get(key) {
|
||||
None => {
|
||||
// Case 1: Key not in common config, keep it
|
||||
target.insert(key.clone(), live_value.clone());
|
||||
}
|
||||
Some(common_value) => {
|
||||
// Check if both are objects for nested handling
|
||||
match (live_value, common_value) {
|
||||
(JsonValue::Object(live_map), JsonValue::Object(common_map)) => {
|
||||
// Case 2: Nested object, recurse
|
||||
let mut nested = Map::new();
|
||||
extract_recursive(live_map, common_map, &mut nested, has_common);
|
||||
if !nested.is_empty() {
|
||||
target.insert(key.clone(), JsonValue::Object(nested));
|
||||
} else {
|
||||
// Nested object matches common config
|
||||
*has_common = true;
|
||||
}
|
||||
}
|
||||
_ if !json_deep_equal(live_value, common_value) => {
|
||||
// Case 3: Value different, keep it (user override)
|
||||
target.insert(key.clone(), live_value.clone());
|
||||
}
|
||||
_ => {
|
||||
// Case 4: Value same, skip (avoid redundancy)
|
||||
*has_common = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extract_recursive(
|
||||
live_obj,
|
||||
common_obj,
|
||||
&mut custom_config,
|
||||
&mut has_common_keys,
|
||||
);
|
||||
|
||||
(JsonValue::Object(custom_config), has_common_keys)
|
||||
}
|
||||
|
||||
/// Extract difference from JSON strings.
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (custom_config_json, has_common_keys, error_message)
|
||||
pub fn extract_json_difference_str(
|
||||
live_config_json: &str,
|
||||
common_config_json: &str,
|
||||
) -> (String, bool, Option<String>) {
|
||||
let live_config: JsonValue = match serde_json::from_str(live_config_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return (
|
||||
live_config_json.to_string(),
|
||||
false,
|
||||
Some("Failed to parse live config JSON".to_string()),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let common_config: JsonValue = if common_config_json.trim().is_empty() {
|
||||
JsonValue::Object(Map::new())
|
||||
} else {
|
||||
match serde_json::from_str(common_config_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return (
|
||||
live_config_json.to_string(),
|
||||
false,
|
||||
Some("Failed to parse common config JSON".to_string()),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (custom_config, has_common_keys) = extract_json_difference(&live_config, &common_config);
|
||||
|
||||
match serde_json::to_string_pretty(&custom_config) {
|
||||
Ok(s) => (s, has_common_keys, None),
|
||||
Err(e) => (
|
||||
live_config_json.to_string(),
|
||||
false,
|
||||
Some(format!("Failed to serialize: {e}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TOML Configuration Merge Functions
|
||||
// ============================================================================
|
||||
|
||||
/// Deep merge two TOML tables where `source` overrides `target`.
|
||||
fn deep_merge_toml(target: &mut TomlValue, source: &TomlValue) {
|
||||
// First check if both are tables without destructuring
|
||||
let both_tables =
|
||||
matches!(target, TomlValue::Table(_)) && matches!(source, TomlValue::Table(_));
|
||||
|
||||
if both_tables {
|
||||
// Safe to destructure now since we know both are tables
|
||||
if let (TomlValue::Table(target_map), TomlValue::Table(source_map)) = (target, source) {
|
||||
for (key, source_value) in source_map {
|
||||
match target_map.get_mut(key) {
|
||||
Some(target_value) if target_value.is_table() && source_value.is_table() => {
|
||||
// Nested table: recursive merge
|
||||
deep_merge_toml(target_value, source_value);
|
||||
}
|
||||
_ => {
|
||||
// Other cases: source overrides
|
||||
target_map.insert(key.clone(), source_value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Non-table: source overrides
|
||||
*target = source.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute final TOML config.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `custom_config` - Provider's custom TOML configuration
|
||||
/// * `common_config` - Common TOML configuration snippet
|
||||
/// * `enabled` - Whether common config is enabled
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (final_config_toml, error_message)
|
||||
pub fn compute_final_toml_config_str(
|
||||
custom_toml: &str,
|
||||
common_toml: &str,
|
||||
enabled: bool,
|
||||
) -> (String, Option<String>) {
|
||||
if !enabled || common_toml.trim().is_empty() {
|
||||
return (custom_toml.to_string(), None);
|
||||
}
|
||||
|
||||
// Check if common TOML has actual content (not just comments)
|
||||
let common_has_content = common_toml.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
!trimmed.is_empty() && !trimmed.starts_with('#')
|
||||
});
|
||||
|
||||
if !common_has_content {
|
||||
return (custom_toml.to_string(), None);
|
||||
}
|
||||
|
||||
// Parse custom TOML
|
||||
let custom_config: TomlValue = match custom_toml.parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) if custom_toml.trim().is_empty() => TomlValue::Table(toml::map::Map::new()),
|
||||
Err(e) => {
|
||||
return (
|
||||
custom_toml.to_string(),
|
||||
Some(format!("Failed to parse custom TOML: {e}")),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Parse common TOML
|
||||
let common_config: TomlValue = match common_toml.parse() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return (
|
||||
custom_toml.to_string(),
|
||||
Some(format!("Failed to parse common TOML: {e}")),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Start with common config as base
|
||||
let mut result = common_config;
|
||||
|
||||
// Merge custom config on top
|
||||
deep_merge_toml(&mut result, &custom_config);
|
||||
|
||||
// Serialize back to TOML string
|
||||
match toml::to_string_pretty(&result) {
|
||||
Ok(s) => (s, None),
|
||||
Err(e) => (
|
||||
custom_toml.to_string(),
|
||||
Some(format!("Failed to serialize TOML: {e}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if two TOML values are deeply equal.
|
||||
fn toml_deep_equal(a: &TomlValue, b: &TomlValue) -> bool {
|
||||
match (a, b) {
|
||||
(TomlValue::String(a), TomlValue::String(b)) => a == b,
|
||||
(TomlValue::Integer(a), TomlValue::Integer(b)) => a == b,
|
||||
(TomlValue::Float(a), TomlValue::Float(b)) => (a - b).abs() < f64::EPSILON,
|
||||
(TomlValue::Boolean(a), TomlValue::Boolean(b)) => a == b,
|
||||
(TomlValue::Datetime(a), TomlValue::Datetime(b)) => a == b,
|
||||
(TomlValue::Array(a), TomlValue::Array(b)) => {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter().zip(b.iter()).all(|(x, y)| toml_deep_equal(x, y))
|
||||
}
|
||||
(TomlValue::Table(a), TomlValue::Table(b)) => {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter()
|
||||
.all(|(k, v)| b.get(k).is_some_and(|bv| toml_deep_equal(v, bv)))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract difference between live TOML config and common config.
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (custom_toml, has_common_keys, error_message)
|
||||
pub fn extract_toml_difference_str(
|
||||
live_toml: &str,
|
||||
common_toml: &str,
|
||||
) -> (String, bool, Option<String>) {
|
||||
if common_toml.trim().is_empty() {
|
||||
return (live_toml.to_string(), false, None);
|
||||
}
|
||||
|
||||
// Check if common TOML has actual content
|
||||
let common_has_content = common_toml.lines().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
!trimmed.is_empty() && !trimmed.starts_with('#')
|
||||
});
|
||||
|
||||
if !common_has_content {
|
||||
return (live_toml.to_string(), false, None);
|
||||
}
|
||||
|
||||
// Parse live TOML
|
||||
let live_config: TomlValue = match live_toml.parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) if live_toml.trim().is_empty() => TomlValue::Table(toml::map::Map::new()),
|
||||
Err(e) => {
|
||||
return (
|
||||
live_toml.to_string(),
|
||||
false,
|
||||
Some(format!("Failed to parse live TOML: {e}")),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Parse common TOML
|
||||
let common_config: TomlValue = match common_toml.parse() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
return (
|
||||
live_toml.to_string(),
|
||||
false,
|
||||
Some(format!("Failed to parse common TOML: {e}")),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let live_table = match &live_config {
|
||||
TomlValue::Table(m) => m,
|
||||
_ => return (live_toml.to_string(), false, None),
|
||||
};
|
||||
|
||||
let common_table = match &common_config {
|
||||
TomlValue::Table(m) => m,
|
||||
_ => return (live_toml.to_string(), false, None),
|
||||
};
|
||||
|
||||
let mut custom_table = toml::map::Map::new();
|
||||
let mut has_common_keys = false;
|
||||
|
||||
fn extract_recursive_toml(
|
||||
live: &toml::map::Map<String, TomlValue>,
|
||||
common: &toml::map::Map<String, TomlValue>,
|
||||
target: &mut toml::map::Map<String, TomlValue>,
|
||||
has_common: &mut bool,
|
||||
) {
|
||||
for (key, live_value) in live {
|
||||
match common.get(key) {
|
||||
None => {
|
||||
target.insert(key.clone(), live_value.clone());
|
||||
}
|
||||
Some(common_value) => match (live_value, common_value) {
|
||||
(TomlValue::Table(live_map), TomlValue::Table(common_map)) => {
|
||||
let mut nested = toml::map::Map::new();
|
||||
extract_recursive_toml(live_map, common_map, &mut nested, has_common);
|
||||
if !nested.is_empty() {
|
||||
target.insert(key.clone(), TomlValue::Table(nested));
|
||||
} else {
|
||||
*has_common = true;
|
||||
}
|
||||
}
|
||||
_ if !toml_deep_equal(live_value, common_value) => {
|
||||
target.insert(key.clone(), live_value.clone());
|
||||
}
|
||||
_ => {
|
||||
*has_common = true;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extract_recursive_toml(
|
||||
live_table,
|
||||
common_table,
|
||||
&mut custom_table,
|
||||
&mut has_common_keys,
|
||||
);
|
||||
|
||||
let custom_config = TomlValue::Table(custom_table);
|
||||
|
||||
match toml::to_string_pretty(&custom_config) {
|
||||
Ok(s) if s.trim().is_empty() => (String::new(), has_common_keys, None),
|
||||
Ok(s) => (s, has_common_keys, None),
|
||||
Err(e) => (
|
||||
live_toml.to_string(),
|
||||
false,
|
||||
Some(format!("Failed to serialize TOML: {e}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unit Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_compute_final_json_config_disabled() {
|
||||
let custom = json!({"a": 1, "b": 2});
|
||||
let common = json!({"c": 3});
|
||||
|
||||
let result = compute_final_json_config(&custom, &common, false);
|
||||
assert_eq!(result, custom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_final_json_config_enabled() {
|
||||
let custom = json!({"a": 1, "b": 2});
|
||||
let common = json!({"b": 99, "c": 3});
|
||||
|
||||
let result = compute_final_json_config(&custom, &common, true);
|
||||
|
||||
// custom overrides common, so b should be 2
|
||||
assert_eq!(result["a"], 1);
|
||||
assert_eq!(result["b"], 2); // custom wins
|
||||
assert_eq!(result["c"], 3); // from common
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_final_json_config_nested() {
|
||||
let custom = json!({
|
||||
"env": {
|
||||
"API_KEY": "custom-key",
|
||||
"CUSTOM_VAR": "value"
|
||||
}
|
||||
});
|
||||
let common = json!({
|
||||
"env": {
|
||||
"API_KEY": "common-key",
|
||||
"SHARED_VAR": "shared"
|
||||
},
|
||||
"includeCoAuthoredBy": false
|
||||
});
|
||||
|
||||
let result = compute_final_json_config(&custom, &common, true);
|
||||
|
||||
assert_eq!(result["env"]["API_KEY"], "custom-key"); // custom wins
|
||||
assert_eq!(result["env"]["CUSTOM_VAR"], "value"); // from custom
|
||||
assert_eq!(result["env"]["SHARED_VAR"], "shared"); // from common
|
||||
assert_eq!(result["includeCoAuthoredBy"], false); // from common
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_json_difference() {
|
||||
let live = json!({
|
||||
"env": {
|
||||
"API_KEY": "my-key",
|
||||
"SHARED_VAR": "shared"
|
||||
},
|
||||
"includeCoAuthoredBy": false,
|
||||
"custom_field": true
|
||||
});
|
||||
let common = json!({
|
||||
"env": {
|
||||
"SHARED_VAR": "shared"
|
||||
},
|
||||
"includeCoAuthoredBy": false
|
||||
});
|
||||
|
||||
let (custom, has_common) = extract_json_difference(&live, &common);
|
||||
|
||||
// Should keep API_KEY (not in common) and custom_field
|
||||
assert_eq!(custom["env"]["API_KEY"], "my-key");
|
||||
assert_eq!(custom["custom_field"], true);
|
||||
// Should NOT have SHARED_VAR or includeCoAuthoredBy (same as common)
|
||||
assert!(custom["env"].get("SHARED_VAR").is_none());
|
||||
assert!(custom.get("includeCoAuthoredBy").is_none());
|
||||
assert!(has_common);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_json_difference_with_override() {
|
||||
let live = json!({
|
||||
"includeCoAuthoredBy": true, // Different from common!
|
||||
"shared": "value"
|
||||
});
|
||||
let common = json!({
|
||||
"includeCoAuthoredBy": false,
|
||||
"shared": "value"
|
||||
});
|
||||
|
||||
let (custom, has_common) = extract_json_difference(&live, &common);
|
||||
|
||||
// Should keep includeCoAuthoredBy because value is different
|
||||
assert_eq!(custom["includeCoAuthoredBy"], true);
|
||||
// Should NOT have shared (same as common)
|
||||
assert!(custom.get("shared").is_none());
|
||||
assert!(has_common);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_final_toml_config() {
|
||||
let custom = r#"
|
||||
model = "custom-model"
|
||||
[custom_section]
|
||||
key = "value"
|
||||
"#;
|
||||
let common = r#"
|
||||
model = "common-model"
|
||||
shared_key = "shared"
|
||||
"#;
|
||||
|
||||
let (result, error) = compute_final_toml_config_str(custom, common, true);
|
||||
|
||||
assert!(error.is_none());
|
||||
assert!(result.contains("custom-model")); // custom wins
|
||||
assert!(result.contains("shared_key")); // from common
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_toml_difference() {
|
||||
let live = r#"
|
||||
model = "my-model"
|
||||
shared_key = "shared"
|
||||
[custom_section]
|
||||
key = "value"
|
||||
"#;
|
||||
let common = r#"
|
||||
shared_key = "shared"
|
||||
"#;
|
||||
|
||||
let (custom, has_common, error) = extract_toml_difference_str(live, common);
|
||||
|
||||
assert!(error.is_none());
|
||||
assert!(custom.contains("model")); // not in common
|
||||
assert!(custom.contains("custom_section")); // not in common
|
||||
assert!(!custom.contains("shared_key")); // same as common
|
||||
assert!(has_common);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod claude_plugin;
|
||||
mod codex_config;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod config_merge;
|
||||
mod database;
|
||||
mod deeplink;
|
||||
mod error;
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* 配置合并工具函数
|
||||
*
|
||||
* 用于公共配置重构后的运行时合并逻辑:
|
||||
* - computeFinalConfig: 计算最终配置(通用配置 + 自定义配置)
|
||||
* - extractDifference: 从 live 配置中提取与通用配置不同的部分
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// 工具函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 检查值是否为普通对象
|
||||
*/
|
||||
export const isPlainObject = (
|
||||
value: unknown,
|
||||
): value is Record<string, unknown> => {
|
||||
return Object.prototype.toString.call(value) === "[object Object]";
|
||||
};
|
||||
|
||||
/**
|
||||
* 深拷贝对象
|
||||
*/
|
||||
export const deepClone = <T>(obj: T): T => {
|
||||
if (obj === null || typeof obj !== "object") return obj;
|
||||
if (obj instanceof Date) return new Date(obj.getTime()) as T;
|
||||
if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as T;
|
||||
if (isPlainObject(obj)) {
|
||||
const clonedObj = {} as Record<string, unknown>;
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
clonedObj[key] = deepClone((obj as Record<string, unknown>)[key]);
|
||||
}
|
||||
}
|
||||
return clonedObj as T;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
/**
|
||||
* 深度相等比较
|
||||
*/
|
||||
export const deepEqual = (a: unknown, b: unknown): boolean => {
|
||||
if (a === b) return true;
|
||||
|
||||
if (typeof a !== typeof b) return false;
|
||||
if (a === null || b === null) return a === b;
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((item, index) => deepEqual(item, b[index]));
|
||||
}
|
||||
|
||||
if (isPlainObject(a) && isPlainObject(b)) {
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
return keysA.every((key) => deepEqual(a[key], b[key]));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 配置合并函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 深度合并两个对象(source 覆盖 target)
|
||||
*
|
||||
* 合并规则:
|
||||
* - 嵌套对象:递归合并
|
||||
* - 数组:source 完全替换 target(不做元素级合并)
|
||||
* - 原始值:source 覆盖 target
|
||||
* - undefined:不覆盖
|
||||
*/
|
||||
export const deepMerge = <T extends Record<string, unknown>>(
|
||||
target: T,
|
||||
source: T,
|
||||
): T => {
|
||||
const result = deepClone(target);
|
||||
|
||||
for (const key of Object.keys(source)) {
|
||||
const sourceValue = source[key];
|
||||
const targetValue = result[key];
|
||||
|
||||
// undefined 不覆盖
|
||||
if (sourceValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPlainObject(sourceValue) && isPlainObject(targetValue)) {
|
||||
// 嵌套对象:递归合并
|
||||
result[key as keyof T] = deepMerge(
|
||||
targetValue as Record<string, unknown>,
|
||||
sourceValue as Record<string, unknown>,
|
||||
) as T[keyof T];
|
||||
} else {
|
||||
// 其他情况(数组、原始值):source 覆盖
|
||||
result[key as keyof T] = deepClone(sourceValue) as T[keyof T];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算最终配置
|
||||
*
|
||||
* 通用配置作为 base,自定义配置覆盖(自定义优先)
|
||||
*
|
||||
* @param customConfig - 供应商自定义配置(来自 settings_config 字段)
|
||||
* @param commonConfig - 通用配置片段(来自数据库)
|
||||
* @param enabled - 是否启用通用配置
|
||||
* @returns 合并后的最终配置
|
||||
*/
|
||||
export const computeFinalConfig = (
|
||||
customConfig: Record<string, unknown>,
|
||||
commonConfig: Record<string, unknown>,
|
||||
enabled: boolean,
|
||||
): Record<string, unknown> => {
|
||||
const safeCustom = customConfig ?? {};
|
||||
const safeCommon = commonConfig ?? {};
|
||||
|
||||
if (!enabled || Object.keys(safeCommon).length === 0) {
|
||||
return deepClone(safeCustom);
|
||||
}
|
||||
|
||||
// 通用配置作为 base,自定义配置覆盖
|
||||
// 这样自定义配置的值会优先
|
||||
return deepMerge(safeCommon, safeCustom);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 差异提取函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 差异提取结果
|
||||
*/
|
||||
export interface ExtractDifferenceResult {
|
||||
/** 自定义配置(与通用配置不同的部分) */
|
||||
customConfig: Record<string, unknown>;
|
||||
/** 是否检测到通用配置的键(用于判断是否应启用通用配置) */
|
||||
hasCommonKeys: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 live 配置中提取与通用配置不同的部分作为自定义配置
|
||||
*
|
||||
* 提取规则:
|
||||
* - 通用配置中不存在的键 → 加入自定义配置
|
||||
* - 通用配置中存在但值不同 → 加入自定义配置(用户覆盖)
|
||||
* - 通用配置中存在且值相同 → 跳过(避免冗余存储)
|
||||
*
|
||||
* @param liveConfig - 从本地文件读取的配置
|
||||
* @param commonConfig - 通用配置片段
|
||||
* @returns { customConfig, hasCommonKeys }
|
||||
*/
|
||||
export const extractDifference = (
|
||||
liveConfig: Record<string, unknown>,
|
||||
commonConfig: Record<string, unknown>,
|
||||
): ExtractDifferenceResult => {
|
||||
const customConfig: Record<string, unknown> = {};
|
||||
let hasCommonKeys = false;
|
||||
|
||||
/**
|
||||
* 递归提取差异
|
||||
*/
|
||||
const extract = (
|
||||
live: Record<string, unknown>,
|
||||
common: Record<string, unknown>,
|
||||
target: Record<string, unknown>,
|
||||
): void => {
|
||||
for (const [key, liveValue] of Object.entries(live)) {
|
||||
const commonValue = common[key];
|
||||
|
||||
if (commonValue === undefined) {
|
||||
// Case 1: 通用配置中不存在该键,完整保留到自定义配置
|
||||
target[key] = deepClone(liveValue);
|
||||
} else if (isPlainObject(liveValue) && isPlainObject(commonValue)) {
|
||||
// Case 2: 嵌套对象,递归处理
|
||||
const nested: Record<string, unknown> = {};
|
||||
extract(
|
||||
liveValue as Record<string, unknown>,
|
||||
commonValue as Record<string, unknown>,
|
||||
nested,
|
||||
);
|
||||
if (Object.keys(nested).length > 0) {
|
||||
// 嵌套对象有差异,保留差异部分
|
||||
target[key] = nested;
|
||||
} else {
|
||||
// 嵌套对象完全相同,标记有通用配置的键
|
||||
hasCommonKeys = true;
|
||||
}
|
||||
} else if (!deepEqual(liveValue, commonValue)) {
|
||||
// Case 3: 值不同,保留到自定义配置(用户覆盖)
|
||||
target[key] = deepClone(liveValue);
|
||||
} else {
|
||||
// Case 4: 值完全相同,不保存到自定义配置(避免冗余)
|
||||
hasCommonKeys = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
extract(liveConfig, commonConfig, customConfig);
|
||||
|
||||
return { customConfig, hasCommonKeys };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// JSON 格式便捷函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 计算最终配置(JSON 字符串版本)
|
||||
*/
|
||||
export const computeFinalConfigJson = (
|
||||
customConfigJson: string,
|
||||
commonConfigJson: string,
|
||||
enabled: boolean,
|
||||
): { finalConfig: string; error?: string } => {
|
||||
try {
|
||||
const customConfig = customConfigJson ? JSON.parse(customConfigJson) : {};
|
||||
const commonConfig = commonConfigJson ? JSON.parse(commonConfigJson) : {};
|
||||
|
||||
if (!isPlainObject(customConfig)) {
|
||||
return {
|
||||
finalConfig: customConfigJson,
|
||||
error: "自定义配置必须是 JSON 对象",
|
||||
};
|
||||
}
|
||||
|
||||
if (!isPlainObject(commonConfig)) {
|
||||
return {
|
||||
finalConfig: customConfigJson,
|
||||
error: "通用配置必须是 JSON 对象",
|
||||
};
|
||||
}
|
||||
|
||||
const final = computeFinalConfig(customConfig, commonConfig, enabled);
|
||||
return {
|
||||
finalConfig: JSON.stringify(final, null, 2),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
finalConfig: customConfigJson,
|
||||
error: `JSON 解析失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 JSON 字符串中提取差异
|
||||
*/
|
||||
export const extractDifferenceJson = (
|
||||
liveConfigJson: string,
|
||||
commonConfigJson: string,
|
||||
): { customConfig: string; hasCommonKeys: boolean; error?: string } => {
|
||||
try {
|
||||
const liveConfig = liveConfigJson ? JSON.parse(liveConfigJson) : {};
|
||||
const commonConfig = commonConfigJson ? JSON.parse(commonConfigJson) : {};
|
||||
|
||||
if (!isPlainObject(liveConfig)) {
|
||||
return {
|
||||
customConfig: liveConfigJson,
|
||||
hasCommonKeys: false,
|
||||
error: "Live 配置必须是 JSON 对象",
|
||||
};
|
||||
}
|
||||
|
||||
if (!isPlainObject(commonConfig)) {
|
||||
return {
|
||||
customConfig: liveConfigJson,
|
||||
hasCommonKeys: false,
|
||||
error: "通用配置必须是 JSON 对象",
|
||||
};
|
||||
}
|
||||
|
||||
const result = extractDifference(liveConfig, commonConfig);
|
||||
return {
|
||||
customConfig: JSON.stringify(result.customConfig, null, 2),
|
||||
hasCommonKeys: result.hasCommonKeys,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
customConfig: liveConfigJson,
|
||||
hasCommonKeys: false,
|
||||
error: `JSON 解析失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* TOML 配置合并工具函数
|
||||
*
|
||||
* 用于 Codex 的 TOML 格式配置合并和差异提取。
|
||||
* TOML 配置需要先解析为对象,然后使用通用的合并/提取算法。
|
||||
*/
|
||||
|
||||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||
import { normalizeTomlText } from "@/utils/textNormalization";
|
||||
import {
|
||||
computeFinalConfig,
|
||||
extractDifference,
|
||||
isPlainObject,
|
||||
} from "./configMerge";
|
||||
|
||||
// ============================================================================
|
||||
// TOML 解析/序列化工具
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 安全解析 TOML 字符串为对象
|
||||
*/
|
||||
export const safeParseToml = (
|
||||
tomlString: string,
|
||||
): { config: Record<string, unknown> | null; error: string | null } => {
|
||||
try {
|
||||
if (!tomlString.trim()) {
|
||||
return { config: {}, error: null };
|
||||
}
|
||||
const normalized = normalizeTomlText(tomlString);
|
||||
const parsed = parseToml(normalized);
|
||||
if (!isPlainObject(parsed)) {
|
||||
return { config: null, error: "TOML 解析结果不是对象" };
|
||||
}
|
||||
return { config: parsed as Record<string, unknown>, error: null };
|
||||
} catch (e) {
|
||||
return {
|
||||
config: null,
|
||||
error: e instanceof Error ? e.message : "TOML 解析失败",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将对象序列化为 TOML 字符串
|
||||
* 并移除冗余的空父级 section 头(如 [model_providers] 后面紧跟 [model_providers.packycode])
|
||||
*/
|
||||
export const safeStringifyToml = (
|
||||
config: Record<string, unknown>,
|
||||
): { toml: string; error: string | null } => {
|
||||
try {
|
||||
if (Object.keys(config).length === 0) {
|
||||
return { toml: "", error: null };
|
||||
}
|
||||
let toml = stringifyToml(config);
|
||||
|
||||
// 移除冗余的空父级 section 头
|
||||
// 例如:[model_providers]\n[model_providers.packycode] -> [model_providers.packycode]
|
||||
toml = removeEmptyParentSections(toml);
|
||||
|
||||
return { toml, error: null };
|
||||
} catch (e) {
|
||||
return {
|
||||
toml: "",
|
||||
error: e instanceof Error ? e.message : "TOML 序列化失败",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 移除冗余的空父级 section 头
|
||||
* 当一个 section 头后面紧跟的是它的子 section(没有任何键值对),则移除这个空的父级 section
|
||||
*/
|
||||
function removeEmptyParentSections(toml: string): string {
|
||||
const lines = toml.split("\n");
|
||||
const result: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
// 检查是否是 section 头
|
||||
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
|
||||
if (sectionMatch) {
|
||||
const currentSection = sectionMatch[1];
|
||||
|
||||
// 查找下一个非空行
|
||||
let nextIdx = i + 1;
|
||||
while (nextIdx < lines.length && !lines[nextIdx].trim()) {
|
||||
nextIdx++;
|
||||
}
|
||||
|
||||
// 如果下一个非空行也是一个 section 头,检查是否是子 section
|
||||
if (nextIdx < lines.length) {
|
||||
const nextLine = lines[nextIdx].trim();
|
||||
const nextSectionMatch = nextLine.match(/^\[([^\]]+)\]$/);
|
||||
if (nextSectionMatch) {
|
||||
const nextSection = nextSectionMatch[1];
|
||||
// 如果下一个 section 是当前 section 的子 section,跳过当前空的父 section
|
||||
if (nextSection.startsWith(currentSection + ".")) {
|
||||
// 跳过当前行和之间的空行
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push(line);
|
||||
}
|
||||
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TOML 配置合并函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 计算最终 TOML 配置
|
||||
*
|
||||
* @param customToml - 自定义 TOML 配置字符串
|
||||
* @param commonToml - 通用 TOML 配置字符串
|
||||
* @param enabled - 是否启用通用配置
|
||||
* @returns 合并后的 TOML 字符串
|
||||
*/
|
||||
export const computeFinalTomlConfig = (
|
||||
customToml: string,
|
||||
commonToml: string,
|
||||
enabled: boolean,
|
||||
): { finalConfig: string; error?: string } => {
|
||||
// 如果未启用或通用配置为空,直接返回自定义配置
|
||||
if (!enabled || !commonToml.trim()) {
|
||||
return { finalConfig: customToml };
|
||||
}
|
||||
|
||||
// 解析自定义配置
|
||||
const customResult = safeParseToml(customToml);
|
||||
if (customResult.error) {
|
||||
return {
|
||||
finalConfig: customToml,
|
||||
error: `自定义配置解析失败: ${customResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 解析通用配置
|
||||
const commonResult = safeParseToml(commonToml);
|
||||
if (commonResult.error) {
|
||||
return {
|
||||
finalConfig: customToml,
|
||||
error: `通用配置解析失败: ${commonResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 使用通用合并函数
|
||||
const merged = computeFinalConfig(
|
||||
customResult.config!,
|
||||
commonResult.config!,
|
||||
true, // enabled 已在上面检查
|
||||
);
|
||||
|
||||
// 序列化回 TOML
|
||||
const stringifyResult = safeStringifyToml(merged);
|
||||
if (stringifyResult.error) {
|
||||
return {
|
||||
finalConfig: customToml,
|
||||
error: `TOML 序列化失败: ${stringifyResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { finalConfig: stringifyResult.toml };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TOML 差异提取函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* TOML 差异提取结果
|
||||
*/
|
||||
export interface ExtractTomlDifferenceResult {
|
||||
/** 自定义 TOML 配置字符串(与通用配置不同的部分) */
|
||||
customToml: string;
|
||||
/** 是否检测到通用配置的键 */
|
||||
hasCommonKeys: boolean;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 live TOML 配置中提取与通用配置不同的部分
|
||||
*
|
||||
* @param liveToml - 从本地文件读取的 TOML 字符串
|
||||
* @param commonToml - 通用 TOML 配置字符串
|
||||
* @returns { customToml, hasCommonKeys, error }
|
||||
*/
|
||||
export const extractTomlDifference = (
|
||||
liveToml: string,
|
||||
commonToml: string,
|
||||
): ExtractTomlDifferenceResult => {
|
||||
// 如果通用配置为空,live 配置就是自定义配置
|
||||
if (!commonToml.trim()) {
|
||||
return { customToml: liveToml, hasCommonKeys: false };
|
||||
}
|
||||
|
||||
// 解析 live 配置
|
||||
const liveResult = safeParseToml(liveToml);
|
||||
if (liveResult.error) {
|
||||
return {
|
||||
customToml: liveToml,
|
||||
hasCommonKeys: false,
|
||||
error: `Live 配置解析失败: ${liveResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 解析通用配置
|
||||
const commonResult = safeParseToml(commonToml);
|
||||
if (commonResult.error) {
|
||||
return {
|
||||
customToml: liveToml,
|
||||
hasCommonKeys: false,
|
||||
error: `通用配置解析失败: ${commonResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 使用通用差异提取函数
|
||||
const diffResult = extractDifference(
|
||||
liveResult.config!,
|
||||
commonResult.config!,
|
||||
);
|
||||
|
||||
// 序列化回 TOML
|
||||
const stringifyResult = safeStringifyToml(diffResult.customConfig);
|
||||
if (stringifyResult.error) {
|
||||
return {
|
||||
customToml: liveToml,
|
||||
hasCommonKeys: false,
|
||||
error: `TOML 序列化失败: ${stringifyResult.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
customToml: stringifyResult.toml,
|
||||
hasCommonKeys: diffResult.hasCommonKeys,
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TOML Section 级别处理(保留原有格式和注释的场景)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* TOML 解析结构(用于保留格式的场景)
|
||||
*/
|
||||
export interface TomlParsedStructure {
|
||||
topLevel: string[];
|
||||
sections: Map<string, string[]>;
|
||||
sectionOrder: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 TOML 文本为段落结构(保留原始行格式)
|
||||
*/
|
||||
export const parseTomlStructure = (tomlText: string): TomlParsedStructure => {
|
||||
const lines = tomlText.split("\n");
|
||||
const topLevel: string[] = [];
|
||||
const sections = new Map<string, string[]>();
|
||||
const sectionOrder: string[] = [];
|
||||
|
||||
let currentSection: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// 检查是否是 section 头
|
||||
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
|
||||
if (sectionMatch) {
|
||||
currentSection = sectionMatch[1];
|
||||
if (!sections.has(currentSection)) {
|
||||
sections.set(currentSection, []);
|
||||
sectionOrder.push(currentSection);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentSection === null) {
|
||||
topLevel.push(line);
|
||||
} else {
|
||||
const sectionLines = sections.get(currentSection)!;
|
||||
sectionLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return { topLevel, sections, sectionOrder };
|
||||
};
|
||||
|
||||
/**
|
||||
* 从行中提取键名
|
||||
*/
|
||||
export const extractKeyFromLine = (line: string): string | null => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
return null;
|
||||
}
|
||||
const match = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 重建 TOML 文本(从结构)
|
||||
*/
|
||||
export const rebuildTomlFromStructure = (
|
||||
structure: TomlParsedStructure,
|
||||
): string => {
|
||||
const resultLines: string[] = [];
|
||||
|
||||
// 添加顶级内容
|
||||
let lastWasEmpty = true;
|
||||
for (const line of structure.topLevel) {
|
||||
const isEmpty = !line.trim();
|
||||
if (isEmpty && lastWasEmpty) {
|
||||
continue;
|
||||
}
|
||||
resultLines.push(line);
|
||||
lastWasEmpty = isEmpty;
|
||||
}
|
||||
|
||||
// 添加 sections
|
||||
for (const sectionName of structure.sectionOrder) {
|
||||
const sectionLines = structure.sections.get(sectionName)!;
|
||||
|
||||
if (resultLines.length > 0 && resultLines[resultLines.length - 1].trim()) {
|
||||
resultLines.push("");
|
||||
}
|
||||
|
||||
resultLines.push(`[${sectionName}]`);
|
||||
|
||||
lastWasEmpty = false;
|
||||
for (const line of sectionLines) {
|
||||
const isEmpty = !line.trim();
|
||||
if (isEmpty && lastWasEmpty) {
|
||||
continue;
|
||||
}
|
||||
resultLines.push(line);
|
||||
lastWasEmpty = isEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
// 清理末尾空行
|
||||
while (
|
||||
resultLines.length > 0 &&
|
||||
!resultLines[resultLines.length - 1].trim()
|
||||
) {
|
||||
resultLines.pop();
|
||||
}
|
||||
|
||||
return resultLines.length > 0 ? resultLines.join("\n") + "\n" : "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查 TOML 配置是否包含通用配置的内容
|
||||
* 使用对象级别比较,而非字符串匹配
|
||||
*/
|
||||
export const hasTomlCommonConfig = (
|
||||
tomlString: string,
|
||||
commonTomlString: string,
|
||||
): boolean => {
|
||||
if (!commonTomlString.trim()) return false;
|
||||
|
||||
const liveResult = safeParseToml(tomlString);
|
||||
const commonResult = safeParseToml(commonTomlString);
|
||||
|
||||
if (liveResult.error || commonResult.error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查 common 中的所有键是否存在于 live 中且值相等
|
||||
const checkSubset = (
|
||||
live: Record<string, unknown>,
|
||||
common: Record<string, unknown>,
|
||||
): boolean => {
|
||||
for (const [key, commonValue] of Object.entries(common)) {
|
||||
const liveValue = live[key];
|
||||
|
||||
if (liveValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPlainObject(commonValue) && isPlainObject(liveValue)) {
|
||||
if (
|
||||
!checkSubset(
|
||||
liveValue as Record<string, unknown>,
|
||||
commonValue as Record<string, unknown>,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} else if (Array.isArray(commonValue) && Array.isArray(liveValue)) {
|
||||
if (JSON.stringify(commonValue) !== JSON.stringify(liveValue)) {
|
||||
return false;
|
||||
}
|
||||
} else if (liveValue !== commonValue) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return checkSubset(liveResult.config!, commonResult.config!);
|
||||
};
|
||||
Reference in New Issue
Block a user