use std::path::{Path, PathBuf};
pub const PLATFORM_FILE: &str = "flux.yaml";
pub const PLATFORM_DIR: &str = ".flux.d";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AiProvider {
None,
External,
}
impl AiProvider {
fn parse(s: &str) -> AiProvider {
match s.trim() {
"external" => AiProvider::External,
_ => AiProvider::None,
}
}
fn as_str(&self) -> &'static str {
match self {
AiProvider::None => "none",
AiProvider::External => "external",
}
}
}
#[derive(Debug, Clone)]
pub struct PlatformConfig {
pub project_name: Option<String>,
pub pipeline_auto_detect: bool,
pub agents_enabled: bool,
pub ai_provider: AiProvider,
pub ai_command: Option<String>,
pub github_enabled: bool,
pub deployment_enabled: bool,
}
impl Default for PlatformConfig {
fn default() -> Self {
PlatformConfig {
project_name: None,
pipeline_auto_detect: true,
agents_enabled: true,
ai_provider: AiProvider::None,
ai_command: None,
github_enabled: true,
deployment_enabled: false,
}
}
}
impl PlatformConfig {
pub fn load(root: &Path) -> PlatformConfig {
let path = root.join(PLATFORM_FILE);
match std::fs::read_to_string(&path) {
Ok(src) => parse(&src),
Err(_) => PlatformConfig::default(),
}
}
pub fn exists(root: &Path) -> bool {
root.join(PLATFORM_FILE).is_file()
}
pub fn ai_command(&self) -> Option<&str> {
match self.ai_provider {
AiProvider::External => self.ai_command.as_deref().filter(|c| !c.is_empty()),
AiProvider::None => None,
}
}
pub fn render(&self) -> String {
let name = self.project_name.as_deref().unwrap_or("my-app");
let cmd = self.ai_command.as_deref().unwrap_or("");
format!(
"project:\n name: {name}\n\
pipeline:\n autoDetect: {}\n\
agents:\n enabled: {}\n\
ai:\n provider: {}\n command: \"{cmd}\"\n\
github:\n enabled: {}\n\
deployment:\n enabled: {}\n\
integrations:\n github: {}\n",
self.pipeline_auto_detect,
self.agents_enabled,
self.ai_provider.as_str(),
self.github_enabled,
self.deployment_enabled,
self.github_enabled,
)
}
pub fn dir(root: &Path) -> PathBuf {
root.join(PLATFORM_DIR)
}
}
fn parse(src: &str) -> PlatformConfig {
let mut cfg = PlatformConfig::default();
let mut section = String::new();
for raw in src.lines() {
let line = strip_comment(raw);
if line.trim().is_empty() {
continue;
}
let indent = line.len() - line.trim_start().len();
let trimmed = line.trim();
if indent == 0 {
section = trimmed.trim_end_matches(':').trim().to_string();
continue;
}
let Some((key, value)) = trimmed.split_once(':') else {
continue;
};
let key = key.trim();
let value = unquote(value.trim());
match (section.as_str(), key) {
("project", "name") => cfg.project_name = Some(value.to_string()),
("pipeline", "autoDetect") => cfg.pipeline_auto_detect = truthy(&value),
("agents", "enabled") => cfg.agents_enabled = truthy(&value),
("ai", "provider") => cfg.ai_provider = AiProvider::parse(&value),
("ai", "command") => {
cfg.ai_command = if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
("github", "enabled") | ("integrations", "github") => {
cfg.github_enabled = truthy(&value)
}
("deployment", "enabled") => cfg.deployment_enabled = truthy(&value),
_ => {}
}
}
cfg
}
fn strip_comment(line: &str) -> String {
let mut out = String::new();
let mut in_quote = false;
for c in line.chars() {
match c {
'"' => {
in_quote = !in_quote;
out.push(c);
}
'#' if !in_quote => break,
_ => out.push(c),
}
}
out
}
fn unquote(s: &str) -> String {
let s = s.trim();
if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
}
fn truthy(s: &str) -> bool {
matches!(s.trim(), "true" | "yes" | "on" | "1")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_sensible() {
let c = PlatformConfig::default();
assert!(c.pipeline_auto_detect);
assert!(c.agents_enabled);
assert_eq!(c.ai_provider, AiProvider::None);
assert!(c.ai_command().is_none());
}
#[test]
fn parses_a_full_config() {
let src = "\
project:\n name: demo\n\
pipeline:\n autoDetect: false\n\
agents:\n enabled: true\n\
ai:\n provider: external\n command: \"ollama run llama3\"\n\
github:\n enabled: true\n\
deployment:\n enabled: true\n";
let c = parse(src);
assert_eq!(c.project_name.as_deref(), Some("demo"));
assert!(!c.pipeline_auto_detect);
assert_eq!(c.ai_provider, AiProvider::External);
assert_eq!(c.ai_command(), Some("ollama run llama3"));
assert!(c.deployment_enabled);
}
#[test]
fn external_provider_without_command_is_not_usable() {
let src = "ai:\n provider: external\n command: \"\"\n";
let c = parse(src);
assert_eq!(c.ai_provider, AiProvider::External);
assert!(c.ai_command().is_none());
}
#[test]
fn comments_and_blanks_are_ignored() {
let src = "# top\nproject:\n name: x # inline\n\nagents:\n enabled: no\n";
let c = parse(src);
assert_eq!(c.project_name.as_deref(), Some("x"));
assert!(!c.agents_enabled);
}
#[test]
fn render_round_trips() {
let c = PlatformConfig {
project_name: Some("round".into()),
ai_provider: AiProvider::External,
ai_command: Some("claude -p".into()),
..PlatformConfig::default()
};
let reparsed = parse(&c.render());
assert_eq!(reparsed.project_name.as_deref(), Some("round"));
assert_eq!(reparsed.ai_command(), Some("claude -p"));
}
}