1use std::collections::{BTreeMap, BTreeSet};
8
9use serde_json::{Map as JsonMap, Value as JsonValue};
10use yaml_rust2::{Yaml, YamlLoader};
11
12pub const PROJECT_MANIFEST_API_VERSION: &str = "wesley.project-manifest/v1";
14
15#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17#[serde(rename_all = "camelCase", deny_unknown_fields)]
18pub struct ProjectManifest {
19 #[serde(default = "default_api_version")]
21 pub api_version: String,
22 #[serde(default)]
24 pub schema_paths: Vec<SchemaPathConfig>,
25 #[serde(default = "default_bundle_dir")]
27 pub bundle_dir: String,
28 #[serde(default)]
30 pub rebuild_on_globs: Vec<String>,
31 #[serde(default)]
33 pub comment_mode: CommentMode,
34 #[serde(default)]
36 pub dashboard: DashboardConfig,
37 #[serde(default)]
39 pub targets: Vec<ManifestTargetConfig>,
40}
41
42impl ProjectManifest {
43 pub fn resolved_schema_paths(&self) -> Vec<ResolvedSchemaPath> {
45 self.schema_paths
46 .iter()
47 .enumerate()
48 .map(|(index, entry)| entry.resolve(index))
49 .collect()
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
55#[serde(untagged)]
56pub enum SchemaPathConfig {
57 Path(String),
59 Detailed(DetailedSchemaPathConfig),
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
65#[serde(rename_all = "camelCase", deny_unknown_fields)]
66pub struct DetailedSchemaPathConfig {
67 #[serde(default)]
69 pub id: Option<String>,
70 pub path: String,
72 #[serde(default)]
74 pub rebuild_on_globs: Vec<String>,
75}
76
77impl SchemaPathConfig {
78 fn resolve(&self, index: usize) -> ResolvedSchemaPath {
79 match self {
80 Self::Path(path) => ResolvedSchemaPath {
81 id: slug_from_path(path, index),
82 path: path.clone(),
83 rebuild_on_globs: Vec::new(),
84 },
85 Self::Detailed(DetailedSchemaPathConfig {
86 id,
87 path,
88 rebuild_on_globs,
89 }) => ResolvedSchemaPath {
90 id: id.clone().unwrap_or_else(|| slug_from_path(path, index)),
91 path: path.clone(),
92 rebuild_on_globs: rebuild_on_globs.clone(),
93 },
94 }
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
100#[serde(rename_all = "camelCase")]
101pub struct ResolvedSchemaPath {
102 pub id: String,
104 pub path: String,
106 pub rebuild_on_globs: Vec<String>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
112#[serde(rename_all = "camelCase")]
113pub struct SelectedSchemaPath {
114 pub id: String,
116 pub path: String,
118 pub bundle_dir: String,
120 pub reason: String,
122}
123
124#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
126#[serde(rename_all = "kebab-case")]
127pub enum CommentMode {
128 #[default]
130 Update,
131 Append,
133 Silent,
135}
136
137impl CommentMode {
138 pub fn as_str(self) -> &'static str {
140 match self {
141 Self::Update => "update",
142 Self::Append => "append",
143 Self::Silent => "silent",
144 }
145 }
146}
147
148#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
150#[serde(rename_all = "camelCase", deny_unknown_fields)]
151pub struct DashboardConfig {
152 #[serde(default)]
154 pub enabled: bool,
155 #[serde(default)]
157 pub artifact_path: Option<String>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
162#[serde(rename_all = "camelCase", deny_unknown_fields)]
163pub struct ManifestTargetConfig {
164 pub name: String,
166 #[serde(default)]
168 pub module: Option<String>,
169 #[serde(default, rename = "default")]
171 pub is_default: bool,
172 #[serde(default)]
174 pub output_dir: Option<String>,
175 #[serde(default)]
177 pub exclusive_group: Option<String>,
178 #[serde(default)]
180 pub conflicts_with: Vec<String>,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
185pub enum ProjectManifestError {
186 Parse(String),
188 UnsupportedApiVersion(String),
190 Validation(Vec<String>),
192}
193
194impl std::fmt::Display for ProjectManifestError {
195 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196 match self {
197 Self::Parse(message) => write!(formatter, "manifest parse error: {message}"),
198 Self::UnsupportedApiVersion(version) => write!(
199 formatter,
200 "unsupported manifest apiVersion '{version}'; expected {PROJECT_MANIFEST_API_VERSION}"
201 ),
202 Self::Validation(diagnostics) => {
203 write!(formatter, "manifest validation failed")?;
204 for diagnostic in diagnostics {
205 write!(formatter, "; {diagnostic}")?;
206 }
207 Ok(())
208 }
209 }
210 }
211}
212
213impl std::error::Error for ProjectManifestError {}
214
215pub fn load_project_manifest(source: &str) -> Result<ProjectManifest, ProjectManifestError> {
217 let value = parse_manifest_value(source)?;
218 let manifest = serde_json::from_value::<ProjectManifest>(value)
219 .map_err(|source| ProjectManifestError::Parse(source.to_string()))?;
220 validate_project_manifest(&manifest)?;
221 Ok(manifest)
222}
223
224pub fn validate_project_manifest(manifest: &ProjectManifest) -> Result<(), ProjectManifestError> {
226 if manifest.api_version != PROJECT_MANIFEST_API_VERSION {
227 return Err(ProjectManifestError::UnsupportedApiVersion(
228 manifest.api_version.clone(),
229 ));
230 }
231
232 let mut diagnostics = Vec::new();
233 let schemas = manifest.resolved_schema_paths();
234 let mut schema_ids = BTreeSet::new();
235 let mut schema_paths = BTreeSet::new();
236 for schema in &schemas {
237 if schema.id.trim().is_empty() {
238 diagnostics.push("schemaPaths contains a blank id".to_owned());
239 }
240 if schema_id_is_dot_only(&schema.id) {
241 diagnostics.push(format!(
242 "schemaPath id '{}' must not be '.', '..', or only dots",
243 schema.id
244 ));
245 } else if !schema_id_is_path_safe(&schema.id) {
246 diagnostics.push(format!(
247 "schemaPath id '{}' must contain only ASCII letters, digits, '.', '_', or '-'",
248 schema.id
249 ));
250 }
251 if schema.path.trim().is_empty() {
252 diagnostics.push(format!("schemaPath `{}` has a blank path", schema.id));
253 }
254 if !schema_ids.insert(schema.id.clone()) {
255 diagnostics.push(format!("duplicate schemaPath id '{}'", schema.id));
256 }
257 if !schema_paths.insert(normalize_path(&schema.path)) {
258 diagnostics.push(format!("duplicate schemaPath path '{}'", schema.path));
259 }
260 }
261
262 if manifest.bundle_dir.trim().is_empty() {
263 diagnostics.push("bundleDir must not be blank".to_owned());
264 }
265
266 let mut target_names = BTreeSet::new();
267 let mut default_targets = Vec::new();
268 let mut exclusive_groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
269 for target in &manifest.targets {
270 if target.name.trim().is_empty() {
271 diagnostics.push("targets contains a blank name".to_owned());
272 continue;
273 }
274 if !target_names.insert(target.name.clone()) {
275 diagnostics.push(format!("duplicate target '{}'", target.name));
276 }
277 if target.is_default {
278 default_targets.push(target.name.clone());
279 }
280 if let Some(group) = target.exclusive_group.as_deref() {
281 if group.trim().is_empty() {
282 diagnostics.push(format!(
283 "target '{}' has a blank exclusiveGroup",
284 target.name
285 ));
286 } else {
287 exclusive_groups
288 .entry(group.to_owned())
289 .or_default()
290 .push(target.name.clone());
291 }
292 }
293 }
294
295 if default_targets.len() > 1 {
296 diagnostics.push(format!(
297 "multiple default targets selected: {}",
298 default_targets.join(", ")
299 ));
300 }
301
302 for (group, targets) in exclusive_groups {
303 if targets.len() > 1 {
304 diagnostics.push(format!(
305 "exclusive group '{group}' selects multiple targets: {}",
306 targets.join(", ")
307 ));
308 }
309 }
310
311 for target in &manifest.targets {
312 for conflict in &target.conflicts_with {
313 if target_names.contains(conflict) {
314 diagnostics.push(format!(
315 "target '{}' conflicts with selected target '{}'",
316 target.name, conflict
317 ));
318 }
319 }
320 }
321
322 if diagnostics.is_empty() {
323 Ok(())
324 } else {
325 Err(ProjectManifestError::Validation(diagnostics))
326 }
327}
328
329pub fn select_changed_schema_paths(
331 manifest: &ProjectManifest,
332 changed_files: impl IntoIterator<Item = impl AsRef<str>>,
333) -> Vec<SelectedSchemaPath> {
334 let changed = changed_files
335 .into_iter()
336 .map(|path| normalize_path(path.as_ref()))
337 .filter(|path| !path.is_empty())
338 .collect::<Vec<_>>();
339 let schemas = manifest.resolved_schema_paths();
340
341 if changed.is_empty() {
342 let schema_count = schemas.len();
343 return schemas
344 .into_iter()
345 .map(|schema| SelectedSchemaPath {
346 bundle_dir: schema_bundle_dir(&manifest.bundle_dir, &schema.id, schema_count),
347 id: schema.id,
348 path: schema.path,
349 reason: "no changed files provided".to_owned(),
350 })
351 .collect();
352 }
353
354 for glob in &manifest.rebuild_on_globs {
355 if changed.iter().any(|path| glob_matches(glob, path)) {
356 let schema_count = schemas.len();
357 return schemas
358 .into_iter()
359 .map(|schema| SelectedSchemaPath {
360 bundle_dir: schema_bundle_dir(&manifest.bundle_dir, &schema.id, schema_count),
361 id: schema.id,
362 path: schema.path,
363 reason: format!("matched global rebuild glob `{glob}`"),
364 })
365 .collect();
366 }
367 }
368
369 let schema_count = schemas.len();
370 schemas
371 .into_iter()
372 .filter_map(|schema| {
373 let schema_path = normalize_path(&schema.path);
374 let bundle_dir = schema_bundle_dir(&manifest.bundle_dir, &schema.id, schema_count);
375 if changed.iter().any(|path| path == &schema_path) {
376 return Some(SelectedSchemaPath {
377 bundle_dir,
378 id: schema.id,
379 path: schema.path,
380 reason: format!("matched schema path `{schema_path}`"),
381 });
382 }
383
384 for glob in &schema.rebuild_on_globs {
385 if changed.iter().any(|path| glob_matches(glob, path)) {
386 return Some(SelectedSchemaPath {
387 bundle_dir,
388 id: schema.id,
389 path: schema.path,
390 reason: format!("matched schema rebuild glob `{glob}`"),
391 });
392 }
393 }
394
395 None
396 })
397 .collect()
398}
399
400fn schema_bundle_dir(base: &str, schema_id: &str, schema_count: usize) -> String {
401 let base = normalize_path(base);
402 if schema_count <= 1 {
403 return base;
404 }
405 join_normalized_path(&base, schema_id)
406}
407
408fn join_normalized_path(base: &str, child: &str) -> String {
409 match base {
410 "." => format!("./{child}"),
411 "/" => format!("/{child}"),
412 _ => format!("{base}/{child}"),
413 }
414}
415
416fn schema_id_is_path_safe(id: &str) -> bool {
417 id.chars()
418 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-'))
419}
420
421fn schema_id_is_dot_only(id: &str) -> bool {
422 !id.is_empty() && id.chars().all(|character| character == '.')
423}
424
425fn default_api_version() -> String {
426 PROJECT_MANIFEST_API_VERSION.to_owned()
427}
428
429fn default_bundle_dir() -> String {
430 ".wesley-cache".to_owned()
431}
432
433fn parse_manifest_value(source: &str) -> Result<JsonValue, ProjectManifestError> {
434 match serde_json::from_str::<JsonValue>(source) {
435 Ok(value) => Ok(value),
436 Err(json_error) => {
437 let documents = YamlLoader::load_from_str(source).map_err(|yaml_error| {
438 ProjectManifestError::Parse(format!(
439 "not valid JSON ({json_error}); not valid YAML ({yaml_error})"
440 ))
441 })?;
442 if documents.is_empty() {
443 return Err(ProjectManifestError::Parse(
444 "YAML source contained no document".to_owned(),
445 ));
446 }
447 if documents.len() != 1 {
448 return Err(ProjectManifestError::Parse(
449 "YAML manifests must contain exactly one document".to_owned(),
450 ));
451 }
452 yaml_to_json(&documents[0])
453 }
454 }
455}
456
457fn yaml_to_json(value: &Yaml) -> Result<JsonValue, ProjectManifestError> {
458 match value {
459 Yaml::Real(value) => value
460 .parse::<f64>()
461 .map(JsonValue::from)
462 .map_err(|source| ProjectManifestError::Parse(source.to_string())),
463 Yaml::Integer(value) => Ok(JsonValue::from(*value)),
464 Yaml::String(value) => Ok(JsonValue::from(value.clone())),
465 Yaml::Boolean(value) => Ok(JsonValue::from(*value)),
466 Yaml::Array(values) => values
467 .iter()
468 .map(yaml_to_json)
469 .collect::<Result<Vec<_>, _>>()
470 .map(JsonValue::from),
471 Yaml::Hash(values) => {
472 let mut object = JsonMap::new();
473 for (key, value) in values {
474 let key = match key {
475 Yaml::String(key) => key.clone(),
476 _ => {
477 return Err(ProjectManifestError::Parse(
478 "YAML manifest keys must be strings".to_owned(),
479 ));
480 }
481 };
482 object.insert(key, yaml_to_json(value)?);
483 }
484 Ok(JsonValue::Object(object))
485 }
486 Yaml::Null | Yaml::BadValue => Ok(JsonValue::Null),
487 Yaml::Alias(_) => Err(ProjectManifestError::Parse(
488 "YAML aliases are not supported in Wesley manifests".to_owned(),
489 )),
490 }
491}
492
493fn slug_from_path(path: &str, index: usize) -> String {
494 let mut slug = normalize_path(path)
495 .chars()
496 .map(|character| {
497 if character.is_ascii_alphanumeric() {
498 character.to_ascii_lowercase()
499 } else {
500 '-'
501 }
502 })
503 .collect::<String>();
504 while slug.contains("--") {
505 slug = slug.replace("--", "-");
506 }
507 let slug = slug.trim_matches('-').to_owned();
508 if slug.is_empty() {
509 format!("schema-{index}")
510 } else {
511 slug
512 }
513}
514
515fn normalize_path(path: &str) -> String {
516 let path = path.trim().replace('\\', "/");
517 let is_absolute = path.starts_with('/');
518 let is_current = path == "." || path == "./";
519 let normalized = path
520 .split('/')
521 .filter(|segment| !segment.is_empty() && *segment != ".")
522 .collect::<Vec<_>>()
523 .join("/");
524
525 if normalized.is_empty() {
526 if is_absolute {
527 "/".to_owned()
528 } else if is_current {
529 ".".to_owned()
530 } else {
531 String::new()
532 }
533 } else if is_absolute {
534 format!("/{normalized}")
535 } else {
536 normalized
537 }
538}
539
540fn glob_matches(pattern: &str, path: &str) -> bool {
541 let pattern = normalize_path(pattern);
542 let path = normalize_path(path);
543 if pattern.is_empty() {
544 return false;
545 }
546 if !pattern.contains('*') && !pattern.contains('?') {
547 return pattern == path;
548 }
549
550 let pattern_segments = pattern.split('/').collect::<Vec<_>>();
551 let path_segments = path.split('/').collect::<Vec<_>>();
552 match_glob_segments(&pattern_segments, &path_segments)
553}
554
555fn match_glob_segments(pattern: &[&str], path: &[&str]) -> bool {
556 match pattern.split_first() {
557 None => path.is_empty(),
558 Some((&"**", rest)) => {
559 match_glob_segments(rest, path)
560 || (!path.is_empty() && match_glob_segments(pattern, &path[1..]))
561 }
562 Some((segment_pattern, rest)) => {
563 if let Some((path_segment, path_rest)) = path.split_first() {
564 segment_matches(segment_pattern, path_segment)
565 && match_glob_segments(rest, path_rest)
566 } else {
567 false
568 }
569 }
570 }
571}
572
573fn segment_matches(pattern: &str, value: &str) -> bool {
574 let pattern = pattern.as_bytes();
575 let value = value.as_bytes();
576 let mut pattern_index = 0;
577 let mut value_index = 0;
578 let mut star_index = None;
579 let mut star_value_index = 0;
580
581 while value_index < value.len() {
582 if pattern_index < pattern.len()
583 && (pattern[pattern_index] == b'?' || pattern[pattern_index] == value[value_index])
584 {
585 pattern_index += 1;
586 value_index += 1;
587 } else if pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
588 star_index = Some(pattern_index);
589 star_value_index = value_index;
590 pattern_index += 1;
591 } else if let Some(star) = star_index {
592 pattern_index = star + 1;
593 star_value_index += 1;
594 value_index = star_value_index;
595 } else {
596 return false;
597 }
598 }
599
600 while pattern_index < pattern.len() && pattern[pattern_index] == b'*' {
601 pattern_index += 1;
602 }
603
604 pattern_index == pattern.len()
605}