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())
}
}