xagent-pi 0.2.5

Self-contained local brain (chat UI + API + SSE) for the Pi agent, tunneled into xagent-service.
//! Runtime configuration loaded from ~/.xagent/setting.json.

use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::io::{self, IsTerminal, Write};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};

fn default_pi_path() -> String {
    "pi".to_string()
}
fn default_brain_addr() -> String {
    "127.0.0.1:15000".to_string()
}
fn default_service_url() -> String {
    "ws://127.0.0.1:18080".to_string()
}
fn default_cwd() -> PathBuf {
    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Settings {
    #[serde(default)]
    pub username: String,
    #[serde(default)]
    pub password: String,
    #[serde(default = "default_service_url")]
    pub service_url: String,
    #[serde(default = "default_cwd")]
    pub cwd: PathBuf,
    #[serde(default = "default_pi_path")]
    pub pi_path: String,
    #[serde(default = "default_brain_addr")]
    pub brain_addr: String,
    #[serde(default)]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub data_dir: Option<PathBuf>,
}

impl Default for Settings {
    fn default() -> Self {
        Self {
            username: String::new(),
            password: String::new(),
            service_url: default_service_url(),
            cwd: default_cwd(),
            pi_path: default_pi_path(),
            brain_addr: default_brain_addr(),
            data_dir: None,
        }
    }
}

#[derive(Debug, Clone)]
pub struct AccountConfig {
    pub username: String,
    pub password: String,
}

impl Settings {
    pub fn load() -> anyhow::Result<Self> {
        let path = settings_path()?;
        if !path.exists() {
            std::fs::create_dir_all(data_home()?)?;
            Self::default().save(&path)?;
            println!("已创建默认配置: {}", path.display());
        }
        let content = std::fs::read_to_string(&path)
            .with_context(|| format!("读取配置失败: {}", path.display()))?;
        let mut settings: Self = serde_json::from_str(&content)
            .with_context(|| format!("配置格式错误: {}", path.display()))?;
        settings.apply_env();
        if settings.needs_onboarding() {
            if !io::stdin().is_terminal() {
                anyhow::bail!(
                    "配置尚未完成,请编辑 {},填写 serviceUrl、username 和 password",
                    path.display()
                );
            }
            settings.onboard()?;
            settings.save(&path)?;
            println!("配置已保存,正在启动...");
        }
        settings.validate()?;
        settings.cwd = settings
            .cwd
            .canonicalize()
            .with_context(|| format!("工作目录不存在: {}", settings.cwd.display()))?;
        if !settings.cwd.is_dir() {
            anyhow::bail!("cwd 不是目录: {}", settings.cwd.display());
        }
        settings
            .data_dir
            .get_or_insert(data_home()?.join("sessions"));
        Ok(settings)
    }

    fn apply_env(&mut self) {
        if let Ok(value) = std::env::var("XAGENT_SERVICE_URL") {
            self.service_url = value;
        }
        if let Ok(value) = std::env::var("XAGENT_USERNAME") {
            self.username = value;
        }
        if let Ok(value) = std::env::var("XAGENT_PASSWORD") {
            self.password = value;
        }
        if let Some(value) = std::env::var_os("XAGENT_CWD") {
            self.cwd = PathBuf::from(value);
        }
        if let Ok(value) = std::env::var("XAGENT_PI_PATH") {
            self.pi_path = value;
        }
        if let Ok(value) = std::env::var("XAGENT_BRAIN_ADDR") {
            self.brain_addr = value;
        }
        if let Some(value) = std::env::var_os("XAGENT_DATA_DIR") {
            self.data_dir = Some(PathBuf::from(value));
        }
    }

    fn needs_onboarding(&self) -> bool {
        self.username.trim().is_empty()
            || self.password.len() < 6
            || self.service_url.trim().is_empty()
    }

    fn onboard(&mut self) -> anyhow::Result<()> {
        println!("首次使用 xagent-pi,请完成连接配置。");
        self.service_url = prompt("服务端地址", &self.service_url)?;
        self.username = prompt("用户名", &self.username)?;
        if self.password.len() < 6 {
            loop {
                let password = rpassword::prompt_password("密码(至少 6 个字符): ")?;
                if password.len() >= 6 {
                    self.password = password;
                    break;
                }
                println!("密码至少需要 6 个字符。");
            }
        }
        let cwd = prompt("工作目录", &self.cwd.to_string_lossy())?;
        self.cwd = PathBuf::from(cwd);
        Ok(())
    }

    fn validate(&self) -> anyhow::Result<()> {
        if self.username.trim().is_empty() {
            anyhow::bail!("用户名不能为空");
        }
        if self.password.len() < 6 {
            anyhow::bail!("密码至少需要 6 个字符");
        }
        if self.service_url.trim().is_empty() {
            anyhow::bail!("服务端地址不能为空");
        }
        Ok(())
    }

    fn save(&self, path: &Path) -> anyhow::Result<()> {
        std::fs::write(path, format!("{}\n", serde_json::to_string_pretty(self)?))?;
        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
        Ok(())
    }

    pub fn data_dir(&self) -> &Path {
        self.data_dir.as_deref().expect("dataDir initialized")
    }
    pub fn account(&self) -> AccountConfig {
        AccountConfig {
            username: self.username.clone(),
            password: self.password.clone(),
        }
    }
}

fn prompt(label: &str, current: &str) -> anyhow::Result<String> {
    loop {
        if current.is_empty() {
            print!("{label}: ");
        } else {
            print!("{label} [{current}]: ");
        }
        io::stdout().flush()?;
        let mut value = String::new();
        if io::stdin().read_line(&mut value)? == 0 {
            anyhow::bail!("输入已取消");
        }
        let value = value.trim();
        let selected = if value.is_empty() { current } else { value };
        if !selected.is_empty() {
            return Ok(selected.to_string());
        }
        println!("{label}不能为空。");
    }
}

pub fn data_home() -> anyhow::Result<PathBuf> {
    let home = std::env::var_os("HOME").context("HOME 环境变量未设置")?;
    Ok(PathBuf::from(home).join(".xagent"))
}

pub fn settings_path() -> anyhow::Result<PathBuf> {
    Ok(data_home()?.join("setting.json"))
}
pub fn pid_path() -> anyhow::Result<PathBuf> {
    Ok(data_home()?.join("xagent-pi.pid"))
}
pub fn log_path() -> anyhow::Result<PathBuf> {
    Ok(data_home()?.join("xagent-pi.log"))
}