use crate::gate::GateSpec;
use crate::policy::Policy;
use crate::{Error, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorktreeConfig {
#[serde(default = "default_worktree_base")]
pub base: String,
#[serde(default)]
pub link: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub setup: Option<String>,
}
fn is_contained(path: &str) -> bool {
use std::path::{Component, Path};
let path = Path::new(path);
!path.as_os_str().is_empty()
&& path
.components()
.all(|c| matches!(c, Component::Normal(_) | Component::CurDir))
}
fn default_worktree_base() -> String {
".ostraka/worktrees".to_string()
}
impl Default for WorktreeConfig {
fn default() -> Self {
Self {
base: default_worktree_base(),
link: Vec::new(),
setup: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub gate: GateSpec,
#[serde(default)]
pub policy: Policy,
#[serde(default)]
pub worktree: WorktreeConfig,
}
impl Config {
pub fn parse(text: &str) -> Result<Self> {
toml::from_str(text).map_err(|source| Error::Parse {
what: "ostraka.toml",
source,
})
}
pub fn validate(&self) -> Result<()> {
if self.gate.checks.is_empty() {
return Err(Error::Invalid(
"[gate] declares no checks; the gate would pass everything".to_string(),
));
}
for path in &self.worktree.link {
if !is_contained(path) {
return Err(Error::Invalid(format!(
"[worktree] link {path:?} must be a relative path inside the project"
)));
}
}
for check in &self.gate.checks {
if check.cmd.trim().is_empty() {
return Err(Error::Invalid(format!(
"check {:?} has an empty command",
check.name
)));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_link_cannot_climb_out_of_the_project() {
for bad in ["../secrets", "/etc", "a/../../b"] {
let text = format!(
"[gate]\nchecks = [{{ name = \"t\", cmd = \"true\", required = true }}]\n[worktree]\nlink = [\"{bad}\"]\n"
);
let config = Config::parse(&text).expect("parses");
assert!(config.validate().is_err(), "{bad} was accepted");
}
}
#[test]
fn an_ordinary_link_is_accepted() {
let text = "[gate]\nchecks = [{ name = \"t\", cmd = \"true\", required = true }]\n[worktree]\nlink = [\"node_modules\", \"packages/app/node_modules\"]\n";
Config::parse(text)
.expect("parses")
.validate()
.expect("valid");
}
const SAMPLE: &str = r#"
[gate]
checks = [
{ name = "test", cmd = "cargo test --workspace", required = true },
]
[gate.review]
must_differ_from_author = true
"#;
#[test]
fn parses_and_validates_a_real_config() {
let cfg = Config::parse(SAMPLE).expect("parses");
cfg.validate().expect("valid");
assert_eq!(cfg.gate.checks.len(), 1);
assert!(cfg.gate.review.must_differ_from_author);
assert_eq!(cfg.worktree.base, ".ostraka/worktrees");
}
#[test]
fn an_absent_worktree_table_still_yields_a_usable_base() {
let cfg = Config::parse(SAMPLE).expect("parses");
assert_eq!(cfg.worktree.base, ".ostraka/worktrees");
assert_eq!(WorktreeConfig::default().base, ".ostraka/worktrees");
}
#[test]
fn a_gate_with_no_checks_is_refused() {
let cfg = Config::parse("[gate]\nchecks = []\n").expect("parses");
assert!(cfg.validate().is_err());
}
#[test]
fn an_empty_command_is_refused() {
let cfg = Config::parse(
r#"
[gate]
checks = [{ name = "lint", cmd = " " }]
"#,
)
.expect("parses");
assert!(cfg.validate().is_err());
}
}