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, InlineFeatures, Journal, add_import, declared_module, inline_features, insert_at,
13    path_dependency, read, toml_section, with_dependency_line, without_toml_comment,
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_toml_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 new app's workspace declares only `sofie` and `sofie-cli`, so a framework crate a
316/// generator inherits joins `[workspace.dependencies]` from where they come: the workspace
317/// manifest and its new text, or `None` when it declares the crate already.
318pub(crate) fn workspace_declaration(
319    directory: &Path,
320    package: &str,
321) -> Result<Option<(PathBuf, String)>, GenError> {
322    const WORKSPACE_DEPENDENCIES: &str = "[workspace.dependencies]";
323    let Some(manifest) = workspace_manifest(directory) else {
324        return Ok(None);
325    };
326    let text = read(&manifest)?;
327    let line = format!("{package} = {{ {} }}", framework_source(package));
328    let Some((start, end)) = toml_section(&text, WORKSPACE_DEPENDENCIES) else {
329        let table = format!("\n{WORKSPACE_DEPENDENCIES}\n{line}\n");
330        return Ok(Some((manifest, insert_at(&text, None, &table))));
331    };
332    if dependency_line_start(&text[start..end], package).is_some() {
333        return Ok(None);
334    }
335    let body = text[start..end].trim_end();
336    let at = start + body.len();
337    let inserted = if body.is_empty() {
338        format!("{line}\n")
339    } else {
340        format!("\n{line}")
341    };
342    Ok(Some((
343        manifest,
344        format!("{}{inserted}{}", &text[..at], &text[at..]),
345    )))
346}
347
348/// The nearest manifest at or above `directory` with a `[workspace]` table, as Cargo finds it.
349fn workspace_manifest(directory: &Path) -> Option<PathBuf> {
350    directory
351        .ancestors()
352        .map(|directory| directory.join("Cargo.toml"))
353        .find(|manifest| {
354            std::fs::read_to_string(manifest)
355                .is_ok_and(|text| text.lines().any(|line| line.trim_end() == "[workspace]"))
356        })
357}
358
359pub(crate) fn dependency_line_start(section: &str, package: &str) -> Option<usize> {
360    let mut offset = 0;
361    for line in section.split_inclusive('\n') {
362        let named = line
363            .strip_prefix(package)
364            .is_some_and(|rest| rest.starts_with([' ', '=', '.']));
365        if named {
366            return Some(offset);
367        }
368        offset += line.len();
369    }
370    None
371}
372
373fn with_feature(line: &str, feature: &str) -> Option<String> {
374    if let Some(package) = line.trim_end().strip_suffix(".workspace = true") {
375        return Some(format!(
376            "{package} = {{ workspace = true, features = [{feature}] }}"
377        ));
378    }
379    match inline_features(line) {
380        InlineFeatures::Array(array) => {
381            let listed = &line[array.start + 1..array.end - 1];
382            if listed.split(',').any(|listed| listed.trim() == feature) {
383                return Some(line.to_owned());
384            }
385            let separator = if listed.trim().is_empty() { "" } else { ", " };
386            let close = array.end - 1;
387            Some(format!(
388                "{}{separator}{feature}{}",
389                line[..close].trim_end(),
390                &line[close..]
391            ))
392        }
393        InlineFeatures::Unreadable => None,
394        InlineFeatures::Absent => {
395            let close = line.rfind('}')?;
396            Some(format!(
397                "{}, features = [{feature}] {}",
398                line[..close].trim_end(),
399                &line[close..]
400            ))
401        }
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::with_feature_on;
408
409    #[test]
410    fn turns_a_feature_on_in_any_form_of_the_sofie_dependency() {
411        for (dependency, expected) in [
412            (
413                "sofie.workspace = true",
414                "sofie = { workspace = true, features = [\"tasks\"] }",
415            ),
416            (
417                "sofie = { workspace = true, features = [\"config\"] }",
418                "sofie = { workspace = true, features = [\"config\", \"tasks\"] }",
419            ),
420            (
421                "sofie = { workspace = true, features = [] }",
422                "sofie = { workspace = true, features = [\"tasks\"] }",
423            ),
424            (
425                "sofie = { path = \"../sofie\" }",
426                "sofie = { path = \"../sofie\", features = [\"tasks\"] }",
427            ),
428            (
429                "sofie = { workspace = true, features = [\"tasks\"] }",
430                "sofie = { workspace = true, features = [\"tasks\"] }",
431            ),
432            (
433                "sofie = { workspace = true, features=[\"config\"] }",
434                "sofie = { workspace = true, features=[\"config\", \"tasks\"] }",
435            ),
436            (
437                "sofie = { workspace = true, features = [\"tasks-extra\"] }",
438                "sofie = { workspace = true, features = [\"tasks-extra\", \"tasks\"] }",
439            ),
440        ] {
441            let manifest = format!(
442                "[package]\nname = \"billing\"\n\n[dependencies]\nsofie-identity.workspace = true\n{dependency}\nserde.workspace = true\n"
443            );
444            assert_eq!(
445                with_feature_on(&manifest, "sofie", "tasks").unwrap(),
446                manifest.replace(dependency, expected),
447                "{dependency}"
448            );
449        }
450        assert_eq!(
451            with_feature_on("[dependencies]\nserde.workspace = true\n", "sofie", "tasks"),
452            None
453        );
454    }
455
456    #[test]
457    fn a_trailing_comment_stays_after_the_dependency_it_notes() {
458        let commented = "[dependencies]\nsofie-identity.workspace = true # members sign in\r\n";
459        let hashed = "[dependencies]\nsofie = { path = \"../so#fie\" } # \"permissions\"\n";
460
461        assert_eq!(
462            with_feature_on(commented, "sofie-identity", "permissions").unwrap(),
463            "[dependencies]\nsofie-identity = { workspace = true, features = [\"permissions\"] } \
464             # members sign in\r\n"
465        );
466        assert_eq!(
467            with_feature_on(hashed, "sofie", "permissions").unwrap(),
468            "[dependencies]\nsofie = { path = \"../so#fie\", features = [\"permissions\"] } \
469             # \"permissions\"\n"
470        );
471    }
472}