use color_eyre::eyre::{Result, WrapErr, bail};
use log::{debug, trace, warn};
use std::path::{Path, PathBuf};
mod commands;
mod copy;
mod path;
use copy::CopyPlan;
pub use commands::run_command;
pub use path::config_name_from_path;
const MINIMAL_DEVCONTAINER: &str = r#"{
"name": "{name}",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu"
}
"#;
#[derive(Debug, Clone)]
pub struct ConfigEntry {
pub name: String,
pub root: PathBuf,
pub description: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ConfigStore {
dir: PathBuf,
}
impl ConfigStore {
pub fn new(dir: Option<PathBuf>) -> Self {
let dir = dir.unwrap_or_else(|| {
let mut path = dirs::data_local_dir().expect("Local data dir not found");
path.push("vscli");
path.push("configs");
path
});
Self { dir }
}
pub fn dir(&self) -> &Path {
&self.dir
}
pub fn resolve(&self, name_or_path: &Path) -> Option<PathBuf> {
if let Some(path) = Self::resolve_as_path(name_or_path) {
trace!("Resolved config as direct path: {}", name_or_path.display());
return Some(path);
}
if let Some(name) = name_or_path.to_str()
&& let Some(path) = self.resolve_name(name)
{
debug!("Resolved config name '{}' to: {}", name, path.display());
return Some(path);
}
None
}
pub fn list(&self) -> Vec<ConfigEntry> {
let mut entries = Vec::new();
if !self.dir.is_dir() {
debug!("Config directory does not exist: {}", self.dir.display());
return entries;
}
let Ok(read_dir) = std::fs::read_dir(&self.dir) else {
warn!("Failed to read config directory: {}", self.dir.display());
return entries;
};
for dir_entry in read_dir.flatten() {
let path = dir_entry.path();
if !path.is_dir() {
continue;
}
let config_path = path.join(".devcontainer").join("devcontainer.json");
if !config_path.is_file() {
trace!("Skipping invalid config directory: {}", path.display());
continue;
}
let name = dir_entry.file_name().to_string_lossy().into_owned();
let description = Self::read_config_name(&config_path);
entries.push(ConfigEntry {
name,
root: path,
description,
});
}
entries.sort_by(|a, b| a.name.cmp(&b.name));
entries
}
pub fn add(&self, name: &str) -> Result<PathBuf> {
Self::validate_name(name)?;
let root = self.dir.join(name);
if root.exists() {
bail!("Config '{}' already exists at: {}", name, root.display());
}
let devcontainer_dir = root.join(".devcontainer");
std::fs::create_dir_all(&devcontainer_dir).wrap_err_with(|| {
format!(
"Failed to create config directory: {}",
devcontainer_dir.display()
)
})?;
let config_path = devcontainer_dir.join("devcontainer.json");
let content = MINIMAL_DEVCONTAINER.replace("{name}", name);
std::fs::write(&config_path, content)
.wrap_err_with(|| format!("Failed to write config: {}", config_path.display()))?;
Ok(root)
}
pub fn rm(&self, name: &str) -> Result<()> {
Self::validate_name(name)?;
let root = self.dir.join(name);
if !root.exists() {
bail!("Config '{}' not found", name);
}
let canonical_root = root
.canonicalize()
.wrap_err("Failed to canonicalize config path")?;
let canonical_dir = self
.dir
.canonicalize()
.wrap_err("Failed to canonicalize config dir")?;
if !canonical_root.starts_with(&canonical_dir) {
bail!(
"Refusing to remove path outside config directory: {}",
root.display()
);
}
std::fs::remove_dir_all(&root)
.wrap_err_with(|| format!("Failed to remove config: {}", root.display()))?;
Ok(())
}
pub fn copy_into(&self, name: &str, target_dir: &Path) -> Result<PathBuf> {
Self::validate_name(name)?;
let source_root = self.dir.join(name);
if !source_root.is_dir() {
bail!("Config '{}' not found", name);
}
if !target_dir.exists() {
bail!("Target directory does not exist: {}", target_dir.display());
}
if !target_dir.is_dir() {
bail!("Target path is not a directory: {}", target_dir.display());
}
let plans = CopyPlan::children(&source_root, target_dir)?;
for plan in plans {
plan.execute()?;
}
Ok(target_dir.join(".devcontainer"))
}
fn validate_name(name: &str) -> Result<()> {
if name.is_empty()
|| name.contains('/')
|| (cfg!(windows) && name.contains('\\'))
|| name.contains('\0')
|| name == "."
|| name == ".."
{
bail!("Invalid config name: '{name}'");
}
Ok(())
}
fn resolve_name(&self, name: &str) -> Option<PathBuf> {
let candidate = self
.dir
.join(name)
.join(".devcontainer")
.join("devcontainer.json");
candidate.is_file().then_some(candidate)
}
fn resolve_as_path(path: &Path) -> Option<PathBuf> {
if path.is_file() {
if path.file_name().and_then(|f| f.to_str()) == Some("devcontainer.json") {
return Some(path.to_owned());
}
return None;
}
if path.is_dir() {
let candidate = path.join(".devcontainer").join("devcontainer.json");
if candidate.is_file() {
return Some(candidate);
}
let candidate = path.join("devcontainer.json");
if candidate.is_file() {
return Some(candidate);
}
}
None
}
fn read_config_name(config_path: &Path) -> Option<String> {
let content = std::fs::read_to_string(config_path).ok()?;
let parsed: serde_json::Value = json5::from_str(&content).ok()?;
parsed["name"].as_str().map(String::from)
}
}
#[cfg(test)]
mod tests;