Skip to main content

cc_switch_lib/cli/commands/
completions.rs

1use clap::{Args, Subcommand, ValueEnum};
2use clap_complete::Shell;
3use std::env;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::config::{home_dir, write_text_file};
8use crate::AppError;
9
10const MANAGED_BLOCK_START: &str = "# >>> cc-switch completions >>>";
11const MANAGED_BLOCK_END: &str = "# <<< cc-switch completions <<<";
12const COMMAND_NAME: &str = "cc-switch-tui";
13
14#[derive(Args, Debug, Clone)]
15#[command(arg_required_else_help = true)]
16pub struct CompletionsCommand {
17    /// The shell to generate completions for
18    #[arg(value_enum)]
19    pub shell: Option<Shell>,
20
21    #[command(subcommand)]
22    pub action: Option<CompletionsAction>,
23}
24
25#[derive(Subcommand, Debug, Clone)]
26pub enum CompletionsAction {
27    /// Install managed shell completions for bash or zsh
28    Install(CompletionsInstallCommand),
29
30    /// Show managed shell completions status for bash or zsh
31    Status(CompletionLifecycleCommand),
32
33    /// Remove managed shell completions for bash or zsh
34    Uninstall(CompletionLifecycleCommand),
35}
36
37#[derive(Args, Debug, Clone)]
38pub struct CompletionsInstallCommand {
39    /// Shell to manage automatically
40    #[arg(long, value_enum, default_value_t = ManagedShellSelection::Auto)]
41    pub shell: ManagedShellSelection,
42
43    /// Explicitly modify the shell rc file with a managed activation block
44    #[arg(long)]
45    pub activate: bool,
46}
47
48#[derive(Args, Debug, Clone)]
49pub struct CompletionLifecycleCommand {
50    /// Shell to inspect or uninstall
51    #[arg(long, value_enum, default_value_t = ManagedShellSelection::Auto)]
52    pub shell: ManagedShellSelection,
53}
54
55#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
56pub enum ManagedShellSelection {
57    Auto,
58    Bash,
59    Zsh,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63enum ManagedShell {
64    Bash,
65    Zsh,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
69struct CompletionPaths {
70    completion_file: PathBuf,
71    completion_dir: PathBuf,
72    rc_file: PathBuf,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76enum ActivationChange {
77    Added,
78    Updated,
79    Unchanged,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
83struct StatusSnapshot {
84    shell: ManagedShell,
85    shell_message: String,
86    paths: CompletionPaths,
87    completion_file_exists: bool,
88    rc_file_exists: bool,
89    activation_managed: bool,
90    reload_recommended: bool,
91    next_step: String,
92}
93
94pub fn execute(command: CompletionsCommand) -> Result<(), AppError> {
95    match (command.shell, command.action) {
96        (Some(shell), None) => {
97            crate::cli::generate_completions(shell);
98            Ok(())
99        }
100        (None, Some(CompletionsAction::Install(args))) => execute_install(args),
101        (None, Some(CompletionsAction::Status(args))) => execute_status(args),
102        (None, Some(CompletionsAction::Uninstall(args))) => execute_uninstall(args),
103        (Some(_), Some(_)) => Err(AppError::InvalidInput(
104            "shell generator path cannot be combined with lifecycle subcommands".to_string(),
105        )),
106        (None, None) => Err(AppError::InvalidInput(
107            "missing completions shell or lifecycle subcommand".to_string(),
108        )),
109    }
110}
111
112fn execute_install(args: CompletionsInstallCommand) -> Result<(), AppError> {
113    let (shell, shell_message) = resolve_managed_shell(args.shell)?;
114    let home = require_home_dir()?;
115    let paths = completion_paths(shell, &home);
116    let script = generated_completion_script(shell)?;
117
118    write_text_file(&paths.completion_file, &script)?;
119
120    println!("{shell_message}");
121    println!(
122        "Installed completion file: {}",
123        paths.completion_file.display()
124    );
125
126    if args.activate {
127        let activation_change = upsert_activation_block(shell, &paths)?;
128        println!(
129            "Managed activation block: {}",
130            match activation_change {
131                ActivationChange::Added => format!("added to {}", paths.rc_file.display()),
132                ActivationChange::Updated => format!("updated in {}", paths.rc_file.display()),
133                ActivationChange::Unchanged => {
134                    format!("already present in {}", paths.rc_file.display())
135                }
136            }
137        );
138        println!(
139            "Next step: reload your shell or run `{}`",
140            reload_command(shell, &paths)
141        );
142    } else {
143        println!(
144            "Managed activation block: not changed (use --activate to let cc-switch edit {})",
145            paths.rc_file.display()
146        );
147        println!(
148            "Next step: run `{COMMAND_NAME} completions install --shell {} --activate` to activate it automatically, or wire {} manually.",
149            shell.as_str(),
150            paths.rc_file.display()
151        );
152    }
153
154    Ok(())
155}
156
157fn execute_status(args: CompletionLifecycleCommand) -> Result<(), AppError> {
158    let snapshot = completion_status(args.shell)?;
159
160    println!("{}", snapshot.shell_message);
161    println!(
162        "Completion file: {} ({})",
163        snapshot.paths.completion_file.display(),
164        if snapshot.completion_file_exists {
165            "installed"
166        } else {
167            "missing"
168        }
169    );
170    println!(
171        "RC file: {} ({})",
172        snapshot.paths.rc_file.display(),
173        if snapshot.rc_file_exists {
174            "present"
175        } else {
176            "missing"
177        }
178    );
179    println!(
180        "Managed activation block: {}",
181        if snapshot.activation_managed {
182            "yes"
183        } else {
184            "no"
185        }
186    );
187    println!(
188        "Current shell likely needs reload: {}",
189        if snapshot.reload_recommended {
190            "yes"
191        } else {
192            "no"
193        }
194    );
195    println!("Next step: {}", snapshot.next_step);
196
197    Ok(())
198}
199
200fn execute_uninstall(args: CompletionLifecycleCommand) -> Result<(), AppError> {
201    let snapshot = completion_status(args.shell)?;
202
203    if snapshot.completion_file_exists {
204        fs::remove_file(&snapshot.paths.completion_file)
205            .map_err(|err| AppError::io(&snapshot.paths.completion_file, err))?;
206    }
207
208    let activation_removed = remove_activation_block(&snapshot.paths)?;
209
210    println!("{}", snapshot.shell_message);
211    println!(
212        "Removed completion file: {}",
213        if snapshot.completion_file_exists {
214            snapshot.paths.completion_file.display().to_string()
215        } else {
216            format!(
217                "{} (already absent)",
218                snapshot.paths.completion_file.display()
219            )
220        }
221    );
222    println!(
223        "Managed activation block: {}",
224        if activation_removed {
225            format!("removed from {}", snapshot.paths.rc_file.display())
226        } else {
227            format!("not present in {}", snapshot.paths.rc_file.display())
228        }
229    );
230    println!(
231        "Next step: reload your shell or restart it to drop any already-loaded completion definitions."
232    );
233
234    Ok(())
235}
236
237fn completion_status(selection: ManagedShellSelection) -> Result<StatusSnapshot, AppError> {
238    let (shell, shell_message) = resolve_managed_shell(selection)?;
239    let home = require_home_dir()?;
240    let paths = completion_paths(shell, &home);
241    let rc_content = read_optional_text_file(&paths.rc_file)?;
242    let activation_managed = managed_block_range(&rc_content).is_some();
243    let completion_file_exists = paths.completion_file.exists();
244
245    Ok(StatusSnapshot {
246        shell,
247        shell_message,
248        rc_file_exists: paths.rc_file.exists(),
249        completion_file_exists,
250        activation_managed,
251        reload_recommended: completion_file_exists && activation_managed,
252        next_step: next_step_for_status(shell, &paths, completion_file_exists, activation_managed),
253        paths,
254    })
255}
256
257fn next_step_for_status(
258    shell: ManagedShell,
259    paths: &CompletionPaths,
260    completion_file_exists: bool,
261    activation_managed: bool,
262) -> String {
263    if !completion_file_exists {
264        return format!(
265            "Run `{COMMAND_NAME} completions install --shell {}` to install the completion file.",
266            shell.as_str()
267        );
268    }
269
270    if !activation_managed {
271        return format!(
272            "Run `{COMMAND_NAME} completions install --shell {} --activate` to add a managed activation block to {}.",
273            shell.as_str(),
274            paths.rc_file.display()
275        );
276    }
277
278    format!(
279        "Reload your shell or run `{}`.",
280        reload_command(shell, paths)
281    )
282}
283
284fn resolve_managed_shell(
285    selection: ManagedShellSelection,
286) -> Result<(ManagedShell, String), AppError> {
287    match selection {
288        ManagedShellSelection::Bash => Ok((ManagedShell::Bash, "Selected shell: bash".to_string())),
289        ManagedShellSelection::Zsh => Ok((ManagedShell::Zsh, "Selected shell: zsh".to_string())),
290        ManagedShellSelection::Auto => detect_shell_from_env(),
291    }
292}
293
294fn detect_shell_from_env() -> Result<(ManagedShell, String), AppError> {
295    let shell_env = env::var("SHELL").ok();
296    let shell_name = shell_env
297        .as_deref()
298        .and_then(|value| Path::new(value).file_name())
299        .and_then(|value| value.to_str());
300
301    match shell_name {
302        Some("bash") => Ok((
303            ManagedShell::Bash,
304            format!(
305                "Detected shell: bash{}",
306                shell_env
307                    .as_deref()
308                    .map(|value| format!(" (from SHELL={value})"))
309                    .unwrap_or_default()
310            ),
311        )),
312        Some("zsh") => Ok((
313            ManagedShell::Zsh,
314            format!(
315                "Detected shell: zsh{}",
316                shell_env
317                    .as_deref()
318                    .map(|value| format!(" (from SHELL={value})"))
319                    .unwrap_or_default()
320            ),
321        )),
322        Some(other) => Err(AppError::Message(format!(
323            "Automatic completion lifecycle currently supports only bash and zsh. Detected SHELL={other}. Re-run with `--shell bash` or `--shell zsh`, or use raw generation via `{COMMAND_NAME} completions <shell>`."
324        ))),
325        None => Err(AppError::Message(format!(
326            "Could not detect a supported shell from SHELL. Re-run with `--shell bash` or `--shell zsh`, or use raw generation via `{COMMAND_NAME} completions <shell>`."
327        ))),
328    }
329}
330
331fn require_home_dir() -> Result<PathBuf, AppError> {
332    home_dir().ok_or_else(|| AppError::Config("无法获取用户主目录".to_string()))
333}
334
335fn completion_paths(shell: ManagedShell, home: &Path) -> CompletionPaths {
336    match shell {
337        ManagedShell::Bash => CompletionPaths {
338            completion_file: home
339                .join(".local/share/bash-completion/completions")
340                .join(COMMAND_NAME),
341            completion_dir: home.join(".local/share/bash-completion/completions"),
342            rc_file: home.join(".bashrc"),
343        },
344        ManagedShell::Zsh => CompletionPaths {
345            completion_file: home
346                .join(".local/share/zsh/site-functions")
347                .join(format!("_{COMMAND_NAME}")),
348            completion_dir: home.join(".local/share/zsh/site-functions"),
349            rc_file: home.join(".zshrc"),
350        },
351    }
352}
353
354fn generated_completion_script(shell: ManagedShell) -> Result<String, AppError> {
355    let mut buffer = Vec::new();
356    crate::cli::generate_completions_to(shell.generator_shell(), &mut buffer);
357    String::from_utf8(buffer)
358        .map_err(|err| AppError::Message(format!("Failed to decode generated completions: {err}")))
359}
360
361fn upsert_activation_block(
362    shell: ManagedShell,
363    paths: &CompletionPaths,
364) -> Result<ActivationChange, AppError> {
365    let existing = read_optional_text_file(&paths.rc_file)?;
366    let (stripped, had_block) = remove_managed_block(&existing);
367    let insert_before = match shell {
368        ManagedShell::Zsh => find_compinit_insert_offset(&stripped),
369        ManagedShell::Bash => None,
370    };
371    let block = activation_block(shell, insert_before.is_none());
372    let updated = insert_block(&stripped, &block, insert_before);
373
374    if updated != existing {
375        write_text_file(&paths.rc_file, &updated)?;
376    }
377
378    Ok(match (had_block, updated == existing) {
379        (false, _) => ActivationChange::Added,
380        (true, true) => ActivationChange::Unchanged,
381        (true, false) => ActivationChange::Updated,
382    })
383}
384
385fn remove_activation_block(paths: &CompletionPaths) -> Result<bool, AppError> {
386    let existing = read_optional_text_file(&paths.rc_file)?;
387    let (updated, had_block) = remove_managed_block(&existing);
388
389    if had_block && updated != existing {
390        write_text_file(&paths.rc_file, &updated)?;
391    }
392
393    Ok(had_block)
394}
395
396fn read_optional_text_file(path: &Path) -> Result<String, AppError> {
397    match fs::read_to_string(path) {
398        Ok(content) => Ok(content),
399        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
400        Err(err) => Err(AppError::io(path, err)),
401    }
402}
403
404fn managed_block_range(content: &str) -> Option<(usize, usize)> {
405    let start = content.find(MANAGED_BLOCK_START)?;
406    let end_start = content[start..].find(MANAGED_BLOCK_END)? + start;
407    let mut end = end_start + MANAGED_BLOCK_END.len();
408
409    if content[end..].starts_with("\r\n") {
410        end += 2;
411    } else if content[end..].starts_with('\n') {
412        end += 1;
413    }
414
415    Some((start, end))
416}
417
418fn remove_managed_block(content: &str) -> (String, bool) {
419    let Some((start, end)) = managed_block_range(content) else {
420        return (content.to_string(), false);
421    };
422
423    let mut updated = String::with_capacity(content.len());
424    updated.push_str(&content[..start]);
425    updated.push_str(&content[end..]);
426
427    (updated, true)
428}
429
430fn find_compinit_insert_offset(content: &str) -> Option<usize> {
431    let mut offset = 0usize;
432
433    for line in content.split_inclusive('\n') {
434        let trimmed = line.trim_start();
435        if !trimmed.starts_with('#') && trimmed.contains("compinit") {
436            return Some(offset);
437        }
438        offset += line.len();
439    }
440
441    None
442}
443
444fn insert_block(content: &str, block: &str, before: Option<usize>) -> String {
445    let mut updated = String::new();
446
447    match before {
448        Some(index) => {
449            updated.push_str(&content[..index]);
450            if !updated.is_empty() && !updated.ends_with('\n') {
451                updated.push('\n');
452            }
453            updated.push_str(block);
454            if !updated.ends_with('\n') {
455                updated.push('\n');
456            }
457            if !content[index..].is_empty() && !content[index..].starts_with('\n') {
458                updated.push('\n');
459            }
460            updated.push_str(&content[index..]);
461        }
462        None => {
463            updated.push_str(content);
464            if !updated.is_empty() && !updated.ends_with('\n') {
465                updated.push('\n');
466            }
467            updated.push_str(block);
468            if !updated.ends_with('\n') {
469                updated.push('\n');
470            }
471        }
472    }
473
474    updated
475}
476
477fn activation_block(shell: ManagedShell, bootstrap_compinit: bool) -> String {
478    match shell {
479        ManagedShell::Bash => format!(
480            "{MANAGED_BLOCK_START}\nif [ -f \"$HOME/.local/share/bash-completion/completions/{COMMAND_NAME}\" ]; then\n  . \"$HOME/.local/share/bash-completion/completions/{COMMAND_NAME}\"\nfi\n{MANAGED_BLOCK_END}\n"
481        ),
482        ManagedShell::Zsh if bootstrap_compinit => format!(
483            "{MANAGED_BLOCK_START}\nif [ -d \"$HOME/.local/share/zsh/site-functions\" ]; then\n  fpath=(\"$HOME/.local/share/zsh/site-functions\" $fpath)\nfi\nautoload -Uz compinit\ncompinit\n{MANAGED_BLOCK_END}\n"
484        ),
485        ManagedShell::Zsh => format!(
486            "{MANAGED_BLOCK_START}\nif [ -d \"$HOME/.local/share/zsh/site-functions\" ]; then\n  fpath=(\"$HOME/.local/share/zsh/site-functions\" $fpath)\nfi\n{MANAGED_BLOCK_END}\n"
487        ),
488    }
489}
490
491fn reload_command(shell: ManagedShell, paths: &CompletionPaths) -> String {
492    match shell {
493        ManagedShell::Bash | ManagedShell::Zsh => format!("source {}", paths.rc_file.display()),
494    }
495}
496
497impl ManagedShell {
498    fn as_str(self) -> &'static str {
499        match self {
500            Self::Bash => "bash",
501            Self::Zsh => "zsh",
502        }
503    }
504
505    fn generator_shell(self) -> Shell {
506        match self {
507            Self::Bash => Shell::Bash,
508            Self::Zsh => Shell::Zsh,
509        }
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use crate::test_support::{lock_test_home_and_settings, set_test_home_override};
517    use serial_test::serial;
518    use std::ffi::OsString;
519
520    struct ShellEnvGuard {
521        original: Option<OsString>,
522    }
523
524    impl ShellEnvGuard {
525        fn set(value: Option<&str>) -> Self {
526            let original = env::var_os("SHELL");
527            match value {
528                Some(value) => unsafe { env::set_var("SHELL", value) },
529                None => unsafe { env::remove_var("SHELL") },
530            }
531            Self { original }
532        }
533    }
534
535    impl Drop for ShellEnvGuard {
536        fn drop(&mut self) {
537            match self.original.as_ref() {
538                Some(value) => unsafe { env::set_var("SHELL", value) },
539                None => unsafe { env::remove_var("SHELL") },
540            }
541        }
542    }
543
544    struct TestHomeGuard {
545        _lock: crate::test_support::TestHomeSettingsLock,
546        old_shell: ShellEnvGuard,
547    }
548
549    impl TestHomeGuard {
550        fn new(home: &Path, shell: Option<&str>) -> Self {
551            let lock = lock_test_home_and_settings();
552            set_test_home_override(Some(home));
553            let old_shell = ShellEnvGuard::set(shell);
554            Self {
555                _lock: lock,
556                old_shell,
557            }
558        }
559    }
560
561    impl Drop for TestHomeGuard {
562        fn drop(&mut self) {
563            let _ = &self.old_shell;
564            set_test_home_override(None);
565        }
566    }
567
568    fn marker_count(content: &str) -> usize {
569        content.matches(MANAGED_BLOCK_START).count()
570    }
571
572    #[test]
573    #[serial(home_settings)]
574    fn detects_supported_shell_from_env() {
575        let _guard = ShellEnvGuard::set(Some("/bin/bash"));
576        let (shell, message) =
577            resolve_managed_shell(ManagedShellSelection::Auto).expect("bash should resolve");
578        assert_eq!(shell, ManagedShell::Bash);
579        assert!(message.contains("Detected shell: bash"));
580    }
581
582    #[test]
583    #[serial(home_settings)]
584    fn auto_shell_detection_rejects_unsupported_shell() {
585        let _guard = ShellEnvGuard::set(Some("/usr/bin/fish"));
586        let err = resolve_managed_shell(ManagedShellSelection::Auto)
587            .expect_err("fish should not be supported for managed lifecycle");
588        assert!(err.to_string().contains("only bash and zsh"));
589    }
590
591    #[test]
592    #[serial(home_settings)]
593    fn auto_shell_detection_requires_shell_env() {
594        let _guard = ShellEnvGuard::set(None);
595        let err = resolve_managed_shell(ManagedShellSelection::Auto)
596            .expect_err("missing SHELL should fail");
597        assert!(err.to_string().contains("Could not detect"));
598    }
599
600    #[test]
601    fn calculates_bash_and_zsh_completion_paths_under_home_override() {
602        let home = PathBuf::from("/tmp/cc-switch-home");
603
604        let bash = completion_paths(ManagedShell::Bash, &home);
605        assert_eq!(
606            bash.completion_file,
607            home.join(".local/share/bash-completion/completions/cc-switch-tui")
608        );
609        assert_eq!(bash.rc_file, home.join(".bashrc"));
610
611        let zsh = completion_paths(ManagedShell::Zsh, &home);
612        assert_eq!(
613            zsh.completion_file,
614            home.join(".local/share/zsh/site-functions/_cc-switch-tui")
615        );
616        assert_eq!(zsh.rc_file, home.join(".zshrc"));
617    }
618
619    #[test]
620    #[serial(home_settings)]
621    fn install_with_activate_creates_completion_file_and_managed_bash_block() {
622        let temp = tempfile::tempdir().expect("create temp home");
623        let _guard = TestHomeGuard::new(temp.path(), Some("/bin/bash"));
624
625        execute_install(CompletionsInstallCommand {
626            shell: ManagedShellSelection::Auto,
627            activate: true,
628        })
629        .expect("install should succeed");
630
631        let paths = completion_paths(ManagedShell::Bash, temp.path());
632        let script = fs::read_to_string(&paths.completion_file).expect("read bash completion");
633        let rc = fs::read_to_string(&paths.rc_file).expect("read bash rc");
634
635        assert!(script.contains("_cc-switch-tui"));
636        assert_eq!(marker_count(&rc), 1);
637        assert!(rc.contains(". \"$HOME/.local/share/bash-completion/completions/cc-switch-tui\""));
638    }
639
640    #[test]
641    #[serial(home_settings)]
642    fn activate_is_idempotent_for_bash() {
643        let temp = tempfile::tempdir().expect("create temp home");
644        let _guard = TestHomeGuard::new(temp.path(), Some("/bin/bash"));
645
646        execute_install(CompletionsInstallCommand {
647            shell: ManagedShellSelection::Auto,
648            activate: true,
649        })
650        .expect("first install should succeed");
651        execute_install(CompletionsInstallCommand {
652            shell: ManagedShellSelection::Auto,
653            activate: true,
654        })
655        .expect("second install should succeed");
656
657        let paths = completion_paths(ManagedShell::Bash, temp.path());
658        let rc = fs::read_to_string(&paths.rc_file).expect("read bash rc");
659        assert_eq!(marker_count(&rc), 1);
660    }
661
662    #[test]
663    #[serial(home_settings)]
664    fn uninstall_removes_only_managed_block_and_keeps_surrounding_bash_content() {
665        let temp = tempfile::tempdir().expect("create temp home");
666        let _guard = TestHomeGuard::new(temp.path(), Some("/bin/bash"));
667        let paths = completion_paths(ManagedShell::Bash, temp.path());
668        write_text_file(
669            &paths.rc_file,
670            "export PATH=\"$HOME/.local/bin:$PATH\"\n\n# >>> cc-switch completions >>>\nlegacy\n# <<< cc-switch completions <<<\n\nalias ll='ls -al'\n",
671        )
672        .expect("write bash rc");
673
674        execute_uninstall(CompletionLifecycleCommand {
675            shell: ManagedShellSelection::Auto,
676        })
677        .expect("uninstall should succeed");
678
679        let rc = fs::read_to_string(&paths.rc_file).expect("read bash rc");
680        assert!(!rc.contains(MANAGED_BLOCK_START));
681        assert!(rc.contains("export PATH=\"$HOME/.local/bin:$PATH\""));
682        assert!(rc.contains("alias ll='ls -al'"));
683    }
684
685    #[test]
686    #[serial(home_settings)]
687    fn zsh_activate_inserts_managed_block_before_existing_compinit() {
688        let temp = tempfile::tempdir().expect("create temp home");
689        let _guard = TestHomeGuard::new(temp.path(), Some("/bin/zsh"));
690        let paths = completion_paths(ManagedShell::Zsh, temp.path());
691        write_text_file(
692            &paths.rc_file,
693            "export PATH=\"$HOME/.local/bin:$PATH\"\nautoload -Uz compinit\ncompinit\n",
694        )
695        .expect("seed zshrc");
696
697        execute_install(CompletionsInstallCommand {
698            shell: ManagedShellSelection::Auto,
699            activate: true,
700        })
701        .expect("install should succeed");
702
703        let rc = fs::read_to_string(&paths.rc_file).expect("read zshrc");
704        let block_pos = rc.find(MANAGED_BLOCK_START).expect("managed block exists");
705        let compinit_pos = rc.find("autoload -Uz compinit").expect("compinit exists");
706        assert!(block_pos < compinit_pos);
707        assert!(!rc[block_pos..compinit_pos].contains("compinit\ncompinit"));
708    }
709
710    #[test]
711    #[serial(home_settings)]
712    fn zsh_activate_bootstraps_compinit_when_missing() {
713        let temp = tempfile::tempdir().expect("create temp home");
714        let _guard = TestHomeGuard::new(temp.path(), Some("/bin/zsh"));
715
716        execute_install(CompletionsInstallCommand {
717            shell: ManagedShellSelection::Auto,
718            activate: true,
719        })
720        .expect("install should succeed");
721
722        let paths = completion_paths(ManagedShell::Zsh, temp.path());
723        let rc = fs::read_to_string(&paths.rc_file).expect("read zshrc");
724        assert!(rc.contains("autoload -Uz compinit"));
725        assert!(rc.contains("\ncompinit\n"));
726    }
727
728    #[test]
729    #[serial(home_settings)]
730    fn status_reports_missing_activation_when_install_did_not_edit_rc() {
731        let temp = tempfile::tempdir().expect("create temp home");
732        let _guard = TestHomeGuard::new(temp.path(), Some("/bin/bash"));
733
734        execute_install(CompletionsInstallCommand {
735            shell: ManagedShellSelection::Auto,
736            activate: false,
737        })
738        .expect("install should succeed");
739
740        let snapshot = completion_status(ManagedShellSelection::Auto).expect("status");
741        assert!(snapshot.completion_file_exists);
742        assert!(!snapshot.activation_managed);
743        assert!(snapshot.next_step.contains("--activate"));
744    }
745}