Skip to main content

gpui_rhai_cli/
lib.rs

1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3use std::fmt::Write as _;
4use std::fs;
5use std::fs::OpenOptions;
6use std::io::Write as _;
7use std::ops::Range;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::rc::Rc;
11
12use gpui_rhai::{
13    AppManifest, ComponentDefinition, ComponentInstancePath, ComponentMetadata, ComponentRegistry,
14    DirectoryAssetProvider, EmbeddedScriptSource, FontSource, LocaleManager, ModuleId,
15    RUNTIME_API_VERSION, RestrictedModuleResolver, RuntimeEngine, ScriptAsset, ScriptLifecycle,
16    ThemeManager, ThemeSelection, UiRuntimeState, load_component_styles, load_locale_source,
17    load_theme_source, parse_component_header, validate_font_sources,
18};
19use semver::Version;
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22use toml_edit::{Array, DocumentMut, InlineTable, Item, Value};
23
24pub mod theme_studio;
25
26use gpui_rhai_registry::{
27    AR_LOCALE, BUNDLED_ASSET_SOURCES, BUNDLED_CHART_SOURCES_BY_ID, BUNDLED_COMPONENT_SOURCES_BY_ID,
28    BUNDLED_MOTION_SOURCES_BY_ID, BUNDLED_THEME_SOURCES, DEFAULT_THEME, EN_LOCALE, STUDIO_SOURCE,
29    ZH_CN_LOCALE,
30};
31#[cfg(test)]
32use gpui_rhai_registry::{
33    CALENDAR_SVG, CHECK_SVG, CHEVRON_DOWN_SVG, CHEVRON_LEFT_SVG, CHEVRON_RIGHT_SVG, CHEVRON_UP_SVG,
34    CLOSE_SVG, DATE_NEXT_SVG, DATE_PREVIOUS_SVG, DISCLOSURE_DOWN_SVG, ETHEREAL_THEME, HELP_SVG,
35    INFO_SVG, MINUS_SVG, PLUS_SVG, SEARCH_SVG, SORT_ASCENDING_SVG, SORT_DESCENDING_SVG,
36    WARNING_SVG,
37};
38
39#[derive(Clone, Debug)]
40struct RegistryEntry {
41    metadata: ComponentMetadata,
42    source: &'static str,
43    assets: Vec<RegistryAsset>,
44}
45
46#[derive(Clone, Debug)]
47struct RegistryAsset {
48    path: &'static str,
49    source: &'static str,
50}
51
52const DEFAULT_COMPONENT_STYLES: &str = r"// Application-wide typed component overrides.
53fn component_styles() {
54    #{}
55}
56";
57
58#[derive(Clone, Debug, Default)]
59pub struct BundledRegistry {
60    entries: BTreeMap<ModuleId, RegistryEntry>,
61}
62
63impl BundledRegistry {
64    /// Load and validate the CLI's built-in registry snapshot.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`ProjectError`] when a bundled component header is invalid or
69    /// duplicates another component ID.
70    pub fn load() -> Result<Self, ProjectError> {
71        let mut registry = Self::default();
72        let asset_sources = BUNDLED_ASSET_SOURCES
73            .iter()
74            .copied()
75            .collect::<BTreeMap<_, _>>();
76        for &(catalog_id, source) in BUNDLED_COMPONENT_SOURCES_BY_ID
77            .iter()
78            .chain(BUNDLED_MOTION_SOURCES_BY_ID)
79            .chain(BUNDLED_CHART_SOURCES_BY_ID)
80        {
81            let metadata = parse_component_header(source)?;
82            let id = metadata.id.clone();
83            if id.as_str() != catalog_id {
84                return Err(ProjectError::RegistryIdMismatch {
85                    catalog: catalog_id.to_owned(),
86                    metadata: id,
87                });
88            }
89            validate_component_documentation(source, &id)?;
90            let declared_assets = metadata
91                .assets
92                .iter()
93                .map(String::as_str)
94                .collect::<BTreeSet<_>>();
95            let assets = metadata
96                .assets
97                .iter()
98                .filter_map(|path| {
99                    asset_sources
100                        .get_key_value(path.as_str())
101                        .map(|(&path, &source)| RegistryAsset { path, source })
102                })
103                .collect::<Vec<_>>();
104            let bundled_assets = assets
105                .iter()
106                .map(|asset| asset.path)
107                .collect::<BTreeSet<_>>();
108            if declared_assets != bundled_assets {
109                return Err(ProjectError::RegistryAssetMismatch {
110                    component: id,
111                    declared: declared_assets.into_iter().map(ToOwned::to_owned).collect(),
112                    bundled: bundled_assets.into_iter().map(ToOwned::to_owned).collect(),
113                });
114            }
115            let id = metadata.id.clone();
116            if registry
117                .entries
118                .insert(
119                    id.clone(),
120                    RegistryEntry {
121                        metadata,
122                        source,
123                        assets,
124                    },
125                )
126                .is_some()
127            {
128                return Err(ProjectError::DuplicateRegistry(id));
129            }
130        }
131        Ok(registry)
132    }
133
134    fn resolve(&self, requested: &[String]) -> Result<Vec<ModuleId>, ProjectError> {
135        let mut ordered = Vec::new();
136        let mut complete = BTreeSet::new();
137        let mut stack = Vec::new();
138        for request in requested {
139            let normalized = if request.contains('/') {
140                request.clone()
141            } else if self
142                .entries
143                .contains_key(&ModuleId::parse(format!("charts/{request}"))?)
144            {
145                format!("charts/{request}")
146            } else {
147                format!("components/{request}")
148            };
149            let id = ModuleId::parse(normalized)?;
150            self.visit(&id, &mut stack, &mut complete, &mut ordered)?;
151        }
152        Ok(ordered)
153    }
154
155    fn visit(
156        &self,
157        id: &ModuleId,
158        stack: &mut Vec<ModuleId>,
159        complete: &mut BTreeSet<ModuleId>,
160        ordered: &mut Vec<ModuleId>,
161    ) -> Result<(), ProjectError> {
162        if complete.contains(id) {
163            return Ok(());
164        }
165        if let Some(start) = stack.iter().position(|active| active == id) {
166            let mut cycle = stack[start..].to_vec();
167            cycle.push(id.clone());
168            return Err(ProjectError::RegistryCycle(cycle));
169        }
170        let entry = self
171            .entries
172            .get(id)
173            .ok_or_else(|| ProjectError::UnknownComponent(id.clone()))?;
174        stack.push(id.clone());
175        for dependency in &entry.metadata.dependencies {
176            self.visit(dependency, stack, complete, ordered)?;
177        }
178        stack.pop();
179        complete.insert(id.clone());
180        ordered.push(id.clone());
181        Ok(())
182    }
183}
184
185#[derive(Clone, Debug)]
186pub struct Project {
187    root: PathBuf,
188}
189
190impl Project {
191    #[must_use]
192    pub fn new(root: impl Into<PathBuf>) -> Self {
193        Self { root: root.into() }
194    }
195
196    /// Plan a non-destructive project initialization.
197    ///
198    /// # Errors
199    ///
200    /// Returns [`ProjectError`] for missing/invalid Cargo metadata or conflicting
201    /// generated files.
202    pub fn plan_init(&self) -> Result<ProjectPlan, ProjectError> {
203        let cargo_path = self.root.join("Cargo.toml");
204        let cargo_source = read(&cargo_path)?;
205        let mut cargo =
206            cargo_source
207                .parse::<DocumentMut>()
208                .map_err(|source| ProjectError::CargoToml {
209                    path: cargo_path.clone(),
210                    source,
211                })?;
212        if cargo
213            .get("dependencies")
214            .and_then(|dependencies| dependencies.get("gpui-rhai"))
215            .is_none()
216        {
217            let mut features = Array::new();
218            features.push("dev-reload");
219            let mut dependency = InlineTable::new();
220            dependency.insert("version", Value::from("0.1"));
221            dependency.insert("features", Value::Array(features));
222            cargo["dependencies"]["gpui-rhai"] = Item::Value(Value::InlineTable(dependency));
223        }
224
225        let mut plan = ProjectPlan::new(self.root.clone());
226        plan.update(cargo_path, cargo.to_string(), cargo_source);
227        let main_path = self.root.join("src/main.rs");
228        if main_path.exists() {
229            plan.create(self.root.join("gpui-rhai-host-snippet.rs"), host_source())?;
230        } else {
231            plan.create(main_path, host_source())?;
232        }
233        plan.create(self.root.join("ui/main.rhai"), starter_ui())?;
234        plan.create(self.root.join("ui/theme.rhai"), DEFAULT_THEME.to_owned())?;
235        plan.create(
236            self.root.join("ui/styles.rhai"),
237            DEFAULT_COMPONENT_STYLES.to_owned(),
238        )?;
239        for &(name, source) in BUNDLED_THEME_SOURCES
240            .iter()
241            .filter(|(name, _)| *name != "default_dark.rhai")
242        {
243            plan.create(self.root.join("ui/themes").join(name), source.to_owned())?;
244        }
245        plan.create(self.root.join("ui/locales/en.rhai"), EN_LOCALE.to_owned())?;
246        plan.create(
247            self.root.join("ui/locales/zh_cn.rhai"),
248            ZH_CN_LOCALE.to_owned(),
249        )?;
250        plan.create(self.root.join("ui/locales/ar.rhai"), AR_LOCALE.to_owned())?;
251        plan.create(self.root.join("ui/app.toml"), app_manifest_source()?)?;
252        plan.create(
253            self.root.join(".gpui-rhai/manifest.toml"),
254            toml::to_string_pretty(&LocalManifest::default())?,
255        )?;
256        Ok(plan)
257    }
258
259    /// Plan installation of components and transitive dependencies.
260    ///
261    /// # Errors
262    ///
263    /// Returns [`ProjectError`] for missing manifests, unknown components,
264    /// compatibility failures, or existing untracked source.
265    pub fn plan_add(
266        &self,
267        registry: &BundledRegistry,
268        requested: &[String],
269    ) -> Result<ProjectPlan, ProjectError> {
270        if requested.is_empty() {
271            return Err(ProjectError::NoComponents);
272        }
273        let manifest_path = self.root.join(".gpui-rhai/manifest.toml");
274        let manifest_source = read(&manifest_path)?;
275        let mut manifest: LocalManifest = toml::from_str(&manifest_source)?;
276        let resolved = registry.resolve(requested)?;
277        let mut plan = ProjectPlan::new(self.root.clone());
278        if resolved.iter().any(|id| id.as_str().starts_with("charts/")) {
279            self.plan_enable_dependency_feature(&mut plan, "charts")?;
280        }
281        for id in resolved {
282            if manifest.components.contains_key(id.as_str()) {
283                continue;
284            }
285            let entry = registry
286                .entries
287                .get(&id)
288                .ok_or_else(|| ProjectError::UnknownComponent(id.clone()))?;
289            if !entry.metadata.runtime_api.contains(RUNTIME_API_VERSION) {
290                return Err(ProjectError::IncompatibleRuntime(id));
291            }
292            let relative = component_relative_path(&id)?;
293            plan.create(
294                self.root.join("ui").join(&relative),
295                entry.source.to_owned(),
296            )?;
297            plan.create(
298                self.root.join(".gpui-rhai/baselines").join(&relative),
299                entry.source.to_owned(),
300            )?;
301            for asset in &entry.assets {
302                plan.create(
303                    self.root.join("ui/assets").join(asset.path),
304                    asset.source.to_owned(),
305                )?;
306                plan.create(
307                    self.root
308                        .join(".gpui-rhai/baselines/assets")
309                        .join(asset.path),
310                    asset.source.to_owned(),
311                )?;
312            }
313            let hash = ScriptAsset::new(id.clone(), entry.source.to_owned()).content_hash;
314            manifest.components.insert(
315                id.as_str().to_owned(),
316                InstalledComponent {
317                    version: entry.metadata.version.clone(),
318                    content_hash: format!("{hash:016x}"),
319                    dependencies: entry
320                        .metadata
321                        .dependencies
322                        .iter()
323                        .map(ToString::to_string)
324                        .collect(),
325                },
326            );
327        }
328        plan.update(
329            manifest_path,
330            toml::to_string_pretty(&manifest)?,
331            manifest_source,
332        );
333        Ok(plan)
334    }
335
336    fn plan_enable_dependency_feature(
337        &self,
338        plan: &mut ProjectPlan,
339        feature: &str,
340    ) -> Result<(), ProjectError> {
341        let path = self.root.join("Cargo.toml");
342        let source = read(&path)?;
343        let mut document =
344            source
345                .parse::<DocumentMut>()
346                .map_err(|source| ProjectError::CargoToml {
347                    path: path.clone(),
348                    source,
349                })?;
350        let dependency = document
351            .get_mut("dependencies")
352            .and_then(Item::as_table_mut)
353            .and_then(|dependencies| dependencies.get_mut("gpui-rhai"))
354            .ok_or_else(|| ProjectError::MissingRuntimeDependency(path.clone()))?;
355        match dependency {
356            Item::Value(Value::String(version)) => {
357                let version = version.value().to_owned();
358                let mut features = Array::new();
359                features.push(feature);
360                let mut table = InlineTable::new();
361                table.insert("version", Value::from(version));
362                table.insert("features", Value::Array(features));
363                *dependency = Item::Value(Value::InlineTable(table));
364            }
365            Item::Value(Value::InlineTable(table)) => {
366                let features = table
367                    .entry("features")
368                    .or_insert(Value::Array(Array::new()));
369                let Value::Array(features) = features else {
370                    return Err(ProjectError::InvalidRuntimeFeatures(path));
371                };
372                if !features.iter().any(|value| value.as_str() == Some(feature)) {
373                    features.push(feature);
374                }
375            }
376            Item::Table(table) => {
377                if !table.contains_key("features") {
378                    table.insert("features", Item::Value(Value::Array(Array::new())));
379                }
380                let features = table
381                    .get_mut("features")
382                    .and_then(Item::as_value_mut)
383                    .and_then(Value::as_array_mut)
384                    .ok_or_else(|| ProjectError::InvalidRuntimeFeatures(path.clone()))?;
385                if !features.iter().any(|value| value.as_str() == Some(feature)) {
386                    features.push(feature);
387                }
388            }
389            _ => return Err(ProjectError::InvalidRuntimeDependency(path)),
390        }
391        plan.update(path, document.to_string(), source);
392        Ok(())
393    }
394
395    /// Validate installed baselines, headers, themes, app manifest, imports, and
396    /// the required `view(ctx)` function.
397    ///
398    /// # Errors
399    ///
400    /// Returns [`ProjectError`] for any invalid artifact.
401    pub fn check(&self) -> Result<CheckReport, ProjectError> {
402        validate_gpui_dependency_family(&self.root.join("Cargo.toml"))?;
403        let manifest_path = self.root.join(".gpui-rhai/manifest.toml");
404        let manifest: LocalManifest = toml::from_str(&read(&manifest_path)?)?;
405        if manifest.runtime_api != RUNTIME_API_VERSION {
406            return Err(ProjectError::ManifestRuntime {
407                required: manifest.runtime_api,
408                actual: RUNTIME_API_VERSION,
409            });
410        }
411        let mut modules = BTreeMap::new();
412        let mut headers = BTreeMap::new();
413        for (raw_id, installed) in &manifest.components {
414            let id = ModuleId::parse(raw_id.clone())?;
415            let relative = component_relative_path(&id)?;
416            let baseline_source = read(&self.root.join(".gpui-rhai/baselines").join(&relative))?;
417            let baseline_hash = ScriptAsset::new(id.clone(), baseline_source).content_hash;
418            if format!("{baseline_hash:016x}") != installed.content_hash {
419                return Err(ProjectError::BaselineHash(id));
420            }
421            let source = read(&self.root.join("ui").join(relative))?;
422            let header = parse_component_header(&source)?;
423            validate_component_documentation(&source, &id)?;
424            if header.id != id || header.version != installed.version {
425                return Err(ProjectError::InstalledMetadata(id));
426            }
427            headers.insert(id.clone(), header);
428            modules.insert(id, source);
429        }
430
431        let components = validate_component_exports(&modules, &headers)?;
432        validate_entry(&self.root, &modules, &components)?;
433        let app: AppManifest = toml::from_str(&read(&self.root.join("ui/app.toml"))?)?;
434        if app.entry.as_str() != "main" {
435            return Err(ProjectError::ManifestEntry(app.entry));
436        }
437        if app.runtime_api != RUNTIME_API_VERSION {
438            return Err(ProjectError::ManifestRuntime {
439                required: app.runtime_api,
440                actual: RUNTIME_API_VERSION,
441            });
442        }
443        app.validate_components(&components)?;
444        let styles_path = self.root.join("ui/styles.rhai");
445        if styles_path.exists() {
446            load_component_styles(
447                RuntimeEngine::new().engine(),
448                "ui/styles.rhai",
449                &read(&styles_path)?,
450                &components,
451            )
452            .map_err(|error| ProjectError::ComponentStyle(error.to_string()))?;
453        }
454        let theme_source = read(&self.root.join("ui/theme.rhai"))?;
455        let theme_runtime = RuntimeEngine::new();
456        let primary = load_theme_source(theme_runtime.engine(), "ui/theme.rhai", &theme_source)
457            .map_err(|error| ProjectError::Theme(error.to_string()))?;
458        let mut variants = vec![primary.clone()];
459        for path in collect_paths(&self.root.join("ui/themes"))? {
460            let source = read(&path)?;
461            variants.push(
462                load_theme_source(theme_runtime.engine(), &path.to_string_lossy(), &source)
463                    .map_err(|error| ProjectError::Theme(error.to_string()))?,
464            );
465        }
466        ThemeManager::from_variants(variants, ThemeSelection::new(primary.family, primary.name))
467            .map_err(|error| ProjectError::Theme(error.to_string()))?;
468        validate_locales(&self.root)?;
469        validate_embedded_assets(&self.root)?;
470        validate_fonts(&self.root)?;
471        Ok(CheckReport {
472            components: manifest.components.len(),
473            entry: app.entry,
474        })
475    }
476
477    /// Generate schema-driven component metadata and basic editor snippets.
478    ///
479    /// # Errors
480    ///
481    /// Returns manifest, source, compilation, export, or serialization errors.
482    pub fn plan_editor_metadata(&self) -> Result<ProjectPlan, ProjectError> {
483        let manifest: LocalManifest =
484            toml::from_str(&read(&self.root.join(".gpui-rhai/manifest.toml"))?)?;
485        let mut modules = BTreeMap::new();
486        let mut headers = BTreeMap::new();
487        for raw_id in manifest.components.keys() {
488            let id = ModuleId::parse(raw_id.clone())?;
489            let source = read(&self.root.join("ui").join(component_relative_path(&id)?))?;
490            headers.insert(id.clone(), parse_component_header(&source)?);
491            modules.insert(id, source);
492        }
493        let registry = validate_component_exports(&modules, &headers)?;
494        let metadata = EditorMetadata {
495            runtime_api: RUNTIME_API_VERSION,
496            components: registry
497                .iter()
498                .map(|(_, component)| component.clone())
499                .collect(),
500        };
501        let snippets = editor_snippets(&registry);
502        let definitions = RuntimeEngine::new().definition_source();
503        let mut plan = ProjectPlan::new(self.root.clone());
504        plan.replace_or_create(
505            self.root.join(".gpui-rhai/editor/components.json"),
506            format!("{}\n", serde_json::to_string_pretty(&metadata)?),
507        )?;
508        plan.replace_or_create(
509            self.root.join(".gpui-rhai/editor/snippets.json"),
510            format!("{}\n", serde_json::to_string_pretty(&snippets)?),
511        )?;
512        plan.replace_or_create(
513            self.root
514                .join(".gpui-rhai/editor/definitions/gpui_rhai.d.rhai"),
515            definitions,
516        )?;
517        Ok(plan)
518    }
519
520    /// Compare editable files with their committed baselines.
521    ///
522    /// # Errors
523    ///
524    /// Returns [`ProjectError`] when project metadata or files cannot be read.
525    pub fn diff(&self) -> Result<Vec<String>, ProjectError> {
526        let manifest: LocalManifest =
527            toml::from_str(&read(&self.root.join(".gpui-rhai/manifest.toml"))?)?;
528        manifest
529            .components
530            .keys()
531            .map(|raw_id| {
532                let id = ModuleId::parse(raw_id.clone())?;
533                let relative = component_relative_path(&id)?;
534                let source = read(&self.root.join("ui").join(&relative))?;
535                let baseline = read(&self.root.join(".gpui-rhai/baselines").join(relative))?;
536                Ok(format!(
537                    "{raw_id}: {}",
538                    if source == baseline {
539                        "unchanged"
540                    } else {
541                        "modified"
542                    }
543                ))
544            })
545            .collect()
546    }
547
548    /// Plan safe updates from the bundled registry.
549    ///
550    /// Locally modified files are three-way merged against their install
551    /// baseline. Conflicts leave the editable source and baseline untouched and
552    /// create an inspectable artifact under `.gpui-rhai/conflicts/`.
553    ///
554    /// # Errors
555    ///
556    /// Returns I/O, manifest, or path errors. Merge conflicts are recorded on
557    /// the returned plan and are not silent overwrites.
558    pub fn plan_update(&self, registry: &BundledRegistry) -> Result<ProjectPlan, ProjectError> {
559        let manifest_path = self.root.join(".gpui-rhai/manifest.toml");
560        let manifest_source = read(&manifest_path)?;
561        let mut manifest: LocalManifest = toml::from_str(&manifest_source)?;
562        let mut plan = ProjectPlan::new(self.root.clone());
563        let requested = manifest.components.keys().cloned().collect::<Vec<_>>();
564        for id in registry.resolve(&requested)? {
565            let upstream = registry
566                .entries
567                .get(&id)
568                .ok_or_else(|| ProjectError::UnknownComponent(id.clone()))?;
569            if !upstream.metadata.runtime_api.contains(RUNTIME_API_VERSION) {
570                return Err(ProjectError::IncompatibleRuntime(id));
571            }
572            let relative = component_relative_path(&id)?;
573            let source_path = self.root.join("ui").join(&relative);
574            let baseline_path = self.root.join(".gpui-rhai/baselines").join(&relative);
575            let installed = manifest.components.get(id.as_str()).cloned();
576            if let Some(installed) = &installed
577                && upstream.metadata.version <= installed.version
578            {
579                continue;
580            }
581            let write_checkpoint = plan.writes.len();
582            if installed.is_some() {
583                let source = read(&source_path)?;
584                let baseline = read(&baseline_path)?;
585                let Ok(merged) = three_way_merge(&baseline, &source, upstream.source) else {
586                    let conflict_path = self.root.join(".gpui-rhai/conflicts").join(&relative);
587                    plan.replace_or_create(
588                        conflict_path,
589                        conflict_artifact(&source, &baseline, upstream.source),
590                    )?;
591                    plan.conflicts.push(id);
592                    continue;
593                };
594                plan.update(source_path, merged, source);
595                plan.update(baseline_path, upstream.source.to_owned(), baseline);
596            } else {
597                plan.create(source_path, upstream.source.to_owned())?;
598                plan.create(baseline_path, upstream.source.to_owned())?;
599            }
600            let mut asset_conflict = false;
601            for asset in &upstream.assets {
602                asset_conflict |= self.plan_asset_update(&mut plan, asset)?;
603            }
604            if asset_conflict {
605                let added = plan.writes.split_off(write_checkpoint);
606                let conflicts = self.root.join(".gpui-rhai/conflicts");
607                plan.writes.extend(
608                    added
609                        .into_iter()
610                        .filter(|write| write.path.starts_with(&conflicts)),
611                );
612                plan.conflicts.push(id);
613                continue;
614            }
615            let content_hash = format!(
616                "{:016x}",
617                ScriptAsset::new(id.clone(), upstream.source.to_owned()).content_hash
618            );
619            manifest.components.insert(
620                id.as_str().to_owned(),
621                InstalledComponent {
622                    version: upstream.metadata.version.clone(),
623                    content_hash,
624                    dependencies: upstream
625                        .metadata
626                        .dependencies
627                        .iter()
628                        .map(ToString::to_string)
629                        .collect(),
630                },
631            );
632        }
633        for &(name, source) in BUNDLED_THEME_SOURCES
634            .iter()
635            .filter(|(name, _)| *name != "default_dark.rhai")
636        {
637            let path = self.root.join("ui/themes").join(name);
638            if !path.exists() {
639                plan.create(path, source.to_owned())?;
640            }
641        }
642        let styles = self.root.join("ui/styles.rhai");
643        if !styles.exists() {
644            plan.create(styles, DEFAULT_COMPONENT_STYLES.to_owned())?;
645        }
646        plan.update(
647            manifest_path,
648            toml::to_string_pretty(&manifest)?,
649            manifest_source,
650        );
651        Ok(plan)
652    }
653
654    fn plan_asset_update(
655        &self,
656        plan: &mut ProjectPlan,
657        asset: &RegistryAsset,
658    ) -> Result<bool, ProjectError> {
659        let source_path = self.root.join("ui/assets").join(asset.path);
660        let baseline_path = self
661            .root
662            .join(".gpui-rhai/baselines/assets")
663            .join(asset.path);
664        match (source_path.exists(), baseline_path.exists()) {
665            (false, false) => {
666                plan.create(source_path, asset.source.to_owned())?;
667                plan.create(baseline_path, asset.source.to_owned())?;
668            }
669            (true, true) => {
670                let source = read(&source_path)?;
671                let baseline = read(&baseline_path)?;
672                let Ok(merged) = three_way_merge(&baseline, &source, asset.source) else {
673                    let conflict_path = self
674                        .root
675                        .join(".gpui-rhai/conflicts/assets")
676                        .join(asset.path);
677                    plan.replace_or_create(
678                        conflict_path,
679                        conflict_artifact(&source, &baseline, asset.source),
680                    )?;
681                    return Ok(true);
682                };
683                plan.update(source_path, merged, source);
684                plan.update(baseline_path, asset.source.to_owned(), baseline);
685            }
686            _ => return Err(ProjectError::IncompleteAssetBaseline(asset.path.to_owned())),
687        }
688        Ok(false)
689    }
690
691    /// Generate a deterministic Rust module for production embedding.
692    ///
693    /// # Errors
694    ///
695    /// Returns [`ProjectError`] when manifests or referenced resources cannot
696    /// be read.
697    pub fn plan_embed(&self) -> Result<ProjectPlan, ProjectError> {
698        let manifest: LocalManifest =
699            toml::from_str(&read(&self.root.join(".gpui-rhai/manifest.toml"))?)?;
700        let app: AppManifest = toml::from_str(&read(&self.root.join("ui/app.toml"))?)?;
701        if app.entry.as_str() != "main" {
702            return Err(ProjectError::ManifestEntry(app.entry));
703        }
704        let mut modules = vec![(app.entry.to_string(), "../ui/main.rhai".to_owned())];
705        for raw_id in manifest.components.keys() {
706            let id = ModuleId::parse(raw_id.clone())?;
707            let relative = component_relative_path(&id)?;
708            modules.push((id.to_string(), format!("../ui/{}", slash_path(&relative))));
709        }
710        modules.sort();
711
712        let locales = collect_relative_files(&self.root.join("ui/locales"), "../ui/locales")?;
713        let themes = collect_relative_files(&self.root.join("ui/themes"), "../ui/themes")?;
714        let assets = collect_relative_files(&self.root.join("ui/assets"), "../ui/assets")?;
715        let fonts = collect_relative_files(&self.root.join("ui/fonts"), "../ui/fonts")?;
716        let generated = generated_embed_module(&app, &modules, &locales, &themes, &assets, &fonts)?;
717        let path = self.root.join("src/gpui_rhai_embedded.rs");
718        let mut plan = ProjectPlan::new(self.root.clone());
719        match fs::read_to_string(&path) {
720            Ok(existing) => plan.update(path, generated, existing),
721            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
722                plan.create(path, generated)?;
723            }
724            Err(source) => return Err(ProjectError::Io { path, source }),
725        }
726        Ok(plan)
727    }
728
729    /// Run the Cargo application in the project root.
730    ///
731    /// # Errors
732    ///
733    /// Returns [`ProjectError`] when Cargo cannot start or exits unsuccessfully.
734    pub fn dev(&self) -> Result<(), ProjectError> {
735        let status = Command::new("cargo")
736            .arg("run")
737            .current_dir(&self.root)
738            .status()
739            .map_err(ProjectError::Spawn)?;
740        if status.success() {
741            Ok(())
742        } else {
743            Err(ProjectError::DevFailed(status.code()))
744        }
745    }
746}
747
748fn validate_gpui_dependency_family(path: &Path) -> Result<(), ProjectError> {
749    const GPUI_PRE_VERSION: &str = "=0.3.7";
750    let source = read(path)?;
751    let manifest: toml::Value = toml::from_str(&source)?;
752    let mut tables = Vec::new();
753    for name in ["dependencies", "dev-dependencies", "build-dependencies"] {
754        if let Some(table) = manifest.get(name).and_then(toml::Value::as_table) {
755            tables.push(table);
756        }
757    }
758    if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) {
759        for target in targets.values().filter_map(toml::Value::as_table) {
760            for name in ["dependencies", "dev-dependencies", "build-dependencies"] {
761                if let Some(table) = target.get(name).and_then(toml::Value::as_table) {
762                    tables.push(table);
763                }
764            }
765        }
766    }
767    for table in tables {
768        for (alias, dependency) in table {
769            let (package, version) = match dependency {
770                toml::Value::String(version) => (alias.clone(), Some(version.clone())),
771                toml::Value::Table(spec)
772                    if spec.get("workspace").and_then(toml::Value::as_bool) == Some(true) =>
773                {
774                    workspace_dependency(path, alias)?.ok_or_else(|| {
775                        ProjectError::GpuiDependency(format!(
776                            "`{alias}` inherits from workspace dependencies, but no workspace declaration was found"
777                        ))
778                    })?
779                }
780                toml::Value::Table(spec) => (
781                    spec.get("package")
782                        .and_then(toml::Value::as_str)
783                        .unwrap_or(alias)
784                        .to_owned(),
785                    spec.get("version")
786                        .and_then(toml::Value::as_str)
787                        .map(ToOwned::to_owned),
788                ),
789                _ => continue,
790            };
791            let expected = match package.as_str() {
792                "gpui" | "gpui-pre" => Some(("gpui", "gpui-pre")),
793                "gpui-pre-platform" => Some(("gpui_platform", "gpui-pre-platform")),
794                _ => None,
795            };
796            let Some((expected_alias, expected_package)) = expected else {
797                continue;
798            };
799            if alias != expected_alias
800                || package != expected_package
801                || version.as_deref() != Some(GPUI_PRE_VERSION)
802            {
803                return Err(ProjectError::GpuiDependency(format!(
804                    "`{alias}` resolves to `{package}` at {version:?}; use `{expected_alias} = {{ package = \"{expected_package}\", version = \"{GPUI_PRE_VERSION}\", default-features = false }}`"
805                )));
806            }
807        }
808    }
809    Ok(())
810}
811
812fn workspace_dependency(
813    member_manifest: &Path,
814    alias: &str,
815) -> Result<Option<(String, Option<String>)>, ProjectError> {
816    let Some(directory) = member_manifest.parent() else {
817        return Ok(None);
818    };
819    for ancestor in directory.ancestors() {
820        let candidate = ancestor.join("Cargo.toml");
821        if !candidate.exists() {
822            continue;
823        }
824        let manifest: toml::Value = toml::from_str(&read(&candidate)?)?;
825        let Some(spec) = manifest
826            .get("workspace")
827            .and_then(|workspace| workspace.get("dependencies"))
828            .and_then(|dependencies| dependencies.get(alias))
829        else {
830            continue;
831        };
832        return Ok(match spec {
833            toml::Value::String(version) => Some((alias.to_owned(), Some(version.clone()))),
834            toml::Value::Table(spec) => Some((
835                spec.get("package")
836                    .and_then(toml::Value::as_str)
837                    .unwrap_or(alias)
838                    .to_owned(),
839                spec.get("version")
840                    .and_then(toml::Value::as_str)
841                    .map(ToOwned::to_owned),
842            )),
843            _ => None,
844        });
845    }
846    Ok(None)
847}
848
849fn validate_component_documentation(source: &str, id: &ModuleId) -> Result<(), ProjectError> {
850    let lower = source.to_ascii_lowercase();
851    for (label, present) in [
852        ("props", source.contains("// Props:")),
853        (
854            "state",
855            lower.contains("state:") || lower.contains("state/"),
856        ),
857        (
858            "events",
859            lower.contains("event") || lower.contains("emits no events"),
860        ),
861        ("example", source.contains("// Example:")),
862        (
863            "render_component wrapper",
864            source.contains("render_component("),
865        ),
866    ] {
867        if !present {
868            return Err(ProjectError::ComponentDocumentation {
869                component: id.clone(),
870                missing: label,
871            });
872        }
873    }
874    Ok(())
875}
876
877fn validate_locales(root: &Path) -> Result<(), ProjectError> {
878    let engine = RuntimeEngine::new();
879    let mut bundles = Vec::new();
880    for path in collect_paths(&root.join("ui/locales"))? {
881        let source = read(&path)?;
882        bundles.push(
883            load_locale_source(engine.engine(), &path.to_string_lossy(), &source)
884                .map_err(|error| ProjectError::Locale(error.to_string()))?,
885        );
886    }
887    if bundles.is_empty() {
888        return Ok(());
889    }
890    let fallback = bundles
891        .iter()
892        .find(|bundle| bundle.locale == "en")
893        .unwrap_or(&bundles[0])
894        .locale
895        .clone();
896    LocaleManager::new(bundles, fallback.clone(), fallback)
897        .map(|_| ())
898        .map_err(|error| ProjectError::Locale(error.to_string()))
899}
900
901fn validate_embedded_assets(root: &Path) -> Result<(), ProjectError> {
902    let assets = collect_relative_files(&root.join("ui/assets"), "../ui/assets")?;
903    let mut logical = BTreeSet::new();
904    for (name, _) in assets {
905        embedded_asset_mime(&name)?;
906        let name = name
907            .rsplit_once('.')
908            .map_or(name.as_str(), |(stem, _)| stem);
909        if !logical.insert(name.to_owned()) {
910            return Err(ProjectError::DuplicateAssetLogical(name.to_owned()));
911        }
912    }
913    Ok(())
914}
915
916fn validate_fonts(root: &Path) -> Result<(), ProjectError> {
917    let files = collect_relative_files(&root.join("ui/fonts"), "../ui/fonts")?;
918    let mut fonts = Vec::with_capacity(files.len());
919    for (name, _) in files {
920        validate_font_extension(&name)?;
921        let path = root.join("ui/fonts").join(&name);
922        let bytes = fs::read(&path).map_err(|source| ProjectError::Io {
923            path: path.clone(),
924            source,
925        })?;
926        fonts.push(
927            FontSource::new(name, bytes).map_err(|error| ProjectError::Font(error.to_string()))?,
928        );
929    }
930    validate_font_sources(&fonts).map_err(|error| ProjectError::Font(error.to_string()))
931}
932
933#[derive(Clone, Debug, Serialize)]
934struct EditorMetadata {
935    runtime_api: u32,
936    components: Vec<ComponentDefinition>,
937}
938
939#[derive(Clone, Debug, Serialize)]
940struct EditorSnippet {
941    prefix: String,
942    body: Vec<String>,
943    description: String,
944}
945
946fn editor_snippets(registry: &ComponentRegistry) -> BTreeMap<String, EditorSnippet> {
947    registry
948        .iter()
949        .map(|(id, component)| {
950            let alias = id.as_str().rsplit('/').next().unwrap_or("component");
951            let mut body = vec![
952                format!("import \"{id}\" as {alias};"),
953                format!("{alias}::{}(#{{", component.metadata.export),
954            ];
955            let mut placeholder = 1;
956            for (name, field) in &component.schema.props {
957                if field.required {
958                    body.push(format!("    {name}: ${{{placeholder}:{name}}},"));
959                    placeholder += 1;
960                }
961            }
962            body.push("})".to_owned());
963            (
964                component.metadata.export.clone(),
965                EditorSnippet {
966                    prefix: component.metadata.export.clone(),
967                    body,
968                    description: format!(
969                        "{} from {} {}",
970                        component.metadata.export, id, component.metadata.version
971                    ),
972                },
973            )
974        })
975        .collect()
976}
977
978fn validate_component_exports(
979    modules: &BTreeMap<ModuleId, String>,
980    headers: &BTreeMap<ModuleId, ComponentMetadata>,
981) -> Result<ComponentRegistry, ProjectError> {
982    if modules.is_empty() {
983        return Ok(ComponentRegistry::default());
984    }
985    let source = EmbeddedScriptSource::new(modules.clone());
986    let mut runtime = RuntimeEngine::new();
987    runtime.set_module_resolver(RestrictedModuleResolver::from_source(&source)?);
988    let imports = modules
989        .keys()
990        .enumerate()
991        .map(|(index, id)| format!("import \"{id}\" as component_{index};"))
992        .collect::<Vec<_>>()
993        .join("\n");
994    runtime.compile_self_contained_named("<component-check>", &imports)?;
995    let exported = runtime.component_exports()?;
996    for (id, header) in headers {
997        exported
998            .get(id)
999            .ok_or_else(|| ProjectError::MissingExport(id.clone()))?
1000            .validate_header(header)?;
1001    }
1002    Ok(exported)
1003}
1004
1005fn validate_entry(
1006    root: &Path,
1007    modules: &BTreeMap<ModuleId, String>,
1008    components: &ComponentRegistry,
1009) -> Result<(), ProjectError> {
1010    let entry_path = root.join("ui/main.rhai");
1011    let entry = read(&entry_path)?;
1012    let source = EmbeddedScriptSource::new(modules.clone());
1013    let mut runtime = RuntimeEngine::new();
1014    let call_diagnostics = runtime.lint_known_calls("ui/main.rhai", &entry, modules)?;
1015    if !call_diagnostics.is_empty() {
1016        return Err(ProjectError::KnownCalls(
1017            call_diagnostics
1018                .iter()
1019                .map(ToString::to_string)
1020                .collect::<Vec<_>>()
1021                .join("\n"),
1022        ));
1023    }
1024    runtime.set_module_resolver(RestrictedModuleResolver::from_source(&source)?);
1025    let compiled = runtime.compile_self_contained_named("ui/main.rhai", &entry)?;
1026    if !compiled.has_function("view", 1) {
1027        return Err(ProjectError::MissingView);
1028    }
1029    let schema = runtime.root_state_schema(&compiled)?;
1030    let primary_source = read(&root.join("ui/theme.rhai"))?;
1031    let primary = load_theme_source(runtime.engine(), "ui/theme.rhai", &primary_source)
1032        .map_err(|error| ProjectError::Theme(error.to_string()))?;
1033    let mut themes = vec![primary.clone()];
1034    for path in collect_paths(&root.join("ui/themes"))? {
1035        themes.push(
1036            load_theme_source(runtime.engine(), &path.to_string_lossy(), &read(&path)?)
1037                .map_err(|error| ProjectError::Theme(error.to_string()))?,
1038        );
1039    }
1040    let mut state = UiRuntimeState::new();
1041    let styles_path = root.join("ui/styles.rhai");
1042    if styles_path.exists() {
1043        let styles = load_component_styles(
1044            runtime.engine(),
1045            &styles_path.to_string_lossy(),
1046            &read(&styles_path)?,
1047            components,
1048        )
1049        .map_err(|error| ProjectError::ComponentStyle(error.to_string()))?;
1050        state.replace_component_styles_from_host(styles);
1051    }
1052    state.theme = Some(
1053        ThemeManager::from_variants(
1054            themes,
1055            ThemeSelection::new(primary.family.clone(), primary.name.clone()),
1056        )
1057        .map_err(|error| ProjectError::Theme(error.to_string()))?,
1058    );
1059    state.locale = load_check_locales(root)?;
1060    let asset_root = root.join("ui/assets");
1061    if asset_root.exists() {
1062        state
1063            .assets
1064            .register(
1065                "app",
1066                DirectoryAssetProvider::new(&asset_root)
1067                    .map_err(|error| ProjectError::Asset(error.to_string()))?,
1068            )
1069            .map_err(|error| ProjectError::Asset(error.to_string()))?;
1070    }
1071    let runtime_state = Rc::new(RefCell::new(state));
1072    let mut lifecycle = ScriptLifecycle::new(
1073        compiled,
1074        runtime_state,
1075        ComponentInstancePath::root("App", "check"),
1076        Some("check".to_owned()),
1077        BTreeMap::new(),
1078        &schema,
1079    )?;
1080    lifecycle.start(&mut runtime)?;
1081    Ok(())
1082}
1083
1084fn load_check_locales(root: &Path) -> Result<Option<LocaleManager>, ProjectError> {
1085    let runtime = RuntimeEngine::new();
1086    let mut bundles = Vec::new();
1087    for path in collect_paths(&root.join("ui/locales"))? {
1088        bundles.push(
1089            load_locale_source(runtime.engine(), &path.to_string_lossy(), &read(&path)?)
1090                .map_err(|error| ProjectError::Locale(error.to_string()))?,
1091        );
1092    }
1093    if bundles.is_empty() {
1094        return Ok(None);
1095    }
1096    let selected = bundles
1097        .iter()
1098        .find(|bundle| bundle.locale == "en")
1099        .unwrap_or(&bundles[0])
1100        .locale
1101        .clone();
1102    LocaleManager::new(bundles, selected.clone(), selected)
1103        .map(Some)
1104        .map_err(|error| ProjectError::Locale(error.to_string()))
1105}
1106
1107#[derive(Clone, Debug, Eq, PartialEq)]
1108struct LineChange {
1109    base: Range<usize>,
1110    replacement: Vec<String>,
1111}
1112
1113fn three_way_merge(base: &str, local: &str, upstream: &str) -> Result<String, ()> {
1114    if local == base {
1115        return Ok(upstream.to_owned());
1116    }
1117    if upstream == base || local == upstream {
1118        return Ok(local.to_owned());
1119    }
1120    let base_lines = split_lines(base);
1121    let local_changes = line_changes(&base_lines, &split_lines(local))?;
1122    let upstream_changes = line_changes(&base_lines, &split_lines(upstream))?;
1123    for local in &local_changes {
1124        for upstream in &upstream_changes {
1125            if changes_overlap(local, upstream) && local != upstream {
1126                return Err(());
1127            }
1128        }
1129    }
1130    let mut changes = local_changes;
1131    for change in upstream_changes {
1132        if !changes.contains(&change) {
1133            changes.push(change);
1134        }
1135    }
1136    changes.sort_by_key(|change| (change.base.start, change.base.end));
1137    let mut output = String::new();
1138    let mut cursor = 0;
1139    for change in changes {
1140        if change.base.start < cursor {
1141            return Err(());
1142        }
1143        output.extend(
1144            base_lines[cursor..change.base.start]
1145                .iter()
1146                .map(String::as_str),
1147        );
1148        output.extend(change.replacement.iter().map(String::as_str));
1149        cursor = change.base.end;
1150    }
1151    output.extend(base_lines[cursor..].iter().map(String::as_str));
1152    Ok(output)
1153}
1154
1155fn split_lines(source: &str) -> Vec<String> {
1156    source
1157        .split_inclusive('\n')
1158        .map(ToOwned::to_owned)
1159        .collect()
1160}
1161
1162fn line_changes(base: &[String], target: &[String]) -> Result<Vec<LineChange>, ()> {
1163    const MAX_LCS_CELLS: usize = 4_000_000;
1164    if base.len().saturating_mul(target.len()) > MAX_LCS_CELLS {
1165        return Err(());
1166    }
1167    let mut lcs = vec![vec![0_u32; target.len().saturating_add(1)]; base.len().saturating_add(1)];
1168    for base_index in (0..base.len()).rev() {
1169        for target_index in (0..target.len()).rev() {
1170            lcs[base_index][target_index] = if base[base_index] == target[target_index] {
1171                lcs[base_index + 1][target_index + 1].saturating_add(1)
1172            } else {
1173                lcs[base_index + 1][target_index].max(lcs[base_index][target_index + 1])
1174            };
1175        }
1176    }
1177    let mut changes = Vec::new();
1178    let mut current: Option<LineChange> = None;
1179    let (mut base_index, mut target_index) = (0, 0);
1180    while base_index < base.len() || target_index < target.len() {
1181        if base_index < base.len()
1182            && target_index < target.len()
1183            && base[base_index] == target[target_index]
1184        {
1185            if let Some(change) = current.take() {
1186                changes.push(change);
1187            }
1188            base_index += 1;
1189            target_index += 1;
1190        } else if target_index < target.len()
1191            && (base_index == base.len()
1192                || lcs[base_index][target_index + 1] > lcs[base_index + 1][target_index])
1193        {
1194            current
1195                .get_or_insert_with(|| LineChange {
1196                    base: base_index..base_index,
1197                    replacement: Vec::new(),
1198                })
1199                .replacement
1200                .push(target[target_index].clone());
1201            target_index += 1;
1202        } else {
1203            let change = current.get_or_insert_with(|| LineChange {
1204                base: base_index..base_index,
1205                replacement: Vec::new(),
1206            });
1207            base_index += 1;
1208            change.base.end = base_index;
1209        }
1210    }
1211    if let Some(change) = current {
1212        changes.push(change);
1213    }
1214    Ok(changes)
1215}
1216
1217fn changes_overlap(left: &LineChange, right: &LineChange) -> bool {
1218    if left.base.is_empty() && right.base.is_empty() {
1219        left.base.start == right.base.start
1220    } else if left.base.is_empty() {
1221        right.base.start < left.base.start && left.base.start < right.base.end
1222    } else if right.base.is_empty() {
1223        left.base.start < right.base.start && right.base.start < left.base.end
1224    } else {
1225        left.base.start < right.base.end && right.base.start < left.base.end
1226    }
1227}
1228
1229fn conflict_artifact(local: &str, baseline: &str, upstream: &str) -> String {
1230    format!(
1231        "<<<<<<< local\n{}||||||| installed baseline\n{}=======\n{}>>>>>>> bundled registry\n",
1232        with_final_newline(local),
1233        with_final_newline(baseline),
1234        with_final_newline(upstream)
1235    )
1236}
1237
1238fn with_final_newline(source: &str) -> String {
1239    if source.ends_with('\n') {
1240        source.to_owned()
1241    } else {
1242        format!("{source}\n")
1243    }
1244}
1245
1246fn component_relative_path(id: &ModuleId) -> Result<PathBuf, ProjectError> {
1247    let path = id.as_str();
1248    let (directory, component) = path
1249        .strip_prefix("components/")
1250        .map(|name| ("components", name))
1251        .or_else(|| path.strip_prefix("motion/").map(|name| ("motion", name)))
1252        .or_else(|| path.strip_prefix("charts/").map(|name| ("charts", name)))
1253        .ok_or_else(|| ProjectError::InvalidComponentPath(id.clone()))?;
1254    Ok(PathBuf::from(directory)
1255        .join(component)
1256        .with_extension("rhai"))
1257}
1258
1259fn slash_path(path: &Path) -> String {
1260    path.components()
1261        .map(|component| component.as_os_str().to_string_lossy())
1262        .collect::<Vec<_>>()
1263        .join("/")
1264}
1265
1266fn collect_relative_files(
1267    directory: &Path,
1268    include_prefix: &str,
1269) -> Result<Vec<(String, String)>, ProjectError> {
1270    if !directory.exists() {
1271        return Ok(Vec::new());
1272    }
1273    let mut pending = vec![directory.to_path_buf()];
1274    let mut files = Vec::new();
1275    while let Some(current) = pending.pop() {
1276        for entry in fs::read_dir(&current).map_err(|source| ProjectError::Io {
1277            path: current.clone(),
1278            source,
1279        })? {
1280            let entry = entry.map_err(|source| ProjectError::Io {
1281                path: current.clone(),
1282                source,
1283            })?;
1284            let path = entry.path();
1285            if path.is_dir() {
1286                pending.push(path);
1287            } else {
1288                let relative = path
1289                    .strip_prefix(directory)
1290                    .map_err(|_| ProjectError::InvalidPath(path.clone()))?;
1291                let logical = slash_path(relative);
1292                files.push((logical.clone(), format!("{include_prefix}/{logical}")));
1293            }
1294        }
1295    }
1296    files.sort();
1297    Ok(files)
1298}
1299
1300fn collect_paths(directory: &Path) -> Result<Vec<PathBuf>, ProjectError> {
1301    if !directory.exists() {
1302        return Ok(Vec::new());
1303    }
1304    let mut paths = fs::read_dir(directory)
1305        .map_err(|source| ProjectError::Io {
1306            path: directory.to_path_buf(),
1307            source,
1308        })?
1309        .map(|entry| {
1310            entry
1311                .map(|entry| entry.path())
1312                .map_err(|source| ProjectError::Io {
1313                    path: directory.to_path_buf(),
1314                    source,
1315                })
1316        })
1317        .collect::<Result<Vec<_>, _>>()?;
1318    paths.retain(|path| path.extension().and_then(|value| value.to_str()) == Some("rhai"));
1319    paths.sort();
1320    Ok(paths)
1321}
1322
1323fn generated_embed_module(
1324    manifest: &AppManifest,
1325    modules: &[(String, String)],
1326    locales: &[(String, String)],
1327    themes: &[(String, String)],
1328    assets: &[(String, String)],
1329    fonts: &[(String, String)],
1330) -> Result<String, ProjectError> {
1331    let mut output =
1332        String::from("// @generated by `gpui-rhai embed`; edit files under `ui/` instead.\n\n");
1333    let _ = writeln!(
1334        output,
1335        "pub fn app_manifest() -> gpui_rhai::AppManifest {{\n    let mut manifest = gpui_rhai::AppManifest::new(gpui_rhai::ModuleId::parse({:?}).expect(\"generated entry id\"));",
1336        manifest.entry.as_str()
1337    );
1338    for (id, requirement) in &manifest.capabilities {
1339        let _ = writeln!(
1340            output,
1341            "    manifest = manifest.with_capability({:?}, {:?}).expect(\"generated capability requirement\");",
1342            id.as_str(),
1343            requirement.to_string()
1344        );
1345    }
1346    output.push_str("    manifest\n}\n\n");
1347    output.push_str("pub fn script_source() -> gpui_rhai::EmbeddedScriptSource {\n");
1348    output
1349        .push_str("    gpui_rhai::EmbeddedScriptSource::new(std::collections::BTreeMap::from([\n");
1350    for (id, path) in modules {
1351        let _ = writeln!(
1352            output,
1353            "        (gpui_rhai::ModuleId::parse({id:?}).expect(\"generated module id\"), include_str!({path:?}).to_owned()),"
1354        );
1355    }
1356    output.push_str("    ]))\n}\n\n");
1357    output.push_str("pub const THEME_SOURCE: &str = include_str!(\"../ui/theme.rhai\");\n\n");
1358    output.push_str(
1359        "pub const COMPONENT_STYLES_SOURCE: &str = include_str!(\"../ui/styles.rhai\");\n\n",
1360    );
1361    output.push_str("pub const LOCALES: &[(&str, &str)] = &[\n");
1362    for (name, path) in locales {
1363        let _ = writeln!(output, "    ({name:?}, include_str!({path:?})),");
1364    }
1365    output.push_str("];\n\n");
1366    output.push_str("pub const THEMES: &[(&str, &str)] = &[\n");
1367    for (name, path) in themes {
1368        let _ = writeln!(output, "    ({name:?}, include_str!({path:?})),");
1369    }
1370    output.push_str("];\n\n");
1371    output.push_str("pub const ASSETS: &[(&str, &[u8])] = &[\n");
1372    for (name, path) in assets {
1373        let _ = writeln!(output, "    ({name:?}, include_bytes!({path:?})),");
1374    }
1375    output.push_str("];\n\n");
1376    output.push_str("pub fn asset_sources() -> Vec<(String, gpui_rhai::AssetData)> {\n    vec![\n");
1377    let mut logical_assets = BTreeSet::new();
1378    for (name, path) in assets {
1379        let mime_type = embedded_asset_mime(name)?;
1380        let logical_name = name
1381            .rsplit_once('.')
1382            .map_or(name.as_str(), |(stem, _)| stem);
1383        if !logical_assets.insert(logical_name) {
1384            return Err(ProjectError::DuplicateAssetLogical(logical_name.to_owned()));
1385        }
1386        let _ = writeln!(
1387            output,
1388            "        ({logical_name:?}.to_owned(), gpui_rhai::AssetData {{ mime_type: {mime_type:?}.to_owned(), bytes: include_bytes!({path:?}).to_vec() }}),"
1389        );
1390    }
1391    output.push_str("    ]\n}\n");
1392    output.push_str("\npub const FONTS: &[(&str, &[u8])] = &[\n");
1393    for (name, path) in fonts {
1394        validate_font_extension(name)?;
1395        let _ = writeln!(output, "    ({name:?}, include_bytes!({path:?})),");
1396    }
1397    output.push_str("];\n\n");
1398    output.push_str("pub fn font_sources() -> Vec<gpui_rhai::FontSource> {\n    vec![\n");
1399    for (name, path) in fonts {
1400        let _ = writeln!(
1401            output,
1402            "        gpui_rhai::FontSource::new({name:?}, include_bytes!({path:?}).to_vec()).expect(\"generated font source\"),"
1403        );
1404    }
1405    output.push_str("    ]\n}\n");
1406    Ok(output)
1407}
1408
1409fn validate_font_extension(path: &str) -> Result<(), ProjectError> {
1410    match path.rsplit('.').next().unwrap_or_default() {
1411        "ttf" | "otf" | "ttc" => Ok(()),
1412        _ => Err(ProjectError::UnsupportedFont(path.to_owned())),
1413    }
1414}
1415
1416fn embedded_asset_mime(path: &str) -> Result<&'static str, ProjectError> {
1417    match path.rsplit('.').next().unwrap_or_default() {
1418        "svg" => Ok("image/svg+xml"),
1419        "png" => Ok("image/png"),
1420        "jpg" | "jpeg" => Ok("image/jpeg"),
1421        "gif" => Ok("image/gif"),
1422        "webp" => Ok("image/webp"),
1423        "bmp" => Ok("image/bmp"),
1424        "tif" | "tiff" => Ok("image/tiff"),
1425        _ => Err(ProjectError::UnsupportedAsset(path.to_owned())),
1426    }
1427}
1428
1429fn host_source() -> String {
1430    r#"fn main() {
1431    let view = gpui_rhai::FileScriptView::new("ui/main.rhai")
1432        .prepare()
1433        .expect("GPUI Rhai view preparation failed");
1434    gpui_rhai::ScriptApplication::new(view)
1435        .run()
1436        .expect("GPUI Rhai application failed");
1437}
1438"#
1439    .to_owned()
1440}
1441
1442fn starter_ui() -> String {
1443    r#"fn view(ctx) {
1444    column([
1445        text("GPUI Rhai"),
1446        text("Run `gpui-rhai add button label` to install source components.")
1447    ]).with_style(style().gap(px(8)).padding(px(16)))
1448}
1449"#
1450    .to_owned()
1451}
1452
1453fn app_manifest_source() -> Result<String, ProjectError> {
1454    Ok(toml::to_string_pretty(&AppManifest {
1455        entry: ModuleId::parse("main")?,
1456        runtime_api: RUNTIME_API_VERSION,
1457        capabilities: BTreeMap::new(),
1458    })?)
1459}
1460
1461fn read(path: &Path) -> Result<String, ProjectError> {
1462    fs::read_to_string(path).map_err(|source| ProjectError::Io {
1463        path: path.to_path_buf(),
1464        source,
1465    })
1466}
1467
1468#[derive(Clone, Debug)]
1469enum ExpectedFile {
1470    Missing,
1471    Exact(String),
1472}
1473
1474#[derive(Clone, Debug)]
1475struct PlannedWrite {
1476    path: PathBuf,
1477    content: String,
1478    expected: ExpectedFile,
1479}
1480
1481struct StagedProjectWrite<'a> {
1482    write: &'a PlannedWrite,
1483    temporary: PathBuf,
1484    backup: Option<PathBuf>,
1485    committed: bool,
1486}
1487
1488struct ProjectApplyLock {
1489    path: PathBuf,
1490}
1491
1492impl ProjectApplyLock {
1493    fn acquire(root: &Path) -> Result<Self, ProjectError> {
1494        let path = root.join(".gpui-rhai-apply.lock");
1495        let mut lock = match OpenOptions::new().write(true).create_new(true).open(&path) {
1496            Ok(lock) => lock,
1497            Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
1498                return Err(ProjectError::ConcurrentChange {
1499                    path,
1500                    detail: "another apply is active or crash recovery is required".to_owned(),
1501                });
1502            }
1503            Err(source) => {
1504                return Err(ProjectError::Io { path, source });
1505            }
1506        };
1507        let guard = Self { path: path.clone() };
1508        writeln!(lock, "pid={}", std::process::id()).map_err(|source| ProjectError::Io {
1509            path: path.clone(),
1510            source,
1511        })?;
1512        Ok(guard)
1513    }
1514}
1515
1516impl Drop for ProjectApplyLock {
1517    fn drop(&mut self) {
1518        let _ = fs::remove_file(&self.path);
1519    }
1520}
1521
1522#[derive(Clone, Debug)]
1523pub struct ProjectPlan {
1524    root: PathBuf,
1525    writes: Vec<PlannedWrite>,
1526    conflicts: Vec<ModuleId>,
1527}
1528
1529impl ProjectPlan {
1530    fn new(root: PathBuf) -> Self {
1531        Self {
1532            root,
1533            writes: Vec::new(),
1534            conflicts: Vec::new(),
1535        }
1536    }
1537
1538    fn create(&mut self, path: PathBuf, content: String) -> Result<(), ProjectError> {
1539        match fs::read_to_string(&path) {
1540            Ok(existing) if existing == content => Ok(()),
1541            Ok(_) => Err(ProjectError::WouldOverwrite(path)),
1542            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1543                self.writes.push(PlannedWrite {
1544                    path,
1545                    content,
1546                    expected: ExpectedFile::Missing,
1547                });
1548                Ok(())
1549            }
1550            Err(source) => Err(ProjectError::Io { path, source }),
1551        }
1552    }
1553
1554    fn update(&mut self, path: PathBuf, content: String, existing: String) {
1555        if content != existing {
1556            self.writes.push(PlannedWrite {
1557                path,
1558                content,
1559                expected: ExpectedFile::Exact(existing),
1560            });
1561        }
1562    }
1563
1564    fn replace_or_create(&mut self, path: PathBuf, content: String) -> Result<(), ProjectError> {
1565        match fs::read_to_string(&path) {
1566            Ok(existing) => {
1567                self.update(path, content, existing);
1568                Ok(())
1569            }
1570            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1571                self.create(path, content)
1572            }
1573            Err(source) => Err(ProjectError::Io { path, source }),
1574        }
1575    }
1576
1577    #[must_use]
1578    pub fn conflicts(&self) -> &[ModuleId] {
1579        &self.conflicts
1580    }
1581
1582    #[must_use]
1583    pub fn summary(&self) -> String {
1584        let mut lines = if self.writes.is_empty() {
1585            vec!["No filesystem changes required.".to_owned()]
1586        } else {
1587            vec![format!("{} file change(s):", self.writes.len())]
1588        };
1589        lines.extend(self.writes.iter().map(|write| {
1590            format!(
1591                "- {} {}",
1592                match &write.expected {
1593                    ExpectedFile::Missing => "create",
1594                    ExpectedFile::Exact(_) => "update",
1595                },
1596                write
1597                    .path
1598                    .strip_prefix(&self.root)
1599                    .unwrap_or(&write.path)
1600                    .display()
1601            )
1602        }));
1603        if !self.conflicts.is_empty() {
1604            lines.push(format!(
1605                "{} merge conflict(s): {}",
1606                self.conflicts.len(),
1607                self.conflicts
1608                    .iter()
1609                    .map(ToString::to_string)
1610                    .collect::<Vec<_>>()
1611                    .join(", ")
1612            ));
1613        }
1614        lines.join("\n")
1615    }
1616
1617    /// Verify all expectations and atomically replace each planned file.
1618    ///
1619    /// # Errors
1620    ///
1621    /// Returns [`ProjectError::ConcurrentChange`] or an I/O error. All
1622    /// expectations are checked before the first write.
1623    #[allow(clippy::too_many_lines)] // Linear prepare/commit/rollback protocol is easier to audit in order.
1624    pub fn apply(self) -> Result<(), ProjectError> {
1625        let _lock = ProjectApplyLock::acquire(&self.root)?;
1626        for write in &self.writes {
1627            match (&write.expected, fs::read_to_string(&write.path)) {
1628                (ExpectedFile::Missing, Err(error))
1629                    if error.kind() == std::io::ErrorKind::NotFound => {}
1630                (ExpectedFile::Exact(expected), Ok(actual)) if expected == &actual => {}
1631                (_, result) => {
1632                    return Err(ProjectError::ConcurrentChange {
1633                        path: write.path.clone(),
1634                        detail: result.map_or_else(
1635                            |error| error.to_string(),
1636                            |_| "content changed".to_owned(),
1637                        ),
1638                    });
1639                }
1640            }
1641        }
1642        let nonce = std::time::SystemTime::now()
1643            .duration_since(std::time::UNIX_EPOCH)
1644            .unwrap_or_default()
1645            .as_nanos();
1646        let mut staged = Vec::with_capacity(self.writes.len());
1647        for (index, write) in self.writes.iter().enumerate() {
1648            let parent = write
1649                .path
1650                .parent()
1651                .ok_or_else(|| ProjectError::InvalidPath(write.path.clone()))?;
1652            fs::create_dir_all(parent).map_err(|source| ProjectError::Io {
1653                path: parent.to_path_buf(),
1654                source,
1655            })?;
1656            let temporary = parent.join(format!(
1657                ".gpui-rhai-tmp-{}-{nonce}-{index}",
1658                std::process::id()
1659            ));
1660            let mut file = match OpenOptions::new()
1661                .write(true)
1662                .create_new(true)
1663                .open(&temporary)
1664            {
1665                Ok(file) => file,
1666                Err(source) => {
1667                    cleanup_staged_files(&staged);
1668                    return Err(ProjectError::Io {
1669                        path: temporary,
1670                        source,
1671                    });
1672                }
1673            };
1674            if let Err(source) = file.write_all(write.content.as_bytes()) {
1675                let _ = fs::remove_file(&temporary);
1676                cleanup_staged_files(&staged);
1677                return Err(ProjectError::Io {
1678                    path: temporary,
1679                    source,
1680                });
1681            }
1682            staged.push(StagedProjectWrite {
1683                write,
1684                temporary,
1685                backup: matches!(write.expected, ExpectedFile::Exact(_)).then(|| {
1686                    parent.join(format!(
1687                        ".gpui-rhai-backup-{}-{nonce}-{index}",
1688                        std::process::id()
1689                    ))
1690                }),
1691                committed: false,
1692            });
1693        }
1694
1695        // Close the planning-to-commit race as far as a cooperative CLI can
1696        // without claiming process-crash atomicity.
1697        for staged_write in &staged {
1698            if !expectation_matches(staged_write.write) {
1699                cleanup_staged_files(&staged);
1700                return Err(ProjectError::ConcurrentChange {
1701                    path: staged_write.write.path.clone(),
1702                    detail: "content changed while staging the update".to_owned(),
1703                });
1704            }
1705        }
1706
1707        for index in 0..staged.len() {
1708            let commit = (|| -> Result<(), std::io::Error> {
1709                if let Some(backup) = &staged[index].backup {
1710                    fs::rename(&staged[index].write.path, backup)?;
1711                }
1712                fs::rename(&staged[index].temporary, &staged[index].write.path)?;
1713                staged[index].committed = true;
1714                Ok(())
1715            })();
1716            if let Err(source) = commit {
1717                let rollback = rollback_staged_files(&mut staged, index);
1718                if let Err(rollback) = rollback {
1719                    return Err(ProjectError::ApplyRollback {
1720                        path: staged[index].write.path.clone(),
1721                        source,
1722                        rollback,
1723                    });
1724                }
1725                return Err(ProjectError::Io {
1726                    path: staged[index].write.path.clone(),
1727                    source,
1728                });
1729            }
1730        }
1731        for staged_write in &staged {
1732            if let Some(backup) = &staged_write.backup {
1733                let _ = fs::remove_file(backup);
1734            }
1735        }
1736        Ok(())
1737    }
1738}
1739
1740fn expectation_matches(write: &PlannedWrite) -> bool {
1741    match (&write.expected, fs::read_to_string(&write.path)) {
1742        (ExpectedFile::Missing, Err(error)) if error.kind() == std::io::ErrorKind::NotFound => true,
1743        (ExpectedFile::Exact(expected), Ok(actual)) => expected == &actual,
1744        _ => false,
1745    }
1746}
1747
1748fn cleanup_staged_files(staged: &[StagedProjectWrite<'_>]) {
1749    for staged in staged {
1750        let _ = fs::remove_file(&staged.temporary);
1751    }
1752}
1753
1754fn rollback_staged_files(
1755    staged: &mut [StagedProjectWrite<'_>],
1756    failed: usize,
1757) -> Result<(), String> {
1758    let mut errors = Vec::new();
1759    for index in (0..=failed).rev() {
1760        let item = &mut staged[index];
1761        if item.committed
1762            && let Err(error) = fs::remove_file(&item.write.path)
1763        {
1764            errors.push(format!("remove {}: {error}", item.write.path.display()));
1765        }
1766        if let Some(backup) = &item.backup
1767            && backup.exists()
1768            && let Err(error) = fs::rename(backup, &item.write.path)
1769        {
1770            errors.push(format!(
1771                "restore {} from {}: {error}",
1772                item.write.path.display(),
1773                backup.display()
1774            ));
1775        }
1776        if item.temporary.exists()
1777            && let Err(error) = fs::remove_file(&item.temporary)
1778        {
1779            errors.push(format!("remove {}: {error}", item.temporary.display()));
1780        }
1781    }
1782    for item in staged.iter().skip(failed + 1) {
1783        if let Err(error) = fs::remove_file(&item.temporary) {
1784            errors.push(format!("remove {}: {error}", item.temporary.display()));
1785        }
1786    }
1787    if errors.is_empty() {
1788        Ok(())
1789    } else {
1790        Err(errors.join("; "))
1791    }
1792}
1793
1794#[derive(Clone, Debug, Serialize, Deserialize)]
1795struct LocalManifest {
1796    runtime_api: u32,
1797    #[serde(default)]
1798    components: BTreeMap<String, InstalledComponent>,
1799}
1800
1801impl Default for LocalManifest {
1802    fn default() -> Self {
1803        Self {
1804            runtime_api: RUNTIME_API_VERSION,
1805            components: BTreeMap::new(),
1806        }
1807    }
1808}
1809
1810#[derive(Clone, Debug, Serialize, Deserialize)]
1811struct InstalledComponent {
1812    version: Version,
1813    content_hash: String,
1814    #[serde(default)]
1815    dependencies: Vec<String>,
1816}
1817
1818#[derive(Clone, Debug)]
1819pub struct CheckReport {
1820    pub components: usize,
1821    pub entry: ModuleId,
1822}
1823
1824impl CheckReport {
1825    #[must_use]
1826    pub fn summary(&self) -> String {
1827        format!(
1828            "Check passed: entry `{}`, {} installed component(s).",
1829            self.entry, self.components
1830        )
1831    }
1832}
1833
1834#[derive(Debug, Error)]
1835pub enum ProjectError {
1836    #[error("I/O failed for `{path}`: {source}")]
1837    Io {
1838        path: PathBuf,
1839        #[source]
1840        source: std::io::Error,
1841    },
1842    #[error("I/O failed for `{path}`: {source}; rollback was incomplete: {rollback}")]
1843    ApplyRollback {
1844        path: PathBuf,
1845        #[source]
1846        source: std::io::Error,
1847        rollback: String,
1848    },
1849    #[error("invalid Cargo.toml `{path}`: {source}")]
1850    CargoToml {
1851        path: PathBuf,
1852        source: toml_edit::TomlError,
1853    },
1854    #[error("Cargo.toml `{0}` has no gpui-rhai dependency to configure")]
1855    MissingRuntimeDependency(PathBuf),
1856    #[error("Cargo.toml `{0}` uses an unsupported gpui-rhai dependency shape")]
1857    InvalidRuntimeDependency(PathBuf),
1858    #[error("Cargo.toml `{0}` has a non-array gpui-rhai feature list")]
1859    InvalidRuntimeFeatures(PathBuf),
1860    #[error("incompatible GPUI dependency family: {0}")]
1861    GpuiDependency(String),
1862    #[error("initialization would overwrite existing file `{0}`")]
1863    WouldOverwrite(PathBuf),
1864    #[error("file `{path}` changed after planning: {detail}")]
1865    ConcurrentChange { path: PathBuf, detail: String },
1866    #[error("path `{0}` has no parent directory")]
1867    InvalidPath(PathBuf),
1868    #[error("component registry contains duplicate `{0}`")]
1869    DuplicateRegistry(ModuleId),
1870    #[error("component catalog id `{catalog}` differs from source metadata `{metadata}`")]
1871    RegistryIdMismatch { catalog: String, metadata: ModuleId },
1872    #[error(
1873        "component `{component}` asset metadata differs from bundled assets: declared {declared:?}, bundled {bundled:?}"
1874    )]
1875    RegistryAssetMismatch {
1876        component: ModuleId,
1877        declared: BTreeSet<String>,
1878        bundled: BTreeSet<String>,
1879    },
1880    #[error("component `{0}` is not in the bundled registry")]
1881    UnknownComponent(ModuleId),
1882    #[error("component registry dependency cycle: {0:?}")]
1883    RegistryCycle(Vec<ModuleId>),
1884    #[error("at least one component name is required")]
1885    NoComponents,
1886    #[error("component `{0}` is incompatible with this runtime API")]
1887    IncompatibleRuntime(ModuleId),
1888    #[error("module `{0}` is not an official component path")]
1889    InvalidComponentPath(ModuleId),
1890    #[error("baseline hash for `{0}` does not match the install manifest")]
1891    BaselineHash(ModuleId),
1892    #[error("asset `{0}` has source without a baseline or a baseline without source")]
1893    IncompleteAssetBaseline(String),
1894    #[error("installed metadata for `{0}` does not match its manifest")]
1895    InstalledMetadata(ModuleId),
1896    #[error("component `{0}` did not call define_component")]
1897    MissingExport(ModuleId),
1898    #[error("component `{component}` source documentation is missing {missing}")]
1899    ComponentDocumentation {
1900        component: ModuleId,
1901        missing: &'static str,
1902    },
1903    #[error("application entry must declare `view(ctx)`")]
1904    MissingView,
1905    #[error("Rhai known-call validation failed:\n{0}")]
1906    KnownCalls(String),
1907    #[error("manifest requires runtime API {required}, current API is {actual}")]
1908    ManifestRuntime { required: u32, actual: u32 },
1909    #[error("generated host requires manifest entry `main`, got `{0}`")]
1910    ManifestEntry(ModuleId),
1911    #[error("embedded asset `{0}` has an unsupported image extension")]
1912    UnsupportedAsset(String),
1913    #[error("font `{0}` has an unsupported extension; expected ttf, otf, or ttc")]
1914    UnsupportedFont(String),
1915    #[error("font validation failed: {0}")]
1916    Font(String),
1917    #[error("asset validation failed: {0}")]
1918    Asset(String),
1919    #[error("embedded assets resolve to duplicate logical ID `app/{0}`")]
1920    DuplicateAssetLogical(String),
1921    #[error("theme validation failed: {0}")]
1922    Theme(String),
1923    #[error("component stylesheet validation failed: {0}")]
1924    ComponentStyle(String),
1925    #[error("locale validation failed: {0}")]
1926    Locale(String),
1927    #[error("component update conflicts require inspection: {0:?}")]
1928    UpdateConflictsWritten(Vec<ModuleId>),
1929    #[error("failed to start Cargo: {0}")]
1930    Spawn(std::io::Error),
1931    #[error("Cargo application exited unsuccessfully with code {0:?}")]
1932    DevFailed(Option<i32>),
1933    #[error(transparent)]
1934    ModuleId(#[from] gpui_rhai::ModuleIdError),
1935    #[error(transparent)]
1936    Header(#[from] gpui_rhai::ComponentHeaderError),
1937    #[error(transparent)]
1938    Component(#[from] gpui_rhai::ComponentError),
1939    #[error(transparent)]
1940    ComponentExport(#[from] gpui_rhai::ComponentExportError),
1941    #[error(transparent)]
1942    Capability(#[from] gpui_rhai::CapabilityError),
1943    #[error(transparent)]
1944    Runtime(#[from] gpui_rhai::RuntimeError),
1945    #[error(transparent)]
1946    KnownCallLint(#[from] gpui_rhai::KnownCallLintError),
1947    #[error(transparent)]
1948    Lifecycle(#[from] gpui_rhai::LifecycleError),
1949    #[error(transparent)]
1950    Source(#[from] gpui_rhai::ScriptSourceError),
1951    #[error(transparent)]
1952    TomlSerialize(#[from] toml::ser::Error),
1953    #[error(transparent)]
1954    TomlDeserialize(#[from] toml::de::Error),
1955    #[error(transparent)]
1956    JsonSerialize(#[from] serde_json::Error),
1957    #[error("Theme Studio failed: {0}")]
1958    ThemeStudio(String),
1959}
1960
1961#[cfg(test)]
1962mod tests {
1963    use super::*;
1964
1965    fn fixture() -> tempfile::TempDir {
1966        let directory = tempfile::tempdir().unwrap();
1967        fs::create_dir_all(directory.path().join("src")).unwrap();
1968        fs::write(
1969            directory.path().join("Cargo.toml"),
1970            "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n",
1971        )
1972        .unwrap();
1973        directory
1974    }
1975
1976    #[test]
1977    fn bundled_component_icons_share_an_explicit_24px_coordinate_space() {
1978        for (name, source) in [
1979            ("check", CHECK_SVG),
1980            ("close", CLOSE_SVG),
1981            ("chevron_left", CHEVRON_LEFT_SVG),
1982            ("chevron_right", CHEVRON_RIGHT_SVG),
1983            ("calendar", CALENDAR_SVG),
1984            ("date_previous", DATE_PREVIOUS_SVG),
1985            ("date_next", DATE_NEXT_SVG),
1986            ("disclosure_down", DISCLOSURE_DOWN_SVG),
1987            ("sort_ascending", SORT_ASCENDING_SVG),
1988            ("sort_descending", SORT_DESCENDING_SVG),
1989            ("chevron_down", CHEVRON_DOWN_SVG),
1990            ("chevron_up", CHEVRON_UP_SVG),
1991            ("minus", MINUS_SVG),
1992            ("plus", PLUS_SVG),
1993            ("search", SEARCH_SVG),
1994            ("info", INFO_SVG),
1995            ("warning", WARNING_SVG),
1996            ("help", HELP_SVG),
1997        ] {
1998            assert!(source.contains("width=\"24\""), "{name} has no 24px width");
1999            assert!(
2000                source.contains("height=\"24\""),
2001                "{name} has no 24px height"
2002            );
2003            assert!(
2004                source.contains("viewBox=\"0 0 24 24\""),
2005                "{name} has a non-standard viewBox"
2006            );
2007        }
2008    }
2009
2010    #[test]
2011    fn dry_run_plan_has_zero_writes_until_applied() {
2012        let directory = fixture();
2013        let project = Project::new(directory.path());
2014        let plan = project.plan_init().unwrap();
2015        assert!(!plan.writes.is_empty());
2016        assert!(!directory.path().join("ui/main.rhai").exists());
2017        drop(plan);
2018        assert!(!directory.path().join("ui/main.rhai").exists());
2019    }
2020
2021    #[test]
2022    fn init_add_and_check_form_a_reproducible_flow() {
2023        let directory = fixture();
2024        let project = Project::new(directory.path());
2025        project.plan_init().unwrap().apply().unwrap();
2026        project
2027            .plan_add(
2028                &BundledRegistry::load().unwrap(),
2029                &["button".to_owned(), "label".to_owned()],
2030            )
2031            .unwrap()
2032            .apply()
2033            .unwrap();
2034        let report = project.check().unwrap();
2035        assert_eq!(report.components, 2);
2036        assert!(
2037            directory
2038                .path()
2039                .join(".gpui-rhai/baselines/components/button.rhai")
2040                .exists()
2041        );
2042    }
2043
2044    #[test]
2045    fn check_rejects_old_or_misaligned_gpui_package_identity() {
2046        let directory = fixture();
2047        let cargo = directory.path().join("Cargo.toml");
2048        fs::write(
2049            &cargo,
2050            "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\ngpui = \"=0.2.2\"\n",
2051        )
2052        .unwrap();
2053        assert!(matches!(
2054            validate_gpui_dependency_family(&cargo),
2055            Err(ProjectError::GpuiDependency(_))
2056        ));
2057
2058        fs::write(
2059            &cargo,
2060            "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\ngpui = { package = \"gpui-pre\", version = \"=0.3.7\", default-features = false }\ngpui_platform = { package = \"gpui-pre-platform\", version = \"=0.3.7\", default-features = false }\n",
2061        )
2062        .unwrap();
2063        validate_gpui_dependency_family(&cargo).unwrap();
2064
2065        fs::write(
2066            &cargo,
2067            "[workspace]\nmembers = []\n\n[workspace.dependencies]\ngpui = { package = \"gpui-pre\", version = \"=0.3.7\", default-features = false }\ngpui_platform = { package = \"gpui-pre-platform\", version = \"=0.3.7\", default-features = false }\n\n[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\ngpui.workspace = true\ngpui_platform.workspace = true\n",
2068        )
2069        .unwrap();
2070        validate_gpui_dependency_family(&cargo).unwrap();
2071    }
2072
2073    #[test]
2074    fn check_validates_the_typed_component_stylesheet() {
2075        let directory = fixture();
2076        let project = Project::new(directory.path());
2077        project.plan_init().unwrap().apply().unwrap();
2078        project
2079            .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2080            .unwrap()
2081            .apply()
2082            .unwrap();
2083        fs::write(
2084            directory.path().join("ui/main.rhai"),
2085            r#"import "components/button" as button;
2086            fn view(ctx) { button::Button(#{ text: "Styled" }) }
2087            "#,
2088        )
2089        .unwrap();
2090        let styles = directory.path().join("ui/styles.rhai");
2091        fs::write(
2092            &styles,
2093            r#"fn component_styles() {
2094                #{ "components/button": #{ root: style().height(px(34)) } }
2095            }
2096            "#,
2097        )
2098        .unwrap();
2099        project.check().unwrap();
2100
2101        fs::write(
2102            styles,
2103            r#"fn component_styles() {
2104                #{ "components/button": #{ missing: style() } }
2105            }
2106            "#,
2107        )
2108        .unwrap();
2109        let error = project.check().unwrap_err();
2110        assert!(matches!(error, ProjectError::ComponentStyle(_)));
2111        assert!(error.to_string().contains("unknown part"));
2112    }
2113
2114    #[test]
2115    fn check_executes_the_headless_initial_view() {
2116        let directory = fixture();
2117        let project = Project::new(directory.path());
2118        project.plan_init().unwrap().apply().unwrap();
2119        fs::write(
2120            directory.path().join("ui/main.rhai"),
2121            "fn view(ctx) { throw \"headless render failed\"; }\n",
2122        )
2123        .unwrap();
2124        assert!(matches!(
2125            project.check(),
2126            Err(ProjectError::Lifecycle(_) | ProjectError::Runtime(_))
2127        ));
2128    }
2129
2130    #[test]
2131    fn check_accepts_nested_templates_and_imports_after_them() {
2132        let directory = fixture();
2133        let project = Project::new(directory.path());
2134        project.plan_init().unwrap().apply().unwrap();
2135        project
2136            .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2137            .unwrap()
2138            .apply()
2139            .unwrap();
2140        fs::write(
2141            directory.path().join("ui/main.rhai"),
2142            r#"
2143                fn nested_label() {
2144                    `外层${if true { "中文" } else { `内层${1}` }}结束`
2145                }
2146                import "components/button" as button;
2147                fn view(ctx) {
2148                    column([
2149                        text(nested_label()),
2150                        button::Button(#{ text: "Ready" }),
2151                    ])
2152                }
2153            "#,
2154        )
2155        .unwrap();
2156
2157        let report = project.check().unwrap();
2158        assert_eq!(report.components, 1);
2159    }
2160
2161    #[test]
2162    fn check_lints_known_calls_in_unexecuted_branches() {
2163        let directory = fixture();
2164        let project = Project::new(directory.path());
2165        project.plan_init().unwrap().apply().unwrap();
2166        fs::write(
2167            directory.path().join("ui/main.rhai"),
2168            r#"
2169                fn view(ctx) {
2170                    if false { text(); }
2171                    text("still renders")
2172                }
2173            "#,
2174        )
2175        .unwrap();
2176
2177        let error = project.check().unwrap_err();
2178        assert!(matches!(error, ProjectError::KnownCalls(_)));
2179        assert!(error.to_string().contains("text/0"));
2180        assert!(error.to_string().contains("expected 1"));
2181    }
2182
2183    #[test]
2184    fn init_never_overwrites_existing_main() {
2185        let directory = fixture();
2186        fs::write(directory.path().join("src/main.rs"), "fn main() {}\n").unwrap();
2187        let project = Project::new(directory.path());
2188        project.plan_init().unwrap().apply().unwrap();
2189        assert_eq!(
2190            read(&directory.path().join("src/main.rs")).unwrap(),
2191            "fn main() {}\n"
2192        );
2193        assert!(directory.path().join("gpui-rhai-host-snippet.rs").exists());
2194    }
2195
2196    #[test]
2197    fn apply_detects_changes_after_planning() {
2198        let directory = fixture();
2199        let project = Project::new(directory.path());
2200        let plan = project.plan_init().unwrap();
2201        fs::write(
2202            directory.path().join("Cargo.toml"),
2203            "[package]\nname = \"changed\"\nversion = \"0.1.0\"\n",
2204        )
2205        .unwrap();
2206        assert!(matches!(
2207            plan.apply(),
2208            Err(ProjectError::ConcurrentChange { .. })
2209        ));
2210    }
2211
2212    #[test]
2213    fn apply_stages_every_write_before_replacing_any_target() {
2214        let directory = fixture();
2215        let original = read(&directory.path().join("Cargo.toml")).unwrap();
2216        let blocked = directory.path().join("blocked");
2217        fs::write(&blocked, "not a directory").unwrap();
2218        let mut plan = ProjectPlan::new(directory.path().to_path_buf());
2219        plan.update(
2220            directory.path().join("Cargo.toml"),
2221            format!("{original}\n# planned\n"),
2222            original.clone(),
2223        );
2224        plan.writes.push(PlannedWrite {
2225            path: blocked.join("child.rhai"),
2226            content: "fn view() {}".to_owned(),
2227            expected: ExpectedFile::Missing,
2228        });
2229        assert!(plan.apply().is_err());
2230        assert_eq!(
2231            read(&directory.path().join("Cargo.toml")).unwrap(),
2232            original
2233        );
2234    }
2235
2236    #[test]
2237    fn embed_plan_contains_entry_components_theme_locale_and_assets() {
2238        let directory = fixture();
2239        let project = Project::new(directory.path());
2240        project.plan_init().unwrap().apply().unwrap();
2241        project
2242            .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2243            .unwrap()
2244            .apply()
2245            .unwrap();
2246        fs::create_dir_all(directory.path().join("ui/locales")).unwrap();
2247        fs::create_dir_all(directory.path().join("ui/assets")).unwrap();
2248        fs::create_dir_all(directory.path().join("ui/fonts")).unwrap();
2249        fs::write(directory.path().join("ui/locales/en.rhai"), "#{}\n").unwrap();
2250        fs::write(directory.path().join("ui/assets/check.svg"), "<svg/>\n").unwrap();
2251        fs::write(
2252            directory.path().join("ui/fonts/art.otf"),
2253            [b"OTTO".as_slice(), &[0, 1, 2]].concat(),
2254        )
2255        .unwrap();
2256
2257        project.plan_embed().unwrap().apply().unwrap();
2258        let generated = read(&directory.path().join("src/gpui_rhai_embedded.rs")).unwrap();
2259        assert!(generated.contains("pub fn app_manifest()"));
2260        assert!(generated.contains("AppManifest::new"));
2261        assert!(generated.contains("pub fn asset_sources()"));
2262        assert!(generated.contains("pub fn font_sources()"));
2263        assert!(generated.contains("image/svg+xml"));
2264        assert!(generated.contains("components/button"));
2265        assert!(generated.contains("THEME_SOURCE"));
2266        assert!(generated.contains("COMPONENT_STYLES_SOURCE"));
2267        assert!(generated.contains("locales/en.rhai"));
2268        assert!(generated.contains("assets/check.svg"));
2269        assert!(generated.contains("fonts/art.otf"));
2270    }
2271
2272    #[test]
2273    fn adding_icon_installs_core_svg_asset_pack() {
2274        let directory = fixture();
2275        let project = Project::new(directory.path());
2276        project.plan_init().unwrap().apply().unwrap();
2277        project
2278            .plan_add(&BundledRegistry::load().unwrap(), &["icon".to_owned()])
2279            .unwrap()
2280            .apply()
2281            .unwrap();
2282        project.check().unwrap();
2283        assert!(directory.path().join("ui/assets/icons/check.svg").exists());
2284        assert!(directory.path().join("ui/assets/icons/close.svg").exists());
2285        assert!(directory.path().join("ui/assets/icons/search.svg").exists());
2286        assert!(
2287            directory
2288                .path()
2289                .join(".gpui-rhai/baselines/assets/icons/check.svg")
2290                .exists()
2291        );
2292    }
2293
2294    #[test]
2295    fn adding_chart_installs_source_and_enables_the_runtime_feature() {
2296        let directory = fixture();
2297        let project = Project::new(directory.path());
2298        project.plan_init().unwrap().apply().unwrap();
2299        project
2300            .plan_add(&BundledRegistry::load().unwrap(), &["bar_chart".to_owned()])
2301            .unwrap()
2302            .apply()
2303            .unwrap();
2304
2305        assert!(directory.path().join("ui/charts/chart.rhai").exists());
2306        assert!(directory.path().join("ui/charts/bar_chart.rhai").exists());
2307        let cargo = read(&directory.path().join("Cargo.toml")).unwrap();
2308        let cargo = cargo.parse::<DocumentMut>().unwrap();
2309        let features = cargo["dependencies"]["gpui-rhai"]["features"]
2310            .as_array()
2311            .unwrap();
2312        assert!(
2313            features
2314                .iter()
2315                .any(|value| value.as_str() == Some("charts"))
2316        );
2317        project.check().unwrap();
2318    }
2319
2320    #[test]
2321    fn complex_components_install_transitive_sources_and_assets() {
2322        let directory = fixture();
2323        let project = Project::new(directory.path());
2324        project.plan_init().unwrap().apply().unwrap();
2325        project
2326            .plan_add(
2327                &BundledRegistry::load().unwrap(),
2328                &[
2329                    "pagination".to_owned(),
2330                    "table".to_owned(),
2331                    "textarea".to_owned(),
2332                    "date_picker".to_owned(),
2333                ],
2334            )
2335            .unwrap()
2336            .apply()
2337            .unwrap();
2338        let report = project.check().unwrap();
2339        assert_eq!(report.components, 9);
2340        for component in [
2341            "button.rhai",
2342            "icon.rhai",
2343            "input.rhai",
2344            "combobox.rhai",
2345            "select.rhai",
2346            "pagination.rhai",
2347            "table.rhai",
2348            "textarea.rhai",
2349            "date_picker.rhai",
2350        ] {
2351            assert!(
2352                directory
2353                    .path()
2354                    .join("ui/components")
2355                    .join(component)
2356                    .exists()
2357            );
2358        }
2359        for asset in [
2360            "icons/check.svg",
2361            "icons/close.svg",
2362            "icons/search.svg",
2363            "icons/chevron_left.svg",
2364            "icons/chevron_right.svg",
2365            "icons/chevron_down.svg",
2366            "icons/chevron_up.svg",
2367            "icons/calendar.svg",
2368            "icons/date_previous.svg",
2369            "icons/date_next.svg",
2370            "icons/disclosure_down.svg",
2371            "icons/sort_ascending.svg",
2372            "icons/sort_descending.svg",
2373        ] {
2374            assert!(directory.path().join("ui/assets").join(asset).exists());
2375        }
2376    }
2377
2378    #[test]
2379    fn every_bundled_component_passes_known_call_validation() {
2380        let directory = fixture();
2381        let project = Project::new(directory.path());
2382        project.plan_init().unwrap().apply().unwrap();
2383        let registry = BundledRegistry::load().unwrap();
2384        assert_eq!(registry.entries.len(), 66);
2385        let requested = registry
2386            .entries
2387            .keys()
2388            .map(ToString::to_string)
2389            .collect::<Vec<_>>();
2390        project
2391            .plan_add(&registry, &requested)
2392            .unwrap()
2393            .apply()
2394            .unwrap();
2395
2396        let report = project.check().unwrap();
2397        assert_eq!(report.components, registry.entries.len());
2398    }
2399
2400    #[test]
2401    fn check_rejects_invalid_locales_and_duplicate_logical_assets() {
2402        let directory = fixture();
2403        let project = Project::new(directory.path());
2404        project.plan_init().unwrap().apply().unwrap();
2405        fs::write(
2406            directory.path().join("ui/locales/en.rhai"),
2407            "fn locale() { #{ locale: \"en\", direction: \"left_to_right\", messages: #{} } }",
2408        )
2409        .unwrap();
2410        assert!(matches!(project.check(), Err(ProjectError::Locale(_))));
2411
2412        fs::write(directory.path().join("ui/locales/en.rhai"), EN_LOCALE).unwrap();
2413        fs::create_dir_all(directory.path().join("ui/assets/icons")).unwrap();
2414        fs::write(directory.path().join("ui/assets/icons/check.svg"), "<svg/>").unwrap();
2415        fs::write(directory.path().join("ui/assets/icons/check.png"), [0u8]).unwrap();
2416        assert!(matches!(
2417            project.check(),
2418            Err(ProjectError::DuplicateAssetLogical(name)) if name == "icons/check"
2419        ));
2420    }
2421
2422    fn bumped_button_registry(upstream_purpose: &str) -> BundledRegistry {
2423        let mut registry = BundledRegistry::load().unwrap();
2424        let id = ModuleId::parse("components/button").unwrap();
2425        let entry = registry.entries.get_mut(&id).unwrap();
2426        let installed_version = entry.metadata.version.to_string();
2427        let source = entry
2428            .source
2429            .replace(&installed_version, "0.2.0")
2430            .replace("// Button presents a desktop action.", upstream_purpose);
2431        let source: &'static str = Box::leak(source.into_boxed_str());
2432        entry.metadata = parse_component_header(source).unwrap();
2433        entry.source = source;
2434        registry
2435    }
2436
2437    #[test]
2438    fn update_three_way_merges_non_overlapping_local_changes() {
2439        let directory = fixture();
2440        let project = Project::new(directory.path());
2441        project.plan_init().unwrap().apply().unwrap();
2442        project
2443            .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2444            .unwrap()
2445            .apply()
2446            .unwrap();
2447        let source_path = directory.path().join("ui/components/button.rhai");
2448        let local = format!(
2449            "{}\n// application-owned footer\n",
2450            read(&source_path).unwrap()
2451        );
2452        fs::write(&source_path, &local).unwrap();
2453        let registry = bumped_button_registry("// Button presents a refined desktop action.");
2454        let plan = project.plan_update(&registry).unwrap();
2455        assert!(plan.conflicts().is_empty());
2456        plan.apply().unwrap();
2457
2458        let merged = read(&source_path).unwrap();
2459        assert!(merged.contains("refined desktop action"));
2460        assert!(merged.contains("application-owned footer"));
2461        assert!(merged.contains("0.2.0"));
2462        assert_eq!(
2463            read(
2464                &directory
2465                    .path()
2466                    .join(".gpui-rhai/baselines/components/button.rhai")
2467            )
2468            .unwrap(),
2469            registry.entries[&ModuleId::parse("components/button").unwrap()].source
2470        );
2471    }
2472
2473    #[test]
2474    fn update_installs_new_transitive_dependencies_and_records_the_target_graph() {
2475        let directory = fixture();
2476        let project = Project::new(directory.path());
2477        project.plan_init().unwrap().apply().unwrap();
2478        project
2479            .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2480            .unwrap()
2481            .apply()
2482            .unwrap();
2483
2484        let mut registry = BundledRegistry::load().unwrap();
2485        let button = ModuleId::parse("components/button").unwrap();
2486        let entry = registry.entries.get_mut(&button).unwrap();
2487        let installed_version = entry.metadata.version.to_string();
2488        let source = entry
2489            .source
2490            .replace(&installed_version, "0.2.0")
2491            .replace("dependencies: []", "dependencies: [\"components/badge\"]")
2492            .replace(
2493                "\"dependencies\": []",
2494                "\"dependencies\": [\"components/badge\"]",
2495            );
2496        let source: &'static str = Box::leak(source.into_boxed_str());
2497        entry.metadata = parse_component_header(source).unwrap();
2498        entry.source = source;
2499
2500        project.plan_update(&registry).unwrap().apply().unwrap();
2501        assert!(directory.path().join("ui/components/badge.rhai").exists());
2502        let manifest: LocalManifest =
2503            toml::from_str(&read(&directory.path().join(".gpui-rhai/manifest.toml")).unwrap())
2504                .unwrap();
2505        assert!(manifest.components.contains_key("components/badge"));
2506        assert_eq!(
2507            manifest.components["components/button"].dependencies,
2508            vec!["components/badge"]
2509        );
2510    }
2511
2512    #[test]
2513    fn update_adds_missing_bundled_themes_without_overwriting_owned_sources() {
2514        let directory = fixture();
2515        let project = Project::new(directory.path());
2516        project.plan_init().unwrap().apply().unwrap();
2517        let owned = directory.path().join("ui/themes/nord.rhai");
2518        fs::write(&owned, "// application-owned Nord\n").unwrap();
2519        let missing = directory.path().join("ui/themes/ethereal.rhai");
2520        fs::remove_file(&missing).unwrap();
2521        let styles = directory.path().join("ui/styles.rhai");
2522        fs::remove_file(&styles).unwrap();
2523
2524        project
2525            .plan_update(&BundledRegistry::load().unwrap())
2526            .unwrap()
2527            .apply()
2528            .unwrap();
2529
2530        assert_eq!(read(&owned).unwrap(), "// application-owned Nord\n");
2531        assert_eq!(read(&missing).unwrap(), ETHEREAL_THEME);
2532        assert_eq!(read(&styles).unwrap(), DEFAULT_COMPONENT_STYLES);
2533    }
2534
2535    #[test]
2536    fn update_conflict_preserves_sources_and_writes_artifact() {
2537        let directory = fixture();
2538        let project = Project::new(directory.path());
2539        project.plan_init().unwrap().apply().unwrap();
2540        project
2541            .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2542            .unwrap()
2543            .apply()
2544            .unwrap();
2545        let source_path = directory.path().join("ui/components/button.rhai");
2546        let baseline_path = directory
2547            .path()
2548            .join(".gpui-rhai/baselines/components/button.rhai");
2549        let baseline = read(&baseline_path).unwrap();
2550        let local = baseline.replace(
2551            "// Button presents a desktop action.",
2552            "// Button presents the application's custom action.",
2553        );
2554        fs::write(&source_path, &local).unwrap();
2555        let registry = bumped_button_registry("// Button presents the registry action.");
2556        let plan = project.plan_update(&registry).unwrap();
2557        assert_eq!(
2558            plan.conflicts(),
2559            &[ModuleId::parse("components/button").unwrap()]
2560        );
2561        plan.apply().unwrap();
2562
2563        assert_eq!(read(&source_path).unwrap(), local);
2564        assert_eq!(read(&baseline_path).unwrap(), baseline);
2565        let artifact = read(
2566            &directory
2567                .path()
2568                .join(".gpui-rhai/conflicts/components/button.rhai"),
2569        )
2570        .unwrap();
2571        assert!(artifact.contains("<<<<<<< local"));
2572        assert!(artifact.contains("||||||| installed baseline"));
2573        assert!(artifact.contains(">>>>>>> bundled registry"));
2574    }
2575
2576    #[test]
2577    fn line_merge_combines_independent_insertions() {
2578        let merged = three_way_merge(
2579            "alpha\nbeta\n",
2580            "local\nalpha\nbeta\n",
2581            "alpha\nbeta\nupstream\n",
2582        )
2583        .unwrap();
2584        assert_eq!(merged, "local\nalpha\nbeta\nupstream\n");
2585    }
2586
2587    #[test]
2588    fn editor_metadata_comes_from_installed_component_schemas() {
2589        let directory = fixture();
2590        let project = Project::new(directory.path());
2591        project.plan_init().unwrap().apply().unwrap();
2592        project
2593            .plan_add(
2594                &BundledRegistry::load().unwrap(),
2595                &["combobox".to_owned(), "button".to_owned()],
2596            )
2597            .unwrap()
2598            .apply()
2599            .unwrap();
2600        let plan = project.plan_editor_metadata().unwrap();
2601        assert_eq!(plan.writes.len(), 3);
2602        plan.apply().unwrap();
2603
2604        let metadata: serde_json::Value = serde_json::from_str(
2605            &read(&directory.path().join(".gpui-rhai/editor/components.json")).unwrap(),
2606        )
2607        .unwrap();
2608        let components = metadata["components"].as_array().unwrap();
2609        let installed = components
2610            .iter()
2611            .map(|component| component["metadata"]["id"].as_str().unwrap())
2612            .collect::<BTreeSet<_>>();
2613        assert_eq!(
2614            installed,
2615            BTreeSet::from([
2616                "components/button",
2617                "components/combobox",
2618                "components/input",
2619            ])
2620        );
2621        assert!(components.iter().any(|component| {
2622            component["metadata"]["id"] == "components/combobox"
2623                && component["schema"]["parts"]
2624                    .as_array()
2625                    .is_some_and(|parts| parts.iter().any(|part| part == "option"))
2626        }));
2627        let snippets = read(&directory.path().join(".gpui-rhai/editor/snippets.json")).unwrap();
2628        assert!(snippets.contains("import \\\"components/combobox\\\" as combobox;"));
2629        assert!(snippets.contains("${1:key}"));
2630        assert!(snippets.contains("label: ${2:label}"));
2631        assert!(snippets.contains("options: ${4:options}"));
2632        let definitions = read(
2633            &directory
2634                .path()
2635                .join(".gpui-rhai/editor/definitions/gpui_rhai.d.rhai"),
2636        )
2637        .unwrap();
2638        assert!(definitions.contains("fn render_component"));
2639        assert!(definitions.contains("fn canvas_fill_path"));
2640    }
2641}