Files
CC-Switch/src-tauri/src/hermes_config.rs
T
thisTom 61d7ac01fb fix(hermes): Windows 上 Hermes 供应商配置不生效 (#4680)
* fix(hermes): resolve config dir via HERMES_HOME and platform defaults

On Windows, CC Switch hardcoded `~/.hermes` as the Hermes config directory,
but Hermes resolves it through `get_hermes_home()`: `HERMES_HOME` env var,
then a platform default (Windows `%LOCALAPPDATA%\hermes`, mac/Linux
`~/.hermes`). So CC Switch wrote configs where Hermes never reads them and
provider changes had no effect (refs #3178).

`get_hermes_dir()` now mirrors Hermes' own resolution order:
  1. `settings.hermes_config_dir` — CC Switch explicit override
  2. `HERMES_HOME` env var — trimmed, non-empty, taken as-is (no `~`
     expansion, matching Hermes' `Path(val)`)
  3. platform default — Windows reads the `LOCALAPPDATA` env var (the exact
     value Hermes reads, not a re-derived Known-Folder path) and falls back
     to `~\AppData\Local\hermes`; mac/Linux keep `~/.hermes`

Relation to #3470: that PR dropped `HERMES_HOME` for "consistency with
Codex/Claude". But unlike those tools Hermes treats `HERMES_HOME` as a
first-class mechanism (its own Windows installer sets it), so ignoring it
reintroduces the same mismatch for relocated installs. This keeps it.

Tests cover HERMES_HOME precedence, settings-override > HERMES_HOME, blank
HERMES_HOME fall-through, the platform-correct default, and a pure
`windows_local_hermes_dir` helper exercising the LOCALAPPDATA-set and
LOCALAPPDATA-empty fallback paths on every host.

* test(hermes): isolate config-dir tests from ambient HERMES_HOME/LOCALAPPDATA

`get_hermes_dir()` now consults `HERMES_HOME` (all platforms) and `LOCALAPPDATA`
(Windows). The `with_test_home` helper only neutralized `CC_SWITCH_TEST_HOME`,
so an ambient `HERMES_HOME` — which Hermes' own Windows installer sets — would
make tests that write `get_hermes_config_path()` escape the temp home and touch
a real Hermes config (and the same hazard exists via `LOCALAPPDATA` on Windows).

Clear and restore both env vars in `with_test_home`. This is safe: only this
module reads those env vars, and `dirs` uses the Known-Folder API rather than the
env var. Adds a test asserting both are neutralized inside `with_test_home`.

Addresses the Codex review comment on #4680.

* fix(hermes): trim LOCALAPPDATA to match Hermes' .strip()

`windows_local_hermes_dir` checked `OsStr::is_empty()` on the raw value, but
Hermes does `os.environ.get("LOCALAPPDATA", "").strip()`. A whitespace/padded
`LOCALAPPDATA` would make CC Switch write under the literal padded path while
Hermes trims and falls back, hiding the config from Hermes. Trim before the
empty check, matching the existing `HERMES_HOME` handling. Adds
`windows_local_hermes_dir_trims_localappdata`.

Addresses the Codex review comment on #4680.

---------

Co-authored-by: thisTom <19346741+thisTom@users.noreply.github.com>
2026-06-28 17:49:52 +08:00

2445 lines
87 KiB
Rust

//! Hermes Agent 配置文件读写模块
//!
//! 处理 `~/.hermes/config.yaml` 配置文件的读写操作(YAML 格式)。
//! Hermes 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
//!
//! ## 配置结构示例
//!
//! ```yaml
//! model:
//! default: "anthropic/claude-opus-4-8"
//! provider: "openrouter"
//! base_url: "https://openrouter.ai/api/v1"
//!
//! agent:
//! max_turns: 50
//! reasoning_effort: "high"
//!
//! custom_providers:
//! - name: openrouter
//! base_url: https://openrouter.ai/api/v1
//! api_key: sk-or-...
//! model: anthropic/claude-opus-4-8
//! models:
//! anthropic/claude-opus-4-8:
//! context_length: 200000
//!
//! mcp_servers:
//! filesystem:
//! command: npx
//! args: ["-y", "@modelcontextprotocol/server-filesystem"]
//! ```
use crate::config::{atomic_write, get_app_config_dir};
use crate::error::AppError;
use crate::settings::{effective_backup_retain_count, get_hermes_override_dir};
use chrono::Local;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
// ============================================================================
// Path Functions
// ============================================================================
/// 获取 Hermes 配置目录
///
/// 解析顺序对齐 Hermes 自身的 `get_hermes_home()`:
/// 1. CCS 设置 `hermes_config_dir`(显式覆盖)
/// 2. `HERMES_HOME` 环境变量(trim 后非空;按原样,不展开 `~`,与 Hermes `Path(val)` 一致)
/// 3. 平台默认(Windows: `%LOCALAPPDATA%\hermes`,Mac/Linux: `~/.hermes`)
pub fn get_hermes_dir() -> PathBuf {
if let Some(override_dir) = get_hermes_override_dir() {
return override_dir;
}
if let Some(raw) = std::env::var_os("HERMES_HOME") {
let value = raw.to_string_lossy();
let trimmed = value.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
default_hermes_dir()
}
/// 平台默认 Hermes 目录(Windows):对齐 Hermes `_get_platform_default_hermes_home()`——
/// 读 `LOCALAPPDATA` 环境变量,缺失/空时回退 `~\AppData\Local`,再拼 `hermes`。
#[cfg(target_os = "windows")]
fn default_hermes_dir() -> PathBuf {
windows_local_hermes_dir(
std::env::var_os("LOCALAPPDATA").as_deref(),
&crate::config::get_home_dir(),
)
}
/// 平台默认 Hermes 目录(Mac/Linux):`~/.hermes`。
#[cfg(not(target_os = "windows"))]
fn default_hermes_dir() -> PathBuf {
crate::config::get_home_dir().join(".hermes")
}
/// Windows `%LOCALAPPDATA%\hermes` 路径计算(纯函数,便于跨平台单测)。
/// 对齐 Hermes 的 `os.environ.get("LOCALAPPDATA", "").strip()`:trim 后为空
/// (缺失/空/纯空白)则回退 `<home>\AppData\Local\hermes`。
#[cfg(any(target_os = "windows", test))]
fn windows_local_hermes_dir(localappdata: Option<&std::ffi::OsStr>, home: &Path) -> PathBuf {
localappdata
.map(|value| value.to_string_lossy().trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData").join("Local"))
.join("hermes")
}
/// 获取 Hermes 配置文件路径
///
/// 返回 `~/.hermes/config.yaml`
pub fn get_hermes_config_path() -> PathBuf {
get_hermes_dir().join("config.yaml")
}
fn hermes_write_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
// ============================================================================
// Type Definitions
// ============================================================================
/// Hermes 写入结果
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct HermesWriteOutcome {
#[serde(skip_serializing_if = "Option::is_none")]
pub backup_path: Option<String>,
}
/// Hermes model section config
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HermesModelConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_length: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u64>,
/// Preserve unknown fields for forward compatibility
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
}
// ============================================================================
// Core YAML Read Functions
// ============================================================================
/// 读取 Hermes 配置文件为 serde_yaml::Value
///
/// 如果文件不存在,返回空 Mapping
pub fn read_hermes_config() -> Result<serde_yaml::Value, AppError> {
let path = get_hermes_config_path();
if !path.exists() {
return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
}
let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
if content.trim().is_empty() {
return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
}
// Heal duplicate top-level keys left behind by the pre-CRLF-fix append
// bug (#3633); serde_yaml rejects them outright, which bricked the panel.
let deduped = deduplicate_top_level_keys(&content);
serde_yaml::from_str(&deduped)
.map_err(|e| AppError::Config(format!("Failed to parse Hermes config as YAML: {e}")))
}
/// Remove duplicate top-level YAML sections, keeping the LAST occurrence of
/// each key.
///
/// Keep-last is deliberate, not arbitrary: the duplicates come from section
/// replacement degrading into appends (#3633), so the last block is the
/// newest data — and Hermes itself reads the file with PyYAML, whose
/// duplicate-key semantics are last-wins. Keeping the first occurrence would
/// silently roll the user back to stale config and diverge from what Hermes
/// actually runs with.
fn deduplicate_top_level_keys(raw: &str) -> String {
use std::collections::HashMap;
// Pass 1: locate every top-level key line as (key, byte offset).
let mut sections: Vec<(&str, usize)> = Vec::new();
let mut offset = 0;
for line in raw.split('\n') {
if is_top_level_key_line(line) {
if let Some(colon_pos) = line.find(':') {
sections.push((&line[..colon_pos], offset));
}
}
offset += line.len() + 1;
}
let mut remaining: HashMap<&str, usize> = HashMap::new();
for (key, _) in &sections {
*remaining.entry(key).or_insert(0) += 1;
}
if remaining.values().all(|&count| count <= 1) {
return raw.to_string();
}
// Pass 2: re-emit, dropping every section that has a later occurrence of
// the same key. A section spans from its key line to the next top-level
// key line (or EOF), matching find_yaml_section_range. Content before the
// first section (comments, document markers) is always kept.
let mut result = String::with_capacity(raw.len());
let head_end = sections
.first()
.map(|&(_, start)| start)
.unwrap_or(raw.len());
result.push_str(&raw[..head_end]);
for (i, &(key, start)) in sections.iter().enumerate() {
let end = sections
.get(i + 1)
.map(|&(_, next_start)| next_start)
.unwrap_or(raw.len());
let count = remaining.get_mut(key).expect("key collected in pass 1");
*count -= 1;
if *count > 0 {
log::warn!(
"Hermes config: dropped duplicate top-level section '{key}' (keeping the last occurrence)"
);
continue;
}
result.push_str(&raw[start..end]);
}
result
}
// ============================================================================
// YAML Section-Level Replacement
// ============================================================================
/// Check if a line is a YAML top-level key (mapping key at column 0).
///
/// A top-level key line must:
/// - Start at column 0 (no leading whitespace)
/// - Not be empty or whitespace-only
/// - Not be a comment (starting with `#`)
/// - Not be a sequence item (starting with `-`)
/// - Contain `:` followed by space, tab, newline, or end-of-line
///
/// Lines may carry a trailing `\r` (CRLF files split on `\n`) or `\n`
/// (callers using `split_inclusive`); both count as end-of-line after the
/// colon. Rejecting `\r` here used to make every section lookup miss on
/// CRLF configs, turning section replacement into endless appends (#3633).
fn is_top_level_key_line(line: &str) -> bool {
if line.is_empty() {
return false;
}
let first_char = line.as_bytes()[0];
if first_char == b' ' || first_char == b'\t' || first_char == b'#' || first_char == b'-' {
return false;
}
if let Some(colon_pos) = line.find(':') {
let after_colon = &line[colon_pos + 1..];
after_colon.is_empty() || after_colon.starts_with([' ', '\t', '\r', '\n'])
} else {
false
}
}
/// Find the byte range of a top-level YAML section.
///
/// A YAML top-level key is a line that starts at column 0 (no leading
/// whitespace), is not a comment, and contains `:` after the key name.
///
/// Returns `(start_byte_inclusive, end_byte_exclusive)` or `None` if not found.
fn find_yaml_section_range(raw: &str, section_key: &str) -> Option<(usize, usize)> {
let target = format!("{}:", section_key);
let mut section_start = None;
let mut offset = 0;
for line in raw.split('\n') {
if section_start.is_none() && is_top_level_key_line(line) && line.starts_with(&target) {
// Verify exact match: after "key:" must be whitespace or EOL
let after_target = &line[target.len()..];
if after_target.is_empty()
|| after_target.starts_with(' ')
|| after_target.starts_with('\t')
|| after_target.starts_with('\r')
{
section_start = Some(offset);
}
} else if section_start.is_some() && is_top_level_key_line(line) {
// Found the next top-level key — this is the end of our section
return Some((section_start.unwrap(), offset));
}
offset += line.len() + 1; // +1 for the \n
}
// Section extends to end of file
section_start.map(|start| (start, raw.len()))
}
/// Serialize a section key + value into a YAML fragment like:
///
/// ```yaml
/// model:
/// default: "anthropic/claude-opus-4-8"
/// provider: "openrouter"
/// ```
fn serialize_yaml_section(key: &str, value: &serde_yaml::Value) -> Result<String, AppError> {
let mut section = serde_yaml::Mapping::new();
section.insert(serde_yaml::Value::String(key.to_string()), value.clone());
let yaml_str = serde_yaml::to_string(&serde_yaml::Value::Mapping(section))
.map_err(|e| AppError::Config(format!("Failed to serialize YAML section '{key}': {e}")))?;
Ok(yaml_str)
}
/// Remove every top-level section with the given key from raw YAML text.
/// Used to clean residual duplicates of a key after replacing its first
/// occurrence; safe values come from the keep-last healed read, so dropping
/// all on-disk copies here loses nothing.
fn remove_all_sections(raw: &str, section_key: &str) -> String {
let mut result = String::with_capacity(raw.len());
let mut rest = raw;
while let Some((start, end)) = find_yaml_section_range(rest, section_key) {
result.push_str(&rest[..start]);
rest = &rest[end..];
}
result.push_str(rest);
result
}
/// Replace a YAML section in raw text, or append it if not found.
fn replace_yaml_section(
raw: &str,
section_key: &str,
value: &serde_yaml::Value,
) -> Result<String, AppError> {
let serialized = serialize_yaml_section(section_key, value)?;
if let Some((start, end)) = find_yaml_section_range(raw, section_key) {
let mut result = String::with_capacity(raw.len());
result.push_str(&raw[..start]);
result.push_str(&serialized);
// Drop duplicate sections of this key from the remainder — configs
// written before the CRLF fix may carry several appended copies.
let remainder = remove_all_sections(&raw[end..], section_key);
// Ensure proper separation between sections
if !serialized.ends_with('\n') && !remainder.is_empty() && !remainder.starts_with('\n') {
result.push('\n');
}
result.push_str(&remainder);
Ok(result)
} else {
// Section not found — append at end
let mut result = raw.to_string();
if !result.is_empty() && !result.ends_with('\n') {
result.push('\n');
}
result.push_str(&serialized);
if !result.ends_with('\n') {
result.push('\n');
}
Ok(result)
}
}
// ============================================================================
// Backup & Cleanup
// ============================================================================
fn create_hermes_backup(source: &str) -> Result<PathBuf, AppError> {
let backup_dir = get_app_config_dir().join("backups").join("hermes");
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
let base_id = format!("hermes_{}", Local::now().format("%Y%m%d_%H%M%S"));
let mut filename = format!("{base_id}.yaml");
let mut backup_path = backup_dir.join(&filename);
let mut counter = 1;
while backup_path.exists() {
filename = format!("{base_id}_{counter}.yaml");
backup_path = backup_dir.join(&filename);
counter += 1;
}
atomic_write(&backup_path, source.as_bytes())?;
cleanup_hermes_backups(&backup_dir)?;
Ok(backup_path)
}
fn cleanup_hermes_backups(dir: &Path) -> Result<(), AppError> {
let retain = effective_backup_retain_count();
let mut entries = fs::read_dir(dir)
.map_err(|e| AppError::io(dir, e))?
.filter_map(|entry| entry.ok())
.filter(|entry| {
entry
.path()
.extension()
.map(|ext| ext == "yaml" || ext == "yml")
.unwrap_or(false)
})
.collect::<Vec<_>>();
if entries.len() <= retain {
return Ok(());
}
entries.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok());
let remove_count = entries.len().saturating_sub(retain);
for entry in entries.into_iter().take(remove_count) {
if let Err(err) = fs::remove_file(entry.path()) {
log::warn!(
"Failed to remove old Hermes config backup {}: {err}",
entry.path().display()
);
}
}
Ok(())
}
// ============================================================================
// High-level Write Helper
// ============================================================================
/// Write a single top-level YAML section to config.yaml using section-level replacement.
///
/// This preserves comments and unrelated sections while only modifying the
/// target section.
fn write_yaml_section_to_config(
section_key: &str,
value: &serde_yaml::Value,
) -> Result<HermesWriteOutcome, AppError> {
let _guard = hermes_write_lock().lock()?;
write_yaml_section_to_config_locked(section_key, value)
}
/// Inner write helper — caller must already hold the write lock.
fn write_yaml_section_to_config_locked(
section_key: &str,
value: &serde_yaml::Value,
) -> Result<HermesWriteOutcome, AppError> {
let config_path = get_hermes_config_path();
let raw = if config_path.exists() {
fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?
} else {
String::new()
};
let new_raw = replace_yaml_section(&raw, section_key, value)?;
if new_raw == raw {
return Ok(HermesWriteOutcome::default());
}
let backup_path = if !raw.is_empty() {
Some(create_hermes_backup(&raw)?)
} else {
None
};
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
atomic_write(&config_path, new_raw.as_bytes())?;
log::debug!(
"Hermes config section '{}' written to {:?}",
section_key,
config_path
);
Ok(HermesWriteOutcome {
backup_path: backup_path.map(|p| p.display().to_string()),
})
}
// ============================================================================
// Provider Functions
// ============================================================================
/// Convert a provider's `models` field from a UI-friendly array to the YAML
/// dict shape that Hermes expects.
///
/// Input (from CC Switch UI / database):
/// ```json
/// "models": [{ "id": "foo", "context_length": 200000 }, { "id": "bar" }]
/// ```
///
/// Output (what we write to YAML):
/// ```json
/// "models": { "foo": { "context_length": 200000 }, "bar": {} }
/// ```
///
/// Entries with a missing or empty `id` are dropped. The top-level `id` key
/// is stripped from each value since it now lives on the parent as the map
/// key. Insertion order is preserved (serde_json uses IndexMap under the
/// `preserve_order` feature).
fn models_array_to_dict(array: Vec<serde_json::Value>) -> serde_json::Value {
let mut map = serde_json::Map::new();
for item in array {
let serde_json::Value::Object(mut obj) = item else {
continue;
};
let Some(id) = obj
.remove("id")
.and_then(|v| v.as_str().map(|s| s.trim().to_string()))
.filter(|s| !s.is_empty())
else {
continue;
};
map.insert(id, serde_json::Value::Object(obj));
}
serde_json::Value::Object(map)
}
/// Inverse of [`models_array_to_dict`]. Converts the YAML dict shape back to
/// the UI-friendly ordered array, re-injecting `id` as an object field.
fn models_dict_to_array(dict: serde_json::Map<String, serde_json::Value>) -> serde_json::Value {
let mut out = Vec::with_capacity(dict.len());
for (id, value) in dict {
let mut obj = match value {
serde_json::Value::Object(obj) => obj,
serde_json::Value::Null => serde_json::Map::new(),
other => {
log::warn!("Unexpected Hermes model entry for '{id}': {other:?}, skipping");
continue;
}
};
obj.insert("id".to_string(), serde_json::Value::String(id));
out.push(serde_json::Value::Object(obj));
}
serde_json::Value::Array(out)
}
/// Rewrite historical camelCase keys to Hermes' snake_case schema.
///
/// Older DeepLink import paths emitted `baseUrl` / `apiKey` / `apiMode` /
/// `maxTokens` / `contextLength`, which do not belong to Hermes'
/// `_VALID_CUSTOM_PROVIDER_FIELDS` set. Writing those raw to YAML silently
/// poisons `custom_providers:` entries. This sanitiser runs defensively on
/// every `set_provider` call so stored data heals on the next activation;
/// unknown keys pass through untouched to keep forward-compat with new
/// Hermes fields (e.g. `request_timeout_seconds`).
fn sanitize_hermes_provider_keys(config: &mut serde_json::Value) {
const KEY_ALIASES: &[(&str, &str)] = &[
("baseUrl", "base_url"),
("apiKey", "api_key"),
("apiMode", "api_mode"),
("maxTokens", "max_tokens"),
("contextLength", "context_length"),
];
// Legacy DeepLink emitted `api: "openai-completions"` which is neither a
// Hermes field nor mappable to `api_mode`. `_cc_source` / `provider_key`
// are UI-only markers injected on read — they must never reach YAML.
const LEGACY_FIELDS_TO_DROP: &[&str] = &["api", PROVIDER_SOURCE_FIELD, "provider_key"];
let Some(obj) = config.as_object_mut() else {
return;
};
for (from, to) in KEY_ALIASES {
if let Some(val) = obj.remove(*from) {
// snake_case wins when both are present; stale camelCase is dropped.
obj.entry((*to).to_string()).or_insert(val);
}
}
for field in LEGACY_FIELDS_TO_DROP {
obj.remove(*field);
}
}
/// If `config.models` is a JSON array, convert it in-place to the dict shape.
/// No-op when `models` is absent or already a dict.
fn normalize_provider_models_for_write(config: &mut serde_json::Value) {
let Some(obj) = config.as_object_mut() else {
return;
};
let Some(models_val) = obj.get_mut("models") else {
return;
};
if models_val.is_array() {
let taken = std::mem::take(models_val);
if let serde_json::Value::Array(arr) = taken {
*models_val = models_array_to_dict(arr);
}
}
}
/// If `config.models` is a JSON dict, convert it in-place to the ordered array
/// shape. No-op when `models` is absent or already an array.
fn denormalize_provider_models_for_read(config: &mut serde_json::Value) {
let Some(obj) = config.as_object_mut() else {
return;
};
let Some(models_val) = obj.get_mut("models") else {
return;
};
if models_val.is_object() {
let taken = std::mem::take(models_val);
if let serde_json::Value::Object(map) = taken {
*models_val = models_dict_to_array(map);
}
}
}
/// Marker field injected on provider payloads sourced from Hermes v12+
/// `providers:` dict. CC Switch treats those as read-only — writes have to
/// go through Hermes' own Web UI to keep its overlay semantics intact.
pub const PROVIDER_SOURCE_FIELD: &str = "_cc_source";
pub const PROVIDER_SOURCE_CUSTOM_LIST: &str = "custom_providers";
pub const PROVIDER_SOURCE_DICT: &str = "providers_dict";
/// Normalize a single entry from the v12+ `providers:` dict into the same
/// JSON shape that `custom_providers:` list entries take, mirroring upstream
/// `_normalize_custom_provider_entry` (hermes_cli/config.py).
///
/// Returns `None` when the entry is not a mapping or lacks any usable name.
fn normalize_providers_dict_entry(
key: &str,
entry: &serde_yaml::Value,
) -> Result<Option<serde_json::Value>, AppError> {
if !entry.is_mapping() {
return Ok(None);
}
let mut json_val = yaml_to_json(entry)?;
let Some(obj) = json_val.as_object_mut() else {
return Ok(None);
};
// Upstream prefers an explicit `name` when present, falling back to the
// dict key. Always round-trip it to a trimmed non-empty string.
let resolved_name = obj
.get("name")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| key.trim().to_string());
if resolved_name.is_empty() {
return Ok(None);
}
obj.insert("name".to_string(), serde_json::json!(resolved_name));
obj.insert("provider_key".to_string(), serde_json::json!(key));
obj.insert(
PROVIDER_SOURCE_FIELD.to_string(),
serde_json::json!(PROVIDER_SOURCE_DICT),
);
Ok(Some(json_val))
}
/// Collect provider entries living under the v12+ `providers:` dict.
fn read_providers_dict_entries(config: &serde_yaml::Value) -> Vec<(String, serde_json::Value)> {
let Some(mapping) = config.get("providers").and_then(|v| v.as_mapping()) else {
return Vec::new();
};
let mut out = Vec::with_capacity(mapping.len());
for (k, v) in mapping {
let Some(key_str) = k.as_str().map(str::trim).filter(|s| !s.is_empty()) else {
continue;
};
match normalize_providers_dict_entry(key_str, v) {
Ok(Some(entry)) => {
let name = entry
.get("name")
.and_then(|n| n.as_str())
.unwrap_or(key_str)
.to_string();
out.push((name, entry));
}
Ok(None) => {
log::debug!("Skipping Hermes providers['{key_str}']: not a mapping");
}
Err(e) => {
log::warn!("Failed to normalize Hermes providers['{key_str}']: {e}");
}
}
}
out
}
/// Get all providers as a JSON map keyed by provider name.
///
/// Unions two on-disk sources, matching upstream `get_compatible_custom_providers`:
/// - `custom_providers:` list entries (writable by CC Switch)
/// - `providers:` dict entries (v12+ schema, surfaced read-only with
/// `_cc_source = "providers_dict"` so the UI can disable edit/delete)
///
/// When a name appears in both, the list entry wins (upstream dedup order),
/// keeping CC Switch free to edit it. Models are denormalized from the YAML
/// dict shape to the UI-friendly ordered array.
pub fn get_providers() -> Result<serde_json::Map<String, serde_json::Value>, AppError> {
let config = read_hermes_config()?;
let mut map = serde_json::Map::new();
if let Some(seq) = config.get("custom_providers").and_then(|v| v.as_sequence()) {
for item in seq {
if let Some(name) = item.get("name").and_then(|n| n.as_str()) {
match yaml_to_json(item) {
Ok(mut json_val) => {
// Heal legacy camelCase records (from older DeepLink
// imports) before the UI sees them, so editing doesn't
// reveal stale `baseUrl` / `apiKey` fields.
sanitize_hermes_provider_keys(&mut json_val);
denormalize_provider_models_for_read(&mut json_val);
if let Some(obj) = json_val.as_object_mut() {
obj.insert(
PROVIDER_SOURCE_FIELD.to_string(),
serde_json::json!(PROVIDER_SOURCE_CUSTOM_LIST),
);
}
map.insert(name.to_string(), json_val);
}
Err(e) => {
log::warn!("Failed to convert Hermes provider '{name}' to JSON: {e}");
}
}
}
}
}
for (name, mut entry) in read_providers_dict_entries(&config) {
if map.contains_key(&name) {
continue; // list wins over dict on duplicate names
}
denormalize_provider_models_for_read(&mut entry);
map.insert(name, entry);
}
Ok(map)
}
/// Reject writes that would target a dict-only overlay entry.
///
/// `verb` is inlined into the user-facing error so both "edit" and "remove"
/// callers can share one implementation.
fn ensure_provider_writable(
config: &serde_yaml::Value,
name: &str,
verb: &str,
) -> Result<(), AppError> {
if is_dict_only_provider(config, name) {
return Err(AppError::Config(format!(
"Provider '{name}' is managed by Hermes' 'providers:' dict — {verb} via Hermes Web UI"
)));
}
Ok(())
}
/// True when `name` appears in `providers:` dict but not in `custom_providers:`
/// list — i.e. it is a read-only overlay CC Switch must not touch.
fn is_dict_only_provider(config: &serde_yaml::Value, name: &str) -> bool {
let list_has = config
.get("custom_providers")
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.any(|item| item.get("name").and_then(|n| n.as_str()) == Some(name))
})
.unwrap_or(false);
if list_has {
return false;
}
config
.get("providers")
.and_then(|v| v.as_mapping())
.map(|m| {
m.iter().any(|(k, v)| {
let key_matches = k.as_str() == Some(name);
let name_matches = v
.get("name")
.and_then(|n| n.as_str())
.map(|s| s == name)
.unwrap_or(false);
(key_matches || name_matches) && v.is_mapping()
})
})
.unwrap_or(false)
}
/// Get a single custom provider by name.
pub fn get_provider(name: &str) -> Result<Option<serde_json::Value>, AppError> {
Ok(get_providers()?.get(name).cloned())
}
/// Set (upsert) a custom provider by name.
///
/// Upserts into the `custom_providers:` YAML sequence (matched by `name`).
/// The entry includes:
/// - `name:` field matching the provider id
/// - singular `model:` field set to the first model id from the `models:`
/// dict — the Hermes runtime and `/model` picker both read this field
/// (runtime_provider.py reads it via `_normalize_custom_provider_entry`;
/// main.py:1436/1450 uses it for picker hints)
/// - plural `models:` dict carrying per-model `context_length` etc.
///
/// The entire read-modify-write is done under the write lock to prevent
/// TOCTOU races.
pub fn set_provider(
name: &str,
provider_config: serde_json::Value,
) -> Result<HermesWriteOutcome, AppError> {
let _guard = hermes_write_lock().lock()?;
let config = read_hermes_config()?;
ensure_provider_writable(&config, name, "edit")?;
let mut providers: Vec<serde_yaml::Value> = config
.get("custom_providers")
.and_then(|v| v.as_sequence())
.cloned()
.unwrap_or_default();
// Rewrite any historical camelCase keys (e.g. from older DeepLink imports)
// before touching models / YAML — avoids writing non-Hermes fields back.
let mut normalized = provider_config;
sanitize_hermes_provider_keys(&mut normalized);
// Normalize `models` from UI array to Hermes YAML dict before serializing.
normalize_provider_models_for_write(&mut normalized);
// Extract the first model id (now a key in the normalized dict) so we can
// propagate it to the singular `model:` field Hermes reads.
let first_model_id = normalized
.get("models")
.and_then(|v| v.as_object())
.and_then(|obj| obj.keys().next())
.cloned();
let mut yaml_val: serde_yaml::Value = json_to_yaml(&normalized)?;
if let serde_yaml::Value::Mapping(ref mut m) = yaml_val {
m.insert(
serde_yaml::Value::String("name".to_string()),
serde_yaml::Value::String(name.to_string()),
);
if let Some(model_id) = first_model_id {
m.insert(
serde_yaml::Value::String("model".to_string()),
serde_yaml::Value::String(model_id),
);
} else {
m.remove(serde_yaml::Value::String("model".to_string()));
}
}
if let Some(existing) = providers
.iter_mut()
.find(|p| p.get("name").and_then(|n| n.as_str()) == Some(name))
{
// Forward-compat: carry over any on-disk fields the UI payload didn't
// include. Hermes keeps evolving (e.g. `request_timeout_seconds`,
// `key_env`), and users may set those via Hermes Web UI — without
// this merge, a CC Switch edit to an unrelated field would silently
// strip them on write-back.
if let (Some(existing_map), serde_yaml::Value::Mapping(new_map)) =
(existing.as_mapping(), &mut yaml_val)
{
for (k, v) in existing_map {
new_map.entry(k.clone()).or_insert_with(|| v.clone());
}
}
*existing = yaml_val;
} else {
providers.push(yaml_val);
}
let providers_value = serde_yaml::Value::Sequence(providers);
write_yaml_section_to_config_locked("custom_providers", &providers_value)
}
/// Remove a custom provider by name.
///
/// Filters out the matching entry from the `custom_providers:` sequence.
/// No-op if the section is missing or no entry matches. The entire
/// read-modify-write is done under the write lock to prevent TOCTOU races.
pub fn remove_provider(name: &str) -> Result<HermesWriteOutcome, AppError> {
let _guard = hermes_write_lock().lock()?;
let config = read_hermes_config()?;
ensure_provider_writable(&config, name, "remove")?;
let mut providers: Vec<serde_yaml::Value> = config
.get("custom_providers")
.and_then(|v| v.as_sequence())
.cloned()
.unwrap_or_default();
let original_len = providers.len();
providers.retain(|p| p.get("name").and_then(|n| n.as_str()) != Some(name));
if providers.len() == original_len {
return Ok(HermesWriteOutcome::default());
}
let providers_value = serde_yaml::Value::Sequence(providers);
write_yaml_section_to_config_locked("custom_providers", &providers_value)
}
// ============================================================================
// Model Config Functions
// ============================================================================
/// Get the `model` section as a typed config.
pub fn get_model_config() -> Result<Option<HermesModelConfig>, AppError> {
let config = read_hermes_config()?;
let Some(model_value) = config.get("model") else {
return Ok(None);
};
let json_val = yaml_to_json(model_value)?;
let model = serde_json::from_value(json_val)
.map_err(|e| AppError::Config(format!("Failed to parse Hermes model config: {e}")))?;
Ok(Some(model))
}
/// Set the `model` section.
pub fn set_model_config(model: &HermesModelConfig) -> Result<HermesWriteOutcome, AppError> {
let json_val =
serde_json::to_value(model).map_err(|e| AppError::JsonSerialize { source: e })?;
let yaml_val = json_to_yaml(&json_val)?;
write_yaml_section_to_config("model", &yaml_val)
}
/// Apply the top-level `model:` defaults when switching to a Hermes provider.
///
/// `model.provider` is **always** updated to the new provider id — without
/// this, switching to a provider whose settings lack a `models` list would
/// leave the runtime routing requests to the previously active provider.
///
/// `model.default` is only overwritten when the new provider declares at
/// least one model; otherwise the previous default is preserved so users
/// still have a runnable configuration (Hermes will surface a clear error
/// if the default no longer belongs to the active provider).
///
/// Existing fields in `model:` (`context_length` / `max_tokens` / `base_url`
/// / `extra`) are preserved via struct-update.
pub fn apply_switch_defaults(
provider_id: &str,
settings_config: &serde_json::Value,
) -> Result<HermesWriteOutcome, AppError> {
let first_model_id = settings_config
.get("models")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|m| m.get("id"))
.and_then(|id| id.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let current = get_model_config()?.unwrap_or_default();
let merged = HermesModelConfig {
default: first_model_id.or(current.default.clone()),
provider: Some(provider_id.to_string()),
..current
};
set_model_config(&merged)
}
// ============================================================================
// MCP Section Access (for mcp/hermes.rs to use in Phase 4)
// ============================================================================
/// Get the `mcp_servers` section as a YAML Mapping.
pub fn get_mcp_servers_yaml() -> Result<serde_yaml::Mapping, AppError> {
let config = read_hermes_config()?;
Ok(config
.get("mcp_servers")
.and_then(|v| v.as_mapping())
.cloned()
.unwrap_or_default())
}
/// Atomically read-modify-write the `mcp_servers` section under the write lock.
///
/// Prevents TOCTOU races when multiple sync operations run concurrently.
pub fn update_mcp_servers_yaml<F>(updater: F) -> Result<(), AppError>
where
F: FnOnce(&mut serde_yaml::Mapping) -> Result<(), AppError>,
{
let _guard = hermes_write_lock().lock()?;
let config = read_hermes_config()?;
let mut servers = config
.get("mcp_servers")
.and_then(|v| v.as_mapping())
.cloned()
.unwrap_or_default();
updater(&mut servers)?;
let value = serde_yaml::Value::Mapping(servers);
write_yaml_section_to_config_locked("mcp_servers", &value)?;
Ok(())
}
// ============================================================================
// YAML ↔ JSON Conversion Helpers
// ============================================================================
/// Convert a `serde_yaml::Value` to a `serde_json::Value`.
pub(crate) fn yaml_to_json(yaml: &serde_yaml::Value) -> Result<serde_json::Value, AppError> {
// Serialize YAML value to string, then parse as JSON value.
// This handles all type mappings correctly.
let yaml_str = serde_yaml::to_string(yaml)
.map_err(|e| AppError::Config(format!("Failed to serialize YAML value: {e}")))?;
serde_yaml::from_str::<serde_json::Value>(&yaml_str)
.map_err(|e| AppError::Config(format!("Failed to convert YAML to JSON: {e}")))
}
/// Convert a `serde_json::Value` to a `serde_yaml::Value`.
pub(crate) fn json_to_yaml(json: &serde_json::Value) -> Result<serde_yaml::Value, AppError> {
let json_str = serde_json::to_string(json)
.map_err(|e| AppError::Config(format!("Failed to serialize JSON value: {e}")))?;
serde_yaml::from_str(&json_str)
.map_err(|e| AppError::Config(format!("Failed to convert JSON to YAML: {e}")))
}
// ============================================================================
// Memory Files (~/.hermes/memories/{MEMORY,USER}.md)
// ============================================================================
//
// Hermes Agent persists two memory blobs on disk:
// - `MEMORY.md` — agent's personal notes, snapshotted into the system prompt
// - `USER.md` — user profile, same treatment
// Entries are separated by a `§` on its own line. Hermes' own Web UI only
// exposes on/off toggles and character budgets — it has no content editor.
// CC Switch fills that gap by reading/writing the whole file as a markdown
// blob. Character budgets (`memory_char_limit`, `user_char_limit`) and enable
// flags (`memory_enabled`, `user_profile_enabled`) live at the top level of
// `config.yaml`; Hermes truncates over-budget content at load time.
/// Which of Hermes' two memory files to operate on. Tauri deserializes this
/// directly from the `"memory"` / `"user"` strings the frontend sends, so an
/// unknown value is rejected at the IPC boundary instead of deep in the stack.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MemoryKind {
Memory,
User,
}
impl MemoryKind {
fn filename(self) -> &'static str {
match self {
Self::Memory => "MEMORY.md",
Self::User => "USER.md",
}
}
}
fn memories_dir() -> PathBuf {
get_hermes_dir().join("memories")
}
/// Read a Hermes memory file as a markdown blob. Returns an empty string
/// when the file doesn't exist yet (first-run case).
pub fn read_memory(kind: MemoryKind) -> Result<String, AppError> {
let path = memories_dir().join(kind.filename());
match fs::read_to_string(&path) {
Ok(content) => Ok(content),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(e) => Err(AppError::io(&path, e)),
}
}
/// Atomically replace a Hermes memory file. `atomic_write` creates parent
/// directories as needed, so `~/.hermes/memories/` is materialized on first
/// write without a separate `create_dir_all` call.
pub fn write_memory(kind: MemoryKind, content: &str) -> Result<(), AppError> {
let path = memories_dir().join(kind.filename());
atomic_write(&path, content.as_bytes())
}
/// Character budget + enable flags for the two memory blobs, as configured
/// in Hermes' `config.yaml`. Defaults mirror `~/.hermes`'s own defaults so
/// callers get a usable budget bar even before the user edits config.yaml.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct HermesMemoryLimits {
pub memory: usize,
pub user: usize,
pub memory_enabled: bool,
pub user_enabled: bool,
}
impl Default for HermesMemoryLimits {
fn default() -> Self {
Self {
memory: 2200,
user: 1375,
memory_enabled: true,
user_enabled: true,
}
}
}
/// Toggle the on/off flag for one of Hermes' two memory blobs, preserving all
/// other fields in the `memory:` section (character budgets, external provider
/// settings, etc.). Hermes stores the user-profile toggle under
/// `user_profile_enabled` (not `user_enabled`), so the mapping to on-disk keys
/// lives here rather than leaking to callers.
pub fn set_memory_enabled(kind: MemoryKind, enabled: bool) -> Result<HermesWriteOutcome, AppError> {
let _guard = hermes_write_lock().lock()?;
let config = read_hermes_config()?;
let mut memory = match config.get("memory") {
Some(serde_yaml::Value::Mapping(m)) => m.clone(),
_ => serde_yaml::Mapping::new(),
};
let key = match kind {
MemoryKind::Memory => "memory_enabled",
MemoryKind::User => "user_profile_enabled",
};
memory.insert(
serde_yaml::Value::String(key.to_string()),
serde_yaml::Value::Bool(enabled),
);
write_yaml_section_to_config_locked("memory", &serde_yaml::Value::Mapping(memory))
}
/// Read memory budgets + toggles from `config.yaml`. Missing/unparsable
/// fields fall back to `HermesMemoryLimits::default()` rather than erroring,
/// so an empty or partially-populated config still yields a usable UI.
pub fn read_memory_limits() -> Result<HermesMemoryLimits, AppError> {
let mut out = HermesMemoryLimits::default();
let config = read_hermes_config()?;
let Some(memory) = config.get("memory") else {
return Ok(out);
};
if let Some(v) = memory.get("memory_char_limit").and_then(|v| v.as_u64()) {
out.memory = v as usize;
}
if let Some(v) = memory.get("user_char_limit").and_then(|v| v.as_u64()) {
out.user = v as usize;
}
if let Some(v) = memory.get("memory_enabled").and_then(|v| v.as_bool()) {
out.memory_enabled = v;
}
if let Some(v) = memory.get("user_profile_enabled").and_then(|v| v.as_bool()) {
out.user_enabled = v;
}
Ok(out)
}
// ============================================================================
// Tests
// ============================================================================
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::sync::{Mutex, OnceLock};
fn test_guard() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|err| err.into_inner())
}
/// Run a test with an isolated temp home directory.
///
/// Saves and restores `CC_SWITCH_TEST_HOME` to avoid interfering with
/// parallel tests in other modules.
fn with_test_home<T>(test_fn: impl FnOnce() -> T) -> T {
let _guard = test_guard();
let tmp = tempfile::tempdir().unwrap();
let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME");
std::env::set_var("CC_SWITCH_TEST_HOME", tmp.path());
// Neutralize the env vars get_hermes_dir() consults, so an ambient
// HERMES_HOME / LOCALAPPDATA (e.g. set by a Hermes install) can't make
// tests escape the temp home. Restored below.
let old_hermes_home = std::env::var_os("HERMES_HOME");
let old_local_appdata = std::env::var_os("LOCALAPPDATA");
std::env::remove_var("HERMES_HOME");
std::env::remove_var("LOCALAPPDATA");
let result = test_fn();
match old_local_appdata {
Some(value) => std::env::set_var("LOCALAPPDATA", value),
None => std::env::remove_var("LOCALAPPDATA"),
}
match old_hermes_home {
Some(value) => std::env::set_var("HERMES_HOME", value),
None => std::env::remove_var("HERMES_HOME"),
}
match old_test_home {
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
}
result
}
// ---- sanitize_hermes_provider_keys tests ----
#[test]
fn sanitize_rewrites_camel_case_aliases() {
let mut v = serde_json::json!({
"name": "test",
"baseUrl": "https://api.example.com",
"apiKey": "sk-123",
"apiMode": "chat_completions",
"maxTokens": 8192,
"contextLength": 200000,
});
sanitize_hermes_provider_keys(&mut v);
let obj = v.as_object().unwrap();
assert_eq!(obj.get("base_url").unwrap(), "https://api.example.com");
assert_eq!(obj.get("api_key").unwrap(), "sk-123");
assert_eq!(obj.get("api_mode").unwrap(), "chat_completions");
assert_eq!(obj.get("max_tokens").unwrap(), 8192);
assert_eq!(obj.get("context_length").unwrap(), 200000);
assert!(obj.get("baseUrl").is_none());
assert!(obj.get("apiKey").is_none());
}
#[test]
fn sanitize_drops_stale_duplicate_when_snake_case_exists() {
let mut v = serde_json::json!({
"baseUrl": "https://old.example.com",
"base_url": "https://new.example.com",
});
sanitize_hermes_provider_keys(&mut v);
let obj = v.as_object().unwrap();
// snake_case wins; stale camelCase is dropped
assert_eq!(obj.get("base_url").unwrap(), "https://new.example.com");
assert!(obj.get("baseUrl").is_none());
}
#[test]
fn sanitize_drops_legacy_api_field() {
let mut v = serde_json::json!({
"base_url": "https://api.example.com",
"api": "openai-completions",
});
sanitize_hermes_provider_keys(&mut v);
let obj = v.as_object().unwrap();
assert!(obj.get("api").is_none(), "legacy 'api' key must be removed");
assert!(obj.get("base_url").is_some());
}
#[test]
fn sanitize_preserves_unknown_fields() {
let mut v = serde_json::json!({
"base_url": "https://api.example.com",
"request_timeout_seconds": 300,
"rate_limit_delay": 1.5,
});
sanitize_hermes_provider_keys(&mut v);
let obj = v.as_object().unwrap();
// Forward-compat: Hermes' own new fields pass through untouched
assert_eq!(obj.get("request_timeout_seconds").unwrap(), 300);
assert_eq!(obj.get("rate_limit_delay").unwrap(), 1.5);
}
#[test]
fn sanitize_noop_on_non_object() {
let mut v = serde_json::json!(["not", "an", "object"]);
sanitize_hermes_provider_keys(&mut v);
assert!(v.is_array());
}
// ---- find_yaml_section_range tests ----
#[test]
fn find_section_in_multi_section_yaml() {
let yaml = "\
model:
default: gpt-4
provider: openai
agent:
max_turns: 10
custom_providers:
- name: foo
";
let (start, end) = find_yaml_section_range(yaml, "agent").unwrap();
let section = &yaml[start..end];
assert!(section.starts_with("agent:"));
assert!(section.contains("max_turns"));
assert!(!section.contains("custom_providers"));
}
#[test]
fn find_section_at_end_of_file() {
let yaml = "\
model:
default: gpt-4
agent:
max_turns: 10
";
let (start, end) = find_yaml_section_range(yaml, "agent").unwrap();
let section = &yaml[start..end];
assert!(section.starts_with("agent:"));
assert!(section.contains("max_turns"));
assert_eq!(end, yaml.len());
}
#[test]
fn find_section_not_found() {
let yaml = "\
model:
default: gpt-4
";
assert!(find_yaml_section_range(yaml, "agent").is_none());
}
#[test]
fn find_section_with_comments_between() {
let yaml = "\
model:
default: gpt-4
# This is a comment
# indented comment
agent:
max_turns: 10
";
// model section should span from start to "agent:"
let (start, end) = find_yaml_section_range(yaml, "model").unwrap();
let section = &yaml[start..end];
assert!(section.starts_with("model:"));
// Comments and blank lines between sections are included in the prior section
assert!(section.contains("# This is a comment"));
}
#[test]
fn find_section_with_empty_lines() {
let yaml = "\
model:
default: gpt-4
agent:
max_turns: 10
";
let (start, end) = find_yaml_section_range(yaml, "model").unwrap();
let section = &yaml[start..end];
assert!(section.starts_with("model:"));
// Empty lines don't terminate a section
assert!(section.contains('\n'));
}
#[test]
fn find_section_does_not_match_substring_key() {
let yaml = "\
model_extra:
foo: bar
model:
default: gpt-4
";
let (start, _end) = find_yaml_section_range(yaml, "model").unwrap();
let section = &yaml[start..];
// Should match "model:", not "model_extra:"
assert!(section.starts_with("model:"));
assert!(!section.starts_with("model_extra:"));
}
#[test]
fn find_section_handles_crlf() {
// Regression for #3633: CRLF line endings must not hide sections.
let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\n";
let (start, end) = find_yaml_section_range(yaml, "model").unwrap();
let section = &yaml[start..end];
assert!(section.starts_with("model:"));
assert!(section.contains("default: gpt-4"));
assert!(!section.contains("agent:"));
}
// ---- deduplicate_top_level_keys tests ----
#[test]
fn dedup_keeps_last_occurrence() {
// Duplicates come from replace-degraded-to-append, so the last block
// is the newest data and must win (PyYAML last-wins, like Hermes).
let yaml = "\
model:
default: gpt-4
agent:
max_turns: 10
model:
default: claude-opus-4-8
";
let result = deduplicate_top_level_keys(yaml);
assert_eq!(
result.lines().filter(|l| *l == "model:").count(),
1,
"duplicate model: section was not removed"
);
assert!(result.contains("claude-opus-4-8"));
assert!(!result.contains("gpt-4"));
assert!(result.contains("max_turns"));
}
#[test]
fn dedup_handles_crlf() {
let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\nmodel:\r\n default: claude\r\n";
let result = deduplicate_top_level_keys(yaml);
assert_eq!(result.lines().filter(|l| l.trim() == "model:").count(), 1);
assert!(result.contains("default: claude"));
assert!(!result.contains("gpt-4"));
}
#[test]
fn dedup_is_identity_without_duplicates() {
let yaml = "\
# Hermes config
model:
default: gpt-4
agent:
max_turns: 10
";
assert_eq!(deduplicate_top_level_keys(yaml), yaml);
}
#[test]
fn dedup_result_parses_with_last_value() {
// End-to-end: a config that serde_yaml rejects today must parse after
// healing, and expose the newest (last) value.
let yaml = "\
custom_providers:
- name: old-provider
model:
default: gpt-4
custom_providers:
- name: old-provider
- name: new-provider
";
let healed = deduplicate_top_level_keys(yaml);
let value: serde_yaml::Value = serde_yaml::from_str(&healed).unwrap();
let providers = value
.get("custom_providers")
.unwrap()
.as_sequence()
.unwrap();
assert_eq!(providers.len(), 2);
assert_eq!(
providers[1].get("name").unwrap().as_str().unwrap(),
"new-provider"
);
}
// ---- remove_all_sections tests ----
#[test]
fn remove_all_sections_strips_every_occurrence() {
let yaml = "\
model:
default: gpt-4
agent:
max_turns: 10
model:
default: claude
model:
default: gemini
";
let result = remove_all_sections(yaml, "model");
assert!(!result.contains("model:"));
assert!(result.contains("agent:"));
assert!(result.contains("max_turns"));
}
// ---- replace_yaml_section tests ----
#[test]
fn replace_existing_section() {
let yaml = "\
model:
default: gpt-4
provider: openai
agent:
max_turns: 10
";
let new_model = serde_yaml::Value::Mapping({
let mut m = serde_yaml::Mapping::new();
m.insert(
serde_yaml::Value::String("default".to_string()),
serde_yaml::Value::String("claude-opus-4-8".to_string()),
);
m.insert(
serde_yaml::Value::String("provider".to_string()),
serde_yaml::Value::String("anthropic".to_string()),
);
m
});
let result = replace_yaml_section(yaml, "model", &new_model).unwrap();
// The result should still contain the agent section
assert!(result.contains("agent:"));
assert!(result.contains("max_turns"));
// And the model section should be updated
assert!(result.contains("claude-opus-4-8"));
assert!(result.contains("anthropic"));
assert!(!result.contains("gpt-4"));
assert!(!result.contains("openai"));
}
#[test]
fn replace_section_in_crlf_config_replaces_in_place() {
// Regression for #3633: on CRLF configs every "replace" used to
// degrade into an append, piling up duplicate sections.
let yaml = "model:\r\n default: gpt-4\r\nagent:\r\n max_turns: 10\r\n";
let new_model = serde_yaml::Value::Mapping({
let mut m = serde_yaml::Mapping::new();
m.insert(
serde_yaml::Value::String("default".to_string()),
serde_yaml::Value::String("claude-opus-4-8".to_string()),
);
m
});
let result = replace_yaml_section(yaml, "model", &new_model).unwrap();
assert_eq!(
result.lines().filter(|l| l.trim() == "model:").count(),
1,
"model: must be replaced in place, not appended"
);
assert!(result.contains("claude-opus-4-8"));
assert!(!result.contains("gpt-4"));
assert!(result.contains("max_turns"));
}
#[test]
fn replace_section_removes_residual_duplicates() {
// A config already broken by the append bug: replacing the section
// must also clean the stale duplicate copies after it.
let yaml = "\
model:
default: gpt-4
agent:
max_turns: 10
model:
default: stale-copy
";
let new_model = serde_yaml::Value::Mapping({
let mut m = serde_yaml::Mapping::new();
m.insert(
serde_yaml::Value::String("default".to_string()),
serde_yaml::Value::String("claude-opus-4-8".to_string()),
);
m
});
let result = replace_yaml_section(yaml, "model", &new_model).unwrap();
assert_eq!(result.lines().filter(|l| *l == "model:").count(), 1);
assert!(result.contains("claude-opus-4-8"));
assert!(!result.contains("stale-copy"));
assert!(result.contains("agent:"));
// The healed output must be valid YAML again
let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&result);
assert!(parsed.is_ok());
}
#[test]
fn append_new_section() {
let yaml = "\
model:
default: gpt-4
";
let new_agent = serde_yaml::Value::Mapping({
let mut m = serde_yaml::Mapping::new();
m.insert(
serde_yaml::Value::String("max_turns".to_string()),
serde_yaml::Value::Number(serde_yaml::Number::from(50)),
);
m
});
let result = replace_yaml_section(yaml, "agent", &new_agent).unwrap();
assert!(result.contains("model:"));
assert!(result.contains("gpt-4"));
assert!(result.contains("agent:"));
assert!(result.contains("max_turns: 50"));
}
#[test]
fn replace_section_in_empty_file() {
let yaml = "";
let new_model = serde_yaml::Value::Mapping({
let mut m = serde_yaml::Mapping::new();
m.insert(
serde_yaml::Value::String("default".to_string()),
serde_yaml::Value::String("gpt-4".to_string()),
);
m
});
let result = replace_yaml_section(yaml, "model", &new_model).unwrap();
assert!(result.contains("model:"));
assert!(result.contains("gpt-4"));
assert!(result.ends_with('\n'));
}
// ---- Provider CRUD via mock config ----
#[test]
#[serial]
fn provider_crud_roundtrip() {
with_test_home(|| {
// Initially no providers
let providers = get_providers().unwrap();
assert!(providers.is_empty());
// Add a provider
let config = serde_json::json!({
"base_url": "https://openrouter.ai/api/v1",
"api_key": "sk-or-test"
});
set_provider("openrouter", config).unwrap();
let providers = get_providers().unwrap();
assert_eq!(providers.len(), 1);
assert!(providers.contains_key("openrouter"));
let provider = get_provider("openrouter").unwrap().unwrap();
assert_eq!(provider["base_url"], "https://openrouter.ai/api/v1");
assert_eq!(provider["name"], "openrouter");
// Update the provider
let config2 = serde_json::json!({
"base_url": "https://openrouter.ai/api/v2",
"api_key": "sk-or-updated"
});
set_provider("openrouter", config2).unwrap();
let provider = get_provider("openrouter").unwrap().unwrap();
assert_eq!(provider["base_url"], "https://openrouter.ai/api/v2");
// Remove the provider
remove_provider("openrouter").unwrap();
let providers = get_providers().unwrap();
assert!(providers.is_empty());
});
}
#[test]
#[serial]
fn set_provider_preserves_unknown_fields_on_update() {
// Hermes keeps adding provider-level fields (e.g.
// `request_timeout_seconds`, `key_env`). Users may set those via
// Hermes Web UI; a later CC Switch edit must not strip them — set_provider
// carries over any existing on-disk fields that the UI payload didn't
// submit.
with_test_home(|| {
let yaml = "\
custom_providers:
- name: acme
base_url: https://old.example.com
api_key: sk-old
request_timeout_seconds: 300
key_env: ACME_API_KEY
";
let config_path = get_hermes_config_path();
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, yaml).unwrap();
let update = serde_json::json!({
"base_url": "https://new.example.com",
"api_key": "sk-new"
});
set_provider("acme", update).unwrap();
let provider = get_provider("acme").unwrap().unwrap();
assert_eq!(provider["base_url"], "https://new.example.com");
assert_eq!(provider["api_key"], "sk-new");
assert_eq!(provider["request_timeout_seconds"], 300);
assert_eq!(provider["key_env"], "ACME_API_KEY");
});
}
#[test]
#[serial]
fn get_providers_surfaces_providers_dict_as_read_only() {
with_test_home(|| {
let yaml = "\
_config_version: 19
custom_providers:
- name: mine
base_url: https://mine.example.com
api_key: sk-mine
providers:
anthropic:
base_url: https://api.anthropic.com
api_key: sk-ant
model: claude-opus-4.6
ollama-local:
base_url: http://localhost:11434/v1
request_timeout_seconds: 300
";
let config_path = get_hermes_config_path();
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, yaml).unwrap();
let providers = get_providers().unwrap();
assert_eq!(providers.len(), 3);
let mine = providers.get("mine").unwrap();
assert_eq!(mine[PROVIDER_SOURCE_FIELD], PROVIDER_SOURCE_CUSTOM_LIST);
let anthropic = providers.get("anthropic").unwrap();
assert_eq!(anthropic[PROVIDER_SOURCE_FIELD], PROVIDER_SOURCE_DICT);
assert_eq!(anthropic["provider_key"], "anthropic");
assert_eq!(anthropic["base_url"], "https://api.anthropic.com");
let ollama = providers.get("ollama-local").unwrap();
assert_eq!(ollama[PROVIDER_SOURCE_FIELD], PROVIDER_SOURCE_DICT);
// Forward-compat fields from the dict pass through untouched
assert_eq!(ollama["request_timeout_seconds"], 300);
});
}
#[test]
#[serial]
fn get_providers_list_wins_on_name_collision() {
with_test_home(|| {
let yaml = "\
_config_version: 19
custom_providers:
- name: shared
base_url: https://writable.example.com
providers:
shared:
base_url: https://overlay.example.com
";
let config_path = get_hermes_config_path();
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, yaml).unwrap();
let providers = get_providers().unwrap();
assert_eq!(providers.len(), 1);
let shared = providers.get("shared").unwrap();
assert_eq!(shared["base_url"], "https://writable.example.com");
assert_eq!(shared[PROVIDER_SOURCE_FIELD], PROVIDER_SOURCE_CUSTOM_LIST);
});
}
#[test]
#[serial]
fn set_provider_rejects_dict_only_entries() {
with_test_home(|| {
let yaml = "\
_config_version: 19
providers:
anthropic:
base_url: https://api.anthropic.com
model: claude-opus-4.6
";
let config_path = get_hermes_config_path();
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, yaml).unwrap();
let update = serde_json::json!({ "base_url": "https://hacked.example.com" });
let err = set_provider("anthropic", update).unwrap_err();
assert!(
format!("{err}").contains("providers:"),
"error message should point user at providers dict: {err}"
);
});
}
#[test]
#[serial]
fn remove_provider_rejects_dict_only_entries() {
with_test_home(|| {
let yaml = "\
_config_version: 19
providers:
anthropic:
base_url: https://api.anthropic.com
";
let config_path = get_hermes_config_path();
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, yaml).unwrap();
assert!(remove_provider("anthropic").is_err());
});
}
#[test]
fn sanitize_strips_ui_only_markers() {
let mut v = serde_json::json!({
"base_url": "https://api.example.com",
"_cc_source": "providers_dict",
"provider_key": "anthropic",
});
sanitize_hermes_provider_keys(&mut v);
let obj = v.as_object().unwrap();
assert!(obj.get("_cc_source").is_none());
assert!(obj.get("provider_key").is_none());
assert!(obj.get("base_url").is_some());
}
#[test]
#[serial]
fn get_providers_heals_legacy_camel_case_on_read() {
// A DB may still hold records from older DeepLink imports that wrote
// camelCase fields into `settings_config`. The read path must surface
// them in Hermes' native snake_case so UI editors aren't lying to users.
with_test_home(|| {
let yaml = "\
custom_providers:
- name: legacy
baseUrl: https://legacy.example.com
apiKey: sk-legacy
apiMode: chat_completions
api: openai-completions
";
let config_path = get_hermes_config_path();
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, yaml).unwrap();
let provider = get_provider("legacy").unwrap().unwrap();
assert_eq!(provider["base_url"], "https://legacy.example.com");
assert_eq!(provider["api_key"], "sk-legacy");
assert_eq!(provider["api_mode"], "chat_completions");
assert!(provider.get("baseUrl").is_none());
assert!(provider.get("apiKey").is_none());
assert!(provider.get("api").is_none());
});
}
// ---- Model config tests ----
#[test]
#[serial]
fn model_config_roundtrip() {
with_test_home(|| {
// Initially none
assert!(get_model_config().unwrap().is_none());
let model = HermesModelConfig {
default: Some("anthropic/claude-opus-4-8".to_string()),
provider: Some("openrouter".to_string()),
base_url: Some("https://openrouter.ai/api/v1".to_string()),
context_length: Some(200000),
max_tokens: None,
extra: HashMap::new(),
};
set_model_config(&model).unwrap();
let read_model = get_model_config().unwrap().unwrap();
assert_eq!(
read_model.default.as_deref(),
Some("anthropic/claude-opus-4-8")
);
assert_eq!(read_model.provider.as_deref(), Some("openrouter"));
assert_eq!(read_model.context_length, Some(200000));
});
}
// ---- yaml_to_json / json_to_yaml ----
#[test]
fn yaml_json_conversion_roundtrip() {
let json = serde_json::json!({
"name": "test",
"count": 42,
"nested": {
"flag": true
}
});
let yaml = json_to_yaml(&json).unwrap();
let back = yaml_to_json(&yaml).unwrap();
assert_eq!(json, back);
}
// ---- models array ↔ dict transforms ----
#[test]
fn models_array_to_dict_strips_id_and_preserves_order() {
let arr = vec![
serde_json::json!({ "id": "foo", "context_length": 100 }),
serde_json::json!({ "id": "bar", "max_tokens": 2000 }),
serde_json::json!({ "id": "baz" }),
];
let dict = models_array_to_dict(arr);
let obj = dict.as_object().unwrap();
let keys: Vec<&String> = obj.keys().collect();
assert_eq!(keys, vec!["foo", "bar", "baz"]);
assert_eq!(obj["foo"]["context_length"], 100);
assert_eq!(obj["bar"]["max_tokens"], 2000);
assert!(obj["baz"].as_object().unwrap().is_empty());
// id must not leak into values
assert!(obj["foo"].get("id").is_none());
}
#[test]
fn models_array_to_dict_drops_empty_and_missing_ids() {
let arr = vec![
serde_json::json!({ "id": "", "context_length": 1 }),
serde_json::json!({ "id": " ", "context_length": 2 }),
serde_json::json!({ "context_length": 3 }),
serde_json::json!({ "id": "kept" }),
];
let dict = models_array_to_dict(arr);
let obj = dict.as_object().unwrap();
assert_eq!(obj.len(), 1);
assert!(obj.contains_key("kept"));
}
#[test]
fn models_dict_to_array_reinjects_id_and_preserves_order() {
let mut map = serde_json::Map::new();
map.insert(
"alpha".to_string(),
serde_json::json!({ "context_length": 10 }),
);
map.insert("beta".to_string(), serde_json::json!({ "max_tokens": 20 }));
map.insert("gamma".to_string(), serde_json::Value::Null);
let arr = models_dict_to_array(map);
let list = arr.as_array().unwrap();
assert_eq!(list.len(), 3);
assert_eq!(list[0]["id"], "alpha");
assert_eq!(list[0]["context_length"], 10);
assert_eq!(list[1]["id"], "beta");
assert_eq!(list[2]["id"], "gamma");
}
#[test]
#[serial]
fn provider_with_models_array_writes_dict_to_yaml() {
with_test_home(|| {
let config = serde_json::json!({
"base_url": "https://api.example.com/v1",
"api_key": "sk-test",
"api_mode": "chat_completions",
"models": [
{ "id": "model-a", "context_length": 200000, "max_tokens": 32000 },
{ "id": "model-b", "context_length": 100000 },
]
});
set_provider("demo", config).unwrap();
// Read raw YAML to verify the on-disk shape is a sequence under `custom_providers:`.
let raw = fs::read_to_string(get_hermes_config_path()).unwrap();
let yaml: serde_yaml::Value = serde_yaml::from_str(&raw).unwrap();
let providers = yaml
.get("custom_providers")
.and_then(|v| v.as_sequence())
.unwrap();
let provider = &providers[0];
assert_eq!(
provider.get("name").and_then(|v| v.as_str()),
Some("demo"),
"entry should carry a name field"
);
assert_eq!(
provider.get("model").and_then(|v| v.as_str()),
Some("model-a"),
"entry should carry a singular `model:` field set to the first model id \
so Hermes runtime/picker reads it"
);
let models = provider.get("models").and_then(|v| v.as_mapping()).unwrap();
assert_eq!(models.len(), 2);
assert!(models.contains_key(serde_yaml::Value::String("model-a".into())));
assert!(models.contains_key(serde_yaml::Value::String("model-b".into())));
let model_a = models
.get(serde_yaml::Value::String("model-a".into()))
.unwrap();
assert_eq!(
model_a
.get("context_length")
.and_then(|v| v.as_u64())
.unwrap(),
200000
);
// id should not leak into each model value
assert!(model_a.get("id").is_none());
});
}
#[test]
#[serial]
fn provider_models_roundtrip_array_dict_array_preserves_order() {
with_test_home(|| {
let input = serde_json::json!({
"base_url": "https://api.example.com/v1",
"api_key": "sk-test",
"models": [
{ "id": "first", "context_length": 1 },
{ "id": "second", "context_length": 2 },
{ "id": "third", "context_length": 3 },
]
});
set_provider("order", input).unwrap();
let providers = get_providers().unwrap();
let provider = providers.get("order").unwrap();
let models = provider.get("models").and_then(|v| v.as_array()).unwrap();
let ids: Vec<&str> = models
.iter()
.map(|m| m.get("id").and_then(|v| v.as_str()).unwrap())
.collect();
assert_eq!(ids, vec!["first", "second", "third"]);
assert_eq!(models[0].get("context_length").unwrap(), 1);
});
}
#[test]
#[serial]
fn provider_without_models_is_unaffected() {
with_test_home(|| {
let input = serde_json::json!({
"base_url": "https://api.example.com/v1",
"api_key": "sk-test"
});
set_provider("simple", input).unwrap();
let providers = get_providers().unwrap();
let provider = providers.get("simple").unwrap();
assert!(provider.get("models").is_none());
assert!(
provider.get("model").is_none(),
"singular `model:` should not appear when no models are declared"
);
});
}
// ---- apply_switch_defaults ----
#[test]
#[serial]
fn apply_switch_defaults_sets_default_and_provider() {
with_test_home(|| {
let settings = serde_json::json!({
"base_url": "https://api.example.com/v1",
"models": [
{ "id": "primary-model", "context_length": 200000 },
{ "id": "fallback", "context_length": 100000 },
]
});
apply_switch_defaults("demo", &settings).unwrap();
let model = get_model_config().unwrap().unwrap();
assert_eq!(model.default.as_deref(), Some("primary-model"));
assert_eq!(model.provider.as_deref(), Some("demo"));
});
}
#[test]
#[serial]
fn apply_switch_defaults_preserves_user_context_length() {
with_test_home(|| {
// User previously set a custom context_length via the Model panel.
let initial = HermesModelConfig {
default: Some("old-model".to_string()),
provider: Some("old-provider".to_string()),
base_url: Some("https://user-override.example.com".to_string()),
context_length: Some(131072),
max_tokens: Some(16384),
extra: HashMap::new(),
};
set_model_config(&initial).unwrap();
let settings = serde_json::json!({
"models": [{ "id": "new-model" }]
});
apply_switch_defaults("new-provider", &settings).unwrap();
let model = get_model_config().unwrap().unwrap();
assert_eq!(model.default.as_deref(), Some("new-model"));
assert_eq!(model.provider.as_deref(), Some("new-provider"));
// User-customized fields must survive the switch.
assert_eq!(
model.base_url.as_deref(),
Some("https://user-override.example.com")
);
assert_eq!(model.context_length, Some(131072));
assert_eq!(model.max_tokens, Some(16384));
});
}
#[test]
#[serial]
fn apply_switch_defaults_updates_provider_even_without_models() {
with_test_home(|| {
// Seed an existing `model:` section — the user was already running
// some provider before this switch.
let initial = HermesModelConfig {
default: Some("legacy-default".to_string()),
provider: Some("legacy-provider".to_string()),
..Default::default()
};
set_model_config(&initial).unwrap();
// New provider has no `models` list — previously this would no-op
// and leave `model.provider` pointing at the legacy provider,
// causing "switch succeeds but has no effect" bug.
let settings = serde_json::json!({
"base_url": "https://api.example.com/v1"
});
apply_switch_defaults("bare", &settings).unwrap();
let model = get_model_config().unwrap().unwrap();
assert_eq!(model.provider.as_deref(), Some("bare"));
assert_eq!(model.default.as_deref(), Some("legacy-default"));
});
}
#[test]
#[serial]
fn apply_switch_defaults_keeps_old_default_when_first_model_id_is_blank() {
with_test_home(|| {
let initial = HermesModelConfig {
default: Some("prev-default".to_string()),
provider: Some("prev-provider".to_string()),
..Default::default()
};
set_model_config(&initial).unwrap();
let settings = serde_json::json!({
"models": [{ "id": " " }, { "id": "real" }]
});
apply_switch_defaults("edge", &settings).unwrap();
let model = get_model_config().unwrap().unwrap();
// Provider always updates.
assert_eq!(model.provider.as_deref(), Some("edge"));
// First entry's id is whitespace-only → blank → fall back to old default
// (we intentionally don't scan past the first entry for a default).
assert_eq!(model.default.as_deref(), Some("prev-default"));
});
}
// ---- memory file tests ----
#[test]
#[serial]
fn read_memory_returns_empty_when_file_missing() {
with_test_home(|| {
let memory = read_memory(MemoryKind::Memory).unwrap();
let user = read_memory(MemoryKind::User).unwrap();
assert!(memory.is_empty());
assert!(user.is_empty());
});
}
#[test]
#[serial]
fn write_then_read_memory_round_trip() {
with_test_home(|| {
let blob = "> note\n§\nfirst entry\n§\nsecond entry\n";
write_memory(MemoryKind::Memory, blob).unwrap();
assert_eq!(read_memory(MemoryKind::Memory).unwrap(), blob);
// Writing USER.md doesn't clobber MEMORY.md.
write_memory(MemoryKind::User, "user profile").unwrap();
assert_eq!(read_memory(MemoryKind::Memory).unwrap(), blob);
assert_eq!(read_memory(MemoryKind::User).unwrap(), "user profile");
});
}
#[test]
#[serial]
fn memory_limits_fall_back_to_defaults_when_config_missing() {
with_test_home(|| {
let limits = read_memory_limits().unwrap();
let defaults = HermesMemoryLimits::default();
assert_eq!(limits.memory, defaults.memory);
assert_eq!(limits.user, defaults.user);
assert_eq!(limits.memory_enabled, defaults.memory_enabled);
assert_eq!(limits.user_enabled, defaults.user_enabled);
});
}
#[test]
#[serial]
fn set_memory_enabled_preserves_other_fields() {
// Flipping one toggle must preserve character budgets and external
// provider settings the user configured via Hermes Web UI — otherwise
// a CC Switch toggle would silently wipe those fields.
with_test_home(|| {
let yaml = "\
memory:
memory_char_limit: 4096
user_char_limit: 2048
memory_enabled: true
user_profile_enabled: true
provider: mem0
";
let config_path = get_hermes_config_path();
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, yaml).unwrap();
set_memory_enabled(MemoryKind::Memory, false).unwrap();
let limits = read_memory_limits().unwrap();
assert!(!limits.memory_enabled, "toggle applied");
assert!(limits.user_enabled, "unrelated toggle untouched");
assert_eq!(limits.memory, 4096, "budgets preserved");
assert_eq!(limits.user, 2048);
// Verify the external provider field survived the section replacement.
let config = read_hermes_config().unwrap();
let provider = config
.get("memory")
.and_then(|v| v.get("provider"))
.and_then(|v| v.as_str());
assert_eq!(provider, Some("mem0"));
});
}
#[test]
#[serial]
fn memory_limits_read_from_config_yaml() {
with_test_home(|| {
let yaml = "\
memory:
memory_char_limit: 4096
user_char_limit: 2048
memory_enabled: false
user_profile_enabled: true
";
let config_path = get_hermes_config_path();
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, yaml).unwrap();
let limits = read_memory_limits().unwrap();
assert_eq!(limits.memory, 4096);
assert_eq!(limits.user, 2048);
assert!(!limits.memory_enabled);
assert!(limits.user_enabled);
});
}
#[test]
#[serial]
fn memory_limits_ignore_top_level_keys() {
// Regression guard: Hermes nests memory settings under `memory:`, so
// identically-named keys at the top level must be ignored rather than
// silently consumed.
with_test_home(|| {
let yaml = "\
memory_char_limit: 9999
user_char_limit: 9999
memory_enabled: false
user_profile_enabled: false
";
let config_path = get_hermes_config_path();
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(&config_path, yaml).unwrap();
let limits = read_memory_limits().unwrap();
let defaults = HermesMemoryLimits::default();
assert_eq!(limits.memory, defaults.memory);
assert_eq!(limits.user, defaults.user);
assert_eq!(limits.memory_enabled, defaults.memory_enabled);
assert_eq!(limits.user_enabled, defaults.user_enabled);
});
}
#[test]
fn memory_kind_deserializes_from_lowercase_strings() {
let memory: MemoryKind = serde_json::from_str("\"memory\"").unwrap();
let user: MemoryKind = serde_json::from_str("\"user\"").unwrap();
assert_eq!(memory, MemoryKind::Memory);
assert_eq!(user, MemoryKind::User);
assert!(serde_json::from_str::<MemoryKind>("\"bogus\"").is_err());
}
// ---- get_hermes_dir resolution (platform default + HERMES_HOME) ----
#[test]
#[serial]
fn hermes_home_env_takes_precedence_over_platform_default() {
with_test_home(|| {
// Clear any settings override so resolution reaches the HERMES_HOME branch.
let mut s = crate::settings::get_settings();
s.hermes_config_dir = None;
crate::settings::update_settings(s).unwrap();
let old = std::env::var_os("HERMES_HOME");
let custom = std::env::temp_dir().join("ccs-hermes-home-precedence");
std::env::set_var("HERMES_HOME", &custom);
let dir = get_hermes_dir();
match old {
Some(v) => std::env::set_var("HERMES_HOME", v),
None => std::env::remove_var("HERMES_HOME"),
}
assert_eq!(
dir, custom,
"HERMES_HOME should take precedence over the platform default, got {dir:?}"
);
});
}
#[test]
#[serial]
fn settings_override_takes_precedence_over_hermes_home() {
with_test_home(|| {
let custom = tempfile::tempdir().unwrap();
let custom_path = custom.path().to_path_buf();
let mut s = crate::settings::get_settings();
s.hermes_config_dir = Some(custom_path.to_string_lossy().to_string());
crate::settings::update_settings(s).unwrap();
let old = std::env::var_os("HERMES_HOME");
std::env::set_var("HERMES_HOME", "/tmp/should-be-ignored");
let dir = get_hermes_dir();
match old {
Some(v) => std::env::set_var("HERMES_HOME", v),
None => std::env::remove_var("HERMES_HOME"),
}
let mut s = crate::settings::get_settings();
s.hermes_config_dir = None;
crate::settings::update_settings(s).unwrap();
assert_eq!(
dir, custom_path,
"settings override should win over HERMES_HOME, got {dir:?}"
);
});
}
#[test]
#[serial]
fn blank_hermes_home_falls_through_to_platform_default() {
with_test_home(|| {
let mut s = crate::settings::get_settings();
s.hermes_config_dir = None;
crate::settings::update_settings(s).unwrap();
let old = std::env::var_os("HERMES_HOME");
std::env::set_var("HERMES_HOME", " ");
let dir = get_hermes_dir();
match old {
Some(v) => std::env::set_var("HERMES_HOME", v),
None => std::env::remove_var("HERMES_HOME"),
}
// Blank HERMES_HOME is ignored (matches Hermes' `.strip()` non-empty check),
// so resolution must reach the platform default, never the literal blank path.
assert_ne!(dir, PathBuf::from(" "));
assert_eq!(dir, default_hermes_dir());
});
}
#[test]
#[serial]
fn default_hermes_dir_without_override_is_platform_correct() {
with_test_home(|| {
let mut s = crate::settings::get_settings();
s.hermes_config_dir = None;
crate::settings::update_settings(s).unwrap();
let old = std::env::var_os("HERMES_HOME");
std::env::remove_var("HERMES_HOME");
let dir = get_hermes_dir();
if let Some(v) = old {
std::env::set_var("HERMES_HOME", v);
}
#[cfg(target_os = "windows")]
assert!(
dir.ends_with("hermes") && dir.to_string_lossy().to_lowercase().contains("local"),
"Windows default should be %LOCALAPPDATA%\\hermes, got {dir:?}"
);
#[cfg(not(target_os = "windows"))]
assert!(
dir.ends_with(".hermes"),
"Unix default should be ~/.hermes, got {dir:?}"
);
});
}
#[test]
fn windows_local_hermes_dir_uses_localappdata_when_set() {
let local = std::ffi::OsString::from("C:\\Users\\tester\\AppData\\Local");
let home = Path::new("C:\\Users\\tester");
// Uses LOCALAPPDATA (ignoring home) and appends `hermes`. Build the expected
// path with `join` so the separator matches the host OS.
assert_eq!(
windows_local_hermes_dir(Some(local.as_os_str()), home),
PathBuf::from(&local).join("hermes"),
);
}
#[test]
fn windows_local_hermes_dir_falls_back_to_home_when_localappdata_missing_or_empty() {
let home = Path::new("C:\\Users\\tester");
let expected = home.join("AppData").join("Local").join("hermes");
assert_eq!(windows_local_hermes_dir(None, home), expected);
let empty = std::ffi::OsString::from("");
assert_eq!(
windows_local_hermes_dir(Some(empty.as_os_str()), home),
expected,
);
}
#[test]
fn windows_local_hermes_dir_trims_localappdata() {
// Mirror Hermes' `os.environ.get("LOCALAPPDATA", "").strip()`.
let home = Path::new("C:\\Users\\tester");
// Whitespace-only -> treated as unset, fall back to <home>\AppData\Local.
let blank = std::ffi::OsString::from(" ");
assert_eq!(
windows_local_hermes_dir(Some(blank.as_os_str()), home),
home.join("AppData").join("Local").join("hermes"),
);
// Padded value -> trimmed before use.
let padded = std::ffi::OsString::from(" C:\\Custom\\Local ");
assert_eq!(
windows_local_hermes_dir(Some(padded.as_os_str()), home),
PathBuf::from("C:\\Custom\\Local").join("hermes"),
);
}
#[test]
#[serial]
fn with_test_home_neutralizes_hermes_env_vars() {
// An ambient HERMES_HOME / LOCALAPPDATA (e.g. set by a Hermes install)
// must not leak into the test home — otherwise other tests in this
// module would read/write a real Hermes config via get_hermes_config_path().
let saved_hh = std::env::var_os("HERMES_HOME");
let saved_la = std::env::var_os("LOCALAPPDATA");
std::env::set_var("HERMES_HOME", "/ambient/hermes-home");
std::env::set_var("LOCALAPPDATA", "/ambient/local-appdata");
let inside = with_test_home(|| {
(
std::env::var_os("HERMES_HOME"),
std::env::var_os("LOCALAPPDATA"),
)
});
match saved_hh {
Some(v) => std::env::set_var("HERMES_HOME", v),
None => std::env::remove_var("HERMES_HOME"),
}
match saved_la {
Some(v) => std::env::set_var("LOCALAPPDATA", v),
None => std::env::remove_var("LOCALAPPDATA"),
}
assert_eq!(
inside.0, None,
"with_test_home must clear ambient HERMES_HOME"
);
assert_eq!(
inside.1, None,
"with_test_home must clear ambient LOCALAPPDATA"
);
}
}