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