Skip to main content

gpuicn_cli/
lib.rs

1//! The `gpuicn` installer: copies editable gpuicn components into GPUI apps.
2
3use anyhow::{Context, Result, ensure};
4use clap::{Parser, Subcommand};
5use serde::{Deserialize, Serialize};
6use std::{
7    collections::{BTreeMap, BTreeSet},
8    fs::{self, OpenOptions},
9    io::{Read, Write},
10    path::{Path, PathBuf},
11    time::Duration,
12};
13use tempfile::NamedTempFile;
14
15mod maintain;
16mod theme;
17
18use maintain::{doctor, unused_components, update};
19use theme::{
20    AppearanceSetting, ThemeConfig, assets_dir, check_theme_css, write_config, write_theme_files,
21};
22
23/// Raw files from the release this CLI was built from, so the default registry,
24/// fonts, and Cargo pins always match the CLI version.
25const RELEASE: &str = concat!(
26    "https://raw.githubusercontent.com/devaryakjha/gpuicn/v",
27    env!("CARGO_PKG_VERSION")
28);
29const MAX_ITEM_BYTES: u64 = 4 * 1024 * 1024;
30const MAX_FONT_BYTES: u64 = 2 * 1024 * 1024;
31/// Cargo dependencies for this release; kept equal to the install fixture by tests.
32const DEPENDENCIES: &str = include_str!("../templates/dependencies.toml");
33/// The installation starter; kept equal to the install fixture by tests.
34const STARTER: &str = include_str!("../templates/main.rs");
35/// Component source inside the repository; the published `gpuicn` crate compiles it too.
36const REGISTRY_SOURCE: &str = "crates/ui/registry";
37/// `src/ui/mod.rs` for crate mode: every component comes from the `gpuicn` crate, and an
38/// ejected module declared below the re-export replaces the crate's copy.
39const CRATE_MOD: &str = "\
40//! gpuicn components, re-exported from the `gpuicn` crate.
41//! `gpuicn eject <component>` copies one here to edit; its `pub mod` replaces the crate's.
42pub use gpuicn::*;
43";
44const CARGO_NEW_MAIN: &str = "fn main() {\n    println!(\"Hello, world!\");\n}\n";
45/// Geist files and their SHA-256 from release-pins.toml.
46const FONTS: [(&str, &str); 4] = [
47    (
48        "Geist-Regular.ttf",
49        "85a1c6b18a6b0a06dfe9fd4f6d6a5d4979f74ec861eaef4bc7868b5492b8a117",
50    ),
51    (
52        "Geist-Medium.ttf",
53        "3a3b36f0d0b981f4857f7f00eeef4a5ee123605575d362ab31ee7c19e3d11f2f",
54    ),
55    (
56        "Geist-SemiBold.ttf",
57        "b9f52322d6d20a7ff1e7fe1310ff12ec851aebb83d24f8b4f1d4149298f489b2",
58    ),
59    (
60        "GeistMono-Regular.ttf",
61        "5a0de4b3d54ab272f76a1d8c84b7fb24c67bbec6591d5300e61c7bc10094b6c8",
62    ),
63];
64
65fn default_registry() -> String {
66    format!("{RELEASE}/site/pages/r")
67}
68
69#[derive(Parser)]
70#[command(
71    name = "gpuicn",
72    version,
73    about = "Install editable Rust UI components. No Node.js or runtime service required."
74)]
75struct Cli {
76    /// Configuration path; relative paths inside it resolve beside this file.
77    #[arg(long, global = true, default_value = "gpuicn.toml")]
78    config: PathBuf,
79    /// Override the registry directory (HTTPS URL or local path).
80    #[arg(long, global = true)]
81    registry: Option<String>,
82    #[command(subcommand)]
83    command: Command,
84}
85#[derive(Subcommand)]
86enum Command {
87    /// Set up an app: gpuicn.toml, Cargo dependencies, Geist fonts, and a starter
88    /// window when src/main.rs is still the `cargo new` default.
89    Init {
90        #[arg(long, default_value = "src/ui")]
91        output: PathBuf,
92        /// Only create gpuicn.toml; leave Cargo.toml, fonts, and src/main.rs alone.
93        #[arg(long)]
94        config_only: bool,
95        /// Depend on the `gpuicn` crate instead of copying source; `eject` copies a
96        /// component when you want to edit it.
97        #[arg(long = "crate")]
98        crate_mode: bool,
99        #[command(flatten)]
100        theme: ThemeArgs,
101        /// Also install every component.
102        #[arg(long)]
103        all: bool,
104    },
105    /// Create a new Cargo app and set it up like `init`, ready for `cargo run`.
106    New {
107        /// Directory for the new app, as for `cargo new`.
108        path: PathBuf,
109        #[arg(long, default_value = "src/ui")]
110        output: PathBuf,
111        /// Depend on the `gpuicn` crate instead of copying source.
112        #[arg(long = "crate")]
113        crate_mode: bool,
114        #[command(flatten)]
115        theme: ThemeArgs,
116        /// Also install every component.
117        #[arg(long)]
118        all: bool,
119    },
120    /// List the components available in the configured registry.
121    List,
122    /// Show which installed components differ from the registry.
123    Status,
124    /// Show what `add --overwrite` would change, as a unified diff.
125    Diff {
126        /// Components to compare; defaults to every installed component.
127        components: Vec<String>,
128    },
129    /// Delete components' own source files and their mod.rs declarations.
130    /// Files shared with other installed components are kept.
131    Remove {
132        components: Vec<String>,
133        /// Remove every installed component no file outside the output directory uses.
134        #[arg(long)]
135        unused: bool,
136        /// Also delete files you have edited.
137        #[arg(long)]
138        force: bool,
139        /// Report changes without deleting files.
140        #[arg(long)]
141        dry_run: bool,
142    },
143    /// Copy components and their shared source files. Existing edits are kept by default.
144    Add {
145        components: Vec<String>,
146        #[arg(long)]
147        all: bool,
148        /// Replace existing source files. Review your local changes first.
149        #[arg(long)]
150        overwrite: bool,
151        /// Report changes without writing files.
152        #[arg(long)]
153        dry_run: bool,
154    },
155    /// Copy components out of the `gpuicn` crate so you can edit them (crate mode).
156    /// `remove` switches a component back to the crate.
157    Eject {
158        components: Vec<String>,
159        /// Eject every component except the shared theme.
160        #[arg(long)]
161        all: bool,
162        /// Report changes without writing files.
163        #[arg(long)]
164        dry_run: bool,
165    },
166    /// Change the app's theme: a shadcn base color, radius and appearance, or imported CSS.
167    Theme {
168        #[command(subcommand)]
169        action: ThemeAction,
170    },
171    /// Move the app to a newer gpuicn release: gpuicn.toml, Cargo pins, fonts and theme files.
172    Update {
173        /// Release version, such as 0.9.0; the latest release by default.
174        #[arg(long)]
175        to: Option<String>,
176        /// Also replace installed component files with the release's.
177        #[arg(long)]
178        overwrite: bool,
179        /// Report changes without writing files.
180        #[arg(long)]
181        dry_run: bool,
182    },
183    /// Check the app against this CLI's release and print a fix for each finding.
184    Doctor,
185    /// Build an open, shadcn-compatible source registry from registry.toml and crates/ui/registry/.
186    Build {
187        #[arg(long, default_value = ".")]
188        source: PathBuf,
189        #[arg(long, default_value = "site/pages/r")]
190        output: PathBuf,
191    },
192}
193
194#[derive(clap::Args, Default)]
195struct ThemeArgs {
196    /// shadcn base color: neutral, stone, zinc, mauve, olive, mist or taupe.
197    #[arg(long)]
198    base_color: Option<String>,
199    /// Corner radius as CSS, such as 0.625rem.
200    #[arg(long)]
201    radius: Option<String>,
202    /// Open in light or dark mode, or follow the system.
203    #[arg(long, value_enum)]
204    appearance: Option<AppearanceSetting>,
205    /// A shadcn theme CSS file or URL to use instead of a base color.
206    #[arg(long, conflicts_with = "base_color")]
207    theme: Option<String>,
208}
209
210#[derive(Subcommand)]
211enum ThemeAction {
212    /// Use a shadcn theme CSS (a file or URL with `:root` and `.dark` blocks).
213    Import { source: String },
214    /// Set the base color, radius or appearance and regenerate the theme files.
215    Set {
216        /// shadcn base color: neutral, stone, zinc, mauve, olive, mist or taupe.
217        #[arg(long)]
218        base_color: Option<String>,
219        /// Corner radius as CSS, such as 0.625rem.
220        #[arg(long)]
221        radius: Option<String>,
222        /// Open in light or dark mode, or follow the system.
223        #[arg(long, value_enum)]
224        appearance: Option<AppearanceSetting>,
225    },
226    /// Print every theme token in light and dark mode.
227    Preview,
228}
229
230#[derive(Clone, Serialize, Deserialize)]
231#[serde(deny_unknown_fields)]
232struct Config {
233    version: u32,
234    registry: String,
235    output: PathBuf,
236    #[serde(default, skip_serializing_if = "Mode::is_source")]
237    mode: Mode,
238    #[serde(default)]
239    theme: ThemeConfig,
240}
241
242/// How an app gets components: copied source, or the `gpuicn` crate plus ejected copies.
243#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(rename_all = "lowercase")]
245enum Mode {
246    #[default]
247    Source,
248    Crate,
249}
250
251impl Mode {
252    fn is_source(&self) -> bool {
253        *self == Mode::Source
254    }
255}
256
257#[derive(Clone, Serialize, Deserialize)]
258struct SourceFile {
259    path: String,
260    #[serde(rename = "type")]
261    kind: String,
262    target: String,
263    #[serde(skip_serializing_if = "Option::is_none")]
264    content: Option<String>,
265}
266#[derive(Clone, Serialize, Deserialize)]
267struct Item {
268    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
269    schema: Option<String>,
270    name: String,
271    #[serde(rename = "type")]
272    kind: String,
273    title: String,
274    description: String,
275    files: Vec<SourceFile>,
276}
277#[derive(Serialize, Deserialize)]
278struct Registry {
279    #[serde(rename = "$schema")]
280    schema: String,
281    name: String,
282    homepage: String,
283    items: Vec<Item>,
284}
285#[derive(Deserialize)]
286#[serde(deny_unknown_fields)]
287struct Manifest {
288    name: String,
289    homepage: String,
290    #[serde(default)]
291    components: BTreeMap<String, Metadata>,
292}
293#[derive(Default, Deserialize)]
294#[serde(default, deny_unknown_fields)]
295struct Metadata {
296    title: Option<String>,
297    description: Option<String>,
298    dependencies: Vec<String>,
299}
300
301/// Runs the CLI with the process arguments and exits on error.
302pub fn main() {
303    // `cargo gpuicn …` runs `cargo-gpuicn gpuicn …`; drop the subcommand name Cargo repeats.
304    let mut args: Vec<_> = std::env::args_os().collect();
305    if args.get(1).is_some_and(|arg| arg == "gpuicn")
306        && Path::new(&args[0])
307            .file_stem()
308            .is_some_and(|stem| stem == "cargo-gpuicn")
309    {
310        args.remove(1);
311    }
312    if let Err(error) = run(Cli::parse_from(args)) {
313        eprintln!("error: {error:#}");
314        std::process::exit(1);
315    }
316}
317
318fn config_root(config: &Path) -> Result<PathBuf> {
319    Ok(config
320        .parent()
321        .filter(|path| !path.as_os_str().is_empty())
322        .unwrap_or(Path::new("."))
323        .canonicalize()?)
324}
325
326fn run(cli: Cli) -> Result<()> {
327    match cli.command {
328        Command::Init {
329            output,
330            config_only,
331            crate_mode,
332            theme,
333            all,
334        } => init(
335            &cli.config,
336            cli.registry,
337            InitOptions {
338                output,
339                config_only,
340                crate_mode,
341                theme,
342                all,
343            },
344        )?,
345        Command::New {
346            path,
347            output,
348            crate_mode,
349            theme,
350            all,
351        } => {
352            ensure!(
353                fs::symlink_metadata(&path).is_err(),
354                "{} already exists",
355                path.display()
356            );
357            let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
358            let status = std::process::Command::new(cargo)
359                .arg("new")
360                .arg(&path)
361                .status()
362                .context("run `cargo new`; is Cargo installed?")?;
363            ensure!(status.success(), "`cargo new` failed");
364            init(
365                &path.join("gpuicn.toml"),
366                cli.registry,
367                InitOptions {
368                    output,
369                    config_only: false,
370                    crate_mode,
371                    theme,
372                    all,
373                },
374            )?;
375            println!("Next: cd {} && cargo run", path.display());
376        }
377        Command::Build { source, output } => build(&source, &output)?,
378        command => {
379            let config = read_config(&cli.config, cli.registry)?;
380            let root = config_root(&cli.config)?;
381            let source = Source::new(&config.registry, &root)?;
382            let own = config.mode == Mode::Crate;
383            let output = root.join(&config.output);
384            match command {
385                Command::List => {
386                    for item in source.index()?.items {
387                        println!("{}\t{}", item.name, item.description);
388                    }
389                }
390                Command::Status => status(&source, &output, own)?,
391                Command::Diff { components } => diff(&source, &output, &components, own)?,
392                Command::Remove {
393                    mut components,
394                    unused,
395                    force,
396                    dry_run,
397                } => {
398                    ensure!(
399                        !unused || components.is_empty(),
400                        "use component names or --unused, not both"
401                    );
402                    if unused {
403                        let installed = installed_components(&source, &output)?;
404                        components = unused_components(&root, &output, &installed)?;
405                        if components.is_empty() {
406                            println!("Every installed component is used.");
407                            return Ok(());
408                        }
409                        println!("Unused: {}", components.join(", "));
410                    }
411                    remove(&source, &output, &components, force, dry_run, own)?
412                }
413                Command::Theme { action } => match action {
414                    ThemeAction::Import { source } => {
415                        theme::import(&root, &cli.config, config, &source)?
416                    }
417                    ThemeAction::Set {
418                        base_color,
419                        radius,
420                        appearance,
421                    } => theme::set(&root, &cli.config, config, base_color, radius, appearance)?,
422                    ThemeAction::Preview => theme::preview(&root, &config)?,
423                },
424                Command::Update {
425                    to,
426                    overwrite,
427                    dry_run,
428                } => update(&root, &cli.config, config, to, overwrite, dry_run)?,
429                Command::Doctor => doctor(&root, &cli.config, &config)?,
430                Command::Eject {
431                    mut components,
432                    all,
433                    dry_run,
434                } => {
435                    ensure!(
436                        !all || components.is_empty(),
437                        "use component names or --all, not both"
438                    );
439                    if all {
440                        components = source
441                            .index()?
442                            .items
443                            .into_iter()
444                            .map(|item| item.name)
445                            .filter(|name| name != "theme")
446                            .collect();
447                    }
448                    ensure!(
449                        own,
450                        "eject is for crate mode (`gpuicn init --crate`); in source mode installed components are already in {}",
451                        output.display()
452                    );
453                    eject(&source, &root, &output, &components, dry_run)?
454                }
455                Command::Add {
456                    mut components,
457                    all,
458                    overwrite,
459                    dry_run,
460                } => {
461                    ensure!(
462                        !own,
463                        "this app uses the gpuicn crate, so every component is already available as `ui::<component>`; `gpuicn eject <component>` copies one to edit"
464                    );
465                    ensure!(
466                        !all || components.is_empty(),
467                        "use component names or --all, not both"
468                    );
469                    if all {
470                        components = source
471                            .index()?
472                            .items
473                            .into_iter()
474                            .map(|item| item.name)
475                            .collect();
476                    }
477                    ensure!(!components.is_empty(), "name a component or use --all");
478                    let components: Vec<_> =
479                        components.iter().map(|name| normalize(name)).collect();
480                    let files = collect_files(&source, &components, false)?;
481                    let required = required_dependencies(&files);
482                    install(&output, files, overwrite, dry_run)?;
483                    if !dry_run {
484                        report_missing_dependencies(&root, &required)?;
485                    }
486                }
487                _ => unreachable!(),
488            }
489        }
490    }
491    Ok(())
492}
493
494struct InitOptions {
495    output: PathBuf,
496    config_only: bool,
497    crate_mode: bool,
498    theme: ThemeArgs,
499    all: bool,
500}
501
502fn init(path: &Path, registry: Option<String>, options: InitOptions) -> Result<()> {
503    let InitOptions {
504        output,
505        config_only,
506        crate_mode,
507        theme,
508        all,
509    } = options;
510    let chosen_theme = theme.base_color.is_some()
511        || theme.radius.is_some()
512        || theme.appearance.is_some()
513        || theme.theme.is_some();
514    if current_file(path)?.is_none() {
515        if let Some(name) = &theme.base_color {
516            theme::base_color_css(name)?;
517        }
518        let config = Config {
519            version: 2,
520            registry: registry.clone().unwrap_or_else(default_registry),
521            output,
522            mode: if crate_mode {
523                Mode::Crate
524            } else {
525                Mode::Source
526            },
527            theme: ThemeConfig {
528                base_color: if theme.theme.is_some() {
529                    None
530                } else {
531                    Some(theme.base_color.clone().unwrap_or_else(|| "neutral".into()))
532                },
533                radius: theme.radius.clone(),
534                appearance: theme.appearance.unwrap_or_default(),
535            },
536        };
537        let text = toml::to_string_pretty(&config)?;
538        OpenOptions::new()
539            .write(true)
540            .create_new(true)
541            .open(path)
542            .context("create config (an existing config is never overwritten)")?
543            .write_all(text.as_bytes())?;
544        println!("Write {}", path.display());
545    } else {
546        ensure!(
547            !config_only,
548            "{} already exists; an existing config is never overwritten",
549            path.display()
550        );
551        ensure!(
552            !chosen_theme,
553            "{} already exists; `gpuicn theme set` and `gpuicn theme import` change its theme",
554            path.display()
555        );
556        println!("Keep {}", path.display());
557    }
558    if config_only {
559        println!(
560            "Run `gpuicn add button` next. Add `mod ui;` to your crate root and start through `ui::app::run`, or call `ui::theme::init(cx)` at application startup."
561        );
562        return Ok(());
563    }
564    let config = read_config(path, registry)?;
565    ensure!(
566        !crate_mode || config.mode == Mode::Crate,
567        "{} already uses source mode; set `mode = \"crate\"` in it to switch",
568        path.display()
569    );
570    let root = config_root(path)?;
571    if let Some(source) = &theme.theme {
572        let css = theme::read_css(source, &root)?;
573        check_theme_css(&css)
574            .with_context(|| format!("{source} is not a complete shadcn theme"))?;
575        let css_path = assets_dir(&root, &config.output).join("theme.css");
576        write_new(&css_path, css.as_bytes())?;
577    }
578    setup(&root, &config)?;
579    if all && config.mode == Mode::Source {
580        let source = Source::new(&config.registry, &root)?;
581        let components: Vec<_> = source
582            .index()?
583            .items
584            .into_iter()
585            .map(|item| item.name)
586            .collect();
587        let files = collect_files(&source, &components, false)?;
588        install(&root.join(&config.output), files, false, false)?;
589    }
590    Ok(())
591}
592
593fn read_config(path: &Path, registry: Option<String>) -> Result<Config> {
594    let mut config: Config = toml::from_str(
595        &fs::read_to_string(path).context("read gpuicn.toml; run `gpuicn init` first")?,
596    )?;
597    ensure!(
598        matches!(config.version, 1 | 2),
599        "unsupported config version {}; this CLI reads versions 1 and 2",
600        config.version
601    );
602    if let Some(registry) = registry {
603        config.registry = registry;
604    }
605    Ok(config)
606}
607
608/// Accepts module-style and display-style names: `alert_dialog`, `Alert-Dialog`.
609fn normalize(name: &str) -> String {
610    name.trim().to_ascii_lowercase().replace('_', "-")
611}
612
613/// A registry item that does not exist, as opposed to a network or I/O failure.
614#[derive(Debug)]
615struct Missing;
616impl std::fmt::Display for Missing {
617    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
618        formatter.write_str("not found in the registry")
619    }
620}
621impl std::error::Error for Missing {}
622
623fn edit_distance(a: &str, b: &str) -> usize {
624    let b: Vec<char> = b.chars().collect();
625    let mut row: Vec<usize> = (0..=b.len()).collect();
626    for (i, ca) in a.chars().enumerate() {
627        let mut previous = row[0];
628        row[0] = i + 1;
629        for (j, cb) in b.iter().enumerate() {
630            let current = row[j + 1];
631            row[j + 1] = if ca == *cb {
632                previous
633            } else {
634                1 + previous.min(row[j]).min(current)
635            };
636            previous = current;
637        }
638    }
639    row[b.len()]
640}
641
642/// Names close to an unknown component, best first.
643fn suggestions<'a>(name: &str, available: impl Iterator<Item = &'a str>) -> Vec<&'a str> {
644    let mut close: Vec<_> = available
645        .filter_map(|candidate| {
646            let distance = edit_distance(name, candidate);
647            (distance <= 2.max(name.len() / 3) || (name.len() >= 3 && candidate.contains(name)))
648                .then_some((distance, candidate))
649        })
650        .collect();
651    close.sort();
652    close.into_iter().take(3).map(|(_, name)| name).collect()
653}
654
655fn unknown_component(source: &Source, name: &str) -> anyhow::Error {
656    let hint = match source.index() {
657        Ok(index) => {
658            let close = suggestions(name, index.items.iter().map(|item| item.name.as_str()));
659            if close.is_empty() {
660                String::new()
661            } else {
662                format!("; did you mean {}?", close.join(", "))
663            }
664        }
665        Err(_) => String::new(),
666    };
667    anyhow::anyhow!(
668        "unknown component {name:?}{hint}\nRun `gpuicn list` to see every component in {}",
669        source.location
670    )
671}
672
673struct Source {
674    location: String,
675    root: PathBuf,
676    client: Option<reqwest::blocking::Client>,
677}
678impl Source {
679    fn new(location: &str, root: &Path) -> Result<Self> {
680        let remote = location.starts_with("https://") || location.starts_with("http://");
681        ensure!(
682            remote || !location.contains("://"),
683            "registry must be HTTP(S) or a local directory"
684        );
685        Ok(Self {
686            location: location.trim_end_matches('/').to_owned(),
687            root: root.to_path_buf(),
688            client: if remote { Some(http_client()?) } else { None },
689        })
690    }
691    fn read(&self, name: &str) -> Result<Vec<u8>> {
692        ensure!(valid_name(name), "invalid component name {name:?}");
693        if let Some(client) = &self.client {
694            let url = format!("{}/{name}.json", self.location);
695            return download(client, &url, MAX_ITEM_BYTES).with_context(|| format!("fetch {url}"));
696        }
697        let path = self.root.join(&self.location).join(format!("{name}.json"));
698        let file = match fs::File::open(&path) {
699            Ok(file) => file,
700            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
701                return Err(anyhow::Error::new(Missing).context(path.display().to_string()));
702            }
703            Err(error) => return Err(error.into()),
704        };
705        read_limited(file, MAX_ITEM_BYTES)
706    }
707    fn index(&self) -> Result<Registry> {
708        serde_json::from_slice(&self.read("registry")?).context("parse registry index")
709    }
710}
711
712fn http_client() -> Result<reqwest::blocking::Client> {
713    Ok(reqwest::blocking::Client::builder()
714        .timeout(Duration::from_secs(30))
715        .redirect(reqwest::redirect::Policy::limited(5))
716        .build()?)
717}
718fn download(client: &reqwest::blocking::Client, url: &str, limit: u64) -> Result<Vec<u8>> {
719    let response = client
720        .get(url)
721        .send()
722        .map_err(reqwest::Error::without_url)?;
723    if response.status() == reqwest::StatusCode::NOT_FOUND {
724        return Err(Missing.into());
725    }
726    let response = response
727        .error_for_status()
728        .map_err(reqwest::Error::without_url)?;
729    read_limited(response, limit)
730}
731fn read_limited(reader: impl Read, limit: u64) -> Result<Vec<u8>> {
732    let mut bytes = Vec::new();
733    reader.take(limit + 1).read_to_end(&mut bytes)?;
734    ensure!(
735        bytes.len() as u64 <= limit,
736        "download exceeds {} MiB",
737        limit / 1024 / 1024
738    );
739    Ok(bytes)
740}
741
742fn valid_name(name: &str) -> bool {
743    name.as_bytes().first().is_some_and(u8::is_ascii_lowercase)
744        && name.bytes().all(|byte| {
745            byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-' || byte == b'_'
746        })
747}
748fn module_name(path: &str) -> Result<String> {
749    ensure!(
750        !path.contains('\\')
751            && path
752                .split('/')
753                .all(|part| !part.is_empty() && part != "." && part != ".."),
754        "unsafe source path {path:?}"
755    );
756    let name = path
757        .rsplit('/')
758        .next()
759        .and_then(|part| part.strip_suffix(".rs"))
760        .context("only Rust source files can be installed")?;
761    ensure!(
762        name != "mod"
763            && name
764                .bytes()
765                .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
766            && syn::parse_str::<syn::Ident>(name).is_ok(),
767        "invalid Rust module name {name:?}"
768    );
769    Ok(name.to_owned())
770}
771
772/// Source modules for `components`. With `own`, only each component's own modules
773/// (its root and `#[path]` children), as ejected into a crate-mode app.
774fn collect_files(
775    source: &Source,
776    components: &[String],
777    own: bool,
778) -> Result<BTreeMap<String, String>> {
779    let mut files = BTreeMap::new();
780    for name in components.iter().collect::<BTreeSet<_>>() {
781        let bytes = match source.read(name) {
782            Ok(bytes) => bytes,
783            Err(error) if error.downcast_ref::<Missing>().is_some() => {
784                return Err(unknown_component(source, name));
785            }
786            Err(error) => return Err(error.context(format!("load component {name}"))),
787        };
788        let item: Item = serde_json::from_slice(&bytes)?;
789        ensure!(
790            &item.name == name,
791            "requested {name}, registry returned {}",
792            item.name
793        );
794        ensure!(
795            !item.files.is_empty() && item.files.len() <= 256,
796            "invalid source-file count for {name}"
797        );
798        let mut item_files = BTreeMap::new();
799        for file in item.files {
800            ensure!(
801                file.kind == "registry:file",
802                "unsupported file kind {}",
803                file.kind
804            );
805            let module = module_name(&file.path)?;
806            let content = file
807                .content
808                .context("registry source has no inline content")?;
809            syn::parse_file(&content).with_context(|| format!("parse source module {module}"))?;
810            item_files.insert(module, content);
811        }
812        if own {
813            let mut wanted = vec![name.replace('-', "_")];
814            let mut owned = BTreeMap::new();
815            while let Some(module) = wanted.pop() {
816                if let Some(content) = item_files.remove(&module) {
817                    wanted.extend(path_children(&content)?);
818                    owned.insert(module, content);
819                }
820            }
821            item_files = owned;
822        }
823        for (module, content) in item_files {
824            // The destination is always derived locally; remote `target` never controls filesystem writes.
825            if let Some(previous) = files.insert(module.clone(), content.clone()) {
826                ensure!(
827                    previous == content,
828                    "conflicting versions of shared module {module}"
829                );
830            }
831        }
832    }
833    Ok(files)
834}
835
836fn declarations(existing: &str, modules: impl Iterator<Item = String>) -> Result<String> {
837    let modules: BTreeSet<_> = modules.collect();
838    let parsed =
839        syn::parse_file(existing).context("parse existing mod.rs; no source was changed")?;
840    let mut declared = BTreeSet::new();
841    for item in parsed.items {
842        if let syn::Item::Mod(module) = item {
843            let name = module.ident.to_string();
844            if modules.contains(&name) {
845                ensure!(
846                    module.content.is_none()
847                        && !module
848                            .attrs
849                            .iter()
850                            .any(|attribute| attribute.path().is_ident("path")),
851                    "module {name} uses inline content or an alternate path; no source was changed"
852                );
853            }
854            declared.insert(name);
855        }
856    }
857    let mut text = existing.to_owned();
858    for module in modules {
859        if declared.insert(module.clone()) {
860            if !text.is_empty() && !text.ends_with('\n') {
861                text.push('\n');
862            }
863            text.push_str(&format!("pub mod {module};\n"));
864        }
865    }
866    Ok(text)
867}
868fn root_modules(files: &BTreeMap<String, String>) -> Result<BTreeSet<String>> {
869    let mut modules: BTreeSet<_> = files.keys().cloned().collect();
870    for content in files.values() {
871        for child in path_children(content)? {
872            modules.remove(&child);
873        }
874    }
875    Ok(modules)
876}
877/// Modules a source file declares with `#[path = "..."]`, which live beside it.
878fn path_children(content: &str) -> Result<Vec<String>> {
879    let mut children = Vec::new();
880    for item in syn::parse_file(content)?.items {
881        if let syn::Item::Mod(module) = item {
882            for attribute in module.attrs {
883                if let syn::Meta::NameValue(meta) = attribute.meta
884                    && meta.path.is_ident("path")
885                    && let syn::Expr::Lit(syn::ExprLit {
886                        lit: syn::Lit::Str(path),
887                        ..
888                    }) = meta.value
889                {
890                    children.push(module_name(&path.value())?);
891                }
892            }
893        }
894    }
895    Ok(children)
896}
897fn current_file(path: &Path) -> Result<Option<Vec<u8>>> {
898    match fs::symlink_metadata(path) {
899        Ok(metadata) => {
900            ensure!(
901                metadata.is_file() && !metadata.file_type().is_symlink(),
902                "refusing non-file destination {}",
903                path.display()
904            );
905            Ok(Some(fs::read(path)?))
906        }
907        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
908        Err(error) => Err(error.into()),
909    }
910}
911fn install(
912    output: &Path,
913    files: BTreeMap<String, String>,
914    overwrite: bool,
915    dry_run: bool,
916) -> Result<()> {
917    ensure!(!files.is_empty(), "no Rust source to install");
918    let mod_path = output.join("mod.rs");
919    let existing_mod = current_file(&mod_path)?.unwrap_or_default();
920    let updated_mod = declarations(
921        std::str::from_utf8(&existing_mod)?,
922        root_modules(&files)?.into_iter(),
923    )?;
924    let mut writes = Vec::new();
925    for (module, content) in files {
926        let path = output.join(format!("{module}.rs"));
927        let existing = current_file(&path)?;
928        if existing.as_deref() == Some(content.as_bytes()) {
929            continue;
930        }
931        if existing.is_some() && !overwrite {
932            println!("Keep {} (differs; --overwrite replaces it)", path.display());
933        } else {
934            writes.push((path, content.into_bytes(), existing.is_some()));
935        }
936    }
937    if existing_mod != updated_mod.as_bytes() {
938        writes.push((mod_path, updated_mod.into_bytes(), true));
939    }
940    for (path, _, _) in &writes {
941        println!(
942            "{} {}",
943            if dry_run { "Would write" } else { "Write" },
944            path.display()
945        );
946    }
947    if dry_run || writes.is_empty() {
948        return Ok(());
949    }
950    fs::create_dir_all(output)?;
951    // Download and validate everything before staging. Persist each file atomically,
952    // and publish mod.rs last so failed staging cannot leave unresolved declarations.
953    let mut staged = Vec::new();
954    for (path, bytes, replace) in writes {
955        let mut file = NamedTempFile::new_in(output)?;
956        file.write_all(&bytes)?;
957        file.flush()?;
958        staged.push((file, path, replace));
959    }
960    for (file, path, replace) in staged {
961        if replace {
962            file.persist(&path).map_err(|error| error.error)?;
963        } else {
964            file.persist_noclobber(&path).map_err(|error| error.error)?;
965        }
966    }
967    Ok(())
968}
969
970/// Prepares an app beside gpuicn.toml: Cargo pins, fonts, and a starter window.
971fn setup(root: &Path, config: &Config) -> Result<()> {
972    let template = dependency_template(config.mode)?;
973    let manifest_path = root.join("Cargo.toml");
974    match current_file(&manifest_path)? {
975        Some(bytes) => {
976            let merged = merge_cargo_dependencies(std::str::from_utf8(&bytes)?, &template)?;
977            for (table, key) in &merged.kept {
978                println!(
979                    "Keep {table}.{key} in Cargo.toml (differs from gpuicn v{}; mismatched GPUI pins may not compile)",
980                    env!("CARGO_PKG_VERSION")
981                );
982            }
983            if !merged.added.is_empty() {
984                fs::write(&manifest_path, merged.text)?;
985                println!(
986                    "Write {} (add {})",
987                    manifest_path.display(),
988                    merged.added.join(", ")
989                );
990            }
991        }
992        None => println!(
993            "No Cargo.toml beside gpuicn.toml. Run `cargo new my-app` first, or add:\n\n{template}"
994        ),
995    }
996
997    let fonts = assets_dir(root, &config.output).join("fonts");
998    let mut client = None;
999    for (file, sha256) in FONTS {
1000        let path = fonts.join(file);
1001        if let Some(existing) = current_file(&path)? {
1002            if hex_sha256(&existing) != sha256 {
1003                println!("Keep {} (differs from Geist)", path.display());
1004            }
1005            continue;
1006        }
1007        let url = release_location(&format!("site/assets/fonts/{file}"));
1008        let bytes = release_file(&mut client, &url, MAX_FONT_BYTES)?;
1009        ensure!(
1010            hex_sha256(&bytes) == sha256,
1011            "{url} does not match its pinned SHA-256"
1012        );
1013        write_new(&path, &bytes)?;
1014    }
1015    let license = fonts.join("OFL.txt");
1016    if current_file(&license)?.is_none() {
1017        let url = release_location("LICENSES/Geist-OFL-1.1");
1018        let bytes = release_file(&mut client, &url, MAX_ITEM_BYTES)?;
1019        write_new(&license, &bytes)?;
1020    }
1021
1022    let main_path = root.join("src/main.rs");
1023    let main = current_file(&main_path)?;
1024    let starter = match &main {
1025        None => true,
1026        Some(text) => text == CARGO_NEW_MAIN.as_bytes(),
1027    };
1028    let next = match config.mode {
1029        Mode::Source => "`gpuicn add <component>`",
1030        Mode::Crate => "use any `ui::<component>`; `gpuicn eject <component>` copies one to edit",
1031    };
1032    let output = root.join(&config.output);
1033    if config.mode == Mode::Source {
1034        // The theme and `app::run`, which the generated app_theme.rs and starter use.
1035        let source = Source::new(&config.registry, root)?;
1036        let files = collect_files(&source, &["app".into()], false)?;
1037        install(&output, files, false, false)?;
1038    }
1039    if config.mode == Mode::Crate {
1040        let mod_path = output.join("mod.rs");
1041        match current_file(&mod_path)? {
1042            None => {
1043                write_new(&mod_path, CRATE_MOD.as_bytes())?;
1044            }
1045            Some(text) if !String::from_utf8_lossy(&text).contains("pub use gpuicn::*;") => {
1046                println!(
1047                    "Keep {} (add `pub use gpuicn::*;` to use the crate's components)",
1048                    mod_path.display()
1049                );
1050            }
1051            Some(_) => {}
1052        }
1053    }
1054    write_theme_files(root, config, false)?;
1055    if starter {
1056        if config.mode == Mode::Source {
1057            let source = Source::new(&config.registry, root)?;
1058            let files = collect_files(&source, &["button".into()], false)?;
1059            install(&output, files, false, false)?;
1060        }
1061        fs::create_dir_all(main_path.parent().context("src directory")?)?;
1062        fs::write(&main_path, STARTER)?;
1063        println!("Write {} (starter window)", main_path.display());
1064        println!("\nReady. Run `cargo run`, then {next}; `gpuicn list` shows them all.");
1065    } else if main.as_deref() == Some(STARTER.as_bytes()) {
1066        println!("\nReady. Run `cargo run`, then {next}.");
1067    } else {
1068        let text = String::from_utf8_lossy(main.as_deref().unwrap_or_default());
1069        println!("\nKeep {} (your code).", main_path.display());
1070        if !text.contains("mod ui;") {
1071            println!("Add `mod ui;` to your crate root.");
1072        }
1073        if !text.contains("app::run") && !text.contains("theme::init") {
1074            println!(
1075                "Start through `ui::app::run(AppConfig::new(\"My App\").themes(ui::app_theme::light(), ui::app_theme::dark()), |_, _| Root)`, or call `ui::theme::init(cx)` once at startup."
1076            );
1077        }
1078        println!("Then {next}; `gpuicn list` shows every component.");
1079    }
1080    Ok(())
1081}
1082
1083/// A release file's URL, or its path under `GPUICN_RELEASE` (a checkout), which lets CI
1084/// set up apps before the release tag exists.
1085fn release_location(path: &str) -> String {
1086    let base = std::env::var("GPUICN_RELEASE").unwrap_or_else(|_| RELEASE.to_owned());
1087    format!("{}/{path}", base.trim_end_matches('/'))
1088}
1089
1090fn release_file(
1091    client: &mut Option<reqwest::blocking::Client>,
1092    location: &str,
1093    limit: u64,
1094) -> Result<Vec<u8>> {
1095    if !location.starts_with("https://") {
1096        return fs::read(location).with_context(|| format!("read {location}"));
1097    }
1098    let client = match client {
1099        Some(client) => client,
1100        None => client.insert(http_client()?),
1101    };
1102    download(client, location, limit).with_context(|| format!("fetch {location}"))
1103}
1104
1105fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
1106    fs::create_dir_all(path.parent().context("parent directory")?)?;
1107    let mut file = NamedTempFile::new_in(path.parent().context("parent directory")?)?;
1108    file.write_all(bytes)?;
1109    file.persist_noclobber(path).map_err(|error| error.error)?;
1110    println!("Write {}", path.display());
1111    Ok(())
1112}
1113
1114fn hex_sha256(bytes: &[u8]) -> String {
1115    use sha2::Digest;
1116    sha2::Sha256::digest(bytes)
1117        .iter()
1118        .map(|byte| format!("{byte:02x}"))
1119        .collect()
1120}
1121
1122struct MergedManifest {
1123    text: String,
1124    added: Vec<String>,
1125    kept: Vec<(String, String)>,
1126}
1127
1128/// Comparable form of a dependency, ignoring formatting and table style.
1129fn dependency_value(item: &toml_edit::Item) -> Option<toml::Value> {
1130    if item.is_table() {
1131        toml::from_str::<toml::Table>(&item.to_string())
1132            .ok()
1133            .map(toml::Value::Table)
1134    } else {
1135        toml::from_str::<toml::Table>(&format!("v = {}", item.to_string().trim()))
1136            .ok()
1137            .and_then(|mut table| table.remove("v"))
1138    }
1139}
1140
1141/// This release's Cargo pins; crate mode also depends on the `gpuicn` crate itself.
1142fn dependency_template(mode: Mode) -> Result<String> {
1143    let mut template: toml_edit::DocumentMut = DEPENDENCIES.parse()?;
1144    if mode == Mode::Crate {
1145        template["dependencies"]["gpuicn"] =
1146            toml_edit::value(concat!("=", env!("CARGO_PKG_VERSION")));
1147    }
1148    Ok(template.to_string())
1149}
1150
1151/// Adds the template's pinned dependencies without replacing any the app already declares.
1152fn merge_cargo_dependencies(manifest: &str, template: &str) -> Result<MergedManifest> {
1153    let template: toml_edit::DocumentMut = template.parse()?;
1154    let mut document: toml_edit::DocumentMut = manifest
1155        .parse()
1156        .context("parse Cargo.toml; it was not changed")?;
1157    let mut added = Vec::new();
1158    let mut kept = Vec::new();
1159    for (table_name, wanted) in template.iter() {
1160        let wanted = wanted.as_table().context("dependency template table")?;
1161        let table = document
1162            .entry(table_name)
1163            .or_insert_with(toml_edit::table)
1164            .as_table_like_mut()
1165            .with_context(|| format!("Cargo.toml [{table_name}] is not a table"))?;
1166        for (key, item) in wanted.iter() {
1167            match table.get(key) {
1168                Some(existing) => {
1169                    if dependency_value(existing) != dependency_value(item) {
1170                        kept.push((table_name.to_owned(), key.to_owned()));
1171                    }
1172                }
1173                None => {
1174                    table.insert(key, item.clone());
1175                    added.push(if table_name == "dependencies" {
1176                        key.to_owned()
1177                    } else {
1178                        format!("{key} ({table_name})")
1179                    });
1180                }
1181            }
1182        }
1183    }
1184    Ok(MergedManifest {
1185        text: document.to_string(),
1186        added,
1187        kept,
1188    })
1189}
1190
1191/// Cargo dependencies the installed source refers to.
1192fn required_dependencies(files: &BTreeMap<String, String>) -> BTreeSet<&'static str> {
1193    let mut required = BTreeSet::from(["gpui-kit"]);
1194    for content in files.values() {
1195        if content.contains("gpui_icons") {
1196            required.insert("gpui-icons");
1197        }
1198        if content.contains("web_time") {
1199            required.insert("web-time");
1200        }
1201    }
1202    required
1203}
1204
1205fn report_missing_dependencies(root: &Path, required: &BTreeSet<&str>) -> Result<()> {
1206    let Some(bytes) = current_file(&root.join("Cargo.toml"))? else {
1207        return Ok(());
1208    };
1209    let Ok(manifest) = std::str::from_utf8(&bytes)?.parse::<toml::Table>() else {
1210        return Ok(());
1211    };
1212    let declared = manifest.get("dependencies").and_then(toml::Value::as_table);
1213    let missing: Vec<_> = required
1214        .iter()
1215        .filter(|name| declared.is_none_or(|table| !table.contains_key(**name)))
1216        .collect();
1217    if !missing.is_empty() {
1218        let template: toml::Table = toml::from_str(DEPENDENCIES)?;
1219        let pins = template["dependencies"]
1220            .as_table()
1221            .context("dependency template")?;
1222        println!(
1223            "\nCargo.toml is missing dependencies these components use. Run `gpuicn init`, or add:"
1224        );
1225        for name in missing {
1226            println!("{name} = {}", pins[*name]);
1227        }
1228    }
1229    Ok(())
1230}
1231
1232/// Registry components whose own module exists in the output directory.
1233fn installed_components(source: &Source, output: &Path) -> Result<Vec<String>> {
1234    let mut installed = Vec::new();
1235    for item in source.index()?.items {
1236        if current_file(&output.join(format!("{}.rs", item.name.replace('-', "_"))))?.is_some() {
1237            installed.push(item.name);
1238        }
1239    }
1240    Ok(installed)
1241}
1242
1243/// Normalized names, defaulting to every installed component.
1244fn targets(source: &Source, output: &Path, components: &[String]) -> Result<Vec<String>> {
1245    if components.is_empty() {
1246        let installed = installed_components(source, output)?;
1247        ensure!(
1248            !installed.is_empty(),
1249            "no gpuicn components are installed in {}",
1250            output.display()
1251        );
1252        return Ok(installed);
1253    }
1254    Ok(components.iter().map(|name| normalize(name)).collect())
1255}
1256
1257fn status(source: &Source, output: &Path, own: bool) -> Result<()> {
1258    let installed = installed_components(source, output)?;
1259    if installed.is_empty() {
1260        println!(
1261            "No gpuicn components are installed in {}. Run `gpuicn {} button`.",
1262            output.display(),
1263            if own { "eject" } else { "add" }
1264        );
1265        return Ok(());
1266    }
1267    let width = installed.iter().map(String::len).max().unwrap_or(0);
1268    let mut changed = false;
1269    for name in &installed {
1270        let mut modified = Vec::new();
1271        let mut missing = Vec::new();
1272        for (module, content) in collect_files(source, std::slice::from_ref(name), own)? {
1273            match current_file(&output.join(format!("{module}.rs")))? {
1274                Some(local) if local == content.as_bytes() => {}
1275                Some(_) => modified.push(format!("{module}.rs")),
1276                None => missing.push(format!("{module}.rs")),
1277            }
1278        }
1279        let mut notes = Vec::new();
1280        if !modified.is_empty() {
1281            notes.push(format!("differs: {}", modified.join(", ")));
1282        }
1283        if !missing.is_empty() {
1284            notes.push(format!("missing: {}", missing.join(", ")));
1285        }
1286        changed |= !notes.is_empty();
1287        println!(
1288            "{name:width$}  {}",
1289            if notes.is_empty() {
1290                "matches registry".to_owned()
1291            } else {
1292                notes.join("; ")
1293            }
1294        );
1295    }
1296    if changed {
1297        println!(
1298            "\n`gpuicn diff <component>` shows the changes; `gpuicn add <component> --overwrite` replaces local files."
1299        );
1300    }
1301    Ok(())
1302}
1303
1304fn diff(source: &Source, output: &Path, components: &[String], own: bool) -> Result<()> {
1305    let components = targets(source, output, components)?;
1306    let mut same = true;
1307    for (module, content) in collect_files(source, &components, own)? {
1308        let path = output.join(format!("{module}.rs"));
1309        let local = current_file(&path)?.unwrap_or_default();
1310        if local == content.as_bytes() {
1311            continue;
1312        }
1313        same = false;
1314        let local = String::from_utf8_lossy(&local);
1315        let display = path.display().to_string();
1316        print!(
1317            "{}",
1318            similar::TextDiff::from_lines(local.as_ref(), content.as_str())
1319                .unified_diff()
1320                .context_radius(3)
1321                .header(
1322                    &format!("{display} (local)"),
1323                    &format!("{display} (registry)")
1324                )
1325        );
1326    }
1327    if same {
1328        println!("Installed source matches the registry.");
1329    }
1330    Ok(())
1331}
1332
1333fn remove(
1334    source: &Source,
1335    output: &Path,
1336    components: &[String],
1337    force: bool,
1338    dry_run: bool,
1339    own: bool,
1340) -> Result<()> {
1341    ensure!(!components.is_empty(), "name a component to remove");
1342    let components: Vec<_> = components.iter().map(|name| normalize(name)).collect();
1343    let installed = installed_components(source, output)?;
1344    for name in &components {
1345        ensure!(
1346            installed.contains(name),
1347            "{name} is not installed in {}",
1348            output.display()
1349        );
1350    }
1351    let remaining: Vec<_> = installed
1352        .into_iter()
1353        .filter(|name| !components.contains(name))
1354        .collect();
1355    let keep = if remaining.is_empty() {
1356        BTreeMap::new()
1357    } else {
1358        collect_files(source, &remaining, own)?
1359    };
1360    let mut doomed = Vec::new();
1361    let mut edited = Vec::new();
1362    for (module, content) in collect_files(source, &components, own)? {
1363        if keep.contains_key(&module) {
1364            continue;
1365        }
1366        let path = output.join(format!("{module}.rs"));
1367        if let Some(local) = current_file(&path)? {
1368            if local != content.as_bytes() {
1369                edited.push(path.display().to_string());
1370            }
1371            doomed.push((module, path));
1372        }
1373    }
1374    ensure!(
1375        force || edited.is_empty(),
1376        "these files have local edits; nothing was removed (--force deletes them anyway):\n  {}",
1377        edited.join("\n  ")
1378    );
1379    let mod_path = output.join("mod.rs");
1380    let existing_mod = String::from_utf8(current_file(&mod_path)?.unwrap_or_default())?;
1381    let declarations: BTreeSet<_> = doomed
1382        .iter()
1383        .map(|(module, _)| format!("pub mod {module};"))
1384        .collect();
1385    let updated_mod: String = existing_mod
1386        .split_inclusive('\n')
1387        .filter(|line| !declarations.contains(line.trim()))
1388        .collect();
1389    for (_, path) in &doomed {
1390        println!(
1391            "{} {}",
1392            if dry_run { "Would remove" } else { "Remove" },
1393            path.display()
1394        );
1395    }
1396    if updated_mod != existing_mod {
1397        println!(
1398            "{} {}",
1399            if dry_run { "Would write" } else { "Write" },
1400            mod_path.display()
1401        );
1402    }
1403    if dry_run {
1404        return Ok(());
1405    }
1406    // Drop declarations first so a failed delete never leaves mod.rs naming a missing file.
1407    if updated_mod != existing_mod {
1408        let mut file = NamedTempFile::new_in(output)?;
1409        file.write_all(updated_mod.as_bytes())?;
1410        file.persist(&mod_path).map_err(|error| error.error)?;
1411    }
1412    for (_, path) in doomed {
1413        fs::remove_file(path)?;
1414    }
1415    Ok(())
1416}
1417
1418/// Copies components' own modules into a crate-mode app; their shared modules keep
1419/// coming from the crate through `pub use gpuicn::*`.
1420fn eject(
1421    source: &Source,
1422    root: &Path,
1423    output: &Path,
1424    components: &[String],
1425    dry_run: bool,
1426) -> Result<()> {
1427    ensure!(!components.is_empty(), "name a component to eject");
1428    let components: Vec<_> = components.iter().map(|name| normalize(name)).collect();
1429    ensure!(
1430        !components.iter().any(|name| name == "theme"),
1431        "theme stays in the crate so every component reads the same theme; customize it with `UiTheme::set` or `UiTheme::from_shadcn_css`"
1432    );
1433    let files = collect_files(source, &components, true)?;
1434    let required = required_dependencies(&files);
1435    install(output, files, false, dry_run)?;
1436    if dry_run {
1437        return Ok(());
1438    }
1439    report_missing_dependencies(root, &required)?;
1440    let index = source.index()?;
1441    for name in &components {
1442        let module = name.replace('-', "_");
1443        let path = format!("registry/{module}/{module}.rs");
1444        let dependents: Vec<_> = index
1445            .items
1446            .iter()
1447            .filter(|item| {
1448                !components.contains(&item.name) && item.files.iter().any(|file| file.path == path)
1449            })
1450            .map(|item| item.name.as_str())
1451            .collect();
1452        println!(
1453            "\n`ui::{module}` is now {} and yours to edit; `gpuicn remove {name}` goes back to the crate.",
1454            output.join(format!("{module}.rs")).display()
1455        );
1456        if !dependents.is_empty() {
1457            println!(
1458                "{} still use the crate's {name}; eject them too if you pass them {name} types.",
1459                dependents.join(", ")
1460            );
1461        }
1462    }
1463    Ok(())
1464}
1465
1466fn dependencies(
1467    name: &str,
1468    manifest: &Manifest,
1469    available: &BTreeSet<String>,
1470    visiting: &mut BTreeSet<String>,
1471    result: &mut Vec<String>,
1472) -> Result<()> {
1473    ensure!(available.contains(name), "unknown dependency {name}");
1474    if result.iter().any(|item| item == name) {
1475        return Ok(());
1476    }
1477    ensure!(visiting.insert(name.into()), "dependency cycle at {name}");
1478    if name != "theme" {
1479        dependencies("theme", manifest, available, visiting, result)?;
1480    }
1481    if let Some(metadata) = manifest.components.get(name) {
1482        for dependency in &metadata.dependencies {
1483            dependencies(dependency, manifest, available, visiting, result)?;
1484        }
1485    }
1486    visiting.remove(name);
1487    result.push(name.to_owned());
1488    Ok(())
1489}
1490fn json_file(path: &Path, value: &impl Serialize) -> Result<()> {
1491    let mut bytes = serde_json::to_vec_pretty(value)?;
1492    bytes.push(b'\n');
1493    fs::write(path, bytes)?;
1494    Ok(())
1495}
1496fn build(source: &Path, output: &Path) -> Result<()> {
1497    let manifest: Manifest = toml::from_str(&fs::read_to_string(source.join("registry.toml"))?)?;
1498    let components = source.join(REGISTRY_SOURCE);
1499    let available: BTreeSet<_> = fs::read_dir(&components)?
1500        .map(|entry| {
1501            let entry = entry?;
1502            Ok(entry
1503                .file_type()?
1504                .is_dir()
1505                .then(|| entry.file_name().to_string_lossy().into_owned()))
1506        })
1507        .collect::<Result<Vec<_>>>()?
1508        .into_iter()
1509        .flatten()
1510        .collect();
1511    for name in available.iter().chain(manifest.components.keys()) {
1512        ensure!(
1513            valid_name(name) && available.contains(name),
1514            "invalid or missing component {name}"
1515        );
1516    }
1517    let mut items = Vec::new();
1518    for name in available
1519        .iter()
1520        .filter(|name| name.as_str() == "theme")
1521        .chain(available.iter().filter(|name| name.as_str() != "theme"))
1522    {
1523        let mut included = Vec::new();
1524        dependencies(
1525            name,
1526            &manifest,
1527            &available,
1528            &mut BTreeSet::new(),
1529            &mut included,
1530        )?;
1531        let mut files = Vec::new();
1532        let mut names = BTreeSet::new();
1533        for dependency in included {
1534            let mut paths = fs::read_dir(components.join(&dependency))?
1535                .map(|entry| entry.map(|entry| entry.path()))
1536                .collect::<std::io::Result<Vec<_>>>()?;
1537            paths.sort();
1538            for path in paths
1539                .into_iter()
1540                .filter(|path| path.extension().is_some_and(|ext| ext == "rs"))
1541            {
1542                let filename = path
1543                    .file_name()
1544                    .context("source filename")?
1545                    .to_str()
1546                    .context("non-UTF8 source filename")?;
1547                let source_path = format!("registry/{dependency}/{filename}");
1548                let module = module_name(&source_path)?;
1549                ensure!(names.insert(module.clone()), "duplicate module {module}");
1550                files.push(SourceFile {
1551                    path: source_path,
1552                    kind: "registry:file".into(),
1553                    target: format!("~/src/ui/{filename}"),
1554                    content: Some(fs::read_to_string(path)?),
1555                });
1556            }
1557        }
1558        let metadata = manifest.components.get(name);
1559        let title = metadata
1560            .and_then(|meta| meta.title.clone())
1561            .unwrap_or_else(|| {
1562                name.split('_')
1563                    .filter(|part| !part.is_empty())
1564                    .map(|part| format!("{}{}", part[..1].to_uppercase(), &part[1..]))
1565                    .collect::<Vec<_>>()
1566                    .join(" ")
1567            });
1568        items.push(Item {
1569            schema: Some("https://ui.shadcn.com/schema/registry-item.json".into()),
1570            name: name.replace('_', "-"),
1571            kind: "registry:item".into(),
1572            title: title.clone(),
1573            description: metadata
1574                .and_then(|meta| meta.description.clone())
1575                .unwrap_or_else(|| {
1576                    format!("The shadcn default {title} visual port backed by GPUI Kit behavior.")
1577                }),
1578            files,
1579        });
1580    }
1581    fs::create_dir_all(output)?;
1582    for item in &items {
1583        json_file(&output.join(format!("{}.json", item.name)), item)?;
1584    }
1585    for item in &mut items {
1586        item.schema = None;
1587        for file in &mut item.files {
1588            file.content = None;
1589        }
1590    }
1591    let index = Registry {
1592        schema: "https://ui.shadcn.com/schema/registry.json".into(),
1593        name: manifest.name,
1594        homepage: manifest.homepage,
1595        items,
1596    };
1597    json_file(&source.join("registry.json"), &index)?;
1598    json_file(&output.join("registry.json"), &index)?;
1599    println!(
1600        "Built {} registry items in {}",
1601        index.items.len(),
1602        output.display()
1603    );
1604    Ok(())
1605}
1606
1607#[cfg(test)]
1608mod tests {
1609    use super::*;
1610    #[test]
1611    fn local_install_keeps_edits_updates_modules_and_rejects_unsafe_input_before_writing()
1612    -> Result<()> {
1613        let temp = tempfile::tempdir()?;
1614        let root = temp.path();
1615        let registry = root.join("r");
1616        fs::create_dir(&registry)?;
1617        let item = Item {
1618            schema: None,
1619            name: "button".into(),
1620            kind: "registry:item".into(),
1621            title: "Button".into(),
1622            description: String::new(),
1623            files: vec![SourceFile {
1624                path: "registry/button/button.rs".into(),
1625                kind: "registry:file".into(),
1626                target: "../../outside.rs".into(),
1627                content: Some("pub struct Button;\n".into()),
1628            }],
1629        };
1630        json_file(&registry.join("button.json"), &item)?;
1631        let source = Source::new("r", root)?;
1632        let files = collect_files(&source, &["button".into()], false)?;
1633        let output = root.join("src/ui");
1634        install(&output, files.clone(), false, true)?;
1635        assert!(!output.exists());
1636        install(&output, files.clone(), false, false)?;
1637        assert_eq!(
1638            fs::read_to_string(output.join("mod.rs"))?,
1639            "pub mod button;\n"
1640        );
1641        assert!(!root.join("outside.rs").exists());
1642        fs::write(output.join("button.rs"), "// caller edit\n")?;
1643        fs::write(
1644            output.join("mod.rs"),
1645            "// Keep me\npub mod button;\nfn custom() {}\n",
1646        )?;
1647        install(&output, files.clone(), false, false)?;
1648        assert_eq!(
1649            fs::read_to_string(output.join("button.rs"))?,
1650            "// caller edit\n"
1651        );
1652        assert!(fs::read_to_string(output.join("mod.rs"))?.contains("fn custom() {}"));
1653        install(&output, files, true, false)?;
1654        assert_eq!(
1655            fs::read_to_string(output.join("button.rs"))?,
1656            "pub struct Button;\n"
1657        );
1658        for bad in [
1659            "../outside.rs",
1660            "/absolute.rs",
1661            "registry/../bad.rs",
1662            "registry\\bad.rs",
1663            "registry/mod.rs",
1664            "registry/type.rs",
1665        ] {
1666            assert!(module_name(bad).is_err(), "{bad}");
1667        }
1668        assert!(collect_files(&source, &["button".into(), "missing".into()], false).is_err());
1669        assert!(!valid_name("../escape"));
1670        assert!(declarations("not valid Rust", ["button".into()].into_iter()).is_err());
1671        assert_eq!(
1672            root_modules(&BTreeMap::from([
1673                (
1674                    "dialog".into(),
1675                    "#[path = \"modal_focus.rs\"] mod focus;".into()
1676                ),
1677                ("modal_focus".into(), "fn helper() {}".into()),
1678            ]))?,
1679            BTreeSet::from(["dialog".into()])
1680        );
1681        Ok(())
1682    }
1683    #[test]
1684    fn crate_mode_ejects_own_modules_and_pins_the_crate() -> Result<()> {
1685        let temp = tempfile::tempdir()?;
1686        let registry = temp.path().join("r");
1687        fs::create_dir(&registry)?;
1688        let file = |path: &str, content: &str| SourceFile {
1689            path: path.into(),
1690            kind: "registry:file".into(),
1691            target: String::new(),
1692            content: Some(content.into()),
1693        };
1694        let theme = file("registry/theme/theme.rs", "pub struct UiTheme;\n");
1695        let dialog = file(
1696            "registry/dialog/dialog.rs",
1697            "#[path = \"modal_focus.rs\"]\npub mod modal_focus;\n",
1698        );
1699        let focus = file("registry/dialog/modal_focus.rs", "pub fn prepare() {}\n");
1700        let alert = file(
1701            "registry/alert_dialog/alert_dialog.rs",
1702            "pub fn alert() {}\n",
1703        );
1704        for (name, files) in [
1705            ("dialog", vec![theme.clone(), dialog.clone(), focus.clone()]),
1706            ("alert-dialog", vec![theme, dialog, focus, alert]),
1707        ] {
1708            let item = Item {
1709                schema: None,
1710                name: name.into(),
1711                kind: "registry:item".into(),
1712                title: String::new(),
1713                description: String::new(),
1714                files,
1715            };
1716            json_file(&registry.join(format!("{name}.json")), &item)?;
1717        }
1718        let source = Source::new("r", temp.path())?;
1719        let modules = |names: &[&str]| -> Result<Vec<String>> {
1720            let names: Vec<String> = names.iter().map(|name| name.to_string()).collect();
1721            Ok(collect_files(&source, &names, true)?.into_keys().collect())
1722        };
1723        assert_eq!(modules(&["dialog"])?, ["dialog", "modal_focus"]);
1724        assert_eq!(modules(&["alert-dialog"])?, ["alert_dialog"]);
1725        assert_eq!(
1726            collect_files(&source, &["alert-dialog".into()], false)?.len(),
1727            4
1728        );
1729
1730        let pinned: toml::Table = toml::from_str(&dependency_template(Mode::Crate)?)?;
1731        assert_eq!(
1732            pinned["dependencies"]["gpuicn"].as_str(),
1733            Some(concat!("=", env!("CARGO_PKG_VERSION")))
1734        );
1735        let copied: toml::Table = toml::from_str(&dependency_template(Mode::Source)?)?;
1736        assert!(
1737            !copied["dependencies"]
1738                .as_table()
1739                .unwrap()
1740                .contains_key("gpuicn")
1741        );
1742
1743        let config = Config {
1744            version: 2,
1745            registry: "r".into(),
1746            output: "src/ui".into(),
1747            mode: Mode::Source,
1748            theme: ThemeConfig::default(),
1749        };
1750        let text = toml::to_string(&config)?;
1751        assert!(!text.contains("mode"));
1752        assert!(text.contains("[theme]\nbase_color = \"neutral\"\nappearance = \"system\""));
1753        let config: Config = toml::from_str(
1754            "version = 1\nregistry = \"r\"\noutput = \"src/ui\"\nmode = \"crate\"\n",
1755        )?;
1756        assert_eq!(config.mode, Mode::Crate);
1757        assert_eq!(config.theme, ThemeConfig::default());
1758        Ok(())
1759    }
1760    #[test]
1761    fn names_normalize_and_unknown_names_get_suggestions() {
1762        assert_eq!(normalize(" Alert_Dialog "), "alert-dialog");
1763        let names = ["alert-dialog", "dialog", "button", "checkbox-group"];
1764        assert_eq!(
1765            suggestions("alert-dialg", names.into_iter()),
1766            ["alert-dialog"]
1767        );
1768        assert_eq!(suggestions("buton", names.into_iter()), ["button"]);
1769        assert_eq!(
1770            suggestions("checkbox", names.into_iter()),
1771            ["checkbox-group"]
1772        );
1773        assert!(suggestions("zzzzzzzz", names.into_iter()).is_empty());
1774    }
1775    #[test]
1776    fn missing_local_component_reports_suggestions() -> Result<()> {
1777        let temp = tempfile::tempdir()?;
1778        let registry = temp.path().join("r");
1779        fs::create_dir(&registry)?;
1780        let index = Registry {
1781            schema: String::new(),
1782            name: "test".into(),
1783            homepage: String::new(),
1784            items: vec![Item {
1785                schema: None,
1786                name: "alert-dialog".into(),
1787                kind: "registry:item".into(),
1788                title: "Alert Dialog".into(),
1789                description: String::new(),
1790                files: Vec::new(),
1791            }],
1792        };
1793        json_file(&registry.join("registry.json"), &index)?;
1794        let source = Source::new("r", temp.path())?;
1795        let message = format!(
1796            "{:#}",
1797            collect_files(&source, &["alert-dialg".into()], false)
1798                .err()
1799                .unwrap()
1800        );
1801        assert!(message.contains("did you mean alert-dialog?"), "{message}");
1802        assert!(message.contains("gpuicn list"), "{message}");
1803        Ok(())
1804    }
1805    #[test]
1806    fn cargo_merge_adds_pins_and_keeps_existing_choices() -> Result<()> {
1807        let fresh = "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n";
1808        let merged = merge_cargo_dependencies(fresh, DEPENDENCIES)?;
1809        assert_eq!(
1810            merged.added,
1811            [
1812                "web-time",
1813                "gpui_platform",
1814                "gpui-icons",
1815                "gpui-kit",
1816                "gpui-kit (dev-dependencies)"
1817            ]
1818        );
1819        assert!(merged.kept.is_empty());
1820        assert!(merged.text.starts_with("[package]\nname = \"app\""));
1821        let again = merge_cargo_dependencies(&merged.text, DEPENDENCIES)?;
1822        assert!(again.added.is_empty() && again.kept.is_empty());
1823
1824        let custom = "[dependencies]\nweb-time = \"1.1.0\"\n\n[dependencies.gpui-kit]\nversion = \"=0.9.0\"\ndefault-features = false\nfeatures = [\"component\"]\n";
1825        let merged = merge_cargo_dependencies(custom, DEPENDENCIES)?;
1826        assert_eq!(merged.kept, [("dependencies".into(), "web-time".into())]);
1827        assert!(!merged.added.contains(&"gpui-kit".to_owned()));
1828        assert!(merged.text.contains("web-time = \"1.1.0\""));
1829        assert!(merge_cargo_dependencies("not = [valid", DEPENDENCIES).is_err());
1830        Ok(())
1831    }
1832    #[test]
1833    fn release_templates_match_the_install_fixture_and_pins() -> Result<()> {
1834        let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
1835        assert_eq!(
1836            STARTER,
1837            fs::read_to_string(repo.join("fixtures/registry-install/src/main.rs"))?
1838        );
1839        let fixture: toml::Table = toml::from_str(&fs::read_to_string(
1840            repo.join("fixtures/registry-install/Cargo.toml"),
1841        )?)?;
1842        let template: toml::Table = toml::from_str(DEPENDENCIES)?;
1843        for table in ["dependencies", "dev-dependencies"] {
1844            assert_eq!(fixture[table], template[table], "{table}");
1845        }
1846        let docs = fs::read_to_string(repo.join("docs/registry.md"))?;
1847        assert!(
1848            docs.contains(DEPENDENCIES.trim()),
1849            "docs/registry.md dependency block"
1850        );
1851        let pins: toml::Table =
1852            toml::from_str(&fs::read_to_string(repo.join("release-pins.toml"))?)?;
1853        let deps = template["dependencies"].as_table().unwrap();
1854        assert_eq!(
1855            deps["gpui_platform"]["version"].as_str(),
1856            Some(format!("={}", pins["gpui"]["version"].as_str().unwrap()).as_str())
1857        );
1858        assert_eq!(
1859            deps["gpui-kit"]["version"].as_str(),
1860            Some(format!("={}", pins["gpui_kit"]["version"].as_str().unwrap()).as_str())
1861        );
1862        assert_eq!(
1863            deps["gpui-icons"].as_str(),
1864            Some(format!("={}", pins["gpui_icons"]["version"].as_str().unwrap()).as_str())
1865        );
1866        let geist = pins["geist"].as_table().unwrap();
1867        for ((_, sha), key) in FONTS.iter().zip([
1868            "sans_regular_sha256",
1869            "sans_medium_sha256",
1870            "sans_semibold_sha256",
1871            "mono_regular_sha256",
1872        ]) {
1873            assert_eq!(geist[key].as_str(), Some(*sha), "{key}");
1874        }
1875        for (file, sha) in FONTS {
1876            for dir in ["site/assets/fonts", "crates/ui/assets/fonts"] {
1877                assert_eq!(
1878                    hex_sha256(&fs::read(repo.join(dir).join(file))?),
1879                    sha,
1880                    "{dir}/{file}"
1881                );
1882            }
1883        }
1884        assert!(STARTER.contains("app::run(") || STARTER.contains("run(\n"));
1885        assert!(STARTER.contains("app_theme::light()"));
1886        assert_eq!(
1887            fs::read_to_string(repo.join("LICENSES/Geist-OFL-1.1"))?,
1888            fs::read_to_string(repo.join("crates/ui/assets/fonts/OFL.txt"))?
1889        );
1890        Ok(())
1891    }
1892    #[test]
1893    fn remove_keeps_shared_and_edited_files() -> Result<()> {
1894        let temp = tempfile::tempdir()?;
1895        let registry = temp.path().join("r");
1896        fs::create_dir(&registry)?;
1897        let file = |module: &str| SourceFile {
1898            path: format!("registry/{module}/{module}.rs"),
1899            kind: "registry:file".into(),
1900            target: String::new(),
1901            content: Some(format!("pub struct {module};\n")),
1902        };
1903        let item = |name: &str, modules: &[&str]| Item {
1904            schema: None,
1905            name: name.into(),
1906            kind: "registry:item".into(),
1907            title: name.into(),
1908            description: String::new(),
1909            files: modules.iter().map(|module| file(module)).collect(),
1910        };
1911        let items = vec![
1912            item("theme", &["theme"]),
1913            item("button", &["theme", "button"]),
1914            item("menu", &["theme", "button", "menu"]),
1915        ];
1916        for item in &items {
1917            json_file(&registry.join(format!("{}.json", item.name)), item)?;
1918        }
1919        json_file(
1920            &registry.join("registry.json"),
1921            &Registry {
1922                schema: String::new(),
1923                name: "test".into(),
1924                homepage: String::new(),
1925                items,
1926            },
1927        )?;
1928        let source = Source::new("r", temp.path())?;
1929        let output = temp.path().join("ui");
1930        install(
1931            &output,
1932            collect_files(&source, &["menu".into()], false)?,
1933            false,
1934            false,
1935        )?;
1936        fs::write(
1937            output.join("mod.rs"),
1938            format!("// mine\n{}", fs::read_to_string(output.join("mod.rs"))?),
1939        )?;
1940        assert_eq!(
1941            installed_components(&source, &output)?,
1942            ["theme", "button", "menu"]
1943        );
1944
1945        fs::write(output.join("menu.rs"), "// edited\n")?;
1946        assert!(remove(&source, &output, &["menu".into()], false, false, false).is_err());
1947        assert!(output.join("menu.rs").exists());
1948        remove(&source, &output, &["menu".into()], true, true, false)?;
1949        assert!(output.join("menu.rs").exists());
1950        remove(&source, &output, &["menu".into()], true, false, false)?;
1951        assert!(!output.join("menu.rs").exists());
1952        assert!(output.join("button.rs").exists() && output.join("theme.rs").exists());
1953        assert_eq!(
1954            fs::read_to_string(output.join("mod.rs"))?,
1955            "// mine\npub mod button;\npub mod theme;\n"
1956        );
1957        assert!(remove(&source, &output, &["menu".into()], false, false, false).is_err());
1958        remove(
1959            &source,
1960            &output,
1961            &["button".into(), "theme".into()],
1962            false,
1963            false,
1964            false,
1965        )?;
1966        assert_eq!(fs::read_to_string(output.join("mod.rs"))?, "// mine\n");
1967        Ok(())
1968    }
1969    #[test]
1970    fn dependency_order_is_shared_once_and_cycles_fail() -> Result<()> {
1971        let manifest: Manifest = toml::from_str(
1972            "name='test'\nhomepage='local'\n[components.menu]\ndependencies=['button']\n[components.context_menu]\ndependencies=['menu']",
1973        )?;
1974        let available = ["theme", "button", "menu", "context_menu"]
1975            .map(String::from)
1976            .into_iter()
1977            .collect();
1978        let mut result = Vec::new();
1979        dependencies(
1980            "context_menu",
1981            &manifest,
1982            &available,
1983            &mut BTreeSet::new(),
1984            &mut result,
1985        )?;
1986        assert_eq!(result, ["theme", "button", "menu", "context_menu"]);
1987        let cyclic: Manifest = toml::from_str(
1988            "name='test'\nhomepage='local'\n[components.theme]\ndependencies=['button']",
1989        )?;
1990        assert!(
1991            dependencies(
1992                "button",
1993                &cyclic,
1994                &available,
1995                &mut BTreeSet::new(),
1996                &mut Vec::new()
1997            )
1998            .is_err()
1999        );
2000        Ok(())
2001    }
2002}