use std::collections::{BTreeMap, BTreeSet};
use crate::config::WorktrunkConfig;
#[derive(Default, Debug, Clone)]
pub struct UnknownTree {
pub keys: BTreeSet<String>,
pub nested: BTreeMap<String, UnknownTree>,
}
impl UnknownTree {
pub fn is_empty(&self) -> bool {
self.keys.is_empty() && self.nested.is_empty()
}
}
#[derive(Debug)]
pub enum UnknownAnalysis {
Parsed(UnknownTree),
Unreliable(UnknownTree),
}
impl UnknownAnalysis {
pub fn preserve_tree(&self) -> &UnknownTree {
match self {
Self::Parsed(t) | Self::Unreliable(t) => t,
}
}
pub fn warn_tree(&self) -> Option<&UnknownTree> {
match self {
Self::Parsed(t) => Some(t),
Self::Unreliable(_) => None,
}
}
}
pub fn compute_unknown_tree<C>(contents: &str) -> UnknownAnalysis
where
C: WorktrunkConfig,
{
let Ok(raw) = contents.parse::<toml::Table>() else {
return UnknownAnalysis::Unreliable(UnknownTree::default());
};
let parsed: Result<C, _> = toml::Value::Table(raw.clone()).try_into();
let Ok(config) = parsed else {
return UnknownAnalysis::Unreliable(diff_tables(&raw, &toml::Table::new()));
};
let mut reserialized: toml::Table = toml::to_string(&config)
.expect("config type is serializable")
.parse()
.expect("serialized config is valid TOML");
seed_schema_skeleton::<C>(&mut reserialized);
UnknownAnalysis::Parsed(diff_tables(&raw, &reserialized))
}
fn seed_schema_skeleton<C: WorktrunkConfig>(reserialized: &mut toml::Table) {
for key in C::valid_top_level_keys() {
reserialized
.entry(key.clone())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
}
}
fn diff_tables(raw: &toml::Table, known: &toml::Table) -> UnknownTree {
let mut tree = UnknownTree::default();
for (key, raw_val) in raw {
match (known.get(key), raw_val) {
(Some(toml::Value::Table(known_t)), toml::Value::Table(raw_t)) => {
let nested = diff_tables(raw_t, known_t);
if !nested.is_empty() {
tree.nested.insert(key.clone(), nested);
}
}
(Some(_), _) => {}
(None, toml::Value::Table(raw_t)) => {
tree.keys.insert(key.clone());
let nested = diff_tables(raw_t, &toml::Table::new());
if !nested.is_empty() {
tree.nested.insert(key.clone(), nested);
}
}
(None, _) => {
tree.keys.insert(key.clone());
}
}
}
tree
}
#[derive(Debug)]
pub enum UnknownWarning {
TopLevelUnknown { key: String },
TopLevelWrongConfig {
key: String,
other_description: &'static str,
},
TopLevelDeprecatedWrongConfig {
key: String,
other_description: &'static str,
canonical_display: &'static str,
},
NestedUnknown { path: String },
}
pub fn collect_unknown_warnings<C: WorktrunkConfig>(raw_contents: &str) -> Vec<UnknownWarning> {
let raw_tree = match compute_unknown_tree::<C>(raw_contents) {
UnknownAnalysis::Parsed(t) => t,
UnknownAnalysis::Unreliable(_) => return Vec::new(),
};
let migrated = crate::config::migrate_content(raw_contents);
let migrated_tree = match compute_unknown_tree::<C>(&migrated) {
UnknownAnalysis::Parsed(t) => t,
UnknownAnalysis::Unreliable(_) => return Vec::new(),
};
let mut out = Vec::new();
for key in &raw_tree.keys {
use crate::config::UnknownKeyKind;
let warning = match crate::config::classify_unknown_key::<C>(key) {
UnknownKeyKind::DeprecatedHandled => continue,
UnknownKeyKind::DeprecatedWrongConfig {
other_description,
canonical_display,
} => UnknownWarning::TopLevelDeprecatedWrongConfig {
key: key.clone(),
other_description,
canonical_display,
},
UnknownKeyKind::WrongConfig { other_description } => {
UnknownWarning::TopLevelWrongConfig {
key: key.clone(),
other_description,
}
}
UnknownKeyKind::Unknown => UnknownWarning::TopLevelUnknown { key: key.clone() },
};
out.push(warning);
}
for (key, sub) in &migrated_tree.nested {
if !C::is_valid_key(key) {
continue; }
walk_nested(sub, key, &mut out);
}
out
}
fn walk_nested(tree: &UnknownTree, prefix: &str, out: &mut Vec<UnknownWarning>) {
for key in &tree.keys {
out.push(UnknownWarning::NestedUnknown {
path: format!("{prefix}.{key}"),
});
}
for (key, sub) in &tree.nested {
if tree.keys.contains(key) {
continue;
}
let path = format!("{prefix}.{key}");
walk_nested(sub, &path, out);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ProjectConfig, UserConfig};
fn parsed<C: WorktrunkConfig>(contents: &str) -> UnknownTree {
match compute_unknown_tree::<C>(contents) {
UnknownAnalysis::Parsed(t) => t,
UnknownAnalysis::Unreliable(_) => panic!("expected Parsed"),
}
}
#[test]
fn empty_input_has_no_unknowns() {
let tree = parsed::<UserConfig>("");
assert!(tree.is_empty());
}
#[test]
fn known_keys_are_not_flagged() {
let tree = parsed::<UserConfig>(
r#"
worktree-path = "../test"
[list]
full = true
[commit.generation]
command = "llm"
"#,
);
assert!(tree.is_empty(), "tree should be empty, got {tree:?}");
}
#[test]
fn unknown_top_level_key() {
let tree = parsed::<UserConfig>("unknown-key = \"value\"\n");
assert!(tree.keys.contains("unknown-key"));
assert!(tree.nested.is_empty());
}
#[test]
fn nested_unknown_key_under_known_section() {
let tree = parsed::<UserConfig>(
r#"
[merge]
future-option = true
"#,
);
assert!(tree.keys.is_empty());
let merge = tree.nested.get("merge").expect("merge subtree");
assert!(merge.keys.contains("future-option"));
}
#[test]
fn deeply_nested_unknown_key() {
let tree = parsed::<UserConfig>(
r#"
[commit.generation]
command = "llm"
future-knob = "x"
"#,
);
let commit = tree.nested.get("commit").expect("commit subtree");
let generation = commit.nested.get("generation").expect("generation subtree");
assert!(generation.keys.contains("future-knob"));
}
#[test]
fn unknown_whole_subtree_is_marked_at_top_level() {
let tree = parsed::<UserConfig>(
r#"
[unknown-section]
a = 1
b = 2
"#,
);
assert!(tree.keys.contains("unknown-section"));
}
#[test]
fn project_config_detects_user_only_key() {
let tree = parsed::<ProjectConfig>("skip-shell-integration-prompt = true\n");
assert!(tree.keys.contains("skip-shell-integration-prompt"));
}
#[test]
fn syntax_error_yields_unreliable() {
let analysis = compute_unknown_tree::<UserConfig>("not valid {{{");
assert!(matches!(analysis, UnknownAnalysis::Unreliable(_)));
assert!(analysis.warn_tree().is_none());
}
#[test]
fn type_mismatch_yields_unreliable_but_preserves_all() {
let analysis = compute_unknown_tree::<UserConfig>(
r#"
commit = "scalar"
skip-shell-integration-prompt = true
"#,
);
assert!(matches!(analysis, UnknownAnalysis::Unreliable(_)));
assert!(analysis.warn_tree().is_none());
let preserve = analysis.preserve_tree();
assert!(preserve.keys.contains("commit"));
assert!(preserve.keys.contains("skip-shell-integration-prompt"));
}
}