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::{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 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
48fn 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
119pub(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 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 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
168pub(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 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}