use serde_json::Value;
use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub enum ConfigType {
Bool,
Int,
Float,
String,
Color,
Path,
Enum(Vec<&'static str>),
StringList,
Array,
Stanza,
#[allow(dead_code)]
Map,
Unknown,
}
impl ConfigType {
pub fn infer(value: &Value) -> Self {
match value {
Value::Null => Self::Unknown,
Value::Bool(_) => Self::Bool,
Value::Number(n) => {
if n.is_i64() || n.is_u64() {
Self::Int
} else {
Self::Float
}
}
Value::String(_) => Self::String,
Value::Array(arr) => {
if arr.iter().all(|v| v.is_string()) {
Self::StringList
} else {
Self::Array
}
}
Value::Object(_) => Self::Stanza,
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Bool => "bool",
Self::Int => "int",
Self::Float => "float",
Self::String => "string",
Self::Color => "color (#RRGGBB)",
Self::Path => "filesystem path",
Self::Enum(_) => "enum",
Self::StringList => "list of strings",
Self::Array => "array",
Self::Stanza => "stanza",
Self::Map => "map",
Self::Unknown => "unknown",
}
}
}
pub fn refined_type(path: &str, inferred: &ConfigType) -> ConfigType {
if let Some(variants) = enum_variants_for(path) {
return ConfigType::Enum(variants);
}
if matches!(inferred, ConfigType::String) {
if path.starts_with("theme.")
&& (path.ends_with("_bg")
|| path.ends_with("_fg")
|| path.ends_with("_border"))
{
return ConfigType::Color;
}
if path.ends_with("_directory")
|| path.ends_with("_dir")
|| path.ends_with("_path")
|| path.ends_with("_file")
{
return ConfigType::Path;
}
}
inferred.clone()
}
fn enum_variants_for(path: &str) -> Option<Vec<&'static str>> {
match path {
"typst_compile.engine" => Some(vec!["external", "inprocess"]),
"embeddings.model" => Some(vec![
"MultilingualE5Small",
"MultilingualE5Base",
"MultilingualE5Large",
"BGEM3",
]),
"editor.prompt_language_mode" => {
Some(vec!["book_defined", "paragraph_detected"])
}
"language" => Some(vec![
"english",
"russian",
"french",
"german",
"spanish",
]),
"typst_page.language" => {
Some(vec!["en", "ru", "fr", "de", "es"])
}
"typst_page.page_numbering" => Some(vec![
"",
"1",
"i",
"I",
"a",
"A",
"1 of 1",
]),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValueSource {
Default,
Configured,
#[allow(dead_code)]
Unknown,
}
#[derive(Debug, Clone)]
pub struct SchemaNode {
pub path: String,
pub display: String,
pub ty: ConfigType,
pub default: Value,
pub current: Value,
pub source: ValueSource,
pub children: Vec<SchemaNode>,
}
impl SchemaNode {
pub fn is_leaf(&self) -> bool {
self.children.is_empty()
&& !matches!(self.ty, ConfigType::Stanza | ConfigType::Map)
}
pub fn flatten<'a>(
&'a self,
collapsed: &std::collections::HashSet<String>,
out: &mut Vec<(usize, &'a SchemaNode)>,
depth: usize,
) {
out.push((depth, self));
if collapsed.contains(&self.path) {
return;
}
for child in &self.children {
child.flatten(collapsed, out, depth + 1);
}
}
}
pub fn build(
defaults: &Value,
live: &Value,
) -> (SchemaNode, Vec<(String, Value)>) {
let mut unknowns: Vec<(String, Value)> = Vec::new();
let root = build_node(
"",
"<root>",
defaults,
live,
&mut unknowns,
);
(root, unknowns)
}
const KNOWN_MAP_PATHS: &[&str] = &["llm.providers"];
pub fn is_known_map_path(path: &str) -> bool {
KNOWN_MAP_PATHS.contains(&path)
}
fn force_configured(node: &mut SchemaNode) {
node.source = ValueSource::Configured;
for child in &mut node.children {
force_configured(child);
}
}
fn build_node(
path: &str,
display: &str,
default: &Value,
live: &Value,
unknowns: &mut Vec<(String, Value)>,
) -> SchemaNode {
let ty = ConfigType::infer(default);
match default {
Value::Object(default_map) => {
let live_map = live.as_object();
let mut children: Vec<SchemaNode> =
Vec::with_capacity(default_map.len());
let mut any_configured = false;
for (key, child_default) in default_map {
let child_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
let child_live = live_map
.and_then(|m| m.get(key))
.cloned()
.unwrap_or_else(|| child_default.clone());
let child = build_node(
&child_path,
key,
child_default,
&child_live,
unknowns,
);
if child.source == ValueSource::Configured {
any_configured = true;
}
children.push(child);
}
if let Some(map) = live_map {
let map_here = is_known_map_path(path);
for (key, value) in map {
if default_map.contains_key(key) {
continue;
}
let child_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
if map_here {
let template: Value = default_map
.values()
.next()
.cloned()
.unwrap_or_else(|| value.clone());
let mut child = build_node(
&child_path,
key,
&template,
value,
unknowns,
);
force_configured(&mut child);
any_configured = true;
children.push(child);
} else {
collect_unknowns(&child_path, value, unknowns);
}
}
}
children.sort_by(|a, b| a.display.cmp(&b.display));
let source = if any_configured {
ValueSource::Configured
} else {
ValueSource::Default
};
SchemaNode {
path: path.to_string(),
display: display.to_string(),
ty,
default: default.clone(),
current: live.clone(),
source,
children,
}
}
_ => {
let source = if live == default {
ValueSource::Default
} else {
ValueSource::Configured
};
let refined_ty = refined_type(path, &ty);
SchemaNode {
path: path.to_string(),
display: display.to_string(),
ty: refined_ty,
default: default.clone(),
current: live.clone(),
source,
children: Vec::new(),
}
}
}
}
fn collect_unknowns(
path: &str,
value: &Value,
unknowns: &mut Vec<(String, Value)>,
) {
match value {
Value::Object(map) => {
for (key, child) in map {
let child_path = format!("{path}.{key}");
collect_unknowns(&child_path, child, unknowns);
}
}
_ => unknowns.push((path.to_string(), value.clone())),
}
}
#[allow(dead_code)]
pub fn index_by_path(root: &SchemaNode) -> BTreeMap<String, &SchemaNode> {
let mut out = BTreeMap::new();
fn walk<'a>(
node: &'a SchemaNode,
out: &mut BTreeMap<String, &'a SchemaNode>,
) {
if !node.path.is_empty() {
out.insert(node.path.clone(), node);
}
for child in &node.children {
walk(child, out);
}
}
walk(root, &mut out);
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn empty_live_marks_everything_default() {
let defaults = json!({
"editor": {
"autosave_seconds": 5,
"wrap": true,
},
});
let live = json!({});
let (root, unknowns) = build(&defaults, &live);
let editor = &root.children[0];
assert_eq!(editor.path, "editor");
assert_eq!(editor.source, ValueSource::Default);
for leaf in &editor.children {
assert_eq!(leaf.source, ValueSource::Default);
}
assert!(unknowns.is_empty());
}
#[test]
fn configured_leaf_marked_configured() {
let defaults = json!({
"editor": { "autosave_seconds": 5 },
});
let live = json!({
"editor": { "autosave_seconds": 30 },
});
let (root, _) = build(&defaults, &live);
let editor = &root.children[0];
assert_eq!(editor.source, ValueSource::Configured);
let leaf = &editor.children[0];
assert_eq!(leaf.source, ValueSource::Configured);
assert_eq!(leaf.current, json!(30));
assert_eq!(leaf.default, json!(5));
}
#[test]
fn unknown_field_collected_not_in_tree() {
let defaults = json!({ "editor": { "wrap": true } });
let live = json!({
"editor": { "wrap": false },
"experimental": { "my_custom_flag": "yes" },
});
let (root, unknowns) = build(&defaults, &live);
assert_eq!(root.children.len(), 1);
assert_eq!(root.children[0].display, "editor");
assert_eq!(unknowns.len(), 1);
assert_eq!(unknowns[0].0, "experimental.my_custom_flag");
}
#[test]
fn type_inference_recognises_string_list() {
let defaults = json!({
"editor": { "extra_words": ["just", "very"] },
});
let (root, _) = build(&defaults, &json!({}));
let editor = &root.children[0];
let leaf = &editor.children[0];
assert!(matches!(leaf.ty, ConfigType::StringList));
}
#[test]
fn flatten_skips_collapsed_subtree() {
let defaults = json!({
"a": { "b": 1, "c": 2 },
"d": 3,
});
let (root, _) = build(&defaults, &json!({}));
let mut out = Vec::new();
let mut collapsed = std::collections::HashSet::new();
collapsed.insert("a".to_string());
root.flatten(&collapsed, &mut out, 0);
let displays: Vec<&str> =
out.iter().map(|(_, n)| n.display.as_str()).collect();
assert_eq!(displays, vec!["<root>", "a", "d"]);
}
#[test]
fn index_by_path_skips_root() {
let defaults = json!({
"editor": { "wrap": true },
});
let (root, _) = build(&defaults, &json!({}));
let idx = index_by_path(&root);
assert!(idx.contains_key("editor"));
assert!(idx.contains_key("editor.wrap"));
assert!(!idx.contains_key(""));
}
#[test]
fn user_added_provider_appears_under_known_map_path() {
let defaults = json!({
"llm": {
"providers": {
"gemini": { "model": "gemini-2.5-pro", "api_key_env": "GEMINI_API_KEY" }
}
}
});
let live = json!({
"llm": {
"providers": {
"gemini": { "model": "gemini-2.5-pro", "api_key_env": "GEMINI_API_KEY" },
"ollama_remote": { "model": "llama3.2", "api_key_env": "OLLAMA_KEY" }
}
}
});
let (root, unknowns) = build(&defaults, &live);
assert!(
unknowns.iter().all(|(p, _)| !p.starts_with("llm.providers.ollama_remote")),
"expected llm.providers.ollama_remote to live in the tree, not unknowns; got: {unknowns:?}"
);
let idx = index_by_path(&root);
assert!(idx.contains_key("llm.providers.ollama_remote"));
assert!(idx.contains_key("llm.providers.ollama_remote.model"));
assert!(idx.contains_key("llm.providers.ollama_remote.api_key_env"));
}
#[test]
fn user_added_provider_marked_configured() {
let defaults = json!({
"llm": {
"providers": {
"gemini": { "model": "gemini-2.5-pro", "api_key_env": "GEMINI_API_KEY" }
}
}
});
let live = json!({
"llm": {
"providers": {
"gemini": { "model": "gemini-2.5-pro", "api_key_env": "GEMINI_API_KEY" },
"ollama_remote": { "model": "llama3.2", "api_key_env": "OLLAMA_KEY" }
}
}
});
let (root, _) = build(&defaults, &live);
let idx = index_by_path(&root);
let entry = idx.get("llm.providers.ollama_remote").unwrap();
assert_eq!(entry.source, ValueSource::Configured);
let model = idx.get("llm.providers.ollama_remote.model").unwrap();
assert_eq!(model.source, ValueSource::Configured);
}
#[test]
fn unknown_path_outside_map_still_collected() {
let defaults = json!({ "editor": { "wrap": true } });
let live = json!({
"editor": { "wrap": true },
"experimental": { "my_flag": "yes" }
});
let (_, unknowns) = build(&defaults, &live);
assert!(unknowns.iter().any(|(p, _)| p == "experimental.my_flag"));
}
#[test]
fn map_path_with_extra_field_in_entry_still_reports_unknown() {
let defaults = json!({
"llm": {
"providers": {
"gemini": { "model": "x", "api_key_env": "Y" }
}
}
});
let live = json!({
"llm": {
"providers": {
"gemini": { "model": "x", "api_key_env": "Y" },
"custom": { "model": "z", "api_token": "T" }
}
}
});
let (root, unknowns) = build(&defaults, &live);
let idx = index_by_path(&root);
assert!(idx.contains_key("llm.providers.custom"));
assert!(
unknowns.iter().any(|(p, _)| p == "llm.providers.custom.api_token"),
"expected typo'd field to land in unknowns; got: {unknowns:?}"
);
}
}