use serde::{Deserialize, Serialize};
use serde_yaml_ng::{Mapping, Value};
use std::collections::HashMap;
use std::path::Path;
use tracing::warn;
#[derive(Debug, thiserror::Error)]
pub(crate) enum WaveConfigError {
#[error("failed to read {path}: {source}")]
Read {
path: std::path::PathBuf,
source: std::io::Error,
},
#[error("invalid wave goal frontmatter in {path}: {source}")]
Parse {
path: std::path::PathBuf,
source: serde_yaml_ng::Error,
},
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct WaveCronDef {
pub flow: String,
pub schedule: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq)]
pub struct WavePmConfig {
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub linear_initiative: Option<String>,
#[serde(default)]
pub linear_team: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(tag = "provider", rename_all = "snake_case", deny_unknown_fields)]
#[non_exhaustive]
pub enum WaveChatConfig {
Local,
Discord {
guild_id: String,
channel_id: String,
},
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct WaveConfig {
pub owner: Option<String>,
pub home: Option<String>,
pub crons: Option<Vec<WaveCronDef>>,
pub agent: Option<String>,
pub skill_agents: Option<HashMap<String, String>>,
pub pm: Option<WavePmConfig>,
pub chat: Option<WaveChatConfig>,
}
pub fn read_wave_config(repo: &Path, name: &str) -> Option<WaveConfig> {
match try_read_wave_config(repo, name) {
Ok(config) => config,
Err(err) => {
warn!(error = %err, "failed to read wave config");
None
}
}
}
pub(crate) fn try_read_wave_config(
repo: &Path,
name: &str,
) -> Result<Option<WaveConfig>, WaveConfigError> {
let path = goal_path(repo, name);
let content = match std::fs::read_to_string(&path) {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(WaveConfigError::Read { path, source }),
};
Ok(Some(match split_frontmatter(&content) {
Some((frontmatter, _)) => serde_yaml_ng::from_str::<WaveConfig>(&frontmatter)
.map_err(|source| WaveConfigError::Parse { path, source })?,
None => WaveConfig::default(),
}))
}
pub fn read_wave_summary(repo: &Path, name: &str) -> std::io::Result<String> {
let content = std::fs::read_to_string(goal_path(repo, name))?;
let body = split_frontmatter(&content)
.map(|(_, body)| body)
.unwrap_or(content);
let objective = markdown_section(&body, "Objective");
let summary = first_paragraph(&objective);
if summary.is_empty() {
Ok(first_prose_paragraph(&body))
} else {
Ok(summary)
}
}
fn split_frontmatter(content: &str) -> Option<(String, String)> {
if !content.starts_with("---") {
return None;
}
let mut parts = content.splitn(3, "---");
let _ = parts.next();
let frontmatter = parts.next()?;
let rest = parts.next()?;
let body = rest.strip_prefix('\n').unwrap_or(rest).to_string();
Some((frontmatter.to_string(), body))
}
fn goal_path(repo: &Path, name: &str) -> std::path::PathBuf {
repo.join("wave").join(name).join("GOAL.md")
}
fn markdown_section(content: &str, heading: &str) -> String {
let marker = format!("## {heading}");
let mut in_section = false;
let mut lines = Vec::new();
for line in content.lines() {
if line.trim() == marker {
in_section = true;
continue;
}
if in_section && line.trim_start().starts_with("## ") {
break;
}
if in_section {
lines.push(line);
}
}
lines.join("\n").trim().to_string()
}
fn first_paragraph(content: &str) -> String {
content
.split("\n\n")
.map(|paragraph| paragraph.split_whitespace().collect::<Vec<_>>().join(" "))
.find(|paragraph| !paragraph.is_empty())
.unwrap_or_default()
}
fn first_prose_paragraph(content: &str) -> String {
content
.split("\n\n")
.map(str::trim)
.filter(|paragraph| !paragraph.is_empty())
.filter(|paragraph| !paragraph.starts_with('#'))
.map(|paragraph| paragraph.split_whitespace().collect::<Vec<_>>().join(" "))
.next()
.unwrap_or_default()
}
fn empty_goal_body(name: &str) -> String {
format!("Run one loop iteration for the {name} wave.\n")
}
fn goal_value_from_content(path: &Path, name: &str) -> Result<(Value, String), String> {
if !path.exists() {
return Ok((Value::Mapping(Mapping::new()), empty_goal_body(name)));
}
let content = std::fs::read_to_string(path)
.map_err(|err| format!("failed to read {}: {err}", path.display()))?;
let Some((frontmatter, body)) = split_frontmatter(&content) else {
return Ok((Value::Mapping(Mapping::new()), content));
};
let value = serde_yaml_ng::from_str::<Value>(&frontmatter)
.map_err(|err| format!("invalid yaml in {}: {err}", path.display()))?;
Ok((value, body))
}
fn render_goal_md(value: &Value, body: &str) -> Result<String, String> {
let rendered = serde_yaml_ng::to_string(value)
.map_err(|err| format!("failed to render wave goal frontmatter: {err}"))?;
Ok(format!("---\n{}---\n{}", rendered, body))
}
fn wave_config_map<'a>(value: &'a mut Value, path: &Path) -> Result<&'a mut Mapping, String> {
value.as_mapping_mut().ok_or_else(|| {
format!(
"wave goal frontmatter at {} must be a mapping",
path.display()
)
})
}
fn remove_or_set_string(map: &mut Mapping, field: &str, value: Option<String>) {
let key = Value::String(field.to_string());
match value {
Some(value) if !value.trim().is_empty() => {
map.insert(key, Value::String(value));
}
Some(_) => {
map.remove(&key);
}
None => {}
}
}
fn remove_or_set_skill_agents(
map: &mut Mapping,
skill_agents: Option<HashMap<String, String>>,
) -> Result<(), String> {
let key = Value::String("skill_agents".to_string());
match skill_agents {
Some(skill_agents) if !skill_agents.is_empty() => {
map.insert(
key,
serde_yaml_ng::to_value(skill_agents)
.map_err(|err| format!("failed to encode skill_agents: {err}"))?,
);
}
Some(_) => {
map.remove(&key);
}
None => {}
}
Ok(())
}
pub fn update_wave_goal_config(
repo: &Path,
name: &str,
update: impl FnOnce(&mut Mapping) -> Result<(), String>,
) -> Result<(), String> {
let path = goal_path(repo, name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|err| format!("failed to create {}: {err}", parent.display()))?;
}
let (mut value, body) = goal_value_from_content(&path, name)?;
let map = wave_config_map(&mut value, &path)?;
update(map)?;
let rendered = render_goal_md(&value, &body)?;
std::fs::write(&path, rendered)
.map_err(|err| format!("failed to write {}: {err}", path.display()))?;
Ok(())
}
pub fn update_wave_agent_config(
repo: &Path,
name: &str,
agent: Option<String>,
skill_agents: Option<HashMap<String, String>>,
) -> Result<(), String> {
update_wave_goal_config(repo, name, |map| {
remove_or_set_string(map, "agent", agent);
remove_or_set_skill_agents(map, skill_agents)
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn read_wave_config_parses_machine_frontmatter() {
let temp = tempdir().expect("temp dir");
let dir = temp.path().join("wave").join("scan");
fs::create_dir_all(&dir).expect("create dir");
fs::write(
dir.join("GOAL.md"),
"---\nowner: jack\nhome: build.example.com\nagent: codex\n---\nDrive the work.\n",
)
.expect("write");
let config = read_wave_config(temp.path(), "scan").expect("config should parse");
assert_eq!(config.owner.as_deref(), Some("jack"));
assert_eq!(config.home.as_deref(), Some("build.example.com"));
assert_eq!(config.agent.as_deref(), Some("codex"));
}
#[test]
fn read_wave_summary_prefers_the_objective() {
let temp = tempdir().expect("temp dir");
let dir = temp.path().join("wave").join("scan");
fs::create_dir_all(&dir).expect("create dir");
fs::write(
dir.join("GOAL.md"),
"---\nagent: codex\n---\n\n## Objective\n\nKeep the system\nboring.\n\n## Process\n\nDo the work.\n",
)
.expect("write");
assert_eq!(
read_wave_summary(temp.path(), "scan").expect("summary"),
"Keep the system boring."
);
}
#[test]
fn read_wave_config_parses_linear_pm_block() {
let temp = tempdir().expect("temp dir");
let dir = temp.path().join("wave").join("scan");
fs::create_dir_all(&dir).expect("create dir");
fs::write(
dir.join("GOAL.md"),
"---\npm:\n provider: linear\n linear_initiative: \"lin-123\"\n linear_team: \"team-prd\"\n---\nDrive the work.\n",
)
.expect("write");
let config = read_wave_config(temp.path(), "scan").expect("config should parse");
let pm = config.pm.expect("pm config should exist");
assert_eq!(pm.provider.as_deref(), Some("linear"));
assert_eq!(pm.linear_initiative.as_deref(), Some("lin-123"));
assert_eq!(pm.linear_team.as_deref(), Some("team-prd"));
}
#[test]
fn read_wave_config_parses_crons_and_ignores_legacy_triggers() {
let temp = tempdir().expect("temp dir");
let dir = temp.path().join("wave").join("scan");
fs::create_dir_all(&dir).expect("create dir");
fs::write(
dir.join("GOAL.md"),
"---\ncrons:\n - flow: wave-polish\n schedule: '0 0 0 * * Mon *'\ntriggers:\n signal: wave\n source: infra\n source_repo: /tmp/source\n---\nDrive the work.\n",
)
.expect("write");
let config = read_wave_config(temp.path(), "scan").expect("config should parse");
let crons = config.crons.expect("crons parse from frontmatter");
assert_eq!(crons.len(), 1);
assert_eq!(crons[0].flow, "wave-polish");
assert_eq!(crons[0].schedule, "0 0 0 * * Mon *");
}
#[test]
fn read_wave_config_returns_none_for_missing() {
let temp = tempdir().expect("temp dir");
assert!(read_wave_config(temp.path(), "nonexistent").is_none());
}
#[test]
fn update_wave_agent_config_writes_agent_fields() {
let temp = tempdir().expect("temp dir");
let dir = temp.path().join("wave").join("scan");
fs::create_dir_all(&dir).expect("create dir");
fs::write(
dir.join("GOAL.md"),
"---\narea: ['.']\n---\nDrive the work.\n",
)
.expect("write");
update_wave_agent_config(
temp.path(),
"scan",
Some("codex:o3".to_string()),
Some(HashMap::from([(
"implement".to_string(),
"claude:sonnet".to_string(),
)])),
)
.expect("update config");
let config = read_wave_config(temp.path(), "scan").expect("config should parse");
assert_eq!(config.agent.as_deref(), Some("codex:o3"));
assert_eq!(
config.skill_agents,
Some(HashMap::from([(
"implement".to_string(),
"claude:sonnet".to_string(),
)]))
);
}
#[test]
fn update_wave_agent_config_removes_fields_on_empty_values() {
let temp = tempdir().expect("temp dir");
let dir = temp.path().join("wave").join("scan");
fs::create_dir_all(&dir).expect("create dir");
fs::write(
dir.join("GOAL.md"),
"---\narea: ['.']\nagent: codex:o3\nskill_agents:\n implement: claude:sonnet\n---\nDrive the work.\n",
)
.expect("write");
update_wave_agent_config(
temp.path(),
"scan",
Some(String::new()),
Some(HashMap::new()),
)
.expect("update config");
let config = read_wave_config(temp.path(), "scan").expect("config should parse");
assert!(config.agent.is_none());
assert!(config.skill_agents.is_none());
}
}