use {
anyhow::Context,
clap::{Parser, Subcommand},
indicatif::{ProgressBar, ProgressStyle},
serde::{Deserialize, Serialize},
std::{
fs::{OpenOptions, create_dir_all},
io::{Read, Write},
path::{Path, PathBuf},
time::Duration,
},
tokio::{fs::remove_dir_all, process::Command as TokioCommand, task},
};
fn config_loc() -> PathBuf {
let path = Path::new(&std::env::var("HOME").expect("You don't seem to have a homedir"))
.join(".config")
.join("flesh")
.to_path_buf();
if !path.exists() {
let _ = create_dir_all(&path);
}
path
}
fn config_file() -> anyhow::Result<FleshConfig> {
let config_path = config_loc().join("config.toml");
let mut oo = OpenOptions::new()
.create(true)
.truncate(true)
.read(true)
.write(true)
.open(&config_path)
.context(format!("Failed to open or create config file at {:?}", &config_path))?;
let mut string = String::new();
oo.read_to_string(&mut string)?;
if string.is_empty() {
let default_config = FleshConfig::default();
let default_toml = toml::to_string_pretty(&default_config)?;
oo.set_len(0)?;
oo.write_all(default_toml.as_bytes())?;
Ok(default_config)
} else {
toml::from_str(&string).context("Failed to parse config file as TOML")
}
}
#[derive(Parser, Debug)]
struct FleshManagerArgs {
#[command(subcommand)]
command: SubCommand,
}
#[derive(Debug, Subcommand)]
enum SubCommand {
List,
Add { github_url: String, subdomain: String },
Remove { subdomain: String },
Run,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FleshApp {
pub subdomain: String,
pub module: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct FleshConfig {
pub apps: Vec<FleshApp>,
}
fn normalize_github_url(url: &str) -> String {
url.trim_end_matches(".git")
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_start_matches("github.com/")
.replace('/', "-")
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = FleshManagerArgs::parse();
let mut config = config_file()?;
match args.command {
SubCommand::List => {
println!("{:#?}", config);
}
SubCommand::Add { github_url, subdomain } => {
if config.apps.iter().any(|app| app.subdomain == subdomain) {
anyhow::bail!("App with subdomain '{}' already exists.", subdomain);
}
let normalized_name = normalize_github_url(&github_url);
let app_dir = config_loc().join(&normalized_name);
if app_dir.exists() {
anyhow::bail!("Application folder for URL '{}' already exists at {:?}", github_url, app_dir);
}
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::with_template("{spinner:.green} {msg}")
.unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
);
spinner.set_message(format!("Cloning '{}' to {:?}...", github_url, app_dir));
spinner.enable_steady_tick(Duration::from_millis(100));
let clone_result = task::spawn({
let app_dir = app_dir.clone();
async move { TokioCommand::new("git").arg("clone").arg(&github_url).arg(app_dir).output().await }
})
.await
.context("Task execution failed")?;
let output = clone_result.context("Failed to execute git clone. Is git installed and in your PATH?")?;
spinner.finish_and_clear();
if !output.status.success() {
if app_dir.clone().exists() {
let _ = remove_dir_all(&app_dir).await;
}
anyhow::bail!("Git clone failed:\n{}", String::from_utf8_lossy(&output.stderr));
}
println!("✅ Clone successful.");
let new_app = FleshApp { subdomain, module: normalized_name };
config.apps.push(new_app.clone());
println!("Added app: {:#?}", new_app);
}
SubCommand::Remove { subdomain } => {
let initial_len = config.apps.len();
let app_to_remove = config.apps.iter().find(|app| app.subdomain == subdomain).cloned();
if let Some(app) = app_to_remove {
config.apps.retain(|app| app.subdomain != subdomain);
if config.apps.len() == initial_len {
anyhow::bail!("Failed to remove app from config vector.");
}
println!("Removed app '{}' from configuration.", subdomain);
let app_dir = config_loc().join(&app.module);
if app_dir.exists() {
let spinner = ProgressBar::new_spinner();
spinner.set_style(
ProgressStyle::with_template("{spinner:.yellow} {msg}").unwrap().tick_strings(&["◴", "◷", "◶", "◵"]),
);
spinner.set_message(format!("Removing application folder at {:?}...", app_dir));
spinner.enable_steady_tick(Duration::from_millis(150));
remove_dir_all(&app_dir)
.await
.with_context(|| format!("Failed to remove application folder at {:?}", app_dir))?;
spinner.finish_and_clear();
println!("🗑️ Folder removed.");
} else {
println!("Warning: Application folder {:?} not found. Configuration updated.", app_dir);
}
} else {
anyhow::bail!("App with subdomain '{}' not found in config.", subdomain);
}
}
SubCommand::Run => {}
}
let save_spinner = ProgressBar::new_spinner();
save_spinner.set_style(
ProgressStyle::with_template("{spinner:.blue} {msg}")
.unwrap()
.tick_strings(&["⠁", "⠂", "⠃", "⠇", "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠁"]),
);
save_spinner.set_message("Saving configuration...");
save_spinner.enable_steady_tick(Duration::from_millis(80));
let config_path = config_loc().join("config.toml");
let mut oo = OpenOptions::new()
.write(true)
.truncate(true)
.open(&config_path)
.context(format!("Failed to open config file for writing at {:?}", &config_path))?;
let toml_string = toml::to_string_pretty(&config)?;
oo.write_all(toml_string.as_bytes())?;
save_spinner.finish_and_clear();
println!("🎉 Configuration saved successfully.");
Ok(())
}