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 #[clap(name = "list")]
39 List(ListCommand),
40 #[clap(name = "del")]
42 Del(DelCommand),
43 #[clap(name = "new")]
45 New(NewCommand),
46 #[clap(name = "default")]
48 Default(DefaultCommand),
49 #[clap(name = "edit")]
51 Edit(EditCommand),
52}
53
54#[derive(Args, Debug, Clone)]
55pub struct ListCommand {
56 #[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 #[clap(long = "directory", env = "WASH_CONTEXTS", hide_env_values = true)]
65 directory: Option<PathBuf>,
66
67 #[clap(name = "name")]
69 name: Option<String>,
70}
71
72#[derive(Args, Debug, Clone)]
73pub struct NewCommand {
74 #[clap(name = "name", required_unless_present("interactive"))]
76 pub name: Option<String>,
77
78 #[clap(long = "directory", env = "WASH_CONTEXTS", hide_env_values = true)]
80 directory: Option<PathBuf>,
81
82 #[clap(long = "interactive", short = 'i')]
84 interactive: bool,
85}
86
87#[derive(Args, Debug, Clone)]
88pub struct DefaultCommand {
89 #[clap(long = "directory", env = "WASH_CONTEXTS", hide_env_values = true)]
91 directory: Option<PathBuf>,
92
93 #[clap(name = "name")]
95 name: Option<String>,
96}
97
98#[derive(Args, Debug, Clone)]
99pub struct EditCommand {
100 #[clap(long = "directory", env = "WASH_CONTEXTS", hide_env_values = true)]
102 directory: Option<PathBuf>,
103
104 #[clap(name = "name")]
106 pub name: Option<String>,
107
108 #[clap(short = 'e', long = "editor", env = "EDITOR")]
110 pub editor: String,
111}
112
113fn 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
147fn 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
161fn 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
175fn 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 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
201fn 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
239fn 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
258fn 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 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}