Skip to main content

zoi_cli/cmd/
alt.rs

1//! The `zoi alt` command: inspecting and choosing alternative implementations.
2//!
3//! `awk`, `vi` and `editor` each have several interchangeable implementations,
4//! and the system still has to put *something* at the canonical path. This
5//! command is how an administrator sees what is registered and changes which
6//! one is current, without reinstalling anything.
7//!
8//! See `crates/core/src/alternatives.rs` for the mechanism and the layout.
9
10use anyhow::{Result, bail};
11use colored::Colorize;
12use comfy_table::Table;
13use comfy_table::presets::UTF8_FULL;
14use zoi_core::alternatives::{self, AlternativeStatus};
15
16use crate::cli::AltCommand;
17
18/// Runs the `alt` subcommand.
19///
20/// The argument is the clap-generated enum rather than a hand-rolled one, so
21/// `zoi alt --help` and the shell completions stay in step with the
22/// implementation automatically.
23///
24/// # Errors
25///
26/// Returns an error if the named alternative does not exist, if `set` names a
27/// path that is not registered for it, or if the registry state cannot be read
28/// or written. Every failure is reported without touching the current
29/// selection, because group state is written to a temporary file and renamed.
30pub fn run(args: &AltCommand) -> Result<()> {
31    match args {
32        AltCommand::List { verbose } => list(*verbose),
33        AltCommand::Get { name } => get(name),
34        AltCommand::Set { name, path } => set(name, path),
35        AltCommand::Auto { name } => auto(name),
36        AltCommand::Remove { name, path } => remove(name, path)
37    }
38}
39
40/// Prints every registered alternative group.
41fn list(verbose: bool) -> Result<()> {
42    let statuses = alternatives::status()?;
43
44    if statuses.is_empty() {
45        println!(
46            "No alternatives are registered. A package registers one by \
47             declaring an 'alternatives' block in its .pkg.lua."
48        );
49        return Ok(());
50    }
51
52    if !verbose {
53        let mut table = Table::new();
54        table.load_style(UTF8_FULL);
55        table.set_header(vec![
56            "Name",
57            "Link",
58            "Current",
59            "Source",
60            "Registered",
61        ]);
62
63        for status in &statuses {
64            let current = status.current.clone().unwrap_or_else(|| "-".into());
65
66            table.add_row(vec![
67                status.name.clone(),
68                status.link.clone(),
69                current,
70                if status.manual { "manual" } else { "auto" }.to_string(),
71                status
72                    .alternatives
73                    .iter()
74                    .map(|a| format!("{} ({})", a.path, a.priority))
75                    .collect::<Vec<_>>()
76                    .join("\n"),
77            ]);
78        }
79
80        println!("{table}");
81        return Ok(());
82    }
83
84    // Verbose output groups by name so every candidate and its owner is
85    // legible.
86    for status in &statuses {
87        print_group(status);
88    }
89
90    Ok(())
91}
92
93/// Prints one group in full.
94fn print_group(status: &AlternativeStatus) {
95    println!("{} -> {}", status.name.bold(), status.link);
96
97    let current = status.current.clone().unwrap_or_else(|| "-".into());
98    let mut table = Table::new();
99    table.load_style(UTF8_FULL);
100    table.set_header(vec!["Priority", "Path", "Package", "Current"]);
101
102    // Highest priority first, so the listing reads in the order a reader cares
103    // about rather than in registration order.
104    let mut sorted = status.alternatives.clone();
105    sorted.sort_by_key(|a| std::cmp::Reverse(a.priority));
106
107    for alternative in sorted {
108        let is_current = Some(&alternative.path) == status.current.as_ref();
109        table.add_row(vec![
110            alternative.priority.to_string(),
111            alternative.path.clone(),
112            alternative.package.clone(),
113            if is_current {
114                "*".to_string()
115            } else {
116                String::new()
117            },
118        ]);
119    }
120
121    println!("{table}");
122    println!(
123        "  selected: {} ({})\n",
124        current,
125        if status.manual {
126            "manual"
127        } else {
128            "highest priority"
129        }
130    );
131}
132
133/// Shows one group.
134fn get(name: &str) -> Result<()> {
135    let Some(group) = alternatives::get(name)? else {
136        bail!("No such alternative: '{name}'. Run 'zoi alt list' to see them.");
137    };
138
139    print_group(&group.status());
140    Ok(())
141}
142
143/// Manually selects an implementation.
144fn set(name: &str, path: &str) -> Result<()> {
145    alternatives::set(name, path)?;
146
147    let group = alternatives::get(name)?
148        .ok_or_else(|| anyhow::anyhow!("Alternative '{name}' disappeared"))?;
149
150    println!(
151        "{} '{}' now points to {}",
152        "Updated".green().bold(),
153        group.link,
154        path.bold()
155    );
156
157    Ok(())
158}
159
160/// Returns to priority-based selection.
161fn auto(name: &str) -> Result<()> {
162    if let Some(selected) = alternatives::auto(name)? {
163        println!(
164            "{} '{}' now points to {} (highest priority)",
165            "Updated".green().bold(),
166            name,
167            selected.bold()
168        );
169        Ok(())
170    } else {
171        println!("Alternative '{name}' has no registered implementations.");
172        Ok(())
173    }
174}
175
176/// Removes one implementation from a group.
177fn remove(name: &str, path: &str) -> Result<()> {
178    alternatives::remove_implementation(name, path)?;
179
180    println!(
181        "{} '{path}' from alternative '{name}'",
182        "Removed".green().bold()
183    );
184
185    // Report what is now current, so the user is not left guessing.
186    if let Some(group) = alternatives::get(name)?
187        && let Some(current) = group.current()
188    {
189        println!("{} now points to {}", name, current.path.as_str().bold());
190    }
191
192    Ok(())
193}
194
195/// Announces alternative changes after a transaction.
196///
197/// Called from the install, update and uninstall paths. Kept here rather than
198/// inlined at each call site so the wording stays consistent, and quiet when
199/// nothing changed, because most transactions touch no alternatives at all.
200pub fn announce_changes(changed: &[String]) {
201    if changed.is_empty() {
202        return;
203    }
204
205    for name in changed {
206        match alternatives::get(name) {
207            Ok(Some(group)) => match group.current() {
208                Some(current) => println!(
209                    "{} alternative '{}' -> {}",
210                    "::".blue().bold(),
211                    name,
212                    current.path.as_str().bold()
213                ),
214                None => println!(
215                    "{} alternative '{}' has no implementations",
216                    "::".blue().bold(),
217                    name
218                )
219            },
220            Ok(None) => {
221                println!("{} alternative '{name}' removed", "::".blue().bold());
222            }
223            // Reporting must never fail a transaction that already succeeded.
224            Err(_) => {}
225        }
226    }
227}