Skip to main content

sofie_cli/
gen_command.rs

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 is missing or refused the output: a broken toolchain, not a broken app.
40    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    /// The app's own `cargo build` failed; the text holds its diagnostics.
51    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
87/// Writing, it first makes the generated code reachable, as `sofie fix` would.
88pub fn gen_app(root: &Path, mode: GenMode) -> Result<Vec<GenOutcome>, GenError> {
89    generated_app(root, mode).map(|(_, outcomes)| outcomes)
90}
91
92/// The checked model the generated code was rendered from, so docs need not load it again.
93pub(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
116/// `sofie gen`: the generated code, then `docs/gen/` for an app keeping its docs, read from
117/// the model the new code gives.
118pub 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/// The model is loaded again only when the code just written can change it; that code was
126/// rendered from a checked model, so it is not checked again.
127#[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
244/// Only a file `sofie gen` wrote is removed; anything else there is the developer's.
245fn 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
278/// Cached, so a read error is copied out for the caller to own.
279pub(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}