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