devist 0.15.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
#![allow(dead_code)]
// One-way rules sync (Supabase → disk).
//
// Table is canonical:
//   - On startup, the on-disk global rules.md is uploaded once if the
//     table is empty for scope='global' (bootstrap, so existing content
//     isn't lost).
//   - Every poll interval, fetch all rows; if a row is newer than what
//     we last wrote, mirror it to disk.
//
// Per-project rules were removed in v0.12 — `scope` is always 'global'.
// Stale `project:*` rows are ignored (cleaned up by migration 0008).

use anyhow::{Context, Result};
use chrono::Local;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::thread;
use std::time::Duration;

use crate::paths;
use crate::worker::config::WorkerConfig;
use crate::worker::supabase::{RuleRow, SupabaseClient};

const POLL_INTERVAL_SECS: u64 = 10;

pub fn run(cfg: WorkerConfig) -> Result<()> {
    let client_id = cfg
        .client_id
        .clone()
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| "unknown".into());
    let (url, key) = match (&cfg.supabase_url, &cfg.supabase_key) {
        (Some(u), Some(k)) if !u.is_empty() && !k.is_empty() => (u.clone(), k.clone()),
        _ => {
            log_line("[rules-sync] disabled (Supabase not configured)");
            return Ok(());
        }
    };

    let supabase = SupabaseClient::new(&url, &key, &client_id)?;
    log_line("[rules-sync] thread up");

    // Bootstrap once.
    if let Err(e) = bootstrap(&supabase, &cfg) {
        log_line(&format!("[rules-sync] bootstrap err: {}", e));
    }

    // Track last-applied updated_at per scope so we don't re-write on every poll.
    let mut last_applied: HashMap<String, String> = HashMap::new();

    loop {
        let _ = supabase.heartbeat("rules-sync");
        match supabase.list_rules() {
            Ok(rows) => {
                for row in rows {
                    let prev = last_applied.get(&row.scope);
                    if prev.map(|s| s == &row.updated_at).unwrap_or(false) {
                        continue;
                    }
                    match write_to_disk(&cfg, &row) {
                        Ok(path) => {
                            log_line(&format!(
                                "[rules-sync] wrote {} (updated_at={})",
                                path.display(),
                                row.updated_at
                            ));
                            last_applied.insert(row.scope.clone(), row.updated_at.clone());
                        }
                        Err(e) => log_line(&format!(
                            "[rules-sync] write err for scope={}: {}",
                            row.scope, e
                        )),
                    }
                }
            }
            Err(e) => log_line(&format!("[rules-sync] list err: {}", e)),
        }
        thread::sleep(Duration::from_secs(POLL_INTERVAL_SECS));
    }
}

/// Push the on-disk global rules file once if the table doesn't have it yet.
fn bootstrap(supabase: &SupabaseClient, _cfg: &WorkerConfig) -> Result<()> {
    let existing: std::collections::HashSet<String> = supabase
        .list_rules()
        .unwrap_or_default()
        .into_iter()
        .map(|r| r.scope)
        .collect();

    let global_path = paths::worker_rules_file()?;
    if !existing.contains("global") {
        if let Ok(text) = fs::read_to_string(&global_path) {
            if !text.trim().is_empty() {
                supabase.upsert_rule("global", &text)?;
                log_line(&format!(
                    "[rules-sync] bootstrapped global rules from {}",
                    global_path.display()
                ));
            }
        }
    }

    Ok(())
}

fn write_to_disk(_cfg: &WorkerConfig, row: &RuleRow) -> Result<PathBuf> {
    let path = match row.scope.as_str() {
        "global" => paths::worker_rules_file()?,
        // Per-project rules were removed in v0.12. Ignore any stale rows
        // (cleanup via migration 0008).
        other => {
            return Err(anyhow::anyhow!(
                "Ignoring rule with unsupported scope: {}",
                other
            ))
        }
    };
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
    }
    fs::write(&path, &row.content).with_context(|| format!("write {}", path.display()))?;
    Ok(path)
}

fn log_line(msg: &str) {
    let now = Local::now().format("%Y-%m-%d %H:%M:%S");
    println!("{} {}", now, msg);
}