Skip to main content

nex_pkg/
edit.rs

1use std::fs;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5use tempfile::NamedTempFile;
6
7use crate::nixfile::NixList;
8
9/// Find the line range (start inclusive, end inclusive) of a NixList in the file lines.
10fn 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    // Walk forward from open to find the matching close.
18    // The close must be at the same or lesser indentation as the open line.
19    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
38/// Check whether a package is present in a list within the given file.
39pub 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
59/// Validate that a package name is safe to insert into a nix file.
60/// Rejects names with characters that could break nix syntax or enable injection.
61fn 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
78/// Insert a package into a list. Returns true if inserted, false if already present.
79pub 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    // Check for duplicate
89    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    // Insert before the closing line
99    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
107/// Remove a package from a list. Returns true if removed, false if not found.
108pub 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
133/// Check whether any of the given package names is present in a list.
134/// Reads the file once and checks all names in a single pass.
135/// Returns the matched name, or None if no match.
136pub 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
156/// List all package names in a list within the given file.
157/// Returns an empty vec if the list is not found (not every module has one).
158pub 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
177/// Write lines to a file atomically (temp file + fsync + rename).
178fn 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
190/// Write bytes to a file atomically (temp file + fsync + rename).
191/// Use this instead of `std::fs::write` for any file that must survive power loss.
192pub 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
202/// Back up a file before editing. Returns the backup path.
203pub 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
216/// Restore a file from its backup. Returns an error if the backup file is missing.
217pub 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
230/// Delete a backup file.
231pub fn delete_backup(backup_path: &Path) -> Result<()> {
232    if backup_path.exists() {
233        fs::remove_file(backup_path)?;
234    }
235    Ok(())
236}
237
238/// An edit session tracks backups for atomic multi-file operations.
239pub struct EditSession {
240    backups: Vec<(std::path::PathBuf, std::path::PathBuf)>, // (original, backup)
241}
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    /// Back up a file before editing. Idempotent per path.
257    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    /// Revert all edits by restoring backups.
267    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    /// Commit all edits by deleting backups.
283    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        // Duplicate returns false
364        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        // Remove non-existent returns false
382        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}