Skip to content

Commit 7144334

Browse files
authored
feat(config): layered config with defaults.toml and override-only user config (#864)
1 parent ad4a755 commit 7144334

14 files changed

Lines changed: 1592 additions & 876 deletions

File tree

crates/config/src/defaults.rs

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
//! Moltis-managed `defaults.toml` — shipped defaults that users are not
2+
//! expected to edit directly.
3+
//!
4+
//! This file is regenerated on every startup so that new defaults are picked
5+
//! up after an upgrade. User overrides in `moltis.toml` take precedence.
6+
7+
use {
8+
crate::schema::MoltisConfig,
9+
std::path::{Path, PathBuf},
10+
tracing::{debug, warn},
11+
};
12+
13+
/// Filename for the Moltis-managed defaults file.
14+
pub const DEFAULTS_FILENAME: &str = "defaults.toml";
15+
16+
/// Generate the defaults TOML string from `MoltisConfig::default()`.
17+
///
18+
/// The output is a complete serialization of the built-in defaults with a
19+
/// header comment explaining the ownership model.
20+
pub fn generate_defaults_toml() -> crate::Result<String> {
21+
let config = MoltisConfig::default();
22+
let body = toml::to_string_pretty(&config)
23+
.map_err(|source| crate::Error::external("serialize defaults", source))?;
24+
Ok(format!("{DEFAULTS_HEADER}{body}"))
25+
}
26+
27+
/// Write (or refresh) `defaults.toml` in the given config directory.
28+
///
29+
/// This is called on every startup. The file is always overwritten because
30+
/// it is Moltis-managed — user edits belong in `moltis.toml`.
31+
pub fn write_defaults_toml(config_dir: &Path) -> crate::Result<PathBuf> {
32+
let path = config_dir.join(DEFAULTS_FILENAME);
33+
std::fs::create_dir_all(config_dir)?;
34+
let content = generate_defaults_toml()?;
35+
std::fs::write(&path, &content)?;
36+
debug!(path = %path.display(), "wrote Moltis-managed defaults.toml");
37+
Ok(path)
38+
}
39+
40+
/// Load and parse `defaults.toml` from the given config directory.
41+
///
42+
/// Returns `MoltisConfig::default()` if the file does not exist or fails
43+
/// to parse (with a warning).
44+
pub fn load_defaults(config_dir: &Path) -> MoltisConfig {
45+
let path = config_dir.join(DEFAULTS_FILENAME);
46+
if !path.exists() {
47+
return MoltisConfig::default();
48+
}
49+
match std::fs::read_to_string(&path) {
50+
Ok(raw) => match toml::from_str::<MoltisConfig>(&raw) {
51+
Ok(cfg) => cfg,
52+
Err(e) => {
53+
warn!(
54+
path = %path.display(),
55+
error = %e,
56+
"failed to parse defaults.toml, using in-memory defaults"
57+
);
58+
MoltisConfig::default()
59+
},
60+
},
61+
Err(e) => {
62+
warn!(
63+
path = %path.display(),
64+
error = %e,
65+
"failed to read defaults.toml, using in-memory defaults"
66+
);
67+
MoltisConfig::default()
68+
},
69+
}
70+
}
71+
72+
/// Merge user overrides on top of defaults using TOML-level deep merge.
73+
///
74+
/// The merge loads both files as `toml_edit::DocumentMut`, then walks the
75+
/// user document and applies each key/value on top of the defaults document.
76+
/// This means:
77+
/// - Keys present only in defaults are preserved (user inherits them).
78+
/// - Keys present in both are overridden by the user value.
79+
/// - Keys present only in the user file are added (custom user config).
80+
///
81+
/// The merged document is then parsed into `MoltisConfig`.
82+
pub fn merge_defaults_with_user_toml(
83+
defaults_toml: &str,
84+
user_toml: &str,
85+
path: &Path,
86+
) -> crate::Result<MoltisConfig> {
87+
let mut base_doc = defaults_toml
88+
.parse::<toml_edit::DocumentMut>()
89+
.map_err(|source| crate::Error::external("parse defaults TOML", source))?;
90+
let user_doc = user_toml
91+
.parse::<toml_edit::DocumentMut>()
92+
.map_err(|source| crate::Error::external("parse user TOML", source))?;
93+
94+
apply_user_overrides(base_doc.as_table_mut(), user_doc.as_table());
95+
96+
let merged_str = base_doc.to_string();
97+
let config: MoltisConfig = toml::from_str(&merged_str).map_err(|source| {
98+
crate::Error::external(
99+
format!("deserialize merged config from {}", path.display()),
100+
source,
101+
)
102+
})?;
103+
Ok(config)
104+
}
105+
106+
/// Apply user override table on top of defaults table (recursive deep merge).
107+
///
108+
/// Unlike `merge_toml_tables` in config_io.rs (which removes keys not in
109+
/// the updated doc), this function is additive: defaults keys not mentioned
110+
/// in the user doc are preserved.
111+
fn apply_user_overrides(defaults: &mut toml_edit::Table, user: &toml_edit::Table) {
112+
for (key, user_item) in user.iter() {
113+
match (defaults.get_mut(key), user_item) {
114+
// Both have tables → recurse
115+
(Some(toml_edit::Item::Table(def_table)), toml_edit::Item::Table(usr_table)) => {
116+
apply_user_overrides(def_table, usr_table);
117+
},
118+
// User overrides a value or introduces a new key
119+
_ => {
120+
defaults.insert(key, user_item.clone());
121+
},
122+
}
123+
}
124+
}
125+
126+
// ── Provenance ───────────────────────────────────────────────────────
127+
128+
/// Where a config value came from in the layered config model.
129+
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
130+
#[serde(rename_all = "snake_case")]
131+
pub enum ConfigSource {
132+
/// Shipped built-in default (from `MoltisConfig::default()`).
133+
BuiltIn,
134+
/// User override (from `moltis.toml`).
135+
UserOverride,
136+
/// Custom value not present in defaults (user-added).
137+
Custom,
138+
}
139+
140+
/// Provenance information for an agent preset.
141+
#[derive(Debug, Clone, serde::Serialize)]
142+
pub struct PresetProvenance {
143+
/// The preset ID.
144+
pub id: String,
145+
/// Where this preset comes from.
146+
pub source: ConfigSource,
147+
}
148+
149+
/// Compute provenance for all agent presets in the effective config.
150+
///
151+
/// Compares the effective config's presets against the built-in defaults
152+
/// to determine which are built-in, overridden, or custom.
153+
pub fn compute_preset_provenance(effective: &crate::schema::AgentsConfig) -> Vec<PresetProvenance> {
154+
let defaults = MoltisConfig::default();
155+
let default_presets = &defaults.agents.presets;
156+
157+
effective
158+
.presets
159+
.keys()
160+
.map(|id| {
161+
let source = if default_presets.contains_key(id) {
162+
// Present in defaults — is the effective version identical?
163+
let eff_toml = toml::to_string(&effective.presets[id]).unwrap_or_default();
164+
let def_toml = toml::to_string(&default_presets[id]).unwrap_or_default();
165+
if eff_toml == def_toml {
166+
ConfigSource::BuiltIn
167+
} else {
168+
ConfigSource::UserOverride
169+
}
170+
} else {
171+
ConfigSource::Custom
172+
};
173+
PresetProvenance {
174+
id: id.clone(),
175+
source,
176+
}
177+
})
178+
.collect()
179+
}
180+
181+
/// Check which keys in the user TOML file shadow built-in defaults.
182+
///
183+
/// Returns a list of dotted-path keys that exist in both the user config
184+
/// and the built-in defaults. Useful for diagnostics.
185+
pub fn find_shadowed_defaults(user_toml: &str) -> Vec<String> {
186+
let Ok(user_doc) = user_toml.parse::<toml_edit::DocumentMut>() else {
187+
return Vec::new();
188+
};
189+
let Ok(defaults_toml) = generate_defaults_toml() else {
190+
return Vec::new();
191+
};
192+
let Ok(defaults_doc) = defaults_toml.parse::<toml_edit::DocumentMut>() else {
193+
return Vec::new();
194+
};
195+
196+
let mut shadowed = Vec::new();
197+
collect_shadowed_keys(
198+
user_doc.as_table(),
199+
defaults_doc.as_table(),
200+
&mut String::new(),
201+
&mut shadowed,
202+
);
203+
shadowed
204+
}
205+
206+
fn collect_shadowed_keys(
207+
user: &toml_edit::Table,
208+
defaults: &toml_edit::Table,
209+
prefix: &mut String,
210+
out: &mut Vec<String>,
211+
) {
212+
for (key, user_item) in user.iter() {
213+
let path = if prefix.is_empty() {
214+
key.to_string()
215+
} else {
216+
format!("{prefix}.{key}")
217+
};
218+
219+
let Some(def_item) = defaults.get(key) else {
220+
continue; // Not in defaults — custom key, not a shadow
221+
};
222+
223+
match (user_item, def_item) {
224+
(toml_edit::Item::Table(u), toml_edit::Item::Table(d)) => {
225+
collect_shadowed_keys(u, d, &mut path.clone(), out);
226+
},
227+
(toml_edit::Item::Value(u_val), toml_edit::Item::Value(d_val)) => {
228+
// Only flag when the user value matches the default — that's
229+
// a true shadow (frozen default). Differing values are
230+
// intentional overrides and should not be reported.
231+
if u_val.to_string().trim() == d_val.to_string().trim() {
232+
out.push(path);
233+
}
234+
},
235+
_ => {},
236+
}
237+
}
238+
}
239+
240+
const DEFAULTS_HEADER: &str = "\
241+
# ┌─────────────────────────────────────────────────────────────────────┐
242+
# │ MOLTIS-MANAGED DEFAULTS — DO NOT EDIT │
243+
# │ │
244+
# │ This file is regenerated on every startup. Any manual edits │
245+
# │ will be lost. To override a value, set it in moltis.toml │
246+
# │ instead. │
247+
# │ │
248+
# │ Merge order: │
249+
# │ 1. Built-in Rust defaults │
250+
# │ 2. This file (defaults.toml) │
251+
# │ 3. User overrides (moltis.toml) │
252+
# │ 4. Environment variable overrides (MOLTIS_*) │
253+
# └─────────────────────────────────────────────────────────────────────┘
254+
255+
";

crates/config/src/lib.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//! Supports `${ENV_VAR}` substitution in all string values.
77
88
pub mod agent_defs;
9+
pub mod defaults;
910
pub mod env_subst;
1011
pub mod error;
1112
pub mod loader;
@@ -23,12 +24,12 @@ pub use {
2324
loader::{
2425
DEFAULT_SOUL, LoadedWorkspaceMarkdown, WorkspaceMarkdownSource, agent_workspace_dir,
2526
agents_path, apply_env_overrides, boot_path, clear_config_dir, clear_data_dir,
26-
clear_share_dir, config_dir, data_dir, discover_and_load, extract_yaml_frontmatter,
27-
find_or_default_config_path, find_user_global_config_file, guidelines_path, heartbeat_path,
28-
home_dir, identity_path, load_agents_md, load_agents_md_for_agent, load_boot_md,
29-
load_boot_md_for_agent, load_guidelines_md, load_guidelines_md_for_agent,
30-
load_heartbeat_md, load_identity, load_identity_for_agent, load_memory_md,
31-
load_memory_md_for_agent, load_memory_md_for_agent_with_source, load_soul,
27+
clear_share_dir, config_dir, data_dir, discover_and_load, discover_and_load_readonly,
28+
extract_yaml_frontmatter, find_or_default_config_path, find_user_global_config_file,
29+
guidelines_path, heartbeat_path, home_dir, identity_path, load_agents_md,
30+
load_agents_md_for_agent, load_boot_md, load_boot_md_for_agent, load_guidelines_md,
31+
load_guidelines_md_for_agent, load_heartbeat_md, load_identity, load_identity_for_agent,
32+
load_memory_md, load_memory_md_for_agent, load_memory_md_for_agent_with_source, load_soul,
3233
load_soul_for_agent, load_tools_md, load_tools_md_for_agent, load_user, memory_path,
3334
normalize_workspace_markdown_content, resolve_identity, resolve_identity_from_config,
3435
resolve_user_profile, resolve_user_profile_from_config, resubstitute_config, save_config,

0 commit comments

Comments
 (0)