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
61pub 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}