Skip to main content

sofie_cli/new_command/
edits.rs

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    /// What the generator chose that the files do not say, such as a field it left out.
21    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    /// Adds `item` to the end of `file` with the `imports` it needs, creating the file when missing.
79    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    /// Declares the module at `path` from `krate`'s `lib.rs`.
93    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    /// From the workspace, as in every manifest `sofie new` writes. Known by its
115    /// crate name: the package is `sofie-framework`.
116    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    /// Adds the dependency named `package` from the workspace first when `krate` lacks it.
161    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    /// Lets a scaffolded item pass the dead code check until code uses it;
195    /// `sofie fix` then removes the entry, which `sofie check` reports as stale.
196    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    /// A generator that uses a scaffolded item removes its entry, which `sofie check` would report as stale.
215    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    /// The changes, or on a failure the error once every change is undone.
246    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
258/// Refuses a second `fn name(` in `file`, when it exists.
259pub(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
269/// `src/<name>/mod.rs` when the module is a folder, else `src/<name>.rs`.
270pub(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
279/// The `use` line an area's file needs for its type, and the type's name.
280pub(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
292/// The one-line dependency on `package` under `section` with `feature` on.
293pub(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
315/// A `#` inside a quoted string starts no comment.
316fn 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
335/// A new app's workspace declares only `sofie` and `sofie-cli`, so a framework crate a
336/// generator inherits joins `[workspace.dependencies]` from where they come: the workspace
337/// manifest and its new text, or `None` when it declares the crate already.
338pub(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
368/// The nearest manifest at or above `directory` with a `[workspace]` table, as Cargo finds it.
369fn 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}