Skip to main content

dotr_dear/config/
mod.rs

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
20/// JSON Schema for `config.toml`, referenced via a `#:schema` directive at
21/// the top of newly-generated config files (see `Config::init`).
22const 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    /// Name of the Bitwarden secure note used when `prompt_backend =
38    /// "bitwarden"`. Defaults to `prompt_store::DEFAULT_BITWARDEN_NOTE` when unset.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub bitwarden_note: Option<String>,
41    /// Repo-wide default backend for every prompt. A profile's own
42    /// `prompt_backend` overrides this when that profile is active.
43    #[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        // Every write needs the `#:schema` directive, not just the one from
66        // `init()` - otherwise the very next save (import, deploy, profiles
67        // add/remove, ...) drops it, since serializing `Config` back to TOML
68        // has no way to preserve a leading comment on its own.
69        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        // `save()` prepends the `#:schema` directive understood by taplo (the
327        // LSP behind VS Code's "Even Better TOML", Neovim's nvim-lspconfig
328        // taplo preset, and other taplo-based editor integrations) to
329        // associate this file with a JSON Schema for validation and
330        // autocomplete, without relying on ambiguous filename-based catalog
331        // matching.
332        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}