ezcli-win 0.1.1

Windows shell helper for developer workflows such as loading MSVC environments and switching projects
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
mod config;
mod env_capture;
mod render;
mod wrapper;

use clap::{Parser, Subcommand, ValueEnum};
use colored::Colorize;
use config::{
    Config, Project, add_project, delete_project, find_project, get_config_path, load_config,
    save_config,
};
use env_capture::capture_vcvars_env;
use inquire::{Select, error::InquireError};
use render::{ScriptPlan, ShellKind, render_cmd_script, render_powershell_script};
use std::path::PathBuf;
use wrapper::{
    build_powershell_profile_source_line, get_powershell_profile_path,
    install_powershell_profile_source_line, render_powershell_wrapper_script,
    save_cmd_wrapper_scripts, save_powershell_wrapper_script,
};

use std::collections::HashMap;
use std::env;
use std::env::home_dir;
use std::fs::{self, create_dir_all};
use std::io;
use std::mem;
use windows::Win32::System::Com::{
    CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
};
use windows::Win32::UI::Controls::Dialogs::{
    GetOpenFileNameW, OFN_FILEMUSTEXIST, OFN_PATHMUSTEXIST, OPENFILENAMEW,
};
use windows::Win32::UI::Shell::{FILEOPENDIALOGOPTIONS, FOS_PICKFOLDERS, IFileOpenDialog};
use windows::core::PWSTR;
use winreg::RegKey;
use winreg::enums::*;

const MAX_PATH: u32 = 260;

struct ComInitializer;
impl ComInitializer {
    fn new() -> Self {
        unsafe {
            let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
        }
        ComInitializer
    }
}
impl Drop for ComInitializer {
    fn drop(&mut self) {
        unsafe {
            CoUninitialize();
        }
    }
}

#[derive(Parser)]
#[command(version, about = "Windows shell wrapper helper for MSVC and project switching", long_about = None, after_help = "\
  Installed wrapper commands after `ezcli --find-cl`:
    ecl                         Load MSVC cl environment into current shell
    ep <name>                   Enter project and prepend project path
    ezcli-load-cl               Long wrapper command for ecl
    ezcli-enter-project <name>  Long wrapper command for ep

  Examples:
    ezcli --find-cl
    ecl
    ep handmade
    ezcli emit --shell powershell load-cl
  "
)]
struct Cli {
    #[command(subcommand)]
    command: Option<Commands>,

    /// Run a setup wizard to find vcvarsall.bat and install shell wrappers.
    #[arg(short, long)]
    find_cl: bool,

    /// Show the configured vcvarsall.bat path.
    #[arg(short, long)]
    show_cl: bool,

    /// Add a project path to ezcli config.
    #[arg(short, long)]
    add_project: Option<String>,

    /// Select a project and show its configured path.
    #[arg(short = 'w', long)]
    show_project: bool,

    /// Select and delete a project from ezcli config.
    #[arg(short, long)]
    del_project: bool,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
enum ShellArg {
    /// Emit cmd.exe compatible script.
    Cmd,

    /// Emit Windows PowerShell compatible script.
    Powershell,
}

#[derive(Debug, Eq, PartialEq, Subcommand)]
enum EmitAction {
    /// Emit script to load the MSVC cl environment.
    LoadCl,

    /// Emit script to enter a configured project.
    EnterProject {
        /// Project name from ezcli config.
        name: String,
    },

    /// Emit wrapper bootstrap script for the selected shell.
    Init,

    /// Install wrapper files for the selected shell.
    InstallWrapper,

    /// Show profile information for the selected shell.
    ShowProfile,

    /// Install profile integration for the selected shell.
    InstallProfile,
}

#[derive(Debug, Eq, PartialEq, Subcommand)]
enum Commands {
    /// Emit shell script or install shell wrappers.
    Emit {
        /// Target shell for emitted scripts or wrappers.
        #[arg(long, value_enum)]
        shell: ShellArg,

        #[command(subcommand)]
        action: EmitAction,
    },
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();

    if let Some(Commands::Emit { shell, action }) = cli.command {
        if let Err(error) = handle_emit(shell, action) {
            eprintln!("{error}");
            std::process::exit(1);
        }
        return Ok(());
    }

    if cli.find_cl {
        let mut file_buf = [0u16; MAX_PATH as usize];

        let mut ofn = OPENFILENAMEW {
            lStructSize: mem::size_of::<OPENFILENAMEW>() as u32,
            lpstrFile: PWSTR(file_buf.as_mut_ptr()),
            nMaxFile: file_buf.len() as u32,
            Flags: OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST,
            ..Default::default()
        };
        let str = unsafe {
            if GetOpenFileNameW(&mut ofn).as_bool() {
                let len = file_buf
                    .iter()
                    .position(|&c| c == 0)
                    .unwrap_or(file_buf.len());
                Some(String::from_utf16_lossy(&file_buf[..len]))
            } else {
                None
            }
        };

        let cl_str = str.unwrap_or_else(|| "".to_string());

        println!("file path is {}", cl_str.green().bold());

        if cl_str.as_str().ends_with("vcvarsall.bat") {
            add_ezcli_to_path()?;
            let config_file = get_config_path().ok_or("get config path failed!".red())?;
            let config_file_dir = config_file.parent().unwrap();

            if !config_file_dir.exists() {
                create_dir_all(config_file_dir).map_err(|_| "create config dir failed!".red())?;
            }
            if !config_file.exists() {
                let default_config = Config {
                    vc_path: cl_str,
                    default_arch: "x64".to_string(),
                    projects: vec![],
                };

                let toml_content = toml::to_string_pretty(&default_config)
                    .map_err(|_| "toml to_string_pretty failed!".red())?;

                fs::write(&config_file, toml_content)?;

                println!("already create new ezcli.toml!");
            } else {
                let mut config = load_config().map_err(|_| "load config file failed!".red())?;
                config.vc_path = cl_str;
                save_config(&config).map_err(|_| "save config file failed!".red())?;
            }
            println!("{}", "config updated:".green().bold());
            println!("  vcvarsall.bat saved to ezcli config");

            println!();
            println!("{}", "cmd setup:".green().bold());
            let cmd_wrapper_path = install_wrapper(ShellArg::Cmd)?;
            for wrapper_path in cmd_wrapper_path {
                println!(
                    "cmd wrapper save to {}",
                    wrapper_path.display().to_string().green()
                );
            }
            println!("open a new cmd.exe, then you can run:");
            println!("  {}", "ecl".green());
            println!("  {} {}", "ep".green(), "<name>".yellow());
            println!("long commands are still available:");
            println!("  {}", "ezcli-load-cl".green());
            println!("  {} {}", "ezcli-enter-project".green(), "<name>".yellow());
            println!("if cmd was already open before PATH changed, reopen it first");

            println!();
            println!("{}", "powershell setup:".green().bold());
            let powershell_wrapper_path = install_wrapper(ShellArg::Powershell)?;
            if let Some(wrapper_path) = powershell_wrapper_path.first() {
                println!(
                    "powershell wrapper save to {}",
                    wrapper_path.display().to_string().green()
                );
            }
            println!(
                "  short commands: {} / {} {}",
                "ecl".green(),
                "ep".green(),
                "<name>".yellow()
            );

            println!();

            let answer = confirm_continue("write ezcli wrapper into Powershell profile?");

            if answer {
                let message = install_profile(ShellArg::Powershell)?;
                println!("  {}", message.green());
            } else {
                let profile_path = get_powershell_profile_path()?;
                let source_line = build_powershell_profile_source_line()?;

                println!("  {}", "skip writing Powershell profile".yellow());
                println!(
                    "  Powershell profile path: {}",
                    profile_path.display().to_string().green()
                );
                println!(
                    "  you can manually add this line to your Powershell profile: {}",
                    source_line.green()
                );
            }
        } else {
            println!("current file is not cl vcvarsall.bat!");
        }
    }

    if cli.show_cl {
        let config = load_config().map_err(|_| "load config file failed!".red())?;
        println!("cl at {}", config.vc_path.as_str().green());
    }

    if let Some(name) = cli.add_project.as_deref() {
        println!("add new project: {}", name.green());
        println!("{}", "please find project path".yellow());

        let project_path_str = select_folder_modern().unwrap_or("".to_string());

        println!("project_path_str: {}", &project_path_str.green());

        let mut config = load_config().map_err(|_| "load config file failed!".red())?;

        add_project(&mut config, name, &project_path_str);
    }

    if cli.show_project {
        let config = load_config().map_err(|_| "load config file failed!".red())?;
        let projects_map: HashMap<String, String> = config
            .projects
            .iter()
            .map(|p| (p.name.clone(), p.path.clone()))
            .collect();
        let project_names: Vec<&str> = projects_map.keys().map(|s| s.as_str()).collect();

        let ans: Result<&str, InquireError> =
            Select::new("select to show path", project_names).prompt();

        match ans {
            Ok(choice) => {
                let path = projects_map
                    .get(choice)
                    .ok_or("can't find the project".red())?;
                println!("\n project {} path is {}", choice.green(), path.green());
            }
            Err(_) => {
                println!(
                    "{}",
                    "There was an error when select project to show, please try again"
                        .red()
                        .bold()
                )
            }
        }
    }

    if cli.del_project {
        let config = load_config().map_err(|_| "load config file failed!".red())?;
        let project_names: Vec<&str> = config.projects.iter().map(|p| p.name.as_str()).collect();
        let options: Vec<&str> = project_names;

        let ans: Result<&str, InquireError> = Select::new("select to delete", options).prompt();

        match ans {
            Ok(choice) => {
                let answer = confirm_continue(
                    format!("will delete {} project, continue?", choice.green()).as_str(),
                );

                if answer {
                    let mut config = load_config().map_err(|_| "load config file failed!".red())?;
                    delete_project(&mut config, choice)?;
                }
            }
            Err(_) => {
                println!(
                    "{}",
                    "There was an error when select project to delete, please try again"
                        .red()
                        .bold()
                )
            }
        }
    }

    Ok(())
}

fn confirm_continue(prompt: &str) -> bool {
    loop {
        println!("{prompt} y/n");

        let mut input = String::new();
        io::stdin()
            .read_line(&mut input)
            .expect(format!("{}", "read input line failed!".red().bold()).as_str());

        match input.trim().to_lowercase().as_str() {
            "y" => return true,
            "n" => return false,
            _ => println!("{}", "please input y or n \n".yellow()),
        }
    }
}

pub fn select_folder_modern() -> Option<String> {
    unsafe {
        let _com_init = ComInitializer::new();

        let dialog_result: Result<IFileOpenDialog, windows::core::Error> =
            CoCreateInstance(&windows::Win32::UI::Shell::FileOpenDialog, None, CLSCTX_ALL);

        let dialog = dialog_result.ok()?;

        let options: FILEOPENDIALOGOPTIONS = Default::default();
        dialog.GetOptions().ok()?;
        dialog.SetOptions(options | FOS_PICKFOLDERS).ok()?;

        if dialog.Show(None).is_err() {
            return None;
        }

        let item_result = dialog.GetResult();
        let item = item_result.ok()?;

        let display_name = item
            .GetDisplayName(windows::Win32::UI::Shell::SIGDN_FILESYSPATH)
            .ok()?;

        let path = display_name.to_string().ok()?;

        Some(path)
    }
}

pub fn add_ezcli_to_path() -> Result<bool, Box<dyn std::error::Error>> {
    let home = home_dir().ok_or("get home dir failed".red())?;
    let cli_dir = home.join(".ezcli");
    let cli_dir_str = cli_dir.to_str().unwrap_or("");

    if !cli_dir.exists() {
        create_dir_all(cli_dir_str).map_err(|_| "create config dir failed!".red())?;
    }

    let hklm = RegKey::predef(HKEY_LOCAL_MACHINE);

    let env_key = hklm.open_subkey_with_flags(
        "SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
        KEY_READ | KEY_WRITE,
    )?;

    let current: String = env_key.get_value("PATH").unwrap_or_default();

    if !current
        .split(';')
        .any(|p| p.eq_ignore_ascii_case(cli_dir_str))
    {
        let new_path = if current.is_empty() {
            cli_dir_str.to_string()
        } else {
            format!("{};{}", current, cli_dir_str)
        };

        env_key.set_value("PATH", &new_path)?;

        println!("already let {} write into HKLM PATH", cli_dir_str.green());
    } else {
        println!("HKLM PATH already has {}, skip", cli_dir_str.green());
    }

    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
    let env_key = hkcu.open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)?;

    let current: String = env_key.get_value("PATH").unwrap_or_default();

    if !current
        .split(';')
        .any(|p| p.eq_ignore_ascii_case(cli_dir_str))
    {
        let new_path = if current.is_empty() {
            cli_dir_str.to_string()
        } else {
            format!("{};{}", current, cli_dir_str)
        };
        env_key.set_value("PATH", &new_path)?;

        println!("already let {} write into HKCU PATH", cli_dir_str.green());
    } else {
        println!("HKCU PATH already has {}, skip", cli_dir_str.green());
    }

    Ok(true)
}

fn to_shell_kind(shell: ShellArg) -> ShellKind {
    match shell {
        ShellArg::Cmd => ShellKind::Cmd,
        ShellArg::Powershell => ShellKind::Powershell,
    }
}

fn build_load_cl_script(
    shell: ShellArg,
    captured: std::collections::BTreeMap<String, String>,
) -> String {
    let plan = ScriptPlan {
        set_env: captured,
        prepend_path: Vec::new(),
        cwd: None,
    };

    match to_shell_kind(shell) {
        ShellKind::Cmd => render_cmd_script(&plan),
        ShellKind::Powershell => render_powershell_script(&plan),
    }
}

fn build_enter_project_script(shell: ShellArg, project: &Project) -> String {
    let plan = ScriptPlan {
        set_env: Default::default(),
        prepend_path: vec![PathBuf::from(&project.path)],
        cwd: Some(PathBuf::from(&project.path)),
    };

    match to_shell_kind(shell) {
        ShellKind::Cmd => render_cmd_script(&plan),
        ShellKind::Powershell => render_powershell_script(&plan),
    }
}

fn build_init_script(shell: ShellArg) -> Result<String, Box<dyn std::error::Error>> {
    let program = env::current_exe()?;
    let program = program.to_string_lossy().into_owned();

    match to_shell_kind(shell) {
        ShellKind::Powershell => Ok(render_powershell_wrapper_script(&program)),
        ShellKind::Cmd => Err(io::Error::new(
            io::ErrorKind::Unsupported,
            "cmd init is not supported; use `ezcli emit --shell cmd install-wrapper` instead",
        )
        .into()),
    }
}

fn install_wrapper(shell: ShellArg) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
    let program = env::current_exe()?;
    let program = program.to_string_lossy().into_owned();

    match to_shell_kind(shell) {
        ShellKind::Powershell => Ok(vec![save_powershell_wrapper_script(&program)?]),
        ShellKind::Cmd => save_cmd_wrapper_scripts(&program),
    }
}

fn show_profile(shell: ShellArg) -> Result<String, Box<dyn std::error::Error>> {
    match to_shell_kind(shell) {
        ShellKind::Powershell => {
            let profile_path = get_powershell_profile_path()?;
            let source_line = build_powershell_profile_source_line()?;

            Ok(format!(
                "profile path: {}\nsource line: {}",
                profile_path.display(),
                source_line
            ))
        }
        ShellKind::Cmd => Err(io::Error::new(
            io::ErrorKind::Unsupported,
            "cmd has no profile support; use `ezcli emit --shell cmd install-wrapper` instead",
        )
        .into()),
    }
}

fn install_profile(shell: ShellArg) -> Result<String, Box<dyn std::error::Error>> {
    match to_shell_kind(shell) {
        ShellKind::Powershell => {
            let changed = install_powershell_profile_source_line()?;
            let profile_path = get_powershell_profile_path()?;

            if changed {
                Ok(format!(
                    "powershell profile updated: {}",
                    profile_path.display()
                ))
            } else {
                Ok(format!(
                    "powershell profile already configured: {}",
                    profile_path.display(),
                ))
            }
        }
        ShellKind::Cmd => Err(io::Error::new(
            io::ErrorKind::Unsupported,
            "cmd has no profile support; use `ezcli emit --shell cmd install-wrapper` instead",
        )
        .into()),
    }
}

fn handle_emit(shell: ShellArg, action: EmitAction) -> Result<(), Box<dyn std::error::Error>> {
    match action {
        EmitAction::LoadCl => {
            let config = load_config()?;
            let captured = capture_vcvars_env(&config.vc_path, &config.default_arch)?;
            print!("{}", build_load_cl_script(shell, captured));
            Ok(())
        }
        EmitAction::EnterProject { name } => {
            let config = load_config()?;
            let project = find_project(&config, &name).ok_or_else(|| {
                std::io::Error::new(
                    std::io::ErrorKind::NotFound,
                    format!("project not found: {name}"),
                )
            })?;

            print!("{}", build_enter_project_script(shell, project));
            Ok(())
        }
        EmitAction::Init => {
            print!("{}", build_init_script(shell)?);
            Ok(())
        }
        EmitAction::InstallWrapper => {
            let wrapper_paths = install_wrapper(shell)?;
            for wrapper_path in wrapper_paths {
                println!("powershell wrapper saved to {}", wrapper_path.display());
            }
            Ok(())
        }
        EmitAction::ShowProfile => {
            print!("{}", show_profile(shell)?);
            Ok(())
        }
        EmitAction::InstallProfile => {
            print!("{}", install_profile(shell)?);
            Ok(())
        }
    }
}