Skip to main content

ralph_core/
preset_source.rs

1//! Preset source abstraction — extensible pluggable loaders for hat-collection
2//! presets authored in formats other than Ralph's native YAML.
3//!
4//! This module defines the [`PresetSource`] trait and ships two built-in
5//! implementations:
6//!
7//! - [`YamlPresetSource`] — the canonical Ralph single-file YAML shape.
8//! - [`TomlPresetSource`] — imports multi-file TOML presets authored for
9//!   the [`@mobrienv/autoloop`](https://github.com/mobrienv/autoloop) runtime
10//!   (directory containing `autoloops.toml` + `topology.toml` + `roles/*.md`
11//!   + optional `harness.md`).
12//!
13//! New preset shapes plug in by implementing [`PresetSource::detect`] +
14//! [`PresetSource::load`] and registering with [`PresetRegistry`].
15//!
16//! All impls produce a [`serde_yaml::Value`] that can be consumed by the
17//! existing hat-overlay merging pipeline in `ralph-cli`, so downstream code
18//! does not need to know which preset format was on disk.
19//!
20//! ## Autoloop → Ralph mapping
21//!
22//! | Autoloop                                          | Ralph                                      |
23//! | ------------------------------------------------- | ------------------------------------------ |
24//! | `[[role]] id`                                     | `hats.<id>` key                            |
25//! | `[[role]] prompt_file` (read from disk)           | `hats.<id>.instructions`                   |
26//! | `[[role]] emits`                                  | `hats.<id>.publishes`                      |
27//! | `[handoff] <event> = [role, ...]` (inverted)      | `hats.<role>.triggers` list                |
28//! | `topology.completion`                             | `event_loop.completion_promise`            |
29//! | `autoloops.toml` `event_loop.completion_event`    | `event_loop.completion_promise` (fallback) |
30//! | `autoloops.toml` `event_loop.required_events`     | `event_loop.required_events`               |
31//! | `autoloops.toml` `event_loop.max_iterations`      | `event_loop.max_iterations`                |
32//! | `handoff["loop.start"]` (first target's role id)  | `event_loop.starting_event = "loop.start"` |
33//! | `harness.md` (whole file)                         | appended to `core.guardrails`              |
34//!
35//! Fields Ralph has but autoloop lacks (backend, cli, core.specs_dir, etc.)
36//! stay unset — the overlay only populates hats/events/event_loop slots, and
37//! the caller's `ralph.yml` supplies the rest exactly as it does for builtin
38//! presets.
39
40use serde_yaml::{Mapping, Value};
41use std::fs;
42use std::io;
43use std::path::{Path, PathBuf};
44
45/// Error returned by a [`PresetSource::load`] implementation.
46#[derive(Debug, thiserror::Error)]
47pub enum PresetSourceError {
48    #[error("i/o error reading preset at {path}: {source}")]
49    Io {
50        path: PathBuf,
51        #[source]
52        source: io::Error,
53    },
54    #[error("malformed preset at {path}: {message}")]
55    Malformed { path: PathBuf, message: String },
56    #[error("unsupported preset shape at {path}")]
57    Unsupported { path: PathBuf },
58}
59
60impl PresetSourceError {
61    pub(crate) fn io(path: impl Into<PathBuf>, source: io::Error) -> Self {
62        Self::Io {
63            path: path.into(),
64            source,
65        }
66    }
67
68    pub(crate) fn malformed(path: impl Into<PathBuf>, message: impl Into<String>) -> Self {
69        Self::Malformed {
70            path: path.into(),
71            message: message.into(),
72        }
73    }
74}
75
76/// Pluggable loader for a preset shape.
77///
78/// Implementations are stateless; a single instance is reused across loads.
79/// Detection is cheap and path-based so callers can probe multiple sources
80/// without incurring the cost of a full parse.
81pub trait PresetSource: Send + Sync {
82    /// Short identifier used in error messages and logs (e.g., `"yaml"`,
83    /// `"toml"`).
84    fn id(&self) -> &'static str;
85
86    /// Returns `true` iff this source can handle the preset at `path`.
87    ///
88    /// Callers walk through registered sources in order; the first one whose
89    /// `detect` returns `true` is chosen. Detection MUST be side-effect free
90    /// (read-only fs access is fine).
91    fn detect(&self, path: &Path) -> bool;
92
93    /// Parse the preset at `path` into a hat-overlay YAML value.
94    ///
95    /// The returned [`Value`] MUST be a mapping with a `hats:` key (and
96    /// optionally `events:` / `event_loop:`). It is merged into the core
97    /// Ralph config by the caller.
98    fn load(&self, path: &Path) -> Result<Value, PresetSourceError>;
99}
100
101/// Ordered registry of [`PresetSource`] impls.
102///
103/// Sources registered earlier win detection ties. The default registry is
104/// `[TomlPresetSource, YamlPresetSource]` — TOML-dir first because its
105/// detection is strict (requires a directory with two specific TOML files),
106/// YAML second as the permissive fallback.
107pub struct PresetRegistry {
108    sources: Vec<Box<dyn PresetSource>>,
109}
110
111impl PresetRegistry {
112    /// Empty registry. Use [`PresetRegistry::default`] for the shipped sources.
113    pub fn new() -> Self {
114        Self {
115            sources: Vec::new(),
116        }
117    }
118
119    /// Append a source to the registry.
120    pub fn register(mut self, source: Box<dyn PresetSource>) -> Self {
121        self.sources.push(source);
122        self
123    }
124
125    /// Find the first registered source whose `detect` returns true, then
126    /// invoke its `load`.
127    pub fn load(&self, path: &Path) -> Result<Value, PresetSourceError> {
128        for source in &self.sources {
129            if source.detect(path) {
130                return source.load(path);
131            }
132        }
133        Err(PresetSourceError::Unsupported {
134            path: path.to_path_buf(),
135        })
136    }
137
138    /// Peek which source handles `path` without loading. Returns the source's
139    /// `id()` string, or `None` if no source matches.
140    pub fn detect(&self, path: &Path) -> Option<&'static str> {
141        self.sources.iter().find(|s| s.detect(path)).map(|s| s.id())
142    }
143}
144
145impl Default for PresetRegistry {
146    fn default() -> Self {
147        Self::new()
148            .register(Box::new(TomlPresetSource::new()))
149            .register(Box::new(YamlPresetSource::new()))
150    }
151}
152
153// ──────────────────────────────────────────────────────────────────────────
154// YAML source — the native Ralph shape.
155// ──────────────────────────────────────────────────────────────────────────
156
157#[derive(Default)]
158pub struct YamlPresetSource;
159
160impl YamlPresetSource {
161    pub fn new() -> Self {
162        Self
163    }
164}
165
166impl PresetSource for YamlPresetSource {
167    fn id(&self) -> &'static str {
168        "yaml"
169    }
170
171    fn detect(&self, path: &Path) -> bool {
172        if !path.is_file() {
173            return false;
174        }
175        matches!(
176            path.extension().and_then(|e| e.to_str()),
177            Some("yml" | "yaml")
178        )
179    }
180
181    fn load(&self, path: &Path) -> Result<Value, PresetSourceError> {
182        let text = fs::read_to_string(path).map_err(|e| PresetSourceError::io(path, e))?;
183        serde_yaml::from_str(&text).map_err(|e| PresetSourceError::malformed(path, e.to_string()))
184    }
185}
186
187// ──────────────────────────────────────────────────────────────────────────
188// Autoloop source — multi-file TOML preset directory.
189// ──────────────────────────────────────────────────────────────────────────
190
191#[derive(Default)]
192pub struct TomlPresetSource;
193
194impl TomlPresetSource {
195    pub fn new() -> Self {
196        Self
197    }
198}
199
200impl PresetSource for TomlPresetSource {
201    fn id(&self) -> &'static str {
202        "toml"
203    }
204
205    fn detect(&self, path: &Path) -> bool {
206        path.is_dir()
207            && path.join("topology.toml").is_file()
208            && path.join("autoloops.toml").is_file()
209    }
210
211    fn load(&self, path: &Path) -> Result<Value, PresetSourceError> {
212        let topology = read_toml(&path.join("topology.toml"))?;
213        let autoloops = read_toml(&path.join("autoloops.toml"))?;
214        let harness_text = maybe_read_text(&path.join("harness.md"))?;
215
216        build_overlay(path, &topology, &autoloops, harness_text.as_deref())
217    }
218}
219
220fn read_toml(path: &Path) -> Result<toml::Value, PresetSourceError> {
221    let text = fs::read_to_string(path).map_err(|e| PresetSourceError::io(path, e))?;
222    toml::from_str(&text).map_err(|e| PresetSourceError::malformed(path, e.to_string()))
223}
224
225fn maybe_read_text(path: &Path) -> Result<Option<String>, PresetSourceError> {
226    if !path.is_file() {
227        return Ok(None);
228    }
229    fs::read_to_string(path)
230        .map(Some)
231        .map_err(|e| PresetSourceError::io(path, e))
232}
233
234fn build_overlay(
235    preset_dir: &Path,
236    topology: &toml::Value,
237    autoloops: &toml::Value,
238    harness: Option<&str>,
239) -> Result<Value, PresetSourceError> {
240    let topology_table = topology
241        .as_table()
242        .ok_or_else(|| PresetSourceError::malformed(preset_dir, "topology.toml must be a table"))?;
243
244    // ── Extract topology fields ────────────────────────────────────────────
245    let preset_name = topology_table
246        .get("name")
247        .and_then(|v| v.as_str())
248        .unwrap_or("")
249        .to_string();
250
251    let completion_event = topology_table
252        .get("completion")
253        .and_then(|v| v.as_str())
254        .map(ToString::to_string);
255
256    let roles = extract_roles(preset_dir, topology_table)?;
257    let handoff = extract_handoff(preset_dir, topology_table)?;
258
259    // Invert the handoff map: Ralph defines triggers per hat, autoloop
260    // defines handoff per event → role list. Pattern keys (`/regex/`) are
261    // passed through verbatim — Ralph matches raw strings for triggers so
262    // regex handoffs become literal trigger strings. Acceptable because
263    // 15/16 autoloop presets use exact-match handoffs.
264    let triggers_by_role = invert_handoff(&handoff);
265
266    // ── Build hats mapping ─────────────────────────────────────────────────
267    let mut hats = Mapping::new();
268    for role in &roles {
269        let mut hat = Mapping::new();
270        insert_str(&mut hat, "name", &role.name);
271        // Ralph requires non-empty `description`. Autoloop presets don't have
272        // this field, so synthesize one from id+first emit.
273        let description = role
274            .description
275            .clone()
276            .unwrap_or_else(|| match role.emits.first() {
277                Some(ev) => format!("Autoloop role `{}` — emits {}", role.id, ev),
278                None => format!("Autoloop role `{}`", role.id),
279            });
280        insert_str(&mut hat, "description", &description);
281        insert_str_list(
282            &mut hat,
283            "triggers",
284            triggers_by_role.get(&role.id).map(Vec::as_slice),
285        );
286        insert_str_list(&mut hat, "publishes", Some(&role.emits));
287        insert_str(&mut hat, "instructions", &role.prompt);
288        if let Some(default) = role.emits.first() {
289            insert_str(&mut hat, "default_publishes", default);
290        }
291        hats.insert(Value::String(role.id.clone()), Value::Mapping(hat));
292    }
293
294    // ── Build event_loop overlay ───────────────────────────────────────────
295    let mut event_loop = Mapping::new();
296    let autoloops_event_loop = autoloops
297        .get("event_loop")
298        .and_then(|v| v.as_table())
299        .cloned()
300        .unwrap_or_default();
301
302    // Completion: topology.completion wins, then autoloops event_loop.completion_event.
303    let completion = completion_event.or_else(|| {
304        autoloops_event_loop
305            .get("completion_event")
306            .and_then(|v| v.as_str())
307            .map(ToString::to_string)
308    });
309    if let Some(c) = completion {
310        insert_str(&mut event_loop, "completion_promise", &c);
311    }
312
313    if let Some(max_iters) = autoloops_event_loop
314        .get("max_iterations")
315        .and_then(toml_int)
316    {
317        event_loop.insert(
318            Value::String("max_iterations".into()),
319            Value::Number(max_iters.into()),
320        );
321    }
322
323    if let Some(required) = autoloops_event_loop
324        .get("required_events")
325        .and_then(|v| v.as_array())
326    {
327        let items: Vec<Value> = required
328            .iter()
329            .filter_map(|v| v.as_str().map(|s| Value::String(s.to_string())))
330            .collect();
331        event_loop.insert(
332            Value::String("required_events".into()),
333            Value::Sequence(items),
334        );
335    }
336
337    // Starting event: autoloop preset convention is `loop.start`. If the
338    // handoff defines a route out of `loop.start`, honor it; otherwise leave
339    // unset so Ralph derives from the hat topology.
340    if handoff.iter().any(|(event, _)| event == "loop.start") {
341        insert_str(&mut event_loop, "starting_event", "loop.start");
342    }
343
344    // ── Overlay envelope ───────────────────────────────────────────────────
345    let mut overlay = Mapping::new();
346    if !preset_name.is_empty() {
347        insert_str(&mut overlay, "name", &preset_name);
348    }
349    insert_str(
350        &mut overlay,
351        "description",
352        &format!(
353            "Imported autoloop preset{}",
354            if preset_name.is_empty() {
355                String::new()
356            } else {
357                format!(": {}", preset_name)
358            }
359        ),
360    );
361    overlay.insert(Value::String("hats".into()), Value::Mapping(hats));
362    if !event_loop.is_empty() {
363        overlay.insert(
364            Value::String("event_loop".into()),
365            Value::Mapping(event_loop),
366        );
367    }
368
369    // Harness text goes into guardrails as a single entry prefixed with a
370    // marker. The caller's `load_hats_value` restricts overlays to
371    // hats/events/event_loop by default, but the `name`+`description`+harness
372    // payload is captured in each hat's instructions as well so nothing is
373    // lost when the strict overlay filter drops the top-level extras.
374    if let Some(harness_text) = harness {
375        prepend_harness_into_hats(&mut overlay, harness_text);
376    }
377
378    Ok(Value::Mapping(overlay))
379}
380
381/// Prepend the `harness.md` content (global autoloop rules) to every hat's
382/// `instructions`. This is the only slot in Ralph's hats-overlay schema where
383/// the text survives hat-overlay extraction — top-level keys like `core:` are
384/// dropped by `hats_disallowed_keys`.
385fn prepend_harness_into_hats(overlay: &mut Mapping, harness: &str) {
386    let Some(hats) = overlay
387        .get_mut(Value::String("hats".into()))
388        .and_then(Value::as_mapping_mut)
389    else {
390        return;
391    };
392
393    let harness_block = format!(
394        "## Shared harness rules (imported from autoloop `harness.md`)\n\n{}\n\n---\n\n",
395        harness.trim_end()
396    );
397
398    for (_k, v) in hats.iter_mut() {
399        let Some(hat) = v.as_mapping_mut() else {
400            continue;
401        };
402        let key = Value::String("instructions".into());
403        let merged = match hat.get(&key).and_then(Value::as_str) {
404            Some(existing) => format!("{}{}", harness_block, existing),
405            None => harness_block.clone(),
406        };
407        hat.insert(key, Value::String(merged));
408    }
409}
410
411struct AutoloopRole {
412    id: String,
413    name: String,
414    description: Option<String>,
415    emits: Vec<String>,
416    prompt: String,
417}
418
419fn extract_roles(
420    preset_dir: &Path,
421    topology: &toml::map::Map<String, toml::Value>,
422) -> Result<Vec<AutoloopRole>, PresetSourceError> {
423    let raw_roles = topology
424        .get("role")
425        .and_then(|v| v.as_array())
426        .cloned()
427        .unwrap_or_default();
428
429    let mut roles = Vec::with_capacity(raw_roles.len());
430    for role_value in raw_roles {
431        let role_table = role_value.as_table().ok_or_else(|| {
432            PresetSourceError::malformed(preset_dir, "every [[role]] must be a TOML table")
433        })?;
434
435        let id = role_table
436            .get("id")
437            .and_then(|v| v.as_str())
438            .ok_or_else(|| PresetSourceError::malformed(preset_dir, "role missing `id`"))?
439            .to_string();
440
441        let emits: Vec<String> = role_table
442            .get("emits")
443            .and_then(|v| v.as_array())
444            .map(|arr| {
445                arr.iter()
446                    .filter_map(|v| v.as_str().map(ToString::to_string))
447                    .collect()
448            })
449            .unwrap_or_default();
450
451        let inline_prompt = role_table
452            .get("prompt")
453            .and_then(|v| v.as_str())
454            .map(ToString::to_string);
455
456        let prompt_file = role_table
457            .get("prompt_file")
458            .and_then(|v| v.as_str())
459            .map(ToString::to_string);
460
461        let prompt = resolve_role_prompt(preset_dir, inline_prompt, prompt_file.as_deref())?;
462
463        let name = role_table
464            .get("name")
465            .and_then(|v| v.as_str())
466            .map(ToString::to_string)
467            .unwrap_or_else(|| humanize_role_id(&id));
468
469        let description = role_table
470            .get("description")
471            .and_then(|v| v.as_str())
472            .map(ToString::to_string);
473
474        roles.push(AutoloopRole {
475            id,
476            name,
477            description,
478            emits,
479            prompt,
480        });
481    }
482
483    Ok(roles)
484}
485
486fn resolve_role_prompt(
487    preset_dir: &Path,
488    inline: Option<String>,
489    prompt_file: Option<&str>,
490) -> Result<String, PresetSourceError> {
491    if let Some(inline) = inline
492        && !inline.trim().is_empty()
493    {
494        return Ok(inline);
495    }
496    let Some(rel) = prompt_file else {
497        return Ok(String::new());
498    };
499    let full = preset_dir.join(rel);
500    if !full.is_file() {
501        return Ok(String::new());
502    }
503    fs::read_to_string(&full).map_err(|e| PresetSourceError::io(full, e))
504}
505
506fn extract_handoff(
507    preset_dir: &Path,
508    topology: &toml::map::Map<String, toml::Value>,
509) -> Result<Vec<(String, Vec<String>)>, PresetSourceError> {
510    let Some(raw) = topology.get("handoff") else {
511        return Ok(Vec::new());
512    };
513    let table = raw
514        .as_table()
515        .ok_or_else(|| PresetSourceError::malformed(preset_dir, "handoff must be a TOML table"))?;
516
517    let mut out = Vec::with_capacity(table.len());
518    for (event, value) in table {
519        let targets: Vec<String> = match value {
520            toml::Value::Array(arr) => arr
521                .iter()
522                .filter_map(|v| v.as_str().map(ToString::to_string))
523                .collect(),
524            toml::Value::String(s) => vec![s.clone()],
525            _ => continue,
526        };
527        out.push((event.clone(), targets));
528    }
529    Ok(out)
530}
531
532fn invert_handoff(
533    handoff: &[(String, Vec<String>)],
534) -> std::collections::BTreeMap<String, Vec<String>> {
535    let mut by_role: std::collections::BTreeMap<String, Vec<String>> =
536        std::collections::BTreeMap::new();
537    for (event, targets) in handoff {
538        for role in targets {
539            let entry = by_role.entry(role.clone()).or_default();
540            if !entry.iter().any(|e| e == event) {
541                entry.push(event.clone());
542            }
543        }
544    }
545    by_role
546}
547
548fn humanize_role_id(id: &str) -> String {
549    if id.is_empty() {
550        return String::new();
551    }
552    let mut chars = id.chars();
553    match chars.next() {
554        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
555        None => String::new(),
556    }
557}
558
559fn insert_str(map: &mut Mapping, key: &str, value: &str) {
560    map.insert(Value::String(key.into()), Value::String(value.to_string()));
561}
562
563fn insert_str_list(map: &mut Mapping, key: &str, values: Option<&[String]>) {
564    let list = values
565        .map(|xs| {
566            xs.iter()
567                .map(|v| Value::String(v.clone()))
568                .collect::<Vec<_>>()
569        })
570        .unwrap_or_default();
571    map.insert(Value::String(key.into()), Value::Sequence(list));
572}
573
574fn toml_int(v: &toml::Value) -> Option<i64> {
575    match v {
576        toml::Value::Integer(i) => Some(*i),
577        toml::Value::String(s) => s.parse().ok(),
578        _ => None,
579    }
580}
581
582// ──────────────────────────────────────────────────────────────────────────
583// Tests
584// ──────────────────────────────────────────────────────────────────────────
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use std::fs;
590    use tempfile::TempDir;
591
592    fn write_preset(dir: &Path, files: &[(&str, &str)]) {
593        for (rel, content) in files {
594            let full = dir.join(rel);
595            if let Some(parent) = full.parent() {
596                fs::create_dir_all(parent).unwrap();
597            }
598            fs::write(full, content).unwrap();
599        }
600    }
601
602    fn minimal_preset(dir: &Path) {
603        write_preset(
604            dir,
605            &[
606                (
607                    "autoloops.toml",
608                    r#"
609event_loop.max_iterations = 42
610event_loop.completion_event = "task.complete"
611event_loop.required_events = ["review.passed"]
612"#,
613                ),
614                (
615                    "topology.toml",
616                    r#"
617name = "demo"
618completion = "task.complete"
619
620[[role]]
621id = "planner"
622emits = ["tasks.ready"]
623prompt_file = "roles/planner.md"
624
625[[role]]
626id = "builder"
627emits = ["review.ready"]
628prompt_file = "roles/builder.md"
629
630[[role]]
631id = "critic"
632emits = ["review.passed", "review.rejected"]
633prompt_file = "roles/critic.md"
634
635[handoff]
636"loop.start" = ["planner"]
637"tasks.ready" = ["builder"]
638"review.ready" = ["critic"]
639"review.rejected" = ["builder"]
640"#,
641                ),
642                ("roles/planner.md", "Plan the work."),
643                ("roles/builder.md", "Build the work."),
644                ("roles/critic.md", "Criticize the work."),
645                ("harness.md", "Always be honest.\n"),
646            ],
647        );
648    }
649
650    #[test]
651    fn yaml_source_detects_yml_files() {
652        let tmp = TempDir::new().unwrap();
653        let yml = tmp.path().join("x.yml");
654        fs::write(&yml, "event_loop: {}").unwrap();
655        let src = YamlPresetSource::new();
656        assert!(src.detect(&yml));
657    }
658
659    #[test]
660    fn yaml_source_rejects_directories() {
661        let tmp = TempDir::new().unwrap();
662        assert!(!YamlPresetSource::new().detect(tmp.path()));
663    }
664
665    #[test]
666    fn autoloop_source_detects_valid_preset_dir() {
667        let tmp = TempDir::new().unwrap();
668        minimal_preset(tmp.path());
669        assert!(TomlPresetSource::new().detect(tmp.path()));
670    }
671
672    #[test]
673    fn autoloop_source_rejects_files() {
674        let tmp = TempDir::new().unwrap();
675        let yml = tmp.path().join("x.yml");
676        fs::write(&yml, "").unwrap();
677        assert!(!TomlPresetSource::new().detect(&yml));
678    }
679
680    #[test]
681    fn autoloop_source_rejects_dir_missing_topology() {
682        let tmp = TempDir::new().unwrap();
683        fs::write(tmp.path().join("autoloops.toml"), "").unwrap();
684        assert!(!TomlPresetSource::new().detect(tmp.path()));
685    }
686
687    #[test]
688    fn autoloop_source_loads_preset_with_inverted_handoffs() {
689        let tmp = TempDir::new().unwrap();
690        minimal_preset(tmp.path());
691
692        let overlay = TomlPresetSource::new().load(tmp.path()).unwrap();
693        let map = overlay.as_mapping().unwrap();
694
695        // Hats are populated for each role.
696        let hats = map
697            .get(Value::String("hats".into()))
698            .and_then(Value::as_mapping)
699            .unwrap();
700        assert_eq!(hats.len(), 3);
701
702        // Builder hat has triggers derived from inverted handoff.
703        let builder = hats
704            .get(Value::String("builder".into()))
705            .and_then(Value::as_mapping)
706            .unwrap();
707        let triggers: Vec<String> = builder
708            .get(Value::String("triggers".into()))
709            .and_then(Value::as_sequence)
710            .unwrap()
711            .iter()
712            .filter_map(|v| v.as_str().map(ToString::to_string))
713            .collect();
714        assert!(triggers.contains(&"tasks.ready".to_string()));
715        assert!(triggers.contains(&"review.rejected".to_string()));
716
717        // Builder publishes its emits.
718        let publishes: Vec<String> = builder
719            .get(Value::String("publishes".into()))
720            .and_then(Value::as_sequence)
721            .unwrap()
722            .iter()
723            .filter_map(|v| v.as_str().map(ToString::to_string))
724            .collect();
725        assert_eq!(publishes, vec!["review.ready".to_string()]);
726
727        // Instructions include both the harness block and the role prompt.
728        let instructions = builder
729            .get(Value::String("instructions".into()))
730            .and_then(Value::as_str)
731            .unwrap();
732        assert!(instructions.contains("Always be honest"));
733        assert!(instructions.contains("Build the work."));
734    }
735
736    #[test]
737    fn autoloop_source_populates_event_loop() {
738        let tmp = TempDir::new().unwrap();
739        minimal_preset(tmp.path());
740
741        let overlay = TomlPresetSource::new().load(tmp.path()).unwrap();
742        let event_loop = overlay
743            .as_mapping()
744            .unwrap()
745            .get(Value::String("event_loop".into()))
746            .and_then(Value::as_mapping)
747            .unwrap();
748
749        assert_eq!(
750            event_loop
751                .get(Value::String("completion_promise".into()))
752                .and_then(Value::as_str),
753            Some("task.complete")
754        );
755        assert_eq!(
756            event_loop
757                .get(Value::String("max_iterations".into()))
758                .and_then(Value::as_i64),
759            Some(42)
760        );
761        assert_eq!(
762            event_loop
763                .get(Value::String("starting_event".into()))
764                .and_then(Value::as_str),
765            Some("loop.start")
766        );
767
768        let required: Vec<String> = event_loop
769            .get(Value::String("required_events".into()))
770            .and_then(Value::as_sequence)
771            .unwrap()
772            .iter()
773            .filter_map(|v| v.as_str().map(ToString::to_string))
774            .collect();
775        assert_eq!(required, vec!["review.passed".to_string()]);
776    }
777
778    #[test]
779    fn autoloop_completion_falls_back_to_event_loop_config() {
780        let tmp = TempDir::new().unwrap();
781        write_preset(
782            tmp.path(),
783            &[
784                (
785                    "autoloops.toml",
786                    r#"event_loop.completion_event = "done.fire""#,
787                ),
788                (
789                    "topology.toml",
790                    r#"
791name = "x"
792[[role]]
793id = "one"
794emits = ["done.fire"]
795prompt = "be done"
796[handoff]
797"loop.start" = ["one"]
798"#,
799                ),
800            ],
801        );
802
803        let overlay = TomlPresetSource::new().load(tmp.path()).unwrap();
804        let cp = overlay
805            .as_mapping()
806            .unwrap()
807            .get(Value::String("event_loop".into()))
808            .and_then(Value::as_mapping)
809            .unwrap()
810            .get(Value::String("completion_promise".into()))
811            .and_then(Value::as_str)
812            .unwrap();
813        assert_eq!(cp, "done.fire");
814    }
815
816    #[test]
817    fn registry_default_picks_autoloop_for_preset_dirs_and_yaml_for_files() {
818        let registry = PresetRegistry::default();
819
820        let tmp = TempDir::new().unwrap();
821        minimal_preset(tmp.path());
822        assert_eq!(registry.detect(tmp.path()), Some("toml"));
823
824        let yml = tmp.path().join("out.yml");
825        fs::write(&yml, "event_loop: {}").unwrap();
826        assert_eq!(registry.detect(&yml), Some("yaml"));
827    }
828
829    #[test]
830    fn registry_reports_unsupported_for_unknown_shape() {
831        let registry = PresetRegistry::default();
832        let tmp = TempDir::new().unwrap();
833        let weird = tmp.path().join("weird.txt");
834        fs::write(&weird, "").unwrap();
835
836        let err = registry.load(&weird).unwrap_err();
837        assert!(matches!(err, PresetSourceError::Unsupported { .. }));
838    }
839
840    #[test]
841    fn handoff_inversion_preserves_event_order_per_role() {
842        let handoff = vec![
843            ("a.first".to_string(), vec!["r1".to_string()]),
844            (
845                "a.second".to_string(),
846                vec!["r1".to_string(), "r2".to_string()],
847            ),
848            ("a.third".to_string(), vec!["r1".to_string()]),
849        ];
850        let inverted = invert_handoff(&handoff);
851        assert_eq!(
852            inverted.get("r1").unwrap(),
853            &vec![
854                "a.first".to_string(),
855                "a.second".to_string(),
856                "a.third".to_string()
857            ]
858        );
859        assert_eq!(inverted.get("r2").unwrap(), &vec!["a.second".to_string()]);
860    }
861
862    /// Smoke test against the real autoloop `autocode` preset shipped in the
863    /// sibling workspace. Skipped when the fixtures aren't present so CI on a
864    /// bare clone still passes.
865    #[test]
866    fn autoloop_source_loads_real_autocode_fixture_when_available() {
867        let fixture = Path::new(env!("CARGO_MANIFEST_DIR"))
868            .join("../../../autoloop/packages/presets/presets/autocode");
869        if !fixture.is_dir() {
870            eprintln!("skip: {} not present", fixture.display());
871            return;
872        }
873
874        let overlay = TomlPresetSource::new()
875            .load(&fixture)
876            .expect("real autocode preset must load");
877
878        let hats = overlay
879            .as_mapping()
880            .unwrap()
881            .get(Value::String("hats".into()))
882            .and_then(Value::as_mapping)
883            .expect("hats mapping populated");
884
885        for expected in ["planner", "builder", "critic", "finalizer"] {
886            assert!(
887                hats.contains_key(Value::String(expected.into())),
888                "missing hat: {expected}"
889            );
890        }
891
892        let event_loop = overlay
893            .as_mapping()
894            .unwrap()
895            .get(Value::String("event_loop".into()))
896            .and_then(Value::as_mapping)
897            .expect("event_loop overlay populated");
898        assert_eq!(
899            event_loop
900                .get(Value::String("completion_promise".into()))
901                .and_then(Value::as_str),
902            Some("task.complete")
903        );
904    }
905}