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