Skip to main content

gpui_rhai_cli/
lib.rs

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