1use std::path::Path;
2use std::process::Command;
3
4use anyhow::{bail, Context, Result};
5
6pub fn nix_experimental_args() -> [&'static str; 2] {
7 ["--extra-experimental-features", "nix-command flakes"]
8}
9
10fn rebuild_experimental_args(platform: crate::discover::Platform) -> &'static [&'static str] {
11 match platform {
12 crate::discover::Platform::Darwin => {
13 &["--option", "experimental-features", "nix-command flakes"]
14 }
15 crate::discover::Platform::Linux => {
16 &["--extra-experimental-features", "nix-command flakes"]
17 }
18 }
19}
20
21pub fn nix_command() -> Command {
22 let mut command = Command::new(find_nix());
23 command.args(nix_experimental_args());
24 command
25}
26
27pub fn find_nix() -> String {
31 let candidates = [
32 "/nix/var/nix/profiles/default/bin/nix",
33 "/run/current-system/sw/bin/nix",
34 "/etc/profiles/per-user/default/bin/nix",
35 ];
36 for path in &candidates {
37 if Path::new(path).exists() {
38 tracing::debug!(path, "resolved nix binary");
39 return path.to_string();
40 }
41 }
42
43 if let Ok(output) = Command::new("nix").arg("--version").output() {
45 if output.status.success() {
46 tracing::debug!(path = "nix", "resolved nix binary");
47 return "nix".to_string();
48 }
49 }
50
51 tracing::debug!(path = "nix", "resolved nix binary");
53 "nix".to_string()
54}
55
56pub fn captured_text(bytes: &[u8]) -> String {
58 crate::ansi::sanitize_terminal_capture(&String::from_utf8_lossy(bytes))
59}
60
61fn run(cmd: &mut Command) -> Result<()> {
63 let program = cmd.get_program().to_string_lossy().to_string();
64 tracing::debug!(program = %program, "running command");
65 match cmd.status() {
66 Ok(status) if status.success() => Ok(()),
67 Ok(status) => {
68 tracing::error!(program = %program, code = ?status.code(), "command failed");
69 bail!(
70 "{program} failed with exit code {}",
71 status.code().unwrap_or(-1)
72 )
73 }
74 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
75 bail!(
76 "{program} not found — is it installed and in your PATH?\n\
77 hint: if you haven't run darwin-rebuild yet, see: nex.styrene.io"
78 )
79 }
80 Err(e) => Err(e).with_context(|| format!("failed to run {program}")),
81 }
82}
83
84pub fn git_commit(repo: &Path, message: &str) {
87 tracing::debug!(repo = %repo.display(), %message, "git commit");
88 let add = Command::new("git")
89 .args(["add", "-A"])
90 .current_dir(repo)
91 .output();
92
93 match add {
94 Ok(o) if o.status.success() => {}
95 Ok(o) => {
96 tracing::warn!(
97 exit_code = o.status.code().unwrap_or(-1),
98 stderr = %captured_text(&o.stderr).trim(),
99 "git add failed"
100 );
101 return;
102 }
103 Err(e) => {
104 tracing::warn!(error = %e, "could not run git add");
105 return;
106 }
107 }
108
109 let commit = Command::new("git")
110 .args(["commit", "-m", message])
111 .current_dir(repo)
112 .output();
113
114 match commit {
115 Ok(o) if o.status.success() => {}
116 Ok(o) => {
117 let stderr = captured_text(&o.stderr);
118 if !stderr.contains("nothing to commit") {
120 tracing::warn!(
121 stderr = %stderr.trim(),
122 "git commit failed — please commit manually"
123 );
124 }
125 }
126 Err(e) => {
127 tracing::warn!(error = %e, "could not run git commit");
128 }
129 }
130}
131
132pub fn nix_eval_exists(pkg: &str) -> Result<bool> {
134 tracing::debug!(%pkg, "checking nixpkgs existence");
135 let output = nix_command()
136 .args(["eval", &format!("nixpkgs#{pkg}.name"), "--raw"])
137 .stderr(std::process::Stdio::null())
138 .output()
139 .context("failed to run nix eval")?;
140 Ok(output.status.success())
141}
142
143pub fn nix_eval_version(pkg: &str) -> Result<Option<String>> {
145 tracing::debug!(%pkg, "querying nixpkgs version");
146 let output = nix_command()
147 .args(["eval", &format!("nixpkgs#{pkg}.version"), "--raw"])
148 .stderr(std::process::Stdio::null())
149 .output()
150 .context("failed to run nix eval")?;
151 if output.status.success() {
152 let version = captured_text(&output.stdout).trim().to_string();
153 if version.is_empty() {
154 Ok(None)
155 } else {
156 Ok(Some(version))
157 }
158 } else {
159 Ok(None)
160 }
161}
162
163pub fn brew_available() -> bool {
165 Command::new("brew")
166 .arg("--version")
167 .stdout(std::process::Stdio::null())
168 .stderr(std::process::Stdio::null())
169 .status()
170 .map(|s| s.success())
171 .unwrap_or(false)
172}
173
174pub fn brew_cask_info(pkg: &str) -> Result<Option<String>> {
176 let output = Command::new("brew")
177 .args(["info", "--json=v2", "--cask", pkg])
178 .stderr(std::process::Stdio::null())
179 .output();
180
181 let output = match output {
182 Ok(o) => o,
183 Err(_) => return Ok(None), };
185
186 if !output.status.success() {
187 return Ok(None);
188 }
189
190 let json: serde_json::Value =
191 serde_json::from_slice(&output.stdout).unwrap_or(serde_json::Value::Null);
192
193 let version = json
194 .get("casks")
195 .and_then(|c| c.get(0))
196 .and_then(|c| c.get("version"))
197 .and_then(|v| v.as_str())
198 .map(String::from);
199
200 Ok(version)
201}
202
203pub fn brew_formula_info(pkg: &str) -> Result<Option<String>> {
205 let output = Command::new("brew")
206 .args(["info", "--json=v2", "--formula", pkg])
207 .stderr(std::process::Stdio::null())
208 .output();
209
210 let output = match output {
211 Ok(o) => o,
212 Err(_) => return Ok(None),
213 };
214
215 if !output.status.success() {
216 return Ok(None);
217 }
218
219 let json: serde_json::Value =
220 serde_json::from_slice(&output.stdout).unwrap_or(serde_json::Value::Null);
221
222 let version = json
223 .get("formulae")
224 .and_then(|f| f.get(0))
225 .and_then(|f| f.get("versions"))
226 .and_then(|v| v.get("stable"))
227 .and_then(|v| v.as_str())
228 .map(String::from);
229
230 Ok(version)
231}
232
233pub fn brew_leaves() -> Result<Vec<String>> {
235 let output = Command::new("brew")
236 .arg("leaves")
237 .stderr(std::process::Stdio::null())
238 .output();
239
240 let output = match output {
241 Ok(o) if o.status.success() => o,
242 _ => return Ok(Vec::new()),
243 };
244
245 Ok(captured_text(&output.stdout)
246 .lines()
247 .filter(|l| !l.is_empty())
248 .map(String::from)
249 .collect())
250}
251
252pub fn brew_list_casks() -> Result<Vec<String>> {
254 let output = Command::new("brew")
255 .args(["list", "--cask"])
256 .stderr(std::process::Stdio::null())
257 .output();
258
259 let output = match output {
260 Ok(o) if o.status.success() => o,
261 _ => return Ok(Vec::new()),
262 };
263
264 Ok(captured_text(&output.stdout)
265 .lines()
266 .filter(|l| !l.is_empty())
267 .map(String::from)
268 .collect())
269}
270
271pub fn nix_search(query: &str) -> Result<()> {
273 run(nix_command().args(["search", "nixpkgs", query]))
274}
275
276fn test_mode() -> bool {
277 std::env::var_os("NEX_TESTING").is_some()
278}
279
280fn find_darwin_rebuild() -> Result<String> {
283 if test_mode() {
284 return Ok("darwin-rebuild".to_string());
285 }
286
287 let candidates = [
289 "/run/current-system/sw/bin/darwin-rebuild",
290 "/nix/var/nix/profiles/system/sw/bin/darwin-rebuild",
291 ];
292 for path in &candidates {
293 if std::path::Path::new(path).exists() {
294 return Ok(path.to_string());
295 }
296 }
297
298 if let Ok(output) = Command::new("darwin-rebuild").arg("--help").output() {
300 if output.status.success() {
301 return Ok("darwin-rebuild".to_string());
302 }
303 }
304
305 bail!(
306 "darwin-rebuild not found — is nix-darwin activated?\n\
307 hint: run `nex init` first, or see nex.styrene.io"
308 )
309}
310
311fn find_nixos_rebuild() -> Result<String> {
314 if test_mode() {
315 return Ok("nixos-rebuild".to_string());
316 }
317
318 let candidates = [
319 "/run/current-system/sw/bin/nixos-rebuild",
320 "/nix/var/nix/profiles/system/sw/bin/nixos-rebuild",
321 ];
322 for path in &candidates {
323 if std::path::Path::new(path).exists() {
324 return Ok(path.to_string());
325 }
326 }
327
328 if let Ok(output) = Command::new("nixos-rebuild").arg("--help").output() {
330 if output.status.success() {
331 return Ok("nixos-rebuild".to_string());
332 }
333 }
334
335 bail!(
336 "nixos-rebuild not found — is NixOS installed?\n\
337 hint: run `nex init` first, or see nex.styrene.io"
338 )
339}
340
341pub fn darwin_rebuild_switch(repo: &Path, hostname: &str) -> Result<()> {
345 tracing::info!(repo = %repo.display(), %hostname, "system rebuild switch");
346 ensure_profile_dirs();
347 let dr = find_darwin_rebuild()?;
348 let flake = format!(".#{hostname}");
349 run(Command::new("sudo")
350 .arg(&dr)
351 .arg("switch")
352 .args(rebuild_experimental_args(crate::discover::Platform::Darwin))
353 .args(["--flake", &flake])
354 .current_dir(repo))?;
355 refresh_app_icons();
356 Ok(())
357}
358
359pub fn nixos_rebuild_switch(repo: &Path, hostname: &str) -> Result<()> {
361 tracing::info!(repo = %repo.display(), %hostname, "system rebuild switch");
362 ensure_profile_dirs();
363 let nr = find_nixos_rebuild()?;
364 let flake = format!(".#{hostname}");
365 run(Command::new("sudo")
366 .arg(&nr)
367 .arg("switch")
368 .args(rebuild_experimental_args(crate::discover::Platform::Linux))
369 .args(["--flake", &flake])
370 .current_dir(repo))
371}
372
373pub fn system_rebuild_switch(
375 repo: &Path,
376 hostname: &str,
377 platform: crate::discover::Platform,
378) -> Result<()> {
379 match platform {
380 crate::discover::Platform::Darwin => darwin_rebuild_switch(repo, hostname),
381 crate::discover::Platform::Linux => nixos_rebuild_switch(repo, hostname),
382 }
383}
384
385pub fn ensure_profile_dirs() {
390 tracing::debug!("ensuring profile directories");
391 let user = std::env::var("USER")
392 .or_else(|_| std::env::var("LOGNAME"))
393 .unwrap_or_default();
394 if user.is_empty() {
395 eprintln!(" warning: USER not set, skipping profile directory setup");
396 return;
397 }
398
399 if let Some(home) = dirs::home_dir() {
401 let dot_local = home.join(".local");
402 if dot_local.exists() {
403 if let Ok(meta) = std::fs::metadata(&dot_local) {
404 use std::os::unix::fs::MetadataExt;
405 if meta.uid() == 0 {
406 if let Ok(s) = Command::new("sudo")
407 .args(["chown", "-R", &user, &dot_local.to_string_lossy()])
408 .status()
409 {
410 if !s.success() {
411 eprintln!(
412 " warning: failed to fix ownership of {}",
413 dot_local.display()
414 );
415 }
416 }
417 }
418 }
419 }
420 }
421
422 let dirs = [
423 format!("/nix/var/nix/profiles/per-user/{user}"),
424 dirs::home_dir()
425 .map(|h| {
426 h.join(".local/state/nix/profiles")
427 .to_string_lossy()
428 .into_owned()
429 })
430 .unwrap_or_default(),
431 ];
432
433 for dir in &dirs {
434 if dir.is_empty() {
435 continue;
436 }
437 let path = Path::new(dir);
438 if path.exists() {
439 continue;
440 }
441 if dir.starts_with("/nix/") {
442 let ok = Command::new("sudo")
443 .args(["mkdir", "-p", dir])
444 .status()
445 .map(|s| s.success())
446 .unwrap_or(false)
447 && Command::new("sudo")
448 .args(["chown", &user, dir])
449 .status()
450 .map(|s| s.success())
451 .unwrap_or(false);
452 if !ok {
453 eprintln!(" warning: failed to create {dir}");
454 }
455 } else if std::fs::create_dir_all(path).is_err() {
456 eprintln!(" warning: failed to create {dir}");
457 }
458 }
459}
460
461fn refresh_app_icons() {
463 let lsregister = "/System/Library/Frameworks/CoreServices.framework/\
464 Frameworks/LaunchServices.framework/Support/lsregister";
465
466 if !Path::new(lsregister).exists() {
467 return;
468 }
469
470 let app_dirs: Vec<std::path::PathBuf> = [
471 dirs::home_dir().map(|h| h.join("Applications/Home Manager Apps")),
472 Some(std::path::PathBuf::from("/Applications/Nix Apps")),
473 ]
474 .into_iter()
475 .flatten()
476 .filter(|d| d.exists())
477 .collect();
478
479 if app_dirs.is_empty() {
480 return;
481 }
482
483 for dir in &app_dirs {
484 let entries = match std::fs::read_dir(dir) {
485 Ok(e) => e,
486 Err(_) => continue,
487 };
488 for entry in entries.flatten() {
489 let path = entry.path();
490 if path.extension().and_then(|e| e.to_str()) == Some("app") {
491 let _ = Command::new(lsregister).args(["-f"]).arg(&path).output();
492 }
493 }
494 }
495}
496
497pub fn darwin_rebuild_build(repo: &Path, hostname: &str) -> Result<()> {
499 let dr = find_darwin_rebuild()?;
500 let flake = format!(".#{hostname}");
501 run(Command::new(&dr)
502 .arg("build")
503 .args(rebuild_experimental_args(crate::discover::Platform::Darwin))
504 .args(["--flake", &flake])
505 .current_dir(repo))
506}
507
508pub fn nixos_rebuild_build(repo: &Path, hostname: &str) -> Result<()> {
510 let nr = find_nixos_rebuild()?;
511 let flake = format!(".#{hostname}");
512 run(Command::new(&nr)
513 .arg("build")
514 .args(rebuild_experimental_args(crate::discover::Platform::Linux))
515 .args(["--flake", &flake])
516 .current_dir(repo))
517}
518
519pub fn system_rebuild_build(
521 repo: &Path,
522 hostname: &str,
523 platform: crate::discover::Platform,
524) -> Result<()> {
525 match platform {
526 crate::discover::Platform::Darwin => darwin_rebuild_build(repo, hostname),
527 crate::discover::Platform::Linux => nixos_rebuild_build(repo, hostname),
528 }
529}
530
531pub fn darwin_rebuild_rollback(repo: &Path, hostname: &str) -> Result<()> {
533 let dr = find_darwin_rebuild()?;
534 let flake = format!(".#{hostname}");
535 run(Command::new("sudo")
536 .arg(&dr)
537 .arg("switch")
538 .args(rebuild_experimental_args(crate::discover::Platform::Darwin))
539 .args(["--rollback", "--flake", &flake])
540 .current_dir(repo))
541}
542
543pub fn nixos_rebuild_rollback(repo: &Path, hostname: &str) -> Result<()> {
545 let nr = find_nixos_rebuild()?;
546 let flake = format!(".#{hostname}");
547 run(Command::new("sudo")
548 .arg(&nr)
549 .arg("switch")
550 .args(rebuild_experimental_args(crate::discover::Platform::Linux))
551 .args(["--rollback", "--flake", &flake])
552 .current_dir(repo))
553}
554
555pub fn system_rebuild_rollback(
557 repo: &Path,
558 hostname: &str,
559 platform: crate::discover::Platform,
560) -> Result<()> {
561 match platform {
562 crate::discover::Platform::Darwin => darwin_rebuild_rollback(repo, hostname),
563 crate::discover::Platform::Linux => nixos_rebuild_rollback(repo, hostname),
564 }
565}
566
567pub fn nix_flake_update(repo: &Path) -> Result<()> {
569 run(nix_command().args(["flake", "update"]).current_dir(repo))
570}
571
572pub fn nix_shell(pkg: &str) -> Result<()> {
574 run(nix_command().args(["shell", &format!("nixpkgs#{pkg}")]))
575}
576
577pub fn nix_diff_closures(repo: &Path) -> Result<()> {
579 run(nix_command()
580 .args([
581 "store",
582 "diff-closures",
583 "/nix/var/nix/profiles/system",
584 "./result",
585 ])
586 .current_dir(repo))
587}
588
589pub fn nix_gc() -> Result<()> {
591 let nix = find_nix();
592 run(nix_command().args(["store", "gc"]))?;
593 let nix_path = std::path::PathBuf::from(&nix);
595 if let Some(bin_dir) = nix_path.parent() {
596 let gc_bin = bin_dir.join("nix-collect-garbage");
597 if gc_bin.exists() {
598 run(Command::new(&gc_bin).args(["-d"]))?;
599 }
600 }
601 Ok(())
602}
603
604#[cfg(test)]
605mod tests {
606 use super::rebuild_experimental_args;
607
608 #[test]
609 fn darwin_rebuild_uses_supported_experimental_feature_option() {
610 assert_eq!(
611 rebuild_experimental_args(crate::discover::Platform::Darwin),
612 ["--option", "experimental-features", "nix-command flakes"]
613 );
614 }
615
616 #[test]
617 fn nixos_rebuild_keeps_nixos_rebuild_experimental_feature_flag() {
618 assert_eq!(
619 rebuild_experimental_args(crate::discover::Platform::Linux),
620 ["--extra-experimental-features", "nix-command flakes"]
621 );
622 }
623}