cellstate_pipeline/config/
parser.rs

1//! Config parser for Markdown fence blocks
2//! Uses serde_yaml for ALL parsing (no custom mini-syntax)
3
4use crate::ast::*;
5use crate::pack::PackError;
6use cellstate_core::SecretString;
7use serde::{Deserialize, Serialize};
8
9// ============================================================================
10// ERROR TYPES
11// ============================================================================
12
13#[derive(Debug)]
14pub enum ConfigError {
15    YamlParse(String),
16    NameConflict(String),
17    MissingName(String),
18    InvalidValue(String),
19    UnknownProvider(String),
20    UnknownAdapter(String),
21}
22
23impl std::fmt::Display for ConfigError {
24    /// Formats a ConfigError into a human-readable message.
25    ///
26    /// Each variant is rendered as "<prefix>: <message>", for example
27    /// "YAML parse error: ..." or "Missing name: ...".
28    ///
29    /// # Examples
30    ///
31    /// ```
32    /// use cellstate_pipeline::ConfigError;
33    /// let err = ConfigError::MissingName("memory".into());
34    /// assert_eq!(format!("{}", err), "Missing name: memory");
35    /// ```
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            ConfigError::YamlParse(msg) => write!(f, "YAML parse error: {}", msg),
39            ConfigError::NameConflict(msg) => write!(f, "Name conflict: {}", msg),
40            ConfigError::MissingName(msg) => write!(f, "Missing name: {}", msg),
41            ConfigError::InvalidValue(msg) => write!(f, "Invalid value: {}", msg),
42            ConfigError::UnknownProvider(msg) => write!(f, "Unknown provider: {}", msg),
43            ConfigError::UnknownAdapter(msg) => write!(f, "Unknown adapter: {}", msg),
44        }
45    }
46}
47
48impl std::error::Error for ConfigError {}
49
50impl From<ConfigError> for PackError {
51    /// Convert a `ConfigError` into a `PackError` by mapping it to `PackError::Validation`
52    /// containing the error's string representation.
53    ///
54    /// # Examples
55    ///
56    /// ```
57    /// use cellstate_pipeline::{ConfigError, PackError};
58    /// let cfg_err = ConfigError::MissingName("adapter".into());
59    /// let pack_err: PackError = cfg_err.into();
60    /// assert!(matches!(pack_err, PackError::Validation(_)));
61    /// ```
62    fn from(err: ConfigError) -> Self {
63        PackError::Validation(err.to_string())
64    }
65}
66
67// ============================================================================
68// NAME RESOLUTION HELPER
69// ============================================================================
70
71/// Resolves the name of a fence block from the optional header name and the
72/// optional payload name field. The header name takes precedence when present;
73/// if both are provided, a conflict error is returned. If neither is provided,
74/// a missing-name error is returned.
75fn resolve_block_name(
76    header_name: Option<&str>,
77    payload_name: &Option<String>,
78    block_kind: &str,
79) -> Result<String, ConfigError> {
80    match (header_name, payload_name) {
81        (Some(header), None) => Ok(header.to_owned()),
82        (Some(_), Some(_)) => Err(ConfigError::NameConflict(format!(
83            "Name in both fence header and YAML payload for {}",
84            block_kind
85        ))),
86        (None, Some(name)) => Ok(name.clone()),
87        (None, None) => Err(ConfigError::MissingName(format!(
88            "{} block has no name in header or payload",
89            block_kind
90        ))),
91    }
92}
93
94// ============================================================================
95// CONFIG STRUCTS (The Schema)
96// ============================================================================
97
98#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
99#[serde(deny_unknown_fields)]
100pub struct AdapterConfig {
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub name: Option<String>,
103    pub adapter_type: String,
104    pub connection: String,
105    #[serde(default)]
106    pub options: Vec<(String, String)>,
107}
108
109#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
110#[serde(deny_unknown_fields)]
111pub struct MemoryConfig {
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub name: Option<String>,
114    pub memory_type: String,
115    #[serde(default)]
116    pub schema: Vec<FieldConfig>,
117    pub retention: String,
118    pub lifecycle: String,
119    #[serde(default)]
120    pub parent: Option<String>,
121    #[serde(default)]
122    pub indexes: Vec<IndexConfig>,
123    #[serde(default)]
124    pub inject_on: Vec<String>,
125    #[serde(default)]
126    pub artifacts: Vec<String>,
127    #[serde(default)]
128    pub modifiers: Vec<String>,
129}
130
131#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
132#[serde(deny_unknown_fields)]
133pub struct FieldConfig {
134    pub name: String,
135    /// Field type (accepts both "field_type" and "type" for compatibility)
136    #[serde(alias = "type")]
137    pub field_type: String,
138    #[serde(default)]
139    pub nullable: bool,
140    #[serde(default)]
141    pub default: Option<String>,
142}
143
144#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
145#[serde(deny_unknown_fields)]
146pub struct IndexConfig {
147    pub field: String,
148    /// Index type (accepts both "index_type" and "type" for compatibility)
149    #[serde(alias = "type")]
150    pub index_type: String,
151    #[serde(default)]
152    pub options: Vec<(String, String)>,
153}
154
155#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
156#[serde(deny_unknown_fields)]
157pub struct PolicyConfig {
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub name: Option<String>,
160    pub rules: Vec<PolicyRuleConfig>,
161}
162
163#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
164#[serde(deny_unknown_fields)]
165pub struct PolicyRuleConfig {
166    pub trigger: String,
167    pub actions: Vec<ActionConfig>,
168}
169
170#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
171#[serde(deny_unknown_fields)]
172#[serde(tag = "type")]
173pub enum ActionConfig {
174    #[serde(rename = "summarize")]
175    Summarize { target: String },
176    #[serde(rename = "checkpoint")]
177    Checkpoint { target: String },
178    #[serde(rename = "extract_artifacts")]
179    ExtractArtifacts { target: String },
180    #[serde(rename = "notify")]
181    Notify { target: String },
182    #[serde(rename = "inject")]
183    Inject { target: String, mode: String },
184}
185
186#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
187#[serde(deny_unknown_fields)]
188pub struct InjectionConfig {
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub name: Option<String>,
191    pub source: String,
192    pub target: String,
193    pub mode: String,
194    pub priority: i32,
195    #[serde(default)]
196    pub max_tokens: Option<i32>,
197}
198
199#[derive(Clone, Deserialize, Serialize, PartialEq)]
200#[serde(deny_unknown_fields)]
201pub struct ProviderConfig {
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub name: Option<String>,
204    pub provider_type: String,
205    pub api_key: SecretString,
206    pub model: String,
207    #[serde(default)]
208    pub options: Vec<(String, String)>,
209}
210
211impl std::fmt::Debug for ProviderConfig {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        f.debug_struct("ProviderConfig")
214            .field("name", &self.name)
215            .field("provider_type", &self.provider_type)
216            .field(
217                "api_key",
218                &format!("[REDACTED, {} chars]", self.api_key.len()),
219            )
220            .field("model", &self.model)
221            .field("options", &self.options)
222            .finish()
223    }
224}
225
226#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
227#[serde(deny_unknown_fields)]
228pub struct CacheConfig {
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub name: Option<String>,
231    pub backend: String,
232    #[serde(default)]
233    pub path: Option<String>,
234    pub size_mb: i32,
235    pub default_freshness: FreshnessConfig,
236    #[serde(default)]
237    pub max_entries: Option<i32>,
238    #[serde(default)]
239    pub ttl: Option<String>,
240}
241
242#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
243#[serde(deny_unknown_fields)]
244#[serde(tag = "type")]
245pub enum FreshnessConfig {
246    #[serde(rename = "best_effort")]
247    BestEffort { max_staleness: String },
248    #[serde(rename = "strict")]
249    Strict,
250}
251
252#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
253#[serde(deny_unknown_fields)]
254pub struct TrajectoryConfig {
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub name: Option<String>,
257    #[serde(default)]
258    pub description: Option<String>,
259    pub agent_type: String,
260    pub token_budget: i32,
261    #[serde(default)]
262    pub memory_refs: Vec<String>,
263    #[serde(default)]
264    pub metadata: Option<serde_json::Value>,
265}
266
267#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
268#[serde(deny_unknown_fields)]
269pub struct AgentConfig {
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub name: Option<String>,
272    pub capabilities: Vec<String>,
273    pub constraints: AgentConstraintsConfig,
274    pub permissions: PermissionMatrixConfig,
275}
276
277#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
278#[serde(deny_unknown_fields)]
279pub struct AgentConstraintsConfig {
280    pub max_concurrent: i32,
281    pub timeout_ms: i64,
282}
283
284#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)]
285#[serde(deny_unknown_fields)]
286pub struct PermissionMatrixConfig {
287    #[serde(default)]
288    pub read: Vec<String>,
289    #[serde(default)]
290    pub write: Vec<String>,
291    #[serde(default)]
292    pub lock: Vec<String>,
293}
294
295// ============================================================================
296// INTENT ENGINEERING CONFIG
297// ============================================================================
298
299#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
300#[serde(deny_unknown_fields)]
301pub struct IntentConfig {
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub name: Option<String>,
304    #[serde(default)]
305    pub goals: Vec<String>,
306    #[serde(default)]
307    pub resolution_rules: Vec<ResolutionRuleConfig>,
308    #[serde(default)]
309    pub autonomy_level: Option<String>,
310    #[serde(default)]
311    pub delegation_boundaries: Option<DelegationBoundaryConfig>,
312    #[serde(default)]
313    pub alignment_signals: Vec<AlignmentSignalConfig>,
314    #[serde(default)]
315    pub drift_threshold: Option<f64>,
316}
317
318#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
319#[serde(deny_unknown_fields)]
320pub struct ResolutionRuleConfig {
321    pub condition: String,
322    pub priority: Vec<String>,
323    #[serde(default)]
324    pub escalate_to: Option<String>,
325    #[serde(default)]
326    pub max_authority: f64,
327}
328
329#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
330#[serde(deny_unknown_fields)]
331pub struct DelegationBoundaryConfig {
332    #[serde(default)]
333    pub authorized_actions: Vec<String>,
334    #[serde(default)]
335    pub requires_approval: Vec<String>,
336    #[serde(default)]
337    pub forbidden_actions: Vec<String>,
338}
339
340#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
341#[serde(deny_unknown_fields)]
342pub struct AlignmentSignalConfig {
343    pub name: String,
344    pub source: String,
345    pub metric: String,
346    pub target: SignalTargetConfig,
347}
348
349#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
350#[serde(deny_unknown_fields)]
351#[serde(tag = "type")]
352pub enum SignalTargetConfig {
353    #[serde(rename = "above")]
354    Above { value: f64 },
355    #[serde(rename = "below")]
356    Below { value: f64 },
357    #[serde(rename = "between")]
358    Between { min: f64, max: f64 },
359}
360
361// ============================================================================
362// PARSER FUNCTIONS (serde_yaml does the heavy lifting)
363// ============================================================================
364
365/// Parses an adapter YAML fence block into an `AdapterDef`.
366///
367/// The function deserializes `content` as YAML, determines the adapter name using
368/// the fence header if present (erroring on conflicts or absence), validates the
369/// adapter configuration, and converts it into an `AdapterDef`.
370///
371/// # Errors
372///
373/// Returns a `ConfigError` when:
374/// - YAML deserialization fails (`ConfigError::YamlParse`),
375/// - both a header name and a payload name are provided (`ConfigError::NameConflict`),
376/// - no name is provided (`ConfigError::MissingName`),
377/// - the adapter type is unrecognized (`ConfigError::UnknownAdapter`),
378/// - or other validation failures occur.
379///
380/// # Examples
381///
382/// ```
383/// use cellstate_pipeline::parse_adapter_block;
384///
385/// let header = Some("my_adapter");
386/// let yaml = r#"
387/// adapter_type: "postgres"
388/// connection: "postgres://user:pass@localhost/db"
389/// options: []
390/// "#;
391/// let def = parse_adapter_block(header, yaml).expect("should parse");
392/// assert_eq!(def.name, "my_adapter");
393/// ```
394pub fn parse_adapter_block(
395    header_name: Option<&str>,
396    content: &str,
397) -> Result<AdapterDef, ConfigError> {
398    // Step 1: Deserialize YAML into config struct
399    let config: AdapterConfig =
400        serde_yaml::from_str(content).map_err(|e| ConfigError::YamlParse(e.to_string()))?;
401
402    // Step 2: Enforce name precedence rule
403    let name = resolve_block_name(header_name, &config.name, "adapter")?;
404
405    // Step 3: Validate and convert to AST
406    validate_adapter_config(&config)?;
407
408    let adapter_type = parse_adapter_type(&config.adapter_type)?;
409
410    Ok(AdapterDef {
411        name,
412        adapter_type,
413        connection: config.connection,
414        options: config.options,
415    })
416}
417
418/// Parses a YAML memory block (fence content) into a MemoryDef.
419///
420/// The header-provided name takes precedence when the payload omits a name.
421/// If both header and payload provide names, or if neither provides a name, an error is returned.
422/// Returns an error for invalid YAML or any invalid/unknown values found during conversion.
423///
424/// # Errors
425///
426/// Returns `ConfigError::YamlParse` if the content is not valid YAML; `ConfigError::NameConflict`
427/// if both header and payload specify different names; `ConfigError::MissingName` if no name is provided;
428/// or other `ConfigError` variants produced by value parsing helpers when fields contain invalid values.
429///
430/// # Examples
431///
432/// ```
433/// use cellstate_pipeline::parse_memory_block;
434///
435/// let yaml = r#"
436/// memory_type: episodic
437/// retention: persistent
438/// lifecycle: explicit
439/// schema:
440///   - name: id
441///     field_type: uuid
442///   - name: content
443///     field_type: text
444/// indexes: []
445/// inject_on: []
446/// modifiers: []
447/// "#;
448///
449/// let def = parse_memory_block(Some("my_memory"), yaml).unwrap();
450/// assert_eq!(def.name, "my_memory");
451/// ```
452pub fn parse_memory_block(
453    header_name: Option<&str>,
454    content: &str,
455) -> Result<MemoryDef, ConfigError> {
456    let config: MemoryConfig =
457        serde_yaml::from_str(content).map_err(|e| ConfigError::YamlParse(e.to_string()))?;
458
459    let name = resolve_block_name(header_name, &config.name, "memory")?;
460
461    // Convert to AST
462    let memory_type = parse_memory_type(&config.memory_type)?;
463    let retention = parse_retention(&config.retention)?;
464    let lifecycle = parse_lifecycle(&config.lifecycle)?;
465    let schema = config
466        .schema
467        .into_iter()
468        .map(parse_field_def)
469        .collect::<Result<Vec<_>, _>>()?;
470    let indexes = config
471        .indexes
472        .into_iter()
473        .map(parse_index_def)
474        .collect::<Result<Vec<_>, _>>()?;
475    let inject_on = config
476        .inject_on
477        .into_iter()
478        .map(|s| parse_trigger(&s))
479        .collect::<Result<Vec<_>, _>>()?;
480    let modifiers = config
481        .modifiers
482        .into_iter()
483        .map(parse_modifier)
484        .collect::<Result<Vec<_>, _>>()?;
485
486    Ok(MemoryDef {
487        name,
488        memory_type,
489        schema,
490        retention,
491        lifecycle,
492        parent: config.parent,
493        indexes,
494        inject_on,
495        artifacts: config.artifacts,
496        modifiers,
497    })
498}
499
500/// Parses a policy YAML block from a Markdown fence into a PolicyDef.
501///
502/// The function deserializes `content` as YAML into a PolicyConfig, applies name precedence
503/// (the fence `header_name` is used when present; an explicit payload name conflicts with the
504/// header; an absent name in both locations is an error), converts each policy rule into the
505/// AST via `parse_policy_rule`, and returns a `PolicyDef` with the resolved name and rules.
506///
507/// # Errors
508///
509/// Returns a `ConfigError::YamlParse` if YAML deserialization fails, `ConfigError::NameConflict`
510/// if both header and payload provide different names, `ConfigError::MissingName` if no name is
511/// supplied, or any `ConfigError` produced while parsing policy rules.
512///
513/// # Examples
514///
515/// ```
516/// use cellstate_pipeline::parse_policy_block;
517///
518/// let yaml = r#"
519/// name: example-policy
520/// rules:
521///   - trigger: task_end
522///     actions:
523///       - type: summarize
524///         target: "summary-target"
525/// "#;
526/// let def = parse_policy_block(None, yaml).unwrap();
527/// assert_eq!(def.name, "example-policy");
528/// assert_eq!(def.rules.len(), 1);
529/// ```
530pub fn parse_policy_block(
531    header_name: Option<&str>,
532    content: &str,
533) -> Result<PolicyDef, ConfigError> {
534    let config: PolicyConfig =
535        serde_yaml::from_str(content).map_err(|e| ConfigError::YamlParse(e.to_string()))?;
536
537    let name = resolve_block_name(header_name, &config.name, "policy")?;
538
539    let rules = config
540        .rules
541        .into_iter()
542        .map(parse_policy_rule)
543        .collect::<Result<Vec<_>, _>>()?;
544
545    Ok(PolicyDef { name, rules })
546}
547
548/// Parses an InjectionConfig YAML block and converts it into an InjectionDef.
549///
550/// If both a header name and a `name` field in the YAML payload are present, this returns
551/// a `ConfigError::NameConflict`. The returned `InjectionDef` contains the parsed
552/// source, target, injection mode, priority, and optional max_tokens. `filter` is unset.
553///
554/// # Examples
555///
556/// ```
557/// use cellstate_pipeline::parse_injection_block;
558///
559/// let yaml = r#"
560/// source: "memory_a"
561/// target: "agent_b"
562/// mode: "full"
563/// priority: 10
564/// "#;
565/// let def = parse_injection_block(None, yaml).unwrap();
566/// assert_eq!(def.source, "memory_a");
567/// assert_eq!(def.target, "agent_b");
568/// assert_eq!(def.priority, 10);
569/// ```
570pub fn parse_injection_block(
571    header_name: Option<&str>,
572    content: &str,
573) -> Result<InjectionDef, ConfigError> {
574    let config: InjectionConfig =
575        serde_yaml::from_str(content).map_err(|e| ConfigError::YamlParse(e.to_string()))?;
576
577    // Note: Injection might not need a name (it's identified by source/target)
578    // But we'll enforce name precedence if a name is provided
579    if let (Some(_), Some(_)) = (header_name, &config.name) {
580        return Err(ConfigError::NameConflict(
581            "Name in both fence header and YAML payload for injection".to_string(),
582        ));
583    }
584
585    let mode = parse_injection_mode(&config.mode)?;
586
587    Ok(InjectionDef {
588        source: config.source,
589        target: config.target,
590        mode,
591        priority: config.priority,
592        max_tokens: config.max_tokens,
593        filter: None, // Not supported in YAML yet
594    })
595}
596
597/// Parse a provider fence block from YAML content into a ProviderDef.
598///
599/// Parses `content` as YAML into a `ProviderConfig`, applies name precedence between
600/// the optional `header_name` and the payload `name` (header wins; conflict is an error),
601/// resolves the `provider_type`, and converts `api_key` into an environment-aware `EnvValue`.
602///
603/// # Errors
604///
605/// Returns `ConfigError::YamlParse` if the YAML is invalid, `ConfigError::NameConflict` if
606/// both header and payload names are provided, `ConfigError::MissingName` if no name is present,
607/// or other `ConfigError` variants produced while parsing `provider_type` or `api_key`.
608///
609/// # Examples
610///
611/// ```
612/// use cellstate_pipeline::parse_provider_block;
613///
614/// let yaml = r#"
615/// provider_type: openai
616/// api_key: env:OPENAI_KEY
617/// model: gpt-4
618/// options: []
619/// "#;
620///
621/// let def = parse_provider_block(Some("my-provider"), yaml).unwrap();
622/// assert_eq!(def.name, "my-provider");
623/// ```
624pub fn parse_provider_block(
625    header_name: Option<&str>,
626    content: &str,
627) -> Result<ProviderDef, ConfigError> {
628    let config: ProviderConfig =
629        serde_yaml::from_str(content).map_err(|e| ConfigError::YamlParse(e.to_string()))?;
630
631    let name = resolve_block_name(header_name, &config.name, "provider")?;
632
633    let provider_type = parse_provider_type(&config.provider_type)?;
634    let api_key = parse_env_value(config.api_key.expose_secret());
635
636    Ok(ProviderDef {
637        name,
638        provider_type,
639        api_key,
640        model: config.model,
641        options: config.options,
642    })
643}
644
645/// Parses a cache configuration YAML block and converts it into a CacheDef.
646///
647/// Returns an error if YAML parsing fails, if the header and payload both provide a name,
648/// or if backend/freshness values are invalid.
649///
650/// # Examples
651///
652/// ```
653/// use cellstate_pipeline::parse_cache_block;
654///
655/// let yaml = r#"
656/// backend: memory
657/// size_mb: 100
658/// default_freshness:
659///   type: strict
660/// "#;
661/// let def = parse_cache_block(None, yaml).unwrap();
662/// assert_eq!(def.size_mb, 100);
663/// ```
664pub fn parse_cache_block(
665    header_name: Option<&str>,
666    content: &str,
667) -> Result<CacheDef, ConfigError> {
668    let config: CacheConfig =
669        serde_yaml::from_str(content).map_err(|e| ConfigError::YamlParse(e.to_string()))?;
670
671    // Cache might use header name or be singleton
672    if let (Some(_), Some(_)) = (header_name, &config.name) {
673        return Err(ConfigError::NameConflict(
674            "Name in both fence header and YAML payload for cache".to_string(),
675        ));
676    }
677
678    let backend = parse_cache_backend(&config.backend)?;
679    let default_freshness = parse_freshness_def(config.default_freshness)?;
680
681    Ok(CacheDef {
682        backend,
683        path: config.path,
684        size_mb: config.size_mb,
685        default_freshness,
686        max_entries: config.max_entries,
687        ttl: config.ttl,
688    })
689}
690
691/// Parse a Markdown fence trajectory YAML block into a TrajectoryDef, resolving the block name from the header or payload.
692///
693/// The resulting `TrajectoryDef` is populated from the deserialized YAML payload. The trajectory `name` is taken from `header_name` when provided; if `header_name` is `None`, the payload `name` is used. If both are present the function returns an error, and if neither is present it returns an error.
694///
695/// # Errors
696///
697/// Returns `ConfigError::YamlParse` if the YAML cannot be deserialized, `ConfigError::NameConflict` if both a header name and a payload name are supplied, or `ConfigError::MissingName` if no name is provided.
698///
699/// # Examples
700///
701/// ```
702/// use cellstate_pipeline::parse_trajectory_block;
703///
704/// let yaml = r#"
705/// name: my_trajectory
706/// description: "A sample trajectory"
707/// agent_type: "assistant"
708/// token_budget: 1000
709/// memory_refs:
710///   - mem1
711///   - mem2
712/// "#;
713///
714/// let def = parse_trajectory_block(None, yaml).expect("parse");
715/// assert_eq!(def.name, "my_trajectory");
716/// assert_eq!(def.agent_type, "assistant");
717/// assert_eq!(def.token_budget, 1000);
718/// assert_eq!(def.memory_refs.len(), 2);
719/// ```
720pub fn parse_trajectory_block(
721    header_name: Option<&str>,
722    content: &str,
723) -> Result<TrajectoryDef, ConfigError> {
724    let config: TrajectoryConfig =
725        serde_yaml::from_str(content).map_err(|e| ConfigError::YamlParse(e.to_string()))?;
726
727    let name = resolve_block_name(header_name, &config.name, "trajectory")?;
728
729    Ok(TrajectoryDef {
730        name,
731        description: config.description,
732        agent_type: config.agent_type,
733        token_budget: config.token_budget,
734        memory_refs: config.memory_refs,
735        metadata: config.metadata,
736    })
737}
738
739/// Parses an agent YAML block and converts it into an AgentDef.
740///
741/// The function deserializes `content` as an `AgentConfig`, resolves the agent name by
742/// preferring `header_name` when present (and returns an error on conflict), and maps
743/// capabilities, constraints, and permissions into an `AgentDef`.
744///
745/// # Errors
746///
747/// Returns `ConfigError::YamlParse` if the YAML cannot be deserialized,
748/// `ConfigError::NameConflict` if both `header_name` and the payload specify a name,
749/// and `ConfigError::MissingName` if no name is provided in either place.
750///
751/// # Examples
752///
753/// ```
754/// use cellstate_pipeline::parse_agent_block;
755///
756/// let yaml = r#"
757/// name: my-agent
758/// capabilities:
759///   - read
760///   - write
761/// constraints:
762///   max_concurrent: 4
763///   timeout_ms: 10000
764/// permissions:
765///   read: [ "resource_a" ]
766///   write: [ "resource_b" ]
767///   lock: []
768/// "#;
769///
770/// let def = parse_agent_block(None, yaml).unwrap();
771/// assert_eq!(def.name, "my-agent");
772/// assert_eq!(def.capabilities, vec!["read".to_string(), "write".to_string()]);
773/// assert_eq!(def.constraints.max_concurrent, 4);
774/// ```
775pub fn parse_agent_block(
776    header_name: Option<&str>,
777    content: &str,
778) -> Result<AgentDef, ConfigError> {
779    let config: AgentConfig =
780        serde_yaml::from_str(content).map_err(|e| ConfigError::YamlParse(e.to_string()))?;
781
782    let name = resolve_block_name(header_name, &config.name, "agent")?;
783
784    Ok(AgentDef {
785        name,
786        capabilities: config.capabilities,
787        constraints: AgentConstraints {
788            max_concurrent: config.constraints.max_concurrent,
789            timeout_ms: config.constraints.timeout_ms,
790        },
791        permissions: PermissionMatrix {
792            read: config.permissions.read,
793            write: config.permissions.write,
794            lock: config.permissions.lock,
795        },
796    })
797}
798
799/// Parses an intent YAML fence block into an `IntentDef`.
800///
801/// The function deserializes `content` as YAML, determines the intent name using
802/// the fence header if present (erroring on conflicts or absence), validates the
803/// intent configuration, and converts it into an `IntentDef`.
804///
805/// # Errors
806///
807/// Returns a `ConfigError` when:
808/// - YAML deserialization fails (`ConfigError::YamlParse`),
809/// - both a header name and a payload name are provided (`ConfigError::NameConflict`),
810/// - no name is provided (`ConfigError::MissingName`),
811/// - or other validation failures occur.
812///
813/// # Examples
814///
815/// ```
816/// use cellstate_pipeline::parse_intent_block;
817///
818/// let yaml = r#"
819/// goals:
820///   - "maximize_customer_retention"
821///   - "minimize_churn"
822/// autonomy_level: collaborator
823/// drift_threshold: 0.9
824/// "#;
825/// let def = parse_intent_block(Some("customer_success"), yaml).expect("should parse");
826/// assert_eq!(def.name, "customer_success");
827/// assert_eq!(def.goals.len(), 2);
828/// ```
829pub fn parse_intent_block(
830    header_name: Option<&str>,
831    content: &str,
832) -> Result<IntentDef, ConfigError> {
833    let config: IntentConfig =
834        serde_yaml::from_str(content).map_err(|e| ConfigError::YamlParse(e.to_string()))?;
835
836    let name = resolve_block_name(header_name, &config.name, "intent")?;
837
838    // Convert resolution rules
839    let resolution_rules = config
840        .resolution_rules
841        .into_iter()
842        .map(|r| ResolutionRule {
843            condition: r.condition,
844            priority: r.priority,
845            escalate_to: r.escalate_to,
846            max_authority: r.max_authority,
847        })
848        .collect();
849
850    // Convert autonomy level
851    let autonomy_level = match config.autonomy_level.as_deref() {
852        Some(s) => parse_autonomy_level(s)?,
853        None => AutonomyLevel::default(),
854    };
855
856    // Convert delegation boundaries
857    let delegation_boundaries = match config.delegation_boundaries {
858        Some(db) => DelegationBoundary {
859            authorized_actions: db.authorized_actions,
860            requires_approval: db.requires_approval,
861            forbidden_actions: db.forbidden_actions,
862        },
863        None => DelegationBoundary::default(),
864    };
865
866    // Convert alignment signals
867    let alignment_signals = config
868        .alignment_signals
869        .into_iter()
870        .map(|s| {
871            let target = match s.target {
872                SignalTargetConfig::Above { value } => SignalTarget::Above(value),
873                SignalTargetConfig::Below { value } => SignalTarget::Below(value),
874                SignalTargetConfig::Between { min, max } => SignalTarget::Between { min, max },
875            };
876            AlignmentSignal {
877                name: s.name,
878                source: s.source,
879                metric: s.metric,
880                target,
881            }
882        })
883        .collect();
884
885    let drift_threshold = config.drift_threshold.unwrap_or(0.85);
886    if !(0.0..=1.0).contains(&drift_threshold) {
887        return Err(ConfigError::InvalidValue(
888            "drift_threshold must be between 0.0 and 1.0".to_string(),
889        ));
890    }
891
892    Ok(IntentDef {
893        name,
894        goals: config.goals,
895        resolution_rules,
896        autonomy_level,
897        delegation_boundaries,
898        alignment_signals,
899        drift_threshold,
900    })
901}
902
903// ============================================================================
904// VALIDATION LAYER (refinement types via runtime checks)
905// ============================================================================
906
907/// Validates that the adapter configuration specifies a known adapter type.
908///
909/// This checks the `adapter_type` field of the provided `AdapterConfig` and
910/// returns an error if it does not map to a supported adapter.
911///
912/// # Returns
913///
914/// `Err(ConfigError::UnknownAdapter)` if `adapter_type` is not recognized, `Ok(())` otherwise.
915///
916/// # Examples
917///
918/// ```ignore
919/// let cfg = AdapterConfig {
920///     name: Some("main".into()),
921///     adapter_type: "postgres".into(),
922///     connection: "postgres://user:pass@localhost/db".into(),
923///     options: vec![],
924/// };
925/// assert!(validate_adapter_config(&cfg).is_ok());
926/// ```
927fn validate_adapter_config(config: &AdapterConfig) -> Result<(), ConfigError> {
928    // Validate adapter_type is known
929    parse_adapter_type(&config.adapter_type)?;
930    Ok(())
931}
932
933// ============================================================================
934// TYPE CONVERSION HELPERS
935// ============================================================================
936
937/// Parse a string into an AdapterType.
938///
939/// # Returns
940///
941/// `Ok(AdapterType)` corresponding to the input string (case-insensitive), or
942/// `Err(ConfigError::UnknownAdapter(_))` if the input does not match a known adapter.
943///
944/// # Examples
945///
946/// ```ignore
947/// let a = parse_adapter_type("Postgres").unwrap();
948/// assert_eq!(a, AdapterType::Postgres);
949/// assert!(matches!(parse_adapter_type("unknown"), Err(ConfigError::UnknownAdapter(_))));
950/// ```
951fn parse_adapter_type(s: &str) -> Result<AdapterType, ConfigError> {
952    match s.to_lowercase().as_str() {
953        "postgres" | "postgresql" => Ok(AdapterType::Postgres),
954        "redis" => Ok(AdapterType::Redis),
955        "memory" => Ok(AdapterType::Memory),
956        other => Err(ConfigError::UnknownAdapter(other.to_string())),
957    }
958}
959
960/// Parse a memory type name into its corresponding MemoryType.
961///
962/// Accepts a case-insensitive string and returns the matching MemoryType variant.
963/// If the input does not match a known memory type, returns `ConfigError::InvalidValue`.
964///
965/// # Examples
966///
967/// ```ignore
968/// let t = parse_memory_type("Ephemeral").unwrap();
969/// assert_eq!(t, MemoryType::Ephemeral);
970///
971/// assert!(matches!(
972///     parse_memory_type("unknown"),
973///     Err(ConfigError::InvalidValue(_))
974/// ));
975/// ```
976fn parse_memory_type(s: &str) -> Result<MemoryType, ConfigError> {
977    match s.to_lowercase().as_str() {
978        "ephemeral" => Ok(MemoryType::Ephemeral),
979        "working" => Ok(MemoryType::Working),
980        "episodic" => Ok(MemoryType::Episodic),
981        "semantic" => Ok(MemoryType::Semantic),
982        "procedural" => Ok(MemoryType::Procedural),
983        "meta" => Ok(MemoryType::Meta),
984        other => Err(ConfigError::InvalidValue(format!(
985            "Unknown memory type '{}'",
986            other
987        ))),
988    }
989}
990
991/// Parse a retention specifier string into a `Retention` value.
992///
993/// Recognizes the literals `persistent`, `session`, and `scope`, the
994/// `duration:<value>` form which yields `Retention::Duration(<value>)`,
995/// and the `max:<n>` form which yields `Retention::Max(n)`.
996/// If `max:<n>` contains a non-integer `n`, returns `ConfigError::InvalidValue`.
997///
998/// # Examples
999///
1000/// ```ignore
1001/// assert_eq!(parse_retention("persistent").unwrap(), Retention::Persistent);
1002/// assert_eq!(parse_retention("duration:7d").unwrap(), Retention::Duration("7d".into()));
1003/// assert_eq!(parse_retention("max:5").unwrap(), Retention::Max(5));
1004/// ```
1005fn parse_retention(s: &str) -> Result<Retention, ConfigError> {
1006    match s.to_lowercase().as_str() {
1007        "persistent" => Ok(Retention::Persistent),
1008        "session" => Ok(Retention::Session),
1009        "scope" => Ok(Retention::Scope),
1010        other => {
1011            if let Some(duration) = other.strip_prefix("duration:") {
1012                Ok(Retention::Duration(duration.to_string()))
1013            } else if let Some(max_str) = other.strip_prefix("max:") {
1014                let count = max_str
1015                    .parse()
1016                    .map_err(|e| ConfigError::InvalidValue(format!("Invalid max count: {e}")))?;
1017                Ok(Retention::Max(count))
1018            } else {
1019                Err(ConfigError::InvalidValue(format!(
1020                    "Unknown retention '{}'",
1021                    other
1022                )))
1023            }
1024        }
1025    }
1026}
1027
1028/// Parse a lifecycle descriptor string into a `Lifecycle`.
1029///
1030/// Accepts the literal "explicit" or the form "autoclose:<trigger-expression>"; the latter produces
1031/// `Lifecycle::AutoClose(trigger)` where `<trigger-expression>` is parsed into a `Trigger`.
1032///
1033/// # Returns
1034///
1035/// `Lifecycle::Explicit` for "explicit", `Lifecycle::AutoClose(...)` for "autoclose:<...>", or a
1036/// `ConfigError::InvalidValue` if the input does not match a known lifecycle.
1037///
1038/// # Examples
1039///
1040/// ```ignore
1041/// let explicit = parse_lifecycle("explicit").unwrap();
1042/// assert_eq!(explicit, Lifecycle::Explicit);
1043///
1044/// let auto = parse_lifecycle("autoclose: task_end").unwrap();
1045/// match auto {
1046///     Lifecycle::AutoClose(_) => {}
1047///     _ => panic!("expected AutoClose"),
1048/// }
1049/// ```
1050fn parse_lifecycle(s: &str) -> Result<Lifecycle, ConfigError> {
1051    match s.to_lowercase().as_str() {
1052        "explicit" => Ok(Lifecycle::Explicit),
1053        other => {
1054            if let Some(autoclose_str) = other.strip_prefix("autoclose:") {
1055                let trigger = parse_trigger(autoclose_str.trim())?;
1056                Ok(Lifecycle::AutoClose(trigger))
1057            } else {
1058                Err(ConfigError::InvalidValue(format!(
1059                    "Unknown lifecycle '{}'",
1060                    other
1061                )))
1062            }
1063        }
1064    }
1065}
1066
1067/// Parses a trigger specifier string into a `Trigger`.
1068///
1069/// Recognizes the literal values `task_start`, `task_end`, `scope_close`, `turn_end`, and `manual` (case-insensitive),
1070/// and `schedule:<expr>` which produces `Trigger::Schedule` with `<expr>` as its payload.
1071///
1072/// # Examples
1073///
1074/// ```ignore
1075/// let t = parse_trigger("task_start").unwrap();
1076/// assert!(matches!(t, Trigger::TaskStart));
1077///
1078/// let s = parse_trigger("schedule:0 0 * * *").unwrap();
1079/// assert!(matches!(s, Trigger::Schedule(expr) if expr == "0 0 * * *"));
1080/// ```
1081///
1082/// # Returns
1083///
1084/// `Ok(Trigger)` when the input matches a known trigger; `Err(ConfigError::InvalidValue)` when it does not.
1085fn parse_trigger(s: &str) -> Result<Trigger, ConfigError> {
1086    match s.to_lowercase().as_str() {
1087        "task_start" => Ok(Trigger::TaskStart),
1088        "task_end" => Ok(Trigger::TaskEnd),
1089        "scope_close" => Ok(Trigger::ScopeClose),
1090        "turn_end" => Ok(Trigger::TurnEnd),
1091        "manual" => Ok(Trigger::Manual),
1092        other => {
1093            if let Some(schedule_str) = other.strip_prefix("schedule:") {
1094                Ok(Trigger::Schedule(schedule_str.to_string()))
1095            } else {
1096                Err(ConfigError::InvalidValue(format!(
1097                    "Unknown trigger '{}'",
1098                    other
1099                )))
1100            }
1101        }
1102    }
1103}
1104
1105/// Converts a YAML-deserialized FieldConfig into the internal FieldDef.
1106///
1107/// The function parses the textual `field_type` into a `FieldType` and
1108/// constructs a `FieldDef` preserving `name`, `nullable`, and `default`.
1109/// The `security` field is set to `None` because YAML-based security is not supported yet.
1110///
1111/// # Returns
1112///
1113/// `FieldDef` built from the given config; `Err(ConfigError)` if the `field_type` is invalid.
1114///
1115/// # Examples
1116///
1117/// ```ignore
1118/// let cfg = FieldConfig {
1119///     name: "id".to_string(),
1120///     field_type: "uuid".to_string(),
1121///     nullable: false,
1122///     default: None,
1123/// };
1124/// let def = parse_field_def(cfg).unwrap();
1125/// assert_eq!(def.name, "id");
1126/// ```
1127fn parse_field_def(config: FieldConfig) -> Result<FieldDef, ConfigError> {
1128    let field_type = parse_field_type(&config.field_type)?;
1129    Ok(FieldDef {
1130        name: config.name,
1131        field_type,
1132        nullable: config.nullable,
1133        default: config.default,
1134        security: None, // Not supported in YAML yet
1135    })
1136}
1137
1138/// Parses a field type identifier into a `FieldType`.
1139///
1140/// Accepts case-insensitive names: `uuid`, `text`, `int`, `float`, `bool`,
1141/// `timestamp`, and `json`. Recognizes `embedding:<dim>` where `<dim>` is an
1142/// integer; if the dimension fails to parse the embedding's dimension will be
1143/// `None` (the embedding type is still returned). Returns `Err(ConfigError::InvalidValue(_))`
1144/// for unknown type strings.
1145///
1146/// # Examples
1147///
1148/// ```ignore
1149/// assert_eq!(parse_field_type("text").unwrap(), FieldType::Text);
1150/// assert_eq!(parse_field_type("EMBEDDING:128").unwrap(), FieldType::Embedding(Some(128)));
1151/// assert_eq!(parse_field_type("embedding:bad").unwrap(), FieldType::Embedding(None));
1152/// assert!(matches!(parse_field_type("unknown"), Err(ConfigError::InvalidValue(_))));
1153/// ```
1154fn parse_field_type(s: &str) -> Result<FieldType, ConfigError> {
1155    match s.to_lowercase().as_str() {
1156        "uuid" => Ok(FieldType::Uuid),
1157        "text" => Ok(FieldType::Text),
1158        "int" => Ok(FieldType::Int),
1159        "float" => Ok(FieldType::Float),
1160        "bool" => Ok(FieldType::Bool),
1161        "timestamp" => Ok(FieldType::Timestamp),
1162        "json" => Ok(FieldType::Json),
1163        other => {
1164            if let Some(dim_str) = other.strip_prefix("embedding:") {
1165                let dim = dim_str.parse().ok();
1166                Ok(FieldType::Embedding(dim))
1167            } else {
1168                Err(ConfigError::InvalidValue(format!(
1169                    "Unknown field type '{}'",
1170                    other
1171                )))
1172            }
1173        }
1174    }
1175}
1176
1177/// Converts an IndexConfig into an IndexDef by parsing the configured index type.
1178///
1179/// Parses the `index_type` string and returns an `IndexDef` preserving the `field` and `options` from the input config.
1180///
1181/// # Returns
1182///
1183/// `Ok(IndexDef)` on success; `Err(ConfigError::InvalidValue)` if the `index_type` text is not a known index type.
1184///
1185/// # Examples
1186///
1187/// ```ignore
1188/// let cfg = IndexConfig {
1189///     field: "title".into(),
1190///     index_type: "btree".into(),
1191///     options: vec![],
1192/// };
1193/// let def = parse_index_def(cfg).unwrap();
1194/// assert_eq!(def.field, "title");
1195/// assert_eq!(def.options.len(), 0);
1196/// ```
1197fn parse_index_def(config: IndexConfig) -> Result<IndexDef, ConfigError> {
1198    let index_type = parse_index_type(&config.index_type)?;
1199    Ok(IndexDef {
1200        field: config.field,
1201        index_type,
1202        options: config.options,
1203    })
1204}
1205
1206/// Parses a string representation of an index type into an `IndexType`.
1207///
1208/// Recognized values (case-insensitive): `"btree"`, `"hash"`, `"gin"`, `"hnsw"`, `"ivfflat"`.
1209///
1210/// # Returns
1211///
1212/// `Ok(IndexType)` for a recognized value, or `Err(ConfigError::InvalidValue)` if the value is unknown.
1213///
1214/// # Examples
1215///
1216/// ```ignore
1217/// let t = parse_index_type("hnsw").unwrap();
1218/// assert_eq!(t, IndexType::Hnsw);
1219/// ```
1220fn parse_index_type(s: &str) -> Result<IndexType, ConfigError> {
1221    match s.to_lowercase().as_str() {
1222        "btree" => Ok(IndexType::Btree),
1223        "hash" => Ok(IndexType::Hash),
1224        "gin" => Ok(IndexType::Gin),
1225        "hnsw" => Ok(IndexType::Hnsw),
1226        "ivfflat" => Ok(IndexType::Ivfflat),
1227        other => Err(ConfigError::InvalidValue(format!(
1228            "Unknown index type '{}'",
1229            other
1230        ))),
1231    }
1232}
1233
1234/// Parses a modifier specification string into a `ModifierDef`.
1235///
1236/// Currently this is a placeholder implementation that treats the entire input
1237/// string as the name of an embeddable provider.
1238///
1239/// # Parameters
1240///
1241/// - `s`: Modifier specification string.
1242///
1243/// # Returns
1244///
1245/// `Ok(ModifierDef::Embeddable { provider })` where `provider` is the original
1246/// input string.
1247///
1248/// # Examples
1249///
1250/// ```ignore
1251/// let def = parse_modifier("openai".to_string()).unwrap();
1252/// match def {
1253///     ModifierDef::Embeddable { provider } => assert_eq!(provider, "openai"),
1254///     _ => panic!("unexpected modifier variant"),
1255/// }
1256/// ```
1257fn parse_modifier(s: String) -> Result<ModifierDef, ConfigError> {
1258    let s_lower = s.to_lowercase();
1259
1260    if let Some(provider) = s_lower.strip_prefix("embeddable:") {
1261        Ok(ModifierDef::Embeddable {
1262            provider: provider.to_string(),
1263        })
1264    } else if let Some(style_str) = s_lower.strip_prefix("summarizable:") {
1265        let style = match style_str {
1266            "brief" => SummaryStyle::Brief,
1267            "detailed" => SummaryStyle::Detailed,
1268            other => {
1269                return Err(ConfigError::InvalidValue(format!(
1270                    "invalid summary style '{}', expected 'brief' or 'detailed'",
1271                    other
1272                )))
1273            }
1274        };
1275        Ok(ModifierDef::Summarizable {
1276            style,
1277            on_triggers: vec![], // Default to empty triggers
1278        })
1279    } else if let Some(mode_str) = s_lower.strip_prefix("lockable:") {
1280        let mode = match mode_str {
1281            "exclusive" => LockMode::Exclusive,
1282            "shared" => LockMode::Shared,
1283            other => {
1284                return Err(ConfigError::InvalidValue(format!(
1285                    "invalid lock mode '{}', expected 'exclusive' or 'shared'",
1286                    other
1287                )))
1288            }
1289        };
1290        Ok(ModifierDef::Lockable { mode })
1291    } else if s_lower == "embeddable" {
1292        // Default embeddable with empty provider
1293        Ok(ModifierDef::Embeddable {
1294            provider: String::new(),
1295        })
1296    } else if s_lower == "summarizable" {
1297        // Default summarizable with brief style
1298        Ok(ModifierDef::Summarizable {
1299            style: SummaryStyle::Brief,
1300            on_triggers: vec![],
1301        })
1302    } else if s_lower == "lockable" {
1303        // Default lockable with exclusive mode
1304        Ok(ModifierDef::Lockable {
1305            mode: LockMode::Exclusive,
1306        })
1307    } else {
1308        Err(ConfigError::InvalidValue(format!(
1309            "invalid modifier '{}', expected 'embeddable', 'summarizable', or 'lockable'",
1310            s
1311        )))
1312    }
1313}
1314
1315/// Converts a deserialized PolicyRuleConfig into a validated PolicyRule.
1316///
1317/// Returns an error if the rule's trigger or any contained action is invalid.
1318///
1319/// # Examples
1320///
1321/// ```ignore
1322/// let cfg = PolicyRuleConfig {
1323///     trigger: "task_end".to_string(),
1324///     actions: vec![ActionConfig::Summarize { target: "log".to_string() }],
1325/// };
1326/// let rule = parse_policy_rule(cfg).expect("valid policy rule");
1327/// assert_eq!(rule.trigger.to_string(), "task_end");
1328/// assert_eq!(rule.actions.len(), 1);
1329/// ```
1330fn parse_policy_rule(config: PolicyRuleConfig) -> Result<PolicyRule, ConfigError> {
1331    let trigger = parse_trigger(&config.trigger)?;
1332    let actions = config
1333        .actions
1334        .into_iter()
1335        .map(parse_action)
1336        .collect::<Result<Vec<_>, _>>()?;
1337    Ok(PolicyRule { trigger, actions })
1338}
1339
1340/// Converts an ActionConfig (parsed from YAML) into the corresponding internal Action.
1341///
1342/// # Returns
1343///
1344/// `Ok(Action)` when conversion succeeds; `Err(ConfigError)` if a value is invalid (for example, an unrecognized injection mode).
1345///
1346/// # Examples
1347///
1348/// ```ignore
1349/// let cfg = ActionConfig::Summarize { target: String::from("doc") };
1350/// let action = parse_action(cfg).unwrap();
1351/// assert_eq!(action, Action::Summarize(String::from("doc")));
1352/// ```
1353fn parse_action(config: ActionConfig) -> Result<Action, ConfigError> {
1354    match config {
1355        ActionConfig::Summarize { target } => Ok(Action::Summarize(target)),
1356        ActionConfig::Checkpoint { target } => Ok(Action::Checkpoint(target)),
1357        ActionConfig::ExtractArtifacts { target } => Ok(Action::ExtractArtifacts(target)),
1358        ActionConfig::Notify { target } => Ok(Action::Notify(target)),
1359        ActionConfig::Inject { target, mode } => {
1360            let injection_mode = parse_injection_mode(&mode)?;
1361            Ok(Action::Inject {
1362                target,
1363                mode: injection_mode,
1364            })
1365        }
1366    }
1367}
1368
1369/// Parses an injection mode string into an InjectionMode.
1370///
1371/// Accepts (case-insensitive) "full", "summary", "topk:<n>" and "relevant:<n>".
1372/// Returns `Err(ConfigError::InvalidValue(_))` for unknown modes or invalid numeric arguments.
1373///
1374/// # Examples
1375///
1376/// ```ignore
1377/// let m = parse_injection_mode("full").unwrap();
1378/// assert_eq!(m, InjectionMode::Full);
1379///
1380/// let m = parse_injection_mode("TopK:3").unwrap();
1381/// assert_eq!(m, InjectionMode::TopK(3));
1382/// ```
1383fn parse_injection_mode(s: &str) -> Result<InjectionMode, ConfigError> {
1384    match s.to_lowercase().as_str() {
1385        "full" => Ok(InjectionMode::Full),
1386        "summary" => Ok(InjectionMode::Summary),
1387        other if other.starts_with("topk:") => {
1388            let k = other["topk:".len()..]
1389                .parse()
1390                .map_err(|e| ConfigError::InvalidValue(format!("Invalid topk value: {e}")))?;
1391            Ok(InjectionMode::TopK(k))
1392        }
1393        other if other.starts_with("relevant:") => {
1394            let threshold = other["relevant:".len()..]
1395                .parse()
1396                .map_err(|e| ConfigError::InvalidValue(format!("Invalid threshold: {e}")))?;
1397            Ok(InjectionMode::Relevant(threshold))
1398        }
1399        other => Err(ConfigError::InvalidValue(format!(
1400            "Unknown injection mode '{}'",
1401            other
1402        ))),
1403    }
1404}
1405
1406/// Parse a provider identifier string into the corresponding ProviderType.
1407///
1408/// # Returns
1409///
1410/// `Ok(ProviderType::OpenAI | ProviderType::Anthropic | ProviderType::Custom)` on recognized input,
1411/// `Err(ConfigError::UnknownProvider(_))` if the input is not a known provider.
1412///
1413/// # Examples
1414///
1415/// ```ignore
1416/// let p = parse_provider_type("OpenAI").unwrap();
1417/// assert_eq!(p, ProviderType::OpenAI);
1418/// ```
1419fn parse_provider_type(s: &str) -> Result<ProviderType, ConfigError> {
1420    match s.to_lowercase().as_str() {
1421        "openai" => Ok(ProviderType::OpenAI),
1422        "anthropic" => Ok(ProviderType::Anthropic),
1423        "local" => Ok(ProviderType::Local),
1424        "custom" => Ok(ProviderType::Custom),
1425        other => Err(ConfigError::UnknownProvider(other.to_string())),
1426    }
1427}
1428
1429/// Parses a string into an EnvValue, interpreting values that start with `env:` as environment variable references.
1430///
1431/// The prefix `env:` (case-sensitive) is removed and the remainder is trimmed; if the prefix is present the result is `EnvValue::Env(var)`, otherwise the original string is returned as `EnvValue::Literal`.
1432///
1433/// # Examples
1434///
1435/// ```ignore
1436/// // env reference
1437/// assert_eq!(parse_env_value("env:API_KEY"), EnvValue::Env("API_KEY".to_string()));
1438/// // with whitespace after prefix
1439/// assert_eq!(parse_env_value("env:  VAR  "), EnvValue::Env("VAR".to_string()));
1440/// // literal value
1441/// assert_eq!(parse_env_value("plain"), EnvValue::Literal("plain".to_string()));
1442/// ```
1443fn parse_env_value(s: &str) -> EnvValue {
1444    if let Some(rest) = s.strip_prefix("env:") {
1445        EnvValue::Env(rest.trim().to_string())
1446    } else {
1447        EnvValue::Literal(s.to_string())
1448    }
1449}
1450
1451/// Parse a string into a CacheBackendType.
1452///
1453/// Accepts case-insensitive names for supported cache backends and fails for unknown values.
1454///
1455/// # Returns
1456///
1457/// `CacheBackendType::Lmdb` for "lmdb", `CacheBackendType::Memory` for "memory", or a `ConfigError::InvalidValue` for any other input.
1458///
1459/// # Examples
1460///
1461/// ```ignore
1462/// let be = parse_cache_backend("LMDB").unwrap();
1463/// assert_eq!(be, CacheBackendType::Lmdb);
1464/// let mem = parse_cache_backend("memory").unwrap();
1465/// assert_eq!(mem, CacheBackendType::Memory);
1466/// assert!(parse_cache_backend("unknown").is_err());
1467/// ```
1468fn parse_cache_backend(s: &str) -> Result<CacheBackendType, ConfigError> {
1469    match s.to_lowercase().as_str() {
1470        "lmdb" => Ok(CacheBackendType::Lmdb),
1471        "memory" => Ok(CacheBackendType::Memory),
1472        other => Err(ConfigError::InvalidValue(format!(
1473            "Unknown cache backend '{}'",
1474            other
1475        ))),
1476    }
1477}
1478
1479/// Convert a freshness configuration into its AST representation.
1480///
1481/// # Returns
1482///
1483/// `Ok(FreshnessDef::BestEffort { max_staleness })` when the input is `BestEffort`, `Ok(FreshnessDef::Strict)` when the input is `Strict`.
1484///
1485/// # Examples
1486///
1487/// ```ignore
1488/// let cfg = FreshnessConfig::BestEffort { max_staleness: "5m".into() };
1489/// let def = parse_freshness_def(cfg).unwrap();
1490/// match def {
1491///     FreshnessDef::BestEffort { max_staleness } => assert_eq!(max_staleness, "5m"),
1492///     _ => panic!("unexpected variant"),
1493/// }
1494/// ```
1495fn parse_freshness_def(config: FreshnessConfig) -> Result<FreshnessDef, ConfigError> {
1496    match config {
1497        FreshnessConfig::BestEffort { max_staleness } => {
1498            Ok(FreshnessDef::BestEffort { max_staleness })
1499        }
1500        FreshnessConfig::Strict => Ok(FreshnessDef::Strict),
1501    }
1502}
1503
1504/// Parse an autonomy level string into an `AutonomyLevel`.
1505///
1506/// Accepts case-insensitive names: `operator`, `collaborator`, `consultant`,
1507/// `approver`, `observer`.
1508///
1509/// # Examples
1510///
1511/// ```ignore
1512/// let level = parse_autonomy_level("collaborator").unwrap();
1513/// assert_eq!(level, AutonomyLevel::Collaborator);
1514/// ```
1515fn parse_autonomy_level(s: &str) -> Result<AutonomyLevel, ConfigError> {
1516    match s.to_lowercase().as_str() {
1517        "operator" => Ok(AutonomyLevel::Operator),
1518        "collaborator" => Ok(AutonomyLevel::Collaborator),
1519        "consultant" => Ok(AutonomyLevel::Consultant),
1520        "approver" => Ok(AutonomyLevel::Approver),
1521        "observer" => Ok(AutonomyLevel::Observer),
1522        other => Err(ConfigError::InvalidValue(format!(
1523            "Unknown autonomy level '{}', expected operator|collaborator|consultant|approver|observer",
1524            other
1525        ))),
1526    }
1527}
1528
1529// ============================================================================
1530// TESTS
1531// ============================================================================
1532
1533#[cfg(test)]
1534mod tests {
1535    use super::*;
1536
1537    #[test]
1538    fn test_adapter_parse_with_header_name() {
1539        let yaml = r#"
1540adapter_type: postgres
1541connection: "postgresql://localhost/test"
1542"#;
1543        let result = parse_adapter_block(Some("postgres_main"), yaml);
1544        assert!(
1545            result.is_ok(),
1546            "Failed to parse adapter: {:?}",
1547            result.err()
1548        );
1549
1550        let adapter = result.expect("adapter parsing verified above");
1551        assert_eq!(adapter.name, "postgres_main");
1552        assert_eq!(adapter.adapter_type, AdapterType::Postgres);
1553        assert_eq!(adapter.connection, "postgresql://localhost/test");
1554    }
1555
1556    #[test]
1557    fn test_adapter_parse_with_payload_name() {
1558        let yaml = r#"
1559name: postgres_main
1560adapter_type: postgres
1561connection: "postgresql://localhost/test"
1562"#;
1563        let result = parse_adapter_block(None, yaml);
1564        assert!(
1565            result.is_ok(),
1566            "Failed to parse adapter: {:?}",
1567            result.err()
1568        );
1569
1570        let adapter = result.expect("adapter parsing verified above");
1571        assert_eq!(adapter.name, "postgres_main");
1572    }
1573
1574    #[test]
1575    fn test_adapter_deny_unknown_fields() {
1576        let yaml = r#"
1577adapter_type: postgres
1578connection: "postgresql://localhost/test"
1579unknown_field: bad
1580"#;
1581        let result = parse_adapter_block(Some("test"), yaml);
1582        assert!(result.is_err(), "Should reject unknown field");
1583
1584        let err = result.unwrap_err();
1585        match err {
1586            ConfigError::YamlParse(msg) => {
1587                assert!(
1588                    msg.contains("unknown field"),
1589                    "Expected 'unknown field' error, got: {}",
1590                    msg
1591                );
1592            }
1593            _ => panic!("Expected YamlParse error, got: {:?}", err),
1594        }
1595    }
1596
1597    #[test]
1598    fn test_adapter_name_conflict() {
1599        let yaml = r#"
1600name: payload_name
1601adapter_type: postgres
1602connection: "postgresql://localhost/test"
1603"#;
1604        let result = parse_adapter_block(Some("header_name"), yaml);
1605        assert!(result.is_err(), "Should reject name conflict");
1606
1607        let err = result.unwrap_err();
1608        match err {
1609            ConfigError::NameConflict(_) => {
1610                // Expected
1611            }
1612            _ => panic!("Expected NameConflict error, got: {:?}", err),
1613        }
1614    }
1615
1616    #[test]
1617    fn test_adapter_missing_name() {
1618        let yaml = r#"
1619adapter_type: postgres
1620connection: "postgresql://localhost/test"
1621"#;
1622        let result = parse_adapter_block(None, yaml);
1623        assert!(result.is_err(), "Should require name");
1624
1625        let err = result.unwrap_err();
1626        match err {
1627            ConfigError::MissingName(_) => {
1628                // Expected
1629            }
1630            _ => panic!("Expected MissingName error, got: {:?}", err),
1631        }
1632    }
1633
1634    #[test]
1635    fn test_adapter_case_preservation() {
1636        let yaml = r#"
1637adapter_type: PostgreS
1638connection: "PostgreSQL://LocalHost/Test"
1639"#;
1640        let result = parse_adapter_block(Some("MyAdapter"), yaml);
1641        assert!(result.is_ok(), "Failed to parse: {:?}", result.err());
1642
1643        let adapter = result.expect("adapter parsing verified above");
1644        assert_eq!(adapter.name, "MyAdapter");
1645        // Note: adapter_type is normalized to lowercase in parsing, but connection preserves case
1646        assert_eq!(adapter.connection, "PostgreSQL://LocalHost/Test");
1647    }
1648
1649    #[test]
1650    fn test_provider_parse_with_env_key() {
1651        let yaml = r#"
1652provider_type: openai
1653api_key: env:OPENAI_API_KEY
1654model: "gpt-4"
1655"#;
1656        let result = parse_provider_block(Some("my_provider"), yaml);
1657        assert!(result.is_ok(), "Failed to parse: {:?}", result.err());
1658
1659        let provider = result.expect("provider parsing verified above");
1660        assert_eq!(provider.name, "my_provider");
1661        assert_eq!(provider.provider_type, ProviderType::OpenAI);
1662        match provider.api_key {
1663            EnvValue::Env(var) => assert_eq!(var, "OPENAI_API_KEY"),
1664            _ => panic!("Expected Env variant"),
1665        }
1666    }
1667
1668    #[test]
1669    fn test_provider_deny_unknown_fields() {
1670        let yaml = r#"
1671provider_type: openai
1672api_key: "secret"
1673model: "gpt-4"
1674invalid_option: true
1675"#;
1676        let result = parse_provider_block(Some("test"), yaml);
1677        assert!(result.is_err(), "Should reject unknown field");
1678    }
1679
1680    #[test]
1681    fn test_provider_config_debug_redacts_api_key() {
1682        let yaml = r#"
1683provider_type: openai
1684api_key: "sk_test_real_secret"
1685model: "gpt-4"
1686"#;
1687        let parsed: ProviderConfig = serde_yaml::from_str(yaml).expect("provider yaml");
1688        let dbg = format!("{parsed:?}");
1689        assert!(dbg.contains("[REDACTED"));
1690        assert!(!dbg.contains("sk_test_real_secret"));
1691    }
1692
1693    #[test]
1694    fn test_injection_mode_parsing() {
1695        let yaml = r#"
1696source: "memories.episodic"
1697target: "context.main"
1698mode: full
1699priority: 100
1700"#;
1701        let result = parse_injection_block(None, yaml);
1702        assert!(result.is_ok(), "Failed to parse: {:?}", result.err());
1703
1704        let injection = result.expect("injection parsing verified above");
1705        assert_eq!(injection.mode, InjectionMode::Full);
1706        assert_eq!(injection.priority, 100);
1707    }
1708
1709    #[test]
1710    fn test_cache_freshness_parsing() {
1711        let yaml = r#"
1712backend: lmdb
1713path: "/var/cache"
1714size_mb: 1024
1715default_freshness:
1716  type: best_effort
1717  max_staleness: "60s"
1718"#;
1719        let result = parse_cache_block(Some("main"), yaml);
1720        assert!(result.is_ok(), "Failed to parse: {:?}", result.err());
1721
1722        let cache = result.expect("cache parsing verified above");
1723        match cache.default_freshness {
1724            FreshnessDef::BestEffort { max_staleness } => {
1725                assert_eq!(max_staleness, "60s");
1726            }
1727            _ => panic!("Expected BestEffort variant"),
1728        }
1729    }
1730
1731    // ========================================================================
1732    // INTENT ENGINEERING TESTS
1733    // ========================================================================
1734
1735    #[test]
1736    fn test_intent_parse_with_header_name() {
1737        let yaml = r#"
1738goals:
1739  - "maximize_customer_retention"
1740  - "minimize_churn"
1741autonomy_level: collaborator
1742drift_threshold: 0.9
1743"#;
1744        let result = parse_intent_block(Some("customer_success"), yaml);
1745        assert!(result.is_ok(), "Failed to parse intent: {:?}", result.err());
1746
1747        let intent = result.expect("intent parsing verified above");
1748        assert_eq!(intent.name, "customer_success");
1749        assert_eq!(intent.goals.len(), 2);
1750        assert_eq!(intent.autonomy_level, AutonomyLevel::Collaborator);
1751        assert!((intent.drift_threshold - 0.9).abs() < f64::EPSILON);
1752    }
1753
1754    #[test]
1755    fn test_intent_parse_with_payload_name() {
1756        let yaml = r#"
1757name: revenue_growth
1758goals:
1759  - "increase_arpu"
1760"#;
1761        let result = parse_intent_block(None, yaml);
1762        assert!(result.is_ok(), "Failed to parse: {:?}", result.err());
1763
1764        let intent = result.expect("intent parsing verified above");
1765        assert_eq!(intent.name, "revenue_growth");
1766    }
1767
1768    #[test]
1769    fn test_intent_name_conflict() {
1770        let yaml = r#"
1771name: payload_name
1772goals: []
1773"#;
1774        let result = parse_intent_block(Some("header_name"), yaml);
1775        assert!(result.is_err(), "Should reject name conflict");
1776        assert!(matches!(result.unwrap_err(), ConfigError::NameConflict(_)));
1777    }
1778
1779    #[test]
1780    fn test_intent_missing_name() {
1781        let yaml = r#"
1782goals: []
1783"#;
1784        let result = parse_intent_block(None, yaml);
1785        assert!(result.is_err(), "Should require name");
1786        assert!(matches!(result.unwrap_err(), ConfigError::MissingName(_)));
1787    }
1788
1789    #[test]
1790    fn test_intent_full_config() {
1791        let yaml = r#"
1792goals:
1793  - "retain_customers"
1794  - "grow_revenue"
1795resolution_rules:
1796  - condition: "customer_sentiment < 0.3"
1797    priority: ["retain_customers", "grow_revenue"]
1798    escalate_to: "customer_success_manager"
1799    max_authority: 500.0
1800autonomy_level: approver
1801delegation_boundaries:
1802  authorized_actions:
1803    - "send_discount"
1804    - "schedule_call"
1805  requires_approval:
1806    - "refund_over_100"
1807  forbidden_actions:
1808    - "delete_account"
1809alignment_signals:
1810  - name: retention_rate
1811    source: customer_events
1812    metric: 30d_retention
1813    target:
1814      type: above
1815      value: 0.85
1816drift_threshold: 0.8
1817"#;
1818        let result = parse_intent_block(Some("full_intent"), yaml);
1819        assert!(result.is_ok(), "Failed to parse: {:?}", result.err());
1820
1821        let intent = result.expect("intent parsing verified above");
1822        assert_eq!(intent.name, "full_intent");
1823        assert_eq!(intent.goals.len(), 2);
1824        assert_eq!(intent.resolution_rules.len(), 1);
1825        assert_eq!(
1826            intent.resolution_rules[0].escalate_to,
1827            Some("customer_success_manager".to_string())
1828        );
1829        assert!((intent.resolution_rules[0].max_authority - 500.0).abs() < f64::EPSILON);
1830        assert_eq!(intent.autonomy_level, AutonomyLevel::Approver);
1831        assert_eq!(intent.delegation_boundaries.authorized_actions.len(), 2);
1832        assert_eq!(intent.delegation_boundaries.requires_approval.len(), 1);
1833        assert_eq!(intent.delegation_boundaries.forbidden_actions.len(), 1);
1834        assert_eq!(intent.alignment_signals.len(), 1);
1835        assert!(
1836            matches!(intent.alignment_signals[0].target, SignalTarget::Above(v) if (v - 0.85).abs() < f64::EPSILON)
1837        );
1838        assert!((intent.drift_threshold - 0.8).abs() < f64::EPSILON);
1839    }
1840
1841    #[test]
1842    fn test_intent_defaults() {
1843        let yaml = r#"
1844goals: []
1845"#;
1846        let result = parse_intent_block(Some("minimal"), yaml);
1847        assert!(result.is_ok(), "Failed to parse: {:?}", result.err());
1848
1849        let intent = result.expect("intent parsing verified above");
1850        assert_eq!(intent.autonomy_level, AutonomyLevel::Operator);
1851        assert_eq!(intent.delegation_boundaries, DelegationBoundary::default());
1852        assert!(intent.alignment_signals.is_empty());
1853        assert!((intent.drift_threshold - 0.85).abs() < f64::EPSILON);
1854    }
1855
1856    #[test]
1857    fn test_intent_deny_unknown_fields() {
1858        let yaml = r#"
1859goals: []
1860unknown_field: bad
1861"#;
1862        let result = parse_intent_block(Some("test"), yaml);
1863        assert!(result.is_err(), "Should reject unknown field");
1864        assert!(matches!(result.unwrap_err(), ConfigError::YamlParse(_)));
1865    }
1866}