Skip to main content

nex_pkg/
config.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{bail, Context, Result};
4use serde::{Deserialize, Serialize};
5
6use crate::discover::{self, Platform};
7
8pub const CONFIG_FILE: &str = "config.pkl";
9pub const CONFIG_TOML_COMPAT_FILE: &str = "config.toml";
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12enum LocalConfigFormat {
13    Pkl,
14    TomlCompat,
15}
16
17struct LocalConfigSource {
18    path: PathBuf,
19    format: LocalConfigFormat,
20}
21
22/// Resolved configuration for a nex session.
23pub struct Config {
24    /// Path to the nix config repo root (nix-darwin or NixOS).
25    pub repo: PathBuf,
26    /// Hostname for system rebuild.
27    pub hostname: String,
28    /// Path to the primary nix packages file (relative to repo).
29    pub nix_packages_file: PathBuf,
30    /// Path to the homebrew nix file (relative to repo). None on Linux.
31    pub homebrew_file: PathBuf,
32    /// Additional nix module files with home.packages lists.
33    pub module_files: Vec<(String, PathBuf)>,
34    /// When true, auto-pick nix for equal-version conflicts without prompting.
35    pub prefer_nix_on_equal: bool,
36    /// The detected platform (Darwin or Linux).
37    pub platform: Platform,
38}
39
40/// Optional config file at ~/.config/nex/config.pkl; config.toml is compatibility.
41#[derive(Deserialize, Serialize, Default)]
42struct FileConfig {
43    repo_path: Option<String>,
44    hostname: Option<String>,
45    prefer_nix_on_equal: Option<bool>,
46    identity: Option<IdentityConfig>,
47}
48
49/// Identity-related configuration.
50#[derive(Deserialize, Serialize, Default, Clone)]
51pub struct IdentityConfig {
52    /// Git commit signing settings.
53    pub git: Option<IdentityGitConfig>,
54    /// SSH key label registry.
55    pub ssh: Option<IdentitySshConfig>,
56}
57
58/// Git signing config stored in nex config.
59#[derive(Deserialize, Serialize, Default, Clone)]
60pub struct IdentityGitConfig {
61    pub name: Option<String>,
62    pub email: Option<String>,
63}
64
65/// SSH key labels registered for this identity.
66#[derive(Deserialize, Serialize, Default, Clone)]
67pub struct IdentitySshConfig {
68    pub labels: Option<Vec<String>>,
69}
70
71impl Config {
72    /// Resolve configuration from CLI args, env vars, config file, and auto-discovery.
73    pub fn resolve(cli_repo: Option<PathBuf>, cli_hostname: Option<String>) -> Result<Self> {
74        tracing::debug!(cli_repo = ?cli_repo, cli_hostname = ?cli_hostname, "resolving config");
75        let file_config = load_file_config().unwrap_or_default();
76        let platform = discover::detect_platform();
77
78        let not_found_msg = match platform {
79            Platform::Darwin => {
80                "Could not find nix-darwin repo. Run `nex init`, set NEX_REPO, \
81                 or create ~/.config/nex/config.pkl with repo_path."
82            }
83            Platform::Linux => {
84                "Could not find NixOS config repo. Run `nex init`, set NEX_REPO, \
85                 or create ~/.config/nex/config.pkl with repo_path."
86            }
87        };
88
89        let repo = cli_repo
90            .or_else(|| file_config.repo_path.map(PathBuf::from))
91            .or_else(|| discover::find_repo().ok())
92            .context(not_found_msg)?;
93
94        tracing::debug!(repo = %repo.display(), "config repo resolved");
95
96        let hostname = cli_hostname
97            .or(file_config.hostname)
98            .or_else(|| discover::hostname().ok())
99            .context("Could not detect hostname. Set NEX_HOSTNAME.")?;
100
101        tracing::debug!(%hostname, "hostname resolved");
102
103        // Validate hostname: alphanumeric and hyphens only, no leading/trailing hyphen
104        if hostname.is_empty()
105            || hostname.starts_with('-')
106            || hostname.ends_with('-')
107            || !hostname
108                .chars()
109                .all(|c| c.is_ascii_alphanumeric() || c == '-')
110        {
111            anyhow::bail!(
112                "invalid hostname \"{hostname}\": must be alphanumeric with hyphens, \
113                 no leading/trailing hyphen"
114            );
115        }
116
117        // Standard file locations — detect scaffolded vs flat layout
118        let scaffolded = repo.join("nix/modules/home").exists();
119
120        let nix_packages_file = if scaffolded {
121            repo.join("nix/modules/home/base.nix")
122        } else if repo.join("home.nix").exists() {
123            repo.join("home.nix")
124        } else {
125            repo.join("nix/modules/home/base.nix") // fallback
126        };
127
128        let homebrew_file = match platform {
129            Platform::Darwin => repo.join("nix/modules/darwin/homebrew.nix"),
130            Platform::Linux => {
131                if scaffolded {
132                    repo.join("nix/modules/nixos/packages.nix")
133                } else {
134                    repo.join("configuration.nix")
135                }
136            }
137        };
138
139        // Discover additional module files with home.packages
140        let mut module_files = Vec::new();
141        let home_modules_dir = repo.join("nix/modules/home");
142        if home_modules_dir.is_dir() {
143            if let Ok(entries) = std::fs::read_dir(&home_modules_dir) {
144                for entry in entries.flatten() {
145                    let path = entry.path();
146                    if path.extension().and_then(|e| e.to_str()) != Some("nix") {
147                        continue;
148                    }
149                    let stem = path
150                        .file_stem()
151                        .and_then(|s| s.to_str())
152                        .unwrap_or("")
153                        .to_string();
154                    // Skip the primary packages file and module entry points
155                    if stem == "base" || stem == "default" {
156                        continue;
157                    }
158                    tracing::debug!(module = %stem, path = %path.display(), "discovered module");
159                    module_files.push((stem, path));
160                }
161            }
162            module_files.sort_by(|a, b| a.0.cmp(&b.0));
163        }
164
165        let prefer_nix_on_equal = file_config.prefer_nix_on_equal.unwrap_or(false);
166
167        Ok(Config {
168            repo,
169            hostname,
170            nix_packages_file,
171            homebrew_file,
172            module_files,
173            prefer_nix_on_equal,
174            platform,
175        })
176    }
177
178    /// All nix files that contain home.packages lists (for duplicate checking).
179    pub fn all_nix_package_files(&self) -> Vec<&PathBuf> {
180        let mut files = vec![&self.nix_packages_file];
181        for (_, path) in &self.module_files {
182            files.push(path);
183        }
184        files
185    }
186}
187
188/// Persist a key=value into the config file, preserving existing content.
189pub fn set_preference(key: &str, value: &str) -> Result<()> {
190    tracing::debug!(%key, %value, "setting preference");
191    let path = writable_config_path()?;
192    let content = read_config_value_for_write(&path)?;
193
194    let mut table = match content {
195        toml::Value::Table(table) => table,
196        _ => bail!("local Nex config root must be a table"),
197    };
198
199    let parsed_value: toml::Value =
200        toml::from_str::<toml::map::Map<String, toml::Value>>(&format!("v = {value}"))
201            .with_context(|| format!("invalid TOML value: {value}"))
202            .and_then(|t| {
203                t.into_iter()
204                    .next()
205                    .map(|(_, v)| v)
206                    .context("empty TOML parse result")
207            })?;
208
209    table.insert(key.to_string(), parsed_value);
210    write_config_value(&path, &toml::Value::Table(table))
211}
212
213/// Canonical config directory: ~/.config/nex/
214pub fn config_dir() -> Result<PathBuf> {
215    let home = dirs::home_dir().context("no home directory")?;
216    Ok(home.join(".config/nex"))
217}
218
219pub fn canonical_config_path() -> Result<PathBuf> {
220    Ok(config_dir()?.join(CONFIG_FILE))
221}
222
223pub fn toml_compat_config_path() -> Result<PathBuf> {
224    Ok(config_dir()?.join(CONFIG_TOML_COMPAT_FILE))
225}
226
227/// Load just the identity portion of the config (does not require a nix repo).
228pub fn load_identity_config() -> Result<IdentityConfig> {
229    let fc = load_file_config().unwrap_or_default();
230    Ok(fc.identity.unwrap_or_default())
231}
232
233/// Set a nested config key using dotted notation (e.g. "identity.git.name").
234/// Creates intermediate tables as needed.
235pub fn set_nested_preference(dotted_key: &str, value: toml::Value) -> Result<()> {
236    let path = writable_config_path()?;
237    let mut root = read_config_value_for_write(&path)?;
238
239    let parts: Vec<&str> = dotted_key.split('.').collect();
240    let mut current = &mut root;
241    for part in &parts[..parts.len() - 1] {
242        if !current.is_table() {
243            bail!("config key '{part}' is not a table");
244        }
245        current = current
246            .as_table_mut()
247            .expect("checked above")
248            .entry(part.to_string())
249            .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
250    }
251
252    let leaf = parts.last().context("empty key")?;
253    current
254        .as_table_mut()
255        .context("leaf parent is not a table")?
256        .insert(leaf.to_string(), value);
257
258    write_config_value(&path, &root)
259}
260
261/// Append a string to an array config value, creating it if it doesn't exist.
262/// Skips duplicates.
263pub fn append_to_list(dotted_key: &str, item: &str) -> Result<()> {
264    let path = writable_config_path()?;
265    let mut root = read_config_value_for_write(&path)?;
266
267    let parts: Vec<&str> = dotted_key.split('.').collect();
268    let mut current = &mut root;
269    for part in &parts[..parts.len() - 1] {
270        current = current
271            .as_table_mut()
272            .context("not a table")?
273            .entry(part.to_string())
274            .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
275    }
276
277    let leaf = parts.last().context("empty key")?;
278    let table = current
279        .as_table_mut()
280        .context("leaf parent is not a table")?;
281    let arr = table
282        .entry(leaf.to_string())
283        .or_insert_with(|| toml::Value::Array(Vec::new()));
284
285    let arr = arr.as_array_mut().context("config key is not an array")?;
286    let item_val = toml::Value::String(item.to_string());
287    if !arr.contains(&item_val) {
288        arr.push(item_val);
289    }
290
291    write_config_value(&path, &root)
292}
293
294pub fn write_initial_config(repo_path: &Path, hostname: &str) -> Result<PathBuf> {
295    let path = canonical_config_path()?;
296    let mut table = toml::map::Map::new();
297    table.insert(
298        "repo_path".to_string(),
299        toml::Value::String(repo_path.display().to_string()),
300    );
301    table.insert(
302        "hostname".to_string(),
303        toml::Value::String(hostname.to_string()),
304    );
305    write_config_value(&path, &toml::Value::Table(table))?;
306    Ok(path)
307}
308
309pub fn migrate_to_pkl(keep_toml: bool) -> Result<PathBuf> {
310    let source = resolve_config_source()?;
311    let value = match source {
312        Some(LocalConfigSource {
313            path,
314            format: LocalConfigFormat::Pkl,
315        }) => read_config_value_for_write(&path)?,
316        Some(LocalConfigSource {
317            path,
318            format: LocalConfigFormat::TomlCompat,
319        }) => read_config_value_for_write(&path)?,
320        None => toml::Value::Table(toml::map::Map::new()),
321    };
322
323    let canonical = canonical_config_path()?;
324    write_config_value(&canonical, &value)?;
325
326    if keep_toml {
327        let compat = toml_compat_config_path()?;
328        let rendered = toml::to_string_pretty(&value).context("serializing compatibility TOML")?;
329        crate::edit::atomic_write_bytes(&compat, rendered.as_bytes())
330            .with_context(|| format!("writing {}", compat.display()))?;
331    }
332
333    Ok(canonical)
334}
335
336pub fn export_config_toml() -> Result<String> {
337    let config = load_file_config()?;
338    let value = toml::Value::try_from(config).context("converting config to TOML value")?;
339    toml::to_string_pretty(&value).context("serializing config TOML")
340}
341
342fn load_file_config() -> Result<FileConfig> {
343    match resolve_config_source()? {
344        Some(source) => match source.format {
345            LocalConfigFormat::Pkl => {
346                tracing::debug!(path = %source.path.display(), "loaded canonical Pkl config file");
347                crate::document::load_document::<FileConfig>(&source.path, "local Nex config")
348                    .map(|loaded| loaded.value)
349            }
350            LocalConfigFormat::TomlCompat => {
351                tracing::debug!(path = %source.path.display(), "loaded compatibility TOML config file");
352                let content = std::fs::read_to_string(&source.path)
353                    .with_context(|| format!("reading {}", source.path.display()))?;
354                toml::from_str(&content)
355                    .with_context(|| format!("invalid config in {}", source.path.display()))
356            }
357        },
358        None => Ok(FileConfig::default()),
359    }
360}
361
362fn resolve_config_source() -> Result<Option<LocalConfigSource>> {
363    let canonical = canonical_config_path()?;
364    if canonical.exists() {
365        return Ok(Some(LocalConfigSource {
366            path: canonical,
367            format: LocalConfigFormat::Pkl,
368        }));
369    }
370
371    let compat = toml_compat_config_path()?;
372    if compat.exists() {
373        return Ok(Some(LocalConfigSource {
374            path: compat,
375            format: LocalConfigFormat::TomlCompat,
376        }));
377    }
378
379    if let Some(platform_dir) = dirs::config_dir() {
380        let legacy = platform_dir.join(format!("nex/{CONFIG_TOML_COMPAT_FILE}"));
381        if legacy.exists() {
382            return Ok(Some(LocalConfigSource {
383                path: legacy,
384                format: LocalConfigFormat::TomlCompat,
385            }));
386        }
387    }
388
389    Ok(None)
390}
391
392fn writable_config_path() -> Result<PathBuf> {
393    let canonical = canonical_config_path()?;
394    if canonical.exists() {
395        return Ok(canonical);
396    }
397    let compat = toml_compat_config_path()?;
398    if compat.exists() {
399        return Ok(compat);
400    }
401    Ok(canonical)
402}
403
404fn read_config_value_for_write(path: &Path) -> Result<toml::Value> {
405    if !path.exists() {
406        return Ok(toml::Value::Table(toml::map::Map::new()));
407    }
408    match config_format_for_path(path)? {
409        LocalConfigFormat::Pkl => {
410            let config =
411                crate::document::load_document::<FileConfig>(path, "local Nex config")?.value;
412            toml::Value::try_from(config).context("converting config to mutable value")
413        }
414        LocalConfigFormat::TomlCompat => {
415            let content = std::fs::read_to_string(path)
416                .with_context(|| format!("reading {}", path.display()))?;
417            if content.trim().is_empty() {
418                Ok(toml::Value::Table(toml::map::Map::new()))
419            } else {
420                toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))
421            }
422        }
423    }
424}
425
426fn write_config_value(path: &Path, value: &toml::Value) -> Result<()> {
427    let serialized = serialize_config_value(value, config_format_for_path(path)?)?;
428    std::fs::create_dir_all(config_dir()?)?;
429    crate::edit::atomic_write_bytes(path, serialized.as_bytes())
430        .with_context(|| format!("writing {}", path.display()))
431}
432
433fn config_format_for_path(path: &Path) -> Result<LocalConfigFormat> {
434    match path.extension().and_then(|ext| ext.to_str()) {
435        Some("pkl") => Ok(LocalConfigFormat::Pkl),
436        Some("toml") => Ok(LocalConfigFormat::TomlCompat),
437        Some(ext) => bail!("unsupported config extension .{ext}; canonical Nex config uses .pkl"),
438        None => bail!("config path must have an extension"),
439    }
440}
441
442fn serialize_config_value(value: &toml::Value, format: LocalConfigFormat) -> Result<String> {
443    match format {
444        LocalConfigFormat::Pkl => serialize_pkl_document(value),
445        LocalConfigFormat::TomlCompat => {
446            toml::to_string_pretty(value).context("serializing config TOML")
447        }
448    }
449}
450
451fn serialize_pkl_document(value: &toml::Value) -> Result<String> {
452    let mut out =
453        String::from("// Generated by nex. Edit config.pkl as the canonical local config.\n");
454    let table = value.as_table().context("config root must be a table")?;
455    for (key, value) in table {
456        write_pkl_entry(&mut out, key, value, 0)?;
457    }
458    Ok(out)
459}
460
461fn write_pkl_entry(out: &mut String, key: &str, value: &toml::Value, indent: usize) -> Result<()> {
462    let padding = "  ".repeat(indent);
463    match value {
464        toml::Value::Table(table) => {
465            out.push_str(&format!("{padding}{key} {{\n"));
466            for (child_key, child_value) in table {
467                write_pkl_entry(out, child_key, child_value, indent + 1)?;
468            }
469            out.push_str(&format!("{padding}}}\n"));
470        }
471        _ => {
472            out.push_str(&format!(
473                "{padding}{key} = {}\n",
474                pkl_literal(value).with_context(|| format!("serializing config key {key}"))?
475            ));
476        }
477    }
478    Ok(())
479}
480
481fn pkl_literal(value: &toml::Value) -> Result<String> {
482    match value {
483        toml::Value::String(value) => Ok(format!("{value:?}")),
484        toml::Value::Boolean(value) => Ok(value.to_string()),
485        toml::Value::Integer(value) => Ok(value.to_string()),
486        toml::Value::Float(value) => Ok(value.to_string()),
487        toml::Value::Array(values) => {
488            let rendered = values
489                .iter()
490                .map(pkl_literal)
491                .collect::<Result<Vec<_>>>()?
492                .join(", ");
493            Ok(format!("List({rendered})"))
494        }
495        toml::Value::Datetime(value) => Ok(format!("{:?}", value.to_string())),
496        toml::Value::Table(_) => bail!("nested table should be serialized as a Pkl block"),
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use std::fs;
504
505    #[test]
506    fn serializes_generated_pkl_config() {
507        let mut table = toml::map::Map::new();
508        table.insert(
509            "repo_path".to_string(),
510            toml::Value::String("/tmp/repo".to_string()),
511        );
512        table.insert(
513            "hostname".to_string(),
514            toml::Value::String("test-host".to_string()),
515        );
516        table.insert(
517            "prefer_nix_on_equal".to_string(),
518            toml::Value::Boolean(true),
519        );
520
521        let rendered = serialize_pkl_document(&toml::Value::Table(table)).unwrap();
522        assert!(rendered.contains("repo_path = \"/tmp/repo\""));
523        assert!(rendered.contains("hostname = \"test-host\""));
524        assert!(rendered.contains("prefer_nix_on_equal = true"));
525    }
526
527    #[test]
528    fn resolves_pkl_before_toml() {
529        let dir = tempfile::tempdir().unwrap();
530        fs::write(dir.path().join(CONFIG_FILE), "repo_path = \"/pkl\"\n").unwrap();
531        fs::write(
532            dir.path().join(CONFIG_TOML_COMPAT_FILE),
533            "repo_path = \"/toml\"\n",
534        )
535        .unwrap();
536
537        let pkl = dir.path().join(CONFIG_FILE);
538        let toml = dir.path().join(CONFIG_TOML_COMPAT_FILE);
539        assert!(pkl.exists());
540        assert!(toml.exists());
541        assert_eq!(
542            config_format_for_path(&pkl).unwrap(),
543            LocalConfigFormat::Pkl
544        );
545        assert_eq!(
546            config_format_for_path(&toml).unwrap(),
547            LocalConfigFormat::TomlCompat
548        );
549    }
550    #[test]
551    fn migrate_preserves_toml_export_shape() {
552        let mut table = toml::map::Map::new();
553        table.insert(
554            "repo_path".to_string(),
555            toml::Value::String("/tmp/repo".to_string()),
556        );
557        table.insert(
558            "hostname".to_string(),
559            toml::Value::String("test-host".to_string()),
560        );
561        let value = toml::Value::Table(table);
562        let pkl = serialize_config_value(&value, LocalConfigFormat::Pkl).unwrap();
563        let toml = serialize_config_value(&value, LocalConfigFormat::TomlCompat).unwrap();
564
565        assert!(pkl.contains("repo_path = \"/tmp/repo\""));
566        assert!(toml.contains("repo_path = \"/tmp/repo\""));
567        assert!(toml::from_str::<toml::Value>(&toml).is_ok());
568    }
569}