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