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