1use crate::commands::CompletionShell;
2use clap::CommandFactory;
3use clap_complete::engine::{ArgValueCandidates, CompletionCandidate};
4use std::collections::{BTreeMap, BTreeSet};
5use std::path::{Path, PathBuf};
6
7pub fn complete_from_env() {
8 clap_complete::CompleteEnv::with_factory(command)
9 .bin("shine")
10 .complete();
11}
12
13pub fn generate_registration(shell: CompletionShell) {
14 unsafe { std::env::set_var("COMPLETE", shell.as_str()) };
17 let _ = clap_complete::CompleteEnv::with_factory(command)
18 .bin("shine")
19 .completer("shine")
20 .try_complete(["shine"], std::env::current_dir().ok().as_deref())
21 .unwrap_or_else(|e| e.exit());
22}
23
24pub fn command() -> clap::Command {
25 let preset_targets = ArgValueCandidates::new(preset_target_candidates);
26 let shell_categories = ArgValueCandidates::new(shell_category_candidates);
27 let shell_targets = ArgValueCandidates::new(shell_info_candidates);
28 let app_categories = ArgValueCandidates::new(app_category_candidates);
29 let app_build_categories = ArgValueCandidates::new(app_build_candidates);
30 let app_unbuild_categories = ArgValueCandidates::new(app_unbuild_candidates);
31 let app_refresh_categories = ArgValueCandidates::new(app_refresh_candidates);
32 let sys_items = ArgValueCandidates::new(sys_item_candidates);
33 let sys_updates = ArgValueCandidates::new(sys_update_candidates);
34 let resource_targets = ArgValueCandidates::new(resource_target_candidates);
35 let upgrade_targets = ArgValueCandidates::new(upgrade_target_candidates);
36 let installed_targets = ArgValueCandidates::new(installed_target_candidates);
37 let task_names = ArgValueCandidates::new(task_name_candidates);
38 let preset_copy_targets = ArgValueCandidates::new(preset_copy_candidates);
39
40 crate::commands::Cli::command()
41 .mut_subcommand("install", |cmd| {
42 cmd.mut_arg("target", |arg| arg.add(preset_targets.clone()))
43 })
44 .mut_subcommand("uninstall", |cmd| {
45 cmd.mut_arg("target", |arg| arg.add(preset_targets.clone()))
46 })
47 .mut_subcommand("info", |cmd| {
48 cmd.mut_arg("target", |arg| arg.add(resource_targets.clone()))
49 })
50 .mut_subcommand("update", |cmd| {
51 cmd.mut_arg("target", |arg| arg.add(installed_targets.clone()))
52 })
53 .mut_subcommand("upgrade", |cmd| {
54 cmd.mut_arg("target", |arg| arg.add(upgrade_targets))
55 })
56 .mut_subcommand("shell", |cmd| {
57 cmd.mut_subcommand("info", |cmd| {
58 cmd.mut_arg("target", |arg| arg.add(shell_targets.clone()))
59 })
60 .mut_subcommand("install", |cmd| {
61 cmd.mut_arg("category", |arg| arg.add(shell_categories.clone()))
62 })
63 .mut_subcommand("uninstall", |cmd| {
64 cmd.mut_arg("category", |arg| arg.add(shell_categories.clone()))
65 })
66 })
67 .mut_subcommand("app", |cmd| {
68 cmd.mut_subcommand("info", |cmd| {
69 cmd.mut_arg("category", |arg| arg.add(app_categories.clone()))
70 })
71 .mut_subcommand("install", |cmd| {
72 cmd.mut_arg("category", |arg| arg.add(app_categories.clone()))
73 })
74 .mut_subcommand("uninstall", |cmd| {
75 cmd.mut_arg("category", |arg| arg.add(app_categories.clone()))
76 })
77 .mut_subcommand("refresh", |cmd| {
78 cmd.mut_arg("category", |arg| {
79 arg.index(1).add(app_refresh_categories.clone())
80 })
81 .mut_arg("file", |arg| arg.index(2))
82 })
83 .mut_subcommand("artifact", |cmd| {
84 cmd.mut_subcommand("apply", |cmd| {
85 cmd.mut_arg("app_id", |arg| arg.add(app_build_categories.clone()))
86 })
87 .mut_subcommand("remove", |cmd| {
88 cmd.mut_arg("app_id", |arg| arg.add(app_unbuild_categories.clone()))
89 })
90 })
91 })
92 .mut_subcommand("sys", |cmd| {
93 cmd.mut_subcommand("info", |cmd| {
94 cmd.mut_arg("item", |arg| arg.add(sys_items.clone()))
95 })
96 .mut_subcommand("apply", |cmd| {
97 cmd.mut_arg("item", |arg| arg.add(sys_items.clone()))
98 })
99 .mut_subcommand("update", |cmd| {
100 cmd.mut_arg("item", |arg| arg.add(sys_updates.clone()))
101 })
102 .mut_subcommand("uninstall", |cmd| {
103 cmd.mut_arg("item", |arg| arg.add(sys_items.clone()))
104 })
105 })
106 .mut_subcommand("task", |cmd| {
107 cmd.mut_subcommand("run", |cmd| {
108 cmd.mut_arg("name", |arg| arg.index(1).add(task_names.clone()))
109 .mut_arg("extra", |arg| arg.index(2))
110 })
111 .mut_subcommand("info", |cmd| {
112 cmd.mut_arg("name", |arg| arg.add(task_names.clone()))
113 })
114 .mut_subcommand("delete", |cmd| {
115 cmd.mut_arg("name", |arg| arg.add(task_names.clone()))
116 })
117 })
118 .mut_subcommand("preset", |cmd| {
119 cmd.mut_subcommand("copy", |cmd| {
120 cmd.mut_arg("target", |arg| arg.add(preset_copy_targets))
121 })
122 })
123 .mut_subcommand("run", |cmd| {
124 cmd.mut_arg("name", |arg| arg.index(1).add(task_names))
125 .mut_arg("extra", |arg| arg.index(2))
126 })
127}
128
129fn preset_copy_candidates() -> Vec<CompletionCandidate> {
130 let targets = crate::presets::embedded_asset_paths("")
131 .into_iter()
132 .filter_map(|path| {
133 let mut parts = path.split('/');
134 let kind = parts.next()?;
135 let name = parts.next()?;
136 matches!(kind, "app" | "shell" | "sys").then(|| format!("{kind}/{name}"))
137 })
138 .collect();
139 completion_candidates(targets)
140}
141
142fn preset_target_candidates() -> Vec<CompletionCandidate> {
143 let mut targets = BTreeSet::new();
144 for category in category_names("app") {
145 targets.insert(category.clone());
146 targets.insert(format!("app/{category}"));
147 }
148 for category in category_names("shell") {
149 targets.insert(category.clone());
150 targets.insert(format!("shell/{category}"));
151 }
152 completion_candidates(targets)
153}
154
155fn shell_category_candidates() -> Vec<CompletionCandidate> {
156 completion_candidates(category_names("shell"))
157}
158
159fn shell_info_candidates() -> Vec<CompletionCandidate> {
160 let mut names = shell_category_names();
161 for (category, commands) in shell_command_names() {
162 for command in commands {
163 names.insert(command.clone());
164 names.insert(format!("{category}/{command}"));
165 }
166 }
167 completion_candidates(names)
168}
169
170fn shell_category_names() -> BTreeSet<String> {
171 category_names("shell")
172}
173
174fn shell_command_names() -> Vec<(String, BTreeSet<String>)> {
175 let Some(paths) = runtime_paths() else {
176 return Vec::new();
177 };
178 if paths.is_external_presets {
179 return collect_fs_shell_command_names(&paths.presets_dir.join("shell"))
180 .into_iter()
181 .collect();
182 }
183
184 let mut commands = crate::shells::metadata::load_embedded_categories(None)
185 .unwrap_or_default()
186 .into_iter()
187 .map(|category| {
188 (
189 category.name,
190 category
191 .files
192 .into_iter()
193 .map(|file| file.command_name)
194 .collect::<BTreeSet<_>>(),
195 )
196 })
197 .collect::<BTreeMap<_, _>>();
198 if let Some(overlay_dir) = paths.presets_overlay_dir {
199 for (category, overlay_commands) in
200 collect_fs_shell_command_names(&overlay_dir.join("shell"))
201 {
202 commands
203 .entry(category)
204 .or_default()
205 .extend(overlay_commands);
206 }
207 }
208 commands.into_iter().collect()
209}
210
211fn collect_fs_shell_command_names(root: &Path) -> BTreeMap<String, BTreeSet<String>> {
212 #[derive(serde::Deserialize)]
213 struct ShellManifest {
214 files: Option<Vec<ShellFile>>,
215 }
216 #[derive(serde::Deserialize)]
217 struct ShellFile {
218 source: PathBuf,
219 target: Option<String>,
220 }
221
222 let mut result = BTreeMap::new();
223 let Ok(entries) = std::fs::read_dir(root) else {
224 return result;
225 };
226 for entry in entries.flatten() {
227 let Ok(file_type) = entry.file_type() else {
228 continue;
229 };
230 if !file_type.is_dir() {
231 continue;
232 }
233 let Some(category) = entry.file_name().to_str().map(str::to_string) else {
234 continue;
235 };
236 let dir = entry.path();
237 let declared_commands = if let Ok(content) = std::fs::read_to_string(dir.join("shine.toml"))
238 {
239 toml::from_str::<ShellManifest>(&content)
240 .map(|manifest| {
241 manifest.files.map(|files| {
242 files
243 .into_iter()
244 .filter_map(|file| {
245 file.target.or_else(|| {
246 crate::bin_links::link_stem(&file.source).into_string().ok()
247 })
248 })
249 .collect()
250 })
251 })
252 .unwrap_or(None)
253 } else {
254 None
255 };
256 let commands = declared_commands.unwrap_or_else(|| {
257 std::fs::read_dir(&dir)
258 .into_iter()
259 .flatten()
260 .flatten()
261 .filter_map(|file| {
262 let path = file.path();
263 (path.is_file()
264 && matches!(
265 path.extension().and_then(|extension| extension.to_str()),
266 Some("sh" | "ps1")
267 ))
268 .then(|| crate::bin_links::link_stem(&path).into_string().ok())
269 .flatten()
270 })
271 .collect()
272 });
273 result.insert(category, commands);
274 }
275 result
276}
277
278fn app_category_candidates() -> Vec<CompletionCandidate> {
279 completion_candidates(category_names("app"))
280}
281
282fn app_build_candidates() -> Vec<CompletionCandidate> {
283 completion_candidates(app_capability_names(AppCapability::Build))
284}
285
286fn app_unbuild_candidates() -> Vec<CompletionCandidate> {
287 completion_candidates(app_capability_names(AppCapability::Unbuild))
288}
289
290fn app_refresh_candidates() -> Vec<CompletionCandidate> {
291 completion_candidates(app_capability_names(AppCapability::Refresh))
292}
293
294#[derive(Clone, Copy)]
295enum AppCapability {
296 Build,
297 Unbuild,
298 Refresh,
299}
300
301fn app_capability_names(capability: AppCapability) -> BTreeSet<String> {
302 let Some(paths) = runtime_paths() else {
303 return BTreeSet::new();
304 };
305 if paths.is_external_presets {
306 return collect_fs_app_capabilities(&paths.presets_dir.join("app"), capability);
307 }
308
309 let mut names: BTreeSet<String> = crate::apps::load_embedded_categories(None)
310 .unwrap_or_default()
311 .into_iter()
312 .filter(|category| match capability {
313 AppCapability::Build => category.artifact.is_some(),
314 AppCapability::Unbuild => category
315 .artifact
316 .as_ref()
317 .is_some_and(|artifact| artifact.teardown.is_some()),
318 AppCapability::Refresh => category.files.iter().any(|file| file.generator.is_some()),
319 })
320 .map(|category| category.name)
321 .collect();
322 if let Some(overlay_dir) = paths.presets_overlay_dir {
323 names.extend(collect_fs_app_capabilities(
324 &overlay_dir.join("app"),
325 capability,
326 ));
327 }
328 names
329}
330
331fn collect_fs_app_capabilities(root: &Path, capability: AppCapability) -> BTreeSet<String> {
332 let Ok(entries) = std::fs::read_dir(root) else {
333 return BTreeSet::new();
334 };
335 entries
336 .flatten()
337 .filter_map(|entry| {
338 let name = entry.file_name().to_str()?.to_string();
339 let content = std::fs::read_to_string(entry.path().join("shine.toml")).ok()?;
340 let value = toml::from_str::<toml::Value>(&content).ok()?;
341 let matches = match capability {
342 AppCapability::Build => value.get("artifact").is_some_and(|artifact| {
343 artifact
344 .get("script")
345 .and_then(toml::Value::as_str)
346 .is_some()
347 }),
348 AppCapability::Unbuild => value.get("artifact").is_some_and(|artifact| {
349 artifact
350 .get("teardown")
351 .and_then(toml::Value::as_str)
352 .is_some()
353 }),
354 AppCapability::Refresh => value
355 .get("files")
356 .and_then(toml::Value::as_array)
357 .is_some_and(|files| files.iter().any(|file| file.get("generator").is_some())),
358 };
359 matches.then_some(name)
360 })
361 .collect()
362}
363
364fn installed_target_candidates() -> Vec<CompletionCandidate> {
365 completion_candidates(installed_target_names())
366}
367
368fn resource_target_candidates() -> Vec<CompletionCandidate> {
369 let mut names = installed_target_names();
370 for category in category_names("app") {
371 names.insert(category.clone());
372 names.insert(format!("app/{category}"));
373 }
374 for (category, commands) in shell_command_names() {
375 names.insert(category.clone());
376 names.insert(format!("shell/{category}"));
377 for command in commands {
378 names.insert(command.clone());
379 names.insert(format!("{category}/{command}"));
380 names.insert(format!("shell/{category}/{command}"));
381 }
382 }
383 names.extend(
384 sys_item_names()
385 .into_iter()
386 .map(|item| format!("sys/{item}")),
387 );
388 completion_candidates(names)
389}
390
391fn upgrade_target_candidates() -> Vec<CompletionCandidate> {
392 let mut names = installed_target_names();
393 names.extend(
394 sys_item_names()
395 .into_iter()
396 .map(|item| format!("sys/{item}")),
397 );
398 completion_candidates(names)
399}
400
401fn installed_target_names() -> BTreeSet<String> {
402 #[derive(serde::Deserialize)]
403 struct AppManifest {
404 #[serde(default)]
405 entries: Vec<AppEntry>,
406 }
407 #[derive(serde::Deserialize)]
408 struct AppEntry {
409 source: String,
410 }
411
412 let Some(paths) = runtime_paths() else {
413 return BTreeSet::new();
414 };
415 let mut names = BTreeSet::new();
416 if let Ok(content) = std::fs::read_to_string(paths.shine_dir.join("app-manifest.toml"))
417 && let Ok(manifest) = toml::from_str::<AppManifest>(&content)
418 {
419 for entry in manifest.entries {
420 let mut parts = entry.source.splitn(3, '/');
421 if let (Some("app"), Some(category), Some(file)) =
422 (parts.next(), parts.next(), parts.next())
423 {
424 names.insert(category.to_string());
425 names.insert(format!("app/{category}"));
426 names.insert(format!("{category}/{file}"));
427 names.insert(format!("app/{category}/{file}"));
428 }
429 }
430 }
431
432 for (category, commands) in collect_fs_shell_command_names(&paths.presets_dir.join("shell")) {
433 names.insert(category.clone());
434 names.insert(format!("shell/{category}"));
435 for command in commands {
436 names.insert(command.clone());
437 names.insert(format!("{category}/{command}"));
438 names.insert(format!("shell/{category}/{command}"));
439 }
440 }
441 names
442}
443
444fn sys_item_candidates() -> Vec<CompletionCandidate> {
445 completion_candidates(sys_item_names())
446}
447
448fn sys_item_names() -> BTreeSet<String> {
449 let mut names = BTreeSet::new();
450 let Some(paths) = runtime_paths() else {
451 return names;
452 };
453 if paths.is_external_presets {
454 collect_fs_sys_item_names(&paths.presets_dir.join("sys"), &mut names);
455 return names;
456 }
457
458 for path in crate::presets::asset_paths("sys") {
459 if !path.ends_with("/shine.toml") {
460 continue;
461 }
462 if let Some(bytes) = crate::presets::read_asset_bytes(&path) {
463 collect_toml_sys_item_names(&bytes, &mut names);
464 }
465 }
466 if let Some(overlay_dir) = paths.presets_overlay_dir {
467 collect_fs_sys_item_names(&overlay_dir.join("sys"), &mut names);
468 }
469 names
470}
471
472fn sys_update_candidates() -> Vec<CompletionCandidate> {
473 #[derive(serde::Deserialize)]
474 struct Manifest {
475 #[serde(default)]
476 entries: Vec<Entry>,
477 }
478 #[derive(serde::Deserialize)]
479 struct Entry {
480 item_id: String,
481 #[serde(default)]
482 managed: bool,
483 }
484
485 let names = runtime_paths()
486 .and_then(|paths| std::fs::read_to_string(paths.shine_dir.join("sys-manifest.toml")).ok())
487 .and_then(|content| toml::from_str::<Manifest>(&content).ok())
488 .map(|manifest| {
489 manifest
490 .entries
491 .into_iter()
492 .filter(|entry| !entry.managed)
493 .map(|entry| entry.item_id)
494 .collect()
495 })
496 .unwrap_or_default();
497 completion_candidates(names)
498}
499
500fn task_name_candidates() -> Vec<CompletionCandidate> {
501 #[derive(serde::Deserialize)]
502 struct Manifest {
503 #[serde(default)]
504 tasks: BTreeMap<String, toml::Value>,
505 }
506
507 let names = runtime_paths()
508 .and_then(|paths| std::fs::read_to_string(paths.shine_dir.join("tasks.toml")).ok())
509 .and_then(|content| toml::from_str::<Manifest>(&content).ok())
510 .map(|manifest| manifest.tasks.into_keys().collect())
511 .unwrap_or_default();
512 completion_candidates(names)
513}
514
515fn collect_fs_sys_item_names(root: &Path, names: &mut BTreeSet<String>) {
516 let Ok(entries) = std::fs::read_dir(root) else {
517 return;
518 };
519 for entry in entries.flatten() {
520 let manifest = entry.path().join("shine.toml");
521 if let Ok(bytes) = std::fs::read(manifest) {
522 collect_toml_sys_item_names(&bytes, names);
523 }
524 }
525}
526
527fn collect_toml_sys_item_names(bytes: &[u8], names: &mut BTreeSet<String>) {
528 let Ok(content) = std::str::from_utf8(bytes) else {
529 return;
530 };
531 let Ok(value) = toml::from_str::<toml::Value>(content) else {
532 return;
533 };
534 let Some(items) = value.get("items").and_then(toml::Value::as_array) else {
535 return;
536 };
537 names.extend(items.iter().filter_map(|item| {
538 item.get("id")
539 .and_then(toml::Value::as_str)
540 .map(str::to_string)
541 }));
542}
543
544fn completion_candidates(names: BTreeSet<String>) -> Vec<CompletionCandidate> {
545 names.into_iter().map(CompletionCandidate::new).collect()
546}
547
548fn category_names(kind: &str) -> BTreeSet<String> {
549 let mut names = BTreeSet::new();
550 let Some(paths) = runtime_paths() else {
551 return names;
552 };
553 if paths.is_external_presets
554 && let Some(fs_names) = fs_category_names(&paths.presets_dir.join(kind))
555 {
556 names.extend(fs_names);
557 return names;
558 }
559
560 names.extend(embedded_category_names(kind));
561 if let Some(overlay_dir) = paths.presets_overlay_dir
562 && let Some(fs_names) = fs_category_names(&overlay_dir.join(kind))
563 {
564 names.extend(fs_names);
565 }
566 names
567}
568
569fn runtime_paths() -> Option<crate::config::ReadOnlyRuntimePaths> {
570 crate::config::discover_runtime_paths_read_only()
571}
572
573fn fs_category_names(root: &Path) -> Option<BTreeSet<String>> {
574 if !root.is_dir() {
575 return None;
576 }
577
578 let mut names = BTreeSet::new();
579 for entry in std::fs::read_dir(root).ok()? {
580 let entry = entry.ok()?;
581 if entry.file_type().ok()?.is_dir()
582 && let Some(name) = entry.file_name().to_str()
583 {
584 names.insert(name.to_string());
585 }
586 }
587 Some(names)
588}
589
590fn embedded_category_names(kind: &str) -> BTreeSet<String> {
591 let prefix = format!("{kind}/");
592 crate::presets::asset_paths(kind)
593 .into_iter()
594 .filter_map(|path| {
595 let rest = path.strip_prefix(&prefix)?;
596 let (category, _) = rest.split_once('/')?;
597 Some(category.to_string())
598 })
599 .collect()
600}
601
602#[cfg(test)]
603mod tests {
604 use super::*;
605 use crate::test_support::env_lock;
606 use std::ffi::OsString;
607
608 fn temp_dir(name: &str) -> PathBuf {
609 std::env::temp_dir().join(format!("shine-completion-{name}-{}", uuid::Uuid::new_v4()))
610 }
611
612 fn complete_values(args: &[&str], arg_index: usize) -> BTreeSet<String> {
613 clap_complete::engine::complete(
614 &mut command(),
615 args.iter().map(OsString::from).collect(),
616 arg_index,
617 None,
618 )
619 .unwrap()
620 .into_iter()
621 .map(|candidate| candidate.get_value().to_string_lossy().into_owned())
622 .collect()
623 }
624
625 #[test]
626 fn embedded_candidates_include_known_categories() {
627 let _guard = env_lock();
628 unsafe {
629 std::env::remove_var("SHINE_CONFIG_DIR");
630 std::env::remove_var("SHINE_PRESETS");
631 }
632
633 let shell = category_names("shell");
634 let app = category_names("app");
635 let sys = sys_item_names();
636
637 assert!(shell.contains("proxy"), "shell candidates: {shell:?}");
638 assert!(app.contains("starship"), "app candidates: {app:?}");
639 assert!(sys.contains("split-dns"), "sys candidates: {sys:?}");
640 let shell_info = shell_info_candidates()
641 .into_iter()
642 .map(|candidate| candidate.get_value().to_string_lossy().into_owned())
643 .collect::<BTreeSet<_>>();
644 assert!(shell_info.contains("setproxy"));
645 assert!(shell_info.contains("proxy/setproxy"));
646 let build = complete_values(&["shine", "app", "artifact", "apply", ""], 4);
647 assert!(build.contains("surge"));
648 assert!(!build.contains("starship"));
649 assert!(complete_values(&["shine", "app", "refresh", ""], 3).contains("surge"));
650 assert!(complete_values(&["shine", "info", ""], 2).contains("sys/split-dns"));
651 assert!(complete_values(&["shine", "info", ""], 2).contains("app/starship"));
652 assert!(complete_values(&["shine", "install", ""], 2).contains("shell/proxy"));
653 let preset_copy = complete_values(&["shine", "preset", "copy", ""], 3);
654 assert!(preset_copy.contains("app/surge"));
655 assert!(preset_copy.contains("shell/proxy"));
656 assert!(preset_copy.contains("sys/macos"));
657 assert!(!preset_copy.contains("surge"));
658 }
659
660 #[test]
661 fn config_dir_candidates_read_external_presets_without_creating_config() {
662 let _guard = env_lock();
663 let dir = temp_dir("config-dir");
664 std::fs::create_dir_all(dir.join("presets/shell/custom-shell")).unwrap();
665 std::fs::create_dir_all(dir.join("presets/app/custom-app")).unwrap();
666 std::fs::create_dir_all(dir.join("presets/sys/custom-os")).unwrap();
667 std::fs::write(
668 dir.join("presets/sys/custom-os/shine.toml"),
669 "[[items]]\nid = \"custom-sys\"\nlabel = \"Custom sys\"\n",
670 )
671 .unwrap();
672 std::fs::write(
673 dir.join("presets/shell/custom-shell/shine.toml"),
674 "[[files]]\nsource = \"tool.sh\"\ntarget = \"custom-tool\"\n",
675 )
676 .unwrap();
677 std::fs::write(
678 dir.join("presets/shell/custom-shell/tool.sh"),
679 "#!/bin/sh\n",
680 )
681 .unwrap();
682 std::fs::write(
683 dir.join("tasks.toml"),
684 "[tasks.ship]\ncommand = [\"cargo\", \"publish\"]\n",
685 )
686 .unwrap();
687 std::fs::write(
688 dir.join("sys-manifest.toml"),
689 "[[entries]]\nitem_id = \"brew\"\n\n[[entries]]\nitem_id = \"split-dns\"\nmanaged = true\n",
690 )
691 .unwrap();
692
693 unsafe {
694 std::env::set_var("SHINE_CONFIG_DIR", &dir);
695 std::env::remove_var("SHINE_PRESETS");
696 }
697
698 assert!(category_names("shell").contains("custom-shell"));
699 assert!(category_names("app").contains("custom-app"));
700 assert!(sys_item_names().contains("custom-sys"));
701 let shell_info = shell_info_candidates()
702 .into_iter()
703 .map(|candidate| candidate.get_value().to_string_lossy().into_owned())
704 .collect::<BTreeSet<_>>();
705 assert!(shell_info.contains("custom-tool"));
706 assert!(shell_info.contains("custom-shell/custom-tool"));
707 assert!(complete_values(&["shine", "run", ""], 2).contains("ship"));
708 let sys_updates = complete_values(&["shine", "sys", "update", ""], 3);
709 assert!(sys_updates.contains("brew"));
710 assert!(!sys_updates.contains("split-dns"));
711 assert!(
712 !dir.join("config.toml").exists(),
713 "completion must not initialize config files"
714 );
715
716 unsafe {
717 std::env::remove_var("SHINE_CONFIG_DIR");
718 }
719 std::fs::remove_dir_all(dir).unwrap();
720 }
721
722 #[test]
723 fn overlay_candidates_extend_embedded_presets() {
724 let _guard = env_lock();
725 let home = temp_dir("overlay-home");
726 let overlay = temp_dir("overlay");
727 let old_home = std::env::var_os("HOME");
728 std::fs::create_dir_all(home.join(".shine")).unwrap();
729 std::fs::create_dir_all(overlay.join("shell/personal")).unwrap();
730 std::fs::write(
731 home.join(".shine/config.toml"),
732 format!(
733 "presets_overlay_dir = \"{}\"\n",
734 overlay.to_string_lossy().replace('\\', "\\\\")
735 ),
736 )
737 .unwrap();
738
739 unsafe {
740 std::env::set_var("HOME", &home);
741 std::env::remove_var("SHINE_CONFIG_DIR");
742 std::env::remove_var("SHINE_PRESETS");
743 }
744
745 let shell = category_names("shell");
746
747 unsafe {
748 match old_home {
749 Some(value) => std::env::set_var("HOME", value),
750 None => std::env::remove_var("HOME"),
751 }
752 }
753 std::fs::remove_dir_all(home).unwrap();
754 std::fs::remove_dir_all(overlay).unwrap();
755
756 assert!(shell.contains("proxy"), "shell candidates: {shell:?}");
757 assert!(shell.contains("personal"), "shell candidates: {shell:?}");
758 }
759
760 #[test]
761 fn completion_command_accepts_dynamic_registration_shells() {
762 let _guard = env_lock();
763 for shell in ["bash", "powershell", "zsh"] {
764 unsafe { std::env::set_var("COMPLETE", shell) };
765 let completed = clap_complete::CompleteEnv::with_factory(command)
766 .bin("shine")
767 .completer("shine")
768 .try_complete(["shine"], None);
769 assert!(
770 completed.is_ok_and(|completed| completed),
771 "completion setup should be callable for {shell}"
772 );
773 }
774 }
775}