Skip to main content

check_updates/
changes.rs

1use std::fs;
2
3use anyhow::{Context, Result, anyhow, ensure};
4use similar::TextDiff;
5use toml_edit::{Document, Item, Value};
6
7use crate::dependencies::{Dependency, Update, apply_update};
8use crate::workspace::Project;
9
10#[derive(Debug)]
11pub struct Change<'a> {
12    pub project: &'a Project,
13    pub content: Vec<u8>,
14    pub count: usize,
15}
16
17fn value_at<'a>(document: &'a Item, dependency: &Dependency) -> Result<&'a Value> {
18    let mut item = document;
19    for key in &dependency.section {
20        item = item
21            .get(key)
22            .ok_or_else(|| anyhow!("Dependency section no longer exists"))?;
23    }
24    item.as_array()
25        .and_then(|array| array.get(dependency.index))
26        .context("Dependency occurrence no longer exists")
27}
28
29pub fn prepare_change<'a>(project: &'a Project, updates: &[Update]) -> Result<Change<'a>> {
30    let original = std::str::from_utf8(&project.original)?;
31    let parsed = Document::parse(original)?;
32    let mut document = project.document.clone();
33    let mut replacements = Vec::new();
34    for update in updates {
35        let span = value_at(parsed.as_item(), &update.dependency)?
36            .span()
37            .context("Missing dependency source span")?;
38        apply_update(&mut document, update)?;
39        let mut value = value_at(document.as_item(), &update.dependency)?.clone();
40        value.decor_mut().clear();
41        replacements.push((span, value.to_string()));
42    }
43    replacements.sort_by_key(|(span, _)| span.start);
44    ensure!(
45        replacements
46            .windows(2)
47            .all(|pair| pair[0].0.end <= pair[1].0.start),
48        "Duplicate dependency update"
49    );
50    let mut content = original.to_owned();
51    for (span, replacement) in replacements.into_iter().rev() {
52        content.replace_range(span, &replacement);
53    }
54    Ok(Change {
55        project,
56        content: content.into_bytes(),
57        count: updates.len(),
58    })
59}
60
61/// Escape terminal controls without changing bytes written to the project.
62pub fn safe_text(value: &str, multiline: bool) -> String {
63    let mut output = String::new();
64    for character in value.chars() {
65        if !character.is_control() || (multiline && matches!(character, '\n' | '\t')) {
66            output.push(character);
67        } else if (character as u32) < 256 {
68            output.push_str(&format!("\\x{:02x}", character as u32));
69        } else {
70            output.extend(character.escape_default());
71        }
72    }
73    output
74}
75
76pub fn preview_diff(change: &Change<'_>) -> String {
77    let before = String::from_utf8_lossy(&change.project.original).replace("\r\n", "\n");
78    let after = String::from_utf8_lossy(&change.content).replace("\r\n", "\n");
79    let path = change.project.path.to_string_lossy();
80    safe_text(
81        &TextDiff::from_lines(&before, &after)
82            .unified_diff()
83            .header(&path, &path)
84            .to_string(),
85        true,
86    )
87}
88
89pub fn check_snapshots(changes: &[Change<'_>]) -> Result<()> {
90    for change in changes {
91        let path = &change.project.path;
92        let current = fs::read(path)
93            .with_context(|| format!("Cannot read {} before writing", path.display()))?;
94        ensure!(
95            current == change.project.original,
96            "{} changed during review. No files were written; run the command again.",
97            path.display()
98        );
99    }
100    Ok(())
101}
102
103pub fn write_changes(changes: &[Change<'_>]) -> Result<()> {
104    check_snapshots(changes)?;
105    for change in changes {
106        crate::check_interrupt()?;
107        fs::write(&change.project.path, &change.content).with_context(|| {
108            format!(
109                "Cannot write {}. Earlier successful updates remain; review affected files.",
110                change.project.path.display()
111            )
112        })?;
113        println!(
114            "Updated {} dependencies in {}.",
115            change.count,
116            safe_text(&change.project.path.to_string_lossy(), false)
117        );
118    }
119    Ok(())
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use crate::dependencies::dependencies;
126    use crate::workspace::read_project;
127
128    #[test]
129    fn edits_only_selected_occurrence_and_preserves_source_bytes() {
130        for newline in ["\n", "\r\n"] {
131            for quote in ["\"", "'", "\"\"\"", "'''"] {
132                let temp = tempfile::tempdir().unwrap();
133                let path = temp.path().join("pyproject.toml");
134                let original = format!("[project]\ndependencies = [\"demo>=1.0\", {quote}demo>=1.0{quote}] # keep\n[dependency-groups]\ndev = [{{include-group = 'other'}}, 'demo>=1.0']").replace('\n', newline);
135                fs::write(&path, &original).unwrap();
136                let project = read_project(&path).unwrap();
137                let update = Update {
138                    dependency: dependencies(&project.document)[1].clone(),
139                    replacement: "demo>=2.0".into(),
140                    version: "2.0".parse().unwrap(),
141                    release_link: None,
142                    project_link: None,
143                };
144                let change = prepare_change(&project, &[update]).unwrap();
145                let expected = original.replacen(
146                    &format!("{quote}demo>=1.0{quote}]"),
147                    &format!("{quote}demo>=2.0{quote}]"),
148                    1,
149                );
150                assert_eq!(change.content, expected.as_bytes());
151                assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
152                assert!(preview_diff(&change).contains("+dependencies ="));
153                check_snapshots(&[change]).unwrap();
154            }
155        }
156    }
157
158    #[test]
159    fn snapshot_failure_prevents_all_writes() {
160        let temp = tempfile::tempdir().unwrap();
161        let path = temp.path().join("pyproject.toml");
162        fs::write(&path, "[project]\ndependencies = []").unwrap();
163        let project = read_project(&path).unwrap();
164        let change = Change {
165            project: &project,
166            content: b"new".to_vec(),
167            count: 1,
168        };
169        fs::write(&path, "user edit").unwrap();
170        assert!(
171            write_changes(&[change])
172                .unwrap_err()
173                .to_string()
174                .contains("changed during review")
175        );
176        assert_eq!(fs::read_to_string(path).unwrap(), "user edit");
177    }
178
179    #[test]
180    fn diff_marks_missing_newlines_and_escapes_controls() {
181        let project = Project {
182            path: "\x1b[2Jpyproject.toml".into(),
183            document: Default::default(),
184            original: b"old\x07".to_vec(),
185        };
186        let change = Change {
187            project: &project,
188            content: b"new\x07".to_vec(),
189            count: 1,
190        };
191        let diff = preview_diff(&change);
192        assert!(!diff.contains('\x1b'));
193        assert!(diff.contains("\\x07"));
194        assert_eq!(diff.matches("\\ No newline at end of file").count(), 2);
195    }
196}