1use serde_yaml::{Mapping, Value};
41use std::fs;
42use std::io;
43use std::path::{Path, PathBuf};
44
45#[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
76pub trait PresetSource: Send + Sync {
82 fn id(&self) -> &'static str;
85
86 fn detect(&self, path: &Path) -> bool;
92
93 fn load(&self, path: &Path) -> Result<Value, PresetSourceError>;
99}
100
101pub struct PresetRegistry {
108 sources: Vec<Box<dyn PresetSource>>,
109}
110
111impl PresetRegistry {
112 pub fn new() -> Self {
114 Self {
115 sources: Vec::new(),
116 }
117 }
118
119 pub fn register(mut self, source: Box<dyn PresetSource>) -> Self {
121 self.sources.push(source);
122 self
123 }
124
125 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 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#[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#[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 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 let triggers_by_role = invert_handoff(&handoff);
265
266 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 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 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 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 if handoff.iter().any(|(event, _)| event == "loop.start") {
341 insert_str(&mut event_loop, "starting_event", "loop.start");
342 }
343
344 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 if let Some(harness_text) = harness {
375 prepend_harness_into_hats(&mut overlay, harness_text);
376 }
377
378 Ok(Value::Mapping(overlay))
379}
380
381fn 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#[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 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 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 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 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 #[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}