fgate 0.1.0

A build gate for coding agents sharing one Windows machine: builds wait their turn, within CPU and memory ceilings
//! fgate's settings: one file per machine, so every agent counts the same slots. At night (local time) stricter
//! limits take over, so unattended agents can't run the machine out of memory.

use std::path::{Path, PathBuf};

pub struct Config {
    pub dir: PathBuf,
    /// Builds and test runs at once.
    pub build_slots: usize,
    /// GPU runs at once (games, renderers, screenshot scripts).
    pub gpu_slots: usize,
    /// `CARGO_BUILD_JOBS` for a gated cargo command that doesn't set its own.
    pub jobs: u32,
    /// Each gated command's share of all CPU threads, a hard ceiling (0 or 100: none).
    pub cpu_percent: u32,
    /// Each gated command's memory ceiling, everything it starts together (0: none).
    pub mem_gb: u32,
    /// A second command waits while less memory than this is free (at night, any command).
    pub min_free_gb: u32,
    /// The night's local hours, `from` until `until` (wrapping past midnight).
    pub night_from: u32,
    pub night_until: u32,
    pub night_build_slots: usize,
    pub night_mem_gb: u32,
    pub night_min_free_gb: u32,
    /// Whether the night limits are in force for this command.
    pub night: bool,
}

const KEYS: [&str; 11] = [
    "build_slots",
    "gpu_slots",
    "jobs",
    "cpu_percent",
    "mem_gb",
    "min_free_gb",
    "night_from",
    "night_until",
    "night_build_slots",
    "night_mem_gb",
    "night_min_free_gb",
];

impl Config {
    pub fn load() -> Config {
        let dir = std::env::var_os("FGATE_DIR")
            .map(PathBuf::from)
            .unwrap_or_else(|| {
                let base = std::env::var_os("LOCALAPPDATA")
                    .map(PathBuf::from)
                    .unwrap_or_else(std::env::temp_dir);
                base.join("fgate")
            });
        let total_gb = (crate::job::memory().0 as f64 / (1u64 << 30) as f64).round() as u32;
        let mut c = Config {
            dir,
            build_slots: 1,
            gpu_slots: 1,
            jobs: 6,
            cpu_percent: 40,
            mem_gb: (total_gb * 2 / 5).max(8),
            min_free_gb: 6,
            night_from: 23,
            night_until: 7,
            night_build_slots: 1,
            night_mem_gb: 10,
            night_min_free_gb: 8,
            night: false,
        };
        let file = c.file();
        match std::fs::read_to_string(&file) {
            Ok(text) => {
                for line in text.lines() {
                    let line = line.split('#').next().unwrap_or("").trim();
                    if let Some((key, value)) = line.split_once('=') {
                        c.set(key.trim(), value.trim());
                    }
                }
                if !text.contains("night_from") {
                    c.append_night(&file, &text);
                }
            }
            Err(_) => c.write_default(&file),
        }
        for key in KEYS {
            if let Ok(value) = std::env::var(format!("FGATE_{}", key.to_uppercase())) {
                c.set(key, &value);
            }
        }
        c.night = is_night(crate::job::local_hour(), c.night_from, c.night_until);
        if c.night {
            c.build_slots = c.night_build_slots;
            c.mem_gb = c.night_mem_gb;
            c.min_free_gb = c.night_min_free_gb;
        }
        c.build_slots = c.build_slots.max(1);
        c.gpu_slots = c.gpu_slots.max(1);
        c
    }

    pub fn file(&self) -> PathBuf {
        self.dir.join("config.txt")
    }

    pub fn mem_bytes(&self) -> u64 {
        self.mem_gb as u64 * (1u64 << 30)
    }

    fn set(&mut self, key: &str, value: &str) {
        let Ok(n) = value.parse::<u32>() else {
            eprintln!("fgate: ignoring setting {key} = {value} (not a whole number)");
            return;
        };
        match key {
            "build_slots" => self.build_slots = n as usize,
            "gpu_slots" => self.gpu_slots = n as usize,
            "jobs" => self.jobs = n.max(1),
            "cpu_percent" => self.cpu_percent = n.min(100),
            "mem_gb" => self.mem_gb = n,
            "min_free_gb" => self.min_free_gb = n,
            "night_from" => self.night_from = n.min(24),
            "night_until" => self.night_until = n.min(24),
            "night_build_slots" => self.night_build_slots = n as usize,
            "night_mem_gb" => self.night_mem_gb = n,
            "night_min_free_gb" => self.night_min_free_gb = n,
            _ => eprintln!("fgate: unknown setting {key}"),
        }
    }

    fn night_text(&self) -> String {
        format!(
            "# Night limits, local hours night_from until night_until (0 and 24: always). At night a command\n\
             # also waits for free memory when it would run alone, so unattended agents can't run out of memory.\n\
             night_from = {}\n\
             night_until = {}\n\
             night_build_slots = {}\n\
             night_mem_gb = {}\n\
             night_min_free_gb = {}\n",
            self.night_from,
            self.night_until,
            self.night_build_slots,
            self.night_mem_gb,
            self.night_min_free_gb
        )
    }

    fn append_night(&self, file: &Path, text: &str) {
        let gap = if text.ends_with('\n') { "" } else { "\n" };
        let _ = std::fs::write(file, format!("{text}{gap}{}", self.night_text()));
    }

    fn write_default(&self, file: &Path) {
        let text = format!(
            "# fgate settings for this machine (https://github.com/alexander-frase/fgate). Every agent reads this file,\n\
             # so all count the same slots.\n\
             # Builds and test runs at once:\n\
             build_slots = {}\n\
             # GPU runs at once (games, renderers, screenshot scripts):\n\
             gpu_slots = {}\n\
             # Compiler jobs for each gated cargo command (CARGO_BUILD_JOBS) unless it sets its own:\n\
             jobs = {}\n\
             # Each gated command's hard CPU ceiling, percent of all threads (0 = none):\n\
             cpu_percent = {}\n\
             # Each gated command's memory ceiling in GB, everything it starts together (0 = none):\n\
             mem_gb = {}\n\
             # A second command waits while less than this many GB are free:\n\
             min_free_gb = {}\n{}",
            self.build_slots,
            self.gpu_slots,
            self.jobs,
            self.cpu_percent,
            self.mem_gb,
            self.min_free_gb,
            self.night_text()
        );
        let _ = std::fs::create_dir_all(&self.dir);
        let _ = std::fs::write(file, text);
    }
}

/// Whether `hour` falls in the night `from`..`until`, which may wrap past midnight.
fn is_night(hour: u32, from: u32, until: u32) -> bool {
    if from == until {
        false
    } else if from < until {
        (from..until).contains(&hour)
    } else {
        hour >= from || hour < until
    }
}

#[cfg(test)]
mod tests {
    use super::is_night;

    #[test]
    fn night_hours_wrap_past_midnight() {
        assert!(is_night(23, 23, 7) && is_night(0, 23, 7) && is_night(6, 23, 7));
        assert!(!is_night(7, 23, 7) && !is_night(22, 23, 7) && !is_night(12, 23, 7));
        assert!(is_night(12, 0, 24) && !is_night(5, 5, 5));
    }
}