diff --git a/lib/crates/fabro-config/src/builders.rs b/lib/crates/fabro-config/src/builders.rs index ed714a5aa..f3639b137 100644 --- a/lib/crates/fabro-config/src/builders.rs +++ b/lib/crates/fabro-config/src/builders.rs @@ -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 { - 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(value: T, context: &'static str, errors: Vec) -> Result { diff --git a/lib/crates/fabro-config/src/layers/splice_array.rs b/lib/crates/fabro-config/src/layers/splice_array.rs index 51a033907..abcfb24c8 100644 --- a/lib/crates/fabro-config/src/layers/splice_array.rs +++ b/lib/crates/fabro-config/src/layers/splice_array.rs @@ -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` 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, -} - -#[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`. - pub(crate) fn from_raw(raw: Vec) -> Result { - 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 { - 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) -> Vec { - 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(&self, serializer: S) -> Result { - 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>(deserializer: D) -> Result { - 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>(self, mut seq: A) -> Result { - let mut raw: Vec = Vec::new(); - while let Some(item) = seq.next_element::()? { - 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::(input).unwrap_err(); - assert!(err.to_string().contains("at most one")); - } -} diff --git a/lib/crates/fabro-config/src/project.rs b/lib/crates/fabro-config/src/project.rs index 326ab2062..e34cb9346 100644 --- a/lib/crates/fabro-config/src/project.rs +++ b/lib/crates/fabro-config/src/project.rs @@ -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 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, ); diff --git a/lib/crates/fabro-config/src/resolve/mod.rs b/lib/crates/fabro-config/src/resolve/mod.rs index 88edbd070..0a9ca6528 100644 --- a/lib/crates/fabro-config/src/resolve/mod.rs +++ b/lib/crates/fabro-config/src/resolve/mod.rs @@ -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; diff --git a/lib/crates/fabro-config/src/resolve/server.rs b/lib/crates/fabro-config/src/resolve/server.rs index 891d575a1..eeb8371ca 100644 --- a/lib/crates/fabro-config/src/resolve/server.rs +++ b/lib/crates/fabro-config/src/resolve/server.rs @@ -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) -> ServerNamespace { let storage = resolve_storage(layer.storage.as_ref()); let listen = resolve_listen(layer.listen.as_ref(), errors); diff --git a/lib/crates/fabro-config/src/run.rs b/lib/crates/fabro-config/src/run.rs index bb512d943..86e4f0866 100644 --- a/lib/crates/fabro-config/src/run.rs +++ b/lib/crates/fabro-config/src/run.rs @@ -68,17 +68,6 @@ impl std::error::Error for ResolveRunGoalError { } } -pub(crate) fn resolve_run_goal( - settings: &SettingsLayer, - base_dir: &Path, -) -> std::result::Result, 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, diff --git a/lib/crates/fabro-config/src/tests/resolve_server.rs b/lib/crates/fabro-config/src/tests/resolve_server.rs index fb159b6f5..febec8178 100644 --- a/lib/crates/fabro-config/src/tests/resolve_server.rs +++ b/lib/crates/fabro-config/src/tests/resolve_server.rs @@ -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 diff --git a/lib/crates/fabro-types/src/settings/cli.rs b/lib/crates/fabro-types/src/settings/cli.rs index 65f3055a9..19aa30d76 100644 --- a/lib/crates/fabro-types/src/settings/cli.rs +++ b/lib/crates/fabro-types/src/settings/cli.rs @@ -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, } -/// 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub exec: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub output: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub updates: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub logging: Option, -} - -/// `[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, - }, - Unix { - #[serde(default)] - path: Option, - }, -} - -/// `[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, -} - #[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent: Option, -} - -#[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, -} - -#[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, - /// Agent-scoped MCP entries for `fabro exec`. - #[serde(default, skip_serializing_if = "StickyMap::is_empty")] - pub mcps: StickyMap, -} - -/// `[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub verbosity: Option, -} - #[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, -} - -/// `[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, -} diff --git a/lib/crates/fabro-types/src/settings/combine.rs b/lib/crates/fabro-types/src/settings/combine.rs deleted file mode 100644 index 9a781d955..000000000 --- a/lib/crates/fabro-types/src/settings/combine.rs +++ /dev/null @@ -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 Combine for Option { - 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> { - fn combine(self, other: Self) -> Self { - self.or(other) - } -} - -impl Combine for Option> { - fn combine(self, other: Self) -> Self { - self.or(other) - } -} - -impl Combine for Option> { - 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 Combine for Vec { - fn combine(self, other: Self) -> Self { - splice_combine(other, self) - } -} - -impl Combine for Vec { - fn combine(self, other: Self) -> Self { - combine_hooks(&other, self) - } -} - -fn splice_combine(fallback: Vec, current: Vec) -> Vec { - 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) -> Vec { - 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) = ¤t_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, - b: Option, - } - - 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, - b: Option, - } - - impl Combine for WholeReplaceLayer { - fn combine(self, _other: Self) -> Self { - self - } - } - - #[track_caller] - fn assert_option_leaf(this: T, fallback: T) - where - T: Clone + std::fmt::Debug + PartialEq, - Option: Combine, - { - assert_eq!( - Some(this.clone()).combine(Some(fallback.clone())), - Some(this) - ); - assert_eq!( - Option::::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, - }) - ); - } -} diff --git a/lib/crates/fabro-types/src/settings/features.rs b/lib/crates/fabro-types/src/settings/features.rs index 29df5e09c..bd82b630f 100644 --- a/lib/crates/fabro-types/src/settings/features.rs +++ b/lib/crates/fabro-types/src/settings/features.rs @@ -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` 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, -} diff --git a/lib/crates/fabro-types/src/settings/maps.rs b/lib/crates/fabro-types/src/settings/maps.rs deleted file mode 100644 index 21db83484..000000000 --- a/lib/crates/fabro-types/src/settings/maps.rs +++ /dev/null @@ -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(pub HashMap); - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(transparent)] -pub(crate) struct StickyMap(pub HashMap); - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(transparent)] -pub(crate) struct MergeMap(pub HashMap); - -macro_rules! impl_map_wrapper { - ($name:ident) => { - impl $name { - #[must_use] - pub(crate) fn is_empty(&self) -> bool { - self.0.is_empty() - } - - #[must_use] - pub(crate) fn into_inner(self) -> HashMap { - self.0 - } - } - - impl Deref for $name { - type Target = HashMap; - - fn deref(&self) -> &Self::Target { - &self.0 - } - } - - impl DerefMut for $name { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } - } - - impl From> for $name { - fn from(value: HashMap) -> Self { - Self(value) - } - } - - impl Default for $name { - fn default() -> Self { - Self(HashMap::new()) - } - } - - impl IntoIterator for $name { - type IntoIter = IntoIter; - 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 Combine for ReplaceMap { - fn combine(self, other: Self) -> Self { - if self.0.is_empty() { other } else { self } - } -} - -impl Combine for StickyMap { - fn combine(self, other: Self) -> Self { - let mut combined = other.0; - for (key, value) in self.0 { - combined.insert(key, value); - } - Self(combined) - } -} - -impl Combine for MergeMap { - 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, - b: Option, - } - - 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::(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 = - 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 = - 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()), - },)])) - ); - } -} diff --git a/lib/crates/fabro-types/src/settings/mod.rs b/lib/crates/fabro-types/src/settings/mod.rs index 99da38e81..81c31f134 100644 --- a/lib/crates/fabro-types/src/settings/mod.rs +++ b/lib/crates/fabro-types/src/settings/mod.rs @@ -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::{ diff --git a/lib/crates/fabro-types/src/settings/project.rs b/lib/crates/fabro-types/src/settings/project.rs index 470076b8e..7e141dbcb 100644 --- a/lib/crates/fabro-types/src/settings/project.rs +++ b/lib/crates/fabro-types/src/settings/project.rs @@ -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, } - -/// 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - /// 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, - #[serde(default, skip_serializing_if = "ReplaceMap::is_empty")] - pub metadata: ReplaceMap, -} diff --git a/lib/crates/fabro-types/src/settings/run.rs b/lib/crates/fabro-types/src/settings/run.rs index a1033eb15..1d4626aef 100644 --- a/lib/crates/fabro-types/src/settings/run.rs +++ b/lib/crates/fabro-types/src/settings/run.rs @@ -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, } +#[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, } - -/// 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub working_dir: Option, - /// Flat string-to-string map. Replaces wholesale across layers. - #[serde(default, skip_serializing_if = "ReplaceMap::is_empty")] - pub metadata: ReplaceMap, - /// Run inputs: typed scalar values. Replaces wholesale across layers. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inputs: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub git: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prepare: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub execution: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub checkpoint: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sandbox: Option, - #[serde(default, skip_serializing_if = "MergeMap::is_empty")] - pub notifications: MergeMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub interviews: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub hooks: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub scm: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub pull_request: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifacts: Option, -} - -/// 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - /// 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, -} - -/// 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(&self, serializer: S) -> Result { - 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>(deserializer: D) -> Result { - use serde::de::Error; - let raw = String::deserialize(deserializer)?; - if raw == super::splice_array::SPLICE_MARKER { - return Ok(Self::Splice); - } - let model = raw.parse::().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, -} - -#[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub email: Option, -} - -/// `[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, - /// Optional timeout applied to each prepare step. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout: Option, -} - /// 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, } -/// `[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub approval: Option, - /// Positive-form: `true` runs retros, `false` skips them. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub retros: Option, -} - #[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, -} - -/// `[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preserve: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub devcontainer: Option, - /// Sticky merge-by-key across layers. - #[serde(default, skip_serializing_if = "StickyMap::is_empty")] - pub env: StickyMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub daytona: Option, -} - -#[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, -} - #[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, - /// Sticky merge-by-key (provider-native labels). - #[serde(default, skip_serializing_if = "StickyMap::is_empty")] - pub labels: StickyMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub snapshot: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub network: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub skip_clone: Option, -} - -#[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub cpu: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub memory: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disk: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dockerfile: Option, -} - -#[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 }, } -/// `[run.notifications.]` — 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider: Option, - /// Raw Fabro event names. Splice marker supported at layering time. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub events: Vec, - /// Provider-specific destination subtables. First-pass chat providers. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slack: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub discord: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub teams: Option, -} - -/// 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(&self, serializer: S) -> Result { - 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>(deserializer: D) -> Result { - 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, -} - -/// `[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slack: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub discord: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub teams: Option, -} - -#[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, -} - -/// `[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, - /// Agent-scoped MCP server entries, keyed by name. - #[serde(default, skip_serializing_if = "StickyMap::is_empty")] - pub mcps: StickyMap, -} - #[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, - url: InterpString, - #[serde(default)] - headers: HashMap, - #[serde(default)] - startup_timeout: Option, - #[serde(default)] - tool_timeout: Option, - }, - Stdio { - #[serde(default)] - enabled: Option, - #[serde(default)] - script: Option, - #[serde(default)] - command: Option>, - #[serde(default)] - env: HashMap, - #[serde(default)] - startup_timeout: Option, - #[serde(default)] - tool_timeout: Option, - }, - Sandbox { - #[serde(default)] - enabled: Option, - #[serde(default)] - script: Option, - #[serde(default)] - command: Option>, - port: u16, - #[serde(default)] - env: HashMap, - #[serde(default)] - startup_timeout: Option, - #[serde(default)] - tool_timeout: Option, - }, -} - -/// 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, - /// Display-only human name. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - pub event: HookEvent, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub matcher: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub blocking: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub sandbox: Option, - // Exactly one of the following groups is expected: - #[serde(default, skip_serializing_if = "Option::is_none")] - pub script: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub command: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub url: Option, - #[serde(default, skip_serializing_if = "HashMap::is_empty")] - pub headers: HashMap, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub allowed_env_vars: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tls: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prompt: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_tool_rounds: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub agent: Option, -} - -#[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub owner: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub repository: Option, - /// Provider-specific SCM leaves. First-pass providers. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, -} - -/// `[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub draft: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auto_merge: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub merge_strategy: Option, -} - #[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, -} diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs index 9e49f0486..410a02dd6 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub api: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub web: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub auth: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ip_allowlist: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub storage: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub artifacts: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slatedb: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub scheduler: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub logging: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub integrations: Option, -} - -/// `[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, - }, - Unix { - #[serde(default)] - path: Option, - }, -} - -/// `[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, -} - -/// `[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub url: Option, -} - -/// `[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>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub github: Option, -} - -#[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, -} - -#[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>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trusted_proxy_count: Option, -} - -#[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>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trusted_proxy_count: Option, -} - -/// `[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, -} - -/// `[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prefix: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub s3: Option, -} - -/// `[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub prefix: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub flush_interval: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub local: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub s3: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub disk_cache: Option, -} - /// 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, -} - -#[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub region: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub endpoint: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path_style: Option, -} - -/// `[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, -} - -/// `[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, -} - -/// `[server.integrations.]` — 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slack: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub discord: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub teams: Option, -} - -/// `[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub strategy: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub app_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub slug: Option, - #[serde(default, skip_serializing_if = "StickyMap::is_empty")] - pub permissions: StickyMap, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub webhooks: Option, -} - -/// `[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub default_channel: Option, -} - -/// `[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, -} - -/// `[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, -} - -#[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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ip_allowlist: Option, -} - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum GithubIntegrationStrategy { diff --git a/lib/crates/fabro-types/src/settings/splice_array.rs b/lib/crates/fabro-types/src/settings/splice_array.rs deleted file mode 100644 index ed435be5e..000000000 --- a/lib/crates/fabro-types/src/settings/splice_array.rs +++ /dev/null @@ -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` 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, -} - -#[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`. - pub(crate) fn from_raw(raw: Vec) -> Result { - 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) -> 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 { - 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) -> Vec { - 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(&self, serializer: S) -> Result { - 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>(deserializer: D) -> Result { - 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>(self, mut seq: A) -> Result { - let mut raw: Vec = Vec::new(); - while let Some(item) = seq.next_element::()? { - 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::(input).unwrap_err(); - assert!(err.to_string().contains("at most one")); - } -} diff --git a/lib/crates/fabro-types/src/settings/workflow.rs b/lib/crates/fabro-types/src/settings/workflow.rs index 4f356d6c3..fd3a5b8b9 100644 --- a/lib/crates/fabro-types/src/settings/workflow.rs +++ b/lib/crates/fabro-types/src/settings/workflow.rs @@ -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, } - -/// 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - /// Optional override for the default `workflow.fabro` graph path. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub graph: Option, - #[serde(default, skip_serializing_if = "ReplaceMap::is_empty")] - pub metadata: ReplaceMap, -}