use std::{collections::HashMap, path::Path};
use serde::{Deserialize, Serialize};
use toml::Table;
use crate::{
cli::{
DeployArgs, DiffArgs, ImportArgs, PackagesListArgs, ProfileRemoveArgs, ProfilesAddArgs,
RemovePackageArgs, UpdateArgs,
},
context::Context,
package::{BackupDeployResult, Package},
profile::Profile,
utils::{LogLevel, cprintln, is_empty_table},
};
#[cfg(test)]
mod tests;
const SCHEMA_URL: &str =
"https://raw.githubusercontent.com/uroybd/DotR/main/schema/config.schema.json";
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
pub struct Config {
pub banner: bool,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub packages: HashMap<String, Package>,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub profiles: HashMap<String, Profile>,
#[serde(skip_serializing_if = "is_empty_table")]
pub variables: Table,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub prompts: HashMap<String, String>,
pub symlink: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub bitwarden_note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_backend: Option<crate::prompt_store::PromptBackendType>,
}
pub(crate) enum OpType {
Backup,
Deploy,
}
impl Config {
pub fn from_path(cwd: &Path) -> anyhow::Result<Self> {
let config_path = cwd.join("config.toml");
if !config_path.exists() {
anyhow::bail!("config.toml not found in the current directory");
}
let config_content = std::fs::read_to_string(config_path)?;
let conf_table = config_content.parse::<Table>()?;
Self::from_table(&conf_table)
}
pub fn save(&self, cwd: &Path) -> anyhow::Result<()> {
let config_content = toml::to_string_pretty(self)?;
let config_content = format!("#:schema {}\n{}", SCHEMA_URL, config_content);
std::fs::write(cwd.join("config.toml"), config_content)?;
Ok(())
}
pub fn from_table(table: &Table) -> anyhow::Result<Self> {
let mut packages: HashMap<String, Package> = HashMap::new();
let package_confs = table.get("packages").and_then(|v| v.as_table());
if let Some(pkg_confs) = package_confs {
for (key, val) in pkg_confs.iter() {
let pkg_val = val
.as_table()
.ok_or_else(|| anyhow::anyhow!("Package '{}' must be a table", key))?;
let pkg = Package::from_table(key, pkg_val)?;
packages.insert(pkg.name.clone(), pkg);
}
}
let mut profiles: HashMap<String, Profile> = HashMap::new();
let profile_confs = table.get("profiles").and_then(|v| v.as_table());
if let Some(prof_confs) = profile_confs {
for (key, val) in prof_confs.iter() {
let prof_val = val
.as_table()
.ok_or_else(|| anyhow::anyhow!("Profile '{}' must be a table", key))?;
let profile = Profile::from_table(key, prof_val)?;
profiles.insert(profile.name.clone(), profile);
}
}
let mut variables: Table = Table::new();
if let Some(vars) = table.get("variables").and_then(|v| v.as_table()) {
for (k, v) in vars.iter() {
variables.insert(k.clone(), v.clone());
}
}
let prompts = crate::utils::get_string_hashmap_from_value(table.get("prompts"))?;
let bitwarden_note = table
.get("bitwarden_note")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let prompt_backend = table
.get("prompt_backend")
.and_then(|v| v.as_str())
.map(crate::prompt_store::PromptBackendType::parse)
.transpose()?;
Ok(Self {
banner: table
.get("banner")
.and_then(|v| v.as_bool())
.unwrap_or(false),
symlink: table
.get("symlink")
.and_then(|v| v.as_bool())
.unwrap_or(false),
packages,
profiles,
variables,
prompts,
bitwarden_note,
prompt_backend,
})
}
pub fn import_package(&mut self, args: &ImportArgs, ctx: &Context) -> anyhow::Result<()> {
let mut profile = ctx.profile.clone();
let profile_name = profile.name.clone();
cprintln(&format!("Importing from {}", args.path), &LogLevel::Info);
let mut package = Package::from_path(args, &ctx.working_dir)?;
let pkg_name = package.name.clone();
let backup_args = crate::cli::UpdateArgs {
packages: None,
profile: Some(profile_name.clone()),
ignore_errors: false,
clean: Some(false),
dry_run: false,
};
package.backup(ctx, &backup_args)?;
profile.dependencies.push(pkg_name.clone());
if profile_name != "default" {
package
.targets
.insert(profile_name.clone(), package.dest.clone());
}
let should_deploy = args.symlink;
self.packages.insert(pkg_name.clone(), package);
self.profiles.insert(profile_name.clone(), profile);
self.save(&ctx.working_dir)?;
if should_deploy {
let pkg = self.packages.get(&pkg_name).ok_or_else(|| {
anyhow::anyhow!("Package '{}' not found after insertion", pkg_name)
})?;
pkg.deploy(
ctx,
&crate::cli::DeployArgs {
packages: Some(vec![pkg_name.clone()]),
profile: Some(profile_name),
ignore_errors: false,
clean: Some(false),
dry_run: false,
skip_actions: false,
skip_pre_actions: false,
skip_post_actions: false,
ignore_dependencies: false,
},
)?;
}
cprintln(&format!("Package '{}' imported", pkg_name), &LogLevel::Info);
Ok(())
}
pub fn filter_packages(
&self,
ctx: &Profile,
names: &Option<Vec<String>>,
ignore_dependencies: bool,
) -> anyhow::Result<HashMap<String, Package>> {
let mut packages: HashMap<String, Package> = HashMap::new();
if let Some(pkg_names) = names {
for name in pkg_names {
if let Some(pkg) = self.packages.get(name) {
packages.insert(name.clone(), pkg.clone());
} else {
return Err(anyhow::anyhow!("Package '{}' not found", name));
}
}
} else {
for dep in &ctx.dependencies {
if let Some(pkg) = self.packages.get(dep) {
if !pkg.skip {
packages.insert(dep.clone(), pkg.clone());
}
} else {
anyhow::bail!("Package '{}' not found for profile '{}'", dep, ctx.name);
}
}
}
if ignore_dependencies {
return Ok(packages);
}
let mut dependencies: HashMap<String, Package> = HashMap::new();
for pkg in packages.values() {
if let Some(deps) = &pkg.dependencies {
for dep in deps {
if let Some(dep_pkg) = self.packages.get(dep) {
dependencies.insert(dep.clone(), dep_pkg.clone());
} else {
anyhow::bail!("Dependency package '{}' not found in configuration", dep);
}
}
}
}
packages.extend(dependencies);
Ok(packages)
}
pub fn backup_packages(&self, ctx: &Context, args: &UpdateArgs) -> Result<(), anyhow::Error> {
cprintln("Backing up packages...", &LogLevel::Info);
let mut stats: HashMap<BackupDeployResult, u32> = HashMap::new();
for pkg in self
.filter_packages(&ctx.profile, &args.packages, false)?
.values()
{
match pkg.backup(ctx, args) {
Err(e) => {
if args.ignore_errors {
cprintln(
&format!("Error backing up package '{}': {}", pkg.name, e),
&LogLevel::Error,
);
*stats.entry(BackupDeployResult::Failed).or_insert(0) += 1;
} else {
return Err(e);
}
}
Ok(res) => {
*stats.entry(res).or_insert(0) += 1;
}
}
}
print_stats(&stats, OpType::Backup);
Ok(())
}
pub fn deploy_packages(&self, ctx: &Context, args: &DeployArgs) -> Result<(), anyhow::Error> {
cprintln("Deploying packages...", &LogLevel::Info);
let mut stats: HashMap<BackupDeployResult, u32> = HashMap::new();
for pkg in self
.filter_packages(&ctx.profile, &args.packages, args.ignore_dependencies)?
.values()
{
match pkg.deploy(ctx, args) {
Err(e) => {
if args.ignore_errors {
cprintln(
&format!("Error deploying package '{}': {}", pkg.name, e),
&LogLevel::Error,
);
*stats.entry(BackupDeployResult::Failed).or_insert(0) += 1;
} else {
return Err(e);
}
}
Ok(res) => {
*stats.entry(res).or_insert(0) += 1;
}
}
}
print_stats(&stats, OpType::Deploy);
Ok(())
}
pub fn diff_packages(&self, ctx: &Context, args: &DiffArgs) -> Result<(), anyhow::Error> {
cprintln("Checking differences...", &LogLevel::Info);
for pkg in self
.filter_packages(&ctx.profile, &args.packages, false)?
.values()
{
cprintln(&format!("Package: {}", pkg.name), &LogLevel::Info);
if let Err(e) = pkg.diff(ctx) {
if args.ignore_errors {
cprintln(
&format!("Error diffing package '{}': {}", pkg.name, e),
&LogLevel::Error,
);
} else {
return Err(e);
}
}
}
Ok(())
}
pub fn update_profiles(&mut self, profile: &Profile, ctx: &Context) -> anyhow::Result<()> {
self.profiles
.entry(profile.name.clone())
.or_insert_with(|| {
cprintln(
&format!(
"Profile '{}' not found in configuration, creating empty profile",
profile.name
),
&LogLevel::Warning,
);
profile.clone()
});
self.save(&ctx.working_dir)?;
Ok(())
}
pub fn init(cwd: &Path) -> Result<Self, anyhow::Error> {
let config_path = cwd.join("config.toml");
if config_path.exists() {
cprintln("config.toml exists, skipping", &LogLevel::Warning);
return Self::from_path(cwd);
}
let default_config = Config::new();
default_config.save(cwd)?;
std::fs::create_dir_all(cwd.join("dotfiles"))?;
let gitignore_path = cwd.join(".gitignore");
let gitignore_content = ".uservariables.toml\ndeployed";
std::fs::write(gitignore_path, gitignore_content)?;
cprintln("Repository initialized", &LogLevel::Info);
Ok(default_config)
}
pub fn new() -> Self {
let mut profiles: HashMap<String, Profile> = HashMap::new();
profiles.insert("default".to_string(), Profile::new("default"));
Self {
banner: true,
profiles,
..Default::default()
}
}
pub fn list_packages(&self, ctx: &Context, args: &PackagesListArgs) -> anyhow::Result<()> {
let packages = self.filter_packages(&ctx.profile, &None, false)?;
if args.plain {
for pkg in packages.values() {
println!("{}", pkg.name);
}
} else if packages.is_empty() {
cprintln("No packages found.", &LogLevel::Info);
} else {
for pkg in packages.values() {
println!("{}", pkg.name);
if args.verbose {
print!(
" Source: {}\n Destination: {}\n skipped: {}\n",
pkg.src, pkg.dest, pkg.skip
);
if let Some(deps) = &pkg.dependencies {
println!(" Dependencies: {:?}", deps);
}
if !pkg.targets.is_empty() {
println!(" Targets:");
for (target_name, target_dest) in pkg.targets.iter() {
println!(" - {}: {}", target_name, target_dest);
}
}
}
}
}
Ok(())
}
pub fn list_profiles(&self, args: &crate::cli::ProfilesListArgs) -> anyhow::Result<()> {
if args.plain {
for profile in self.profiles.values() {
println!("{}", profile.name);
}
} else if self.profiles.is_empty() {
cprintln("No profiles found.", &LogLevel::Info);
} else {
for profile in self.profiles.values() {
println!("{}", profile.name);
if args.verbose {
println!(" Dependencies: {:?}", profile.dependencies);
println!(" Variables: {:?}", profile.variables);
if let Some(backend) = profile.prompt_backend {
println!(" Prompt backend: {}", backend.as_str());
}
if let Some(platform) = &profile.platform {
println!(" Platform: {}", platform);
}
if !profile.prompts.is_empty() {
println!(" Prompts:");
for (var, prompt) in profile.prompts.iter() {
println!(" - {}: {}", var, prompt);
}
}
}
}
}
Ok(())
}
pub fn add_profile(&mut self, args: &ProfilesAddArgs, ctx: &mut Context) -> anyhow::Result<()> {
if self.profiles.contains_key(&args.name) {
anyhow::bail!("Profile '{}' already exists", args.name);
}
let profile = Profile::new(&args.name);
self.profiles.insert(args.name.clone(), profile.clone());
self.save(&ctx.working_dir)?;
cprintln(&format!("Profile '{}' added", args.name), &LogLevel::Info);
if args.set_as_current {
ctx.save_to_uservariables("DOTR_PROFILE", toml::Value::String(profile.name.clone()))?;
cprintln(
&format!("Setting profile '{}' as current", args.name),
&LogLevel::Info,
);
}
Ok(())
}
pub fn get_orphan_packages(&self) -> Vec<String> {
self.packages
.keys()
.filter_map(
|name| match self.is_package_safe_to_remove(name, &[], &[]) {
(true, _, _) => Some(name.clone()),
_ => None,
},
)
.collect()
}
pub fn remove_packages(
&mut self,
args: &RemovePackageArgs,
ctx: &Context,
) -> anyhow::Result<()> {
let packages = match &args.packages {
Some(pkgs) => pkgs.clone(),
None => {
if args.remove_orphans {
vec![]
} else {
anyhow::bail!("No packages specified for removal");
}
}
};
let ignored_profiles: Vec<String> = vec![ctx.profile.name.clone()];
let mut dirty = false;
let mut to_remove = HashMap::new();
for package_name in packages.iter() {
if !self.packages.contains_key(package_name) {
anyhow::bail!("Package '{}' not found in configuration", package_name);
}
let (is_safe, dependent_profiles, dependent_packages) =
self.is_package_safe_to_remove(package_name, &ignored_profiles, &packages);
if !is_safe && !args.force {
anyhow::bail!(
"Package '{}' cannot be removed because it is depended on by profiles: {:?} and packages: {:?}. Use --force to override.",
package_name,
dependent_profiles,
dependent_packages
);
}
to_remove.insert(
package_name.clone(),
self.packages
.get(package_name)
.ok_or_else(|| {
anyhow::anyhow!("Package '{}' not found in configuration", package_name)
})?
.clone(),
);
}
if to_remove.is_empty() && !args.remove_orphans {
cprintln("No packages to remove.", &LogLevel::Info);
return Ok(());
}
for (package_name, pkg) in to_remove.iter() {
if args.dry_run {
cprintln(
&format!("Package '{}' would be removed (dry run)", package_name),
&LogLevel::Info,
);
continue;
}
match self.remove_package(pkg, ctx) {
Err(e) => {
anyhow::bail!("Error removing package '{}': {}", package_name, e);
}
Ok(_) => {
dirty = true;
cprintln(
&format!("Package '{}' removed", package_name),
&LogLevel::Info,
);
}
}
}
if args.remove_orphans {
let orphan_packages = self.get_orphan_packages();
for orphan in orphan_packages.iter() {
if args.dry_run {
cprintln(
&format!("Orphan package '{}' would be removed (dry run)", orphan),
&LogLevel::Info,
);
continue;
}
let pkg = self
.packages
.get(orphan)
.ok_or_else(|| {
anyhow::anyhow!("Orphan package '{}' not found in configuration", orphan)
})?
.clone();
match self.remove_package(&pkg, ctx) {
Err(e) => {
anyhow::bail!("Error removing orphan package '{}': {}", orphan, e);
}
Ok(_) => {
dirty = true;
cprintln(
&format!("Orphan package '{}' removed", orphan),
&LogLevel::Info,
);
}
}
}
}
if dirty {
self.save(&ctx.working_dir)?;
}
Ok(())
}
pub fn remove_package(&mut self, pkg: &Package, ctx: &Context) -> anyhow::Result<()> {
let src = ctx.working_dir.join(&pkg.src);
let name = pkg.name.clone();
self.packages.remove(&pkg.name);
for profile in self.profiles.values_mut() {
profile.dependencies.retain(|dep| dep != &name);
}
for pkg in self.packages.values_mut() {
if let Some(deps) = &mut pkg.dependencies {
deps.retain(|dep| dep != &name);
}
}
if src.exists() {
if src.is_dir() {
if src.read_dir()?.next().is_some() {
std::fs::remove_dir_all(&src)?;
} else {
std::fs::remove_dir(&src)?;
}
} else {
std::fs::remove_file(&src)?;
}
}
Ok(())
}
pub fn is_package_safe_to_remove(
&self,
package_name: &str,
ignored_profiles: &[String],
ignored_packages: &[String],
) -> (bool, Vec<String>, Vec<String>) {
let mut dependent_profiles: Vec<String> = vec![];
let mut dependent_packages: Vec<String> = vec![];
let mut is_safe = true;
for profile in self.profiles.values() {
if ignored_profiles.contains(&profile.name) {
continue;
}
if profile.dependencies.contains(&package_name.to_string()) {
dependent_profiles.push(profile.name.clone());
is_safe = false;
}
}
for pkg in self.packages.values() {
if ignored_packages.contains(&pkg.name) {
continue;
}
if let Some(deps) = &pkg.dependencies
&& deps.contains(&package_name.to_string())
{
dependent_packages.push(pkg.name.clone());
is_safe = false;
}
}
(is_safe, dependent_profiles, dependent_packages)
}
pub fn remove_profile(
&mut self,
args: &ProfileRemoveArgs,
ctx: &Context,
) -> anyhow::Result<()> {
if !self.profiles.contains_key(&args.name) {
anyhow::bail!("Profile '{}' not found in configuration", args.name);
}
if args.name == "default" {
anyhow::bail!("Cannot remove the default profile");
}
if args.dry_run {
cprintln(
&format!("Profile '{}' would be removed (dry run)", args.name),
&LogLevel::Info,
);
return Ok(());
}
self.profiles.remove(&args.name);
self.save(&ctx.working_dir)?;
cprintln(&format!("Profile '{}' removed", args.name), &LogLevel::Info);
if args.remove_orphans {
let orphan_packages = self.get_orphan_packages();
let mut dirty = false;
for orphan in orphan_packages.iter() {
let pkg = self
.packages
.get(orphan)
.ok_or_else(|| {
anyhow::anyhow!("Orphan package '{}' not found in configuration", orphan)
})?
.clone();
match self.remove_package(&pkg, ctx) {
Err(e) => {
anyhow::bail!("Error removing orphan package '{}': {}", orphan, e);
}
Ok(_) => {
dirty = true;
cprintln(
&format!("Orphan package '{}' removed", orphan),
&LogLevel::Info,
);
}
}
}
if dirty {
self.save(&ctx.working_dir)?;
}
}
Ok(())
}
}
pub(crate) fn print_stats(stats: &HashMap<BackupDeployResult, u32>, op_type: OpType) {
let (op_name, op_success_name) = match op_type {
OpType::Backup => ("Backup", "backed up"),
OpType::Deploy => ("Deployment", "deployed"),
};
let mut summary_parts = vec![];
if let Some(count) = stats.get(&BackupDeployResult::Success) {
summary_parts.push(format!("✅ {} {}", count, op_success_name));
}
if let Some(count) = stats.get(&BackupDeployResult::Skipped) {
summary_parts.push(format!("🔄 {} no changes", count));
}
if let Some(count) = stats.get(&BackupDeployResult::Failed) {
summary_parts.push(format!("❌ {} failed", count));
}
if summary_parts.is_empty() {
cprintln(
&format!("No packages processed for {}", op_name),
&LogLevel::Info,
);
} else {
let mut summary_string = summary_parts.join(", ");
summary_string.push('.');
cprintln(&format!("{} summary:", op_name), &LogLevel::Info);
cprintln(&summary_string, &LogLevel::Info);
}
}