1use 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 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
50fn 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
121pub(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 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 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 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 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
194pub(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 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}