Skip to main content

nex_pkg/
devenv_surface.rs

1use std::collections::BTreeMap;
2
3use anyhow::{bail, Context, Result};
4use serde::{Deserialize, Serialize};
5
6const NEX_MAPPING_PKL: &str = include_str!("../data/devenv/nex-mapping.v1.pkl");
7const UPSTREAM_SOURCE_JSON: &str = include_str!("../data/devenv/upstream/source.json");
8const UPSTREAM_DEVENV_SCHEMA_JSON: &str =
9    include_str!("../data/devenv/upstream/devenv.schema.json");
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12#[serde(rename_all = "camelCase")]
13pub struct DevenvMappingCatalog {
14    pub schema: String,
15    pub reviewed_at: String,
16    pub source: String,
17    pub mappings: BTreeMap<String, DevenvSurfaceMapping>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21#[serde(rename_all = "camelCase")]
22pub struct DevenvSurfaceMapping {
23    pub kind: String,
24    pub bucket: String,
25    pub target: String,
26    #[serde(default)]
27    pub safety: Vec<String>,
28    pub action: String,
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub rationale: Option<String>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34#[serde(rename_all = "snake_case")]
35pub struct DevenvUpstreamSource {
36    pub schema: String,
37    pub repo: String,
38    pub rev: String,
39    pub reviewed_at: String,
40    pub options_source: String,
41    pub yaml_schema_source: String,
42    #[serde(default)]
43    pub notes: Vec<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47#[serde(rename_all = "camelCase")]
48pub struct DevenvSurfaceCatalogReport {
49    pub schema: String,
50    pub mapping: DevenvMappingCatalog,
51    pub upstream: DevenvUpstreamSource,
52    pub yaml_top_level_properties: Vec<String>,
53}
54
55pub fn load_devenv_surface_catalog() -> Result<DevenvSurfaceCatalogReport> {
56    let mapping = parse_mapping_pkl(NEX_MAPPING_PKL)?;
57    validate_mapping(&mapping)?;
58    let upstream: DevenvUpstreamSource = serde_json::from_str(UPSTREAM_SOURCE_JSON)
59        .context("parsing embedded devenv upstream source metadata")?;
60    let yaml_schema: serde_json::Value = serde_json::from_str(UPSTREAM_DEVENV_SCHEMA_JSON)
61        .context("parsing embedded devenv.yaml schema")?;
62    let mut yaml_top_level_properties = yaml_schema
63        .get("properties")
64        .and_then(serde_json::Value::as_object)
65        .map(|properties| properties.keys().cloned().collect::<Vec<_>>())
66        .unwrap_or_default();
67    yaml_top_level_properties.sort();
68
69    Ok(DevenvSurfaceCatalogReport {
70        schema: "io.styrene.nex.devenv-surface-catalog-report.v1".to_string(),
71        mapping,
72        upstream,
73        yaml_top_level_properties,
74    })
75}
76
77pub fn find_mapping<'a>(
78    catalog: &'a DevenvMappingCatalog,
79    option_path: &str,
80) -> Option<(&'a str, &'a DevenvSurfaceMapping)> {
81    catalog
82        .mappings
83        .iter()
84        .filter(|(pattern, _)| mapping_pattern_matches(pattern, option_path))
85        .max_by_key(|(pattern, _)| pattern_specificity(pattern))
86        .map(|(pattern, mapping)| (pattern.as_str(), mapping))
87}
88
89fn parse_mapping_pkl(input: &str) -> Result<DevenvMappingCatalog> {
90    let schema = capture_scalar(input, "schema").context("mapping schema is missing")?;
91    let reviewed_at =
92        capture_scalar(input, "reviewedAt").context("mapping reviewedAt is missing")?;
93    let source = capture_scalar(input, "source").unwrap_or_default();
94    let mappings_body = capture_block(input, "mappings").context("mappings block is missing")?;
95    let mappings = parse_mapping_entries(&mappings_body)?;
96    Ok(DevenvMappingCatalog {
97        schema,
98        reviewed_at,
99        source,
100        mappings,
101    })
102}
103
104fn parse_mapping_entries(body: &str) -> Result<BTreeMap<String, DevenvSurfaceMapping>> {
105    let mut mappings = BTreeMap::new();
106    let mut remaining = body;
107    while let Some(start) = remaining.find("[\"") {
108        remaining = &remaining[start + 2..];
109        let Some(end_pattern) = remaining.find("\"]") else {
110            bail!("unterminated mapping pattern");
111        };
112        let pattern = remaining[..end_pattern].to_string();
113        remaining = &remaining[end_pattern + 2..];
114        let Some(open_brace) = remaining.find('{') else {
115            bail!("mapping {pattern} is missing body");
116        };
117        let (entry_body, consumed) = extract_braced_block(&remaining[open_brace..])?;
118        remaining = &remaining[open_brace + consumed..];
119        mappings.insert(pattern, parse_mapping_entry(&entry_body)?);
120    }
121    Ok(mappings)
122}
123
124fn parse_mapping_entry(body: &str) -> Result<DevenvSurfaceMapping> {
125    Ok(DevenvSurfaceMapping {
126        kind: capture_scalar(body, "kind").context("mapping kind is missing")?,
127        bucket: capture_scalar(body, "bucket").context("mapping bucket is missing")?,
128        target: capture_scalar(body, "target").context("mapping target is missing")?,
129        safety: capture_string_list(body, "safety")?,
130        action: capture_scalar(body, "action").context("mapping action is missing")?,
131        rationale: capture_scalar(body, "rationale"),
132    })
133}
134
135fn validate_mapping(mapping: &DevenvMappingCatalog) -> Result<()> {
136    if mapping.schema != "io.styrene.nex.devenv-mapping.v1" {
137        bail!("unsupported devenv mapping schema {}", mapping.schema);
138    }
139    if mapping.mappings.is_empty() {
140        bail!("devenv mapping catalog must contain at least one mapping");
141    }
142    for (pattern, entry) in &mapping.mappings {
143        if pattern.trim().is_empty() {
144            bail!("devenv mapping pattern cannot be empty");
145        }
146        if entry.kind.trim().is_empty()
147            || entry.bucket.trim().is_empty()
148            || entry.action.trim().is_empty()
149        {
150            bail!("devenv mapping {pattern} has an empty required field");
151        }
152    }
153    Ok(())
154}
155
156fn mapping_pattern_matches(pattern: &str, option_path: &str) -> bool {
157    if let Some(prefix) = pattern.strip_suffix(".*") {
158        option_path == prefix || option_path.starts_with(&format!("{prefix}."))
159    } else {
160        option_path == pattern
161    }
162}
163
164fn pattern_specificity(pattern: &str) -> usize {
165    pattern.trim_end_matches(".*").len()
166}
167
168fn capture_scalar(input: &str, key: &str) -> Option<String> {
169    let needle = format!("{key} = \"");
170    let start = input.find(&needle)? + needle.len();
171    let end = input[start..].find('"')?;
172    Some(input[start..start + end].to_string())
173}
174
175fn capture_string_list(input: &str, key: &str) -> Result<Vec<String>> {
176    let needle = format!("{key} = List(");
177    let Some(start) = input.find(&needle) else {
178        return Ok(Vec::new());
179    };
180    let start = start + needle.len();
181    let Some(end) = input[start..].find(')') else {
182        bail!("unterminated List for {key}");
183    };
184    Ok(input[start..start + end]
185        .split(',')
186        .filter_map(|raw| {
187            let trimmed = raw.trim();
188            trimmed
189                .strip_prefix('"')
190                .and_then(|value| value.strip_suffix('"'))
191                .map(ToString::to_string)
192        })
193        .collect())
194}
195
196fn capture_block(input: &str, key: &str) -> Option<String> {
197    let start = input.find(key)?;
198    let after_key = &input[start + key.len()..];
199    let open_brace = after_key.find('{')?;
200    let (block, _) = extract_braced_block(&after_key[open_brace..]).ok()?;
201    Some(block)
202}
203
204fn extract_braced_block(input: &str) -> Result<(String, usize)> {
205    if !input.starts_with('{') {
206        bail!("block must start with an opening brace");
207    }
208    let mut depth = 0usize;
209    let mut in_string = false;
210    let mut escaped = false;
211    for (index, ch) in input.char_indices() {
212        if in_string {
213            if escaped {
214                escaped = false;
215            } else if ch == '\\' {
216                escaped = true;
217            } else if ch == '"' {
218                in_string = false;
219            }
220            continue;
221        }
222        match ch {
223            '"' => in_string = true,
224            '{' => depth += 1,
225            '}' => {
226                depth = depth.saturating_sub(1);
227                if depth == 0 {
228                    return Ok((input[1..index].to_string(), index + 1));
229                }
230            }
231            _ => {}
232        }
233    }
234    bail!("unterminated braced block")
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    #[test]
242    fn loads_embedded_devenv_surface_catalog() -> Result<()> {
243        let report = load_devenv_surface_catalog()?;
244
245        assert_eq!(report.mapping.schema, "io.styrene.nex.devenv-mapping.v1");
246        assert_eq!(
247            report.upstream.rev,
248            "21d68a204558895af93ad82014f8fa83f9c9a51e"
249        );
250        assert!(report.mapping.mappings.contains_key("packages"));
251        assert!(report.mapping.mappings.contains_key("languages.*"));
252        assert!(report
253            .yaml_top_level_properties
254            .contains(&"secretspec".to_string()));
255        Ok(())
256    }
257
258    #[test]
259    fn matches_exact_and_wildcard_surface_mappings() -> Result<()> {
260        let report = load_devenv_surface_catalog()?;
261
262        let (_, packages) = find_mapping(&report.mapping, "packages").expect("packages mapping");
263        assert_eq!(packages.target, "profile.packages");
264
265        let (_, rust) =
266            find_mapping(&report.mapping, "languages.rust.enable").expect("rust mapping");
267        assert_eq!(rust.kind, "language");
268        assert_eq!(rust.bucket, "portable");
269
270        let (_, postgres) =
271            find_mapping(&report.mapping, "services.postgres.enable").expect("service mapping");
272        assert_eq!(postgres.bucket, "machine-scoped-candidate");
273        assert!(find_mapping(&report.mapping, "unknown.future.option").is_none());
274        Ok(())
275    }
276}