1use std::fs;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use tempfile::NamedTempFile;
6
7use crate::nixfile::NixList;
8
9fn find_list_range(lines: &[String], list: &NixList) -> Result<(usize, usize)> {
11 tracing::trace!(open_line = list.open_line, "searching for list range");
12 let open = lines
13 .iter()
14 .position(|l| l.trim_start().starts_with(list.open_line))
15 .context(format!("could not find list opening: {}", list.open_line))?;
16
17 let open_indent = lines[open].len() - lines[open].trim_start().len();
20 let close = lines
21 .iter()
22 .enumerate()
23 .skip(open + 1)
24 .find(|(_, l)| {
25 let trimmed = l.trim_start();
26 let indent = l.len() - trimmed.len();
27 trimmed.starts_with(list.close_line) && indent <= open_indent
28 })
29 .map(|(i, _)| i)
30 .context(format!(
31 "could not find list closing for: {}",
32 list.open_line
33 ))?;
34
35 Ok((open, close))
36}
37
38pub fn contains(path: &Path, list: &NixList, pkg: &str) -> Result<bool> {
40 let content =
41 fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
42 let lines: Vec<String> = content.lines().map(String::from).collect();
43
44 let (open, close) = match find_list_range(&lines, list) {
45 Ok(range) => range,
46 Err(_) => return Ok(false),
47 };
48
49 for line in &lines[open + 1..close] {
50 if let Some(name) = list.parse_item(line) {
51 if name == pkg {
52 return Ok(true);
53 }
54 }
55 }
56 Ok(false)
57}
58
59fn validate_pkg_name(pkg: &str) -> Result<()> {
62 if pkg.is_empty() {
63 anyhow::bail!("package name cannot be empty");
64 }
65 for ch in pkg.chars() {
66 if !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') {
67 anyhow::bail!(
68 "invalid character '{}' in package name \"{}\": \
69 only alphanumeric, hyphen, and underscore are allowed",
70 ch,
71 pkg
72 );
73 }
74 }
75 Ok(())
76}
77
78pub fn insert(path: &Path, list: &NixList, pkg: &str) -> Result<bool> {
80 validate_pkg_name(pkg)?;
81 tracing::debug!(path = %path.display(), pkg, "inserting package");
82 let content =
83 fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
84 let mut lines: Vec<String> = content.lines().map(String::from).collect();
85
86 let (open, close) = find_list_range(&lines, list)?;
87
88 for line in &lines[open + 1..close] {
90 if let Some(name) = list.parse_item(line) {
91 if name == pkg {
92 tracing::debug!(pkg, "duplicate detected, skipping insert");
93 return Ok(false);
94 }
95 }
96 }
97
98 let new_line = list.format_item(pkg);
100 lines.insert(close, new_line);
101
102 atomic_write(path, &lines).with_context(|| format!("writing {}", path.display()))?;
103
104 Ok(true)
105}
106
107pub fn remove(path: &Path, list: &NixList, pkg: &str) -> Result<bool> {
109 validate_pkg_name(pkg)?;
110 tracing::debug!(path = %path.display(), pkg, "removing package");
111 let content =
112 fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
113 let mut lines: Vec<String> = content.lines().map(String::from).collect();
114
115 let (open, close) = find_list_range(&lines, list)?;
116
117 let found = lines[open + 1..close]
118 .iter()
119 .enumerate()
120 .find(|(_, line)| list.parse_item(line).is_some_and(|name| name == pkg))
121 .map(|(i, _)| open + 1 + i);
122
123 match found {
124 Some(idx) => {
125 lines.remove(idx);
126 atomic_write(path, &lines).with_context(|| format!("writing {}", path.display()))?;
127 Ok(true)
128 }
129 None => Ok(false),
130 }
131}
132
133pub fn contains_any(path: &Path, list: &NixList, names: &[&str]) -> Result<Option<String>> {
137 let content =
138 fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
139 let lines: Vec<String> = content.lines().map(String::from).collect();
140
141 let (open, close) = match find_list_range(&lines, list) {
142 Ok(range) => range,
143 Err(_) => return Ok(None),
144 };
145
146 for line in &lines[open + 1..close] {
147 if let Some(name) = list.parse_item(line) {
148 if names.contains(&name.as_str()) {
149 return Ok(Some(name));
150 }
151 }
152 }
153 Ok(None)
154}
155
156pub fn list_packages(path: &Path, list: &NixList) -> Result<Vec<String>> {
159 let content =
160 fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
161 let lines: Vec<String> = content.lines().map(String::from).collect();
162
163 let (open, close) = match find_list_range(&lines, list) {
164 Ok(range) => range,
165 Err(_) => return Ok(Vec::new()),
166 };
167
168 let mut pkgs = Vec::new();
169 for line in &lines[open + 1..close] {
170 if let Some(name) = list.parse_item(line) {
171 pkgs.push(name);
172 }
173 }
174 Ok(pkgs)
175}
176
177fn atomic_write(path: &Path, lines: &[String]) -> Result<()> {
179 tracing::trace!(path = %path.display(), "atomic write");
180 let dir = path.parent().context("file has no parent directory")?;
181 let content = lines.join("\n") + "\n";
182
183 let mut tmp = NamedTempFile::new_in(dir)?;
184 std::io::Write::write_all(&mut tmp, content.as_bytes())?;
185 tmp.as_file().sync_all()?;
186 tmp.persist(path)?;
187 Ok(())
188}
189
190pub fn atomic_write_bytes(path: &Path, content: &[u8]) -> Result<()> {
193 tracing::trace!(path = %path.display(), "atomic write bytes");
194 let dir = path.parent().context("file has no parent directory")?;
195 let mut tmp = NamedTempFile::new_in(dir)?;
196 std::io::Write::write_all(&mut tmp, content)?;
197 tmp.as_file().sync_all()?;
198 tmp.persist(path)?;
199 Ok(())
200}
201
202pub fn backup(path: &Path) -> Result<std::path::PathBuf> {
204 let backup_path = path.with_extension("nix.nex-backup");
205 tracing::debug!(path = %path.display(), backup = %backup_path.display(), "backing up file");
206 let dir = path.parent().context("file has no parent directory")?;
207 let content = fs::read(path).with_context(|| format!("reading {}", path.display()))?;
208 let mut tmp = NamedTempFile::new_in(dir)?;
209 std::io::Write::write_all(&mut tmp, &content)?;
210 tmp.as_file().sync_all()?;
211 tmp.persist(&backup_path)
212 .with_context(|| format!("backing up {}", path.display()))?;
213 Ok(backup_path)
214}
215
216pub fn restore(path: &Path, backup_path: &Path) -> Result<()> {
218 tracing::debug!(path = %path.display(), backup = %backup_path.display(), "restoring from backup");
219 if !backup_path.exists() {
220 anyhow::bail!(
221 "backup file missing for {}: expected {}",
222 path.display(),
223 backup_path.display()
224 );
225 }
226 fs::rename(backup_path, path).with_context(|| format!("restoring {}", path.display()))?;
227 Ok(())
228}
229
230pub fn delete_backup(backup_path: &Path) -> Result<()> {
232 if backup_path.exists() {
233 fs::remove_file(backup_path)?;
234 }
235 Ok(())
236}
237
238pub struct EditSession {
240 backups: Vec<(std::path::PathBuf, std::path::PathBuf)>, }
242
243impl Default for EditSession {
244 fn default() -> Self {
245 Self::new()
246 }
247}
248
249impl EditSession {
250 pub fn new() -> Self {
251 Self {
252 backups: Vec::new(),
253 }
254 }
255
256 pub fn backup(&mut self, path: &Path) -> Result<()> {
258 if self.backups.iter().any(|(p, _)| p == path) {
259 return Ok(());
260 }
261 let bp = backup(path)?;
262 self.backups.push((path.to_path_buf(), bp));
263 Ok(())
264 }
265
266 pub fn revert_all(&self) -> Result<()> {
268 tracing::warn!(count = self.backups.len(), "reverting all edits");
269 let mut errors = Vec::new();
270 for (original, bp) in &self.backups {
271 if let Err(e) = restore(original, bp) {
272 errors.push(format!("{}: {e}", original.display()));
273 }
274 }
275 if errors.is_empty() {
276 Ok(())
277 } else {
278 anyhow::bail!("failed to revert some files:\n {}", errors.join("\n "))
279 }
280 }
281
282 pub fn commit_all(&self) -> Result<()> {
284 tracing::debug!(count = self.backups.len(), "committing all edits");
285 for (_, bp) in &self.backups {
286 delete_backup(bp)?;
287 }
288 Ok(())
289 }
290
291 #[allow(dead_code)]
292 pub fn has_changes(&self) -> bool {
293 !self.backups.is_empty()
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use crate::nixfile;
301 use tempfile::TempDir;
302
303 fn write_fixture(dir: &Path, name: &str, content: &str) -> std::path::PathBuf {
304 let path = dir.join(name);
305 fs::write(&path, content).expect("write fixture");
306 path
307 }
308
309 const BASE_NIX: &str = r#"{ pkgs, username, ... }:
310
311{
312 home.packages = with pkgs; [
313 ## Shell
314 bash
315 git
316 vim
317 ];
318}
319"#;
320
321 const BREW_NIX: &str = r#"{ ... }:
322
323{
324 homebrew = {
325 brews = [
326 "rustup"
327 "esptool"
328 ];
329
330 casks = [
331 "firefox"
332 "kitty"
333 ];
334 };
335}
336"#;
337
338 #[test]
339 fn test_contains_bare() {
340 let dir = TempDir::new().expect("tmpdir");
341 let path = write_fixture(dir.path(), "base.nix", BASE_NIX);
342 assert!(contains(&path, &nixfile::NIX_PACKAGES, "bash").expect("contains"));
343 assert!(contains(&path, &nixfile::NIX_PACKAGES, "vim").expect("contains"));
344 assert!(!contains(&path, &nixfile::NIX_PACKAGES, "htop").expect("contains"));
345 }
346
347 #[test]
348 fn test_contains_quoted() {
349 let dir = TempDir::new().expect("tmpdir");
350 let path = write_fixture(dir.path(), "brew.nix", BREW_NIX);
351 assert!(contains(&path, &nixfile::HOMEBREW_BREWS, "rustup").expect("contains"));
352 assert!(!contains(&path, &nixfile::HOMEBREW_BREWS, "qemu").expect("contains"));
353 assert!(contains(&path, &nixfile::HOMEBREW_CASKS, "firefox").expect("contains"));
354 assert!(!contains(&path, &nixfile::HOMEBREW_CASKS, "slack").expect("contains"));
355 }
356
357 #[test]
358 fn test_insert_bare() {
359 let dir = TempDir::new().expect("tmpdir");
360 let path = write_fixture(dir.path(), "base.nix", BASE_NIX);
361 assert!(insert(&path, &nixfile::NIX_PACKAGES, "htop").expect("insert"));
362 assert!(contains(&path, &nixfile::NIX_PACKAGES, "htop").expect("contains"));
363 assert!(!insert(&path, &nixfile::NIX_PACKAGES, "htop").expect("insert dup"));
365 }
366
367 #[test]
368 fn test_insert_quoted() {
369 let dir = TempDir::new().expect("tmpdir");
370 let path = write_fixture(dir.path(), "brew.nix", BREW_NIX);
371 assert!(insert(&path, &nixfile::HOMEBREW_CASKS, "slack").expect("insert"));
372 assert!(contains(&path, &nixfile::HOMEBREW_CASKS, "slack").expect("contains"));
373 }
374
375 #[test]
376 fn test_remove_bare() {
377 let dir = TempDir::new().expect("tmpdir");
378 let path = write_fixture(dir.path(), "base.nix", BASE_NIX);
379 assert!(remove(&path, &nixfile::NIX_PACKAGES, "vim").expect("remove"));
380 assert!(!contains(&path, &nixfile::NIX_PACKAGES, "vim").expect("contains"));
381 assert!(!remove(&path, &nixfile::NIX_PACKAGES, "vim").expect("remove again"));
383 }
384
385 #[test]
386 fn test_remove_quoted() {
387 let dir = TempDir::new().expect("tmpdir");
388 let path = write_fixture(dir.path(), "brew.nix", BREW_NIX);
389 assert!(remove(&path, &nixfile::HOMEBREW_BREWS, "esptool").expect("remove"));
390 assert!(!contains(&path, &nixfile::HOMEBREW_BREWS, "esptool").expect("contains"));
391 }
392
393 #[test]
394 fn test_list_packages() {
395 let dir = TempDir::new().expect("tmpdir");
396 let path = write_fixture(dir.path(), "base.nix", BASE_NIX);
397 let pkgs = list_packages(&path, &nixfile::NIX_PACKAGES).expect("list");
398 assert_eq!(pkgs, vec!["bash", "git", "vim"]);
399 }
400
401 #[test]
402 fn test_list_packages_quoted() {
403 let dir = TempDir::new().expect("tmpdir");
404 let path = write_fixture(dir.path(), "brew.nix", BREW_NIX);
405 let brews = list_packages(&path, &nixfile::HOMEBREW_BREWS).expect("list");
406 assert_eq!(brews, vec!["rustup", "esptool"]);
407 let casks = list_packages(&path, &nixfile::HOMEBREW_CASKS).expect("list");
408 assert_eq!(casks, vec!["firefox", "kitty"]);
409 }
410
411 #[test]
412 fn test_edit_session_revert() {
413 let dir = TempDir::new().expect("tmpdir");
414 let path = write_fixture(dir.path(), "base.nix", BASE_NIX);
415
416 let mut session = EditSession::new();
417 session.backup(&path).expect("backup");
418
419 insert(&path, &nixfile::NIX_PACKAGES, "htop").expect("insert");
420 assert!(contains(&path, &nixfile::NIX_PACKAGES, "htop").expect("contains"));
421
422 session.revert_all().expect("revert");
423 assert!(!contains(&path, &nixfile::NIX_PACKAGES, "htop").expect("contains after revert"));
424 }
425}