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