Skip to main content

loopsmith_core/
lib.rs

1//! Config model and validation for loopsmith.
2//!
3//! A loop config is four bundles, each answering one question:
4//!
5//! - `intent`    — what is this loop for, and how would we know it worked?
6//! - `execution` — how does the work get done?
7//! - `safety`    — what must not happen, and when does this stop?
8//! - `evolution` — how is this allowed to change itself?
9//!
10//! Until 1.0 these were fourteen flat keys, ten of them known by a letter.
11//! Every one of those spellings still loads: [`parse_str`] runs the document
12//! through [`config::legacy`] before typing it, and reports what moved. See
13//! [`config::bundles`] for why the grouping is what it is.
14//!
15//! Validation exists to make the corpus rule enforceable: a goal without a
16//! machine-checkable validation is the single most common way loops fail, so
17//! the config is rejected rather than run.
18
19pub mod config;
20pub mod md;
21pub mod permissions;
22pub mod validate;
23
24pub use config::*;
25pub use md::{parse_md, parse_md_reporting, render_md};
26pub use validate::{validate, Issue, Severity, ValidationReport};
27
28use std::path::Path;
29
30#[derive(Debug, thiserror::Error)]
31pub enum CoreError {
32    #[error("io error reading {path}: {source}")]
33    Io {
34        path: String,
35        #[source]
36        source: std::io::Error,
37    },
38    #[error("could not parse {path} as YAML or JSON:\n  yaml: {yaml}\n  json: {json}")]
39    Parse {
40        path: String,
41        yaml: String,
42        json: String,
43    },
44    #[error("config is invalid:\n{0}")]
45    Invalid(String),
46}
47
48/// Load a config from Markdown, YAML, or JSON.
49///
50/// Markdown is chosen by extension, because a `.md` config is a different
51/// grammar rather than a different serialization — guessing at it would mean
52/// reporting a YAML parse error for a document that was never YAML.
53/// Everything else falls through to [`parse_str`], which tries YAML then JSON.
54pub fn load(path: impl AsRef<Path>) -> Result<LoopConfig, CoreError> {
55    let path = path.as_ref();
56    let text = std::fs::read_to_string(path).map_err(|source| CoreError::Io {
57        path: path.display().to_string(),
58        source,
59    })?;
60    let origin = path.display().to_string();
61    if is_markdown(path) {
62        return md::parse_md(&text, &origin);
63    }
64    parse_str(&text, &origin)
65}
66
67/// Whether a path should be read as a markdown config.
68pub fn is_markdown(path: &Path) -> bool {
69    path.extension()
70        .and_then(|e| e.to_str())
71        .map(|e| e.eq_ignore_ascii_case("md") || e.eq_ignore_ascii_case("markdown"))
72        .unwrap_or(false)
73}
74
75/// Parse config text, trying YAML first (a superset of JSON in practice) and
76/// falling back to strict JSON so both error messages survive to the caller.
77///
78/// Any 0.3 top-level key is relocated by [`config::legacy`] before typing, so
79/// an old file loads unchanged. Use [`parse_str_reporting`] to find out whether
80/// that happened.
81pub fn parse_str(text: &str, origin: &str) -> Result<LoopConfig, CoreError> {
82    parse_str_reporting(text, origin).map(|(cfg, _)| cfg)
83}
84
85/// [`parse_str`], additionally reporting which 0.3 keys were relocated.
86///
87/// The list is empty for a file already in the 1.0 shape. Callers that have
88/// somewhere to put a deprecation notice — the CLI, the wizard, `migrate` —
89/// use this; everything else uses [`parse_str`].
90pub fn parse_str_reporting(
91    text: &str,
92    origin: &str,
93) -> Result<(LoopConfig, Vec<config::legacy::Moved>), CoreError> {
94    // Both formats are read into an untyped document first so the same
95    // relocation runs for each. Typing directly and only falling back on
96    // failure would mean a file mixing old and new keys parses as whichever
97    // half the model happened to accept.
98    let yaml_err = match serde_yaml::from_str::<serde_yaml::Value>(text) {
99        Ok(doc) => match type_document(doc) {
100            Ok(out) => return Ok(out),
101            Err(e) => e.to_string(),
102        },
103        Err(e) => e.to_string(),
104    };
105    let json_err = match serde_json::from_str::<serde_yaml::Value>(text) {
106        Ok(doc) => match type_document(doc) {
107            Ok(out) => return Ok(out),
108            Err(e) => e.to_string(),
109        },
110        Err(e) => e.to_string(),
111    };
112    Err(CoreError::Parse {
113        path: origin.to_string(),
114        yaml: yaml_err,
115        json: json_err,
116    })
117}
118
119fn type_document(
120    doc: serde_yaml::Value,
121) -> Result<(LoopConfig, Vec<config::legacy::Moved>), serde_yaml::Error> {
122    let (doc, moved) = config::legacy::migrate(&doc);
123    serde_yaml::from_value::<LoopConfig>(doc).map(|cfg| (cfg, moved))
124}
125
126/// The JSON Schema for a loop config, derived from the Rust model.
127///
128/// Generated rather than hand-written. The previous schema was 800 lines
129/// nothing executed, and it had already drifted — `max_revisions_per_node` was
130/// declared there, defaulted in Rust, documented twice, and read by no runtime
131/// code at all. A generated schema cannot describe a field that does not exist
132/// or miss one that does.
133///
134/// `config/loop.schema.json` is this value, written out. CI regenerates it and
135/// fails if the committed copy differs.
136pub fn json_schema() -> serde_json::Value {
137    let schema = schemars::schema_for!(LoopConfig);
138    serde_json::to_value(schema).expect("a generated schema serialises")
139}
140
141/// Load and validate in one step, treating any error-severity issue as fatal.
142pub fn load_validated(path: impl AsRef<Path>) -> Result<LoopConfig, CoreError> {
143    let cfg = load(path)?;
144    let report = validate(&cfg);
145    if report.has_errors() {
146        return Err(CoreError::Invalid(report.render()));
147    }
148    Ok(cfg)
149}