Skip to main content

wesley_core/domain/
project_manifest.rs

1//! Domain-free Wesley project manifest parsing and validation.
2//!
3//! The manifest names GraphQL source files, generic output/evidence locations,
4//! and extension target metadata. It intentionally does not assign database,
5//! runtime, renderer, or product semantics to those targets.
6
7use std::collections::{BTreeMap, BTreeSet};
8
9use serde_json::{Map as JsonMap, Value as JsonValue};
10use yaml_rust2::{Yaml, YamlLoader};
11
12/// Supported Wesley project manifest API version.
13pub const PROJECT_MANIFEST_API_VERSION: &str = "wesley.project-manifest/v1";
14
15/// Domain-free project manifest for Wesley compiler and evidence workflows.
16#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
17#[serde(rename_all = "camelCase", deny_unknown_fields)]
18pub struct ProjectManifest {
19    /// Manifest API version.
20    #[serde(default = "default_api_version")]
21    pub api_version: String,
22    /// GraphQL schema paths, optionally with per-schema rebuild globs.
23    #[serde(default)]
24    pub schema_paths: Vec<SchemaPathConfig>,
25    /// Directory used for generated Wesley evidence bundles.
26    #[serde(default = "default_bundle_dir")]
27    pub bundle_dir: String,
28    /// Global file globs that force every schema set to rebuild.
29    #[serde(default)]
30    pub rebuild_on_globs: Vec<String>,
31    /// Preferred PR comment update behavior for automation.
32    #[serde(default)]
33    pub comment_mode: CommentMode,
34    /// Optional dashboard artifact settings.
35    #[serde(default)]
36    pub dashboard: DashboardConfig,
37    /// Selected extension targets. Target-specific semantics belong to modules.
38    #[serde(default)]
39    pub targets: Vec<ManifestTargetConfig>,
40}
41
42impl ProjectManifest {
43    /// Returns schema paths with stable ids and explicit per-schema globs.
44    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/// Schema path entry, either compact string form or object form.
54#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
55#[serde(untagged)]
56pub enum SchemaPathConfig {
57    /// Compact schema path form.
58    Path(String),
59    /// Detailed schema path form.
60    Detailed(DetailedSchemaPathConfig),
61}
62
63/// Detailed schema path form with a stable id and rebuild globs.
64#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
65#[serde(rename_all = "camelCase", deny_unknown_fields)]
66pub struct DetailedSchemaPathConfig {
67    /// Stable schema set id.
68    #[serde(default)]
69    pub id: Option<String>,
70    /// GraphQL SDL path.
71    pub path: String,
72    /// File globs that rebuild this schema set.
73    #[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/// Normalized schema path used for validation and changed-file selection.
99#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
100#[serde(rename_all = "camelCase")]
101pub struct ResolvedSchemaPath {
102    /// Stable schema set id.
103    pub id: String,
104    /// GraphQL SDL path.
105    pub path: String,
106    /// File globs that rebuild this schema set.
107    pub rebuild_on_globs: Vec<String>,
108}
109
110/// Schema selected by changed-file analysis.
111#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
112#[serde(rename_all = "camelCase")]
113pub struct SelectedSchemaPath {
114    /// Stable schema set id.
115    pub id: String,
116    /// GraphQL SDL path.
117    pub path: String,
118    /// Bundle directory for this schema set.
119    pub bundle_dir: String,
120    /// Why this schema set was selected.
121    pub reason: String,
122}
123
124/// PR comment update behavior for Wesley automation.
125#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
126#[serde(rename_all = "kebab-case")]
127pub enum CommentMode {
128    /// Update the existing bot comment when possible.
129    #[default]
130    Update,
131    /// Append a new comment for each run.
132    Append,
133    /// Do not write PR comments.
134    Silent,
135}
136
137impl CommentMode {
138    /// Stable manifest string for this mode.
139    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/// Optional dashboard artifact settings.
149#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
150#[serde(rename_all = "camelCase", deny_unknown_fields)]
151pub struct DashboardConfig {
152    /// Whether to emit dashboard artifacts.
153    #[serde(default)]
154    pub enabled: bool,
155    /// Dashboard artifact path.
156    #[serde(default)]
157    pub artifact_path: Option<String>,
158}
159
160/// Domain-free extension target selection.
161#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
162#[serde(rename_all = "camelCase", deny_unknown_fields)]
163pub struct ManifestTargetConfig {
164    /// Stable target name.
165    pub name: String,
166    /// Optional external module identity that owns target semantics.
167    #[serde(default)]
168    pub module: Option<String>,
169    /// Whether this target is selected when no explicit target is requested.
170    #[serde(default, rename = "default")]
171    pub is_default: bool,
172    /// Optional output directory for this target.
173    #[serde(default)]
174    pub output_dir: Option<String>,
175    /// Generic mutually-exclusive target group.
176    #[serde(default)]
177    pub exclusive_group: Option<String>,
178    /// Target names that cannot be selected with this target.
179    #[serde(default)]
180    pub conflicts_with: Vec<String>,
181}
182
183/// Parse or validation error for a Wesley project manifest.
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub enum ProjectManifestError {
186    /// The manifest could not be parsed as JSON or YAML.
187    Parse(String),
188    /// The manifest API version is unsupported.
189    UnsupportedApiVersion(String),
190    /// The manifest shape parsed but failed validation.
191    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
215/// Parse and validate a Wesley project manifest from JSON or YAML.
216pub 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
224/// Validate a parsed Wesley project manifest.
225pub 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
329/// Select schema paths affected by a set of changed files.
330pub 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}