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