Skip to main content

loopsmith_core/
lib.rs

1//! Config model and validation for loopsmith.
2//!
3//! A loop config is the A–H model from the template:
4//!
5//! - **A** `information`      — static context handed to every node
6//! - **B** `pre_execution`    — the "do it manually first" work list
7//! - **C** `goals`            — named objectives in natural language
8//! - **D** `validations`      — how a goal is checked, per goal or `overall`
9//! - **E** `success`          — what counts as success, per goal or `overall`
10//! - **F** `stop_gates`       — the four layered exits
11//! - **G** `schedules`        — time or event triggers
12//! - **H** `constraints`      — limits applied per node or globally
13//!
14//! Validation exists to make the corpus rule enforceable: a goal without a
15//! machine-checkable validation is the single most common way loops fail, so
16//! the config is rejected rather than run.
17
18pub mod config;
19pub mod md;
20pub mod validate;
21
22pub use config::*;
23pub use md::{parse_md, render_md};
24pub use validate::{validate, Issue, Severity, ValidationReport};
25
26use std::path::Path;
27
28#[derive(Debug, thiserror::Error)]
29pub enum CoreError {
30    #[error("io error reading {path}: {source}")]
31    Io {
32        path: String,
33        #[source]
34        source: std::io::Error,
35    },
36    #[error("could not parse {path} as YAML or JSON:\n  yaml: {yaml}\n  json: {json}")]
37    Parse {
38        path: String,
39        yaml: String,
40        json: String,
41    },
42    #[error("config is invalid:\n{0}")]
43    Invalid(String),
44}
45
46/// Load a config from Markdown, YAML, or JSON.
47///
48/// Markdown is chosen by extension, because a `.md` config is a different
49/// grammar rather than a different serialization — guessing at it would mean
50/// reporting a YAML parse error for a document that was never YAML.
51/// Everything else falls through to [`parse_str`], which tries YAML then JSON.
52pub fn load(path: impl AsRef<Path>) -> Result<LoopConfig, CoreError> {
53    let path = path.as_ref();
54    let text = std::fs::read_to_string(path).map_err(|source| CoreError::Io {
55        path: path.display().to_string(),
56        source,
57    })?;
58    let origin = path.display().to_string();
59    if is_markdown(path) {
60        return md::parse_md(&text, &origin);
61    }
62    parse_str(&text, &origin)
63}
64
65/// Whether a path should be read as a markdown config.
66pub fn is_markdown(path: &Path) -> bool {
67    path.extension()
68        .and_then(|e| e.to_str())
69        .map(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown"))
70        .unwrap_or(false)
71}
72
73/// Parse config text, trying YAML first (a superset of JSON in practice) and
74/// falling back to strict JSON so both error messages survive to the caller.
75pub fn parse_str(text: &str, origin: &str) -> Result<LoopConfig, CoreError> {
76    let yaml_err = match serde_yaml::from_str::<LoopConfig>(text) {
77        Ok(cfg) => return Ok(cfg),
78        Err(e) => e.to_string(),
79    };
80    let json_err = match serde_json::from_str::<LoopConfig>(text) {
81        Ok(cfg) => return Ok(cfg),
82        Err(e) => e.to_string(),
83    };
84    Err(CoreError::Parse {
85        path: origin.to_string(),
86        yaml: yaml_err,
87        json: json_err,
88    })
89}
90
91/// Load and validate in one step, treating any error-severity issue as fatal.
92pub fn load_validated(path: impl AsRef<Path>) -> Result<LoopConfig, CoreError> {
93    let cfg = load(path)?;
94    let report = validate(&cfg);
95    if report.has_errors() {
96        return Err(CoreError::Invalid(report.render()));
97    }
98    Ok(cfg)
99}