Skip to main content

gpui_rhai_cli/
lib.rs

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