1use std::fmt;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5
6use sofie_check::{Finding, Screen, Screens};
7use sofie_model::{App, ModelError};
8
9use crate::agents_md::render_agents_md;
10use crate::api::typescript::TYPESCRIPT_HEADER;
11use crate::contract_gen::render_contract_gens;
12use crate::env_example::{ENV_HEADER, render_env_examples};
13use crate::fix_command::wire_generated_code;
14use crate::forms::described_forms;
15use crate::module_roles::{missing_role_migrations, with_role_migrations};
16use crate::rustfmt::format_rust;
17use crate::source_edits::write;
18use crate::translations::AppLanguages;
19use crate::wiring::{HEADER, render_module_wiring, render_wiring};
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum GenMode {
23 Write,
24 Check,
25}
26
27#[derive(Debug, PartialEq, Eq)]
28pub enum GenOutcome {
29 Written(PathBuf),
30 Removed(PathBuf),
31 UpToDate(PathBuf),
32 Stale(PathBuf),
33}
34
35#[derive(Debug)]
36pub enum GenError {
37 Model(ModelError),
38 Check(Vec<Finding>),
39 Rustfmt(String),
41 Io {
42 path: PathBuf,
43 source: io::Error,
44 },
45 Screen {
46 path: PathBuf,
47 message: String,
48 },
49 App(String),
50 Build(String),
52 Usage(String),
53}
54
55impl fmt::Display for GenError {
56 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57 match self {
58 Self::Model(error) => write!(formatter, "{error}"),
59 Self::Check(findings) => {
60 let rendered: Vec<String> = findings.iter().map(ToString::to_string).collect();
61 write!(formatter, "{}", rendered.join("\n"))
62 }
63 Self::Rustfmt(message)
64 | Self::App(message)
65 | Self::Build(message)
66 | Self::Usage(message) => {
67 write!(formatter, "{message}")
68 }
69 Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
70 Self::Screen { path, message } => write!(formatter, "{}: {message}", path.display()),
71 }
72 }
73}
74
75impl std::error::Error for GenError {}
76
77impl GenOutcome {
78 pub(crate) fn change(&self) -> Option<String> {
79 match self {
80 Self::Written(path) => Some(format!("wrote {}", path.display())),
81 Self::Removed(path) => Some(format!("removed {}", path.display())),
82 Self::UpToDate(_) | Self::Stale(_) => None,
83 }
84 }
85}
86
87pub fn gen_app(root: &Path, mode: GenMode) -> Result<Vec<GenOutcome>, GenError> {
89 generated_app(root, mode).map(|(_, outcomes)| outcomes)
90}
91
92pub(crate) fn generated_app(
94 root: &Path,
95 mode: GenMode,
96) -> Result<(App, Vec<GenOutcome>), GenError> {
97 let mut app = sofie_model::load(root).map_err(GenError::Model)?;
98 let mut outcomes = Vec::new();
99 if mode == GenMode::Write {
100 let wired = wire_generated_code(&app)?;
101 if !wired.is_empty() {
102 app = sofie_model::load(root).map_err(GenError::Model)?;
103 outcomes.extend(wired.into_iter().map(|(file, _)| GenOutcome::Written(file)));
104 }
105 }
106 let (screens, languages) = checked_parts(&app)?;
107 outcomes.extend(sync_generated(&app, &screens, languages.as_ref(), mode)?);
108 Ok((app, outcomes))
109}
110
111pub fn regenerate(root: &Path) -> Result<Vec<String>, GenError> {
112 let outcomes = regenerated(root, GenMode::Write)?;
113 Ok(outcomes.iter().filter_map(GenOutcome::change).collect())
114}
115
116pub fn regenerated(root: &Path, mode: GenMode) -> Result<Vec<GenOutcome>, GenError> {
119 let (app, mut outcomes) = generated_app(root, mode)?;
120 let docs = regenerate_docs(&app, &outcomes, mode)?;
121 outcomes.extend(docs);
122 Ok(outcomes)
123}
124
125#[cfg(feature = "docs")]
128fn regenerate_docs(
129 app: &App,
130 outcomes: &[GenOutcome],
131 mode: GenMode,
132) -> Result<Vec<GenOutcome>, GenError> {
133 if !crate::docs::keeps_docs(app) {
134 return Ok(Vec::new());
135 }
136 if !outcomes.iter().any(|outcome| outcome.change().is_some()) {
137 return crate::docs::sync_docs(app, mode);
138 }
139 let reloaded = sofie_model::load(&app.root).map_err(GenError::Model)?;
140 crate::docs::sync_docs(&reloaded, mode)
141}
142
143#[cfg(not(feature = "docs"))]
144#[expect(
145 clippy::unnecessary_wraps,
146 reason = "the same signature as with `docs`"
147)]
148fn regenerate_docs(
149 _app: &App,
150 _outcomes: &[GenOutcome],
151 _mode: GenMode,
152) -> Result<Vec<GenOutcome>, GenError> {
153 Ok(Vec::new())
154}
155
156#[cfg(feature = "docs")]
157pub(crate) fn checked_model(root: &Path) -> Result<App, GenError> {
158 checked(root).map(|(app, _, _)| app)
159}
160
161#[cfg(feature = "docs")]
162fn checked(root: &Path) -> Result<(App, Screens, Option<AppLanguages>), GenError> {
163 let app = sofie_model::load(root).map_err(GenError::Model)?;
164 let (screens, languages) = checked_parts(&app)?;
165 Ok((app, screens, languages))
166}
167
168fn checked_parts(app: &App) -> Result<(Screens, Option<AppLanguages>), GenError> {
169 let screens = Screens::read(&app.modules);
170 let languages = AppLanguages::read(app)?;
171 let mut findings = sofie_check::check(app, &screens);
172 if let Some(languages) = &languages {
173 findings.extend(languages.findings(app, &screens)?);
174 }
175 if !findings.is_empty() {
176 return Err(GenError::Check(findings));
177 }
178 Ok((screens, languages))
179}
180
181pub(crate) fn sync_generated(
182 app: &App,
183 screens: &Screens,
184 languages: Option<&AppLanguages>,
185 mode: GenMode,
186) -> Result<Vec<GenOutcome>, GenError> {
187 let role_migrations = missing_role_migrations(app);
188 let app = &with_role_migrations(app, &role_migrations);
189 let mut files: Vec<(PathBuf, Option<String>)> = role_migrations
190 .into_iter()
191 .map(|(path, text)| (path, Some(text)))
192 .collect();
193 let wiring = render_wiring(app, languages, &described_forms(app, screens)?);
194 let wiring_file = app.wiring_file();
195 files.push((wiring_file.clone(), Some(formatted(&wiring, &wiring_file)?)));
196 for module in &app.modules {
197 let file = module.krate.wiring_file();
198 let text = formatted(&render_module_wiring(app, module), &file)?;
199 files.push((file, Some(text)));
200 }
201 let contract_gens = render_contract_gens(app, languages)
202 .map_err(|error| GenError::App(format!("reading the translation keys: {error}")))?;
203 for (path, text) in contract_gens {
204 let text = text
205 .as_deref()
206 .map(|text| formatted(text, &path))
207 .transpose()?;
208 files.push((path, text));
209 }
210 files.extend(render_env_examples(app)?);
211 #[cfg(feature = "typescript")]
212 files.push(crate::islands::types_file(app));
213 files.push(render_agents_md(app)?);
214 let mut outcomes = Vec::new();
215 for (path, text) in files {
216 outcomes.extend(sync_file(&path, text, mode)?);
217 }
218 Ok(outcomes)
219}
220
221fn formatted(rust: &str, file: &Path) -> Result<String, GenError> {
222 format_rust(rust, file).map_err(GenError::Rustfmt)
223}
224
225pub(crate) fn sync_file(
226 path: &Path,
227 text: Option<String>,
228 mode: GenMode,
229) -> Result<Option<GenOutcome>, GenError> {
230 let existing = read_existing(path)?;
231 let Some(text) = text else {
232 return remove_generated(path, existing.as_deref(), mode);
233 };
234 if existing.as_deref() == Some(text.as_str()) {
235 return Ok(Some(GenOutcome::UpToDate(path.to_path_buf())));
236 }
237 if mode == GenMode::Check {
238 return Ok(Some(GenOutcome::Stale(path.to_path_buf())));
239 }
240 write(path, &text)?;
241 Ok(Some(GenOutcome::Written(path.to_path_buf())))
242}
243
244fn remove_generated(
246 path: &Path,
247 existing: Option<&str>,
248 mode: GenMode,
249) -> Result<Option<GenOutcome>, GenError> {
250 if !existing.is_some_and(is_generated) {
251 return Ok(None);
252 }
253 if mode == GenMode::Check {
254 return Ok(Some(GenOutcome::Stale(path.to_path_buf())));
255 }
256 fs::remove_file(path).map_err(|source| io_error(path, source))?;
257 Ok(Some(GenOutcome::Removed(path.to_path_buf())))
258}
259
260fn is_generated(text: &str) -> bool {
261 #[cfg(feature = "docs")]
262 if text.starts_with(crate::docs::DOCS_HEADER) {
263 return true;
264 }
265 [HEADER, ENV_HEADER, TYPESCRIPT_HEADER]
266 .iter()
267 .any(|header| text.starts_with(header))
268}
269
270pub(crate) fn read_existing(path: &Path) -> Result<Option<String>, GenError> {
271 match fs::read_to_string(path) {
272 Ok(text) => Ok(Some(text)),
273 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
274 Err(source) => Err(io_error(path, source)),
275 }
276}
277
278pub(crate) fn readable_screen<'s>(
280 screens: &'s Screens,
281 file: &Path,
282) -> Result<&'s Screen, GenError> {
283 screens
284 .screen(file)
285 .as_ref()
286 .map_err(|error| io_error(file, io::Error::new(error.kind(), error.to_string())))
287}
288
289pub(crate) fn io_error(path: &Path, source: io::Error) -> GenError {
290 GenError::Io {
291 path: path.to_path_buf(),
292 source,
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use super::is_generated;
299 use crate::api::typescript::TYPESCRIPT_HEADER;
300
301 #[test]
302 fn the_island_types_file_counts_as_generated() {
303 assert!(is_generated(&format!("{TYPESCRIPT_HEADER}export {{}};\n")));
304 assert!(!is_generated("// Written by hand.\nexport {};\n"));
305 }
306}