cargo-teaql 0.1.1

TeaQL code generation CLI with cargo-style alias support
Documentation
use std::{
    env, fs,
    path::{Path, PathBuf},
};

use anyhow::{Context, Result};
use dialoguer::Input;
use serde::{Deserialize, Serialize};

const DEFAULT_SERVICE_URL: &str =
    "http://springboot.teaql-gen-code.1496855407387739.cn-chengdu.fc.devsapp.net/generate";
const DEFAULT_BUILD_DIR: &str = "build";
const DEFAULT_TIMEOUT_SECONDS: u64 = 300;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TeaqlConfig {
    #[serde(default = "default_service_url")]
    pub service_url: String,
    #[serde(default)]
    pub license_file: Option<PathBuf>,
    #[serde(default = "default_build_dir")]
    pub build_dir: PathBuf,
    #[serde(default = "default_timeout_seconds")]
    pub timeout_seconds: u64,
}

#[derive(Debug, Clone)]
pub struct ConfigOverrides {
    pub service_url: Option<String>,
    pub license_file: Option<PathBuf>,
    pub build_dir: Option<PathBuf>,
    pub timeout_seconds: Option<u64>,
}

#[derive(Debug, Clone)]
pub struct ResolvedConfig {
    pub service_url: String,
    pub license_file: PathBuf,
    pub build_dir: PathBuf,
    pub timeout_seconds: u64,
}

impl Default for TeaqlConfig {
    fn default() -> Self {
        Self {
            service_url: default_service_url(),
            license_file: None,
            build_dir: default_build_dir(),
            timeout_seconds: default_timeout_seconds(),
        }
    }
}

impl TeaqlConfig {
    pub fn load() -> Result<Self> {
        let path = config_file_path()?;
        if !path.exists() {
            return Ok(Self::default());
        }

        let content = fs::read_to_string(&path)
            .with_context(|| format!("failed to read {}", path.display()))?;
        let config: Self = serde_yaml::from_str(&content)
            .with_context(|| format!("failed to parse {}", path.display()))?;
        Ok(config)
    }

    pub fn save(&self, path: &Path) -> Result<()> {
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("failed to create {}", parent.display()))?;
        }

        let yaml = serde_yaml::to_string(self)?;
        fs::write(path, yaml).with_context(|| format!("failed to write {}", path.display()))?;
        Ok(())
    }

    pub fn resolve(&self, overrides: ConfigOverrides, cwd: &Path) -> ResolvedConfig {
        let service_url = overrides
            .service_url
            .unwrap_or_else(|| self.service_url.clone());
        let build_dir = normalize_path(
            overrides
                .build_dir
                .unwrap_or_else(|| self.build_dir.clone()),
            cwd,
        );
        let timeout_seconds = overrides.timeout_seconds.unwrap_or(self.timeout_seconds);
        let configured_license = overrides.license_file.or_else(|| self.license_file.clone());
        let license_file = configured_license
            .map(|path| normalize_path(path, cwd))
            .unwrap_or_else(default_license_path);

        ResolvedConfig {
            service_url,
            license_file,
            build_dir,
            timeout_seconds,
        }
    }
}

pub fn config_file_path() -> Result<PathBuf> {
    let home = env::var_os("HOME").context("HOME environment variable is not set")?;
    Ok(config_file_path_from_home(Path::new(&home)))
}

pub fn run_wizard(existing: TeaqlConfig) -> Result<TeaqlConfig> {
    let service_url = Input::new()
        .with_prompt("TeaQL service URL")
        .default(existing.service_url)
        .interact_text()?;

    let license_default = existing
        .license_file
        .unwrap_or_else(default_license_path)
        .display()
        .to_string();
    let license_file = Input::new()
        .with_prompt("License file path")
        .default(license_default)
        .interact_text()?;

    let build_dir = Input::new()
        .with_prompt("Build output directory")
        .default(existing.build_dir.display().to_string())
        .interact_text()?;

    let timeout_seconds = Input::new()
        .with_prompt("Request timeout (seconds)")
        .default(existing.timeout_seconds)
        .interact_text()?;

    Ok(TeaqlConfig {
        service_url,
        license_file: Some(PathBuf::from(license_file)),
        build_dir: PathBuf::from(build_dir),
        timeout_seconds,
    })
}

fn default_service_url() -> String {
    DEFAULT_SERVICE_URL.to_string()
}

fn default_build_dir() -> PathBuf {
    PathBuf::from(DEFAULT_BUILD_DIR)
}

fn default_timeout_seconds() -> u64 {
    DEFAULT_TIMEOUT_SECONDS
}

fn default_license_path() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("assets")
        .join("public.LICENSE")
}

fn normalize_path(path: PathBuf, cwd: &Path) -> PathBuf {
    if path.is_absolute() {
        path
    } else {
        cwd.join(path)
    }
}

fn config_file_path_from_home(home: &Path) -> PathBuf {
    home.join(".teaql").join("config.yml")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn config_file_path_uses_home_directory() {
        let path = config_file_path_from_home(Path::new("/tmp/alice"));
        assert_eq!(path, PathBuf::from("/tmp/alice/.teaql/config.yml"));
    }

    #[test]
    fn resolve_uses_defaults_and_normalizes_relative_paths() {
        let cwd = Path::new("/workspace/project");
        let config = TeaqlConfig {
            service_url: "https://example.com/generate".to_string(),
            license_file: Some(PathBuf::from("licenses/public.LICENSE")),
            build_dir: PathBuf::from("dist"),
            timeout_seconds: 42,
        };

        let resolved = config.resolve(
            ConfigOverrides {
                service_url: None,
                license_file: None,
                build_dir: None,
                timeout_seconds: None,
            },
            cwd,
        );

        assert_eq!(resolved.service_url, "https://example.com/generate");
        assert_eq!(
            resolved.license_file,
            PathBuf::from("/workspace/project/licenses/public.LICENSE")
        );
        assert_eq!(resolved.build_dir, PathBuf::from("/workspace/project/dist"));
        assert_eq!(resolved.timeout_seconds, 42);
    }

    #[test]
    fn resolve_applies_overrides() {
        let cwd = Path::new("/workspace/project");
        let config = TeaqlConfig::default();

        let resolved = config.resolve(
            ConfigOverrides {
                service_url: Some("https://override.test/generate".to_string()),
                license_file: Some(PathBuf::from("/tmp/license.txt")),
                build_dir: Some(PathBuf::from("custom-build")),
                timeout_seconds: Some(15),
            },
            cwd,
        );

        assert_eq!(resolved.service_url, "https://override.test/generate");
        assert_eq!(resolved.license_file, PathBuf::from("/tmp/license.txt"));
        assert_eq!(
            resolved.build_dir,
            PathBuf::from("/workspace/project/custom-build")
        );
        assert_eq!(resolved.timeout_seconds, 15);
    }
}