Skip to main content

llman_core/
schema_utils.rs

1//! Generic schema utilities shared by all config layers (global / project /
2//! llmanspec). Pure logic only: no i18n, no filesystem writes — the
3//! facade-side file handling stays in `config_schema`.
4//!
5//! This module is a member of the top-level utility layer (future
6//! `llman-core`); it MUST NOT import feature modules (sdd/skills/tool/x).
7
8use jsonschema::validator_for;
9use schemars::JsonSchema;
10use schemars::generate::SchemaSettings;
11
12const SCHEMA_ERROR_LIMIT: usize = 5;
13
14/// Generate a draft-07 root schema with subschemas inlined (the project-wide
15/// schema style, previously private in `config_schema`).
16pub fn generate_schema<T: JsonSchema>() -> schemars::Schema {
17    let mut settings = SchemaSettings::draft07();
18    settings.inline_subschemas = true;
19    settings.into_generator().into_root_schema_for::<T>()
20}
21
22/// Validate a YAML-derived value against the JSON schema of `T` (draft-07,
23/// inlined).
24///
25/// Generic over the config type so feature crates (e.g. sdd owning
26/// `SddConfig`) can validate without importing the facade's schema registry —
27/// this is what breaks the former `config_schema <-> sdd::project` ring.
28///
29/// Callers parse YAML into `serde_json::Value` (via `serde-saphyr`) before
30/// validation; the dynamic value tree is JSON-schema-shaped by construction.
31pub fn validate_yaml_value_against<T: JsonSchema>(value: &serde_json::Value) -> Result<(), String> {
32    let schema_value = serde_json::to_value(generate_schema::<T>()).map_err(|e| e.to_string())?;
33    validate_schema_value(&schema_value, value)
34}
35
36/// Validate a value against an already-materialized JSON schema value.
37pub fn validate_schema_value(
38    schema_value: &serde_json::Value,
39    value: &serde_json::Value,
40) -> Result<(), String> {
41    let validator = validator_for(schema_value).map_err(|e| e.to_string())?;
42    if !validator.is_valid(value) {
43        return Err(format_schema_errors(
44            validator.iter_errors(value).map(|err| err.to_string()),
45        ));
46    }
47    Ok(())
48}
49
50pub fn format_schema_errors<I>(errors: I) -> String
51where
52    I: IntoIterator<Item = String>,
53{
54    let mut iter = errors.into_iter();
55    let mut items = Vec::new();
56    for _ in 0..SCHEMA_ERROR_LIMIT {
57        if let Some(err) = iter.next() {
58            items.push(err);
59        } else {
60            break;
61        }
62    }
63    let remaining = iter.count();
64    if items.is_empty() {
65        return "unknown".to_string();
66    }
67    let mut message = items.join("; ");
68    if remaining > 0 {
69        message.push_str(&format!("; ... (+{remaining} more)"));
70    }
71    message
72}
73
74pub fn schema_header_line(schema_url: &str) -> String {
75    format!("# yaml-language-server: $schema={schema_url}")
76}
77
78pub fn prepend_schema_header(content: &str, schema_url: &str) -> String {
79    let header = schema_header_line(schema_url);
80    if content.is_empty() {
81        return format!("{header}\n");
82    }
83    let newline = if content.contains("\r\n") {
84        "\r\n"
85    } else {
86        "\n"
87    };
88    format!("{header}{newline}{content}")
89}
90
91pub fn apply_schema_header_to_content(content: &str, schema_url: &str) -> (String, bool) {
92    let header = schema_header_line(schema_url);
93    if content.is_empty() {
94        return (format!("{header}\n"), true);
95    }
96    let newline = if content.contains("\r\n") {
97        "\r\n"
98    } else {
99        "\n"
100    };
101    let has_trailing = content.ends_with('\n') || content.ends_with("\r\n");
102    let all_lines = content.lines().collect::<Vec<_>>();
103
104    // Only normalize the leading header/comment region. Do not delete schema headers that
105    // appear later in the file.
106    let mut header_end = 0;
107    while header_end < all_lines.len() {
108        let line = all_lines[header_end];
109        if line.trim().is_empty() || line.trim_start().starts_with('#') {
110            header_end += 1;
111            continue;
112        }
113        break;
114    }
115
116    let mut normalized_header_lines = Vec::new();
117    for line in &all_lines[..header_end] {
118        if line
119            .trim_start()
120            .starts_with("# yaml-language-server: $schema=")
121        {
122            continue;
123        }
124        normalized_header_lines.push((*line).to_string());
125    }
126
127    let mut out_lines = Vec::with_capacity(all_lines.len() + 1);
128    out_lines.push(header);
129    out_lines.extend(normalized_header_lines);
130    out_lines.extend(
131        all_lines[header_end..]
132            .iter()
133            .map(|line| (*line).to_string()),
134    );
135    let mut updated = out_lines.join(newline);
136    if has_trailing {
137        updated.push_str(newline);
138    }
139    let changed = updated != content;
140    (updated, changed)
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146
147    #[test]
148    fn apply_schema_header_inserts_before_doc_start() {
149        let content = "---\nversion: \"0.1\"\n";
150        let (updated, changed) =
151            apply_schema_header_to_content(content, "https://example.com/g.json");
152        assert!(changed);
153        assert!(updated.starts_with("# yaml-language-server: $schema="));
154        assert!(updated.contains("\n---\n"));
155    }
156
157    #[test]
158    fn apply_schema_header_replaces_existing() {
159        let content =
160            "# yaml-language-server: $schema=https://example.com/old.json\nversion: \"0.1\"\n";
161        let (updated, changed) =
162            apply_schema_header_to_content(content, "https://example.com/g.json");
163        assert!(changed);
164        assert!(updated.starts_with(&schema_header_line("https://example.com/g.json")));
165        assert!(!updated.contains("old.json"));
166    }
167
168    #[test]
169    fn apply_schema_header_does_not_delete_late_schema_headers() {
170        let content = "# comment\n# yaml-language-server: $schema=https://example.com/old.json\nkey: value\n# yaml-language-server: $schema=https://example.com/keep.json\n".to_string();
171        let (updated, changed) =
172            apply_schema_header_to_content(&content, "https://example.com/g.json");
173        assert!(changed);
174        assert!(updated.starts_with(&schema_header_line("https://example.com/g.json")));
175        assert!(updated.contains("https://example.com/keep.json"));
176    }
177}