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    let mut app = sofie_model::load(root).map_err(GenError::Model)?;
89    let mut outcomes = Vec::new();
90    if mode == GenMode::Write {
91        let wired = wire_generated_code(&app)?;
92        if !wired.is_empty() {
93            app = sofie_model::load(root).map_err(GenError::Model)?;
94            outcomes.extend(wired.into_iter().map(|(file, _)| GenOutcome::Written(file)));
95        }
96    }
97    let (screens, languages) = checked_parts(&app)?;
98    outcomes.extend(sync_generated(&app, &screens, languages.as_ref(), mode)?);
99    Ok(outcomes)
100}
101
102pub fn regenerate(root: &Path) -> Result<Vec<String>, GenError> {
103    let outcomes = regenerated(root, GenMode::Write)?;
104    Ok(outcomes.iter().filter_map(GenOutcome::change).collect())
105}
106
107/// `sofie gen`: the generated code, then `docs/gen/` for an app keeping its docs, read from
108/// the model the new code gives.
109pub fn regenerated(root: &Path, mode: GenMode) -> Result<Vec<GenOutcome>, GenError> {
110    let mut outcomes = gen_app(root, mode)?;
111    outcomes.extend(regenerate_docs(root, mode)?);
112    Ok(outcomes)
113}
114
115#[cfg(feature = "docs")]
116fn regenerate_docs(root: &Path, mode: GenMode) -> Result<Vec<GenOutcome>, GenError> {
117    let app = checked_model(root)?;
118    if !crate::docs::keeps_docs(&app) {
119        return Ok(Vec::new());
120    }
121    crate::docs::sync_docs(&app, mode)
122}
123
124#[cfg(not(feature = "docs"))]
125#[expect(
126    clippy::unnecessary_wraps,
127    reason = "the same signature as with `docs`"
128)]
129fn regenerate_docs(_root: &Path, _mode: GenMode) -> Result<Vec<GenOutcome>, GenError> {
130    Ok(Vec::new())
131}
132
133#[cfg(feature = "docs")]
134pub(crate) fn checked_model(root: &Path) -> Result<App, GenError> {
135    checked(root).map(|(app, _, _)| app)
136}
137
138#[cfg(feature = "docs")]
139fn checked(root: &Path) -> Result<(App, Screens, Option<AppLanguages>), GenError> {
140    let app = sofie_model::load(root).map_err(GenError::Model)?;
141    let (screens, languages) = checked_parts(&app)?;
142    Ok((app, screens, languages))
143}
144
145fn checked_parts(app: &App) -> Result<(Screens, Option<AppLanguages>), GenError> {
146    let screens = Screens::read(&app.modules);
147    let languages = AppLanguages::read(app)?;
148    let mut findings = sofie_check::check(app, &screens);
149    if let Some(languages) = &languages {
150        findings.extend(languages.findings(app, &screens)?);
151    }
152    if !findings.is_empty() {
153        return Err(GenError::Check(findings));
154    }
155    Ok((screens, languages))
156}
157
158pub(crate) fn sync_generated(
159    app: &App,
160    screens: &Screens,
161    languages: Option<&AppLanguages>,
162    mode: GenMode,
163) -> Result<Vec<GenOutcome>, GenError> {
164    let role_migrations = missing_role_migrations(app);
165    let app = &with_role_migrations(app, &role_migrations);
166    let mut files: Vec<(PathBuf, Option<String>)> = role_migrations
167        .into_iter()
168        .map(|(path, text)| (path, Some(text)))
169        .collect();
170    let wiring = render_wiring(app, languages, &described_forms(app, screens)?);
171    let wiring_file = app.wiring_file();
172    files.push((wiring_file.clone(), Some(formatted(&wiring, &wiring_file)?)));
173    for module in &app.modules {
174        let file = module.krate.wiring_file();
175        let text = formatted(&render_module_wiring(app, module), &file)?;
176        files.push((file, Some(text)));
177    }
178    let contract_gens = render_contract_gens(app, languages)
179        .map_err(|error| GenError::App(format!("reading the translation keys: {error}")))?;
180    for (path, text) in contract_gens {
181        let text = text
182            .as_deref()
183            .map(|text| formatted(text, &path))
184            .transpose()?;
185        files.push((path, text));
186    }
187    files.extend(render_env_examples(app)?);
188    #[cfg(feature = "typescript")]
189    files.push(crate::islands::types_file(app));
190    files.push(render_agents_md(app)?);
191    let mut outcomes = Vec::new();
192    for (path, text) in files {
193        outcomes.extend(sync_file(&path, text, mode)?);
194    }
195    Ok(outcomes)
196}
197
198fn formatted(rust: &str, file: &Path) -> Result<String, GenError> {
199    format_rust(rust, file).map_err(GenError::Rustfmt)
200}
201
202pub(crate) fn sync_file(
203    path: &Path,
204    text: Option<String>,
205    mode: GenMode,
206) -> Result<Option<GenOutcome>, GenError> {
207    let existing = read_existing(path)?;
208    let Some(text) = text else {
209        return remove_generated(path, existing.as_deref(), mode);
210    };
211    if existing.as_deref() == Some(text.as_str()) {
212        return Ok(Some(GenOutcome::UpToDate(path.to_path_buf())));
213    }
214    if mode == GenMode::Check {
215        return Ok(Some(GenOutcome::Stale(path.to_path_buf())));
216    }
217    write(path, &text)?;
218    Ok(Some(GenOutcome::Written(path.to_path_buf())))
219}
220
221/// Only a file `sofie gen` wrote is removed; anything else there is the developer's.
222fn remove_generated(
223    path: &Path,
224    existing: Option<&str>,
225    mode: GenMode,
226) -> Result<Option<GenOutcome>, GenError> {
227    if !existing.is_some_and(is_generated) {
228        return Ok(None);
229    }
230    if mode == GenMode::Check {
231        return Ok(Some(GenOutcome::Stale(path.to_path_buf())));
232    }
233    fs::remove_file(path).map_err(|source| io_error(path, source))?;
234    Ok(Some(GenOutcome::Removed(path.to_path_buf())))
235}
236
237fn is_generated(text: &str) -> bool {
238    #[cfg(feature = "docs")]
239    if text.starts_with(crate::docs::DOCS_HEADER) {
240        return true;
241    }
242    text.starts_with(HEADER) || text.starts_with(ENV_HEADER)
243}
244
245pub(crate) fn read_existing(path: &Path) -> Result<Option<String>, GenError> {
246    match fs::read_to_string(path) {
247        Ok(text) => Ok(Some(text)),
248        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
249        Err(source) => Err(io_error(path, source)),
250    }
251}
252
253/// Cached, so a read error is copied out for the caller to own.
254pub(crate) fn readable_screen<'s>(
255    screens: &'s Screens,
256    file: &Path,
257) -> Result<&'s Screen, GenError> {
258    screens
259        .screen(file)
260        .as_ref()
261        .map_err(|error| io_error(file, io::Error::new(error.kind(), error.to_string())))
262}
263
264pub(crate) fn io_error(path: &Path, source: io::Error) -> GenError {
265    GenError::Io {
266        path: path.to_path_buf(),
267        source,
268    }
269}