1mod content_security;
6mod features;
7mod password;
8
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use proc_macro2::LineColumn;
13use sofie_check::Finding;
14use sofie_model::Crate;
15
16use crate::check_command::check_app;
17use crate::gen_command::{GenError, io_error, regenerate};
18use crate::new_command::{dependency_line_start, workspace_declaration};
19use crate::source_edits::{DEPENDENCIES, Journal, read, toml_section, with_dependency_line};
20
21struct Codemod {
22 name: &'static str,
23 change: &'static str,
25 edits: fn(&str, &syn::File) -> Vec<Edit>,
26 needs: Option<&'static str>,
28}
29
30const CODEMODS: &[Codemod] = &[
31 Codemod {
32 name: "content-security-on-serving",
33 change: "e586a89: content security sources are set on `Serving`, not `App`",
34 edits: content_security::edits,
35 needs: None,
36 },
37 Codemod {
38 name: "password-from-identity",
39 change: "1eb6a1d: `Password` and `PasswordHash` come from sofie_identity",
40 edits: password::edits,
41 needs: Some("sofie-identity"),
42 },
43];
44
45pub(crate) struct Edit {
47 start: LineColumn,
48 end: LineColumn,
49 text: String,
50}
51
52impl Edit {
53 fn spanning(spanned: &impl syn::spanned::Spanned, text: String) -> Self {
54 let span = spanned.span();
55 Self {
56 start: span.start(),
57 end: span.end(),
58 text,
59 }
60 }
61
62 fn insert(at: LineColumn, text: String) -> Self {
63 Self {
64 start: at,
65 end: at,
66 text,
67 }
68 }
69}
70
71#[derive(Debug, PartialEq, Eq)]
72pub struct Change {
73 pub file: PathBuf,
74 pub codemod: &'static str,
75 pub change: &'static str,
76 pub line: usize,
77 pub before: String,
78 pub after: String,
79}
80
81pub struct UpgradeReport {
82 pub changes: Vec<Change>,
83 pub remaining: Option<Vec<Finding>>,
85 pub sources_pending: bool,
88}
89
90pub fn upgrade(root: &Path, write: bool) -> Result<UpgradeReport, GenError> {
92 let manifests = features::planned(root)?;
94 if !write && !manifests.changes.is_empty() {
95 return Ok(UpgradeReport {
96 changes: manifests.changes,
97 remaining: None,
98 sources_pending: true,
99 });
100 }
101 let mut journal = Journal::default();
102 let written = write_upgrade(&mut journal, root, manifests, write);
103 let changes = journal.finish(written)?;
104 let remaining = if write && !changes.is_empty() {
105 Some(check_app(root)?)
106 } else {
107 None
108 };
109 Ok(UpgradeReport {
110 changes,
111 remaining,
112 sources_pending: false,
113 })
114}
115
116fn write_upgrade(
119 journal: &mut Journal,
120 root: &Path,
121 manifests: Planned,
122 write: bool,
123) -> Result<Vec<Change>, GenError> {
124 for (file, upgraded) in &manifests.files {
125 journal.write(file, upgraded)?;
126 }
127 let mut changes = manifests.changes;
128 let planned = plan(&sofie_model::load(root).map_err(GenError::Model)?)?;
129 changes.extend(planned.changes);
130 if !write || changes.is_empty() {
131 return Ok(changes);
132 }
133 for (file, upgraded) in &planned.files {
134 journal.write(file, upgraded)?;
135 }
136 regenerate(root)?;
137 Ok(changes)
138}
139
140fn plan(app: &sofie_model::App) -> Result<Planned, GenError> {
141 let mut planned = Planned::default();
142 for krate in crates(app) {
143 for (package, codemod) in planned.upgrade_files(krate)? {
144 planned.depend(krate, package, codemod)?;
145 planned.depend(&app.app, package, codemod)?;
147 }
148 }
149 Ok(planned)
150}
151
152fn codemod(name: &str) -> &'static Codemod {
153 CODEMODS
154 .iter()
155 .find(|codemod| codemod.name == name)
156 .expect("a change names the codemod making it")
157}
158
159#[derive(Default)]
161struct Planned {
162 changes: Vec<Change>,
163 files: Vec<(PathBuf, String)>,
164}
165
166impl Planned {
167 fn upgrade_files(
169 &mut self,
170 krate: &Crate,
171 ) -> Result<Vec<(&'static str, &'static Codemod)>, GenError> {
172 let mut needed = Vec::new();
173 for file in rust_files(krate) {
174 if self.files.iter().any(|(planned, _)| *planned == file) {
175 continue;
176 }
177 let text = fs::read_to_string(&file).map_err(|source| io_error(&file, source))?;
178 let (upgraded, found) = upgrade_text(&text)
179 .map_err(|error| GenError::App(format!("{}: {error}", file.display())))?;
180 if found.is_empty() {
181 continue;
182 }
183 for change in found {
184 let codemod = codemod(change.codemod);
185 if let Some(package) = codemod.needs
186 && needed.iter().all(|(needed, _)| *needed != package)
187 {
188 needed.push((package, codemod));
189 }
190 self.changes.push(Change {
191 file: file.clone(),
192 ..change
193 });
194 }
195 self.files.push((file, upgraded));
196 }
197 Ok(needed)
198 }
199
200 fn depend(&mut self, krate: &Crate, package: &str, codemod: &Codemod) -> Result<(), GenError> {
201 if krate.depends_on(package) {
202 return Ok(());
203 }
204 let manifest = krate.path.join("Cargo.toml");
205 let text = self.text(&manifest)?;
206 let declared = toml_section(&text, DEPENDENCIES).is_some_and(|(start, end)| {
207 dependency_line_start(&text[start..end], package).is_some()
208 });
209 if declared {
210 return Ok(());
211 }
212 let line = format!("{package}.workspace = true\n");
213 self.plan(manifest, with_dependency_line(&text, &line), &line, codemod);
214 let Some((workspace, text)) = workspace_declaration(&krate.path, package)? else {
215 return Ok(());
216 };
217 if self.files.iter().all(|(planned, _)| *planned != workspace) {
218 let line = text
219 .lines()
220 .find(|line| dependency_line_start(line, package).is_some())
221 .unwrap_or_default()
222 .to_owned();
223 self.plan(workspace, text, &line, codemod);
224 }
225 Ok(())
226 }
227
228 fn text(&self, file: &Path) -> Result<String, GenError> {
229 match self.files.iter().find(|(planned, _)| planned == file) {
230 Some((_, text)) => Ok(text.clone()),
231 None => read(file),
232 }
233 }
234
235 fn plan(&mut self, file: PathBuf, text: String, added: &str, codemod: &Codemod) {
236 let added = added.trim_end();
237 let line = text
238 .lines()
239 .position(|line| line == added)
240 .map_or(0, |index| index + 1);
241 self.changes.push(Change {
242 file: file.clone(),
243 codemod: codemod.name,
244 change: codemod.change,
245 line,
246 before: String::new(),
247 after: added.to_owned(),
248 });
249 match self.files.iter_mut().find(|(planned, _)| *planned == file) {
250 Some((_, planned)) => *planned = text,
251 None => self.files.push((file, text)),
252 }
253 }
254}
255
256fn crates(app: &sofie_model::App) -> impl Iterator<Item = &Crate> {
257 std::iter::once(&app.app).chain(
258 app.modules
259 .iter()
260 .flat_map(|module| std::iter::once(&module.krate).chain(&module.contract)),
261 )
262}
263
264fn rust_files(krate: &Crate) -> Vec<PathBuf> {
265 let mut files: Vec<PathBuf> = krate
266 .sources
267 .iter()
268 .chain(&krate.tests_and_examples)
269 .map(|source| source.file.clone())
270 .collect();
271 files.sort();
272 files.dedup();
273 files
274}
275
276fn upgrade_text(text: &str) -> syn::Result<(String, Vec<Change>)> {
278 let mut text = text.to_owned();
279 let mut file = syn::parse_file(&text)?;
280 let mut changes = Vec::new();
281 for codemod in CODEMODS {
282 let mut edits = (codemod.edits)(&text, &file);
283 if edits.is_empty() {
284 continue;
285 }
286 edits.sort_by_key(|edit| (edit.start.line, edit.start.column));
287 let upgraded = apply(&text, &edits);
288 let (mut added_before, mut removed_before) = (0, 0);
290 for edit in &edits {
291 let removed = edit.end.line - edit.start.line;
292 let added = edit.text.matches('\n').count();
293 let line = edit.start.line + added_before - removed_before;
294 changes.push(Change {
295 file: PathBuf::new(),
296 codemod: codemod.name,
297 change: codemod.change,
298 line,
299 before: lines(&text, edit.start.line, removed),
300 after: lines(&upgraded, line, added),
301 });
302 added_before += added;
303 removed_before += removed;
304 }
305 text = upgraded;
306 file = syn::parse_file(&text)?;
307 }
308 Ok((text, changes))
309}
310
311fn lines(text: &str, first: usize, more: usize) -> String {
313 text.lines()
314 .skip(first - 1)
315 .take(more + 1)
316 .map(str::trim)
317 .collect::<Vec<_>>()
318 .join("\n")
319}
320
321fn apply(text: &str, edits: &[Edit]) -> String {
323 let mut text = text.to_owned();
324 for edit in edits.iter().rev() {
325 let start = offset(&text, edit.start);
326 let end = offset(&text, edit.end);
327 text.replace_range(start..end, &edit.text);
328 }
329 text
330}
331
332fn offset(text: &str, at: LineColumn) -> usize {
334 let line_start: usize = text
335 .split_inclusive('\n')
336 .take(at.line - 1)
337 .map(str::len)
338 .sum();
339 line_start
340 + text[line_start..]
341 .chars()
342 .take(at.column)
343 .map(char::len_utf8)
344 .sum::<usize>()
345}
346
347#[cfg(test)]
348mod tests {
349 use super::upgrade_text;
350
351 #[test]
352 fn an_upgraded_text_is_left_as_it_is() {
353 let text = "fn app(app: App) -> App {\n app.serving(|serving| serving.content_security::<Web>(sources()))\n}\n";
354
355 let (upgraded, changes) = upgrade_text(text).unwrap();
356
357 assert_eq!(upgraded, text);
358 assert_eq!(changes, []);
359 }
360
361 #[test]
362 fn reports_each_change_with_its_line_before_and_after() {
363 let text = "fn app(app: App) -> App {\n app.content_security_all(sources())\n}\n";
364
365 let (_, changes) = upgrade_text(text).unwrap();
366
367 assert_eq!(changes.len(), 1);
368 assert_eq!(changes[0].line, 2);
369 assert_eq!(changes[0].before, "app.content_security_all(sources())");
370 assert_eq!(
371 changes[0].after,
372 "app.serving(|serving| serving.content_security_all(sources()))"
373 );
374 }
375}