devist 0.7.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;

use crate::paths;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkerConfig {
    /// Root folder containing projects to monitor (recursive watch)
    pub monitor_dir: PathBuf,
    /// Local SQLite database path
    #[serde(default = "default_db_path")]
    pub db_path: PathBuf,
    /// Supabase REST URL for sync (optional, sync stub for now)
    #[serde(default)]
    pub supabase_url: Option<String>,
    /// Supabase service key for sync
    #[serde(default)]
    pub supabase_key: Option<String>,
    /// Sync interval in seconds (batch flush to Supabase)
    #[serde(default = "default_sync_interval")]
    pub sync_interval_secs: u64,
    /// Debounce window for file events (ms)
    #[serde(default = "default_debounce_ms")]
    pub debounce_ms: u64,

    // -- mem0 + advice generation --
    /// mem0 cloud API key (https://app.mem0.ai)
    #[serde(default)]
    pub mem0_api_key: Option<String>,
    /// mem0 user_id partition (defaults to hostname at first run)
    #[serde(default)]
    pub mem0_user_id: Option<String>,
    /// Master kill switch for advice generation (LLM + mem0 calls)
    #[serde(default = "default_true")]
    pub advice_enabled: bool,
    /// Quiet seconds after last file event before processing the burst
    #[serde(default = "default_advice_idle")]
    pub advice_idle_seconds: u64,
    /// Skip burst smaller than this many events
    #[serde(default = "default_advice_min_batch")]
    pub advice_min_batch: usize,
    /// Hard cap: advice generations per project per hour
    #[serde(default = "default_advice_max_per_hour")]
    pub advice_max_per_hour: u32,
    /// Hard cap: mem0 writes per hour (across all projects)
    #[serde(default = "default_mem0_max_writes_per_hour")]
    pub mem0_max_writes_per_hour: u32,
    /// Minimum confidence (0.0-1.0) for a fact to be persisted to mem0
    #[serde(default = "default_mem0_confidence_threshold")]
    pub mem0_confidence_threshold: f32,
    /// Path to claude CLI binary (defaults to "claude" on PATH)
    #[serde(default = "default_claude_bin")]
    pub claude_bin: String,
    /// Stable identifier for this client (host) — used for Supabase push idempotency
    #[serde(default)]
    pub client_id: Option<String>,
}

fn default_db_path() -> PathBuf {
    paths::worker_db_file().unwrap_or_else(|_| PathBuf::from("worker.db"))
}
fn default_sync_interval() -> u64 {
    30
}
fn default_debounce_ms() -> u64 {
    500
}
fn default_true() -> bool {
    true
}
fn default_advice_idle() -> u64 {
    60
}
fn default_advice_min_batch() -> usize {
    3
}
fn default_advice_max_per_hour() -> u32 {
    6
}
fn default_mem0_max_writes_per_hour() -> u32 {
    20
}
fn default_mem0_confidence_threshold() -> f32 {
    0.7
}
fn default_claude_bin() -> String {
    "claude".to_string()
}

impl WorkerConfig {
    pub fn load() -> Result<Self> {
        let path = paths::worker_config_file()?;
        if !path.exists() {
            return Err(anyhow!(
                "Worker config not found at {}. Run `devist worker start` to set up.",
                path.display()
            ));
        }
        let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
        let cfg: WorkerConfig =
            toml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
        Ok(cfg)
    }

    pub fn save(&self) -> Result<()> {
        let dir = paths::worker_dir()?;
        fs::create_dir_all(&dir)?;
        let path = paths::worker_config_file()?;
        let text = toml::to_string_pretty(self)?;
        fs::write(&path, text).with_context(|| format!("write {}", path.display()))?;
        Ok(())
    }

    pub fn exists() -> bool {
        paths::worker_config_file()
            .map(|p| p.exists())
            .unwrap_or(false)
    }

    pub fn new_default(monitor_dir: PathBuf) -> Result<Self> {
        Ok(WorkerConfig {
            monitor_dir,
            db_path: paths::worker_db_file()?,
            supabase_url: None,
            supabase_key: None,
            sync_interval_secs: default_sync_interval(),
            debounce_ms: default_debounce_ms(),
            mem0_api_key: None,
            mem0_user_id: hostname(),
            advice_enabled: default_true(),
            advice_idle_seconds: default_advice_idle(),
            advice_min_batch: default_advice_min_batch(),
            advice_max_per_hour: default_advice_max_per_hour(),
            mem0_max_writes_per_hour: default_mem0_max_writes_per_hour(),
            mem0_confidence_threshold: default_mem0_confidence_threshold(),
            claude_bin: default_claude_bin(),
            client_id: hostname(),
        })
    }

    /// Mutate by string key (used by `worker config set <key> <value>`).
    pub fn set_key(&mut self, key: &str, value: &str) -> Result<()> {
        match key {
            "monitor_dir" => self.monitor_dir = PathBuf::from(value),
            "supabase_url" => self.supabase_url = Some(value.to_string()),
            "supabase_key" => self.supabase_key = Some(value.to_string()),
            "sync_interval_secs" => {
                self.sync_interval_secs = value
                    .parse()
                    .context("sync_interval_secs must be a number")?
            }
            "debounce_ms" => {
                self.debounce_ms = value.parse().context("debounce_ms must be a number")?
            }
            "mem0_api_key" => self.mem0_api_key = Some(value.to_string()),
            "mem0_user_id" => self.mem0_user_id = Some(value.to_string()),
            "advice_enabled" => {
                self.advice_enabled = value.parse().context("advice_enabled must be true/false")?
            }
            "advice_idle_seconds" => {
                self.advice_idle_seconds = value.parse().context("must be a number")?
            }
            "advice_min_batch" => {
                self.advice_min_batch = value.parse().context("must be a number")?
            }
            "advice_max_per_hour" => {
                self.advice_max_per_hour = value.parse().context("must be a number")?
            }
            "mem0_max_writes_per_hour" => {
                self.mem0_max_writes_per_hour = value.parse().context("must be a number")?
            }
            "mem0_confidence_threshold" => {
                self.mem0_confidence_threshold =
                    value.parse().context("must be a number 0.0..=1.0")?
            }
            "claude_bin" => self.claude_bin = value.to_string(),
            "client_id" => self.client_id = Some(value.to_string()),
            _ => return Err(anyhow!("Unknown config key: {}", key)),
        }
        Ok(())
    }
}

fn hostname() -> Option<String> {
    std::process::Command::new("hostname")
        .output()
        .ok()
        .and_then(|o| String::from_utf8(o.stdout).ok())
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
}