pub const SCHEMA_VERSION: u32 = 1;
const MAX_ITEMS: usize = 50;
const GOAL_CHARS: usize = 2000;
const TEXT_CHARS: usize = 500;
const PATH_CHARS: usize = 300;
const PROFILE_CHARS: usize = 200;
const URL_CHARS: usize = 2000;
#[derive(Debug, Clone, PartialEq)]
pub struct RdshSettings {
pub schema: u32,
pub general: GeneralSection,
pub discord: DiscordSection,
pub tokens: TokensSection,
pub search: SearchSection,
pub compact: CompactSection,
pub sessions: SessionsSection,
pub logs: LogsSection,
pub serve: ServeSection,
pub guard: GuardSection,
pub bench: BenchSection,
pub setup: SetupSection,
pub beta: BetaSection,
pub context: ContextSection,
pub extras: ExtrasSection,
pub features: FeaturesSection,
pub subagents: SubagentsSection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SubagentsSection {
pub max_parallel: usize,
pub max_items_per_agent: usize,
pub max_weight_per_agent: usize,
pub budget_units: usize,
}
impl Default for SubagentsSection {
fn default() -> Self {
Self {
max_parallel: 3,
max_items_per_agent: 3,
max_weight_per_agent: 9,
budget_units: 0,
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ExtrasSection {
pub enable: Vec<String>,
}
pub const KNOWN_EXTRAS: &[&str] = &["serve", "search-web"];
pub fn extra_enabled(cfg: &RdshSettings, id: &str) -> bool {
KNOWN_EXTRAS.contains(&id) && cfg.extras.enable.iter().any(|e| e == id)
}
pub fn enabled_extras(cfg: &RdshSettings) -> Vec<String> {
KNOWN_EXTRAS
.iter()
.filter(|id| feature_enabled(cfg, id))
.map(|id| (*id).to_string())
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FeatureDef {
pub id: &'static str,
pub default: bool,
pub summary: &'static str,
}
pub const FEATURES: &[FeatureDef] = &[
FeatureDef {
id: "discord",
default: false,
summary: "Discord Rich Presence, off by default (needs the DSH web service running)",
},
FeatureDef {
id: "telemetry",
default: false,
summary: "let delegated DSH send session telemetry (OpenTelemetry)",
},
FeatureDef {
id: "update_check",
default: true,
summary: "look for rustdsh updates and show the update banner",
},
FeatureDef {
id: "update_notifications",
default: true,
summary: "push a notification when an update is found",
},
FeatureDef {
id: "sound",
default: true,
summary: "allow the DSH sound notifications install (installer SOUND=0 to skip)",
},
FeatureDef {
id: "gitview",
default: true,
summary: "allow the DSH git panel install (installer GITVIEW=0 to skip)",
},
FeatureDef {
id: "serve",
default: false,
summary: "local read-only dashboard (rdsh serve)",
},
FeatureDef {
id: "search-web",
default: false,
summary: "SearXNG web search (rdsh search-web)",
},
FeatureDef {
id: "context_engine",
default: false,
summary: "experimental per-turn context engine (rdsh context)",
},
FeatureDef {
id: "dep_graph",
default: true,
summary: "static code-dependency graph and change impact (rdsh deps)",
},
FeatureDef {
id: "models_sync",
default: true,
summary: "refresh provider model catalogs from the daily auto-update run",
},
FeatureDef {
id: "compute",
default: false,
summary: "allow the resident compute bridge (RDSH_COMPUTE=1)",
},
FeatureDef {
id: "budget_guard",
default: true,
summary: "budget guard hook in the DSH plugin",
},
FeatureDef {
id: "answer_guard",
default: true,
summary: "ask the model once more when a turn ends with reasoning only",
},
FeatureDef {
id: "agent_tool_sandbox",
default: false,
summary: "confine delegated agent tools to --share-file files (no network, no host writes)",
},
FeatureDef {
id: "release_probe",
default: false,
summary: "run the release qualification probe when its environment is set",
},
FeatureDef {
id: "setup_web",
default: true,
summary: "rdsh setup --web local setup page",
},
FeatureDef {
id: "sessions_token_cache",
default: true,
summary: "cache decompressed session token sizes",
},
FeatureDef {
id: "node_compile_cache",
default: true,
summary: "Node compile cache for delegated runs (Node >= 22.1)",
},
FeatureDef {
id: "slim_lazy_plugins",
default: true,
summary: "ask DSH to load plugins lazily in slim mode",
},
FeatureDef {
id: "slim_disable_voice",
default: true,
summary: "disable DSH voice features in slim mode",
},
FeatureDef {
id: "slim_disable_auto_review",
default: true,
summary: "disable DSH auto review in slim mode",
},
];
pub fn feature_def(id: &str) -> Option<&'static FeatureDef> {
FEATURES.iter().find(|f| f.id == id)
}
pub fn feature_default(id: &str) -> bool {
feature_def(id).is_some_and(|f| f.default)
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct FeaturesSection {
pub values: std::collections::BTreeMap<String, bool>,
}
impl FeaturesSection {
fn from_value(v: &serde_json::Value) -> Self {
let mut values = std::collections::BTreeMap::new();
if let Some(map) = v.as_object() {
for (id, value) in map {
if let Some(on) = value.as_bool() {
if feature_def(id).is_some() {
values.insert(id.clone(), on);
}
}
}
}
Self { values }
}
fn to_value(&self) -> serde_json::Value {
serde_json::Value::Object(
self.values
.iter()
.map(|(id, on)| (id.clone(), serde_json::Value::Bool(*on)))
.collect(),
)
}
}
pub fn feature_enabled(cfg: &RdshSettings, id: &str) -> bool {
if let Some(on) = cfg.features.values.get(id) {
return *on;
}
match id {
"serve" | "search-web" => extra_enabled(cfg, id),
"context_engine" => cfg.beta.context_engine,
"discord" => cfg.discord.enabled,
_ => feature_default(id),
}
}
pub fn feature_is_explicit(cfg: &RdshSettings, id: &str) -> bool {
cfg.features.values.contains_key(id)
}
#[derive(Debug, Clone, PartialEq)]
pub struct GeneralSection {
pub slim: bool,
pub passthrough: bool,
pub dry_run: bool,
pub default_profile: String,
pub language: String,
}
pub const DEFAULT_DISCORD_APPLICATION_ID: &str = "1557873849280888903";
#[derive(Debug, Clone, PartialEq)]
pub struct DiscordSection {
pub enabled: bool,
pub application_id: String,
pub details: String,
pub show_agent_status: bool,
pub show_elapsed: bool,
pub show_image: bool,
pub status_display: String,
pub large_image: String,
pub large_text: String,
pub button_label: String,
pub button_url: String,
}
impl Default for DiscordSection {
fn default() -> Self {
Self {
enabled: false,
application_id: DEFAULT_DISCORD_APPLICATION_ID.into(),
details: String::new(),
show_agent_status: true,
show_elapsed: true,
show_image: true,
status_display: "details".into(),
large_image: String::new(),
large_text: String::new(),
button_label: String::new(),
button_url: String::new(),
}
}
}
impl DiscordSection {
fn from_value(v: &serde_json::Value) -> Self {
let details = text(v, "details", 128);
let application_id = text(v, "application_id", 20);
Self {
enabled: flag(v, "enabled", false),
application_id: if application_id.trim().is_empty() {
DEFAULT_DISCORD_APPLICATION_ID.into()
} else {
application_id.trim().into()
},
details: if details.trim().is_empty() {
Self::default().details
} else {
details
},
show_agent_status: flag(v, "show_agent_status", true),
show_elapsed: flag(v, "show_elapsed", true),
show_image: flag(v, "show_image", true),
status_display: match v["status_display"].as_str() {
Some("name") => "name",
Some("state") => "state",
_ => "details",
}
.into(),
large_image: text(v, "large_image", 512).trim().into(),
large_text: text(v, "large_text", 128),
button_label: text(v, "button_label", 32).trim().into(),
button_url: text(v, "button_url", 512).trim().into(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TokensSection {
pub default_budget: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SearchSection {
pub dir: String,
pub max: usize,
pub web_limit: usize,
pub searxng_url: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CompactSection {
pub max_tokens: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SessionsSection {
pub limit: usize,
pub with_tokens: bool,
pub stale_secs: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LogsSection {
pub tail: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ServeSection {
pub port: u16,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct GuardSection {
pub deny: Vec<String>,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct BenchSection {
pub n: u32,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct SetupSection {
pub web_port: u16,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct BetaSection {
pub context_engine: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ContextSection {
pub token_budget: usize,
pub enable_retriever: bool,
pub enable_packer: bool,
pub enable_verifier: bool,
pub goal: String,
pub decisions: Vec<String>,
pub constraints: Vec<String>,
pub working_files: Vec<String>,
pub open_tasks: Vec<String>,
pub max_code_hits: usize,
pub max_sessions: usize,
pub include_git_diff: bool,
pub inject_budget: usize,
pub inject_deadline_ms: usize,
pub inject_sessions: bool,
pub inject_session_count: usize,
}
impl Default for GeneralSection {
fn default() -> Self {
Self {
slim: true,
passthrough: false,
dry_run: false,
default_profile: String::new(),
language: "auto".to_string(),
}
}
}
impl Default for TokensSection {
fn default() -> Self {
Self {
default_budget: 4000,
}
}
}
impl Default for SearchSection {
fn default() -> Self {
Self {
dir: ".".to_string(),
max: 100,
web_limit: 10,
searxng_url: String::new(),
}
}
}
impl Default for CompactSection {
fn default() -> Self {
Self { max_tokens: 8000 }
}
}
impl Default for SessionsSection {
fn default() -> Self {
Self {
limit: 20,
with_tokens: false,
stale_secs: 60,
}
}
}
impl Default for LogsSection {
fn default() -> Self {
Self { tail: 50 }
}
}
impl Default for ServeSection {
fn default() -> Self {
Self { port: 38080 }
}
}
impl Default for BenchSection {
fn default() -> Self {
Self { n: 5 }
}
}
impl Default for ContextSection {
fn default() -> Self {
Self {
token_budget: 4000,
enable_retriever: true,
enable_packer: true,
enable_verifier: true,
goal: String::new(),
decisions: vec![],
constraints: vec![],
working_files: vec![],
open_tasks: vec![],
max_code_hits: 20,
max_sessions: 0,
include_git_diff: true,
inject_budget: 1200,
inject_deadline_ms: 1200,
inject_sessions: true,
inject_session_count: 2,
}
}
}
impl Default for RdshSettings {
fn default() -> Self {
Self {
schema: SCHEMA_VERSION,
general: GeneralSection::default(),
discord: DiscordSection::default(),
tokens: TokensSection::default(),
search: SearchSection::default(),
compact: CompactSection::default(),
sessions: SessionsSection::default(),
logs: LogsSection::default(),
serve: ServeSection::default(),
guard: GuardSection::default(),
bench: BenchSection::default(),
setup: SetupSection::default(),
beta: BetaSection::default(),
context: ContextSection::default(),
extras: ExtrasSection::default(),
features: FeaturesSection::default(),
subagents: SubagentsSection::default(),
}
}
}
pub fn settings_path() -> String {
format!("{}/rdsh.json", crate::inspect::dsh_home())
}
pub fn load() -> RdshSettings {
match try_load() {
Ok(cfg) => cfg,
Err(e) => {
eprintln!("[rdsh] error: {e:#}");
std::process::exit(1);
}
}
}
pub fn load_with_locale(locale: crate::locale::Locale) -> RdshSettings {
match try_load_with_locale(locale) {
Ok(cfg) => cfg,
Err(e) => {
eprintln!(
"[rdsh] {}: {e:#}",
crate::locale::message(locale, crate::locale::Message::Error)
);
std::process::exit(1);
}
}
}
pub fn try_load_with_locale(locale: crate::locale::Locale) -> anyhow::Result<RdshSettings> {
let path = settings_path();
try_load_at(&path, locale)
}
pub fn try_load() -> anyhow::Result<RdshSettings> {
try_load_with_locale(crate::locale::Locale::English)
}
fn settings_read_error(
path: &str,
error: &dyn std::fmt::Display,
locale: crate::locale::Locale,
) -> anyhow::Error {
anyhow::anyhow!(
"{} {path}: {error}",
settings_text(
locale,
"cannot read settings file at",
"无法读取设置文件",
"設定ファイルを読み込めません"
)
)
}
const SETTINGS_MAX_BYTES: u64 = 4 * 1024 * 1024;
pub(crate) fn read_small_config(
path: &str,
locale: crate::locale::Locale,
) -> anyhow::Result<Option<String>> {
use std::io::Read;
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NONBLOCK);
}
let file = match options.open(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(settings_read_error(path, &e, locale)),
};
let meta = file
.metadata()
.map_err(|e| settings_read_error(path, &e, locale))?;
if !meta.is_file() {
anyhow::bail!("settings file at {path} is not a regular file");
}
if meta.len() > SETTINGS_MAX_BYTES {
anyhow::bail!(
"settings file at {path} is {} bytes (limit {SETTINGS_MAX_BYTES})",
meta.len()
);
}
let mut raw = String::new();
file.take(SETTINGS_MAX_BYTES + 1)
.read_to_string(&mut raw)
.map_err(|e| settings_read_error(path, &e, locale))?;
if raw.len() as u64 > SETTINGS_MAX_BYTES {
anyhow::bail!("settings file at {path} exceeds {SETTINGS_MAX_BYTES} bytes");
}
Ok(Some(raw))
}
fn try_load_at(path: &str, locale: crate::locale::Locale) -> anyhow::Result<RdshSettings> {
let raw: Option<String> = read_small_config(path, locale)?;
let Some(raw) = raw else {
let mut cfg = RdshSettings::default();
complement_from_legacy(&mut cfg, false, locale);
return Ok(cfg);
};
let parsed: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
return Err(anyhow::anyhow!(
"{} {path}: {e} {}; {}",
settings_text(
locale,
"invalid settings file at",
"无效的设置文件",
"無効な設定ファイル"
),
settings_location(locale, e.line(), e.column()),
settings_text(
locale,
"run `rdsh settings init --force` to restore defaults",
"运行 `rdsh settings init --force` 恢复默认值",
"`rdsh settings init --force` を実行して既定値を復元します"
)
));
}
};
if !parsed.is_object() {
return Err(anyhow::anyhow!(
"{} {path}: {}; {}",
settings_text(
locale,
"invalid settings file at",
"无效的设置文件",
"無効な設定ファイル"
),
settings_text(
locale,
"expected a JSON object",
"应为 JSON 对象",
"JSON オブジェクトが必要です"
),
settings_text(
locale,
"run `rdsh settings init --force` to restore defaults",
"运行 `rdsh settings init --force` 恢复默认值",
"`rdsh settings init --force` を実行して既定値を復元します"
)
));
}
validate_language_setting(&parsed, path, locale)?;
let has_context = parsed.get("context").is_some_and(|c| !c.is_null());
let mut cfg = if parsed.is_null() {
RdshSettings::default()
} else {
RdshSettings::from_value(&parsed)
};
complement_from_legacy(&mut cfg, has_context, locale);
Ok(cfg)
}
pub(crate) fn update<T>(
locale: crate::locale::Locale,
mutate: impl FnOnce(&mut RdshSettings) -> anyhow::Result<T>,
) -> anyhow::Result<(RdshSettings, T)> {
let path = settings_path();
let _lock = crate::file_lock::FileLock::exclusive_for(std::path::Path::new(&path), locale)?;
let mut cfg = try_load_at(&path, locale)?;
let result = mutate(&mut cfg)?;
cfg.save_locked(&path)?;
Ok((cfg, result))
}
pub(crate) fn initialize(force: bool, locale: crate::locale::Locale) -> anyhow::Result<bool> {
let path = settings_path();
let _lock = crate::file_lock::FileLock::exclusive_for(std::path::Path::new(&path), locale)?;
if std::path::Path::new(&path).exists() && !force {
return Ok(false);
}
RdshSettings::default().save_locked(&path)?;
Ok(true)
}
impl RdshSettings {
#[cfg(test)]
pub fn save(&self) -> anyhow::Result<()> {
let path = settings_path();
let _lock = crate::file_lock::FileLock::exclusive_for(
std::path::Path::new(&path),
crate::locale::Locale::English,
)?;
self.save_locked(&path)
}
fn save_locked(&self, path: &str) -> anyhow::Result<()> {
if let Some(parent) = std::path::Path::new(path).parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let mut clean = self.clone();
clean.sanitize();
let text = serde_json::to_string_pretty(&clean.to_value())?;
crate::auth::write_creds(path, &format!("{text}\n"))
}
fn from_value(v: &serde_json::Value) -> Self {
let sec = |key: &str| -> serde_json::Value {
v.get(key).cloned().unwrap_or(serde_json::Value::Null)
};
let g = sec("general");
let t = sec("tokens");
let s = sec("search");
let c = sec("compact");
let ss = sec("sessions");
let l = sec("logs");
let sv = sec("serve");
let gd = sec("guard");
let b = sec("bench");
let su = sec("setup");
let be = sec("beta");
let cx = sec("context");
let sub = sec("subagents");
let mut out = Self {
schema: SCHEMA_VERSION,
general: GeneralSection {
slim: flag(&g, "slim", true),
passthrough: flag(&g, "passthrough", false),
dry_run: flag(&g, "dry_run", false),
default_profile: text(&g, "default_profile", PROFILE_CHARS),
language: language(&g, "language"),
},
discord: DiscordSection::from_value(&sec("discord")),
tokens: TokensSection {
default_budget: clamp_u64(num(&t, "default_budget"), 4000, 500, 200_000) as usize,
},
search: SearchSection {
dir: {
let d = text(&s, "dir", PATH_CHARS);
if d.trim().is_empty() {
".".to_string()
} else {
d
}
},
max: clamp_u64(num(&s, "max"), 100, 1, 100) as usize,
web_limit: clamp_u64(num(&s, "web_limit"), 10, 1, 100) as usize,
searxng_url: text(&s, "searxng_url", URL_CHARS),
},
compact: CompactSection {
max_tokens: clamp_u64(num(&c, "max_tokens"), 8000, 500, 200_000) as usize,
},
sessions: SessionsSection {
limit: clamp_u64(num(&ss, "limit"), 20, 1, 100) as usize,
with_tokens: flag(&ss, "with_tokens", false),
stale_secs: clamp_u64(num(&ss, "stale_secs"), 60, 0, 3600),
},
logs: LogsSection {
tail: clamp_u64(num(&l, "tail"), 50, 1, 500) as usize,
},
serve: ServeSection {
port: clamp_u64(num(&sv, "port"), 38080, 1, 65535) as u16,
},
guard: GuardSection {
deny: list(&gd, "deny", TEXT_CHARS),
reason: text(&gd, "reason", TEXT_CHARS),
},
bench: BenchSection {
n: clamp_u64(num(&b, "n"), 5, 1, 20) as u32,
},
setup: SetupSection {
web_port: match num(&su, "web_port") {
None => 0,
Some(0) => 0,
Some(n) => n.clamp(1, 65535) as u16,
},
},
beta: BetaSection {
context_engine: flag(&be, "context_engine", false),
},
context: ContextSection {
token_budget: clamp_u64(num(&cx, "token_budget"), 4000, 500, 200_000) as usize,
enable_retriever: flag(&cx, "enable_retriever", true),
enable_packer: flag(&cx, "enable_packer", true),
enable_verifier: flag(&cx, "enable_verifier", true),
goal: text(&cx, "goal", GOAL_CHARS),
decisions: list(&cx, "decisions", TEXT_CHARS),
constraints: list(&cx, "constraints", TEXT_CHARS),
working_files: working_file_list(&cx),
open_tasks: list(&cx, "open_tasks", TEXT_CHARS),
max_code_hits: clamp_u64(num(&cx, "max_code_hits"), 20, 1, 100) as usize,
max_sessions: clamp_u64(num(&cx, "max_sessions"), 0, 0, 100) as usize,
include_git_diff: flag(&cx, "include_git_diff", true),
inject_budget: clamp_u64(num(&cx, "inject_budget"), 1200, 200, 8000) as usize,
inject_deadline_ms: clamp_u64(num(&cx, "inject_deadline_ms"), 1200, 100, 10_000)
as usize,
inject_sessions: flag(&cx, "inject_sessions", true),
inject_session_count: clamp_u64(num(&cx, "inject_session_count"), 2, 0, 10)
as usize,
},
extras: ExtrasSection {
enable: list(&sec("extras"), "enable", TEXT_CHARS)
.into_iter()
.filter(|e| KNOWN_EXTRAS.contains(&e.as_str()))
.collect(),
},
features: FeaturesSection::from_value(&sec("features")),
subagents: SubagentsSection {
max_parallel: clamp_u64(num(&sub, "max_parallel"), 3, 1, 16) as usize,
max_items_per_agent: clamp_u64(num(&sub, "max_items_per_agent"), 3, 1, 20) as usize,
max_weight_per_agent: clamp_u64(num(&sub, "max_weight_per_agent"), 9, 1, 128)
as usize,
budget_units: clamp_u64(num(&sub, "budget_units"), 0, 0, 100_000) as usize,
},
};
out.sanitize();
out
}
fn sanitize(&mut self) {
let v = self.to_value();
*self = Self::from_value_raw(&v);
}
fn from_value_raw(v: &serde_json::Value) -> Self {
let mut out = Self {
schema: SCHEMA_VERSION,
..Self::default()
};
let g = v.get("general").cloned().unwrap_or(serde_json::Value::Null);
out.general.slim = flag(&g, "slim", true);
out.general.passthrough = flag(&g, "passthrough", false);
out.general.dry_run = flag(&g, "dry_run", false);
out.general.default_profile = text(&g, "default_profile", PROFILE_CHARS);
out.general.language = language(&g, "language");
out.discord = DiscordSection::from_value(&v["discord"]);
let t = v.get("tokens").cloned().unwrap_or(serde_json::Value::Null);
out.tokens.default_budget =
clamp_u64(num(&t, "default_budget"), 4000, 500, 200_000) as usize;
let s = v.get("search").cloned().unwrap_or(serde_json::Value::Null);
out.search.dir = {
let d = text(&s, "dir", PATH_CHARS);
if d.trim().is_empty() {
".".to_string()
} else {
d
}
};
out.search.max = clamp_u64(num(&s, "max"), 100, 1, 100) as usize;
out.search.web_limit = clamp_u64(num(&s, "web_limit"), 10, 1, 100) as usize;
out.search.searxng_url = text(&s, "searxng_url", URL_CHARS);
let c = v.get("compact").cloned().unwrap_or(serde_json::Value::Null);
out.compact.max_tokens = clamp_u64(num(&c, "max_tokens"), 8000, 500, 200_000) as usize;
let ss = v
.get("sessions")
.cloned()
.unwrap_or(serde_json::Value::Null);
out.sessions.limit = clamp_u64(num(&ss, "limit"), 20, 1, 100) as usize;
out.sessions.with_tokens = flag(&ss, "with_tokens", false);
out.sessions.stale_secs = clamp_u64(num(&ss, "stale_secs"), 60, 0, 3600);
let l = v.get("logs").cloned().unwrap_or(serde_json::Value::Null);
out.logs.tail = clamp_u64(num(&l, "tail"), 50, 1, 500) as usize;
let sv = v.get("serve").cloned().unwrap_or(serde_json::Value::Null);
out.serve.port = clamp_u64(num(&sv, "port"), 38080, 1, 65535) as u16;
let gd = v.get("guard").cloned().unwrap_or(serde_json::Value::Null);
out.guard.deny = list(&gd, "deny", TEXT_CHARS);
out.guard.reason = text(&gd, "reason", TEXT_CHARS);
let bn = v.get("bench").cloned().unwrap_or(serde_json::Value::Null);
out.bench.n = clamp_u64(num(&bn, "n"), 5, 1, 20) as u32;
let su = v.get("setup").cloned().unwrap_or(serde_json::Value::Null);
out.setup.web_port = match num(&su, "web_port") {
None => 0,
Some(0) => 0,
Some(n) => n.clamp(1, 65535) as u16,
};
let be = v.get("beta").cloned().unwrap_or(serde_json::Value::Null);
out.beta.context_engine = flag(&be, "context_engine", false);
let ex = v.get("extras").cloned().unwrap_or(serde_json::Value::Null);
out.extras.enable = list(&ex, "enable", TEXT_CHARS)
.into_iter()
.filter(|e| KNOWN_EXTRAS.contains(&e.as_str()))
.collect();
out.features = FeaturesSection::from_value(
&v.get("features")
.cloned()
.unwrap_or(serde_json::Value::Null),
);
let sub = v
.get("subagents")
.cloned()
.unwrap_or(serde_json::Value::Null);
out.subagents.max_parallel = clamp_u64(num(&sub, "max_parallel"), 3, 1, 16) as usize;
out.subagents.max_items_per_agent =
clamp_u64(num(&sub, "max_items_per_agent"), 3, 1, 20) as usize;
out.subagents.max_weight_per_agent =
clamp_u64(num(&sub, "max_weight_per_agent"), 9, 1, 128) as usize;
out.subagents.budget_units = clamp_u64(num(&sub, "budget_units"), 0, 0, 100_000) as usize;
let cx = v.get("context").cloned().unwrap_or(serde_json::Value::Null);
out.context.token_budget = clamp_u64(num(&cx, "token_budget"), 4000, 500, 200_000) as usize;
out.context.enable_retriever = flag(&cx, "enable_retriever", true);
out.context.enable_packer = flag(&cx, "enable_packer", true);
out.context.enable_verifier = flag(&cx, "enable_verifier", true);
out.context.goal = text(&cx, "goal", GOAL_CHARS);
out.context.decisions = list(&cx, "decisions", TEXT_CHARS);
out.context.constraints = list(&cx, "constraints", TEXT_CHARS);
out.context.working_files = working_file_list(&cx);
out.context.open_tasks = list(&cx, "open_tasks", TEXT_CHARS);
out.context.max_code_hits = clamp_u64(num(&cx, "max_code_hits"), 20, 1, 100) as usize;
out.context.max_sessions = clamp_u64(num(&cx, "max_sessions"), 0, 0, 100) as usize;
out.context.include_git_diff = flag(&cx, "include_git_diff", true);
out.context.inject_budget = clamp_u64(num(&cx, "inject_budget"), 1200, 200, 8000) as usize;
out.context.inject_deadline_ms =
clamp_u64(num(&cx, "inject_deadline_ms"), 1200, 100, 10_000) as usize;
out.context.inject_sessions = flag(&cx, "inject_sessions", true);
out.context.inject_session_count =
clamp_u64(num(&cx, "inject_session_count"), 2, 0, 10) as usize;
out
}
pub fn to_value(&self) -> serde_json::Value {
serde_json::json!({
"schema": self.schema,
"general": {
"slim": self.general.slim,
"passthrough": self.general.passthrough,
"dry_run": self.general.dry_run,
"default_profile": self.general.default_profile,
"language": self.general.language,
},
"discord": {
"enabled": self.discord.enabled,
"application_id": self.discord.application_id,
"details": self.discord.details,
"show_agent_status": self.discord.show_agent_status,
"show_elapsed": self.discord.show_elapsed,
"show_image": self.discord.show_image,
"status_display": self.discord.status_display,
"large_image": self.discord.large_image,
"large_text": self.discord.large_text,
"button_label": self.discord.button_label,
"button_url": self.discord.button_url,
},
"tokens": { "default_budget": self.tokens.default_budget },
"search": {
"dir": self.search.dir,
"max": self.search.max,
"web_limit": self.search.web_limit,
"searxng_url": self.search.searxng_url,
},
"compact": { "max_tokens": self.compact.max_tokens },
"sessions": { "limit": self.sessions.limit, "with_tokens": self.sessions.with_tokens, "stale_secs": self.sessions.stale_secs },
"logs": { "tail": self.logs.tail },
"serve": { "port": self.serve.port },
"guard": { "deny": self.guard.deny, "reason": self.guard.reason },
"bench": { "n": self.bench.n },
"setup": { "web_port": self.setup.web_port },
"beta": { "context_engine": self.beta.context_engine },
"extras": { "enable": self.extras.enable },
"features": self.features.to_value(),
"subagents": {
"max_parallel": self.subagents.max_parallel,
"max_items_per_agent": self.subagents.max_items_per_agent,
"max_weight_per_agent": self.subagents.max_weight_per_agent,
"budget_units": self.subagents.budget_units,
},
"context": {
"token_budget": self.context.token_budget,
"enable_retriever": self.context.enable_retriever,
"enable_packer": self.context.enable_packer,
"enable_verifier": self.context.enable_verifier,
"goal": self.context.goal,
"decisions": self.context.decisions,
"constraints": self.context.constraints,
"working_files": self.context.working_files,
"open_tasks": self.context.open_tasks,
"max_code_hits": self.context.max_code_hits,
"max_sessions": self.context.max_sessions,
"include_git_diff": self.context.include_git_diff,
"inject_budget": self.context.inject_budget,
"inject_deadline_ms": self.context.inject_deadline_ms,
"inject_sessions": self.context.inject_sessions,
"inject_session_count": self.context.inject_session_count,
},
})
}
pub fn get_dotted(&self, key: &str) -> Option<serde_json::Value> {
let v = self.to_value();
let key = key.trim().trim_matches('.');
if key.is_empty() || key == "all" {
return Some(v);
}
let key = match key {
"context.files" => "context.working_files",
other => other,
};
let mut cur = &v;
for part in key.split('.') {
cur = cur.get(part)?;
}
Some(cur.clone())
}
pub fn set_dotted(&mut self, key: &str, raw: &str) -> anyhow::Result<()> {
self.set_dotted_with_locale(key, raw, crate::locale::Locale::English)
}
pub fn set_dotted_with_locale(
&mut self,
key: &str,
raw: &str,
locale: crate::locale::Locale,
) -> anyhow::Result<()> {
let key = key.trim().trim_matches('.').to_string();
let bad = || {
anyhow::anyhow!(
"{}: {key} ({})",
settings_text(
locale,
"unknown settings key",
"未知的设置键",
"不明な設定キー"
),
settings_text(
locale,
"try: rdsh settings keys",
"请尝试: rdsh settings keys",
"rdsh settings keys を実行してください"
)
)
};
match key.as_str() {
"general.slim" => self.general.slim = parse_bool(raw, locale)?,
"general.passthrough" => self.general.passthrough = parse_bool(raw, locale)?,
"general.dry_run" => self.general.dry_run = parse_bool(raw, locale)?,
"general.default_profile" => {
self.general.default_profile = parse_string(raw, PROFILE_CHARS)
}
"general.language" => {
self.general.language = parse_language(raw, locale)?;
}
"discord.enabled" => self.discord.enabled = parse_bool(raw, locale)?,
"discord.application_id" => self.discord.application_id = parse_string(raw, 20),
"discord.details" => self.discord.details = parse_string(raw, 128),
"discord.show_agent_status" => {
self.discord.show_agent_status = parse_bool(raw, locale)?
}
"discord.show_elapsed" => self.discord.show_elapsed = parse_bool(raw, locale)?,
"discord.show_image" => self.discord.show_image = parse_bool(raw, locale)?,
"discord.status_display" => {
if !matches!(raw, "name" | "state" | "details") {
anyhow::bail!("{}", settings_text(locale, "discord.status_display must be name, state, or details", "discord.status_display 必须为 name、state 或 details", "discord.status_display は name、state、details のいずれかである必要があります"));
}
self.discord.status_display = raw.into();
}
"discord.large_image" => self.discord.large_image = parse_string(raw, 512),
"discord.large_text" => self.discord.large_text = parse_string(raw, 128),
"discord.button_label" => self.discord.button_label = parse_string(raw, 32),
"discord.button_url" => self.discord.button_url = parse_string(raw, 512),
"tokens.default_budget" => {
self.tokens.default_budget = parse_usize(raw, 4000, locale)? as usize
}
"search.dir" => {
let d = parse_string(raw, PATH_CHARS);
self.search.dir = if d.trim().is_empty() {
".".to_string()
} else {
d
};
}
"search.max" => self.search.max = parse_usize(raw, 100, locale)? as usize,
"search.web_limit" => self.search.web_limit = parse_usize(raw, 10, locale)? as usize,
"search.searxng_url" => self.search.searxng_url = parse_string(raw, URL_CHARS),
"compact.max_tokens" => {
self.compact.max_tokens = parse_usize(raw, 8000, locale)? as usize
}
"sessions.limit" => self.sessions.limit = parse_usize(raw, 20, locale)? as usize,
"sessions.with_tokens" => self.sessions.with_tokens = parse_bool(raw, locale)?,
"sessions.stale_secs" => {
self.sessions.stale_secs = parse_usize(raw, 60, locale)? as u64
}
"logs.tail" => self.logs.tail = parse_usize(raw, 50, locale)? as usize,
"serve.port" => self.serve.port = parse_u16(raw, "serve.port", locale)?,
"guard.deny" => self.guard.deny = parse_list(raw, TEXT_CHARS),
"guard.reason" => self.guard.reason = parse_string(raw, TEXT_CHARS),
"bench.n" => self.bench.n = parse_u32(raw, "bench.n", locale)?,
"setup.web_port" => self.setup.web_port = parse_u16(raw, "setup.web_port", locale)?,
"beta.context_engine" => self.beta.context_engine = parse_bool(raw, locale)?,
"extras.enable" => self.extras.enable = parse_list(raw, TEXT_CHARS),
"context.token_budget" => {
self.context.token_budget = parse_usize(raw, 4000, locale)? as usize
}
"context.enable_retriever" => self.context.enable_retriever = parse_bool(raw, locale)?,
"context.enable_packer" => self.context.enable_packer = parse_bool(raw, locale)?,
"context.enable_verifier" => self.context.enable_verifier = parse_bool(raw, locale)?,
"context.goal" => self.context.goal = parse_string(raw, GOAL_CHARS),
"context.decisions" => self.context.decisions = parse_list(raw, TEXT_CHARS),
"context.constraints" => self.context.constraints = parse_list(raw, TEXT_CHARS),
"context.working_files" | "context.files" => {
self.context.working_files = parse_list(raw, PATH_CHARS)
}
"context.open_tasks" => self.context.open_tasks = parse_list(raw, TEXT_CHARS),
"context.max_code_hits" => {
self.context.max_code_hits = parse_usize(raw, 20, locale)? as usize
}
"context.max_sessions" => {
self.context.max_sessions = parse_usize(raw, 0, locale)? as usize
}
"context.include_git_diff" => self.context.include_git_diff = parse_bool(raw, locale)?,
"context.inject_budget" => {
self.context.inject_budget = parse_usize(raw, 1200, locale)? as usize
}
"context.inject_deadline_ms" => {
self.context.inject_deadline_ms = parse_usize(raw, 1200, locale)? as usize
}
"context.inject_sessions" => self.context.inject_sessions = parse_bool(raw, locale)?,
"context.inject_session_count" => {
self.context.inject_session_count = parse_usize(raw, 2, locale)? as usize
}
"subagents.max_parallel" => {
self.subagents.max_parallel = parse_usize(raw, 3, locale)? as usize
}
"subagents.max_items_per_agent" => {
self.subagents.max_items_per_agent = parse_usize(raw, 3, locale)? as usize
}
"subagents.max_weight_per_agent" => {
self.subagents.max_weight_per_agent = parse_usize(raw, 9, locale)? as usize
}
"subagents.budget_units" => {
self.subagents.budget_units = parse_usize(raw, 0, locale)? as usize
}
k if k.starts_with("features.") => {
let id = &k["features.".len()..];
if feature_def(id).is_none() {
anyhow::bail!(
"{}: {id} ({})",
settings_text(locale, "unknown feature id", "未知功能 ID", "不明な機能 ID"),
settings_text(
locale,
"try: rdsh features",
"请尝试: rdsh features",
"rdsh features を実行してください"
)
);
}
self.features
.values
.insert(id.to_string(), parse_bool(raw, locale)?);
}
_ => return Err(bad()),
}
self.sanitize();
Ok(())
}
#[allow(dead_code)] pub fn reset_dotted(&mut self, key: &str) -> anyhow::Result<()> {
self.reset_dotted_with_locale(key, crate::locale::Locale::English)
}
pub fn reset_dotted_with_locale(
&mut self,
key: &str,
locale: crate::locale::Locale,
) -> anyhow::Result<()> {
let key = key.trim().trim_matches('.').to_string();
let known_section = matches!(
key.as_str(),
"general"
| "discord"
| "tokens"
| "search"
| "compact"
| "sessions"
| "logs"
| "serve"
| "guard"
| "bench"
| "setup"
| "beta"
| "extras"
| "features"
| "context"
| "subagents"
| "all"
| ""
);
let known_feature = key
.strip_prefix("features.")
.is_some_and(|id| feature_def(id).is_some());
if !known_section && !known_feature && RdshSettings::default().get_dotted(&key).is_none() {
return Err(anyhow::anyhow!(
"{}: {key} ({})",
settings_text(
locale,
"unknown settings key",
"未知的设置键",
"不明な設定キー"
),
settings_text(
locale,
"try: rdsh settings keys",
"请尝试: rdsh settings keys",
"rdsh settings keys を実行してください"
)
));
}
let d = RdshSettings::default();
match key.as_str() {
"general" => self.general = d.general,
"discord" => self.discord = d.discord,
"tokens" => self.tokens = d.tokens,
"search" => self.search = d.search,
"compact" => self.compact = d.compact,
"sessions" => self.sessions = d.sessions,
"logs" => self.logs = d.logs,
"serve" => self.serve = d.serve,
"guard" => self.guard = d.guard,
"bench" => self.bench = d.bench,
"setup" => self.setup = d.setup,
"beta" => self.beta = d.beta,
"extras" => self.extras = d.extras,
"features" => self.features = d.features,
"context" => self.context = d.context,
"subagents" => self.subagents = d.subagents,
"all" | "" => *self = d,
k if k.starts_with("features.") => {
let id = &k["features.".len()..];
if feature_def(id).is_none() {
anyhow::bail!(
"{}: {id} ({})",
settings_text(locale, "unknown feature id", "未知功能 ID", "不明な機能 ID"),
settings_text(
locale,
"try: rdsh features",
"请尝试: rdsh features",
"rdsh features を実行してください"
)
);
}
self.features.values.remove(id);
}
_ => {
self.set_dotted_fallback(&key, &d, locale)?;
}
}
self.sanitize();
Ok(())
}
fn set_dotted_fallback(
&mut self,
key: &str,
src: &RdshSettings,
locale: crate::locale::Locale,
) -> anyhow::Result<()> {
let v = src.get_dotted(key).ok_or_else(|| {
anyhow::anyhow!(
"{}: {key}",
settings_text(
locale,
"unknown settings key",
"未知的设置键",
"不明な設定キー"
)
)
})?;
let raw = if v.is_string() {
v.as_str().unwrap_or_default().to_string()
} else {
serde_json::to_string(&v).unwrap_or_default()
};
self.set_dotted_with_locale(key, &raw, locale)
}
pub fn keys() -> &'static [&'static str] {
&[
"general.slim(bool)",
"general.passthrough(bool)",
"general.dry_run(bool)",
"general.default_profile(string)",
"general.language(auto|en|zh-CN|ja)",
"discord.enabled(bool, default OFF)",
"discord.application_id(string)",
"discord.details(string, max 128 chars)",
"discord.show_agent_status(bool)",
"discord.show_elapsed(bool)",
"discord.show_image(bool)",
"discord.status_display(name|state|details)",
"discord.large_image(string, asset key or image URL)",
"discord.large_text(string)",
"discord.button_label(string, max 32 chars)",
"discord.button_url(string, HTTP/HTTPS URL)",
"tokens.default_budget(500-200000)",
"search.dir(string)",
"search.max(1-100)",
"search.web_limit(1-100)",
"search.searxng_url(string)",
"compact.max_tokens(500-200000)",
"subagents.max_parallel(1-16, default 3)",
"subagents.max_items_per_agent(1-20, default 3)",
"subagents.max_weight_per_agent(1-128, default 9)",
"subagents.budget_units(0=off, 1-100000)",
"sessions.limit(1-100)",
"sessions.with_tokens(bool)",
"sessions.stale_secs(0-3600, 0=off)",
"logs.tail(1-500)",
"serve.port(1-65535)",
"guard.deny(list)",
"guard.reason(string)",
"bench.n(1-20)",
"setup.web_port(0-65535, 0=random)",
"beta.context_engine(bool, default OFF)",
"extras.enable(list: serve,search-web; default OFF)",
"context.token_budget(500-200000)",
"context.enable_retriever(bool)",
"context.enable_packer(bool)",
"context.enable_verifier(bool)",
"context.goal(string)",
"context.decisions(list)",
"context.constraints(list)",
"context.working_files(list)",
"context.open_tasks(list)",
"context.max_code_hits(1-100)",
"context.max_sessions(0-100)",
"context.include_git_diff(bool)",
"context.inject_budget(200-8000)",
"context.inject_deadline_ms(100-10000)",
"context.inject_sessions(bool)",
"context.inject_session_count(0-10)",
]
}
pub fn feature_keys() -> Vec<String> {
FEATURES
.iter()
.map(|f| {
format!(
"features.{}(bool, default {})",
f.id,
if f.default { "ON" } else { "OFF" }
)
})
.collect()
}
pub fn feature_state(cfg: &RdshSettings) -> Vec<serde_json::Value> {
FEATURES
.iter()
.map(|f| {
serde_json::json!({
"id": f.id,
"enabled": feature_enabled(cfg, f.id),
"default": f.default,
"explicit": feature_is_explicit(cfg, f.id),
"summary": f.summary,
})
})
.collect()
}
}
fn legacy_context_path() -> String {
format!("{}/rdsh-context.json", crate::inspect::dsh_home())
}
fn complement_from_legacy(
cfg: &mut RdshSettings,
has_context: bool,
locale: crate::locale::Locale,
) {
if has_context {
return;
}
let raw = read_small_config(&legacy_context_path(), locale)
.ok()
.flatten()
.unwrap_or_default();
if raw.trim().is_empty() {
return;
}
let v: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(_) => return,
};
if v.is_null() {
return;
}
let c = &mut cfg.context;
let budget = clamp_u64(num(&v, "token_budget"), 4000, 500, 200_000) as usize;
if c.token_budget == ContextSection::default().token_budget && budget != c.token_budget {
c.token_budget = budget;
}
if c.enable_retriever && !flag(&v, "enable_retriever", true) {
c.enable_retriever = false;
}
if c.enable_packer && !flag(&v, "enable_packer", true) {
c.enable_packer = false;
}
if c.enable_verifier && !flag(&v, "enable_verifier", true) {
c.enable_verifier = false;
}
if c.include_git_diff && !flag(&v, "include_git_diff", true) {
c.include_git_diff = false;
}
if c.goal.is_empty() {
c.goal = text(&v, "goal", GOAL_CHARS);
}
if c.decisions.is_empty() {
c.decisions = list(&v, "decisions", TEXT_CHARS);
}
if c.constraints.is_empty() {
c.constraints = list(&v, "constraints", TEXT_CHARS);
}
if c.working_files.is_empty() {
c.working_files = working_file_list(&v);
}
if c.open_tasks.is_empty() {
c.open_tasks = list(&v, "open_tasks", TEXT_CHARS);
}
if c.max_code_hits == ContextSection::default().max_code_hits {
if let Some(n) = num(&v, "max_code_hits") {
c.max_code_hits = n.clamp(1, 100) as usize;
}
}
if c.max_sessions == ContextSection::default().max_sessions {
if let Some(n) = num(&v, "max_sessions") {
c.max_sessions = n.clamp(0, 100) as usize;
}
}
if c.inject_budget == ContextSection::default().inject_budget {
if let Some(n) = num(&v, "inject_budget") {
c.inject_budget = n.clamp(200, 8000) as usize;
}
}
if c.inject_deadline_ms == ContextSection::default().inject_deadline_ms {
if let Some(n) = num(&v, "inject_deadline_ms") {
c.inject_deadline_ms = n.clamp(100, 10_000) as usize;
}
}
if c.inject_sessions == ContextSection::default().inject_sessions {
if let Some(b) = v.get("inject_sessions").and_then(|x| x.as_bool()) {
c.inject_sessions = b;
}
}
if c.inject_session_count == ContextSection::default().inject_session_count {
if let Some(n) = num(&v, "inject_session_count") {
c.inject_session_count = n.clamp(0, 10) as usize;
}
}
}
fn num(v: &serde_json::Value, key: &str) -> Option<u64> {
v.get(key).and_then(|x| x.as_u64())
}
fn flag(v: &serde_json::Value, key: &str, default: bool) -> bool {
v.get(key).and_then(|x| x.as_bool()).unwrap_or(default)
}
fn text(v: &serde_json::Value, key: &str, max_chars: usize) -> String {
v.get(key)
.and_then(|x| x.as_str())
.map(|s| truncate(s, max_chars))
.unwrap_or_default()
}
fn language(v: &serde_json::Value, key: &str) -> String {
v.get(key)
.and_then(|x| x.as_str())
.and_then(canonical_language)
.unwrap_or("auto")
.to_string()
}
fn validate_language_setting(
value: &serde_json::Value,
path: &str,
locale: crate::locale::Locale,
) -> anyhow::Result<()> {
let Some(general) = value.get("general").and_then(serde_json::Value::as_object) else {
return Ok(());
};
let Some(language) = general.get("language") else {
return Ok(());
};
let Some(language) = language.as_str() else {
anyhow::bail!(
"{} {path}: {}",
settings_text(
locale,
"invalid settings file at",
"无效的设置文件",
"無効な設定ファイル"
),
settings_text(
locale,
"general.language must be auto, en, zh-CN, or ja",
"general.language 必须为 auto、en、zh-CN 或 ja",
"general.language は auto、en、zh-CN、ja のいずれかである必要があります"
)
);
};
if canonical_language(language).is_none() {
anyhow::bail!(
"{} {path}: {}",
settings_text(
locale,
"invalid settings file at",
"无效的设置文件",
"無効な設定ファイル"
),
settings_text(
locale,
"general.language must be auto, en, zh-CN, or ja",
"general.language 必须为 auto、en、zh-CN 或 ja",
"general.language は auto、en、zh-CN、ja のいずれかである必要があります"
)
);
}
Ok(())
}
fn settings_text(
locale: crate::locale::Locale,
english: &'static str,
chinese: &'static str,
japanese: &'static str,
) -> &'static str {
match locale {
crate::locale::Locale::English => english,
crate::locale::Locale::Chinese => chinese,
crate::locale::Locale::Japanese => japanese,
}
}
fn settings_location(locale: crate::locale::Locale, line: usize, column: usize) -> String {
match locale {
crate::locale::Locale::English => format!("(line {line}, column {column})"),
crate::locale::Locale::Chinese => format!("(第 {line} 行,第 {column} 列)"),
crate::locale::Locale::Japanese => format!("({line} 行、{column} 列)"),
}
}
fn canonical_language(raw: &str) -> Option<&'static str> {
match raw.trim() {
"auto" => Some("auto"),
"en" => Some("en"),
"zh-CN" => Some("zh-CN"),
"ja" => Some("ja"),
_ => None,
}
}
fn list(v: &serde_json::Value, key: &str, max_chars: usize) -> Vec<String> {
match v.get(key).and_then(|x| x.as_array()) {
Some(arr) => arr
.iter()
.filter_map(|x| x.as_str())
.map(|s| truncate(s, max_chars))
.filter(|s: &String| !s.trim().is_empty())
.take(MAX_ITEMS)
.collect(),
None => vec![],
}
}
fn working_file_list(v: &serde_json::Value) -> Vec<String> {
let key = if v.get("working_files").is_some_and(|value| !value.is_null()) {
"working_files"
} else {
"files"
};
list(v, key, PATH_CHARS)
}
fn truncate(s: &str, max_chars: usize) -> String {
s.chars().take(max_chars).collect()
}
fn clamp_u64(n: Option<u64>, default: u64, lo: u64, hi: u64) -> u64 {
n.unwrap_or(default).clamp(lo, hi)
}
fn parse_bool(raw: &str, locale: crate::locale::Locale) -> anyhow::Result<bool> {
let s = raw.trim().to_lowercase();
match s.as_str() {
"true" | "1" | "yes" | "on" | "enable" | "enabled" => Ok(true),
"false" | "0" | "no" | "off" | "disable" | "disabled" => Ok(false),
_ => {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(raw.trim()) {
if let Some(b) = v.as_bool() {
return Ok(b);
}
}
Err(anyhow::anyhow!(
"{}: {raw} (true/false)",
settings_text(
locale,
"bool ではありません",
"不是布尔值",
"bool ではありません"
)
))
}
}
}
fn parse_u16(raw: &str, key: &str, locale: crate::locale::Locale) -> anyhow::Result<u16> {
let value = parse_usize(raw, 0, locale)?;
u16::try_from(value).map_err(|_| {
anyhow::anyhow!(
"{}: {raw}",
match locale {
crate::locale::Locale::English => format!("{key} is out of range for u16"),
crate::locale::Locale::Chinese => format!("{key} 超出 u16 范围"),
crate::locale::Locale::Japanese => format!("{key} は u16 の範囲外です"),
}
)
})
}
fn parse_u32(raw: &str, key: &str, locale: crate::locale::Locale) -> anyhow::Result<u32> {
let value = parse_usize(raw, 0, locale)?;
u32::try_from(value).map_err(|_| {
anyhow::anyhow!(
"{}: {raw}",
match locale {
crate::locale::Locale::English => format!("{key} is out of range for u32"),
crate::locale::Locale::Chinese => format!("{key} 超出 u32 范围"),
crate::locale::Locale::Japanese => format!("{key} は u32 の範囲外です"),
}
)
})
}
fn parse_usize(raw: &str, _fallback: u64, locale: crate::locale::Locale) -> anyhow::Result<u64> {
let s = raw.trim();
if let Ok(v) = serde_json::from_str::<serde_json::Value>(s) {
if let Some(n) = v.as_u64() {
return Ok(n);
}
if let Some(n) = v.as_i64() {
if n >= 0 {
return Ok(n as u64);
}
}
}
s.parse::<u64>().map_err(|_| {
anyhow::anyhow!(
"{}: {raw}",
settings_text(
locale,
"数値ではありません",
"不是数值",
"数値ではありません"
)
)
})
}
fn parse_string(raw: &str, max_chars: usize) -> String {
let s = raw.trim();
if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
|| (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
{
if let Ok(v) = serde_json::from_str::<serde_json::Value>(s) {
if let Some(st) = v.as_str() {
return truncate(st, max_chars);
}
}
return truncate(&s[1..s.len() - 1], max_chars);
}
truncate(s, max_chars)
}
fn parse_language(raw: &str, locale: crate::locale::Locale) -> anyhow::Result<String> {
canonical_language(&parse_string(raw, 16))
.map(str::to_string)
.ok_or_else(|| {
anyhow::anyhow!(
"{}",
settings_text(
locale,
"general.language must be auto, en, zh-CN, or ja",
"general.language 必须为 auto、en、zh-CN 或 ja",
"general.language は auto、en、zh-CN、ja のいずれかである必要があります"
)
)
})
}
fn parse_list(raw: &str, max_chars: usize) -> Vec<String> {
let s = raw.trim();
if s.is_empty() {
return vec![];
}
if let Ok(v) = serde_json::from_str::<serde_json::Value>(s) {
if let Some(arr) = v.as_array() {
return arr
.iter()
.filter_map(|x| x.as_str().map(|t| truncate(t, max_chars)))
.filter(|t| !t.trim().is_empty())
.take(MAX_ITEMS)
.collect();
}
if let Some(st) = v.as_str() {
return split_list(st, max_chars);
}
}
split_list(s, max_chars)
}
fn split_list(s: &str, max_chars: usize) -> Vec<String> {
let mut out = vec![];
for part in s.split(['\n', ',']).map(str::trim) {
if part.is_empty() {
continue;
}
let t = part.trim_matches('"').trim_matches('\'').trim();
if t.is_empty() {
continue;
}
out.push(truncate(t, max_chars));
if out.len() >= MAX_ITEMS {
break;
}
}
out
}
#[cfg(test)]
mod rdsh_config_tests {
use super::*;
#[test]
fn bounded_config_reader_accepts_regular_files_and_rejects_oversize() {
let dir = std::env::temp_dir().join(format!(
"rdsh-config-read-{}-{}",
std::process::id(),
crate::local_http::random_token().unwrap()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("settings.json");
std::fs::write(&path, "{\"schema\":1}").unwrap();
assert_eq!(
read_small_config(path.to_str().unwrap(), crate::locale::Locale::English)
.unwrap()
.as_deref(),
Some("{\"schema\":1}")
);
std::fs::write(&path, vec![b'x'; SETTINGS_MAX_BYTES as usize + 1]).unwrap();
assert!(read_small_config(path.to_str().unwrap(), crate::locale::Locale::English).is_err());
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(unix)]
#[test]
fn bounded_config_reader_rejects_fifo_without_waiting_for_a_writer() {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
let dir = std::env::temp_dir().join(format!(
"rdsh-config-fifo-{}-{}",
std::process::id(),
crate::local_http::random_token().unwrap()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("settings.json");
let name = CString::new(path.as_os_str().as_bytes()).unwrap();
assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0);
let error =
read_small_config(path.to_str().unwrap(), crate::locale::Locale::English).unwrap_err();
assert!(error.to_string().contains("not a regular file"));
let _ = std::fs::remove_dir_all(dir);
}
fn lock() -> std::sync::MutexGuard<'static, ()> {
static M: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
M.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn with_home(tag: &str, f: impl FnOnce(&str)) {
let _guard = lock();
let pid = std::process::id();
let dir = std::env::temp_dir().join(format!("rdsh-settings-{pid}-{tag}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let prev = std::env::var("DSH_HOME").ok();
std::env::set_var("DSH_HOME", &dir);
f(&dir.to_string_lossy());
match prev {
Some(p) => std::env::set_var("DSH_HOME", p),
None => std::env::remove_var("DSH_HOME"),
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn defaults_match_spec() {
let d = RdshSettings::default();
assert_eq!(d.schema, 1);
assert!(d.general.slim && !d.general.passthrough && !d.general.dry_run);
assert_eq!(d.general.default_profile, "");
assert_eq!(d.tokens.default_budget, 4000);
assert_eq!(d.search.dir, ".");
assert_eq!((d.search.max, d.search.web_limit), (100, 10));
assert_eq!(d.search.searxng_url, "");
assert_eq!(d.compact.max_tokens, 8000);
assert_eq!((d.sessions.limit, d.sessions.with_tokens), (20, false));
assert_eq!(d.logs.tail, 50);
assert_eq!(d.serve.port, 38080);
assert!(d.guard.deny.is_empty() && d.guard.reason.is_empty());
assert_eq!(d.bench.n, 5);
assert_eq!(d.setup.web_port, 0);
assert!(!d.beta.context_engine);
assert_eq!(d.context.token_budget, 4000);
assert!(d.context.enable_retriever && d.context.enable_packer && d.context.enable_verifier);
assert!(d.context.goal.is_empty());
assert!(d.context.decisions.is_empty() && d.context.constraints.is_empty());
assert!(d.context.working_files.is_empty() && d.context.open_tasks.is_empty());
assert_eq!((d.context.max_code_hits, d.context.max_sessions), (20, 0));
assert!(d.context.include_git_diff);
}
#[test]
fn missing_file_returns_default() {
with_home("missing", |_| {
assert!(!std::path::Path::new(&settings_path()).exists());
assert_eq!(load(), RdshSettings::default());
assert_eq!(try_load().unwrap(), RdshSettings::default());
});
}
#[cfg(unix)]
#[test]
fn settings_save_never_truncates_a_symlink_target() {
with_home("settings-link", |home| {
let outside = std::path::Path::new(home).join("unrelated.txt");
std::fs::write(&outside, "DUMMY_PRIVATE_VALUE").unwrap();
std::os::unix::fs::symlink(&outside, settings_path()).unwrap();
RdshSettings::default().save().unwrap();
assert_eq!(
std::fs::read_to_string(outside).unwrap(),
"DUMMY_PRIVATE_VALUE"
);
assert!(!std::fs::symlink_metadata(settings_path())
.unwrap()
.file_type()
.is_symlink());
});
}
#[test]
fn corrupt_json_is_hard_error_with_location() {
with_home("corrupt", |_| {
let p = settings_path();
std::fs::create_dir_all(std::path::Path::new(&p).parent().unwrap()).unwrap();
std::fs::write(&p, "{\n \"search\": {\n oops\n").unwrap();
let err = try_load().unwrap_err().to_string();
assert!(err.contains(&p), "path missing: {err}");
assert!(err.contains("line"), "line missing: {err}");
assert!(err.contains("column"), "column missing: {err}");
assert!(
err.contains("settings init --force"),
"recovery hint missing: {err}"
);
assert_eq!(
std::fs::read_to_string(&p).unwrap(),
"{\n \"search\": {\n oops\n"
);
});
}
#[test]
fn empty_file_is_hard_error() {
with_home("empty", |_| {
let p = settings_path();
std::fs::create_dir_all(std::path::Path::new(&p).parent().unwrap()).unwrap();
std::fs::write(&p, "").unwrap();
let err = try_load().unwrap_err().to_string();
assert!(err.contains(&p), "path missing: {err}");
assert!(
err.contains("settings init --force"),
"recovery hint missing: {err}"
);
});
}
#[test]
fn guard_deny_list_survives_reload() {
with_home("guard", |_| {
let mut cfg = RdshSettings::default();
cfg.guard.deny = vec!["rm -rf *".to_string(), "shutdown*".to_string()];
cfg.guard.reason = "safety".to_string();
cfg.save().unwrap();
let back = try_load().unwrap();
assert_eq!(back.guard.deny, cfg.guard.deny);
assert_eq!(back.guard.reason, "safety");
assert_eq!(load(), cfg);
});
}
#[test]
fn out_of_range_values_are_clamped() {
with_home("clamp", |_| {
let p = settings_path();
std::fs::create_dir_all(std::path::Path::new(&p).parent().unwrap()).unwrap();
std::fs::write(
&p,
serde_json::json!({
"tokens": { "default_budget": 9_999_999 },
"compact": { "max_tokens": 1 },
"context": { "token_budget": 10 },
"search": { "max": 500, "web_limit": 0 },
"sessions": { "limit": 0 },
"logs": { "tail": 9999 },
"bench": { "n": 99 },
"serve": { "port": 0 },
"setup": { "web_port": 70000 },
})
.to_string(),
)
.unwrap();
let c = load();
assert_eq!(c.tokens.default_budget, 200_000);
assert_eq!(c.compact.max_tokens, 500);
assert_eq!(c.context.token_budget, 500);
assert_eq!(c.search.max, 100);
assert_eq!(c.search.web_limit, 1);
assert_eq!(c.sessions.limit, 1);
assert_eq!(c.logs.tail, 500);
assert_eq!(c.bench.n, 20);
assert_eq!(c.serve.port, 1);
assert_eq!(c.setup.web_port, 65535);
});
}
#[test]
fn setup_web_port_zero_means_random() {
with_home("webport", |_| {
let p = settings_path();
std::fs::create_dir_all(std::path::Path::new(&p).parent().unwrap()).unwrap();
std::fs::write(
&p,
serde_json::json!({ "setup": { "web_port": 0 } }).to_string(),
)
.unwrap();
assert_eq!(load().setup.web_port, 0);
});
}
#[test]
fn long_strings_and_lists_are_truncated() {
with_home("trunc", |_| {
let p = settings_path();
std::fs::create_dir_all(std::path::Path::new(&p).parent().unwrap()).unwrap();
let big_goal: String = "あ".repeat(2500);
let big_path: String = "x".repeat(400);
let many: Vec<String> = (0..60).map(|i| format!("dec-{i}")).collect();
std::fs::write(
&p,
serde_json::json!({
"context": {
"goal": big_goal,
"decisions": many,
"working_files": [big_path],
},
})
.to_string(),
)
.unwrap();
let c = load();
assert_eq!(c.context.goal.chars().count(), 2000);
assert_eq!(c.context.decisions.len(), 50);
assert_eq!(c.context.working_files[0].chars().count(), 300);
});
}
#[test]
fn save_roundtrip_and_mode_600() {
with_home("roundtrip", |dir| {
let mut cfg = RdshSettings::default();
cfg.general.default_profile = "tui".to_string();
cfg.context.goal = "dsh互換性を維持する".to_string();
cfg.context.working_files = vec!["src/search.rs".to_string()];
cfg.save().unwrap();
let p = settings_path();
assert!(std::path::Path::new(&p).exists());
assert!(p.starts_with(dir));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "save は mode 600 相当のはず");
}
assert_eq!(load(), cfg);
});
}
#[test]
fn concurrent_settings_transactions_keep_both_updates() {
with_home("concurrent-rmw", |_| {
let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
let mut joins = Vec::new();
for update_passthrough in [true, false] {
let barrier = std::sync::Arc::clone(&barrier);
joins.push(std::thread::spawn(move || {
barrier.wait();
update(crate::locale::Locale::English, |cfg| {
std::thread::sleep(std::time::Duration::from_millis(100));
if update_passthrough {
cfg.general.passthrough = true;
} else {
cfg.general.dry_run = true;
}
Ok(())
})
.unwrap();
}));
}
barrier.wait();
for join in joins {
join.join().unwrap();
}
let saved = try_load().unwrap();
assert!(saved.general.passthrough);
assert!(saved.general.dry_run);
});
}
#[test]
fn explicit_working_files_override_legacy_alias() {
for (context, expected) in [
(
serde_json::json!({"working_files": [], "files": ["stale.rs"]}),
vec![],
),
(
serde_json::json!({"working_files": ["current.rs"], "files": ["stale.rs"]}),
vec!["current.rs"],
),
(
serde_json::json!({"files": ["legacy.rs"]}),
vec!["legacy.rs"],
),
(
serde_json::json!({"working_files": null, "files": ["legacy.rs"]}),
vec!["legacy.rs"],
),
] {
let value = serde_json::json!({"context": context});
assert_eq!(
RdshSettings::from_value(&value).context.working_files,
expected
);
assert_eq!(
RdshSettings::from_value_raw(&value).context.working_files,
expected
);
}
}
#[test]
fn legacy_file_completes_empty_context_without_writing() {
with_home("legacy", |_| {
let legacy = legacy_context_path();
std::fs::create_dir_all(std::path::Path::new(&legacy).parent().unwrap()).unwrap();
std::fs::write(
&legacy,
serde_json::json!({
"token_budget": 6000,
"enable_verifier": false,
"goal": "旧ファイルのgoal",
"files": ["src/tokens.rs"],
"open_tasks": ["packing評価"],
"include_git_diff": false,
})
.to_string(),
)
.unwrap();
let c = load();
assert_eq!(c.context.token_budget, 6000);
assert!(!c.context.enable_verifier);
assert!(c.context.enable_retriever && c.context.enable_packer);
assert_eq!(c.context.goal, "旧ファイルのgoal");
assert_eq!(c.context.working_files, vec!["src/tokens.rs".to_string()]);
assert_eq!(c.context.open_tasks, vec!["packing評価".to_string()]);
assert!(!c.context.include_git_diff);
assert!(!std::path::Path::new(&settings_path()).exists());
std::fs::write(
settings_path(),
serde_json::json!({"context": {"working_files": []}}).to_string(),
)
.unwrap();
assert_eq!(load().context, ContextSection::default());
std::fs::remove_file(settings_path()).unwrap();
std::fs::write(
&legacy,
serde_json::json!({"working_files": [], "files": ["stale.rs"]}).to_string(),
)
.unwrap();
assert!(load().context.working_files.is_empty());
});
}
#[test]
fn dotted_get_set_unset_cover_all_sections() {
let mut c = RdshSettings::default();
assert_eq!(c.get_dotted("search.max").unwrap(), serde_json::json!(100));
assert_eq!(
c.get_dotted("beta.context_engine").unwrap().as_bool(),
Some(false)
);
c.set_dotted("search.max", "42").unwrap();
assert_eq!(c.search.max, 42);
c.set_dotted("beta.context_engine", "true").unwrap();
assert!(c.beta.context_engine);
c.set_dotted("context.goal", "v2-goal").unwrap();
assert_eq!(c.context.goal, "v2-goal");
c.set_dotted("guard.deny", r#"["a*","b"]"#).unwrap();
assert_eq!(c.guard.deny, vec!["a*".to_string(), "b".to_string()]);
c.set_dotted("context.working_files", "src/a.rs, src/b.rs")
.unwrap();
assert_eq!(c.context.working_files.len(), 2);
c.set_dotted("context.files", "src/c.rs").unwrap();
assert_eq!(
c.get_dotted("context.files").unwrap(),
serde_json::json!(["src/c.rs"])
);
c.set_dotted("serve.port", "38080").unwrap();
assert_eq!(c.serve.port, 38080);
assert!(c.set_dotted("unknown.key", "1").is_err());
c.set_dotted("search.max", "7").unwrap();
c.reset_dotted("search.max").unwrap();
assert_eq!(c.search.max, 100);
c.set_dotted("context.goal", "tmp").unwrap();
c.reset_dotted("context").unwrap();
assert!(c.context.goal.is_empty());
c.set_dotted("search.max", "7").unwrap();
c.reset_dotted("all").unwrap();
assert_eq!(c, RdshSettings::default());
assert!(c.reset_dotted("unknown.key").is_err());
}
#[test]
fn feature_enabled_precedence_and_defaults() {
let mut c = RdshSettings::default();
assert!(!feature_enabled(&c, "discord"));
assert!(!feature_enabled(&c, "telemetry"));
assert!(feature_enabled(&c, "sound"));
assert!(feature_enabled(&c, "gitview"));
assert!(!feature_enabled(&c, "no-such-feature"));
assert!(!feature_is_explicit(&c, "discord"));
c.features.values.insert("sound".to_string(), false);
c.features.values.insert("gitview".to_string(), false);
assert!(!feature_enabled(&c, "sound"));
assert!(!feature_enabled(&c, "gitview"));
assert!(feature_is_explicit(&c, "sound"));
assert!(feature_is_explicit(&c, "gitview"));
c.discord.enabled = true;
assert!(feature_enabled(&c, "discord"));
c.extras.enable = vec!["search-web".to_string()];
assert!(feature_enabled(&c, "search-web"));
c.beta.context_engine = true;
assert!(feature_enabled(&c, "context_engine"));
assert_eq!(enabled_extras(&c), vec!["search-web".to_string()]);
c.features.values.insert("discord".to_string(), false);
c.features.values.insert("telemetry".to_string(), true);
c.features.values.insert("search-web".to_string(), false);
c.features
.values
.insert("context_engine".to_string(), false);
assert!(!feature_enabled(&c, "discord"));
assert!(feature_is_explicit(&c, "discord"));
assert!(feature_enabled(&c, "telemetry"));
assert!(!feature_enabled(&c, "search-web"));
assert!(!feature_enabled(&c, "context_engine"));
assert!(enabled_extras(&c).is_empty());
}
}