Skip to main content

presolve_compiler/
route_metadata.rs

1//! Compiler-owned metadata projection over canonical file routes.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use serde::Serialize;
6
7use crate::FileRoutePublicationManifestV1;
8
9pub const ROUTE_METADATA_SCHEMA_VERSION: u32 = 1;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct RouteMetadataInputV1 {
13    pub route_path: String,
14    pub title: String,
15    pub description: Option<String>,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "camelCase")]
20pub struct RouteMetadataManifestV1 {
21    pub schema_version: u32,
22    pub routes: Vec<RouteMetadataRecordV1>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "camelCase")]
27pub struct RouteMetadataRecordV1 {
28    pub path: String,
29    pub entry_component_id: String,
30    pub title: String,
31    pub description: Option<String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RouteMetadataErrorV1 {
36    pub code: &'static str,
37    pub message: String,
38}
39
40pub fn build_route_metadata_manifest_v1(
41    routes: &FileRoutePublicationManifestV1,
42    inputs: &[RouteMetadataInputV1],
43) -> Result<RouteMetadataManifestV1, RouteMetadataErrorV1> {
44    let canonical = routes
45        .routes
46        .iter()
47        .map(|route| (route.path.as_str(), route.entry_component_id.as_str()))
48        .collect::<BTreeMap<_, _>>();
49    let mut seen = BTreeSet::new();
50    let mut records = Vec::new();
51    for input in inputs {
52        if !seen.insert(&input.route_path) {
53            return Err(RouteMetadataErrorV1 {
54                code: "PSMETA1001_DUPLICATE_ROUTE",
55                message: input.route_path.clone(),
56            });
57        }
58        let Some(entry_component_id) = canonical.get(input.route_path.as_str()) else {
59            return Err(RouteMetadataErrorV1 {
60                code: "PSMETA1002_ROUTE_UNKNOWN",
61                message: input.route_path.clone(),
62            });
63        };
64        if input.title.trim().is_empty()
65            || input
66                .description
67                .as_ref()
68                .is_some_and(|value| value.trim().is_empty())
69        {
70            return Err(RouteMetadataErrorV1 {
71                code: "PSMETA1003_FIELD_INVALID",
72                message: input.route_path.clone(),
73            });
74        }
75        records.push(RouteMetadataRecordV1 {
76            path: input.route_path.clone(),
77            entry_component_id: (*entry_component_id).into(),
78            title: input.title.clone(),
79            description: input.description.clone(),
80        });
81    }
82    records.sort_by(|left, right| left.path.cmp(&right.path));
83    Ok(RouteMetadataManifestV1 {
84        schema_version: ROUTE_METADATA_SCHEMA_VERSION,
85        routes: records,
86    })
87}
88
89#[must_use]
90pub fn route_metadata_manifest_json_v1(value: &RouteMetadataManifestV1) -> String {
91    serde_json::to_string_pretty(value).expect("route metadata serializes") + "\n"
92}
93
94#[cfg(test)]
95mod tests {
96    use super::{build_route_metadata_manifest_v1, RouteMetadataInputV1};
97    use crate::{FileRoutePublicationManifestV1, FileRoutePublicationRouteV1};
98    fn routes() -> FileRoutePublicationManifestV1 {
99        FileRoutePublicationManifestV1 {
100            schema_version: 1,
101            compiler_contract: "presolve-file-route-publication:1".into(),
102            profile: "production".into(),
103            routes: vec![FileRoutePublicationRouteV1 {
104                path: "/docs".into(),
105                entry_component_id: "component:docs".into(),
106                artifact_root: "routes/segment-docs".into(),
107                layout_component_ids: Vec::new(),
108            }],
109            artifacts: Vec::new(),
110        }
111    }
112    #[test]
113    fn joins_metadata_only_to_a_canonical_route() {
114        let product = build_route_metadata_manifest_v1(
115            &routes(),
116            &[RouteMetadataInputV1 {
117                route_path: "/docs".into(),
118                title: "Docs".into(),
119                description: None,
120            }],
121        )
122        .unwrap();
123        assert_eq!(product.routes[0].entry_component_id, "component:docs");
124        assert_eq!(
125            build_route_metadata_manifest_v1(
126                &routes(),
127                &[RouteMetadataInputV1 {
128                    route_path: "/missing".into(),
129                    title: "Missing".into(),
130                    description: None
131                }]
132            )
133            .unwrap_err()
134            .code,
135            "PSMETA1002_ROUTE_UNKNOWN"
136        );
137    }
138}