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