cellstate_pipeline/config/
markdown_printer.rs

1//! Canonical Markdown generator for config AST
2//! Generates deterministic Markdown output for round-trip testing
3
4use crate::ast::*;
5
6/// Quote a string value if it could be misinterpreted by YAML.
7///
8/// YAML special values include: null, true, false, ~, -, numbers, and strings
9/// starting with special characters like !, &, *, >, |, {, [, :, #, @, `.
10fn yaml_safe_string(s: &str) -> String {
11    if s.is_empty() {
12        return "\"\"".to_string();
13    }
14
15    // Check if it needs quoting
16    let needs_quoting = s == "-"
17        || s == "~"
18        || s == "null"
19        || s == "true"
20        || s == "false"
21        || s.starts_with('-')
22        || s.starts_with('!')
23        || s.starts_with('&')
24        || s.starts_with('*')
25        || s.starts_with('>')
26        || s.starts_with('|')
27        || s.starts_with('{')
28        || s.starts_with('[')
29        || s.starts_with(':')
30        || s.starts_with('#')
31        || s.starts_with('@')
32        || s.starts_with('`')
33        || s.contains(':')
34        || s.contains('\n')
35        || s.parse::<f64>().is_ok(); // Looks like a number
36
37    if needs_quoting {
38        // Use double quotes and escape any internal quotes
39        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
40    } else {
41        s.to_string()
42    }
43}
44
45/// Generate a deterministic, canonical Markdown representation of a CELLSTATE AST.
46///
47/// The output is a stable, round-trip friendly Markdown snapshot that serializes
48/// adapters, providers, memories, policies, injections, caches, trajectories,
49/// and agents in a deterministic order and format. Certain definition kinds
50/// (e.g., Evolution, SummarizationPolicy) are omitted.
51pub fn ast_to_markdown(ast: &CellstateAst) -> String {
52    let mut output = String::new();
53
54    // Sort definitions by type and name for deterministic output
55    let mut adapters = Vec::new();
56    let mut memories = Vec::new();
57    let mut policies = Vec::new();
58    let mut injections = Vec::new();
59    let mut providers = Vec::new();
60    let mut caches = Vec::new();
61    let mut trajectories = Vec::new();
62    let mut agents = Vec::new();
63    let mut intents = Vec::new();
64
65    for def in &ast.definitions {
66        match def {
67            Definition::Adapter(a) => adapters.push(a),
68            Definition::Memory(m) => memories.push(m),
69            Definition::Policy(p) => policies.push(p),
70            Definition::Injection(i) => injections.push(i),
71            Definition::Provider(prov) => providers.push(prov),
72            Definition::Cache(c) => caches.push(c),
73            Definition::Trajectory(t) => trajectories.push(t),
74            Definition::Agent(a) => agents.push(a),
75            Definition::Intent(i) => intents.push(i),
76            // These definition types have no markdown representation yet.
77            // Listed explicitly so adding a new Definition variant triggers
78            // a compiler warning rather than being silently swallowed.
79            Definition::Evolution(_) | Definition::SummarizationPolicy(_) => {}
80        }
81    }
82
83    // Sort alphabetically by name
84    adapters.sort_by(|a, b| a.name.cmp(&b.name));
85    memories.sort_by(|a, b| a.name.cmp(&b.name));
86    policies.sort_by(|a, b| a.name.cmp(&b.name));
87    providers.sort_by(|a, b| a.name.cmp(&b.name));
88    trajectories.sort_by(|a, b| a.name.cmp(&b.name));
89    agents.sort_by(|a, b| a.name.cmp(&b.name));
90    intents.sort_by(|a, b| a.name.cmp(&b.name));
91    caches.sort_by(|a, b| a.backend.cmp(&b.backend)); // Cache has no name, sort by backend
92                                                      // Injections have no name field - sort by (source, target) tuple
93    injections.sort_by(|a, b| {
94        a.source
95            .cmp(&b.source)
96            .then_with(|| a.target.cmp(&b.target))
97    });
98
99    // Generate Markdown for each type
100    for adapter in adapters {
101        output.push_str(&format!("```adapter {}\n", adapter.name));
102        output.push_str(&format!("adapter_type: {}\n", adapter.adapter_type));
103        output.push_str(&format!(
104            "connection: {}\n",
105            yaml_safe_string(&adapter.connection)
106        ));
107        if !adapter.options.is_empty() {
108            output.push_str("options:\n");
109            for (k, v) in &adapter.options {
110                output.push_str(&format!("  {}: {}\n", k, yaml_safe_string(v)));
111            }
112        }
113        output.push_str("```\n\n");
114    }
115
116    for provider in providers {
117        output.push_str(&format!("```provider {}\n", provider.name));
118        output.push_str(&format!("provider_type: {}\n", provider.provider_type));
119        output.push_str(&format!(
120            "api_key: {}\n",
121            env_value_to_string(&provider.api_key)
122        ));
123        output.push_str(&format!("model: {}\n", yaml_safe_string(&provider.model)));
124        if !provider.options.is_empty() {
125            output.push_str("options:\n");
126            for (k, v) in &provider.options {
127                output.push_str(&format!("  {}: {}\n", k, yaml_safe_string(v)));
128            }
129        }
130        output.push_str("```\n\n");
131    }
132
133    for memory in memories {
134        output.push_str(&format!("```memory {}\n", memory.name));
135        output.push_str(&format!("memory_type: {}\n", memory.memory_type));
136        output.push_str("schema:\n");
137        for field in &memory.schema {
138            output.push_str(&format!("  - name: {}\n", field.name));
139            output.push_str(&format!("    type: {}\n", field.field_type));
140            output.push_str(&format!("    nullable: {}\n", field.nullable));
141            if let Some(default) = &field.default {
142                output.push_str(&format!("    default: {}\n", yaml_safe_string(default)));
143            }
144        }
145        output.push_str(&format!("retention: {}\n", memory.retention));
146        output.push_str(&format!("lifecycle: {}\n", memory.lifecycle));
147        if let Some(parent) = &memory.parent {
148            output.push_str(&format!("parent: {}\n", parent));
149        }
150        if !memory.indexes.is_empty() {
151            output.push_str("indexes:\n");
152            for index in &memory.indexes {
153                output.push_str(&format!("  - field: {}\n", index.field));
154                output.push_str(&format!("    type: {}\n", index.index_type));
155            }
156        }
157        if !memory.inject_on.is_empty() {
158            output.push_str("inject_on:\n");
159            for trigger in &memory.inject_on {
160                output.push_str(&format!("  - {}\n", trigger));
161            }
162        }
163        if !memory.artifacts.is_empty() {
164            output.push_str("artifacts:\n");
165            for artifact in &memory.artifacts {
166                output.push_str(&format!("  - {}\n", artifact));
167            }
168        }
169        if !memory.modifiers.is_empty() {
170            output.push_str("modifiers:\n");
171            for modifier in &memory.modifiers {
172                output.push_str(&format!("  - {}\n", modifier));
173            }
174        }
175        output.push_str("```\n\n");
176    }
177
178    for policy in policies {
179        output.push_str(&format!("```policy {}\n", policy.name));
180        output.push_str("rules:\n");
181        for rule in &policy.rules {
182            output.push_str(&format!("  - trigger: {}\n", rule.trigger));
183            output.push_str("    actions:\n");
184            for action in &rule.actions {
185                output.push_str(&action_to_yaml(action));
186            }
187        }
188        output.push_str("```\n\n");
189    }
190
191    for injection in injections {
192        output.push_str("```injection\n");
193        output.push_str(&format!("source: {}\n", injection.source));
194        output.push_str(&format!("target: {}\n", injection.target));
195        output.push_str(&format!("mode: {}\n", injection.mode));
196        output.push_str(&format!("priority: {}\n", injection.priority));
197        if let Some(max_tokens) = injection.max_tokens {
198            output.push_str(&format!("max_tokens: {}\n", max_tokens));
199        }
200        if let Some(filter) = &injection.filter {
201            output.push_str(&format!("filter: {}\n", filter_expr_to_string(filter)));
202        }
203        output.push_str("```\n\n");
204    }
205
206    for cache in caches {
207        output.push_str("```cache\n");
208        output.push_str(&format!("backend: {}\n", cache.backend));
209        if let Some(path) = &cache.path {
210            output.push_str(&format!("path: {}\n", yaml_safe_string(path)));
211        }
212        output.push_str(&format!("size_mb: {}\n", cache.size_mb));
213        output.push_str(&format!("default_freshness: {}\n", cache.default_freshness));
214        if let Some(max_entries) = cache.max_entries {
215            output.push_str(&format!("max_entries: {}\n", max_entries));
216        }
217        if let Some(ttl) = &cache.ttl {
218            output.push_str(&format!("ttl: {}\n", yaml_safe_string(ttl)));
219        }
220        output.push_str("```\n\n");
221    }
222
223    for trajectory in trajectories {
224        output.push_str(&format!("```trajectory {}\n", trajectory.name));
225        if let Some(description) = &trajectory.description {
226            output.push_str(&format!("description: {}\n", yaml_safe_string(description)));
227        }
228        output.push_str(&format!(
229            "agent_type: {}\n",
230            yaml_safe_string(&trajectory.agent_type)
231        ));
232        output.push_str(&format!("token_budget: {}\n", trajectory.token_budget));
233        output.push_str("memory_refs:\n");
234        for mem_ref in &trajectory.memory_refs {
235            output.push_str(&format!("  - {}\n", yaml_safe_string(mem_ref)));
236        }
237        if let Some(metadata) = &trajectory.metadata {
238            // Serialize JSON value - it's already safe YAML if it's valid JSON
239            output.push_str(&format!("metadata: {}\n", metadata));
240        }
241        output.push_str("```\n\n");
242    }
243
244    for agent in agents {
245        output.push_str(&format!("```agent {}\n", agent.name));
246        output.push_str("capabilities:\n");
247        for capability in &agent.capabilities {
248            output.push_str(&format!("  - {}\n", yaml_safe_string(capability)));
249        }
250        output.push_str("constraints:\n");
251        output.push_str(&format!(
252            "  max_concurrent: {}\n",
253            agent.constraints.max_concurrent
254        ));
255        output.push_str(&format!("  timeout_ms: {}\n", agent.constraints.timeout_ms));
256        output.push_str("permissions:\n");
257        output.push_str("  read:\n");
258        for r in &agent.permissions.read {
259            output.push_str(&format!("    - {}\n", yaml_safe_string(r)));
260        }
261        output.push_str("  write:\n");
262        for w in &agent.permissions.write {
263            output.push_str(&format!("    - {}\n", yaml_safe_string(w)));
264        }
265        output.push_str("  lock:\n");
266        for l in &agent.permissions.lock {
267            output.push_str(&format!("    - {}\n", yaml_safe_string(l)));
268        }
269        output.push_str("```\n\n");
270    }
271
272    for intent in intents {
273        output.push_str(&format!("```intent {}\n", intent.name));
274        if !intent.goals.is_empty() {
275            output.push_str("goals:\n");
276            for goal in &intent.goals {
277                output.push_str(&format!("  - {}\n", yaml_safe_string(goal)));
278            }
279        }
280        output.push_str(&format!(
281            "autonomy_level: {}\n",
282            autonomy_level_to_string(&intent.autonomy_level)
283        ));
284        output.push_str(&format!("drift_threshold: {}\n", intent.drift_threshold));
285        if !intent.delegation_boundaries.authorized_actions.is_empty()
286            || !intent.delegation_boundaries.requires_approval.is_empty()
287            || !intent.delegation_boundaries.forbidden_actions.is_empty()
288        {
289            output.push_str("delegation_boundaries:\n");
290            if !intent.delegation_boundaries.authorized_actions.is_empty() {
291                output.push_str("  authorized_actions:\n");
292                for action in &intent.delegation_boundaries.authorized_actions {
293                    output.push_str(&format!("    - {}\n", yaml_safe_string(action)));
294                }
295            }
296            if !intent.delegation_boundaries.requires_approval.is_empty() {
297                output.push_str("  requires_approval:\n");
298                for action in &intent.delegation_boundaries.requires_approval {
299                    output.push_str(&format!("    - {}\n", yaml_safe_string(action)));
300                }
301            }
302            if !intent.delegation_boundaries.forbidden_actions.is_empty() {
303                output.push_str("  forbidden_actions:\n");
304                for action in &intent.delegation_boundaries.forbidden_actions {
305                    output.push_str(&format!("    - {}\n", yaml_safe_string(action)));
306                }
307            }
308        }
309        if !intent.resolution_rules.is_empty() {
310            output.push_str("resolution_rules:\n");
311            for rule in &intent.resolution_rules {
312                output.push_str(&format!(
313                    "  - condition: {}\n",
314                    yaml_safe_string(&rule.condition)
315                ));
316                output.push_str("    priority:\n");
317                for p in &rule.priority {
318                    output.push_str(&format!("      - {}\n", yaml_safe_string(p)));
319                }
320                if let Some(ref escalate) = rule.escalate_to {
321                    output.push_str(&format!(
322                        "    escalate_to: {}\n",
323                        yaml_safe_string(escalate)
324                    ));
325                }
326                output.push_str(&format!("    max_authority: {}\n", rule.max_authority));
327            }
328        }
329        if !intent.alignment_signals.is_empty() {
330            output.push_str("alignment_signals:\n");
331            for signal in &intent.alignment_signals {
332                output.push_str(&format!("  - name: {}\n", yaml_safe_string(&signal.name)));
333                output.push_str(&format!(
334                    "    source: {}\n",
335                    yaml_safe_string(&signal.source)
336                ));
337                output.push_str(&format!(
338                    "    metric: {}\n",
339                    yaml_safe_string(&signal.metric)
340                ));
341                output.push_str(&format!(
342                    "    target: {}\n",
343                    signal_target_to_yaml(&signal.target)
344                ));
345            }
346        }
347        output.push_str("```\n\n");
348    }
349
350    output
351}
352
353/// Converts an EnvValue into its canonical string representation for Markdown output.
354/// This function is kept as a local helper because EnvValue::Literal uses yaml_safe_string
355/// which is specific to the Markdown printer context.
356///
357/// # Examples
358///
359/// ```ignore
360/// let e = EnvValue::Env("API_KEY".into());
361/// assert_eq!(env_value_to_string(&e), "env:API_KEY");
362///
363/// let l = EnvValue::Literal("plain".into());
364/// assert_eq!(env_value_to_string(&l), "plain");
365/// ```
366fn env_value_to_string(v: &EnvValue) -> String {
367    match v {
368        EnvValue::Env(var) => format!("env:{}", var),
369        EnvValue::Literal(s) => yaml_safe_string(s),
370    }
371}
372
373/// Convert action to YAML format (for policy rules).
374fn action_to_yaml(a: &Action) -> String {
375    match a {
376        Action::Summarize(target) => {
377            format!("      - type: summarize\n        target: {}\n", target)
378        }
379        Action::ExtractArtifacts(target) => format!(
380            "      - type: extract_artifacts\n        target: {}\n",
381            target
382        ),
383        Action::Checkpoint(target) => {
384            format!("      - type: checkpoint\n        target: {}\n", target)
385        }
386        Action::Prune { target, criteria } => {
387            format!(
388                "      - type: prune\n        target: {}\n        criteria: {}\n",
389                target,
390                filter_expr_to_string(criteria)
391            )
392        }
393        Action::Notify(msg) => format!("      - type: notify\n        target: {}\n", msg),
394        Action::Inject { target, mode } => {
395            format!(
396                "      - type: inject\n        target: {}\n        mode: {}\n",
397                target, mode
398            )
399        }
400        Action::AutoSummarize {
401            source_level,
402            target_level,
403            create_edges,
404        } => {
405            format!("      - type: auto_summarize\n        source_level: {:?}\n        target_level: {:?}\n        create_edges: {}\n", source_level, target_level, create_edges)
406        }
407    }
408}
409
410/// Formats a filter expression into a canonical, human-readable string.
411///
412/// The output uses operators `==`, `!=`, `>`, `<`, `>=`, `<=`, `contains`, `regex`, and `in`.
413/// String values are quoted, numbers and booleans are unquoted, `null` is rendered as `null`,
414/// special values are rendered as `current_trajectory`, `current_scope`, or `now`, and arrays
415/// are simplified to `[...]`. `AND`/`OR` combine sub-expressions and are wrapped in
416/// parentheses; `NOT` prefixes a single sub-expression.
417///
418/// # Examples
419///
420/// ```ignore
421/// let expr = FilterExpr::And(vec![
422///     FilterExpr::Comparison {
423///         field: "age".into(),
424///         op: CompareOp::Gt,
425///         value: FilterValue::Number(18.0),
426///     },
427///     FilterExpr::Comparison {
428///         field: "active".into(),
429///         op: CompareOp::Eq,
430///         value: FilterValue::Bool(true),
431///     },
432/// ]);
433/// let s = filter_expr_to_string(&expr);
434/// assert_eq!(s, "(age > 18 AND active == true)");
435/// ```
436fn filter_expr_to_string(f: &FilterExpr) -> String {
437    match f {
438        FilterExpr::Comparison { field, op, value } => {
439            let op_str = match op {
440                CompareOp::Eq => "==",
441                CompareOp::Ne => "!=",
442                CompareOp::Gt => ">",
443                CompareOp::Lt => "<",
444                CompareOp::Ge => ">=",
445                CompareOp::Le => "<=",
446                CompareOp::Contains => "contains",
447                CompareOp::Regex => "regex",
448                CompareOp::In => "in",
449            };
450            let value_str = match value {
451                FilterValue::String(s) => format!("\"{}\"", s),
452                FilterValue::Number(n) => n.to_string(),
453                FilterValue::Bool(b) => b.to_string(),
454                FilterValue::Null => "null".to_string(),
455                FilterValue::CurrentTrajectory => "current_trajectory".to_string(),
456                FilterValue::CurrentScope => "current_scope".to_string(),
457                FilterValue::Now => "now".to_string(),
458                FilterValue::Array(_) => "[...]".to_string(), // Simplified
459            };
460            format!("{} {} {}", field, op_str, value_str)
461        }
462        FilterExpr::And(exprs) => {
463            let exprs_str = exprs
464                .iter()
465                .map(filter_expr_to_string)
466                .collect::<Vec<_>>()
467                .join(" AND ");
468            format!("({})", exprs_str)
469        }
470        FilterExpr::Or(exprs) => {
471            let exprs_str = exprs
472                .iter()
473                .map(filter_expr_to_string)
474                .collect::<Vec<_>>()
475                .join(" OR ");
476            format!("({})", exprs_str)
477        }
478        FilterExpr::Not(expr) => format!("NOT {}", filter_expr_to_string(expr)),
479    }
480}
481
482/// Converts an AutonomyLevel into its canonical lowercase string representation.
483/// Kept as a local helper because AutonomyLevel is defined in cellstate_core.
484fn autonomy_level_to_string(level: &AutonomyLevel) -> &'static str {
485    match level {
486        AutonomyLevel::Operator => "operator",
487        AutonomyLevel::Collaborator => "collaborator",
488        AutonomyLevel::Consultant => "consultant",
489        AutonomyLevel::Approver => "approver",
490        AutonomyLevel::Observer => "observer",
491    }
492}
493
494/// Converts a SignalTarget into a YAML-compatible string representation.
495fn signal_target_to_yaml(target: &SignalTarget) -> String {
496    match target {
497        SignalTarget::Above(v) => format!("{{type: above, value: {}}}", v),
498        SignalTarget::Below(v) => format!("{{type: below, value: {}}}", v),
499        SignalTarget::Between { min, max } => {
500            format!("{{type: between, min: {}, max: {}}}", min, max)
501        }
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508    use cellstate_core::intent::SignalTarget;
509
510    // ── yaml_safe_string ───────────────────────────────────────────────
511
512    #[test]
513    fn yaml_safe_empty_string() {
514        assert_eq!(yaml_safe_string(""), "\"\"");
515    }
516
517    #[test]
518    fn yaml_safe_plain_string() {
519        assert_eq!(yaml_safe_string("hello"), "hello");
520    }
521
522    #[test]
523    fn yaml_safe_quotes_reserved_words() {
524        assert_eq!(yaml_safe_string("true"), "\"true\"");
525        assert_eq!(yaml_safe_string("false"), "\"false\"");
526        assert_eq!(yaml_safe_string("null"), "\"null\"");
527    }
528
529    #[test]
530    fn yaml_safe_quotes_numbers() {
531        assert_eq!(yaml_safe_string("42"), "\"42\"");
532        assert_eq!(yaml_safe_string("3.14"), "\"3.14\"");
533    }
534
535    #[test]
536    fn yaml_safe_quotes_special_prefixes() {
537        assert_eq!(yaml_safe_string("-item"), "\"-item\"");
538        assert_eq!(yaml_safe_string("!tag"), "\"!tag\"");
539        assert_eq!(yaml_safe_string("&anchor"), "\"&anchor\"");
540        assert_eq!(yaml_safe_string("*alias"), "\"*alias\"");
541        assert_eq!(yaml_safe_string(":colon"), "\":colon\"");
542        assert_eq!(yaml_safe_string("#comment"), "\"#comment\"");
543    }
544
545    #[test]
546    fn yaml_safe_quotes_colons_in_middle() {
547        assert_eq!(yaml_safe_string("key:value"), "\"key:value\"");
548    }
549
550    #[test]
551    fn yaml_safe_escapes_internal_quotes() {
552        // String with quotes doesn't need YAML quoting since it doesn't match any special rules
553        let result = yaml_safe_string("say \"hi\"");
554        assert_eq!(result, "say \"hi\"");
555    }
556
557    // ── type-to-string converters ──────────────────────────────────────
558
559    #[test]
560    fn adapter_type_strings() {
561        assert_eq!(AdapterType::Postgres.to_string(), "postgres");
562        assert_eq!(AdapterType::Redis.to_string(), "redis");
563        assert_eq!(AdapterType::Memory.to_string(), "memory");
564    }
565
566    #[test]
567    fn memory_type_strings() {
568        assert_eq!(MemoryType::Ephemeral.to_string(), "ephemeral");
569        assert_eq!(MemoryType::Working.to_string(), "working");
570        assert_eq!(MemoryType::Episodic.to_string(), "episodic");
571        assert_eq!(MemoryType::Semantic.to_string(), "semantic");
572        assert_eq!(MemoryType::Procedural.to_string(), "procedural");
573        assert_eq!(MemoryType::Meta.to_string(), "meta");
574    }
575
576    #[test]
577    fn field_type_strings() {
578        assert_eq!(FieldType::Text.to_string(), "text");
579        assert_eq!(FieldType::Int.to_string(), "int");
580        assert_eq!(FieldType::Float.to_string(), "float");
581        assert_eq!(FieldType::Bool.to_string(), "bool");
582        assert_eq!(FieldType::Json.to_string(), "json");
583        assert_eq!(
584            FieldType::Embedding(Some(1536)).to_string(),
585            "embedding(1536)"
586        );
587        assert_eq!(FieldType::Embedding(None).to_string(), "embedding");
588    }
589
590    #[test]
591    fn retention_strings() {
592        assert_eq!(Retention::Persistent.to_string(), "persistent");
593        assert_eq!(Retention::Session.to_string(), "session");
594        assert_eq!(Retention::Scope.to_string(), "scope");
595        assert!(Retention::Duration("3600s".into())
596            .to_string()
597            .contains("3600s"));
598    }
599
600    #[test]
601    fn lifecycle_strings() {
602        assert_eq!(Lifecycle::Explicit.to_string(), "explicit");
603        assert!(Lifecycle::AutoClose(Trigger::ScopeClose)
604            .to_string()
605            .contains("scope_close"));
606    }
607
608    #[test]
609    fn trigger_strings() {
610        assert_eq!(Trigger::TaskStart.to_string(), "task_start");
611        assert_eq!(Trigger::Manual.to_string(), "manual");
612        assert_eq!(
613            Trigger::Schedule("0 * * * *".into()).to_string(),
614            "schedule:0 * * * *"
615        );
616    }
617
618    #[test]
619    fn index_type_strings() {
620        assert_eq!(IndexType::Btree.to_string(), "btree");
621        assert_eq!(IndexType::Hash.to_string(), "hash");
622        assert_eq!(IndexType::Hnsw.to_string(), "hnsw");
623    }
624
625    #[test]
626    fn injection_mode_strings() {
627        assert_eq!(InjectionMode::Full.to_string(), "full");
628        assert_eq!(InjectionMode::Summary.to_string(), "summary");
629        assert_eq!(InjectionMode::TopK(5).to_string(), "topk:5");
630        assert_eq!(InjectionMode::Relevant(0.8).to_string(), "relevant:0.8");
631    }
632
633    #[test]
634    fn env_value_strings() {
635        assert_eq!(
636            env_value_to_string(&EnvValue::Literal("hello".into())),
637            "hello"
638        );
639        assert_eq!(
640            env_value_to_string(&EnvValue::Env("MY_VAR".into())),
641            "env:MY_VAR"
642        );
643    }
644
645    #[test]
646    fn freshness_strings() {
647        assert_eq!(FreshnessDef::Strict.to_string(), "strict");
648        assert!(FreshnessDef::BestEffort {
649            max_staleness: "60s".into()
650        }
651        .to_string()
652        .contains("60s"));
653    }
654
655    // ── signal_target_to_yaml ──────────────────────────────────────────
656
657    #[test]
658    fn signal_target_above() {
659        let s = signal_target_to_yaml(&SignalTarget::Above(0.9));
660        assert!(s.contains("above"));
661        assert!(s.contains("0.9"));
662    }
663
664    #[test]
665    fn signal_target_below() {
666        let s = signal_target_to_yaml(&SignalTarget::Below(0.1));
667        assert!(s.contains("below"));
668        assert!(s.contains("0.1"));
669    }
670
671    #[test]
672    fn signal_target_between() {
673        let s = signal_target_to_yaml(&SignalTarget::Between { min: 0.3, max: 0.7 });
674        assert!(s.contains("between"));
675        assert!(s.contains("0.3"));
676        assert!(s.contains("0.7"));
677    }
678
679    // ── ast_to_markdown smoke ──────────────────────────────────────────
680
681    #[test]
682    fn ast_to_markdown_empty_ast() {
683        let ast = CellstateAst {
684            version: "1.0".into(),
685            definitions: vec![],
686        };
687        let md = ast_to_markdown(&ast);
688        // Empty AST produces empty output
689        assert!(md.is_empty());
690    }
691
692    #[test]
693    fn ast_to_markdown_with_adapter() {
694        let ast = CellstateAst {
695            version: "1.0".into(),
696            definitions: vec![Definition::Adapter(AdapterDef {
697                name: "test-adapter".into(),
698                adapter_type: AdapterType::Postgres,
699                connection: "postgres://localhost/test".into(),
700                options: vec![],
701            })],
702        };
703        let md = ast_to_markdown(&ast);
704        assert!(md.contains("test-adapter"));
705    }
706
707    // ── provider type Display ──────────────────────────────────────────
708
709    #[test]
710    fn provider_type_strings() {
711        assert_eq!(ProviderType::OpenAI.to_string(), "openai");
712        assert_eq!(ProviderType::Anthropic.to_string(), "anthropic");
713        assert_eq!(ProviderType::Local.to_string(), "local");
714        assert_eq!(ProviderType::Custom.to_string(), "custom");
715    }
716
717    // ── cache backend Display ──────────────────────────────────────────
718
719    #[test]
720    fn cache_backend_strings() {
721        assert_eq!(CacheBackendType::Lmdb.to_string(), "lmdb");
722        assert_eq!(CacheBackendType::Memory.to_string(), "memory");
723    }
724
725    // ── autonomy_level_to_string ───────────────────────────────────────
726
727    #[test]
728    fn autonomy_level_strings() {
729        assert_eq!(
730            autonomy_level_to_string(&AutonomyLevel::Operator),
731            "operator"
732        );
733        assert_eq!(
734            autonomy_level_to_string(&AutonomyLevel::Collaborator),
735            "collaborator"
736        );
737        assert_eq!(
738            autonomy_level_to_string(&AutonomyLevel::Consultant),
739            "consultant"
740        );
741        assert_eq!(
742            autonomy_level_to_string(&AutonomyLevel::Approver),
743            "approver"
744        );
745        assert_eq!(
746            autonomy_level_to_string(&AutonomyLevel::Observer),
747            "observer"
748        );
749    }
750
751    // ── modifier Display ───────────────────────────────────────────────
752
753    #[test]
754    fn modifier_embeddable() {
755        let m = ModifierDef::Embeddable {
756            provider: "openai".into(),
757        };
758        assert_eq!(m.to_string(), "embeddable(provider: openai)");
759    }
760
761    #[test]
762    fn modifier_summarizable() {
763        let m = ModifierDef::Summarizable {
764            style: SummaryStyle::Brief,
765            on_triggers: vec![Trigger::TaskEnd, Trigger::TurnEnd],
766        };
767        let s = m.to_string();
768        assert!(s.starts_with("summarizable(style: brief, on: ["));
769        assert!(s.contains("task_end"));
770        assert!(s.contains("turn_end"));
771    }
772
773    #[test]
774    fn modifier_summarizable_detailed() {
775        let m = ModifierDef::Summarizable {
776            style: SummaryStyle::Detailed,
777            on_triggers: vec![],
778        };
779        let s = m.to_string();
780        assert!(s.contains("detailed"));
781        assert!(s.contains("on: []"));
782    }
783
784    #[test]
785    fn modifier_lockable_exclusive() {
786        let m = ModifierDef::Lockable {
787            mode: LockMode::Exclusive,
788        };
789        assert_eq!(m.to_string(), "lockable(mode: exclusive)");
790    }
791
792    #[test]
793    fn modifier_lockable_shared() {
794        let m = ModifierDef::Lockable {
795            mode: LockMode::Shared,
796        };
797        assert_eq!(m.to_string(), "lockable(mode: shared)");
798    }
799
800    // ── action_to_yaml ─────────────────────────────────────────────────
801
802    #[test]
803    fn action_summarize() {
804        let s = action_to_yaml(&Action::Summarize("scope".into()));
805        assert!(s.contains("type: summarize"));
806        assert!(s.contains("target: scope"));
807    }
808
809    #[test]
810    fn action_extract_artifacts() {
811        let s = action_to_yaml(&Action::ExtractArtifacts("trajectory".into()));
812        assert!(s.contains("type: extract_artifacts"));
813        assert!(s.contains("target: trajectory"));
814    }
815
816    #[test]
817    fn action_checkpoint() {
818        let s = action_to_yaml(&Action::Checkpoint("scope".into()));
819        assert!(s.contains("type: checkpoint"));
820        assert!(s.contains("target: scope"));
821    }
822
823    #[test]
824    fn action_prune() {
825        let s = action_to_yaml(&Action::Prune {
826            target: "artifacts".into(),
827            criteria: FilterExpr::Comparison {
828                field: "age".into(),
829                op: CompareOp::Gt,
830                value: FilterValue::Number(30.0),
831            },
832        });
833        assert!(s.contains("type: prune"));
834        assert!(s.contains("target: artifacts"));
835        assert!(s.contains("criteria: age > 30"));
836    }
837
838    #[test]
839    fn action_notify() {
840        let s = action_to_yaml(&Action::Notify("alert".into()));
841        assert!(s.contains("type: notify"));
842        assert!(s.contains("target: alert"));
843    }
844
845    #[test]
846    fn action_inject() {
847        let s = action_to_yaml(&Action::Inject {
848            target: "context".into(),
849            mode: InjectionMode::TopK(5),
850        });
851        assert!(s.contains("type: inject"));
852        assert!(s.contains("target: context"));
853        assert!(s.contains("mode: topk:5"));
854    }
855
856    #[test]
857    fn action_auto_summarize() {
858        let s = action_to_yaml(&Action::AutoSummarize {
859            source_level: AbstractionLevelParsed::Raw,
860            target_level: AbstractionLevelParsed::Summary,
861            create_edges: true,
862        });
863        assert!(s.contains("type: auto_summarize"));
864        assert!(s.contains("create_edges: true"));
865    }
866
867    // ── filter_expr_to_string ──────────────────────────────────────────
868
869    #[test]
870    fn filter_comparison_eq_string() {
871        let f = FilterExpr::Comparison {
872            field: "name".into(),
873            op: CompareOp::Eq,
874            value: FilterValue::String("alice".into()),
875        };
876        assert_eq!(filter_expr_to_string(&f), "name == \"alice\"");
877    }
878
879    #[test]
880    fn filter_comparison_ne_bool() {
881        let f = FilterExpr::Comparison {
882            field: "active".into(),
883            op: CompareOp::Ne,
884            value: FilterValue::Bool(false),
885        };
886        assert_eq!(filter_expr_to_string(&f), "active != false");
887    }
888
889    #[test]
890    fn filter_comparison_gt_number() {
891        let f = FilterExpr::Comparison {
892            field: "score".into(),
893            op: CompareOp::Gt,
894            value: FilterValue::Number(42.0),
895        };
896        assert_eq!(filter_expr_to_string(&f), "score > 42");
897    }
898
899    #[test]
900    fn filter_comparison_lt() {
901        let f = FilterExpr::Comparison {
902            field: "x".into(),
903            op: CompareOp::Lt,
904            value: FilterValue::Number(10.0),
905        };
906        assert_eq!(filter_expr_to_string(&f), "x < 10");
907    }
908
909    #[test]
910    fn filter_comparison_ge_le() {
911        let ge = FilterExpr::Comparison {
912            field: "a".into(),
913            op: CompareOp::Ge,
914            value: FilterValue::Number(1.0),
915        };
916        assert_eq!(filter_expr_to_string(&ge), "a >= 1");
917
918        let le = FilterExpr::Comparison {
919            field: "b".into(),
920            op: CompareOp::Le,
921            value: FilterValue::Number(99.0),
922        };
923        assert_eq!(filter_expr_to_string(&le), "b <= 99");
924    }
925
926    #[test]
927    fn filter_comparison_contains() {
928        let f = FilterExpr::Comparison {
929            field: "tags".into(),
930            op: CompareOp::Contains,
931            value: FilterValue::String("rust".into()),
932        };
933        assert_eq!(filter_expr_to_string(&f), "tags contains \"rust\"");
934    }
935
936    #[test]
937    fn filter_comparison_regex() {
938        let f = FilterExpr::Comparison {
939            field: "name".into(),
940            op: CompareOp::Regex,
941            value: FilterValue::String("^test.*".into()),
942        };
943        assert_eq!(filter_expr_to_string(&f), "name regex \"^test.*\"");
944    }
945
946    #[test]
947    fn filter_comparison_in() {
948        let f = FilterExpr::Comparison {
949            field: "status".into(),
950            op: CompareOp::In,
951            value: FilterValue::Array(vec![
952                FilterValue::String("a".into()),
953                FilterValue::String("b".into()),
954            ]),
955        };
956        assert_eq!(filter_expr_to_string(&f), "status in [...]");
957    }
958
959    #[test]
960    fn filter_comparison_null() {
961        let f = FilterExpr::Comparison {
962            field: "deleted_at".into(),
963            op: CompareOp::Eq,
964            value: FilterValue::Null,
965        };
966        assert_eq!(filter_expr_to_string(&f), "deleted_at == null");
967    }
968
969    #[test]
970    fn filter_comparison_special_values() {
971        let ct = FilterExpr::Comparison {
972            field: "traj".into(),
973            op: CompareOp::Eq,
974            value: FilterValue::CurrentTrajectory,
975        };
976        assert_eq!(filter_expr_to_string(&ct), "traj == current_trajectory");
977
978        let cs = FilterExpr::Comparison {
979            field: "scope".into(),
980            op: CompareOp::Eq,
981            value: FilterValue::CurrentScope,
982        };
983        assert_eq!(filter_expr_to_string(&cs), "scope == current_scope");
984
985        let now = FilterExpr::Comparison {
986            field: "ts".into(),
987            op: CompareOp::Lt,
988            value: FilterValue::Now,
989        };
990        assert_eq!(filter_expr_to_string(&now), "ts < now");
991    }
992
993    #[test]
994    fn filter_and_expression() {
995        let f = FilterExpr::And(vec![
996            FilterExpr::Comparison {
997                field: "age".into(),
998                op: CompareOp::Gt,
999                value: FilterValue::Number(18.0),
1000            },
1001            FilterExpr::Comparison {
1002                field: "active".into(),
1003                op: CompareOp::Eq,
1004                value: FilterValue::Bool(true),
1005            },
1006        ]);
1007        assert_eq!(filter_expr_to_string(&f), "(age > 18 AND active == true)");
1008    }
1009
1010    #[test]
1011    fn filter_or_expression() {
1012        let f = FilterExpr::Or(vec![
1013            FilterExpr::Comparison {
1014                field: "role".into(),
1015                op: CompareOp::Eq,
1016                value: FilterValue::String("admin".into()),
1017            },
1018            FilterExpr::Comparison {
1019                field: "role".into(),
1020                op: CompareOp::Eq,
1021                value: FilterValue::String("super".into()),
1022            },
1023        ]);
1024        assert_eq!(
1025            filter_expr_to_string(&f),
1026            "(role == \"admin\" OR role == \"super\")"
1027        );
1028    }
1029
1030    #[test]
1031    fn filter_not_expression() {
1032        let f = FilterExpr::Not(Box::new(FilterExpr::Comparison {
1033            field: "deleted".into(),
1034            op: CompareOp::Eq,
1035            value: FilterValue::Bool(true),
1036        }));
1037        assert_eq!(filter_expr_to_string(&f), "NOT deleted == true");
1038    }
1039
1040    // ── FieldType Display additional coverage ─────────────────────────
1041
1042    #[test]
1043    fn field_type_uuid_and_timestamp() {
1044        assert_eq!(FieldType::Uuid.to_string(), "uuid");
1045        assert_eq!(FieldType::Timestamp.to_string(), "timestamp");
1046    }
1047
1048    #[test]
1049    fn field_type_enum_variants() {
1050        let f = FieldType::Enum(vec!["Active".into(), "Inactive".into(), "Pending".into()]);
1051        assert_eq!(f.to_string(), "enum(Active, Inactive, Pending)");
1052    }
1053
1054    #[test]
1055    fn field_type_nested_array() {
1056        let f = FieldType::Array(Box::new(FieldType::Array(Box::new(FieldType::Int))));
1057        assert_eq!(f.to_string(), "array(array(int))");
1058    }
1059
1060    // ── Retention Display additional coverage ──────────────────────────
1061
1062    #[test]
1063    fn retention_max() {
1064        assert_eq!(Retention::Max(100).to_string(), "max(100)");
1065    }
1066
1067    // ── Trigger Display additional coverage ────────────────────────────
1068
1069    #[test]
1070    fn trigger_all_variants() {
1071        assert_eq!(Trigger::TaskEnd.to_string(), "task_end");
1072        assert_eq!(Trigger::ScopeClose.to_string(), "scope_close");
1073        assert_eq!(Trigger::TurnEnd.to_string(), "turn_end");
1074    }
1075
1076    // ── IndexType Display additional coverage ──────────────────────────
1077
1078    #[test]
1079    fn index_type_gin_and_ivfflat() {
1080        assert_eq!(IndexType::Gin.to_string(), "gin");
1081        assert_eq!(IndexType::Ivfflat.to_string(), "ivfflat");
1082    }
1083
1084    // ── Lifecycle Display additional coverage ──────────────────────────
1085
1086    #[test]
1087    fn lifecycle_auto_close_with_schedule() {
1088        let l = Lifecycle::AutoClose(Trigger::Schedule("5m".into()));
1089        assert_eq!(l.to_string(), "auto_close(schedule:5m)");
1090    }
1091}