Skip to main content

mj_controller/controller/
path_completion.rs

1//! One entry point for completing a path on the machine that owns it.
2//!
3//! Every path field in every surface asks the same question: given what the
4//! user typed and the host the path belongs to, what could it become? The
5//! host mapping, the `~` round trip, and the candidate limit live here so no
6//! screen has to repeat them.
7
8use std::path::Path;
9
10use anyhow::{Context, Result};
11use mj_core::path_completion::{
12    CompletionHost, CompletionKind, MAX_CANDIDATES, PathCompletion, common_insert,
13    local_completions, ssh_completions,
14};
15
16use super::Controller;
17use super::cache_host::CacheHost;
18use crate::targets::CommandExecutor;
19
20impl Controller {
21    /// Complete `prefix` on `host`. Call only from background work: it may run
22    /// a command on a remote machine.
23    pub fn complete_path(
24        &self,
25        host: &CompletionHost,
26        prefix: &str,
27        kind: CompletionKind,
28        executor: &impl CommandExecutor,
29    ) -> Result<PathCompletion> {
30        if prefix.is_empty() {
31            return Ok(PathCompletion::default());
32        }
33        let host = match host {
34            CompletionHost::Local => CacheHost::Local,
35            CompletionHost::Target(target_id) => {
36                let target = self
37                    .config
38                    .targets
39                    .get(target_id)
40                    .with_context(|| format!("unknown target template {target_id:?}"))?;
41                CacheHost::for_path_target(target)?
42            }
43            CompletionHost::Machine(machine) => CacheHost::for_path_machine(machine)?,
44        };
45        let home = if mj_core::path_input::needs_home(Path::new(prefix))? {
46            Some(host.home(executor)?)
47        } else {
48            None
49        };
50        let expanded = mj_core::path_input::expand_home(Path::new(prefix), home.as_deref())?;
51        let mut lookup = expanded.to_string_lossy().into_owned();
52        // Completion is a text protocol: a trailing separator requests children.
53        if prefix.ends_with('/') && !lookup.ends_with('/') {
54            lookup.push('/');
55        }
56        let mut candidates = match &host {
57            CacheHost::Local => local_completions(&lookup, kind),
58            CacheHost::Ssh(ssh) => ssh_completions(ssh, &lookup, kind, executor)?,
59        };
60        let truncated = candidates.len() > MAX_CANDIDATES;
61        candidates.truncate(MAX_CANDIDATES);
62        let candidates = candidates
63            .into_iter()
64            .map(|candidate| fold_home(candidate, home.as_deref()))
65            .collect::<Result<Vec<_>>>()?;
66        let insert = common_insert(prefix, &candidates);
67        Ok(PathCompletion {
68            candidates,
69            insert,
70            truncated,
71        })
72    }
73}
74
75/// Return a candidate in the shape the user typed: a path under a home that
76/// was expanded for the host goes back to `~/...`.
77fn fold_home(candidate: String, home: Option<&Path>) -> Result<String> {
78    let Some(home) = home else {
79        return Ok(candidate);
80    };
81    let suffix = Path::new(&candidate)
82        .strip_prefix(home)
83        .context("Completed path is outside the requested home")?;
84    let mut value = Path::new("~").join(suffix).to_string_lossy().into_owned();
85    if candidate.ends_with('/') && !value.ends_with('/') {
86        value.push('/');
87    }
88    Ok(value)
89}