1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use sofie_check::{ALLOW_FILE, AllowList};
5use sofie_model::naming::{crate_name, relative_path};
6use sofie_model::{Crate, KnownArea};
7
8use super::{framework_source, refuse_existing};
9use crate::gen_command::GenError;
10use crate::rustfmt::format_rust;
11use crate::source_edits::{
12 DEPENDENCIES, Journal, add_import, declared_module, insert_at, path_dependency, read,
13 toml_section, with_dependency_line,
14};
15
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub enum FileChange {
18 Created(PathBuf),
19 Changed(PathBuf),
20 Note(String),
22}
23
24impl FileChange {
25 #[must_use]
26 pub fn path(&self) -> Option<&Path> {
27 match self {
28 Self::Created(path) | Self::Changed(path) => Some(path),
29 Self::Note(_) => None,
30 }
31 }
32}
33
34impl fmt::Display for FileChange {
35 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
36 match self {
37 Self::Created(path) => write!(formatter, "created {}", path.display()),
38 Self::Changed(path) => write!(formatter, "changed {}", path.display()),
39 Self::Note(note) => write!(formatter, "note: {note}"),
40 }
41 }
42}
43
44#[derive(Default)]
45pub(super) struct Edits {
46 changes: Vec<FileChange>,
47 journal: Journal,
48}
49
50impl Edits {
51 pub(super) fn create(&mut self, file: &Path, text: &str) -> Result<(), GenError> {
52 refuse_existing(file)?;
53 self.journal.write(file, text)?;
54 self.changes.push(FileChange::Created(file.to_path_buf()));
55 Ok(())
56 }
57
58 fn write(&mut self, file: &Path, text: &str) -> Result<(), GenError> {
59 self.journal.write(file, text)?;
60 self.changed(file);
61 Ok(())
62 }
63
64 pub(super) fn create_rust(&mut self, file: &Path, text: &str) -> Result<(), GenError> {
65 self.create(file, &format_rust(text, file).map_err(GenError::Rustfmt)?)
66 }
67
68 pub(super) fn edit_rust(
69 &mut self,
70 file: &Path,
71 edit: impl FnOnce(&str) -> Result<String, GenError>,
72 ) -> Result<(), GenError> {
73 let text = read(file)?;
74 let edited = format_rust(&edit(&text)?, file).map_err(GenError::Rustfmt)?;
75 self.write(file, &edited)
76 }
77
78 pub(super) fn append(
80 &mut self,
81 file: &Path,
82 imports: &[(&str, &str)],
83 item: &str,
84 ) -> Result<(), GenError> {
85 let with_item = |text: &str| format!("{}\n{item}", with_imports(text, imports));
86 if file.is_file() {
87 return self.edit_rust(file, |text| Ok(with_item(text)));
88 }
89 self.create_rust(file, &with_item(""))
90 }
91
92 pub(super) fn declare(&mut self, krate: &Crate, path: &[&str]) -> Result<(), GenError> {
94 match declared_module(&krate.path.join("src").join("lib.rs"), path)? {
95 Some((file, text)) => self.write(&file, &text),
96 None => Ok(()),
97 }
98 }
99
100 pub(super) fn depend(&mut self, dependent: &Crate, dependency: &Crate) -> Result<(), GenError> {
101 if dependent.depends_on(&dependency.package) {
102 return Ok(());
103 }
104 let path = relative_path(&dependent.path, &dependency.path).ok_or_else(|| {
105 GenError::Usage(format!(
106 "{} cannot name {} by a relative path",
107 dependent.path.display(),
108 dependency.path.display()
109 ))
110 })?;
111 self.add_dependency(dependent, &path_dependency(&dependency.package, &path))
112 }
113
114 pub(super) fn depend_on_framework(&mut self, dependent: &Crate) -> Result<(), GenError> {
117 let present = dependent
118 .dependencies
119 .iter()
120 .any(|dependency| dependency.crate_name == "sofie");
121 if present {
122 return Ok(());
123 }
124 self.add_dependency(dependent, "sofie.workspace = true\n")
125 }
126
127 fn add_dependency(&mut self, dependent: &Crate, line: &str) -> Result<(), GenError> {
128 let manifest = dependent.path.join("Cargo.toml");
129 let text = with_dependency_line(&read(&manifest)?, line);
130 self.write(&manifest, &text)
131 }
132
133 pub(super) fn enable_framework_feature(
134 &mut self,
135 krate: &Crate,
136 feature: &str,
137 ) -> Result<(), GenError> {
138 self.enable_feature(krate, "sofie", feature)
139 }
140
141 pub(super) fn depend_on_workspace(
142 &mut self,
143 krate: &Crate,
144 package: &str,
145 ) -> Result<(), GenError> {
146 if krate.depends_on(package) {
147 return Ok(());
148 }
149 self.declare_in_workspace(krate, package)?;
150 self.add_dependency(krate, &format!("{package}.workspace = true\n"))
151 }
152
153 fn declare_in_workspace(&mut self, krate: &Crate, package: &str) -> Result<(), GenError> {
154 match workspace_declaration(&krate.path, package)? {
155 Some((manifest, text)) => self.write(&manifest, &text),
156 None => Ok(()),
157 }
158 }
159
160 pub(super) fn enable_feature(
162 &mut self,
163 krate: &Crate,
164 package: &str,
165 feature: &str,
166 ) -> Result<(), GenError> {
167 let wanted = crate_name(package);
168 let present = krate
169 .dependencies
170 .iter()
171 .any(|dependency| dependency.crate_name == wanted);
172 if !present {
173 self.declare_in_workspace(krate, package)?;
174 return self.add_dependency(
175 krate,
176 &format!("{package} = {{ workspace = true, features = [\"{feature}\"] }}\n"),
177 );
178 }
179 let manifest = krate.path.join("Cargo.toml");
180 let text = read(&manifest)?;
181 let edited = with_feature_on(&text, package, feature).ok_or_else(|| {
182 GenError::Usage(format!(
183 "{} has no one-line `{package}` dependency to turn the `{feature}` feature on in; \
184 add `features = [\"{feature}\"]` to it",
185 manifest.display()
186 ))
187 })?;
188 if edited == text {
189 return Ok(());
190 }
191 self.write(&manifest, &edited)
192 }
193
194 pub(super) fn allow_unused(
197 &mut self,
198 root: &Path,
199 package: &str,
200 name: &str,
201 generator: &str,
202 ) -> Result<(), GenError> {
203 let file = root.join(ALLOW_FILE);
204 let reason = format!(
205 "scaffolded by `sofie new {generator}`; `sofie fix` removes this entry once code uses it"
206 );
207 let entry = AllowList::item_entry(package, name, &reason);
208 if !file.is_file() {
209 return self.create(&file, &entry);
210 }
211 self.write(&file, &(read(&file)? + &entry))
212 }
213
214 pub(super) fn stop_allowing_unused(
216 &mut self,
217 root: &Path,
218 package: &str,
219 name: &str,
220 ) -> Result<(), GenError> {
221 let Some(line) = AllowList::read(root)
222 .map_err(GenError::Model)?
223 .item_line(package, name)
224 else {
225 return Ok(());
226 };
227 let file = root.join(ALLOW_FILE);
228 self.write(&file, &AllowList::without_entries(&read(&file)?, &[line]))
229 }
230
231 pub(super) fn changed(&mut self, file: &Path) {
232 if self
233 .changes
234 .iter()
235 .all(|change| change.path() != Some(file))
236 {
237 self.changes.push(FileChange::Changed(file.to_path_buf()));
238 }
239 }
240
241 pub(super) fn note(&mut self, note: String) {
242 self.changes.push(FileChange::Note(note));
243 }
244
245 pub(super) fn finish(self, result: Result<(), GenError>) -> Result<Vec<FileChange>, GenError> {
247 self.journal.finish(result)?;
248 Ok(self.changes)
249 }
250}
251
252pub(super) fn with_imports(text: &str, imports: &[(&str, &str)]) -> String {
253 imports.iter().fold(text.to_owned(), |text, (path, name)| {
254 add_import(&text, path, name)
255 })
256}
257
258pub(super) fn refuse_function(file: &Path, name: &str) -> Result<(), GenError> {
260 if file.is_file() && read(file)?.contains(&format!("fn {name}(")) {
261 return Err(GenError::Usage(format!(
262 "{} has a function `{name}` already; choose another name",
263 file.display()
264 )));
265 }
266 Ok(())
267}
268
269pub(super) fn module_file(krate: &Crate, name: &str) -> PathBuf {
271 let source = krate.path.join("src");
272 let folder = source.join(name).join("mod.rs");
273 if folder.is_file() {
274 return folder;
275 }
276 source.join(format!("{name}.rs"))
277}
278
279pub(super) fn area_type<'area>(area: &'area KnownArea<'_>) -> (String, &'area str) {
281 let name = area.type_path.rsplit("::").next().unwrap_or_default();
282 if area.definition.is_none() {
283 return (String::new(), name);
284 }
285 (format!("use {};\n", area.type_path), name)
286}
287
288pub(crate) fn with_feature_on(manifest: &str, package: &str, feature: &str) -> Option<String> {
289 with_feature_in(manifest, DEPENDENCIES, package, feature)
290}
291
292pub(crate) fn with_feature_in(
294 manifest: &str,
295 section: &str,
296 package: &str,
297 feature: &str,
298) -> Option<String> {
299 let (start, end) = toml_section(manifest, section)?;
300 let offset = start + dependency_line_start(&manifest[start..end], package)?;
301 let line_end = manifest[offset..]
302 .find('\n')
303 .map_or(manifest.len(), |index| offset + index);
304 let line = &manifest[offset..line_end];
305 let dependency = without_comment(line).trim_end();
306 let edited = with_feature(dependency, &format!("\"{feature}\""))?;
307 Some(format!(
308 "{}{edited}{}{}",
309 &manifest[..offset],
310 &line[dependency.len()..],
311 &manifest[line_end..]
312 ))
313}
314
315fn without_comment(line: &str) -> &str {
317 let mut quote = None;
318 let mut escaped = false;
319 for (index, character) in line.char_indices() {
320 match quote {
321 None if character == '#' => return &line[..index],
322 None if matches!(character, '"' | '\'') => quote = Some(character),
323 Some('"') if character == '\\' && !escaped => {
324 escaped = true;
325 continue;
326 }
327 Some(open) if character == open && !escaped => quote = None,
328 _ => {}
329 }
330 escaped = false;
331 }
332 line
333}
334
335pub(crate) fn workspace_declaration(
339 directory: &Path,
340 package: &str,
341) -> Result<Option<(PathBuf, String)>, GenError> {
342 const WORKSPACE_DEPENDENCIES: &str = "[workspace.dependencies]";
343 let Some(manifest) = workspace_manifest(directory) else {
344 return Ok(None);
345 };
346 let text = read(&manifest)?;
347 let line = format!("{package} = {{ {} }}", framework_source(package));
348 let Some((start, end)) = toml_section(&text, WORKSPACE_DEPENDENCIES) else {
349 let table = format!("\n{WORKSPACE_DEPENDENCIES}\n{line}\n");
350 return Ok(Some((manifest, insert_at(&text, None, &table))));
351 };
352 if dependency_line_start(&text[start..end], package).is_some() {
353 return Ok(None);
354 }
355 let body = text[start..end].trim_end();
356 let at = start + body.len();
357 let inserted = if body.is_empty() {
358 format!("{line}\n")
359 } else {
360 format!("\n{line}")
361 };
362 Ok(Some((
363 manifest,
364 format!("{}{inserted}{}", &text[..at], &text[at..]),
365 )))
366}
367
368fn workspace_manifest(directory: &Path) -> Option<PathBuf> {
370 directory
371 .ancestors()
372 .map(|directory| directory.join("Cargo.toml"))
373 .find(|manifest| {
374 std::fs::read_to_string(manifest)
375 .is_ok_and(|text| text.lines().any(|line| line.trim_end() == "[workspace]"))
376 })
377}
378
379pub(crate) fn dependency_line_start(section: &str, package: &str) -> Option<usize> {
380 let mut offset = 0;
381 for line in section.split_inclusive('\n') {
382 let named = line
383 .strip_prefix(package)
384 .is_some_and(|rest| rest.starts_with([' ', '=', '.']));
385 if named {
386 return Some(offset);
387 }
388 offset += line.len();
389 }
390 None
391}
392
393fn with_feature(line: &str, feature: &str) -> Option<String> {
394 if line.contains(feature) {
395 return Some(line.to_owned());
396 }
397 if let Some(package) = line.trim_end().strip_suffix(".workspace = true") {
398 return Some(format!(
399 "{package} = {{ workspace = true, features = [{feature}] }}"
400 ));
401 }
402 if let Some(list) = line.find("features = [") {
403 let open = list + "features = [".len();
404 let close = open + line[open..].find(']')?;
405 let separator = if line[open..close].trim().is_empty() {
406 ""
407 } else {
408 ", "
409 };
410 return Some(format!(
411 "{}{separator}{feature}{}",
412 line[..close].trim_end(),
413 &line[close..]
414 ));
415 }
416 let close = line.rfind('}')?;
417 Some(format!(
418 "{}, features = [{feature}] {}",
419 line[..close].trim_end(),
420 &line[close..]
421 ))
422}
423
424#[cfg(test)]
425mod tests {
426 use super::with_feature_on;
427
428 #[test]
429 fn turns_a_feature_on_in_any_form_of_the_sofie_dependency() {
430 for (dependency, expected) in [
431 (
432 "sofie.workspace = true",
433 "sofie = { workspace = true, features = [\"tasks\"] }",
434 ),
435 (
436 "sofie = { workspace = true, features = [\"config\"] }",
437 "sofie = { workspace = true, features = [\"config\", \"tasks\"] }",
438 ),
439 (
440 "sofie = { workspace = true, features = [] }",
441 "sofie = { workspace = true, features = [\"tasks\"] }",
442 ),
443 (
444 "sofie = { path = \"../sofie\" }",
445 "sofie = { path = \"../sofie\", features = [\"tasks\"] }",
446 ),
447 (
448 "sofie = { workspace = true, features = [\"tasks\"] }",
449 "sofie = { workspace = true, features = [\"tasks\"] }",
450 ),
451 ] {
452 let manifest = format!(
453 "[package]\nname = \"billing\"\n\n[dependencies]\nsofie-identity.workspace = true\n{dependency}\nserde.workspace = true\n"
454 );
455 assert_eq!(
456 with_feature_on(&manifest, "sofie", "tasks").unwrap(),
457 manifest.replace(dependency, expected),
458 "{dependency}"
459 );
460 }
461 assert_eq!(
462 with_feature_on("[dependencies]\nserde.workspace = true\n", "sofie", "tasks"),
463 None
464 );
465 }
466
467 #[test]
468 fn a_trailing_comment_stays_after_the_dependency_it_notes() {
469 let commented = "[dependencies]\nsofie-identity.workspace = true # members sign in\r\n";
470 let hashed = "[dependencies]\nsofie = { path = \"../so#fie\" } # \"permissions\"\n";
471
472 assert_eq!(
473 with_feature_on(commented, "sofie-identity", "permissions").unwrap(),
474 "[dependencies]\nsofie-identity = { workspace = true, features = [\"permissions\"] } \
475 # members sign in\r\n"
476 );
477 assert_eq!(
478 with_feature_on(hashed, "sofie", "permissions").unwrap(),
479 "[dependencies]\nsofie = { path = \"../so#fie\", features = [\"permissions\"] } \
480 # \"permissions\"\n"
481 );
482 }
483}