mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
clean up settings warning fallout
This commit is contained in:
parent
6fc7251471
commit
f16becd2f7
17 changed files with 38 additions and 1990 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) = ¤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<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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
},)]))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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>,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue