1pub mod dotfile;
4pub mod hook;
5pub mod link;
6pub mod source;
7pub mod transform;
8pub mod variables;
9
10use std::collections::{HashMap, HashSet};
11use std::fs::File;
12use std::ops::Deref;
13use std::path::{Path, PathBuf};
14
15use color_eyre::eyre::{bail, eyre, Context};
16use color_eyre::Result;
17use serde::{Deserialize, Serialize};
18
19use crate::profile::hook::Hook;
20use crate::profile::link::Symlink;
21use crate::profile::transform::ContentTransformer;
22use crate::profile::variables::{Variables, Vars};
23use crate::profile::{dotfile::Dotfile, source::PunktfSource};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub enum MergeMode {
31 Overwrite,
33
34 Keep,
36
37 Ask,
39}
40
41impl Default for MergeMode {
42 fn default() -> Self {
43 Self::Overwrite
44 }
45}
46
47#[derive(
52 Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
53)]
54#[serde(deny_unknown_fields)]
55pub struct Priority(pub u32);
56
57impl Priority {
58 pub const fn new(priority: u32) -> Self {
60 Self(priority)
61 }
62}
63
64#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct Profile {
68 #[serde(skip_serializing_if = "Vec::is_empty", default)]
70 pub aliases: Vec<String>,
71
72 #[serde(skip_serializing_if = "Vec::is_empty", default)]
76 pub extends: Vec<String>,
77
78 #[serde(skip_serializing_if = "Option::is_none", default)]
80 pub variables: Option<Variables>,
81
82 #[serde(skip_serializing_if = "Vec::is_empty", default)]
84 pub transformers: Vec<ContentTransformer>,
85
86 #[serde(skip_serializing_if = "Option::is_none", default)]
90 pub target: Option<PathBuf>,
91
92 #[serde(skip_serializing_if = "Vec::is_empty", default)]
95 pub pre_hooks: Vec<Hook>,
96
97 #[serde(skip_serializing_if = "Vec::is_empty", default)]
99 pub post_hooks: Vec<Hook>,
100
101 #[serde(skip_serializing_if = "Vec::is_empty", default)]
103 pub dotfiles: Vec<Dotfile>,
104
105 #[serde(rename = "links", skip_serializing_if = "Vec::is_empty", default)]
107 pub symlinks: Vec<Symlink>,
108}
109
110impl Profile {
111 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
121 let path = path.as_ref();
122
123 fn from_file_inner(path: &Path) -> Result<Profile> {
126 #[allow(unused_variables)]
128 let file = File::open(path)?;
129
130 let extension = path.extension().ok_or_else(|| {
131 std::io::Error::new(
132 std::io::ErrorKind::InvalidData,
133 "Failed to get file extension for profile",
134 )
135 })?;
136
137 #[cfg(feature = "profile-json")]
138 {
139 if extension.eq_ignore_ascii_case("json") {
140 return Profile::from_json_file(file);
141 }
142 }
143
144 #[cfg(feature = "profile-yaml")]
145 {
146 if extension.eq_ignore_ascii_case("yaml") || extension.eq_ignore_ascii_case("yml") {
147 return Profile::from_yaml_file(file);
148 }
149 }
150
151 Err(eyre!(
152 "Found unsupported file extension for profile (extension: {:?})",
153 extension
154 ))
155 }
156
157 from_file_inner(path).wrap_err(format!(
158 "Failed to process profile at path `{}`",
159 path.display()
160 ))
161 }
162
163 #[cfg(feature = "profile-json")]
165 fn from_json_file(file: File) -> Result<Self> {
166 serde_json::from_reader(&file).map_err(|err| {
167 color_eyre::Report::msg(err).wrap_err("Failed to parse profile from json content.")
168 })
169 }
170
171 #[cfg(feature = "profile-yaml")]
173 fn from_yaml_file(file: File) -> Result<Self> {
174 serde_yaml::from_reader(file).map_err(|err| {
175 color_eyre::Report::msg(err).wrap_err("Failed to parse profile from yaml content.")
176 })
177 }
178}
179
180#[derive(Default, Debug, Clone, PartialEq, Eq)]
183pub struct LayeredVariables {
184 pub inner: HashMap<String, (usize, String)>,
188}
189
190impl Vars for LayeredVariables {
191 fn var<K>(&self, key: K) -> Option<&str>
192 where
193 K: AsRef<str>,
194 {
195 self.inner.get(key.as_ref()).map(|(_, value)| value.deref())
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct LayeredProfile {
202 pub profile_names: Vec<String>,
204
205 pub target: Option<(usize, PathBuf)>,
210
211 pub variables: LayeredVariables,
213
214 pub transformers: Vec<(usize, ContentTransformer)>,
216
217 pub pre_hooks: Vec<(usize, Hook)>,
219
220 pub post_hooks: Vec<(usize, Hook)>,
222
223 pub dotfiles: Vec<(usize, Dotfile)>,
229
230 pub symlinks: Vec<(usize, Symlink)>,
236}
237
238impl LayeredProfile {
239 pub fn build() -> LayeredProfileBuilder {
241 LayeredProfileBuilder::default()
242 }
243
244 pub fn target(&self) -> Option<(&str, &Path)> {
247 self.target
248 .as_ref()
249 .map(|(name_idx, path)| (self.profile_names[*name_idx].as_ref(), path.deref()))
250 }
251
252 pub fn target_path(&self) -> Option<&Path> {
254 self.target.as_ref().map(|(_, path)| path.deref())
255 }
256
257 pub const fn variables(&self) -> &LayeredVariables {
259 &self.variables
260 }
261
262 pub fn transformers_len(&self) -> usize {
264 self.transformers.len()
265 }
266
267 pub fn transformers(&self) -> impl Iterator<Item = &ContentTransformer> {
269 self.transformers.iter().map(|(_, transformer)| transformer)
270 }
271
272 pub fn pre_hooks(&self) -> impl Iterator<Item = &Hook> {
274 self.pre_hooks.iter().map(|(_, hook)| hook)
275 }
276
277 pub fn post_hooks(&self) -> impl Iterator<Item = &Hook> {
279 self.post_hooks.iter().map(|(_, hook)| hook)
280 }
281
282 pub fn dotfiles(&self) -> impl Iterator<Item = &Dotfile> {
284 self.dotfiles.iter().map(|(_, dotfile)| dotfile)
285 }
286
287 pub fn symlinks(&self) -> impl Iterator<Item = &Symlink> {
289 self.symlinks.iter().map(|(_, symlink)| symlink)
290 }
291}
292
293#[derive(Default, Debug, Clone, PartialEq, Eq)]
295pub struct LayeredProfileBuilder {
296 profile_names: Vec<String>,
298
299 profiles: Vec<Profile>,
302}
303
304impl LayeredProfileBuilder {
305 pub fn add(&mut self, name: String, profile: Profile) -> &mut Self {
307 self.profiles.push(profile);
308 self.profile_names.push(name);
309
310 self
311 }
312
313 pub fn finish(self) -> LayeredProfile {
315 let target = self.profiles.iter().enumerate().find_map(|(idx, profile)| {
316 profile
317 .target
318 .as_ref()
319 .map(move |target| (idx, target.to_path_buf()))
320 });
321
322 let mut variables = LayeredVariables::default();
323
324 for (idx, vars) in self
325 .profiles
326 .iter()
327 .enumerate()
328 .filter_map(move |(idx, profile)| profile.variables.as_ref().map(|vars| (idx, vars)))
329 {
330 for (key, value) in vars.inner.iter() {
331 if !variables.inner.contains_key(key) {
332 variables
333 .inner
334 .insert(key.to_owned(), (idx, value.to_owned()));
335 }
336 }
337 }
338
339 let mut transformers = Vec::new();
340
341 for (idx, transformer) in self
342 .profiles
343 .iter()
344 .enumerate()
345 .map(|(idx, profile)| (idx, &profile.transformers))
346 {
347 for t in transformer.iter() {
348 if !transformers.iter().any(|(_, tt)| t == tt) {
349 transformers.push((idx, *t));
350 }
351 }
352 }
353
354 let pre_hooks = self
355 .profiles
356 .iter()
357 .enumerate()
358 .flat_map(|(idx, profile)| {
359 profile
360 .pre_hooks
361 .iter()
362 .cloned()
363 .map(move |hook| (idx, hook))
364 })
365 .collect();
366
367 let post_hooks = self
368 .profiles
369 .iter()
370 .enumerate()
371 .flat_map(|(idx, profile)| {
372 profile
373 .post_hooks
374 .iter()
375 .cloned()
376 .map(move |hook| (idx, hook))
377 })
378 .collect();
379
380 let mut added_dotfile_paths = HashSet::new();
381 let mut dotfiles = Vec::new();
382
383 for (idx, dfiles) in self
384 .profiles
385 .iter()
386 .enumerate()
387 .map(|(idx, profile)| (idx, &profile.dotfiles))
388 {
389 for dotfile in dfiles.iter() {
390 if !added_dotfile_paths.contains(&dotfile.path) {
391 dotfiles.push((idx, dotfile.clone()));
392 added_dotfile_paths.insert(dotfile.path.clone());
393 }
394 }
395 }
396
397 let symlinks = self
398 .profiles
399 .iter()
400 .enumerate()
401 .flat_map(|(idx, profile)| {
402 profile
403 .symlinks
404 .iter()
405 .cloned()
406 .map(move |link| (idx, link))
407 })
408 .collect();
409
410 LayeredProfile {
411 profile_names: self.profile_names,
412 target,
413 variables,
414 transformers,
415 pre_hooks,
416 post_hooks,
417 dotfiles,
418 symlinks,
419 }
420 }
421}
422
423#[derive(Default, Debug, Serialize, Deserialize)]
427#[serde(default)]
428struct Aliases {
429 aliases: Vec<String>,
433}
434
435pub fn collect_profile_names(source: &PunktfSource) -> Result<HashMap<String, PathBuf>> {
437 log::info!("Collecting profile names and aliases");
438
439 fn get_aliases(path: &Path, extension: &str) -> Option<Aliases> {
441 let Ok(file) = File::open(path) else {
442 log::debug!("[{}] Failed to read content", path.display());
443 return None;
444 };
445
446 #[cfg(feature = "profile-json")]
447 {
448 if extension.eq_ignore_ascii_case("json") {
449 let Ok(aliases) = serde_json::from_reader(file) else {
450 log::debug!("[{}] Failed to read aliases", path.display());
451 return None;
452 };
453
454 return Some(aliases);
455 }
456 }
457
458 #[cfg(feature = "profile-yaml")]
459 {
460 if extension.eq_ignore_ascii_case("yaml") || extension.eq_ignore_ascii_case("yml") {
461 let Ok(aliases) = serde_yaml::from_reader(file) else {
462 log::debug!("[{}] Failed to read aliases", path.display());
463 return None;
464 };
465
466 return Some(aliases);
467 }
468 }
469
470 None
471 }
472
473 let mut names = HashMap::new();
474
475 let dents = source.profiles().read_dir()?;
476 for dent in dents {
477 let dent = dent?;
478 let path = dent.path();
479
480 let Ok(ft) = dent.file_type() else {
481 log::debug!("[{}] Failed to get file type", path.display());
482 continue;
483 };
484
485 if !ft.is_file() {
486 log::debug!("[{}] Not a file", path.display());
487 continue;
488 }
489
490 let Some(extension) = path.extension().and_then(|e| e.to_str()) else {
491 log::debug!("[{}] Failed to get file extension", path.display());
492 continue;
493 };
494
495 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
496 log::debug!("[{}] Failed to get file name", path.display());
497 continue;
498 };
499 let name = &name[..(name.len() - (extension.len() + 1))];
501
502 for alias in get_aliases(&path, extension)
503 .into_iter()
504 .flat_map(|a| a.aliases.into_iter())
505 {
506 log::debug!("[{}] Adding alias {}", path.display(), alias);
507
508 if let Some(evicted) = names.insert(alias.clone(), path.clone()) {
509 bail!(
510 "[{}] The profile alias {} is already taken by {}",
511 path.display(),
512 alias,
513 evicted.display()
514 );
515 }
516 }
517
518 if let Some(evicted) = names.insert(name.to_string(), path.clone()) {
519 bail!(
520 "[{}] The profile name {} is already taken by {}",
521 path.display(),
522 name,
523 evicted.display()
524 );
525 }
526 }
527
528 log::info!("Found {} profile names and aliases", names.len());
529
530 Ok(names)
531}
532
533pub fn resolve_profile(
537 builder: &mut LayeredProfileBuilder,
538 source: &PunktfSource,
539 name: &str,
540) -> Result<()> {
541 fn _resolve_profile_inner(
545 profiles: &HashMap<String, PathBuf>,
546 builder: &mut LayeredProfileBuilder,
547 name: &str,
548 resolved_profiles: &mut Vec<String>,
549 ) -> Result<()> {
550 log::trace!("Resolving profile `{}`", name);
551
552 let path = profiles
553 .get(name)
554 .ok_or_else(|| eyre!("No profile found for name {}", name))?;
555
556 let mut profile = Profile::from_file(path)?;
557 let name = name.to_string();
558
559 if !profile.extends.is_empty() && resolved_profiles.contains(&name) {
560 return Err(eyre!(
563 "Circular dependency detected while parsing `{}` (required by: `{:?}`) (Stack: {:#?})",
564 name,
565 resolved_profiles.last(),
566 resolved_profiles
567 ));
568 }
569
570 let mut extends = Vec::new();
571 std::mem::swap(&mut extends, &mut profile.extends);
572
573 builder.add(name.clone(), profile);
574
575 resolved_profiles.push(name);
576
577 for child in extends {
578 _resolve_profile_inner(profiles, builder, &child, resolved_profiles)?;
579 }
580
581 let _ = resolved_profiles
582 .pop()
583 .expect("Misaligned push/pop operation");
584
585 Ok(())
586 }
587
588 let available_profiles = collect_profile_names(source)?;
589 let mut resolved_profiles = Vec::new();
590
591 _resolve_profile_inner(&available_profiles, builder, name, &mut resolved_profiles)
592}
593
594#[cfg(test)]
595mod tests {
596 use std::collections::HashMap;
597
598 use super::*;
599 use crate::profile::hook::Hook;
600 use crate::profile::variables::Variables;
601 use crate::profile::Profile;
602 use crate::profile::{MergeMode, Priority};
603
604 #[test]
605 fn priority_order() {
606 crate::tests::setup_test_env();
607
608 assert!(Priority::default() == Priority::new(0));
609 assert!(Priority::new(0) == Priority::new(0));
610 assert!(Priority::new(2) > Priority::new(1));
611 }
612
613 #[test]
614 #[cfg(feature = "profile-json")]
615 fn profile_serde() {
616 crate::tests::setup_test_env();
617
618 let mut profile_vars = HashMap::new();
619 profile_vars.insert(String::from("RUSTC_VERSION"), String::from("XX.YY"));
620 profile_vars.insert(String::from("RUSTC_PATH"), String::from("/usr/bin/rustc"));
621
622 let mut dotfile_vars = HashMap::new();
623 dotfile_vars.insert(String::from("RUSTC_VERSION"), String::from("55.22"));
624 dotfile_vars.insert(String::from("USERNAME"), String::from("demo"));
625
626 let profile = Profile {
627 extends: Vec::new(),
628 aliases: vec![],
629 variables: Some(Variables {
630 inner: profile_vars,
631 }),
632 transformers: Vec::new(),
633 target: Some(PathBuf::from("/home/demo/.config")),
634 pre_hooks: vec![Hook::new("echo \"Foo\"")],
635 post_hooks: vec![Hook::new("profiles/test.sh")],
636 dotfiles: vec![
637 Dotfile {
638 path: PathBuf::from("init.vim.ubuntu"),
639 rename: Some(PathBuf::from("init.vim")),
640 overwrite_target: None,
641 priority: Some(Priority::new(2)),
642 variables: None,
643 transformers: Vec::new(),
644 merge: Some(MergeMode::Overwrite),
645 template: None,
646 },
647 Dotfile {
648 path: PathBuf::from(".bashrc"),
649 rename: None,
650 overwrite_target: Some(PathBuf::from("/home/demo")),
651 priority: None,
652 variables: Some(Variables {
653 inner: dotfile_vars,
654 }),
655 transformers: Vec::new(),
656 merge: Some(MergeMode::Overwrite),
657 template: Some(false),
658 },
659 ],
660 symlinks: vec![],
661 };
662
663 let json = serde_json::to_string(&profile).expect("Profile to be serializeable");
664
665 let parsed: Profile = serde_json::from_str(&json).expect("Profile to be deserializable");
666
667 assert_eq!(parsed, profile);
668 }
669}