wash_cli/ctx/
mod.rs

1use std::{
2    collections::HashMap,
3    io::{Error, ErrorKind},
4    path::PathBuf,
5    process::Command,
6};
7
8use anyhow::{bail, Result};
9use clap::{Args, Subcommand};
10use serde_json::json;
11use tracing::warn;
12use wash_lib::{
13    cli::CommandOutput,
14    config::{DEFAULT_LATTICE, DEFAULT_NATS_HOST, DEFAULT_NATS_PORT, DEFAULT_NATS_TIMEOUT_MS},
15    context::{fs::ContextDir, ContextManager, WashContext, HOST_CONFIG_NAME},
16    id::ClusterSeed,
17};
18
19use wash_lib::generate::{
20    interactive::{prompt_for_choice, user_question},
21    project_variables::StringEntry,
22};
23
24pub async fn handle_command(ctx_cmd: CtxCommand) -> Result<CommandOutput> {
25    use CtxCommand::*;
26    match ctx_cmd {
27        List(cmd) => handle_list(cmd),
28        Default(cmd) => handle_default(cmd),
29        Edit(cmd) => handle_edit(cmd),
30        New(cmd) => handle_new(cmd),
31        Del(cmd) => handle_del(cmd),
32    }
33}
34
35#[derive(Subcommand, Debug, Clone)]
36pub enum CtxCommand {
37    /// Lists all stored contexts (JSON files) found in the context directory, with the exception of index.json
38    #[clap(name = "list")]
39    List(ListCommand),
40    /// Delete a stored context
41    #[clap(name = "del")]
42    Del(DelCommand),
43    /// Create a new context
44    #[clap(name = "new")]
45    New(NewCommand),
46    /// Set the default context
47    #[clap(name = "default")]
48    Default(DefaultCommand),
49    /// Edit a context directly using a text editor
50    #[clap(name = "edit")]
51    Edit(EditCommand),
52}
53
54#[derive(Args, Debug, Clone)]
55pub struct ListCommand {
56    /// Location of context files for managing. Defaults to $WASH_CONTEXTS ($HOME/.wash/contexts)
57    #[clap(long = "directory", env = "WASH_CONTEXTS", hide_env_values = true)]
58    directory: Option<PathBuf>,
59}
60
61#[derive(Args, Debug, Clone)]
62pub struct DelCommand {
63    /// Location of context files for managing. Defaults to $WASH_CONTEXTS ($HOME/.wash/contexts)
64    #[clap(long = "directory", env = "WASH_CONTEXTS", hide_env_values = true)]
65    directory: Option<PathBuf>,
66
67    /// Name of the context to delete. If not supplied, the user will be prompted to select an existing context
68    #[clap(name = "name")]
69    name: Option<String>,
70}
71
72#[derive(Args, Debug, Clone)]
73pub struct NewCommand {
74    /// Name of the context, will be sanitized to ensure it's a valid filename
75    #[clap(name = "name", required_unless_present("interactive"))]
76    pub name: Option<String>,
77
78    /// Location of context files for managing. Defaults to $WASH_CONTEXTS ($HOME/.wash/contexts)
79    #[clap(long = "directory", env = "WASH_CONTEXTS", hide_env_values = true)]
80    directory: Option<PathBuf>,
81
82    /// Create the context in an interactive terminal prompt, instead of an autogenerated default context
83    #[clap(long = "interactive", short = 'i')]
84    interactive: bool,
85}
86
87#[derive(Args, Debug, Clone)]
88pub struct DefaultCommand {
89    /// Location of context files for managing. Defaults to $WASH_CONTEXTS ($HOME/.wash/contexts)
90    #[clap(long = "directory", env = "WASH_CONTEXTS", hide_env_values = true)]
91    directory: Option<PathBuf>,
92
93    /// Name of the context to use for default. If not supplied, the user will be prompted to select a default
94    #[clap(name = "name")]
95    name: Option<String>,
96}
97
98#[derive(Args, Debug, Clone)]
99pub struct EditCommand {
100    /// Location of context files for managing. Defaults to $WASH_CONTEXTS ($HOME/.wash/contexts)
101    #[clap(long = "directory", env = "WASH_CONTEXTS", hide_env_values = true)]
102    directory: Option<PathBuf>,
103
104    /// Name of the context to edit, if not supplied the user will be prompted to select a context
105    #[clap(name = "name")]
106    pub name: Option<String>,
107
108    /// Your terminal text editor of choice. This editor must be present in your $PATH, or an absolute filepath.
109    #[clap(short = 'e', long = "editor", env = "EDITOR")]
110    pub editor: String,
111}
112
113/// Lists all JSON files found in the context directory, with the exception of `index.json`
114/// Being present in this list does not guarantee a valid context
115fn handle_list(cmd: ListCommand) -> Result<CommandOutput> {
116    let dir = ContextDir::from_dir(cmd.directory)?;
117
118    let default_context_name = dir.default_context_name()?;
119    let contexts = dir.list_contexts()?;
120
121    let text_contexts = contexts
122        .iter()
123        .map(|f| {
124            if f == &default_context_name {
125                format!("{f} (default)")
126            } else {
127                f.clone()
128            }
129        })
130        .collect::<Vec<String>>()
131        .join("\n");
132
133    let mut map = HashMap::new();
134    map.insert("contexts".to_string(), json!(contexts));
135    map.insert("default".to_string(), json!(default_context_name));
136
137    Ok(CommandOutput::new(
138        format!(
139            "== Contexts found in {} ==\n{}",
140            dir.display(),
141            text_contexts
142        ),
143        map,
144    ))
145}
146
147/// Handles selecting a default context, which can be selected in the terminal or provided as an argument
148fn handle_default(cmd: DefaultCommand) -> Result<CommandOutput> {
149    let dir = ContextDir::from_dir(cmd.directory)?;
150
151    let new_default = if let Some(n) = cmd.name {
152        n
153    } else {
154        select_context(&dir, "Select a default context:")?.unwrap_or_default()
155    };
156
157    dir.set_default_context(&new_default)?;
158    Ok(CommandOutput::from("Set new context successfully"))
159}
160
161/// Handles deleting an existing context
162fn handle_del(cmd: DelCommand) -> Result<CommandOutput> {
163    let dir = ContextDir::from_dir(cmd.directory)?;
164
165    let ctx_to_delete = if let Some(n) = cmd.name {
166        n
167    } else {
168        select_context(&dir, "Select a context to delete:")?.unwrap_or_default()
169    };
170
171    dir.delete_context(&ctx_to_delete)?;
172    Ok(CommandOutput::from("Removed file successfully"))
173}
174
175/// Handles creating a new context by writing the default WashContext object to the specified path
176fn handle_new(cmd: NewCommand) -> Result<CommandOutput> {
177    let dir = ContextDir::from_dir(cmd.directory)?;
178
179    let mut new_context = if cmd.interactive {
180        prompt_for_context()?
181    } else {
182        WashContext::named(cmd.name.unwrap())
183    };
184
185    let options = sanitize_filename::Options {
186        truncate: true,
187        windows: true,
188        replacement: "_",
189    };
190
191    // Ensure filename doesn't include uphill/downhill\ slashes, or reserved prefixes
192    let sanitized = sanitize_filename::sanitize_with_options(&new_context.name, options);
193    new_context.name = sanitized;
194    dir.save_context(&new_context)?;
195    Ok(CommandOutput::from(format!(
196        "Created context {} with default values",
197        new_context.name
198    )))
199}
200
201/// Handles editing a context by opening the JSON file in the user's text editor of choice
202fn handle_edit(cmd: EditCommand) -> Result<CommandOutput> {
203    let dir = ContextDir::from_dir(cmd.directory)?;
204    let editor = which::which(cmd.editor)?;
205
206    let mut ctx_name = String::new();
207
208    let ctx = if let Some(ctx) = cmd.name {
209        let path = dir.get_context_path(&ctx)?;
210        ctx_name = ctx;
211        path
212    } else if let Some(name) = select_context(&dir, "Select a context to edit:")? {
213        let path = dir.get_context_path(&name)?;
214        ctx_name = name;
215        path
216    } else {
217        None
218    };
219
220    if let Some(path) = ctx {
221        if ctx_name == HOST_CONFIG_NAME {
222            warn!("Edits to the host_config context will be overwritten, make changes to the host config instead");
223        }
224        let status = Command::new(editor).arg(&path).status()?;
225
226        match status.success() {
227            true => Ok(CommandOutput::from("Finished editing context successfully")),
228            false => bail!("Failed to edit context"),
229        }
230    } else {
231        Err(Error::new(
232            ErrorKind::NotFound,
233            "Unable to find context supplied, please ensure it exists".to_string(),
234        )
235        .into())
236    }
237}
238
239/// Prompts the user with the provided `contexts` choices and returns the user's response.
240/// This can be used to determine which context to delete, edit, or set as a default, for example
241fn select_context(dir: &ContextDir, prompt: &str) -> Result<Option<String>> {
242    let default = dir.default_context_name()?;
243    let choices: Vec<String> = dir.list_contexts()?;
244
245    let entry = StringEntry {
246        default: Some(default),
247        choices: Some(choices.clone()),
248        regex: None,
249    };
250
251    if let Ok(choice) = prompt_for_choice(&entry, prompt) {
252        Ok(choices.get(choice).map(|c| c.to_string()))
253    } else {
254        Ok(None)
255    }
256}
257
258/// Prompts the user interactively for context values, returning a constructed context
259fn prompt_for_context() -> Result<WashContext> {
260    let name = user_question(
261        "What do you want to name the context?",
262        &Some("default".to_string()),
263    )?;
264
265    let cluster_seed = match user_question(
266        "What cluster seed do you want to use to sign invocations?",
267        &Some(String::new()),
268    ) {
269        Ok(s) if s.is_empty() => None,
270        Ok(s) => Some(s.parse::<ClusterSeed>()?),
271        _ => None,
272    };
273    let ctl_host = user_question(
274        "What is the control interface connection host?",
275        &Some(DEFAULT_NATS_HOST.to_string()),
276    )?;
277    let ctl_port = user_question(
278        "What is the control interface connection port?",
279        &Some(DEFAULT_NATS_PORT.to_string()),
280    )?;
281    let ctl_jwt = match user_question(
282        "Enter your JWT that you use to authenticate to the control interface connection, if applicable",
283        &Some(String::new()),
284    ) {
285        Ok(s) if s.is_empty() => None,
286        Ok(s) => Some(s),
287        _ => None,
288    };
289    let ctl_seed = match user_question(
290        "Enter your user seed that you use to authenticate to the control interface connection, if applicable",
291        &Some(String::new()),
292    ) {
293        Ok(s) if s.is_empty() => None,
294        Ok(s) => Some(s),
295        _ => None,
296    };
297    let ctl_credsfile = match user_question(
298        "Enter the absolute path to control interface connection credsfile, if applicable",
299        &Some(String::new()),
300    ) {
301        Ok(s) if s.is_empty() => None,
302        Ok(s) => Some(s),
303        _ => None,
304    };
305
306    let ctl_tls_ca_file = match user_question(
307        "Enter the absolute path to the CTL connection CA file, if applicable",
308        &Some(String::new()),
309    ) {
310        Ok(s) if s.is_empty() => None,
311        Ok(s) => Some(s),
312        _ => None,
313    };
314
315    let ctl_timeout = user_question(
316        "What should the control interface timeout be (in milliseconds)?",
317        &Some(DEFAULT_NATS_TIMEOUT_MS.to_string()),
318    )?;
319
320    let ctl_tls_first = match prompt_for_choice(
321        &StringEntry {
322            default: Some("false".to_string()),
323            choices: Some(vec!["true".to_string(), "false".to_string()]),
324            regex: None,
325        },
326        "Should the control interface use TLS first?",
327    ) {
328        Ok(0) => Some(true),
329        Ok(1) => Some(false),
330        _ => None,
331    };
332
333    let lattice = user_question(
334        "What is the lattice prefix that the host will communicate on?",
335        &Some(DEFAULT_LATTICE.to_string()),
336    )?;
337
338    let js_domain = match user_question(
339        "What JetStream domain will the host be running, if any?",
340        &Some(String::new()),
341    ) {
342        Ok(s) if s.is_empty() => None,
343        Ok(s) => Some(s),
344        _ => None,
345    };
346
347    let rpc_host = user_question(
348        "What is the RPC host?",
349        &Some(DEFAULT_NATS_HOST.to_string()),
350    )?;
351    let rpc_port = user_question(
352        "What is the RPC connection port?",
353        &Some(DEFAULT_NATS_PORT.to_string()),
354    )?;
355    let rpc_jwt = match user_question(
356        "Enter your JWT that you use to authenticate to the RPC connection, if applicable",
357        &Some(String::new()),
358    ) {
359        Ok(s) if s.is_empty() => None,
360        Ok(s) => Some(s),
361        _ => None,
362    };
363    let rpc_seed = match user_question(
364        "Enter your user seed that you use to authenticate to the RPC connection, if applicable",
365        &Some(String::new()),
366    ) {
367        Ok(s) if s.is_empty() => None,
368        Ok(s) => Some(s),
369        _ => None,
370    };
371    let rpc_credsfile = match user_question(
372        "Enter the absolute path to RPC connection credsfile, if applicable",
373        &Some(String::new()),
374    ) {
375        Ok(s) if s.is_empty() => None,
376        Ok(s) => Some(s),
377        _ => None,
378    };
379    let rpc_tls_ca_file = match user_question(
380        "Enter the absolute path to the RPC connection CA file, if applicable",
381        &Some(String::new()),
382    ) {
383        Ok(s) if s.is_empty() => None,
384        Ok(s) => Some(s),
385        _ => None,
386    };
387    let rpc_timeout = user_question(
388        "What should the RPC timeout be (in milliseconds)?",
389        &Some(DEFAULT_NATS_TIMEOUT_MS.to_string()),
390    )?;
391
392    let rpc_tls_first = match prompt_for_choice(
393        &StringEntry {
394            default: Some("false".to_string()),
395            choices: Some(vec!["true".to_string(), "false".to_string()]),
396            regex: None,
397        },
398        "Should the control interface use TLS first?",
399    ) {
400        Ok(0) => Some(true),
401        Ok(1) => Some(false),
402        _ => None,
403    };
404
405    Ok(WashContext {
406        name,
407        cluster_seed,
408        ctl_host,
409        ctl_port: ctl_port.parse().unwrap_or_default(),
410        ctl_jwt,
411        ctl_seed,
412        ctl_tls_ca_file: ctl_tls_ca_file.map(PathBuf::from),
413        ctl_credsfile: ctl_credsfile.map(PathBuf::from),
414        ctl_timeout: ctl_timeout.parse()?,
415        ctl_tls_first,
416        lattice,
417        js_domain,
418        rpc_host,
419        rpc_port: rpc_port.parse().unwrap_or_default(),
420        rpc_jwt,
421        rpc_seed,
422        rpc_credsfile: rpc_credsfile.map(PathBuf::from),
423        rpc_tls_ca_file: rpc_tls_ca_file.map(PathBuf::from),
424        rpc_timeout: rpc_timeout.parse()?,
425        rpc_tls_first,
426    })
427}
428
429#[cfg(test)]
430mod test {
431    use super::*;
432    use clap::Parser;
433
434    #[derive(Parser)]
435    struct Cmd {
436        #[clap(subcommand)]
437        cmd: CtxCommand,
438    }
439    #[test]
440    // Enumerates all options of ctx subcommands to ensure
441    // changes are not made to the ctx API
442    fn test_ctx_comprehensive() {
443        let cmd: Cmd = Parser::try_parse_from([
444            "ctx",
445            "new",
446            "my_name",
447            "--interactive",
448            "--directory",
449            "./contexts",
450        ])
451        .unwrap();
452
453        match cmd.cmd {
454            CtxCommand::New(cmd) => {
455                assert_eq!(cmd.directory.unwrap(), PathBuf::from("./contexts"));
456                assert!(cmd.interactive);
457                assert_eq!(cmd.name.unwrap(), "my_name");
458            }
459            _ => panic!("ctx constructed incorrect command"),
460        }
461
462        let cmd: Cmd = Parser::try_parse_from([
463            "ctx",
464            "edit",
465            "my_context",
466            "--editor",
467            "vim",
468            "--directory",
469            "./contexts",
470        ])
471        .unwrap();
472        match cmd.cmd {
473            CtxCommand::Edit(cmd) => {
474                assert_eq!(cmd.directory.unwrap(), PathBuf::from("./contexts"));
475                assert_eq!(cmd.editor, "vim");
476                assert_eq!(cmd.name.unwrap(), "my_context");
477            }
478            _ => panic!("ctx constructed incorrect command"),
479        }
480
481        let cmd: Cmd =
482            Parser::try_parse_from(["ctx", "del", "my_context", "--directory", "./contexts"])
483                .unwrap();
484        match cmd.cmd {
485            CtxCommand::Del(cmd) => {
486                assert_eq!(cmd.directory.unwrap(), PathBuf::from("./contexts"));
487                assert_eq!(cmd.name.unwrap(), "my_context");
488            }
489            _ => panic!("ctx constructed incorrect command"),
490        }
491
492        let cmd: Cmd =
493            Parser::try_parse_from(["ctx", "list", "--directory", "./contexts"]).unwrap();
494        match cmd.cmd {
495            CtxCommand::List(cmd) => {
496                assert_eq!(cmd.directory.unwrap(), PathBuf::from("./contexts"));
497            }
498            _ => panic!("ctx constructed incorrect command"),
499        }
500
501        let cmd: Cmd =
502            Parser::try_parse_from(["ctx", "default", "host_config", "--directory", "./contexts"])
503                .unwrap();
504        match cmd.cmd {
505            CtxCommand::Default(cmd) => {
506                assert_eq!(cmd.directory.unwrap(), PathBuf::from("./contexts"));
507                assert_eq!(cmd.name.unwrap(), "host_config");
508            }
509            _ => panic!("ctx constructed incorrect command"),
510        }
511    }
512}