1use std::{collections::HashMap, path::Path};
2
3use serde::{Deserialize, Serialize};
4use toml::Table;
5
6use crate::{
7 cli::{
8 DeployArgs, DiffArgs, ImportArgs, PackagesListArgs, ProfileRemoveArgs, ProfilesAddArgs,
9 RemovePackageArgs, UpdateArgs,
10 },
11 context::Context,
12 package::{BackupDeployResult, Package},
13 profile::Profile,
14 utils::{LogLevel, cprintln, is_empty_table},
15};
16
17#[cfg(test)]
18mod tests;
19
20const SCHEMA_URL: &str =
23 "https://raw.githubusercontent.com/uroybd/DotR/main/schema/config.schema.json";
24
25#[derive(Deserialize, Serialize, Debug, Clone, Default)]
26pub struct Config {
27 pub banner: bool,
28 #[serde(skip_serializing_if = "HashMap::is_empty")]
29 pub packages: HashMap<String, Package>,
30 #[serde(skip_serializing_if = "HashMap::is_empty")]
31 pub profiles: HashMap<String, Profile>,
32 #[serde(skip_serializing_if = "is_empty_table")]
33 pub variables: Table,
34 #[serde(skip_serializing_if = "HashMap::is_empty")]
35 pub prompts: HashMap<String, String>,
36 pub symlink: bool,
37 #[serde(skip_serializing_if = "Option::is_none")]
40 pub bitwarden_note: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
44 pub prompt_backend: Option<crate::prompt_store::PromptBackendType>,
45}
46
47pub(crate) enum OpType {
48 Backup,
49 Deploy,
50}
51
52impl Config {
53 pub fn from_path(cwd: &Path) -> anyhow::Result<Self> {
54 let config_path = cwd.join("config.toml");
55 if !config_path.exists() {
56 anyhow::bail!("config.toml not found in the current directory");
57 }
58 let config_content = std::fs::read_to_string(config_path)?;
59 let conf_table = config_content.parse::<Table>()?;
60 Self::from_table(&conf_table)
61 }
62
63 pub fn save(&self, cwd: &Path) -> anyhow::Result<()> {
64 let config_content = toml::to_string_pretty(self)?;
65 let config_content = format!("#:schema {}\n{}", SCHEMA_URL, config_content);
70 std::fs::write(cwd.join("config.toml"), config_content)?;
71 Ok(())
72 }
73
74 pub fn from_table(table: &Table) -> anyhow::Result<Self> {
75 let mut packages: HashMap<String, Package> = HashMap::new();
76 let package_confs = table.get("packages").and_then(|v| v.as_table());
77 if let Some(pkg_confs) = package_confs {
78 for (key, val) in pkg_confs.iter() {
79 let pkg_val = val
80 .as_table()
81 .ok_or_else(|| anyhow::anyhow!("Package '{}' must be a table", key))?;
82 let pkg = Package::from_table(key, pkg_val)?;
83 packages.insert(pkg.name.clone(), pkg);
84 }
85 }
86
87 let mut profiles: HashMap<String, Profile> = HashMap::new();
88 let profile_confs = table.get("profiles").and_then(|v| v.as_table());
89 if let Some(prof_confs) = profile_confs {
90 for (key, val) in prof_confs.iter() {
91 let prof_val = val
92 .as_table()
93 .ok_or_else(|| anyhow::anyhow!("Profile '{}' must be a table", key))?;
94 let profile = Profile::from_table(key, prof_val)?;
95 profiles.insert(profile.name.clone(), profile);
96 }
97 }
98 let mut variables: Table = Table::new();
99 if let Some(vars) = table.get("variables").and_then(|v| v.as_table()) {
100 for (k, v) in vars.iter() {
101 variables.insert(k.clone(), v.clone());
102 }
103 }
104 let prompts = crate::utils::get_string_hashmap_from_value(table.get("prompts"))?;
105 let bitwarden_note = table
106 .get("bitwarden_note")
107 .and_then(|v| v.as_str())
108 .map(|s| s.to_string());
109 let prompt_backend = table
110 .get("prompt_backend")
111 .and_then(|v| v.as_str())
112 .map(crate::prompt_store::PromptBackendType::parse)
113 .transpose()?;
114 Ok(Self {
115 banner: table
116 .get("banner")
117 .and_then(|v| v.as_bool())
118 .unwrap_or(false),
119 symlink: table
120 .get("symlink")
121 .and_then(|v| v.as_bool())
122 .unwrap_or(false),
123 packages,
124 profiles,
125 variables,
126 prompts,
127 bitwarden_note,
128 prompt_backend,
129 })
130 }
131
132 pub fn import_package(&mut self, args: &ImportArgs, ctx: &Context) -> anyhow::Result<()> {
133 let mut profile = ctx.profile.clone();
134 let profile_name = profile.name.clone();
135 cprintln(&format!("Importing from {}", args.path), &LogLevel::Info);
136 let mut package = Package::from_path(args, &ctx.working_dir)?;
137 let pkg_name = package.name.clone();
138
139 let backup_args = crate::cli::UpdateArgs {
140 packages: None,
141 profile: Some(profile_name.clone()),
142 ignore_errors: false,
143 clean: Some(false),
144 dry_run: false,
145 };
146 package.backup(ctx, &backup_args)?;
147 profile.dependencies.push(pkg_name.clone());
148 if profile_name != "default" {
149 package
150 .targets
151 .insert(profile_name.clone(), package.dest.clone());
152 }
153 let should_deploy = args.symlink;
154 self.packages.insert(pkg_name.clone(), package);
155 self.profiles.insert(profile_name.clone(), profile);
156 self.save(&ctx.working_dir)?;
157 if should_deploy {
158 let pkg = self.packages.get(&pkg_name).ok_or_else(|| {
159 anyhow::anyhow!("Package '{}' not found after insertion", pkg_name)
160 })?;
161 pkg.deploy(
162 ctx,
163 &crate::cli::DeployArgs {
164 packages: Some(vec![pkg_name.clone()]),
165 profile: Some(profile_name),
166 ignore_errors: false,
167 clean: Some(false),
168 dry_run: false,
169 skip_actions: false,
170 skip_pre_actions: false,
171 skip_post_actions: false,
172 ignore_dependencies: false,
173 },
174 )?;
175 }
176 cprintln(&format!("Package '{}' imported", pkg_name), &LogLevel::Info);
177 Ok(())
178 }
179
180 pub fn filter_packages(
181 &self,
182 ctx: &Profile,
183 names: &Option<Vec<String>>,
184 ignore_dependencies: bool,
185 ) -> anyhow::Result<HashMap<String, Package>> {
186 let mut packages: HashMap<String, Package> = HashMap::new();
187 if let Some(pkg_names) = names {
188 for name in pkg_names {
189 if let Some(pkg) = self.packages.get(name) {
190 packages.insert(name.clone(), pkg.clone());
191 } else {
192 return Err(anyhow::anyhow!("Package '{}' not found", name));
193 }
194 }
195 } else {
196 for dep in &ctx.dependencies {
197 if let Some(pkg) = self.packages.get(dep) {
198 if !pkg.skip {
199 packages.insert(dep.clone(), pkg.clone());
200 }
201 } else {
202 anyhow::bail!("Package '{}' not found for profile '{}'", dep, ctx.name);
203 }
204 }
205 }
206 if ignore_dependencies {
207 return Ok(packages);
208 }
209 let mut dependencies: HashMap<String, Package> = HashMap::new();
210 for pkg in packages.values() {
211 if let Some(deps) = &pkg.dependencies {
212 for dep in deps {
213 if let Some(dep_pkg) = self.packages.get(dep) {
214 dependencies.insert(dep.clone(), dep_pkg.clone());
215 } else {
216 anyhow::bail!("Dependency package '{}' not found in configuration", dep);
217 }
218 }
219 }
220 }
221 packages.extend(dependencies);
222 Ok(packages)
223 }
224
225 pub fn backup_packages(&self, ctx: &Context, args: &UpdateArgs) -> Result<(), anyhow::Error> {
226 cprintln("Backing up packages...", &LogLevel::Info);
227 let mut stats: HashMap<BackupDeployResult, u32> = HashMap::new();
228 for pkg in self
229 .filter_packages(&ctx.profile, &args.packages, false)?
230 .values()
231 {
232 match pkg.backup(ctx, args) {
233 Err(e) => {
234 if args.ignore_errors {
235 cprintln(
236 &format!("Error backing up package '{}': {}", pkg.name, e),
237 &LogLevel::Error,
238 );
239 *stats.entry(BackupDeployResult::Failed).or_insert(0) += 1;
240 } else {
241 return Err(e);
242 }
243 }
244 Ok(res) => {
245 *stats.entry(res).or_insert(0) += 1;
246 }
247 }
248 }
249 print_stats(&stats, OpType::Backup);
250 Ok(())
251 }
252
253 pub fn deploy_packages(&self, ctx: &Context, args: &DeployArgs) -> Result<(), anyhow::Error> {
254 cprintln("Deploying packages...", &LogLevel::Info);
255 let mut stats: HashMap<BackupDeployResult, u32> = HashMap::new();
256 for pkg in self
257 .filter_packages(&ctx.profile, &args.packages, args.ignore_dependencies)?
258 .values()
259 {
260 match pkg.deploy(ctx, args) {
261 Err(e) => {
262 if args.ignore_errors {
263 cprintln(
264 &format!("Error deploying package '{}': {}", pkg.name, e),
265 &LogLevel::Error,
266 );
267 *stats.entry(BackupDeployResult::Failed).or_insert(0) += 1;
268 } else {
269 return Err(e);
270 }
271 }
272 Ok(res) => {
273 *stats.entry(res).or_insert(0) += 1;
274 }
275 }
276 }
277 print_stats(&stats, OpType::Deploy);
278 Ok(())
279 }
280
281 pub fn diff_packages(&self, ctx: &Context, args: &DiffArgs) -> Result<(), anyhow::Error> {
282 cprintln("Checking differences...", &LogLevel::Info);
283 for pkg in self
284 .filter_packages(&ctx.profile, &args.packages, false)?
285 .values()
286 {
287 cprintln(&format!("Package: {}", pkg.name), &LogLevel::Info);
288 if let Err(e) = pkg.diff(ctx) {
289 if args.ignore_errors {
290 cprintln(
291 &format!("Error diffing package '{}': {}", pkg.name, e),
292 &LogLevel::Error,
293 );
294 } else {
295 return Err(e);
296 }
297 }
298 }
299 Ok(())
300 }
301
302 pub fn update_profiles(&mut self, profile: &Profile, ctx: &Context) -> anyhow::Result<()> {
303 self.profiles
304 .entry(profile.name.clone())
305 .or_insert_with(|| {
306 cprintln(
307 &format!(
308 "Profile '{}' not found in configuration, creating empty profile",
309 profile.name
310 ),
311 &LogLevel::Warning,
312 );
313 profile.clone()
314 });
315 self.save(&ctx.working_dir)?;
316 Ok(())
317 }
318
319 pub fn init(cwd: &Path) -> Result<Self, anyhow::Error> {
320 let config_path = cwd.join("config.toml");
321 if config_path.exists() {
322 cprintln("config.toml exists, skipping", &LogLevel::Warning);
323 return Self::from_path(cwd);
324 }
325 let default_config = Config::new();
326 default_config.save(cwd)?;
333 std::fs::create_dir_all(cwd.join("dotfiles"))?;
334
335 let gitignore_path = cwd.join(".gitignore");
336 let gitignore_content = ".uservariables.toml\ndeployed";
337 std::fs::write(gitignore_path, gitignore_content)?;
338
339 cprintln("Repository initialized", &LogLevel::Info);
340 Ok(default_config)
341 }
342
343 pub fn new() -> Self {
344 let mut profiles: HashMap<String, Profile> = HashMap::new();
345 profiles.insert("default".to_string(), Profile::new("default"));
346 Self {
347 banner: true,
348 profiles,
349 ..Default::default()
350 }
351 }
352
353 pub fn list_packages(&self, ctx: &Context, args: &PackagesListArgs) -> anyhow::Result<()> {
354 let packages = self.filter_packages(&ctx.profile, &None, false)?;
355 if args.plain {
356 for pkg in packages.values() {
357 println!("{}", pkg.name);
358 }
359 } else if packages.is_empty() {
360 cprintln("No packages found.", &LogLevel::Info);
361 } else {
362 for pkg in packages.values() {
363 println!("{}", pkg.name);
364 if args.verbose {
365 print!(
366 " Source: {}\n Destination: {}\n skipped: {}\n",
367 pkg.src, pkg.dest, pkg.skip
368 );
369 if let Some(deps) = &pkg.dependencies {
370 println!(" Dependencies: {:?}", deps);
371 }
372 if !pkg.targets.is_empty() {
373 println!(" Targets:");
374 for (target_name, target_dest) in pkg.targets.iter() {
375 println!(" - {}: {}", target_name, target_dest);
376 }
377 }
378 }
379 }
380 }
381 Ok(())
382 }
383
384 pub fn list_profiles(&self, args: &crate::cli::ProfilesListArgs) -> anyhow::Result<()> {
385 if args.plain {
386 for profile in self.profiles.values() {
387 println!("{}", profile.name);
388 }
389 } else if self.profiles.is_empty() {
390 cprintln("No profiles found.", &LogLevel::Info);
391 } else {
392 for profile in self.profiles.values() {
393 println!("{}", profile.name);
394 if args.verbose {
395 println!(" Dependencies: {:?}", profile.dependencies);
396 println!(" Variables: {:?}", profile.variables);
397 if let Some(backend) = profile.prompt_backend {
398 println!(" Prompt backend: {}", backend.as_str());
399 }
400 if let Some(platform) = &profile.platform {
401 println!(" Platform: {}", platform);
402 }
403 if !profile.prompts.is_empty() {
404 println!(" Prompts:");
405 for (var, prompt) in profile.prompts.iter() {
406 println!(" - {}: {}", var, prompt);
407 }
408 }
409 }
410 }
411 }
412 Ok(())
413 }
414
415 pub fn add_profile(&mut self, args: &ProfilesAddArgs, ctx: &mut Context) -> anyhow::Result<()> {
416 if self.profiles.contains_key(&args.name) {
417 anyhow::bail!("Profile '{}' already exists", args.name);
418 }
419 let profile = Profile::new(&args.name);
420 self.profiles.insert(args.name.clone(), profile.clone());
421 self.save(&ctx.working_dir)?;
422 cprintln(&format!("Profile '{}' added", args.name), &LogLevel::Info);
423 if args.set_as_current {
424 ctx.save_to_uservariables("DOTR_PROFILE", toml::Value::String(profile.name.clone()))?;
425 cprintln(
426 &format!("Setting profile '{}' as current", args.name),
427 &LogLevel::Info,
428 );
429 }
430 Ok(())
431 }
432
433 pub fn get_orphan_packages(&self) -> Vec<String> {
434 self.packages
435 .keys()
436 .filter_map(
437 |name| match self.is_package_safe_to_remove(name, &[], &[]) {
438 (true, _, _) => Some(name.clone()),
439 _ => None,
440 },
441 )
442 .collect()
443 }
444
445 pub fn remove_packages(
446 &mut self,
447 args: &RemovePackageArgs,
448 ctx: &Context,
449 ) -> anyhow::Result<()> {
450 let packages = match &args.packages {
451 Some(pkgs) => pkgs.clone(),
452 None => {
453 if args.remove_orphans {
454 vec![]
455 } else {
456 anyhow::bail!("No packages specified for removal");
457 }
458 }
459 };
460 let ignored_profiles: Vec<String> = vec![ctx.profile.name.clone()];
461 let mut dirty = false;
462 let mut to_remove = HashMap::new();
463 for package_name in packages.iter() {
464 if !self.packages.contains_key(package_name) {
465 anyhow::bail!("Package '{}' not found in configuration", package_name);
466 }
467 let (is_safe, dependent_profiles, dependent_packages) =
468 self.is_package_safe_to_remove(package_name, &ignored_profiles, &packages);
469 if !is_safe && !args.force {
470 anyhow::bail!(
471 "Package '{}' cannot be removed because it is depended on by profiles: {:?} and packages: {:?}. Use --force to override.",
472 package_name,
473 dependent_profiles,
474 dependent_packages
475 );
476 }
477 to_remove.insert(
478 package_name.clone(),
479 self.packages
480 .get(package_name)
481 .ok_or_else(|| {
482 anyhow::anyhow!("Package '{}' not found in configuration", package_name)
483 })?
484 .clone(),
485 );
486 }
487 if to_remove.is_empty() && !args.remove_orphans {
488 cprintln("No packages to remove.", &LogLevel::Info);
489 return Ok(());
490 }
491 for (package_name, pkg) in to_remove.iter() {
492 if args.dry_run {
493 cprintln(
494 &format!("Package '{}' would be removed (dry run)", package_name),
495 &LogLevel::Info,
496 );
497 continue;
498 }
499 match self.remove_package(pkg, ctx) {
500 Err(e) => {
501 anyhow::bail!("Error removing package '{}': {}", package_name, e);
502 }
503 Ok(_) => {
504 dirty = true;
505 cprintln(
506 &format!("Package '{}' removed", package_name),
507 &LogLevel::Info,
508 );
509 }
510 }
511 }
512 if args.remove_orphans {
513 let orphan_packages = self.get_orphan_packages();
514 for orphan in orphan_packages.iter() {
515 if args.dry_run {
516 cprintln(
517 &format!("Orphan package '{}' would be removed (dry run)", orphan),
518 &LogLevel::Info,
519 );
520 continue;
521 }
522 let pkg = self
523 .packages
524 .get(orphan)
525 .ok_or_else(|| {
526 anyhow::anyhow!("Orphan package '{}' not found in configuration", orphan)
527 })?
528 .clone();
529 match self.remove_package(&pkg, ctx) {
530 Err(e) => {
531 anyhow::bail!("Error removing orphan package '{}': {}", orphan, e);
532 }
533 Ok(_) => {
534 dirty = true;
535 cprintln(
536 &format!("Orphan package '{}' removed", orphan),
537 &LogLevel::Info,
538 );
539 }
540 }
541 }
542 }
543 if dirty {
544 self.save(&ctx.working_dir)?;
545 }
546 Ok(())
547 }
548
549 pub fn remove_package(&mut self, pkg: &Package, ctx: &Context) -> anyhow::Result<()> {
550 let src = ctx.working_dir.join(&pkg.src);
551 let name = pkg.name.clone();
552 self.packages.remove(&pkg.name);
553 for profile in self.profiles.values_mut() {
554 profile.dependencies.retain(|dep| dep != &name);
555 }
556 for pkg in self.packages.values_mut() {
557 if let Some(deps) = &mut pkg.dependencies {
558 deps.retain(|dep| dep != &name);
559 }
560 }
561 if src.exists() {
562 if src.is_dir() {
563 if src.read_dir()?.next().is_some() {
564 std::fs::remove_dir_all(&src)?;
565 } else {
566 std::fs::remove_dir(&src)?;
567 }
568 } else {
569 std::fs::remove_file(&src)?;
570 }
571 }
572 Ok(())
573 }
574
575 pub fn is_package_safe_to_remove(
576 &self,
577 package_name: &str,
578 ignored_profiles: &[String],
579 ignored_packages: &[String],
580 ) -> (bool, Vec<String>, Vec<String>) {
581 let mut dependent_profiles: Vec<String> = vec![];
582 let mut dependent_packages: Vec<String> = vec![];
583 let mut is_safe = true;
584 for profile in self.profiles.values() {
585 if ignored_profiles.contains(&profile.name) {
586 continue;
587 }
588 if profile.dependencies.contains(&package_name.to_string()) {
589 dependent_profiles.push(profile.name.clone());
590 is_safe = false;
591 }
592 }
593 for pkg in self.packages.values() {
594 if ignored_packages.contains(&pkg.name) {
595 continue;
596 }
597 if let Some(deps) = &pkg.dependencies
598 && deps.contains(&package_name.to_string())
599 {
600 dependent_packages.push(pkg.name.clone());
601 is_safe = false;
602 }
603 }
604 (is_safe, dependent_profiles, dependent_packages)
605 }
606
607 pub fn remove_profile(
608 &mut self,
609 args: &ProfileRemoveArgs,
610 ctx: &Context,
611 ) -> anyhow::Result<()> {
612 if !self.profiles.contains_key(&args.name) {
613 anyhow::bail!("Profile '{}' not found in configuration", args.name);
614 }
615 if args.name == "default" {
616 anyhow::bail!("Cannot remove the default profile");
617 }
618 if args.dry_run {
619 cprintln(
620 &format!("Profile '{}' would be removed (dry run)", args.name),
621 &LogLevel::Info,
622 );
623 return Ok(());
624 }
625 self.profiles.remove(&args.name);
626 self.save(&ctx.working_dir)?;
627 cprintln(&format!("Profile '{}' removed", args.name), &LogLevel::Info);
628 if args.remove_orphans {
629 let orphan_packages = self.get_orphan_packages();
630 let mut dirty = false;
631 for orphan in orphan_packages.iter() {
632 let pkg = self
633 .packages
634 .get(orphan)
635 .ok_or_else(|| {
636 anyhow::anyhow!("Orphan package '{}' not found in configuration", orphan)
637 })?
638 .clone();
639 match self.remove_package(&pkg, ctx) {
640 Err(e) => {
641 anyhow::bail!("Error removing orphan package '{}': {}", orphan, e);
642 }
643 Ok(_) => {
644 dirty = true;
645 cprintln(
646 &format!("Orphan package '{}' removed", orphan),
647 &LogLevel::Info,
648 );
649 }
650 }
651 }
652 if dirty {
653 self.save(&ctx.working_dir)?;
654 }
655 }
656 Ok(())
657 }
658}
659
660pub(crate) fn print_stats(stats: &HashMap<BackupDeployResult, u32>, op_type: OpType) {
661 let (op_name, op_success_name) = match op_type {
662 OpType::Backup => ("Backup", "backed up"),
663 OpType::Deploy => ("Deployment", "deployed"),
664 };
665 let mut summary_parts = vec![];
666 if let Some(count) = stats.get(&BackupDeployResult::Success) {
667 summary_parts.push(format!("✅ {} {}", count, op_success_name));
668 }
669 if let Some(count) = stats.get(&BackupDeployResult::Skipped) {
670 summary_parts.push(format!("🔄 {} no changes", count));
671 }
672 if let Some(count) = stats.get(&BackupDeployResult::Failed) {
673 summary_parts.push(format!("❌ {} failed", count));
674 }
675 if summary_parts.is_empty() {
676 cprintln(
677 &format!("No packages processed for {}", op_name),
678 &LogLevel::Info,
679 );
680 } else {
681 let mut summary_string = summary_parts.join(", ");
682 summary_string.push('.');
683 cprintln(&format!("{} summary:", op_name), &LogLevel::Info);
684 cprintln(&summary_string, &LogLevel::Info);
685 }
686}