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!(build.contains("surge"));
640 assert!(!build.contains("starship"));
641 assert!(complete_values(&["shine", "app", "refresh", ""], 3).contains("surge"));
642 assert!(complete_values(&["shine", "info", ""], 2).contains("sys/split-dns"));
643 assert!(complete_values(&["shine", "info", ""], 2).contains("app/starship"));
644 assert!(complete_values(&["shine", "install", ""], 2).contains("shell/proxy"));
645 assert!(
646 complete_values(&["shine", "install", ""], 2).contains("shell/utils/shine-env-export")
647 );
648 assert!(
649 complete_values(&["shine", "shell", "install", ""], 3)
650 .contains("utils/shine-env-export")
651 );
652 let preset_copy = complete_values(&["shine", "preset", "copy", ""], 3);
653 assert!(preset_copy.contains("app/surge"));
654 assert!(preset_copy.contains("shell/proxy"));
655 assert!(preset_copy.contains("sys/macos"));
656 assert!(!preset_copy.contains("surge"));
657 }
658
659 #[test]
660 fn config_dir_candidates_read_external_presets_without_creating_config() {
661 let _guard = env_lock();
662 let dir = temp_dir("config-dir");
663 std::fs::create_dir_all(dir.join("presets/shell/custom-shell")).unwrap();
664 std::fs::create_dir_all(dir.join("presets/app/custom-app")).unwrap();
665 std::fs::create_dir_all(dir.join("presets/sys/custom-os")).unwrap();
666 std::fs::write(
667 dir.join("presets/sys/custom-os/shine.toml"),
668 "[[items]]\nid = \"custom-sys\"\nlabel = \"Custom sys\"\n",
669 )
670 .unwrap();
671 std::fs::write(
672 dir.join("presets/shell/custom-shell/shine.toml"),
673 "[[files]]\nsource = \"tool.sh\"\ntarget = \"custom-tool\"\n",
674 )
675 .unwrap();
676 std::fs::write(
677 dir.join("presets/shell/custom-shell/tool.sh"),
678 "#!/bin/sh\n",
679 )
680 .unwrap();
681 std::fs::write(
682 dir.join("tasks.toml"),
683 "[tasks.ship]\ncommand = [\"cargo\", \"publish\"]\n",
684 )
685 .unwrap();
686 unsafe {
687 std::env::set_var("SHINE_CONFIG_DIR", &dir);
688 std::env::remove_var("SHINE_PRESETS");
689 }
690
691 assert!(category_names("shell").contains("custom-shell"));
692 assert!(category_names("app").contains("custom-app"));
693 assert!(sys_item_names().contains("custom-sys"));
694 let shell_info = shell_info_candidates()
695 .into_iter()
696 .map(|candidate| candidate.get_value().to_string_lossy().into_owned())
697 .collect::<BTreeSet<_>>();
698 assert!(shell_info.contains("custom-tool"));
699 assert!(shell_info.contains("custom-shell/custom-tool"));
700 assert!(complete_values(&["shine", "run", ""], 2).contains("ship"));
701 let sys_apply = complete_values(&["shine", "sys", "apply", ""], 3);
702 assert!(sys_apply.contains("custom-sys"));
703 assert!(!sys_apply.contains("split-dns"));
704 assert!(
705 !dir.join("config.toml").exists(),
706 "completion must not initialize config files"
707 );
708
709 unsafe {
710 std::env::remove_var("SHINE_CONFIG_DIR");
711 }
712 std::fs::remove_dir_all(dir).unwrap();
713 }
714
715 #[test]
716 fn overlay_candidates_extend_embedded_presets() {
717 let _guard = env_lock();
718 let home = temp_dir("overlay-home");
719 let overlay = temp_dir("overlay");
720 let old_home = std::env::var_os("HOME");
721 std::fs::create_dir_all(home.join(".shine")).unwrap();
722 std::fs::create_dir_all(overlay.join("shell/personal")).unwrap();
723 std::fs::write(
724 home.join(".shine/config.toml"),
725 format!(
726 "presets_overlay_dir = \"{}\"\n",
727 overlay.to_string_lossy().replace('\\', "\\\\")
728 ),
729 )
730 .unwrap();
731
732 unsafe {
733 std::env::set_var("HOME", &home);
734 std::env::remove_var("SHINE_CONFIG_DIR");
735 std::env::remove_var("SHINE_PRESETS");
736 }
737
738 let shell = category_names("shell");
739
740 unsafe {
741 match old_home {
742 Some(value) => std::env::set_var("HOME", value),
743 None => std::env::remove_var("HOME"),
744 }
745 }
746 std::fs::remove_dir_all(home).unwrap();
747 std::fs::remove_dir_all(overlay).unwrap();
748
749 assert!(shell.contains("proxy"), "shell candidates: {shell:?}");
750 assert!(shell.contains("personal"), "shell candidates: {shell:?}");
751 }
752
753 #[test]
754 fn completion_command_accepts_dynamic_registration_shells() {
755 let _guard = env_lock();
756 for shell in ["bash", "powershell", "zsh"] {
757 unsafe { std::env::set_var("COMPLETE", shell) };
758 let completed = clap_complete::CompleteEnv::with_factory(command)
759 .bin("shine")
760 .completer("shine")
761 .try_complete(["shine"], None);
762 assert!(
763 completed.is_ok_and(|completed| completed),
764 "completion setup should be callable for {shell}"
765 );
766 }
767 }
768}