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