Skip to main content

krypt_core/
setup.rs

1//! Interactive setup wizard for `krypt setup`.
2//!
3//! Reads `[prompts.<name>]` sections from `.krypt.toml`, asks the user
4//! questions via [`Prompter`], and writes the collected values to destination
5//! files using one of four built-in writers:
6//!
7//! - **`gitconfig`** — merges key=value pairs into a git-style ini file.
8//! - **`hypr_vars`** — patches `$VAR = value` lines in a Hyprland config.
9//! - **`env`** — writes `export K=V` lines to a shell env file.
10//! - **`generic_template`** — substitutes `{{key}}` placeholders in a template.
11//!
12//! # Default resolution order (first hit wins)
13//!
14//! 1. `read_var = "X"` — read `$X = <value>` from the destination file.
15//! 2. `default_from` prefix:
16//!    - `git:<key>` — shell out to `git config --get <key>`.  We deliberately
17//!      keep this one shell-out because git-level user config (name, email,
18//!      signing key) lives inside git's own config system and cannot be read any
19//!      other way without reimplementing the full git config search order.  gix
20//!      is only used for repo operations in `update`; it is not used here.
21//!    - `env:<VAR>` — read env var.
22//!    - `field:<key>` — value of an earlier field in this same run.
23//!    - `read_var:<X>` — same as `read_var` shorthand.
24//! 3. `default = <toml value>`.
25//! 4. No default.
26
27#![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// ─── Errors ──────────────────────────────────────────────────────────────────
40
41/// Errors from [`setup`].
42#[derive(Debug, Error)]
43pub enum SetupError {
44    /// A section name passed via `--prompts` is not in the config.
45    #[error("unknown prompt section: {0:?}")]
46    UnknownPromptSection(String),
47
48    /// The `writer` field names an unsupported writer.
49    #[error("unknown writer {0:?} — must be one of: gitconfig, hypr_vars, env, generic_template")]
50    UnknownWriter(String),
51
52    /// A required field has no default and `--yes` was requested.
53    #[error("required field {key:?} has no default; cannot run unattended")]
54    RequiredFieldHasNoDefault {
55        /// The field key.
56        key: String,
57    },
58
59    /// I/O failure.
60    #[error("io: {0}")]
61    Io(#[from] io::Error),
62
63    /// Default resolver failure.
64    #[error("resolve: {0}")]
65    Resolve(#[from] ResolveError),
66}
67
68/// Errors from the default resolver.
69#[derive(Debug, Error)]
70pub enum ResolveError {
71    /// `git config --get` was called but the binary was not found.
72    #[error("git binary not found when resolving git: default")]
73    GitBinaryNotFound,
74
75    /// `git config --get` exited non-zero or produced no output.
76    #[error("git config key {0:?} not set")]
77    GitKeyNotSet(String),
78}
79
80// ─── Options & report ────────────────────────────────────────────────────────
81
82/// Inputs to [`setup`].
83pub struct SetupOpts {
84    /// Ordered prompt sections to run. If empty, all sections are run in
85    /// BTreeMap key order.
86    pub sections: Vec<String>,
87
88    /// When true, use defaults for every field without prompting. Error if a
89    /// required field has no default.
90    pub yes: bool,
91
92    /// The parsed prompt sections from the config (clone out of `Config`).
93    pub prompt_sections: BTreeMap<String, PromptSection>,
94}
95
96/// Summary of a [`setup`] run.
97#[derive(Debug, Default)]
98pub struct SetupReport {
99    /// Names of prompt sections that were processed.
100    pub sections_run: Vec<String>,
101
102    /// Total number of fields that were prompted (or auto-filled in `--yes`).
103    pub fields_collected: usize,
104
105    /// Destination file paths that each writer touched.
106    pub files_written: Vec<PathBuf>,
107
108    /// Field keys that were silently skipped due to a `requires` gate.
109    pub skipped_by_requires: Vec<String>,
110}
111
112// ─── Prompter trait ──────────────────────────────────────────────────────────
113
114/// Abstraction over interactive prompts so tests can inject scripted answers.
115pub trait Prompter {
116    /// Ask for a free-form string.
117    fn ask_string(
118        &mut self,
119        prompt: &str,
120        default: Option<&str>,
121        optional: bool,
122    ) -> io::Result<String>;
123
124    /// Ask for a boolean (y/n).
125    fn ask_bool(&mut self, prompt: &str, default: bool) -> io::Result<bool>;
126
127    /// Ask for an integer.
128    fn ask_int(&mut self, prompt: &str, default: Option<i64>) -> io::Result<i64>;
129}
130
131// ─── RealPrompter (dialoguer) ────────────────────────────────────────────────
132
133/// Dialoguer-backed prompter used in production.
134pub 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
173// ─── YesPrompter (--yes / unattended) ────────────────────────────────────────
174
175/// Returns each field's computed default without prompting. Errors if a
176/// required field has no default.
177pub 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
207// ─── ScriptedPrompter (tests) ─────────────────────────────────────────────────
208
209/// Pre-canned answers for unit tests. Pops answers in FIFO order.
210pub struct ScriptedPrompter {
211    /// String answers (used for string + int fields).
212    pub answers: std::collections::VecDeque<String>,
213    /// Bool answers (used for bool fields).
214    pub bool_answers: std::collections::VecDeque<bool>,
215}
216
217impl ScriptedPrompter {
218    /// Build from plain slices.
219    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
255// ─── GitConfig trait (injectable for tests) ──────────────────────────────────
256
257/// Abstraction over `git config --get <key>` so tests can avoid a real `git`
258/// binary.
259pub trait GitConfig {
260    /// Return the value of `git config --get <key>`, or `None` if unset /
261    /// unavailable.
262    fn get(&self, key: &str) -> Option<String>;
263}
264
265/// Shells out to the system `git` binary.
266///
267/// We deliberately keep this one shell-out because resolving user-level git
268/// config (name, email, signing key) requires honouring git's own config
269/// search order (system → global → local). Reimplementing that search is
270/// fragile; shelling out to `git config --get` is the safe, forward-compatible
271/// choice.  Failure (binary not found, key unset) is silently treated as "no
272/// default" — it never hard-errors the wizard.
273pub 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
290/// Fake git config for tests.
291pub 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
299// ─── Default resolver ─────────────────────────────────────────────────────────
300
301/// Read `$<var_name> = <value>` from `dst`. Returns `None` if not found.
302fn 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
313/// Resolve the default value for a single field.
314///
315/// `collected` holds values gathered earlier in the same section run.
316fn resolve_default(
317    field: &PromptField,
318    collected: &BTreeMap<String, String>,
319    dst: Option<&Path>,
320    git: &dyn GitConfig,
321) -> Option<String> {
322    // 1. read_var shorthand
323    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    // 2. default_from
331    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    // 3. literal default
357    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
371// ─── Writers ──────────────────────────────────────────────────────────────────
372
373/// Write a value atomically: write to `<dst>.krypt-tmp-<pid>`, then rename.
374fn 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
387// ── gitconfig writer ──────────────────────────────────────────────────────────
388
389/// Merge `values` into a git-style ini file at `dst`.
390///
391/// Keys are dot-separated: `user.name` → `[user]` section, key `name`.
392/// Existing sections/keys not in `values` are preserved. Keys with empty
393/// string values are skipped entirely.
394fn write_gitconfig(values: &BTreeMap<String, String>, dst: &Path) -> io::Result<()> {
395    // Parse existing file.
396    let existing = if dst.exists() {
397        fs::read_to_string(dst)?
398    } else {
399        String::new()
400    };
401
402    // Build a mutable representation: Vec<(section, Vec<(key, value)>)>
403    // preserving order from the file, then we'll overwrite / append.
404    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    // Apply our values: group by section prefix.
425    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    // Overwrite existing keys and add new ones within existing sections.
439    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            // New section.
450            sections.push((
451                sec.clone(),
452                kv_map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
453            ));
454        }
455    }
456
457    // Render.
458    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
472// ── hypr_vars writer ──────────────────────────────────────────────────────────
473
474/// Patch `$key = value` lines in a Hyprland config at `dst`.
475///
476/// If a `$key = ...` line is not found, append it to the end. All other lines
477/// are preserved verbatim.
478fn 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
513// ── env writer ────────────────────────────────────────────────────────────────
514
515/// Write `export KEY=value` lines to `dst`. Skips empty values. Quotes values
516/// with embedded whitespace.
517fn 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
533// ── generic_template writer ───────────────────────────────────────────────────
534
535/// Substitute `{{key}}` placeholders in a template file and write to `dst`.
536/// Missing keys leave the placeholder intact.
537pub 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
550// ─── Section orchestration ────────────────────────────────────────────────────
551
552/// Run a single prompt section. Returns the collected `(key, value)` map and
553/// the list of skipped field keys.
554fn 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 &section.fields {
565        // requires gate
566        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                // string (default)
608                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
630// ─── Entry point ──────────────────────────────────────────────────────────────
631
632/// Run the interactive setup wizard.
633///
634/// `git` is injected so tests can avoid shelling out to a real `git` binary.
635/// In production, pass [`RealGitConfig`].
636pub 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    // Determine run order.
644    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    // Validate all names upfront so we don't write anything on error.
651    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        // For writers that read existing dest file (hypr_vars, read_var), we
665        // need a dst path.  The section schema has `dst` on the writer side, so
666        // we pass None here and let writers handle the file themselves.
667        // read_var needs dst too — but dst is on the writer, not the section.
668        // We pass None; read_var without a known dst simply produces no default.
669        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        // Dispatch writer.
675        match section.writer.as_str() {
676            "gitconfig" | "hypr_vars" | "env" | "generic_template" => {
677                // Writers that need a dst path cannot operate here without a
678                // dst configured.  In practice callers supply dst via a
679                // SetupOpts extension or the CLI resolves it.  For now we
680                // surface a noop so the API stays stable while we complete the
681                // integration.  Tests use the writer functions directly.
682            }
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
694/// Run setup with per-section destination paths and optional template sources.
695///
696/// This is the full-featured entry point used by the CLI. Each section name
697/// maps to an optional destination path; the writer uses it to read existing
698/// content and write the output. For `generic_template` sections, `srcs`
699/// provides the template source path.
700pub 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
709/// Full entry point with both destination and source paths.
710///
711/// `srcs` maps section name → template source path for `generic_template`
712/// sections. Other writers ignore `srcs`.
713pub 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                // No src → no-op; dst still counts as "touched" for section tracking.
775            }
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// ─── Tests ────────────────────────────────────────────────────────────────────
788
789#[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    // 1. Fields run in declared order and values are collected.
817    #[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    // 2. `requires` skips a field when gating field is empty.
843    #[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        // key = "" (empty optional), sign should be skipped.
871        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    // 3. `default_from = "env:..."` picks up the env value.
882    //
883    // We can't mutate env vars without `unsafe` (forbidden in this crate), so
884    // we pick a var that is always set in CI and on developer machines.
885    #[test]
886    fn default_from_env() {
887        // HOME is always set; PATH is always set. Either works.
888        // We skip the test gracefully if neither is set (extremely unlikely).
889        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; // nothing to test; skip silently
895        };
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    // 4. `default_from = "field:..."` picks up a prior field's value.
909    #[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    // 5. `default_from = "git:..."` uses injected FakeGitConfig.
925    #[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    // 6. `read_var` reads from an existing hypr config file.
941    #[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    // 7. `--yes` with no default + required field → errors before writing.
958    #[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    // 8. gitconfig writer merges: old keys preserved, new keys added.
983    #[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    // 9. hypr_vars writer: existing `$terminal = kitty` replaced, other lines kept.
1008    #[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    // 10. env writer: produces `export K=V`, skips empty, quotes whitespace.
1029    #[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    // 11. generic_template: `{{k}}` substituted, missing `{{x}}` left intact.
1051    #[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    // 12. `--yes` with defaults present → succeeds without prompting.
1068    #[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    // 13. `--prompts a,b` — only those sections run.
1106    #[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    // 14. Unknown section in --prompts filter → error.
1140    #[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    // 15. `default_from = "read_var:terminal"` reads from existing hypr file via default_from.
1165    #[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    // 16. ScriptedPrompter answers two env fields; verify file contents.
1182    #[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    // 17. generic_template writer end-to-end via setup_with_destinations_and_srcs.
1214    #[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}