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 find_darwin_rebuild() -> Result<String> {
279 let candidates = [
281 "/run/current-system/sw/bin/darwin-rebuild",
282 "/nix/var/nix/profiles/system/sw/bin/darwin-rebuild",
283 ];
284 for path in &candidates {
285 if std::path::Path::new(path).exists() {
286 return Ok(path.to_string());
287 }
288 }
289
290 if let Ok(output) = Command::new("darwin-rebuild").arg("--help").output() {
292 if output.status.success() {
293 return Ok("darwin-rebuild".to_string());
294 }
295 }
296
297 bail!(
298 "darwin-rebuild not found — is nix-darwin activated?\n\
299 hint: run `nex init` first, or see nex.styrene.io"
300 )
301}
302
303fn find_nixos_rebuild() -> Result<String> {
306 let candidates = [
307 "/run/current-system/sw/bin/nixos-rebuild",
308 "/nix/var/nix/profiles/system/sw/bin/nixos-rebuild",
309 ];
310 for path in &candidates {
311 if std::path::Path::new(path).exists() {
312 return Ok(path.to_string());
313 }
314 }
315
316 if let Ok(output) = Command::new("nixos-rebuild").arg("--help").output() {
318 if output.status.success() {
319 return Ok("nixos-rebuild".to_string());
320 }
321 }
322
323 bail!(
324 "nixos-rebuild not found — is NixOS installed?\n\
325 hint: run `nex init` first, or see nex.styrene.io"
326 )
327}
328
329pub fn darwin_rebuild_switch(repo: &Path, hostname: &str) -> Result<()> {
333 tracing::info!(repo = %repo.display(), %hostname, "system rebuild switch");
334 ensure_profile_dirs();
335 let dr = find_darwin_rebuild()?;
336 let flake = format!(".#{hostname}");
337 run(Command::new("sudo")
338 .arg(&dr)
339 .arg("switch")
340 .args(rebuild_experimental_args(crate::discover::Platform::Darwin))
341 .args(["--flake", &flake])
342 .current_dir(repo))?;
343 refresh_app_icons();
344 Ok(())
345}
346
347pub fn nixos_rebuild_switch(repo: &Path, hostname: &str) -> Result<()> {
349 tracing::info!(repo = %repo.display(), %hostname, "system rebuild switch");
350 ensure_profile_dirs();
351 let nr = find_nixos_rebuild()?;
352 let flake = format!(".#{hostname}");
353 run(Command::new("sudo")
354 .arg(&nr)
355 .arg("switch")
356 .args(rebuild_experimental_args(crate::discover::Platform::Linux))
357 .args(["--flake", &flake])
358 .current_dir(repo))
359}
360
361pub fn system_rebuild_switch(
363 repo: &Path,
364 hostname: &str,
365 platform: crate::discover::Platform,
366) -> Result<()> {
367 match platform {
368 crate::discover::Platform::Darwin => darwin_rebuild_switch(repo, hostname),
369 crate::discover::Platform::Linux => nixos_rebuild_switch(repo, hostname),
370 }
371}
372
373pub fn ensure_profile_dirs() {
378 tracing::debug!("ensuring profile directories");
379 let user = std::env::var("USER")
380 .or_else(|_| std::env::var("LOGNAME"))
381 .unwrap_or_default();
382 if user.is_empty() {
383 eprintln!(" warning: USER not set, skipping profile directory setup");
384 return;
385 }
386
387 if let Some(home) = dirs::home_dir() {
389 let dot_local = home.join(".local");
390 if dot_local.exists() {
391 if let Ok(meta) = std::fs::metadata(&dot_local) {
392 use std::os::unix::fs::MetadataExt;
393 if meta.uid() == 0 {
394 if let Ok(s) = Command::new("sudo")
395 .args(["chown", "-R", &user, &dot_local.to_string_lossy()])
396 .status()
397 {
398 if !s.success() {
399 eprintln!(
400 " warning: failed to fix ownership of {}",
401 dot_local.display()
402 );
403 }
404 }
405 }
406 }
407 }
408 }
409
410 let dirs = [
411 format!("/nix/var/nix/profiles/per-user/{user}"),
412 dirs::home_dir()
413 .map(|h| {
414 h.join(".local/state/nix/profiles")
415 .to_string_lossy()
416 .into_owned()
417 })
418 .unwrap_or_default(),
419 ];
420
421 for dir in &dirs {
422 if dir.is_empty() {
423 continue;
424 }
425 let path = Path::new(dir);
426 if path.exists() {
427 continue;
428 }
429 if dir.starts_with("/nix/") {
430 let ok = Command::new("sudo")
431 .args(["mkdir", "-p", dir])
432 .status()
433 .map(|s| s.success())
434 .unwrap_or(false)
435 && Command::new("sudo")
436 .args(["chown", &user, dir])
437 .status()
438 .map(|s| s.success())
439 .unwrap_or(false);
440 if !ok {
441 eprintln!(" warning: failed to create {dir}");
442 }
443 } else if std::fs::create_dir_all(path).is_err() {
444 eprintln!(" warning: failed to create {dir}");
445 }
446 }
447}
448
449fn refresh_app_icons() {
451 let lsregister = "/System/Library/Frameworks/CoreServices.framework/\
452 Frameworks/LaunchServices.framework/Support/lsregister";
453
454 if !Path::new(lsregister).exists() {
455 return;
456 }
457
458 let app_dirs: Vec<std::path::PathBuf> = [
459 dirs::home_dir().map(|h| h.join("Applications/Home Manager Apps")),
460 Some(std::path::PathBuf::from("/Applications/Nix Apps")),
461 ]
462 .into_iter()
463 .flatten()
464 .filter(|d| d.exists())
465 .collect();
466
467 if app_dirs.is_empty() {
468 return;
469 }
470
471 for dir in &app_dirs {
472 let entries = match std::fs::read_dir(dir) {
473 Ok(e) => e,
474 Err(_) => continue,
475 };
476 for entry in entries.flatten() {
477 let path = entry.path();
478 if path.extension().and_then(|e| e.to_str()) == Some("app") {
479 let _ = Command::new(lsregister).args(["-f"]).arg(&path).output();
480 }
481 }
482 }
483}
484
485pub fn darwin_rebuild_build(repo: &Path, hostname: &str) -> Result<()> {
487 let dr = find_darwin_rebuild()?;
488 let flake = format!(".#{hostname}");
489 run(Command::new(&dr)
490 .arg("build")
491 .args(rebuild_experimental_args(crate::discover::Platform::Darwin))
492 .args(["--flake", &flake])
493 .current_dir(repo))
494}
495
496pub fn nixos_rebuild_build(repo: &Path, hostname: &str) -> Result<()> {
498 let nr = find_nixos_rebuild()?;
499 let flake = format!(".#{hostname}");
500 run(Command::new(&nr)
501 .arg("build")
502 .args(rebuild_experimental_args(crate::discover::Platform::Linux))
503 .args(["--flake", &flake])
504 .current_dir(repo))
505}
506
507pub fn system_rebuild_build(
509 repo: &Path,
510 hostname: &str,
511 platform: crate::discover::Platform,
512) -> Result<()> {
513 match platform {
514 crate::discover::Platform::Darwin => darwin_rebuild_build(repo, hostname),
515 crate::discover::Platform::Linux => nixos_rebuild_build(repo, hostname),
516 }
517}
518
519pub fn darwin_rebuild_rollback(repo: &Path, hostname: &str) -> Result<()> {
521 let dr = find_darwin_rebuild()?;
522 let flake = format!(".#{hostname}");
523 run(Command::new("sudo")
524 .arg(&dr)
525 .arg("switch")
526 .args(rebuild_experimental_args(crate::discover::Platform::Darwin))
527 .args(["--rollback", "--flake", &flake])
528 .current_dir(repo))
529}
530
531pub fn nixos_rebuild_rollback(repo: &Path, hostname: &str) -> Result<()> {
533 let nr = find_nixos_rebuild()?;
534 let flake = format!(".#{hostname}");
535 run(Command::new("sudo")
536 .arg(&nr)
537 .arg("switch")
538 .args(rebuild_experimental_args(crate::discover::Platform::Linux))
539 .args(["--rollback", "--flake", &flake])
540 .current_dir(repo))
541}
542
543pub fn system_rebuild_rollback(
545 repo: &Path,
546 hostname: &str,
547 platform: crate::discover::Platform,
548) -> Result<()> {
549 match platform {
550 crate::discover::Platform::Darwin => darwin_rebuild_rollback(repo, hostname),
551 crate::discover::Platform::Linux => nixos_rebuild_rollback(repo, hostname),
552 }
553}
554
555pub fn nix_flake_update(repo: &Path) -> Result<()> {
557 run(nix_command().args(["flake", "update"]).current_dir(repo))
558}
559
560pub fn nix_shell(pkg: &str) -> Result<()> {
562 run(nix_command().args(["shell", &format!("nixpkgs#{pkg}")]))
563}
564
565pub fn nix_diff_closures(repo: &Path) -> Result<()> {
567 run(nix_command()
568 .args([
569 "store",
570 "diff-closures",
571 "/nix/var/nix/profiles/system",
572 "./result",
573 ])
574 .current_dir(repo))
575}
576
577pub fn nix_gc() -> Result<()> {
579 let nix = find_nix();
580 run(nix_command().args(["store", "gc"]))?;
581 let nix_path = std::path::PathBuf::from(&nix);
583 if let Some(bin_dir) = nix_path.parent() {
584 let gc_bin = bin_dir.join("nix-collect-garbage");
585 if gc_bin.exists() {
586 run(Command::new(&gc_bin).args(["-d"]))?;
587 }
588 }
589 Ok(())
590}
591
592#[cfg(test)]
593mod tests {
594 use super::rebuild_experimental_args;
595
596 #[test]
597 fn darwin_rebuild_uses_supported_experimental_feature_option() {
598 assert_eq!(
599 rebuild_experimental_args(crate::discover::Platform::Darwin),
600 ["--option", "experimental-features", "nix-command flakes"]
601 );
602 }
603
604 #[test]
605 fn nixos_rebuild_keeps_nixos_rebuild_experimental_feature_flag() {
606 assert_eq!(
607 rebuild_experimental_args(crate::discover::Platform::Linux),
608 ["--extra-experimental-features", "nix-command flakes"]
609 );
610 }
611}