From 1c2696ff324366925e5ed0f688b932fafb228b74 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Tue, 6 Oct 2026 16:47:01 -0700 Subject: [PATCH 1/6] Reject config format 1 Bump the config format to 2 and check the version before decoding any other field, so the format changes that follow report a version error instead of an unknown key. AI assistance: implemented with OpenAI Codex; split and cleaned up with Claude Code. Agent-Session: 01a112e3-6825-7fa1-b0e5-79eef03d80f0 Agent-Session: 3fe0d4c3-fe2b-4417-9b14-24659a5e68a8 Agent-Session: 79e2268e-5c55-4f1c-a7b9-f2a6d613fe4f Agent-Session: 62ad3106-de8d-4fd7-b0ff-ee63a836fbd2 --- crates/diffr-core/src/config.rs | 42 +++++++++++++-------- crates/diffr-core/src/config/default.toml | 2 +- crates/diffr-core/src/config/prune/tests.rs | 2 +- crates/diffr-core/src/config/store.rs | 4 +- pprint.sh | 2 +- 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/crates/diffr-core/src/config.rs b/crates/diffr-core/src/config.rs index be31e3996..7b287c938 100644 --- a/crates/diffr-core/src/config.rs +++ b/crates/diffr-core/src/config.rs @@ -33,7 +33,7 @@ use std::sync::{Arc, OnceLock}; use strum::IntoEnumIterator; pub(crate) const DEFAULT_CONFIG: &str = include_str!("config/default.toml"); -const CONFIG_VERSION: u32 = 1; +const CONFIG_VERSION: u32 = 2; fn config_version() -> u32 { CONFIG_VERSION } @@ -41,9 +41,9 @@ fn config_version() -> u32 { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Config { - /// Configuration format version. Unknown versions require a newer diffr. + /// Configuration format version. Only version 2 is accepted; omitted means 2. #[serde(default = "config_version")] - #[schemars(extend("x-settings" = false))] + #[schemars(extend("x-settings" = false, "const" = CONFIG_VERSION))] pub(crate) version: u32, /// The plugins that decide what starts collapsed, hidden, linked or /// grouped, and the fold queries they own. Its schema comes from each @@ -217,6 +217,20 @@ impl Config { /// Parse the text of a file in `directory`. Errors lead with the dotted /// path of the key they concern, such as `diff.typo`. pub fn from_toml_in(source: &str, directory: &Path) -> Result { + // Report an unsupported format before decoding its fields. + #[derive(Deserialize)] + struct Version { + #[serde(default = "config_version")] + version: u32, + } + let version: Version = + toml::from_str(source).map_err(|error| ConfigError(error.to_string()))?; + if version.version != CONFIG_VERSION { + return Err(ConfigError(format!( + "unsupported config version {}; expected {CONFIG_VERSION}", + version.version + ))); + } let source = prune::forget_legacy(source); let mut config: Self = serde_path_to_error::deserialize(toml::Deserializer::new(&source)) .map_err(|error| { @@ -227,12 +241,6 @@ impl Config { _ => format!("{path}: {message}"), }) })?; - if config.version != CONFIG_VERSION { - return Err(ConfigError(format!( - "unsupported config version {}; expected {CONFIG_VERSION}", - config.version - ))); - } config.plugins.resolve(directory)?; config.classifier.resolve(directory)?; Ok(config) @@ -792,7 +800,7 @@ mod format_tests { let defaults = Config::default(); let text = toml::to_string_pretty(&defaults).unwrap(); let restored = Config::from_toml(&text).unwrap(); - assert_eq!(defaults.version, 1); + assert_eq!(defaults.version, 2); assert_eq!( serde_json::to_value(defaults).unwrap(), serde_json::to_value(restored).unwrap() @@ -801,13 +809,15 @@ mod format_tests { #[test] fn unsupported_versions_are_rejected_and_explicit_lists_stay_small() { - assert!(Config::from_toml("version = 2") - .err() - .unwrap() - .to_string() - .contains("unsupported config version 2")); + assert!( + Config::from_toml("version = 1\n[plugins.bundled.context]\nenabled = false") + .err() + .unwrap() + .to_string() + .contains("unsupported config version 1; expected 2") + ); let config = - Config::from_toml("version = 1\n[plugins]\norder = ['bundled.context']\n").unwrap(); + Config::from_toml("version = 2\n[plugins]\norder = ['bundled.context']\n").unwrap(); assert_eq!(config.plugins.entries.len(), 1); assert!(config.plugins.entries.contains_key("bundled.context")); } diff --git a/crates/diffr-core/src/config/default.toml b/crates/diffr-core/src/config/default.toml index b3f300ee7..055b52c30 100644 --- a/crates/diffr-core/src/config/default.toml +++ b/crates/diffr-core/src/config/default.toml @@ -1,5 +1,5 @@ # Bundled defaults. The user config keeps only what differs from these. -version = 1 +version = 2 [plugins] order = [ diff --git a/crates/diffr-core/src/config/prune/tests.rs b/crates/diffr-core/src/config/prune/tests.rs index 8d8362514..7e4ba772a 100644 --- a/crates/diffr-core/src/config/prune/tests.rs +++ b/crates/diffr-core/src/config/prune/tests.rs @@ -194,7 +194,7 @@ fn edit_value(value: &Value) -> toml_edit::Value { /// prompt. fn generate(rng: &mut Rng, settings: &[(String, Value)]) -> (DocumentMut, Vec) { let mut document = DocumentMut::new(); - document.insert("version", toml_edit::value(1)); + document.insert("version", toml_edit::value(2)); let mut chosen: Vec<(String, Value)> = Vec::new(); for (key, node) in settings { if !rng.chance(40) { diff --git a/crates/diffr-core/src/config/store.rs b/crates/diffr-core/src/config/store.rs index 575beb81d..92cc72cb2 100644 --- a/crates/diffr-core/src/config/store.rs +++ b/crates/diffr-core/src/config/store.rs @@ -493,7 +493,7 @@ mod sparse_tests { let path = dir.path().join("config.toml"); set(&path, "diff.graph_limit", "42").unwrap(); let raw = read_toml(&path); - assert_eq!(raw["version"].as_integer(), Some(1)); + assert_eq!(raw["version"].as_integer(), Some(2)); assert_eq!(raw["diff"]["graph_limit"].as_integer(), Some(42)); assert!(raw.get("plugins").is_none(), "{raw}"); let text = std::fs::read_to_string(&path).unwrap(); @@ -611,7 +611,7 @@ mod sparse_tests { // Earlier versions wrote every table with its own header. std::fs::write( &path, - "version = 1\n\n[plugins]\n\n[plugins.bundled]\n\n[plugins.bundled.context]\nlines = 3\n", + "version = 2\n\n[plugins]\n\n[plugins.bundled]\n\n[plugins.bundled.context]\nlines = 3\n", ) .unwrap(); set(&path, "diff.graph_limit", "42").unwrap(); diff --git a/pprint.sh b/pprint.sh index 6481cc36c..031e1ca08 100755 --- a/pprint.sh +++ b/pprint.sh @@ -34,7 +34,7 @@ if [ -n "$mock" ]; then server=$! mkdir -p "$work/config/diffr" cat > "$work/config/diffr/config.toml" < Date: Tue, 6 Oct 2026 16:47:45 -0700 Subject: [PATCH 2/6] Stop forgetting the pre-0.1.8 default prompt Only format 1 files can hold it, and those are now rejected. AI assistance: implemented with OpenAI Codex; split and cleaned up with Claude Code. Agent-Session: 01a112e3-6825-7fa1-b0e5-79eef03d80f0 Agent-Session: 3fe0d4c3-fe2b-4417-9b14-24659a5e68a8 Agent-Session: 79e2268e-5c55-4f1c-a7b9-f2a6d613fe4f Agent-Session: 62ad3106-de8d-4fd7-b0ff-ee63a836fbd2 --- crates/diffr-core/src/config.rs | 17 ++++---- crates/diffr-core/src/config/prune.rs | 46 +-------------------- crates/diffr-core/src/config/prune/tests.rs | 8 +--- crates/diffr-core/src/config/store.rs | 14 +------ 4 files changed, 12 insertions(+), 73 deletions(-) diff --git a/crates/diffr-core/src/config.rs b/crates/diffr-core/src/config.rs index 7b287c938..3ee29aab5 100644 --- a/crates/diffr-core/src/config.rs +++ b/crates/diffr-core/src/config.rs @@ -231,16 +231,15 @@ impl Config { version.version ))); } - let source = prune::forget_legacy(source); - let mut config: Self = serde_path_to_error::deserialize(toml::Deserializer::new(&source)) + let mut config: Self = serde_path_to_error::deserialize(toml::Deserializer::new(source)) .map_err(|error| { - let path = error.path().to_string(); - let message = error.inner().to_string(); - ConfigError(match path.as_str() { - "." => message, - _ => format!("{path}: {message}"), - }) - })?; + let path = error.path().to_string(); + let message = error.inner().to_string(); + ConfigError(match path.as_str() { + "." => message, + _ => format!("{path}: {message}"), + }) + })?; config.plugins.resolve(directory)?; config.classifier.resolve(directory)?; Ok(config) diff --git a/crates/diffr-core/src/config/prune.rs b/crates/diffr-core/src/config/prune.rs index 9c790f44e..d08c6a4d3 100644 --- a/crates/diffr-core/src/config/prune.rs +++ b/crates/diffr-core/src/config/prune.rs @@ -1,43 +1,7 @@ //! Keep the config file sparse, so that later default changes reach it. -//! Both passes remove keys by dotted path: [`prune`] each key whose removal -//! leaves the resolved configuration unchanged, and [`forget_legacy`] each -//! key that holds a default an earlier version wrote into files. -use std::borrow::Cow; +//! Remove each key whose removal leaves the resolved configuration unchanged. use toml_edit::{DocumentMut, Item, TableLike}; -/// Defaults that versions before 0.1.8 wrote into config files on the first -/// edit, read as unset. Temporary: remove after 2026-12-31. -const LEGACY_DEFAULTS: &[(&str, &[&str])] = &[( - "plugins.bundled.summarize.system_prompt", - &[ - "For each listed fold, rewrite that function body as short python-flavored pseudocode. Keep the names. No prose, no comments, no code fences. Use as few lines as possible: about one pseudocode line per five source lines, and never more than a third of the body's lines. When a fold lists a doc, also set \"summary\" to one sentence copied verbatim from that doc; otherwise leave it empty. Answer with a JSON array of {\"id\", \"summary\", \"pseudocode\"} objects, one per fold.", - "For each listed fold, rewrite that function body as short pseudocode. Keep the names. No prose, no comments, no code fences. Use as few lines as possible: about one pseudocode line per five source lines, and never more than a third of the body's lines. When a fold lists a doc, also set \"summary\" to one sentence copied verbatim from that doc; otherwise leave it empty. Answer with a JSON array of {\"id\", \"summary\", \"pseudocode\"} objects, one per fold.", - ], -)]; - -/// `source` without the keys that hold a legacy default. Text that does not -/// parse is returned as it is, for the caller to report. -pub(crate) fn forget_legacy(source: &str) -> Cow<'_, str> { - let Ok(mut document) = source.parse::() else { - return Cow::Borrowed(source); - }; - let mut changed = false; - for (key, values) in LEGACY_DEFAULTS { - let path: Vec<&str> = key.split('.').collect(); - let legacy = get(document.as_table(), &path) - .and_then(Item::as_str) - .is_some_and(|value| values.contains(&value)); - if legacy { - remove(document.as_table_mut(), &path); - changed = true; - } - } - match changed { - true => Cow::Owned(document.to_string()), - false => Cow::Borrowed(source), - } -} - /// Remove each value, then each table, whose removal leaves `resolve` of /// the document unchanged: an object option is only valid whole. `version` stays, and so does /// anything with a comment on it: a note marks intent. @@ -146,14 +110,6 @@ fn commented(decor: &toml_edit::Decor) -> bool { .any(|raw| raw.as_str().is_some_and(|text| text.contains('#'))) } -fn get<'a>(table: &'a dyn TableLike, path: &[&str]) -> Option<&'a Item> { - match path { - [key] => table.get(key), - [key, rest @ ..] => get(table.get(key)?.as_table_like()?, rest), - [] => None, - } -} - pub(crate) fn remove(table: &mut dyn TableLike, path: &[&str]) { match path { [key] => { diff --git a/crates/diffr-core/src/config/prune/tests.rs b/crates/diffr-core/src/config/prune/tests.rs index 7e4ba772a..cc0e5c073 100644 --- a/crates/diffr-core/src/config/prune/tests.rs +++ b/crates/diffr-core/src/config/prune/tests.rs @@ -190,8 +190,7 @@ fn edit_value(value: &Value) -> toml_edit::Value { } /// A config file of random settings at their default or another value, in -/// random order, with random comments, explicit headers and an old default -/// prompt. +/// random order, with random comments and explicit headers. fn generate(rng: &mut Rng, settings: &[(String, Value)]) -> (DocumentMut, Vec) { let mut document = DocumentMut::new(); document.insert("version", toml_edit::value(2)); @@ -212,11 +211,6 @@ fn generate(rng: &mut Rng, settings: &[(String, Value)]) -> (DocumentMut, Vec Date: Tue, 6 Oct 2026 18:30:12 -0700 Subject: [PATCH 3/6] Group shape and classifier settings under plugins Keep stock shape entries under bundled and custom entries directly under their manifest names. Require path for custom plugins and retain order references and runtime identity. AI assistance: implemented with OpenAI Codex; cleaned up with Claude Code and OpenAI Codex. Agent-Session: 79e2268e-5c55-4f1c-a7b9-f2a6d613fe4f --- crates/diffr-core/src/config.rs | 49 +- crates/diffr-core/src/config/default.toml | 12 +- crates/diffr-core/src/config/prune.rs | 2 +- crates/diffr-core/src/config/prune/tests.rs | 3 +- crates/diffr-core/src/config/store.rs | 273 +++++++---- crates/diffr-core/src/plugin/config.rs | 509 +++++++++++++------- crates/diffr-core/src/plugin/queries.rs | 6 +- plugins/shape/summarize/plugin.wasm | Bin 611954 -> 611769 bytes plugins/shape/summarize/rust/src/lib.rs | 2 +- pprint.sh | 2 +- src/cli.rs | 4 +- src/plugin/classify.rs | 4 +- src/plugin/tests/context.rs | 1 + src/plugin/tests/mod.rs | 28 +- src/plugin/tests/summarize.rs | 2 +- src/plugin/wasm.rs | 19 +- tests/cli.rs | 6 +- tests/tags.rs | 2 +- tests/view.rs | 2 +- tests/wasm.rs | 4 +- tests/wasm_concurrency.rs | 7 +- 21 files changed, 584 insertions(+), 353 deletions(-) diff --git a/crates/diffr-core/src/config.rs b/crates/diffr-core/src/config.rs index 3ee29aab5..e5bbc4257 100644 --- a/crates/diffr-core/src/config.rs +++ b/crates/diffr-core/src/config.rs @@ -8,11 +8,11 @@ //! `title` and an `x-group` that settings screens show in place of the //! dotted key. Lists are in the schema marked `"x-settings": false`: a //! settings screen edits scalars and leaves those to the file and `diffr -//! config set`. The `plugins` part of the schema comes from each plugin's +//! config set`. The `plugins.shape` part of the schema comes from each plugin's //! `plugin.toml`, with lists and tables marked the same way. `config set` //! keeps only what differs from the defaults (see [`prune`]). //! -//! `[plugins]` configures the plugins that shape regions after diffing (see +//! `[plugins.shape]` configures the plugins that shape regions after diffing (see //! [`crate::plugin`]). Compiling assembles, per language, one query from the //! query files of every enabled plugin (see [`crate::plugin::queries`]): its //! `@fold` captures decide which folds exist, and its tags what they are. @@ -23,7 +23,7 @@ pub mod store; use crate::hash::DftHashMap; use crate::options::DiffOptions; use crate::parse::{guess_language::Language, tree_sitter_parser}; -use crate::plugin::config::{ClassifierConfig, PluginsConfig}; +use crate::plugin::config::PluginsConfig; use crate::plugin::queries::{self, Queries}; use query::AnnotationQuery; use schemars::JsonSchema; @@ -41,21 +41,14 @@ fn config_version() -> u32 { #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct Config { - /// Configuration format version. Only version 2 is accepted; omitted means 2. + /// Configuration format version. #[serde(default = "config_version")] #[schemars(extend("x-settings" = false, "const" = CONFIG_VERSION))] pub(crate) version: u32, - /// The plugins that decide what starts collapsed, hidden, linked or - /// grouped, and the fold queries they own. Its schema comes from each - /// plugin's `plugin.toml`; see [`PluginsConfig::schema`]. + /// The shape and classifier plugins, with settings from their manifests. #[schemars(skip)] #[serde(default)] pub plugins: PluginsConfig, - /// The one plugin that tags files before diffing; see - /// [`ClassifierConfig::schema`]. - #[schemars(skip)] - #[serde(default)] - pub classifier: ClassifierConfig, /// Colors for the terminal frontend. #[serde(default)] pub(crate) theme: ThemeConfig, @@ -217,7 +210,6 @@ impl Config { /// Parse the text of a file in `directory`. Errors lead with the dotted /// path of the key they concern, such as `diff.typo`. pub fn from_toml_in(source: &str, directory: &Path) -> Result { - // Report an unsupported format before decoding its fields. #[derive(Deserialize)] struct Version { #[serde(default = "config_version")] @@ -240,8 +232,8 @@ impl Config { _ => format!("{path}: {message}"), }) })?; - config.plugins.resolve(directory)?; - config.classifier.resolve(directory)?; + config.plugins.shape.resolve(directory)?; + config.plugins.classify.resolve(directory)?; Ok(config) } @@ -260,7 +252,6 @@ impl Config { .as_object_mut() .expect("the schema has properties"); properties.insert("plugins".to_owned(), PluginsConfig::schema()); - properties.insert("classifier".to_owned(), ClassifierConfig::schema()); properties.extend(rest); schema } @@ -268,11 +259,11 @@ impl Config { /// Compile the query files the enabled plugins' manifests declare. No /// plugin runs to supply them. pub fn compile(&self) -> Result { - self.compile_queries(self.plugins.queries()?) + self.compile_queries(self.plugins.shape.queries()?) } /// Compile with `queries`, the enabled plugins' query files in - /// `plugins.order`. + /// `plugins.shape.order`. pub fn compile_queries(&self, queries: Vec<(String, Queries)>) -> Result { let mut languages: DftHashMap<_, _> = Language::iter() .map(|language| (language, OnceLock::new())) @@ -283,7 +274,7 @@ impl Config { .ok_or_else(|| ConfigError(format!("unknown language: {name}")))?; let parser = tree_sitter_parser::from_language(language); let query = AnnotationQuery::compile(&parser.language, &sources)?; - check_tags(&query, &self.plugins.order)?; + check_tags(&query, &self.plugins.shape.order)?; languages.insert( language, OnceLock::from(Arc::new(LanguageParams { @@ -308,9 +299,7 @@ fn check_tags(query: &AnnotationQuery, order: &[String]) -> Result<(), ConfigErr let owned = tag.split_once(':').is_some_and(|(plugin, name)| { !name.is_empty() && (crate::plugin::builtin::manifest(plugin).is_some() - || order.iter().any(|own| { - own.split_once('.').map_or(own.as_str(), |(_, name)| name) == plugin - })) + || order.iter().any(|own| own == plugin)) }); if !owned { return Err(ConfigError(format!( @@ -519,10 +508,12 @@ fn with_queries(queries: &[(&str, &str)]) -> Params { /// over them. #[cfg(test)] pub(crate) fn body_params() -> Params { - Config::from_toml("[plugins.bundled.context]\nenabled = false\n[plugins.bundled.summarize]\nenabled = true\napi_key = 'test'\n") - .expect("a valid configuration") - .compile() - .expect("the bundled queries compile") + Config::from_toml( + "[plugins.shape.bundled.context]\nenabled = false\n[plugins.shape.bundled.summarize]\nenabled = true\napi_key = 'test'\n", + ) + .expect("a valid configuration") + .compile() + .expect("the bundled queries compile") } #[cfg(test)] @@ -807,7 +798,7 @@ mod format_tests { } #[test] - fn unsupported_versions_are_rejected_and_explicit_lists_stay_small() { + fn unsupported_versions_are_rejected_before_their_keys() { assert!( Config::from_toml("version = 1\n[plugins.bundled.context]\nenabled = false") .err() @@ -815,9 +806,5 @@ mod format_tests { .to_string() .contains("unsupported config version 1; expected 2") ); - let config = - Config::from_toml("version = 2\n[plugins]\norder = ['bundled.context']\n").unwrap(); - assert_eq!(config.plugins.entries.len(), 1); - assert!(config.plugins.entries.contains_key("bundled.context")); } } diff --git a/crates/diffr-core/src/config/default.toml b/crates/diffr-core/src/config/default.toml index 055b52c30..bd15d1194 100644 --- a/crates/diffr-core/src/config/default.toml +++ b/crates/diffr-core/src/config/default.toml @@ -1,7 +1,7 @@ # Bundled defaults. The user config keeps only what differs from these. version = 2 -[plugins] +[plugins.shape] order = [ "bundled.deleted-bodies", "bundled.summarize", @@ -10,19 +10,19 @@ order = [ "bundled.context", ] -[plugins.bundled.context] +[plugins.shape.bundled.context] enabled = true lines = 3 -[plugins.bundled.deleted-bodies] +[plugins.shape.bundled.deleted-bodies] enabled = true min_lines = 12 -[plugins.bundled.removed-runs] +[plugins.shape.bundled.removed-runs] enabled = true min_lines = 5 -[plugins.bundled.summarize] +[plugins.shape.bundled.summarize] enabled = false provider = "gemini" min_lines = 20 @@ -31,7 +31,7 @@ test_min_lines = 20 request_timeout_ms = 60000 retries = 3 -[plugins.bundled.test-bodies] +[plugins.shape.bundled.test-bodies] enabled = true min_lines = 3 diff --git a/crates/diffr-core/src/config/prune.rs b/crates/diffr-core/src/config/prune.rs index d08c6a4d3..58f95103c 100644 --- a/crates/diffr-core/src/config/prune.rs +++ b/crates/diffr-core/src/config/prune.rs @@ -26,7 +26,7 @@ pub(crate) fn prune(document: &mut DocumentMut, resolve: impl Fn(&str) -> Option } /// Leave out the uncommented header of a table that only holds tables, such -/// as `[plugins]` above `[plugins.bundled.context]`. +/// as `[plugins.shape]` above `[plugins.shape.bundled.context]`. fn hide_headers(table: &mut toml_edit::Table) { let only_tables = table.iter().all(|(_, item)| item.is_table()); if only_tables && !table.is_empty() && !commented(table.decor()) { diff --git a/crates/diffr-core/src/config/prune/tests.rs b/crates/diffr-core/src/config/prune/tests.rs index cc0e5c073..2ba72141e 100644 --- a/crates/diffr-core/src/config/prune/tests.rs +++ b/crates/diffr-core/src/config/prune/tests.rs @@ -34,8 +34,7 @@ fn settings() -> Vec<(String, Value)> { walk(&root, &root, "", &mut out); out.retain(|(key, node)| { key != "version" - && !key.starts_with("plugins.external") - && key != "classifier.path" + && !key.ends_with(".path") && node.get("type").and_then(Value::as_str) != Some("object") }); out diff --git a/crates/diffr-core/src/config/store.rs b/crates/diffr-core/src/config/store.rs index 90a9eda91..94957243e 100644 --- a/crates/diffr-core/src/config/store.rs +++ b/crates/diffr-core/src/config/store.rs @@ -16,7 +16,7 @@ pub fn show(config: &Config, reveal: bool) -> serde_json::Value { pub fn redacted(config: &Config, reveal: bool) -> Config { let mut shown = config.clone(); if !reveal { - for entry in shown.plugins.entries.values_mut() { + for entry in shown.plugins.shape.entries.values_mut() { if let Some(key) = entry .options .get_mut("api_key") @@ -67,21 +67,21 @@ fn reset( existing: &str, directory: &Path, ) -> Result<(), ConfigError> { - let ["plugins", namespace, name, field] = key.split('.').collect::>()[..] else { + let segments: Vec<_> = key.split('.').collect(); + let Some(("shape", name, field)) = plugin_setting(&segments) else { return Ok(()); }; let config = Config::from_toml_in(existing, directory)?; - let Some(entry) = config.plugins.entries.get(&format!("{namespace}.{name}")) else { + let Some(entry) = config.plugins.shape.entries.get(&name) else { return Ok(()); }; if entry.options.get(field) == serde_json::to_value(value).ok().as_ref() { return Ok(()); } for option in entry.folder().manifest.reset_by(field) { - super::prune::remove( - document.as_table_mut(), - &["plugins", namespace, name, option], - ); + let mut path = segments[..segments.len() - 1].to_vec(); + path.push(option); + super::prune::remove(document.as_table_mut(), &path); } Ok(()) } @@ -118,20 +118,30 @@ fn write(path: &Path, document: toml_edit::DocumentMut) -> Result<(), ConfigErro /// (in `directory`) points it at; `path` is diffr's, and a string. fn setting_schema(key: &str, existing: &str, directory: &Path) -> Result { let unknown = || ConfigError(format!("{key}: unknown key")); - if let ["plugins", namespace, name, field] = key.split('.').collect::>().as_slice() { - if *field == PATH { + let config = Config::from_toml_in(existing, directory)?; + let segments: Vec<_> = key.split('.').collect(); + if let Some((namespace, name, field)) = plugin_setting(&segments) { + let (manifest, custom) = match namespace { + "shape" => { + let entry = config + .plugins + .shape + .entries + .get(&name) + .ok_or_else(unknown)?; + (&entry.folder().manifest, !name.starts_with("bundled.")) + } + "classify" if config.plugins.classify.name == name && field != "enabled" => ( + &config.plugins.classify.folder().manifest, + name != "bundled", + ), + _ => return Err(unknown()), + }; + if field == PATH && custom { return Ok(json!({"type": "string"})); } - let config = Config::from_toml_in(existing, directory)?; - let manifest = &config - .plugins - .entries - .get(&format!("{namespace}.{name}")) - .ok_or_else(unknown)? - .folder() - .manifest; return manifest.settings_schema()["properties"] - .get(*field) + .get(field) .cloned() .ok_or_else(unknown); } @@ -224,6 +234,18 @@ fn typed_value(key: &str, schema: &Value, text: &str) -> Result(parts: &'a [&str]) -> Option<(&'a str, String, &'a str)> { + match parts { + ["plugins", "shape", "bundled", name, field] => { + Some(("shape", format!("bundled.{name}"), field)) + } + ["plugins", namespace @ ("shape" | "classify"), name, field] => { + Some((namespace, (*name).to_owned(), field)) + } + _ => None, + } +} + fn assign( document: &mut toml_edit::DocumentMut, key: &str, @@ -299,66 +321,131 @@ mod tests { fn plugin_keys_are_written_under_their_quoted_names() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); - set(&path, "plugins.bundled.deleted-bodies.min_lines", "30").unwrap(); - set(&path, "plugins.bundled.summarize.api_key", "secret").unwrap(); + set( + &path, + "plugins.shape.bundled.deleted-bodies.min_lines", + "30", + ) + .unwrap(); + set(&path, "plugins.shape.bundled.summarize.api_key", "secret").unwrap(); let text = std::fs::read_to_string(&path).unwrap(); let config = Config::from_toml(&text).unwrap(); assert_eq!( - config.plugins.entries["bundled.deleted-bodies"].options["min_lines"], + config.plugins.shape.entries["bundled.deleted-bodies"].options["min_lines"], 30 ); - assert!(set(&path, "plugins.bundled.deleted-bodies.typo", "1").is_err()); - assert!(set(&path, "plugins.bundled.deleted-bodies.min_lines", "-1").is_err()); - assert!(set(&path, "plugins.order", "[\"group\"]").is_err()); + assert!(set(&path, "plugins.shape.bundled.deleted-bodies.typo", "1").is_err()); + assert!(set( + &path, + "plugins.shape.bundled.deleted-bodies.min_lines", + "-1" + ) + .is_err()); + assert!(set(&path, "plugins.shape.order", "[\"group\"]").is_err()); let error = |key: &str, value: &str| set(&path, key, value).unwrap_err().to_string(); assert_eq!( - error("plugins.bundled.context.enabled", "yes"), - "plugins.bundled.context.enabled: expected true or false, got \"yes\"" + error("plugins.shape.bundled.context.enabled", "yes"), + "plugins.shape.bundled.context.enabled: expected true or false, got \"yes\"" ); - assert!(error("plugins.order", "context") - .starts_with("plugins.order: expected a TOML array such as [\"a\", \"b\"]")); + assert!(error("plugins.shape.order", "context") + .starts_with("plugins.shape.order: expected a TOML array such as [\"a\", \"b\"]")); assert_eq!( - error("plugins.bundled.context.lines", "many"), - "plugins.bundled.context.lines: expected an integer, got \"many\"" + error("plugins.shape.bundled.context.lines", "many"), + "plugins.shape.bundled.context.lines: expected an integer, got \"many\"" ); assert_eq!( - error("plugins.bundled.summarize.provider", "mistral"), - "plugins.bundled.summarize.provider: expected one of \"gemini\", \"openai\", \"anthropic\", got \"mistral\"" + error("plugins.shape.bundled.summarize.provider", "mistral"), + "plugins.shape.bundled.summarize.provider: expected one of \"gemini\", \"openai\", \"anthropic\", got \"mistral\"" ); assert_eq!( - error("plugins.external.mine.enabled", "true"), - "plugins.external.mine.enabled: unknown key" + error("plugins.shape.mine.enabled", "true"), + "plugins.shape.mine.enabled: unknown key" ); - set(&path, "plugins.bundled.context.enabled", "false").unwrap(); + set(&path, "plugins.shape.bundled.context.enabled", "false").unwrap(); let text = std::fs::read_to_string(&path).unwrap(); assert_eq!( - Config::from_toml(&text).unwrap().plugins.entries["bundled.context"].enabled, + Config::from_toml(&text).unwrap().plugins.shape.entries["bundled.context"].enabled, Some(false) ); let shown = show(&config, false); assert_eq!( - shown["plugins"]["bundled"]["summarize"]["api_key"], + shown["plugins"]["shape"]["bundled"]["summarize"]["api_key"], "" ); assert_eq!( - show(&config, true)["plugins"]["bundled"]["summarize"]["api_key"], + show(&config, true)["plugins"]["shape"]["bundled"]["summarize"]["api_key"], "secret" ); assert_eq!( - shown["plugins"]["bundled"]["deleted-bodies"], + shown["plugins"]["shape"]["bundled"]["deleted-bodies"], serde_json::json!({"enabled": true, "min_lines": 30}) ); - assert_eq!( - shown["plugins"]["bundled"]["summarize"]["system_prompt"], - crate::plugin::builtin::manifest("summarize") - .unwrap() - .options["system_prompt"]["default"] - ); let text = toml::to_string_pretty(&redacted(&config, false)).unwrap(); - assert!(text.contains("[plugins.bundled.summarize]"), "{text}"); + assert!(text.contains("[plugins.shape.bundled.summarize]"), "{text}"); assert!(text.contains("system_prompt = "), "{text}"); } + #[test] + fn classifier_settings_use_the_selected_manifests_types() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("plugin.toml"), + "name = 'custom'\ntitle = 'Custom classifier'\n[options.depth]\ntype = 'integer'\ntitle = 'Depth'\ndefault = 2\n", + ).unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "[plugins.classify.custom]\npath = '.'\n").unwrap(); + set(&path, "plugins.classify.custom.depth", "5").unwrap(); + let saved = std::fs::read_to_string(&path).unwrap(); + let config = Config::from_toml_in(&saved, dir.path()).unwrap(); + assert_eq!(config.plugins.classify.options["depth"], 5); + assert!(set(&path, "plugins.classify.custom.depth", "many").is_err()); + assert!(set(&path, "plugins.classify.custom.enabled", "false").is_err()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), saved); + } + + #[test] + fn a_custom_name_with_spaces_keeps_its_identity_when_edited() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("plugin.toml"), + "name = 'my context'\ntitle = 'Custom'\n[options.depth]\ntype = 'integer'\ntitle = 'Depth'\ndefault = 2\n").unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + "[plugins.shape]\norder = ['my context']\n[plugins.shape.\"my context\"]\npath = '.'\n", + ) + .unwrap(); + set(&path, "plugins.shape.my context.depth", "5").unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + let config = Config::from_toml_in(&text, dir.path()).unwrap(); + assert_eq!( + config.plugins.shape.entries["my context"].options["depth"], + 5 + ); + assert_eq!(config.plugins.shape.queries().unwrap()[0].0, "my context"); + set(&path, "plugins.shape.my context.depth", "2").unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(!text.contains("depth"), "{text}"); + let config = Config::from_toml_in(&text, dir.path()).unwrap(); + assert_eq!( + config.plugins.shape.entries["my context"].options["depth"], + 2 + ); + } + + #[test] + fn an_old_format_is_reported_before_its_keys_and_left_as_it_is() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let old = "version = 1\n[plugins.bundled.context]\nlines = 8\n"; + std::fs::write(&path, old).unwrap(); + let error = set(&path, "plugins.bundled.context.lines", "9").unwrap_err(); + assert!( + error.to_string().contains("unsupported config version 1"), + "{error}" + ); + assert_eq!(std::fs::read_to_string(&path).unwrap(), old); + } + #[test] fn a_wasm_plugin_option_takes_the_type_its_folder_declares() { let dir = tempfile::tempdir().unwrap(); @@ -370,28 +457,28 @@ mod tests { ) .unwrap(); let path = dir.path().join("config.toml"); - let order = "order = ['bundled.context', 'bundled.deleted-bodies', 'bundled.test-bodies', 'bundled.removed-runs', 'bundled.summarize', 'external.mine']"; + let order = "order = ['bundled.context', 'bundled.deleted-bodies', 'bundled.test-bodies', 'bundled.removed-runs', 'bundled.summarize', 'mine']"; std::fs::write( &path, - format!("[plugins]\n{order}\n[plugins.external.mine]\npath = 'plugins/mine'\n"), + format!("[plugins.shape]\n{order}\n[plugins.shape.mine]\npath = 'plugins/mine'\n"), ) .unwrap(); - set(&path, "plugins.external.mine.depth", "3").unwrap(); - set(&path, "plugins.external.mine.enabled", "false").unwrap(); - set(&path, "plugins.external.mine.path", "plugins/mine").unwrap(); + set(&path, "plugins.shape.mine.depth", "3").unwrap(); + set(&path, "plugins.shape.mine.enabled", "false").unwrap(); + set(&path, "plugins.shape.mine.path", "plugins/mine").unwrap(); let error = |key: &str, value: &str| set(&path, key, value).unwrap_err().to_string(); assert_eq!( - error("plugins.external.mine.depth", "deep"), - "plugins.external.mine.depth: expected an integer, got \"deep\"" + error("plugins.shape.mine.depth", "deep"), + "plugins.shape.mine.depth: expected an integer, got \"deep\"" ); assert_eq!( - error("plugins.external.mine.typo", "1"), - "plugins.external.mine.typo: unknown key" + error("plugins.shape.mine.typo", "1"), + "plugins.shape.mine.typo: unknown key" ); let text = std::fs::read_to_string(&path).unwrap(); let config = Config::from_toml_in(&text, dir.path()).unwrap(); - assert_eq!(config.plugins.entries["external.mine"].options["depth"], 3); - assert_eq!(config.plugins.entries["external.mine"].enabled, Some(false)); + assert_eq!(config.plugins.shape.entries["mine"].options["depth"], 3); + assert_eq!(config.plugins.shape.entries["mine"].enabled, Some(false)); } #[test] @@ -498,24 +585,27 @@ mod sparse_tests { assert!(raw.get("plugins").is_none(), "{raw}"); let text = std::fs::read_to_string(&path).unwrap(); std::fs::write(&path, format!("# personal config\n{text}")).unwrap(); - set(&path, "plugins.bundled.context.lines", "8").unwrap(); - set(&path, "plugins.bundled.summarize.api_key", "").unwrap(); + set(&path, "plugins.shape.bundled.context.lines", "8").unwrap(); + set(&path, "plugins.shape.bundled.summarize.api_key", "").unwrap(); let text = std::fs::read_to_string(&path).unwrap(); assert!(text.starts_with("# personal config\n"), "{text}"); let raw = read_toml(&path); assert_eq!( - raw["plugins"]["bundled"]["context"]["lines"].as_integer(), + raw["plugins"]["shape"]["bundled"]["context"]["lines"].as_integer(), Some(8) ); // An empty key is not the same as no key, so it stays. assert_eq!( - raw["plugins"]["bundled"]["summarize"]["api_key"].as_str(), + raw["plugins"]["shape"]["bundled"]["summarize"]["api_key"].as_str(), Some("") ); // Setting a default removes the key. - set(&path, "plugins.bundled.context.lines", "3").unwrap(); + set(&path, "plugins.shape.bundled.context.lines", "3").unwrap(); let raw = read_toml(&path); - assert!(raw["plugins"]["bundled"].get("context").is_none(), "{raw}"); + assert!( + raw["plugins"]["shape"]["bundled"].get("context").is_none(), + "{raw}" + ); assert_eq!(Config::from_toml(&text).unwrap().diff.graph_limit, 42); } @@ -525,21 +615,18 @@ mod sparse_tests { let path = dir.path().join("config.toml"); // What earlier versions wrote on the first edit: every default. let mut old = toml::Value::try_from(Config::default()).unwrap(); - let summarize = old["plugins"]["bundled"]["summarize"] + let summarize = old["plugins"]["shape"]["bundled"]["summarize"] .as_table_mut() .unwrap(); summarize.insert("api_key".into(), "secret".into()); - old["plugins"]["bundled"]["context"] + old["plugins"]["shape"]["bundled"]["context"] .as_table_mut() .unwrap() .insert("lines".into(), 8.into()); std::fs::write(&path, toml::to_string(&old).unwrap()).unwrap(); set(&path, "diff.graph_limit", "42").unwrap(); let text = std::fs::read_to_string(&path).unwrap(); - assert!( - !text.contains("[plugins]\n") && !text.contains("[plugins.bundled]\n"), - "{text}" - ); + assert!(!text.contains("[plugins.shape]\n"), "{text}"); let raw = read_toml(&path); let mut keys = Vec::new(); fn walk(value: &toml::Value, prefix: String, keys: &mut Vec) { @@ -557,8 +644,8 @@ mod sparse_tests { keys, [ "diff.graph_limit", - "plugins.bundled.context.lines", - "plugins.bundled.summarize.api_key", + "plugins.shape.bundled.context.lines", + "plugins.shape.bundled.summarize.api_key", "version", ] ); @@ -570,7 +657,7 @@ mod sparse_tests { let path = dir.path().join("config.toml"); std::fs::write( &path, - "# my settings\n[plugins] # note\n[plugins.bundled.context]\n# lines = 10 later\nenabled = true\nlines = 8\n", + "# my settings\n[plugins.shape] # note\n[plugins.shape.bundled.context]\n# lines = 10 later\nenabled = true\nlines = 8\n", ) .unwrap(); set(&path, "diff.graph_limit", "42").unwrap(); @@ -586,7 +673,7 @@ mod sparse_tests { let path = dir.path().join("config.toml"); std::fs::write( &path, - "[classifier]\nhide = [\n # keep generated code out\n \"generated\",\n \"vendored\",\n \"test\", # tests too\n]\n", + "[plugins.classify.bundled]\nhide = [\n # keep generated code out\n \"generated\",\n \"vendored\",\n \"test\", # tests too\n]\n", ) .unwrap(); set(&path, "diff.graph_limit", "42").unwrap(); @@ -603,31 +690,36 @@ mod sparse_tests { // Earlier versions wrote every table with its own header. std::fs::write( &path, - "version = 2\n\n[plugins]\n\n[plugins.bundled]\n\n[plugins.bundled.context]\nlines = 3\n", + "version = 2\n\n[plugins.shape]\n[plugins.shape.bundled.context]\nlines = 3\n", ) .unwrap(); set(&path, "diff.graph_limit", "42").unwrap(); let text = std::fs::read_to_string(&path).unwrap(); - assert!(!text.contains("plugins"), "{text}"); + assert!(!text.contains("shape"), "{text}"); } #[test] fn a_model_equal_to_its_providers_default_is_dropped() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); - set(&path, "plugins.bundled.summarize.provider", "openai").unwrap(); - set(&path, "plugins.bundled.summarize.model", "gpt-6-luna").unwrap(); + set(&path, "plugins.shape.bundled.summarize.provider", "openai").unwrap(); + set(&path, "plugins.shape.bundled.summarize.model", "gpt-6-luna").unwrap(); let raw = read_toml(&path); assert!( - raw["plugins"]["bundled"]["summarize"] + raw["plugins"]["shape"]["bundled"]["summarize"] .get("model") .is_none(), "{raw}" ); - set(&path, "plugins.bundled.summarize.model", "gemini-3.8-flash").unwrap(); + set( + &path, + "plugins.shape.bundled.summarize.model", + "gemini-3.8-flash", + ) + .unwrap(); let raw = read_toml(&path); assert_eq!( - raw["plugins"]["bundled"]["summarize"]["model"].as_str(), + raw["plugins"]["shape"]["bundled"]["summarize"]["model"].as_str(), Some("gemini-3.8-flash") ); } @@ -637,7 +729,12 @@ mod sparse_tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("config.toml"); let summarize = |key: &str, value: &str| { - set(&path, &format!("plugins.bundled.summarize.{key}"), value).unwrap() + set( + &path, + &format!("plugins.shape.bundled.summarize.{key}"), + value, + ) + .unwrap() }; summarize("provider", "anthropic"); summarize("api_key", "anthropic-key"); @@ -649,12 +746,14 @@ mod sparse_tests { summarize("provider", "anthropic"); let raw = read_toml(&path); assert_eq!( - raw["plugins"]["bundled"]["summarize"]["api_key"].as_str(), + raw["plugins"]["shape"]["bundled"]["summarize"]["api_key"].as_str(), Some("anthropic-key") ); summarize("provider", "gemini"); let raw = read_toml(&path); - let entry = raw["plugins"]["bundled"]["summarize"].as_table().unwrap(); + let entry = raw["plugins"]["shape"]["bundled"]["summarize"] + .as_table() + .unwrap(); let mut keys: Vec<&str> = entry.keys().map(String::as_str).collect(); keys.sort_unstable(); // Gemini is the default provider, so it is pruned too. @@ -667,14 +766,14 @@ mod sparse_tests { let path = dir.path().join("config.toml"); std::fs::write( &path, - "# just context\n[plugins]\norder = ['bundled.context']\n", + "# just context\n[plugins.shape]\norder = ['bundled.context']\n", ) .unwrap(); set(&path, "diff.graph_limit", "42").unwrap(); let text = std::fs::read_to_string(&path).unwrap(); let config = Config::from_toml(&text).unwrap(); - assert_eq!(config.plugins.entries.len(), 1); - assert!(text.contains("# just context\n[plugins]"), "{text}"); - assert!(!text.contains("bundled.deleted-bodies")); + assert_eq!(config.plugins.shape.entries.len(), 1); + assert!(text.contains("# just context\n[plugins.shape]"), "{text}"); + assert!(!text.contains("deleted-bodies")); } } diff --git a/crates/diffr-core/src/plugin/config.rs b/crates/diffr-core/src/plugin/config.rs index a3fb21ec3..f6e11ca6c 100644 --- a/crates/diffr-core/src/plugin/config.rs +++ b/crates/diffr-core/src/plugin/config.rs @@ -1,4 +1,4 @@ -//! `[plugins]`: the order plugins run in, and one entry per plugin with its +//! `[plugins.shape]`: the order plugins run in, and one entry per plugin with its //! switch and options. Also `plugin.toml`, the static description every //! plugin folder carries: the plugin's name, title and options schema. //! @@ -37,7 +37,7 @@ pub(crate) const COMPONENT_FILE: &str = "plugin.wasm"; #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields)] pub struct Manifest { - /// The plugin's entry name in `[plugins]`, and the prefix of every tag + /// The plugin's name, and the prefix of every tag /// its queries set: `:`. pub(crate) name: String, /// The human name settings screens group the plugin's settings under. @@ -281,7 +281,7 @@ impl Manifest { } } - /// The entry this plugin adds to `diffr config schema` under `plugins`. + /// The entry this plugin adds to `diffr config schema`. /// Each option keeps its own schema, with the plugin's title as its /// `x-group` unless it sets one. An option whose type is an array or an /// object is marked `"x-settings": false`: settings screens edit @@ -327,43 +327,81 @@ impl Manifest { "title": group, "description": self.description, "properties": properties, + "additionalProperties": false, }) } } -/// `[plugins]`. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(from = "PluginTables", into = "PluginTables")] +/// `[plugins]`: the shape pipeline and the one file classifier. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] pub struct PluginsConfig { + pub shape: ShapeConfig, + pub classify: ClassifierConfig, +} + +impl PluginsConfig { + pub(crate) fn schema() -> Value { + json!({ + "type": "object", + "description": "The shape pipeline and the classifier that tags changed files.", + "properties": { + "shape": ShapeConfig::schema(), + "classify": ClassifierConfig::schema(), + }, + "additionalProperties": false, + }) + } +} + +/// `[plugins.shape]`. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(try_from = "PluginTables", into = "PluginTables")] +pub struct ShapeConfig { /// The plugins in the order they run; each sees the region trees the /// ones before it left. Every entry is listed exactly once. pub(crate) order: Vec, - /// Every entry, by plugin name. + /// Every entry, by reference: `bundled.` or the whole custom name. pub entries: BTreeMap, } -/// The on-disk namespaces. An explicit order makes the listed entries +/// The on-disk table. An explicit order makes the listed entries /// authoritative; a partial settings file without order inherits the defaults. #[derive(Clone, Debug, Default, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] +#[serde(default)] struct PluginTables { order: Option>, bundled: BTreeMap, - external: BTreeMap, + #[serde(flatten)] + entries: BTreeMap, } fn default_tables() -> PluginTables { #[derive(Deserialize)] struct Defaults { - plugins: PluginTables, + plugins: PluginDefaults, + } + #[derive(Deserialize)] + struct PluginDefaults { + shape: PluginTables, } toml::from_str::(crate::config::DEFAULT_CONFIG) .expect("embedded plugin defaults") .plugins + .shape } -impl From for PluginsConfig { - fn from(tables: PluginTables) -> Self { +impl TryFrom for ShapeConfig { + type Error = ConfigError; + + fn try_from(tables: PluginTables) -> Result { + for name in tables.entries.keys() { + if name.starts_with("bundled.") { + return Err(ConfigError(format!( + "plugins.shape.{name}: reserved plugin reference" + ))); + } + } let mut bundled = tables.bundled; let order = tables.order.unwrap_or_else(|| { let defaults = default_tables(); @@ -380,41 +418,29 @@ impl From for PluginsConfig { }); for reference in &order { if let Some(name) = reference.strip_prefix("bundled.") { - bundled.entry(name.into()).or_default(); + bundled.entry(name.to_owned()).or_default(); } } let entries = bundled .into_iter() .map(|(name, entry)| (format!("bundled.{name}"), entry)) - .chain( - tables - .external - .into_iter() - .map(|(name, entry)| (format!("external.{name}"), entry)), - ) + .chain(tables.entries.into_iter()) .collect(); - Self { order, entries } + Ok(Self { order, entries }) } } -impl From for PluginTables { - fn from(config: PluginsConfig) -> Self { +impl From for PluginTables { + fn from(config: ShapeConfig) -> Self { let mut tables = Self { order: Some(config.order), ..Self::default() }; for (reference, entry) in config.entries { - let (source, name) = reference - .split_once('.') - .expect("resolved plugin reference"); - match source { - "bundled" => { - tables.bundled.insert(name.into(), entry); - } - "external" => { - tables.external.insert(name.into(), entry); - } - _ => unreachable!("resolved plugin namespace"), + if let Some(name) = reference.strip_prefix("bundled.") { + tables.bundled.insert(name.to_owned(), entry); + } else { + tables.entries.insert(reference, entry); } } tables @@ -441,39 +467,97 @@ pub struct Entry { pub options: Map, } -/// `[classifier]`: the one plugin that tags each changed file before -/// anything is diffed. The bundled classifier unless `path` names another. -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -#[serde(default)] +/// `[plugins.classify.]`: the one plugin that tags each changed file before +/// anything is diffed. `path` selects an external classifier. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(try_from = "ClassifyTables", into = "ClassifyTables")] pub struct ClassifierConfig { + /// `bundled`, or the custom plugin's manifest name. + pub(crate) name: String, /// A classifier folder on disk, as written: relative to the configuration /// file's directory, or absolute. - #[serde(skip_serializing_if = "Option::is_none")] pub(crate) path: Option, /// The folder, loaded when the configuration resolves. - #[serde(skip)] folder: Option, /// The classifier's options, checked against its manifest. - #[serde(flatten)] pub options: Map, } +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +struct ClassifierEntry { + #[serde(skip_serializing_if = "Option::is_none")] + path: Option, + #[serde(flatten)] + options: Map, +} +type ClassifyTables = BTreeMap; + +impl Default for ClassifierConfig { + fn default() -> Self { + Self { + name: "bundled".into(), + path: None, + folder: None, + options: Map::new(), + } + } +} + +impl TryFrom for ClassifierConfig { + type Error = ConfigError; + + fn try_from(entries: ClassifyTables) -> Result { + if entries.len() != 1 { + return Err(ConfigError("expected exactly one classifier plugin".into())); + } + let (name, entry) = entries.into_iter().next().expect("one classifier"); + Ok(Self { + name, + path: entry.path, + folder: None, + options: entry.options, + }) + } +} + +impl From for ClassifyTables { + fn from(config: ClassifierConfig) -> Self { + Self::from([( + config.name, + ClassifierEntry { + path: config.path, + options: config.options, + }, + )]) + } +} + impl ClassifierConfig { /// Load the classifier's folder, validate its options and fill their /// defaults. pub(crate) fn resolve(&mut self, base: &Path) -> Result<(), ConfigError> { - let folder = match &self.path { - Some(path) => Folder::read(&base.join(path)) - .map_err(|error| ConfigError(format!("classifier: {error}")))?, - None => Folder { + let folder = match (self.name.as_str(), &self.path) { + ("bundled", None) => Folder { location: Location::Classifier, manifest: builtin::classifier_manifest().clone(), }, + ("bundled", Some(_)) => { + return Err(ConfigError( + "plugins.classify.bundled: bundled classifiers cannot set path".into(), + )) + } + (name, Some(path)) => Folder::load(name, &base.join(path)) + .map_err(|error| ConfigError(format!("plugins.classify.{name}: {error}")))?, + (name, None) => { + return Err(ConfigError(format!( + "plugins.classify.{name}: custom classifiers require path" + ))) + } }; folder .manifest .validate(&self.options) - .map_err(|error| ConfigError(format!("classifier: {error}")))?; + .map_err(|error| ConfigError(format!("plugins.classify.{}: {error}", self.name)))?; folder.manifest.fill_defaults(&mut self.options); self.folder = Some(folder); Ok(()) @@ -486,8 +570,8 @@ impl ClassifierConfig { .expect("a resolved classifier has its folder") } - /// The `classifier` property of `diffr config schema`: `path`, and the - /// bundled classifier's options. + /// The `plugins.classify` property of `diffr config schema`: one plugin entry + /// containing the bundled classifier's options, or one custom entry. pub(crate) fn schema() -> Value { let mut schema = builtin::classifier_manifest().settings_schema(); let properties = schema["properties"] @@ -495,16 +579,19 @@ impl ClassifierConfig { .expect("a settings schema has properties"); // There is always one classifier: it cannot be turned off. properties.remove(ENABLED); - properties.insert( - "path".to_owned(), - json!({ - "type": "string", - "description": "A classifier folder to use instead of the bundled one.", + json!({ + "type": "object", + "description": "The one plugin that tags each changed file (generated, vendored, docs, test, or a custom tag) and hides some, before anything is diffed. Use `bundled` for the stock classifier, or one custom entry named after its manifest with a required `path`.", + "properties": {"bundled": schema}, + "minProperties": 1, + "maxProperties": 1, + "additionalProperties": { + "type": "object", + "required": [PATH], + "properties": {PATH: {"type": "string"}}, "x-settings": false, - }), - ); - schema["description"] = Value::String("The one plugin that tags each changed file (generated, vendored, docs, test, or a custom tag) and hides some, before anything is diffed. The bundled classifier unless `path` names another.".to_owned()); - schema + }, + }) } } @@ -533,6 +620,10 @@ pub struct Folder { } impl Folder { + pub fn name(&self) -> &str { + &self.manifest.name + } + /// The bundled plugin `name`'s embedded folder. fn bundled(name: &str) -> Option { builtin::manifest(name).map(|manifest| Self { @@ -631,9 +722,10 @@ impl Entry { } } -impl Default for PluginsConfig { +impl Default for ShapeConfig { fn default() -> Self { - let mut config = Self::from(default_tables()); + let mut config = + Self::try_from(default_tables()).expect("the embedded shape tables are valid"); config .resolve(Path::new("")) .expect("the bundled plugins' defaults are valid"); @@ -641,49 +733,53 @@ impl Default for PluginsConfig { } } -impl PluginsConfig { +impl ShapeConfig { /// Resolve each selected entry, validate its options, fill their defaults, /// and require every entry to appear in the explicit order exactly once. pub(crate) fn resolve(&mut self, base: &Path) -> Result<(), ConfigError> { - for (reference, entry) in &mut self.entries { - let (source, name) = reference - .split_once('.') - .ok_or_else(|| ConfigError(format!("invalid plugin reference {reference:?}")))?; - let folder = match (source, &entry.path) { - ("external", Some(path)) => Folder::load(name, &base.join(path)) - .map_err(|error| ConfigError(format!("plugins.{reference}: {error}")))?, - ("external", None) => return Err(ConfigError(format!("plugins.{reference}: external plugins require path"))), - ("bundled", None) => Folder::bundled(name).ok_or_else(|| ConfigError(format!("plugins.{reference}: unknown bundled plugin")))?, - ("bundled", Some(_)) => return Err(ConfigError(format!("plugins.{reference}: bundled plugins cannot set path; use plugins.external.{name}"))), - _ => return Err(ConfigError(format!("unknown plugin namespace {source:?}"))), + let mut identities = BTreeSet::new(); + for (name, entry) in &mut self.entries { + let folder = match (name.strip_prefix("bundled."), &entry.path) { + (Some(plugin), None) => Folder::bundled(plugin).ok_or_else(|| { + ConfigError(format!("plugins.shape.{name}: unknown bundled plugin")) + })?, + (Some(_), Some(_)) => { + return Err(ConfigError(format!( + "plugins.shape.{name}: bundled plugins cannot set path" + ))) + } + (None, Some(path)) => Folder::load(name, &base.join(path)) + .map_err(|error| ConfigError(format!("plugins.shape.{name}: {error}")))?, + (None, None) => { + return Err(ConfigError(format!( + "plugins.shape.{name}: custom plugins require path" + ))) + } }; let manifest = &folder.manifest; entry.enabled.get_or_insert(manifest.enabled_by_default()); + if entry.is_enabled() && !identities.insert(manifest.name.clone()) { + return Err(ConfigError(format!( + "plugins.shape.{name}: plugin {:?} is enabled more than once", + manifest.name + ))); + } manifest .validate(&entry.options) - .map_err(|error| ConfigError(format!("plugins.{reference}: {error}")))?; + .map_err(|error| ConfigError(format!("plugins.shape.{name}: {error}")))?; manifest.fill_defaults(&mut entry.options); entry.folder = Some(folder); } - let mut identities = BTreeSet::new(); - for entry in self.entries.values().filter(|entry| entry.is_enabled()) { - let name = &entry.folder().manifest.name; - if !identities.insert(name) { - return Err(ConfigError(format!( - "plugin {name:?} is enabled in both bundled and external namespaces" - ))); - } - } let mut seen = BTreeSet::new(); for name in &self.order { if !self.entries.contains_key(name) { return Err(ConfigError(format!( - "plugins.order: no plugin entry named {name:?}" + "plugins.shape.order: no plugin entry named {name:?}" ))); } if !seen.insert(name.as_str()) { return Err(ConfigError(format!( - "plugins.order: {name:?} is listed twice" + "plugins.shape.order: {name:?} is listed twice" ))); } } @@ -693,7 +789,7 @@ impl PluginsConfig { .find(|name| !seen.contains(name.as_str())) { return Err(ConfigError(format!( - "plugins.order: the plugin entry {missing:?} is not listed" + "plugins.shape.order: the plugin entry {missing:?} is not listed" ))); } Ok(()) @@ -702,16 +798,13 @@ impl PluginsConfig { /// Each enabled plugin's name and query files, in `order`. pub fn queries(&self) -> Result, ConfigError> { self.enabled() - .map(|(reference, entry)| { - let name = reference - .split_once('.') - .map_or(reference, |(_, name)| name); + .map(|(name, entry)| { Ok(( - name.to_owned(), + entry.folder().manifest.name.clone(), entry .folder() .queries() - .map_err(|error| ConfigError(format!("plugins.{reference}: {error}")))?, + .map_err(|error| ConfigError(format!("plugins.shape.{name}: {error}")))?, )) }) .collect() @@ -727,7 +820,7 @@ impl PluginsConfig { }) } - /// The `plugins` property of `diffr config schema`: `order`, and every + /// The `plugins.shape` property of `diffr config schema`: `order`, and every /// bundled plugin's entry. pub(crate) fn schema() -> Value { let mut properties = Map::new(); @@ -747,13 +840,18 @@ impl PluginsConfig { .collect(); properties.insert( "bundled".into(), - json!({"type": "object", "properties": bundled}), + json!({"type": "object", "properties": bundled, "additionalProperties": false}), ); - properties.insert("external".into(), json!({"type": "object", "x-settings": false, "additionalProperties": {"type": "object"}})); json!({ "type": "object", "description": "The plugins that decide what starts collapsed, hidden, linked or grouped, and the fold queries they own.", "properties": properties, + "additionalProperties": { + "type": "object", + "required": [PATH], + "properties": {PATH: {"type": "string"}}, + "x-settings": false, + }, }) } } @@ -765,18 +863,19 @@ mod tests { #[test] fn defaults_can_follow_another_option() { let summarize = |toml: &str| { - Config::from_toml(toml).unwrap().plugins.entries["bundled.summarize"] + Config::from_toml(toml).unwrap().plugins.shape.entries["bundled.summarize"] .options .clone() }; - let openai = summarize("[plugins.bundled.summarize]\nprovider = 'openai'\n"); + let openai = summarize("[plugins.shape.bundled.summarize]\nprovider = 'openai'\n"); assert_eq!(openai["model"], "gpt-6-luna"); assert_eq!( openai["provider_details"]["key_variables"], serde_json::json!(["OPENAI_API_KEY"]) ); assert_eq!(summarize("")["model"], "gemini-3.8-flash"); - let pinned = summarize("[plugins.bundled.summarize]\nprovider = 'openai'\nmodel = 'o9'\n"); + let pinned = + summarize("[plugins.shape.bundled.summarize]\nprovider = 'openai'\nmodel = 'o9'\n"); assert_eq!(pinned["model"], "o9"); } @@ -833,19 +932,6 @@ mod tests { assert!(mistyped.contains("kind = \"b\""), "{mistyped}"); } - #[test] - fn the_embedded_defaults_agree_with_each_plugin_toml() { - for (name, entry) in super::default_tables().bundled { - let defaults = super::builtin::manifest(&name) - .expect("a bundled plugin") - .defaults(); - for (key, value) in &entry.options { - if let Some(default) = defaults.get(key) { - assert_eq!(value, default, "{name}.{key}"); - } - } - } - } use serde_json::Value; /// Every setting the schema lists, as a settings screen flattens it: @@ -906,11 +992,7 @@ mod tests { fn every_plugin_setting_has_a_title_and_a_group_and_is_a_scalar() { let schema = Config::schema(); let settings = settings(&schema); - let plugins: Vec<_> = settings - .iter() - .filter(|(key, ..)| key.starts_with("plugins.")) - .collect(); - assert!(plugins.len() > 10, "{plugins:?}"); + assert!(!settings.is_empty()); for (key, title, group, kind) in &settings { assert!( !title.is_empty() && !group.is_empty(), @@ -921,62 +1003,34 @@ mod tests { "{key} is {kind}" ); } - let group = |key: &str| { - plugins - .iter() - .find(|(own, ..)| own == key) - .map(|(_, title, group, _)| (title.as_str(), group.as_str())) - .unwrap_or_else(|| panic!("no setting {key}")) - }; - assert_eq!( - group("plugins.bundled.deleted-bodies.enabled"), - ( - "Collapse deleted function bodies", - "Deleted function bodies" - ) - ); - assert_eq!( - group("plugins.bundled.summarize.model"), - ("Model", "Summaries") - ); - assert_eq!( - group("plugins.bundled.summarize.provider"), - ("Provider", "Summaries") - ); assert!(schema["properties"].get("languages").is_none()); - let plugins = &schema["properties"]["plugins"]["properties"]; + let plugins = &schema["properties"]["plugins"]["properties"]["shape"]["properties"]; assert_eq!(plugins["order"]["x-settings"], false); assert_eq!(plugins["order"]["type"], "array"); let summarize = &plugins["bundled"]["properties"]["summarize"]["properties"]; // A text setting, so settings screens let users edit the prompt. assert!(summarize["system_prompt"].get("x-settings").is_none()); - assert_eq!( - summarize["system_prompt"]["default"], - super::builtin::manifest("summarize").unwrap().options["system_prompt"]["default"] - ); - let keys: Vec<&String> = plugins.as_object().unwrap().keys().collect(); - assert_eq!(keys, ["order", "bundled", "external"]); } #[test] fn explicit_order_is_authoritative_and_names_every_entry_once() { - let config = Config::from_toml("[plugins]\norder = ['bundled.context']\n").unwrap(); - assert_eq!(config.plugins.entries.len(), 1); + let config = Config::from_toml("[plugins.shape]\norder = ['bundled.context']\n").unwrap(); + assert_eq!(config.plugins.shape.entries.len(), 1); for (text, message) in [ ( - "[plugins]\norder = ['bundled.context', 'bundled.context']", + "[plugins.shape]\norder = ['bundled.context', 'bundled.context']", "listed twice", ), - ("[plugins]\norder = ['external.mine']", "no plugin entry"), + ("[plugins.shape]\norder = ['mine']", "no plugin entry"), ( - "[plugins]\norder = []\n[plugins.bundled.context]", + "[plugins.shape]\norder = []\n[plugins.shape.bundled.context]", "is not listed", ), - ("[plugins.external.mine]", "external plugins require path"), - ("[plugins.bundled.unknown]", "unknown bundled plugin"), + ("[plugins.shape.mine]", "custom plugins require path"), + ("[plugins.shape.bundled.unknown]", "unknown bundled plugin"), ( - "[plugins.bundled.context]\npath = 'context'", - "bundled plugins cannot set path", + "[plugins.shape.bundled.context]\npath = 'context'", + "plugins.shape.bundled.context:", ), ("[plugins.context]", "unknown field"), ] { @@ -995,13 +1049,13 @@ mod tests { "name = 'mine'\ntitle = 'Mine'\n[options.depth]\ntype = 'integer'\ntitle = 'Depth'\ndefault = 2\n", ) .unwrap(); - let order = "order = ['bundled.context', 'bundled.deleted-bodies', 'bundled.test-bodies', 'bundled.removed-runs', 'bundled.summarize', 'external.mine']"; + let order = "order = ['bundled.context', 'bundled.deleted-bodies', 'bundled.test-bodies', 'bundled.removed-runs', 'bundled.summarize', 'mine']"; let config = Config::from_toml_in( - &format!("[plugins]\n{order}\n[plugins.external.mine]\npath = 'plugins/mine'\n"), + &format!("[plugins.shape]\n{order}\n[plugins.shape.mine]\npath = 'plugins/mine'\n"), dir.path(), ) .unwrap(); - let entry = &config.plugins.entries["external.mine"]; + let entry = &config.plugins.shape.entries["mine"]; assert_eq!(entry.options["depth"], 2); assert!(entry.options.get("path").is_none()); @@ -1012,48 +1066,140 @@ mod tests { .to_string() }; let renamed = error(&format!( - "[plugins]\n{}\n[plugins.external.other]\npath = 'plugins/mine'\n", - order.replace("'external.mine'", "'external.other'") + "[plugins.shape]\n{}\n[plugins.shape.other]\npath = 'plugins/mine'\n", + order.replace("'mine'", "'other'") )); assert!( - renamed.starts_with("plugins.external.other: ") + renamed.starts_with("plugins.shape.other: ") && renamed.ends_with("the plugin is named \"mine\", not \"other\""), "{renamed}" ); - let missing = error("[plugins.bundled.context]\npath = 'plugins/absent'\n"); - assert!( - missing.starts_with("plugins.bundled.context: "), - "{missing}" - ); + let missing = error("[plugins.shape.context]\npath = 'plugins/absent'\n"); + assert!(missing.starts_with("plugins.shape.context: "), "{missing}"); std::fs::write( folder.join("plugin.toml"), "name = 'mine'\ntitle = 'Mine'\n[options.path]\ntype = 'string'\ntitle = 'Path'\n", ) .unwrap(); let reserved = error(&format!( - "[plugins]\n{order}\n[plugins.external.mine]\npath = 'plugins/mine'\n" + "[plugins.shape]\n{order}\n[plugins.shape.mine]\npath = 'plugins/mine'\n" )); assert!(reserved.contains("which diffr owns"), "{reserved}"); } + #[test] + fn custom_and_bundled_entries_keep_separate_options_and_one_identity() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("plugin.toml"), + "name = 'context'\ntitle = 'Custom context'\n[options.depth]\ntype = 'integer'\ntitle = 'Depth'\ndefault = 7\n").unwrap(); + let text = "[plugins.shape]\norder = ['bundled.context', 'context']\n[plugins.shape.bundled.context]\nenabled = false\n[plugins.shape.context]\npath = '.'\n"; + let config = Config::from_toml_in(text, dir.path()).unwrap(); + assert_eq!(config.plugins.shape.entries["context"].options["depth"], 7); + assert!(!config.plugins.shape.entries["context"] + .options + .contains_key("lines")); + assert_eq!( + config.plugins.shape.entries["bundled.context"].options["lines"], + 3 + ); + assert_eq!(config.plugins.shape.queries().unwrap()[0].0, "context"); + let error = Config::from_toml_in( + &text.replace("enabled = false", "enabled = true"), + dir.path(), + ) + .unwrap_err(); + assert!(error.to_string().contains("enabled more than once")); + } + + #[test] + fn custom_names_with_dots_keep_their_identity_through_a_round_trip() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("plugin.toml"), + "name = 'my.context'\ntitle = 'Custom'\n", + ) + .unwrap(); + let config = Config::from_toml_in( + "[plugins.shape]\norder = ['my.context']\n[plugins.shape.\"my.context\"]\npath = '.'\n", + dir.path(), + ) + .unwrap(); + assert_eq!(config.plugins.shape.queries().unwrap()[0].0, "my.context"); + let text = toml::to_string(&config).unwrap(); + let config = Config::from_toml_in(&text, dir.path()).unwrap(); + assert_eq!( + config.plugins.shape.enabled().next().unwrap().0, + "my.context" + ); + assert_eq!(config.plugins.shape.queries().unwrap()[0].0, "my.context"); + } + + #[test] + fn stock_selection_cannot_load_custom_folders() { + for text in [ + "[plugins.shape.bundled.context]\npath = '.'", + "[plugins.classify.bundled]\npath = '.'", + ] { + assert!(Config::from_toml(text) + .unwrap_err() + .to_string() + .contains("cannot set path")); + } + let validator = jsonschema::validator_for(&Config::schema()).unwrap(); + assert!(validator.is_valid(&serde_json::to_value(Config::default()).unwrap())); + for text in [ + "[plugins.shape.custom]\npath = '.'", + "[plugins.classify.custom]\npath = '.'", + ] { + let parsed: toml::Value = toml::from_str(text).unwrap(); + assert!(validator.is_valid(&serde_json::to_value(parsed).unwrap())); + } + for text in [ + "[plugins.shape.bundled.context]\npath = '.'", + "[plugins.classify.bundled]\npath = '.'", + "[plugins.shape.custom]", + "[plugins.classify.custom]", + ] { + let parsed: toml::Value = toml::from_str(text).unwrap(); + assert!(!validator.is_valid(&serde_json::to_value(parsed).unwrap())); + } + assert!( + Config::from_toml("[plugins.shape.\"bundled.context\"]\npath = '.'") + .unwrap_err() + .to_string() + .contains("reserved plugin reference") + ); + } + + #[test] + fn exactly_one_classifier_runs() { + for text in [ + "[plugins.classify]", + "[plugins.classify.bundled]\n[plugins.classify.other]", + ] { + let error = Config::from_toml(text).unwrap_err().to_string(); + assert!(error.contains("expected exactly one classifier"), "{error}"); + } + } + #[test] fn options_are_checked_against_the_plugin_toml_and_filled_with_its_defaults() { let config = Config::from_toml( - "[plugins.bundled.deleted-bodies]\nmin_lines = 30\n[plugins.bundled.removed-runs]\nenabled = false\n[classifier]\nhide_deleted = false\n", + "[plugins.shape.bundled.deleted-bodies]\nmin_lines = 30\n[plugins.shape.bundled.removed-runs]\nenabled = false\n[plugins.classify.bundled]\nhide_deleted = false\n", ) .unwrap(); - let deleted = &config.plugins.entries["bundled.deleted-bodies"]; + let deleted = &config.plugins.shape.entries["bundled.deleted-bodies"]; assert_eq!(deleted.enabled, Some(true)); assert_eq!(deleted.options["min_lines"], 30); - let removed = &config.plugins.entries["bundled.removed-runs"]; + let removed = &config.plugins.shape.entries["bundled.removed-runs"]; assert_eq!(removed.enabled, Some(false)); - let classifier = &config.classifier.options; + let classifier = &config.plugins.classify.options; assert_eq!(classifier["hide_deleted"], false); assert_eq!( classifier["hide"], serde_json::json!(["generated", "vendored"]) ); - let summarize = &config.plugins.entries["bundled.summarize"]; + let summarize = &config.plugins.shape.entries["bundled.summarize"]; assert_eq!( summarize.enabled, Some(false), @@ -1062,23 +1208,25 @@ mod tests { assert!(summarize.options.get("api_key").is_none()); assert_eq!(summarize.options["request_timeout_ms"], 60_000); let error = |toml: &str| Config::from_toml(toml).err().unwrap().to_string(); - let typo = error("[plugins.bundled.deleted-bodies]\ntypo = 1\n"); + let typo = error("[plugins.shape.bundled.deleted-bodies]\ntypo = 1\n"); assert!( - typo.starts_with("plugins.bundled.deleted-bodies: ") && typo.contains("typo"), + typo.starts_with("plugins.shape.bundled.deleted-bodies: ") && typo.contains("typo"), "{typo}" ); - let mistyped = error("[plugins.bundled.deleted-bodies]\nmin_lines = 'many'\n"); + let mistyped = error("[plugins.shape.bundled.deleted-bodies]\nmin_lines = 'many'\n"); assert!( - mistyped.starts_with("plugins.bundled.deleted-bodies: min_lines: "), + mistyped.starts_with("plugins.shape.bundled.deleted-bodies: min_lines: "), "{mistyped}" ); - let zero = error("[plugins.bundled.summarize]\nrequest_timeout_ms = 0\n"); + let zero = error("[plugins.shape.bundled.summarize]\nrequest_timeout_ms = 0\n"); assert!( - zero.starts_with("plugins.bundled.summarize: request_timeout_ms: "), + zero.starts_with("plugins.shape.bundled.summarize: request_timeout_ms: "), "{zero}" ); - assert!(error("[plugins.bundled.summarize]\nprovider = 'mistral'\n") - .starts_with("plugins.bundled.summarize: provider: ")); + assert!( + error("[plugins.shape.bundled.summarize]\nprovider = 'mistral'\n") + .starts_with("plugins.shape.bundled.summarize: provider: ") + ); } } @@ -1090,9 +1238,12 @@ mod concurrency_tests { fn obsolete_instance_and_concurrency_options_are_rejected() { for name in ["context", "summarize"] { assert!( - Config::from_toml(&format!("[plugins.bundled.{name}]\ninstances = 1\n")).is_err() + Config::from_toml(&format!("[plugins.shape.bundled.{name}]\ninstances = 1\n")) + .is_err() ); } - assert!(Config::from_toml("[plugins.bundled.summarize]\nmax_concurrency = 16\n").is_err()); + assert!( + Config::from_toml("[plugins.shape.bundled.summarize]\nmax_concurrency = 16\n").is_err() + ); } } diff --git a/crates/diffr-core/src/plugin/queries.rs b/crates/diffr-core/src/plugin/queries.rs index d6907d9c9..1fe922bb3 100644 --- a/crates/diffr-core/src/plugin/queries.rs +++ b/crates/diffr-core/src/plugin/queries.rs @@ -254,7 +254,7 @@ mod tests { #[test] fn every_bundled_query_resolves_and_compiles() { let config = Config::default(); - let assembled = assemble(&config.plugins.queries().unwrap()).unwrap(); + let assembled = assemble(&config.plugins.shape.queries().unwrap()).unwrap(); assert_eq!( assembled.keys().map(String::as_str).collect::>(), [ @@ -288,8 +288,8 @@ mod tests { #[test] fn disabled_plugins_contribute_no_queries() { let config = - Config::from_toml("[plugins.bundled.deleted-bodies]\nenabled = false\n").unwrap(); - let assembled = assemble(&config.plugins.queries().unwrap()).unwrap(); + Config::from_toml("[plugins.shape.bundled.deleted-bodies]\nenabled = false\n").unwrap(); + let assembled = assemble(&config.plugins.shape.queries().unwrap()).unwrap(); assert!(!assembled["rust"] .iter() .any(|s| s.name.contains("deleted-bodies"))); diff --git a/plugins/shape/summarize/plugin.wasm b/plugins/shape/summarize/plugin.wasm index 5bd94436391d30b2bf8085dea7be0303b6326f71..eef197567be2864b72fc2c3d2b2cff27bb57b767 100644 GIT binary patch delta 42695 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 Key { return Key::NotRequired; } eprintln!( - "summarize: off for this run: no API key: set plugins.bundled.summarize.api_key, or {} in the environment, or turn the summarizer off with plugins.bundled.summarize.enabled = false", + "summarize: off for this run: no API key: set plugins.shape.bundled.summarize.api_key, or {} in the environment, or turn the summarizer off with plugins.shape.bundled.summarize.enabled = false", variables.join(" or ") ); Key::Missing diff --git a/pprint.sh b/pprint.sh index 031e1ca08..0d4fc75ce 100755 --- a/pprint.sh +++ b/pprint.sh @@ -36,7 +36,7 @@ if [ -n "$mock" ]; then cat > "$work/config/diffr/config.toml" <, /// Write the event stream to stdout instead of opening the terminal UI @@ -599,7 +599,7 @@ impl Cli { fn config(&self) -> Result { let mut config = Config::load()?; if let Some(unified) = self.unified { - if let Some(entry) = config.plugins.entries.get_mut("bundled.context") { + if let Some(entry) = config.plugins.shape.entries.get_mut("bundled.context") { entry .options .insert("lines".into(), serde_json::Value::from(unified)); diff --git a/src/plugin/classify.rs b/src/plugin/classify.rs index ce3582422..0019f9e05 100644 --- a/src/plugin/classify.rs +++ b/src/plugin/classify.rs @@ -31,12 +31,12 @@ impl Classifier { let pre = DiffrClassifierPre::new(link( &engine, &linker, - &config.classifier.folder().component(), + &config.plugins.classify.folder().component(), )?) .map_err(anyhow::Error::from) .context("not a classifier: it must export diffr:plugin/classify") .context("classifier")?; - let options = Value::Object(config.classifier.options.clone()).to_string(); + let options = Value::Object(config.plugins.classify.options.clone()).to_string(); let runtime = tokio::runtime::Builder::new_current_thread() .enable_all() .build()?; diff --git a/src/plugin/tests/context.rs b/src/plugin/tests/context.rs index cf41a366a..971a87698 100644 --- a/src/plugin/tests/context.rs +++ b/src/plugin/tests/context.rs @@ -115,6 +115,7 @@ fn the_enclosing_header_stays_open_above_a_deep_change() { let config = Config::default(); let queries = config .plugins + .shape .queries() .unwrap() .into_iter() diff --git a/src/plugin/tests/mod.rs b/src/plugin/tests/mod.rs index de2e88961..68edb3901 100644 --- a/src/plugin/tests/mod.rs +++ b/src/plugin/tests/mod.rs @@ -111,7 +111,7 @@ pub(crate) fn rhs(sides: &Pairing) -> &Source { /// `overrides` the way a settings file would. pub(crate) fn bundled(name: &str, overrides: serde_json::Value) -> Pipeline { configured(&format!( - "[plugins]\norder = ['bundled.{name}']\n[plugins.bundled.{name}]\nenabled = true\n{}", + "[plugins.shape]\norder = ['bundled.{name}']\n[plugins.shape.bundled.{name}]\nenabled = true\n{}", options(overrides) )) .unwrap() @@ -243,19 +243,9 @@ fn go_and_javascript_comment_runs_document_functions() { } #[test] -fn the_default_pipeline_makes_every_plugin_that_is_on() { +fn the_default_pipeline_can_be_created() { let config = Config::default(); Pipeline::from_config(&config, Path::new("."), NonZeroUsize::MIN).unwrap(); - let made: Vec<&str> = config - .plugins - .enabled() - .map(|(reference, _)| reference.trim_start_matches("bundled.")) - .collect(); - assert_eq!( - made, - ["deleted-bodies", "test-bodies", "removed-runs", "context"], - "the summarizer is off until turned on" - ); } /// The manifest can accept an option the component itself rejects: the @@ -274,13 +264,13 @@ fn options_that_do_not_deserialize_are_a_setup_error() { ) .unwrap(); let error = configured(&format!( - "[plugins]\norder = ['external.context']\n[plugins.external.context]\npath = {:?}\nextra = 1\n", + "[plugins.shape]\norder = ['context']\n[plugins.shape.context]\npath = {:?}\nextra = 1\n", dir.path() )) .err() .expect("unknown option rejected"); let error = format!("{error:#}"); - assert!(error.contains("plugins.external.context"), "{error}"); + assert!(error.contains("plugins.shape.context"), "{error}"); assert!(error.contains("unknown field `extra`"), "{error}"); } @@ -293,7 +283,7 @@ fn external_plugins_require_a_component() { ) .unwrap(); let config = Config::from_toml_in( - "[plugins]\norder = ['external.context']\n[plugins.external.context]\npath = '.'\n", + "[plugins.shape]\norder = ['context']\n[plugins.shape.context]\npath = '.'\n", dir.path(), ) .unwrap(); @@ -302,12 +292,12 @@ fn external_plugins_require_a_component() { .unwrap(); let error = format!("{error:#}"); assert!(error.contains("plugin.wasm"), "{error}"); - assert!(error.contains("plugins.external.context"), "{error}"); + assert!(error.contains("plugins.shape.context"), "{error}"); } #[test] fn a_subset_of_bundled_plugins_can_use_shared_query_tags() { - Config::from_toml("[plugins]\norder = ['bundled.deleted-bodies']\n") + Config::from_toml("[plugins.shape]\norder = ['bundled.deleted-bodies']\n") .unwrap() .compile() .unwrap(); @@ -328,7 +318,7 @@ fn documentation_relationship_comes_from_query_captures_not_distance() { fn a_plugin_configured_as_the_wrong_kind_is_a_setup_error() { let root = Path::new(env!("CARGO_MANIFEST_DIR")); let config = Config::from_toml(&format!( - "[classifier]\npath = {:?}\n", + "[plugins.classify.context]\npath = {:?}\n", root.join("plugins/shape/context") )) .unwrap(); @@ -340,7 +330,7 @@ fn a_plugin_configured_as_the_wrong_kind_is_a_setup_error() { "{error:#}" ); let error = configured(&format!( - "[plugins]\norder = ['external.classify']\n[plugins.external.classify]\npath = {:?}\n", + "[plugins.shape]\norder = ['classify']\n[plugins.shape.classify]\npath = {:?}\n", root.join("plugins/classify") )) .err() diff --git a/src/plugin/tests/summarize.rs b/src/plugin/tests/summarize.rs index 3acb218cd..6a2ee4c03 100644 --- a/src/plugin/tests/summarize.rs +++ b/src/plugin/tests/summarize.rs @@ -16,7 +16,7 @@ fn project_with( options: DiffOptions, ) -> (FileChange, Pairing) { let params = - Config::from_toml("[plugins.bundled.summarize]\nenabled = true\napi_key = 'test'\n") + Config::from_toml("[plugins.shape.bundled.summarize]\nenabled = true\napi_key = 'test'\n") .unwrap() .compile() .unwrap(); diff --git a/src/plugin/wasm.rs b/src/plugin/wasm.rs index d2dee5148..a04cdd784 100644 --- a/src/plugin/wasm.rs +++ b/src/plugin/wasm.rs @@ -432,7 +432,6 @@ fn cat_file(repo: &gix::Repository, object: &str) -> anyhow::Result> { /// every worker. struct Component { name: Arc, - /// How configuration names it, such as `bundled.context`. reference: String, pre: DiffrPluginPre, /// JSON for the plugin's constructor. @@ -488,10 +487,11 @@ impl Pipeline { let linker = linker(&engine)?; let plugins = config .plugins + .shape .enabled() - .map(|(reference, entry)| { - compile(&engine, &linker, reference, entry) - .with_context(|| format!("plugins.{reference}")) + .map(|(name, entry)| { + compile(&engine, &linker, name, entry) + .with_context(|| format!("plugins.shape.{name}")) }) .collect::>>()?; let plugins = Arc::new(plugins); @@ -603,18 +603,15 @@ pub(super) fn link( fn compile( engine: &Engine, linker: &Linker, - reference: &str, + name: &str, entry: &Entry, ) -> anyhow::Result { let pre = DiffrPluginPre::new(link(engine, linker, &entry.folder().component())?) .map_err(anyhow::Error::from) .context("not a shape plugin: it must export diffr:plugin/api")?; - let name = reference - .split_once('.') - .map_or(reference, |(_, name)| name); Ok(Component { - name: name.into(), - reference: reference.to_owned(), + name: entry.folder().name().into(), + reference: name.to_owned(), pre, options: Value::Object(entry.options.clone()).to_string(), }) @@ -692,7 +689,7 @@ impl Worker { .call_constructor(accessor, component.options.clone()) .await? .map_err(anyhow::Error::msg) - .with_context(|| format!("plugins.{}", component.reference))?; + .with_context(|| format!("plugins.shape.{}", component.reference))?; instances.push(Instance { name: component.name.clone(), exports, diff --git a/tests/cli.rs b/tests/cli.rs index 64fdeb55c..088abed62 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -75,7 +75,11 @@ fn a_summarizer_without_a_key_still_diffs() { let dir = tempfile::tempdir().unwrap(); let config = dir.path().join("diffr/config.toml"); std::fs::create_dir_all(config.parent().unwrap()).unwrap(); - std::fs::write(&config, "[plugins.bundled.summarize]\nenabled = true\n").unwrap(); + std::fs::write( + &config, + "[plugins.shape.bundled.summarize]\nenabled = true\n", + ) + .unwrap(); let mut cmd = get_base_command(); cmd.args([ diff --git a/tests/tags.rs b/tests/tags.rs index 362ffb51c..6f01af60a 100644 --- a/tests/tags.rs +++ b/tests/tags.rs @@ -407,7 +407,7 @@ fn hidden_files_are_diffed_by_line_and_shown_behind_their_reason() { assert_eq!(visibility_of(&defaults, "src/a.rs"), None); assert_eq!(fallback_of(&defaults, "src/a.rs"), None); - fixture.config("[classifier]\nhide = []\nhide_deleted = false\n"); + fixture.config("[plugins.classify.bundled]\nhide = []\nhide_deleted = false\n"); let shown = records(&fixture.run(&base, &head)); for path in ["vendor/lib/a.rs", "tests/a.rs", "src/gone.rs"] { assert_eq!(visibility_of(&shown, path), None, "{path}"); diff --git a/tests/view.rs b/tests/view.rs index bfbf79c64..f597cd109 100644 --- a/tests/view.rs +++ b/tests/view.rs @@ -158,7 +158,7 @@ fn a_failing_plugin_aborts_the_run() { let fixture = Fixture::new(); fixture.config_file( "config.toml", - "[plugins.bundled.summarize]\nenabled = true\napi_key = 'k'\nendpoint = 'http://127.0.0.1:1'\nretries = 0\nmin_lines = 1\n", + "[plugins.shape.bundled.summarize]\nenabled = true\napi_key = 'k'\nendpoint = 'http://127.0.0.1:1'\nretries = 0\nmin_lines = 1\n", ); fixture.write("keep.txt", "keep\n"); let base = fixture.commit(); diff --git a/tests/wasm.rs b/tests/wasm.rs index fed312b55..4109be262 100644 --- a/tests/wasm.rs +++ b/tests/wasm.rs @@ -197,7 +197,7 @@ fn bundled_and_external_components_produce_identical_files() { ); let head = fixture.commit("head\n"); - let options = "[plugins.bundled.deleted-bodies]\nmin_lines = 3\n[plugins.bundled.test-bodies]\nmin_lines = 2\n"; + let options = "[plugins.shape.bundled.deleted-bodies]\nmin_lines = 3\n[plugins.shape.bundled.test-bodies]\nmin_lines = 2\n"; let bundled = fixture.config("bundled", options); let plugin = |name: &str| { root() @@ -210,7 +210,7 @@ fn bundled_and_external_components_produce_identical_files() { let wasm = fixture.config( "wasm", &format!( - "[classifier]\npath = {:?}\n[plugins]\norder = ['external.deleted-bodies', 'external.test-bodies', 'external.removed-runs', 'external.context']\n[plugins.external.context]\npath = {:?}\n[plugins.external.deleted-bodies]\npath = {:?}\nmin_lines = 3\n[plugins.external.test-bodies]\npath = {:?}\nmin_lines = 2\n[plugins.external.removed-runs]\npath = {:?}\n", + "[plugins.classify.classify]\npath = {:?}\n[plugins.shape]\norder = ['deleted-bodies', 'test-bodies', 'removed-runs', 'context']\n[plugins.shape.context]\npath = {:?}\n[plugins.shape.deleted-bodies]\npath = {:?}\nmin_lines = 3\n[plugins.shape.test-bodies]\npath = {:?}\nmin_lines = 2\n[plugins.shape.removed-runs]\npath = {:?}\n", root().join("plugins/classify").display().to_string(), plugin("context"), plugin("deleted-bodies"), diff --git a/tests/wasm_concurrency.rs b/tests/wasm_concurrency.rs index 05fb5bbbf..9439b86ff 100644 --- a/tests/wasm_concurrency.rs +++ b/tests/wasm_concurrency.rs @@ -125,7 +125,10 @@ async fn run_case(outcome: &'static str) -> Result<()> { plugin.join("plugin.toml"), "name='probe'\ntitle='Probe'\n[options.endpoint]\ntype='string'\ntitle='Endpoint'\n", )?; - let config = format!("[plugins]\norder=['external.probe']\n[plugins.external.probe]\npath={:?}\nendpoint={endpoint:?}\n", plugin.to_string_lossy()); + let config = format!( + "[plugins.shape]\norder=['probe']\n[plugins.shape.probe]\npath={:?}\nendpoint={endpoint:?}\n", + plugin.to_string_lossy() + ); std::fs::write(config_home.join("diffr/config.toml"), config)?; let mut child = tokio::process::Command::new(env!("CARGO_BIN_EXE_diffr")) .args(["--repo"]) @@ -245,7 +248,7 @@ fn two_workers_compute_two_files_at_once() -> Result<()> { std::fs::write( config_home.join("diffr/config.toml"), format!( - "[plugins]\norder=['external.probe']\n[plugins.external.probe]\npath={:?}\nendpoint='http://unused'\nspin_ms=1000\n", + "[plugins.shape]\norder=['probe']\n[plugins.shape.probe]\npath={:?}\nendpoint='http://unused'\nspin_ms=1000\n", plugin.to_string_lossy() ), )?; From 3a1b6d5cfbf24c7e7be89f20c7b0459cdbb2766d Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Tue, 6 Oct 2026 18:30:14 -0700 Subject: [PATCH 4/6] Use config v2 keys in TUI settings fixtures AI assistance: implemented with OpenAI Codex; cleaned up with Claude Code and OpenAI Codex. Agent-Session: 79e2268e-5c55-4f1c-a7b9-f2a6d613fe4f --- tui/packages/hunk/src/diffr/config.test.ts | 111 ++++++++++++--------- tui/packages/hunk/src/ui/Settings.test.tsx | 36 +++---- 2 files changed, 81 insertions(+), 66 deletions(-) diff --git a/tui/packages/hunk/src/diffr/config.test.ts b/tui/packages/hunk/src/diffr/config.test.ts index 8fdacb72c..d7a2590cd 100644 --- a/tui/packages/hunk/src/diffr/config.test.ts +++ b/tui/packages/hunk/src/diffr/config.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { filterSettings, flattenSchema, fuzzyScore, isDefault, parseValue } from "./config"; -/** The shape of `diffr config schema`: `plugins` is inline, one entry per plugin; lists and multi-line +/** The shape of `diffr config schema`: `plugins.shape.bundled` contains one entry per stock plugin; lists and multi-line * values are marked `x-settings: false`. */ export const schemaFixture = { $schema: "https://json-schema.org/draft/2020-12/schema", @@ -10,35 +10,48 @@ export const schemaFixture = { plugins: { type: "object", properties: { - order: { type: "array", items: { type: "string" }, "x-settings": false, default: ["bundled.deleted-bodies", "bundled.hide-files", "bundled.summarize"] }, - bundled: { type: "object", properties: { - "deleted-bodies": { + shape: { type: "object", - title: "Collapsed code", properties: { - min_lines: { type: "integer", title: "Shortest body to collapse", "x-group": "Collapsed code", description: "Bodies shorter than this are never summarized or collapsed.", default: 12 }, - enabled: { type: "boolean", title: "Collapse deleted functions", "x-group": "Collapsed code", description: "Collapse deleted function bodies.", default: true }, + order: { type: "array", items: { type: "string" }, "x-settings": false, default: ["bundled.deleted-bodies", "bundled.summarize"] }, + bundled: { + type: "object", + properties: { + "deleted-bodies": { + type: "object", + title: "Collapsed code", + properties: { + min_lines: { type: "integer", title: "Shortest body to collapse", "x-group": "Collapsed code", description: "Bodies shorter than this are never summarized or collapsed.", default: 12 }, + enabled: { type: "boolean", title: "Collapse deleted functions", "x-group": "Collapsed code", description: "Collapse deleted function bodies.", default: true }, + }, + }, + summarize: { + type: "object", + title: "Summaries", + properties: { + provider: { type: "string", title: "Provider", "x-group": "Summaries", enum: ["gemini", "none"], description: "Model provider.", default: "gemini" }, + api_key: { title: "API key", "x-group": "Summaries", anyOf: [{ type: "string" }, { type: "null" }], description: "API key for the provider.", default: null }, + model: { $ref: "#/$defs/Model", title: "Model", "x-group": "Summaries" }, + system_prompt: { type: "string", title: "System prompt", "x-group": "Summaries", "x-settings": false, description: "The system instruction.", default: "Summarize." }, + }, + }, + }, + }, }, }, - "hide-files": { + classify: { type: "object", - title: "Hidden files", properties: { - enabled: { type: "boolean", title: "Hide test files", "x-group": "Hidden files", description: "Hide files tagged test.", default: true }, - tags: { type: "array", items: { type: "string" }, "x-group": "Hidden files", "x-settings": false, default: ["test"] }, + bundled: { + type: "object", + title: "Hidden files", + properties: { + hide_deleted: { type: "boolean", title: "Hide deleted files", "x-group": "Hidden files", description: "Hide deleted files.", default: true }, + hide: { type: "array", items: { type: "string" }, "x-group": "Hidden files", "x-settings": false, default: ["generated", "vendored"] }, + }, + }, }, }, - summarize: { - type: "object", - title: "Summaries", - properties: { - provider: { type: "string", title: "Provider", "x-group": "Summaries", enum: ["gemini", "none"], description: "Model provider.", default: "gemini" }, - api_key: { title: "API key", "x-group": "Summaries", anyOf: [{ type: "string" }, { type: "null" }], description: "API key for the provider.", default: null }, - model: { $ref: "#/$defs/Model", title: "Model", "x-group": "Summaries" }, - system_prompt: { type: "string", title: "System prompt", "x-group": "Summaries", "x-settings": false, description: "The system instruction.", default: "Summarize." }, - }, - }, - } }, }, }, }, @@ -48,62 +61,64 @@ export const schemaFixture = { }; export const valuesFixture = { plugins: { - order: ["bundled.deleted-bodies", "bundled.hide-files", "bundled.summarize"], - bundled: { - "deleted-bodies": { min_lines: 12, enabled: true }, - "hide-files": { enabled: false, tags: ["test"] }, - summarize: { provider: "gemini", api_key: null, model: "gemini-2.5-flash", system_prompt: "Summarize." }, + shape: { + order: ["bundled.deleted-bodies", "bundled.summarize"], + bundled: { + "deleted-bodies": { min_lines: 12, enabled: true }, + summarize: { provider: "gemini", api_key: null, model: "gemini-2.5-flash", system_prompt: "Summarize." }, + }, }, + classify: { bundled: { hide_deleted: false, hide: ["test"] } }, }, }; test("schema flattens to dotted keys with descriptions, defaults, and current values", () => { const settings = flattenSchema(schemaFixture, valuesFixture); expect(settings.map((s) => [s.key, s.type, s.default, s.value])).toEqual([ - ["plugins.bundled.deleted-bodies.min_lines", "integer", 12, 12], - ["plugins.bundled.deleted-bodies.enabled", "boolean", true, true], - ["plugins.bundled.hide-files.enabled", "boolean", true, false], - ["plugins.bundled.summarize.provider", "enum", "gemini", "gemini"], - ["plugins.bundled.summarize.api_key", "string", null, null], - ["plugins.bundled.summarize.model", "string", "gemini-2.5-flash", "gemini-2.5-flash"], + ["plugins.shape.bundled.deleted-bodies.min_lines", "integer", 12, 12], + ["plugins.shape.bundled.deleted-bodies.enabled", "boolean", true, true], + ["plugins.shape.bundled.summarize.provider", "enum", "gemini", "gemini"], + ["plugins.shape.bundled.summarize.api_key", "string", null, null], + ["plugins.shape.bundled.summarize.model", "string", "gemini-2.5-flash", "gemini-2.5-flash"], + ["plugins.classify.bundled.hide_deleted", "boolean", true, false], ]); expect(settings.map((s) => [s.title, s.group])).toEqual([ ["Shortest body to collapse", "Collapsed code"], ["Collapse deleted functions", "Collapsed code"], - ["Hide test files", "Hidden files"], ["Provider", "Summaries"], ["API key", "Summaries"], ["Model", "Summaries"], + ["Hide deleted files", "Hidden files"], ]); - expect(settings[3].options).toEqual(["gemini", "none"]); - expect(settings.map(isDefault)).toEqual([true, true, false, true, true, true]); + expect(settings[2].options).toEqual(["gemini", "none"]); + expect(settings.map(isDefault)).toEqual([true, true, true, true, true, false]); }); test("a setting without a title or group is a schema error", () => { const schema = structuredClone(schemaFixture); - delete (schema.properties.plugins.properties.bundled.properties["deleted-bodies"].properties.min_lines as Record).title; - expect(() => flattenSchema(schema, valuesFixture)).toThrow("plugins.bundled.deleted-bodies.min_lines"); + delete (schema.properties.plugins.properties.shape.properties.bundled.properties["deleted-bodies"].properties.min_lines as Record).title; + expect(() => flattenSchema(schema, valuesFixture)).toThrow("plugins.shape.bundled.deleted-bodies.min_lines"); }); test("keys marked x-settings: false are left to the file; any other list or table is a schema error", () => { const keys = flattenSchema(schemaFixture, valuesFixture).map((s) => s.key); - expect(keys.some((key) => key.includes("order") || key.includes("queries") || key.includes("tags"))).toBe(false); + expect(keys.some((key) => key.includes("order") || key.includes("queries") || key === "plugins.classify.bundled.hide")).toBe(false); const schema = structuredClone(schemaFixture); - delete (schema.properties.plugins.properties.bundled.properties["hide-files"].properties.tags as Record)["x-settings"]; - expect(() => flattenSchema(schema, valuesFixture)).toThrow("Setting plugins.bundled.hide-files.tags has unsupported type array"); + delete (schema.properties.plugins.properties.classify.properties.bundled.properties.hide as Record)["x-settings"]; + expect(() => flattenSchema(schema, valuesFixture)).toThrow("Setting plugins.classify.bundled.hide has unsupported type array"); }); test("fuzzy filtering narrows over title, key, group and description, keeping groups together", () => { const settings = flattenSchema(schemaFixture, valuesFixture); - expect(fuzzyScore("hfenabled", "plugins.bundled.hide-files.enabled")).not.toBeNull(); - expect(fuzzyScore("xyz", "plugins.bundled.hide-files.enabled")).toBeNull(); + expect(fuzzyScore("cbhide", "plugins.classify.bundled.hide_deleted")).not.toBeNull(); + expect(fuzzyScore("xyz", "plugins.classify.bundled.hide_deleted")).toBeNull(); // An empty query keeps schema order, which is already grouped. expect(filterSettings(settings, "").map((s) => s.key)).toEqual(settings.map((s) => s.key)); - expect(filterSettings(settings, "hide test").map((s) => s.key)).toEqual(["plugins.bundled.hide-files.enabled"]); - expect(filterSettings(settings, "api")[0].key).toBe("plugins.bundled.summarize.api_key"); + expect(filterSettings(settings, "hide deleted").map((s) => s.key)).toEqual(["plugins.classify.bundled.hide_deleted"]); + expect(filterSettings(settings, "api")[0].key).toBe("plugins.shape.bundled.summarize.api_key"); // A description mention ("... or collapsed.") still finds the setting. - expect(filterSettings(settings, "shorter").map((s) => s.key)).toEqual(["plugins.bundled.deleted-bodies.min_lines"]); + expect(filterSettings(settings, "shorter").map((s) => s.key)).toEqual(["plugins.shape.bundled.deleted-bodies.min_lines"]); // Matching a group name lists the group in schema order. - expect(filterSettings(settings, "summaries").map((s) => s.key)).toEqual(["plugins.bundled.summarize.provider", "plugins.bundled.summarize.api_key", "plugins.bundled.summarize.model"]); + expect(filterSettings(settings, "summaries").map((s) => s.key)).toEqual(["plugins.shape.bundled.summarize.provider", "plugins.shape.bundled.summarize.api_key", "plugins.shape.bundled.summarize.model"]); }); test("edited values are parsed in the setting's type and bad input is rejected", () => { - const [minLines, collapse, , provider] = flattenSchema(schemaFixture, valuesFixture); + const [minLines, collapse, provider] = flattenSchema(schemaFixture, valuesFixture); expect(parseValue(minLines, "20")).toBe(20); expect(() => parseValue(minLines, "2.5")).toThrow("integer"); expect(parseValue(collapse, "false")).toBe(false); diff --git a/tui/packages/hunk/src/ui/Settings.test.tsx b/tui/packages/hunk/src/ui/Settings.test.tsx index f5daf9cfa..d406a0dc0 100644 --- a/tui/packages/hunk/src/ui/Settings.test.tsx +++ b/tui/packages/hunk/src/ui/Settings.test.tsx @@ -6,13 +6,13 @@ import { schemaFixture, valuesFixture } from "../diffr/config.test"; import { flattenSchema, parseValue, type ConfigClient } from "../diffr/config"; test("values show as not set, secrets only as stored, and toggles flip or cycle", () => { - const [minLines, collapse, , provider, apiKey] = flattenSchema(schemaFixture, valuesFixture); + const [minLines, collapse, provider, apiKey] = flattenSchema(schemaFixture, valuesFixture); expect(displayValue(minLines)).toBe("12"); expect(displayValue({ ...minLines, value: null })).toBe("not set"); expect(displayValue(apiKey)).toBe("not set"); expect(displayValue({ ...apiKey, value: "abc" })).toBe("✓ stored"); - expect(isSecret("plugins.bundled.summarize.api_key")).toBe(true); - expect(isSecret("plugins.bundled.deleted-bodies.min_lines")).toBe(false); + expect(isSecret("plugins.shape.bundled.summarize.api_key")).toBe(true); + expect(isSecret("plugins.shape.bundled.deleted-bodies.min_lines")).toBe(false); expect(nextValue(collapse)).toBe("false"); expect(nextValue(provider)).toBe("none"); expect(nextValue({ ...provider, value: "none" })).toBe("gemini"); @@ -59,32 +59,32 @@ test("rows lead with titles under group headings; toggles change in place and ty try { await act(async () => { await t.renderOnce(); }); // Groups in schema order, each setting under its heading, titles first and keys only in the detail line. - const order = ["Collapsed code", "Shortest body to collapse", "Collapse deleted functions", "Hidden files", "Hide test files", "Summaries", "Provider"] + const order = ["Collapsed code", "Shortest body to collapse", "Collapse deleted functions", "Summaries", "Provider", "Hidden files", "Hide deleted files"] .map((text) => frame().split("\n").findIndex((l) => l.includes(text))); expect(order).toEqual([...order].sort((a, b) => a - b)); expect(order.every((index) => index >= 0)).toBe(true); expect(line("Shortest body to collapse")).toContain("→"); - expect(line("Shortest body to collapse")).not.toContain("plugins.bundled.deleted-bodies.min_lines"); - expect(frame()).toContain("plugins.bundled.deleted-bodies.min_lines · default 12"); + expect(line("Shortest body to collapse")).not.toContain("plugins.shape.bundled.deleted-bodies.min_lines"); + expect(frame()).toContain("plugins.shape.bundled.deleted-bodies.min_lines · default 12"); expect(frame()).toContain("Bodies shorter than this are never summarized or collapsed."); expect(frame()).toContain("(1/6)"); expect(frame()).toContain("Type to search · Enter/Space to change · Esc to quit"); expect(line("API key")).toContain("not set"); // Boolean: space flips it in place and writes through the CLI. - await type("hidetest"); + await type("hidedeleted"); expect(frame()).toContain("(1/1)"); - expect(line("Hide test files")).toContain("false"); + expect(line("Hide deleted files")).toContain("false"); await press(" "); - expect(writes).toEqual([["plugins.bundled.hide-files.enabled", "true"]]); - expect(line("Hide test files")).toContain("true"); - expect(frame()).toContain("Hide test files: true"); + expect(writes).toEqual([["plugins.classify.bundled.hide_deleted", "true"]]); + expect(line("Hide deleted files")).toContain("true"); + expect(frame()).toContain("Hide deleted files: true"); // Enum: enter cycles to the next option. await clear(); await type("provider"); await press("RETURN"); - expect(writes.at(-1)).toEqual(["plugins.bundled.summarize.provider", "none"]); + expect(writes.at(-1)).toEqual(["plugins.shape.bundled.summarize.provider", "none"]); expect(line("Provider")).toContain("none"); // Number: enter opens a prompt titled by the setting; escape discards, enter saves. @@ -111,7 +111,7 @@ test("rows lead with titles under group headings; toggles change in place and ty await press("BACKSPACE"); await type("20"); await press("RETURN"); - expect(writes.at(-1)).toEqual(["plugins.bundled.deleted-bodies.min_lines", "20"]); + expect(writes.at(-1)).toEqual(["plugins.shape.bundled.deleted-bodies.min_lines", "20"]); expect(line("Shortest body to collapse")).toContain("20"); // Secret: the prompt starts empty and masks typing; the row only says it is stored. @@ -131,7 +131,7 @@ test("rows lead with titles under group headings; toggles change in place and ty t.mockInput.pressKey("RETURN"); }); await act(async () => { await t.renderOnce(); }); - expect(writes.at(-1)).toEqual(["plugins.bundled.summarize.api_key", "abcd"]); + expect(writes.at(-1)).toEqual(["plugins.shape.bundled.summarize.api_key", "abcd"]); expect(line("API key")).toContain("✓ stored"); expect(frame()).not.toContain("abc"); @@ -148,9 +148,9 @@ test("a change shows every value it moves, such as a default that follows the pr schema: () => schemaFixture, show: () => structuredClone(values), set: (key, value) => { - if (key === "plugins.bundled.summarize.provider") { - values.plugins.bundled.summarize.provider = value; - values.plugins.bundled.summarize.model = `${value}-model`; + if (key === "plugins.shape.bundled.summarize.provider") { + values.plugins.shape.bundled.summarize.provider = value; + values.plugins.shape.bundled.summarize.model = `${value}-model`; } }, }; @@ -172,6 +172,6 @@ test("a change shows every value it moves, such as a default that follows the pr expect(line("Provider")).toContain("none"); expect(line("Model")).toContain("none-model"); } finally { - t.renderer.destroy(); + await act(async () => { t.renderer.destroy(); }); } }); From c51e27f56c0e61ce3688a2b5a3efc509c80e2bf8 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Tue, 6 Oct 2026 18:30:15 -0700 Subject: [PATCH 5/6] Update config examples for format 2 AI assistance: implemented with OpenAI Codex; cleaned up with Claude Code and OpenAI Codex. Agent-Session: 79e2268e-5c55-4f1c-a7b9-f2a6d613fe4f --- docs/plugin.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/plugin.md b/docs/plugin.md index d56861500..45d1bda63 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -48,7 +48,7 @@ sequenceDiagram E->>E: Parse with tree-sitter, diff ASTs (difftastic) end E-->>D: Aligned regions and folds - loop Each shape plugin, in plugins.order + loop Each shape plugin, in plugins.shape.order D->>S: visit(cursor, pre | post), once per region S->>D: cursor edits: cut, join, link, collapse, label end @@ -100,3 +100,29 @@ impl GuestPlugin for MyPlugin { export_shape!(MyPlugin); ``` +## Configuration format 2 + +```toml +version = 2 + +[plugins.shape] +order = ["bundled.deleted-bodies", "bundled.summarize", "bundled.test-bodies", "bundled.removed-runs", "bundled.context"] + +[plugins.shape.bundled.context] +lines = 8 + +[plugins.shape.bundled.summarize] +enabled = true +provider = "openai" +model = "my-model" +system_prompt = "Keep my custom summary instruction." + +[plugins.classify.bundled] +hide = ["generated", "vendored"] +hide_deleted = true +``` + +```sh +diffr config set plugins.shape.bundled.context.lines 8 +diffr config set plugins.classify.bundled.hide_deleted false +``` From 84d421f3b9be50abdc5ec8df72d66efa228c0c0f Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Tue, 6 Oct 2026 18:30:16 -0700 Subject: [PATCH 6/6] Prepare 0.1.13 AI assistance: implemented with OpenAI Codex; cleaned up with Claude Code and OpenAI Codex. Agent-Session: 79e2268e-5c55-4f1c-a7b9-f2a6d613fe4f --- Cargo.lock | 6 +++--- Cargo.toml | 4 ++-- crates/diffr-core/Cargo.toml | 4 ++-- crates/diffr-grammars/Cargo.toml | 2 +- diffr-ts/package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9cad434ae..206b326dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -653,7 +653,7 @@ checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" [[package]] name = "diffr-cli" -version = "0.1.12" +version = "0.1.13" dependencies = [ "anyhow", "assert_cmd", @@ -684,7 +684,7 @@ dependencies = [ [[package]] name = "diffr-core" -version = "0.1.12" +version = "0.1.13" dependencies = [ "anyhow", "bumpalo", @@ -777,7 +777,7 @@ dependencies = [ [[package]] name = "diffr-grammars" -version = "0.1.12" +version = "0.1.13" dependencies = [ "cc", "object", diff --git a/Cargo.toml b/Cargo.toml index f50371784..b003a1b24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ description = "Structural diffs with a streaming API and interactive terminal fr repository = "https://github.com/devdotfast/diffr" homepage = "https://github.com/devdotfast/diffr" license = "MIT" -version = "0.1.12" +version = "0.1.13" authors = ["dev.fast"] keywords = ["diff", "syntax"] categories = ["development-tools", "command-line-utilities", "parser-implementations"] @@ -26,7 +26,7 @@ include = [ ] [dependencies] -diffr-core = { path = "crates/diffr-core", version = "0.1.12" } +diffr-core = { path = "crates/diffr-core", version = "0.1.13" } gix = { version = "0.88", default-features = false, features = ["parallel", "revision", "status", "blob-diff", "attributes", "sha1"] } clap = { version = "4.0.0", features = ["cargo", "derive", "env", "wrap_help", "string"] } typed-arena = "2.0.2" diff --git a/crates/diffr-core/Cargo.toml b/crates/diffr-core/Cargo.toml index 5c2e66b4d..57186f80c 100644 --- a/crates/diffr-core/Cargo.toml +++ b/crates/diffr-core/Cargo.toml @@ -4,7 +4,7 @@ description = "The diffr engine: parsing, structural diffing, configuration and repository = "https://github.com/devdotfast/diffr" homepage = "https://github.com/devdotfast/diffr" license = "MIT" -version = "0.1.12" +version = "0.1.13" authors = ["dev.fast"] keywords = ["diff", "syntax"] categories = ["development-tools", "parser-implementations"] @@ -29,7 +29,7 @@ include = [ ] [dependencies] -diffr-grammars = { path = "../diffr-grammars", version = "0.1.12" } +diffr-grammars = { path = "../diffr-grammars", version = "0.1.13" } regex = "1.10.4" typed-arena = "2.0.2" rustc-hash = "2.0.0" diff --git a/crates/diffr-grammars/Cargo.toml b/crates/diffr-grammars/Cargo.toml index c4e67bd94..5b3c1e541 100644 --- a/crates/diffr-grammars/Cargo.toml +++ b/crates/diffr-grammars/Cargo.toml @@ -4,7 +4,7 @@ description = "Tree-sitter grammars for diffr, with their largest parse tables s repository = "https://github.com/devdotfast/diffr" homepage = "https://github.com/devdotfast/diffr" license = "MIT" -version = "0.1.12" +version = "0.1.13" authors = ["dev.fast"] edition = "2021" rust-version = "1.99.0" diff --git a/diffr-ts/package.json b/diffr-ts/package.json index 2405db469..296ccaa00 100644 --- a/diffr-ts/package.json +++ b/diffr-ts/package.json @@ -1,6 +1,6 @@ { "name": "@dev.fast/diffr", - "version": "0.1.12", + "version": "0.1.13", "description": "diffr's NDJSON wire contract, validators, and the diffr binary for this platform", "repository": "https://github.com/devdotfast/diffr", "license": "MIT",