clean up settings warning fallout

This commit is contained in:
Bryan Helmkamp 2026-04-23 19:53:40 -04:00
parent 6fc7251471
commit f16becd2f7
No known key found for this signature in database
17 changed files with 38 additions and 1990 deletions

View file

@ -32,6 +32,15 @@ impl ResolveErrors {
}
}
impl<'a> IntoIterator for &'a ResolveErrors {
type Item = &'a ResolveError;
type IntoIter = std::slice::Iter<'a, ResolveError>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
impl fmt::Display for ResolveErrors {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let rendered = self
@ -426,15 +435,6 @@ impl WorkflowSettingsBuilder {
let workflow = resolve_workflow(&layer.workflow.clone().unwrap_or_default(), &mut errors);
finish_dense_result(workflow, errors)
}
pub(crate) fn run_from_layer(
layer: &SettingsLayer,
) -> std::result::Result<RunNamespace, ResolveErrors> {
let layer = layer.clone().combine(DEFAULTS_LAYER.clone());
let mut errors = Vec::new();
let run = resolve_run(&layer.run.clone().unwrap_or_default(), &mut errors);
finish_dense_result(run, errors)
}
}
fn finish_result<T>(value: T, context: &'static str, errors: Vec<ResolveError>) -> Result<T> {

View file

@ -1,253 +1,3 @@
//! Splice-capable string arrays.
//!
//! In declared splice-capable array paths, the literal string value `"..."`
//! is reserved: it represents "splice in inherited values from lower-precedence
//! layers here." At most one `"..."` marker may appear per array. In the base
//! layer with no inherited parent, the marker resolves to an empty inherited
//! segment. In non-splice paths the same literal is a hard error — enforced
//! by using the plain `Vec<String>` type elsewhere and this type only where
//! splice semantics are explicitly allowed.
//! Shared splice marker literal for splice-capable arrays in raw settings.
use std::fmt;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// The reserved literal that marks the splice insertion point.
pub(crate) const SPLICE_MARKER: &str = "...";
/// A string array that may contain at most one splice marker.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct SpliceArray {
entries: Vec<Entry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Entry {
Value(String),
Splice,
}
/// An error returned when a splice array fails validation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SpliceArrayError {
/// The array contained more than one splice marker.
MultipleMarkers,
}
impl fmt::Display for SpliceArrayError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MultipleMarkers => {
f.write_str(r#"splice array must contain at most one "..." marker"#)
}
}
}
}
impl std::error::Error for SpliceArrayError {}
impl SpliceArray {
/// Build a splice array from a raw `Vec<String>`.
pub(crate) fn from_raw(raw: Vec<String>) -> Result<Self, SpliceArrayError> {
let mut entries = Vec::with_capacity(raw.len());
let mut marker_count = 0;
for item in raw {
if item == SPLICE_MARKER {
marker_count += 1;
entries.push(Entry::Splice);
} else {
entries.push(Entry::Value(item));
}
}
if marker_count > 1 {
return Err(SpliceArrayError::MultipleMarkers);
}
Ok(Self { entries })
}
/// True when the array contains a splice marker.
#[must_use]
pub(crate) fn has_splice(&self) -> bool {
self.entries.iter().any(|e| matches!(e, Entry::Splice))
}
/// The index of the splice marker, if present.
#[must_use]
pub(crate) fn splice_position(&self) -> Option<usize> {
self.entries.iter().position(|e| matches!(e, Entry::Splice))
}
/// The non-splice values, in source order.
#[must_use]
pub(crate) fn values(&self) -> Vec<&str> {
self.entries
.iter()
.filter_map(|e| match e {
Entry::Value(v) => Some(v.as_str()),
Entry::Splice => None,
})
.collect()
}
/// Resolve this array against an inherited lower-precedence value list.
///
/// - If the array has a splice marker, the inherited list is spliced in at
/// the marker position.
/// - If the array has no splice marker, it replaces the inherited list
/// wholesale.
#[must_use]
pub(crate) fn resolve(self, inherited: Vec<String>) -> Vec<String> {
let Some(pos) = self.splice_position() else {
return self
.entries
.into_iter()
.filter_map(|e| match e {
Entry::Value(v) => Some(v),
Entry::Splice => None,
})
.collect();
};
let mut prefix = Vec::new();
let mut suffix = Vec::new();
for (i, entry) in self.entries.into_iter().enumerate() {
match entry {
Entry::Value(v) => {
if i < pos {
prefix.push(v);
} else {
suffix.push(v);
}
}
Entry::Splice => {}
}
}
let mut out = prefix;
out.extend(inherited);
out.extend(suffix);
out
}
}
impl Serialize for SpliceArray {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(self.entries.len()))?;
for entry in &self.entries {
match entry {
Entry::Value(v) => seq.serialize_element(v)?,
Entry::Splice => seq.serialize_element(SPLICE_MARKER)?,
}
}
seq.end()
}
}
impl<'de> Deserialize<'de> for SpliceArray {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct SpliceArrayVisitor;
impl<'de> Visitor<'de> for SpliceArrayVisitor {
type Value = SpliceArray;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(
r#"an array of strings, optionally containing a single "..." splice marker"#,
)
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<SpliceArray, A::Error> {
let mut raw: Vec<String> = Vec::new();
while let Some(item) = seq.next_element::<String>()? {
raw.push(item);
}
SpliceArray::from_raw(raw).map_err(de::Error::custom)
}
}
deserializer.deserialize_seq(SpliceArrayVisitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_raw_with_no_marker() {
let arr = SpliceArray::from_raw(vec!["a".into(), "b".into()]).unwrap();
assert!(!arr.has_splice());
assert_eq!(arr.values(), vec!["a", "b"]);
}
#[test]
fn append_marker_at_front() {
let arr = SpliceArray::from_raw(vec!["...".into(), "c".into()]).unwrap();
assert_eq!(arr.splice_position(), Some(0));
let resolved = arr.resolve(vec!["a".into(), "b".into()]);
assert_eq!(resolved, vec!["a", "b", "c"]);
}
#[test]
fn prepend_marker_at_back() {
let arr = SpliceArray::from_raw(vec!["a".into(), "...".into()]).unwrap();
assert_eq!(arr.splice_position(), Some(1));
let resolved = arr.resolve(vec!["b".into(), "c".into()]);
assert_eq!(resolved, vec!["a", "b", "c"]);
}
#[test]
fn marker_in_middle() {
let arr = SpliceArray::from_raw(vec!["pre".into(), "...".into(), "post".into()]).unwrap();
let resolved = arr.resolve(vec!["mid".into()]);
assert_eq!(resolved, vec!["pre", "mid", "post"]);
}
#[test]
fn replace_semantics_without_marker() {
let arr = SpliceArray::from_raw(vec!["only".into()]).unwrap();
let resolved = arr.resolve(vec!["inherited".into()]);
assert_eq!(resolved, vec!["only"]);
}
#[test]
fn multiple_markers_rejected() {
let err = SpliceArray::from_raw(vec!["...".into(), "...".into()]).unwrap_err();
assert_eq!(err, SpliceArrayError::MultipleMarkers);
}
#[test]
fn base_layer_with_splice_resolves_to_empty_inherited() {
let arr = SpliceArray::from_raw(vec!["...".into(), "b".into()]).unwrap();
let resolved = arr.resolve(vec![]);
assert_eq!(resolved, vec!["b"]);
}
#[test]
fn serde_round_trip_via_json() {
#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)]
struct Wrap {
a: SpliceArray,
}
let input = r#"{"a":["...","b"]}"#;
let parsed: Wrap = serde_json::from_str(input).unwrap();
assert!(parsed.a.has_splice());
let rendered = serde_json::to_string(&parsed).unwrap();
assert_eq!(rendered, input);
}
#[test]
fn serde_rejects_multiple_markers() {
#[derive(Debug, serde::Deserialize)]
struct Wrap {
_a: SpliceArray,
}
let input = r#"{"_a":["...","..."]}"#;
let err = serde_json::from_str::<Wrap>(input).unwrap_err();
assert!(err.to_string().contains("at most one"));
}
}

View file

@ -12,7 +12,7 @@
use std::fmt::Write;
use std::path::{Component, Path, PathBuf};
use fabro_types::settings::RunNamespace;
use fabro_types::settings::{InterpString, RunNamespace};
use serde::Serialize;
use crate::load::load_settings_path;
@ -108,15 +108,8 @@ pub fn resolve_workflow_path(workflow_path: &Path, cwd: &Path) -> Result<Workflo
}
}
pub(crate) fn resolve_working_directory(settings: &SettingsLayer, caller_cwd: &Path) -> PathBuf {
let Some(run_settings) = WorkflowSettingsBuilder::run_from_layer(settings).ok() else {
return caller_cwd.to_path_buf();
};
resolve_working_directory_from_run(&run_settings, caller_cwd)
}
pub fn resolve_working_directory_from_run(run: &RunNamespace, caller_cwd: &Path) -> PathBuf {
let Some(work_dir) = run.working_dir.as_ref().map(|value| value.as_source()) else {
let Some(work_dir) = run.working_dir.as_ref().map(InterpString::as_source) else {
return caller_cwd.to_path_buf();
};
let path = PathBuf::from(work_dir);
@ -588,9 +581,9 @@ file = "prompts/goal.md"
fn resolve_working_directory_from_run_joins_relative_path() {
let cwd = Path::new("/tmp/workspace");
let resolved = resolve_working_directory_from_run(
&fabro_types::settings::RunNamespace {
working_dir: Some(fabro_types::settings::InterpString::parse("repo")),
..fabro_types::settings::RunNamespace::default()
&RunNamespace {
working_dir: Some(InterpString::parse("repo")),
..RunNamespace::default()
},
cwd,
);

View file

@ -12,8 +12,6 @@ use fabro_types::settings::InterpString;
pub use features::resolve_features;
pub use project::resolve_project;
pub use run::resolve_run;
#[cfg(test)]
pub(crate) use server::dev_token_auth_enabled;
pub use server::resolve_server;
pub use workflow::resolve_workflow;

View file

@ -16,18 +16,9 @@ use crate::{
IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, ServerApiLayer,
ServerArtifactsLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerIpAllowlistLayer,
ServerIpAllowlistOverrideLayer, ServerLayer, ServerListenLayer, ServerSlateDbLayer,
ServerStorageLayer, ServerWebLayer, SettingsLayer,
ServerStorageLayer, ServerWebLayer,
};
pub(crate) fn dev_token_auth_enabled(layer: &SettingsLayer) -> bool {
layer
.server
.as_ref()
.and_then(|server| server.auth.as_ref())
.and_then(|auth| auth.methods.as_ref())
.is_some_and(|methods| methods.contains(&ServerAuthMethod::DevToken))
}
pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> ServerNamespace {
let storage = resolve_storage(layer.storage.as_ref());
let listen = resolve_listen(layer.listen.as_ref(), errors);

View file

@ -68,17 +68,6 @@ impl std::error::Error for ResolveRunGoalError {
}
}
pub(crate) fn resolve_run_goal(
settings: &SettingsLayer,
base_dir: &Path,
) -> std::result::Result<Option<ResolvedRunGoal>, ResolveRunGoalError> {
let Some(goal) = settings.run.as_ref().and_then(|run| run.goal.as_ref()) else {
return Ok(None);
};
resolve_layer_goal(goal, base_dir).map(Some)
}
pub fn resolve_run_goal_from_layer(
run: &RunLayer,
base_dir: &Path,

View file

@ -5,12 +5,12 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::server::{
GithubIntegrationStrategy, IpAllowEntry, ObjectStoreSettings, ServerListenSettings,
GithubIntegrationStrategy, IpAllowEntry, ObjectStoreSettings, ServerAuthMethod,
ServerListenSettings, ServerNamespace,
};
use fabro_util::Home;
use temp_env::with_var;
use crate::resolve::dev_token_auth_enabled;
use crate::user::default_storage_dir;
use crate::{ServerSettingsBuilder, SettingsLayer};
@ -26,7 +26,16 @@ fn empty_settings_with_auth_methods() -> SettingsLayer {
SettingsLayer::test_default()
}
fn resolve_server(file: &SettingsLayer) -> fabro_types::settings::ServerNamespace {
fn dev_token_auth_enabled(layer: &SettingsLayer) -> bool {
layer
.server
.as_ref()
.and_then(|server| server.auth.as_ref())
.and_then(|auth| auth.methods.as_ref())
.is_some_and(|methods| methods.contains(&ServerAuthMethod::DevToken))
}
fn resolve_server(file: &SettingsLayer) -> ServerNamespace {
ServerSettingsBuilder::from_layer(file)
.expect("server settings should resolve")
.server

View file

@ -10,8 +10,7 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::interp::InterpString;
use super::maps::StickyMap;
use super::run::{AgentPermissions, McpEntryLayer, McpServerSettings};
use super::run::{AgentPermissions, McpServerSettings};
/// A structurally resolved `[cli]` view for consumers.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
@ -71,47 +70,6 @@ pub struct CliLoggingSettings {
pub level: Option<String>,
}
/// A sparse `[cli]` layer as it appears in a single settings file.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct CliLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<CliTargetLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<CliAuthLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec: Option<CliExecLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output: Option<CliOutputLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub updates: Option<CliUpdatesLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logging: Option<CliLoggingLayer>,
}
/// `[cli.target]` — explicit transport selection.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")]
pub(crate) enum CliTargetLayer {
Http {
#[serde(default)]
url: Option<InterpString>,
},
Unix {
#[serde(default)]
path: Option<InterpString>,
},
}
/// `[cli.auth]` — explicit auth strategy selection.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct CliAuthLayer {
/// `none` explicitly disables inherited auth.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strategy: Option<CliAuthStrategy>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CliAuthStrategy {
@ -119,48 +77,6 @@ pub enum CliAuthStrategy {
Jwt,
}
/// `[cli.exec]` — `fabro exec` defaults.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct CliExecLayer {
/// Prevent idle sleep on macOS while an exec run is in flight.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prevent_idle_sleep: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<CliExecModelLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<CliExecAgentLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct CliExecModelLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<InterpString>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct CliExecAgentLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<AgentPermissions>,
/// Agent-scoped MCP entries for `fabro exec`.
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub mcps: StickyMap<McpEntryLayer>,
}
/// `[cli.output]` — generic CLI output defaults.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct CliOutputLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<OutputFormat>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verbosity: Option<OutputVerbosity>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum OutputFormat {
@ -177,19 +93,3 @@ pub enum OutputVerbosity {
Normal,
Verbose,
}
/// `[cli.updates]` — upgrade check toggle.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct CliUpdatesLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub check: Option<bool>,
}
/// `[cli.logging]` — process-owned logging configuration for the CLI.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct CliLoggingLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<String>,
}

View file

@ -1,344 +0,0 @@
use std::collections::HashMap;
use super::cli::{
CliAuthLayer, CliAuthStrategy, CliLoggingLayer, CliTargetLayer, OutputFormat, OutputVerbosity,
};
use super::duration::Duration;
use super::features::FeaturesLayer;
use super::interp::InterpString;
use super::run::{
AgentPermissions, ApprovalMode, DaytonaNetworkLayer, DaytonaSnapshotLayer, HookAgentMarker,
HookEntry, HookTlsMode, InterviewProviderLayer, LocalSandboxLayer, MergeStrategy,
ModelRefOrSplice, NotificationProviderLayer, RunArtifactsLayer, RunCheckpointLayer,
RunGoalLayer, RunMode, RunPrepareLayer, ScmGitHubLayer, StringOrSplice, WorktreeMode,
};
use super::server::{
GithubIntegrationStrategy, ObjectStoreLocalLayer, ObjectStoreProvider, ObjectStoreS3Layer,
ServerApiLayer, ServerAuthGithubLayer, ServerAuthMethod, ServerListenLayer, ServerLoggingLayer,
WebhookStrategy,
};
use super::size::Size;
pub(crate) trait Combine {
/// Combine two values, preferring the values in `self`.
#[must_use]
fn combine(self, other: Self) -> Self;
}
impl<T: Combine> Combine for Option<T> {
fn combine(self, other: Self) -> Self {
match (self, other) {
(Some(this), Some(fallback)) => Some(this.combine(fallback)),
(this, fallback) => this.or(fallback),
}
}
}
macro_rules! impl_combine_or_option {
($($ty:ty),+ $(,)?) => {
$(
impl Combine for Option<$ty> {
fn combine(self, other: Self) -> Self {
self.or(other)
}
}
)+
};
}
impl_combine_or_option!(
String,
bool,
u16,
u32,
u64,
usize,
i32,
Duration,
InterpString,
Size,
CliAuthStrategy,
OutputFormat,
OutputVerbosity,
AgentPermissions,
ApprovalMode,
HookAgentMarker,
HookTlsMode,
MergeStrategy,
RunMode,
WorktreeMode,
GithubIntegrationStrategy,
ObjectStoreProvider,
ServerAuthMethod,
WebhookStrategy,
);
impl Combine for Option<Vec<String>> {
fn combine(self, other: Self) -> Self {
self.or(other)
}
}
impl Combine for Option<Vec<ServerAuthMethod>> {
fn combine(self, other: Self) -> Self {
self.or(other)
}
}
impl Combine for Option<HashMap<String, toml::Value>> {
fn combine(self, other: Self) -> Self {
self.or(other)
}
}
macro_rules! impl_combine_self {
($($ty:ty),+ $(,)?) => {
$(
impl Combine for $ty {
fn combine(self, _other: Self) -> Self {
self
}
}
)+
};
}
impl_combine_self!(
CliAuthLayer,
CliLoggingLayer,
CliTargetLayer,
FeaturesLayer,
DaytonaNetworkLayer,
DaytonaSnapshotLayer,
InterviewProviderLayer,
LocalSandboxLayer,
NotificationProviderLayer,
RunArtifactsLayer,
RunGoalLayer,
RunPrepareLayer,
ScmGitHubLayer,
ObjectStoreLocalLayer,
ObjectStoreS3Layer,
ServerApiLayer,
ServerAuthGithubLayer,
ServerListenLayer,
ServerLoggingLayer,
);
impl Combine for RunCheckpointLayer {
fn combine(self, other: Self) -> Self {
if self.exclude_globs.is_empty() {
other
} else {
self
}
}
}
/// An element of a splice-aware sequence: either a regular value or the
/// `...` marker that asks the combiner to expand the fallback list inline.
pub(crate) trait SpliceMarker {
fn is_splice(&self) -> bool;
}
impl SpliceMarker for ModelRefOrSplice {
fn is_splice(&self) -> bool {
matches!(self, Self::Splice)
}
}
impl SpliceMarker for StringOrSplice {
fn is_splice(&self) -> bool {
matches!(self, Self::Splice)
}
}
impl<T: SpliceMarker + Clone> Combine for Vec<T> {
fn combine(self, other: Self) -> Self {
splice_combine(other, self)
}
}
impl Combine for Vec<HookEntry> {
fn combine(self, other: Self) -> Self {
combine_hooks(&other, self)
}
}
fn splice_combine<T: SpliceMarker + Clone>(fallback: Vec<T>, current: Vec<T>) -> Vec<T> {
if current.is_empty() {
return fallback;
}
let Some(pos) = current.iter().position(T::is_splice) else {
return current;
};
let mut out = Vec::with_capacity(current.len() - 1 + fallback.len());
for (index, entry) in current.into_iter().enumerate() {
if index == pos {
out.extend(fallback.iter().filter(|entry| !entry.is_splice()).cloned());
} else if !entry.is_splice() {
out.push(entry);
}
}
out
}
fn combine_hooks(fallback: &[HookEntry], current: Vec<HookEntry>) -> Vec<HookEntry> {
let mut out = Vec::with_capacity(fallback.len() + current.len());
let mut appended_ids = Vec::new();
for fallback_entry in fallback {
if let Some(id) = &fallback_entry.id {
if let Some(replacement) = current
.iter()
.find(|entry| entry.id.as_deref() == Some(id.as_str()))
{
out.push(replacement.clone());
appended_ids.push(id.clone());
continue;
}
}
out.push(fallback_entry.clone());
}
for current_entry in current {
if let Some(id) = &current_entry.id {
if appended_ids.contains(id) {
continue;
}
}
out.push(current_entry);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, PartialEq)]
struct FieldMergeLayer {
a: Option<u32>,
b: Option<u32>,
}
impl Combine for FieldMergeLayer {
fn combine(self, other: Self) -> Self {
Self {
a: self.a.combine(other.a),
b: self.b.combine(other.b),
}
}
}
#[derive(Debug, PartialEq)]
struct WholeReplaceLayer {
a: Option<u32>,
b: Option<u32>,
}
impl Combine for WholeReplaceLayer {
fn combine(self, _other: Self) -> Self {
self
}
}
#[track_caller]
fn assert_option_leaf<T>(this: T, fallback: T)
where
T: Clone + std::fmt::Debug + PartialEq,
Option<T>: Combine,
{
assert_eq!(
Some(this.clone()).combine(Some(fallback.clone())),
Some(this)
);
assert_eq!(
Option::<T>::None.combine(Some(fallback.clone())),
Some(fallback)
);
}
#[test]
fn option_leaf_types_prefer_self_or_fallback() {
assert_option_leaf("this".to_string(), "fallback".to_string());
assert_option_leaf(true, false);
assert_option_leaf(1_u16, 2_u16);
assert_option_leaf(1_u32, 2_u32);
assert_option_leaf(1_u64, 2_u64);
assert_option_leaf(1_usize, 2_usize);
assert_option_leaf(1_i32, 2_i32);
assert_option_leaf(Duration::from_secs(1), Duration::from_secs(2));
assert_option_leaf(InterpString::parse("this"), InterpString::parse("fallback"));
assert_option_leaf(Size::from_bytes(1), Size::from_bytes(2));
assert_option_leaf(CliAuthStrategy::None, CliAuthStrategy::Jwt);
assert_option_leaf(OutputFormat::Json, OutputFormat::Text);
assert_option_leaf(OutputVerbosity::Quiet, OutputVerbosity::Verbose);
assert_option_leaf(AgentPermissions::ReadOnly, AgentPermissions::Full);
assert_option_leaf(ApprovalMode::Auto, ApprovalMode::Prompt);
assert_option_leaf(HookAgentMarker::Enabled, HookAgentMarker::Enabled);
assert_option_leaf(HookTlsMode::NoVerify, HookTlsMode::Verify);
assert_option_leaf(MergeStrategy::Rebase, MergeStrategy::Squash);
assert_option_leaf(RunMode::DryRun, RunMode::Normal);
assert_option_leaf(WorktreeMode::Always, WorktreeMode::Never);
assert_option_leaf(
GithubIntegrationStrategy::App,
GithubIntegrationStrategy::Token,
);
assert_option_leaf(ObjectStoreProvider::S3, ObjectStoreProvider::Local);
assert_option_leaf(ServerAuthMethod::Github, ServerAuthMethod::DevToken);
assert_option_leaf(WebhookStrategy::ServerUrl, WebhookStrategy::TailscaleFunnel);
assert_option_leaf(vec!["this".to_string()], vec!["fallback".to_string()]);
assert_option_leaf(vec![ServerAuthMethod::Github], vec![
ServerAuthMethod::DevToken,
]);
assert_option_leaf(
HashMap::from([("this".to_string(), toml::Value::String("value".to_string()))]),
HashMap::from([(
"fallback".to_string(),
toml::Value::String("value".to_string()),
)]),
);
}
#[test]
fn recursive_option_combines_inner_fields() {
let this = Some(FieldMergeLayer {
a: Some(1),
b: None,
});
let fallback = Some(FieldMergeLayer {
a: Some(2),
b: Some(3),
});
assert_eq!(
this.combine(fallback),
Some(FieldMergeLayer {
a: Some(1),
b: Some(3),
})
);
}
#[test]
fn whole_replace_inner_does_not_inherit_fallback_fields() {
let this = Some(WholeReplaceLayer {
a: Some(1),
b: None,
});
let fallback = Some(WholeReplaceLayer {
a: Some(2),
b: Some(3),
});
assert_eq!(
this.combine(fallback),
Some(WholeReplaceLayer {
a: Some(1),
b: None,
})
);
}
}

View file

@ -10,14 +10,3 @@ use serde::{Deserialize, Serialize};
pub struct FeaturesNamespace {
pub session_sandboxes: bool,
}
/// A sparse `[features]` layer as it appears in a single settings file.
///
/// Every field is an `Option<bool>` so layers can independently set or
/// override a flag without forcing a default that hides an unset value.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct FeaturesLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_sandboxes: Option<bool>,
}

View file

@ -1,207 +0,0 @@
use std::collections::HashMap;
use std::collections::hash_map::IntoIter;
use std::ops::{Deref, DerefMut};
use serde::{Deserialize, Serialize};
use super::combine::Combine;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub(crate) struct ReplaceMap<V>(pub HashMap<String, V>);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub(crate) struct StickyMap<V>(pub HashMap<String, V>);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub(crate) struct MergeMap<V>(pub HashMap<String, V>);
macro_rules! impl_map_wrapper {
($name:ident) => {
impl<V> $name<V> {
#[must_use]
pub(crate) fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub(crate) fn into_inner(self) -> HashMap<String, V> {
self.0
}
}
impl<V> Deref for $name<V> {
type Target = HashMap<String, V>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<V> DerefMut for $name<V> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl<V> From<HashMap<String, V>> for $name<V> {
fn from(value: HashMap<String, V>) -> Self {
Self(value)
}
}
impl<V> Default for $name<V> {
fn default() -> Self {
Self(HashMap::new())
}
}
impl<V> IntoIterator for $name<V> {
type IntoIter = IntoIter<String, V>;
type Item = (String, V);
fn into_iter(self) -> Self::IntoIter {
self.0.into_iter()
}
}
};
}
impl_map_wrapper!(ReplaceMap);
impl_map_wrapper!(StickyMap);
impl_map_wrapper!(MergeMap);
impl<V> Combine for ReplaceMap<V> {
fn combine(self, other: Self) -> Self {
if self.0.is_empty() { other } else { self }
}
}
impl<V> Combine for StickyMap<V> {
fn combine(self, other: Self) -> Self {
let mut combined = other.0;
for (key, value) in self.0 {
combined.insert(key, value);
}
Self(combined)
}
}
impl<V: Combine> Combine for MergeMap<V> {
fn combine(self, other: Self) -> Self {
let mut combined = other.0;
for (key, value) in self.0 {
let value = match combined.remove(&key) {
Some(fallback) => value.combine(fallback),
None => value,
};
combined.insert(key, value);
}
Self(combined)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, PartialEq)]
struct ValueLayer {
a: Option<String>,
b: Option<String>,
}
impl Combine for ValueLayer {
fn combine(self, other: Self) -> Self {
Self {
a: self.a.combine(other.a),
b: self.b.combine(other.b),
}
}
}
#[test]
fn replace_map_self_wins_when_non_empty() {
let this = ReplaceMap(HashMap::from([("a".to_string(), "this".to_string())]));
let fallback = ReplaceMap(HashMap::from([
("a".to_string(), "fallback".to_string()),
("b".to_string(), "fallback".to_string()),
]));
assert_eq!(
this.combine(fallback),
ReplaceMap(HashMap::from([("a".to_string(), "this".to_string())]))
);
}
#[test]
fn replace_map_empty_self_uses_fallback() {
let this = ReplaceMap::<String>(HashMap::new());
let fallback = ReplaceMap(HashMap::from([("a".to_string(), "fallback".to_string())]));
assert_eq!(
this.combine(fallback),
ReplaceMap(HashMap::from([("a".to_string(), "fallback".to_string())]))
);
}
#[test]
fn replace_map_round_trips_as_toml_table() {
let parsed: ReplaceMap<String> =
toml::from_str(r#"a = "one""#).expect("fixture should deserialize");
assert_eq!(
parsed,
ReplaceMap(HashMap::from([("a".to_string(), "one".to_string())]))
);
let serialized = toml::to_string(&parsed).expect("fixture should serialize");
let reparsed: ReplaceMap<String> =
toml::from_str(&serialized).expect("fixture should deserialize again");
assert_eq!(reparsed, parsed);
}
#[test]
fn sticky_map_merges_keys_with_self_winning_conflicts() {
let this = StickyMap(HashMap::from([
("a".to_string(), "this".to_string()),
("c".to_string(), "this".to_string()),
]));
let fallback = StickyMap(HashMap::from([
("a".to_string(), "fallback".to_string()),
("b".to_string(), "fallback".to_string()),
]));
assert_eq!(
this.combine(fallback),
StickyMap(HashMap::from([
("a".to_string(), "this".to_string()),
("b".to_string(), "fallback".to_string()),
("c".to_string(), "this".to_string()),
]))
);
}
#[test]
fn merge_map_recursively_combines_values_for_matching_keys() {
let this = MergeMap(HashMap::from([("ops".to_string(), ValueLayer {
a: Some("this".to_string()),
b: None,
})]));
let fallback = MergeMap(HashMap::from([("ops".to_string(), ValueLayer {
a: Some("fallback".to_string()),
b: Some("fallback".to_string()),
})]));
assert_eq!(
this.combine(fallback),
MergeMap(HashMap::from([("ops".to_string(), ValueLayer {
a: Some("this".to_string()),
b: Some("fallback".to_string()),
},)]))
);
}
}

View file

@ -1,26 +1,23 @@
//! Namespaced settings schema.
//!
//! Top-level schema is strictly namespaced with `_version`, `[project]`,
//! `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`.
//! Value-language helpers live alongside the tree: durations, byte sizes,
//! model references, env interpolation, and splice-capable arrays.
//! `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`. Value-language
//! helpers live alongside the tree: durations, byte sizes, model references,
//! and env interpolation.
//!
//! Stage 6.5b promoted these modules up out of the transitional
//! `settings/v2/` subdirectory, so the `::v2::` path prefix no longer
//! exists.
pub mod cli;
mod combine;
pub mod duration;
pub mod features;
pub mod interp;
mod maps;
pub mod model_ref;
pub mod project;
pub mod run;
pub mod server;
pub mod size;
mod splice_array;
pub mod workflow;
pub use cli::{

View file

@ -7,8 +7,6 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::maps::ReplaceMap;
/// A structurally resolved `[project]` view for consumers.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ProjectNamespace {
@ -17,19 +15,3 @@ pub struct ProjectNamespace {
pub directory: String,
pub metadata: HashMap<String, String>,
}
/// A sparse `[project]` layer as it appears in a single settings file.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ProjectLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// The Fabro-managed project directory inside the repo. Defaults to
/// `.` after layering when unspecified.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
#[serde(default, skip_serializing_if = "ReplaceMap::is_empty")]
pub metadata: ReplaceMap<String>,
}

View file

@ -12,9 +12,7 @@ use std::time::Duration as StdDuration;
use serde::ser::SerializeStruct;
use serde::{Deserialize, Serialize};
use super::duration::Duration;
use super::interp::InterpString;
use super::maps::{MergeMap, ReplaceMap, StickyMap};
use super::model_ref::ModelRef;
/// A structurally resolved `[run]` view for consumers.
@ -399,6 +397,10 @@ pub struct RunScmSettings {
pub github: Option<ScmGitHubSettings>,
}
#[expect(
clippy::empty_structs_with_brackets,
reason = "resolved empty table must stay object-shaped on the wire"
)]
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ScmGitHubSettings {}
@ -425,82 +427,10 @@ impl Default for PullRequestSettings {
pub struct ArtifactsSettings {
pub include: Vec<String>,
}
/// A sparse `[run]` layer as it appears in a single settings file.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RunLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<RunGoalLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_dir: Option<InterpString>,
/// Flat string-to-string map. Replaces wholesale across layers.
#[serde(default, skip_serializing_if = "ReplaceMap::is_empty")]
pub metadata: ReplaceMap<String>,
/// Run inputs: typed scalar values. Replaces wholesale across layers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inputs: Option<HashMap<String, toml::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<RunModelLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git: Option<RunGitLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prepare: Option<RunPrepareLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub execution: Option<RunExecutionLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checkpoint: Option<RunCheckpointLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<RunSandboxLayer>,
#[serde(default, skip_serializing_if = "MergeMap::is_empty")]
pub notifications: MergeMap<NotificationRouteLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub interviews: Option<InterviewsLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<RunAgentLayer>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub hooks: Vec<HookEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scm: Option<RunScmLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pull_request: Option<RunPullRequestLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifacts: Option<RunArtifactsLayer>,
}
/// The source of a run's goal, either inline literal text or a reference to
/// a file on disk.
///
/// TOML surface:
///
/// ```toml
/// # Inline form
/// [run]
/// goal = "Diagnose and fix CI build failures"
///
/// # File form
/// [run.goal]
/// file = "prompts/fix_build.md"
/// ```
///
/// Relative paths inside the `file` variant are resolved against the
/// directory of the config file that declared them at load time (see
/// `fabro_config::resolve_goal_file_paths`). `{{ env.NAME }}` interpolation is
/// supported inside the `file` path; env-tokenized relative paths stay
/// unresolved until consume time and are then resolved against the run's
/// effective working directory.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
pub(crate) enum RunGoalLayer {
Inline(InterpString),
File { file: InterpString },
}
/// Outcome of resolving a [`RunGoalLayer`] to its final goal text.
/// Outcome of resolving a [`RunGoal`] to its final goal text.
///
/// Carries provenance alongside the text so downstream consumers (e.g. the
/// run manifest builder) can distinguish inline goals from file-sourced
/// goals without having to re-walk the layer.
/// run manifest builder) can distinguish inline goals from file-sourced goals.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedRunGoal {
pub text: String,
@ -517,77 +447,6 @@ pub enum ResolvedGoalSource {
File { path: std::path::PathBuf },
}
/// `[run.model]` — provider-neutral default model selection.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RunModelLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<InterpString>,
/// Ordered list of fallback model references. Supports `...` splice marker
/// at layering time — see [`super::splice_array`].
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub fallbacks: Vec<ModelRefOrSplice>,
}
/// A single `fallbacks` entry: either a parsed `ModelRef` or the splice marker.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ModelRefOrSplice {
ModelRef(ModelRef),
Splice,
}
impl Serialize for ModelRefOrSplice {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::ModelRef(m) => m.serialize(serializer),
Self::Splice => serializer.serialize_str(super::splice_array::SPLICE_MARKER),
}
}
}
impl<'de> Deserialize<'de> for ModelRefOrSplice {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error;
let raw = String::deserialize(deserializer)?;
if raw == super::splice_array::SPLICE_MARKER {
return Ok(Self::Splice);
}
let model = raw.parse::<ModelRef>().map_err(D::Error::custom)?;
Ok(Self::ModelRef(model))
}
}
/// `[run.git]` — local git behavior such as commit author.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RunGitLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<GitAuthorLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct GitAuthorLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<InterpString>,
}
/// `[run.prepare]` — ordered list of preparation steps. Whole list replaces
/// across layers.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RunPrepareLayer {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub steps: Vec<PrepareStep>,
/// Optional timeout applied to each prepare step.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<Duration>,
}
/// A single prepare step. Exactly one of `script` or `command` must be set.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@ -600,19 +459,6 @@ pub struct PrepareStep {
pub env: HashMap<String, InterpString>,
}
/// `[run.execution]` — run posture knobs.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RunExecutionLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<RunMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval: Option<ApprovalMode>,
/// Positive-form: `true` runs retros, `false` skips them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retros: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunMode {
@ -627,40 +473,6 @@ pub enum ApprovalMode {
Auto,
}
/// `[run.checkpoint]` — checkpoint policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RunCheckpointLayer {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude_globs: Vec<String>,
}
/// `[run.sandbox]` — sandbox selection and execution-environment surface.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RunSandboxLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preserve: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub devcontainer: Option<bool>,
/// Sticky merge-by-key across layers.
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub env: StickyMap<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local: Option<LocalSandboxLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daytona: Option<DaytonaSandboxLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct LocalSandboxLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worktree_mode: Option<WorktreeMode>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorktreeMode {
@ -671,44 +483,6 @@ pub enum WorktreeMode {
Never,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct DaytonaSandboxLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_stop_interval: Option<i32>,
/// Sticky merge-by-key (provider-native labels).
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub labels: StickyMap<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot: Option<DaytonaSnapshotLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option<DaytonaNetworkLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_clone: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct DaytonaSnapshotLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<super::size::Size>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disk: Option<super::size::Size>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dockerfile: Option<DaytonaDockerfileLayer>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
pub(crate) enum DaytonaDockerfileLayer {
Inline(String),
Path { path: String },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum DaytonaNetworkLayer {
@ -717,93 +491,6 @@ pub enum DaytonaNetworkLayer {
AllowList { allow_list: Vec<String> },
}
/// `[run.notifications.<name>]` — a keyed notification route.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct NotificationRouteLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// Raw Fabro event names. Splice marker supported at layering time.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<StringOrSplice>,
/// Provider-specific destination subtables. First-pass chat providers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slack: Option<NotificationProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discord: Option<NotificationProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub teams: Option<NotificationProviderLayer>,
}
/// A single string array entry that may be the splice marker.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum StringOrSplice {
Value(String),
Splice,
}
impl Serialize for StringOrSplice {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Value(s) => serializer.serialize_str(s),
Self::Splice => serializer.serialize_str(super::splice_array::SPLICE_MARKER),
}
}
}
impl<'de> Deserialize<'de> for StringOrSplice {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
if s == super::splice_array::SPLICE_MARKER {
Ok(Self::Splice)
} else {
Ok(Self::Value(s))
}
}
}
/// Provider-specific destination fields for a notification route.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct NotificationProviderLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel: Option<InterpString>,
}
/// `[run.interviews]` — external interview delivery.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct InterviewsLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slack: Option<InterviewProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discord: Option<InterviewProviderLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub teams: Option<InterviewProviderLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct InterviewProviderLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel: Option<InterpString>,
}
/// `[run.agent]` — agent knobs only (permissions, MCPs).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RunAgentLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<AgentPermissions>,
/// Agent-scoped MCP server entries, keyed by name.
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub mcps: StickyMap<McpEntryLayer>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AgentPermissions {
@ -812,117 +499,6 @@ pub enum AgentPermissions {
Full,
}
/// A single MCP entry. `type` selects the transport; `script`/`command` are
/// mutually exclusive for process-launching transports. Non-launching HTTP
/// transports use neither field.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")]
pub(crate) enum McpEntryLayer {
Http {
#[serde(default)]
enabled: Option<bool>,
url: InterpString,
#[serde(default)]
headers: HashMap<String, InterpString>,
#[serde(default)]
startup_timeout: Option<Duration>,
#[serde(default)]
tool_timeout: Option<Duration>,
},
Stdio {
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
script: Option<InterpString>,
#[serde(default)]
command: Option<Vec<InterpString>>,
#[serde(default)]
env: HashMap<String, InterpString>,
#[serde(default)]
startup_timeout: Option<Duration>,
#[serde(default)]
tool_timeout: Option<Duration>,
},
Sandbox {
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
script: Option<InterpString>,
#[serde(default)]
command: Option<Vec<InterpString>>,
port: u16,
#[serde(default)]
env: HashMap<String, InterpString>,
#[serde(default)]
startup_timeout: Option<Duration>,
#[serde(default)]
tool_timeout: Option<Duration>,
},
}
/// A run hook entry. Exactly one of `script`, `command`, `url`, `prompt`, or
/// `agent` fields determines the hook behavior. The `id` field, when set, is
/// used for cross-layer replace-by-id merging.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct HookEntry {
/// Optional merge identity. Hooks with the same `id` replace in place.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
/// Display-only human name.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub event: HookEvent,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matcher: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocking: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout: Option<Duration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sandbox: Option<bool>,
// Exactly one of the following groups is expected:
#[serde(default, skip_serializing_if = "Option::is_none")]
pub script: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<Vec<InterpString>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<InterpString>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, InterpString>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_env_vars: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tls: Option<HookTlsMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tool_rounds: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<HookAgentMarker>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum HookTlsMode {
#[default]
Verify,
NoVerify,
Off,
}
/// Reserved marker for hook entries that use the `agent` hook type. Having
/// this as its own field rather than a flag lets `HookEntry` remain a flat
/// struct without a discriminator.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum HookAgentMarker {
#[default]
Enabled,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEvent {
@ -944,42 +520,6 @@ pub enum HookEvent {
PostToolUseFailure,
}
/// `[run.scm]` — remote SCM host/provider behavior.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RunScmLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repository: Option<InterpString>,
/// Provider-specific SCM leaves. First-pass providers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<ScmGitHubLayer>,
}
/// `[run.scm.github]` — GitHub-specific SCM leaf. Intentionally minimal in
/// the first pass; additional branch/checkout context stays on `run` or
/// `run.pull_request` until a concrete use case lands.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ScmGitHubLayer;
/// `[run.pull_request]` — provider-neutral PR behavior.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RunPullRequestLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draft: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_merge: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merge_strategy: Option<MergeStrategy>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MergeStrategy {
@ -987,11 +527,3 @@ pub enum MergeStrategy {
Merge,
Rebase,
}
/// `[run.artifacts]` — run artifact collection policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RunArtifactsLayer {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub include: Vec<String>,
}

View file

@ -15,7 +15,6 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::duration::Duration as DurationLayer;
use super::interp::InterpString;
use super::maps::StickyMap;
/// A structurally resolved `[server]` view for consumers.
///
@ -306,147 +305,6 @@ where
Ok(DurationLayer::deserialize(deserializer)?.as_std())
}
/// A sparse `[server]` layer as it appears in a single settings file.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ServerLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub listen: Option<ServerListenLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api: Option<ServerApiLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub web: Option<ServerWebLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<ServerAuthLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ip_allowlist: Option<ServerIpAllowlistLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<ServerStorageLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifacts: Option<ServerArtifactsLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slatedb: Option<ServerSlateDbLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scheduler: Option<ServerSchedulerLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logging: Option<ServerLoggingLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub integrations: Option<ServerIntegrationsLayer>,
}
/// `[server.listen]` — shared bind transport.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "type", rename_all = "lowercase")]
pub(crate) enum ServerListenLayer {
Tcp {
#[serde(default)]
address: Option<InterpString>,
},
Unix {
#[serde(default)]
path: Option<InterpString>,
},
}
/// `[server.api]` — API surface settings.
///
/// `url` is an optional public URL; it is **not** derived from `server.listen`.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ServerApiLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<InterpString>,
}
/// `[server.web]` — web surface settings.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ServerWebLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<InterpString>,
}
/// `[server.auth]` — cohesive server auth surface.
///
/// When absent or resolved to no enabled API or web auth configuration, the
/// default server startup posture is fail-closed. Demo and test helpers may
/// explicitly opt in to insecure configurations.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ServerAuthLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub methods: Option<Vec<ServerAuthMethod>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<ServerAuthGithubLayer>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ServerAuthGithubLayer {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_usernames: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ServerIpAllowlistLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entries: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trusted_proxy_count: Option<u32>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ServerIpAllowlistOverrideLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entries: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trusted_proxy_count: Option<u32>,
}
/// `[server.storage]` — single managed local disk root.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ServerStorageLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<InterpString>,
}
/// `[server.artifacts]` — object-store-backed artifact storage.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ServerArtifactsLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<ObjectStoreProvider>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local: Option<ObjectStoreLocalLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub s3: Option<ObjectStoreS3Layer>,
}
/// `[server.slatedb]` — SlateDB bottomless storage plus tunables.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ServerSlateDbLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<ObjectStoreProvider>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flush_interval: Option<DurationLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub local: Option<ObjectStoreLocalLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub s3: Option<ObjectStoreS3Layer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disk_cache: Option<bool>,
}
/// Closed enum of object-store providers. Unknown providers hard-fail
/// against the schema rather than passing through as opaque strings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -456,117 +314,6 @@ pub enum ObjectStoreProvider {
S3,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ObjectStoreLocalLayer {
/// Overrides the default root, which otherwise falls back to
/// `{server.storage.root}/objects/{domain}`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<InterpString>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ObjectStoreS3Layer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bucket: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_style: Option<bool>,
}
/// `[server.scheduler]` — server-managed execution policy.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ServerSchedulerLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrent_runs: Option<usize>,
}
/// `[server.logging]` — process-owned logging configuration for the server.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ServerLoggingLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub level: Option<String>,
}
/// `[server.integrations.<provider>]` — cohesive integration surface for chat
/// platforms and git providers (GitHub App, webhooks, etc.). First-pass
/// integrations enumerate known providers rather than using a flatten-HashMap
/// shape so strict unknown-field validation still holds.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ServerIntegrationsLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub github: Option<GithubIntegrationLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slack: Option<SlackIntegrationLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discord: Option<DiscordIntegrationLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub teams: Option<TeamsIntegrationLayer>,
}
/// `[server.integrations.github]` — GitHub App, credentials, and inbound
/// webhooks.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct GithubIntegrationLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strategy: Option<GithubIntegrationStrategy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_id: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_id: Option<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slug: Option<InterpString>,
#[serde(default, skip_serializing_if = "StickyMap::is_empty")]
pub permissions: StickyMap<InterpString>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webhooks: Option<IntegrationWebhooksLayer>,
}
/// `[server.integrations.slack]` — Slack workspace credentials and defaults.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct SlackIntegrationLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_channel: Option<InterpString>,
}
/// `[server.integrations.discord]` — Discord workspace configuration.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct DiscordIntegrationLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
/// `[server.integrations.teams]` — Microsoft Teams configuration.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct TeamsIntegrationLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct IntegrationWebhooksLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strategy: Option<WebhookStrategy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ip_allowlist: Option<ServerIpAllowlistOverrideLayer>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GithubIntegrationStrategy {

View file

@ -1,261 +0,0 @@
//! Splice-capable string arrays.
//!
//! In declared splice-capable array paths, the literal string value `"..."`
//! is reserved: it represents "splice in inherited values from lower-precedence
//! layers here." At most one `"..."` marker may appear per array. In the base
//! layer with no inherited parent, the marker resolves to an empty inherited
//! segment. In non-splice paths the same literal is a hard error — enforced
//! by using the plain `Vec<String>` type elsewhere and this type only where
//! splice semantics are explicitly allowed.
use std::fmt;
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// The reserved literal that marks the splice insertion point.
pub(crate) const SPLICE_MARKER: &str = "...";
/// A string array that may contain at most one splice marker.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) struct SpliceArray {
entries: Vec<Entry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Entry {
Value(String),
Splice,
}
/// An error returned when a splice array fails validation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SpliceArrayError {
/// The array contained more than one splice marker.
MultipleMarkers,
}
impl fmt::Display for SpliceArrayError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MultipleMarkers => {
f.write_str(r#"splice array must contain at most one "..." marker"#)
}
}
}
}
impl std::error::Error for SpliceArrayError {}
impl SpliceArray {
/// Build a splice array from a raw `Vec<String>`.
pub(crate) fn from_raw(raw: Vec<String>) -> Result<Self, SpliceArrayError> {
let mut entries = Vec::with_capacity(raw.len());
let mut marker_count = 0;
for item in raw {
if item == SPLICE_MARKER {
marker_count += 1;
entries.push(Entry::Splice);
} else {
entries.push(Entry::Value(item));
}
}
if marker_count > 1 {
return Err(SpliceArrayError::MultipleMarkers);
}
Ok(Self { entries })
}
/// Build a splice array with no inherited splice marker.
#[must_use]
pub(crate) fn from_values(values: impl IntoIterator<Item = String>) -> Self {
Self {
entries: values.into_iter().map(Entry::Value).collect(),
}
}
/// True when the array contains a splice marker.
#[must_use]
pub(crate) fn has_splice(&self) -> bool {
self.entries.iter().any(|e| matches!(e, Entry::Splice))
}
/// The index of the splice marker, if present.
#[must_use]
pub(crate) fn splice_position(&self) -> Option<usize> {
self.entries.iter().position(|e| matches!(e, Entry::Splice))
}
/// The non-splice values, in source order.
#[must_use]
pub(crate) fn values(&self) -> Vec<&str> {
self.entries
.iter()
.filter_map(|e| match e {
Entry::Value(v) => Some(v.as_str()),
Entry::Splice => None,
})
.collect()
}
/// Resolve this array against an inherited lower-precedence value list.
///
/// - If the array has a splice marker, the inherited list is spliced in at
/// the marker position.
/// - If the array has no splice marker, it replaces the inherited list
/// wholesale.
#[must_use]
pub(crate) fn resolve(self, inherited: Vec<String>) -> Vec<String> {
let Some(pos) = self.splice_position() else {
return self
.entries
.into_iter()
.filter_map(|e| match e {
Entry::Value(v) => Some(v),
Entry::Splice => None,
})
.collect();
};
let mut prefix = Vec::new();
let mut suffix = Vec::new();
for (i, entry) in self.entries.into_iter().enumerate() {
match entry {
Entry::Value(v) => {
if i < pos {
prefix.push(v);
} else {
suffix.push(v);
}
}
Entry::Splice => {}
}
}
let mut out = prefix;
out.extend(inherited);
out.extend(suffix);
out
}
}
impl Serialize for SpliceArray {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeSeq;
let mut seq = serializer.serialize_seq(Some(self.entries.len()))?;
for entry in &self.entries {
match entry {
Entry::Value(v) => seq.serialize_element(v)?,
Entry::Splice => seq.serialize_element(SPLICE_MARKER)?,
}
}
seq.end()
}
}
impl<'de> Deserialize<'de> for SpliceArray {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct SpliceArrayVisitor;
impl<'de> Visitor<'de> for SpliceArrayVisitor {
type Value = SpliceArray;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(
r#"an array of strings, optionally containing a single "..." splice marker"#,
)
}
fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<SpliceArray, A::Error> {
let mut raw: Vec<String> = Vec::new();
while let Some(item) = seq.next_element::<String>()? {
raw.push(item);
}
SpliceArray::from_raw(raw).map_err(de::Error::custom)
}
}
deserializer.deserialize_seq(SpliceArrayVisitor)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_raw_with_no_marker() {
let arr = SpliceArray::from_raw(vec!["a".into(), "b".into()]).unwrap();
assert!(!arr.has_splice());
assert_eq!(arr.values(), vec!["a", "b"]);
}
#[test]
fn append_marker_at_front() {
let arr = SpliceArray::from_raw(vec!["...".into(), "c".into()]).unwrap();
assert_eq!(arr.splice_position(), Some(0));
let resolved = arr.resolve(vec!["a".into(), "b".into()]);
assert_eq!(resolved, vec!["a", "b", "c"]);
}
#[test]
fn prepend_marker_at_back() {
let arr = SpliceArray::from_raw(vec!["a".into(), "...".into()]).unwrap();
assert_eq!(arr.splice_position(), Some(1));
let resolved = arr.resolve(vec!["b".into(), "c".into()]);
assert_eq!(resolved, vec!["a", "b", "c"]);
}
#[test]
fn marker_in_middle() {
let arr = SpliceArray::from_raw(vec!["pre".into(), "...".into(), "post".into()]).unwrap();
let resolved = arr.resolve(vec!["mid".into()]);
assert_eq!(resolved, vec!["pre", "mid", "post"]);
}
#[test]
fn replace_semantics_without_marker() {
let arr = SpliceArray::from_raw(vec!["only".into()]).unwrap();
let resolved = arr.resolve(vec!["inherited".into()]);
assert_eq!(resolved, vec!["only"]);
}
#[test]
fn multiple_markers_rejected() {
let err = SpliceArray::from_raw(vec!["...".into(), "...".into()]).unwrap_err();
assert_eq!(err, SpliceArrayError::MultipleMarkers);
}
#[test]
fn base_layer_with_splice_resolves_to_empty_inherited() {
let arr = SpliceArray::from_raw(vec!["...".into(), "b".into()]).unwrap();
let resolved = arr.resolve(vec![]);
assert_eq!(resolved, vec!["b"]);
}
#[test]
fn serde_round_trip_via_json() {
#[derive(Debug, serde::Deserialize, serde::Serialize, PartialEq)]
struct Wrap {
a: SpliceArray,
}
let input = r#"{"a":["...","b"]}"#;
let parsed: Wrap = serde_json::from_str(input).unwrap();
assert!(parsed.a.has_splice());
let rendered = serde_json::to_string(&parsed).unwrap();
assert_eq!(rendered, input);
}
#[test]
fn serde_rejects_multiple_markers() {
#[derive(Debug, serde::Deserialize)]
struct Wrap {
_a: SpliceArray,
}
let input = r#"{"_a":["...","..."]}"#;
let err = serde_json::from_str::<Wrap>(input).unwrap_err();
assert!(err.to_string().contains("at most one"));
}
}

View file

@ -7,8 +7,6 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use super::maps::ReplaceMap;
/// A structurally resolved `[workflow]` view for consumers.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct WorkflowNamespace {
@ -17,18 +15,3 @@ pub struct WorkflowNamespace {
pub graph: String,
pub metadata: HashMap<String, String>,
}
/// A sparse `[workflow]` layer as it appears in a single settings file.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct WorkflowLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Optional override for the default `workflow.fabro` graph path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph: Option<String>,
#[serde(default, skip_serializing_if = "ReplaceMap::is_empty")]
pub metadata: ReplaceMap<String>,
}