Skip to content
use std::fs;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::Duration;

use xshell::Shell;

const FIG_BASE: &str = "https://fig.silenlocatelli.ch";
const WORKSPACES_DIR: &str = "workspaces";
const CONFIG_PATH: &str = "lovelace.toml";

fn main() {
    let config = Config::load(CONFIG_PATH).unwrap_or_else(|e| {
        eprintln!("error: failed to load {CONFIG_PATH}: {e}");
        std::process::exit(1);
    });

    if config.workspaces.is_empty() {
        eprintln!("error: no workspaces configured in {CONFIG_PATH}");
        std::process::exit(1);
    }

    let outputs: Vec<Output> = config
        .outputs
        .iter()
        .filter_map(|name| Output::from_str(name))
        .collect();

    fs::create_dir_all(WORKSPACES_DIR).ok();

    let interval = Duration::from_secs(config.interval_seconds.max(5));
    eprintln!(
        "lovelace: scanning {} workspace(s) every {:?}",
        config.workspaces.len(),
        interval
    );

    // Infinite runner: every cycle we fork off one thread per workspace so the
    // bot is always scanning in parallel, then sleep until the next cycle.
    loop {
        let handles: Vec<_> = config
            .workspaces
            .iter()
            .map(|ws| {
                let ws = ws.clone();
                let outputs = outputs.clone();
                thread::spawn(move || {
                    if let Err(e) = scan_workspace(&ws, &outputs) {
                        eprintln!("[{ws}] scan failed: {e}");
                    }
                })
            })
            .collect();

        for h in handles {
            let _ = h.join();
        }

        eprintln!("lovelace: scan cycle complete, sleeping {:?}", interval);
        thread::sleep(interval);
    }
}

fn scan_workspace(workspace: &str, outputs: &[Output]) -> Result<(), ScanError> {
    let url = format!("{FIG_BASE}/{workspace}");
    let dir: PathBuf = Path::new(WORKSPACES_DIR).join(workspace);

    let fresh = !dir.exists();
    let sh = Shell::new()?;

    if fresh {
        eprintln!("[{workspace}] cloning {url} -> {}", dir.display());
        let o = xshell::cmd!(sh, "git clone {url} {dir}").output()?;
        if !o.status.success() {
            return Err(ScanError::Git("clone failed".into(), failure_text(&o)));
        }
    } else {
        // Fetch so we can compare local HEAD against the fetched tip.
        let o = xshell::cmd!(sh, "git -C {dir} fetch origin").output()?;
        if !o.status.success() {
            return Err(ScanError::Git("fetch failed".into(), failure_text(&o)));
        }
    }

    let local = xshell::cmd!(sh, "git -C {dir} rev-parse HEAD")
        .read()?
        .trim()
        .to_string();
    let remote = xshell::cmd!(sh, "git -C {dir} rev-parse FETCH_HEAD")
        .read()?
        .trim()
        .to_string();

    let short = if remote.len() >= 8 {
        &remote[..8]
    } else {
        &remote
    };

    if fresh {
        eprintln!("[{workspace}] freshly cloned at {short}");
    } else if local != remote {
        eprintln!("[{workspace}] new commit {short} detected, fast-forwarding");
        let o = xshell::cmd!(sh, "git -C {dir} merge --ff-only FETCH_HEAD").output()?;
        if !o.status.success() {
            return Err(ScanError::Git("pull failed".into(), failure_text(&o)));
        }
    } else {
        eprintln!("[{workspace}] no new commits");
        return Ok(());
    }

    // Only run CI if the project actually defines a `ci` mise task.
    if !has_ci_task(&sh, &dir)? {
        eprintln!("[{workspace}] no `ci` mise task, nothing to do");
        return Ok(());
    }

    eprintln!("[{workspace}] running `mise run ci`");
    let o = xshell::cmd!(sh, "mise -C {dir} run ci").output()?;
    let combined = combined_text(&o);

    for out in outputs {
        out.write(workspace, &combined);
    }

    if !o.status.success() {
        eprintln!("[{workspace}] `mise run ci` exited non-zero");
    }

    Ok(())
}

fn has_ci_task(sh: &Shell, dir: &Path) -> Result<bool, ScanError> {
    let o = xshell::cmd!(sh, "mise -C {dir} tasks").output()?;
    if !o.status.success() {
        // If mise itself fails, treat it as "no ci task" rather than aborting.
        return Ok(false);
    }
    let text = String::from_utf8_lossy(&o.stdout);
    let has = text.lines().any(|line| {
        line.split_whitespace()
            .next()
            .map(|n| n == "ci")
            .unwrap_or(false)
    });
    Ok(has)
}

fn combined_text(o: &std::process::Output) -> String {
    let mut s = String::new();
    if !o.stdout.is_empty() {
        s.push_str(&String::from_utf8_lossy(&o.stdout));
    }
    if !o.stderr.is_empty() {
        if !s.is_empty() && !s.ends_with('\n') {
            s.push('\n');
        }
        s.push_str(&String::from_utf8_lossy(&o.stderr));
    }
    s.trim().to_string()
}

fn failure_text(o: &std::process::Output) -> String {
    let stderr = String::from_utf8_lossy(&o.stderr);
    let stdout = String::from_utf8_lossy(&o.stdout);
    if !stderr.trim().is_empty() {
        stderr.trim().to_string()
    } else {
        stdout.trim().to_string()
    }
}

// ---------------------------------------------------------------------------
// Output sinks
// ---------------------------------------------------------------------------

#[derive(Clone)]
enum Output {
    Console,
}

impl Output {
    fn from_str(name: &str) -> Option<Self> {
        match name.trim() {
            "console" => Some(Output::Console),
            other => {
                eprintln!("warning: unknown output `{other}`, ignoring");
                None
            }
        }
    }

    fn write(&self, workspace: &str, content: &str) {
        match self {
            Output::Console => {
                println!("----- `mise run ci` output for {workspace} -----");
                println!("{content}");
                let sep = "-".repeat(workspace.len());
                println!("----------------------------------------{sep}");
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Config (minimal TOML parser — no external deps)
// ---------------------------------------------------------------------------

#[derive(Debug, Default)]
struct Config {
    workspaces: Vec<String>,
    outputs: Vec<String>,
    interval_seconds: u64,
}

impl Config {
    fn load(path: &str) -> Result<Self, std::io::Error> {
        let text = fs::read_to_string(path)?;
        Ok(Self::parse(&text))
    }

    fn parse(text: &str) -> Self {
        let mut cfg = Config {
            interval_seconds: 300,
            ..Default::default()
        };

        for raw in text.lines() {
            // strip comments
            let line = match raw.find('#') {
                Some(i) => &raw[..i],
                None => raw,
            };
            let line = line.trim();
            if line.is_empty() {
                continue;
            }
            let (key, val) = match line.split_once('=') {
                Some(kv) => kv,
                None => continue,
            };
            let key = key.trim();
            let val = val.trim();
            match key {
                "workspaces" => cfg.workspaces = parse_string_array(val),
                "outputs" => cfg.outputs = parse_string_array(val),
                "interval_seconds" => {
                    if let Ok(n) = val.parse::<u64>() {
                        cfg.interval_seconds = n;
                    }
                }
                _ => {}
            }
        }

        cfg
    }
}

fn parse_string_array(val: &str) -> Vec<String> {
    let inner = val.trim().trim_start_matches('[').trim_end_matches(']');
    inner
        .split(',')
        .map(|s| s.trim())
        .filter(|s| !s.is_empty())
        .map(|s| s.trim_matches('"').trim_matches('\'').to_string())
        .collect()
}

// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------

#[derive(Debug)]
enum ScanError {
    Shell(String),
    Git(String, String),
}

impl std::fmt::Display for ScanError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ScanError::Shell(m) => write!(f, "shell error: {m}"),
            ScanError::Git(m, detail) => {
                if detail.is_empty() {
                    write!(f, "git: {m}")
                } else {
                    write!(f, "git: {m}: {detail}")
                }
            }
        }
    }
}

impl From<xshell::Error> for ScanError {
    fn from(e: xshell::Error) -> Self {
        ScanError::Shell(e.to_string())
    }
}

impl From<std::io::Error> for ScanError {
    fn from(e: std::io::Error) -> Self {
        ScanError::Shell(e.to_string())
    }
}