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