Skip to main content

cli/
completion.rs

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    // SAFETY: this is called during single-threaded startup before Tokio is
15    // initialized, and `CompleteEnv` removes the variable before returning.
16    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}