use std::collections::HashSet;
use std::process::Command;
use anyhow::{bail, Context, Result};
use console::style;
use crate::config::Config;
use crate::discover::Platform;
use crate::edit::{self, EditSession};
use crate::nixfile;
use crate::output;
#[derive(serde::Deserialize)]
struct Profile {
meta: Option<ProfileMeta>,
packages: Option<ProfilePackages>,
shell: Option<ProfileShell>,
git: Option<ProfileGit>,
kitty: Option<ProfileKitty>,
macos: Option<ProfileMacos>,
linux: Option<ProfileLinux>,
security: Option<ProfileSecurity>,
}
#[derive(serde::Deserialize)]
struct ProfileMeta {
name: Option<String>,
description: Option<String>,
extends: Option<String>,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileKitty {
font: Option<String>,
font_size: Option<f64>,
theme: Option<String>,
window_padding: Option<u32>,
scrollback_lines: Option<u32>,
macos_option_as_alt: Option<bool>,
macos_quit_when_last_window_closed: Option<bool>,
}
#[derive(serde::Deserialize)]
struct ProfilePackages {
nix: Option<Vec<String>>,
brews: Option<Vec<String>>,
casks: Option<Vec<String>>,
taps: Option<Vec<String>>,
}
#[derive(serde::Deserialize)]
struct ProfileShell {
default: Option<String>,
aliases: Option<std::collections::HashMap<String, String>>,
env: Option<std::collections::HashMap<String, String>>,
#[serde(rename = "profileExtra")]
profile_extra: Option<String>,
#[serde(rename = "initExtra")]
init_extra: Option<String>,
}
#[derive(serde::Deserialize)]
struct ProfileGit {
name: Option<String>,
email: Option<String>,
default_branch: Option<String>,
pull_rebase: Option<bool>,
push_auto_setup_remote: Option<bool>,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileMacos {
show_all_extensions: Option<bool>,
show_hidden_files: Option<bool>,
auto_capitalize: Option<bool>,
auto_correct: Option<bool>,
natural_scroll: Option<bool>,
tap_to_click: Option<bool>,
three_finger_drag: Option<bool>,
dock_autohide: Option<bool>,
dock_show_recents: Option<bool>,
fonts: Option<ProfileFonts>,
dock: Option<ProfileDock>,
appearance: Option<ProfileAppearance>,
input: Option<ProfileInput>,
finder: Option<ProfileFinder>,
screenshots: Option<ProfileScreenshots>,
default_apps: Option<ProfileDefaultApps>,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileFonts {
nerd: Option<Vec<String>>,
families: Option<Vec<String>>,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileDock {
persistent_apps: Option<Vec<String>>,
tile_size: Option<u32>,
position: Option<String>, minimize_effect: Option<String>, magnification: Option<bool>,
magnification_size: Option<u32>,
launchanim: Option<bool>,
mineffect: Option<String>,
show_process_indicators: Option<bool>,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileAppearance {
dark_mode: Option<bool>,
accent_color: Option<String>,
highlight_color: Option<String>,
reduce_transparency: Option<bool>,
sidebar_icon_size: Option<u32>, }
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileInput {
key_repeat: Option<u32>, initial_key_repeat: Option<u32>, fn_as_standard: Option<bool>, press_and_hold: Option<bool>, }
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileFinder {
default_view: Option<String>, show_path_bar: Option<bool>,
show_status_bar: Option<bool>,
show_tab_bar: Option<bool>,
new_window_path: Option<String>,
search_scope: Option<String>, show_extensions: Option<bool>,
warn_on_extension_change: Option<bool>,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileScreenshots {
location: Option<String>,
format: Option<String>, disable_shadow: Option<bool>,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileDefaultApps {
browser: Option<String>, }
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileLinux {
desktop: Option<String>, display_manager: Option<String>, gpu: Option<ProfileGpu>,
audio: Option<ProfileAudio>,
gaming: Option<ProfileGaming>,
services: Option<Vec<String>>, kernel_params: Option<Vec<String>>,
gnome: Option<ProfileGnome>,
kde: Option<ProfileKde>,
cosmic: Option<ProfileCosmic>,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileGpu {
driver: Option<String>, vulkan: Option<bool>,
opencl: Option<bool>,
vaapi: Option<bool>, #[serde(rename = "32bit")]
lib32: Option<bool>, nvidia_open: Option<bool>, }
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileAudio {
backend: Option<String>, low_latency: Option<bool>,
bluetooth: Option<bool>,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileGaming {
steam: Option<bool>,
gamemode: Option<bool>,
mangohud: Option<bool>,
gamescope: Option<bool>,
controllers: Option<bool>, proton_ge: Option<bool>,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileGnome {
dark_mode: Option<bool>,
font_name: Option<String>,
monospace_font: Option<String>,
icon_theme: Option<String>,
cursor_theme: Option<String>,
button_layout: Option<String>,
favorite_apps: Option<Vec<String>>,
extensions: Option<Vec<String>>,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileKde {
color_scheme: Option<String>,
icon_theme: Option<String>,
cursor_theme: Option<String>,
num_desktops: Option<u32>,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileCosmic {
dark_mode: Option<bool>,
accent_color: Option<Vec<f64>>, dock_autohide: Option<bool>,
dock_favorites: Option<Vec<String>>,
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ProfileSecurity {
touchid_sudo: Option<bool>,
}
pub fn run(config: &Config, repo_ref: &str, dry_run: bool) -> Result<()> {
let profile = fetch_profile(repo_ref)?;
if let Some(base_ref) = profile.meta.as_ref().and_then(|m| m.extends.as_deref()) {
println!(" {} extends {}", style("i").cyan(), style(base_ref).bold());
run(config, base_ref, dry_run)?;
println!();
println!(
" {} applying overlay {}",
style("nex profile").bold(),
style(
profile
.meta
.as_ref()
.and_then(|m| m.name.as_deref())
.unwrap_or(repo_ref)
)
.cyan()
);
println!();
} else if let Some(meta) = &profile.meta {
println!();
println!(
" {} applying profile {}",
style("nex profile").bold(),
style(meta.name.as_deref().unwrap_or(repo_ref)).cyan()
);
if let Some(desc) = &meta.description {
println!(" {}", style(desc).dim());
}
println!();
}
let mut session = EditSession::new();
let mut changes = 0;
if let Some(pkgs) = &profile.packages {
changes += apply_nix_packages(config, &mut session, pkgs, dry_run)?;
if config.platform == Platform::Darwin {
changes += apply_brew_packages(config, &mut session, pkgs, dry_run)?;
apply_taps(config, pkgs, dry_run)?;
}
}
if profile.kitty.is_some() {
apply_kitty(config, repo_ref, &profile.kitty, dry_run)?;
}
if let Some(shell) = &profile.shell {
apply_shell(config, shell, dry_run)?;
}
if let Some(git) = &profile.git {
apply_git(config, git, dry_run)?;
}
if config.platform == Platform::Darwin {
if let Some(macos) = &profile.macos {
apply_macos(config, macos, dry_run)?;
}
}
if config.platform == Platform::Linux {
if let Some(linux) = &profile.linux {
apply_linux(config, linux, dry_run)?;
}
}
if let Some(security) = &profile.security {
apply_security(config, security, dry_run)?;
}
if dry_run {
println!();
output::dry_run(&format!("{changes} package(s) would be added"));
return Ok(());
}
if changes > 0 {
session.commit_all()?;
let _ = Command::new("git")
.args(["add", "-A"])
.current_dir(&config.repo)
.output();
let _ = Command::new("git")
.args(["commit", "-m", &format!("nex profile apply: {repo_ref}")])
.current_dir(&config.repo)
.output();
}
let _ = crate::config::set_preference("profile", &format!("\"{repo_ref}\""));
println!();
println!(
" {} profile applied ({} packages added)",
style("✓").green().bold(),
changes
);
println!();
println!(" Run {} to activate.", style("nex switch").bold());
println!();
Ok(())
}
fn fetch_profile(repo_ref: &str) -> Result<Profile> {
let repo = if repo_ref.starts_with("http") {
repo_ref.to_string()
} else {
repo_ref
.trim_start_matches("github.com/")
.trim_start_matches("https://github.com/")
.to_string()
};
output::status(&format!("fetching profile from {repo}..."));
let content = fetch_via_gh(&repo)
.or_else(|_| fetch_via_curl(&repo))
.with_context(|| format!("could not fetch profile.toml from {repo}"))?;
let profile: Profile =
toml::from_str(&content).with_context(|| format!("invalid profile.toml from {repo}"))?;
Ok(profile)
}
fn fetch_via_gh(repo: &str) -> Result<String> {
let output = Command::new("gh")
.args([
"api",
&format!("repos/{repo}/contents/profile.toml"),
"-H",
"Accept: application/vnd.github.raw+json",
])
.output()
.context("gh not available")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let hint = if stderr.contains("404") {
format!("repo {repo} not found (check the name, or run `gh auth refresh -s repo`)")
} else if stderr.contains("401") || stderr.contains("403") {
format!(
"access denied to {repo} — run `gh auth refresh -s repo` to grant private repo access"
)
} else {
format!("gh api failed: {}", stderr.trim())
};
bail!("{hint}");
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn fetch_via_curl(repo: &str) -> Result<String> {
let url = format!("https://raw.githubusercontent.com/{repo}/main/profile.toml");
let output = Command::new("curl")
.args(["-fsSL", &url])
.output()
.context("curl failed")?;
if !output.status.success() {
bail!("not available at {url} (private repo? use `gh auth login` first)");
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn apply_nix_packages(
config: &Config,
session: &mut EditSession,
pkgs: &ProfilePackages,
dry_run: bool,
) -> Result<usize> {
let nix = match &pkgs.nix {
Some(list) if !list.is_empty() => list,
_ => return Ok(0),
};
let mut existing = HashSet::new();
for nix_file in config.all_nix_package_files() {
for pkg in edit::list_packages(nix_file, &nixfile::NIX_PACKAGES)? {
existing.insert(pkg);
}
}
let new: Vec<&String> = nix.iter().filter(|p| !existing.contains(*p)).collect();
if new.is_empty() {
return Ok(0);
}
if dry_run {
for pkg in &new {
output::dry_run(&format!("would add nix package {pkg}"));
}
return Ok(new.len());
}
session.backup(&config.nix_packages_file)?;
let mut added = 0;
for pkg in &new {
if edit::insert(&config.nix_packages_file, &nixfile::NIX_PACKAGES, pkg)? {
println!(" {} {} {}", style("+").green(), pkg, style("(nix)").dim());
added += 1;
}
}
Ok(added)
}
fn apply_brew_packages(
config: &Config,
session: &mut EditSession,
pkgs: &ProfilePackages,
dry_run: bool,
) -> Result<usize> {
let mut added = 0;
if let Some(brews) = &pkgs.brews {
let existing: HashSet<String> =
edit::list_packages(&config.homebrew_file, &nixfile::HOMEBREW_BREWS)?
.into_iter()
.collect();
let new: Vec<&String> = brews.iter().filter(|b| !existing.contains(*b)).collect();
if !new.is_empty() {
if dry_run {
for b in &new {
output::dry_run(&format!("would add brew formula {b}"));
}
return Ok(new.len());
}
session.backup(&config.homebrew_file)?;
for b in &new {
if edit::insert(&config.homebrew_file, &nixfile::HOMEBREW_BREWS, b)? {
println!(" {} {} {}", style("+").green(), b, style("(brew)").dim());
added += 1;
}
}
}
}
if let Some(casks) = &pkgs.casks {
let existing: HashSet<String> =
edit::list_packages(&config.homebrew_file, &nixfile::HOMEBREW_CASKS)?
.into_iter()
.collect();
let new: Vec<&String> = casks.iter().filter(|c| !existing.contains(*c)).collect();
if !new.is_empty() {
if dry_run {
for c in &new {
output::dry_run(&format!("would add brew cask {c}"));
}
return Ok(added + new.len());
}
session.backup(&config.homebrew_file)?;
for c in &new {
if edit::insert(&config.homebrew_file, &nixfile::HOMEBREW_CASKS, c)? {
println!(" {} {} {}", style("+").green(), c, style("(cask)").dim());
added += 1;
}
}
}
}
Ok(added)
}
fn apply_taps(config: &Config, pkgs: &ProfilePackages, dry_run: bool) -> Result<()> {
let taps = match &pkgs.taps {
Some(list) if !list.is_empty() => list,
_ => return Ok(()),
};
let content = std::fs::read_to_string(&config.homebrew_file)
.with_context(|| format!("reading {}", config.homebrew_file.display()))?;
if !content.contains("taps = [") {
if dry_run {
for t in taps {
output::dry_run(&format!("would add tap {t}"));
}
return Ok(());
}
let tap_lines: Vec<String> = taps.iter().map(|t| format!(" \"{t}\"")).collect();
let tap_block = format!("\n taps = [\n{}\n ];\n", tap_lines.join("\n"));
let patched = content.replace(" brews = [", &format!("{tap_block} brews = ["));
std::fs::write(&config.homebrew_file, patched)?;
}
Ok(())
}
fn apply_kitty(
config: &Config,
repo_ref: &str,
kitty: &Option<ProfileKitty>,
dry_run: bool,
) -> Result<()> {
if dry_run {
output::dry_run("would apply kitty configuration");
return Ok(());
}
let repo = repo_ref
.trim_start_matches("github.com/")
.trim_start_matches("https://github.com/");
let json_str = fetch_dir_listing(repo, "kitty").unwrap_or_default();
let entries: Vec<serde_json::Value> = serde_json::from_str(&json_str).unwrap_or_default();
if entries.is_empty() {
return Ok(());
}
let repo_kitty_dir = config.repo.join("nix/modules/home/kitty-files");
std::fs::create_dir_all(&repo_kitty_dir)?;
download_tree(repo, "kitty", &entries, &repo_kitty_dir)?;
let user_kitty_dir = dirs::home_dir()
.context("no home directory")?
.join(".config/kitty");
std::fs::create_dir_all(&user_kitty_dir)?;
download_tree(repo, "kitty", &entries, &user_kitty_dir)?;
if kitty.is_some() {
println!(" {} kitty config applied", style("✓").green(),);
}
Ok(())
}
fn fetch_dir_listing(repo: &str, path: &str) -> Result<String> {
if let Ok(output) = Command::new("gh")
.args(["api", &format!("repos/{repo}/contents/{path}?ref=main")])
.output()
{
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).to_string());
}
}
let url = format!("https://api.github.com/repos/{repo}/contents/{path}?ref=main");
let output = Command::new("curl")
.args(["-fsSL", "-H", "Accept: application/vnd.github+json", &url])
.output()
.context("failed to list directory")?;
if !output.status.success() {
bail!("could not list {path} in {repo}");
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn fetch_file(repo: &str, path: &str) -> Result<Vec<u8>> {
if let Ok(output) = Command::new("gh")
.args([
"api",
&format!("repos/{repo}/contents/{path}"),
"-H",
"Accept: application/vnd.github.raw+json",
])
.output()
{
if output.status.success() {
return Ok(output.stdout);
}
}
let url = format!("https://raw.githubusercontent.com/{repo}/main/{path}");
let output = Command::new("curl")
.args(["-fsSL", &url])
.output()
.context("failed to download file")?;
if !output.status.success() {
bail!("could not download {path}");
}
Ok(output.stdout)
}
fn download_tree(
repo: &str,
path: &str,
entries: &[serde_json::Value],
local_dir: &std::path::Path,
) -> Result<()> {
for entry in entries {
let name = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
let entry_type = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
let entry_path = format!("{path}/{name}");
if entry_type == "file" {
let local_path = local_dir.join(name);
if let Ok(data) = fetch_file(repo, &entry_path) {
std::fs::write(&local_path, &data)?;
println!(
" {} {}",
style("+").green(),
style(local_path.display()).dim()
);
}
} else if entry_type == "dir" {
let subdir = local_dir.join(name);
std::fs::create_dir_all(&subdir)?;
if let Ok(listing) = fetch_dir_listing(repo, &entry_path) {
let sub_entries: Vec<serde_json::Value> =
serde_json::from_str(&listing).unwrap_or_default();
download_tree(repo, &entry_path, &sub_entries, &subdir)?;
}
}
}
Ok(())
}
fn apply_shell(config: &Config, shell: &ProfileShell, dry_run: bool) -> Result<()> {
let has_content = shell.default.is_some()
|| shell.aliases.is_some()
|| shell.env.is_some()
|| shell.profile_extra.is_some()
|| shell.init_extra.is_some();
if !has_content {
return Ok(());
}
if dry_run {
output::dry_run("would apply shell configuration");
return Ok(());
}
let scaffolded = config.repo.join("nix/modules/home").exists();
let shell_nix = if scaffolded {
config.repo.join("nix/modules/home/shell.nix")
} else {
config.repo.join("shell.nix")
};
let existing = if shell_nix.exists() {
std::fs::read_to_string(&shell_nix).unwrap_or_default()
} else {
String::new()
};
let mut aliases: std::collections::BTreeMap<String, String> =
parse_nix_attrset(&existing, "shellAliases");
if let Some(ref new_aliases) = shell.aliases {
for (k, v) in new_aliases {
aliases.insert(k.clone(), v.clone());
}
}
let mut env_vars: std::collections::BTreeMap<String, String> =
parse_nix_attrset(&existing, "sessionVariables");
if let Some(ref new_env) = shell.env {
for (k, v) in new_env {
env_vars.insert(k.clone(), v.clone());
}
}
let profile_extra = merge_nix_multiline(
&parse_nix_multiline(&existing, "profileExtra"),
shell.profile_extra.as_deref(),
);
let init_extra = merge_nix_multiline(
&parse_nix_multiline(&existing, "initExtra"),
shell.init_extra.as_deref(),
);
let mut lines = Vec::new();
lines.push("{ pkgs, ... }:".to_string());
lines.push(String::new());
lines.push("{".to_string());
lines.push(" programs.bash.enable = true;".to_string());
let hist_size = env_vars
.remove("HISTSIZE")
.or_else(|| parse_nix_scalar(&existing, "historySize"));
let hist_file_size = env_vars
.remove("HISTFILESIZE")
.or_else(|| parse_nix_scalar(&existing, "historyFileSize"));
let hist_control = env_vars
.remove("HISTCONTROL")
.or_else(|| parse_nix_list_as_colon(&existing, "historyControl"));
if let Some(size) = hist_size {
if let Ok(n) = size.parse::<u64>() {
lines.push(format!(" programs.bash.historySize = {n};"));
}
}
if let Some(size) = hist_file_size {
if let Ok(n) = size.parse::<u64>() {
lines.push(format!(" programs.bash.historyFileSize = {n};"));
}
}
if let Some(ref control) = hist_control {
let items: Vec<&str> = control.split(':').collect();
let nix_list = items
.iter()
.map(|s| format!("\"{s}\""))
.collect::<Vec<_>>()
.join(" ");
lines.push(format!(" programs.bash.historyControl = [ {nix_list} ];"));
}
if !aliases.is_empty() {
lines.push(" programs.bash.shellAliases = {".to_string());
for (name, cmd) in &aliases {
let escaped = cmd
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace("${", "\\${");
lines.push(format!(" {name} = \"{escaped}\";"));
}
lines.push(" };".to_string());
}
if let Some(ref pe) = profile_extra {
let trimmed = pe.trim();
if !trimmed.is_empty() {
lines.push(format!(
" programs.bash.profileExtra = ''\n{}\n '';",
indent_nix_multiline(trimmed, 4)
));
}
}
if let Some(ref ie) = init_extra {
let trimmed = ie.trim();
if !trimmed.is_empty() {
lines.push(format!(
" programs.bash.initExtra = ''\n{}\n '';",
indent_nix_multiline(trimmed, 4)
));
}
}
if !env_vars.is_empty() {
lines.push(" home.sessionVariables = {".to_string());
for (key, val) in &env_vars {
let escaped = val
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace("${", "\\${");
lines.push(format!(" {key} = \"{escaped}\";"));
}
lines.push(" };".to_string());
}
lines.push("}".to_string());
lines.push(String::new());
if let Some(parent) = shell_nix.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&shell_nix, lines.join("\n"))?;
wire_shell_import(config, scaffolded)?;
println!(" {} shell config written", style("✓").green());
println!(" {}", style(shell_nix.display()).dim());
Ok(())
}
fn parse_nix_attrset(content: &str, attr_name: &str) -> std::collections::BTreeMap<String, String> {
let mut map = std::collections::BTreeMap::new();
let marker = format!("{attr_name} = {{");
let block_start = match content.find(&marker) {
Some(pos) => pos + marker.len(),
None => return map,
};
let block_end = match content[block_start..].find("};") {
Some(pos) => block_start + pos,
None => return map,
};
for line in content[block_start..block_end].lines() {
let trimmed = line.trim();
if let Some(eq_pos) = trimmed.find(" = \"") {
let key = trimmed[..eq_pos].trim();
let val_start = eq_pos + 4; if let Some(val_end) = trimmed[val_start..].rfind("\";") {
let val = &trimmed[val_start..val_start + val_end];
let unescaped = val
.replace("\\\"", "\"")
.replace("\\\\", "\\")
.replace("\\${", "${");
map.insert(key.to_string(), unescaped);
}
}
}
map
}
fn parse_nix_multiline(content: &str, attr_name: &str) -> Option<String> {
let marker = format!("{attr_name} = ''");
let start = content.find(&marker)?;
let after_marker = start + marker.len();
let close = content[after_marker..].find("'';")?;
let inner = &content[after_marker..after_marker + close];
let lines: Vec<&str> = inner.lines().collect();
let min_indent = lines
.iter()
.filter(|l| !l.trim().is_empty())
.map(|l| l.len() - l.trim_start().len())
.min()
.unwrap_or(0);
let dedented: Vec<&str> = lines
.iter()
.map(|l| {
if l.trim().is_empty() {
""
} else if l.len() >= min_indent {
&l[min_indent..]
} else {
l.trim()
}
})
.collect();
let result = dedented.join("\n");
let trimmed = result.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn parse_nix_scalar(content: &str, attr_name: &str) -> Option<String> {
let suffix = format!("{attr_name} = ");
for line in content.lines() {
let trimmed = line.trim();
if trimmed.contains(&suffix) && trimmed.ends_with(';') {
if let Some(pos) = trimmed.find(&suffix) {
let val = &trimmed[pos + suffix.len()..trimmed.len() - 1];
return Some(val.trim().to_string());
}
}
}
None
}
fn parse_nix_list_as_colon(content: &str, attr_name: &str) -> Option<String> {
let suffix = format!("{attr_name} = [");
for line in content.lines() {
let trimmed = line.trim();
if let Some(pos) = trimmed.find(&suffix) {
let after = &trimmed[pos + suffix.len()..];
let inner = after.trim_end_matches("];").trim();
let items: Vec<&str> = inner
.split_whitespace()
.map(|s| s.trim_matches('"'))
.collect();
if items.is_empty() {
return None;
}
return Some(items.join(":"));
}
}
None
}
fn merge_nix_multiline(base: &Option<String>, overlay: Option<&str>) -> Option<String> {
let base_trimmed = base.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty());
let overlay_trimmed = overlay.map(|s| s.trim()).filter(|s| !s.is_empty());
match (base_trimmed, overlay_trimmed) {
(Some(b), Some(o)) => {
if b.contains(o) {
Some(b.to_string())
} else if o.contains(b) {
Some(o.to_string())
} else {
Some(format!("{b}\n\n{o}"))
}
}
(Some(b), None) => Some(b.to_string()),
(None, Some(o)) => Some(o.to_string()),
(None, None) => None,
}
}
fn wire_shell_import(config: &Config, scaffolded: bool) -> Result<()> {
if scaffolded {
let host_default = config
.repo
.join(format!("nix/hosts/{}/default.nix", config.hostname));
if !host_default.exists() {
println!(
" {} could not wire shell.nix import — {} not found",
style("!").yellow(),
style(host_default.display()).dim()
);
println!(
" Add {} to your home-manager imports manually",
style("../../modules/home/shell.nix").bold()
);
return Ok(());
}
let content = std::fs::read_to_string(&host_default)?;
if !content.contains("shell.nix") {
let patched = if content.contains("import ../../modules/home/base.nix") {
content.replace(
"import ../../modules/home/base.nix;",
"{\n imports = [\n ../../modules/home/base.nix\n ../../modules/home/shell.nix\n ];\n };",
)
} else if content.contains("../../modules/home/base.nix") {
content.replace(
"../../modules/home/base.nix",
"../../modules/home/base.nix\n ../../modules/home/shell.nix",
)
} else {
println!(
" {} could not find home-manager base.nix import in {}",
style("!").yellow(),
style(host_default.display()).dim()
);
println!(
" Add {} to your home-manager imports manually",
style("../../modules/home/shell.nix").bold()
);
content
};
std::fs::write(&host_default, patched)?;
}
} else {
let home_nix = config.repo.join("home.nix");
if home_nix.exists() {
let content = std::fs::read_to_string(&home_nix)?;
if !content.contains("shell.nix") {
let patched = if content.contains("imports = [") {
content.replace("imports = [", "imports = [\n ./shell.nix")
} else {
content.replace("{\n", "{\n imports = [ ./shell.nix ];\n\n")
};
std::fs::write(&home_nix, patched)?;
}
}
}
Ok(())
}
fn indent_nix_multiline(s: &str, spaces: usize) -> String {
let indent = " ".repeat(spaces);
s.lines()
.map(|line| {
if line.trim().is_empty() {
String::new()
} else {
format!("{indent}{line}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn apply_git(_config: &Config, git: &ProfileGit, dry_run: bool) -> Result<()> {
if dry_run {
output::dry_run("would apply git configuration");
return Ok(());
}
if let Some(name) = &git.name {
let _ = Command::new("git")
.args(["config", "--global", "user.name", name])
.output();
println!(" {} git user.name = {}", style("✓").green(), name);
}
if let Some(email) = &git.email {
let _ = Command::new("git")
.args(["config", "--global", "user.email", email])
.output();
println!(" {} git user.email = {}", style("✓").green(), email);
}
if let Some(branch) = &git.default_branch {
let _ = Command::new("git")
.args(["config", "--global", "init.defaultBranch", branch])
.output();
}
if git.pull_rebase == Some(true) {
let _ = Command::new("git")
.args(["config", "--global", "pull.rebase", "true"])
.output();
}
if git.push_auto_setup_remote == Some(true) {
let _ = Command::new("git")
.args(["config", "--global", "push.autoSetupRemote", "true"])
.output();
}
let _ = Command::new("git")
.args([
"config",
"--global",
"credential.https://github.com.helper",
"!gh auth git-credential",
])
.output();
Ok(())
}
fn apply_macos(config: &Config, macos: &ProfileMacos, dry_run: bool) -> Result<()> {
if dry_run {
output::dry_run("would apply macOS preferences");
return Ok(());
}
let bool_defaults = [
(
"NSGlobalDomain",
"AppleShowAllExtensions",
macos.show_all_extensions,
),
(
"NSGlobalDomain",
"AppleShowAllFiles",
macos.show_hidden_files,
),
(
"NSGlobalDomain",
"NSAutomaticCapitalizationEnabled",
macos.auto_capitalize,
),
(
"NSGlobalDomain",
"NSAutomaticSpellingCorrectionEnabled",
macos.auto_correct,
),
(
"NSGlobalDomain",
"com.apple.swipescrolldirection",
macos.natural_scroll,
),
];
for (domain, key, value) in &bool_defaults {
if let Some(v) = value {
defaults_write_bool(domain, key, *v);
}
}
if let Some(v) = macos.dock_autohide {
defaults_write_bool("com.apple.dock", "autohide", v);
}
if let Some(v) = macos.dock_show_recents {
defaults_write_bool("com.apple.dock", "show-recents", v);
}
if let Some(true) = macos.tap_to_click {
defaults_write_bool("com.apple.AppleMultitouchTrackpad", "Clicking", true);
defaults_write_bool(
"com.apple.driver.AppleBluetoothMultitouch.trackpad",
"Clicking",
true,
);
}
if let Some(true) = macos.three_finger_drag {
defaults_write_bool(
"com.apple.AppleMultitouchTrackpad",
"TrackpadThreeFingerDrag",
true,
);
defaults_write_bool(
"com.apple.driver.AppleBluetoothMultitouch.trackpad",
"TrackpadThreeFingerDrag",
true,
);
}
if let Some(dock) = &macos.dock {
if let Some(size) = dock.tile_size {
defaults_write_int("com.apple.dock", "tilesize", size);
}
if let Some(ref pos) = dock.position {
defaults_write_string("com.apple.dock", "orientation", pos);
}
if let Some(ref effect) = dock.minimize_effect {
defaults_write_string("com.apple.dock", "mineffect", effect);
}
if let Some(v) = dock.magnification {
defaults_write_bool("com.apple.dock", "magnification", v);
}
if let Some(size) = dock.magnification_size {
defaults_write_int("com.apple.dock", "largesize", size);
}
if let Some(v) = dock.launchanim {
defaults_write_bool("com.apple.dock", "launchanim", v);
}
if let Some(v) = dock.show_process_indicators {
defaults_write_bool("com.apple.dock", "show-process-indicators", v);
}
if let Some(ref apps) = dock.persistent_apps {
apply_dock_apps(apps);
}
}
if let Some(appearance) = &macos.appearance {
if let Some(true) = appearance.dark_mode {
defaults_write_string("NSGlobalDomain", "AppleInterfaceStyle", "Dark");
} else if appearance.dark_mode == Some(false) {
let _ = Command::new("defaults")
.args(["delete", "NSGlobalDomain", "AppleInterfaceStyle"])
.output();
}
if let Some(ref color) = appearance.accent_color {
let lowered = color.to_lowercase();
let val = match lowered.as_str() {
"blue" => "-1",
"purple" => "5",
"pink" => "6",
"red" => "0",
"orange" => "1",
"yellow" => "2",
"green" => "3",
"graphite" => "-2",
_ => &lowered,
};
defaults_write_string("NSGlobalDomain", "AppleAccentColor", val);
}
if let Some(ref color) = appearance.highlight_color {
defaults_write_string("NSGlobalDomain", "AppleHighlightColor", color);
}
if let Some(v) = appearance.reduce_transparency {
defaults_write_bool("com.apple.universalaccess", "reduceTransparency", v);
}
if let Some(size) = appearance.sidebar_icon_size {
defaults_write_int("NSGlobalDomain", "NSTableViewDefaultSizeMode", size);
}
}
if let Some(input) = &macos.input {
if let Some(rate) = input.key_repeat {
defaults_write_int("NSGlobalDomain", "KeyRepeat", rate);
}
if let Some(delay) = input.initial_key_repeat {
defaults_write_int("NSGlobalDomain", "InitialKeyRepeat", delay);
}
if let Some(v) = input.fn_as_standard {
defaults_write_bool("NSGlobalDomain", "com.apple.keyboard.fnState", v);
}
if let Some(v) = input.press_and_hold {
defaults_write_bool("NSGlobalDomain", "ApplePressAndHoldEnabled", v);
}
}
if let Some(finder) = &macos.finder {
if let Some(ref view) = finder.default_view {
let code = match view.as_str() {
"list" => "Nlsv",
"icon" => "icnv",
"column" => "clmv",
"gallery" => "glyv",
other => other,
};
defaults_write_string("com.apple.finder", "FXPreferredViewStyle", code);
}
if let Some(v) = finder.show_path_bar {
defaults_write_bool("com.apple.finder", "ShowPathbar", v);
}
if let Some(v) = finder.show_status_bar {
defaults_write_bool("com.apple.finder", "ShowStatusBar", v);
}
if let Some(v) = finder.show_tab_bar {
defaults_write_bool("com.apple.finder", "ShowTabView", v);
}
if let Some(ref path) = finder.new_window_path {
defaults_write_string("com.apple.finder", "NewWindowTarget", "PfLo");
defaults_write_string("com.apple.finder", "NewWindowTargetPath", path);
}
if let Some(ref scope) = finder.search_scope {
let val = match scope.as_str() {
"current" => "SCcf",
"previous" => "SCsp",
"computer" => "SCev",
other => other,
};
defaults_write_string("com.apple.finder", "FXDefaultSearchScope", val);
}
if let Some(v) = finder.show_extensions {
defaults_write_bool("NSGlobalDomain", "AppleShowAllExtensions", v);
}
if let Some(v) = finder.warn_on_extension_change {
defaults_write_bool("com.apple.finder", "FXEnableExtensionChangeWarning", v);
}
}
if let Some(ss) = &macos.screenshots {
if let Some(ref loc) = ss.location {
let expanded = loc.replace(
'~',
&dirs::home_dir()
.map(|h| h.display().to_string())
.unwrap_or_default(),
);
defaults_write_string("com.apple.screencapture", "location", &expanded);
}
if let Some(ref fmt) = ss.format {
defaults_write_string("com.apple.screencapture", "type", fmt);
}
if let Some(v) = ss.disable_shadow {
defaults_write_bool("com.apple.screencapture", "disable-shadow", v);
}
}
if let Some(apps) = &macos.default_apps {
if let Some(ref browser) = apps.browser {
let bundle_id = resolve_bundle_id(browser);
let bid = bundle_id.as_deref().unwrap_or(browser);
let _ = Command::new("open")
.args(["-a", browser, "--args", "--make-default-browser"])
.output();
defaults_write_string(
"com.apple.LaunchServices/com.apple.launchservices.secure",
"LSHandlerURLSchemeHTTP",
bid,
);
}
}
let needs_dock_restart =
macos.dock.is_some() || macos.dock_autohide.is_some() || macos.dock_show_recents.is_some();
let needs_finder_restart = macos.finder.is_some();
if needs_dock_restart {
let _ = Command::new("killall").arg("Dock").output();
}
if needs_finder_restart {
let _ = Command::new("killall").arg("Finder").output();
}
if macos.screenshots.is_some() {
let _ = Command::new("killall").arg("SystemUIServer").output();
}
write_system_defaults(config, macos)?;
println!(" {} macOS preferences applied", style("✓").green());
Ok(())
}
fn resolve_bundle_id(app_name: &str) -> Option<String> {
if app_name.contains('.') {
return Some(app_name.to_string());
}
let app_path = format!("/Applications/{app_name}.app");
let output = Command::new("mdls")
.args(["-name", "kMDItemCFBundleIdentifier", "-raw", &app_path])
.output()
.ok()?;
if output.status.success() {
let bid = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !bid.is_empty() && bid != "(null)" {
return Some(bid);
}
}
match app_name {
"Safari" => Some("com.apple.Safari".to_string()),
"Firefox" => Some("org.mozilla.firefox".to_string()),
"Chrome" | "Google Chrome" => Some("com.google.Chrome".to_string()),
"Arc" => Some("company.thebrowser.Browser".to_string()),
"Brave" | "Brave Browser" => Some("com.brave.Browser".to_string()),
_ => None,
}
}
fn defaults_write_bool(domain: &str, key: &str, value: bool) {
let val = if value { "true" } else { "false" };
let _ = Command::new("defaults")
.args(["write", domain, key, "-bool", val])
.output();
}
fn defaults_write_int(domain: &str, key: &str, value: u32) {
let _ = Command::new("defaults")
.args(["write", domain, key, "-int", &value.to_string()])
.output();
}
fn defaults_write_string(domain: &str, key: &str, value: &str) {
let _ = Command::new("defaults")
.args(["write", domain, key, "-string", value])
.output();
}
fn apply_dock_apps(apps: &[String]) {
let has_dockutil = Command::new("which")
.arg("dockutil")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !has_dockutil {
println!(
" {} install dockutil to manage dock apps: {}",
style("!").yellow(),
style("brew install dockutil").bold()
);
return;
}
let _ = Command::new("dockutil")
.args(["--remove", "all", "--no-restart"])
.output();
for app in apps {
let path = if app.starts_with('/') {
app.clone()
} else {
format!("/Applications/{app}.app")
};
let _ = Command::new("dockutil")
.args(["--add", &path, "--no-restart"])
.output();
println!(" {} {}", style("+").green(), style(&path).dim());
}
let _ = Command::new("killall").arg("Dock").output();
}
fn write_system_defaults(config: &Config, macos: &ProfileMacos) -> Result<()> {
let base_nix = config.repo.join("nix/modules/darwin/base.nix");
let content = std::fs::read_to_string(&base_nix)
.with_context(|| format!("reading {}", base_nix.display()))?;
let mut defaults_lines: Vec<String> = Vec::new();
let mut nsg: Vec<String> = Vec::new();
if let Some(v) = macos.show_all_extensions {
nsg.push(format!(" AppleShowAllExtensions = {};", v));
}
if let Some(v) = macos.show_hidden_files {
nsg.push(format!(" AppleShowAllFiles = {};", v));
}
if let Some(v) = macos.auto_capitalize {
nsg.push(format!(" NSAutomaticCapitalizationEnabled = {};", v));
}
if let Some(v) = macos.auto_correct {
nsg.push(format!(" NSAutomaticSpellingCorrectionEnabled = {};", v));
}
if let Some(v) = macos.natural_scroll {
nsg.push(format!(" \"com.apple.swipescrolldirection\" = {};", v));
}
if let Some(appearance) = &macos.appearance {
if let Some(true) = appearance.dark_mode {
nsg.push(" AppleInterfaceStyle = \"Dark\";".to_string());
}
if let Some(size) = appearance.sidebar_icon_size {
nsg.push(format!(" NSTableViewDefaultSizeMode = {};", size));
}
}
if let Some(input) = &macos.input {
if let Some(rate) = input.key_repeat {
nsg.push(format!(" KeyRepeat = {};", rate));
}
if let Some(delay) = input.initial_key_repeat {
nsg.push(format!(" InitialKeyRepeat = {};", delay));
}
if let Some(v) = input.press_and_hold {
nsg.push(format!(" ApplePressAndHoldEnabled = {};", v));
}
}
if !nsg.is_empty() {
defaults_lines.push(" NSGlobalDomain = {".to_string());
defaults_lines.extend(nsg);
defaults_lines.push(" };".to_string());
}
let mut dock: Vec<String> = Vec::new();
if let Some(v) = macos.dock_autohide {
dock.push(format!(" autohide = {};", v));
}
if let Some(v) = macos.dock_show_recents {
dock.push(format!(" show-recents = {};", v));
}
if let Some(d) = &macos.dock {
if let Some(size) = d.tile_size {
dock.push(format!(" tilesize = {};", size));
}
if let Some(ref pos) = d.position {
dock.push(format!(" orientation = \"{}\";", pos));
}
if let Some(ref effect) = d.minimize_effect {
dock.push(format!(" mineffect = \"{}\";", effect));
}
if let Some(v) = d.magnification {
dock.push(format!(" magnification = {};", v));
}
if let Some(size) = d.magnification_size {
dock.push(format!(" largesize = {};", size));
}
if let Some(v) = d.launchanim {
dock.push(format!(" launchanim = {};", v));
}
if let Some(v) = d.show_process_indicators {
dock.push(format!(" show-process-indicators = {};", v));
}
}
if !dock.is_empty() {
defaults_lines.push(" dock = {".to_string());
defaults_lines.extend(dock);
defaults_lines.push(" };".to_string());
}
let mut finder: Vec<String> = Vec::new();
if let Some(f) = &macos.finder {
if let Some(ref view) = f.default_view {
let code = match view.as_str() {
"list" => "Nlsv",
"icon" => "icnv",
"column" => "clmv",
"gallery" => "glyv",
other => other,
};
finder.push(format!(" FXPreferredViewStyle = \"{}\";", code));
}
if let Some(v) = f.show_path_bar {
finder.push(format!(" ShowPathbar = {};", v));
}
if let Some(v) = f.show_status_bar {
finder.push(format!(" ShowStatusBar = {};", v));
}
if let Some(ref scope) = f.search_scope {
let val = match scope.as_str() {
"current" => "SCcf",
"previous" => "SCsp",
"computer" => "SCev",
other => other,
};
finder.push(format!(" FXDefaultSearchScope = \"{}\";", val));
}
if let Some(v) = f.warn_on_extension_change {
finder.push(format!(" FXEnableExtensionChangeWarning = {};", v));
}
}
if !finder.is_empty() {
defaults_lines.push(" finder = {".to_string());
defaults_lines.extend(finder);
defaults_lines.push(" };".to_string());
}
let mut screencap: Vec<String> = Vec::new();
if let Some(ss) = &macos.screenshots {
if let Some(ref loc) = ss.location {
screencap.push(format!(" location = \"{}\";", loc));
}
if let Some(ref fmt) = ss.format {
screencap.push(format!(" type = \"{}\";", fmt));
}
if let Some(v) = ss.disable_shadow {
screencap.push(format!(" disable-shadow = {};", v));
}
}
if !screencap.is_empty() {
defaults_lines.push(" screencapture = {".to_string());
defaults_lines.extend(screencap);
defaults_lines.push(" };".to_string());
}
let mut trackpad: Vec<String> = Vec::new();
if let Some(true) = macos.tap_to_click {
trackpad.push(" Clicking = true;".to_string());
}
if let Some(true) = macos.three_finger_drag {
trackpad.push(" TrackpadThreeFingerDrag = true;".to_string());
}
if !trackpad.is_empty() {
defaults_lines.push(" trackpad = {".to_string());
defaults_lines.extend(trackpad);
defaults_lines.push(" };".to_string());
}
if defaults_lines.is_empty() {
return Ok(());
}
let defaults_block = format!(
"\n system.defaults = {{\n{}\n }};\n",
defaults_lines.join("\n")
);
if content.contains("system.defaults = {") {
let start = match content.find(" system.defaults = {") {
Some(pos) => pos,
None => {
let insert_pos = content.rfind('}').unwrap_or(content.len());
let mut patched = content[..insert_pos].to_string();
patched.push_str(&defaults_block);
patched.push('}');
if content.ends_with('\n') {
patched.push('\n');
}
std::fs::write(&base_nix, patched)?;
return Ok(());
}
};
let after_start = &content[start..];
let mut depth: i32 = 0;
let mut end = content.len(); let mut found = false;
for (i, c) in after_start.char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
let mut consume_end = start + i + 1;
let remaining = &content[consume_end..];
if remaining.starts_with(";\n") {
consume_end += 2;
} else if remaining.starts_with(';') {
consume_end += 1;
} else if remaining.starts_with('\n') {
consume_end += 1;
}
end = consume_end;
found = true;
break;
}
}
_ => {}
}
}
if !found {
let insert_pos = content.rfind('}').unwrap_or(content.len());
let mut patched = content[..insert_pos].to_string();
patched.push_str(&defaults_block);
patched.push('}');
if content.ends_with('\n') {
patched.push('\n');
}
std::fs::write(&base_nix, patched)?;
return Ok(());
}
let mut patched = content[..start].to_string();
patched.push_str(&format!(
" system.defaults = {{\n{}\n }};\n",
defaults_lines.join("\n")
));
patched.push_str(&content[end..]);
std::fs::write(&base_nix, patched)?;
} else {
let insert_pos = content.rfind('}').unwrap_or(content.len());
let mut patched = content[..insert_pos].to_string();
patched.push_str(&defaults_block);
patched.push('}');
if content.ends_with('\n') {
patched.push('\n');
}
std::fs::write(&base_nix, patched)?;
}
Ok(())
}
fn apply_security(_config: &Config, _security: &ProfileSecurity, dry_run: bool) -> Result<()> {
if dry_run {
output::dry_run("would configure security settings");
return Ok(());
}
Ok(())
}
fn apply_linux(config: &Config, linux: &ProfileLinux, dry_run: bool) -> Result<()> {
if dry_run {
output::dry_run("would apply Linux desktop configuration");
return Ok(());
}
let mut lines: Vec<String> = Vec::new();
lines.push("{ pkgs, lib, ... }:".to_string());
lines.push(String::new());
lines.push("{".to_string());
if let Some(ref de) = linux.desktop {
match de.as_str() {
"gnome" => {
lines.push(" services.xserver.enable = true;".to_string());
lines.push(" services.xserver.displayManager.gdm.enable = true;".to_string());
lines.push(" services.xserver.desktopManager.gnome.enable = true;".to_string());
}
"kde" | "plasma" => {
lines.push(" services.desktopManager.plasma6.enable = true;".to_string());
lines.push(" services.displayManager.sddm.enable = true;".to_string());
lines.push(" services.displayManager.sddm.wayland.enable = true;".to_string());
}
"cosmic" => {
lines.push(" services.desktopManager.cosmic.enable = true;".to_string());
lines.push(" services.displayManager.cosmic-greeter.enable = true;".to_string());
}
_ => {}
}
}
if let Some(ref dm) = linux.display_manager {
match dm.as_str() {
"gdm" => {
lines.push(
" services.xserver.displayManager.gdm.enable = lib.mkForce true;".to_string(),
);
}
"sddm" => {
lines.push(" services.displayManager.sddm.enable = lib.mkForce true;".to_string());
}
"greetd" => {
lines.push(" services.greetd.enable = true;".to_string());
}
_ => {}
}
}
if let Some(ref gpu) = linux.gpu {
if let Some(ref driver) = gpu.driver {
lines.push(String::new());
lines.push(" hardware.graphics.enable = true;".to_string());
if gpu.lib32 == Some(true) {
lines.push(" hardware.graphics.enable32Bit = true;".to_string());
}
let drivers: Vec<&str> = driver.split(',').map(|d| d.trim()).collect();
let mut video_drivers: Vec<&str> = Vec::new();
let mut extra_packages: Vec<&str> = Vec::new();
for drv in &drivers {
match *drv {
"amdgpu" => {
lines.push(" # GPU: AMD".to_string());
lines.push(" hardware.amdgpu.initrd.enable = true;".to_string());
if gpu.opencl == Some(true) {
lines.push(" hardware.amdgpu.opencl.enable = true;".to_string());
}
if gpu.vaapi == Some(true) {
extra_packages.push("libva-vdpau-driver");
}
}
"nvidia" => {
lines.push(" # GPU: NVIDIA".to_string());
video_drivers.push("nvidia");
lines.push(" hardware.nvidia.modesetting.enable = true;".to_string());
let open = gpu.nvidia_open.unwrap_or(true);
lines.push(format!(" hardware.nvidia.open = {};", open));
}
"nouveau" => {
lines.push(" # GPU: NVIDIA (nouveau)".to_string());
video_drivers.push("nouveau");
}
"intel" => {
lines.push(" # GPU: Intel".to_string());
if gpu.vaapi == Some(true) {
extra_packages.push("intel-media-driver");
}
}
_ => {}
}
}
if !video_drivers.is_empty() {
let vd = video_drivers
.iter()
.map(|d| format!("\"{d}\""))
.collect::<Vec<_>>()
.join(" ");
lines.push(format!(" services.xserver.videoDrivers = [ {vd} ];"));
}
if !extra_packages.is_empty() {
lines.push(" hardware.graphics.extraPackages = with pkgs; [".to_string());
for pkg in &extra_packages {
lines.push(format!(" {pkg}"));
}
lines.push(" ];".to_string());
}
}
}
if let Some(ref audio) = linux.audio {
lines.push(String::new());
lines.push(" # Audio".to_string());
match audio.backend.as_deref() {
Some("pipewire") | None => {
lines.push(" services.pipewire = {".to_string());
lines.push(" enable = true;".to_string());
lines.push(" alsa.enable = true;".to_string());
lines.push(" alsa.support32Bit = true;".to_string());
lines.push(" pulse.enable = true;".to_string());
if audio.low_latency == Some(true) {
lines.push(" extraConfig.pipewire.\"92-low-latency\" = {".to_string());
lines.push(" \"context.properties\" = { \"default.clock.rate\" = 48000; \"default.clock.quantum\" = 64; };".to_string());
lines.push(" };".to_string());
}
lines.push(" };".to_string());
}
Some("pulseaudio") => {
lines.push(" hardware.pulseaudio.enable = true;".to_string());
}
_ => {}
}
if audio.bluetooth == Some(true) {
lines.push(" hardware.bluetooth.enable = true;".to_string());
lines.push(" hardware.bluetooth.powerOnBoot = true;".to_string());
}
}
if let Some(ref gaming) = linux.gaming {
lines.push(String::new());
lines.push(" # Gaming".to_string());
if gaming.steam == Some(true) {
lines.push(" programs.steam = {".to_string());
lines.push(" enable = true;".to_string());
lines.push(
" gamescopeSession.enable = {};"
.replace(
"{}",
if gaming.gamescope == Some(true) {
"true"
} else {
"false"
},
)
.to_string(),
);
lines.push(" };".to_string());
}
if gaming.gamemode == Some(true) {
lines.push(" programs.gamemode.enable = true;".to_string());
}
if gaming.controllers == Some(true) {
lines.push(" hardware.steam-hardware.enable = true;".to_string());
}
let mut gaming_pkgs: Vec<&str> = Vec::new();
if gaming.mangohud == Some(true) {
gaming_pkgs.push("mangohud");
}
if gaming.proton_ge == Some(true) {
}
if !gaming_pkgs.is_empty() {
lines.push(" environment.systemPackages = with pkgs; [".to_string());
for pkg in &gaming_pkgs {
lines.push(format!(" {pkg}"));
}
lines.push(" ];".to_string());
}
}
if let Some(ref services) = linux.services {
lines.push(String::new());
for svc in services {
lines.push(format!(" services.{svc}.enable = true;"));
}
}
if let Some(ref params) = linux.kernel_params {
lines.push(String::new());
let params_str = params
.iter()
.map(|p| format!("\"{p}\""))
.collect::<Vec<_>>()
.join(" ");
lines.push(format!(" boot.kernelParams = [ {params_str} ];"));
}
lines.push("}".to_string());
lines.push(String::new());
let scaffolded =
config.repo.join("nix/modules/nixos").exists() || config.repo.join("nix/hosts").exists();
let desktop_nix = if scaffolded {
config.repo.join("nix/modules/nixos/desktop.nix")
} else {
config.repo.join("desktop.nix")
};
if let Some(parent) = desktop_nix.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&desktop_nix, lines.join("\n"))?;
if scaffolded {
let host_default = config
.repo
.join(format!("nix/hosts/{}/default.nix", config.hostname));
if host_default.exists() {
let content = std::fs::read_to_string(&host_default)?;
if !content.contains("desktop.nix") {
let patched = content.replace(
"../../modules/nixos/base.nix",
"../../modules/nixos/base.nix\n ../../modules/nixos/desktop.nix",
);
std::fs::write(&host_default, patched)?;
}
}
} else {
let config_nix = config.repo.join("configuration.nix");
if config_nix.exists() {
let content = std::fs::read_to_string(&config_nix)?;
if !content.contains("desktop.nix") {
if let Some(brace_pos) = content.find('{') {
let mut patched = content[..=brace_pos].to_string();
patched.push_str("\n imports = [ ./desktop.nix ];");
patched.push_str(&content[brace_pos + 1..]);
std::fs::write(&config_nix, patched)?;
}
}
}
}
println!(
" {} Linux desktop configuration written",
style("✓").green()
);
println!(" {}", style(desktop_nix.display()).dim());
Ok(())
}