use std::{
env, fs,
io::{self, IsTerminal, Write},
panic::AssertUnwindSafe,
path::Path,
process,
};
use clap::CommandFactory;
use color_eyre::{Result, eyre::Context};
use intelli_shell::{
app::App,
cli::{Cli, CliProcess, ConfigProcess, LogsProcess, Shell},
config::Config,
errors::{self, AppError},
format_error, logging,
process::{OutputInfo, ProcessOutput},
service::IntelliShellService,
storage::SqliteStorage,
utils::execute_shell_command_inherit,
};
use tokio_util::sync::CancellationToken;
const STATUS_DIRTY: &str = "DIRTY\n";
const STATUS_CLEAN: &str = "CLEAN\n";
const ACTION_EXECUTE: &str = "EXECUTE\n";
const ACTION_EXECUTED: &str = "EXECUTED\n";
const ACTION_REPLACE: &str = "REPLACE\n";
const BASH_INIT: &str = include_str!("./_shell/intelli-shell.bash");
const ZSH_INIT: &str = include_str!("./_shell/intelli-shell.zsh");
const FISH_INIT: &str = include_str!("./_shell/intelli-shell.fish");
const NUSHELL_INIT: &str = include_str!("./_shell/intelli-shell.nu");
const POWERSHELL_INIT: &str = include_str!("./_shell/intelli-shell.ps1");
#[tokio::main]
async fn main() -> Result<()> {
let (config, stats) = Config::init(env::var("INTELLI_CONFIG").ok().map(Into::into))?;
let (logs_path, logs_filter) = logging::resolve_path_and_filter(&config);
errors::init(
logs_filter.is_some().then(|| logs_path.clone()),
AssertUnwindSafe(async move {
let args = Cli::parse_extended();
let cancellation_token = CancellationToken::new();
let ctrl_c_token = cancellation_token.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
ctrl_c_token.cancel();
});
match args.process {
CliProcess::Init(init) => {
let mut output = String::new();
let include_integration = init.integration || !init.completions;
let include_completions = init.completions || !init.integration;
if include_completions {
let mut cmd = Cli::command();
let cmd_bin_name = cmd.get_name().to_string();
let mut completions = Vec::new();
match init.shell {
Shell::Bash => clap_complete::generate(
clap_complete::Shell::Bash,
&mut cmd,
cmd_bin_name,
&mut completions,
),
Shell::Zsh => clap_complete::generate(
clap_complete::Shell::Zsh,
&mut cmd,
cmd_bin_name,
&mut completions,
),
Shell::Fish => clap_complete::generate(
clap_complete::Shell::Fish,
&mut cmd,
cmd_bin_name,
&mut completions,
),
Shell::Powershell => clap_complete::generate(
clap_complete::Shell::PowerShell,
&mut cmd,
cmd_bin_name,
&mut completions,
),
Shell::Nushell => clap_complete::generate(
clap_complete_nushell::Nushell,
&mut cmd,
cmd_bin_name,
&mut completions,
),
}
output.push_str(&String::from_utf8_lossy(&completions));
}
if include_integration {
if include_completions {
output.push('\n');
}
let search_hk = escape_hotkey_for_shell(
&config.hotkeys.search.format_key_for_shell(init.shell),
init.shell,
);
let bookmark_hk = escape_hotkey_for_shell(
&config.hotkeys.bookmark.format_key_for_shell(init.shell),
init.shell,
);
let var_hk = escape_hotkey_for_shell(
&config.hotkeys.variable.format_key_for_shell(init.shell),
init.shell,
);
let fix_hk =
escape_hotkey_for_shell(&config.hotkeys.fix.format_key_for_shell(init.shell), init.shell);
let script = match init.shell {
Shell::Bash => BASH_INIT,
Shell::Zsh => ZSH_INIT,
Shell::Fish => FISH_INIT,
Shell::Nushell => NUSHELL_INIT,
Shell::Powershell => POWERSHELL_INIT,
};
let rendered_script = script
.replace("{{SEARCH_HOTKEY}}", &search_hk)
.replace("{{BOOKMARK_HOTKEY}}", &bookmark_hk)
.replace("{{VARIABLE_HOTKEY}}", &var_hk)
.replace("{{FIX_HOTKEY}}", &fix_hk);
output.push_str(&rendered_script);
}
let output_info = OutputInfo {
stdout: Some(output),
..Default::default()
};
return handle_output(
ProcessOutput::Output(output_info),
args.file_output,
args.skip_execution,
cancellation_token,
)
.await;
}
CliProcess::Config(ConfigProcess { path }) => {
if path {
println!("{}", stats.config_path.display());
} else {
if let Some(parent) = stats.config_path.parent() {
fs::create_dir_all(parent)
.wrap_err_with(|| format!("Failed to create config directory: {}", parent.display()))?;
}
edit::edit_file(&stats.config_path)
.wrap_err_with(|| format!("Failed to open config file: {}", stats.config_path.display()))?;
}
return Ok(());
}
CliProcess::Logs(LogsProcess { path }) => {
if path {
println!("{}", logs_path.display());
} else {
match fs::read_to_string(&logs_path) {
Ok(logs_content) if !logs_content.is_empty() => {
println!("{logs_content}");
}
_ => {
eprintln!(
"{}",
format_error!(
config.theme,
"No logs found on: {}\n\nMake sure logging is enabled in the config file: {}",
logs_path.display(),
stats.config_path.display()
)
)
}
}
}
return Ok(());
}
_ => (),
}
logging::init(logs_path, logs_filter)?;
tracing::info!("intelli-shell v{}", env!("CARGO_PKG_VERSION"));
match (stats.config_loaded, stats.default_config_path) {
(true, true) => tracing::info!("Loaded config from default path: {}", stats.config_path.display()),
(true, false) => tracing::info!("Loaded config from custom path: {}", stats.config_path.display()),
(false, true) => tracing::info!("No config found at default path: {}", stats.config_path.display()),
(false, false) => tracing::warn!("No config found at custom path: {}", stats.config_path.display()),
}
if stats.default_data_dir {
tracing::info!("Using default data dir: {}", config.data_dir.display());
} else {
tracing::info!("Using custom data dir: {}", config.data_dir.display());
}
let storage = SqliteStorage::new(&config.data_dir)
.await
.map_err(AppError::into_report)?;
let service = IntelliShellService::new(
storage,
config.tuning,
config.ai.clone(),
&config.data_dir,
config.check_updates,
);
let app_cancellation_token = cancellation_token.clone();
let output = App::new(app_cancellation_token)?
.run(config, service, args.process, args.extra_line)
.await?;
handle_output(output, args.file_output, args.skip_execution, cancellation_token).await
}),
)
.await
}
async fn handle_output(
output: ProcessOutput,
file_output_path: Option<String>,
skip_execution: bool,
cancellation_token: CancellationToken,
) -> Result<()> {
if let Some(path_str) = &file_output_path {
let mut file_content = String::new();
match &output {
ProcessOutput::Execute { cmd } => {
file_content.push_str(STATUS_CLEAN);
if skip_execution {
file_content.push_str(ACTION_EXECUTE);
file_content.push_str(cmd);
} else {
file_content.push_str(ACTION_EXECUTED);
}
}
ProcessOutput::Output(info) => {
if info.stderr.is_some() {
file_content.push_str(STATUS_DIRTY);
} else {
file_content.push_str(STATUS_CLEAN);
}
if let Some(cmd) = &info.fileout {
file_content.push_str(ACTION_REPLACE);
file_content.push_str(cmd);
}
}
}
let content = file_content.trim_end_matches('\n');
tracing::info!("[fileout]\n{content}");
let path_output = Path::new(&path_str);
if let Some(parent) = path_output.parent() {
fs::create_dir_all(parent)
.wrap_err_with(|| format!("Failed to create parent directories for: {}", parent.display()))?;
}
fs::write(path_output, content).wrap_err_with(|| format!("Failed to write to fileout path: {path_str}"))?;
}
match output {
ProcessOutput::Execute { cmd } => {
if !skip_execution {
let status =
execute_shell_command_inherit(&cmd, file_output_path.is_none(), cancellation_token).await?;
if !status.success() {
let code = status.code().unwrap_or(1);
tracing::info!("[exit code] {code}");
process::exit(code);
}
}
}
ProcessOutput::Output(info) => {
let use_color_stderr = should_use_color(io::stderr().is_terminal());
let use_color_stdout = should_use_color(io::stdout().is_terminal());
if let Some(stderr) = info.stderr {
let stderr_nocolor = strip_ansi_escapes::strip_str(&stderr);
tracing::info!("[stderr] {stderr_nocolor}");
let write_result = if use_color_stderr {
writeln!(io::stderr(), "{stderr}")
} else {
writeln!(io::stderr(), "{stderr_nocolor}")
};
if let Err(err) = write_result {
if err.kind() != io::ErrorKind::BrokenPipe {
return Err(err).wrap_err("Failed writing to stderr");
}
tracing::error!("Failed writing to stderr: Broken pipe");
}
}
if file_output_path.is_none()
&& let Some(stdout) = info.stdout
{
let stdout_nocolor = strip_ansi_escapes::strip_str(&stdout);
tracing::info!("[stdout] {stdout_nocolor}");
let write_result = if use_color_stdout {
writeln!(io::stdout(), "{stdout}")
} else {
writeln!(io::stdout(), "{stdout_nocolor}")
};
if let Err(err) = write_result {
if err.kind() != io::ErrorKind::BrokenPipe {
return Err(err).wrap_err("Failed writing to stdout");
}
tracing::error!("Failed writing to stdout: Broken pipe");
}
}
if info.failed {
tracing::info!("[exit code] 1");
process::exit(1);
}
}
}
Ok(())
}
fn should_use_color(stream_is_tty: bool) -> bool {
if env::var("NO_COLOR").is_ok() {
return false;
}
if let Ok(force_val) = env::var("CLICOLOR_FORCE")
&& !force_val.is_empty()
&& force_val != "0"
{
return true;
}
if let Ok(clicolor_val) = env::var("CLICOLOR")
&& clicolor_val == "0"
{
return false;
}
stream_is_tty
}
fn escape_hotkey_for_shell(hotkey: &str, shell: Shell) -> String {
let mut escaped = String::with_capacity(hotkey.len());
for ch in hotkey.chars() {
match (shell, ch) {
(Shell::Powershell, '\'') => escaped.push_str("''"),
(Shell::Fish, '\\') => escaped.push_str("\\\\"),
(Shell::Fish, '\'') => escaped.push_str("\\'"),
(Shell::Nushell, '\\') => escaped.push_str("\\\\"),
(Shell::Nushell, '"') => escaped.push_str("\\\""),
(Shell::Bash | Shell::Zsh, '"') => escaped.push_str("\\\""),
(_, c) => escaped.push(c),
}
}
escaped
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_escape_hotkey_for_shell() {
assert_eq!(escape_hotkey_for_shell("Ctrl+'", Shell::Powershell), "Ctrl+''");
assert_eq!(
escape_hotkey_for_shell("Ctrl+Spacebar", Shell::Powershell),
"Ctrl+Spacebar"
);
assert_eq!(escape_hotkey_for_shell("ctrl-'", Shell::Fish), "ctrl-\\'");
assert_eq!(
escape_hotkey_for_shell("control char_\"", Shell::Nushell),
"control char_\\\""
);
assert_eq!(escape_hotkey_for_shell("\\C-\"", Shell::Bash), "\\C-\\\"");
assert_eq!(escape_hotkey_for_shell("^\"", Shell::Zsh), "^\\\"");
}
}