Skip to main content

sofie_cli/
fix_command.rs

1//! Only fixes that have exactly one form; nothing that needs a decision.
2
3use std::path::{Path, PathBuf};
4
5use sofie_check::{AllowList, Finding, Rule, missing_gen_exports};
6use sofie_model::naming::relative_path;
7use sofie_model::{App, Module};
8
9use crate::check_command::check_app;
10use crate::gen_command::{GenError, io_error, regenerate};
11use crate::new_command::edits::with_feature_on;
12use crate::new_command::task::tasks_wiring;
13use crate::rustfmt::format_rust;
14use crate::source_edits::{
15    add_dependency, append_lines, declare_module, read, with_dependency_line, write,
16};
17
18pub struct FixReport {
19    pub applied: Vec<String>,
20    pub remaining: Vec<Finding>,
21}
22
23pub fn fix_app(root: &Path) -> Result<FixReport, GenError> {
24    let app = sofie_model::load(root).map_err(GenError::Model)?;
25    let mut applied = Vec::new();
26    for module in &app.modules {
27        applied.extend(declare_missing_modules(&app, module)?);
28    }
29    applied.extend(
30        wire_generated_code(&app)?
31            .into_iter()
32            .map(|(_, change)| change),
33    );
34    applied.extend(add_app_dependencies(&app)?);
35    let mut remaining = check_app(root)?;
36    let only_stale = remaining.iter().all(|finding| is_stale(finding.rule));
37    if !remaining.is_empty() && only_stale {
38        applied.extend(regenerate(root)?);
39        remaining = check_app(root)?;
40    }
41    // The dead code check runs only once the generated files are current.
42    let removed = remove_stale_allowances(&app, &remaining)?;
43    if !removed.is_empty() {
44        applied.extend(removed);
45        remaining = check_app(root)?;
46    }
47    Ok(FixReport { applied, remaining })
48}
49
50/// An allowance whose item is used or gone has nothing left to allow.
51fn remove_stale_allowances(app: &App, findings: &[Finding]) -> Result<Vec<String>, GenError> {
52    let mut files: Vec<(&Path, Vec<usize>)> = Vec::new();
53    for finding in findings
54        .iter()
55        .filter(|finding| finding.rule == Rule::StaleAllowance)
56    {
57        match files.iter_mut().find(|(file, _)| *file == finding.file) {
58            Some((_, lines)) => lines.push(finding.line),
59            None => files.push((&finding.file, vec![finding.line])),
60        }
61    }
62    let mut removed = Vec::new();
63    for (file, lines) in files {
64        let left = AllowList::without_entries(&read(file)?, &lines);
65        if left.trim().is_empty() {
66            std::fs::remove_file(file).map_err(|source| io_error(file, source))?;
67        } else {
68            write(file, left)?;
69        }
70        removed.push(format!(
71            "removed {} stale allowance(s) from {}",
72            lines.len(),
73            shown(app, file)
74        ));
75    }
76    Ok(removed)
77}
78
79fn is_stale(rule: Rule) -> bool {
80    matches!(rule, Rule::StaleWiring | Rule::StaleDocs)
81}
82
83fn declare_missing_modules(app: &App, module: &Module) -> Result<Vec<String>, GenError> {
84    let mut paths: Vec<Vec<&str>> = Vec::new();
85    if module
86        .manifest
87        .as_ref()
88        .is_some_and(|manifest| !manifest.declared)
89    {
90        paths.push(vec!["module"]);
91    }
92    if module
93        .config
94        .as_ref()
95        .is_some_and(|config| !config.declared)
96    {
97        paths.push(vec!["config"]);
98    }
99    for area in &module.areas {
100        if !area.declared {
101            paths.push(vec!["areas", &area.area, "routes"]);
102        }
103        if area.menu.as_ref().is_some_and(|menu| !menu.declared) {
104            paths.push(vec!["areas", &area.area, "menu"]);
105        }
106    }
107    let root = module.krate.path.join("src").join("lib.rs");
108    let mut applied = Vec::new();
109    for path in paths {
110        if let Some(file) = declare_module(&root, &path)? {
111            applied.push(format!(
112                "declared `{}` in {}",
113                path.join("::"),
114                shown(app, &file)
115            ));
116        }
117    }
118    Ok(applied)
119}
120
121/// What makes the generated code reachable, in its one form: a contract declaring and
122/// exporting its `gen` module, and `main` handing the wiring's tasks to the runner. `sofie gen`
123/// writes it too, so an app a generator or a hand edit leaves builds.
124pub(crate) fn wire_generated_code(app: &App) -> Result<Vec<(PathBuf, String)>, GenError> {
125    let mut applied = Vec::new();
126    for module in &app.modules {
127        applied.extend(export_generated(app, module)?);
128    }
129    for module in &app.modules {
130        // The derive writes an aggregate's default factory only with `factories` on in the
131        // crate's own build, which an app's features reach only when the app is built too.
132        let features = [
133            (!module.tasks.is_empty()).then_some("tasks"),
134            (!module.code.aggregates.is_empty()).then_some("factories"),
135        ];
136        for feature in features.into_iter().flatten() {
137            let manifest = module.krate.path.join("Cargo.toml");
138            let text = read(&manifest)?;
139            // A dependency written over several lines is left to the build's own error.
140            if let Some(edited) = with_feature_on(&text, "sofie", feature)
141                && edited != text
142            {
143                write(&manifest, &edited)?;
144                let change = format!("turned sofie's `{feature}` on in {}", shown(app, &manifest));
145                applied.push((manifest, change));
146            }
147        }
148    }
149    let declares_tasks = app.modules.iter().any(|module| !module.tasks.is_empty());
150    // A `main` it cannot change is left to `sofie check`'s `task-runner` finding.
151    if declares_tasks && let Ok(files) = tasks_wiring(app)? {
152        for (file, text) in files {
153            write(&file, format_rust(&text, &file).map_err(GenError::Rustfmt)?)?;
154            let change = format!(
155                "handed the wiring's tasks to the runner in {}",
156                shown(app, &file)
157            );
158            applied.push((file, change));
159        }
160    }
161    Ok(applied)
162}
163
164fn export_generated(app: &App, module: &Module) -> Result<Vec<(PathBuf, String)>, GenError> {
165    let Some(contract) = &module.contract else {
166        return Ok(Vec::new());
167    };
168    let lines = generated_export_lines(app, module);
169    let mut applied = Vec::new();
170    if !lines.is_empty() {
171        let root = contract.path.join("src").join("lib.rs");
172        append_lines(&root, &lines)?;
173        let change = format!("added `{}` to {}", lines.join(" "), shown(app, &root));
174        applied.push((root, change));
175    }
176    let exports_gen = module.contract_declares_gen || !lines.is_empty();
177    let names_framework = contract
178        .dependencies
179        .iter()
180        .any(|dependency| dependency.crate_name == "sofie");
181    // The generated code names `sofie`, which a contract fresh from `sofie new module` lacks.
182    if exports_gen && !names_framework {
183        let manifest = contract.path.join("Cargo.toml");
184        write(
185            &manifest,
186            with_dependency_line(&read(&manifest)?, "sofie.workspace = true\n"),
187        )?;
188        let change = format!("added `sofie` to {}", shown(app, &manifest));
189        applied.push((manifest, change));
190    }
191    Ok(applied)
192}
193
194/// What the contract's `lib.rs` lacks to declare and export the code `sofie gen` writes into it.
195pub(crate) fn generated_export_lines(app: &App, module: &Module) -> Vec<String> {
196    let missing = missing_gen_exports(app, module);
197    let needs_gen = !missing.is_empty()
198        || module
199            .defined_areas
200            .iter()
201            .any(sofie_model::AreaDefinition::needs_generated_member_area);
202    let mut lines = Vec::new();
203    if needs_gen && !module.contract_declares_gen {
204        lines.push("mod r#gen;".to_owned());
205    }
206    lines.extend(missing.iter().map(|name| format!("pub use r#gen::{name};")));
207    lines
208}
209
210fn add_app_dependencies(app: &App) -> Result<Vec<String>, GenError> {
211    let mut needed: Vec<(&str, &Path)> = Vec::new();
212    for module in &app.modules {
213        needed.push((module.krate.package.as_str(), module.krate.path.as_path()));
214        // The wiring names a contract's areas only to sign their members in.
215        if app.identity.is_some()
216            && !module.defined_areas.is_empty()
217            && let Some(contract) = &module.contract
218        {
219            needed.push((contract.package.as_str(), contract.path.as_path()));
220        }
221    }
222    needed.sort_unstable();
223    needed.dedup();
224    let manifest = app.app.path.join("Cargo.toml");
225    let mut applied = Vec::new();
226    for (package, path) in needed {
227        if app.depends_on(package) {
228            continue;
229        }
230        let Some(relative) = relative_path(&app.app.path, path) else {
231            continue;
232        };
233        add_dependency(&manifest, package, &relative)?;
234        applied.push(format!(
235            "added `{package}` to the [dependencies] of {}",
236            shown(app, &manifest)
237        ));
238    }
239    Ok(applied)
240}
241
242fn shown(app: &App, file: &Path) -> String {
243    file.strip_prefix(&app.root)
244        .unwrap_or(file)
245        .display()
246        .to_string()
247}
248
249#[cfg(test)]
250mod tests {
251    use sofie_check::{Finding, Rule};
252    use sofie_model::testing::{TemporaryDirectory, app};
253
254    use super::remove_stale_allowances;
255
256    fn stale(file: std::path::PathBuf, line: usize) -> Finding {
257        Finding {
258            rule: Rule::StaleAllowance,
259            file,
260            line,
261            message: String::new(),
262            fix: String::new(),
263        }
264    }
265
266    #[test]
267    fn removes_each_stale_allowance_from_its_own_file() {
268        let directory = TemporaryDirectory::new("sofie-fix-stale");
269        let entries = "[[item]]\nname = \"a\"\n\n[[item]]\nname = \"b\"\n";
270        let items = directory.write("dead-code-allow.toml", entries);
271        let locks = directory.write("lock-allow.toml", entries);
272        let findings = [stale(items.clone(), 1), stale(locks.clone(), 4)];
273
274        let removed =
275            remove_stale_allowances(&app(directory.root(), Vec::new()), &findings).unwrap();
276
277        assert_eq!(removed.len(), 2);
278        assert_eq!(
279            std::fs::read_to_string(items).unwrap(),
280            "[[item]]\nname = \"b\"\n"
281        );
282        assert_eq!(
283            std::fs::read_to_string(locks).unwrap(),
284            "[[item]]\nname = \"a\"\n"
285        );
286    }
287}