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