presolve_compiler/
route_metadata.rs1use 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}