use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use sofie_check::{Finding, Screen, Screens};
use sofie_model::{App, ModelError};
use crate::agents_md::render_agents_md;
use crate::contract_gen::render_contract_gens;
use crate::env_example::{ENV_HEADER, render_env_examples};
use crate::fix_command::wire_generated_code;
use crate::forms::described_forms;
use crate::module_roles::{missing_role_migrations, with_role_migrations};
use crate::rustfmt::format_rust;
use crate::source_edits::write;
use crate::translations::AppLanguages;
use crate::wiring::{HEADER, render_module_wiring, render_wiring};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GenMode {
Write,
Check,
}
#[derive(Debug, PartialEq, Eq)]
pub enum GenOutcome {
Written(PathBuf),
Removed(PathBuf),
UpToDate(PathBuf),
Stale(PathBuf),
}
#[derive(Debug)]
pub enum GenError {
Model(ModelError),
Check(Vec<Finding>),
Rustfmt(String),
Io {
path: PathBuf,
source: io::Error,
},
Screen {
path: PathBuf,
message: String,
},
App(String),
Build(String),
Usage(String),
}
impl fmt::Display for GenError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Model(error) => write!(formatter, "{error}"),
Self::Check(findings) => {
let rendered: Vec<String> = findings.iter().map(ToString::to_string).collect();
write!(formatter, "{}", rendered.join("\n"))
}
Self::Rustfmt(message)
| Self::App(message)
| Self::Build(message)
| Self::Usage(message) => {
write!(formatter, "{message}")
}
Self::Io { path, source } => write!(formatter, "{}: {source}", path.display()),
Self::Screen { path, message } => write!(formatter, "{}: {message}", path.display()),
}
}
}
impl std::error::Error for GenError {}
impl GenOutcome {
pub(crate) fn change(&self) -> Option<String> {
match self {
Self::Written(path) => Some(format!("wrote {}", path.display())),
Self::Removed(path) => Some(format!("removed {}", path.display())),
Self::UpToDate(_) | Self::Stale(_) => None,
}
}
}
pub fn gen_app(root: &Path, mode: GenMode) -> Result<Vec<GenOutcome>, GenError> {
generated_app(root, mode).map(|(_, outcomes)| outcomes)
}
fn generated_app(root: &Path, mode: GenMode) -> Result<(App, Vec<GenOutcome>), GenError> {
let mut app = sofie_model::load(root).map_err(GenError::Model)?;
let mut outcomes = Vec::new();
if mode == GenMode::Write {
let wired = wire_generated_code(&app)?;
if !wired.is_empty() {
app = sofie_model::load(root).map_err(GenError::Model)?;
outcomes.extend(wired.into_iter().map(|(file, _)| GenOutcome::Written(file)));
}
}
let (screens, languages) = checked_parts(&app)?;
outcomes.extend(sync_generated(&app, &screens, languages.as_ref(), mode)?);
Ok((app, outcomes))
}
pub fn regenerate(root: &Path) -> Result<Vec<String>, GenError> {
let outcomes = regenerated(root, GenMode::Write)?;
Ok(outcomes.iter().filter_map(GenOutcome::change).collect())
}
pub fn regenerated(root: &Path, mode: GenMode) -> Result<Vec<GenOutcome>, GenError> {
let (app, mut outcomes) = generated_app(root, mode)?;
let docs = regenerate_docs(&app, &outcomes, mode)?;
outcomes.extend(docs);
Ok(outcomes)
}
#[cfg(feature = "docs")]
fn regenerate_docs(
app: &App,
outcomes: &[GenOutcome],
mode: GenMode,
) -> Result<Vec<GenOutcome>, GenError> {
if !crate::docs::keeps_docs(app) {
return Ok(Vec::new());
}
if !outcomes.iter().any(|outcome| outcome.change().is_some()) {
return crate::docs::sync_docs(app, mode);
}
let reloaded = sofie_model::load(&app.root).map_err(GenError::Model)?;
crate::docs::sync_docs(&reloaded, mode)
}
#[cfg(not(feature = "docs"))]
#[expect(
clippy::unnecessary_wraps,
reason = "the same signature as with `docs`"
)]
fn regenerate_docs(
_app: &App,
_outcomes: &[GenOutcome],
_mode: GenMode,
) -> Result<Vec<GenOutcome>, GenError> {
Ok(Vec::new())
}
#[cfg(feature = "docs")]
pub(crate) fn checked_model(root: &Path) -> Result<App, GenError> {
checked(root).map(|(app, _, _)| app)
}
#[cfg(feature = "docs")]
fn checked(root: &Path) -> Result<(App, Screens, Option<AppLanguages>), GenError> {
let app = sofie_model::load(root).map_err(GenError::Model)?;
let (screens, languages) = checked_parts(&app)?;
Ok((app, screens, languages))
}
fn checked_parts(app: &App) -> Result<(Screens, Option<AppLanguages>), GenError> {
let screens = Screens::read(&app.modules);
let languages = AppLanguages::read(app)?;
let mut findings = sofie_check::check(app, &screens);
if let Some(languages) = &languages {
findings.extend(languages.findings(app, &screens)?);
}
if !findings.is_empty() {
return Err(GenError::Check(findings));
}
Ok((screens, languages))
}
pub(crate) fn sync_generated(
app: &App,
screens: &Screens,
languages: Option<&AppLanguages>,
mode: GenMode,
) -> Result<Vec<GenOutcome>, GenError> {
let role_migrations = missing_role_migrations(app);
let app = &with_role_migrations(app, &role_migrations);
let mut files: Vec<(PathBuf, Option<String>)> = role_migrations
.into_iter()
.map(|(path, text)| (path, Some(text)))
.collect();
let wiring = render_wiring(app, languages, &described_forms(app, screens)?);
let wiring_file = app.wiring_file();
files.push((wiring_file.clone(), Some(formatted(&wiring, &wiring_file)?)));
for module in &app.modules {
let file = module.krate.wiring_file();
let text = formatted(&render_module_wiring(app, module), &file)?;
files.push((file, Some(text)));
}
let contract_gens = render_contract_gens(app, languages)
.map_err(|error| GenError::App(format!("reading the translation keys: {error}")))?;
for (path, text) in contract_gens {
let text = text
.as_deref()
.map(|text| formatted(text, &path))
.transpose()?;
files.push((path, text));
}
files.extend(render_env_examples(app)?);
#[cfg(feature = "typescript")]
files.push(crate::islands::types_file(app));
files.push(render_agents_md(app)?);
let mut outcomes = Vec::new();
for (path, text) in files {
outcomes.extend(sync_file(&path, text, mode)?);
}
Ok(outcomes)
}
fn formatted(rust: &str, file: &Path) -> Result<String, GenError> {
format_rust(rust, file).map_err(GenError::Rustfmt)
}
pub(crate) fn sync_file(
path: &Path,
text: Option<String>,
mode: GenMode,
) -> Result<Option<GenOutcome>, GenError> {
let existing = read_existing(path)?;
let Some(text) = text else {
return remove_generated(path, existing.as_deref(), mode);
};
if existing.as_deref() == Some(text.as_str()) {
return Ok(Some(GenOutcome::UpToDate(path.to_path_buf())));
}
if mode == GenMode::Check {
return Ok(Some(GenOutcome::Stale(path.to_path_buf())));
}
write(path, &text)?;
Ok(Some(GenOutcome::Written(path.to_path_buf())))
}
fn remove_generated(
path: &Path,
existing: Option<&str>,
mode: GenMode,
) -> Result<Option<GenOutcome>, GenError> {
if !existing.is_some_and(is_generated) {
return Ok(None);
}
if mode == GenMode::Check {
return Ok(Some(GenOutcome::Stale(path.to_path_buf())));
}
fs::remove_file(path).map_err(|source| io_error(path, source))?;
Ok(Some(GenOutcome::Removed(path.to_path_buf())))
}
fn is_generated(text: &str) -> bool {
#[cfg(feature = "docs")]
if text.starts_with(crate::docs::DOCS_HEADER) {
return true;
}
text.starts_with(HEADER) || text.starts_with(ENV_HEADER)
}
pub(crate) fn read_existing(path: &Path) -> Result<Option<String>, GenError> {
match fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(source) => Err(io_error(path, source)),
}
}
pub(crate) fn readable_screen<'s>(
screens: &'s Screens,
file: &Path,
) -> Result<&'s Screen, GenError> {
screens
.screen(file)
.as_ref()
.map_err(|error| io_error(file, io::Error::new(error.kind(), error.to_string())))
}
pub(crate) fn io_error(path: &Path, source: io::Error) -> GenError {
GenError::Io {
path: path.to_path_buf(),
source,
}
}