Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion crates/contrib/schematic_macros/src/common/field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,21 @@ impl Field<'_> {
}
value_type
} else {
FieldValue::value(result.value)
// `partial_via` applies to a plain field too, so a list of scalars
// can carry a wrapper that knows its own merge strategy. The
// partial holds the via type and `generate_from_partial_value`
// converts back to the field's own type.
let mut value_type =
FieldValue::value(result.partial_via_ty.as_ref().unwrap_or(result.value));
if result.partial_via_ty.is_some()
&& let FieldValue::Value { info, .. } = &mut value_type
{
let mut field_info = TypeInfo::default();
extract_inner_type(result.value, &mut field_info);
info.optional = field_info.optional;
info.boxed = field_info.boxed;
}
value_type
};

result
Expand Down
39 changes: 34 additions & 5 deletions crates/contrib/schematic_macros/src/config/field.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,22 +152,51 @@ impl Field<'_> {

#[allow(clippy::collapsible_else_if)]
if matches!(self.value_type, FieldValue::Value { .. }) {
// A `partial_via` field stores the via type in the partial and its
// own type in the resolved config, so the value converts on the way
// out. The conversion wraps the *inner* access, before any boxing,
// since it is the value that changes type and not its container.
let via = self.args.partial_via.is_some();
let convert = |value: TokenStream| {
if via {
quote! { Into::into(#value) }
} else {
value
}
};

if self.value_type.is_outer_boxed() {
if self.is_nullable() {
quote! { partial.#key.map(Box::new) }
let inner = convert(quote! { value });
quote! { partial.#key.map(|value| Box::new(#inner)) }
} else {
quote! { Box::new(partial.#key) }
let inner = if !via {
quote! { partial.#key }
} else if self.is_required() {
convert(
quote! { partial.#key.ok_or(schematic::ConfigError::MissingRequired{ fields: { let mut fields = fields.clone(); fields.push(#key_quoted.to_owned()); fields } })? },
)
} else {
convert(quote! { partial.#key.unwrap_or_default() })
};
quote! { Box::new(#inner) }
}
} else {
if self.is_nullable() {
// Use optional values as-is as they're already wrapped in `Option`
quote! { partial.#key }
if via {
quote! { partial.#key.map(Into::into) }
} else {
quote! { partial.#key }
}
} else if self.is_required() {
// Trigger a validation error if the value is missing
quote! { partial.#key.ok_or(schematic::ConfigError::MissingRequired{ fields: { let mut fields = fields.clone(); fields.push(#key_quoted.to_owned()); fields } })? }
convert(
quote! { partial.#key.ok_or(schematic::ConfigError::MissingRequired{ fields: { let mut fields = fields.clone(); fields.push(#key_quoted.to_owned()); fields } })? },
)
} else {
// Otherwise unwrap the resolved value or use the type default
quote! { partial.#key.unwrap_or_default() }
convert(quote! { partial.#key.unwrap_or_default() })
}
}
} else {
Expand Down
2 changes: 1 addition & 1 deletion crates/jp_cli/src/cmd/query_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1803,7 +1803,7 @@ async fn query_sequence_new_cfg_profile_then_model_override_persists_for_plain_q
.unwrap();

let mut base = AppConfig::new_test().to_partial();
base.config_load_paths = Some(vec![RelativePathBuf::from(".jp/config")]);
base.config_load_paths = Some(vec![RelativePathBuf::from(".jp/config")].into());
base.providers.llm.aliases.insert(
"gpt".to_owned(),
ModelIdConfig {
Expand Down
2 changes: 1 addition & 1 deletion crates/jp_cli/src/config_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ fn resolve_cfg_args(
let load_paths: Vec<Utf8PathBuf> = base
.config_load_paths
.iter()
.flatten()
.flat_map(|paths| paths.iter())
.filter_map(|p| {
Utf8PathBuf::try_from(p.to_path(root))
.inspect_err(|e| {
Expand Down
10 changes: 7 additions & 3 deletions crates/jp_cli/src/config_pipeline_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ fn an_override_restating_an_existing_rule_records_nothing() {

/// A list that merges by appending, restated at a value it already holds.
///
/// MCP arguments preserve repetition.
/// Appending without deduplicating is not idempotent: asking for `FOO` twice
/// leaves the list holding it twice, which is a different config and so a real
/// change to record.
Expand All @@ -123,7 +124,10 @@ fn an_override_repeating_an_appending_list_is_recorded() {
let current = base_partial();

let mut overrides = PartialAppConfig::empty();
overrides.editor.envs = Some(vec!["FOO".to_owned()]);
overrides
.providers
.mcp
.insert("bookworm".to_owned(), mcp_server("FOO"));

assert!(
override_to_record(&current, overrides.clone())
Expand Down Expand Up @@ -296,15 +300,15 @@ fn conversation_clears_allow_cfg_resets() {
fn mcp_server(argument: &str) -> PartialMcpProviderConfig {
PartialMcpProviderConfig::Stdio(PartialStdioConfig {
command: Some("just".into()),
arguments: Some(vec![argument.to_owned()]),
arguments: Some(vec![argument.to_owned()].into()),
..PartialStdioConfig::default()
})
}

/// The `arguments` of a server in a resolved partial.
fn mcp_arguments(partial: &PartialAppConfig, server: &str) -> Option<Vec<String>> {
let PartialMcpProviderConfig::Stdio(config) = partial.providers.mcp.get(server)?;
config.arguments.clone()
config.arguments.as_deref().cloned()
}

/// The per-conversation layer is a resolved snapshot, not a contribution.
Expand Down
78 changes: 76 additions & 2 deletions crates/jp_config/src/assignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
use std::{fmt, str::FromStr};

use indexmap::IndexMap;
use schematic::PartialConfig;
use schematic::{MergeResult, PartialConfig};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::{Value, from_str};

use crate::{AppConfig, BoxedError};
use crate::{AppConfig, BoxedError, types::vec::MergeableVec};

/// The result of assigning a key-value pair to a configuration.
pub type AssignResult = Result<(), BoxedError>;
Expand Down Expand Up @@ -1051,6 +1051,80 @@ impl KvAssignment {
self.try_vec_of_strings(vec.get_or_insert_default())
}

/// Assign to a list that carries its own merge strategy.
///
/// Accepts either the list itself or a `{ value, strategy }` object, which
/// is how the user declares a strategy for the field.
/// The two are told apart by shape, the same way [`MergeableVec`]'s own
/// deserializer does it: a sequence cannot be a table.
/// Merge-assigned objects use the field's `merge` function; appends and
/// indexed edits preserve the existing wrapper metadata.
pub(crate) fn try_some_mergeable_vec<T>(
self,
vec: &mut Option<MergeableVec<T>>,
parser: impl Fn(Self) -> Result<T, BoxedError>,
merge: impl Fn(MergeableVec<T>, MergeableVec<T>, &()) -> MergeResult<MergeableVec<T>>,
) -> Result<(), KvAssignmentError>
where
T: Clone + DeserializeOwned,
{
// An absent list and an empty one merge differently: `None` lets a
// later layer's value land verbatim, `Some([])` still runs the field's
// merge strategy against it.
if self.clears_collection() {
*vec = None;
return Ok(());
}

// An object declares a strategy alongside the value, so it is parsed as
// the wrapper rather than element by element.
if self.key.is_empty()
&& let KvValue::Json(value @ Value::Object(_)) = self.value.clone()
{
let next = serde_json::from_value(value).map_err(|error| kv_error(&self.key, error))?;

*vec = if self.is_merge()
&& let Some(prev) = vec.as_ref()
{
merge(prev.clone(), next, &()).or_else(|error| {
assignment_error(&self.key, self.value.clone().into_value(), error.into())
})?
} else {
Some(next)
};
return Ok(());
}

// Appends and indexed edits change the value, not its layering policy.
if self.is_merge() || !self.key.is_empty() {
return self.try_vec(vec.get_or_insert_default(), parser);
}

let mut elements = Vec::new();
self.try_vec(&mut elements, parser)?;
*vec = Some(elements.into());

Ok(())
}

/// Convenience method for [`Self::try_some_mergeable_vec`] whose elements
/// are built from strings.
pub(crate) fn try_some_mergeable_strings<T>(
self,
vec: &mut Option<MergeableVec<T>>,
merge: impl Fn(MergeableVec<T>, MergeableVec<T>, &()) -> MergeResult<MergeableVec<T>>,
) -> Result<(), KvAssignmentError>
where
T: Clone + From<String> + DeserializeOwned,
{
let parser = |kv: Self| match kv.value.clone().into_value() {
Value::String(v) => Ok(v.into()),
_ => type_error(kv.key(), &kv.value, &["string"]).map_err(Into::into),
};

self.try_some_mergeable_vec(vec, parser, merge)
}

/// Try to parse the value as a JSON array of partial configs, and set or
/// merge the elements.
pub(crate) fn try_vec_of_nested<T>(mut self, vec: &mut Vec<T>) -> Result<(), KvAssignmentError>
Expand Down
68 changes: 18 additions & 50 deletions crates/jp_config/src/delta.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,34 +64,6 @@
}
}

/// Delta for an appending list, reporting when appending cannot reach `next`.
///
/// Returns the elements `next` adds while appending suffices.
/// Otherwise pushes `path` to `unsets` and returns the whole of `next`, which
/// is what the caller merges after clearing the field.
pub fn delta_opt_vec_at<T: PartialEq + Clone>(
path: &str,
prev: Option<&Vec<T>>,
next: Option<Vec<T>>,
unsets: &mut Vec<String>,
) -> Option<Vec<T>> {
let next = next?;
let Some(prev) = prev else {
return Some(next);
};

// Appending reaches `next` exactly when `next` starts with `prev`; the
// delta is then the tail. Anything else — a dropped element, a reorder, an
// insertion in the middle — needs the field cleared first.
if next.starts_with(prev) {
let added = next[prev.len()..].to_vec();
return (!added.is_empty()).then_some(added);
}

unsets.push(path.to_owned());
Some(next)
}

/// Calculate the delta between two strategy-carrying lists.
///
/// Appending reaches `next` exactly when `next` starts with `prev` and repeats
Expand All @@ -107,7 +79,7 @@
///
/// A [`MergeableVec`] can express `replace` on the wire, which is why this
/// needs no separate path report.
/// A plain `Vec` cannot; see [`delta_opt_vec_at`].

Check warning on line 82 in crates/jp_config/src/delta.rs

View workflow job for this annotation

GitHub Actions / docs

unresolved link to `delta_opt_vec_at`
pub fn delta_mergeable_vec<T: Clone + PartialEq>(
prev: &MergeableVec<T>,
next: MergeableVec<T>,
Expand Down Expand Up @@ -138,6 +110,24 @@
.any(|(index, item)| items[..index].contains(item))
}

/// Calculate the delta between two optional strategy-carrying lists.
///
/// Wraps [`delta_mergeable_vec`] for a field whose partial is
/// `Option<MergeableVec<T>>`: an absent list on either side is no change, and
/// an empty delta is reported as absent so it does not read as one.
pub fn delta_opt_mergeable_vec<T: Clone + PartialEq>(
prev: Option<&MergeableVec<T>>,
next: Option<MergeableVec<T>>,
) -> Option<MergeableVec<T>> {
let next = next?;
let Some(prev) = prev else {
return Some(next);
};

let delta = delta_mergeable_vec(prev, next);
(!delta.is_empty()).then_some(delta)
}

/// Delta for an optional nested partial, reporting the fields it cannot reach.
///
/// Mirrors [`delta_opt_partial`], descending with `path` as the nested value's
Expand Down Expand Up @@ -236,28 +226,6 @@
}
}

/// Calculate the delta between two optional vectors that merge by appending.
///
/// The delta holds the elements `next` adds to `prev`, since that is what an
/// appending merge needs to reach `next` from `prev`.
///
/// Returns `None` when `next` adds nothing.
/// An element dropped from `prev` cannot be expressed by appending, so a
/// removal also yields `None` rather than a delta that fails to remove
/// anything.
///
/// Use [`delta_opt`] instead for a vector field that merges by replacement:
/// there the whole of `next` is the delta.
pub fn delta_opt_vec<T: PartialEq>(prev: Option<&Vec<T>>, next: Option<Vec<T>>) -> Option<Vec<T>> {
let next = next?;
let Some(prev) = prev else {
return Some(next);
};

let added = delta_vec(prev, next);
(!added.is_empty()).then_some(added)
}

/// Calculate the delta between two maps of partial configurations.
///
/// An entry only `next` has is kept whole.
Expand Down
Loading
Loading