1#![allow(clippy::result_large_err)]
28
29use std::collections::BTreeMap;
30use std::fs;
31use std::io;
32use std::path::{Path, PathBuf};
33use std::process::Command as StdCommand;
34
35use thiserror::Error;
36
37use crate::config::{PromptField, PromptSection};
38
39#[derive(Debug, Error)]
43pub enum SetupError {
44 #[error("unknown prompt section: {0:?}")]
46 UnknownPromptSection(String),
47
48 #[error("unknown writer {0:?} — must be one of: gitconfig, hypr_vars, env, generic_template")]
50 UnknownWriter(String),
51
52 #[error("required field {key:?} has no default; cannot run unattended")]
54 RequiredFieldHasNoDefault {
55 key: String,
57 },
58
59 #[error("io: {0}")]
61 Io(#[from] io::Error),
62
63 #[error("resolve: {0}")]
65 Resolve(#[from] ResolveError),
66}
67
68#[derive(Debug, Error)]
70pub enum ResolveError {
71 #[error("git binary not found when resolving git: default")]
73 GitBinaryNotFound,
74
75 #[error("git config key {0:?} not set")]
77 GitKeyNotSet(String),
78}
79
80pub struct SetupOpts {
84 pub sections: Vec<String>,
87
88 pub yes: bool,
91
92 pub prompt_sections: BTreeMap<String, PromptSection>,
94}
95
96#[derive(Debug, Default)]
98pub struct SetupReport {
99 pub sections_run: Vec<String>,
101
102 pub fields_collected: usize,
104
105 pub files_written: Vec<PathBuf>,
107
108 pub skipped_by_requires: Vec<String>,
110}
111
112pub trait Prompter {
116 fn ask_string(
118 &mut self,
119 prompt: &str,
120 default: Option<&str>,
121 optional: bool,
122 ) -> io::Result<String>;
123
124 fn ask_bool(&mut self, prompt: &str, default: bool) -> io::Result<bool>;
126
127 fn ask_int(&mut self, prompt: &str, default: Option<i64>) -> io::Result<i64>;
129}
130
131pub struct RealPrompter;
135
136impl Prompter for RealPrompter {
137 fn ask_string(
138 &mut self,
139 prompt: &str,
140 default: Option<&str>,
141 _optional: bool,
142 ) -> io::Result<String> {
143 let mut input = dialoguer::Input::<String>::new().with_prompt(prompt);
144 if let Some(d) = default {
145 input = input.with_initial_text(d).allow_empty(true);
146 } else {
147 input = input.allow_empty(true);
148 }
149 input
150 .interact_text()
151 .map_err(|e| io::Error::other(e.to_string()))
152 }
153
154 fn ask_bool(&mut self, prompt: &str, default: bool) -> io::Result<bool> {
155 dialoguer::Confirm::new()
156 .with_prompt(prompt)
157 .default(default)
158 .interact()
159 .map_err(|e| io::Error::other(e.to_string()))
160 }
161
162 fn ask_int(&mut self, prompt: &str, default: Option<i64>) -> io::Result<i64> {
163 let mut input = dialoguer::Input::<i64>::new().with_prompt(prompt);
164 if let Some(d) = default {
165 input = input.default(d);
166 }
167 input
168 .interact_text()
169 .map_err(|e| io::Error::other(e.to_string()))
170 }
171}
172
173pub struct YesPrompter;
178
179impl Prompter for YesPrompter {
180 fn ask_string(
181 &mut self,
182 _prompt: &str,
183 default: Option<&str>,
184 optional: bool,
185 ) -> io::Result<String> {
186 match default {
187 Some(d) => Ok(d.to_owned()),
188 None if optional => Ok(String::new()),
189 None => Err(io::Error::new(
190 io::ErrorKind::InvalidInput,
191 "required field has no default",
192 )),
193 }
194 }
195
196 fn ask_bool(&mut self, _prompt: &str, default: bool) -> io::Result<bool> {
197 Ok(default)
198 }
199
200 fn ask_int(&mut self, _prompt: &str, default: Option<i64>) -> io::Result<i64> {
201 default.ok_or_else(|| {
202 io::Error::new(io::ErrorKind::InvalidInput, "required field has no default")
203 })
204 }
205}
206
207pub struct ScriptedPrompter {
211 pub answers: std::collections::VecDeque<String>,
213 pub bool_answers: std::collections::VecDeque<bool>,
215}
216
217impl ScriptedPrompter {
218 pub fn new(answers: &[&str], bool_answers: &[bool]) -> Self {
220 Self {
221 answers: answers.iter().map(|s| s.to_string()).collect(),
222 bool_answers: bool_answers.iter().copied().collect(),
223 }
224 }
225}
226
227impl Prompter for ScriptedPrompter {
228 fn ask_string(
229 &mut self,
230 _prompt: &str,
231 default: Option<&str>,
232 _optional: bool,
233 ) -> io::Result<String> {
234 if let Some(a) = self.answers.pop_front() {
235 Ok(a)
236 } else {
237 Ok(default.unwrap_or("").to_owned())
238 }
239 }
240
241 fn ask_bool(&mut self, _prompt: &str, default: bool) -> io::Result<bool> {
242 Ok(self.bool_answers.pop_front().unwrap_or(default))
243 }
244
245 fn ask_int(&mut self, _prompt: &str, default: Option<i64>) -> io::Result<i64> {
246 if let Some(a) = self.answers.pop_front() {
247 a.parse()
248 .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, format!("{e}")))
249 } else {
250 Ok(default.unwrap_or(0))
251 }
252 }
253}
254
255pub trait GitConfig {
260 fn get(&self, key: &str) -> Option<String>;
263}
264
265pub struct RealGitConfig;
274
275impl GitConfig for RealGitConfig {
276 fn get(&self, key: &str) -> Option<String> {
277 let output = StdCommand::new("git")
278 .args(["config", "--get", key])
279 .output()
280 .ok()?;
281 if output.status.success() {
282 let s = String::from_utf8_lossy(&output.stdout).trim().to_owned();
283 if s.is_empty() { None } else { Some(s) }
284 } else {
285 None
286 }
287 }
288}
289
290pub struct FakeGitConfig(pub BTreeMap<String, String>);
292
293impl GitConfig for FakeGitConfig {
294 fn get(&self, key: &str) -> Option<String> {
295 self.0.get(key).cloned()
296 }
297}
298
299fn read_hypr_var(dst: &Path, var_name: &str) -> Option<String> {
303 let content = fs::read_to_string(dst).ok()?;
304 let needle = format!("${var_name} = ");
305 for line in content.lines() {
306 if let Some(rest) = line.strip_prefix(&needle) {
307 return Some(rest.trim().to_owned());
308 }
309 }
310 None
311}
312
313fn resolve_default(
317 field: &PromptField,
318 collected: &BTreeMap<String, String>,
319 dst: Option<&Path>,
320 git: &dyn GitConfig,
321) -> Option<String> {
322 if let Some(var_name) = &field.read_var
324 && let Some(dst_path) = dst
325 && let Some(val) = read_hypr_var(dst_path, var_name)
326 {
327 return Some(val);
328 }
329
330 if let Some(from) = &field.default_from {
332 if let Some(key) = from.strip_prefix("git:") {
333 if let Some(val) = git.get(key) {
334 return Some(val);
335 }
336 } else if let Some(var) = from.strip_prefix("env:") {
337 if let Ok(val) = std::env::var(var)
338 && !val.is_empty()
339 {
340 return Some(val);
341 }
342 } else if let Some(key) = from.strip_prefix("field:") {
343 if let Some(val) = collected.get(key)
344 && !val.is_empty()
345 {
346 return Some(val.clone());
347 }
348 } else if let Some(var_name) = from.strip_prefix("read_var:")
349 && let Some(dst_path) = dst
350 && let Some(val) = read_hypr_var(dst_path, var_name)
351 {
352 return Some(val);
353 }
354 }
355
356 if let Some(dv) = &field.default {
358 let s = match dv {
359 toml::Value::String(s) => s.clone(),
360 toml::Value::Boolean(b) => b.to_string(),
361 toml::Value::Integer(i) => i.to_string(),
362 toml::Value::Float(f) => f.to_string(),
363 other => other.to_string(),
364 };
365 return Some(s);
366 }
367
368 None
369}
370
371fn atomic_write(dst: &Path, content: &str) -> io::Result<()> {
375 if let Some(parent) = dst.parent() {
376 fs::create_dir_all(parent)?;
377 }
378 let mut tmp_name = dst.file_name().unwrap_or_default().to_os_string();
379 tmp_name.push(format!(".krypt-tmp-{}", std::process::id()));
380 let tmp = dst.with_file_name(tmp_name);
381 let _ = fs::remove_file(&tmp);
382 fs::write(&tmp, content.as_bytes())?;
383 fs::rename(&tmp, dst)?;
384 Ok(())
385}
386
387fn write_gitconfig(values: &BTreeMap<String, String>, dst: &Path) -> io::Result<()> {
395 let existing = if dst.exists() {
397 fs::read_to_string(dst)?
398 } else {
399 String::new()
400 };
401
402 let mut sections: Vec<(String, Vec<(String, String)>)> = Vec::new();
405
406 let mut current_section: Option<String> = None;
407 for line in existing.lines() {
408 let trimmed = line.trim();
409 if trimmed.starts_with('[') && trimmed.ends_with(']') {
410 let sec = trimmed[1..trimmed.len() - 1].trim().to_owned();
411 current_section = Some(sec.clone());
412 sections.push((sec, Vec::new()));
413 } else if let Some(ref sec) = current_section
414 && let Some(pos) = trimmed.find('=')
415 {
416 let k = trimmed[..pos].trim().to_owned();
417 let v = trimmed[pos + 1..].trim().to_owned();
418 if let Some(entry) = sections.iter_mut().find(|(s, _)| s == sec) {
419 entry.1.push((k, v));
420 }
421 }
422 }
423
424 let mut by_section: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
426 for (dotkey, val) in values {
427 if val.is_empty() {
428 continue;
429 }
430 let (sec, key) = if let Some(pos) = dotkey.find('.') {
431 (dotkey[..pos].to_owned(), dotkey[pos + 1..].to_owned())
432 } else {
433 ("core".to_owned(), dotkey.clone())
434 };
435 by_section.entry(sec).or_default().insert(key, val.clone());
436 }
437
438 for (sec, kv_map) in &by_section {
440 if let Some(section) = sections.iter_mut().find(|(s, _)| s == sec) {
441 for (k, v) in kv_map {
442 if let Some(pair) = section.1.iter_mut().find(|(sk, _)| sk == k) {
443 pair.1 = v.clone();
444 } else {
445 section.1.push((k.clone(), v.clone()));
446 }
447 }
448 } else {
449 sections.push((
451 sec.clone(),
452 kv_map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
453 ));
454 }
455 }
456
457 let mut out = String::new();
459 for (i, (sec, pairs)) in sections.iter().enumerate() {
460 if i > 0 {
461 out.push('\n');
462 }
463 out.push_str(&format!("[{sec}]\n"));
464 for (k, v) in pairs {
465 out.push_str(&format!(" {k} = {v}\n"));
466 }
467 }
468
469 atomic_write(dst, &out)
470}
471
472fn write_hypr_vars(values: &BTreeMap<String, String>, dst: &Path) -> io::Result<()> {
479 let mut lines: Vec<String> = if dst.exists() {
480 fs::read_to_string(dst)?
481 .lines()
482 .map(|l| l.to_owned())
483 .collect()
484 } else {
485 Vec::new()
486 };
487
488 let mut written: BTreeMap<&str, bool> = BTreeMap::new();
489
490 for (key, val) in values {
491 let needle = format!("${key} = ");
492 let mut found = false;
493 for line in lines.iter_mut() {
494 if line.starts_with(&needle) || line == &format!("${key} =") {
495 *line = format!("${key} = {val}");
496 found = true;
497 break;
498 }
499 }
500 if !found {
501 lines.push(format!("${key} = {val}"));
502 }
503 written.insert(key, true);
504 }
505
506 let mut out = lines.join("\n");
507 if !out.is_empty() {
508 out.push('\n');
509 }
510 atomic_write(dst, &out)
511}
512
513fn write_env(values: &BTreeMap<String, String>, dst: &Path) -> io::Result<()> {
518 let mut out = String::new();
519 for (key, val) in values {
520 if val.is_empty() {
521 continue;
522 }
523 let quoted = if val.chars().any(|c| c.is_whitespace()) {
524 format!("\"{val}\"")
525 } else {
526 val.clone()
527 };
528 out.push_str(&format!("export {key}={quoted}\n"));
529 }
530 atomic_write(dst, &out)
531}
532
533pub fn write_generic_template(
538 values: &BTreeMap<String, String>,
539 src: &Path,
540 dst: &Path,
541) -> io::Result<()> {
542 let mut content = fs::read_to_string(src)?;
543 for (key, val) in values {
544 let placeholder = format!("{{{{{key}}}}}");
545 content = content.replace(&placeholder, val);
546 }
547 atomic_write(dst, &content)
548}
549
550fn run_section(
555 section: &PromptSection,
556 prompter: &mut dyn Prompter,
557 git: &dyn GitConfig,
558 dst: Option<&Path>,
559 yes: bool,
560) -> Result<(BTreeMap<String, String>, Vec<String>), SetupError> {
561 let mut collected: BTreeMap<String, String> = BTreeMap::new();
562 let mut skipped: Vec<String> = Vec::new();
563
564 for field in §ion.fields {
565 if let Some(req_key) = &field.requires {
567 let gate_val = collected
568 .get(req_key.as_str())
569 .map(|s| s.as_str())
570 .unwrap_or("");
571 if gate_val.is_empty() {
572 skipped.push(field.key.clone());
573 continue;
574 }
575 }
576
577 let default = resolve_default(field, &collected, dst, git);
578
579 let value = match field.r#type.as_str() {
580 "bool" => {
581 let def_bool = default.as_deref().map(|s| s == "true").unwrap_or(false);
582 if yes {
583 if def_bool { "true" } else { "false" }.to_owned()
584 } else {
585 let b = prompter.ask_bool(&field.prompt, def_bool)?;
586 if b { "true" } else { "false" }.to_owned()
587 }
588 }
589 "int" => {
590 let def_int = default.as_deref().and_then(|s| s.parse::<i64>().ok());
591 if yes {
592 match def_int {
593 Some(d) => d.to_string(),
594 None if field.optional => String::new(),
595 None => {
596 return Err(SetupError::RequiredFieldHasNoDefault {
597 key: field.key.clone(),
598 });
599 }
600 }
601 } else {
602 let i = prompter.ask_int(&field.prompt, def_int)?;
603 i.to_string()
604 }
605 }
606 _ => {
607 if yes {
609 match &default {
610 Some(d) => d.clone(),
611 None if field.optional => String::new(),
612 None => {
613 return Err(SetupError::RequiredFieldHasNoDefault {
614 key: field.key.clone(),
615 });
616 }
617 }
618 } else {
619 prompter.ask_string(&field.prompt, default.as_deref(), field.optional)?
620 }
621 }
622 };
623
624 collected.insert(field.key.clone(), value);
625 }
626
627 Ok((collected, skipped))
628}
629
630pub fn setup(
637 opts: &SetupOpts,
638 prompter: &mut dyn Prompter,
639 git: &dyn GitConfig,
640) -> Result<SetupReport, SetupError> {
641 let mut report = SetupReport::default();
642
643 let names: Vec<String> = if opts.sections.is_empty() {
645 opts.prompt_sections.keys().cloned().collect()
646 } else {
647 opts.sections.clone()
648 };
649
650 for name in &names {
652 if !opts.prompt_sections.contains_key(name.as_str()) {
653 return Err(SetupError::UnknownPromptSection(name.clone()));
654 }
655 }
656
657 for name in &names {
658 let section = &opts.prompt_sections[name.as_str()];
659
660 if !section.heading.is_empty() {
661 println!("\n── {} ──", section.heading);
662 }
663
664 let (collected, skipped) = run_section(section, prompter, git, None, opts.yes)?;
670
671 report.fields_collected += collected.len();
672 report.skipped_by_requires.extend(skipped);
673
674 match section.writer.as_str() {
676 "gitconfig" | "hypr_vars" | "env" | "generic_template" => {
677 }
683 other => {
684 return Err(SetupError::UnknownWriter(other.to_owned()));
685 }
686 }
687
688 report.sections_run.push(name.clone());
689 }
690
691 Ok(report)
692}
693
694pub fn setup_with_destinations(
701 opts: &SetupOpts,
702 dsts: &BTreeMap<String, PathBuf>,
703 prompter: &mut dyn Prompter,
704 git: &dyn GitConfig,
705) -> Result<SetupReport, SetupError> {
706 setup_with_destinations_and_srcs(opts, dsts, &BTreeMap::new(), prompter, git)
707}
708
709pub fn setup_with_destinations_and_srcs(
714 opts: &SetupOpts,
715 dsts: &BTreeMap<String, PathBuf>,
716 srcs: &BTreeMap<String, PathBuf>,
717 prompter: &mut dyn Prompter,
718 git: &dyn GitConfig,
719) -> Result<SetupReport, SetupError> {
720 let mut report = SetupReport::default();
721
722 let names: Vec<String> = if opts.sections.is_empty() {
723 opts.prompt_sections.keys().cloned().collect()
724 } else {
725 opts.sections.clone()
726 };
727
728 for name in &names {
729 if !opts.prompt_sections.contains_key(name.as_str()) {
730 return Err(SetupError::UnknownPromptSection(name.clone()));
731 }
732 }
733
734 for name in &names {
735 let section = &opts.prompt_sections[name.as_str()];
736
737 if !section.heading.is_empty() {
738 println!("\n── {} ──", section.heading);
739 }
740
741 let dst = dsts.get(name).map(|p| p.as_path());
742
743 let (collected, skipped) = run_section(section, prompter, git, dst, opts.yes)?;
744
745 report.fields_collected += collected.len();
746 report.skipped_by_requires.extend(skipped);
747
748 let dst_path = match dst {
749 Some(p) => p,
750 None => {
751 report.sections_run.push(name.clone());
752 continue;
753 }
754 };
755
756 match section.writer.as_str() {
757 "gitconfig" => {
758 write_gitconfig(&collected, dst_path)?;
759 report.files_written.push(dst_path.to_path_buf());
760 }
761 "hypr_vars" => {
762 write_hypr_vars(&collected, dst_path)?;
763 report.files_written.push(dst_path.to_path_buf());
764 }
765 "env" => {
766 write_env(&collected, dst_path)?;
767 report.files_written.push(dst_path.to_path_buf());
768 }
769 "generic_template" => {
770 if let Some(src_path) = srcs.get(name) {
771 write_generic_template(&collected, src_path, dst_path)?;
772 report.files_written.push(dst_path.to_path_buf());
773 }
774 }
776 other => {
777 return Err(SetupError::UnknownWriter(other.to_owned()));
778 }
779 }
780
781 report.sections_run.push(name.clone());
782 }
783
784 Ok(report)
785}
786
787#[cfg(test)]
790mod tests {
791 use super::*;
792 use crate::config::{PromptField, PromptSection};
793 use tempfile::tempdir;
794
795 fn make_field(key: &str, prompt: &str) -> PromptField {
796 PromptField {
797 key: key.to_owned(),
798 prompt: prompt.to_owned(),
799 r#type: "string".to_owned(),
800 default: None,
801 default_from: None,
802 read_var: None,
803 optional: false,
804 requires: None,
805 }
806 }
807
808 fn make_section(fields: Vec<PromptField>, writer: &str) -> PromptSection {
809 PromptSection {
810 heading: String::new(),
811 fields,
812 writer: writer.to_owned(),
813 }
814 }
815
816 #[test]
818 fn fields_collected_in_order() {
819 let mut sections = BTreeMap::new();
820 sections.insert(
821 "git".to_owned(),
822 make_section(
823 vec![make_field("name", "Name"), make_field("email", "Email")],
824 "gitconfig",
825 ),
826 );
827
828 let opts = SetupOpts {
829 sections: vec!["git".to_owned()],
830 yes: false,
831 prompt_sections: sections,
832 };
833
834 let mut p = ScriptedPrompter::new(&["Alice", "alice@example.com"], &[]);
835 let git = FakeGitConfig(BTreeMap::new());
836 let report = setup(&opts, &mut p, &git).unwrap();
837
838 assert_eq!(report.sections_run, vec!["git"]);
839 assert_eq!(report.fields_collected, 2);
840 }
841
842 #[test]
844 fn requires_skips_field_when_gate_empty() {
845 let gated = PromptField {
846 requires: Some("key".to_owned()),
847 ..make_field("sign", "Sign commits?")
848 };
849 let mut sections = BTreeMap::new();
850 sections.insert(
851 "git".to_owned(),
852 make_section(
853 vec![
854 PromptField {
855 optional: true,
856 ..make_field("key", "GPG key")
857 },
858 gated,
859 ],
860 "gitconfig",
861 ),
862 );
863
864 let opts = SetupOpts {
865 sections: vec!["git".to_owned()],
866 yes: false,
867 prompt_sections: sections,
868 };
869
870 let mut p = ScriptedPrompter::new(&[""], &[]);
872 let git = FakeGitConfig(BTreeMap::new());
873 let report = setup(&opts, &mut p, &git).unwrap();
874
875 assert!(
876 report.skipped_by_requires.contains(&"sign".to_owned()),
877 "sign should be skipped"
878 );
879 }
880
881 #[test]
886 fn default_from_env() {
887 let (var_name, expected) = if let Ok(v) = std::env::var("HOME") {
890 ("HOME".to_owned(), v)
891 } else if let Ok(v) = std::env::var("PATH") {
892 ("PATH".to_owned(), v)
893 } else {
894 return; };
896
897 let field = PromptField {
898 default_from: Some(format!("env:{var_name}")),
899 ..make_field("val", "Value")
900 };
901
902 let git = FakeGitConfig(BTreeMap::new());
903 let default = resolve_default(&field, &BTreeMap::new(), None, &git);
904
905 assert_eq!(default, Some(expected));
906 }
907
908 #[test]
910 fn default_from_field() {
911 let field = PromptField {
912 default_from: Some("field:email".to_owned()),
913 ..make_field("key", "Key")
914 };
915
916 let mut prior = BTreeMap::new();
917 prior.insert("email".to_owned(), "mx@example.com".to_owned());
918
919 let git = FakeGitConfig(BTreeMap::new());
920 let default = resolve_default(&field, &prior, None, &git);
921 assert_eq!(default, Some("mx@example.com".to_owned()));
922 }
923
924 #[test]
926 fn default_from_git() {
927 let mut git_map = BTreeMap::new();
928 git_map.insert("user.name".to_owned(), "Mx Addict".to_owned());
929 let git = FakeGitConfig(git_map);
930
931 let field = PromptField {
932 default_from: Some("git:user.name".to_owned()),
933 ..make_field("name", "Name")
934 };
935
936 let default = resolve_default(&field, &BTreeMap::new(), None, &git);
937 assert_eq!(default, Some("Mx Addict".to_owned()));
938 }
939
940 #[test]
942 fn read_var_from_dst_file() {
943 let dir = tempdir().unwrap();
944 let dst = dir.path().join("hyprland.conf");
945 fs::write(&dst, "$terminal = kitty\n$bar = waybar\n").unwrap();
946
947 let field = PromptField {
948 read_var: Some("terminal".to_owned()),
949 ..make_field("terminal", "Terminal")
950 };
951
952 let git = FakeGitConfig(BTreeMap::new());
953 let default = resolve_default(&field, &BTreeMap::new(), Some(&dst), &git);
954 assert_eq!(default, Some("kitty".to_owned()));
955 }
956
957 #[test]
959 fn yes_mode_no_default_required_errors() {
960 let mut sections = BTreeMap::new();
961 sections.insert(
962 "git".to_owned(),
963 make_section(vec![make_field("name", "Name")], "gitconfig"),
964 );
965
966 let opts = SetupOpts {
967 sections: vec!["git".to_owned()],
968 yes: true,
969 prompt_sections: sections,
970 };
971
972 let mut p = YesPrompter;
973 let git = FakeGitConfig(BTreeMap::new());
974 let err = setup(&opts, &mut p, &git).unwrap_err();
975
976 assert!(
977 matches!(err, SetupError::RequiredFieldHasNoDefault { .. }),
978 "expected RequiredFieldHasNoDefault, got {err:?}"
979 );
980 }
981
982 #[test]
984 fn gitconfig_writer_merges() {
985 let dir = tempdir().unwrap();
986 let dst = dir.path().join(".gitconfig");
987 fs::write(&dst, "[user]\n name = Old\n old_key = keep\n").unwrap();
988
989 let mut values = BTreeMap::new();
990 values.insert("user.name".to_owned(), "New".to_owned());
991 values.insert("user.email".to_owned(), "new@example.com".to_owned());
992
993 write_gitconfig(&values, &dst).unwrap();
994
995 let content = fs::read_to_string(&dst).unwrap();
996 assert!(content.contains("name = New"), "name should be updated");
997 assert!(
998 content.contains("old_key = keep"),
999 "old_key should be preserved"
1000 );
1001 assert!(
1002 content.contains("email = new@example.com"),
1003 "email should be added"
1004 );
1005 }
1006
1007 #[test]
1009 fn hypr_vars_writer_replaces_and_preserves() {
1010 let dir = tempdir().unwrap();
1011 let dst = dir.path().join("hyprland.conf");
1012 fs::write(&dst, "$terminal = kitty\n$bar = waybar\n").unwrap();
1013
1014 let mut values = BTreeMap::new();
1015 values.insert("terminal".to_owned(), "alacritty".to_owned());
1016
1017 write_hypr_vars(&values, &dst).unwrap();
1018
1019 let content = fs::read_to_string(&dst).unwrap();
1020 assert!(
1021 content.contains("$terminal = alacritty"),
1022 "terminal replaced"
1023 );
1024 assert!(content.contains("$bar = waybar"), "bar preserved");
1025 assert!(!content.contains("kitty"), "old value gone");
1026 }
1027
1028 #[test]
1030 fn env_writer_output() {
1031 let dir = tempdir().unwrap();
1032 let dst = dir.path().join("env");
1033
1034 let mut values = BTreeMap::new();
1035 values.insert("FOO".to_owned(), "bar".to_owned());
1036 values.insert("EMPTY".to_owned(), String::new());
1037 values.insert("WITH_SPACE".to_owned(), "hello world".to_owned());
1038
1039 write_env(&values, &dst).unwrap();
1040
1041 let content = fs::read_to_string(&dst).unwrap();
1042 assert!(content.contains("export FOO=bar"), "FOO written");
1043 assert!(!content.contains("EMPTY"), "empty skipped");
1044 assert!(
1045 content.contains("export WITH_SPACE=\"hello world\""),
1046 "whitespace quoted"
1047 );
1048 }
1049
1050 #[test]
1052 fn generic_template_substitution() {
1053 let dir = tempdir().unwrap();
1054 let src = dir.path().join("template.txt");
1055 let dst = dir.path().join("output.txt");
1056 fs::write(&src, "Hello {{name}}! Unknown: {{missing}}").unwrap();
1057
1058 let mut values = BTreeMap::new();
1059 values.insert("name".to_owned(), "World".to_owned());
1060
1061 write_generic_template(&values, &src, &dst).unwrap();
1062
1063 let content = fs::read_to_string(&dst).unwrap();
1064 assert_eq!(content, "Hello World! Unknown: {{missing}}");
1065 }
1066
1067 #[test]
1069 fn yes_mode_with_defaults_succeeds() {
1070 let mut sections = BTreeMap::new();
1071 sections.insert(
1072 "env_section".to_owned(),
1073 make_section(
1074 vec![PromptField {
1075 default: Some(toml::Value::String("alice".to_owned())),
1076 ..make_field("USER", "User")
1077 }],
1078 "env",
1079 ),
1080 );
1081
1082 let opts = SetupOpts {
1083 sections: vec!["env_section".to_owned()],
1084 yes: true,
1085 prompt_sections: sections,
1086 };
1087
1088 let dir = tempdir().unwrap();
1089 let dst = dir.path().join("env_out");
1090 let mut dsts = BTreeMap::new();
1091 dsts.insert("env_section".to_owned(), dst.clone());
1092
1093 let mut p = YesPrompter;
1094 let git = FakeGitConfig(BTreeMap::new());
1095 let report = setup_with_destinations(&opts, &dsts, &mut p, &git).unwrap();
1096
1097 assert_eq!(report.sections_run, vec!["env_section"]);
1098 let content = fs::read_to_string(&dst).unwrap();
1099 assert!(
1100 content.contains("export USER=alice"),
1101 "USER written from default"
1102 );
1103 }
1104
1105 #[test]
1107 fn prompts_filter_runs_only_named_sections() {
1108 let mut sections = BTreeMap::new();
1109 sections.insert(
1110 "a".to_owned(),
1111 make_section(vec![make_field("x", "X")], "env"),
1112 );
1113 sections.insert(
1114 "b".to_owned(),
1115 make_section(vec![make_field("y", "Y")], "env"),
1116 );
1117 sections.insert(
1118 "c".to_owned(),
1119 make_section(vec![make_field("z", "Z")], "env"),
1120 );
1121
1122 let opts = SetupOpts {
1123 sections: vec!["a".to_owned(), "b".to_owned()],
1124 yes: false,
1125 prompt_sections: sections,
1126 };
1127
1128 let mut p = ScriptedPrompter::new(&["val_a", "val_b"], &[]);
1129 let git = FakeGitConfig(BTreeMap::new());
1130 let report = setup(&opts, &mut p, &git).unwrap();
1131
1132 assert_eq!(report.sections_run, vec!["a", "b"]);
1133 assert!(
1134 !report.sections_run.contains(&"c".to_owned()),
1135 "c should not run"
1136 );
1137 }
1138
1139 #[test]
1141 fn unknown_section_errors() {
1142 let mut sections = BTreeMap::new();
1143 sections.insert(
1144 "a".to_owned(),
1145 make_section(vec![make_field("x", "X")], "env"),
1146 );
1147
1148 let opts = SetupOpts {
1149 sections: vec!["unknown".to_owned()],
1150 yes: false,
1151 prompt_sections: sections,
1152 };
1153
1154 let mut p = ScriptedPrompter::new(&[], &[]);
1155 let git = FakeGitConfig(BTreeMap::new());
1156 let err = setup(&opts, &mut p, &git).unwrap_err();
1157
1158 assert!(
1159 matches!(err, SetupError::UnknownPromptSection(ref s) if s == "unknown"),
1160 "expected UnknownPromptSection(unknown), got {err:?}"
1161 );
1162 }
1163
1164 #[test]
1166 fn default_from_read_var() {
1167 let dir = tempdir().unwrap();
1168 let dst = dir.path().join("hyprland.conf");
1169 fs::write(&dst, "$terminal = wezterm\n").unwrap();
1170
1171 let field = PromptField {
1172 default_from: Some("read_var:terminal".to_owned()),
1173 ..make_field("terminal", "Terminal")
1174 };
1175
1176 let git = FakeGitConfig(BTreeMap::new());
1177 let default = resolve_default(&field, &BTreeMap::new(), Some(&dst), &git);
1178 assert_eq!(default, Some("wezterm".to_owned()));
1179 }
1180
1181 #[test]
1183 fn scripted_prompter_env_writer() {
1184 let mut sections = BTreeMap::new();
1185 sections.insert(
1186 "env_sec".to_owned(),
1187 make_section(
1188 vec![make_field("FOO", "Foo"), make_field("BAR", "Bar")],
1189 "env",
1190 ),
1191 );
1192
1193 let opts = SetupOpts {
1194 sections: vec!["env_sec".to_owned()],
1195 yes: false,
1196 prompt_sections: sections,
1197 };
1198
1199 let dir = tempdir().unwrap();
1200 let dst = dir.path().join("vars.env");
1201 let mut dsts = BTreeMap::new();
1202 dsts.insert("env_sec".to_owned(), dst.clone());
1203
1204 let mut p = ScriptedPrompter::new(&["hello", "world"], &[]);
1205 let git = FakeGitConfig(BTreeMap::new());
1206 setup_with_destinations(&opts, &dsts, &mut p, &git).unwrap();
1207
1208 let content = fs::read_to_string(&dst).unwrap();
1209 assert!(content.contains("export FOO=hello"));
1210 assert!(content.contains("export BAR=world"));
1211 }
1212
1213 #[test]
1215 fn generic_template_via_setup() {
1216 let dir = tempdir().unwrap();
1217 let src = dir.path().join("template.conf");
1218 let dst = dir.path().join("output.conf");
1219 fs::write(&src, "name = {{name}}\nemail = {{email}}\n").unwrap();
1220
1221 let mut sections = BTreeMap::new();
1222 sections.insert(
1223 "tmpl".to_owned(),
1224 make_section(
1225 vec![make_field("name", "Name"), make_field("email", "Email")],
1226 "generic_template",
1227 ),
1228 );
1229
1230 let opts = SetupOpts {
1231 sections: vec!["tmpl".to_owned()],
1232 yes: false,
1233 prompt_sections: sections,
1234 };
1235
1236 let mut dsts = BTreeMap::new();
1237 dsts.insert("tmpl".to_owned(), dst.clone());
1238 let mut srcs = BTreeMap::new();
1239 srcs.insert("tmpl".to_owned(), src.clone());
1240
1241 let mut p = ScriptedPrompter::new(&["Alice", "alice@example.com"], &[]);
1242 let git = FakeGitConfig(BTreeMap::new());
1243 setup_with_destinations_and_srcs(&opts, &dsts, &srcs, &mut p, &git).unwrap();
1244
1245 let content = fs::read_to_string(&dst).unwrap();
1246 assert!(content.contains("name = Alice"));
1247 assert!(content.contains("email = alice@example.com"));
1248 }
1249}