arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
use std::fs;
use std::path::{Component, Path, PathBuf};

use serde::Deserialize;

use super::{Frontend, ProjectError};

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct ProjectConfig {
    #[serde(skip)]
    root: PathBuf,
    pub(crate) frontend: Frontend,
    pub(crate) frontend_dir: PathBuf,
    pub(crate) backend_package: String,
    pub(crate) backend_binary: String,
    pub(crate) backend_port: u16,
    /// The application source root (ADR-0008). Canonical starters set this
    /// to `"app"`; existing applications omit it and default to `"src"`.
    /// `arc make` writes feature files here; `arc dev` watches it for
    /// backend rebuilds. Validated like `frontend_dir`: relative, normal
    /// components only.
    #[serde(default = "default_backend_src_dir")]
    pub(crate) backend_src_dir: PathBuf,
}

fn default_backend_src_dir() -> PathBuf {
    PathBuf::from("src")
}

impl ProjectConfig {
    pub(crate) fn load(root: &Path) -> Result<Self, ProjectError> {
        let path = root.join("arcature.toml");
        let source = fs::read_to_string(&path).map_err(|source| ProjectError::ReadConfig {
            path: path.clone(),
            source,
        })?;
        let mut config: Self = toml::from_str(&source)
            .map_err(|source| ProjectError::InvalidConfig { path, source })?;
        config.validate()?;
        config.root = root.to_path_buf();
        Ok(config)
    }

    pub(crate) fn root(&self) -> &Path {
        &self.root
    }
    pub(crate) fn frontend_root(&self) -> PathBuf {
        self.root.join(&self.frontend_dir)
    }

    fn validate(&self) -> Result<(), ProjectError> {
        validate_relative_dir(&self.frontend_dir, "frontend_dir")?;
        validate_relative_dir(&self.backend_src_dir, "backend_src_dir")?;
        super::ProjectName::parse(&self.backend_package).map_err(|_| {
            ProjectError::InvalidField {
                field: "backend_package",
                value: self.backend_package.clone(),
            }
        })?;
        super::ProjectName::parse(&self.backend_binary).map_err(|_| {
            ProjectError::InvalidField {
                field: "backend_binary",
                value: self.backend_binary.clone(),
            }
        })?;
        if self.backend_port == 0 {
            return Err(ProjectError::InvalidField {
                field: "backend_port",
                value: self.backend_port.to_string(),
            });
        }
        Ok(())
    }
}

/// Validate that a directory field is a non-empty, relative path with only
/// normal components (no `..`, no absolute, no prefix) — shared by
/// `frontend_dir` and `backend_src_dir`.
fn validate_relative_dir(value: &Path, field: &'static str) -> Result<(), ProjectError> {
    if value.as_os_str().is_empty()
        || value.is_absolute()
        || value
            .components()
            .any(|part| !matches!(part, Component::Normal(_)))
    {
        return Err(ProjectError::InvalidField {
            field,
            value: value.display().to_string(),
        });
    }
    Ok(())
}