use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use kovra_core::{AgentScope, Filter, Operation};
use crate::error::AgentError;
pub const AGENT_CONFIG_FILE: &str = "agent.toml";
pub fn config_path(root: &Path) -> PathBuf {
root.join(AGENT_CONFIG_FILE)
}
fn agent_operations() -> BTreeSet<Operation> {
[Operation::Metadata, Operation::Inject]
.into_iter()
.collect()
}
pub fn load_scope(root: &Path) -> Result<AgentScope, AgentError> {
let path = config_path(root);
if !path.exists() {
return Ok(default_scope());
}
let text = std::fs::read_to_string(&path)
.map_err(|e| AgentError::Socket(format!("read {}: {e}", path.display())))?;
parse_scope(&text)
}
pub fn default_scope() -> AgentScope {
AgentScope {
operations: agent_operations(),
projects: Filter::Any,
environments: Filter::Any,
}
}
pub fn parse_scope(text: &str) -> Result<AgentScope, AgentError> {
let mut environments = Filter::Any;
let mut projects = Filter::Any;
for (lineno, raw) in text.lines().enumerate() {
let line = strip_comment(raw).trim();
if line.is_empty() {
continue;
}
let (key, value) = line.split_once('=').ok_or_else(|| {
AgentError::Socket(format!(
"agent.toml line {}: expected `key = [..]`",
lineno + 1
))
})?;
let key = key.trim();
let values = parse_array(value.trim()).ok_or_else(|| {
AgentError::Socket(format!(
"agent.toml line {}: expected an array like [\"dev\", \"test\"]",
lineno + 1
))
})?;
let filter = if values.is_empty() {
Filter::Any
} else {
Filter::only(values)
};
match key {
"environments" => environments = filter,
"projects" => projects = filter,
other => {
return Err(AgentError::Socket(format!(
"agent.toml line {}: unknown key `{other}` (expected `environments`/`projects`)",
lineno + 1
)));
}
}
}
Ok(AgentScope {
operations: agent_operations(),
projects,
environments,
})
}
fn strip_comment(line: &str) -> &str {
match line.split_once('#') {
Some((before, _)) => before,
None => line,
}
}
fn parse_array(s: &str) -> Option<Vec<String>> {
let inner = s.strip_prefix('[')?.strip_suffix(']')?.trim();
if inner.is_empty() {
return Some(Vec::new());
}
let mut out = Vec::new();
for part in inner.split(',') {
let part = part.trim();
if part.is_empty() {
continue;
}
let unq = part.strip_prefix('"')?.strip_suffix('"')?;
out.push(unq.to_string());
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn absent_default_is_any_no_reveal() {
let s = default_scope();
assert!(s.permits(Operation::Metadata));
assert!(s.permits(Operation::Inject));
assert!(!s.permits(Operation::Reveal));
assert_eq!(s.environments, Filter::Any);
assert_eq!(s.projects, Filter::Any);
}
#[test]
fn parses_environment_and_project_filters() {
let toml = r#"
# scope for the dev box
environments = ["dev", "test"]
projects = ["api"]
"#;
let s = parse_scope(toml).unwrap();
assert_eq!(s.environments, Filter::only(["dev", "test"]));
assert_eq!(s.projects, Filter::only(["api"]));
assert!(!s.permits(Operation::Reveal));
}
#[test]
fn empty_array_means_any() {
let s = parse_scope("environments = []\nprojects = []\n").unwrap();
assert_eq!(s.environments, Filter::Any);
assert_eq!(s.projects, Filter::Any);
}
#[test]
fn unknown_key_is_rejected() {
assert!(parse_scope("revealable = [\"yes\"]").is_err());
}
#[test]
fn malformed_line_is_rejected() {
assert!(parse_scope("environments dev").is_err());
assert!(parse_scope("environments = dev").is_err());
}
}