rustex-project 0.1.0

Project configuration and layout resolution for Rustex.
Documentation
use anyhow::{Context, Result};
use camino::{Utf8Path, Utf8PathBuf};
use serde::{Deserialize, Serialize};
use tracing::debug;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RustexConfig {
    pub project_root: Utf8PathBuf,
    pub convex_root: Utf8PathBuf,
    pub out_dir: Utf8PathBuf,
    #[serde(default = "default_emit")]
    pub emit: Vec<String>,
    #[serde(default)]
    pub strict: bool,
    #[serde(default)]
    pub allow_inferred_returns: bool,
    #[serde(default = "default_naming")]
    pub naming_strategy: String,
    #[serde(default = "default_id_style")]
    pub id_style: String,
    #[serde(default)]
    pub custom_derives: Vec<String>,
    #[serde(default)]
    pub custom_attributes: Vec<String>,
}

fn default_emit() -> Vec<String> {
    vec!["rust".into(), "manifest".into(), "ir".into()]
}

fn default_naming() -> String {
    "safe".into()
}

fn default_id_style() -> String {
    "newtype_per_table".into()
}

impl Default for RustexConfig {
    fn default() -> Self {
        Self {
            project_root: Utf8PathBuf::from("."),
            convex_root: Utf8PathBuf::from("./convex"),
            out_dir: Utf8PathBuf::from("./generated/rustex"),
            emit: default_emit(),
            strict: false,
            allow_inferred_returns: true,
            naming_strategy: default_naming(),
            id_style: default_id_style(),
            custom_derives: Vec::new(),
            custom_attributes: Vec::new(),
        }
    }
}

#[derive(Debug, Clone)]
pub struct ProjectLayout {
    pub root: Utf8PathBuf,
    pub convex_root: Utf8PathBuf,
    pub out_dir: Utf8PathBuf,
    pub config_path: Utf8PathBuf,
    pub discovered_convex_roots: Vec<Utf8PathBuf>,
    pub component_roots: Vec<Utf8PathBuf>,
}

pub fn load_config(root: &Utf8Path) -> Result<(RustexConfig, ProjectLayout)> {
    let _span = tracing::info_span!("rustex_project.load_config", root = %root).entered();
    let config_path = root.join("rustex.toml");
    let raw = std::fs::read_to_string(&config_path)
        .with_context(|| format!("failed to read config at {config_path}"))?;
    let config: RustexConfig =
        toml::from_str(&raw).with_context(|| format!("failed to parse {config_path}"))?;

    if !root.exists() {
        anyhow::bail!("project root does not exist: {root}");
    }

    let discovered_convex_roots = discover_convex_roots(root);
    let configured_convex_root = absolutize(root, &config.convex_root);
    let convex_root = if configured_convex_root.exists() {
        configured_convex_root
    } else if discovered_convex_roots.len() == 1 {
        discovered_convex_roots[0].clone()
    } else {
        configured_convex_root
    };

    let layout = ProjectLayout {
        root: root.to_path_buf(),
        convex_root: convex_root.clone(),
        out_dir: absolutize(root, &config.out_dir),
        config_path,
        component_roots: discover_component_roots(&convex_root),
        discovered_convex_roots,
    };

    validate_layout(&layout)?;
    debug!(
        convex_root = %display_path(&layout.convex_root, &layout.root),
        out_dir = %display_path(&layout.out_dir, &layout.root),
        "resolved project layout"
    );

    Ok((config, layout))
}

fn absolutize(root: &Utf8Path, path: &Utf8Path) -> Utf8PathBuf {
    if path.is_absolute() {
        path.to_path_buf()
    } else {
        root.join(path)
    }
}

fn display_path(path: &Utf8Path, root: &Utf8Path) -> String {
    path.strip_prefix(root)
        .map(Utf8Path::to_string)
        .unwrap_or_else(|_| path.to_string())
}

fn validate_layout(layout: &ProjectLayout) -> Result<()> {
    if !layout.root.is_dir() {
        anyhow::bail!("project root is not a directory: {}", layout.root);
    }

    if !layout.config_path.is_file() {
        anyhow::bail!("missing rustex config: {}", layout.config_path);
    }

    if !layout.convex_root.exists() {
        let candidates = if layout.discovered_convex_roots.is_empty() {
            String::new()
        } else {
            format!(
                " discovered candidates: {}",
                layout
                    .discovered_convex_roots
                    .iter()
                    .map(ToString::to_string)
                    .collect::<Vec<_>>()
                    .join(", ")
            )
        };
        anyhow::bail!(
            "convex root does not exist: {}. rustex supports standard convex/ layouts and can auto-detect common monorepo locations.{}",
            layout.convex_root,
            candidates
        );
    }

    if !layout.convex_root.is_dir() {
        anyhow::bail!("convex root is not a directory: {}", layout.convex_root);
    }

    let schema_path = layout.convex_root.join("schema.ts");
    let generated_dir = layout.convex_root.join("_generated");
    if !schema_path.is_file() && !generated_dir.is_dir() {
        anyhow::bail!(
            "unsupported convex layout at {}: expected schema.ts or _generated/ metadata",
            layout.convex_root
        );
    }

    Ok(())
}

fn discover_convex_roots(root: &Utf8Path) -> Vec<Utf8PathBuf> {
    let mut candidates = vec![root.join("convex")];
    for base in ["apps", "packages"] {
        let dir = root.join(base);
        if let Ok(entries) = std::fs::read_dir(&dir) {
            for entry in entries.flatten() {
                let path = entry.path().join("convex");
                if let Ok(path) = Utf8PathBuf::from_path_buf(path) {
                    candidates.push(path);
                }
            }
        }
    }
    candidates
        .into_iter()
        .filter(|path| path.is_dir())
        .collect()
}

fn discover_component_roots(convex_root: &Utf8Path) -> Vec<Utf8PathBuf> {
    let components_dir = convex_root.join("components");
    let Ok(entries) = std::fs::read_dir(&components_dir) else {
        return Vec::new();
    };
    entries
        .flatten()
        .filter_map(|entry| Utf8PathBuf::from_path_buf(entry.path()).ok())
        .filter(|path| path.is_dir())
        .collect()
}