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_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}