#![warn(
missing_docs,
missing_debug_implementations,
missing_copy_implementations
)]
#![warn(clippy::pedantic)]
mod config_store;
mod container;
mod history;
mod launch;
mod opts;
mod ui;
mod uri;
mod workspace;
use chrono::Utc;
use clap::Parser;
use color_eyre::eyre::{Result, WrapErr};
use log::trace;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::config_store::ConfigStore;
use crate::history::{Entry, Tracker};
use crate::{
launch::{Behavior, Setup},
opts::Opts,
workspace::Workspace,
};
fn load_tracker(history_path: Option<PathBuf>) -> Result<Tracker> {
let path = history_path.unwrap_or_else(|| {
let mut p = dirs::data_local_dir().expect("Local data dir not found.");
p.push("vscli");
p.push("history.json");
p
});
Tracker::load(path)
}
fn resolve_launch_config(config: Option<&PathBuf>, store: &ConfigStore) -> Result<Option<PathBuf>> {
config
.map(|c| {
store
.resolve(c)
.ok_or_else(|| color_eyre::eyre::eyre!("Config not found: {}", c.display()))
})
.transpose()
}
fn workspace_root_from_config(
config: &Path,
path_arg: &Path,
) -> Result<(PathBuf, Option<PathBuf>)> {
let abs = std::fs::canonicalize(config)
.wrap_err_with(|| format!("Config path does not exist: {}", config.display()))?;
let mut current = abs.as_path();
let root = loop {
let Some(parent) = current.parent() else {
break abs.parent().unwrap_or(&abs).to_path_buf();
};
if parent.file_name().is_some_and(|n| n == ".devcontainer") {
break parent.parent().unwrap_or(parent).to_path_buf();
}
current = parent;
};
let path_abs = std::fs::canonicalize(path_arg).unwrap_or(path_arg.to_path_buf());
let sub = if path_abs.starts_with(&root) && path_abs != root {
path_abs.strip_prefix(&root).ok().map(Path::to_path_buf)
} else {
None
};
Ok((root, sub))
}
fn main() -> Result<()> {
color_eyre::install()?;
let opts = Opts::parse();
let opts_dbg = format!("{opts:#?}");
env_logger::Builder::from_default_env()
.filter_level(opts.verbose.log_level_filter())
.format(move |buf, record| log_format(buf, record, opts.verbose.log_level_filter()))
.init();
trace!("Parsed Opts:\n{opts_dbg}");
let config_store = ConfigStore::new(opts.config_dir);
match opts.command {
opts::Commands::Open { path, launch } => {
let mut tracker = load_tracker(opts.history_path)?;
let resolved_config = resolve_launch_config(launch.config.as_ref(), &config_store)?;
let config_name = resolved_config
.as_ref()
.and_then(|p| config_store::config_name_from_path(p, &config_store));
let (workspace_path, subfolder) = if let Some(ref config) = resolved_config {
workspace_root_from_config(config, &path)?
} else {
(path.clone(), None)
};
let ws = Workspace::from_path(&workspace_path)?;
let ws_name = ws.name.clone();
let behavior = Behavior {
strategy: launch.behavior.unwrap_or_default(),
args: launch.args,
command: launch.command.unwrap_or_else(|| "code".to_string()),
};
let setup = Setup::new(ws, behavior.clone(), opts.dry_run);
let dev_container = setup.launch(resolved_config, subfolder.as_deref())?;
tracker.history.upsert(Entry {
workspace_name: ws_name,
dev_container_name: dev_container.as_ref().and_then(|dc| dc.name.clone()),
config_name,
workspace_path: workspace_path.canonicalize()?,
config_path: dev_container.map(|dc| dc.config_path),
behavior,
last_opened: Utc::now(),
});
tracker.store()?;
}
opts::Commands::Recent {
launch,
hide_instructions,
hide_info,
} => {
let mut tracker = load_tracker(opts.history_path)?;
let res = ui::start(&mut tracker, hide_instructions, hide_info)?;
if let Some((id, mut entry)) = res {
let ws = Workspace::from_path(&entry.workspace_path)?;
let ws_name = ws.name.clone();
if let Some(cmd) = launch.command {
entry.behavior.command = cmd;
}
if let Some(beh) = launch.behavior {
entry.behavior.strategy = beh;
}
if !launch.args.is_empty() {
entry.behavior.args = launch.args;
}
let resolved_config = if launch.config.is_some() {
resolve_launch_config(launch.config.as_ref(), &config_store)?
} else {
entry.config_path.clone()
};
let config_name = resolved_config
.as_ref()
.and_then(|p| config_store::config_name_from_path(p, &config_store));
let setup = Setup::new(ws, entry.behavior.clone(), opts.dry_run);
let dev_container = setup.launch(resolved_config, None)?;
tracker.history.update(
id,
Entry {
workspace_name: ws_name,
dev_container_name: dev_container.as_ref().and_then(|dc| dc.name.clone()),
config_name,
workspace_path: entry.workspace_path.clone(),
config_path: dev_container.map(|dc| dc.config_path),
behavior: entry.behavior.clone(),
last_opened: Utc::now(),
},
);
}
tracker.store()?;
}
opts::Commands::Config { action } => {
let editor = std::env::var("VSCLI_EDITOR").unwrap_or_else(|_| "code".to_string());
config_store::run_command(action, &config_store, &editor)?;
}
opts::Commands::Container { action } => {
let editor = std::env::var("VSCLI_EDITOR").unwrap_or_else(|_| "code".to_string());
container::run_command(action, &editor)?;
}
}
Ok(())
}
fn log_format(
buf: &mut env_logger::fmt::Formatter,
record: &log::Record,
filter: log::LevelFilter,
) -> std::io::Result<()> {
let level = record.level();
let level_char = match level {
log::Level::Trace => 'T',
log::Level::Debug => 'D',
log::Level::Info => 'I',
log::Level::Warn => 'W',
log::Level::Error => 'E',
};
let colored_level = match level {
log::Level::Trace => format!("\x1b[37m{level_char}\x1b[0m"),
log::Level::Debug => format!("\x1b[36m{level_char}\x1b[0m"),
log::Level::Info => format!("\x1b[32m{level_char}\x1b[0m"),
log::Level::Warn => format!("\x1b[33m{level_char}\x1b[0m"),
log::Level::Error => format!("\x1b[31m{level_char}\x1b[0m"),
};
if level == log::Level::Info && filter == log::LevelFilter::Info {
writeln!(buf, "{}", record.args())
} else {
writeln!(buf, "{}: {}", colored_level, record.args())
}
}